設計模式概念和七大原則


什么是設計模式

在GoF(Gang of Four)的書籍《Design Patterns - Elements of Reusable Object-Oriented Software(設計模式-可復用面向對象軟件的基礎)》中是這樣定義設計模式的:Christopher Alexander說過:“每一個模式描述了一個在我們周圍不斷重復發生的問題以及該問題的解決方案的核心。這樣,你就能一次又一次地使用該方案而不必做重復勞動” [AIS+77,第10頁]。盡管Alexander所指的是城市和建築模式,但他的思想也同樣適用於於面向對象設計模式,只是在面向對象的解決方案里, 我們喲偶那個對象和接口代替了牆壁和門窗。兩類模式的核心都在於提供了相關問題的解決方案。一般而言,設計模式有四個基本要素:

  • 1、模式名稱(pattern name):一個助記名,它用一兩個詞來描述模式的問題、解決方案和效果。
  • 2、問題(problem):描述了應該在何時使用模式。
  • 3、解決方案(solution):描述了設計的組成成分,它們之間的相關關系以及各自的職責和協作方案。
  • 4、效果(consequences):描述了模式應用的效果以及使用模式應該權衡的問題。

設計模式的創始人很明確地指出了設計模式的基本要素,但是由於現實中浮躁、偏向過度設計等因素的干擾,開發者很多時候會重點關注第1和第3點要素(過度關注設計模式和設計模式的實現),忽略第2和第4點要素(忽視使用設計模式的場景和目標),導致設計出來的編碼邏輯可能過於復雜或者達不到預期的效果。

總的來說,設計模式(Design Pattern)是一套被反復使用、多數人知曉的、經過分類編目的、代碼設計經驗的總結。也就是本來並不存在所謂設計模式,用的人多了,也便成了設計模式。

設計模式的七大原則

面向對象的設計模式有七大基本原則:

  • 開閉原則(Open Closed Principle,OCP)
  • 單一職責原則(Single Responsibility Principle, SRP)
  • 里氏代換原則(Liskov Substitution Principle,LSP)
  • 依賴倒轉原則(Dependency Inversion Principle,DIP)
  • 接口隔離原則(Interface Segregation Principle,ISP)
  • 合成/聚合復用原則(Composite/Aggregate Reuse Principle,CARP)
  • 最少知識原則(Least Knowledge Principle,LKP)或者迪米特法則(Law of Demeter,LOD)
設計模式原則名稱 簡單定義
開閉原則 對擴展開放,對修改關閉
單一職責原則 一個類只負責一個功能領域中的相應職責
里氏代換原則 所有引用基類的地方必須能透明地使用其子類的對象
依賴倒轉原則 依賴於抽象,不能依賴於具體實現
接口隔離原則 類之間的依賴關系應該建立在最小的接口上
合成/聚合復用原則 盡量使用合成/聚合,而不是通過繼承達到復用的目的
迪米特法則 一個軟件實體應當盡可能少的與其他實體發生相互作用

這個表格看起來有點抽象,下面逐條分析。

開閉原則

開閉原則(Open Closed Principle,OCP)的定義是:一個軟件實體如類、模塊和函數應該對擴展開放,對修改關閉。模塊應盡量在不修改原(是"原",指原來的代碼)代碼的情況下進行擴展。

開閉原則的意義:

在軟件的生命周期內,因為變化、升級和維護等原因需要對軟件原有代碼進行修改時,可能會給舊代碼中引入錯誤,也可能會使我們不得不對整個功能進行重構,並且需要原有代碼經過重新測試。當軟件需要變化時,盡量通過擴展軟件實體的行為來實現變化,而不是通過修改已有的代碼來實現變化。

如何實現對擴展開放,對修改關閉?

要實現對擴展開放,對修改關閉,即遵循開閉原則,需要對系統進行抽象化設計,抽象可以基於抽象類或者接口。一般來說需要做到幾點:

  • 1、通過接口或者抽象類約束擴展,對擴展進行邊界限定,不允許出現在接口或抽象類中不存在的public方法,也就是擴展必須添加具體實現而不是改變具體的方法。
  • 2、參數類型、引用對象盡量使用接口或者抽象類,而不是實現類,這樣就能盡量保證抽象層是穩定的。
  • 3、一般抽象模塊設計完成(例如接口的方法已經敲定),不允許修改接口或者抽象方法的定義。

