菜鳥成長系列-物件導向的6種設計原則

glmapper發表於2017-12-10

菜鳥成長系列拖了一週多了,今天繼續之前的思路來進行。按照之前的規劃,這篇主要來學習設計原則先關知識。通過本文學習,希望大家一方面能是能夠認識這些原則是什麼,能夠在日常的開發中起到怎樣的約束,並且用這些原則來提高程式碼的複用性和可維護性,另一方面是對後續的設計模式的學習能夠有一些基礎。

菜鳥成長系列-概述
菜鳥成長系列-物件導向的四大基礎特性
菜鳥成長系列-多型、介面和抽象類


設計原則,在java與模式這本書中有提到,用於提高系統可維護性的同時,也提高系統的可複用性。這本書中主要講了六種設計原則:

  • “開-閉”原則
  • 里氏替換原則
  • 依賴倒置原則
  • 介面隔離原則
  • 單一職責原則
  • 迪特米法則

這些設計原則首先都是複用的原則,遵循這些原則可以有效的提高系統的複用性,同時也提高了系統的可維護性。

“開-閉”原則

網上看到一個人的解釋,他是這樣來比喻的:一個本子,已經寫完了,你不可能撕幾張紙粘上去吧,最好的辦法是買個新的。
道理就是這樣,一個已經做好的程式,不支援修改的,因為修改的話,有可能造成程式無法執行或報錯,所以,通常程式只支援擴充套件,不支援修改。

  • 1.為什麼會有這樣一個原則來作為程式設計的一種約束呢?
    在軟體的生命週期內,由於軟體功能或者結構的變化、升級和維護等原因需要對軟體原有程式碼進行修改,在修改的過程中可能會給舊程式碼中引入錯誤,也可能會使我們不得不對整個功能進行重構,並且還需要進行軟體的重新測試,因此我們希望在軟體設計之初,能夠用一種原則來進行一些基本的約束,使得在軟體後期的功能變更、擴充套件或者維護更加容易
  • 2.開閉原則解決的問題是什麼?
    當軟體需要進行改變時,我們應該儘量通過擴充套件軟體實體的行為來實現變化,而不是通過修改已有的程式碼來實現變化。通過這樣一種原則,可以很好的實現在保證原有功能穩定的前提下擴充套件新的功能
  • 3.什麼是開閉原則呢?
    一個軟體實體(類、模組或函式)應當對擴充套件開放,對修改關閉。也就是說在擴充套件或者修改軟體功能時,應儘量在不修改原有程式碼的情況下進行

舉個簡單的栗子:現在有這樣一個需求,系統需要通過QQ來進行驗證登入。OK,我們來擼程式碼:

  • 使用者類User
package com.glmapper.framerwork;
/**
 * 使用者資訊類
 * @author glmapper
 * @date 2017年12月9日下午10:54:09
 *
 */
public class User {
	private String userName;//使用者名稱
	private String passWord;//密碼
	public String getUserName() {
		return userName;
	}
	public void setUserName(String userName) {
		this.userName = userName;
	}
	public String getPassWord() {
		return passWord;
	}
	public void setPassWord(String passWord) {
		this.passWord = passWord;
	}
}

複製程式碼
  • QQ核心驗證邏輯
package com.glmapper.framerwork;
/**
 * QQ驗證器
 * @author glmapper
 * @date 2017年12月9日下午10:49:24
 */
public class QQAuther {
	/**
	 * 用於驗證QQ登入資訊
	 */
    public boolean validateQQ(User user)
    {
        //模擬下邏輯
        return user.toString()==null?false:true;
    }
}

複製程式碼
  • 核心驗證服務類
package com.glmapper.framerwork;
/**
 * 
 * 用於驗證的核心服務
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 *
 */
public class AuthService {
	//持有一個QQ驗證器物件
	private QQAuther qqAuther;
	//通過構造器注入qqAuther物件
	public AuthService(QQAuther qqAuther) {
		this.qqAuther = qqAuther;
	}
	/*
	 * 驗證使用者合法性
	 */
	public boolean validateUser(User user){
		return qqAuther.validateQQ(user);
	}
}

