走向靈活軟體之路-物件導向的六大原則

ghroosk發表於2019-02-23

前言

關於設計模式六大設計原則的資料網上很多,但感覺很多地方解釋地都太過於籠統化,特此再總結一波。

優化第一步-單一職責原則SRP

單一職責原則(Single Responsibility Principle, SRP):一個類只負責一個功能領域中的相應職責,或者可以定義為:就一個類而言,應該只有一個引起它變化的原因。

經典問題:
類T負責兩個不同的職責:職責P1,職責P2。當職責P1需求發生改變而需要修改類T時,有可能會導致原本執行正常的職責P2功能發生故障。

解決方案:
遵循單一職責原則。分別建立兩個類T1、T2,使T1完成職責P1功能,T2完成職責P2功能。這樣,當修改類T1時,不會使職責P2發生故障風險;同理,當修改T2時,也不會使職責P1發生故障風險。

單一職責原則是實現高內聚、低耦合的指導方針,它是最簡單但又最難運用的原則,因為單一職責的劃分界限並不總是很清晰的,它需要設計人員發現類的不同職責並將其分離,這就需要設計人員具有較強的分析設計能力和相關實踐經驗。

在軟體系統中,一個類(大到模組,小到方法)承擔的職責越多,它被複用的可能性就越小,而且一個類承擔的職責過多,就相當於將這些職責耦合在一起,當其中一個職責發生變化時,可能會影響其他職責的執行,因此需要將這些不同的職責進行封裝在不同的類中,即將不同的變化封裝在不同的類中。如果多個職責總是同時發生改變則可將它們封裝在同一類中。

說到單一職責原則,很多人都會不屑一顧。因為它太簡單了。稍有經驗的程式設計師在進行設計軟體時也會自覺的遵守這一重要原則,而且誰也不希望因為修改了一個功
能導致其他的功能發生故障。雖然單一職責原則如此簡單,但是即便是經驗豐富的程式設計師也會有違背這一原則的程式碼存在。為什麼會出現這種現
象呢?
因為有職責擴散。所謂職責擴散,就是因為某種原因,職責P被分化為粒度更細的職責P1和P2。

比如:類T只負責一個職責P,這樣設計是符合單一職責原則的。後來由於某種原因,也許是需求變更了,也許是程式的設計者境界提高了,需要將職責P細分為粒度更細的職責P1,P2,這時如果要使程式遵循單一職責原則,需要將類T也分解為兩個類T1和T2,分別負責P1、P2兩個職責。但是在程式已經寫好的情況下,這樣做簡直太費時間了。所以,簡單的修改類T,用它來負責兩個職責是一個比較不錯的選擇,雖然這樣做有悖於單一職責原則。(這樣做的風險在於職責擴散的不確定性,因為我們不會想到這個職責P,在未來可能會擴散為P1,P2,P3,P4……Pn。所以記住,在職責擴散到我們無法控制的程度之前,立刻對程式碼進行重構。)

舉例說明,用一個類描述動物運動這個場景:

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");

    }

    private class Animal {

        private void move(String animal) {
            System.out.println(animal + "奔跑");
        }
    }
    
    執行結果:
    牛奔跑
    羊奔跑

複製程式碼

程式上線後,發現問題了,並不是所有的動物都是奔跑的,比如魚就是在水遊的。修改時如果遵循單一職責原則,需要將Animal類細分為陸生動物類Terrestrial,水生動物Aquatic,程式碼如下:

 @Test
     public void SrpTest() {
        Terrestrial terrestrial = new Terrestrial();
        terrestrial.move("牛");
        terrestrial.move("羊");

        Aquatic aquatic = new Aquatic();
        aquatic.move("魚");

    }
    
     private class Terrestrial {

        private void move(String animal) {
            System.out.println(animal + "奔跑");
        }
    }

    private class Aquatic {

        private void move(String animal) {
            System.out.println(animal + "在水裡遊");
        }
    }
    
    執行結果:
    牛奔跑
    羊奔跑
    魚在水裡遊

複製程式碼

