《K的技術學習筆記》——良好OOP的設計原則:<SOLID Principles>(四)
里氏替換原則(Liskov Substitution Principle)
sub-class必須能夠替換其super-class,並且不會出現問題。要做到這個效果,sub-class就最好不要覆寫super-class的已有行為,而是寫一個新的方法去擴展。
舉一個飛行比賽來說明。
現在有多種鳥類要參加這個比賽,並在所有參賽者飛到終點後公報結果。
鳥類(Bird)
鳥類有飛行距離(airRange)和名字(name),而他們有著飛(fly)這一行為。一般鳥類的飛行速度是10。
飛行比賽(Air Race)
飛行比賽有他的賽事長度(distance), 參賽鳥們(contestants)和每位參賽鳥的餘下賽事距離(remainingDistance),而在創造飛行比賽時就需要鳥類們來進行設置。而飛行比賽有著開始(start)這一行為。當開始比賽後,參賽鳥就會開始飛行,而所有鳥類飛到終點後,就會公報誰是第一名。
鷹(Eagle)
鷹和一般鳥類的飛行速度不同,所以創造了鷹類別再覆寫了他飛(fly)這一行為。
企鵝(Penguin)
企鵝和一般鳥類不同,他並不會飛,所以創造了企鵝類別再覆寫了他飛(fly)這一行為。
設置比賽
我們先創造三隻參賽鳥,分別是鳥class的麻雀,鷹class的鷹和企鵝class的企鵝。然後,再創做一個鳥class的array,並把三種鳥都加進去。最後,用這個array來創造飛行比賽,並開始比賽。
這樣看來很正常,並且不覺得邏輯有問題,但是最終運行時出現了大問題。這個大問題就是比賽一直沒有結束,並且沒有公佈誰是第一名。
在這裡鷹class和企鵝class都是鳥class的sub-class,所以他們都可以代替鳥class被放進birds array裡。若果有遵守里氏替換原則的設計,我們即使不理解sub-class的所有資訊,這裡都不會出問題。這裡本來計設的前題是希望任何鳥類參加比賽都能飛,並且飛到終點。若果我們創造的三個參賽者都是用鳥class,這場比賽就會能結束,並公佈結果,但這裡用了企鵝class這個不會飛的鳥class的sub-class代替了。因此,這場比賽會就永遠不會結束。
跟據里氏替換原則,sub-class必須能夠替換其super-class,並且不會出現問題。這個例子還是違反了里氏替換原則。雖然鷹class替換鳥class,並沒有出現問題,但是企鵝class的替換就出現問題了。
當違反里氏替換原則時很大機會是在設計繼承時分類錯了。在我們現實生活中,企鵝和鷹都是鳥類。不同的是程式裡我們還要為class定義他們的行為,而sub-class一定能做到定義好的行為。在這裡我們定義鳥一定會飛,但現實是企鵝並不會飛。因此,我強行覆寫了企鵝的飛(fly)行為。而我們寫飛行比賽的大前題是所有參賽者都能飛。雖然放進比賽的鳥都有飛行(fly)這function被call,但企鵝當中的飛(fly)就被我覆寫為不會增加飛行距離。里氏替換原則希望本來預期所有鳥sub-class的飛(fly)function都是和鳥class一樣做飛行的動作,但企鵝class的飛(fly)function卻不做任何飛行的動作。這和我們預期有所出入,就導致不可預期的bug出現了。
這時就要審視我們自己設計class時的分類是不是出問題了。若果不是,就試試不要進行覆寫,而是進行擴展,例如:寫一個使用舊行為組成的新function。
現在用此例子修改為遵守里氏替換原則的樣子。
鳥類(Bird)<遵守里氏替換原則>
現在鳥類只有名字(name),並且沒有定義任何行為。
速度(Speed)<遵守里氏替換原則>
這個速度class會在之後的interface用到。他的功用是保儲一個不會少於0的數值。
能飛介面(Volant)<遵守里氏替換原則>
這個介面定義了飛行距離(airRange)和飛行速度(flySpeed),並且定義了飛(fly)這個行為。
能游泳介面(Swimming)<遵守里氏替換原則>
這個介面定義了游泳距離(swimmingRange)和游泳速度(swimSpeed),並且定義了游泳(swim)這個行為。
能飛的鳥(VolantBird)<遵守里氏替換原則>
這個就是繼承鳥class並且有飛行行為的新class。在這裡實作了飛行介面,會飛的鳥因為用了速度class,所以他的飛行速度不會是0。
能游泳的鳥(SwimmingBird)<遵守里氏替換原則>
這個就是繼承鳥class並且有游泳行為的新class。在這裡實作了游泳介面,會游泳的鳥因為用了速度class,所以他的游泳速度不會是0。
能飛和游泳的鳥(VolantAndSwimmingBird)<遵守里氏替換原則>
這個就是繼承了能飛的鳥class並且有游泳行為的新class。這個寫法其實不太好,但為了方便講解里氏替換原則,就用了這個寫法。
鷹(Eagle)<遵守里氏替換原則>
鷹繼承了能飛的鳥class。這次只需覆寫他的飛行速度就可以了。
企鵝(Penguin)<遵守里氏替換原則>
企鵝繼承了能游泳的鳥class。這次只需覆寫他的游泳速度就可以了。
海鳥(Penguin)<遵守里氏替換原則>
企鵝繼承了能飛和游泳的鳥class。覆寫了他的飛行速度和游泳速度。
飛行比賽(Air Race)<遵守里氏替換原則>
這次飛行比賽只准許能飛的鳥參加。
設置比賽<遵守里氏替換原則>
這裡看見參加比賽的有麻雀,鷹,海鳥和企鵝。當中麻雀是能飛的鳥class,鷹和海鳥是能飛的鳥的sub-class,而企鵝是能游泳的鳥的sub-class。因此,企鵝是不能參加比賽,程式裡會出現紅底線表示程式寫錯了。在去掉出現紅底線哪行後,這次比賽最後能順利結速,並公佈第一名是鷹。
這次的寫法符合里氏替換原則,每一個sub-class裡繼承自super-class的行為都和我們預期一樣跟從super-class的行為一致性。這樣即使我們不知道sub-class的資訊,都能直接替換super-class,並且不會出問題。因為他們都是和super-class行為有著一致性不會作出我們預期以外的事,所以替換了沒有問題。
對於覆寫可能會違反里氏替換原則我會再講多一個例子。我現在會覆寫鷹class當中的行為。
鷹(Eagle)<違反里氏替換原則>
現在鷹會進行特別飛行。特別飛行會向前飛,再之後向後飛。我們覆寫了鷹的飛(fly)行為,令他跟據isSpecialFly來決定是進行正常飛行,還是特別飛行。
這時不知道鷹class資料的人,並不知道要設置isSpecialFly去決定進行哪種飛行,再把鷹放進比賽,比賽再次不能順利完結。因為我們預期飛(fly)這一行為是只向前並不會向後,所以這樣覆寫來增加特別飛行是違反了里氏替換原則。
我們現在看看符合里氏替換原則的寫法。
鷹(Eagle)<遵守里氏替換原則>
我們今次不會覆寫鷹的飛(fly)行為,而是直接新增一個新行為------特別飛行(specialFly)。特別飛行(specialFly)的組成用到飛(fly)行為,這就是用了擴展的方法。
這次不知道鷹class資料的人,把鷹放進比賽中,比賽都能順利完結。因為擴展的行為並沒有改動到原來繼承自super-class的行為。這些行為都會如我們的預期。
總結
里氏替換原則可以確保在不知道sub-class資料時,替換了他的super-class,也不會出問題。這個原則也確保了使用多型(Polymorphism)時,不會出現不能預測的錯誤。Programmer就可以安心使用各種不同的設計模式(design pattern)。
想遵守里氏替換原則又想加新功能,最好不要覆寫,而是增加一個新function。若果還是要覆寫已有的行為,最好是寫下comment提醒自己這個行為的大前題是什麼,依據大前題來覆寫。
自己遵守是能保證不會出現不能預測的錯誤,所以計設新功能時不用去檢查哪些可能會替換super-class的哪些sub-class。可惜的是現實中你總要與人合作。🤦♂️因為你不知道對方會不會遵守,所以還是要去檢查哪些可能會替換super-class的哪些sub-class,再去設計新功能。或許留下comment,讓對方知道要遵守里氏替換原則才能用這新功能。別人不做,自己還是要好好遵守。這樣才能確保自己寫的code能保持質量。