複製程式碼
  • 客戶端
package com.glmapper.framerwork;
/**
 * 客戶端呼叫驗證
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	
	public static void main(String[] args) {
		//獲取使用者資訊
		User user = UserHolder.getUser();
		QQAuther qqAuther = new QQAuther();
		AuthService authService = new AuthService(qqAuther);
		//獲取驗證結果
		boolean isOK = authService.validateUser(user);
		System.out.println(isOK);
	}
}

複製程式碼

OK,完事了!但是現在需要接入微博的開放平臺介面;修改程式碼...。 增加一個微博驗證器:

package com.glmapper.framerwork;
/**
 * 微博核心驗證器
 * @author glmapper
 * @date 2017年12月9日下午11:01:10
 */
public class WeiBoAuther {
	/**
	 * 用於驗證QQ登入資訊
	 */
    public boolean validateWeiBo(User user)
    {
        return user.toString()==null?false:true;
    }
}

複製程式碼

核心驗證服務修改:

package com.glmapper.framerwork;
/**
 * 
 * 用於驗證的核心服務
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 *
 */
public class AuthService {
	//持有一個QQ驗證器物件
	private Object obj;
	//通過構造器注入qqAuther物件
	public AuthService(Object obj) {
		this.obj = obj;
	}
	/*
	 * 驗證使用者合法性
	 */
	public boolean validateUser(User user){
	    //這裡僅作為模擬,一般情況下會通過使用定義列舉&工廠模式來完成
		if (obj instanceof QQAuther) {
			return new QQAuther().validateQQ(user);
		}
		if(obj instanceof WeiBoAuther){
			return new WeiBoAuther().validateWeiBo(user);
		}
		return false;
	}
}

複製程式碼

客戶端改變:

package com.glmapper.framerwork;
/**
 * 客戶端呼叫驗證
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	
	public static void main(String[] args) {
		//獲取使用者資訊
		User user = UserHolder.getUser();
		
		//QQ
		QQAuther qqAuther = new QQAuther();
		boolean isQQOK = new AuthService(qqAuther).validateUser(user);
		System.out.println(isQQOK);
		
		
		//微博
		WeiBoAuther weiBoAuther = new WeiBoAuther();
		boolean isWeiBoOK = new AuthService(weiBoAuther).validateUser(user);
		System.out.println(isWeiBoOK);
	}
}
複製程式碼

OK,改進完成!但是又有新的需求,接入微信....。假如我們現在把微信開放平臺也接入了,然後又來需求要接入支付寶賬戶、蘇寧易購賬戶等等。。。就需要不斷的修改程式碼。那麼這個時候就需要在設計之初用到我們的開閉原則來做一個約束了。繼續擼:
首先我們需要需要定義一個介面用於約束:

  • 驗證器介面,用於被QQ/WEIBO/微信/蘇寧易購等開發平臺驗證器實現
package com.glmapper.framerwork;
/**
 * 定義一個約束介面 
 * @author glmapper
 * @date 2017年12月9日下午11:32:32
 *
 */
public interface ValidateInteface {
	/**
	 * 提供一個驗證入口
	 */
	boolean validate(User user);
}

複製程式碼
  • QQ修改之後
package com.glmapper.framerwork;
/**
 * QQ驗證器
 * @author glmapper
 * @date 2017年12月9日下午10:49:24
 */
public class QQAuther implements ValidateInteface{
	/**
	 * 用於驗證QQ登入資訊
	 */
	@Override
	public boolean validate(User user) {
		return user.toString()==null?false:true;
	}
}

複製程式碼
  • 微博修改之後
package com.glmapper.framerwork;
/**
 * 微博核心驗證器
 * @author glmapper
 * @date 2017年12月9日下午11:01:10
 */
public class WeiBoAuther implements ValidateInteface{
	/**
	 * 用於驗證QQ登入資訊
	 */
	@Override
	public boolean validate(User user) {
		// TODO Auto-generated method stub
		 return user.toString()==null?false:true;
	}
}
複製程式碼
  • 核心驗證服務
package com.glmapper.framerwork;
/**
 * 用於驗證的核心服務
 * @author glmapper
 * @date 2017年12月9日下午10:47:04
 */