我們會發現如果這樣修改花銷是很大的,除了將原來的類分解之外,還需要修改客戶端。而直接修改類Animal來達成目的雖然違背了單一職責原則,但花銷卻小的多。

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");
        animal.move("魚");

    }

    private class Animal {

       // 極差的擴充方式
        private void move(String animal) {
            if ("魚".equals(animal)) {
                System.out.println(animal + "奔跑");
            } else {
                System.out.println(animal + "在水裡遊");
            }
        }
    }
    
    執行結果:
    牛奔跑
    羊奔跑
    魚在水裡遊
複製程式碼

可以看到,這種修改方式要簡單的多。但是卻存在著隱患:有一天需要將魚分為在淺水遊的魚和在深水裡遊的魚,或者是需要新增鳥要在天上飛等情況,則又需要修改Animal類的move方法,而對原有程式碼的修改會對呼叫“牛”“羊”等相關功能帶來風險,也許某一天你會發現程式執行的結果變為“牛在水裡遊”了。這種修改方式直接在程式碼級別上違背了單一職責原則,雖然修改起來最簡單,但隱患卻是最大的。還有一種修改方式:

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");
        animal.move2("魚");

    }

    private class Animal {

       private void move(String animal) {
            System.out.println(animal + "奔跑");
        }

        private void move2(String animal) {
            System.out.println(animal + "在水裡遊");
        }
    }
    
    執行結果:
    牛奔跑
    羊奔跑
    魚在水裡遊
複製程式碼

可以看到,這種修改方式沒有改動原來的方法,而是在類中新加了一個方法,這樣雖然也違背了單一職責原則,但在方法級別上卻是符合單一職責原則的,因為它並沒有動原來方法的程式碼。這三種方式各有優缺點,那麼在實際程式設計中,採用哪一中呢?其實這真的比較難說,需要根據實際情況來確定。我的原則是:只有邏輯足夠簡單,才可以在程式碼級別上違反單一職責原則;只有類中方法數量足夠少,才可以在方法級別上違反單一職責原則;

遵循單一職責原可以降低類的複雜度(一個類只負責一項職責,其邏輯肯定要比負責多項職責簡單的多);提高類的可讀性,提高系統的可維護性;降低變更引起的風險;
如何劃分一個類、一個函式的職責,每個人都有自己的看法,但是它也是有一些基本的指導原則的:

  1. 兩個完全不一樣的功能就不應該放在同一個類中;
  2. 一個類中應該是一組相關性很高的函式、資料的封裝;

工程師不斷審視自己的程式碼,根據具體的業務、功能對類進行相應的拆分,這是工程師優化程式碼邁出的第一步。

讓程式更穩定、更靈活-開閉原則OCP

開閉原則(Open-Closed Principle, OCP):軟體中的物件(類、模組、函式等)應當對擴充套件開放,對修改關閉。即軟體實體應儘量在不修改原有程式碼的情況下進行擴充套件。

經典問題:
在軟體的生命週期內,因為變化、升級和維護等原因需要對軟體原有程式碼進行修改時,可能會將錯誤引入原本已測試通過的程式碼,也可能會使我們不得不對整個功能進行重構,並且需要原有程式碼經過重新測試。如何確保原有軟體模組的正確性,以及儘量少的影響原有模組?

解決方案:
遵循開閉原則。當軟體需要變化時,儘量通過擴充套件軟體實體的行為來實現變化,而不是通過修改已有的程式碼來實現變化。

用抽象構建框架,用實現擴充套件細節。為了滿足開閉原則,需要對系統進行抽象化設計,抽象化是開閉原則的關鍵。我們為系統定義一個相對穩定的抽象層,通過介面、抽象類等機制將不同的實現行為移至具體的實現層中完成。如果需要修改系統的行為,無須對抽象層進行任何改動,只需要增加新的具體類來實現新的業務功能即可,實現在不修改已有程式碼的基礎上擴充套件系統的功能,達到開閉原則的要求。開閉原則是物件導向的可複用設計的第一塊基石,它是最重要的物件導向設計原則.

