菜雞與物件導向 (12): 里氏替換原則
里氏替換原則 (Liskov Substitution Principle)
子類別必須能夠替換父類別
里氏原則還包含了一個概念:子類別替換父類別後,不需要改變,也不會發生任何錯誤或異常。
從定義就可以看出來,這項原則是來替我們處理繼承問題的。因此,在開始本篇之前,可能需要先對 繼承 以及 多型 有基本的認識。如果可以的話,也請先看過 介面。
那麼,就讓我們從很久很久以前開始說起…
我的子類別進入叛逆期了,怎麼辦?
很久很久以前,有一間公司受到 鴿子封包 所啟發,打算發展鳥類運輸技術,強勢打入無人機市場,用生物智慧掀起對人工智慧的革命。既然鳥類都會飛行,理所當然可以藉由飛行來進行空運,甚至還可以偷偷擊墜那些無人機對手,野心勃勃的老闆立馬徵了一批鳥類物流士,打出「凡是鳥類都可應徵」的旗號,各式各樣的猛禽響應而來,一時之間掀起整個物流業的風暴!
但是好景不常,公司營運之後發貨狀況不佳,頻繁發生丟包問題,甚至有些貨根本就出不了倉庫,虧損越來越大,心急如焚的老闆下令徹查,這才發現—
企鵝是鳥,企鵝不會飛。一堆企鵝在倉庫門口發呆。
但是一切已經來不及,虧損已經造成,這家鳥禽物流公司最後也慢慢消失在塵埃之中……
這個故事告訴我們:如果子類別(企鵝)沒有達到我們對父類別(鳥)的期待,就很容易在不知不覺中出事!
我們已經預期了「鳥=會飛行」這個前提,但繼承的企鵝卻無法實作飛行,如此就會讓我們被誤導、在使用時誤入陷阱。這種子類繼承時搞叛逆,和父類別行為相違所發生的問題,難以預期也難以察覺,絕對是我輩不能容忍的。因此,里氏替換原則就出現了。
註:感謝這篇 里氏替換原則 Liskov Substitution Principle (LSP) - Finn 的附圖,我之後就想不出比企鵝更貼切的例子了囧
所以,我們該如何遵守里氏替換原則?
我們再提一次:子類別必須要能替換掉父類別,而不需要改變。
我們在 多型篇 的時候提過「用子類別實作出各式各樣不同的方法,藉此讓父類別的方法藉此達到延伸和多樣化的效果」如此我們的物件彼此之間才能保持彈性,擁有可替換可擴充的特性,進而達到 開放封閉原則 所要求的:對修改封閉(不需要修改使用到父類別的地方),對擴展開放(而是只需要用子類別進行擴充,就能完成變動)
然而,這個擴展不該是天馬行空隨便亂擴的,必須要有原則。
最首要的就是:至少父類別能做到的事情,子類別也要能做到,不能說今天換成子類別就整組壞光光。畢竟,如果原本的東西變少了或壞掉了,那就不叫延伸了,對吧?
也就是說,一個好的擴展方式,應該能滿足這些條件:
- 要求不應該比父類別多
- 回饋不應該比父類別少
例如說:爸爸每天都去市場賣香蕉,一支二十,數十年間颳風下雨從未改變。某一天爸爸生病,不想打破這個傳統,就請兒子去代班。這時:
-
熟客們知道一支是二十元,他們順路來買香蕉的時候也只會準備二十元。
所以,兒子不能隨便亂漲價到五十元,因為客人也拿不出來,而且臨時漲價還會被留負評 -
熟客們知道給了錢就可以拿到香蕉,他們給了錢之後就會等著老闆把香蕉給他們。
所以,兒子不能今天收了人家二十元,然後只給半支香蕉,客人會很傻眼,攤子會很危險
這些熟客,其實就是我們工程師。我們預期了這個函式或類別需要準備的輸入參數,也預期了應該要有的輸出結果。如果某一天替換了子類別,卻不是這麼一回事,就會發生很多意料外的錯誤。對買香蕉這件事而言:
- 給足夠的錢就是所謂的「前置條件」或「先驗條件」
- 預期拿到香蕉就是「後置條件」或「後驗條件」,
- 每天都會去市場賣香蕉就是「不變條件」
因此當我們想要符合里氏替換原則時候,其實就可以試著遵守這幾條規則:
先驗條件不可以強化:
父類別要求的是矩形,子類別就不能要求得更嚴,只准人家給正方形
後驗條件不可以弱化:
父類別產出的是正方形,子類別不能說沒關係啦,就給人家隨便一個矩形
不變條件必須保持不變:
父類別是一個產生矩形的方法,子類別不能背骨,跑去產生圓形
只要確保了輸入和輸出都是一致的,就可以減少很多神奇妙妙問題。這個也就是所謂的契約式設計 (Design By Contract)。
稍微想一想,你可以不要(隨便)繼承
有沒有發現,這個契約式的描述,和我們提過的 介面 概念是不是很像呢?可以稍微想一想:介面和繼承間的關係,以及介面與里氏替換原則的關係。
首先,為什麼我們要使用繼承呢?如果只是為了減少重複程式碼,那實在是,呃,相當不建議。
這邊需要了解一個觀念:我們不應該因為單純的「IS-A」就濫用繼承,那樣是危險的。企鵝「是」鳥類、正方形「是」矩形,在想法上似乎是沒有問題的,但是貿然繼承就會遇到「企鵝不會飛」、「正方形四邊等長」等問題,讓實作上有種綁手綁腳的感覺。
真正的繼承應該是基於行為的:這個子類別能不能做到父類別期望的行為?這才是里氏替換原則的核心。
不要用繼承去掠奪父類的程式碼,而是把目光放在行為,試著去思考父類別期望的行為是什麼、哪些是不可變的;期望的前置條件、後置條件,也就是輸入和輸出又代表什麼。
當我們需要繼承時,就稍微想一想,把觀看物件的角度集中在它的功能上,去試著了解父類別所期望的繼承方式,和使用者期望的預期結果。如此一來,我們自然就會朝向遵守契約式設計精神的介面來取代繼承,又或是釐清功能之間的職責,利用組合各個功能的子模組的方式來完成我們要的行為。
放下繼承的包袱,了解繼承的原則,才能真正達到多型的精神,這就是里氏替換原則替我們指引出的方向。
既然我們需要用到介面,那介面又有什麼要注意的地方呢?這就要到我們的介面隔離原則再聊了。欲知後續如何,且待下回分曉。
那麼,我們下次見~
本文主要參考至這幾篇,建議想對里氏替換原則更了解的朋友可以閱讀一下呦:
本系列下一篇:菜雞與物件導向 (13): 介面隔離原則
參考資料
- 里氏替換原則 Liskov Substitution Principle (LSP) - Finn
- 使人瘋狂的 SOLID 原則:里氏替換原則 (Liskov Substitution Principle) - 程式愛好者
- 亂談軟體設計(4):Liskov Substitution Principle - 搞笑談軟工
- 物件導向設計原則:里氏替換原則,定義、解析 - WadeHuang的學習迷航記
- Object Oriented物件導向設計原則SOLID-5:Liskov Substitution Principle(LSP) 里氏替換原則 - Sian
- 契約式設計 - 維基百科
- 《無瑕的程式碼:物件導向原則、設計模式與C#實踐》 Ch.10 LSP --Liskov替換原則
同系列文章
- 菜雞與物件導向 (0): 前言
- 菜雞與物件導向 (1): 類別、物件
- 菜雞與物件導向 (2): 建構式、多載
- 菜雞與物件導向 (3): 封裝
- 菜雞與物件導向 (4): 繼承
- 菜雞與物件導向 (5): 多型
- 菜雞與物件導向 (6): 抽象、覆寫
- 菜雞與物件導向 (7): 介面
- 菜雞與物件導向 (8): 內聚、耦合
- 菜雞與物件導向 (9): SOLID
- 菜雞與物件導向 (10): 單一職責原則
- 菜雞與物件導向 (11): 開放封閉原則
- 菜雞與物件導向 (12): 里氏替換原則
- 菜雞與物件導向 (13): 介面隔離原則
- 菜雞與物件導向 (14): 依賴反轉原則
- 菜雞與物件導向 (15): 最少知識原則
- 菜雞與物件導向 (Ex1): 小結
其他文章
哈囉,如果你也有 LikeCoin,也覺得我的文章有幫上忙的話,還請不吝給我拍拍手呦,謝謝~ ;)