public class AuthService {
	//持有一個QQ驗證器物件
	private ValidateInteface validate;
	//通過構造器注入qqAuther物件
	public AuthService(ValidateInteface validate) {
		this.validate = validate;
	}
	/*
	 * 驗證使用者合法性
	 */
	public boolean validateUser(User user){
		return validate.validate(user);
	}
}

複製程式碼
  • 客戶端
package com.glmapper.framerwork;
/**
 * 客戶端呼叫驗證
 * @author glmapper
 * @date 2017年12月9日下午10:50:13
 *
 */
public class AuthClient {
	public static void main(String[] args) {
		//獲取使用者資訊
		User user = UserHolder.getUser();
		//QQ
		ValidateInteface qqAuther = new QQAuther();
		boolean isQQOK = new AuthService(qqAuther).validateUser(user);
		System.out.println(isQQOK);
		//微博
		ValidateInteface weiBoAuther = new WeiBoAuther();
		boolean isWeiBoOK = new AuthService(weiBoAuther).validateUser(user);
		System.out.println(isWeiBoOK);
	}
}

複製程式碼

改進之後我們可以發現,對於原來的核心驗證服務類、各驗證器類,無論增加什麼方式接入,我們都不需要去修改它的程式碼了。而此時我們需要做的就是新增一個驗證器(例如蘇寧易購驗證器),然後繼承ValidateInterface介面就行了。總體來首,開閉原則的核心是:

  • 抽象化
  • 對可變性的封裝原則(1.不可變性不應該散落在程式碼的多處,而應當被封裝到一個物件裡面;2.一種可變性不應當與另外一種可變性混合在一起)

(大家如果有更簡單暴力的例子,可以留言;這個例子想了很多都感覺不是很恰當,還是從工作中抽象出來的)。

里氏替換原則

任何父類可以出現的地方,子類一定可以出現
里氏替換原則算是對“開閉”原則的補充,上面也提到,實現“開閉”原則的關鍵步驟是抽象化,而父類與子類的繼承關係就是抽象化的一種具體體現,所以里氏替換原則是對實現抽象化的具體步驟的規範。

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

下圖中描述了一種繼承關係,從最高層的動物一直衍生出具體的動物。OK,寫一段斷碼來看看:

菜鳥成長系列-物件導向的6種設計原則

  • 頂層抽象父類-Animal
package com.glmapper.framework.model.lsp;
/**
 * 頂層抽象父類動物類
 * @author glmapper
 * @date 2017年12月10日上午10:51:30
 */
public abstract class Animal {
	//提供一個抽象方法,以供不同子類來進行具體的實現
	public abstract void eatFood(String foodName);
}
複製程式碼
  • 具體動物型別-Dog
 package com.glmapper.framework.model.lsp;
/**
 *子類-小狗
 * @author glmapper
 * @date 2017年12月10日上午10:54:17
 *
 */
public class Dog extends Animal{
	@Override
	public void eatFood(String foodName) {
		System.out.println("小狗吃"+foodName);
	}
}
複製程式碼
  • 具體動物-哈士奇
 package com.glmapper.framework.model.lsp;
/**
 * 具體小狗的種類-子類哈士奇
 * @author glmapper
 * @date 2017年12月10日上午10:56:59
 *
 */
public class HSQDog extends Dog{
	/**
	 * 重寫父類方法
	 */
	@Override
	public void eatFood(String foodName) {
		System.out.println("哈士奇吃"+foodName);
	}
}
複製程式碼
  • 客戶端
package com.glmapper.framework.model.lsp;
//客戶端程式
public class ClientMain {
	public static void main(String[] args) {
		//子類
		HSQDog hsqdog=new HSQDog();
		hsqdog.eatFood("餅乾");
		//父類
		Dog dog = new HSQDog();
		dog.eatFood("餅乾");
		//頂層父類
		Animal animal = new HSQDog();
		animal.eatFood("餅乾");
	}
}
複製程式碼
  • 執行結果
哈士奇吃餅乾
哈士奇吃餅乾
哈士奇吃餅乾
複製程式碼