舉例說明,還是描述動物運動的場景:

    @Test
    public void ocpTest() {
        Animal animal = new Animal();
        animal.move("terrestrial", "牛");
        animal.move("terrestrial", "羊");
        animal.move("aquatic", "魚");

    }

    private class Animal {

        // 這裡做個經典的示範
        private void move(String type, String animal) {
            if (type.equals("terrestrial")) {
                Terrestrial terrestrial = new Terrestrial(animal);
                terrestrial.move();
            } else if (type.equals("aquatic")) {
                Aquatic aquatic = new Aquatic(animal);
                aquatic.move();
            }

        }
    }

    private class Terrestrial {
         private String mTerrestrial;

        Terrestrial(String animal) {
            mTerrestrial = animal;
        }

        @Override
        public void move() {
            System.out.println(mTerrestrial + "奔跑");
        }
    }

    private class Aquatic {
        private String mAquatic;

        Aquatic(String animal) {
            mAquatic = animal;
        }

        @Override
        public void move() {
            System.out.println(mAquatic + "在水裡遊");
        }
    }
複製程式碼

但是如果有一天我發現我需要要新增鳥這種需求,因為我不能說鳥是在水裡遊的,此時就需要要修改Animal類的move()方法的原始碼,增加新的判斷邏輯,這就違反了開閉原則,對於擴充是開放的,但對於修改是封閉的。
竟然所有的動物都可以移動,那麼我們是否可以總結抽象出動物都可以移動這一特性?

  @Test
    public void ocpTest() {
        Animal animalTerrestrial = new Terrestrial("牛");
        animalTerrestrial.movement();
        Animal animalAquatic = new Terrestrial("魚");
        animalAquatic.movement();

    }

    // 這裡是通過抽象類的方法實現,介面也可以實現
    // 使用介面還是抽象類請根據實際情況來選擇
    private abstract class Animal {
        abstract void move();

        private void movement() {
            move();
        }
    }

    private class Terrestrial extends Animal {
        private String mTerrestrial;

        Terrestrial(String animal) {
            mTerrestrial = animal;
        }

        @Override
        public void move() {
            System.out.println(mTerrestrial + "奔跑");
        }
    }

    private class Aquatic extends Animal {
        private String mAquatic;

        Aquatic(String animal) {
            mAquatic = animal;
        }

        @Override
        public void move() {
            System.out.println(mAquatic + "在水裡遊");
        }
    }
    
    private class Celestial extends Animal{

        private String mCelestial;

        Celestial(String animal) {
            mCelestial = animal;
        }

        @Override
        public void move() {
            System.out.println(mCelestial + "在天空飛");
        }
    }
複製程式碼

這裡只是為了突出對於擴充是開放的,但對於修改是封閉的一特性,如果想要繼續優化則涉及到工廠模式方面,這裡就先不延伸下去了。

為什麼使用開閉原則?

  1. 開閉原則是最基礎的設計原則,其它的五個設計原則都是開閉原則的具體形態,也就是說其它的五個設計原則是指導設計的工具和方法,而開閉原則才是其精神領袖。
  2. 開閉原則可以提高軟體的維護性和擴充性,符合開閉原則的程式碼更易讀懂理解,對於擴充也不需去修改一個類,而是新增一個類,減少了出錯的可能性。
  3. 物件導向開發的要求,萬物皆在發展變化,有變化就要有策略去應對,在設計之初考慮到可能會變化的因素,抽象出對應的特性,將“可能”轉變為“實際”。

遵守開閉原則的重要手段應該是通過抽象;當軟體需要變化時,應該儘量通過擴充套件的方式來實現變化,而不是通過修改已有的程式碼來實現。
“應該儘量”
說明OCP原則並不是說絕對不可以修改原始類,當我們嗅到程式碼“腐朽氣味”時,應儘早的重構,而不是通過繼承等方式新增新的實現,這會導致型別的膨脹以及歷史遺留程式碼的冗餘;實際的開發過程也沒那麼理想化完全的不需要修改原理的程式碼,因此,在開發過程中要結合實際的具體的情況去進行考量,是通過修改舊程式碼還是通過繼承使得軟體系統更穩定、更靈活;

構建擴充套件性更好的系統-里氏替換原則LSP

里氏代換原則(Liskov Substitution Principle, LSP):

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

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

經典問題:
有一功能P1,由類A完成。現需要將功能P1進行擴充套件,擴充套件後的功能為P,其中P由原有功能P1與新功能P2組成。新功能P由類A的子類B來完成,則子類B在完成新功能P2的同時,有可能會導致原有功能P1發生故障。

