設計模式:物件導向設計的六大原則 (絕對詳細)

xue無止境發表於2019-05-20

前言

很久沒有寫部落格了,一直給自己找藉口說太忙了,過幾天有空再寫,幾天之後又幾天,時間就這麼快速的消逝。說到底就是自己太懶了,不下點決心真是不行。我決定逼自己一把,從今天開始學習設計模式系列,並寫成博文記錄下來,做不到的話,就罰自己一個月不玩遊戲 (作孽啊。。。。)

六大原則

言歸正傳,這是我學習設計模式系列的第一篇文章,本文主要講的是物件導向設計應該遵循的六大原則,掌握這些原則能幫助我們更好的理解物件導向的概念,也能更好的理解設計模式。這六大原則分別是:

  • 單一職責原則——SRP
  • 開閉原則——OCP
  • 裡式替換原則——LSP
  • 依賴倒置原則——DIP
  • 介面隔離原則——ISP
  • 迪米特原則——LOD

單一職責原則

單一職責原則,Single Responsibility Principle,簡稱SRP。其定義是應該有且僅有一個類引起類的變更,這話的意思就是一個類只擔負一個職責。

舉個例子,在創業公司裡,由於人力成本控制和流程不夠規範的原因,往往一個人需要擔任N個職責,一個工程師可能不僅要出需求,還要寫程式碼,甚至要面談客戶,光背的鍋就好幾種,簡單用程式碼表達大概如此:

public class Engineer {
    public void makeDemand(){}
    public void writeCode(){}
    public void meetClient(){}
}

程式碼看上去好像沒什麼問題,因為我們平時就是這麼寫的啊,但是細讀一下就能發現,這種寫法很明顯不符合單一職責的原則,因為引起類的變化不只有一個,至少有三個方法都可以引起類的變化,比如有天因為業務需要,出需求的方法需要加個功能 (比如需求的成本分析),或者是見客戶也需要個引數之類的,那樣一來類的變化就會有多種可能性了,其他引用該類的類也需要相應的變化,如果引用類的數目很多的話,程式碼維護的成本可想而知會有多高。所以我們需要把這些方法拆分成獨立的職責,可以讓一個類只負責一個方法,每個類只專心處理自己的方法即可。

單一職責原則的優點:

  • 類的複雜性降低,實現什麼職責都有明確的定義;
  • 邏輯變得簡單,類的可讀性提高了,而且,因為邏輯簡單,程式碼的可維護性也提高了;
  • 變更的風險降低,因為只會在單一的類中的修改。

開閉原則

開閉原則,Open Closed Principle,是Java世界裡最基礎的設計原則,其定義是:

一個軟體實體如類、模組和函式應該對擴充套件開放,對修改關閉

也就是說,一個軟體實體應該通過擴充套件來實現變化,而不是通過修改已有的程式碼實現變化。這是為軟體實體的未來事件而制定的對現行開發設計進行約束的一個原則。

在我們編碼的過程中,需求變化是不斷的發生的,當我們需要對程式碼進行修改時,我們應該儘量做到能不動原來的程式碼就不動,通過擴充套件的方式來滿足需求。

遵循開閉原則的最好手段就是抽象,例如前面單一職責原則舉的工程師類,我們說的是把方法抽離成單獨的類,每個類負責單一的職責,但其實從開閉原則的角度說,更好的方式是把職責設計成介面,例如把寫程式碼的職責方法抽離成介面的形式,同時,我們在設計之初需要考慮到未來所有可能發生變化的因素,比如未來有可能因為業務需要分成後臺和前端的功能,這時設計之初就可以設計成兩個介面,

public interface BackCode{
    void writeCode();
}
public interface FrontCode{
    void writeCode();
}

如果將來前端程式碼的業務發生變化,我們只需擴充套件前端介面的功能,或者修改前端介面的實現類即可,後臺介面以及實現類就不會受到影響,這就是抽象的好處。

里氏替換原則

里氏替換原則,英文名Liskov Substitution Principle,它的定義是

如果對每一個型別為T1的物件o1,都有型別為T2的物件o2,使得以T1定義的所有程式P在所有物件o1都替換成o2的時候,程式P的行為都沒有發生變化,那麼型別T2是型別T1的子型別。

看起來有點繞口,它還有一個簡單的定義:

所有引用基類的地方必須能夠透明地使用其子類的物件。

