【設計模式】第一篇:概述、耦合、UML、七大原則,詳細分析總結(基於Java)

BWH_Steven發表於2020-10-26

迷茫了一週,一段時間重複的 CRUD ,著實讓我有點煩悶,最近打算將這些技術棧系列的文章先暫時擱置一下,開啟一個新的篇章《設計模式》,畢竟前面寫了不少 “武功招式” 的文章,也該提升一下內功了

一 設計模式概述

(一) 什麼是設計模式

設計模式,即Design Patterns,是指在軟體設計中,被反覆使用的一種程式碼設計經驗。使用設計模式的目的是為了可重用程式碼,提高程式碼的可擴充套件性和可維護性

1995年,GoF(Gang of Four,四人組/四人幫)合作出版了《設計模式:可複用物件導向軟
件的基礎》一書,收錄了23種設計模式,從此樹立了軟體設計模式領域的里程碑,【GoF設計模式】

(二) 為什麼學習設計模式

前面我們學習了 N 種不同的技術,但是歸根結底,也只是 CRUD 與 呼叫之間的堆砌,或許這個創意亦或是業務很完善、很強大,其中也巧妙運用了各種高效的演算法,但是說白了,這也只是為了實現或者說解決某個問題而做的

還有時候,兩個人同時開發一款相同的產品,均滿足了預期的需求,但是 A 的程式,不僅程式碼健壯性強,同時後期維護擴充套件更是便捷(這種感覺,我們會在後面具體的設計模式中愈發的感覺到)而 B 的程式碼卻是一言難盡啊

有一句話總結的非常好:

  • 設計模式的本質是物件導向設計原則的實際運用,是對類的封裝性、繼承性和多型性以及類的關聯關係和組合關係的充分理解

也就是說,畢竟像例如Java這樣物件導向的語言中,如何實現一個可維護,可維護的程式碼,那必然就是要降低程式碼耦合度,適當複用程式碼,而要實現這一切,就需要充分的利用 OOP 程式設計的特性和思想

注:下面第二大點補充【耦合】的相關概念,若不需要跳轉第三四大點【UML類圖及類圖間的關係】/【設計模式七大原則】

在之前我寫 Spring依賴注入的時候【萬字長文】 Spring框架層層遞進輕鬆入門(0C和D),就是從傳統開發,講到了如何通過工廠模式,以及多例到單例的改進,來一步步實現解耦,有興趣的朋友可以看一下哈

二 什麼是耦合?(高/低)

作為一篇新手都能看懂的文章,開始就一堆 IOC AOP等專業名詞扔出去,好像是不太禮貌,我得把需要鋪墊的知識給大家儘量說一說,如果對這塊比較明白的大佬,直接略過就OK了

耦合,就是模組間關聯的程度,每個模組之間的聯絡越多,也就是其耦合性越強,那麼獨立性也就越差了,所以我們在軟體設計中,應該儘量做到低耦合,高內聚

生活中的例子:家裡有一條串燈,上面有很多燈泡,如果燈壞了,你需要將整個燈帶都換掉,這就是高耦合的表現,因為燈和燈帶之間是緊密相連,不可分割的,但是如果燈泡可以隨意拆卸,並不影響整個燈帶,那麼這就叫做低耦合

程式碼中的例子:來看一個多型的呼叫,前提是 B 繼承 A,引用了很多次

A a = new B();
a.method();

如果你想要把B變成C,就需要修改所有new B() 的地方為 new C() 這也就是高耦合

如果如果使用我們今天要說的 spring框架 就可以大大的降低耦合

A a = BeanFactory().getBean(B名稱);
a.method();

這個時候,我們只需要將B名稱改為C,同時將配置檔案中的B改為C就可以了

常見的耦合有這些分類:

(一) 內容耦合

當一個模組直接修改或操作另一個模組的資料,或者直接轉入另一個模組時,就發生了內容耦合。此時,被修改的模組完全依賴於修改它的模組。 這種耦合性是很高的,最好避免

public class A {
    public int numA = 1;
}

public class B {
    public static A a = new A();
    public static void method(){
        a.numA += 1;
    }
    public static void main(String[] args) {
       method();
       System.out.println(a.numA);
    }
}

(二) 公共耦合