解決方案:
當使用繼承時,遵循里氏替換原則。類B繼承類A時,除新增新的方法完成新增功能P2外,儘量不要重寫父類A的方法,也儘量不要過載父類A的方法。

里氏代換原則告訴我們,在軟體中將一個基類物件替換成它的子類物件,程式將不會產生任何錯誤和異常,反過來則不成立,如果一個軟體實體使用的是一個子類物件的話,那麼它不一定能夠使用基類物件。例如:我喜歡動物,那我一定喜歡狗,因為狗是動物的子類;但是我喜歡狗,不能據此斷定我喜歡動物,因為我並不喜歡老鼠,雖然它也是動物。說了那麼多,其實最終總結就兩個字:抽象

里氏代換原則是實現開閉原則的重要方式之一,由於使用基類物件的地方都可以使用子類物件,因此在程式中儘量使用基類型別來對物件進行定義,而在執行時再確定其子類型別,用子類物件來替換父類物件(上面的例子也體現了這一點)。

里氏替換原則的核心原理是抽象,抽象又依賴於繼承這個特性,在OOP中,繼承的優缺點都相當的明顯。
繼承的優點:

  1. 程式碼重用,減少建立類的成本,每個子類都擁有父類的方法和屬性;
  2. 子類和父類基本相似,但又與父類有所區別;
  3. 提高程式碼的可擴充性;

繼承的缺點:

  1. 繼承是侵入性的,只要繼承就必須擁有父類所有的屬性和方法;
  2. 可能造成子類程式碼冗餘,靈活性減低;
  3. 如果一個類被其他的類所繼承,則當這個類需要修改時,必須考慮到所有的子類,並且父類修改後,所有涉及到子類的功能都有可能會產生故障。

繼承包含這樣一層含義:父類中凡是已經實現好的方法(相對於非抽象方法而言),實際上是在設定一系列的規範和契約,雖然它不強制要求所有的子類必須遵從這些契約,但是如果子類對這些非抽象方法任意修改,就會對整個繼承體系造成破壞。而里氏替換原則就是表達了這一層含義。

舉例說明繼承的風險,我們需要完成一個兩數相減的功能,由類A來負責。

    @Test
    public void lspTest() {

        A a = new A();
        System.out.println("100 + 50 = " + a.add(100, 50));

    }


    private class A {
        public int add(int a, int b) {
            return a + b;
        }
    }
    
     執行結果:
    100 + 50 = 150

複製程式碼

後來,我們需要增加一個新的功能:完成兩數相加,然後再與100求和,由類B來負責。由於類A已經實現了第一個功能,所以類B繼承類A後,只需要再完成第二個功能就可以了,程式碼如下:

    @Test
    public void lspTest() {

        A a = new A();
        System.out.println("100 + 50 = " + a.add(100, 50));


         B b = new B();
        System.out.println("100 + 50 = " + b.add(100, 50));
        System.out.println("100 + 50 + 100 = " + b.minus(100, 50));

    }


    private class A {
        public int add(int a, int b) {
            return a + b;
        }
    }

    private class B extends A {

        public int add(int a, int b) {
            // a 不再是加上 b
            return a - b;
        }

        private int minus(int a, int b) {
            return add(a , b) + 100;
        }

    }
    
     執行結果:
    100 + 50 = 150
    100 + 50 = 50
    100 + 50 + 100 = 150
複製程式碼

我們發現原本執行正常的相加功能發生了錯誤。原因就是類B在給方法起名時無意中重寫了父類的方法,造成所有執行相加功能的程式碼全部呼叫了類B重寫後的方法,造成原本執行正常的功能出現了錯誤。
在本例中,引用基類A完成的功能,換成子類B之後,發生了異常。在實際程式設計中,我們常常會通過重寫父類的方法來完成新的功能,這樣寫起來雖然簡單,但是整個繼承體系的可複用性會比較差,特別是運用多型比較頻繁時,程式執行出錯的機率非常大。如果非要重寫父類的方法,比較通用的做法是:原來的父類和子類都繼承一個更通俗的基類,原有的繼承關係去掉,採用依賴、聚合,組合等關係代替。

    里氏替換原則通俗的來講就是:子類可以擴充套件父類的功能,但不能改變父類原有的功能。
