SOLID:物件導向設計的五個基本原則

愛恰飯的八月醬發表於2021-02-06

在程式設計領域,SOLID 是由羅伯特·C·馬丁在 21 世紀早期引入的記憶術首字母縮略字,指代了物件導向程式設計和麵向物件設計的五個基本原則。當這些原則被一起應用時,它們使得一個程式設計師開發一個容易進行軟體維護和擴充套件的系統變得更加可能。SOLID 是以下五個單詞的縮寫:

  • Single Responsibility Principle(單一職責原則)
  • Open Closed Principle(開閉原則)
  • Liskov Substitution Principle(里氏替換原則)
  • Interface Segregation Principle(介面隔離原則)
  • Dependency Inversion Principle(依賴倒置原則)

單一職責原則

什麼是職責?

在The Single Responsibility Principle(SRP)中職責的定義為“變動的原因”(A reason for change)

如果你有多個動機去修改一個類,那麼這個類就有多個職責。這可能比較難理解,因為我們通常把一組職責放在一起思考,下面來看一個具體的例子。下面是一個Modem(調變解調器或者叫貓)的介面

interface Modem
{
    public void dial(String pno);
    public void hangup();
    public void send(char c);
    public char recv();
}

上面這個貓的介面中存在兩個職責:第一個是管理連線(dial和hangup);第二個是資料傳輸(send和recv)

這兩個職責應該被分開,因為 1. 它們沒有共同點,而且通常會因為不同的原因被修改; 2. 呼叫它們的程式碼通常屬於應用的不同部分,而這部分程式碼也會因為不同的原因被修改。

下面是一個Modem的一個優化後的設計:

通過拆分貓的介面,我們可以在應用的其他部分將貓的設計分開來對待;雖然我們又在貓的實現中(Modem Implementation)將這兩部分職責重新耦合在一起,但是除了初始化貓的程式碼意外,在使用面向介面程式設計的原則後,其他程式碼並不需要依賴於貓的實現。

SRP是最簡單的一個物件導向設計原則,但也是最難做正確的一個,因為我們習慣於將職責合併,而不是將它們分開來。找到並且拆分這些職責正是軟體設計真正需要做的事情。

小結一下單一職責原則就是:

核心思想:應該有且僅有一個原因引起類的變更

好處:類的複雜度降低、可讀性提高、可維護性提高、擴充套件性提高、降低了變更引起的風險。

需注意:單一職責原則提出了一個編寫程式的標準,用“職責”或“變化原因”來衡量介面或類設計得是否優良,但是“職責”和“變化原因”都是不可以度量的,因專案和環境而異。

開閉原則

開閉原則的英文是 Open Closed Principle,縮寫為 OCP

開閉原則說的是:軟體實體(模組、類、函式等等)應該對擴充套件是開放的,對修改是關閉的。

  • 對擴充套件是開放的,意味著軟體實體的行為是可擴充套件的,當需求變更的時候,可以對模組進行擴充套件,使其滿足需求變更的要求。
  • 對修改是關閉的,意味著當對軟體實體進行擴充套件的時候,不需要改動當前的軟體實體;不需要修改程式碼;對於已經完成的類檔案不需要重新編輯;對於已經編譯打包好的模組,不需要再重新編譯。

兩者結合起來表述為:新增一個新的功能應該是,在已有程式碼基礎上擴充套件程式碼(新增模組、類、方法等),而非修改已有程式碼(修改模組、類、方法等)。

這裡我們以出售電腦為例,首先定義一個頂層介面Computer,然後定義兩個實現類,華碩電腦與蘋果Mac,類層次結構如下圖所示:

上面是我們一開始的需求,但是隨著軟體釋出執行,我們的需求不可能一成不變,肯定要接軌市場。假設現在是雙十一,華碩膝上型電腦需要搞促銷活動。那麼我們的程式碼肯定要新增新的功能。可能有些剛入職的新人會在原有的程式碼上做改動,這肯定不符合開閉原則,雖然這種做法最直接,也最簡單,但是絕大部分專案中,一個功能的實現遠比想像要複雜的多,我們在原有的程式碼中進行修改,其風險遠比擴充套件和實現一個方法要大的多。正確的做法可以這樣:

我們實現一個關於折扣的子類,其中包含一個關於折扣的方法,這方法相當於一個擴充套件方法。可以看到這個子類是AsusComputer的,那為什麼不把他設計成一個共用的折扣類呢,比如DiscountComputer,所有實現類都繼承這個折扣類。這是因為每種實現類的折扣方案可能是不一樣的。所以我們最好能把它作為每個實現類的子類單獨實現。如果你能確保你的業務中的新功能能相容所有相關聯的需求你也可以共用一個。

小結一下開閉原則就是:

核心思想:儘量通過擴充套件軟體實體來解決需求變化,而不是通過修改已有的程式碼來完成變化

通俗來講:一個軟體產品在生命週期內,都會發生變化,既然變化是一個既定的事實,我們就應該在設計的時候儘量適應這些變化,以提高專案的穩定性和靈活性。

里氏替換原則

里氏替換原則由Barbara Liskov提出,這個原則很明顯,Java的多型或者C++的虛擬函式本身就允許把指向基類的指標或引用,在呼叫其方法或函式的時候,呼叫實際型別的方法或函式。我們來看一個簡單的例子:Circle 和 Square 繼承了基類 Shape,然後在應用的方法中,根據輸入 Shape 物件型別進行判斷,根據物件型別選擇不同的繪圖函式將圖形畫出來。

void drawShape(Shape shape) {
    if (shape.type == Shape.Circle ) {
        drawCircle((Circle) shape);
    } else if (shape.type == Shape.Square) {
        drawSquare((Square) shape);
    } else {
        ……
    }
}

這種寫法的程式碼既常見又糟糕,它同時違反了開閉原則和里氏替換原則。

  • 首先看到這樣的 if/else 程式碼,就可以判斷違反了(我們剛剛在上個部分講過的)開閉原則:當增加新的 Shape 型別的時候,必須修改這個方法,增加 else if 程式碼。
  • 其次也因為同樣的原因違反了里氏替換原則:當增加新的Shape 型別的時候,如果沒有修改這個方法,沒有增加 else if 程式碼,那麼這個新型別就無法替換基類 Shape。

要解決這個問題其實也很簡單,只需要在基類 Shape 中定義 draw 方法,所有 Shape 的子類,Circle、Square 都實現這個方法就可以了:

public abstract Shape{
  public abstract void draw();
}

上面那段 drawShape() 程式碼也就可以變得更簡單:

void drawShape(Shape shape) {
  shape.draw();
}

這段程式碼既滿足開閉原則:增加新的型別不需要修改任何程式碼。也滿足里氏替換原則:在使用基類的這個方法中,可以用子類替換,程式正常執行。

小結一下里氏替換原則就是:

核心思想:在使用基類的的地方可以任意使用其子類,能保證子類完美替換基類。

通俗來講:只要父類能出現的地方子類就能出現。反之,父類則未必能勝任。

好處:增強程式的健壯性,即使增加了子類,原有的子類還可以繼續執行。

需注意:如果子類不能完整地實現父類的方法,或者父類的某些方法在子類中已經發生“畸變”,則建議斷開父子繼承關係 採用依賴、聚合、組合等關係代替繼承。

介面隔離原則

介面隔離原則的英文是 SInterface Segregation Principle,縮寫為 ISP。這個原則是說:客戶端不應該強迫依賴它不需要的介面

我們在設計微服務或者類庫介面的時候,如果部分介面只被部分呼叫者使用,那我們就需要將這部分介面隔離出來,單獨給對應的呼叫者使用,而不是強迫其他呼叫者也依賴這部分不會被用到的介面。舉一個簡單的例子:

public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {
  boolean deleteUserByCellphone(String cellphone);
  boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
  // ...省略實現程式碼...
}

刪除使用者是一個非常慎重的操作,我們只希望通過後臺管理系統來執行,所以這個介面只限於給後臺管理系統使用。如果我們把它放到 UserService 中,那所有使用到 UserService 的系統,都可以呼叫這個介面。不加限制地被其他業務系統呼叫,就有可能導致誤刪使用者。

