里氏替換原則 LSP 深度指南與高品質實踐 實施 SOLID LSP 契約式設計優化複用相容性

me
林彥成
2023-10-06 | 4 min.
文章目錄
  1. 1. 什麼是里氏替換原則 (LSP)?
  2. 2. 里氏替換原則
  3. 3. 契約式設計
  4. 4. FAQ:里氏替換原則常見問題
    1. 4.1. Q1:如何判斷我的程式碼違反了里氏替換原則 (LSP)?
    2. 4.2. Q2:繼承 (Inheritance) 一定會符合 LSP 嗎?
    3. 4.3. Q3:LSP 對於前端 React 元件開發有什麼啟發?

什麼是里氏替換原則 (LSP)?

里氏替換原則 (Liskov Substitution Principle, LSP) 是物件導向設計 SOLID 原則中的第三項,核心定義為:衍生類別 (子類) 物件必須能夠在程式中完美替換其基礎類別 (超類) 物件,且不影響程式的正確性。這意味著子類除了要實作父類的行為外,還必須遵守父類所承諾的「契約」。在實務中,這要求子類不能縮減父類的輸入預期,也不能放寬父類的輸出結果。遵循 LSP 能確保系統具備高度的可互換性與擴展性,當開發者新增功能(如新增 RPG 玩家類型或手機登入方式)時,現有的呼叫端邏輯無需更動即可平滑運行,是建構強韌軟體架構的重要基石。


在模組化收納中,會希望符合規範的模組可以互相替換,想像一下我們有一個很多抽屜的櫃子,抽屜是設計成可替換的,如果今天櫃子的拼布抽屜髒了或壞了也可以改成木製或是塑膠製,這個概念就是里氏替換原則,而符合規範的概念則是契約式設計。

里氏替換原則

里氏替換原則 (Liskov Substitution Principle) 是對子類型的特別定義:

衍生類別 (子類) 物件可以在程式中代替其基礎類別 (超類) 物件。

舉個常見的手機多種登入方式來說,對於身份驗證和登入方式的設計,通常建議提供多個選項,各種登入方式(如 FaceID、圖形、PIN 碼、指紋等)可以互相替代,以滿足不同使用者的需求和偏好。

讓我們複習一下之前提到的情境描述,假設我們正在開發一個角色扮演遊戲 (RPG) 的程式,其中有不同類型的玩家,包括基本玩家和進階玩家,每個玩家類型都有自己的檢查條件,重構的目標是希望確保程式碼易於擴充,以應對未來可能新增的玩家類型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 建立玩家物件,使用包含屬性的物件作為參數
const player1 = createPlayer({
name: "玩家1",
health: 100,
damage: 10,
});
const player2 = createAdvancedPlayer({
name: "玩家2",
health: 150,
damage: 15,
agility: 20,
});

// 處理玩家資料
processPlayerData(player1);
// 輸出:玩家1 符合基本條件
processPlayerData(player2);
// 輸出:玩家2 符合基本條件
// 輸出:玩家2 符合進階條件

在這個例子中會有一個基礎的 Player 物件該有的規範,首先我們定義了一個基本的玩家函式 createPlayer,它接受包含玩家屬性的物件作為參數,這個函式包含擁有通用檢查函式的玩家物件,該函式確保了基本玩家類別遵循了 Player 介面 (LSP 的一部分)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定義一個基本玩家函式,接受包含玩家屬性的物件作為參數
function createPlayer(playerData) {
const { name, health, damage } = playerData;
return {
name,
health,
damage,

// 通用的檢查函式,每個玩家都可以使用
checkPlayerCondition() {
return this.health > 10 && (this.name === "foo" || this.damage < 5);
},
};
}

接著,我們定義了進階玩家函式 createAdvancedPlayer,擴充了基本玩家,同樣接受包含進階玩家屬性的物件作為參數。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定義進階玩家函式,接受包含進階玩家屬性的物件作為參數
function createAdvancedPlayer(advancedPlayerData) {
const { name, health, damage, agility } = advancedPlayerData;
const player = createPlayer({ name, health, damage });
return {
...player,
agility,

// 進階玩家特有的檢查函式
checkAdvancedPlayerCondition() {
return this.name !== "bar" || this.agility > 20;
},
};
}

這確保了進階玩家類別同樣遵循了 Player 介面,擁有基本 Player 物件該有的規範和通用檢查函式 checkPlayerCondition,所以理論上要能替換基本玩家而不會對程式造成問題。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 通用的處理玩家資料函式,接受任何類型的玩家物件作為參數
function processPlayerData(player) {
if (player.checkPlayerCondition()) {
// 做一些基本玩家的操作
console.log(`${player.name} 符合基本條件`);
}

if (
player?.checkAdvancedPlayerCondition &&
player?.checkAdvancedPlayerCondition()
) {
// 做一些進階玩家的操作
console.log(`${player.name} 符合進階條件`);
}

// 非常長的函式內容...
if (statusCode === 20100) {
// ...
}
}

契約式設計

契約式設計 (Design by Contract) 是一個非常重要的軟體設計原則,它強調一旦確立了契約或介面,就應該堅守下去,不輕易變更。

這個原則有助於確保系統的穩定性和可預測性,舉個常見的 API 版本號碼當例子,小編的第一個工作就需要提供 API 服務各種版本的 App,當需要對 API 做出變更或加入新功能時一定要注意相容舊版,這時候增加版本號碼是一個常見的做法,以確保現有的客戶端不會受到破壞。

  • /api/v1/user-info
  • /api/v2/user-info

在 API 設計中,一旦確立了一個特定的輸入和輸出格式,建議堅持不輕易變更它,因為這會影響到使用該 API 的客戶端應用程式。每當對 API 的輸入或輸出格式進行重大變更時,可以建立一個新的版本在路徑中,使得新的客戶端可以選擇使用新版本,而不影響現有客戶端。

契約式設計原則有助於減少後續變更對現有功能的影響,同時確保用戶能夠方便地使用系統。


FAQ:里氏替換原則常見問題

Q1:如何判斷我的程式碼違反了里氏替換原則 (LSP)?

A:最明顯的跡象是「型別檢查」與「空值判斷」。如果在呼叫端程式碼中,您必須撰寫 if (obj instanceof SubClass) 或是頻繁地使用 obj?.specialMethod 來判斷某個子類特有的功能,這通常意謂著子類無法透明地替代父類,即違反了 LSP。

Q2:繼承 (Inheritance) 一定會符合 LSP 嗎?

A:不一定。 繼承只是語法上的關聯,LSP 要求的是「行為上的相容」。例如經典的「正方形繼承長方形」問題:正方形雖然是長方形的一種,但如果修改長方形的「寬」會同時改變正方形的「高」,這就破壞了呼叫端對長方形行為的預期,因而違反 LSP。

Q3:LSP 對於前端 React 元件開發有什麼啟發?

A:這體現於 HOC (高階元件)Render Props 的設計。如果一個封裝後的元件(子元件)無法接受並處理所有原始元件(父元件)所支援的 Props,或者改變了原始事件的觸發邏輯,這會讓使用者感到困惑並增加維護難度。保持 Props 介面的一致性是前端實踐 LSP 的關鍵。



喜歡這篇文章,請幫忙拍拍手喔 🤣