用 TypeScript 簡單介紹 SOLID 原則裡面的 L

此為 SOLID 原則介紹的系列文章之一,所有文章的連結如下。

SOLID 原則中的 L


里氏替換原則 (Liskov substitution principle),也就是 SOLID 原則裡面的 L。

芭芭拉·利斯科夫 (Barbara Liskov) 在一次名為「資料的抽象與層次」的演說中首先提出,後與周以真 (Jeannette Wing) 一同發表論文,正式提出里氏替換原則。

芭芭拉是美國第一位計算機科學女博士,也是 2008 年圖靈獎得主,此乃神人也。

里氏替換原則的說明如下。

if S is a subtype of T, then objects of type T may be replaced with objects of type S .
如果類別 T 有一個子類別 S,那麼 T 可以被 S 替換而不會發生任何錯誤。

Laracasts 的講師的說明如下。

Derived classes must be substitutable for their base classes
衍生的子類別必須可以替換成他們繼承的父類別

里氏替換原則主要是對繼承的規則做出描述,很多時候我們會因為想要重複使用方法而繼承,但是里氏替換原則告訴我們。

繼承不能隨便亂用。

因為繼承是很強的依賴關係,會讓兩個類別有著高度的耦合,在子類別中的方法,其行為必須要依據父類別方法的行為去做設計,以避免意想不到的錯誤產生。

舉個長方形與正方形例子。

// 長方形
class Rectangle {
    public width: number
    public height: number

    constructor(width: number, height: number) {
        // 檢查長與寬是否都大於 0
        if (width <= 0 || height <= 0) {
            throw new Error('Width and height must be bigger than 0')
        }
    
        this.width = width
        this.height = height
    }

    setWidth(width: number): void {
        this.width = width
    }

    setHeight(height: number): void {
        this.height = height
    }

    area(): number {
        return this.width * this.height
    }
}

// 正方形
class Square extends Rectangle {
    setWidth(width: number): void {
        this.width = width
        this.height = width
    }

    setHeight(height: number): void {
        this.width = height
        this.height = height
    }
}

// 寬 + 1
function increaseRectangleWidth(rectangle: Rectangle): void {
    rectangle.setWidth(rectangle.width + 1)
}

// 假設我們不知道 Rectangle 與 Square 的內部實際上做了什麼
// 我們以為給了相同的長與寬,面積會是相等的
const rectangleOne = new Rectangle(5, 5)
const squareOne = new Square(5, 5)

// 驗證一下面積是否相等
// true
console.log(rectangleOne.area() === squareOne.area())

// 讓 rectangleOne 與 squareOne 的寬都 + 1
// 假設我們依然以為面積會是相等的
increaseRectangleWidth(rectangleOne)
increaseRectangleWidth(squareOne)

// 因為正方形會強制讓長寬相等,所以面積結果就會出現差異
// false
console.log(rectangleOne.area() === squareOne.area())

正方形屬於長方形的一種,那麼在這裡 Square 類別繼承 Rectangle 類別應該沒有問題吧?
但是 Square 類別會做一件 Rectangle 類別不會做的行為。

寬與長會強迫相等。

重點就在類別的行為,子類別的行為必須遵循父類別的行為,避免出現意想不到的結果。

但依照這個方式是否符合里氏替換原則,依然有點抽象,所以里氏替換原則大致整理了三個條件。
如果想要成為一個父類別 (Supertype) 的子類別 (Subtype),必須符合這三個條件。

  • Preconditions cannot be strengthened in the subtype.
    子類別的先決條件不能比父類別更強。
  • Postconditions cannot be weakened in the subtype.
    子類別的後置條件不能比父類別更弱。
  • Invariants must be preserved in the subtype.
    子類別必須要保留父類別的不變條件。

先決條件指的是在執行一段程式碼之前,必須要先成立的條件。
例如上述程式碼範例中,如果想要建立一個 Rectangle 實例,長與寬必須都大於 0,否則會拋出錯誤,這就是 Rectangle 類別的前置條件。

class Rectangle {
    // ...
    
    constructor(width: number, height: number) {
        if (width <= 0 || height <= 0) {
            throw new Error('Width and height must be bigger than 0')
        }
    
        this.width = width
        this.height = height
    }
    
    // ...
}

因此繼承 Rectangle 的子類別,其前置條件的長與寬也必須大於 0,但不能增強這個條件,例如多一個長與寬必須相等的條件,這就會違反里氏替換原則。

if (width <= 0 || height <= 0) {
    throw new Error('Width and height must be bigger than 0')
}

// 前置條件,子類別不能比父類別更嚴格
if (width !== height) {
    throw new Error('Width and height must be equal')
}

後置條件則是相反,指的是執行程式碼過後,必須要成立的條件。
例如長與寬相乘,返回的面積一定大於 0。

area(): number {
    return this.width * this.height
}

不變條件是指程式執行過程或部分過程中,始終可被假定成立的條件。
以 Rectangle 類別舉例,長與寬小於 0 會拋出錯誤,不會強迫讓長與寬相等這些條件
都是 Rectangle 類別的不變條件。

參考資料


sharkHead
written by
sharkHead

後端工程師, PHP 基金會每月 5 鎂小額贊助人 稍微擅長 PHP、Python 與 Google Search,偶爾寫寫 TypeScript 對於逗號後面必須加空格有著絕對的堅持