1. 程式人生 > >接口分離原則(Interface Segregation Principle)

接口分離原則(Interface Segregation Principle)

轉載 itblog 禁止 timer 消息 transacti xpl class 設有

接口分離原則(Interface Segregation Principle)

接口分離原則(Interface Segregation Principle)用於處理胖接口(fat interface)所帶來的問題。如果類的接口定義暴露了過多的行為,則說明這個類的接口定義內聚程度不夠好。換句話說,類的接口可以被分解為多組功能函數的組合,每一組都服務於不同的客戶類,而不同的客戶類可以選擇使用不同的功能分組。

ISP 原則承認了對象設計中非內聚接口的存在。但它建議客戶類不應該只通過一個單獨的類來使用這些接口。取而代之的是,客戶類應該通過不同的抽象基類來使用那些內聚的接口。在不同的編程語言中,這裏所指的抽象基類可以指 "接口(interface)"、"協議(protocol)"、"簽名(signature)" 等。

本篇文章我們將探討 "fat" 胖接口或稱為 "polluted" 被汙染的接口在類設計中所帶來的問題。將涉及描述這些接口是如何被創建的,如何改進類的設計以便隱藏它們。最後,我們將通過一個案例來學習 "fat" 接口的產生過程,以及我們如何通過使用 ISP 原則來校正它。

接口汙染(Interface Pollution)

假設我們在設計一個安保系統。在這個系統中,Door 對象包含鎖定(Lock)和解鎖(Unlock)行為,同時也了解門的開關狀態(IsDoorOpen)。

1   class Door
2   {
3     public:
4       virtual void Lock() = 0;
5       virtual void Unlock() = 0;
6       virtual bool IsDoorOpen() = 0;
7   };

類 Door 被設計為抽象類,這樣客戶類可以通過實現它的接口(virtual)來使用,而無需依賴於特定的 Door 實現。

現在假設有一種實現稱為 TimedDoor。TimedDoor 需要在門開了一段時間之後發出一個聲音報警(Sound Alarm)。為了實現這個功能,TimedDoor 需要與另一個叫做 Timer 的對象進行通信。

 1   class Timer
 2   {
 3     public:
 4       void Regsiter(int timeout, TimerClient* client);
 5   };
 6   class TimerClient
 7   {
 8     public:
 9       virtual void TimeOut() = 0;
10   };

當 Timer 對象檢測計時時間已經超時時,它就調用 Register 函數。Register 函數的參數包括超時的時長和一個 TimerClient 對象的指針。TimerClient 類擁有一個 TimeOut 方法,用以當超時發生時被調用。

那麽如何讓 TimerClient 與 TimedDoor 進行通信,以便當超時發生時 TimedDoor 能夠被通知到呢?理論上講有很多種選擇。圖 1 中展示了一個常見的方案。

技術分享

圖 1

我們強制讓 Door 繼承自 TimerClient,然後 TimedDoor 自然也繼承自 TimerClient。這讓 TimerClient 可以將自己註冊到 Timer 對象中以提供 TimeOut 接口。

這個方案很好理解,但是有很多問題。其中最主要的問題是,Door 類直接依賴了 TimerClient。然而,並不是所有的 Door 的衍生類都需要考慮時間和超時問題。而實際上,在我們這麽設計之前, Door 類根本不關心任何有關時間的問題。然後,那些不需要使用時間功能的 Door 的衍生類也將不得不為 TimeOut 提供一個實現,即使是空實現。此外,使用這些衍生類的客戶類也將不得不引用 TimerClient 的定義,即使它們並沒有使用這些功能。

圖 1 展示了在面向對象設計中的一個常見的問題,尤其是在類似於 C++ 這樣的靜態類型語言中。確切的說,這個問題屬於接口汙染(interface pollution)問題,Door 的接口已經被一個並不需要的接口所汙染。而在實際使用中,新增的接口僅是 Door 的一個子類所需要的功能。如果這樣演進下去,每當一個衍生類需要使用一個新的接口時,我們就將這個接口添加到它的 Door 基類中,持續的汙染使得基類的接口變得越來越臃腫。

而且,每當將一個新的接口添加到基類定義中時,基類的所有其他衍生類也相應實現了該接口。一般來說,新增的接口在基類中已經有了默認實現或空實現,而子類如果不需要使用該功能可以不提供實現。我們知道,這樣的設計已經違背了裏氏替換原則(Liskov Substitution Principle),降低了代碼的可維護性和可復用性。

隔離客戶類意味著隔離接口