兩個以上的模組共同引用一個全域性資料項就稱為公共耦合。大量的公共耦合結構中,會讓你很難確定是哪個模組給全域性變數賦了一個特定的值

(三) 外部耦合

一組模組都訪問同一全域性簡單變數,而且不通過參數列傳遞該全域性變數的資訊,則稱之為外部耦合 從定義和圖中也可以看出,公共耦合和外部耦合的區別就在於前者是全域性資料結構後者是全域性簡單變數

(四) 控制耦合

控制耦合 。一個模組通過介面向另一個模組傳遞一個控制訊號,接受訊號的模組根據訊號值而進行適當的動作,這種耦合被稱為控制耦合,也就是說,模組之間傳遞的不是資料,而是一些標誌,開關量等等

(五) 標記耦合

標記耦合指兩個模組之間傳遞的是資料機構,如高階語言的陣列名、記錄名、檔名等這些名字即為標記,其實傳遞的是這個資料結構的地址

(六) 資料耦合

模組之間通過引數來傳遞資料,那麼被稱為資料耦合。資料耦合是最低的一種耦合形 式,系統中一般都存在這種型別的耦合,因為為了完成一些有意義的功能,往往需要將某些模組的輸出資料作為另 一些模組的輸入資料

(七) 非直接耦合

兩個模組之間沒有直接關係,它們之間的聯絡完全是通過主模組的控制和呼叫來實現的

三 UML 類圖及類圖之間的關係

在一個相對完善的軟體系統中,每個類都有其責任,類與類之間,類與介面之間同時也存在著各種關係,UML(統一建模語言)從不同的角度定義了多種圖,在軟體建模時非常常用,下面我們說一下在設計模式中涉及相對較多的類圖,因為在後面單個設計模式的講解中,我們會涉及到,也算是一個基礎鋪墊。

(一) 類

類是一組相關的屬性和行為的集合,是一個抽象的概念,在UML中,一般用一個分為三層的矩形框來代表類

  • 第一層:類名稱,是一個字串,例如 Student

  • 第二層:類屬性(欄位、成員變數)格式如下:

    • [可見性]屬性名:型別[=預設值]
    • 例如:-name:String
  • 第三層:類操作(方法、行為),格式如下:

    • [可見性]名稱(引數列表)[:返回型別]
  • 例如:+ display():void

(二) 介面

介面,是一種特殊而又常用的類,不可被例項化,定義了一些抽象的操作(方法),但不包含屬性其實能見到介面 UML 描述的有三種形式:

  • 第一種:使用一個帶有名稱的小圓圈來表示,上面的Dog是介面名,下面是介面定義的方法
  • 第二種:使用一個“框”來表示,和類很像,但是在最上面特別標註了 <<interface>>

(三) 關係

(1) 依賴關係

定義:如果一個元素 A 的變化影響到另一個元素 B,但是反之卻不成立,那麼這兩個元素 B 和 A 就可以稱為 B 依賴 A

  • 例如:開門的人 想要執行開門這個動作,就必須藉助於鑰匙,這裡也就可以說,這個開門的人,依賴於鑰匙,如果鑰匙發生了什麼變化就會影響到開門的人,但是開門的人變化卻不會影響到鑰匙開門
  • 例如:動物生活需要氧氣、水分、食物,這就是一個很字面的依賴關係

依賴關係作為物件之間耦合度最低的一種臨時性關聯方式

在程式碼中,某個類的方法通過區域性變數、方法的引數或者對靜態方法的呼叫來訪問另一個類(被依賴類)中的某些方法來完成一些職責。

(2) 關聯關係

關聯就是類(準確的說是例項化後的物件)之間的關係,也就是說,如果兩個物件需要在一定時間內保持一定的關係,那麼就可以稱為關聯關係。

  • 例如:學生(Student)在學校(School)學習知識(Knowledge)那麼這三者之間就存一個某種聯絡,可以建立關聯關係
  • 例如:大雁(WildGoose)年年南下遷徙,因為它知道氣候(climate)規律

關聯關係的雙方是可以互相通訊的,也就是說,“一個類知道另一個類”

