Liskov substitution principle (LSP) 里氏替換原則

子類別必須要能完全取代它的父類別

Huashen
Apr 10, 2021

什麼是LSP

里氏替換原則,簡稱LSP(Liskov substitution principle),是專門針對繼承來做規範的一個原則。LSP的說明是這樣 : 子類別必須要能完全取代它的父類別,何謂完全取代?

鴕鳥是鳥類,但鴕鳥不會飛,因此鴕鳥無法取代鳥類,違反了LSP原則。

為什麼要有LSP

OCP原則告訴我們,面對修改時,不應該直接修改舊的類別,而是應該以繼承介面的方式來重新實作新類別、擴充新的功能。

但是繼承實際上不一定是個好作法,因為使用繼承會為系統帶來更高的耦合度,因此最簡單的做法就是"能不繼承就不要繼承"。但這代表我們完全不能使用任何繼承嗎?未必,還是有方法可以讓我們妥善地使用繼承,那就是遵守LSP原則

如何做到LSP

比起其他的原則告訴我們該如何設計,LSP更像是規範我們不該如此設計。在Liskov的論文中,提到了7個設計的規則:

1 . 子類別輸入參數的數量、類型不能比父類型更嚴謹。

我們思考一下:若是子類別的方法或建構子(constructor)輸入的參數數量類型比父類別更加嚴謹(更加嚴格),就意味著符合父類別要求的輸入參數不一定可以符合子類別更加嚴謹的要求,因而無法被子類別接受,此時子類別就不可以替換掉父類別。

子類別接受參數比父類別更嚴謹,導致能被父類別接受的參數並不一定能被子類別所接受。

我們來看一個簡單的範例:

在這裡我們定義一個動物(Animal)類別,並允許動物不一定要有名字,接下來定義了一個寵物(Pet)類別繼承動物,而寵物的名字則是必要的,這會造成什麼結果?

我們可以產生一個沒有名字的動物,但同樣的條件卻不能產生寵物,因為寵物的產生條件比動物更嚴格了,無法用寵物取代動物,不符合LSP原則。

2 . 子類別輸出參數的數量、類型不能比父類型更寬鬆。

若是子類別的方法輸出的參數數量類型比父類型更加寬鬆,可能會導致子類別方法的回傳參數無法被原先使用父類別參數的程式碼接受,因為能夠接受父類別參數,不一定可以接受子類別更加寬鬆的參數,這樣子類別就不可以替換掉父類別。

子類別輸出參數比父類別更寬鬆,導致子類別輸出的參數並不一定能被父類別所輸出。

這裡給個稍微複雜一點的範例。

這裡我們定義了一個基本的訊息(Message)類別,以及繼承了基本訊息的署名訊息(SignedMessage)類別,另外定義了兩個用來發送訊息的電話(Phone)類別以及繼承電話的公共電話(PublicPhone)類別。

可以看到父類別電話的sendMessage方法回傳的是有署名的SignedMessage,而子類別PublicPhone卻是回傳沒有署名的父類別Message,Message缺少的logName方法將會導致我們發生錯誤,因此不符合LSP原則。

3 . 子類別拋出錯誤時,錯誤的類型不能比父類型更寬鬆

這一個規則跟剛剛提到的輸出參數規則相似,子類別拋出的錯誤必須不能比父類別可能拋出的錯誤更寬鬆,才不會有程式碼可以處理父類別錯誤,但沒辦法處理子類別錯誤的意外狀況發生。

4 . 子類別需要的前置條件不能比父類別更嚴謹。

需要的前置條件可以理解為運作的前提,只要是能夠使父類別運作的條件,也應該要能讓子類別運作。這個規則跟前面提到的輸入參數規則非常相近,我們再看一次輸入參數規則的範例:

原先的Animal類別允許名字是undefined,但是在繼承Animal的Pet類別卻不允許傳入undefined,代表子類別Pet運作的前置條件比父類別Animal更嚴謹,不符合LSP原則。

5 . 子類別影響的後置條件不能比父類別更寬鬆。