通俗點說,只要父類能出現的地方子類就可以出現,而且替換為子類也不會產生任何異常。 但是反過來就不行了,因為子類可以擴充套件父類沒有的功能,同時子類還不能改變父類原有的功能。

我們都知道,物件導向的三大特徵是封裝、繼承和多型,這三者缺一不可,但三者之間卻並不 “和諧“。因為繼承有很多缺點,當子類繼承父類時,雖然可以複用父類的程式碼,但是父類的屬性和方法對子類都是透明的,子類可以隨意修改父類的成員。如果需求變更,子類對父類的方法進行了一些複寫的時候,其他的子類可能就需要隨之改變,這在一定程度上就違反了封裝的原則,解決的方案就是引入里氏替換原則。

里氏替換原則為良好的繼承定義了一個規範,它包含了4層含義:

1、子類可以實現父類的抽象方法,但是不能覆蓋父類的非抽象方法。

2、子類可以有自己的個性,可以有自己的屬性和方法。

3、子類覆蓋或過載父類的方法時輸入引數可以被放大。

比如父類有一個方法,引數是HashMap

public class Father {
    public void test(HashMap map){
        System.out.println("父類被執行。。。。。");
    }
}

那麼子類的同名方法輸入引數的型別可以擴大,例如我們輸入引數為Map

public class Son extends Father{
    public void test(Map map){
        System.out.println("子類被執行。。。。");
    }
}

我們寫一個場景類測試一下父類的方法執行效果,

public class Client {
    public static void main(String[] args) {
        Father father = new Father();
        HashMap map = new HashMap();
        father.test(map);
    }
}

結果輸出:父類被執行。。。。。

因為里氏替換原則,只要父類能出現的地方子類就可以出現,而且替換為子類也不會產生任何異常。我們改下程式碼,呼叫子類的方法,

public class Client {
    public static void main(String[] args) {
        Son son = new Son();
        HashMap map = new HashMap();
        father.test(map);
    }
}

執行結果是一樣的,因為子類方法的輸入引數型別範圍擴大了,子類代替父類傳遞到呼叫者中,子類的方法永遠不會被執行,這樣的結果其實是正確的,如果想讓子類方法執行,可以重寫方法體。

反之,如果子類的輸入引數型別範圍比父類還小,比如父類中的引數是Map,而子類是HashMap,那麼執行上述程式碼的結果就會是子類的方法體,有人說,這難道不對嗎?子類顯示自己的內容啊。其實這是不對的,因為子類沒有複寫父類的同名方法,方法就被執行了,這會引起邏輯的混亂,如果父類是抽象類,子類是實現類,你傳遞一個這樣的實現類就違背了父類的意圖了,容易引起邏輯混亂,所以子類覆蓋或過載父類的方法時輸入引數必定是相同或者放大的。

4、子類覆蓋或過載父類的方法時輸出結果可以被縮小,也就是說返回值要小於或等於父類的方法返回值。

確保程式遵循里氏替換原則可以要求我們的程式建立抽象,通過抽象去建立規範,然後用實現去擴充套件細節,所以,它跟開閉原則往往是相互依存的。

依賴倒置原則

依賴倒置原則,Dependence Inversion Principle,簡稱DIP,它的定義是:

高層模組不應該依賴底層模組,兩者都應該依賴其抽象;

抽象不應該依賴細節;

細節應該依賴抽象;

什麼是高層模組和底層模組呢?不可分割的原子邏輯就是底層模組,原子邏輯的再組裝就是高層模組。

在Java語言中,抽象就是指介面或抽象類,兩者都不能被例項化;而細節就是實現介面或繼承抽象類產生的類,也就是可以被例項化的實現類。依賴倒置原則是指模組間的依賴是通過抽象來發生的,實現類之間不發生直接的依賴關係,其依賴關係是通過介面是來實現的,這就是俗稱的面向介面程式設計。

我們用歌手唱歌來舉例,比如一個歌手唱國語歌,用程式碼表示就是:

public class ChineseSong {
    public String language() {
        return "國語歌";
    }
}
public class Singer {
    //唱歌的方法
    public void sing(ChineseSong song) {
        System.out.println("歌手" + song.language());
    }
}
public class Client {
    public static void main(String[] args) {
        Singer singer = new Singer();
        ChineseSong song = new ChineseSong();
        singer.sing(song);
    }
}

執行main方法,結果就會輸出:歌手唱國語歌

