設計模式之美 - 22 | 理論八:如何用迪米特法則(LOD)實現“高內聚、鬆耦合”?

這系列相關博客,參考 設計模式之美

設計模式之美 - 22 | 理論八:如何用迪米特法則(LOD)實現“高內聚、鬆耦合”?

今天,我們講最後一個設計原則:迪米特法則。儘管它不像 SOLID、KISS、DRY 原則那樣,人盡皆知,但它卻非常實用。利用這個原則,能夠幫我們實現代碼的“高內聚、鬆耦合”。今天,我們就圍繞下面幾個問題,並結合兩個代碼實戰案例,來深入地學習這個法則。

  • 什麼是“高內聚、鬆耦合”?
  • 如何利用迪米特法則來實現“高內聚、鬆耦合”?
  • 有哪些代碼設計是明顯違背迪米特法則的?對此又該如何重構?

話不多說,讓我們開始今天的學習吧!

何爲“高內聚、鬆耦合”?

“高內聚、鬆耦合”是一個非常重要的設計思想,能夠有效地提高代碼的可讀性和可維護性,縮小功能改動導致的代碼改動範圍。實際上,在前面的章節中,我們已經多次提到過這個設計思想。很多設計原則都以實現代碼的“高內聚、鬆耦合”爲目的,比如單一職責原則、基於接口而非實現編程等。

實際上,“高內聚、鬆耦合”是一個比較通用的設計思想,可以用來指導不同粒度代碼的設計與開發,比如系統、模塊、類,甚至是函數,也可以應用到不同的開發場景中,比如微服務、框架、組件、類庫等。爲了方便我講解,接下來我以“類”作爲這個設計思想的應用對象來展開講解,其他應用場景你可以自行類比。

在這個設計思想中,“高內聚”用來指導類本身的設計,“鬆耦合”用來指導類與類之間依賴關係的設計。不過,這兩者並非完全獨立不相干。高內聚有助於鬆耦合,鬆耦合又需要高內聚的支持。

那到底什麼是“高內聚”呢?

所謂高內聚,就是指相近的功能應該放到同一個類中,不相近的功能不要放到同一個類中。相近的功能往往會被同時修改,放到同一個類中,修改會比較集中,代碼容易維護。實際上,我們前面講過的單一職責原則是實現代碼高內聚非常有效的設計原則。對於這一點,你可以回過頭再去看下專欄的第 15 講。

我們再來看一下,什麼是“鬆耦合”?

所謂鬆耦合是說,在代碼中,類與類之間的依賴關係簡單清晰。即使兩個類有依賴關係,一個類的代碼改動不會或者很少導致依賴類的代碼改動。實際上,我們前面講的依賴注入、接口隔離、基於接口而非實現編程,以及今天講的迪米特法則,都是爲了實現代碼的鬆耦合。

最後,我們來看一下,“內聚”和“耦合”之間的關係

前面也提到,“高內聚”有助於“鬆耦合”,同理,“低內聚”也會導致“緊耦合”。關於這一點,畫了一張對比圖來解釋。圖中左邊部分的代碼結構是“高內聚、鬆耦合”;右邊部分正好相反,是“低內聚、緊耦合”。
在這裏插入圖片描述
圖中左邊部分的代碼設計中,類的粒度比較小,每個類的職責都比較單一。相近的功能都放到了一個類中,不相近的功能被分割到了多個類中。這樣類更加獨立,代碼的內聚性更好。因爲職責單一,所以每個類被依賴的類就會比較少,代碼低耦合。一個類的修改,只會影響到一個依賴類的代碼改動。我們只需要測試這一個依賴類是否還能正常工作就行了。

圖中右邊部分的代碼設計中,類粒度比較大,低內聚,功能大而全,不相近的功能放到了一個類中。這就導致很多其他類都依賴這個類。當我們修改這個類的某一個功能代碼的時候,會影響依賴它的多個類。我們需要測試這三個依賴類,是否還能正常工作。這也就是所謂的“牽一髮而動全身”。

除此之外,從圖中我們也可以看出,高內聚、低耦合的代碼結構更加簡單、清晰,相應地,在可維護性和可讀性上確實要好很多。

“迪米特法則”理論描述

