1. 程式人生 > >談談到底什麼是抽象,以及軟體設計的抽象原則

談談到底什麼是抽象,以及軟體設計的抽象原則

我們在日常開發中,我們常常會提到抽象。但很多人常常搞不清楚,究竟什麼是抽象,以及如何進行抽象。今天我們就來談談抽象。

什麼是抽象?

首先,抽象這個詞在中文裡可以作為動詞也可以作為名詞。作為動詞的抽象就是指一種行為,這種行為的結果,就是作為名詞的抽象。Wikipedia 上是這麼定義抽象的:

Conceptual abstractions may be formed by filtering the information content of a concept or an observable phenomenon, selecting only the aspects which are relevant for a particular subjectively valued purpose.

也就是說,抽象是指為了某種目的,對一個概念或一種現象包含的資訊進行過濾,移除不相關的資訊,只保留與某種最終目的相關的資訊。例如,一個*皮質的足球*,我們可以過濾它的質料等資訊,得到更一般性的概念,也就是*球*。從另外一個角度看,抽象就是簡化事物,抓住事物本質的過程。

需要注意的是,抽象是分層次的。還是用 Wikipedia 上的例子,以下是對一份報紙在多個不同層次的抽象:

  1. 我的 5 月 18 日的《舊金山紀事報》

  2. 5 月 18 日的《舊金山紀事報》

  3. 《舊金山紀事報》

  4. 一份報紙

  5. 一個出版品

可以看到,在不同層次的抽象,就是過濾掉了不同的資訊。這裡沒有展現出來的是,我們需要確保最終留下來的資訊,都是當前抽象層需要的資訊。

生活中的抽象

其實我們生活中每時每刻都在接觸或者進行各種抽象。接觸最多的,應該就是數字了。其實原始人類並沒有數字這個概念,他們可能能夠理解三個蘋果,也能夠理解三隻鴨子,但是對他們來說,是不存在數字“三”這個概念的。在他們的理解裡,三個蘋果和三隻鴨子是沒有任何聯絡的。直到某一天,某個原始人發現了這兩者之間,有那麼一個共性,也即是數字“三”,於是就有了數字這個概念。從那以後,人們就開始用數字對各類事物進行計數。

赫拉利在《人類簡史》裡說,人類之所以成為人類,是因為人類能夠想象。這裡的想象,我認為很大程度上也是指抽象。只有人類能夠從具體的事物本身,抽象出各種概念。可以說,人類的幾乎所有事情,包括政治(例如民族、國家)、經濟(例如貨幣、證券)、文學、藝術、科學等等,都是建立在抽象的基礎上的。繪畫有一個流派叫抽象主義,很多人(包括我)都表示看不懂,但下面幾幅畢加索畫的牛,也許能夠從直觀上讓我們更好的理解什麼是抽象。

 

0?wx_fmt=jpeg

 

科學裡的抽象就更廣泛了,我們可以認為所有的科學理論和定理都是一種抽象。物體的質量是一種抽象,它不關注物體是什麼以及它的形狀或質地;牛頓定律是對物體運動規律的抽象,我們現在知道它不準確,但它在常規世界裡,卻依然是一個相當可靠的抽象。在科學和工程裡,常常需要建立一些模型或者假設,比如量子力學的標準粒子模型、經濟學的理性人假設,這些都是抽象。甚至包括現在 AI 裡通過訓練生成的模型,某種程度上說,也是一種抽象。

當然,哲學上對抽象有很多討論,什麼本體論、白馬非馬之類的,這些已經在本人的理解範圍之外了,就不討論了。

開發中的抽象

現在我們應該能大致理解抽象這個概念了,讓我們回到軟體開發領域。

在軟體開發裡面,最重要的抽象就可能是分層了。分層隨處可見,例如我們的系統就是分層的。最早的程式是直接執行在硬體上的,開發成本非常高。然後慢慢開始有了作業系統,作業系統提供了資源管理、程序排程、輸入輸出等所有程式都需要的基礎功能,開發程式時呼叫作業系統的介面就可以了。再後來發現作業系統也不夠,於是又有了各種執行環境(如 JVM)。

程式語言也是一種分層的抽象。機器理解的其實是機器語言,即各種二進位制的指令。但我們不可能直接用機器語言程式設計,於是我們發明了組合語言、C 語言以及 Java 等各種高階語言,一直到 Ruby、Python 等動態語言。

開發中,我們應該也都聽說過各種分層模型。例如經典的三層模型(展現層、業務邏輯層、資料層),還有 MVC 模型等。有一句名言:“軟體領域的任何問題,都可以通過增加一個間接的中間層來解決”。分層架構的核心其實就是抽象的分層,每一層的抽象只需要而且只能關注本層相關的資訊,從而簡化整個系統的設計。