下面通過一個例子遵循開閉原則進行設計,場景是這樣:某系統的后台需要監測業務數據展示圖表,如柱狀圖、折線圖等,在未來需要支持圖表的着色操作。在開始設計的時候,代碼可能是這樣的:

public class BarChart {

	public void draw(){
		System.out.println("Draw bar chart...");
	}
}

public class LineChart {

	public void draw(){
		System.out.println("Draw line chart...");
	}
}

public class App {

	public void drawChart(String type){
		if (type.equalsIgnoreCase("line")){
			new LineChart().draw();
		}else if (type.equalsIgnoreCase("bar")){
			new BarChart().draw();
		}
	}
}

這樣做在初期是能滿足業務需要的,開發效率也十分高,但是當后面需要新增一個餅狀圖的時候,既要添加一個餅狀圖的類,原來的客戶端App類的drawChart方法也要新增一個if分支,這樣做就是修改了原有客戶端類庫的方法,是十分不合理的。如果這個時候,在圖中加入一個顏色屬性,復雜性也大大提高。基於此,需要引入一個抽象Chart類AbstractChart,App類在畫圖的時候總是把相關的操作委托到具體的AbstractChart的派生類實例,這樣的話App類的代碼就不用修改:

public abstract class AbstractChart {

	public abstract void draw();
}

public class BarChart extends AbstractChart{

	@Override
	public void draw() {
		System.out.println("Draw bar chart...");
	}
}

public class LineChart extends AbstractChart {

	@Override
	public void draw() {
		System.out.println("Draw line chart...");
	}
}

public class App {

	public void drawChart(AbstractChart chart){
		chart.draw();
	}
}

如果新加一種圖,只需要新增一個AbstractChart的子類即可。客戶端類App不需要改變原來的邏輯。修改后的設計符合開閉原則,因為整個系統在擴展時原有的代碼沒有做任何修改。

單一職責原則

單一職責原則(Single Responsibility Principle, SRP)的定義是:指一個類或者模塊應該有且只有一個改變的原因。如果一個類承擔的職責過多,就等於把這些職責耦合在一起了。一個職責的變化可能會削弱或者抑制這個類完成其他職責的能力。這種耦合會導致脆弱的設計,當發生變化時,設計會遭受到意想不到的破壞。而如果想要避免這種現象的發生,就要盡可能的遵守單一職責原則。此原則的核心就是解耦和增強內聚性。

單一職責原則的意義:

單一職責原則告訴我們:一個類不能做太多的東西。在軟件系統中,一個類(一個模塊、或者一個方法)承擔的職責越多,那么其被復用的可能性就會越低。一個很典型的例子就是萬能類。其實可以說一句大實話:任何一個常規的MVC項目,在極端的情況下,可以用一個類(甚至一個方法)完成所有的功能。但是這樣做就會嚴重耦合,甚至牽一發動全身。一個類承(一個模塊、或者一個方法)擔的職責過多,就相當於將這些職責耦合在一起,當其中一個職責變化時,可能會影響其他職責的運作,因此要將這些職責進行分離,將不同的職責封裝在不同的類中,即將不同的變化原因封裝在不同的類中,如果多個職責總是同時發生改變則可將它們封裝在同一類中。

不過說實話,其實有的時候很難去衡量一個類的職責,主要是很難確定職責的粒度。這一點不僅僅體現在一個類或者一個模塊中,也體現在采用微服務的分布式系統中。這也就是為什么我們在實施微服務拆分的時候經常會撕逼:"這個功能不應該發在A服務中,它不做這個領域的東西,應該放在B服務中"諸如此類的爭論。存在爭論是合理的,不過最好不要不了了之,而應該按照領域定義好每個服務的職責(職責的粒度最好找業務和架構專家咨詢),得出相對合理的職責分配。

下面通過一個很簡單的實例說明一下單一職責原則:

在一個項目系統代碼編寫的時候,由於歷史原因和人為的不規范,導致項目沒有分層,一個Service類的偽代碼是這樣的:

public class Service {
	
	public UserDTO findUser(String name){
		Connection connection = getConnection();
		PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM t_user WHERE name = ?");
		preparedStatement.setObject(1, name);
        User user = //處理結果
        UserDTO dto = new UserDTO();
        //entity值拷貝到dto
        return dto;
	}
}

這里出現一個問題,Service做了太多東西,包括數據庫連接的管理,Sql的執行這些業務層不應該接觸到的邏輯,更可怕的是,例如到時候如果數據庫換成了Oracle,這個方法將會大改。因此,拆分出新的DataBaseUtils類用於專門管理數據庫資源,Dao類用於專門執行查詢和查詢結果封裝,改造后Service類的偽代碼如下:

public class Service {

    private Dao dao;
	
	public UserDTO findUser(String name){
       User user =  dao.findUserByName(name);
       UserDTO dto = new UserDTO();
        //entity值拷貝到dto
       return dto;
    }
}


public class Dao{

    public User findUserByName(String name){
       Connection connection = DataBaseUtils.getConnnection();
       PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM t_user WHERE name = ?");
		preparedStatement.setObject(1, name);
        User user = //處理結果
        return user;
    }
}

現在,如果有查詢封裝的變動只需要修改Dao類,數據庫相關變動只需要修改DataBaseUtils類,每個類的職責分明。這個時候,如果我們要把底層的存儲結構緩成Redis或者MongoDB怎么辦,這樣顯然要重建整個Dao類,這種情況下,需要進行接口隔離,下面分析接口隔離原則的時候再詳細分析。

里氏代換原則

里氏代換原則(Liskov Substitution Principle,LSP)的定義是:所有引用基類的地方必須能透明地使用其子類的對象,也可以簡單理解為任何基類可以出現的地方,子類一定可以出現。

里氏代換原則的意義:

只有當衍生類可以替換掉基類,軟件單位的功能不受到影響時,基類才能真正被復用,而衍生類也能夠在基類的基礎上增加新的行為。里氏代換原則是對"開-閉"原則的補充。實現"開-閉"原則的關鍵步驟就是抽象化。而基類與子類的繼承關系就是抽象化的具體實現,所以里氏代換原則是對實現抽象化的具體步驟的規范。當然,如果反過來,軟件單位使用的是一個子類對象的話,那么它不一定能夠使用基類對象。舉個很簡單的例子說明這個問題:如果一個方法接收Map類型參數,那么它一定可以接收Map的子類參數例如HashMap、LinkedHashMap、ConcurrentHashMap類型的參數;但是返過來,如果另一個方法只接收HashMap類型的參數,那么它一定不能接收所有Map類型的參數,否則它可以接收LinkedHashMap、ConcurrentHashMap類型的參數。

子類為什么可以替換基類的位置?

其實原因很簡單,只要存在繼承關系,基類的所有非私有屬性或者方法,子類都可以通過繼承獲得(白箱復用),反過來不成立,因為子類很有可能擴充自身的非私有屬性或者方法,這個時候不能用基類獲取子類新增的這些屬性或者方法。

里氏代換原則是實現開閉原則的基礎,它告訴我們在設計程序的時候進可能使用基類進行對象的定義和引用,在運行時再決定基類的具體子類型。

舉個簡單的例子,假設一種會呼吸的動物作為父類,子類豬和鳥也有自身的呼吸方式:

public abstract class Animal {

	protected abstract void breathe();
}

public class Bird extends Animal {

	@Override
	public void breathe() {
		System.out.println("Bird breathes...");
	}
}

public class Pig extends Animal {

	@Override
	public void breathe() {
		System.out.println("Pig breathes...");
	}
}

public class App {

	public static void main(String[] args) throws Exception {
		Animal bird = new Bird();
		bird.breathe();
		Animal pig = new Pig();
		pig.breathe();
	}
}    

依賴倒轉原則

依賴倒轉原則(Dependency Inversion Principle,DIP)的定義:程序要依賴於抽象接口,不要依賴於具體實現。簡單的說就是要求對抽象進行編程,不要對實現進行編程,這樣就降低了客戶與實現模塊間的耦合。