複製程式碼

里氏代換原則是實現開閉原則的重要方式之一。除了父類外,在傳遞引數時使用基類物件,在定義成員變數、定義區域性變數、確定方法返回型別時都可使用里氏代換
原則,針對基類程式設計,在程式執行時再確定具體子類。

讓專案擁有變化的能力-依賴倒置原則DIP

依賴倒轉原則(Dependency Inversion Principle, DIP):高層模組不應該依賴底層模組,兩者都應該依賴其抽象,抽象不應該依賴於細節,細節應當依賴於抽象。

經典問題:
類A直接依賴類B,假如要將類A改為依賴類C,則必須通過修改類A的程式碼來達成。這種場景下,類A一般是高層模組,負責複雜的業務邏輯;類B和類C是低層模組,負責基本的原子操作;假如修改類A,會給程式帶來不必要的風險。

解決方案:
將類A修改為依賴介面I,類B和類C各自實現介面I,類A通過介面I間接與類B或者類C發生聯絡,則會大大降低修改類A的機率。

如果說開閉原則是物件導向設計的目標的話,那麼依賴倒轉原則就是物件導向設計的主要實現機制之一,它是系統抽象化的具體實現.在Java中,抽象就是指介面或抽象類,兩者都是不能被例項化的;細節就是實現類,實現介面或繼承抽象類而產生的類就是細節,它是可以被例項化的;

依賴倒置原則在Java語言中的表現為:模組間的依賴通過抽象發生,實現類之間不發生直接的依賴關係,其依賴關係是通過介面或抽象類產生的;

依賴倒轉原則要求我們在程式程式碼中傳遞引數時或在關聯關係中,儘量引用層次高的抽象層類,即使用介面和抽象類進行變數型別宣告、引數型別宣告、方法返回型別宣告,以及資料型別的轉換等,而不要用具體類來做這些事情。

在實現依賴倒轉原則時,我們需要針對抽象層程式設計,而將具體類的物件通過依賴注入(DependencyInjection,DI)的方式注入到其他物件中,依賴注入是指當一個物件要與其他物件發生依賴關係時,通過抽象來注入所依賴的物件。

常用的注入方式有三種:

  1. 構造注入,構造注入是指通過建構函式來傳入具體類的物件;
  2. 設值注入,設值注入是指通過Setter方法來傳入具體類的物件;
  3. 介面注入,介面注入是指通過在介面中宣告的業務方法來傳入具體類的物件;

這些方法在定義時使用的是抽象型別,在執行時再傳入具體型別的物件,由子類物件來覆蓋父類物件。

依賴倒置原則基於這樣一個事實:相對於細節的多變性,抽象的東西要穩定的多。以抽象為基礎搭建起來的架構比以細節為基礎搭建起來的架構要穩定的多。

依賴倒置原則的核心思想是面向介面程式設計,我們依舊用一個例子來說明面向介面程式設計比相對於面向實現程式設計好在什麼地方。假設當前場景為讀文章:

    @Test
    public void dipTest() {
        Writer writer = new Writer();
        Book book = new Book();
        writer.read(book);
    }


    private class Book {

        private String getContent() {
            return "讀書";
        }
    }

    private class Writer {
        private void read(Book book) {
            System.out.println("作家" + book.getContent());
        }
    }
    
    執行結果:作家讀書
複製程式碼

假如有一天,需求變成這樣:作家讀書,也可以讀報紙,報紙的程式碼如下:

 private class Newspaper {

        private String getContent() {
            return "讀報紙";
        }
    }
    
    private class Writer {
        private void read(Book book) {
            System.out.println("作家" + book.getContent());
        }

        private void read(Newspaper newspaper) {
            System.out.println("作家" + newspaper.getContent());
        }
    }
    
    執行結果:
    作家讀書
    作家讀報紙
複製程式碼

僅僅是新增閱讀報紙的功能就需要去修改Writer類,以後還要讀雜誌、小說等等的怎麼辦呢?這顯然不是好的設計,因為就是Writer與Book、Newspaper之間的耦合性太高了(這裡是很明顯違背了開閉原則)
下面我們引入一個抽象的介面IReader來降低他們之間的耦合度

    @Test
    public void dipTest() {
        Writer writer = new Writer();
        writer.read(new Book());
        writer.read(new Newspaper());
    }
    
    private interface IReader {
        String getContent();
    }
    
    private class Book implements IReader{

        @Override
        public String getContent() {
            return "讀書";
        }
    }

    private class Newspaper implements IReader{

        @Override
        public String getContent() {
            return "讀報紙";
        }
    }
    
     執行結果:
    作家讀書
    作家讀報紙
複製程式碼

這只是一個簡單的例子,實際情況中,代表高層模組的Writer類將負責完成主要的業務邏輯,一旦需要對它進行修改,引入錯誤的風險極大。所以遵循依賴倒置原則可以降低類之間的耦合性,提高系統的穩定性,降低修改程式造成的風險。
注: 這裡的Writer類也可以說是表明一種職業,有興趣的話可以將它再抽象化;

依賴倒置原則的核心就是要我們面向介面程式設計。採用依賴倒置原則給多人並行開發帶來了極大的便利,比如上例中,原本Writer類與Book類直接耦合時,Writer類必須等Book類編碼完成後才可以進行編碼,因為Writer類直接依賴於Book類。修改後的程式則可以同時開工,互不影響,因為Writer與Book類一點關係也沒有。參與協作開發的人越多、專案越龐大,採用依賴導致原則的意義就越重大,現在很流行的TDD開發模式就是依賴倒置原則最成功的應用。

系統有更高的靈活性-介面隔離原則ISP

介面隔離原則(Interface Segregation Principle, ISP):
定義一:客戶端不應該依賴那些它不需要的介面;
定義二:類間的依賴關係應該建立在最小的介面上;

經典問題:
類A通過介面I依賴類B,類C通過介面I依賴類D,如果介面I對於類A和類B來說不是最小介面,則類B和類D必須去實現他們不需要的方法。

解決方案:
將臃腫的介面I拆分為獨立的幾個介面,類A和類C分別與他們需要的介面建立依賴關係。也就是採用介面隔離原則。

介面原則將非常龐大、臃腫的介面拆分為更小更具體的介面,這樣客戶端只需要知道他們感興趣的方法;介面隔離原則目的是系統解開耦合,從而更容易重構、更改和重新部署;
介面隔離原則的含義是:建立單一介面,不要建立龐大臃腫的介面,儘量細化介面,介面中的方法儘量少。在物件導向程式語言中,實現一個介面就需要實現該介面中定義的所有方法,因此最好能根據其職責不同分別放在不同的小介面中,以確保每個介面使用起來都較為方便,並都承擔某一單一角色。介面是設計時對外部設定的“契約”,通過分散定義多個介面,可以預防外來變更的擴散,提高系統的靈活性和可維護性。

未遵循介面隔離原則的設計.png

類A依賴介面I中的方法1、方法2、方法3,類B是對類A依賴的實現。類C依賴介面I中的方法1、方法4、方法5,類D是對類C依賴的實現。對於類B和類D來說,雖然他們都存在著用不到的方法(也就是圖中紅色字型標記的方法),但由於實現了介面I,所以也必須要實現這些用不到的方法。
可以看出如果介面過於臃腫,只要介面中出現的方法,不管對依賴於它的類有沒有用處,實現類中都必須去實現這些方法,這顯然不是好的設計。下面將這個設計修改為符合介面隔離原則的介面,我們需要對介面I進行拆分。

image.png
| 這裡我就不舉例子了,上面兩幅圖已經很清晰的體現了介面隔離原則;
複製程式碼

採用介面隔離原則對介面進行約束時,要注意以下幾點:

  1. 需要注意控制介面的粒度,介面不能太小,如果太小會導致系統中介面氾濫,不利於維護;介面也不能太大,太大的介面將違背介面隔離原則;
  2. 一般而言,介面中僅包含為某一類使用者定製的方法或是為依賴介面的類定製服務,不應該強迫客戶依賴於那些它們不用的方法,只有專注地為一個模組提供定製服務,才能建立最小的依賴關係。
  3. 提高內聚,減少對外互動。使介面用最少的方法去完成最多的事情。