其實軟體開發本身,就是一個不斷抽象的過程。我們把業務需求抽象成資料模型、模組、服務和系統,面向物件開發時我們抽象出類和物件,面向過程開發時我們抽象出方法和函式。也即是說,上面提到的模型、模組、服務、系統、類、物件、方法、函式等,都是一種抽象。可想而知,設計一個好的抽象,對我們軟體開發有多麼重要。

抽象的原則

那麼到底應如何做到好的抽象呢?在軟體開發領域,前人們其實早幫我們整理出了 SOLID 等設計原則以及各種設計模式。對於 SOLID 原則,雖然很多人都聽說過,但其實真正能理解這些原則的開發者並不多。那麼我們就從抽象的角度,再來看下這些原則,也許會有更好的理解。

單一職責原則(Single Responsibility Principle, SRP)

單一職責是指一個模組應該只做一件事,並把這件事做好。其實對照應抽象的定義,可以發現這個原則本身就是抽象的核心體現。如果一個類包含了很多方法,或者一個方法特別長,就要引起我們的特別注意了。例如下面這個 Employee 類,既有業務邏輯(calculatePay)、又有資料庫邏輯(saveToDb),那它其實至少做了兩件事情,也就不符合單一職責原則,當然也就不是一個好的抽象。

class Employee {
   public Pay calculatePay() {...}    
   public void saveToDb() {...}
}

有些人覺得單一職責不太好理解,有時候很那分辨一個模組到底是不是單一職責。其實單一職責的概念,常常需要結合抽象的分層去理解。

在同一個抽象層裡,如果一個類或者一個方法做了不止一件事,一般是比較容易分辨的。例如一個違反單一職責原則的典型徵兆是,一個方法接受一個布林型別或者列舉型別的引數,然後一個大大的 if/else 或者 switch/case,分支裡也是大段的程式碼處理各種情況下的邏輯。這時我們可以用簡單工廠模式、策略模式等設計模式去優化設計。

假如說我們用了簡單工廠模式,改進了一段程式碼,重構後代碼可能像是下面是這樣的。

public Instance getInstance(final int type){
   switch (type) { case 1: return new AAInstance; case 2: return new BBInstance; default: return new DefaultInstance(); }
}

有人可能會有疑問,程式碼裡依然還是存在 if/else 或者 switch/case,這不還是做了不止一件事情麼?其實不是的,使用了簡單工廠模式,其實就是增加了一個抽象層。在這個抽象層裡,getInstance 的職責很明確,就是建立物件。而原來分支裡的邏輯處理,則下沉到了另外一個抽象層裡去了,也就是 Instance 的實現所在的抽象層。

再看下面 Scala 實現的 updateOrder 方法,它似乎也只是做了一件事情:處理訂單,那算不算單一職責呢?

protected def updateOrder(t: TransationEntity) = {
// 1 獲取訂單 ManagedRepo.find[Order]("orderNo" -> t.tradeNo).map { order => // 2 檢查訂單是否已支付 val ps = SQL("""select statue from Order where id ={id} for update""").on("id" -> order.id).as(scalar[Long].singleOpt).getOrElse(0l) if (ps == PAID) { throw ServiceException(ApiError.SUBSCRIPTION_UPDATE_FAIL) } else { // 3 更新訂單資訊,標記為已支付             val updatedOrder = // 略... updatedOrder.saveOrUpdate() // 4 生成收入記錄 createIncome(updatedOrder) } }
}

答案當然是不算,因為很明顯,這個方法裡面既有業務邏輯的程式碼,又有資料庫處理的程式碼,這兩類應該是在不同的抽象層的。我們把資料庫處理的程式碼抽取出來,下沉到資料層,它就能符合單一職責原則了。

protected def updateOrder(t: TransationEntity) = {
findUnpaidOrder(rtent.tradeNo).map { order => val updatedOrder = updateOrderForPayment(rtent) createIncome(updatedOrder) }
}

開放封閉原則(Open/Closed Principle, OCP)

開放封閉原則是指對擴充套件開放,對修改封閉。當需求改變時,我們可以擴充套件模組以滿足新的需求;但擴充套件時,不應該需要修改原模組的實現。

下面兩段程式碼都實現了方形、矩形以及圓形的面積計算。第一種用的是面向過程的方法,第二種用的是面向物件的方法。那麼,到底哪一種更符合開放封閉原則呢?

面向過程方法:

public class Square {
   public double side;
}
public class Rectangle { public double height; public double width;
}
public class Circle { public double radius;
}
public class Geometry { public double area(Object shape) { if (shape instanceof Square) { Square s = (Square) shape; return s.side * s.side; } else if (shape instanceof Rectangle) { Rectangle r = (Rectangle) shape; return r.height * r.width; } else if (shape instanceof Square) { Circle c = (Circle) shape; return PI * c.radius * c.radius; } else { throw new NoSuchShareException(); } }
}

面向物件方法:

public class Square implements Share {
   public double side; public double area() { return side * side; }
}
public class Rectangle implements Share { public double height; public double width; public double area() { return height * width; }
}
public class Circle implements Share { public double radius; public double area() { return PI * radius * radius; }
}

