設計模式六大原則

2023-11-06 12:03:00

前言

什麼是設計模式?

設計模式是軟體設計人員、軟體開發人員在程式程式碼編寫中總結出來的一套編碼規範,設計模式起一個指導作用,用來指導我們寫出高內聚低耦合,具有良好的可延伸性和可維護性的程式碼。

為什麼要學設計模式?

當然,設計模式不是非學不可,不瞭解設計模式一樣可以在工作中寫出符合產品要求的功能。但是隨著功能的不斷迭代,需求不斷增加和變更,專案中的程式碼會不斷在在原有功能程式碼的基礎之上堆疊,最終會形成難以維護的一坨屎山。另外,作為程式設計師,寫出好的程式碼是我們基本的追求,也可以從專業的角度提升自己。

設計模式怎麼學?

設計模式有非常多種,作為一個程式設計師,在日常寫程式碼的過程中肯定有意無意的用到過某些模式。現在我們知道的23種設計模式,都是前輩們在各種實際開發場景中總結提取出來的,是一個通用的解決方案。雖說有23種之多,但這些模式都遵循了6大原則,瞭解了這6大原則再去看具體的設計模式就很容易理解了。

設計模式六大原則

單一職責原則

一個類只能有一個可以引起它變化的原因

說白了就是一個類只做一件事。那我們為什麼需要單一職責?如果某個類A承擔了多個職責A1,A2,A3,因為某些原因需要對這三個任何一個職責進行修改或變更都可能會影響到其他職責,可能導致發生故障。所以最好的做法是將A拆分成三個類,每個類只負責一個職責。
合理的遵循單一職責原則,可以提高類的可讀性、可維護性,降低類複雜度,從而提升了系統的可維護性。但是在我們日常工作中,在各種或者複雜或者簡單的業務需求背景下,如何確定一個類的職責範圍就需要我們好好思考了。

開閉原則

軟體(類、模組、函數等)應該要開放擴充套件,但是不能支援修改,即對修改關閉,對新增開放

我們在做任何系統的時候,都不能指望系統一開始時需求確定,就再也不會變化,這是不現實也不科學的想法,而既然需求是一定會變化的,那麼如何在面對需求的變化時,設計的軟體可以相對容易修改,不至於說新需求一來,就要把整個程式推倒重來。怎樣的設計才能面對需求的改變卻可以保持相對穩定,並且預留好一些可延伸的點,從而使得系統可以在基於第一個版本以後不斷擴充套件處新功能?我們用一個例子來說明怎樣對擴充套件開放,對修改關閉。
假設我們有一個廚師類,該類有一些成員變數和一個方法:炒菜,這個方法接收一個菜名,並且方法內部根據菜名進行不同的製作,具體類如下。

class 廚師 {
    int 年齡;
    string 姓名;
    string 身份證;
    
    public void 炒菜(string 菜名){
        ...
        洗鍋、洗菜、準備調味料等
        ...
        if(菜名=="西紅柿炒雞蛋"){
          ...
          炒西紅柿邏輯;
          ...
          炒雞蛋邏輯;
          ...    
          其他邏輯繼續追加
          ...
        } else if (菜名=="酸辣土豆絲"){
          ...
        }
    }
}

如果有一天廚師突發靈感,想要對西紅柿炒雞蛋的製作工藝進行改良,那麼應該怎麼做?按照當前的做法是直接在炒菜這個方法內,對if塊的邏輯進行邏輯修改,在足夠細心的情況下修改此邏輯或許沒什麼問題,但在一個團隊內,開發人員的風格和習慣各不相同,很難保證每個人在修改完西紅柿炒雞蛋的邏輯後可以不影響其他邏輯,此時就需要一個程式碼層面上的規範來強制約束大家,必須按照某個規範修改邏輯,並且這個規範天然不會影響其他邏輯,這個規範就是設計模式,在當前場景就是單一職責原則開閉原則
單一職責原則和開閉原則在這種場景下要求我們,需要將各種菜的邏輯單獨拆分出來,並且將廚師製作的邏輯進行抽象。拆分後的邏輯類如下。

abstract class 菜譜{
    string 菜名;
    public abstract void 準備配料();
    public abstract void 製作();
}

class 西紅柿炒雞蛋 extend 菜譜{
    string 菜名;
    
    public void 準備配料(){
        ...
        準備鹽醋醬油
        ...
    }
    
    public void 製作(){
        ...
        炒西紅柿邏輯;
        ...
        炒雞蛋邏輯;
        ...    
    }
}

class 酸辣土豆絲 extend 菜譜{
  string 菜名;
  
  public void 準備配料(){
        ...
    }
    
    public void 製作(){
        ...
    }
}

class 廚師{
    int 年齡;
    string 姓名;
    string 身份證;
   
   public void 炒(菜譜 菜譜){
       菜譜.準備配料();
       菜譜.製作();
   }
}

在上面的例子中,我們定義了一個菜譜基礎類別,並且將所有菜的製作邏輯單獨建立類並且繼承菜譜類,實現製作一道菜的準備配料製作邏輯。廚師則不侷限於具體某道菜,而是根據菜譜炒菜。這樣,即使要對西紅柿炒雞蛋的製作邏輯進行改良,也不會影響到其他菜的邏輯。並且以後如果引進新菜譜,廚師也可以直接按照新菜譜進行製作,這樣就遵循了對修改關閉,對新增開放的原則了。

依賴倒置原則

高層模組不應該依賴底層模組,而應該依賴抽象;抽象不應該依賴細節,細節應該依賴抽象。

我們首先看例子,然後再解釋這句話。

class 西紅柿炒雞蛋{
    string 菜名;
    
    public void 準備配料(){
        ...
        準備鹽醋醬油
        ...
    }
    
    public void 製作(){
        ...
        炒西紅柿邏輯;
        ...
        炒雞蛋邏輯;
        ...    
    }
}

class 廚師{
    int 年齡;
    string 姓名;
    string 身份證;
   
   public void 西紅柿炒雞蛋(){
       西紅柿炒雞蛋 菜 = new 西紅柿炒雞蛋();
       菜.準備配料();
       菜.製作();
   }
}

在上面的例子中,廚師類就是高層模組,而西紅柿炒雞蛋酸辣土豆絲屬於底層模組,此時的廚師類依賴了底層的實現。假如西紅柿炒雞蛋這道菜增加了一個邏輯剝西紅柿皮,那麼廚師類也要增加呼叫方法,這樣就減小了系統的可維護性。我們來看看修改後的邏輯實現。

abstract class 菜譜{
    string 菜名;
    public abstract void 製作();
}

class 西紅柿炒雞蛋 extend 菜譜{
    string 菜名;
    
    private void 剝西紅柿皮(){
      ...
    }
    
    private void 準備配料(){
        ...
        準備鹽醋醬油
        ...
    }
    
    private void 炒西紅柿(){
    }
    
    private void 炒雞蛋(){
    }
    
    public void 製作(){
        剝西紅柿皮();
        準備配料();
        炒雞蛋();
        炒西紅柿();
    }
}

class 廚師{
    int 年齡;
    string 姓名;
    string 身份證;
   
   public void 炒(菜譜 菜譜){
       菜譜.製作();
   }
}

修改後的邏輯,廚師類只依賴於抽象類菜譜的抽象方法製作,此時高層模組廚師沒有直接依賴具體實現,而是依賴了菜譜這個抽象類。具體的菜西紅柿炒雞蛋要怎麼炒、哪塊需要增加製作步驟,全部在西紅柿炒雞蛋的菜譜中進行修改。
如何理解「抽象不應該依賴細節,細節應該依賴抽象」這句話?我們有一個菜譜抽象類和西紅柿炒雞蛋實現類,此時如果西紅柿炒雞蛋要增加步驟剝西紅柿皮,如果在抽象類中增加方法剝皮,並在西紅柿炒雞蛋類中將剝西紅柿皮的實現邏輯寫在剝皮方法中,就犯了抽象依賴細節的錯誤了。抽象類應該是從具有某一同一行為的一類活動中抽象出來的通用類,而在本例中,同一行為就是菜的製作,而對於西紅柿炒雞蛋的所有制作過程,都屬於製作。所以在抽象類中提供了製作方法後,實現類西紅柿炒雞蛋的所有制作邏輯都應該在製作方法中實現,而非在抽象類中增加方法並在子類實現,這個就是細節應該依賴抽象

里氏替換原則

任何基礎類別可以出現的地方,子類一定可以出現。

里氏替換原則要求我們在所有依賴父類別的地方,子類可以完全替代父類別並且對邏輯無影響。在子類重寫了父類別已實現邏輯的情況下很容易違反此原則,我們還是看具體栗子。

abstract class 廚師{
    
    abstract void 洗菜();
    
    abstract void 調味();
    
    void 炒(){
       洗菜();
       ...
       下鍋邏輯
       ...
       調味();
       ...
       出鍋邏輯
       ...
    }
}

class 張三 extend 廚師{
    
    string 洗菜(){
        ...
        洗菜邏輯
        ...
    }
    
    string 調味(){
        ...
        調味邏輯
        ...
    }
    //這裡覆蓋了父類別的已實現方法
    void 炒(){
        ...
        下鍋邏輯
        ...
        調味();
        ...
        出鍋邏輯
        ...
        洗菜();
    }
}

class 飯店{
    void 炒菜(){
        廚師 張三 = new 張三();
        張三.炒();
    }
}

抽象類廚師類作為父類別,定義了兩個抽象方法和一個已實現方法。子類張三繼承了廚師類,並實現了兩個抽象方法:洗菜調味,並且又重寫了父類別已實現的方法,此時父類別的方法和子類的邏輯就不一致。在父類別方法中,邏輯流程是「洗菜-下鍋-調味-出鍋」,意味著子類所有的邏輯都必須按照這個流程執行。但子類張三重寫的邏輯時下鍋-調味-出鍋-洗菜,邏輯不同,就不能在父類別出現的地方替換成子類,否則可能會造成系統或者流程異常。

迪米特法則

一個物件應該對其他物件保持最少的瞭解,又叫最少知道原則。

在類的結構設計上,每個類都應當儘量降低成員的存取許可權,不需要讓別的類知道的欄位或行為就不公開,否則會破壞類的預期行為和安全性,我們直接看例子。

class 西紅柿炒雞蛋{
    
    private int 雞蛋;
    private int 西紅柿;
    private int 鹽;
    private int 醋;

    public 西紅柿炒雞蛋(int 雞蛋,int 西紅柿,int 鹽,int 醋){
        this.雞蛋=雞蛋;
        this.西紅柿=西紅柿;
        this.鹽=鹽;
        this.醋=醋;
    }
    
    public void 製作(){
        ...
        攪拌雞蛋(this.雞蛋);
        ...
        切西紅柿(this.西紅柿);
        ...
        加入鹽(this.鹽);
        ...
        加入醋(this.醋);
        ...
    }
}

class 廚師{
    
    public void 炒(){
        西紅柿炒雞蛋 菜=new 西紅柿炒雞蛋(2,1,500克,1升);
        菜.炒();
    }
}

拋開前面講的幾個原則先不管,第一眼看上面的例子好像沒什麼問題,廚師類有方法,西紅柿雞蛋類也沒其他無關邏輯,但我們看範例化西紅柿炒雞蛋的程式碼,範例化時傳入的雞蛋數2、西紅柿1、鹽500克、醋1升,看出問題了吧。誰家炒兩個雞蛋要放500克鹽1升醋,這樣做出來的菜還能吃嗎?所以很明顯這個範例化時的入參是有問題的,鹽和醋作為西紅柿炒雞蛋這道菜中的關鍵引數,需要用多少應該是根據雞蛋和西紅柿的數量來確定的,而不是初始化時任意傳入的。所以這個類的定義就違反了最少知道原則,將關鍵引數通過建構函式暴漏出來了。

class 西紅柿炒雞蛋{
    
    private int 雞蛋;
    private int 西紅柿;

    public 西紅柿炒雞蛋(int 雞蛋,int 西紅柿){
        this.雞蛋=雞蛋;
        this.西紅柿=西紅柿;
    }
    
    public void 製作(){
        ...
        攪拌雞蛋(this.雞蛋);
        ...
        切西紅柿(this.西紅柿);
        int 鹽=0;
        int 醋=0;
        
        if(雞蛋==2 && 西紅柿==1){
            鹽=10;
            醋=10;
        }else if(/*其他判斷邏輯*/){
            
        }
    }
}

class 廚師{
    
    public void 炒(){
        西紅柿炒雞蛋 菜=new 西紅柿炒雞蛋(2,1);
        菜.製作();
    }
}

上面我們修改過後的類定義,西紅柿炒雞蛋建構函式只接受雞蛋西紅柿數量,而關鍵引數則是在正式製作的時候,根據雞蛋和西紅柿的數量來最終確定,這樣,無論要炒多少個雞蛋和西紅柿都會有對應的被放入,確保炒出來的菜是真正可以吃的,即我們定義的類的行為是符合預期的。

介面隔離原則

使用多個專門的介面,而不使用單一的總介面,即使用者端不應該依賴那些它不需要的介面。

我們直接看範例

abstract class 人{
    abstract void 吃飯();
    
    abstract void 睡覺();
    
    abstract void 跑步();
    
    abstract void 工作();
    
   abstract void 爬();
}

class 嬰兒 extends 人{
    void 吃飯(){
    }
    void 睡覺(){
    }
    
    void 跑步(){
        //沒法跑
    }
    void 工作(){
        //沒法工作
    }
    void 爬(){
    }
}

class 成人 extends 人{
    void 吃飯(){
    }
    void 睡覺(){
    }
    void 跑步(){
    }
    void 工作(){
    }
    void 爬(){
        //沒必要
    }
}

在上面的程式碼中,我們定義了一個抽象類,並且定義了5個抽象方法。有兩個子類嬰兒成人,分別實現了抽象類定義的5個方法,但我們注意到,在嬰兒子類中是沒法實現跑步工作邏輯的,因為嬰兒不具備這樣的能力。而在成人子類中,也沒必要實現的方法,因為成人沒必要爬。此時雖然在基礎類別中定義的所有行為都是屬於人的,但並非所有繼承自的子類都需要全部實現這些方法,此時就違背了介面隔離原則。那麼我們看看如何修改基礎類別定義。

abstract class 人{
    abstract void 吃飯();
    
    abstract void 睡覺();
}

abstract class 嬰兒 extends 人{
    abstract void 爬();
}

abstract class 成人 extends 人{
    abstract void 跑步();
    
    abstract void 工作();
}

class 張三 extends 成人{
    void 吃飯(){
    }
    void 睡覺(){
    }
    void 跑(){
    }
    void 工作(){
    }
}

class 寶寶 extends 嬰兒{
    void 吃飯(){
    }
    void 睡覺(){
    }
    void 爬(){
    }
}

在上面修改後的程式碼中,抽象類只定義了兩個抽象方法吃飯睡覺,繼承自的兩個子類抽象類嬰兒成人分別定義各自的抽象方法跑步工作。那麼在具體的實現類中,我們就可以繼承不同的類:張三作為一個成人擁有基本的吃飯、睡覺、跑、工作行為,而寶寶作為嬰兒則有吃飯、睡覺、爬的行為。這樣各個類根據各自需求,繼承滿足要求的單一介面,而不用繼承一個大而全但其中的許多行為都沒法實現的介面,也避免了在依賴方呼叫對應物件方法時,某些行為未實現導致的功能異常。

總結

設計模式可以指導我們程式碼的結構搭建,而這六大原則則指明瞭設計模式的基本遵循的準則,在我們日常編寫程式碼的時候,如果能比較好的遵循這些原則,那麼即便我們沒有按照某個具體的模式套在對應的場景上,寫出來的程式碼也會具有較好的可維護性。