介面隔離原則與單一職責原則的不同

  1. 單一職責原則注重的是職責;而介面隔離原則注重對介面依賴的隔離;
  2. 單一職責原則主要是約束類,其次才是介面和方法,它針對的是程式中的實現和細節;而介面隔離原則主要約束介面介面,主要針對抽象,針對程式整體框架的構建。

更好的可擴充套件性-迪米特原則LOD

迪米特法則(Law of Demeter, LoD),或稱最小知識原則(Least Knowledge Principle, LDP):一個物件應該對其他物件有最少的瞭解。

經典問題:
類與類之間的關係越密切,耦合度越大,當一個類發生改變時,對另一個類的影響也越大。

解決方案:
儘量降低類與類之間的耦合。

迪米特法則還有一個更簡單的定義:只與直接的朋友通訊。
在迪米特法則中,對於一個物件,其朋友包括以下幾類:

  1. 當前物件本身(this);
  2. 以引數形式傳入到當前物件方法中的物件;
  3. 當前物件的成員物件;
  4. 如果當前物件的成員物件是一個集合,那麼集合中的元素也都是朋友;
  5. 當前物件所建立的物件。

迪米特法則要求我們在設計系統時,應該儘量減少物件之間的互動,如果兩個物件之間不必彼此直接通訊,那麼這兩個物件就不應當發生任何直接的相互作用,如果其中的一個物件需要呼叫另一個物件的某一個方法的話,可以通過第三者轉發這個呼叫。簡言之,就是通過引入一個合理的第三者來降低現有物件之間的耦合度。

假設當前場景為通過中介找房:

    @Test
    public void ispTest() {
        Tenant tenant = new Tenant(18, 2720);
        Mediator mediator = new Mediator();
        tenant.rentRoom(mediator);
    }

    private class Room {

        private int mPrice;
        private int mArea;

        Room(int price, int area) {
            mPrice = price;
            mArea = area;
        }

        @Override
        public String toString() {
            return "當前房子" +
                    "房價: " + mPrice +
                    ", 房子面積: " + mArea +
                    `}`;
        }
    }

    // 中介
    private class Mediator {

        List<Room> mRooms = new ArrayList<Room>();

        Mediator() {
            for (int i = 0; i < 5; i++) {
                mRooms.add(new Room(15 + i, (15 + i) * 150));
            }
        }

        private List<Room> getRooms() {
            return mRooms;
        }
    }

    // 租客
    private class Tenant {

        private int mRoomPrice;
        private int mRoomArea;
        private int mDiffPrice = 10;
        private int mDiffArea = 100;

        Tenant(int price, int area) {
            mRoomPrice = price;
            mRoomArea = area;
        }

        private void rentRoom(Mediator mediator) {
            List<Room> rooms = mediator.getRooms();
            for (Room room: rooms) {
                if (isSuitable(room)) {
                    System.out.println("租到房子了," + room.toString());
                    break;
                }
            }
        }

        private boolean isSuitable(Room room) {
            return Math.abs(room.mPrice - mRoomPrice) < mDiffPrice
                    && Math.abs(room.mArea - mRoomArea) < mDiffArea;
        }
    }
複製程式碼

從上面的程式碼可以看到Tenant類不僅依賴了Mediator類還頻繁的跟Room類打交道。當Room變化時Tenant也會隨之變化,而Tenant類又與Mediator類耦合,這就出現了糾纏不清的關係。這個時候我們就需要分清誰是我們的朋友了