估計很多人會覺得面向物件的方式更好,更符合開放封閉原則。但真相其實沒那麼簡單。想象如果我們需要新增一個新的形狀,比如說橢圓,那面向物件的實現肯定更方便,我們只需要實現一個橢圓的類以及它的 area 方法。這時候我們可以說面向物件的方法更符合開放封閉原則。

但如果我們需要新增一個新的方法呢?比如說,我們發現我們還需要計算形狀的周長。這時候,面向物件的實現似乎就沒那麼方便了,要在每個類裡面新增計算周長的方法。而面向過程的方法,則只需要新增一個方法就行了。這時候,我們反而發現面向過程的方法更符合開放封閉原則。

所以開放封閉其實是相對的,有時候,如何進行抽象,取決於我們對未來最有可能的擴充套件的預判。

依賴倒置原則(Dependency Inversion Principle, DIP)

依賴倒置原則是指高層模組不應該依賴於低層模組的實現,兩者都應該依賴於抽象。抽象不應該依賴於細節,細節應該依賴與抽象。前面提到,“軟體領域的任何問題,都可以通過增加一個間接的中間層來解決” ,DIP 就是最典型的增加中間層的方式,也是我們需要解耦兩個模組的最重要的方法之一。

依賴倒置原則的一個例子是 Java 的 JDBC。如果沒有 JDBC,那我們的系統就會嚴格依賴我們使用的那個資料庫。這時如果我們想要切換到另外一個數據庫,就需要修改大量程式碼。但 Java 提供了 JDBC 介面,而所有關係資料庫的連線庫都實現了這個介面,我們的系統也只需要呼叫 JDBC 即可完成資料庫操作。這時我們的系統和資料庫的依賴就解除了。除了 JDBC,其實 SQL 本身也是一種依賴倒置的實現。另外一個很典型的例子就是 Java 的日誌介面 Slf4j。

0?wx_fmt=png

 

其實所有的協議和標準化都是 DIP 的一種實現。包括 TCP、HTTP 等網路協議、作業系統、JVM、Spring 框架的 IOC 等等。設計模式裡有不少模式,也是典型的依賴倒置,例如狀態模式、工廠模式、代理模式、策略模式等等,下圖是策略模式的結構圖。

 

0?wx_fmt=jpeg

 

我們日常生活中也有很多依賴倒置的例子。比如電源插座,家庭的供電只需要提供符合國家標準的電源插座,我們購買電器產品時,就不用擔心買回來無法接入電源。汽車和輪胎、鉛筆和筆、USB/耳機介面等等,也都是同一思想的體現。

里氏替換原則(Liskov Substitution Principle, LSP)

里氏替換原則是指子類必須能夠替換成它們的基類。例如下面這個最常見的例子,Square 可以是 Rectangle 的子類嗎?

public class Rectangle {
   public double height; public double width; public void setHeight(int height) { ... } public void setWidth(int width) { ... }
}
public class Square extends Rectangle { ???
}

雖然幾何上說,Square 是一個特殊的 Rectangle,但把 Square 作為 Rectangle 的子類,卻未必合適,因為它已經不存在寬和高的概念了。如果一個抽象不能符合里氏替換原則,那我們就需要考慮下這個抽象是不是合適了。

介面隔離原則(Interface Segregation Principle, ISP)

介面隔離原則是指客戶端不應該被迫依賴它們不使用的方法。例如下面的 Square 類如果繼承了Shape 介面,該如何計算體積以實現volume方法?

interface Shape {
   public function area(); public function volume();
}
public class Square extends Shape { ???
}

同樣,如果一個抽象不符合介面隔離原則,那可能就不是一個合適的抽象。

迪米特法則(Law of Demeter)

迪米特法則不屬於 SOLID 原則,但我覺得也值得說一下。它是指模組不應該瞭解它所操作的物件的內部情況。想象一下,如果你想讓你的狗狗快點跑的話,你會對狗狗說,還是對四條狗腿說?如果你去店裡買東西,你會把錢交給店員,還是會把錢包交給店員讓他自己拿?

下面是一段違反迪米特法則的典型程式碼。這樣的程式碼把物件內部實現暴露了出來,應該考慮講將功能直接暴露為介面,或者合理使用設計模式(如 Facade)。

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

總結

關於抽象,今天我們就說到這裡。不過要注意的是,軟體開發並不是僅僅只依靠抽象能力就能完成的,最終我們還是要把我們抽象出來的架構、模型等,落地到真正的程式碼層面,那就還需要邏輯思維能力、系統分析能力等。以後如果有機會,我們可以繼續探討。

我希望各位看完本文,對抽象的理解能夠更加深入一點。我們以奧卡姆剃刀原則來結束吧:一個抽象應該足夠簡單,但又不至於過於簡單。這其實就是抽象的真諦。

 

全文完

 

https://blog.csdn.net/y4x5M0nivSrJaY3X92c/article/details/78863467