設計模式之七大基本原則
做什麼事都需要遵循一些準則,設計模式也不例外。我們在設計一些設計模式時,一般遵循如下七項基本原則,它們分別是:
- 單一職責原則 (Single Responsibility Principle)
- 開放-關閉原則 (Open-Closed Principle)
- 里氏替換原則 (Liskov Substitution Principle)
- 依賴倒轉原則 (Dependence Inversion Principle)
- 介面隔離原則 (Interface Segregation Principle)
- 迪米特法則(Law Of Demeter)
- 組合/聚合復用原則 (Composite/Aggregate Reuse Principle)
1.單一職責原則 SRP
單一職責原則表示一個模塊的組成元素之間的功能相關性。從軟體變化的角度來看,就一個類而言,應該僅有一個讓它變化的原因;通俗地說,即一個類只負責一項職責。
假設某個類 P 負責兩個不同的職責,職責 P1 和 職責 P2,那麼當職責 P1 需求發生改變而需要修改類 P,有可能會導致原來運行正常的職責 P2 功能發生故障。
我們假設一個場景:
有一個動物類,它會呼吸空氣,用一個類描述動物呼吸這個場景:
class Animal{ n public void breathe(String animal){ n System.out.println(animal + "呼吸空氣"); n } n} npublic class Client{ n public static void main(String[] args){ n Animal animal = new Animal(); n animal.breathe("牛"); n animal.breathe("羊"); n animal.breathe("豬"); n } n} n
在後來發現新問題,並不是所有的動物都需要呼吸空氣,比如魚需要呼吸水,修改時如果遵循單一職責原則的話,那麼需要將 Animal 類進行拆分為陸生類和水生動物類,代碼如下:
class Terrestrial{ n public void breathe(String animal){ n System.out.println(animal + "呼吸空氣"); n } n} nclass Aquatic{ n public void breathe(String animal){ n System.out.println(animal + "呼吸水"); n } n} n npublic class Client{ n public static void main(String[] args){ n Terrestrial terrestrial = new Terrestrial(); n terrestrial.breathe("牛"); n terrestrial.breathe("羊"); n terrestrial.breathe("豬"); n n Aquatic aquatic = new Aquatic(); n aquatic.breathe("魚"); n } n} n
在實際工作中,如果這樣修改的話開銷是很大的,除了將原來的 Animal 類分解為 Terrestrial 類和 Aquatic 類以外還需要修改客戶端,而直接修改類 Animal 類來達到目的雖然違背了單一職責原則,但是花銷卻小的多,代碼如下:
class Animal{ n public void breathe(String animal){ n if("魚".equals(animal)){ n System.out.println(animal + "呼吸水"); n }else{ n System.out.println(animal + "呼吸空氣"); n } n } n} nnpublic class Client{ n public static void main(String[] args){ n Animal animal = new Animal(); n animal.breathe("牛"); n animal.breathe("羊"); n animal.breathe("豬"); n animal.breathe("魚"); n } n}n
可以看得出,這樣的修改顯然簡便了許多,但是卻存在著隱患,如果有一天有需要加入某類動物不需要呼吸,那麼就要修改 Animal 類的 breathe 方法,而對原有代碼的修改可能會對其他相關功能帶來風險,也許有一天你會發現輸出結果變成了:"牛呼吸水" 了,這種修改方式直接在代碼級別上違背了單一職責原則,雖然修改起來最簡單,但隱患卻最大的。
另外還有一種修改方式:
class Animal{ n public void breathe(String animal){ n System.out.println(animal + "呼吸空氣"); n } n n public void breathe2(String animal){ n System.out.println(animal + "呼吸水"); n } n} n npublic class Client{ n public static void main(String[] args){ n Animal animal = new Animal(); n animal.breathe("牛"); n animal.breathe("羊"); n animal.breathe("豬"); n animal.breathe2("魚"); n } n} n
可以看出,這種修改方式沒有改動原來的代碼,而是在類中新加了一個方法,這樣雖然違背了單一職責原則,但是它並沒有修改原來已存在的代碼,不會對原本已存在的功能造成影響。
那麼在實際編程中,需要根據實際情況來確定使用哪種方式,只有邏輯足夠簡單,才可以在代碼級別上違背單一職責原則。
總結:
- SRP 是一個簡單又直觀的原則,但是在實際編碼的過程中很難將它恰當地運用,需要結合實際情況進行運用。
- 單一職責原則可以降低類的複雜度,一個類僅負責一項職責,其邏輯肯定要比負責多項職責簡單。
- 提高了代碼的可讀性,提高系統的可維護性。
2. 開放-關閉原則 OCP
開放-關閉原則表示軟體實體 (類、模塊、函數等等) 應該是可以被擴展的,但是不可被修改。(Open for extension, close for modification)
如果一個軟體能夠滿足 OCP 原則,那麼它將有兩項優點:
- 能夠擴展已存在的系統,能夠提供新的功能滿足新的需求,因此該軟體有著很強的適應性和靈活性。
- 已存在的模塊,特別是那些重要的抽象模塊,不需要被修改,那麼該軟體就有很強的穩定性和持久性。
舉個簡單例子,這裡有個生產電腦的公司,根據輸入的類型,生產出不同的電腦,代碼如下:
interface Computer {}nclass Macbook implements Computer {}nclass Surface implements Computer {}nclass Factory {n public Computer produceComputer(String type) {n Computer c = null;n if(type.equals("macbook")){n c = new Macbook();n }else if(type.equals("surface")){n c = new Surface();n }n return c;n } n}n
顯然上面的代碼違背了開放 - 關閉原則,如果需要添加新的電腦產品,那麼修改 produceComputer 原本已有的方法,正確的方式如下:
interface Computer {}nclass Macbook implements Computer {}nclass Surface implements Computer {}ninterface Factory {n public Computer produceComputer();n}nclass AppleFactory implements Factory {n public Computer produceComputer() {n return new Macbook();n }n}nclass MSFactory implements Factory {n public Computer produceComputer() {n return new Surface();n }n}n
正確的方式應該是將 Factory 抽象成介面,讓具體的工廠(如蘋果工廠,微軟工廠)去實現它,生產它們公司相應的產品,這樣寫有利於擴展,如果這是需要新增加戴爾工廠生產戴爾電腦,我們僅僅需要創建新的電腦類和新的工廠類,而不需要去修改已經寫好的代碼。
總結:
- OCP 可以具有良好的可擴展性,可維護性。
- 不可能讓一個系統的所有模塊都滿足 OCP 原則,我們能做到的是儘可能地不要修改已經寫好的代碼,已有的功能,而是去擴展它。
3. 里氏替換原則 LSP
在編程中常常會遇到這樣的問題:有一功能 P1, 由類 A 完成,現需要將功能 P1 進行擴展,擴展後的功能為 P,其中P由原有功能P1與新功能P2組成。新功能P由類A的子類B來完成,則子類B在完成新功能P2的同時,有可能會導致原有功能P1發生故障。
里氏替換原則告訴我們,當使用繼承時候,類 B 繼承類 A 時,除添加新的方法完成新增功能 P2,盡量不要修改父類方法預期的行為。
里氏替換原則的重點在不影響原功能,而不是不覆蓋原方法。
繼承包含這樣一層含義:父類中凡是已經實現好的方法(相對於抽象方法而言),實際上是在設定一系列的規範和契約,雖然它不強制要求所有的子類必須遵從這些契約,但是如果子類對這些非抽象方法任意修改,就會對整個繼承體系造成破壞。而里氏替換原則就是表達了這一層含義。
舉個例子,我們需要完成一個兩數相減的功能:
class A{ n public int func1(int a, int b){ n return a-b; n } n} n
後來,我們需要增加一個新的功能:完成兩數相加,然後再與100求和,由類B來負責。即類B需要完成兩個功能:
- 兩數相減
- 兩數相加,然後再加100
由於類A已經實現了第一個功能,所以類B繼承類A後,只需要再完成第二個功能就可以了,代碼如下:
class B extends A{ n public int func1(int a, int b){ n return a+b; n } n n public int func2(int a, int b){ n return func1(a,b)+100; n } n} n
我們發現原來原本運行正常的相減功能發生了錯誤,原因就是類 B 在給方法起名時無意中重寫了父類的方法,造成了所有運行相減功能的代碼全部調用了類 B 重寫後的方法,造成原來運行正常的功能出現了錯誤。在實際編程中,我們常常會通過重寫父類的方法來完成新的功能,這樣寫起來雖然簡單,但是這樣往往也增加了重寫父類方法所帶來的風險。
里氏替換原則通俗的來講就是:子類可以擴展父類的功能,但不能改變父類原有的功能。
4. 依賴倒轉原則 DIP
定義:高層模塊不應該依賴低層模塊,二者都應該於抽象。進一步說,抽象不應該依賴於細節,細節應該依賴於抽象。
舉個例子, 某天產品經理需要添加新的功能,該功能需要操作資料庫,一般負責封裝資料庫操作的和處理業務邏輯分別由不同的程序員編寫。
封裝資料庫操作可認為低層模塊,而處理業務邏輯可認為高層模塊,那麼如果處理業務邏輯需要等到封裝資料庫操作的代碼寫完的話才能添加的話講會嚴重拖垮項目的進度。
正確的做法應該是處理業務邏輯的程序員提供一個封裝好資料庫操作的抽象介面,交給低層模塊的程序員去編寫,這樣雙方可以單獨編寫而互不影響。
依賴倒轉原則的核心思想就是面向介面編程,思考下面這樣一個場景:母親給孩子講故事,只要給她一本書,她就可照著書給孩子講故事了。代碼如下:
class Book{ n public String getContent(){ n return "這是一個有趣的故事"; n } n} n nclass Mother{ n public void say(Book book){ n System.out.println("媽媽開始講故事"); n System.out.println(book.getContent()); n } n} n npublic class Client{ n public static void main(String[] args){ n Mother mother = new Mother(); n mother.say(new Book()); n } n} n
假如有一天,給的是一份報紙,而不是一本書,讓這個母親講下報紙上的故事,報紙的代碼如下:
class Newspaper{ n public String getContent(){ n return "這個一則重要的新聞"; n } n} n
然而這個母親卻辦不到,應該她只會讀書,這太不可思議,只是將書換成報紙,居然需要修改 Mother 類才能讀,假如以後需要換成了雜誌呢?原因是 Mother 和 Book 之間的耦合度太高了,必須降低他們的耦合度才行。
我們可以引入一個抽象介面 IReader 讀物,讓書和報紙去實現這個介面,那麼無論提供什麼樣的讀物,該母親都能讀。代碼如下:
interface IReader{ n public String getContent(); n} nnclass Newspaper implements IReader { n public String getContent(){ n return "這個一則重要的新聞"; n } n} nclass Book implements IReader{ n public String getContent(){ n return "這是一個有趣的故事"; n } n} n nclass Mother{ n public void say(IReader reader){ n System.out.println("媽媽開始講故事"); n System.out.println(reader.getContent()); n } n} n npublic class Client{ n public static void main(String[] args){ n Mother mother = new Mother(); n mother.say(new Book()); n mother.say(new Newspaper()); n } n}n
這樣修改之後,以後無論提供什麼樣的讀物,只要去實現了 IReader 介面之後就可以被母親讀。實際情況中,代表高層模塊的 Mother 類將負責完成主要的業務邏輯,一旦需要對它進行修改,引入錯誤的風險極大。所以遵循依賴倒轉原則可以降低類之間的耦合性,提高系統的穩定性,降低修改程序造成的風險。
依賴倒轉原則的核心就是要我們面向介面編程,理解了面向介面編程,也就理解了依賴倒轉。
5. 介面隔離原則 ISP
介面隔離原則,其 "隔離" 並不是準備的翻譯,真正的意圖是 「分離」 介面(的功能)
介面隔離原則強調:客戶端不應該依賴它不需要的介面;一個類對另一個類的依賴應該建立在最小的介面上。
我們先來看一張圖:
從圖中可以看出,類 A 依賴於 介面 I 中的方法 1,2,3 ,類 B 是對類 A 的具體實現。類 C 依賴介面 I 中的方法 1,4,5,類 D 是對類 C 的具體實現。對於類B和類D來說,雖然他們都存在著用不到的方法(也就是圖中紅色字體標記的方法),但由於實現了介面I,所以也必須要實現這些用不到的方法。
用代碼表示:
interface I { n public void method1(); n public void method2(); n public void method3(); n public void method4(); n public void method5(); n} n nclass A{ n public void depend1(I i){ n i.method1(); n } n public void depend2(I i){ n i.method2(); n } n public void depend3(I i){ n i.method3(); n } n} n nclass B implements I{ nt // 類 B 只需要實現方法 1,2, 3,而其它方法它並不需要,但是也需要實現n public void method1() { n System.out.println("類 B 實現介面 I 的方法 1"); n } n public void method2() { n System.out.println("類 B 實現介面 I 的方法 2"); n } n public void method3() { n System.out.println("類 B 實現介面 I 的方法 3"); n } n public void method4() {} n public void method5() {} n} n nclass C{ n public void depend1(I i){ n i.method1(); n } n public void depend2(I i){ n i.method4(); n } n public void depend3(I i){ n i.method5(); n } n} nnnclass D implements I{ nt// 類 D 只需要實現方法 1,4,5,而其它方法它並不需要,但是也需要實現n public void method1() { n System.out.println("類 D 實現介面 I 的方法 1"); n } n public void method2() {} n public void method3() {} n public void method4() { n System.out.println("類 D 實現介面 I 的方法 4"); n } n public void method5() { n System.out.println("類 D 實現介面 I 的方法 5"); n } n} n npublic class Client{ n public static void main(String[] args){ n A a = new A(); n a.depend1(new B()); n a.depend2(new B()); n a.depend3(new B()); n n C c = new C(); n c.depend1(new D()); n c.depend2(new D()); n c.depend3(new D()); n } n} n
可以看出,如果介面定義的過於臃腫,只要介面中出現的方法,不管依賴於它的類是否需要該方法,實現類都必須去實現這些方法,這就不符合介面隔離原則,如果想符合介面隔離原則,就必須對介面 I 如下圖進行拆分:
代碼可修改為如下:
interface I1 { n public void method1(); n} n ninterface I2 { n public void method2(); n public void method3(); n} n ninterface I3 { n public void method4(); n public void method5(); n} n nclass A{ n public void depend1(I1 i){ n i.method1(); n } n public void depend2(I2 i){ n i.method2(); n } n public void depend3(I2 i){ n i.method3(); n } n} n nclass B implements I1, I2{ n public void method1() { n System.out.println("類 B 實現介面 I1 的方法 1"); n } n public void method2() { n System.out.println("類 B 實現介面 I2 的方法 2"); n } n public void method3() { n System.out.println("類 B 實現介面 I2 的方法 3"); n } n} n nclass C{ n public void depend1(I1 i){ n i.method1(); n } n public void depend2(I3 i){ n i.method4(); n } n public void depend3(I3 i){ n i.method5(); n } n} n nclass D implements I1, I3{ n public void method1() { n System.out.println("類 D 實現介面 I1 的方法 1"); n } n public void method4() { n System.out.println("類 D 實現介面 I3 的方法 4"); n } n public void method5() { n System.out.println("類 D 實現介面 I3 的方法 5"); n } n} n
總結:
- 介面隔離原則的思想在於建立單一介面,儘可能地去細化介面,介面中的方法儘可能少
- 但是凡事都要有個度,如果介面設計過小,則會造成介面數量過多,使設計複雜化。所以一定要適度。
6. 迪米特法則 LOD
迪米特法則又稱為 最少知道原則,它表示一個對象應該對其它對象保持最少的了解。通俗來說就是,只與直接的朋友通信。
首先來解釋一下什麼是直接的朋友:每個對象都會與其他對象有耦合關係,只要兩個對象之間有耦合關係,我們就說這兩個對象之間是朋友關係。耦合的方式很多,依賴、關聯、組合、聚合等。其中,我們稱出現成員變數、方法參數、方法返回值中的類為直接的朋友,而出現在局部變數中的類則不是直接的朋友。也就是說,陌生的類最好不要作為局部變數的形式出現在類的內部。
對於被依賴的類來說,無論邏輯多麼複雜,都盡量的將邏輯封裝在類的內部,對外提供 public 方法,不對泄漏任何信息。
舉個例子,家人探望犯人
- 家人:家人只與犯人是親人,但是不認識他的獄友
public class Family {n public void visitPrisoner(Prisoners prisoners) {n Inmates inmates = prisoners.helpEachOther();n imates.weAreFriend();n }n}n
- 犯人:犯人與家人是親人,犯人與獄友是朋友
public class Prisoners {n private Inmates inmates = new Inmates();n public Inmates helpEachOther() {n System.out.println("家人說:你和獄友之間應該互相幫助...");n return inmates;n }n}n
- 獄友: 犯人與獄友是朋友,但是不認識他的家人
public class Inmates {n public void weAreFriend() {n System.out.println("獄友說:我們是獄友...");n }n}n
- 場景類:發生在監獄裡
public class Prison {n public static void main(String args[])n {n Family family = new Family();n family.visitPrisoner(new Prisoners());n }n}n
運行結果會發現:
家人說:你和獄友之間應該互相幫助...n獄友說:我們是獄友...n
家人和獄友顯然是不認識的,且監獄只允許家人探望犯人,而不是隨便誰都可以見面的,這裡家人和獄友有了溝通顯然是違背了迪米特法則,因為在 Inmates 這個類作為局部變數出現在了 Family 類中的方法里,而他們不認識,不能夠跟直接通信,迪米特法則告訴我們只與直接的朋友通信。所以上述的代碼可以改為:
public class Family {n //家人探望犯人n public void visitPrisoner(Prisoners prisoners) {n System.out.print("家人說:");n prisoners.helpEachOther();n }n}nnpublic class Prisoners {n private Inmates inmates = new Inmates();n public Inmates helpEachOther() {n System.out.println("犯人和獄友之間應該互相幫助...");n System.out.print("犯人說:");n inmates.weAreFriend();n return inmates;n }n n}nnpublic class Inmates {n public void weAreFriend() {n System.out.println("我們是獄友...");n }n}nnpublic class Prison {n public static void main(String args[]) {n Family family = new Family();n family.visitPrisoner(new Prisoners());n }n}n
運行結果
家人說:犯人和獄友之間應該互相幫助...n犯人說:我們是獄友...n
這樣家人和獄友就分開了,但是也表達了家人希望獄友能跟犯人互相幫助的意願。也就是兩個類通過第三個類實現信息傳遞, 而家人和獄友卻沒有直接通信。
7. 組合/聚合復用原則 CRP
組合/聚合復用原則就是在一個新的對象裡面使用一些已有的對象,使之成為新對象的一部分; 新的對象通過向這些對象的委派達到復用已有功能的目的。
在面向對象的設計中,如果直接繼承基類,會破壞封裝,因為繼承將基類的實現細節暴露給子類;如果基類的實現發生了改變,則子類的實現也不得不改變;從基類繼承而來的實現是靜態的,不可能在運行時發生改變,沒有足夠的靈活性。於是就提出了組合/聚合復用原則,也就是在實際開發設計中,盡量使用組合/聚合,不要使用類繼承。
舉個簡單的例子,在某家公司里的員工分為經理,工作者和銷售者。如果畫成 UML 圖可以表示為:
但是這樣違背了組合聚合復用原則,繼承會將 Employee 類中的方法暴露給子類。如果要遵守組合聚合復用原則,可以將其改為:
這樣做降低了類與類之間的耦合度,Employee 類的變化對其它類造成的影響相對較少。
總結:
- 總體說來,組合/聚合復用原則告訴我們:組合或者聚合好過於繼承。
- 聚合組合是一種 「黑箱」 復用,因為細節對象的內容對客戶端來說是不可見的。
推薦閱讀:
※設計模式之單例模式
※抽象工廠模式和工廠模式的區別?
※敏捷開發中如何保證界面設計?
※狀態模式和策略模式的區別與聯繫?
※一個靜態類或者非靜態類,多個方法依賴一個函數,如何實現?
TAG:设计模式 |