這種關聯是可以雙向的,也可以是單向的

  • 雙向的關聯可以用帶兩個箭頭或者沒有箭頭的實線來表示

  • 單向的關聯用帶一個箭頭的實線來表示,箭頭從使用類指向被關聯的類

  • 也可以在關聯線的兩端標註角色名,代表兩種不同的角色

在程式碼中通常將一個類的物件作為另一個類的成員變數來實現關聯關係

下圖是一個教師和學生的雙向關聯關係

(3) 聚合關係

聚合關係也稱為聚集關係,它是一種特殊的較強關聯關係。表示類(準確的說是例項化後的物件)之間整體與部分的關係,是一種 has-a 的關係

  • 例如:汽車(Car)有輪胎(Wheel),Car has a Wheel,這就是一個聚合關係,但是輪胎(Wheel)獨立於汽車也可以單獨存在,輪胎還是輪胎

聚合關係可以用帶空心菱形的實線箭頭來表示,菱形指向整體

(4) 組合關係

組合是一種比聚合更強的關聯關係,其也表示類整體和部分之間的關係。但是整體物件可以控制部分物件的生命週期,一旦整體物件消失,部分也就自然消失了,即部分不能獨立存在

聚合關係可以用帶實心菱形的實線箭頭來表示,菱形指向整體

(5) 泛化關係

泛化描述一般與特殊(類圖中“一般”稱為超類或父類,“特殊”稱為子類)的關係,是父類和子類之間的關係,是一種繼承關係,描述了一種 is a kind of 的關係,特別要說明的是,泛化關係式物件之間耦合度最大的一種關係

Java 中 extend 關鍵字就代表著這種關係,通常抽象類作為父類,具體類作為子類

  • 例如:交通工具為抽象父類,汽車,飛機等就位具體的子類

泛化關係用帶空心三角箭頭的實線來表示,箭頭從子類指向父類

(6) 實現關係

實現關係就是介面和實現類之間的關係,實現類中實現了介面中定義的抽象操作

實現關係使用帶空心三角箭頭的虛線來表示,箭頭從實現類指向介面

四 設計模式七大原則

(一) 開閉原則

定義:軟體實體應當對擴充套件開放,對修改關閉

我們在開發任何產品的時候,別指望需求是一定不變的,當你不得不更改的你的程式碼的時候,一個高質量的程式就體現出其價值了,它只需要在原來的基礎上增加一些擴充套件,而不至於去修改原先的程式碼,因為這樣的做法常常會牽一髮而動全身。

也就是說,開閉原則要求我們在開發一個軟體(模組)的時候,要保證可以在不修改原有程式碼的模組的基礎上,然後能擴充套件其功能

我們下面來具體談談

(1) 對修改關閉

對修改關閉,即不允許在原來的模組或者程式碼上進行修改。

A:抽象層次

例如定義一個介面,不同的定義處理思路,會有怎樣的差別呢

定義一

boolean connectServer(String ip, int port, String user, String pwd)

定義二

boolean connectServer(FTP ftp)
public class FTP{
    private String ip;
    private int port;
    private String user;
    private String pwd;
    ...... 省略 get set
}

兩種方式看似都是差不多的,也都能實現要求,但是如果我們想要在其基礎上增加一個新的引數

  • 如果以定義一的做法,一旦介面被修改,所有呼叫 connectServer 方法的位置都會出現問題
  • 如果以定義二的做法,我們只需要修改 FTP 這個實體類,新增一個屬性即可
    • 這種情況下沒有用到這個新引數的呼叫處就不會出現問題,即使需要呼叫這個引數,我們也可以在 FTP 類的建構函式中,對其進行一個預設的賦值處理

B:具體層次

對原有的具體層次的程式碼進行修改,也是不太好的,雖然帶來的變化可能不如抽象層次的大,或者碰巧也沒問題,但是這種問題有時候是不可預料的,或許一些不經意的修改會帶了和預期完全不一致的結果

(2) 對擴充套件開放

對擴充套件開放,也就是我們不需要在原始碼上進行修改,因為我們定義的抽象層已經足夠的合理,足夠的包容,我們只需要根據需求重新派生一個實現類來擴充套件就可以了

(3) 開發時如何處理