現在,我們需要給歌手加一點難度,比如說唱英文歌,在這個類中,我們發現是很難做的。因為我們Singer類依賴於一個具體的實現類ChineseSong,也許有人會說可以在加一個方法啊,但這樣一來我們就修改了Singer類了,如果以後需要增加更多的歌種,那歌手類不是一直要被修改?也就是說,依賴類已經不穩定了,這顯然不是我們想看到的。

所以我們需要用面向介面程式設計的思想來優化我們的方案,改成如下的程式碼:

public interface Song {
    public String language();
}
public class ChineseSong implements Song{
    public String language() {
        return "唱國語歌";
    }
}
public class EnglishSong implements Song {
    public String language() {
        return "唱英語歌";
    }
}
public class Singer {
    //唱歌的方法
    public void sing(Song song) {
        System.out.println("歌手" + song.language());
    }
}
public class Client {
    public static void main(String[] args) {
        Singer singer = new Singer();
        EnglishSong englishSong = new EnglishSong();
        // 唱英文歌
        singer.sing(englishSong);
    }
}

我們把歌單獨抽成一個介面Song,每個歌種都實現該介面並重寫方法,這樣一來,歌手的程式碼不必改動,如果需要新增歌的種類,只需寫多一個實現類繼承Song即可。

通過這樣的面向介面程式設計,我們的程式碼就有了更好的擴充套件性,同時也降低了耦合,提高了系統的穩定性。

介面隔離原則

介面隔離原則,Interface Segregation Principle,簡稱ISP,其定義是:

客戶端不應該依賴它不需要的介面

意思就是客戶端需要什麼介面就提供什麼介面,把不需要的介面剔除掉,這就需要對介面進行細化,保證介面的純潔性。換成另一種說法就是,類間的依賴關係應該建立在最小的介面上,也就是建立單一的介面。

你可能會疑惑,建立單一介面,這不是單一職責原則嗎?其實不是,單一職責原則要求的是類和介面職責單一,注重的是職責,一個職責的介面是可以有多個方法的,而介面隔離原則要求的是介面的方法儘量少,模組儘量單一,如果需要提供給客戶端很多的模組,那麼就要相應的定義多個介面,不要把所有的模組功能都定義在一個介面中,那樣會顯得很臃腫。

舉個例子,現在的智慧手機非常的發達,幾乎是人手一部的社會狀態,在我們年輕人的觀念裡,好的智慧手機應該是價格便宜,外觀好看,功能豐富的,由此我們可以定義一個智慧手機的抽象介面 ISmartPhone,程式碼如下所示:

public interface ISmartPhone {
    public void cheapPrice();
    public void goodLooking();
    public void richFunction();
}

接著,我們定義一個手機介面的實現類,實現這三個抽象方法,

public class SmartPhone implements ISmartPhone{
    public void cheapPrice() {
        System.out.println("這手機便宜~~~~~");
    }

    public void goodLooking() {
        System.out.println("這手機外觀好看~~~~~");
    }

    public void richFunction() {
        System.out.println("這手機功能真多~~~~~");
    }
}

然後,定義一個使用者的實體類 User,並定義一個構造方法,以ISmartPhone 作為引數傳入,同時,我們也定義一個使用的方法usePhone 來呼叫介面的方法,

public class User {

    private ISmartPhone phone;
    public User(ISmartPhone phone){
        this.phone = phone;
    }
    public void usePhone(){
        phone.cheapPrice();
        phone.goodLooking();
        phone.richFunction();
    }
}

可以看出,當我們例項化User類並呼叫其方法usePhone後,控制檯上就會顯示手機介面三個方法的方法體資訊,這種設計看上去沒什麼大毛病,但是我們可以仔細想下,ISmartPhone這個介面的設計是否已經達到最優了呢?很遺憾,答案是沒有,介面其實還可以再優化。

因為除了年輕人之外,中年商務人士也在用智慧手機,在他們的觀念裡,智慧手機並不需要豐富的功能,甚至不用考慮是否便宜 (有錢就是任性~~~~),因為成功人士都比較忙,對智慧手機的要求大多是外觀大氣,功能簡單即可,這才是他們心中好的智慧手機的特徵,這樣一來,我們定義的 ISmartPhone 介面就無法適用了,因為我們的介面定義了智慧手機必須滿足三個特性,如果實現該介面就必須三個方法都實現,而對商務人員的標準來說,我們定義的方法只有外觀符合且可以重用而已。你可能會說,我可以重寫一個實現類啊,只實現外觀的方法,另外兩個方法置空,什麼都不寫,這不就行了嗎?但是這也不行,因為 User 引用的是ISmartPhone 介面,它呼叫三個方法,你只實現了兩個,那麼列印資訊就少了兩條了,只靠外觀的特性,使用者怎麼知道智慧手機是否符合自己的預期?

