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:以共同抽象取代不當繼承
正確的做法是重新思考型別階層:Rectangle 和 Square 都是「形狀」的一種,但它們不應有直接的繼承關係。
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 的常見訊號
子類別拋出 NotImplementedException 或 NotSupportedException
在覆寫父類別方法時,若發現子類別根本不支援這個操作,這幾乎可以確定是違反 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)或共同的更高層抽象是更好的選擇。