iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
自我挑戰組

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

Day 5|L (里氏替換) 柴咖啡促銷,買一送一遇上無法兌現的承諾

  • 分享至 

  • xImage
  •  

接下來我們談到 Liskov Substitution principle (里氏替換原則 LSP)
看到這個原則,我第一個問題是,里氏是誰?(失禮)

原來 Liskov 是芭芭拉·里斯科夫(Barbara Liskov),她是麻省理工學院的電腦科學家,從零自學程式到獲得圖靈獎的神人

由 Barbara Liskov 在 1987 年的一篇論文中提出統合資料抽象化的概念,主要在探討兩個問題:「程式究竟該如何設計?」以及「程式應該怎樣架構?」,才衍生出「 里氏替換原則 」

Subtype requirement: Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.
如果 S 是 T 的子型別,那所有「用 T 可以成立的事」,換成 S 也應該要能成立

跟我一樣看到這裡就昏倒了嗎,沒事,後來被 Uncle Bob 收進 SOLID,Uncle Bob 的版本或許較為直觀:

Subtypes must be substitutable for their base types.
「子類別必須可以替換掉父類別,而不改變程式的正確性」

這裡討論的,就是如何設計「 好的繼承 」

行為相容:子類別不只要繼承父類別的屬性與方法,更不能改變父類別原本預期的行為

不削弱功能:子類別可以擴充功能,但不能讓原本父類別的功能失效或拋出未預期的錯誤

避免壞味道:如果子類別覆寫(override)方法卻把原本的邏輯改掉或強行丟出例外,就違反了此原則

這裡剛學會物件導向的夥伴們,我們一起來想個好理解的例子,我們以車子跟車子品牌來舉例

public abstract class Vehicle // 抽象類別 Vehicle
{
    public abstract void Refuel();  // 抽象方法:加油(子類別繼承一定要實作)
}

public class Toyota : Vehicle  // 這裡 Toyota 繼承 Vehicle
{
    public override void Refuel()  // 使用 override 實作父類別 Refuel() 方法
    {
        Console.WriteLine("加滿 95 無鉛");
    }
}
public class Honda : Vehicle  // 這裡 Honda 也繼承 Vehicle
{
    public override void Refuel()  // 使用 override 實作 父類別 Refuel() 方法
    {
        Console.WriteLine("加滿 92 無鉛");
    }
}

都是油車,所以可以呼叫 Refuel() 沒問題,但這時候來輛酷哥 Tesla 呢

public class Tesla : Vehicle
{
    public override void Refuel()
    {
       // 第一個疑問,Tesla 能加油嗎? 不行,所以會噴錯 NotSupportedException
    }
}

雖然 Tesla 是 Vehicle 沒錯,但它沒辦法兌現 Refuel() 這個承諾,呼叫端也沒辦法「放心」地對車隊裡每一台車做同一件事

void GoOnRoadTrip(List<Vehicle> fleet)
{
    foreach (var v in fleet)
    {
        v.Refuel();  // 會噴錯,因為 Tesla 沒辦法實作,他只能充電
    }
}

而 LSP 的判斷原則是什麼: 父類別承諾的行為,子類別做不做得到

那這樣是誰的問題? 我的? 你的? 還是 Elon Musk的? 都不是

是 Vehicle 父類別的問題,把「加油」這個不是每種車都適用的行為,當成所有車輛共通的承諾

好我知道是誰的錯了,那該怎麼辦呢?

修正方式是把加油、充電這種具體行為,拆成各自誠實的介面,而不是逼 Tesla 硬演一個不屬於它的角色

也因為 class 不能繼承兩個以上的 class,所以我們用介面去處理

public interface IRefuelable   // 可以加油的車輛契約
{
    void Refuel();
}

public interface IChargeable  // 可以充電的車輛契約
{
    void Charge();
}

public class Toyota : Vehicle, IRefuelable
{
    public void Refuel() => Console.WriteLine("加滿 95 無鉛");
}

public class Honda : Vehicle, IRefuelable
{
    public void Refuel() => Console.WriteLine("加滿 92 無鉛");
}

public class Tesla : Vehicle, IChargeable
{
    public void Charge() => Console.WriteLine("開始充電");
}

這時候呼叫端(加油站、充電站)就不該收廣義的 Vehicle,而是精準依賴它能處理的契約

// 加油站只認「能加油的車」,彼此井水不犯河水
void FillUpStation(List<IRefuelable> cars)
{
    foreach (var car in cars)
    {
        car.Refuel(); // 不用 is 判斷!丟進來的保證能加,隨插即用
    }
}

// 充電站只認「能充電的車」
void ChargingStation(List<IChargeable> evs)
{
    foreach (var ev in evs)
    {
        ev.Charge(); // 安心呼叫,不用擔心有人會噴錯
    }
}

每個子類別只實作自己做得到的介面,不會有人被逼著假裝自己做不到的事

  • 父類別的責任: 只能承諾「 所有子類別都做得到 」的事。Vehicle 放 Refuel(),就是承諾了一件「不是」所有車都做得到的事
  • 子類別的責任: 一旦繼承,就要老實兌現父類別的承諾。如果做不到,不能想辦法硬掰(丟例外、什麼都不做),而是代表這個繼承關係從一開始就不該這樣設計

好我們回到 LSP 定義

「子類別必須可以替換掉父類別,而不改變程式的正確性」

這句話可以怎麼驗證? 起初我很糾結"替換掉父類別"這個詞很久,我才理解到,思路錯了

再看一次 Uncle Bob 原文

Subtypes must be substitutable for their base types.
子類別,要能夠被放到「原本預期是父類別」的那個位置,並且程式依然正確

所以「替換」不是指「拿掉一個正在跑的物件,換上另一個」這種動態動作

而是任何地方,只要程式碼宣告的型別是 T(父類別或介面都算),你丟任何一個屬於 T 的東西進去,程式都要正常、正確地運作,不用管你丟的到底是哪一個具體的東西

接下來,我們來舉一個最小範例

void FillUp(IRefuelable vehicle)  // 這裡 IRefuelable 就是扮演父類別的角色
  {
      vehicle.Refuel();
  }

  FillUp(new Toyota());  // 換這個進去,正確跑出「加滿 95 無鉛」
  FillUp(new Honda());   // 換成這個進去,正確跑出「加滿 92 無鉛」
  FillUp(new Tesla());   // 本來就不會這樣寫,因為 Tesla 沒有 Refuel 方法,在編譯時就會錯了  ❌❌

「替換」在這裡的意思是: 你可以把 Toyota 丟進這個 vehicle 位置,也可以把 Honda 丟進這個 vehicle 位置,同一個位置,換不同的東西進去,FillUp 都不用改、結果都正確,這就是符合 LSP 的表現

哇那這樣是不是以後都要先想最小範例來去驗證是否符合 LSP 原則 ? 其實可以用兩個階段去實現

  1. 設計當下先「腦內替換測試」判斷,不用真的寫程式

在寫一個介面(或抽象類別)時,先想一想:「我現在想到的每一種實作,呼叫這個方法,會不會都正確、不用丟例外?」
這是判斷力層面的事,發生在動手寫 interface 那一刻,不是事後補測試

  1. 有懷疑的時候,用「共用測試套件」去驗證,這是可執行、可重複用的方法

作法: 針對介面的契約寫一套測試,然後拿同一套測試,分別跑過每一個實作

// 假如這套測試是針對 IRefuelable 這個「契約」寫的
void TestRefuelable(IRefuelable vehicle)
{
    vehicle.Refuel(); // 呼叫完,不應該會丟例外
}

TestRefuelable(new Toyota()); // 應該過
TestRefuelable(new Honda()); // 應該過

以後只要新增一個實作 IRefuelable 的類別,直接拿同一套測試跑一次,過了,代表它遵守契約 ; 跑不過(丟例外、斷言失敗),就是抓到 LSP 違反原則

這個技巧業界叫 contract test / 共用測試套件,也是會被來用在 CI 裡自動跑的東西

還可以嗎我的朋友,好,那我們再一起回到故事吧

小黑的新活動

https://ithelp.ithome.com.tw/upload/images/20260902/20183470b7rG2uwjLc.jpg

小黑:「離峰時段太冷清了,美式來個買一送一衝人流吧!」

阿柴心想,先前學會 OCP ,好那促銷邏輯也該比照 OCP 辦理,不要每次都回去改結帳流程,於是設計了一個 Discount 抽象類別,讓不同促銷都繼承它

public abstract class Discount
{
    public abstract decimal Apply(decimal price);
}

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

    public override decimal Apply(decimal price) => price * _rate;
}

public class FixedAmountDiscount : Discount
{
    private readonly decimal _amount;
    public FixedAmountDiscount(decimal amount) => _amount = amount;

    public override decimal Apply(decimal price) => Math.Max(price - _amount, 0);
}

九折、八折、一折或者折抵固定金額,這兩個都只需要「單一價格」就能算,套進 Apply(decimal price) 嘟嘟好

但輪到「買一送一」,阿柴繼承 Discount,寫下去才發現不對勁,買一送一不是「價格打幾折」的問題,而是要知道「這筆訂單買了幾杯美式」,Apply(decimal price) 這個介面根本傳不了數量進來

阿柴想了想,算了先求有再說,額外寫了一個 BuyOneGetOneDiscount 的類別去繼承 Discount

我們來看看小可愛阿柴的寫法

public class BuyOneGetOneDiscount : Discount
{
    public override decimal Apply(decimal price)
    {
        // 買一送一算的是「數量」,不是「單一價格」,這個方法用不上,那就先拋個 NotSupportedException 好了
        throw new NotSupportedException("買一送一請改用 ApplyToOrder(order)");
    }

