Barbara Liskov 提出的定義是: If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program(若 S 是 T 的子型別,則程式中使用 T 物件的地方可以使用 S 物件來替換,而不改變程式的任何預期屬性)。

Barbara Liskov 是麻省理工學院(MIT)的電腦科學教授,這個原則由他在 1987 年的一篇論文中首次提出,後來被 Robert C. Martin 納入 SOLID 體系。

用更白話的方式說:凡是可以使用父類別物件的地方,換成任何子類別的物件,程式應該要能正確執行,結果也應該要符合預期;如果換了子類別之後行為變了,那就是違反了 LSP。


與 OOP 的關係:繼承(Inheritance)

繼承是 OOP 中強大但也容易被濫用的機制,把繼承當作「程式碼複用」的工具:「子類別可以繼承父類別的方法,這樣就不用重寫了。」這個想法本身沒有錯,但它遺漏了繼承更深層的語義。

在物件導向系統中,繼承不只是程式碼的共享機制,它同時建立了一種型別關係(is-a relationship);當寫了 class Dog : Animal 時,不只是讓 Dog 複用了 Animal 的程式碼,同時宣告了「Dog 是一種 Animal」;這個宣告有一個重要的隱含意義:在任何需要 Animal 的場合,都可以放入一隻 Dog,而程式的行為不應改變。

LSP 就是在確保這個「is-a 關係」在行為層面上是真實成立的,而不只是名義上的宣告;若子類別在繼承後改變了父類別建立的行為預期:例如覆寫了一個方法,但讓它拋出例外、或回傳了不符合原有保證的結果,那麼呼叫端就無法安全地以子類別替換父類別;這時繼承不僅沒有幫助,反而成了系統中隱藏的炸彈。

這也是 LSP 的核心:繼承描述的是「行為上的 is-a」,而非「概念分類上的 is-a」,這兩者在現實中不一定吻合。


行為契約(Behavioral Contract)

LSP 的核心概念是「行為契約」,每一個類別(尤其是被繼承的父類別)都隱含了一組關於它的行為保證,這組保證包含三個面向:

  • 前置條件(Preconditions):方法在被呼叫之前,呼叫端需要滿足的條件,例如「傳入的參數不能為 null」、「金額必須大於零」;LSP 要求子類別不能強化前置條件,也就是說,子類別不能要求比父類別更嚴格的輸入,若父類別接受 amount >= 0,子類別不能改成只接受 amount > 100,否則呼叫端在不知情的情況下可能觸發例外。
  • 後置條件(Postconditions):方法執行完畢後,呼叫端可以依賴的結果保證,例如「回傳的列表永遠不為 null」、「儲存後物件的 ID 一定被設定」;LSP 要求子類別不能弱化後置條件,也就是子類別給出的保證不能比父類別少,若父類別保證「不回傳 null」,子類別不能突然可能回傳 null。
  • 不變量(Invariants):物件在整個生命週期中,始終成立的屬性,例如「帳戶餘額永遠不會是負數」、「矩形的寬和高永遠是正整數」;LSP 要求子類別必須完整維持父類別的不變量,不能在繼承後破壞這些恆真的屬性。

程式碼範例

違反 LSP:正方形繼承長方形

這是 LSP 經典的反例,稱為「正方形-長方形問題(Square-Rectangle Problem)」。它很好地說明了「概念上的 is-a」與「行為上的 is-a」之間的差距:

 1public class Rectangle
 2{
 3    // 父類別隱含的行為契約:
 4    // Width 和 Height 是互相獨立的,可以分別設定不同的值
 5    public virtual int Width  { get; set; }
 6    public virtual int Height { get; set; }
 7
 8    public int Area() => Width * Height;
 9
10    public override string ToString() => $"Rectangle({Width}x{Height})";
11}
12
13public class Square : Rectangle
14{
15    // Square 為了維持「正方形的寬高必須相等」的不變量,
16    // 讓 Width 和 Height 的 setter 互相連動
17    // 但這破壞了父類別「寬高獨立設定」的行為契約
18    public override int Width
19    {
20        set { base.Width = base.Height = value; }
21    }
22    public override int Height
23    {
24        set { base.Width = base.Height = value; }
25    }
26}
27
28// 一個依賴 Rectangle 的方法
29void ResizeAndPrint(Rectangle r, int newWidth, int newHeight)
30{
31    r.Width  = newWidth;
32    r.Height = newHeight;
33    
34    // 呼叫端合理地期望:設定 Width=4, Height=6 之後,面積應該是 24
35    Console.WriteLine($"形狀:{r}");
36    Console.WriteLine($"預期面積:{newWidth * newHeight},實際面積:{r.Area()}");
37}
38
39// 測試
40var rect = new Rectangle();
41ResizeAndPrint(rect, 4, 6);
42// 輸出:形狀:Rectangle(4x6)
43// 輸出:預期面積:24,實際面積:24 ✓
44
45var square = new Square();
46ResizeAndPrint(square, 4, 6);
47// 輸出:形狀:Rectangle(6x6)   ← Width 被 Height 的 setter 覆蓋了
48// 輸出:預期面積:24,實際面積:36 ✗
49// 呼叫端以為在操作 Rectangle,但行為已被 Square 悄悄改變

這個問題的根本原因在於:在幾何學上,正方形確實是長方形的特殊情況(概念 is-a);但在行為上,Rectangle 的隱含契約是「寬高可以獨立改變」,而 Square 無法在不違反自身不變量(寬高必須相等)的前提下滿足這個契約(行為 is-a 不成立)。

遵守 LSP:以共同抽象取代不當繼承

正確的做法是重新思考型別階層:RectangleSquare 都是「形狀」的一種,但它們不應有直接的繼承關係。

 1// 共同的抽象基底:只定義兩者真正共有的行為
 2public abstract class Shape
 3{
 4    public abstract int Area();
 5    public abstract int Perimeter();
 6    public abstract string Describe();
 7}
 8
 9// Rectangle:獨立實作,不繼承自 Square,也不被 Square 繼承
10public class Rectangle : Shape
11{
12    public int Width  { get; init; }
13    public int Height { get; init; }
14
15    public Rectangle(int width, int height)
16    {
17        if (width <= 0)  throw new ArgumentException("Width 必須為正整數");
18        if (height <= 0) throw new ArgumentException("Height 必須為正整數");
19        Width  = width;
20        Height = height;
21    }
22
23    public override int Area()      => Width * Height;
24    public override int Perimeter() => 2 * (Width + Height);
25    public override string Describe() => $"矩形({Width} × {Height})";
26}
27
28// Square:獨立實作,有自己的不變量(寬高相等),但不破壞任何父類別契約
29public class Square : Shape
30{
31    public int Side { get; init; }
32
33    public Square(int side)
34    {
35        if (side <= 0) throw new ArgumentException("Side 必須為正整數");
36        Side = side;
37    }
38
39    public override int Area()      => Side * Side;
40    public override int Perimeter() => 4 * Side;
41    public override string Describe() => $"正方形({Side} × {Side})";
42}
43
44// 呼叫端依賴 Shape 抽象,任何子類別都可以安全替換
45void PrintShapeInfo(Shape shape)
46{
47    Console.WriteLine(shape.Describe());
48    Console.WriteLine($"面積:{shape.Area()},周長:{shape.Perimeter()}");
49}
50
51PrintShapeInfo(new Rectangle(4, 6)); // 矩形(4 × 6),面積:24,周長:20
52PrintShapeInfo(new Square(5));       // 正方形(5 × 5),面積:25,周長:20
53// 兩者都正確,行為符合預期 ✓

更多違反 LSP 的常見訊號

子類別拋出 NotImplementedExceptionNotSupportedException

在覆寫父類別方法時,若發現子類別根本不支援這個操作,這幾乎可以確定是違反 LSP 的訊號;子類別宣告它「是」父類別,但拒絕履行父類別承諾的行為。

 1public interface ICollection<T>
 2{
 3    void Add(T item);
 4    void Remove(T item);
 5    int Count { get; }
 6}
 7
 8// ReadOnlyCollection 實作了 ICollection,但根本不支援 Add/Remove
 9public class ReadOnlyCollection<T> : ICollection<T>
10{
11    private readonly List<T> _items;
12
13    public ReadOnlyCollection(IEnumerable<T> items)
14        => _items = new List<T>(items);
15
16    public void Add(T item)    => throw new NotSupportedException("唯讀集合不支援新增"); // ← LSP 違反
17    public void Remove(T item) => throw new NotSupportedException("唯讀集合不支援移除"); // ← LSP 違反
18    public int Count => _items.Count;
19}
20
21// 問題:呼叫端若拿到一個 ICollection<T>,它有理由相信可以呼叫 Add()
22// 若傳入的是 ReadOnlyCollection,就會在執行期爆炸,而不是在編譯期被阻止

正確的解法是定義一個只包含「唯讀」方法的介面(IReadOnlyCollection<T>),讓唯讀集合實作這個較小的介面,而非強行實作包含寫入方法的大介面。

呼叫端需要 is/as 做類型判斷才能正確運作

 1// 若多型設計正確,呼叫端不需要知道具體類型
 2void ProcessShape(Shape shape)
 3{
 4    // 這種 if-is 判斷是 LSP 被破壞的強烈警告訊號
 5    // 它說明 shape 的某些子類別需要「特殊對待」,多型失效了
 6    if (shape is Square sq)
 7    {
 8        // 針對正方形的特殊處理...
 9        Console.WriteLine($"正方形邊長:{sq.Side}");
10    }
11    else if (shape is Rectangle rect)
12    {
13        // 針對長方形的特殊處理...
14        Console.WriteLine($"長方形:{rect.Width}x{rect.Height}");
15    }
16    
17    // 若 Shape 的抽象設計良好,這裡應該只有:
18    // Console.WriteLine(shape.Describe());
19}

LSP 的實務意義:為什麼它比看起來更重要

LSP 的重要性在大型系統中尤其明顯;當程式碼透過介面或抽象類別來處理物件時(這正是 OCP 和 DIP 鼓勵的做法),被預設了一個假設:凡是實作了這個介面的物件,都可以被安全地使用,不需要特別檢查它的具體類型;若是某個實作破壞了這個假設,就會在系統的某個角落埋下難以追查的錯誤;更糟糕的是這種錯誤通常不會在編譯期被發現,而是在執行期的某個特定情境下才爆發。

遵守 LSP 的繼承設計通常具備以下特徵:

  • 子類別的 override 方法,其行為是父類別行為的「具體化」或「特殊化」,而非根本性的改變。
  • 子類別的方法接受與父類別相同(或更寬鬆)的輸入條件。
  • 子類別的方法提供與父類別相同(或更強)的輸出保證。
  • 呼叫端在使用子類別物件時,不需要透過 is/as 進行類型判斷。

數學 is-a 與行為 is-a 的差距

「正方形是不是長方形」這個問題很好地說明了兩種 is-a 的差距:

  • 數學概念上:正方形完全符合長方形的定義(四個直角、對邊平行且相等),所以正方形是長方形的特例,is-a 關係成立。
  • 行為契約上Rectangle 類別的隱含契約是「Width 和 Height 可以獨立改變」,Square 無法在維持自身不變量的前提下滿足這個契約,所以行為上的 is-a 關係不成立。

LSP 要求的是行為上的 is-a,而非概念分類上的 is-a;當設計繼承關係時,核心問題不是「B 在現實概念中是不是 A 的一種?」,而是「在使用 A 的所有場合,換成 B 是否都能正確運作?」


重點回顧

  • LSP 的核心:子類別替換父類別後,程式的行為與正確性不應改變。
  • 行為契約包含三個面向:前置條件(不能強化)、後置條件(不能弱化)、不變量(必須維持)。
  • 違反 LSP 的典型症狀:子類別拋出 NotImplementedException、呼叫端需要 is/as 類型判斷、測試父類別的程式碼無法直接用於子類別。
  • 繼承不等於「程式碼複用」,繼承的本義是「行為契約的傳承」。
  • 當 LSP 無法被滿足時,應重新考慮型別階層設計,通常改用組合(Composition)或共同的更高層抽象是更好的選擇。