淺談軟件設計的七大原則

軟件設計原則這個話題看上去很大,乍一看確實不小,但是如果仔細去分析的話可以發現這些原則其實就是爲了避免一些問題而提出的一些建議,這些建議呢普遍使用於軟件的各個領域,所以給這些建議提高了一個檔次就叫做原則了,哈哈,純屬本人理解。

首先,爲什麼要有軟件設計原則?軟件設計原則的目的是爲了讓我們編寫出更好的代碼,那什麼是“更好的代碼”?“更好的代碼”就是使代碼更簡潔、更易讀、更具有可維護性以及更具有可擴展性。那麼我們寫代碼或者設計代碼結構的時候不遵循軟件設計原則可以嗎?答案是可以的。因爲軟件設計原則不像是Java語法一樣的硬性要求,不這麼做編譯就不通過,你的程序就運行不了,相反,不遵循這七大設計原則你的代碼照樣能夠運行。那麼所謂的設計原則就是在大量的工程實踐的基礎上以及科學研究的基礎上總結出來的一些經驗和理念,我們在設計以及編寫代碼的過程中要儘量地借鑑前人的一些好的經驗來使我們自己少走彎路,這也是軟件設計原則的意義所在。

那麼軟件設計的過程中有哪七大原則呢?分別是開閉原則(Open-Closed Principle,OCP)、依賴倒置原則(Dependence Inversion Principle,DIP)、單一職責原則(Simple Responsibility Pinciple,SRP)、接口隔離原則(Interface Segregation Principle,ISP)、迪米特原則(Law of Demeter,LoD)、里氏替換原則(Liskov Substitution Principle,LSP)和合成複用原則(Composite/Aggregate Reuse Principle,CARP)。下面就逐一介紹一下。

一、開閉原則(Open-Closed Principle,OCP)

開閉原則即對擴展開放對修改關閉,即用擴展而不是修改來適應需求的變化。我們知道系統在正式發佈上線之後如果去修改既有代碼那麼可能會引入一些風險,比如我們修改A功能的代碼可能會使既有的B功能受到影響,如果控制不當的話就會出現A功能符合了新需求的變化但同時B功能出問題了,所以我們要進行迴歸測試來最大限度地降低這種風險的存在。那麼開閉原則就是針對類似這種現象而提出來的,並且這個原則是其他六個設計原則的基礎,或者說其他六個設計原則是開閉原則在不同方面的實現。

我們來舉一個例子來說明開閉原則:張三買了一輛寶馬,然後就開這輛寶馬。我們來看一下代碼實現:

這樣看沒有任何問題。現在需求變更了:張三不開寶馬了,改成開奔馳了。那如果不遵循OCP就得將Person類裏面的drive方法的入參由BMW改成Benz:

在調用方(main方法)也要跟着改:

也就是說如果張三不停地換車,代碼從上到下都要頻繁地跟着改,前面說過更改既有的代碼意味着引入風險。所以,就要用OCP來約束這種情況的發生。看以下代碼的改造:

調用方面向接口編程或者抽象編程:

這樣無論張三再怎麼換車,調用方(main方法)和Person類的drive方法都不需要跟着修改了,只需要在入口處注入不同品牌車的實例即可,這樣一來代碼就靈活了,擴展性就強了。平時我們說的面相接口編程或者面相抽象編程其實就是對OCP原則的一種應用。所以要深刻理解這句話——用擴展來適應需求的變化而不是用修改來適應變化,這就是OCP。

二、依賴倒置原則(Dependence Inversion Principle,DIP)

在介紹依賴倒置原則之前先說兩個概念和一個前提。這兩個概念是高層模塊和底層模塊,高層模塊就是服務的調用方,底層模塊就是服務的提供方。前提就是基於一個事實:抽象層的東西是不經常變動的,實現是經常會變動的,如果抽象層的東西經常變動的話那麼所說的這些原則都沒有什麼意義了。

DIP說得正式一點就是:高層模塊不依賴於低層模塊,二者應依賴其(低層模塊)抽象;抽象不依賴於細節,細節應依賴於抽象。說這些可能不太好理解,其實說白了DIP就解決一件事情——如何讓高層模塊不隨着低層模塊的變化而變化,換句話說就是無論服務提供者怎麼變,只要接口不變那麼服務調用方就不會變。什麼意思?還是拿上面OCP的例子來舉例。

