談談到底什麼是抽象,以及軟體設計的抽象原則
作者 | 章燁明
杏仁醫生CTO。中老年程序員,關注各種技術和團隊管理。
我們在日常開發中,我們常常會提到抽象。但很多人常常搞不清楚,究竟什麼是抽象,以及如何進行抽象。今天我們就來談談抽象。
什麼是抽象?
首先,抽象這個詞在中文裡可以作為動詞也可以作為名詞。作為動詞的抽象就是指一種行為,這種行為的結果,就是作為名詞的抽象。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 上的例子,以下是對一份報紙在多個不同層次的抽象:
- 我的 5 月 18 日的《舊金山紀事報》
- 5 月 18 日的《舊金山紀事報》
- 《舊金山紀事報》
- 一份報紙
- 一個出版品
可以看到,在不同層次的抽象,就是過濾掉了不同的信息。這裡沒有展現出來的是,我們需要確保最終留下來的信息,都是當前抽象層需要的信息。
生活中的抽象
其實我們生活中每時每刻都在接觸或者進行各種抽象。接觸最多的,應該就是數字了。其實原始人類並沒有數字這個概念,他們可能能夠理解三個蘋果,也能夠理解三隻鴨子,但是對他們來說,是不存在數字「三」這個概念的。在他們的理解里,三個蘋果和三隻鴨子是沒有任何聯繫的。直到某一天,某個原始人發現了這兩者之間,有那麼一個共性,也即是數字「三」,於是就有了數字這個概念。從那以後,人們就開始用數字對各類事物進行計數。
赫拉利在《人類簡史》里說,人類之所以成為人類,是因為人類能夠想像。這裡的想像,我認為很大程度上也是指抽象。只有人類能夠從具體的事物本身,抽象出各種概念。可以說,人類的幾乎所有事情,包括政治(例如民族、國家)、經濟(例如貨幣、證券)、文學、藝術、科學等等,都是建立在抽象的基礎上的。繪畫有一個流派叫抽象主義,很多人(包括我)都表示看不懂,但下面幾幅畢加索畫的牛,也許能夠從直觀上讓我們更好的理解什麼是抽象。
科學裡的抽象就更廣泛了,我們可以認為所有的科學理論和定理都是一種抽象。物體的質量是一種抽象,它不關注物體是什麼以及它的形狀或質地;牛頓定律是對物體運動規律的抽象,我們現在知道它不準確,但它在常規世界裡,卻依然是一個相當可靠的抽象。在科學和工程里,常常需要建立一些模型或者假設,比如量子力學的標準粒子模型、經濟學的理性人假設,這些都是抽象。甚至包括現在 AI 里通過訓練生成的模型,某種程度上說,也是一種抽象。
當然,哲學上對抽象有很多討論,什麼本體論、白馬非馬之類的,這些已經在本人的理解範圍之外了,就不討論了。
開發中的抽象
現在我們應該能大致理解抽象這個概念了,讓我們回到軟體開發領域。
在軟體開發裡面,最重要的抽象就可能是分層了。分層隨處可見,例如我們的系統就是分層的。最早的程序是直接運行在硬體上的,開發成本非常高。然後慢慢開始有了操作系統,操作系統提供了資源管理、進程調度、輸入輸出等所有程序都需要的基礎功能,開發程序時調用操作系統的介面就可以了。再後來發現操作系統也不夠,於是又有了各種運行環境(如 JVM)。
編程語言也是一種分層的抽象。機器理解的其實是機器語言,即各種二進位的指令。但我們不可能直接用機器語言編程,於是我們發明了彙編語言、C 語言以及 Java 等各種高級語言,一直到 Ruby、Python 等動態語言。
開發中,我們應該也都聽說過各種分層模型。例如經典的三層模型(展現層、業務邏輯層、數據層),還有 MVC 模型等。有一句名言:「軟體領域的任何問題,都可以通過增加一個間接的中間層來解決」。分層架構的核心其實就是抽象的分層,每一層的抽象只需要而且只能關注本層相關的信息,從而簡化整個系統的設計。
其實軟體開發本身,就是一個不斷抽象的過程。我們把業務需求抽象成數據模型、模塊、服務和系統,面向對象開發時我們抽象出類和對象,面向過程開發時我們抽象出方法和函數。也即是說,上面提到的模型、模塊、服務、系統、類、對象、方法、函數等,都是一種抽象。可想而知,設計一個好的抽象,對我們軟體開發有多麼重要。
抽象的原則
那麼到底應如何做到好的抽象呢?在軟體開發領域,前人們其實早幫我們整理出了 SOLID 等設計原則以及各種設計模式。對於 SOLID 原則,雖然很多人都聽說過,但其實真正能理解這些原則的開發者並不多。那麼我們就從抽象的角度,再來看下這些原則,也許會有更好的理解。
單一職責原則(Single Responsibility Principle, SRP)
單一職責是指一個模塊應該只做一件事,並把這件事做好。其實對照應抽象的定義,可以發現這個原則本身就是抽象的核心體現。如果一個類包含了很多方法,或者一個方法特別長,就要引起我們的特別注意了。例如下面這個 Employee 類,既有業務邏輯(calculatePay)、又有資料庫邏輯(saveToDb),那它其實至少做了兩件事情,也就不符合單一職責原則,當然也就不是一個好的抽象。
class Employee {n public Pay calculatePay() {...}n public void saveToDb() {...}n} n
有些人覺得單一職責不太好理解,有時候很那分辨一個模塊到底是不是單一職責。其實單一職責的概念,常常需要結合抽象的分層去理解。
在同一個抽象層里,如果一個類或者一個方法做了不止一件事,一般是比較容易分辨的。例如一個違反單一職責原則的典型徵兆是,一個方法接受一個布爾類型或者枚舉類型的參數,然後一個大大的 if/else 或者 switch/case,分支里也是大段的代碼處理各種情況下的邏輯。這時我們可以用簡單工廠模式、策略模式等設計模式去優化設計。
假如說我們用了簡單工廠模式,改進了一段代碼,重構後代碼可能像是下面是這樣的。
public Instance getInstance(final int type){n switch (type) {n case 1: return new AAInstance;n case 2: return new BBInstance;n default: return new DefaultInstance();n }n}n
有人可能會有疑問,代碼里依然還是存在 if/else 或者 switch/case,這不還是做了不止一件事情么?其實不是的,使用了簡單工廠模式,其實就是增加了一個抽象層。在這個抽象層里,getInstance 的職責很明確,就是創建對象。而原來分支里的邏輯處理,則下沉到了另外一個抽象層里去了,也就是 Instance 的實現所在的抽象層。
再看下面 Scala 實現的 updateOrder 方法,它似乎也只是做了一件事情:處理訂單,那算不算單一職責呢?
protected def updateOrder(t: TransationEntity) = {n// 1 獲取訂單n ManagedRepo.find[Order]("orderNo" -> t.tradeNo).map { order =>n // 2 檢查訂單是否已支付n val ps = SQL("""select statue from Order where id ={id} for update""").on("id" -> order.id).as(scalar[Long].singleOpt).getOrElse(0l)n if (ps == PAID) {n throw ServiceException(ApiError.SUBSCRIPTION_UPDATE_FAIL) n } else {n // 3 更新訂單信息,標記為已支付 n val updatedOrder = // 略...n updatedOrder.saveOrUpdate()n // 4 生成收入記錄n createIncome(updatedOrder)n }n }n}n
答案當然是不算,因為很明顯,這個方法裡面既有業務邏輯的代碼,又有資料庫處理的代碼,這兩類應該是在不同的抽象層的。我們把資料庫處理的代碼抽取出來,下沉到數據層,它就能符合單一職責原則了。
protected def updateOrder(t: TransationEntity) = {nfindUnpaidOrder(rtent.tradeNo).map { order =>n val updatedOrder = updateOrderForPayment(rtent)n createIncome(updatedOrder)n }n} n
開放封閉原則(Open/Closed Principle, OCP)
開放封閉原則是指對擴展開放,對修改封閉。當需求改變時,我們可以擴展模塊以滿足新的需求;但擴展時,不應該需要修改原模塊的實現。
下面兩段代碼都實現了方形、矩形以及圓形的面積計算。第一種用的是面向過程的方法,第二種用的是面向對象的方法。那麼,到底哪一種更符合開放封閉原則呢?
面向過程方法:
public class Square {n public double side;n}npublic class Rectangle { n public double height;n public double width;n}npublic class Circle { n public double radius;n}npublic class Geometry {n public double area(Object shape) {n if (shape instanceof Square) {n Square s = (Square) shape;n return s.side * s.side;n } else if (shape instanceof Rectangle) {n Rectangle r = (Rectangle) shape;n return r.height * r.width;n } else if (shape instanceof Square) {n Circle c = (Circle) shape;n return PI * c.radius * c.radius;n } else {n throw new NoSuchShareException();n }n }n}n
面向對象方法:
public class Square implements Share {n public double side;n public double area() {n return side * side;n }n}npublic class Rectangle implements Share { n public double height;n public double width;n public double area() {n return height * width;n }n}npublic class Circle implements Share { n public double radius;n public double area() {n return PI * radius * radius;n }n}n
估計很多人會覺得面向對象的方式更好,更符合開放封閉原則。但真相其實沒那麼簡單。想像如果我們需要添加一個新的形狀,比如說橢圓,那面向對象的實現肯定更方便,我們只需要實現一個橢圓的類以及它的 area 方法。這時候我們可以說面向對象的方法更符合開放封閉原則。
但如果我們需要添加一個新的方法呢?比如說,我們發現我們還需要計算形狀的周長。這時候,面向對象的實現似乎就沒那麼方便了,要在每個類裡面添加計算周長的方法。而面向過程的方法,則只需要添加一個方法就行了。這時候,我們反而發現面向過程的方法更符合開放封閉原則。
所以開放封閉其實是相對的,有時候,如何進行抽象,取決於我們對未來最有可能的擴展的預判。
依賴倒置原則(Dependency Inversion Principle, DIP)
依賴倒置原則是指高層模塊不應該依賴於低層模塊的實現,兩者都應該依賴於抽象。抽象不應該依賴於細節,細節應該依賴與抽象。前面提到,「軟體領域的任何問題,都可以通過增加一個間接的中間層來解決」 ,DIP 就是最典型的增加中間層的方式,也是我們需要解耦兩個模塊的最重要的方法之一。
依賴倒置原則的一個例子是 Java 的 JDBC。如果沒有 JDBC,那我們的系統就會嚴格依賴我們使用的那個資料庫。這時如果我們想要切換到另外一個資料庫,就需要修改大量代碼。但 Java 提供了 JDBC 介面,而所有關係資料庫的連接庫都實現了這個介面,我們的系統也只需要調用 JDBC 即可完成資料庫操作。這時我們的系統和資料庫的依賴就解除了。除了 JDBC,其實 SQL 本身也是一種依賴倒置的實現。另外一個很典型的例子就是 Java 的日誌介面 Slf4j。
其實所有的協議和標準化都是 DIP 的一種實現。包括 TCP、HTTP 等網路協議、操作系統、JVM、Spring 框架的 IOC 等等。設計模式里有不少模式,也是典型的依賴倒置,例如狀態模式、工廠模式、代理模式、策略模式等等,下圖是策略模式的結構圖。
我們日常生活中也有很多依賴倒置的例子。比如電源插座,家庭的供電只需要提供符合國家標準的電源插座,我們購買電器產品時,就不用擔心買回來無法接入電源。汽車和輪胎、鉛筆和筆、USB/耳機介面等等,也都是同一思想的體現。
里氏替換原則(Liskov Substitution Principle, LSP)
里氏替換原則是指子類必須能夠替換成它們的基類。例如下面這個最常見的例子,Square 可以是 Rectangle 的子類嗎?
public class Rectangle {n public double height;n public double width;n public void setHeight(int height) { ... }n public void setWidth(int width) { ... }n}npublic class Square extends Rectangle { n ???n}n
雖然幾何上說,Square 是一個特殊的 Rectangle,但把 Square 作為 Rectangle 的子類,卻未必合適,因為它已經不存在寬和高的概念了。如果一個抽象不能符合里氏替換原則,那我們就需要考慮下這個抽象是不是合適了。
介面隔離原則(Interface Segregation Principle, ISP)
介面隔離原則是指客戶端不應該被迫依賴它們不使用的方法。例如下面的 Square 類如果繼承了Shape 介面,該如何計算體積以實現volume方法?
interface Shape {n public function area();n public function volume();n}npublic class Square extends Shape { n ???n}n
同樣,如果一個抽象不符合介面隔離原則,那可能就不是一個合適的抽象。
迪米特法則(Law of Demeter)
迪米特法則不屬於 SOLID 原則,但我覺得也值得說一下。它是指模塊不應該了解它所操作的對象的內部情況。想像一下,如果你想讓你的狗狗快點跑的話,你會對狗狗說,還是對四條狗腿說?如果你去店裡買東西,你會把錢交給店員,還是會把錢包交給店員讓他自己拿?
下面是一段違反迪米特法則的典型代碼。這樣的代碼把對象內部實現暴露了出來,應該考慮講將功能直接暴露為介面,或者合理使用設計模式(如 Facade)。
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();n
總結
關於抽象,今天我們就說到這裡。不過要注意的是,軟體開發並不是僅僅只依靠抽象能力就能完成的,最終我們還是要把我們抽象出來的架構、模型等,落地到真正的代碼層面,那就還需要邏輯思維能力、系統分析能力等。以後如果有機會,我們可以繼續探討。
我希望各位看完本文,對抽象的理解能夠更加深入一點。我們以奧卡姆剃刀原則來結束吧:一個抽象應該足夠簡單,但又不至於過於簡單。這其實就是抽象的真諦。
全文完
我們正在招聘 Java 工程師,歡迎有興趣的同學投遞簡歷到 rd-hr@xingren.com 。
歡迎搜索關注微信公眾號:杏仁技術站(微信號 xingren-tech)。
推薦閱讀:
※朱德群 | 原諒我這一筆不羈放縱愛自由
※Wassily Kandinsky|瓦西里·康定斯基,藍騎士的創始人之一
※Pedro Paricio| 特里西奧的抽象街頭藝術。
※如何看待張濤加入抽象工作室?
※Robert Motherwell| 一個哈佛博士的作品隨意的像打個噴嚏一樣