參照介面隔離原則,呼叫者不應該強迫依賴它不需要的介面,將刪除介面單獨放到另外一個介面RestrictedUserService 中,然後將 RestrictedUserService 只打包提供給後臺管理系統來使用。

小結一下介面隔離原則就是:

核心思想:類間的依賴關係應該建立在最小的介面上

通俗來講:建立單一介面,不要建立龐大臃腫的介面,儘量細化介面,介面中的方法儘量少。也就是說,我們要為各個類建立專用的介面,而不要試圖去建立一個很龐大的介面供所有依賴它的類去呼叫。

需注意:介面儘量小,但是要有限度。對介面進行細化可以提高程式設計靈活性,但是如果過小,則會造成介面數量過多,使設計複雜化。所以一定要適度提高內聚,減少對外互動。使介面用最少的方法去完成最多的事情為依賴介面的類定製服務。只暴露給呼叫的類它需要的方法,它不需要的方法則隱藏起來。只有專注地為一個模組提供定製服務,才能建立最小的依賴關係。

依賴倒置原則

依賴倒置原則的英文是 Dependency Inversion Principle,縮寫為 DIP。依賴倒置原則說的是:高層模組不依賴低層模組,它們共同依賴同一個抽象,這個抽象介面通常是由高層模組定義,低層模組實現。同時抽象不要依賴具體實現細節,具體實現細節依賴抽象。高層模組就是呼叫端,低層模組就是具體實現類,抽象就是指介面或抽象類,細節就是實現類。

來看一個簡單的例子:假設我們要設計一個很簡單的程式,將鍵盤的輸入輸出到印表機上。一個簡單的設計的程式結構圖如下所示。

上面的設計中有三個模組,Copy模組呼叫Read Keyboard模組來讀取輸出,然後Copy呼叫Write Printer模組輸出字元。Read Keyboard和Write Printer是兩個下層模組,並且很容易被複用。

然而我們的Copy模組卻不能被複用於任何不包含鍵盤和印表機的場景中,而Copy恰恰是這個程式的業務邏輯所在的模組,也是我們最希望能夠複用的。

比如,我們還希望將鍵盤的輸入,複製到磁碟檔案。我們當然希望複用Copy模組,而事實上,Copy依賴於鍵盤和印表機,缺一不可,所以不能被複用。 我們也可以往Copy中增加一個if條件來支援新的磁碟檔案輸出,但是這就違背了開閉原則,最終隨著功能的變多,程式碼將變地不可維護。

這個例子中的問題其實是高層級的模組(Copy模組)依賴於層級的模組(Read Keyboard和Write Printer); 如果能夠找到一個讓Copy獨立於它所控制的底層級模組的方法,那麼我們可以自由地複用這個Copy模組。下圖就是一種依賴反轉的解決方案。

在這個新的設計中,我們的Copy模組有一個抽象的Reader和一個抽象的Writer。Copy不再直接依賴於具體的實現,不管有幾個Reader或Writer的實現,我們都不需要修改Copy。

小結一下依賴倒置原則就是:

核心思想:高層模組不應該依賴底層模組,二者都該依賴其抽象;抽象不應該依賴細節;細節應該依賴抽象;

通俗來講:依賴倒置原則的本質就是通過抽象(介面或抽象類)使個各類或模組的實現彼此獨立,互不影響,實現模組間的鬆耦合。

好處:依賴倒置的好處在小型專案中很難體現出來。但在大中型專案中可以減少需求變化引起的工作量。使並行開發更友好。

總結

今天的內容一句話概括就是:單一職責原則告訴我們實現類要職責單一;里氏替換原則告訴我們不要破壞繼承體系;介面隔離原則告訴我們在設計介面的時候要精簡單一;依賴倒置原則告訴我們要面向介面程式設計。而開閉原則是總綱,他告訴我們要對擴充套件開放,對修改關閉。

在實際開發過程中,並不是一定要求所有程式碼都遵循設計原則,我們要考慮人力、時間、成本、質量,不是刻意追求完美,要在適當的場景遵循設計原則,體現的是一種平衡取捨,幫助我們設計出更加優雅的程式碼結構。

相關文章