main方法作爲調用方是高層模塊,BMW或者Benz是具體的實現爲低層模塊,那麼在main方法裏面直接new一個BMW出來就將高層模塊和低層模塊直接產生了關聯也就是直接耦合在了一起,如果需求發生變更:張三不開寶馬了改成了開車奔馳,那麼調用方第31行代碼必須作出修改:將new BMW()改成new Benz()。那麼如何解決低層模塊的變化不影響到高層模塊呢?就是二者共同依賴低層模塊的抽象ICar——調用者面向接口ICar編程,這樣張三在換車的時候注入進來新車的實例即可而無需做任何改動。最後我們可以看到張三換車這個例子不僅很好地解釋了OCP原則也很好地解釋了DIP原則,所以這裏也要很好地感謝張三頻繁地換車😄。

三、單一職責原則(Simple Responsibility Pinciple,SRP)

這個原則從名字就可以看出來,就是要求一個類或者一個方法只幹一件事。我們說類是對現實實體的抽象,比如人類。人類具有姓名、性別等這些屬性,還有吃飯和睡覺等一些列行爲,那麼我們可以抽象成一個Person,將這些屬性和行爲封裝進去。同時犬也是一種實體,我們可以封裝成Dog類,但是將人類和犬類的所有屬性和方法都封裝進Person類肯定不合適吧!這樣Person類就是對現實中兩類不同實體的抽象,明顯不合適,這就是類的單一職責。方法的單一職責也是一樣的,一個方法只幹一件事,如果想在這個方法中幹另一件事就不妨另寫一個方法,然後用這個方法去調用。這樣既有利於代碼的可讀性也使代碼更加簡潔,同時也降低了“改邏輯1的代碼會影響邏輯2代碼”這種情況的發生,這也使程序的可維護性大大提高。單一職責原則就這麼簡單,也沒必要寫代碼來說明了。

四、接口隔離原則(Interface Segregation Principle,ISP)

接口隔離原則就是說要使用多個具體的接口而不是使用單一的總接口,程序不應該依賴於它所不需要的接口。什麼意思?舉個例子就明白了。現在我有一個總體的接口IAnimal,它裏面包括個各種動物的方法,什麼天上飛的、地上跑的、水裏遊的都包括:

問題在截圖的註釋裏描述得很清楚了,解決方法就是將總接口插分成不同的小接口,然後讓Horse類去實現它所需要的接口即可。

五、迪米特原則(Law of Demeter,LoD)

一聽這個名字就會有人問:迪米特這哥們一定很牛叉吧,用他的名字來命名軟件設計原則,是不是和詹姆斯高斯林一樣啊?呵呵...首先迪米特不是哥們兒,也不是姐們兒,是一個項目的名字,它的提出人是伊恩·荷蘭(Ian Holland)。這個原則是說一個類(或者類的對象)要對其他類(或對象)保持最少的瞭解,又稱最少知道原則;一個類要和它的直接朋友打交道不和陌生人打交道。什麼是“一個類的直接朋友”?類的成員變量所屬的類、類方法入參的類型、方法返回值的類型是這個類的“直接朋友”,其他的比如方法裏面用到類都是“間接朋友”或者“陌生人”。是不是有點像黑社會?我們平時看港片經常會有這樣的情節:警察抓到了一個販毒集團的下線,這個人只知道跟他直接交易的上線是誰,上線的上線就不知道了,因爲知道的越少對他越有利;當一個殺手用槍指着一個人的時候,在臨死前問這個殺手爲什麼要殺他,得到的回答就一句話:“因爲你知道的太多了!”

用一個實際的例子來說明迪米特法則:開班會之前老師讓班長去買水果,然後把買的是什麼水果告訴她。這個裏面可以把老師抽象出一個實體,學生抽象出一個實體,水果抽象出一個實體。老師讓學生去買水果,所以學生和水果打交道就可以了,老師不需要和水果打交道,老師只是發出一個命令最後要得到水果名字這樣一個結果。按照LoD原則,老師和學生是“直接朋友”,老師和水果是“間接朋友”。

