本章介紹 SOLID 原則中的最後一個——介面隔離原則(ISP),探討「肥胖介面」(fat interface)如何在客戶端之間製造有害的耦合,以及如何透過介面分離來消除這些耦合。
原則定義#
客戶端不應該被迫依賴它們不使用的方法。 (Clients should not be forced to depend on methods they do not use.)
違反症狀#
- 客戶端依賴了一個包含許多它根本不需要的方法的介面
- 當不需要的方法發生變更時,客戶端也被迫重新編譯與部署
- 介面污染(Interface Pollution):一個介面因為要服務不同的客戶端,而塞入了彼此不相關的方法
案例一:Door 與 Timer#
介面污染的起源#

Figure 12.1: TimerClient at top of hierarchy
假設有一個 Door 介面,以及一個需要定時功能的 TimedDoor。系統中有一個 Timer 類別,它可以在指定時間後呼叫 TimerClient 介面的 TimeOut() 方法:
public interface ITimerClient
{
void TimeOut(int timeOutId);
}天真的做法是讓 Door 繼承 ITimerClient,這樣 TimedDoor 就自動獲得了計時功能。但這帶來了嚴重的問題——所有的 Door 實作都被迫依賴 ITimerClient,即使大多數 Door 根本不需要計時功能。這就是介面污染。
Timer 的 ID 機制#
實務中,Timer 還需要處理多次註冊的問題。如果 TimedDoor 在前一次 timeout 到期前又重新註冊了一次,就必須能區分哪個 timeout 是有效的。解法是使用唯一的 timeOutId:
public class Timer
{
public int Register(int timeout, ITimerClient client)
{
int id = GenerateUniqueId();
// 註冊 timeout
return id;
}
}類別介面與物件介面#
一個物件的介面不等於它的類別介面。一個物件可以透過委派(delegation)或多重繼承/實作來呈現多個不同的介面給不同的客戶端。
透過委派分離:Adapter 模式#

Figure 12.2: Door timer adapter
使用 DoorTimerAdapter 將 ITimerClient 與 Door 分離:
public class DoorTimerAdapter : ITimerClient
{
private TimedDoor timedDoor;
public DoorTimerAdapter(TimedDoor door)
{
this.timedDoor = door;
}
public void TimeOut(int timeOutId)
{
timedDoor.DoorTimeOut(timeOutId);
}
}TimedDoor 在需要計時時,建立一個 DoorTimerAdapter 並將其註冊到 Timer。當 timeout 發生時,adapter 將呼叫轉發給 TimedDoor。Door 介面保持乾淨,不被計時功能污染。
補充: Adapter 方案的缺點是每次註冊都需要建立一個新的 adapter 物件。在空間或時間敏感的場景中,這可能不太理想。
透過多重介面實作分離#
另一種更簡潔的做法是讓 TimedDoor 同時實作 IDoor 和 ITimerClient:
public class TimedDoor : IDoor, ITimerClient
{
public void Lock() { /* ... */ }
public void Unlock() { /* ... */ }
public void TimeOut(int timeOutId) { /* ... */ }
}使用 IDoor 的客戶端只看到 Door 的方法,使用 ITimerClient 的客戶端(如 Timer)只看到 TimeOut——兩個介面完全隔離,互不干擾。IDoor 不再被 ITimerClient 污染。
案例二:ATM 使用者介面#
肥胖介面的問題#

Figure 12.4: ATM user interface
一台 ATM 有多種交易功能:存款、提款、轉帳等。如果定義一個統一的 UI 介面來涵蓋所有交易所需的 UI 方法:
public interface IUI
{
void RequestDepositAmount();
void RequestWithdrawalAmount();
void RequestTransferAmount();
void InformInsufficientFunds();
// ... 更多方法
}
Figure 12.5: ATM transaction hierarchy
每個交易類別都依賴這個 IUI,但每個交易只用其中一小部分的方法。當 DepositTransaction 的 UI 需求變更時,WithdrawalTransaction 和 TransferTransaction 也會被迫重新編譯——即使它們的 UI 完全沒有改變。
隔離後的介面#

Figure 12.6: Segregated ATM UI interface
將肥胖介面拆分為客戶端專屬的介面(client-specific interfaces):
public interface IDepositUI
{
void RequestDepositAmount();
}
public interface IWithdrawalUI
{
void RequestWithdrawalAmount();
void InformInsufficientFunds();
}
public interface ITransferUI
{
void RequestTransferAmount();
void InformInsufficientFunds();
}
public interface IUI : IDepositUI, IWithdrawalUI, ITransferUI
{
}每個交易類別只依賴自己專屬的 UI 介面,而 IUI 介面繼承了所有的隔離介面,讓 UI 的實作類別可以同時實作所有功能。
注意: 如果將隔離後的介面重新合併到一個全域靜態物件中(例如
UIGlobals.ui),就等於把分離的工作全部白做了。每個交易仍然間接依賴整個IUI。應該讓每個交易透過各自的參數接收它需要的介面——這種 polyadic(多參數)形式優於 monadic(單一全域參數)形式。
Polyadic 與 Monadic 形式#
- Monadic 形式:所有交易共用一個
IUI參數——雖然各自看到的是隔離介面,但所有交易仍然繫結到同一個 UI 物件上 - Polyadic 形式:每個交易只接收它需要的介面作為參數——真正實現了隔離
重點: ISP 的目標不只是「介面變小」,而是確保客戶端不被迫依賴它們不需要的東西。肥胖類別(fat classes)會在不相關的客戶端之間建立有害的耦合。將肥胖介面分解為多個客戶端專屬的介面,可以打斷這些耦合,讓系統更容易維護與演化。
本章小結#
ISP 處理的是「肥胖介面」的危害。當一個類別的介面不內聚時——也就是說,它的方法可以被分成服務不同客戶群的子集——就應該將其拆分為多個客戶端專屬的介面。這可以透過 Adapter 模式的委派方式,或透過多重介面實作來達成。目標始終是:讓每個客戶端只看到並依賴它真正需要的方法,避免肥胖介面在不相關的客戶端之間製造連鎖反應。