可以看出我們最開始說的那句話任何父類可以出現的地方,子類一定可以出現,反過來是不成立的。我的理解是子類通過整合獲取的父類的屬性和行為,並且子類自身也具有自己的屬性和行為;父類可以出現的地方必然是需要用到父類的屬性或者行為,而子類都涵蓋了父類的這些資訊,因此可以做到替換。反過來不行是因為父類在上述的例子中只是充當了一種型別約束,它可能不具有子類的某些特徵,因此就無法做到真正的替換。

里氏替換原則是繼承複用的基石,只有當子類可以替換掉基類,軟體單位的功能不會受到影響時,基類才能被真正的複用,而子類也才能夠在基類的基礎上增加新的功能。

依賴倒轉原則

實現“開閉”原則的關鍵是抽象化,並且從抽象化匯出具體化實現。如果說開閉原則是物件導向設計的目標的話,依賴倒轉原則就是物件導向設計的主要機制(java與模式)。
依賴倒轉原則:要依賴與抽象,不依賴於具體實現。

怎麼理解呢?

  • 1)高層模組不應該直接依賴於底層模組的具體實現,而應該依賴於底層的抽象。換言之,模組間的依賴是通過抽象發生,實現類之間不發生直接的依賴關係,其依賴關係是通過介面或抽象類產生的。

  • 2)介面和抽象類不應該依賴於實現類,而實現類依賴介面或抽象類。這一點其實不用多說,很好理解,“面向介面程式設計”思想正是這點的最好體現

首先是第一點,從複用的角度來說,高層次的模組是設計者應當複用的。但是在傳統的過程性的設計中,複用卻側重於具體層次模組的複用。比如演算法的複用,資料結構的複用,函式庫的複用等,都不可避免是具體層次模組裡面的複用。較高層次的結構依賴於較低層次的結構,然後較低層次的結構又依賴於更低層次的結構,直到依賴到每一行程式碼為止。然後對低層次修改也會逐層修改,一直到最高層的設計模組中。

對於一個系統來說,一般抽象層次越高,它的穩定性就越好,因此也是作為複用的重點

“倒轉”,實際上就是指複用應當將複用的重點放在抽象層上,如果抽象層次的模組相對獨立於具體層次模組的話,那麼抽象層次的模組的複用便是相對較為容易的了。

菜鳥成長系列-物件導向的6種設計原則
在很多情況下,一個java程式需要引用一個物件,如果這個物件有一個抽象型別的話,應當使用這個抽象型別作為變數的靜態型別。 在上面我們畫了動物和小狗的類圖關係,在客戶端呼叫的時候有三種方式:

//子類(方式1)
HSQDog hsqdog=new HSQDog();
hsqdog.eatFood("餅乾");
//父類(方式2)
Dog dog = new HSQDog();
dog.eatFood("餅乾");
//頂層父類(方式3)
Animal animal = new HSQDog();
animal.eatFood("餅乾");
複製程式碼

如果我們需要一個哈士奇(HSQDog)的話,我們不應當使用方式1,而是應當使用方式2或者方式3。

介面隔離原則

介面隔離原則:使用多個專門的介面比使用單一的總介面要好。換句話說,從一個客戶類的角度來講:一個類對另外一個類的依賴性應當是建立在最小的介面上的。 這個其實在我們實際的開發中是經常遇到的。比如我們需要編寫一個完成一個產品的一些操作介面。

package com.glmapper.framework.model.isp;
/**
 * 一個產品服務介面
 * @author glmapper
 * @date 2017年12月10日下午12:01:31
 */
public interface ProductService {
	//增加產品
	public int addProduct(Product p);
	//刪除產產品
	public int deleteProduct(int pId);
	//修改產品
	public int updateProduct(Product p);
	//查詢一個產品
	public Product queryProduct(int pId);
}
複製程式碼

OK,我們在ProductService中提供了對產品的增刪改查;但是隨著需求升級,我們需要可以增加對產品新的批量匯入和匯出。OK,這時在介面中繼續新增兩個方法:

//從excel中批量匯入
public void batchImportFromExcel();
//從excel中批量導匯出
public void batchExportFromExcel();
複製程式碼

然後需求又需要擴充套件,需要增加增加購買產品、產品訂單生產、查詢訂單、訂單詳情....;這樣一來,我們的ProductService就會慢慢的急速膨脹。與此對應的具體的實現邏輯ProductServiceImpl類也會變得非常的龐大,可能單類會超過數千行程式碼。

那麼我們就需要進行介面隔離,將產品的基本操作如增刪改查放在一個介面,將產品訂單處理放在一個介面,將產品申購放在一個介面,將批量操作放在一個介面等等...對於每一個介面我們只關心某一類特定的職責,這個其實就是和單一職責原則有點掛鉤了。 通過這種設計,降低了單個介面的複雜度,使得介面的“內聚性”更高,“耦合性”更低。由此可以看出介面隔離原則的必要性。

迪特米法則

迪特米法則:又稱為最少知識原則,就是說一個物件應當對其他物件儘可能少的瞭解;看下迪特米法則的幾種表述:
1.只與你直接的朋友們通訊
2.不跟陌生人說話
3.每一個軟體單位對其他的單位都只有最少知識,而且侷限於那些與本單位密切相關的軟體單位

也就是說,如果兩個雷不必彼此直接通訊,那麼這兩個類就不應當發生直接的相互作用。如果其中一個類需要電泳另一個類的某一個方法的話,可以通過第三者進行訊息的轉發。程式碼看下:

  • 某個人
package com.glmapper.framework.model.isp;
/**
 * 某個人
 * @author glmapper
 * @date 2017年12月10日下午12:39:45
 */
public class SomeOne {
	//具體oprateion行為
	public void oprateion(Friend friend){
		Stranger stranger =friend.provide();
		stranger.oprateion3();
	}
}
SomeOne具有一個oprateion方法,該方法接受Friend為引數,根據上面的定義可以知道Friend是SomeOne的“朋友”(直接通訊了)
複製程式碼
  • 朋友
package com.glmapper.framework.model.isp;
/**
 * 朋友
 * @author glmapper
 * @date 2017年12月10日下午12:40:09
 */
public class Friend {
	private Stranger stranger = new Stranger();
	public Stranger provide(){
		return stranger;
	}
	public void opration2(){
	}
}
很明顯SomeOne的opration方法不滿足迪特米法則,因為這個方法中涉及到了陌生人Stranger,Stranger不是SomeOne的朋友
複製程式碼

OK,我們來通過迪特米法則進行改造。

  • 改造之後的SomeOne
package com.glmapper.framework.model.isp;
/**
 * 某個人
 * @author glmapper
 * @date 2017年12月10日下午12:39:45
 *
 */
public class SomeOne {
	//具體oprateion行為
	public void oprateion(Friend friend){
		friend.forward();
	}
}
複製程式碼
  • 改造之後的朋友
package com.glmapper.framework.model.isp;
/**
 * 朋友
 * @author glmapper
 * @date 2017年12月10日下午12:40:09
 *
 */
public class Friend {
	private Stranger stranger = new Stranger();
	public void opration2(){
		
	}
	//進行轉發
	public void forward() {
		stranger.oprateion3();
	}
}
複製程式碼

由於呼叫了轉發,因此SomeOne中就不會和陌生人Stranger直接的關係就被忽略了。滿足了直接和朋友通訊、不與陌生人說話的條件。
但是迪特米法則帶來的問題也是很明顯的:即會在系統中造出大量的小方法散落在系統的各個角落,這些方法僅僅是傳遞訊息的呼叫,與系統的業務邏輯沒有任何關係。

單一職責

上面在介面隔離中有提到過,單一職責其實很好理解,解釋儘量的使得我們的每一個類或者介面只完成本職工作以內的事情,不參與其他任何邏輯。比如說蘋果榨汁機我就只用來榨蘋果汁,如果你需要榨黃瓜汁的話,你就得買一個黃瓜榨汁機。

總結

OK ,至此,設計原則部分就複習完了。總結一下:

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

大家週末愉快!(如果有不當之處,希望大家及時指出,多謝!)

相關文章