無論模組是多麼“封閉”,都會存在一些無法對之封閉的變化。既然不可能完全封閉,設計人員必須對他設計的模組應該對那種變化封閉做出選擇,他必須先猜測出最有可能發現的變化種類,然後構造抽象來隔離那些變化 ——《大話設計模式》

預先猜測程式的變化,實際上是有很大難度,或許不完善,亦或者完全是錯誤的,所以為了規避這一點,我們可以選擇在剛開始寫程式碼的時候,假設不會有任何變化出現,但當變化發生的時候,我們就要立即採取行動,通過 “抽象約束,封裝變化” 的方式,建立抽象來隔離發生的同類變化

舉例:

例如寫一個加法程式,很容易就可以寫的出來,這個時候變化還沒有發生

如果這個時候讓你增加一個減法或者乘除等的功能,你就發現,你就需要在原來的類上面修改,這顯然違背了 “開閉原則”,所以變化一旦發生,我們就立即採取行動,決定重構程式碼,首先建立一個抽象類的運算類,通過繼承多型等隔離程式碼,以後還想新增什麼型別的運算方式,只需要增加一個新的子類就可以了,也就是說,對程式的改動,是通過新程式碼進行的,而不是更改現有程式碼

小結:

  • 我們希望開發剛開始就知道可能發生的變化,因為等待發現變化的時間越長,要抽象程式碼的代價就越大

  • 不要刻意的去抽象,拒絕不成熟的抽象和抽象本身一樣重要

(二) 里氏替換原則

(1) 詳細說明

定義:繼承必須確保超類所擁有的性質在子類中仍然成立

里氏替換原則,主要說明了關於繼承的內容,明確了何時使用繼承,亦或使用繼承的一些規定,是對於開閉原則中抽象化的一種補充

這裡我們主要談一下,繼承帶來的問題:

  • 繼承是侵入性的,子類繼承了父類,就必須擁有父類的所有屬性和方法,降低了程式碼靈活度
  • 耦合度變高,一旦父類的屬性和方法被修改,就需要考慮子類的修改,或許會造成大量程式碼重構

里氏替換原則說簡單一點就是:它認為,只有當子類可以替換父類,同時程式功能不受到影響,這個父類才算真正被複用

其核心主要有這麼四點內容:

  • ① 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法
  • ② 子類中可以增加自己特有的方法
  • ③ 當子類的方法過載父類的方法時,子類方法的前置條件(即方法的輸入引數)要比父類的方法更寬鬆
  • ④ 當子類的方法實現父類的方法時(重寫/過載或實現抽象方法),方法的後置條件(即方法的的輸出/返回值)要比父類的方法更嚴格或相等

對照簡單的程式碼來看一下,就一目瞭然了

① 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法

前半句很好理解,如果不實現父類的抽象方法,會編譯報錯

後半句是這裡的重點,父類中但凡實現好的方法,其實就是在設定整個繼承體系中的一系列規範和預設的契約,例如 鳥類 Bird 中,getFlyingSpeed(double speed) 用來獲取鳥的飛行速度,但幾維鳥作為一種特殊的鳥類,其實是不能飛行的,所以需要重寫繼承的子類方法 getFlyingSpeed(double speed) 將速度置為 0 ,但是會對整個繼承體系造成破壞

雖然我們平常經常會通過重寫父類方法來完成一些功能,同樣這樣也很簡單,但是一種潛在的繼承複用體系就被打亂了,如果在不適當的地方呼叫重寫後的方法,或多次運用多型,還可能會造成報錯

我們看下面的例子:

父類 Father

public class Father {
    public void speaking(String content){
        System.out.println("父類: " + content);
    }
}

子類 Son

public class Son extends Father {
    @Override
    public void speaking(String content) {
        System.out.println("子類: " + content);
    }
}

子類 Daughter

public class Daughter extends Father{
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        // 直接呼叫父類執行的結果
        Father father = new Father();
        father.speaking("speaking方法被呼叫");

        // Son子類替換父類執行的結果
        Son son = new Son();
        son.speaking("speaking方法被呼叫");

        // Daughter子類替換父類執行的結果
        Daughter daughter = new Daughter();
        daughter.speaking("speaking方法被呼叫");

    }
}

執行結果:

父類: speaking方法被呼叫
子類: speaking方法被呼叫
父類: speaking方法被呼叫

