寫了這麼多年程式碼,你真的瞭解SOLID嗎?
儘管大家都認為SOLID是非常重要的設計原則,並且對每一條原則都耳熟能詳,但我發現大部分開發者並沒有真正理解。要獲得最大收益,就必須理解它們之間的關係,並綜合應用所有這些原則。只有把SOLID作為一個整體,才可能構建出堅實(Solid)的軟體。遺憾的是,我們看到的書籍和文章都在羅列每個原則,沒有把它們作為一個整體來看,甚至提出SOLID原則的Bob大叔也沒能講透徹。因此我嘗試介紹一下我的理解。
先丟擲我的觀點:單一職責是所有設計原則的基礎,開閉原則是設計的終極目標。里氏替換原則強調的是子類替換父類後程式執行時的正確性,它用來幫助實現開閉原則。而介面隔離原則用來幫助實現里氏替換原則,同時它也體現了單一職責。依賴倒置原則是程序式程式設計與OO程式設計的分水嶺,同時它也被用來指導介面隔離原則。關係如下圖:
單一職責原則(Single Responsibility Principle)
單一職責是最容易理解的設計原則,但也是被違反得最多的設計原則之一。
要真正理解並正確運用單一職責原則,並沒有那麼容易。單一職責就跟“鹽少許”一樣,不好把握。Robert C. Martin(又名“Bob大叔”)把職責定義為變化原因,將單一職責描述為 ”A class should have only one reason to change." 也就是說,如果有多種變化原因導致一個類要修改,那麼這個類就違反了單一職責原則。那麼問題來了,什麼是“變化原因”呢?
利益相關者角色是一個重要的變化原因,不同的角色會有不同的需求,從而產生不同的變化原因。作為居民,家用的電線是普通的220V電線,而對電網建設者,使用的是高壓電線。用一個Wire類同時服務於兩類角色,通常意味著壞味道。
變更頻率是另一個值得考慮的變化原因。即使對同一類角色,需求變更的頻率也會存在差異。最典型的例子是業務處理的需求比較穩定,而業務展示的需求更容易發生變更,畢竟人總是喜新厭舊的。因此這兩類需求通常要在不同的類中實現。
單一職責原則某種程度上說是在分離關注點。分離不同角色的關注點,分離不同時間的關注點。
在實踐中,怎麼運用單一職責原則呢?什麼時候要拆分,什麼時候要合併?我們看看新廚師在學炒菜時,是如何掌握“鹽少許”的。他會不斷地品嚐,直到味道剛好為止。寫程式碼也一樣,你需要識別需求變化的訊號,不斷“品嚐”你的程式碼,當“味道”不夠好時,持續重構,直到“味道”剛剛好。
開閉原則(Open-closed Principle)
開閉原則指軟體實體(類、模組等)應當對擴充套件開放,對修改閉合。這聽起來似乎很不合理,不能修改,只能擴充套件?那我怎麼寫程式碼?
我們先看看為什麼要有開閉原則。假設你是一名成功的開源類庫作者,很多開發者使用你的類庫。如果某天你要擴充套件功能,只能通過修改某些程式碼完成,結果導致類庫的使用者都需要修改程式碼。更可怕的是,他們被迫修改了程式碼後,又可能造成別的依賴者也被迫修改程式碼。這種場景絕對是一場災難。
如果你的設計是滿足開閉原則的,那就完全是另一種場景。你可以通過擴充套件,而不是修改來改變軟體的行為,將對依賴方的影響降到最低。
這不正是設計的終極目標嗎?解耦、高內聚、低耦合等等設計原則最終不都是為了這個目標嗎?暢想一下,類、模組、服務都不需要修改,而是通過擴充套件就能夠改變其行為。就像計算機一樣,元件可以輕鬆擴充套件。硬碟太小?直接換個大的,顯示器不夠大的?來個8K的怎麼樣?
什麼時候應該應用開閉原則,怎麼做到呢?沒有人能夠在一開始就識別出所有擴充套件點,也不可能在所有地方都預留出擴充套件點,這麼做的成本是不可接受的。因此一定是由需求變化驅動。如果你有領域專家的支援,他可以幫你識別出變化點。否則,你應該在變化發生時來做決策,因為在沒有任何依據時做過多預先設計違反了 Yagni 。
實現開閉原則的關鍵是抽象。在 Bertrand Meyer 提出開閉原則的年代(上世紀80年代),在類庫中增加屬性或方法,都不可避免地要修改依賴此類庫的程式碼。這顯然導致軟體很難維護,因此他強調的是要允許通過繼承來擴充套件類。隨著技術發展,我們有了更多的方法來實現開閉原則,包括介面、抽象類、策略模式等。
我們也許永遠都無法完全做到開閉原則,但不妨礙它是設計的終極目標。SOLID的其它原則都直接或間接為開閉原則服務,例如接下來要介紹的里氏替換原則。
里氏替換原則 (The Liskov Substitution Principle)
里氏替換原則說的是派生類(子類)物件能夠替換其基類(父類)物件被使用。學過OO的同學都知道,子類本來就可以替換父類,為什麼還要里氏替換原則呢?這裡強調的不是編譯錯誤,而是程式執行時的正確性。
程式執行的正確性通常可以分為兩類。一類是不能出現執行時異常,最典型的是UnsupportedOperationException,也就是子類不支援父類的方法。第二類是業務的正確性,這取決於業務上下文。
下例中,由於java.sql.Date不支援父類的toInstance方法,當父類被它替換時,程式無法正常執行,破壞了父類與呼叫方的契約,因此違反了里氏替換原則。
package java.sql; public class Date extends java.util.Date { @Override public Instant toInstant() { throw new java.lang.UnsupportedOperationException(); } }
接下來我們看破壞業務正確性的例子,最典型的例子就是Bob大叔在《敏捷軟體開發:原則、模式與實踐》中講到的正方形繼承矩形的例子了。從一般意義來看,正方形是一種矩形,但這種繼承關係破壞了業務的正確性。
public class Rectangle { double width; double height; public double area() { return width * height; } } public class Square extends Rectangle { public void setWidth(double width) { this.width = width; this.height = width; } public void setHeight(double height) { this.height = width; this.width = width; } } public void testArea(Rectangle r) { r.setWidth(5); r.setHeight(4); assert(r.area() == 20); //! 如果r是一個正方形,則面積為16 }
程式碼中testArea方法的引數如果是正方形,則面積是16,而不是期望的20,所以結果顯然不正確了。
如果你的設計滿足里氏替換原則,那麼子類(或介面的實現類)就可以保證正確性的前提下替換父類(或介面),改變系統的行為,從而實現擴充套件。 BranchByAbstraction 和 絞殺者模式 都是基於里氏替換原則,實現系統擴充套件和演進。這也就是對修改封閉,對擴充套件開放,因此 里氏替換原則是實現開閉原則的一種解決方案 。
而為了達成里氏替換原則,你需要介面隔離原則。
介面隔離原則 (Interface Segregation Principle)
介面隔離原則說的是客戶端不應該被迫依賴於它不使用的方法。簡單來說就是更小和更具體的瘦介面比龐大臃腫的胖介面好。
胖介面的職責過多,很容易違反單一職責原則,也會導致實現類不得不丟擲UnsupportedOperationException這樣的異常,違反里氏替換原則。因此,應該將介面設計得更瘦。
怎麼給介面減肥呢?介面之所以存在,是為了解耦。開發者常常有一個錯誤的認知,以為是實現類需要介面。其實是消費者需要介面,實現類只是提供服務,因此應該由消費者(客戶端)來定義介面。理解了這一點,才能正確地站在消費者的角度定義 Role interface ,而不是從實現類中提取 Header Interface 。
什麼是Role interface? 舉個例子,磚頭(Brick)可以被建築工人用來蓋房子,也可以被用來正當防衛:
<pre>`public class Brick { private int length; private int width; private int height; private int weight; public void build() { //...包工隊蓋房 } public void defense() { //...正當防衛 } } `</pre>
如果直接提取以下介面,這就是Header Interface:
<pre>`public interface BrickInterface { void buildHouse(); void defense(); } `</pre>
普通大眾需要的是可以防衛的武器,並不需要用磚蓋房子。當普通大眾(Person)被迫依賴了自己不需要的介面方法時,就違反介面隔離原則。正確的做法是站在消費者的角度,抽象出Role interface:
<pre>`public interface BuildHouse { void build(); } public interface StrickCompetence { void defense(); } public class Brick implement BuildHouse, StrickCompetence { } `</pre>
有了Role interface,作為消費者的普通大眾和建築工人就可以分別消費自己的介面:
<pre>`Worker.java brick.build(); Person.java brick.strike(); `</pre>
介面隔離原則本質上也是單一職責原則的體現,同時它也服務於里氏替換原則。而接下來介紹的依賴倒置原則可以用來指導介面隔離原則的實現。
依賴倒置原則 (Dependence Inversion Principle)
依賴倒置原則說的是高層模組不應該依賴底層模組,兩者都應該依賴其抽象。
這個原則其實是在指導如何實現介面隔離原則,也就是前文提到的,高層的消費者不應該依賴於具體實現,應該由消費者定義並依賴於Role interface,底層的具體實現也依賴於Role interface,因為它要實現此介面。
依賴倒置原則是區分程序式程式設計和麵向物件程式設計的分水嶺。程序式程式設計的依賴沒有倒置, A Simple DIP Example | Agile Principles, Patterns, and Practices in C# 這篇文章以開關和燈的例子很好地說明了這一點。
[](https://insights.thoughtworks.cn/wp-content/uploads/2018/09/2-Role-interface.jpg)
上圖的關係中,當Button直接呼叫燈的開和關時,Button就依賴於燈了。其程式碼完全是程序式程式設計:
public class Button { private Lamp lamp; public void Poll(){ if (/*some condition*/) lamp.TurnOn(); } }
如果Button還想控制電視機,微波爐怎麼辦?應對這種變化的辦法就是抽象,抽象出Role interface ButtonServer:
不管是電燈,還是電視機,只要實現了ButtonServer,Button都可以控制。這是面向物件的程式設計方式。
總結
總的來說,單獨應用SOLID的某一個原則並不能讓收益最大化。應該把它作為一個整體來理解和應用,從而更好地指導你的軟體設計。單一職責是所有設計原則的基礎,開閉原則是設計的終極目標。里氏替換原則強調的是子類替換父類後程序執行時的正確性,它用來幫助實現開閉原則。而介面隔離原則用來幫助實現里氏替換原則,同時它也體現了單一職責。依賴倒置原則是程序式程式設計與OO程式設計的分水嶺,同時它也被用來指導介面隔離原則。
文/ThoughtWorks梅雪松