軟體設計最大的難題就是應對需求的變化,但是紛繁複雜的需求變化又是不可預料的,我們要為不可預料的變化做好準備,這本身是一件非常痛苦的事情,但好在有大師們已經給我們提出了非常好的六大設計原則和23種設計模式來「封裝」未來的變化。本文只針對六大設計原則進行介紹,設計模式放在後面的文章進行詳解。
六大設計原則
六大設計原則主要是指:
單一職責原則(Single Responsibility Principle);開閉原則(Open Closed Principle);裡氏替換原則(Liskov Substitution Principle);迪米特法則(Law of Demeter),又叫「最少知道法則」;接口隔離原則(Interface Segregation Principle);依賴倒置原則(Dependence Inversion Principle)。把這 6 個原則的首字母(裡氏替換原則和迪米特法則的首字母重複,只取一個)聯合起來就是:SOLID(穩定的),其代表的含義也就是把這 6 個原則結合使用的好處:建立穩定、靈活、健壯的設計。
單一職責原則
單一職責原則的定義是:應該有且僅有一個原因引起類的變更。
沒錯,單一職責原則就這一句話,不懂沒關係,我們舉個例子。
我們以打電話為例,電話通話的時候有 4 個過程發生:撥號、通話、回應、掛機。那我們寫一個接口,類圖如下:
代碼為:
我們看這個接口有沒有問題?相信大部分同學會覺得沒問題,因為平常我們就是這麼寫的。沒錯,這個接口接近於完美,注意,是「接近」。單一職責原則要求一個接口或一個類只能有一個原因引起變化,也就是一個接口或者類只能有一個職責,它就負責一件事情,看看上面的接口只負責一件事情嗎?明顯不是。
IPhone這個接口包含了兩個職責:協議管理和數據傳送。dial 和 hangup 這兩個方法實現的是協議管理,分別負責撥號接通和掛機,chat 方法實現的是數據傳送。不管是協議接通的變化還是輸出傳送的變化,都會引起這個接口的變化。所以,IPhone這個接口並不符合單一職責原則。若要讓IPhone滿足單一職責原則,我們就要對其進行拆分,拆分後的類圖如下:
這樣設計就完美了,一個類實現了兩個接口,把兩個職責融合在一個類中。你會覺得這個Phone有兩個原因引起變化了啊,是的,但是別忘了我們是面向接口編程,我們對外公布的是接口而不是實現類。
另外,單一職責原則不僅適用於接口和類,也適用於方法。一個方法儘可能只做一件事,比如一個修改用戶密碼的方法,不要把這個方法放到「修改用戶信息」方法中。
單一職責的好處
1. 類的複雜性降低,實現什麼職責都有清晰明確的定義;
2. 可讀性高,複雜性降低,可讀性自然就提高了;
3. 可維護性提高,可讀性提高了,那自然更容易維護了;
4. 變更引起的風險降低,變更是必不可少的,如果接口的單一職責做得好,一個接口修改只對相應的實現類有影響,對其他的接口無影響,這對系統的擴展性、維護性都有非常大的幫助。
裡氏替換原則
在面向對象的語言中,繼承是必不可少的、非常優秀的語言機制,它有如下優點:
代碼共享,減少創建類的工作量,每個子類都擁有父類的屬性和方法;提高代碼的重用性;子類可以形似父類,但又異於父類;提高代碼的可擴展性;提高產品或項目的開放性。有優點就必然存在缺點:
繼承是侵入性的。只要繼承,就必須擁有父類的屬性和方法。降低代碼的靈活性。子類會多一些父類的約束。增強了耦合性。當父類的常量、變量、方法被修改時,需要考慮子類的修改。為了讓「利」的因素髮揮最大的作用,同時減少「弊」帶來的麻煩,引入了裡氏替換原則(LSP)。
歷史替換原則最正宗的定義是:如果對每一個類型為S的對象o1,都有類型為T的對象o2,使得以T定義的所有程序P在所有的對象o1都代替o2時,程序P的行為沒有發生變化,那麼類型S是類型T的子類型。
通俗點講,就是只要父類能出現的地方,子類就可以出現,而且替換為子類也不會產生任何錯誤或異常。
裡氏替換原則為良好的繼承定義了一個規範,一句簡單的定義包含了4層含義。
1. 子類必須完全實現父類的方法。
我們在做系統設計的時候,經常會定義一個接口或抽象類,然後編碼實現,調用類則直接傳入接口或抽象類,其實這裡就已經使用了裡氏替換原則。我們以打CS舉例,來描述一下裡面用到的槍。類圖如下:
槍的主要職責是射擊,如何射擊在各個具體的子類中實現,在士兵類Soldier中定義了一個方法 killEnemy,使用槍來kill敵人,具體用什麼槍,調用的時候才知道。
AbstractGun類源碼如下:
手槍、步槍、機槍的實現類代碼如下:
士兵類的源碼為:
注意,士兵類的killEnemy方法中使用的gun是抽象的,具體時間什麼槍需要由客戶端(Client)調用Soldier的構造方法傳參確定。
客戶端Client源碼如下:
注意:在類中調用其他類時務必要使用父類或接口,如果不能使用父類或接口,則說明類的設計已經違背了LSP原則。
2. 孩子類可以有自己的個性。
孩子類當然可以有自己的屬性和方法了,也正因如此,在子類出現的地方,父類未必就可以代替。
還是以上面的關於槍枝的例子為例,步槍有 AK47、SKS狙擊步槍等型號,把這兩個型號的槍引入後的Rifle的子類圖如下:
SKS狙擊步槍可以配一個8倍鏡進行遠程瞄準,相對於父類步槍,這就是SKS的個性。源碼如下:
狙擊手Spinner類的源碼如下:
狙擊手因為只能使用狙擊槍,所以,狙擊手類中持有的槍只能是狙擊類型的,如果換成父類步槍Rifle,則傳遞進來的可能就不是狙擊槍,而是AK47了,而AK47是沒有zoomOut方法的,所以肯定是不行的。這也驗證了裡氏替換原則的那一句話:有子類出現的地方,父類未必就可以代替。
3. 覆蓋或實現父類的方法時,輸入參數可以被放大。
來看一個例子,我們先定義一個Father類:
然後定義一個子類:
子類方法與父類方法同名,但又不是覆寫父類的方法。你加個@Override看看,會報錯的。像這種方法名相同,方法參數不同,叫做方法的重載。你可能會有疑問:重載不是只能在當前類內部重載嗎?因為Son繼承了Father,Son就有了Father的所有屬性和方法,自然就有了Father的doSomething這個方法,所以,這裡就構成了重載。
接下來看場景類:
根據裡氏替換原則,父類出現的地方子類就可以出現,我們把上面的父類替換為子類:
我們發現運行結果是一樣的。為什麼會這樣呢?因為子類Son繼承了Father,就擁有了doSomething(HashMap map)這個方法,不過由於Son沒有重寫這個方法,當調用Son的這個方法的時候,就會自動調用其父類的這個方法。所以兩次的結果是一致的。
舉個反例,如果父類的輸入參數類型大於子類的輸入參數類型,會出現什麼問題呢?我們直接看代碼執行結果即可輕鬆看出問題:
擴大父類方法入參:
縮小子類方法入參:
場景類:
根據裡氏替換原則,有父類的地方就可以有子類,我們把Father替換為Son看看結果:
兩次運行結果不一致,違反了裡氏替換原則,所以子類中方法的入參類型必須與父類中被覆寫的方法的入參類型相同或更寬鬆。
4. 覆蓋或實現父類的方法時,輸出結果可以被縮小。
這句話的意思就是,父類的一個方法的返回值是類型T,子類的相同方法(重載或重寫)的返回值為類型S,那麼裡氏替換原則就要求S必須小於等於T。為什麼呢?因為重寫父類方法,父類和子類的同名方法的輸入參數是相同的,兩個方法的範圍值S小於等於T,這時重寫父類方法的要求。
依賴倒置原則
依賴倒置原則在Java語言中的表現是:
1. 模塊間的依賴通過抽象發生,實現類之間不直接發生依賴關係,其依賴關係是通過接口或抽象類產生的;
2. 接口或抽象類不依賴於實現類;
3. 實現類依賴接口或抽象類。
說白了,就是「面向接口編程」。
依賴倒置原則可以減少類間的耦合性,提高系統的穩定性,降低並行開發引起的風險,提高代碼的可讀性和可維護性。
我們以汽車和司機舉例,畫出類圖:
奔馳車原始碼:
司機原始碼:
客戶端原始碼:
通過以上的代碼,完成了司機開動奔馳車的場景。可以看到,這個場景並沒有引用依賴倒置原則,司機Driver類直接依賴奔馳車Benz類,這樣會有什麼隱患呢?試想,後期業務變動,司機又買了一輛寶馬車,原始碼如下:
由於司機現在只有開奔馳的方法,所以他是開不了寶馬的。一個拿有C駕照的司機能開奔馳,不能開寶馬?太不合理了。所以,這就暴露出上面的設計問題了。我們對上面的功能重新設計,首先新建兩個接口。
汽車接口ICar:
司機接口IDriver:
IDriver中,通過傳入ICar接口實現了抽象之間的依賴關係。
接下來創建汽車實現類:奔馳和寶馬。
然後創建司機實現類:
最後是場景類調用:
Client屬於高層業務邏輯,它對低層模塊的依賴都建立在抽象上,driver的表面類型是IDriver,benz的表面類型是ICar。
依賴倒置原則的使用建議:
(1)每個類儘量都有接口或抽象類,或者接口和抽象類兩者都具備。
(2)變量的表面類型儘量是接口或抽象類。
(3)任何類都不應該從具體類派生。
(4)儘量不要重寫基類的方法。如果基類是一個抽象類,而且這個方法已經實現了,子類儘量不要重寫。
(5)結合裡氏替換原則使用。
接口隔離原則
接口隔離原則就是客戶端不應該依賴它不需要的接口,或者說類間的依賴關係應該建立在最小的接口上。
我們以搜索美女為例,設計了如下的類圖:
原始碼如下。美女及其實現類:
搜索程序及其子類原始碼如下:
最後是場景調用類:
上面實現了一個搜索美女的小程序。我們想像這個程序有沒有問題?IPettyGirl接口是否做到了最優化?並沒有。
每個人的審美觀不一樣,張三認為顏值高就是美女,即使身材和氣質一般;李四認為身材好就行,不在乎顏值和氣質;而王五則認為顏值和身材都是外在,只要有氣質,那就是美女。這時,IPettyGirl接口就滿足不了了,因為IPettyGirl的要求是顏值、身材、氣質兼具才是美女。所以為了滿足各種人的口味,我們需要重新設計接口的結構。把IPettyGirl拆分為3個接口,分別表示顏值高、身材好、氣質佳。修改後的類圖如下:
搜索類及其子類如下:
通過重構以後,不管以後需要顏值美女,還是需要身材美女,抑或氣質美女,都可以保持接口的穩定性。
以上把一個臃腫的接口拆分為三個獨立的接口所依賴的原則就是接口隔離原則。接口隔離原則是對接口進行規範約束。
迪米特法則
迪米特法則(LoD)也叫最少知道法則:一個對象應該對其他對象有最少的了解。
1.只和朋友交流
迪米特法則還有一個英文解釋是:Only talk to your immediate friends(只和直接的朋友交流)。每個對象都必然會與其他對象耦合,兩個對象的耦合就成為朋友關係。下面我們通過體育課老師讓班長清點女生人數為例講解。
首先設計程序的類圖:
編碼實現:
程序開發完了,我們首先看下Teacher類有幾個朋友類,首先要知道朋友類的定義:出現在成員變量、方法的輸入輸出參數中的類稱為成員朋友類。所以Teacher類只有一個GroupLeader朋友類。根據迪米特法則,一個類只能和朋友類交流,上面的Teacher類內部卻與非朋友類Girl發生了交流,這就不符合迪米特法則,破壞了程序的健壯性。
我們對類圖做下修改:
修改後的代碼:
再看場景類調用:
總之,就是類與類之間的關係是建立在類間的,而不是方法間,因此一個方法儘量不引入一個類中不存在的對象。
2.朋友間也是有距離的
我們在開發中經常有這種場景:調用一個或多個類,先執行第一個方法,然後是第二個方法,根據返回結果再看是否執行第三個方法。我們以安裝某個軟體為例,其類圖為:
代碼如下:
程序很簡單,但也存在一些問題:Wizard類把太多方法暴露給InstallSoftware類了,兩者的朋友關係太親密了,耦合關係變的異常牢固,如果要把Wizard中first方法的返回值改為Boolean類型,則要同時修改InstallSoftware類,增加了風險。因此,這種耦合是不合適的,我們需要對其優化。重構後的類圖如下:
代碼如下。導向類:
我們把安裝步驟改為私有方法,只向外暴露一個安裝方法,這樣,即使修改步驟的邏輯,也只是對Wizard自己有影響,只需要修改自己的安裝方法邏輯即可,其他類不會受到影響。
安裝類:
一個類公開的public屬性或方法越多,修改時涉及的面也就越大,變更引起的風險擴散也就越大。所以,我們開發中儘量不要對外公布太多public方法和非靜態的public變量,儘量內斂。
3.是自己的就是自己的
在實際開發中經常會出現這樣一種情況:一個方法放在吧本類中也可以,放在其他類中也沒有錯。那這時,我們只需要堅持一個原則:如果一個方法放在本類中,既不增加類間關係,也對本類不產生負面影響,那就放置在本類中。
總之,迪米特法則的核心觀念就是類間解耦,弱耦合,只有弱耦合了以後,類的復用率才可以提升上去。
開閉原則
開閉原則是指一個軟體實體如類、模塊和函數應該對擴展開放,對修改關閉。也就是說一個軟體實體應該通過擴展來實現變化,而不是通過修改已有的代碼來實現變化。我們以書店銷售書籍為例來說明什麼是開閉原則。
其類圖如下:
書籍及其實現類代碼如下:
書店類代碼:
項目開發完了,開始正常賣書了。假如到了雙十一,要搞打折活動,上面的功能是不支持的,所以需要修改程序。有三種方法可以解決這個問題:
(1)修改接口
在IBook接口裡新增getOffPrice()方法,專門用於進行打折,所有的實現類都實現該方法。但這樣修改的後果就是,實現類NovelBook要修改,書店類BookStore中的main方法也要修改,同時,IBook作為接口應該是穩定且可靠的,不應該經常發生變化,因此,該方案被否定。
(2)修改實現類
修改NovelBook類中的方法,直接在getPrice()方法中實現打折處理,這個方法可以是可以,但如果採購書籍的人員要看價格怎麼辦,由於該方法已經進行了打折處理,因此採購人員看到的也是打折後的價格,會因信息不對稱出現決策失誤的情況。因此,該方案也不是一個最優的方案。
(3)通過擴展實現變化
增加一個子類OffNovelBook,覆寫getPrice方法,高層次的模塊(也就是BookStore中static靜態塊中)通過OffNovelBook類產生新的對象,完成業務變化對系統的最小開發。這樣修改也少,風險也小,修改後的類圖如下:
OffNovelBook源碼如下:
然後修改BookStore中的書籍類為OffNovelBook:
為什麼要用開閉原則
1. 開閉原則非常著名,只要是做面向對象編程的,在開發時都會提及開閉原則。
2. 開閉原則是最基礎的一個原則,前面介紹的5個原則都是開閉原則的具體形態,而開閉原則才是其精神領袖。
3. 開閉原則提高了復用性,以及可維護性。
總結六大設計原則
1. 單一職責原則:一個類或接口只承擔一個職責。
2. 裡氏替換原則:在繼承類時,務必重寫(override)父類中所有的方法,尤其需要注意父類的protected方法(它們往往是讓你重寫的),子類儘量不要暴露自己的public方法供外界調用。
3. 依賴倒置原則:高層模塊不應該依賴於低層模塊,而應該依賴於抽象。抽象不應依賴於細節,細節應依賴於抽象。
4. 接口隔離原則:不要對外暴露沒有實際意義的接口。
5. 迪米特法則:儘量減少對象之間的交互,從而減小類之間的耦合。
6. 開閉原則:對軟體實體的改動,最好用擴展而非修改的方式。
點個關注吧,我會持續更新更多乾貨~~