Door 和 TimerClient 所表示的接口其實是被不同的客戶類所使用。Timer 使用 TimerClient,而負責控制門的那些類會使用 Door 。因此它們服務的對象是不同的,所以接口也應當保持隔離。為什麽這麽說呢?在下面的描述中我們會知道,客戶類所以依賴的接口之間是可以相互影響的。

當我們面對新的需求引起軟件的變化時,我們通常會考慮這些對接口的更改是否將影響到它們的客戶類。例如,我們可能會關心對 TimerClient 的更改是否會影響到它所有的客戶類,因為有時就是它的客戶類強制讓接口發生變化的。

例如,Timer 的客戶類可能會調用多次 Register 函數。假設 TimedDoor 偵測到了門已經被打開,則它會調用 Timer 的 Register 方法來註冊 TimeOut 回調。如果在超時之前門關上了,關了一會後又重新打開了。這將導致我們又 Register 了一個新的 TimeOut 回調,而之前的那個還沒有被觸發。然後當超時發生時, TimedDoor 的 TimeOut 函數被調用,使得 Door 發出了一個錯誤的報警消息。

我們可以通過下述代碼中的約定機制來處理上面描述的情況。為 Register 添加一個 timeOutId 參數作為識別 TimerClient 的標識。當回調 TimeOut 時攜帶這個註冊時設置的 timeOutId,使得 TimerClient 的子類知道具體調用的是誰的 TimeOut 函數。

 1   class Timer
 2   {
 3     public:
 4       void Regsiter(int timeout, 
 5       int timeOutId, 
 6       TimerClient* client);
 7   };
 8   class TimerClient
 9   {
10     public:
11       virtual void TimeOut(int timeOutId) = 0;
12   };

顯然,這個改動會影響所有 TimerClient 的用戶,而實際上這個問題是一個設計疏漏。然而,如果保持圖 1 中的設計,就是為了添加一個 timeOutId,將有可能導致 Door 和它所有的客戶類都會被影響到(至少要重新編譯吧!)。那為什麽為了解決 TimerClient 中的一個 Bug,而將影響到那些不需要使用時間超時機制的 Door 的衍生類呢?當對程序的一個改動觸發了完全不相關的模塊的改動時,這種代價和變化所帶來的後果就變得無法預測,而且這種變化顯然也會引入更大的風險。

接口分離原則(The Interface Segregation Principle)

Clients should not be forced to depend upon interfaces that they do not use.

接口分離原則描述為 "客戶類不應被強迫依賴那些它們不需要的接口"。

技術分享

當客戶類被強迫依賴那些它們不需要的接口時,則這些客戶類不得不受制於這些接口。這無意間就導致了所有客戶類之間的耦合。換句話說,如果一個客戶類依賴了一個類,這個類包含了客戶類不需要的接口,但這些接口是其他客戶類所需要的,那麽當其他客戶類要求修改這個類時,這個修改也將影響這個客戶類。通常我們都是在盡可能的避免這種耦合,所以我們需要竭盡全力地分離這些接口。

類接口和對象接口

我們再來看下 TimedDoor 類。這個類的對象包含兩個獨立的接口,但同時被兩個不同的客戶類所使用,也就是 Timer 和使用 Door 的用戶。這兩個接口必須在同一個對象上實現,因為這兩個接口的實現需要處理同樣的數據。那麽我們該如何使其符合 ISP 原則呢?如何將那些必須保持在一起的接口進行分離呢?

解決這個問題的基本方式就是讓對象的客戶類不通過對象的接口來訪問,而是通過委托(delegation)或者基類對象來訪問。

通過委托進行分離(Separation through Delegation)

我們可以采用 Adapter 設計模式來解決 TimedDoor 的問題。具體方式是通過從 TimerClient 衍生一個 Adapter 對象,其將操作轉遞至 TimedDoor 對象。如圖 2 所示。

技術分享

圖 2

當 TimedDoor 想要 Register 一個 TimeOut 回調至 Timer 時,先創建一個 DoorTimerAdapter,然後將其Register 給 Timer。當 Timer 調用 DoorTimerAdapter 的 TimeOut 方法時,DoorTimerAdapter 將這個調用轉遞給 TimedDoor。

這個方案滿足了 ISP 原則,並且避免了 Door 的使用者與 Timer 之間的耦合。而且當 Timer 被修改時,Door 的用戶將不再會被影響到。此外,TimedDoor 也無須一定非要與 TimerClient 保持相同的接口。DoorTimerAdapter 能夠將 TimerClient 接口翻譯成 TimedDoor 接口。因此,這是一個非常通用的解決方案。

 1   class TimedDoor : public Door
 2   {
 3     public:
 4       virtual void DoorTimeOut(int timeOutId);
 5   };
 6   class DoorTimerAdapter : public TimerClient
 7   {
 8     public:
 9       DoorTimerAdapter(TimedDoor& theDoor)
10         : itsTimedDoor(theDoor) 
11         {}
12       virtual void TimeOut(int timeOutId)
13         {itsTimedDoor.DoorTimeOut(timeOutId);}
14     private:
15       TimedDoor& itsTimedDoor;
16   };

