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


迷茫了一周,一段時間重復的 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 的不斷重復,一段有質量的代碼,更能讓人有成就感,后面對於常見的設計模式我會一直更新下去,一邊學習,一邊總結,感謝大家的支持。如果你更喜歡手機閱讀,大家也可以關注我的微信公眾號:理想二旬不止


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM