iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 6

Day 6|I(介面隔離) 會員制上線,如何不讓集點功能搞垮結帳系統?

  • 分享至 

  • xImage
  •  

我們來繼續看 SOLID 的第四條: Interface Segregation Principle(介面隔離原則,簡稱 ISP)

先來認識一下 ISP 的身世

ISP 一樣是 Uncle Bob 提出的,這條原則不是憑空想出來的,而是他早期幫全錄(Xerox)做顧問時,踩過的坑

當時全錄要做一台「多功能事務機」,身兼列印、裝訂、傳真多種功能,工程師很直覺地把所有功能都寫進同一個 Job 類別裡

後來 Uncle Bob 發現,只要新增或修改一個跟「傳真」有關的方法,連只用到「列印」功能的那群 Client 端程式碼都要跟著重新編譯,即使它們根本沒呼叫過傳真的方法

此經驗後來被寫成原則,原文是這樣說的:

Clients should not be forced to depend on methods it does not use.
「客戶端不應該被迫依賴用不到的方法」

也可以說:

不要把不相關的功能塞進同一個介面,逼著實作它的人,硬生出一堆自己用不到的方法

我們先用一個小例子感受一下

拿剛剛的例子來寫,介面大概長這樣

public interface IMultiFunctionDevice // 多功能印表機介面
{
    void Print(Document doc);
    void Scan(Document doc);
    void Fax(Document doc);
}

如果只是一台最陽春的印表機,沒有掃描、傳真功能呢

public class SimplePrinter : IMultiFunctionDevice // 簡單印表機 實作 多功能印表機介面
{
    public void Print(Document doc) => Console.WriteLine("列印中...");

    // 沒有這兩個功能,但介面規定要實作,只好丟例外交差
    public void Scan(Document doc) => throw new NotSupportedException("這台印表機不能掃描");
    public void Fax(Document doc) => throw new NotSupportedException("這台印表機不能傳真");
}

SimplePrinter 它被迫實作了兩個它做不到的方法

呼叫端拿到一個 IMultiFunctionDevice,沒辦法放心呼叫 Scan()

因為底下只要換成 SimplePrinter 就會噴錯

言下之意,關於 IMultiFunctionDevice 的介面

不應該強迫用戶依賴它們未使用的方法

那該怎麼辦呢,最直白就是拆,拆開來就簡單多了,我們依照「誰在用」拆成三個小介面

public interface IPrinter { void Print(Document doc); }
public interface IScanner { void Scan(Document doc); }
public interface IFax     { void Fax(Document doc); }

public class SimplePrinter : IPrinter
{
    public void Print(Document doc) => Console.WriteLine("列印中...");
}

public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
    public void Print(Document doc) => Console.WriteLine("列印中...");
    public void Scan(Document doc) => Console.WriteLine("掃描中...");
    public void Fax(Document doc) => Console.WriteLine("傳真中...");
}

SimplePrinter 只實作自己做得到的,只想印文件的 client,也只需要依賴 IPrinter,不用被迫認識 ScanFax 這些用不到的方法

介面隔離原則,我理解成: 每一種 client 的依賴,各自關在自己的介面裡,彼此不互相波及並達成隔離的目的

client 有可能是誰? 可能任何依賴或者呼叫這個介面的使用者

好,帶著這個感覺,我們再回到柴咖啡

小黑的新點子

上一篇阿柴把促銷邏輯整理成乾淨的 IDiscountCheckoutService 終於不用再寫一堆 is 判斷,本來想說這下總算穩了

但小黑又跑來了:「阿柴,最近很多人問有沒有集點,順便把點數折抵也做進來,跟打折用同一套邏輯處理就好」

阿柴心想:「好,都是『優惠』的概念,乾脆都塞進 IDiscount 好了」

public interface IDiscount  // 原先 IDiscount 的介面
{
    decimal Apply(Order order, decimal currentTotal);
}

於是把介面擴大,並改名叫 IPromotion

public interface IPromotion
{
    decimal Apply(Order order, decimal currentTotal); // 結帳時計算套用後金額
    decimal RedeemPoints(int points);                 // 點數折抵
}

接下來把三種折扣,跟一個「點數折抵」,通通實作這個介面

public class PercentageDiscount : IPromotion
{
    private readonly decimal _rate;
    public PercentageDiscount(decimal rate) => _rate = rate;

    public decimal Apply(Order order, decimal currentTotal) => currentTotal * _rate;

    // 打折活動跟集點無關,但介面規定要實作,只好回傳一個 0 交差
    public decimal RedeemPoints(int points) => 0;
}

public class BuyOneGetOneDiscount : IPromotion
{
    public decimal Apply(Order order, decimal currentTotal) { /*略*/ return currentTotal; }

    public decimal RedeemPoints(int points) => 0; // 用不到集點
}

public class PointsRedemption : IPromotion
{
    // 但點數折抵本質上不是「打折」,是拿點數換錢,但因為要塞進同一個介面…
    public decimal Apply(Order order, decimal currentTotal)
        => throw new NotSupportedException("點數折抵請改用 RedeemPoints,這裡用不到");

    public decimal RedeemPoints(int points) => points * 0.5m; // 每點折 0.5 元
}

寫完當下沒事,介面統一,看起來一切美好,好交差

三天後炸了

現場當機,客人在櫃檯前面等,阿柴滿頭問號

https://ithelp.ithome.com.tw/upload/images/20260903/20183470ehdoQQF5SU.jpg

雖然 PercentageDiscountBuyOneGetOneDiscountPointsRedemption 都是同一個 IPromotion 型別,只要宣告成 IPromotion,程式完全看不出裡面裝的是哪一種,編譯也不會錯誤

結果阿柴在結帳的地方,手滑把「今天要套用的促銷」設成了 PointsRedemption,接著執行到 Apply()直接噴錯

IPromotion todayPromotion = new PointsRedemption(); // 型別上是 IPromotion,編譯沒問題
var total = todayPromotion.Apply(order, 100);
System.NotSupportedException: 點數折抵請改用 RedeemPoints,這裡用不到

ISP 說了什麼

客戶端不應該被迫依賴它們用不到的方法

換句話說:一個介面,應該只服務「同一群需要它全部方法」的 client,而不是把多種 client 的需求,一同打包塞進同一份契約

我們來看看這次錯在哪

PointsRedemption 表面上跟 PercentageDiscountBuyOneGetOneDiscount 一樣

都實作了 IPromotion,長得一模一樣

但聰明的你,細看也會發現,它們其實服務著完全不同的使用情境

  • 結帳流程只在乎「錢怎麼算」→ 只需要 Apply
  • 兌換點數的頁面只在乎「幾點可以折多少錢」→ 只需要 RedeemPoints

兩種不同的 client,各自只需要一小塊功能,卻被 IPromotion 這一份契約綁在一起

PointsRedemption 是實作 IPromotion,結帳流程完全沒有辦法在編譯期擋下它

只能等到執行期真的被拿去結帳,才會爆炸

那我們該怎麼做呢

拆拆看,把 IPromotion 依照「誰在用」拆成兩個小介面

public interface IDiscount           // 結帳流程
{
    decimal Apply(Order order, decimal currentTotal);
}

public interface IPointRedeemable    // 兌換點數頁面
{
    decimal RedeemPoints(int points);
}
public class PercentageDiscount : IDiscount
{
    private readonly decimal _rate;
    public PercentageDiscount(decimal rate) => _rate = rate;

    public decimal Apply(Order order, decimal currentTotal) => currentTotal * _rate;
}

public class BuyOneGetOneDiscount : IDiscount
{
    public decimal Apply(Order order, decimal currentTotal) { /* 略 */ return currentTotal; }
}

public class PointsRedemption : IPointRedeemable
{
    public decimal RedeemPoints(int points) => points * 0.5m;
}

那該怎麼使用呢

// 結帳方法只依賴它需要的 IDiscount
public decimal Checkout(Order order, IDiscount discount)
{
    decimal total = _calculator.Calculate(order);
    return discount.Apply(order, total);
}

// 嘗試把點數折抵丟進去結帳:
Checkout(order, new PointsRedemption()); // ❌ 編譯器直接報錯

阿柴又平安度過一天了,平安快樂,可口可樂

讓我們來復盤,透過 ISP 介面隔離,我們可以學到什麼:

用 ISP 當反思

下次處理一個介面,可以反問自己:

  • 呼叫端拿到這個介面時,只用得到其中一兩個方法嗎? 如果只呼叫一部分、其餘完全沒動過,代表這個介面同時服務了不只一種 client
  • 在這個介面新增一個方法,會不會連帶影響不相干的類別重新實作? 會的話,代表太多職責被綁在同一份契約裡了

ISP 的邊界在哪

這時候我的第一個問題,那我就把每個方法都拆成獨立介面就好了呀,一個方法一個介面,總不會錯了吧?

NO NO NO ,當專案一龐大的時候,無限增生的介面,會讓可讀性跟可維護性大爆炸

那讓我們依照「誰在用」來分,不是依照「方法數量」來分

同一群 client 會一起用到的方法,放在同一個介面沒問題

IPointRedeemable,之後就算再多幾種「點數怎麼折抵」的玩法(換飲料、換折價券…),只要都是「點數兌換頁面」這一個 client 在用

通通實作同一個 IPointRedeemable 就好,沒必要因為「類別數量變多」就跟著拆更多介面

該拆的判斷標準是「這群 client 要的方法組合是否相同」,不是「有幾個類別在實作」

ISP 該保護的是「每個 client 只依賴它需要的方法」,不是「把相關功能塞進同一個介面比較方便」


下一篇,我們繼續看 SOLID 最後一條:D——依賴反轉原則

延伸閱讀資料:

The Clean Code Blog

使人瘋狂的 SOLID 原則: 介面隔離原則 (Interface Segregation Principle)

[Day18] 淺出Solid 關於介面隔離原則

介面隔離原則 ISP 深度指南 SOLID 實踐應用介面拆分技巧降低耦合與提高內聚優化擴展性

菜雞與物件導向 (13): 介面隔離原則


上一篇
Day 5|L (里氏替換) 柴咖啡促銷,買一送一遇上無法兌現的承諾
下一篇
Day 7|D (依賴反轉原則) 柴咖啡如何優雅解開資料庫與 LINE 的枷鎖?
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言