設計模式 - 概述

f1uLove發表於2018-10-28

在軟體工程中,設計模式(design pattern)是對軟體設計中普遍存在的各種問題,所提出的解決方案。設計模式並不是固定的一套程式碼,而是針對某一特定問題的具體解決思路與方案。可以認為是一種最佳實踐,因為他是無數軟體開發人員經過長時間的實踐總結出來的。 在瞭解設計模式之前就我們首先要了解一下物件導向的六大原則。

單一職權原則(Single Responsibility Principle, SRP)

定義:就一個類而言,應該僅有一個引起它變化的原因

如果一個類承擔的職責過多,就等於把這些職責耦合在一起,一個職責的變化可能會削弱或者抑制這個類完成其他職責的能力。這種耦合會導致脆弱的設計,當變化發生時,設計會遭到意想不到的破壞。

軟體設計真正要做的許多內容,就是發現職責並把那些職責相互分離,其實要去判斷是否應該分離出來,也不難,那就是如果你能夠想到多餘一個的動機去改變一個類,那麼這個類就是對於一個的職責

在我們現實遇到的需求場景中,完全遵守單一職權原則也不是一件很好的事。比如我們在12306購票的下單的時候,需要對我們的身份資訊做檢查,根據單一職權原則我們單獨編寫了一個對身份資訊驗證。但是隨著產品體驗的優化,需要在新增一個重複訂單的驗證,如果根據單一職權原則我們還要寫一個檢查重複訂單的類進行重複訂單的校驗。但是此時我們的程式碼結構已經定義好了,重新寫一個類,然後修改呼叫方法就顯得比較複雜,此時我們就可以對檢查類進行簡單的修改,編寫一個檢查方法,實現對身份檢查和重複訂單檢查的呼叫。此時我們的單一職權原則可以應用到我們的方法上。雖然這樣做對於類而言有悖於單一職權原則,但從下單前的校驗角度思考它有遵循於單一職權原則。(這樣做的風險在於職責擴散的不確定性,可能以後還需要做更多的檢查,所以記住,在職責擴散到我們無法控制的程度之前,立刻對程式碼進行重構。可根據不同的檢查型別細分為不同的檢查類

遵循單一職責原的優點有:

  • 可以降低類的複雜度,一個類只負責一項職責,其邏輯肯定要比負責多項職責簡單的多;
  • 提高類的可讀性,提高系統的可維護性;
  • 變更引起的風險降低,變更是必然的,如果單一職責原則遵守的好,當修改一個功能時,可以顯著降低對其他功能的影響。

需要說明的一點,單一職權原則並不是物件導向程式語言特有的原則,只要是模組化的程式設計,都適用單一職責原則。

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

定義:子型別必須能夠替換掉他們的父型別。

對於里氏替換原則這個名稱不用太糾結,覺得苦澀難懂,其實是因為這項原則最早是在1988年,由麻省理工學院的一位姓裡的女士(Barbara Liskov)提出來的,就是單純的一個名字。

如果把里氏替換原則翻譯成大白話就是一個軟體實體如果使用的是一個父類的話,那麼一定適用於其子類,而且它察覺不出父類物件和子類物件的區別,也就是說在軟體裡面把父類都替換成它的子類,程式的行為沒有變化

里氏替換原則主要對於繼承而言,B繼承A ,在B中新增新的方法的時候,儘量不要重寫A的方法,也儘量不要過載父類A的方法。

繼承作為物件導向三大特性之一,在給程式設計帶來巨大便利的同時,也帶來了弊端。比如使用繼承會給程式帶來侵入性,程式的可移植性降低,增加了物件間的耦合性,如果一個類被其他的類所繼承,則當這個類需要修改時,必須考慮到所有的子類,並且父類修改後,所有涉及到子類的功能都有可能會產生故障。 舉例說一下整合的風險

class A{
	public int func1(int a, int b){
		return a-b;
	}
}

public class Client{
	public static void main(String[] args){
		A a = new A();
		System.out.println("100-50="+a.func1(100, 50));
		System.out.println("100-80="+a.func1(100, 80));
	}
} 
複製程式碼

執行結果:

100-50=50

100-80=20

後來,我們需要增加一個新的功能:完成兩數相加,然後再與100求和,由類B來負責。即類B需要完成兩個功能:

  • 兩數相減。
  • 兩數相加,然後再加100。 由於類A已經實現了第一個功能,所以類B繼承類A後,只需要再完成第二個功能就可以了,程式碼如下:
class B extends A{
	public int func1(int a, int b){
		return a+b;
	}
	
	public int func2(int a, int b){
		return func1(a,b)+100;
	}
}

public class Client{
	public static void main(String[] args){
		B b = new B();
		System.out.println("100-50="+b.func1(100, 50));
		System.out.println("100-80="+b.func1(100, 80));
		System.out.println("100+20+100="+b.func2(100, 20));
	}
} 
複製程式碼

類B完成後,執行結果:

100-50=150

100-80=180

100+20+100=220

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

里氏替換原則通俗的來講就是:子類可以擴充套件父類的功能,但不能改變父類原有的功能。它包含以下4層含義:

  • 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法。
  • 子類中可以增加自己特有的方法。
  • 當子類的方法過載父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆。
  • 當子類的方法實現父類的抽象方法時,方法的後置條件(即方法的返回值)要比父類更嚴格。

依賴倒置原則(Dependence Inversion Principle)

定義:

  • 高層模組不應該依賴底層模組。兩個都應該依賴抽象。
  • 抽象不應該依賴細節。細節應該依賴抽象。

依賴倒置原則定義比較繞口,說白了就是針對介面程式設計,不要針對實現程式設計。

依賴倒置原則基於這樣一個事實:相對於細節的多變性,抽象的東西要穩定的多。以抽象為基礎搭建起來的架構比以細節為基礎搭建起來的架構要穩定的多。在java中,抽象指的是介面或者抽象類,細節就是具體的實現類,使用介面或者抽象類的目的是制定好規範和契約,而不去涉及任何具體的操作,把展現細節的任務交給他們的實現類去完成。

同樣我們舉個例子說明,雙十一即將來臨,商城搞滿減活動。

public class Client {

    public static void main(String[] args) {
        Activity activity = new Activity();
        activity.sale(new Manjian());
    }
}

class Activity {

    public void sale(Manjian manjian) {
        manjian.activityMode();
    }
}

class Manjian {

    public void activityMode() {
        System.out.println("活動方式:滿300減100");
    }
}
複製程式碼

執行輸出活動方式:滿300減100 過了一天,產品又提出一個打折的需求,但是如果實現就必須需要修改我們的活動類,以此類推,每次不同的活動都要去修改。這顯然不合理,ActivityDicount耦合性太高了,因此我們抽象一個優惠類

public interface Reduce {
    void activityMode();
}
複製程式碼

DiscountManJian都實現Reduce

public class Client {

    public static void main(String[] args) {
        Activity activity = new Activity();
        activity.sale(new Manjian());
        activity.sale(new Discount());
    }
}

class Activity {

    public void sale(Reduce reduce) {
        reduce.activityMode();
    }
}

class Manjian implements Reduce{

    @Override
    public void activityMode() {
        System.out.println("活動方式:滿300減100");
    }
}

class Discount implements Reduce{

    @Override
    public void activityMode() {
        System.out.println("活動方式:打八折");
    }
}
複製程式碼

輸出活動方式:滿300減100活動方式:打八折 這樣修改後無論怎麼修改活動方式都不需要修改Activity類了

傳遞依賴關係有三種方式,以上的例子中使用的方法是介面傳遞,另外還有兩種傳遞方式:構造方法傳遞和setter方法傳遞,相信用過Spring框架的,對依賴的傳遞方式一定不會陌生。

在實際程式設計中,我們一般需要做到如下3點:

  • 低層模組儘量都要有抽象類或介面,或者兩者都有。
  • 變數的宣告型別儘量是抽象類或介面。
  • 使用繼承時遵循里氏替換原則。

依賴倒置原則的核心就是要我們面向介面程式設計,理解了面向介面程式設計,也就理解了依賴倒置。

介面隔離原則(Interface Segregation Principle)

定義:客戶端不應該依賴它不需要的介面;一個類對另一個類的依賴應該建立在最小的介面上。 介面隔離原則簡單來說就是根據類的職責將介面進行更細粒度的拆分,使一個臃腫的介面分散成幾個介面,由實現者根據自身需求去分別實現。

舉個?:我們在封裝JDBC方法的時候會有單表查詢新增查詢分頁等等。如果我們封裝到一個介面裡面,有些不需要這麼多功能的類也要實現這些邏輯,就會造成程式碼的臃腫。這裡拿通用Mapper舉例

public interface SelectOneMapper<T> {

    /**
     * 根據實體中的屬性進行查詢,只能有一個返回值,有多個結果是丟擲異常,查詢條件使用等號
     *
     * @param record
     * @return
     */
    @SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
    T selectOne(T record);

}

public interface SelectMapper<T> {

    /**
     * 根據實體中的屬性值進行查詢,查詢條件使用等號
     *
     * @param record
     * @return
     */
    @SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
    List<T> select(T record);

}

public interface SelectAllMapper<T> {

    /**
     * 查詢全部結果
     *
     * @return
     */
    @SelectProvider(type = BaseSelectProvider.class, method = "dynamicSQL")
    List<T> selectAll();
}
複製程式碼

將每一種查詢都封裝成一個方法,然後寫一個通用的介面

public interface Mapper<T> extends
        BaseMapper<T>,
        ExampleMapper<T>,
        RowBoundsMapper<T>,
        Marker {

}
public interface BaseMapper<T> extends
        BaseSelectMapper<T>,
        BaseInsertMapper<T>,
        BaseUpdateMapper<T>,
        BaseDeleteMapper<T> {

}
複製程式碼

這樣我們就可以根據不同的需要進行選擇性繼承相應功能的介面就可以實現符合我們需要的介面。

介面隔離原則的含義是:建立單一介面,不要建立龐大臃腫的介面,儘量細化介面,介面中的方法儘量少。也就是說,我們要為各個類建立專用的介面,而不要試圖去建立一個很龐大的介面供所有依賴它的類去呼叫。本文例子中,將一個龐大的介面變更為3個專用的介面所採用的就是介面隔離原則。在程式設計中,依賴幾個專用的介面要比依賴一個綜合的介面更靈活。介面是設計時對外部設定的“契約”,通過分散定義多個介面,可以預防外來變更的擴散,提高系統的靈活性和可維護性。

說到這裡,很多人會覺的介面隔離原則跟之前的單一職責原則很相似,其實不然。其一,單一職責原則原注重的是職責;而介面隔離原則注重對介面依賴的隔離。其二,單一職責原則主要是約束類,其次才是介面和方法,它針對的是程式中的實現和細節;而介面隔離原則主要約束介面介面,主要針對抽象,針對程式整體框架的構建。

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

  • 介面儘量小,但是要有限度。對介面進行細化可以提高程式設計靈活性是不掙的事實,但是如果過小,則會造成介面數量過多,使設計複雜化。所以一定要適度。
  • 為依賴介面的類定製服務,只暴露給呼叫的類它需要的方法,它不需要的方法則隱藏起來。只有專注地為一個模組提供定製服務,才能建立最小的依賴關係。
  • 提高內聚,減少對外互動。使介面用最少的方法去完成最多的事情。

迪米特法則(Law Of Demeter)

定義:如果兩個類不必彼此直接通訊,那麼這兩個類就不應當發生直接的相互作用,如果其中一個類需要呼叫另一個類的某一個方法的話,可以通過第三者轉發這個呼叫。

迪米特法則也叫最少知識原則。強調的是一個物件應該對其他物件保持做少的瞭解,在類的結構設計上,每一個類都應當儘量降低成員的訪問許可權,也就是說,一個類包裝好自己的private狀態,不需要讓別的類知道的欄位或行為就不要公開。

迪米特法則其根本思想,是強調了類之間的鬆耦合,類之間耦合越弱,越利於複用,一個處在弱耦合的類被修改,不會對有關係的類造成波及。

迪米特法則的初衷是降低類之間的耦合,由於每個類都減少了不必要的依賴,因此的確可以降低耦合關係。但是凡事都有度,雖然可以避免與非直接的類通訊,但是要通訊,必然會通過一個“中介”來發生聯絡。過分的使用迪米特原則,會產生大量這樣的中介和傳遞類,導致系統複雜度變大。所以在採用迪米特法則時要反覆權衡,既做到結構清晰,又要高內聚低耦合。

開閉原則

定義:軟體實體(類、模組、函式等等)應該可以擴充套件,但是不可修改。

開閉原則有兩個特徵 1.對擴充套件是開放的(Open for extension) 2.對更改是封閉的(Closed for modification)

我們在做任何系統的時候,都不可能一開始指定需求就不在發生變化,但是每次需求的變化都會引起對原有程式碼的修改,很有可能會給舊的程式碼引入錯誤,也可能會使我們不得不對整個功能進行重構,並且還要測試一遍原有的程式碼。

絕對的對修改關閉是不現實的,這就要求設計人員必須對於他設計的程式碼應該應對那種變化封閉做出選擇。他必須先猜測出來最有可能變化的種類,然後構造抽象來隔離那些變化。但是我們是很難進行預先的猜測,這樣要求我們等到變化發生時立即採取行動,當發生變化時,我們就建立抽象來隔離以後發生的同類變化

開閉原則是物件導向設計的核心所在,遵循這個原則可以帶來物件導向技術所聲稱的巨大好處,也就是可維護、可擴充套件、可複用、靈活性好。

其實,我們遵循設計模式前面5大原則,以及使用23種設計模式的目的就是遵循開閉原則。也就是說,只要我們對前面5項原則遵守的好了,設計出的軟體自然是符合開閉原則的,這個開閉原則更像是前面五項原則遵守程度的“平均得分”,前面5項原則遵守的好,平均分自然就高,說明軟體設計開閉原則遵守的好;如果前面5項原則遵守的不好,則說明開閉原則遵守的不好。

再回想一下前面說的5項原則,恰恰是告訴我們用抽象構建框架,用實現擴充套件細節的注意事項而已:單一職責原則告訴我們實現類要職責單一;里氏替換原則告訴我們不要破壞繼承體系;依賴倒置原則告訴我們要面向介面程式設計;介面隔離原則告訴我們在設計介面的時候要精簡單一;迪米特法則告訴我們要降低耦合。而開閉原則是總綱,他告訴我們要對擴充套件開放,對修改關閉。

最後說明一下如何去遵守這六個原則。對這六個原則的遵守並不是是和否的問題,而是多和少的問題,也就是說,我們一般不會說有沒有遵守,而是說遵守程度的多少。任何事都是過猶不及,設計模式的六個設計原則也是一樣,制定這六個原則的目的並不是要我們刻板的遵守他們,而需要根據實際情況靈活運用。對他們的遵守程度只要在一個合理的範圍內,就算是良好的設計。我們用一幅圖來說明一下。

圖片來源網路

圖中的每一條維度各代表一項原則,我們依據對這項原則的遵守程度在維度上畫一個點,則如果對這項原則遵守的合理的話,這個點應該落在紅色的同心圓內部;如果遵守的差,點將會在小圓內部;如果過度遵守,點將會落在大圓外部。一個良好的設計體現在圖中,應該是六個頂點都在同心圓中的六邊形。

圖片來源網路

在上圖中,設計1、設計2屬於良好的設計,他們對六項原則的遵守程度都在合理的範圍內;設計3、設計4設計雖然有些不足,但也基本可以接受;設計5則嚴重不足,對各項原則都沒有很好的遵守;而設計6則遵守過渡了,設計5和設計6都是迫切需要重構的設計。

相關文章