② 子類中可以增加自己特有的方法

這句話理解起來很簡單,直接看程式碼

父類 Father

public class Father {
    public void speaking(String content){
        System.out.println("父類: " + content);
    }
}

子類 Son

public class Son extends Father {
    public void playGuitar () {
        System.out.println("這是Son類playGuitar方法");
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        // 直接呼叫父類執行的結果
        Father father = new Father();
        father.speaking("speaking方法被呼叫");

        // Son子類替換父類執行的結果
        Son son = new Son();
        son.speaking("speaking方法被呼叫");
        son.playGuitar();
    }
}

執行結果:

父類: speaking方法被呼叫
父類: speaking方法被呼叫
這是Son類playGuitar方法

③ 當子類的方法過載父類的方法時,子類方法的前置條件(即方法的輸入引數)要比父類的方法更寬鬆

這裡要注意,我們說的是過載,可不是重寫,下面我們按照里氏替換原則要求的,將父類方法引數範圍設小一點 (ArrayList) ,將子類同名方法引數範圍寫大一些 (List) ,測試後的結果,就是隻會執行父類的方法,不執行父類過載後的方法(注:引數名雖然相同,但是型別不同,還是過載,不是重寫)

父類 Father

public class Father {
    public void speaking(ArrayList arrayList) {
        System.out.println("父類: " + arrayList.get(0));
    }
}

子類 Son

public class Son extends Father {
    public void speaking(List list) {
        System.out.println("子類: " + list.get(0));
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被呼叫");

        // 直接呼叫父類執行的結果
        Father father = new Father();
        father.speaking(arrayList);

        // Son子類替換父類執行的結果
        Son son = new Son();
        son.speaking(arrayList);
    }
}

執行結果:

父類: speaking方法被呼叫
父類: speaking方法被呼叫

如果我們將範圍顛倒一下,將父類方法引數範圍設大一些,子類方法引數設小一些,就會發現我明明想做的是過載方法,而不是重寫,但是父類的方法卻被執行了,邏輯完全出錯了,所以這也是這一條的反例,並不滿足里氏替換原則

父類 Father

public class Father {
    public void speaking(List list) {
        System.out.println("父類: " + list.get(0));
    }
}

子類 Son

public class Son extends Father {
    public void speaking(ArrayList arrayList) {
        System.out.println("子類: " + arrayList.get(0));
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被呼叫");

        // 直接呼叫父類執行的結果
        Father father = new Father();
        father.speaking(arrayList);

        // Son子類替換父類執行的結果
        Son son = new Son();
        son.speaking(arrayList);
    }
}

執行結果:

父類: speaking方法被呼叫
子類: speaking方法被呼叫

④ 當子類的方法實現父類的方法時(重寫/過載或實現抽象方法),方法的後置條件(即方法的的輸出/返回值)要比父類的方法更嚴格或相等

父類中定義一個抽象方法,返回值型別是 List,子類中重寫這個方法,返回值型別可以為 List,也可以更精確或更嚴格,例如 ArrayList

父類 Father

public abstract class Father {
    public abstract List speaking();
}

子類 Son

public class Son extends Father {
    @Override
    public ArrayList speaking() {
        ArrayList arrayList = new ArrayList();
        arrayList.add("speaking方法被呼叫");
        return arrayList;
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        Father father = new Son();
        System.out.println(father.speaking().get(0));
    }
}

執行結果:

speaking方法被呼叫

但是,如果反過來,將父類抽象方法返回值定義為範圍較小的 ArrayList,將子類重寫方法中,反而將返回值型別方法,設定為 List,那麼程式在編寫的時候就會報錯

(2) 修正違背里氏替換原則的程式碼

現在網上幾種比較經典的反例,“幾維鳥不是鳥”,“鯨魚不是魚” 等等

我打個比方,如果按照慣性和字面意思,如果我們將幾維鳥也繼承鳥類

但是幾維鳥是不能飛行的,所別的鳥通過 setSpeed 方法都能附一個有效的值,但是幾維鳥就不得不重寫這個 setSpeed 方法,讓其設定 flySpeed 為 0,這樣已經違反了里氏替換原則