盡管如此,上述方案還是不夠優雅。它要求每當我們想註冊一個 TimeOut 時都需要創建一個新的對象。創建對象顯然多了些開銷。

通過多繼承進行分離(Separation through Multiple Inheritance)

圖 3 中展示了多繼承的使用方式。在這種模型下,TimedDoor 同時繼承了 Door 和 TimerClient。盡管兩個基類的客戶類都能夠使用 TimedDoor,但它們都沒有直接依賴 TimedDoor 類,而是通過獨立的接口來使用相同的對象的。

技術分享

圖 3

1   class TimedDoor : public Door, public TimerClient
2   {
3     public:
4       virtual void TimeOut(int timeOutId);
5   };

我個人是比較推薦這個方案的。多繼承沒有想象的那麽恐怖。事實上,我發現它在這種條件下還特別有用。而且,對於上面圖 2 中的方案,我只在當 DoorTimerAdapter 中所執行的轉換是必要的情況下才會推薦使用。

ATM 用戶接口案例

現在我們來看一個較有實際意義的案例,古老的 ATM(Automated Teller Machine)問題。ATM 機的用戶接口需要非常靈活的設計,因其界面輸出可能需要被翻譯成多種不同的語言和呈現方式。比如,它有可能使用顯示屏進行展現,也有可能使用盲人點字面板,或者通過語音合成技術進行輸出。為了支持這些功能,我們可以通過創建一個抽象基類,包含所有支持的功能接口,並且接口函數設置的 virtual 以供子類擴展。

技術分享

圖 4

ATM 還支持不同的交易(transaction)類型,每種交易類型都衍生自基類 Transaction。這樣就可以得到 DepositTransaction、WithdrawTransaction 和 TransferTransaction 等。每種交易對象都會調用 UI 模塊進行操作。例如,DepositeTransaction 對象調用 UI 的 RequestDepositAmount 成員函數,而 TransferTransaction 對象則調用 UI 的 RequestTransferAmount 成員函數。如圖 5 所示。

技術分享

圖 5

註意到,這種情形恰好是 ISP 原則告訴我們一定要避免的。每種 Transaction 類型都使用了 UI 的一部分功能。這使得對 Transaction 的某一個衍生類的更改將導致相應的 UI 跟著更改。

這種耦合可以通過分離 UI 的接口來避免。我們可以將 UI 的接口分成多個獨立的抽象基類接口,例如 DepositUI、WithdrawUI 和 TransferUI 等。然後 UI 類則通過多繼承來實現這些抽象基類。如圖 6 所示。

技術分享

圖 6

誠然,無論何時創建 Transaction 的一個新的衍生類,都需要相應創建一個抽象的 UI 基類。從而使 UI 及其所有衍生類也必須跟著修改。盡管如此,這些類其實並沒有廣泛的分布在應用程序中。實際上,它們可能僅在 main 函數或者系統的啟動 bootstrap 中才會被實例化。所以,添加新的 UI 基類是可控的。

總結

本篇文章中,我們探討了關於胖接口 "fat interface" 所帶來的問題,也就是接口不是為特定的客戶類服務,而服務了多個不同的客戶類。胖接口使本應該被隔離的客戶類之間產生了耦合。通過應用 Adapter 設計模式,采用委托(delegation)或多繼承方式,胖接口可以被分離成多個抽象的基類接口,從而打破客戶類之間的不必要的耦合。

面向對象設計的原則

SRP

單一職責原則

Single Responsibility Principle

OCP

開放封閉原則

Open Closed Principle

LSP

裏氏替換原則

Liskov Substitution Principle

ISP

接口分離原則

Interface Segregation Principle

DIP

依賴倒置原則

Dependency Inversion Principle

LKP

最少知識原則

Least Knowledge Principle

參考資料

  • ISP:The Interface Segregation Principle by Robert C. Martin “Uncle Bob”
  • The SOLID Principles, Explained with Motivational Posters
  • Dangers of Violating SOLID Principles in C#
  • An introduction to the SOLID principles of OO design

本文《接口分離原則(Interface Segregation Principle)》由 Dennis Gao 翻譯改編自 Robert Martin 的文章《ISP: The Interface Segregation Principle》,未經作者本人同意禁止任何形式的轉載,任何自動或人為的爬蟲行為均為耍流氓

接口分離原則(Interface Segregation Principle)