使人瘋狂的 SOLID 原則:里氏替換原則 (Liskov Substitution Principle)

YC
程式愛好者
Published in
Sep 4, 2020

今天我們要說的是第三個原則:里氏替換原則 (LSP)

很多人以為里氏替換原則只是指導我們如何定義子類別。

LSP 的出處是 Barbara Liskov 的一篇論文:Behavioral Subtyping Using Invariants and Constraints

定義:若對型態 S 的每一個物件 o1,都存在一個型態為 T 的物件 o2,使得在所有針對 T 編寫的程式 P 中,用 o1 替換 o2後,程式 P 的行為功能不變,則 S 是 T 的子型態。

嗯。。。希望你們看得懂,反正我 ╮(╯∀╰)╭。雖然定義看起來很複雜,但這樣的定義我認為可以很準確地表達出 LSP 的思想,即:

子型態必須遵從型態的行為進行設計。

簡單說,假設我們在寫一個模組 P,而模組 P 裡面有用到 Car 類別的物件 car,而今天我們用 BMW 類別的物件 bmw 來替代 car,而 P 的功能都不會被影響的話,那 BMW 就是 Car 的子類別。

也就是說,只要 S 跟 T 替換後,整個 P 的行為沒有差別,那 S 就是 T 的子型態。從類別上來說, S 完全可以繼承 T,成為T的子類別。

那如果我們在子類 Override 父類呢,這樣的動作還符合 LSP 的原則嗎?

我們可以換個角度來看 LSP 原則。

按照 Design by Contract 設計方法,遵守 LSP 就是遵守以下三個條件:

1.子型態的先決條件 (Preconditions) 不應被加強。

先決條件是指執行一段程式前必須成立的條件。使用者在使用子型態前,要確保子型態的先決條件不會比父型態的更強,但可以削弱。

如一個整數相加功能,輸入的參數必須為 2 個整體並回傳一個整數,且輸入的數字不能小於 0 及大於 50 (先決條件)。

let sum = 0;// a,b 必須 >= 0 && <= 50
function add(int a, int b)
{
result = a + b;
return result;
}
sum = add(1,5)

子型態在覆寫這功能時,先決條件不能比父型態強。若父型態輸入的數字要求是「不能小於 0 及大於 50」,子型態輸入的數字則不能是「不能小於 0 及大於 51」,但可以是「不能小於 0 及大於 30」。

2.子型態的後置條件 (Postconditions) 不應被削弱。

後置條件是指執行一段程式後必須成立的條件。使用者在使用子型態後,要確保子型態的後置條件不會比父型態的更弱,但可以加強其後置條件。

let sum = 0;// a,b 必須 >= 0 && <= 50
function add(int a, int b)
{
result = a + b;
// 回傳型態必須為 int
return result;
}
// result 必須等於 sum
sum = add(1,5)
//加強條件
let sum2 = 0;
sum2 = add(1,5)

以相同的例子,這邊的後置條件是回傳的型別必須為 int。即子型態不能回傳非 int 型別,如最後把 int 轉成 String 再回傳。

子型態加強其後置條件,如上例,除了 result 必須等於 sum 外,子型態也可以加強條件,讓 result 也必須等於 sum2。

3. 父型態的不變條件 (Invariants) 必須被子型態所保留。

不變條件指不管在何時何地都不能改變,這是構成整個型態的重要條件。同樣地,子型態必須遵守父型態的不變條件,若然加以修改或不遵守,則會導致多型的重大失敗。

所以,只要 Override 有遵守以上三個原則,他就是符合了 LSP 原則。

試想像一下,如果父類與子類在面對一樣的參數時,子類拋出錯誤,而父類並沒有,或者一個子類有不可預期的副作用等等,這些都是名不符實,沒有真的遵從父類的行為

以單元測試為例,如果今天寫一個多型的測試,但子類的注入得不到跟父類注入時一樣的結果,單元測試就不會通過,也就表示這樣的子類別不符合 LSP 原則。

其實 LSP 在類別的應用上非常容易明白,但真正難以理解的是要將 LSP 放到軟體架構層來看。

在軟體架構層中,我們會期待被同一群使用者所呼叫的介面都有著一樣的行為。

LSP 是指 T只要能被 S 替代,S 就是 T 的子型態。換句話說,我們不希望在為軟體進行某程度的更新後,行為就變得不一致了。如在專案上,現在有套件 A 更新了, 我們會期待套件的更新不會影響原有程式的運作,而不是更新後一堆東西不能用了。

即使用者只依賴於介面,不需要了解到程式的內部在發生什麼事。今天不管是修 bug、是重構、是用全近的語言來寫,讓版本從 1.0 -> 1.1,我們都是期待一致的行為。如此,就是乎合 LSP 原則的軟體架構。

總結一下,繼承請不要隨意使用。因為繼承是依賴性超強的一個特性,如果稍有一項沒有做對,你的子類就會做出超乎預期的行為,在整個系統已經建構起來後,修改起來會是一程地獄之旅。

如果你覺得我的文章幫助到你,希望你也可以為文章拍手,分別 Follow 我的個人頁與程式愛好者出版,按讚我們的粉絲頁喔,支持我們推出更多更好的內容創作!

--

--

YC
程式愛好者

提供更精確的技術內容為目標,另創立「程式愛好者」專頁。資深軟體工程師,專研後端技術、物件導向、軟體架構。