面對子類如果不能完整的實現父類的方法,或者父類的方法已經在子類中發生了“異變”,就例如這裡幾維鳥特殊的 setSpeed 方法,則一般選擇斷開父類和子類的繼承關係,重新設計關係

例如:

取消鳥和幾維鳥的繼承關係,定義鳥和幾維鳥更一般的父類,動物類

(三) 依賴倒置

定義:

  • ① 高層模組不應該依賴低層模組,兩者都應該依賴其抽象

  • ② 抽象不應該依賴細節,細節應該依賴抽象

先解釋第 ① 點,其實這一點在我們以往的分層開發中,就已經用過了,例如我們的業務層 Service(高層模組)就沒有依賴資料訪問層 Dao/Mapper(低層模組),我們都通過 Mapper 的介面進行訪問,這種情況下,如果資料訪問層的細節發生了變化,那麼也不會影響到業務層,但是如果直接依賴於實現,那麼就會影響巨大

第 ② 點,還是在討論要進行抽象的問題,抽象是高層,具體細節是底層,這和前一點也是契合的,正式說明了一條非常關鍵的原則 “面向介面程式設計,而非針對現實程式設計”

舉個例子

例如一個 Client 客戶想訪問學校的 readBook 方法,可以這麼寫

public class Client {
    public void read(ASchool aSchool){
        System.out.println(aSchool.readBook());
    }
}

但是,這個地方其實就出現了一個比較大的問題,我們就是直接依賴了具體,而不是抽象,當我們想要檢視另一個B學校的 readBook 方法,就需要將程式碼修改為

public class Client {
    public void read(BSchool bSchool){
        System.out.println(bSchool.readBook());
    }
}

但是開閉原則規定,對修改關閉,所以明顯違背了開閉原則,如果我們將程式碼抽象出來,以介面訪問就可以解決

定義學校介面 ISchool (I 是大寫的 i 只是命名習慣問題,無特殊意義)

public interface ISchool {
    String readBook();
}

學校 A 和 B 分別實現這個介面,然後實現介面方法

public class ASchool implements ISchool {
    @Override
    public String readBook() {
        return "閱讀《Java 程式設計思想》";
    }
}

public class BSchool implements ISchool {
    @Override
    public String readBook() {
        return "閱讀《程式碼整潔之道》";
    }
}

Client 客戶類,呼叫時,只需要傳入介面引數即可

public class Client {
    public void read(ISchool school){
        System.out.println(school.readBook());
    }
}

看一下測試類

public class Test {
    public static void main(String[] args) {
        Client client = new Client();
        client.read(new ASchool());
        client.read(new BSchool());
    }
}

執行結果

閱讀《Java 程式設計思想》
閱讀《程式碼整潔之道》

(四) 單一職責原則

定義:單一職責原則規定一個類應該有且僅有一個引起它變化的原因,否則類應該被拆分

一個類,並不應該承擔太多的責任,否則當為了引入類中的 A 職責的時候,就不得不把 B 職責 也引入,所以我們必須滿足其高內聚以及細粒度

優點:

  • 降低類的複雜度。一個類只負責一項職責,其邏輯肯定要比負責多項職責簡單得多。
  • 提高類的可讀性。複雜性降低,自然其可讀性會提高。
  • 提高系統的可維護性。可讀性提高,那自然更容易維護了。
  • 變更引起的風險降低。變更是必然的,如果單一職責原則遵守得好,當修改一個功能時,可以顯著降低對其他功能的影響。

就比如大學老師,負責很多很多工作,但是不管是輔導員,授課老師,行政老師,雖然都可以統稱為老師,但是將大量的內容和職責放到一個類中,顯然是不合理的,不如細分開來

例如:

補充:大家可能看過 “羊呼吸空氣,魚呼吸水” 的例子,這裡我不做演示,做一個說明,有時候,在類簡單的情況下,也可以在程式碼或者方法級別上違背單一職責原則,因為即使一定的修改有一定開銷,但是幾乎可以忽略不計了,不過一般情況,我們還是要遵循單一職責原則

(五) 介面隔離原則

定義:

  • 客戶端不應該被迫依賴於它不使用的方法

  • 或者——客戶端不應該被迫依賴於它不使用的方法