依賴倒轉原則的意義:

依賴倒轉原則要求我們在程序代碼中傳遞參數時或在關聯關系中,盡量引用層次高的抽象層類,即使用接口和抽象類進行變量類型聲明、參數類型聲明、方法返回類型聲明,以及數據類型的轉換等,而不要用具體類來做這些事情。為了確保該原則的應用,一個具體類應當只實現接口或抽象類中聲明過的方法,而不要給出多余的方法,否則將無法調用到在子類中增加的新方法。在引入抽象層后,系統將具有很好的靈活性,在程序中盡量使用抽象層進行編程,而將具體類寫在配置文件中,這樣一來,如果系統行為發生變化,只需要對抽象層進行擴展,並修改配置文件,而無須修改原有系統的源代碼,在不修改的情況下來擴展系統的功能,滿足開閉原則的要求。

依賴倒轉原則的注意事項:

  • 高層模塊不應該依賴低層模塊,高層模塊和低層模塊都應該依賴於抽象。
  • 抽象不應該依賴於具體,具體應該依賴於抽象。

在實現依賴倒轉原則時,我們需要針對抽象層編程,而將具體類的對象通過依賴注入(DependencyInjection, DI)的方式注入到其他對象中,依賴注入是指當一個對象要與其他對象發生依賴關系時,通過抽象來注入所依賴的對象。常用的注入方式有三種,分別是:構造注入,設值注入(Setter注入)和接口注入。Spring的IOC是此實現的典范。

從Java角度看待依賴倒轉原則的本質就是:面向接口(抽象)編程。

  • 每個具體的類都應該有其接口或者基類,或者兩者都具備。
  • 類中的引用對象應該是接口或者基類。
  • 任何具體類都不應該派生出子類。
  • 盡量不要覆寫基類中的方法。
  • 結合里氏代換原則使用。

遵循依賴倒轉原則的一個例子,場景是司機開車:

public interface Driver {

	void drive();

	void setCar(Car car);
}

public interface Car {

	void run();
}

public class DefaultDriver implements Driver {

	private Car car;

	@Override
	public void drive() {
		car.run();
	}

	@Override
	public void setCar(Car car) {
		this.car = car;
	}
}

public class Bmw implements Car {

	@Override
	public void run() {
		System.out.println("Bmw runs...");
	}
}

public class Benz implements Car {

	@Override
	public void run() {
		System.out.println("Benz runs...");
	}
}

public class App {

    public static void main(String[] args) throws Exception {
		Driver driver = new DefaultDriver();
		Car car = new Benz();
		driver.setCar(car);
		driver.drive();
		car = new Bmw();
		driver.setCar(car);
		driver.drive();
	}
}

這樣實現了一個司機可以開各種類型的車,如果還有其他類型的車,只需要新加一個Car的實現即可。

接口隔離原則

接口隔離原則(Interface Segregation Principle,ISP)的定義是客戶端不應該依賴它不需要的接口,類間的依賴關系應該建立在最小的接口上。簡單來說就是建立單一的接口,不要建立臃腫龐大的接口。也就是接口盡量細化,同時接口中的方法盡量少。

如何看待接口隔離原則和單一職責原則?

單一職責原則注重的是類和接口的職責單一,這里職責是從業務邏輯上划分的,但是在接口隔離原則要求當一個接口太大時,我們需要將它分割成一些更細小的接口,使用該接口的客戶端僅需知道與之相關的方法即可。也就是說,我們在設計接口的時候有可能滿足單一職責原則但是不滿足接口隔離原則。

接口隔離原則的規范:

  • 使用接口隔離原則前首先需要滿足單一職責原則。
  • 接口需要高內聚,也就是提高接口、類、模塊的處理能力,少對外發布public的方法。
  • 定制服務,就是單獨為一個個體提供優良的服務,簡單來說就是拆分接口,對特定接口進行定制。
  • 接口設計是有限度的,接口的設計粒度越小,系統越靈活,但是值得注意不能過小,否則變成"字節碼編程"。

