設計模式——面向對象設計原則

  • 2019 年 10 月 9 日
  • 筆記

面向對象設計原則究其根源是為了保證軟體的可維護性和可復用性

知名軟體大師Robert C.Martin認為一個可維護性較低的軟體設計通常由於如下4個原因造成:過於僵硬,過於脆弱,復用率低,黏度過高。軟體工程和建模大師Peter Coad認為,一個好的系統設計應該具備三個性質:可擴展性,靈活性,可插入性

由此看出,可維護性和可復用性在軟體的設計中具有舉足輕重的地位

  • 面向對象設計復用的目標在於實現支援可維護性的復用
  • 在面向對象的設計裡面,可維護性復用都是以面向對象設計原則為基礎的,這些設計原則首先都是復用的原則,遵循這些設計原則可以有效地提高系統的復用性和可維護性
  • 重構在不改變軟體現有功能的基礎上,通過調整程式程式碼改善軟體的品質、性能,使其程式的設計模式和架構更趨合理,提高軟體的擴展性和維護性

常用的面向對象設計原則包括7個,這些原則並不是孤立存在的,它們相互依賴,相互補充

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

一、開閉原則

1、定義

一個軟體實體應當對擴展開放,對修改關閉

在設計一個模組的時候,應當使這個模組可以在不被修改的前提下被擴展,即實現在不修改源程式碼的情況下改變這個模組的行為

軟體實體可以指一個軟體模組、一個由多個類組成的局部結構或一個獨立的類

2、分析

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

抽象是開閉原則的關鍵

3、舉例

在創業公司里,由於人力成本控制和流程不夠規範的原因,往往一個人需要擔任N個職責,一個工程師可能不僅要出需求,還要寫程式碼,甚至要面談客戶,程式碼表示如下

public class Engineer {      public void makeDemand() {} //出需求      public void writeCode() {} //寫程式碼      public void meetClient() {} //見客戶  }

為了保證可維護性和可復用性,在滿足開閉原則的條件下,把方法設計成介面,例如把寫程式碼的方法抽離成介面的形式,同時在設計之初考慮未來所有可能發生變化的因素,比如未來有可能因為業務需要分成後端和前端的功能,那麼設計之初就可以設計成兩個介面

public interface BackCode{ //後端      void writeCode();  }
public interface FrontCode{ //前端      void writeCode();  }

如果將來前端程式碼的業務發生變化,只需擴展前端介面的功能,或者修改前端介面的實現類,後台介面以及實現類就不會受到影響

二、依賴倒置原則

1、定義

高層模組低層模組都應該依賴抽象。抽象不應該依賴於細節,細節應該依賴於抽象

或者說,針對介面編程,而不針對實現編程

不可分割的原子邏輯就是底層模組,原子邏輯的再組裝就是高層模組

2、分析

程式碼要依賴於抽象的類,而不要依賴於具體的類;要針對介面或抽象類編程,而不是針對具體類編程

依賴倒轉原則的常用實現方式之一是在程式碼中使用抽象類,而將具體類放在配置文件中

3、舉例

以歌手唱歌為例,比如一個歌手要唱國語歌,程式碼表示:

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

如果現在想讓歌手唱英文歌,但是在這個類中很難實現:Singer類依賴於一個具體的實現類 ChineseSong。如果增加方法,就修改了 Singer類,以後需要增加更多的歌種時,歌手類就要一直修改,依賴類已經不穩定了

這時需要用面向介面編程的思想優化方案,修改程式碼:

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

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

三、里式替換原則

1、定義

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

或者說, 所有引用父類的地方必須能透明地使用其子類的對象

2、分析

在軟體中如果能夠使用父類對象,那麼一定能夠使用其子類對象。把父類都替換成它的子類,程式將不會產生任何錯誤和異常;但是反過來則不成立(子類可以擴展父類沒有的功能,同時子類還不能改變父類原有的功能)

由於使用基類對象的地方都可以使用子類對象,因此在程式中盡量使用基類類型來對對象進行定義,而在運行時再確定其子類類型,用子類對象替換父類對象

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

  1. 子類可以實現父類的抽象方法,但是不能覆蓋父類的非抽象方法
  2. 子類可以有自己的個性,即可以有自己的屬性和方法
  3. 子類覆蓋或重載父類的方法時輸入參數可以被放大
  4. 子類覆蓋或重載父類的方法時輸出結果可以被縮小,即返回值要小於或等於父類的方法返回值

3、舉例

父類有一個方法,參數是HashMap

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

子類的同名方法輸入參數的類型可以擴大,例如輸入參數為Map

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

寫一個場景類測試父類的方法執行效果:

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);      }  }

運行結果一樣。這是因為子類方法的輸入參數類型範圍擴大了,子類代替父類傳遞到調用者中,子類的方法永遠不會被執行。如果想讓子類方法執行,可以重寫方法體

四、單一職責原則

1、定義

一個對象應該只包含單一的職責,並且該職責被完整地封裝在一個類中。或者說,就一個類而言,應該僅有一個引起它變化的原因

2、分析

一個類(或者大到模組,小到方法)承擔的職責越多,它被複用的可能性越小。當一個類承擔的職責越多,其耦合度越高,當其中一個職責變化時,可能會影響其他職責的運作