在上面這個例子中,Fruit作爲Teacher類command方法的入參類型,也就是Fruit成爲了Teacher的“直接朋友”,這明顯不符合LoD原則。既然老師不需要和水果打交道,那應該把創建水果對象的工作放到學生類裏面。改造如下:

這樣就符合了迪米特法則。

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

首先Liskov確實是一個人,她是美國第一個獲得計算機科學博士學位的女性。Liskov於1987年提出了一個關於繼承的原則“Inheritance should ensure that any property proved about supertype objects also holds for subtype objects.”——“繼承必須確保超類所擁有的性質在子類中仍然成立。”也就是說,當一個子類的實例應該能夠替換任何其超類的實例時,它們之間才具有is-A關係。(摘自百度百科:https://baike.baidu.com/item/Barbara%20Liskov/1578598?fr=aladdin

她的主要觀點翻譯過來就是:若對每個類型S的對象o1,都存在一個類型T的對象o2,使得在所有針對T編寫的程序P中,用o1替換o2後,程序P的行爲不變,則S是T的子類型。這樣看有點不知所云,我再給翻譯一下其實就是一句話:用子類去完全替換父類的話那麼程序的行爲應該保持不變。爲了達到這個目的可以細分爲以下三點:

1、子類不能複寫父類的方法。

子類對象完全替換父類對象之後使程序的行爲保持不變的最好的方式就是子類不要去複寫父類的方法。原來用父類的對象調用的一定是父類的方法,現在換成了子類的對象,如果子類複寫了這個方法那麼在多態的情況下就會調用子類的方法,那麼就很可能出現和父類方法不一致的行爲。網上有好多博客在解釋LSP的時候用長方形和正方形來舉例子,這個例子其實不錯,讀者有興趣可以去參考其他資料來學習。這裏我想舉一個更形象化的例子來說明LSP,假設有這樣一個場景:一個人想騎着一隻鳥到天上飛。

這個例子已經充分地說明子類複寫了父類的方法之後表現出了截然相反的功能,而接口fly()方法是沒有變化的,所以調用者感知不到底層實現已經被替換,等上線之後會發現fly()接口乾的已經不是以前的事情了。這就是爲什麼在LSP的原則下爲了保證原有程序不變而強制要求子類不能複寫父類的方法。

2、子類可以實現父類的抽象方法也可以添加新的方法。

這個就不必多說了,但是要注意子類實現父類的抽象方法的規則和子類複寫父類非抽象方法的規則是一樣的,這些規則包括方法名、參數列表、方法訪問控制權限、方法返回值類型範圍、聲明拋出異常的範圍,這一點下面會用到。

3、當子類重載父類的方法時,方法入參的範圍要比父類的更大。我們說方法重載的要求是方法名一定要一樣,方法的參數列表(類型、數量和順序)必須不一樣,對其他的諸如返回值類型等沒有要求,而LSP在重載規則基礎上提出了入參類型範圍的要求。其實理解這點非常簡單,只要我們知道方法的重載有個入參類型的就近原則以及LSP的目的就知道爲什麼要這麼規定了。我們來review一下就近原則:

這就是方法重載的就近原則——當有多個方法可以接收某個入參時會選擇離入參類型範圍最近的那個方法去執行,所以會調用父類的met方法,這也是LSP的目的——當有子類重載父類的方法時最終要保證調用的是父類的方法,這樣纔可以保證被子類替換後程序保持不變的行爲。

在網上有好多資料都說還有第4點——當子類的方法實現(重寫/重載或實現抽象方法)父類的方法時,方法返回值的類型範圍要比父類更小或相等。其實在經過仔細分析之後我認爲這一點的提出有點雞肋,可有可無,所以在本篇博客中我大膽地把這一點去掉了。我們可以詳細地分析一下這第4點:

子類實現父類的方法有三種方式——重寫/重載或實現抽象方法,前面也說過LSP不允許子類重寫父類的方法,所以實現方式就剩兩個了——重載或實現抽象方法,我們來看重載的情況。在上面的第3點說過子類重載父類的方法時入參的範圍必須比父類大,這樣才能保證被替換了子類之後調用的還是父類的方法,從而保證程序行爲的不變。那既然這樣無論子類怎麼重載,在子類中這個重載的方法都不會被調用到,所以它的返回值的範圍大小也就無所謂了,是不是這樣!再來看最後一個子類實現父類方法的方式——實現抽象方法。前面我們說子類不能複寫父類的方法的非抽象方法,但是可以實現父類的抽象方法,而實現抽象方法的規則和複寫非抽象方法的規則是一樣的,這個規則是Java語法層面規定的,是強制的,不這麼做編譯就不通過。什麼意思呢?“當子類實現父類的抽象方法時,方法返回值的類型範圍要比父類更小或相等”這個規則不是LSP提出來的,是Java語法就這麼規定的,不這麼做編譯就不通過,而且肯定是先有Java語法再有里氏替換原則。我們說Java語法是必須遵守的,軟件設計的七大原則是前人的經驗總結,不是硬性要求,並且Java也不可能把軟件設計的原則提高到語法層面來做強制要求。所以,“當子類實現父類的抽象方法時,方法返回值的類型範圍要比父類更小或相等”這句話是和LSP半毛錢關係沒有,放在這只是把Java的語法重複一遍。經過以上的分析我們可以看到第4點沒有存在的必要,這僅代表本人的觀點,如有不當之處歡迎指正!

七、合成複用原則(Composite/Aggregate Reuse Principle,CARP)

這個原則是說優先使用方法的組合或者聚合來達到代碼複用的目的。我們先來看一下繼承的缺點以及關聯、聚合、組合是什麼概念。

首先繼承有其弊端,比如破壞了封裝、加強了類之間的耦合,那麼既要實現代碼的複用又想避免繼承的弊端可以使用聚合來代替繼承。A聚合B,就是在A類裏面持有一個B類的成員變量。A類和B類是完全獨立的,A類可以使用B類的對象來調用B類的方法或者屬性,達到對B類代碼複用的目的。

下面說一下繼承、關聯、聚合、組合的區別:

1、繼承

不用多說了,A繼承B,那麼A就是B,但反過來B不一定是A。所以繼承是is-a的關係,是一種縱向的關係。

2、關聯

關聯是兩個類之間有聯繫,這種聯繫是很微弱的或者說是臨時的,比如人過河要坐船,人和船就產生了聯繫,但是這種聯繫是臨時的,人過了河就跟船沒關係了。表現在代碼層面就是A關聯B,那麼B以A的成員變量的形式存在在類A中。

3、聚合

聚合是關聯關係的一種,也是兩個事物要產生關係,但是這個關係的強度要大於關聯關係,不是微弱或者臨時的,是比較長久的關聯,比如員工和公司的關係。但是這種互相關聯的兩個對象彼此又是獨立的,各有各的生命週期,如果拆散這種關係那麼各自也能玩兒的轉。就好比員工和公司是一種長期的關係,但是公司和員工是彼此獨立的,各有各的生命週期,公司沒有張三這個員工也能玩兒的轉,反過來張三不在這個公司去別的公司也玩兒的轉。這種關係體現的是has-a的關係,代碼表現和關聯關係一樣。

4、組合

組合是比聚合更緊密的關聯關係,是一種強關聯關係,具有組合關係的兩個事物誰都不能缺少誰,如果斷開這種關係各自都玩兒不轉了,比如人和大腦的關係。這中關係是contains-a的關係,代碼表現和關聯關係一樣。

所以從代碼層面上看關聯、聚合和組合表現形式都是一樣的,得從語義上來區分,他們三個都是橫向的關係。

那什麼時候用繼承又什麼時候用組合呢?如果你要對現實世界建模,比如要描述狗類和動物的時候就要用繼承,因爲這些在概念上具有層次關係;如果你想表述你個實體要藉助另一個實體來完成某項任務或者想表達has-a或者contains-a的時候就要用組合;如果只是在實現層面想達到代碼複用的目的,那麼優先使用組合。

相信大家已經知道聚合和組合該怎麼使用了,我就不用用代碼來舉例子了。

軟件設計的七大原則就說到這裏,歡迎大家一起討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章