什麼是好的程式碼?從 SOLID 的設計原則出發(四)里氏替換原則(Liskov Substitution Principle, LSP)
里氏替換原則(Liskov Substitution principle, LSP)
“If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.)”
Liskov 在 1987 年在一次會議上名為「資料的抽象與層次」的演說中首先提出概念。在講這個原則之前,我們簡單聊聊 Inheritance(繼承)
的由來:
- 在 1960–1970 年代,大型軟體的開發經驗讓工程師們了解要有一定的方法論來協助大型軟體系統的開發。
- 當時提出的方法是
結構化程式設計(structured programming)
:透過流程控制、程式區塊(blocks
)、子程序(subroutines
)等語言工具的使用來讓大型程式流程能被分解成不同的小型組件(modules
)、更加清晰易理解。 - 結構化後的系統可以視為是多個程式組件所組裝而成,在實際開發經驗上,就可以感受到
常會有需要開發多個功能類似組件的需求
。 - 在 1970 年代中,Smalltalk 等語言加入了
能讓組件之間建立關聯的語言特性
,例如組件 A 在與組件 B 建立關聯後可以取得 B 的所有操作與資料,此將程式組件建立關聯的語言工具稱為「繼承 Inheritance
」。
知道了由來,我們大概瞭解一下繼承的概念:
- 一個類別可以繼承另一個類別的屬性(
property
)、方法(method
)及其他特性。 - 一個類別繼承其他類別時,這個類別會被稱為子類別(
subclass
)。被繼承的類別則是稱為父類別(superclass
)。
舉一個簡單的例子,在你的程式碼中有幾個相機類別,分別有 Sony, Canon, Nikon,他們裡面的 property 屬性和 method 方法,幾乎一模一樣,此時你會如何設計他們?對繼承有概念的人,第一個想到的解決方案一定就是繼承了,沒錯在這個情境下是很適合用。
繼承 Camera 後,所有牌子的相機都可以馬上擁有「快門、光圈」的屬性以及拍照功能,大大減少我們複製貼上 code,好讓程式碼 reuse。
繼承所帶來的問題
但還記得嗎?在前一篇(依賴反轉原則 DIP )我們一直試圖想解決組件之間的依賴問題,想辦法解耦,但是只要使用了「繼承」,superclass(父類別) 和 subclass(子類別) 之間的關係就會變成很緊密,是一種強耦合關係。
假設我們在父類別加了一堆功能跟屬性,但是我們的子類別根本用不到,或甚至它不該有這些東西時,也無可避免,會強制擁有。像前面敘述的相機例子,假設我們在父類別加上了調整快門、光圈的功能(這幾乎是每台相機都有的功能),但今天有一台傻瓜相機繼承了這個相機父類別,可是他明明就只有按下快門這功能,它還是會繼承擁有「調整快門、光圈」,這些不屬於它的功能。
Liskov 建議在使用繼承來建立 subclass 時(也就是里氏替換原則
)要注意的關鍵就是:「subclass 必須要能取代它的 superclass
」,意思是說你在使用 superclass 的地方,subclass 一定可以代替他,而且替換後是不會出現錯誤或異常
。
例子:
- 沿用我們剛剛的相機例子,我們現在有一個
相機(Camera)
的Class
,然後有 Sony, Canon, Nikon 好多牌子的相機都繼承它,在使用都很正常,但是假如有天有個 Lego(積木樂高)的相機也繼承了Camera Class
,那就糟糕了,因為它是積木玩具,它沒有快門、光圈這些東西,而且無法實作拍照的功能,這時 Lego 繼承 Camera 就違反了 LSP 。 - 假設你有一個叫
鳥(Bird)
的Class
,其中他有一個你覺得所有鳥類都能做的事情「飛」,所以寫一個了 method (fly),它是所有鳥類的 superclass ,一開始鸚鵡繼承了,使用上很正常,過來老鷹、貓頭鷹都來繼承,都很順利,但有一天,企鵝也繼承Bird Class
,想必這時你發現了問題,企鵝他是鳥類…但他會飛嗎?
如何使用 LSP 在程式碼層級做好?
- 里氏替換原則是當我們使用繼承機制時需要遵守的原則。
- 假設有 code 想要 reuse ,
優先考慮組合(Composition)
。
在 Swift 的POP (Protocol Oriented Programming)
概念就是很好的解決方式。 - 當真的需要多態時,
介面(Interface)
和抽象類(Abstract class)
會是更好的選擇。 - 當要使用繼承之前,先問自己:「做好 LSP 了嗎?」「做好多態了嗎?」「是不是一個 composition 不能解的情境嗎?」
如果這些問題的回答有任何一個「不」,那代表我們其實不需要用繼承。 - 簡單來說就是「
如果沒事,就不要用繼承
」。
擴展闡述
- Robert C. Martin 在後續對 LSP 的闡述,視角逐漸提升:
所有對某個介面(可能是語言中的 Interface 或 duck-typing 的共認方法簽名,甚至 Restful API(例如廠商之間對接,互相制定好的 Restful API 格式))的實現都可以視作為對該介面的「subtyping」。
- 依賴這些介面的使用者,均期待這些介面的實現必須要有可替換性。意思就是說
無論介面背後的實現如何更動、調整,其行為都必須與當初對客戶的承諾一致。
否則,使用者對介面的使用需要根據實現而調整,即發生了客戶端對被使用組件的反向依賴。
舉例來說:假設你跟其他公司在合作,對接 API ,你給對方某個介面,就算往後我們換了這介面背後的實作方式(不管換語言、換Server 或維護團隊變動),在這介面使用上還是要跟當初一樣的結果,假如無法做到就變成對方反向依賴,要依賴我們的介面,我們一變動他們就要跟著一起改。 - 例子1: 這很像是我們在維護 iOS App ,可能某個 Apple 給的 API,在 iOS 10–15 是某個預期的行為,但在新版 iOS 16 時,行為竟然和之前不一樣或者有差異,我們這時就要寫
if else
對版本邏輯判斷,這就表示違反 Robert C. Martin 在對 LSP 的更高角度的想法。 - 例子2: App 在發布後,表示 Server API 的使用方式已固定了,所以 App 端完全信任這些 API 的行為會與文件及串接時一致,所以 Server API 的行為在任何時候,都必須與當初完全一致並穩定。
Server API 此時就是系統行為的抽象/描述,在給定後,無論後端實現如何,都必須與當時承諾一致。
小結
透過 OCP 與 DIP,我們的系統基本上已經透過「具備業務規則的介面」去除了組件之間程式碼層級的依賴。但是假如「介面」行為因為與實現時的不一致,可能造成組件客戶端除了依賴介面外,程式碼中存在根據介面的不同實現而出現的不同處理。
原始的 LSP 建議:任何的子類別或抽象的實現,都必須可以在程式中代替其父類別或介面。
LSP 的擴展闡述則是:「無論介面背後的實現如何變動、調整,其行為都必須與其當初對客戶的承諾一致。
」否則系統內就需要增加許多處理這種歪向依賴的應對機制,造成維護性問題。
LSP 是站在組件使用者的角度來看,要求了介面行為的一致性與穩定性,而前面提到的 SRP, OCP, DIP 都是站在被更動的組件角度。
參考
- https://zh.wikipedia.org/zh-tw/%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8D%A2%E5%8E%9F%E5%88%99
- https://ithelp.ithome.com.tw/articles/10192317
- https://medium.com/%40f40507777/%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8F%9B%E5%8E%9F%E5%89%87-liskov-substitution-principle-adc1650ada53
- https://igouist.github.io/post/2020/11/oo-12-liskov-substitution-principle/
- https://www.jyt0532.com/2020/03/22/lsp/
- https://medium.com/%E7%A8%8B%E5%BC%8F%E6%84%9B%E5%A5%BD%E8%80%85/%E4%BD%BF%E4%BA%BA%E7%98%8B%E7%8B%82%E7%9A%84-solid-%E5%8E%9F%E5%89%87-%E9%87%8C%E6%B0%8F%E6%9B%BF%E6%8F%9B%E5%8E%9F%E5%89%87-liskov-substitution-principle-e66659344aed
- https://youtu.be/e0UOuQ_lCUY