其實這一原則的核心就是 “拆” ,如果在一個介面記憶體放過多的方法等內容,就會十分臃腫,竟可能的細化介面,也就是為每個類建立專用介面,畢竟依賴多個專用介面,比依賴一個綜合介面更加靈活方便,同時,介面作為對外的一個 “入口”,拆散,隔離介面能夠縮小外來因素導致的問題擴散範圍

還是通過一個例子來展開:

現在有一個 “好學生的介面和實現類”,還有一個老師的抽象類和其子類,老師能做的,就是去找到好的學生

好學生 IGoodStudent 介面

public interface IGoodStudent {
    //學習成績優秀
    void goodGrades();
    //品德優秀
    void goodMoralCharacter();
    //良好形象
    void goodLooks();
}

好學生 IGoodStudent 介面的實現類 GoodStudentImpl

public class GoodStudentImpl implements IGoodStudent {

    private String name;

    public GoodStudentImpl(String  name) {
        this.name = name;
    }

    @Override
    public void goodGrades() {
        System.out.println("【" +this.name + "】的學習成績優秀");
    }

    @Override
    public void goodMoralCharacter() {
        System.out.println("【" +this.name + "】的品德優良");
    }

    @Override
    public void goodLooks() {
        System.out.println("【" +this.name + "】的形象良好");
    }
}

老師抽象類 AbstractTeacher

public abstract class AbstractTeacher {
    protected IGoodStudent goodStudent;

    public AbstractTeacher(IGoodStudent goodStudent) {
        this.goodStudent = goodStudent;
    }

    public abstract void findGoodStudent();
}

老師類 Teacher

public class Teacher extends AbstractTeacher {
    public Teacher(IGoodStudent goodStudent) {
        super(goodStudent);
    }

    @Override
    public void findGoodStudent() {
        super.goodStudent.goodGrades();
        super.goodStudent.goodMoralCharacter();
        super.goodStudent.goodLooks();
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        IGoodStudent goodStudent = new GoodStudentImpl("阿文");
        AbstractTeacher teacher = new Teacher(goodStudent);
        teacher.findGoodStudent();
    }
}

執行結果:

【阿文】的學習成績優秀
【阿文】的品德優良
【阿文】的形象良好

一下子看來是沒什麼問題的,不過由於每個人的主觀意識形態不同,或許每個人對於 “好學生” 的定義並不同,就例如就我個人而言,我認識為 “師者,傳道授業解惑也” ,學生能學習其為人處世的道理與主動學習更是難能可貴,至於外貌更屬於無稽之談。針對不同人的不同不同定義,這個 IGoodStudent 介面就顯得有一些龐大且不合時宜了,所以我們根據介面隔離原則,將 “好學生” 的定義進行一定的拆分隔離

學習的學生介面

public interface IGoodGradesStudent {
    //學習成績優秀
    void goodGrades();
}

品德優秀的學生介面

public interface IGoodMoralCharacterStudent {
    //品德優秀
    void goodMoralCharacter();
}

好學生實現多個介面

public class GoodStudent implements IGoodGradesStudent,IGoodMoralCharacterStudent {

    private String name;

    public GoodStudent(String name) {
        this.name = name;
    }

    @Override
    public void goodGrades() {
        System.out.println("【" +this.name + "】的學習成績優秀");
    }

    @Override
    public void goodMoralCharacter() {
        System.out.println("【" +this.name + "】的品德優良");
    }
}

(六) 迪米特法則

定義:如果兩個類不必要彼此直接通訊,那麼這兩個類就不應當發生直接的相互作用,如果其中一個類需要呼叫另一個類的某一個方法的話,可以通過第三者轉發這個呼叫

這句話的意思就是說,一個類對自己依賴的類知道越少越好,也就是每一個類都應該降低成員的訪問許可權,就像封裝的概念中提到的,通過 private 隱藏自己的欄位或者行為細節

迪米特法則中的“朋友”是指:當前物件本身、當前物件的成員物件、當前物件所建立的物件、當前物件的方法引數等這些物件當前物件存在關聯、聚合或組合關係,可以直接訪問這些物件的方法

注意:請不要過分的使用迪米特法則,因為其會產生過多的中間類,會導致系統複雜性增大,結構不夠清晰

下面還是用一個例子來說一下