如果有用過spring-data-redis的人就知道,RedisTemplate中持有一些列的基類,分別是ValueOperations(處理K-V)、ListOperations(處理Hash)、SetOperations(處理集合)等等。

public interface ValueOperations<K, V> {

    void set(K key, V value);
    void set(K key, V value, long timeout, TimeUnit unit);
    //....
}

合成/聚合復用原則

合成/聚合復用原則(Composite/Aggregate Reuse Principle,CARP)一般也叫合成復用原則(Composite Reuse Principle, CRP),定義是:盡量使用合成/聚合,而不是通過繼承達到復用的目的

合成/聚合復用原則就是在一個新的對象里面使用一些已有的對象,使之成為新對象的一部分;新的對象通過向內部持有的這些對象的委派達到復用已有功能的目的,而不是通過繼承來獲得已有的功能。

聚合(Aggregate)的概念:

聚合表示一種弱的"擁有"關系,一般表現為松散的整體和部分的關系,其實,所謂整體和部分也可以是完全不相關的。例如A對象持有B對象,B對象並不是A對象的一部分,也就是B對象的生命周期是B對象自身管理,和A對象不相關。

合成(Composite)的概念:

合成表示一種強的"擁有"關系,一般表現為嚴格的整體和部分的關系,部分和整體的生命周期是一樣的。

聚合和合成的關系:

這里用山羊舉例說明聚合和合成的關系:

dp-1.png

為什么要用合成/聚合來替代繼承達到復用的目的?

繼承復用破壞包裝,因為繼承將基類的實現細節暴露給派生類,基類的內部細節通常對子類來說是可見的,這種復用也稱為"白箱復用"。這里有一個明顯的問題是:派生類繼承自基類,如果基類的實現發生改變,將會影響到所有派生類的實現;如果從基類繼承而來的實現是靜態的,不可能在運行時發生改變,不夠靈活。

由於合成或聚合關系可以將已有的對象,一般叫成員對象,納入到新對象中,使之成為新對象的一部分,因此新對象可以調用已有對象的功能,這樣做可以使得成員對象的內部實現細節對於新對象不可見,所以這種復用又稱為"黑箱"復用,相對繼承關系而言,其耦合度相對較低,成員對象的變化對新對象的影響不大,可以在新對象中根據實際需要有選擇性地調用成員對象的操作;合成/聚合復用可以在運行時動態進行,新對象可以動態地引用與成員對象類型相同的其他對象。

如果有閱讀過《Effective Java 2nd》的同學就知道,此書也建議慎用繼承。一般情況下,只有明確知道派生類和基類滿IS A的時候才選用繼承,當滿足HAS A或者不能判斷的情況下應該選用合成/聚合。

下面舉個很極端的例子說明一下如果在非IS A的情況下使用繼承會出現什么問題:

先定義一個抽象手,手有一個搖擺的方法,然后定義左右手繼承抽象手,實現搖擺方法:

public abstract class AbstractHand {

	protected abstract void swing();
}

public class LeftHand extends AbstractHand {

	@Override
	public void swing() {
		System.out.println("Left hand swings...");
	}
}

public class RightHand extends AbstractHand {

	@Override
	public void swing() {
		System.out.println("Right hand swings...");
	}
}

現在看起來沒有任何問題,實現也十分正確,現在出現了人(Person)這個類,具備搖左右手的功能,如果不考慮IS A的關系,很有可能有人會這樣做:

public abstract class AbstractSwingHand extends AbstractHand{

	@Override
	protected void swing() {
		System.out.println(" hand swings...");
	}
}

public class Person extends AbstractSwingHand {

	public void swingLeftHand(){
		System.out.print("Left ");
		super.swing();
	}

	public void swingRightHand(){
		System.out.print("Right ");
		super.swing();
	}
}

上面Person的實現讓人覺得百思不得其解,但是往往這會出現在真實的環境中,因為Hand不是Person,所以Person繼承Hand一定會出現曲線實現等奇葩邏輯。Hand和Person是嚴格的部分和整體的關系,或者說Person和Hand是HAS A的關系,如果使用合成,邏輯將會十分清晰:

public class Person  {

	private AbstractHand leftHand;
	private AbstractHand rightHand;

	public Person() {
		leftHand = new LeftHand();
		rightHand = new RightHand();
	}

	public void swingLeftHand(){
		leftHand.swing();
	}

	public void swingRightHand(){
		rightHand.swing();
	}
}

這里使用了合成,說明了Person和AbstractHand實例的生命周期是一致的。

迪米特法則

迪米特法則(Law of Demeter,LOD),有時候也叫做最少知識原則(Least Knowledge Principle,LKP),它的定義是:一個軟件實體應當盡可能少地與其他實體發生相互作用。每一個軟件單位對其他的單位都只有最少的知識,而且局限於那些與本單位密切相關的軟件單位。迪米特法則的初衷在於降低類之間的耦合。由於每個類盡量減少對其他類的依賴,因此,很容易使得系統的功能模塊功能獨立,相互之間不存在(或很少有)依賴關系。迪米特法則不希望類之間建立直接的聯系。如果真的有需要建立聯系,也希望能通過它的友元類(中間類或者跳轉類)來轉達。

迪米特法則的規則:

  • Only talk to your immediate friends(只與直接的朋友通訊),一個對象的"朋友"包括他本身(this)、它持有的成員對象、入參對象、它所創建的對象。
  • 盡量少發布public的變量和方法,一旦公開的屬性和方法越多,修改的時候影響的范圍越大。
  • "是自己的就是自己的",如果一個方法放在本類中,既不產生新的類間依賴,也不造成負面的影響,那么次方法就應該放在本類中。

迪米特法則的意義:

迪米特法則的核心觀念就是類間解耦,也就降低類之間的耦合,只有類處於弱耦合狀態,類的復用率才會提高。所謂降低類間耦合,實際上就是盡量減少對象之間的交互,如果兩個對象之間不必彼此直接通信,那么這兩個對象就不應當發生任何直接的相互作用,如果其中的一個對象需要調用另一個對象的某一個方法的話,可以通過第三者轉發這個調用。簡言之,就是通過引入一個合理的第三者來降低現有對象之間的耦合度。但是這樣會引發一個問題,有可能產生大量的中間類或者跳轉類,導致系統的復雜性提高,可維護性降低。如果一味追求極度解耦,那么最終有可能變成面向字節碼編程甚至是面向二進制的0和1編程。

舉個很簡單的例子,體育老師要知道班里面女生的人數,他委托體育課代表點清女生的人數:

public class Girl {
	
}

public class GroupLeader {

	private final List<Girl> girls;

	public GroupLeader(List<Girl> girls) {
		this.girls = girls;
	}

	public void countGirls() {
		System.out.println("The sum of girls is " + girls.size());
	}
}

public class Teacher {

	public void command(GroupLeader leader){
		leader.countGirls();
	}
}

public class App {

	public static void main(String[] args) throws Exception {
		Teacher teacher = new Teacher();
		GroupLeader groupLeader = new GroupLeader(Arrays.asList(new Girl(), new Girl()));
		teacher.command(groupLeader);
	}
}

這個例子中,體育課代表就是中間類,體育課代表對於體育老師來說就是"直接的朋友",如果去掉體育課代表這個中間類,體育老師必須親自清點女生的人數(實際上就數人數這個功能,體育老師是不必要獲取所有女生的對象列表),這樣做會違反迪米特法則。

小結

說實話,設計模式的七大原則理解是比較困難的,我們在設計模式的學習和應用中經常會聽到或者看到"XXX模式符合XXX原則"、"YYY模式不符合YYY原則"這樣的語句。因此,為了分析設計模式的合理性和完善我們日常的編碼,掌握和理解這七大原則是十分必要的。

參考

  • 《Java設計模式》
  • 《設計模式之禪-2nd》
  • 《設計模式-可復用面向對象軟件的基礎》

(本文完)

技術公眾號(《Throwable文摘》),不定期推送筆者原創技術文章(絕不抄襲或者轉載):

娛樂公眾號(《天天沙雕》),甄選奇趣沙雕圖文和視頻不定期推送,緩解生活工作壓力:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM