本章介紹 Liskov 替換原則(LSP),由 Barbara Liskov 於 1988 年提出。LSP 為繼承的正確使用提供了嚴格的指引——它重新定義了物件導向中「IS-A」關係的真正含義,並說明違反 LSP 如何導致 OCP 也被破壞。

原則定義#

子型別必須能夠替換其基底型別。 (Subtypes must be substitutable for their base types.)

換言之,使用基底類別參考的函式,必須能在不知情的情況下使用衍生類別的物件,且行為仍然正確。

違反症狀#

  • 客戶端程式碼中出現型別檢查if (shape is Square))——這是 OCP 違反的徵兆,而根源往往是 LSP 違反
  • 衍生類別的方法拋出基底類別不會拋出的例外
  • 衍生類別中存在退化函式(degenerate functions)——實作為空的方法覆寫

注意: LSP 的違反是一種潛伏的 OCP 違反。當子型別不能完全替換基底型別時,客戶端就被迫加入型別檢查或特殊處理邏輯,打破了對修改封閉的特性。

經典案例:Square 與 Rectangle#

直覺的繼承關係#

從數學上看,正方形 IS-A 矩形。因此直覺上會讓 Square 繼承 Rectangle

public class Rectangle
{
    private double width;
    private double height;

    public virtual double Width
    {
        get { return width; }
        set { width = value; }
    }

    public virtual double Height
    {
        get { return height; }
        set { height = value; }
    }

    public double Area()
    {
        return width * height;
    }
}

Square 覆寫 WidthHeight,確保兩者始終相等:

public class Square : Rectangle
{
    public override double Width
    {
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }

    public override double Height
    {
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}

真正的問題#

Square 本身看起來沒問題。但考慮以下使用 Rectangle 的函式:

void g(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Debug.Assert(r.Area() == 20);
}

這個函式對 Rectangle 是合理的——設定寬為 5、高為 4,面積應為 20。但傳入 Square 時,設定高為 4 會連帶將寬也改為 4,導致面積為 16 而非 20——斷言失敗

g 的作者做了一個合理的假設:設定寬度不會影響高度。這個假設對 Rectangle 成立,但對 Square 不成立。Square 不能替換 Rectangle——LSP 被違反了

有效性不是內在的#

重點: 一個模型的有效性不能孤立地判斷——必須從客戶端的角度來看。Square 類別本身可能設計得很完美,但它作為 Rectangle 的替代品是否有效,取決於客戶端對 Rectangle 做了什麼樣的行為假設

IS-A 是關於行為的#

在物件導向設計中,IS-A 關係是關於行為的,不是關於數學分類的。Square 在數學上 IS-A Rectangle,但在行為上——從客戶端的使用方式來看——它並不是。

物件導向中的 IS-A 定義:物件 A 可以 IS-A 物件 B,當且僅當所有對 B 合理的操作,對 A 也都合理

Design by Contract(契約式設計)#

Bertrand Meyer 的 Design by Contract(DBC) 提供了更精確的 LSP 驗證方式:

  • 前置條件(Preconditions):衍生類別的方法只能用相同或更弱的前置條件取代基底類別的前置條件
  • 後置條件(Postconditions):衍生類別的方法只能用相同或更強的後置條件取代基底類別的後置條件

回到 Square 的例子:Rectangle.Width 的 setter 有一個隱含的後置條件——設定寬度後,高度不變。Square.Width 的 setter 違反了這個後置條件(它也改了高度),因此違反了契約。

技巧: 可以在單元測試中明確指定基底類別的契約。這些測試同時也適用於所有衍生類別——如果衍生類別通不過基底類別的測試,就說明 LSP 被違反了。

真實世界的案例:容器類別#

Figure 10.2: Container class adapter layer

假設有一個第三方的 Set 容器類別,以及一個 PersistentSet 需要只允許加入 PersistentObject 類型的元素。如果 PersistentSet 繼承 Set,並在 Add 方法中檢查傳入的物件是否為 PersistentObject,不是就拋出例外——這就違反了 LSP,因為 SetAdd 方法沒有這個限制。

正確的做法是不讓 PersistentSet 繼承 Set,而是讓兩者成為平行的實作,都實作一個更通用的介面(如 MemberContainer),或者使用組合(composition)而非繼承。

分解取代繼承(Factoring Instead of Deriving)#

另一個常見的例子是 Line(直線)與 LineSegment(線段)。直覺上線段 IS-A 直線,但直線有些行為(如無限延伸)不適用於線段。反過來讓直線繼承線段也不合理。

正確的做法是抽取共用的基底類別 LinearObject,讓 LineLineSegment 都繼承它,各自加入自己獨特的行為。這種方式稱為分解(factoring)——找出共同的行為並提取到基底類別中。

經驗法則#

以下跡象暗示 LSP 可能被違反:

  1. 退化函式(Degenerate Functions):衍生類別中覆寫基底類別的方法,但實作為空——這意味著衍生類別不需要這個行為,暗示 IS-A 關係有問題
  2. 從衍生類別拋出例外:如果衍生類別的方法拋出基底類別中不會拋出的例外,使用者就必須知道衍生類別的存在並做特殊處理——破壞了替換性

本章小結#

LSP 揭示了一個重要的事實:IS-A 關係是關於行為的,而行為是由客戶端的合理期望所定義的。違反 LSP 會迫使客戶端了解衍生類別的具體細節,進而破壞 OCP。正確運用 LSP 的關鍵在於,始終從客戶端的角度思考繼承關係是否成立。Design by Contract 是驗證 LSP 的有力工具,而單元測試則是在實務中指定契約的最佳方式。