@Test
    public void ispTest() {
        Tenant tenant = new Tenant(18, 2720);
        Mediator mediator = new Mediator();
        tenant.rentRoom(mediator);
    }

    private class Room {

        private int mPrice;
        private int mArea;

        Room(int price, int area) {
            mPrice = price;
            mArea = area;
        }

        @Override
        public String toString() {
            return "當前房子" +
                    "房價: " + mPrice +
                    ", 房子面積: " + mArea +
                    `}`;
        }
    }

    // 中介
    private class Mediator {
        private int mDiffPrice = 10;
        private int mDiffArea = 100;
        List<Room> mRooms = new ArrayList<Room>();

        Mediator() {
            for (int i = 0; i < 5; i++) {
                mRooms.add(new Room(15 + i, (15 + i) * 150));
            }
        }

        private Room rent(int price, int area) {
            for (Room room : mRooms) {
                if (isSuitable(room, price, area)) {
                    System.out.println("租到房子了," + room.toString());
                    return room;
                }
            }
            return new Room(0, 0);
        }

        private boolean isSuitable(Room room, int price, int area) {
            return Math.abs(room.mPrice - price) < mDiffPrice
                    && Math.abs(room.mArea - area) < mDiffArea;
        }

    }

    // 租客
    private class Tenant {

        private int mRoomPrice;
        private int mRoomArea;

        Tenant(int price, int area) {
            mRoomPrice = price;
            mRoomArea = area;
        }

        private void rentRoom(Mediator mediator) {
             mediator.rent(mRoomPrice, mRoomArea);
        }
    }
複製程式碼

這裡是將Room的判斷操作移到了Mediator類中,這本該是Mediator類的職責,根據租客的設定條件查詢房子,並將結果返回給租客;租客不應該知道太多房子的細節,我們只需通過中介溝通就好,不需要跟房主等其他角色溝通,因為這些角色都不是我們的直接朋友。“只與直接的朋友通訊”能將我們從複雜的關係網中抽離出來,使程式耦合性更低,更穩定;

在將迪米特法則運用到系統設計中時,要注意下面的幾點:

  1. 在類的劃分上,應當儘量建立鬆耦合的類,類之間的耦合度越低,就越有利於複用,一個處在鬆耦合中的類一旦被修改,不會對關聯的類造成太大波及;
  2. 在類的結構設計上,每一個類都應當儘量降低其成員變數和成員函式的訪問許可權;
  3. 在類的設計上,只要有可能,一個型別應當設計成不變類;在對其他類的引用上,一個物件對其他物件的引用應當降到最低。

迪米特法則的做法觀念就是類間解耦,弱耦合,只有弱耦合了以後,類的複用率才可以提高,其要求的結果就是產生了大量的中轉或跳轉類,導致的複雜性提高,同時也為維護帶來了難度,所以在採用迪米特法則時需要反覆權衡,既做到讓結構清晰,又做到高內聚低耦合。

但是過度使用迪米特法則,也會造成系統的不同模組之間的通訊效率降低,使系統的不同模組之間不容易協調等缺點。同時,因為迪米特法則要求類與類之間儘量不直接通訊,如果類之間需要通訊就通過第三方轉發的方式,這就直接導致了系統中存在大量的中介類,這些類存在的唯一原因是為了傳遞類與類之間的相互呼叫關係,這就毫無疑問的增加了系統的複雜度。解決這個問題的方式是:使用依賴倒轉原則(通俗的講就是要針對介面程式設計,不要針對具體程式設計), 這要就可以是呼叫方和被呼叫方之間有了一個抽象層,被呼叫方在遵循抽象層的前提下就可以自由的變化,此時抽象層成了呼叫方的朋友。

總結

單一職責原則告訴我們實現類要職責單一;
里氏替換原則告訴我們不要破壞繼承體系;
依賴倒置原則告訴我們要面向介面程式設計;
介面隔離原則告訴我們在設計介面的時候要精簡單一;
迪米特法則告訴我們要降低耦合。
而開閉原則是總綱,他告訴我們要對擴充套件開放,對修改關閉。

最後說明一下如何去遵守這六個原則。對於六個原則的遵守並非是和否的問題,而是多和少的問題,也就是說,一般我們只會說遵守程度是否合理,是否很好的平衡了各個原則達到一個較優的解決方案。任何事都是過猶不及,設計模式的六個設計原則也是一樣,我們需要的是理解六個原則的思想及原因,根據實際的情況靈活的運用他們,而不是刻意和死板的去遵守它們;

一千個讀者眼中有一千個哈姆雷特,如果大家對這六項原則的理解跟我有所不同或是我哪裡理解錯誤,歡迎留言,大家共同探討。文中例子原始碼:請點選這裡

查考文獻:

相關文章