    public decimal ApplyToOrder(Order order) // 改成塞一個 ApplyToOrder(Order order),還額外多帶參數進去,去支援買一送一的功能
    {
        var americanoItems = order.Items.Where(i => i.DrinkType == DrinkType.Americano).ToList();
        if (!americanoItems.Any()) return 0;

        var freeCount = americanoItems.Sum(i => i.Quantity) / 2;
        return freeCount * americanoItems.First().Price;
    }
}

結帳的地方,本來以為所有 Discount 都可以直接丟進去用,結果繼續寫下去,變成這樣:

public decimal Checkout(Order order, Discount discount)
{
    decimal total = _calculator.Calculate(order);
    if (discount is BuyOneGetOneDiscount buyOneGetOne) // 這裡阿柴額外用 is 去避免錯誤
    {
        total -= buyOneGetOne.ApplyToOrder(order);
    }
    else
    {
        total = discount.Apply(total);
    }
    return total;
}

好目前阿柴寫到這,有了幾個問題

  1. 這樣能不能跑? 能
  2. 這樣符不符合 LSP 原則? 不符合
  3. 多了一個不必要的 is 去判斷,會不會造成可讀性下降? 會
  4. 可擴充性好嗎? 不好
    今天僅使用一個 if-else 去判斷,但如果小黑老闆多想幾個促銷優惠(比如滿千送百、集點兌換),是不是需要無限增長 if-else 去判斷
    除了違反 LSP 還拖累了 OCP (新增需求變成加 class + 改既有程式碼)
  5. 這樣寫會不會被以後的自己或者被前輩氣死? 窩不知道.JPG

那問題出在哪呢

問題不是「繼承」本身,是阿柴設計繼承 Discount 的時候,「沒有」守住父類別的承諾

Discount.Apply(decimal price) 這個方法的承諾是:「給我價格,我還你折扣後的價格」

BuyOneGetOneDiscount 表面上長得像 Discount,一呼叫 Apply 卻直接丟例外,它沒有實現這個承諾,只是「借用」了 Discount 這個外殼

呼叫端本來應該可以放心地說「反正你是 Discount,我就呼叫 Apply」,現在要再額外用 is 辨別真實型別,才知道該怎麼用

那該怎麼安全處理呢

我們可以把「促銷需要什麼資訊」重新定義成同一種契約,不管是打折還是買一送一,讓它們吃得到完整的訂單,而不是只給單一價格

public interface IDiscount
{
    decimal Apply(Order order, decimal currentTotal); // 將訂單跟總金額丟進去
}

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 FixedAmountDiscount : IDiscount
{
    private readonly decimal _amount;
    public FixedAmountDiscount(decimal amount) => _amount = amount;

    public decimal Apply(Order order, decimal currentTotal) => Math.Max(currentTotal - _amount, 0);
}

public class BuyOneGetOneDiscount : IDiscount
{
    public decimal Apply(Order order, decimal currentTotal)
    {
        var americanoItems = order.Items.Where(i => i.DrinkType == DrinkType.Americano).ToList();
        if (!americanoItems.Any()) return currentTotal;

        var freeCount = americanoItems.Sum(i => i.Quantity) / 2;
        return currentTotal - freeCount * americanoItems.First().Price;
    }
}

public class CheckoutService
{
    private readonly OrderCalculator _calculator;

    public CheckoutService(OrderCalculator calculator) => _calculator = calculator;

    public decimal Checkout(Order order, IDiscount discount)
    {
        decimal total = _calculator.Calculate(order);
        return discount.Apply(order, total); // 對三個 class 都成立,不用任何 is 判斷
    }
}

現在不管小黑想什麼新花招,只要實作 IDiscountCheckoutService 都能直接呼叫,不用先問「你是哪個促銷」,這才能互相替換的子型別

用 LSP 當照妖鏡

下次看到繼承結構時,可以反問自己:

  • 子類別 override 的方法裡,是不是會丟例外、回傳空值,還是什麼都不做?
  • 呼叫端有沒有出現 is / as 型別判斷? 如果要先確認「這個物件確切屬於哪個子類別」才敢呼叫,代表子類別沒辦法做到互相替換
  • 呼叫同一個方法,這個子類別是不是要呼叫端多做一件事、或者多準備一份資料,而其他兄弟姊妹都不用?

LSP vs OCP

OCP 講的是「新需求進來,能不能用新增子類別/實作去滿足,而不用改舊程式碼」

LSP 講的是「新增出來的那個子類別,能不能真的安全替換掉父類別」

兩者是搭配關係,沒有 LSP 撐著的 OCP,擴充點只是形式上開放,呼叫端還是得寫 is 判斷,等於沒解決問題

下一篇,我們繼續看 SOLID 的第四條:I——介面隔離原則

延伸閱讀

從零基礎到獲得圖靈獎!提出資料抽象化,奠定程式設計基礎的Barbara Liskov

物件導向程式設計 : 里氏替換原則(LSP)

菜雞與物件導向 (12): 里氏替換原則


上一篇
Day 4|O(開放封閉) 柴咖啡新品銷售,如何擴充菜單才不會搞砸?
下一篇
Day 6|I(介面隔離) 會員制上線,如何不讓集點功能搞垮結帳系統?
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言