假設在學校的一個環境中,校長作為最高的職務所有人,肯定不會直接參與到對於老師和學生的管理中,而是通過一層一層的管理體系來進行統籌規劃,這裡的校長,和老師學生之間就可以理解為陌生關係,而校長和中層的教務主任卻是朋友關係,畢竟教務主任數量少,也可以直接進行溝通

教務主任類 AcademicDirector

public class AcademicDirector {

    private Principal principal;
    private Teacher teacher;
    private Student student;

    public void setPrincipal(Principal principal) {
        this.principal = principal;
    }

    public void setTeacher(Teacher teacher) {
        this.teacher = teacher;
    }

    public void setStudent(Student student) {
        this.student = student;
    }

    public void meetTeacher() {
        System.out.println(teacher.getName() + "通過教務主任向" + principal.getName() + "彙報工作");
    }

    public void meetStudents() {
        System.out.println(student.getName() + "通過教務主任與" + principal.getName() + "見面");
    }

}

校長類 Principal

public class Principal {
    private String name;

    Principal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

老師類 Teacher

public class Teacher {
    private String name;

    Teacher(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

學生類 Student

public class Student {
    private String name;

    Student(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

測試類 Test

public class Test {
    public static void main(String[] args) {
        AcademicDirector a = new AcademicDirector();
        a.setPrincipal(new Principal("【張校長】"));

        a.setTeacher(new Teacher("【王老師】"));
        a.setStudent(new Student("【阿文】"));

        a.meetTeacher();
        a.meetStudents();
    }
}

補充:迪米特法則在《程式設計師修煉之道》一書中也有提及到 —— 26 解耦與得墨忒耳法則

函式的得墨忒耳法則試圖使任何給定程式中的模組之間的耦合減至最少,它設法阻止你為了獲得對第三個物件的方法的訪問而進入某個物件。

通過使用函式的得墨忒耳法則來解耦 編寫“羞怯”的程式碼,我們可以實現我們的目標:

Minimize Coupling Between Modules

使模組之間的耦合減至最少

(七) 合成複用原則

定義:在軟體複用時,要儘量先使用組合或者聚合等關聯關係來實現,其次才考慮使用繼承關係來實現

這一點和里氏替換原則的目的是一致的,都是處理關於繼承的內容,本質都是實現了開閉原則的具體規範

為什麼用組合/聚合,不用繼承

  • 繼承破壞了類的封裝性,因為父類對於子類是透明的,而組合/聚合則不會
  • 繼承父子類之間之間的耦合度比組合/聚合新舊類高
  • 從父類繼承來的實現是靜態的,執行時不會發生變化,而組合/聚合的複用靈活性高,複用可在執行時動態進行

如果程式碼違背了里氏替換原則,彌補的方式,一個就是我們前面說的,加入一個更普通的抽象超類,一個就是取消繼承,修改為組合/聚合關係

我們簡單回憶一下

  • 繼承我們一般都叫做 Is-a 的關係,即一個類是另一個類的一種,比如,狗是一種動物

  • 組合/聚合都叫做 Has-a,即一個角色擁有一項責任或者說特性

例如我們來討論一下常見的特殊自行車(即變速自行車),首先按照型別可以分為 山地自行車和公路自行車,按照速度搭配又可以分為 21速自行車 ,24速自行車,27速自行車(簡單分)

XX速山地自行/公路車,雖然說我們口頭上可能會這麼叫,但是其實這就是將速度這種 Has- a 的關係和 Is-a 的關係搞混了,而且如果通過繼承,會帶來很多的子類,一旦想要增加修改變速自行車種類以及速度型別,就需要修改原始碼,違背了開閉原則,所以修改為組合關係

五 結尾

這篇文章寫到這裡就結束了,又是一篇 接近1W 字的內容,學習到一定階段,確實會有一些瓶頸,經過對於類似設計模式等 “內功” 的學習,也突然發現開發真不是 CRUD 的不斷重複,一段有質量的程式碼,更能讓人有成就感,後面對於常見的設計模式我會一直更新下去,一邊學習,一邊總結,感謝大家的支援。如果你更喜歡手機閱讀,大家也可以關注我的微信公眾號:理想二旬不止

相關文章