分析到這裡,我們大概就明白了,其實ISmartPhone的設計是有缺陷的,過於臃腫了,按照介面隔離原則,我們可以根據不同的特性把智慧手機的介面進行拆分,這樣一來,每個介面的功能就會變得單一,保證了介面的純潔性,也進一步提高了程式碼的靈活性和穩定性。

迪米特原則

迪米特原則,Law of Demeter,簡稱LoD,也被稱為最少知識原則,它描述的規則是:

一個物件應該對其他物件有最少的瞭解

也就是說,一個類應該對自己需要耦合或呼叫的類知道的最少,類與類之間的關係越密切,耦合度越大,那麼類的變化對其耦合的類的影響也會越大,這也是我們面向設計的核心原則:低耦合,高內聚。

迪米特法則還有一個解釋:只與直接的朋友通訊。

什麼是直接的朋友呢?每個物件都必然與其他物件有耦合關係,兩個物件的耦合就成為朋友關係,這種關係的型別很多,例如組合、聚合、依賴等。其中,我們稱出現成員變數、方法引數、方法返回值中的類為直接的朋友,而出現在區域性變數中的類則不是直接的朋友。也就是說,陌生的類最好不要作為區域性變數的形式出現在類的內部。

舉個例子,上體育課之前,老師讓班長先去體務室拿20個籃球,等下上課的時候要用。根據這一場景,我們可以設計出三個類 Teacher(老師),Monitor (班長) 和 BasketBall (籃球),以及釋出命令的方法command 和 拿籃球的方法takeBall

public class Teacher {
    // 命令班長去拿球
    public void command(Monitor monitor) {
        List<BasketBall> ballList = new ArrayList<BasketBall>();
        // 初始化籃球數目
        for (int i = 0;i<20;i++){
            ballList.add(new BasketBall());
        }
        // 通知班長開始去拿球
        monitor.takeBall(ballList);
    }
}
public class BasketBall {
}
public class Monitor {
    // 拿球
    public void takeBall(List<BasketBall> balls) {
        System.out.println("籃球數目:" + balls.size());
    }
}

然後,我們寫一個情景類進行測試:

public class Client {
    public static void main(String[] args) {
        Teacher teacher = new Teacher();
        teacher.command(new Monitor());
    }
}

結果顯示如下:

籃球數目:20

雖然結果是正確的,但我們的程式其實還是存在問題,因為從場景來說,老師只需命令班長拿籃球即可,Teacher只需要一個朋友----Monitor,但在程式裡,Teacher的方法體中卻依賴了BasketBall類,也就是說,Teacher類與一個陌生的類有了交流,這樣Teacher的健壯性就被破壞了,因為一旦BasketBall類做了修改,那麼Teacher也需要做修改,這很明顯違背了迪米特法則。

因此,我們需要對程式做些修改,在Teacher的方法中去掉對BasketBall類的依賴,只讓Teacher類與朋友類Monitor產生依賴,修改後的程式碼如下:

public class Teacher {
    // 命令班長去拿球
    public void command(Monitor monitor) {
        // 通知班長開始去拿球
        monitor.takeBall();
    }
}
public class Monitor {
    // 拿球
    public void takeBall() {
        List<BasketBall> ballList = new ArrayList<BasketBall>();
        // 初始化籃球數目
        for (int i = 0;i<20;i++){
            ballList.add(new BasketBall());
        }
        System.out.println("籃球數目:" + ballList.size());
    }
}

這樣一來,Teacher類就不會與BasketBall類產生依賴了,即時日後因為業務需要修改BasketBall也不會影響Teacher類。

總結

好了,物件導向的六大原則就介紹到這裡了。其實,我們不難發現,六大原則雖說是原則,但它們並不是強制性的,更多的是建議。遵照這些原則固然能幫助我們更好的規範我們的系統設計和程式碼習慣,但並不是所有的場景都適用,就例如介面隔離原則,在現實系統開發中,我們很難完全遵守一個模組一個介面的設計,否則業務多了就會出現程式碼設計過度的情況,讓整個系統變得過於龐大,增加了系統的複雜度,甚至影響自己的專案進度,得不償失啊。

所以,還是那句話,在合適的場景選擇合適的技術!

參考:《設計模式之禪》

相關文章