迪米特法則的英文翻譯是:Law of Demeter,縮寫是 LOD。單從這個名字上來看,我們完全猜不出這個原則講的是什麼。不過,它還有另外一個更加達意的名字,叫作最小知識原則,英文翻譯爲:The Least Knowledge Principle。

關於這個設計原則,我們先來看一下它最原汁原味的英文定義:

Each unit should have only limited knowledge about other units: only units
“closely” related to the current unit. Or: Each unit should only talk to its
friends; Don’t talk to strangers.

我們把它直譯成中文,就是下面這個樣子:

每個模塊(unit)只應該瞭解那些與它關係密切的模塊(units: only units
“closely” related to the current unit)的有限知識(knowledge)。或者說,每
個模塊只和自己的朋友“說話”(talk),不和陌生人“說話”(talk)。

我們之前講過,大部分設計原則和思想都非常抽象,有各種各樣的解讀,要想靈活地應用到實際的開發中,需要有實戰經驗的積累。迪米特法則也不例外。所以,我結合我自己的理解和經驗,對剛剛的定義重新描述一下。注意,爲了統一講解,我把定義描述中的“模塊”替換成了“類”。

不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要
的接口(也就是定義中的“有限知識”)。

從上面的描述中,我們可以看出,迪米特法則包含前後兩部分,這兩部分講的是兩件事情,我用兩個實戰案例分別來解讀一下。

理論解讀與代碼實戰一

我們先來看這條原則中的前半部分,“不該有直接依賴關係的類之間,不要有依賴”。我舉個例子解釋一下。

這個例子實現了簡化版的搜索引擎爬取網頁的功能。代碼中包含三個主要的類。其中,NetworkTransporter 類負責底層網絡通信,根據請求獲取數據;HtmlDownloader 類用來通過 URL 獲取網頁;Document 表示網頁文檔,後續的網頁內容抽取、分詞、索引都是以此爲處理對象。具體的代碼實現如下所示:

public class NetworkTransporter {
	// 省略屬性和其他方法...
	public Byte[] send(HtmlRequest htmlRequest) {
		//...
	}
}
public class HtmlDownloader {
	private NetworkTransporter transporter;// 通過構造函數或 IOC 注入
	public Html downloadHtml(String url) {
		Byte[] rawHtml = transporter.send(new HtmlRequest(url));
		return new Html(rawHtml);
	}
}
public class Document {
	private Html html;
	private String url;
	public Document(String url) {
		this.url = url;
		HtmlDownloader downloader = new HtmlDownloader();
		this.html = downloader.downloadHtml(url);
	}
	//...
}

這段代碼雖然“能用”,能實現我們想要的功能,但是它不夠“好用”,有比較多的設計缺陷。你可以先試着思考一下,看看都有哪些缺陷,然後再來看我下面的講解。

首先,我們來看 NetworkTransporter 類。作爲一個底層網絡通信類,我們希望它的功能儘可能通用,而不只是服務於下載 HTML,所以,我們不應該直接依賴太具體的發送對象 HtmlRequest。從這一點上講,NetworkTransporter 類的設計違背迪米特法則,依賴了不該有直接依賴關係的 HtmlRequest 類。

我們應該如何進行重構,讓 NetworkTransporter 類滿足迪米特法則呢?我這裏有個形象的比喻。假如你現在要去商店買東西,你肯定不會直接把錢包給收銀員,讓收銀員自己從裏面拿錢,而是你從錢包裏把錢拿出來交給收銀員。這裏的 HtmlRequest 對象就相當於錢包,HtmlRequest 裏的 address 和 content 對象就相當於錢。我們應該把address 和 content 交給 NetworkTransporter,而非是直接把 HtmlRequest 交給NetworkTransporter。根據這個思路,NetworkTransporter 重構之後的代碼如下所示:

public class NetworkTransporter {
	// 省略屬性和其他方法...
	public Byte[] send(String address, Byte[] data) {
		//...
	}
}

我們再來看 HtmlDownloader 類。這個類的設計沒有問題。不過,我們修改了NetworkTransporter 的 send() 函數的定義,而這個類用到了 send() 函數,所以我們需要對它做相應的修改,修改後的代碼如下所示:

public class HtmlDownloader {
	private NetworkTransporter transporter;// 通過構造函數或 IOC 注入
	// HtmlDownloader 這裏也要有相應的修改
	public Html downloadHtml(String url) {
		HtmlRequest htmlRequest = new HtmlRequest(url);
		Byte[] rawHtml = transporter.send(htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
		return new Html(rawHtml);
	}
}

最後,我們來看下 Document 類。這個類的問題比較多,主要有三點。第一,構造函數中的 downloader.downloadHtml() 邏輯複雜,耗時長,不應該放到構造函數中,會影響代碼的可測試性。代碼的可測試性我們後面會講到,這裏你先知道有這回事就可以了。第二,HtmlDownloader 對象在構造函數中通過 new 來創建,違反了基於接口而非實現編程的設計思想,也會影響到代碼的可測試性。第三,從業務含義上來講,Document 網頁文檔沒必要依賴 HtmlDownloader 類,違背了迪米特法則。

雖然 Document 類的問題很多,但修改起來比較簡單,只要一處改動就可以解決所有問題。修改之後的代碼如下所示:

public class Document {
	private Html html;
	private String url;
	public Document(String url, Html html) {
		this.html = html;
		this.url = url;
	}
	//...
}
// 通過一個工廠方法來創建 Document
public class DocumentFactory {
	private HtmlDownloader downloader;
	public DocumentFactory(HtmlDownloader downloader) {
		this.downloader = downloader;
	}
	public Document createDocument(String url) {
		Html html = downloader.downloadHtml(url);
		return new Document(url, html);
	}
}

理論解讀與代碼實戰二

現在,我們再來看一下這條原則中的後半部分:“有依賴關係的類之間,儘量只依賴必要的接口”。我們還是結合一個例子來講解。下面這段代碼非常簡單,Serialization 類負責對象的序列化和反序列化。提醒你一下,有個類似的例子在之前的第 15 節課中講過,你可以結合着一塊兒看一下。

public class Serialization {
	public String serialize(Object object) {
		String serializedResult = ...;
		//...
		return serializedResult;
	}
	public Object deserialize(String str) {
		Object deserializedResult = ...;
		//...
		return deserializedResult;
	}
}

單看這個類的設計,沒有一點問題。不過,如果我們把它放到一定的應用場景裏,那就還有繼續優化的空間。假設在我們的項目中,有些類只用到了序列化操作,而另一些類只用到反序列化操作。那基於迪米特法則後半部分“有依賴關係的類之間,儘量只依賴必要的接口”,只用到序列化操作的那部分類不應該依賴反序列化接口。同理,只用到反序列化操作的那部分類不應該依賴序列化接口。

根據這個思路,我們應該將 Serialization 類拆分爲兩個更小粒度的類,一個只負責序列化(Serializer 類),一個只負責反序列化(Deserializer 類)。拆分之後,使用序列化操作的類只需要依賴 Serializer 類,使用反序列化操作的類只需要依賴 Deserializer類。拆分之後的代碼如下所示:

public class Serializer {
	public String serialize(Object object) {
		String serializedResult = ...;
		...
		return serializedResult;
	}
}
public class Deserializer {
	public Object deserialize(String str) {
		Object deserializedResult = ...;
		...
		return deserializedResult;
	}
}

不知道你有沒有看出來,儘管拆分之後的代碼更能滿足迪米特法則,但卻違背了高內聚的設計思想。高內聚要求相近的功能要放到同一個類中,這樣可以方便功能修改的時候,修改的地方不至於過於分散。對於剛剛這個例子來說,如果我們修改了序列化的實現方式,比如從 JSON 換成了 XML,那反序列化的實現邏輯也需要一併修改。在未拆分的情況下,我們只需要修改一個類即可。在拆分之後,我們需要修改兩個類。顯然,這種設計思路的代碼改動範圍變大了。

如果我們既不想違背高內聚的設計思想,也不想違背迪米特法則,那我們該如何解決這個問題呢?實際上,通過引入兩個接口就能輕鬆解決這個問題,具體的代碼如下所示。實際上,我們在第 18 節課中講到“接口隔離原則”的時候,第三個例子就使用了類似的實現思路,你可以結合着一塊兒來看。

public interface Serializable {
	String serialize(Object object);
}
public interface Deserializable {
	Object deserialize(String text);
}
public class Serialization implements Serializable, Deserializable {
	@Override
	public String serialize(Object object) {
		String serializedResult = ...;
		...
		return serializedResult;
	}
	@Override
	public Object deserialize(String str) {
		Object deserializedResult = ...;
		...
		return deserializedResult;
	}
}
public class DemoClass_1 {
	private Serializable serializer;
	public Demo(Serializable serializer) {
		this.serializer = serializer;
	}
	//...
}
public class DemoClass_2 {
	private Deserializable deserializer;
	public Demo(Deserializable deserializer) {
		this.deserializer = deserializer;
	}
	//...
}

儘管我們還是要往 DemoClass_1 的構造函數中,傳入包含序列化和反序列化的Serialization 實現類,但是,我們依賴的 Serializable 接口只包含序列化操作,DemoClass_1 無法使用 Serialization 類中的反序列化接口,對反序列化操作無感知,這也就符合了迪米特法則後半部分所說的“依賴有限接口”的要求。

實際上,上面的的代碼實現思路,也體現了“基於接口而非實現編程”的設計原則,結合迪米特法則,我們可以總結出一條新的設計原則,那就是“基於最小接口而非最大實現編程”。有些同學之前問,新的設計模式和設計原則是怎麼創造出來的,實際上,就是在大量的實踐中,針對開發痛點總結歸納出來的套路。

辯證思考與靈活應用

對於實戰二最終的設計思路,你有沒有什麼不同的觀點呢?

整個類只包含序列化和反序列化兩個操作,只用到序列化操作的使用者,即便能夠感知到僅有的一個反序列化函數,問題也不大。那爲了滿足迪米特法則,我們將一個非常簡單的類,拆分出兩個接口,是否有點過度設計的意思呢?

設計原則本身沒有對錯,只有能否用對之說。不要爲了應用設計原則而應用設計原則,我們在應用設計原則的時候,一定要具體問題具體分析。

對於剛剛這個 Serialization 類來說,只包含兩個操作,確實沒有太大必要拆分成兩個接口。但是,如果我們對 Serialization 類添加更多的功能,實現更多更好用的序列化、反序列化函數,我們來重新考慮一下這個問題。修改之後的具體的代碼如下:

public class Serializer { // 參看 JSON 的接口定義
	public String serialize(Object object) { //... }
	public String serializeMap(Map map) { //... }
	public String serializeList(List list) { //... }
	public Object deserialize(String objectString) { //... }
	public Map deserializeMap(String mapString) { //... }
	public List deserializeList(String listString) { //... }
}

在這種場景下,第二種設計思路要更好些。因爲基於之前的應用場景來說,大部分代碼只需要用到序列化的功能。對於這部分使用者,沒必要了解反序列化的“知識”,而修改之後的 Serialization 類,反序列化的“知識”,從一個函數變成了三個。一旦任一反序列化操作有代碼改動,我們都需要檢查、測試所有依賴 Serialization 類的代碼是否還能正常工作。爲了減少耦合和測試工作量,我們應該按照迪米特法則,將反序列化和序列化的功能隔離開來。

重點回顧

好了,今天的內容到此就講完了。我們一塊來總結回顧一下,你需要掌握的重點內容。

1. 如何理解“高內聚、鬆耦合”?

“高內聚、鬆耦合”是一個非常重要的設計思想,能夠有效提高代碼的可讀性和可維護性,縮小功能改動導致的代碼改動範圍。“高內聚”用來指導類本身的設計,“鬆耦合”用來指導類與類之間依賴關係的設計。

所謂高內聚,就是指相近的功能應該放到同一個類中,不相近的功能不要放到同一類中。相近的功能往往會被同時修改,放到同一個類中,修改會比較集中。所謂鬆耦合指的是,在代碼中,類與類之間的依賴關係簡單清晰。即使兩個類有依賴關係,一個類的代碼改動也不會或者很少導致依賴類的代碼改動。

2. 如何理解“迪米特法則”?

不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要的接口。迪米特法則是希望減少類之間的耦合,讓類越獨立越好。每個類都應該少了解系統的其他部分。一旦發生變化,需要了解這一變化的類就會比較少。

課堂討論

在今天的講解中,我們提到了“高內聚、鬆耦合” “單一職責原則” “接口隔離原則” “基於接口而非實現編程” “迪米特法則”,你能總結一下它們之間的區別和聯繫嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章