接下來我們談到 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(); // 安心呼叫,不用擔心有人會噴錯
}
}
每個子類別只實作自己做得到的介面,不會有人被逼著假裝自己做不到的事
好我們回到 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 原則 ? 其實可以用兩個階段去實現
在寫一個介面(或抽象類別)時,先想一想:「我現在想到的每一種實作,呼叫這個方法,會不會都正確、不用丟例外?」
這是判斷力層面的事,發生在動手寫 interface 那一刻,不是事後補測試
作法: 針對介面的契約寫一套測試,然後拿同一套測試,分別跑過每一個實作
// 假如這套測試是針對 IRefuelable 這個「契約」寫的
void TestRefuelable(IRefuelable vehicle)
{
vehicle.Refuel(); // 呼叫完,不應該會丟例外
}
TestRefuelable(new Toyota()); // 應該過
TestRefuelable(new Honda()); // 應該過
以後只要新增一個實作 IRefuelable 的類別,直接拿同一套測試跑一次,過了,代表它遵守契約 ; 跑不過(丟例外、斷言失敗),就是抓到 LSP 違反原則
這個技巧業界叫 contract test / 共用測試套件,也是會被來用在 CI 裡自動跑的東西
還可以嗎我的朋友,好,那我們再一起回到故事吧

小黑:「離峰時段太冷清了,美式來個買一送一衝人流吧!」
阿柴心想,先前學會 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;
}
好目前阿柴寫到這,有了幾個問題
問題不是「繼承」本身,是阿柴設計繼承 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 判斷
}
}
現在不管小黑想什麼新花招,只要實作 IDiscount,CheckoutService 都能直接呼叫,不用先問「你是哪個促銷」,這才能互相替換的子型別
下次看到繼承結構時,可以反問自己:
is / as 型別判斷? 如果要先確認「這個物件確切屬於哪個子類別」才敢呼叫,代表子類別沒辦法做到互相替換OCP 講的是「新需求進來,能不能用新增子類別/實作去滿足,而不用改舊程式碼」
LSP 講的是「新增出來的那個子類別,能不能真的安全替換掉父類別」
兩者是搭配關係,沒有 LSP 撐著的 OCP,擴充點只是形式上開放,呼叫端還是得寫 is 判斷,等於沒解決問題
下一篇,我們繼續看 SOLID 的第四條:I——介面隔離原則
從零基礎到獲得圖靈獎!提出資料抽象化,奠定程式設計基礎的Barbara Liskov