- 設計模式之禪(第2版)
- 秦小波
- 4600字
- 2018-12-31 23:28:10
第2章
里氏替換原則
2.1 愛恨糾葛的父子關系
在面向對象的語言中,繼承是必不可少的、非常優秀的語言機制,它有如下優點:
- 代碼共享,減少創建類的工作量,每個子類都擁有父類的方法和屬性;
- 提高代碼的重用性;
- 子類可以形似父類,但又異于父類,“龍生龍,鳳生鳳,老鼠生來會打洞”是說子擁有父的“種”,“世界上沒有兩片完全相同的葉子”是指明子與父的不同;
- 提高代碼的可擴展性,實現父類的方法就可以“為所欲為”了,君不見很多開源框架的擴展接口都是通過繼承父類來完成的;
- 提高產品或項目的開放性。
自然界的所有事物都是優點和缺點并存的,即使是雞蛋,有時候也能挑出骨頭來,繼承的缺點如下:
- 繼承是侵入性的。只要繼承,就必須擁有父類的所有屬性和方法;
- 降低代碼的靈活性。子類必須擁有父類的屬性和方法,讓子類自由的世界中多了些約束;
- 增強了耦合性。當父類的常量、變量和方法被修改時,需要考慮子類的修改,而且在缺乏規范的環境下,這種修改可能帶來非常糟糕的結果——大段的代碼需要重構。
Java使用extends關鍵字來實現繼承,它采用了單一繼承的規則,C++則采用了多重繼承的規則,一個子類可以繼承多個父類。從整體上來看,利大于弊,怎么才能讓“利”的因素發揮最大的作用,同時減少“弊”帶來的麻煩呢?解決方案是引入里氏替換原則(Liskov Substitution Principle,LSP),什么是里氏替換原則呢?它有兩種定義:
- 第一種定義,也是最正宗的定義:If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T,the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果對每一個類型為S的對象o1,都有類型為T的對象o2,使得以T定義的所有程序P在所有的對象o1都代換成o2時,程序P的行為沒有發生變化,那么類型S是類型T的子類型。)
- 第二種定義:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基類的地方必須能透明地使用其子類的對象。)
第二個定義是最清晰明確的,通俗點講,只要父類能出現的地方子類就可以出現,而且替換為子類也不會產生任何錯誤或異常,使用者可能根本就不需要知道是父類還是子類。但是,反過來就不行了,有子類出現的地方,父類未必就能適應。
2.2 糾紛不斷,規則壓制
里氏替換原則為良好的繼承定義了一個規范,一句簡單的定義包含了4層含義。
1.子類必須完全實現父類的方法
我們在做系統設計時,經常會定義一個接口或抽象類,然后編碼實現,調用類則直接傳入接口或抽象類,其實這里已經使用了里氏替換原則。我們舉個例子來說明這個原則,大家都打過CS吧,非常經典的FPS類游戲,我們來描述一下里面用到的槍,類圖如圖2-1所示。

圖2-1 CS游戲中的槍支類圖
槍的主要職責是射擊,如何射擊在各個具體的子類中定義,手槍是單發射程比較近,步槍威力大射程遠,機槍用于掃射。在士兵類中定義了一個方法killEnemy,使用槍來殺敵人,具體使用什么槍來殺敵人,調用的時候才知道,AbstractGun類的源程序如代碼清單2-1所示。
代碼清單2-1 槍支的抽象類
public abstract class AbstractGun { //槍用來干什么的?殺敵! public abstract void shoot(); }
手槍、步槍、機槍的實現類如代碼清單2-2所示。
代碼清單2-2 手槍、步槍、機槍的實現類
public class Handgun extends AbstractGun { //手槍的特點是攜帶方便,射程短 @Override public void shoot() { System.out.println("手槍射擊..."); } } public class Rifle extends AbstractGun{ //步槍的特點是射程遠,威力大 public void shoot(){ System.out.println("步槍射擊..."); } } public class MachineGun extends AbstractGun{ public void shoot(){ System.out.println("機槍掃射..."); } }
有了槍支,還要有能夠使用這些槍支的士兵,其源程序如代碼清單2-3所示。
代碼清單2-3 士兵的實現類
public class Soldier { //定義士兵的槍支 private AbstractGun gun; //給士兵一支槍 public void setGun(AbstractGun _gun){ this.gun = _gun; } public void killEnemy(){ System.out.println("士兵開始殺敵人..."); gun.shoot(); } }
注意粗體部分,定義士兵使用槍來殺敵,但是這把槍是抽象的,具體是手槍還是步槍需要在上戰場前(也就是場景中)前通過setGun方法確定。場景類Client的源代碼如代碼清單2-4所示。
代碼清單2-4 場景類
public class Client { public static void main(String[] args) { //產生三毛這個士兵 Soldier sanMao = new Soldier(); //給三毛一支槍 sanMao.setGun(new Rifle()); sanMao.killEnemy(); } }
有人,有槍,也有場景,運行結果如下所示。
士兵開始殺敵人...
步槍射擊...
在這個程序中,我們給三毛這個士兵一把步槍,然后就開始殺敵了。如果三毛要使用機槍,當然也可以,直接把sanMao.setGun(new Rifle())修改為sanMao.setGun(new MachineGun())即可,在編寫程序時Solider士兵類根本就不用知道是哪個型號的槍(子類)被傳入。
注意 在類中調用其他類時務必要使用父類或接口,如果不能使用父類或接口,則說明類的設計已經違背了LSP原則。
我們再來想一想,如果我們有一個玩具手槍,該如何定義呢?我們先在類圖2-1上增加一個類ToyGun,然后繼承于AbstractGun類,修改后的類圖如圖2-2所示。

圖2-2 槍支類圖
首先我們想,玩具槍是不能用來射擊的,殺不死人的,這個不應該寫在shoot方法中。新增加的ToyGun的源代碼如代碼清單2-5所示。
代碼清單2-5 玩具槍源代碼
public class ToyGun extends AbstractGun { //玩具槍是不能射擊的,但是編譯器又要求實現這個方法,怎么辦?虛構一個唄! @Override public void shoot() { //玩具槍不能射擊,這個方法就不實現了 } }
由于引入了新的子類,場景類中也使用了該類,Client稍作修改,源代碼如代碼清單2-6所示。
代碼清單2-6 場景類
public class Client { public static void main(String[] args) { //產生三毛這個士兵 Soldier sanMao = new Soldier(); sanMao.setGun(new ToyGun()); sanMao.killEnemy(); } }
修改了粗體部分,把玩具槍傳遞給三毛用來殺敵,代碼運行結果如下所示:
士兵開始殺敵人...
壞了,士兵拿著玩具槍來殺敵人,射不出子彈呀!如果在CS游戲中有這種事情發生,那你就等著被人爆頭吧,然后看著自己凄慘地倒地。在這種情況下,我們發現業務調用類已經出現了問題,正常的業務邏輯已經不能運行,那怎么辦?好辦,有兩種解決辦法:
- 在Soldier類中增加instanceof的判斷,如果是玩具槍,就不用來殺敵人。這個方法可以解決問題,但是你要知道,在程序中,每增加一個類,所有與這個父類有關系的類都必須修改,你覺得可行嗎?如果你的產品出現了這個問題,因為修正了這樣一個Bug,就要求所有與這個父類有關系的類都增加一個判斷,客戶非跳起來跟你干架不可!你還想要客戶忠誠于你嗎?顯然,這個方案被否定了。
- ToyGun脫離繼承,建立一個獨立的父類,為了實現代碼復用,可以與AbastractGun建立關聯委托關系,如圖2-3所示。

圖2-3 玩具槍與真實槍分離的類圖
例如,可以在AbstractToy中聲明將聲音、形狀都委托給AbstractGun處理,仿真槍嘛,形狀和聲音都要和真實的槍一樣了,然后兩個基類下的子類自由延展,互不影響。
在Java的基礎知識中都會講到繼承,Java的三大特征嘛,封裝、繼承、多態。繼承就是告訴你擁有父類的方法和屬性,然后你就可以重寫父類的方法。按照繼承原則,我們上面的玩具槍繼承AbstractGun是絕對沒有問題的,玩具槍也是槍嘛,但是在具體應用場景中就要考慮下面這個問題了:子類是否能夠完整地實現父類的業務,否則就會出現像上面的拿槍殺敵人時卻發現是把玩具槍的笑話。
注意 如果子類不能完整地實現父類的方法,或者父類的某些方法在子類中已經發生“畸變”,則建議斷開父子繼承關系,采用依賴、聚集、組合等關系代替繼承。
2.子類可以有自己的個性
子類當然可以有自己的行為和外觀了,也就是方法和屬性,那這里為什么要再提呢?是因為里氏替換原則可以正著用,但是不能反過來用。在子類出現的地方,父類未必就可以勝任。還是以剛才的關于槍支的例子為例,步槍有幾個比較“響亮”的型號,比如AK47、AUG狙擊步槍等,把這兩個型號的槍引入后的Rifle子類圖如圖2-4所示。

圖2-4 增加AK47和AUG后的Rifle子類圖
很簡單,AUG繼承了Rifle類,狙擊手(Snipper)則直接使用AUG狙擊步槍,源代碼如代碼清單2-7所示。
代碼清單2-7 AUG狙擊槍源碼代碼
public class AUG extends Rifle { //狙擊槍都攜帶一個精準的望遠鏡 public void zoomOut(){ System.out.println("通過望遠鏡察看敵人..."); } public void shoot(){ System.out.println("AUG射擊..."); } }
有狙擊槍就有狙擊手,狙擊手類的源代碼如代碼清單2-8所示。
代碼清單2-8 AUG狙擊手類的源碼代碼
public class Snipper { public void killEnemy(AUG aug){ //首先看看敵人的情況,別殺死敵人,自己也被人干掉 aug.zoomOut(); //開始射擊 aug.shoot(); } }
狙擊手,為什么叫Snipper?Snipe翻譯過來就是鷸,就是“鷸蚌相爭,漁人得利”中的那只鳥,英國貴族到印度打獵,發現這個鷸很聰明,人一靠近就飛走了,沒辦法就開始偽裝、遠程精準射擊,于是乎Snipper就誕生了。
狙擊手使用狙擊槍來殺死敵人,業務場景Client類的源代碼如代碼清單2-9所示。
代碼清單2-9 狙擊手使用AUG殺死敵人
public class Client { public static void main(String[] args) { //產生三毛這個狙擊手 Snipper sanMao = new Snipper(); sanMao.setRifle(new AUG()); sanMao.killEnemy(); } }
狙擊手使用G3殺死敵人,運行結果如下所示:
通過望遠鏡察看敵人...
AUG射擊...
在這里,系統直接調用了子類,狙擊手是很依賴槍支的,別說換一個型號的槍了,就是換一個同型號的槍也會影響射擊,所以這里就直接把子類傳遞了進來。這個時候,我們能不能直接使用父類傳遞進來呢?修改一下Client類,如代碼清單2-10所示。
代碼清單2-10 使用父類作為參數
public class Client { public static void main(String[] args) { //產生三毛這個狙擊手 Snipper sanMao = new Snipper(); sanMao.setRifle((AUG)(new Rifle())); sanMao.killEnemy(); } }
顯示是不行的,會在運行期拋出java.lang.ClassCastException異常,這也是大家經常說的向下轉型(downcast)是不安全的,從里氏替換原則來看,就是有子類出現的地方父類未必就可以出現。
3.覆蓋或實現父類的方法時輸入參數可以被放大
方法中的輸入參數稱為前置條件,這是什么意思呢?大家做過Web Service開發就應該知道有一個“契約優先”的原則,也就是先定義出WSDL接口,制定好雙方的開發協議,然后再各自實現。里氏替換原則也要求制定一個契約,就是父類或接口,這種設計方法也叫做Design by Contract(契約設計),與里氏替換原則有著異曲同工之妙。契約制定了,也就同時制定了前置條件和后置條件,前置條件就是你要讓我執行,就必須滿足我的條件;后置條件就是我執行完了需要反饋,標準是什么。這個比較難理解,我們來看一個例子,我們先定義一個Father類,如代碼清單2-11所示。
代碼清單2-11 Father類源代碼
public class Father { public Collection doSomething(HashMap map){ System.out.println("父類被執行..."); return map.values(); } }
這個類非常簡單,就是把HashMap轉換為Collection集合類型,然后再定義一個子類,源代碼如代碼清單2-12所示。
代碼清單2-12 子類源代碼
public class Son extends Father { //放大輸入參數類型 public Collection doSomething(Map map){ System.out.println("子類被執行..."); return map.values(); } }
請注意粗體部分,與父類的方法名相同,但又不是覆寫(Override)父類的方法。你加個@Override試試看,會報錯的,為什么呢?方法名雖然相同,但方法的輸入參數不同,就不是覆寫,那這是什么呢?是重載(Overload)!不用大驚小怪的,不在一個類就不能是重載了?繼承是什么意思,子類擁有父類的所有屬性和方法,方法名相同,輸入參數類型又不相同,當然是重載了。父類和子類都已經聲明了,場景類的調用如代碼清單2-13所示。
代碼清單2-13 場景類源代碼
public class Client { public static void invoker(){ //父類存在的地方,子類就應該能夠存在 Father f = new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代碼運行后的結果如下所示:
父類被執行...
根據里氏替換原則,父類出現的地方子類就可以出現,我們把上面的粗體部分修改為子類,如代碼清單2-14所示。
代碼清單2-14 子類替換父類后的源代碼
public class Client { public static void invoker(){ //父類存在的地方,子類就應該能夠存在 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
運行結果還是一樣,看明白是怎么回事了嗎?父類方法的輸入參數是HashMap類型,子類的輸入參數是Map類型,也就是說子類的輸入參數類型的范圍擴大了,子類代替父類傳遞到調用者中,子類的方法永遠都不會被執行。這是正確的,如果你想讓子類的方法運行,就必須覆寫父類的方法。大家可以這樣想,在一個Invoker類中關聯了一個父類,調用了一個父類的方法,子類可以覆寫這個方法,也可以重載這個方法,前提是要擴大這個前置條件,就是輸入參數的類型寬于父類的類型覆蓋范圍。這樣說可能比較難理解,我們再反過來想一下,如果Father類的輸入參數類型寬于子類的輸入參數類型,會出現什么問題呢?會出現父類存在的地方,子類就未必可以存在,因為一旦把子類作為參數傳入,調用者就很可能進入子類的方法范疇。我們把上面的例子修改一下,擴大父類的前置條件,源代碼如代碼清單2-15所示。
代碼清單2-15 父類的前置條件較大
public class Father { public Collection doSomething(Map map){ System.out.println("父類被執行..."); return map.values(); } }
把父類的前置條件修改為Map類型,我們再修改一下子類方法的輸入參數,相對父類縮小輸入參數的類型范圍,也就是縮小前置條件,源代碼如代碼清單2-16所示。
代碼清單2-16 子類的前置條件較小
public class Son extends Father { //縮小輸入參數范圍 public Collection doSomething(HashMap map){ System.out.println("子類被執行..."); return map.values(); } }
在父類的前置條件大于子類的前置條件的情況下,業務場景的源代碼如代碼清單2-17所示。
代碼清單2-17 子類的前置條件較小
public class Client { public static void invoker(){ //有父類的地方就有子類 Father f= new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代碼運行結果如下所示:
父類被執行...
那我們再把里氏替換原則引入進來會有什么問題?有父類的地方子類就可以使用,好,我們把這個Client類修改一下,源代碼如代碼清單2-18所示。
代碼清單2-18 采用里氏替換原則后的業務場景類
public class Client { public static void invoker(){ //有父類的地方就有子類 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代碼運行后的結果如下所示:
子類被執行...
完蛋了吧?!子類在沒有覆寫父類的方法的前提下,子類方法被執行了,這會引起業務邏輯混亂,因為在實際應用中父類一般都是抽象類,子類是實現類,你傳遞一個這樣的實現類就會“歪曲”了父類的意圖,引起一堆意想不到的業務邏輯混亂,所以子類中方法的前置條件必須與超類中被覆寫的方法的前置條件相同或者更寬松。
4.覆寫或實現父類的方法時輸出結果可以被縮小
這是什么意思呢,父類的一個方法的返回值是一個類型T,子類的相同方法(重載或覆寫)的返回值為S,那么里氏替換原則就要求S必須小于等于T,也就是說,要么S和T是同一個類型,要么S是T的子類,為什么呢?分兩種情況,如果是覆寫,父類和子類的同名方法的輸入參數是相同的,兩個方法的范圍值S小于等于T,這是覆寫的要求,這才是重中之重,子類覆寫父類的方法,天經地義。如果是重載,則要求方法的輸入參數類型或數量不相同,在里氏替換原則要求下,就是子類的輸入參數寬于或等于父類的輸入參數,也就是說你寫的這個方法是不會被調用的,參考上面講的前置條件。
采用里氏替換原則的目的就是增強程序的健壯性,版本升級時也可以保持非常好的兼容性。即使增加子類,原有的子類還可以繼續運行。在實際項目中,每個子類對應不同的業務含義,使用父類作為參數,傳遞不同的子類完成不同的業務邏輯,非常完美!
2.3 最佳實踐
在項目中,采用里氏替換原則時,盡量避免子類的“個性”,一旦子類有“個性”,這個子類和父類之間的關系就很難調和了,把子類當做父類使用,子類的“個性”被抹殺——委屈了點;把子類單獨作為一個業務來使用,則會讓代碼間的耦合關系變得撲朔迷離——缺乏類替換的標準。
- Adobe創意大學After Effects CS5 產品專家認證標準教材
- Moodle 2.0 E/Learning Course Development
- Photoshop CC 實戰入門
- FreeSWITCH 1.0.6
- SketchUp印象 城市規劃項目實踐(第2版)
- 中文版CorelDRAW X6基礎培訓教程
- 好的PPT會說話:如何打造完美幻燈片
- Creo Parametric 5.0中文版從入門到精通
- 斯科特·凱爾比的零基礎攝影后期課 Lightroom數碼照片調修技法
- ANSYS 15.0有限元分析自學手冊
- Adobe創意大學Photoshop CS5 產品專家認證標準教材
- Python Testing: Beginner's Guide
- 從零開始:AutoCAD 2010中文版建筑制圖基礎培訓教程(第2版)
- JBoss RichFaces 3.3
- Building Websites with VB.NET and DotNetNuke 4