里氏替換原則 (Liskov Substitution Principle)

子類別必須能夠替換父類別

里氏原則還包含了一個概念:子類別替換父類別後,不需要改變,也不會發生任何錯誤或異常

從定義就可以看出來,這項原則是來替我們處理繼承問題的。因此,在開始本篇之前,可能需要先對 繼承 以及 多型 有基本的認識。如果可以的話,也請先看過 介面

那麼,就讓我們從很久很久以前開始說起…

我的子類別進入叛逆期了,怎麼辦?

很久很久以前,有一間公司受到 鴿子封包 所啟發,打算發展鳥類運輸技術,強勢打入無人機市場,用生物智慧掀起對人工智慧的革命。既然鳥類都會飛行,理所當然可以藉由飛行來進行空運,甚至還可以偷偷擊墜那些無人機對手,野心勃勃的老闆立馬徵了一批鳥類物流士,打出「凡是鳥類都可應徵」的旗號,各式各樣的猛禽響應而來,一時之間掀起整個物流業的風暴!

但是好景不常,公司營運之後發貨狀況不佳,頻繁發生丟包問題,甚至有些貨根本就出不了倉庫,虧損越來越大,心急如焚的老闆下令徹查,這才發現—

企鵝是鳥,企鵝不會飛。一堆企鵝在倉庫門口發呆。

但是一切已經來不及,虧損已經造成,這家鳥禽物流公司最後也慢慢消失在塵埃之中……

這個故事告訴我們:如果子類別(企鵝)沒有達到我們對父類別(鳥)的期待,就很容易在不知不覺中出事!

我們已經預期了「鳥=會飛行」這個前提,但繼承的企鵝卻無法實作飛行,如此就會讓我們被誤導、在使用時誤入陷阱。這種子類繼承時搞叛逆,和父類別行為相違所發生的問題,難以預期也難以察覺,絕對是我輩不能容忍的。因此,里氏替換原則就出現了。

註:感謝這篇 里氏替換原則 Liskov Substitution Principle (LSP) - Finn 的附圖,我之後就想不出比企鵝更貼切的例子了囧

所以,我們該如何遵守里氏替換原則?

我們再提一次:子類別必須要能替換掉父類別,而不需要改變

我們在 多型篇 的時候提過「用子類別實作出各式各樣不同的方法,藉此讓父類別的方法藉此達到延伸和多樣化的效果」如此我們的物件彼此之間才能保持彈性,擁有可替換可擴充的特性,進而達到 開放封閉原則 所要求的:對修改封閉(不需要修改使用到父類別的地方),對擴展開放(而是只需要用子類別進行擴充,就能完成變動)

然而,這個擴展不該是天馬行空隨便亂擴的,必須要有原則。

最首要的就是:至少父類別能做到的事情,子類別也要能做到,不能說今天換成子類別就整組壞光光。畢竟,如果原本的東西變少了或壞掉了,那就不叫延伸了,對吧?

也就是說,一個好的擴展方式,應該能滿足這些條件:

  • 要求不應該比父類別多
  • 回饋不應該比父類別少

例如說:爸爸每天都去市場賣香蕉,一支二十,數十年間颳風下雨從未改變。某一天爸爸生病,不想打破這個傳統,就請兒子去代班。這時:

  • 熟客們知道一支是二十元,他們順路來買香蕉的時候也只會準備二十元。
    所以,兒子不能隨便亂漲價到五十元,因為客人也拿不出來,而且臨時漲價還會被留負評

  • 熟客們知道給了錢就可以拿到香蕉,他們給了錢之後就會等著老闆把香蕉給他們。
    所以,兒子不能今天收了人家二十元,然後只給半支香蕉,客人會很傻眼,攤子會很危險

這些熟客,其實就是我們工程師。我們預期了這個函式或類別需要準備的輸入參數,也預期了應該要有的輸出結果。如果某一天替換了子類別,卻不是這麼一回事,就會發生很多意料外的錯誤。對買香蕉這件事而言:

  • 給足夠的錢就是所謂的「前置條件」或「先驗條件」
  • 預期拿到香蕉就是「後置條件」或「後驗條件」,
  • 每天都會去市場賣香蕉就是「不變條件」

因此當我們想要符合里氏替換原則時候,其實就可以試著遵守:

  • 先驗條件不可以強化:父類別要求的是矩形,子類別就不能要求得更嚴,只准人家給正方形
  • 後驗條件不可以弱化:父類別產出的是正方形,子類別不能說沒關係啦,就給人家隨便一個矩形
  • 不變條件必須保持不變:父類別是一個產生矩形的方法,子類別不能背骨,跑去產生圓形

只要確保了輸入和輸出都是一致的,就可以減少很多神奇妙妙問題。這個也就是所謂的 契約式設計 (Design By Contract)

稍微想一想,你可以不要(隨便)繼承

有沒有發現,這個契約式的描述,和我們提過的 介面 概念是不是很像呢?可以稍微想一想:介面和繼承間的關係,以及介面與里氏替換原則的關係。

首先,為什麼我們要使用繼承呢?如果只是為了減少重複程式碼,那實在是,呃,相當不建議。這邊需要了解一個觀念:我們不應該因為單純的「IS-A」就濫用繼承,那樣是危險的。企鵝「是」鳥類、正方形「是」矩形,在想法上似乎是沒有問題的,但是貿然繼承就會遇到「企鵝不會飛」、「正方形四邊等長」等問題,讓實作上有種綁手綁腳的感覺。

真正的繼承應該是基於行為的:這個子類別能不能做到父類別期望的行為?這才是里氏替換原則的核心。不要用繼承去掠奪父類的程式碼,而是把目光放在行為,試著去思考父類別期望的行為是什麼、哪些是不可變的;期望的前置條件、後置條件,也就是輸入和輸出又代表什麼。當我們需要繼承時,能稍微想一想,把觀看物件的角度集中在它的功能上,去試著了解父類別所期望的繼承方式,和使用者期望的預期結果

如此一來,我們自然就會朝向遵守契約式設計精神的 介面 來取代繼承,又或是釐清功能之間的 職責,利用組合各個功能的子模組的方式來完成我們要的行為。放下繼承的包袱,了解繼承的原則,才能真正達到多型的精神,這就是里氏替換原則替我們指引出的方向。

既然我們需要用到介面,那介面又有什麼要注意的地方呢?這就要到我們的介面隔離原則再聊了。欲知後續如何,且待下回分曉。

那麼,我們下次見~

本文主要參考至這幾篇,建議想對里氏替換原則更了解的朋友可以閱讀一下呦:

本系列下一篇:菜雞與物件導向 (13): 介面隔離原則

參考資料

同系列文章