本章介紹 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

使用 DoorTimerAdapterITimerClientDoor 分離:

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 將呼叫轉發給 TimedDoorDoor 介面保持乾淨,不被計時功能污染。

補充: Adapter 方案的缺點是每次註冊都需要建立一個新的 adapter 物件。在空間或時間敏感的場景中,這可能不太理想。

透過多重介面實作分離#

另一種更簡潔的做法是讓 TimedDoor 同時實作 IDoorITimerClient

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 需求變更時,WithdrawalTransactionTransferTransaction 也會被迫重新編譯——即使它們的 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 模式的委派方式,或透過多重介面實作來達成。目標始終是:讓每個客戶端只看到並依賴它真正需要的方法,避免肥胖介面在不相關的客戶端之間製造連鎖反應。