這一點也跟剛才的第2、3點提到的非常相近,我們直接看一下範例:

我們看到信用卡(CreditCard)類別的getMoney方法,無論如何都會return一個number,但是子類別金融卡(DebitCard)的getMoney卻有可能得到null這個結果,跟原先預期回傳的number不同,違反了LSP原則。若反過來讓信用卡去繼承金融卡,才是符合LSP的設計方法。

在這裡我們先稍微暫停一下,其實看到前面的5個規則,幾乎都是大同小異的,以上規則可以讓我們得出一個結論,那就是:

子類別的輸入一定不能更嚴謹,輸出一定不能更寬鬆。

以上是一般軟體設計中較常遇到的狀況,也是比較容易發現的錯誤類型,因此建議可以再次複習一下這樣規範的原因,再往下看下面兩個比較不一樣的概念。

(大家可以趁這個時間回過頭檢查一下,上一篇 OCP原則的設計上有什麼樣的問題)

接下來兩個規則相較於前面的規則來說更加抽象。因為前面的規則都是違反了結構設計,靠著靜態語言(例如我們使用的TypeScript)的特性,就能夠在編譯時期(TypeScript則為轉譯)直接找出問題。而下面兩個規則牽涉的則是狀態設計,若違反了狀態設計,只有在得到預期之外的結果時,才會知道發生了問題。

6 . 子類別的不變量必須包含父類別的不變量。

這裡所指的不變量,指的其實是"不變的規則",例如類別本身的設計邏輯。子類別的規則一定至少需要包含父類別的規則。這裡有個知名的例子,正方形和長方形:

國小的數學就告訴過我們,正方形是長方形的一種,但在程式設計中卻不是這個樣子。原因在於,長方形的"長跟寬不會受到彼此的影響",這個就是長方形不變的規則。但正方形不是這樣,當我們改變了正方形的長,也等於改變了他的寬,這樣的做法改變了父類別的規則,讓我們得到了不同的結果,違反了LSP原則。

7 . 子類別受約束的屬性必須包含父類別受約束的屬性。

這句話的意思是,如果父類別有受到約束(不能變更)的屬性,那麼子類別必須不能修改該屬性。讓我們直接來看一個例子:

這裡我們有一個可以設定密碼的置物櫃(Locker)類別,這種置物櫃一旦設定了密碼就無法更改了,接下來我們又製造了一個新置物櫃類別,並讓他繼承Locker,新置物櫃多了一個changePassword方法,現在我們可以修改置物櫃密碼了,但這麼做是違反LSP原則的,這麼做的問題在哪裡呢?

在舊置物櫃中,使用者知道他們的密碼是永遠都不會被改變的,但若我們用新置物櫃來取代所有舊置物櫃,並不是每個使用者都知道置物櫃的實作被替換了,有可能導致不小心更改到密碼這樣的意外發生。

總結一下

以上7個規則,能夠幫助我們更好的檢驗繼承的適當性,在不該使用繼承的地方我們就不要使用,這樣能避免掉許多潛在問題。

回歸到一開始說的 :

能不繼承就不要繼承

現在的設計方法中,越來越少看到繼承的身影了,那是因為繼承所能達到的功能,幾乎都可以透過其他的方式來達成,例如DIP、Composition等等,這些方式都有比繼承更低的耦合性,使用上也方便許多,在未來我們都會逐一看到。

這篇文章中有許多的範例程式碼,主要是希望讓大家能夠更容易理解LSP,礙於篇幅關係,我會盡量以簡短的範例來說明,因此可能會有不夠深入或清晰的地方。如果你有更好的舉例,歡迎留言告訴我,讓我能夠用更好的方式將知識帶給大家。

下一篇文章<Interface segregation principle 介面隔離原則>

如果對於我的文章或程式碼有任何問題,歡迎在下方留言指教。

若有幫助到你,也歡迎給文章拍手一下,讓我在寫文章的路上更加進步!

--

--

Huashen

嗨,我是Huashen,一位軟體工程師,這裡會記錄我的程式設計心得與筆記。