類的職責主要包括兩個方面:數據職責和行為職責,數據職責通過屬性來體現,行為職責通過方法來體現

單一職責原則是實現高內聚、低耦合的指導方針,在很多程式碼重構手法中都能找到它的存在。它是最簡單但又最難運用的原則,需要設計人員發現類的不同職責並將其分離,而發現類的多重職責需要設計人員具有較強的分析設計能力和相關重構經驗

3、舉例

仍然舉開閉原則中的工程師類例子

public class Engineer {      public void makeDemand() {} //出需求      public void writeCode() {} //寫程式碼      public void meetClient() {} //見客戶  }

程式碼貌似沒問題,符合我們的寫法,但是它不符合單一職責原則:三個方法都可以引起類的變化,比如有天因為業務需要,出需求的方法需要增加需求成本分析的功能,或者見客戶需要參數,導致類的變化有多種可能性,其他引用該類的類也需要相應的變化,程式碼維護的成本隨之增加

所以需要把這些方法拆分成獨立的職責:讓一個類只負責一個方法,每個類只專心處理自己的方法

單一職責原則優點

  • 降低類的複雜性,每個類有明確的職責
  • 邏輯更簡單,類的可讀性提高,程式碼的可維護性提高
  • 變更的風險降低,因為只會在單一的類中的修改

五、組合復用原則

1、定義

盡量使用對象組合而不是繼承來達到復用的目的

2、分析

在一個新的對象里通過關聯關係(組合、聚合關係)使用一些已有的對象, 使之成為新對象的一部分;新對象通過委派調用已有對象的方法來達到復用其已有功能的目的

面向對象中有兩種方法可以做到復用已有的設計和實現:通過組合,通過繼承

  • 繼承復用:實現簡單,易於擴展。會破壞系統的封裝性;從父類繼承而來的實現是靜態的,運行時不能改變,靈活性不足;只能在有限的環境中使用
  • 組合復用:耦合度相對較低,選擇性地調用成員對象的方法;可在運行時動態進行

組合可以使系統更加靈活,降低類與類之間的耦合度,進而一個類的變化對其他類造成的影響相對較少

使用繼承時需要嚴格遵循里氏代換原則,有效使用繼承會有助於對問題的理解,降低複雜度,否則會增加系統構建和維護的難度以及系統的複雜度

因此一般首選使用組合實現復用,其次才考慮繼承

3、舉例

我們每個人都有一個身份,比如我是老師,你是學生,他是運動員。按照這種情況編寫程式碼

class Person {      public Person() {      }  }    class Teacher extends Person {      public Teacher() {          System.out.println("I am a teacher.");      }  }  class Student extends Person {      public Student() {          System.out.println("I am a student.");      }  }  class Athlete extends Person {      public Athlete() {          System.out.println("I am an athlete");      }  }

但是該設計不符合實際情況,每個人都能有多重身份,可以是學生,也可以是運動員。上述程式碼只給出了一人一種身份的情況,不合理。重新設計程式碼

class Person {      private Role role;        public Person() {      }      public void setRole(Role role) {          this.role = role;      }      public Role getRole() {          return role;      }  }    interface Role {  }  class Teacher implements Role{      public Teacher() {          System.out.println("I am a teacher.");      }  }  class Student implements Role {      public Student() {          System.out.println("I am a student.");      }  }  class Athlete implements Role {      public Athlete() {          System.out.println("I am an athlete");      }  }

原來的聚合關係變為現在的組合關係。一個人可以有多重角色,在不同的情況下,我們還能給人物設置不同的角色

六、迪米特原則

1、定義

」只與直接朋友通訊「

一個對象應該對其他對象有最少的了解

2、分析

一個軟體實體應當儘可能少的與其他實體發生相互作用。這樣一個模組修改時就會盡量少的影響其他的模組,擴展會相對容易。這是面向設計的核心原則:低耦合,高內聚

對於一個對象,其朋友包括以下幾類:

  • 當前對象本身
  • 以參數形式傳入到當前對象方法中的對象
  • 當前對象的成員對象
  • 如果當前對象的成員對象是一個集合,那麼集合中的元素都是朋友
  • 當前對象所創建的對象

3、舉例

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

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);      }  }  class BasketBall {  }  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類產生依賴,修改後的程式碼如下:

class Teacher {      // 命令班長去拿球      public void command(Monitor monitor) {          // 通知班長開始去拿球          monitor.takeBall();      }  }  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類

七、介面隔離原則

1、定義

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

2、分析

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

使用介面隔離原則拆分介面時,首先必須滿足單一職 責原則,將一組相關的操作定義在一個介面中,且在滿足高內聚的前提下,介面中的方法越少越好

3、舉例

在我們年輕人的觀念里,好的智慧手機應該是價格便宜,外觀好看,功能豐富的,由此可以定義一個智慧手機的抽象介面 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介面就不適用了,我們的介面定義了智慧手機必須滿足三個特性,如果實現該介面就必須三個方法都實現

這時就能看出,ISmartPhone過於臃腫了,按照介面隔離原則,我們可以根據不同的特性把智慧手機的介面進行拆分,這樣就保證了介面的單一性和純潔性