- 設(shè)計(jì)模式之禪(第2版)
- 秦小波
- 9字
- 2018-12-31 23:28:09
第2章
里氏替換原則
2.1 愛(ài)恨糾葛的父子關(guān)系
在面向?qū)ο蟮恼Z(yǔ)言中,繼承是必不可少的、非常優(yōu)秀的語(yǔ)言機(jī)制,它有如下優(yōu)點(diǎn):
- 代碼共享,減少創(chuàng)建類的工作量,每個(gè)子類都擁有父類的方法和屬性;
- 提高代碼的重用性;
- 子類可以形似父類,但又異于父類,“龍生龍,鳳生鳳,老鼠生來(lái)會(huì)打洞”是說(shuō)子擁有父的“種”,“世界上沒(méi)有兩片完全相同的葉子”是指明子與父的不同;
- 提高代碼的可擴(kuò)展性,實(shí)現(xiàn)父類的方法就可以“為所欲為”了,君不見很多開源框架的擴(kuò)展接口都是通過(guò)繼承父類來(lái)完成的;
- 提高產(chǎn)品或項(xiàng)目的開放性。
自然界的所有事物都是優(yōu)點(diǎn)和缺點(diǎn)并存的,即使是雞蛋,有時(shí)候也能挑出骨頭來(lái),繼承的缺點(diǎn)如下:
- 繼承是侵入性的。只要繼承,就必須擁有父類的所有屬性和方法;
- 降低代碼的靈活性。子類必須擁有父類的屬性和方法,讓子類自由的世界中多了些約束;
- 增強(qiáng)了耦合性。當(dāng)父類的常量、變量和方法被修改時(shí),需要考慮子類的修改,而且在缺乏規(guī)范的環(huán)境下,這種修改可能帶來(lái)非常糟糕的結(jié)果——大段的代碼需要重構(gòu)。
Java使用extends關(guān)鍵字來(lái)實(shí)現(xiàn)繼承,它采用了單一繼承的規(guī)則,C++則采用了多重繼承的規(guī)則,一個(gè)子類可以繼承多個(gè)父類。從整體上來(lái)看,利大于弊,怎么才能讓“利”的因素發(fā)揮最大的作用,同時(shí)減少“弊”帶來(lái)的麻煩呢?解決方案是引入里氏替換原則(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.(如果對(duì)每一個(gè)類型為S的對(duì)象o1,都有類型為T的對(duì)象o2,使得以T定義的所有程序P在所有的對(duì)象o1都代換成o2時(shí),程序P的行為沒(méi)有發(fā)生變化,那么類型S是類型T的子類型。)
- 第二種定義:Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基類的地方必須能透明地使用其子類的對(duì)象。)
第二個(gè)定義是最清晰明確的,通俗點(diǎn)講,只要父類能出現(xiàn)的地方子類就可以出現(xiàn),而且替換為子類也不會(huì)產(chǎn)生任何錯(cuò)誤或異常,使用者可能根本就不需要知道是父類還是子類。但是,反過(guò)來(lái)就不行了,有子類出現(xiàn)的地方,父類未必就能適應(yīng)。
2.2 糾紛不斷,規(guī)則壓制
里氏替換原則為良好的繼承定義了一個(gè)規(guī)范,一句簡(jiǎn)單的定義包含了4層含義。
1.子類必須完全實(shí)現(xiàn)父類的方法
我們?cè)谧鱿到y(tǒng)設(shè)計(jì)時(shí),經(jīng)常會(huì)定義一個(gè)接口或抽象類,然后編碼實(shí)現(xiàn),調(diào)用類則直接傳入接口或抽象類,其實(shí)這里已經(jīng)使用了里氏替換原則。我們舉個(gè)例子來(lái)說(shuō)明這個(gè)原則,大家都打過(guò)CS吧,非常經(jīng)典的FPS類游戲,我們來(lái)描述一下里面用到的槍,類圖如圖2-1所示。

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

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

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

圖2-4 增加AK47和AUG后的Rifle子類圖
很簡(jiǎn)單,AUG繼承了Rifle類,狙擊手(Snipper)則直接使用AUG狙擊步槍,源代碼如代碼清單2-7所示。
代碼清單2-7 AUG狙擊槍源碼代碼
public class AUG extends Rifle { //狙擊槍都攜帶一個(gè)精準(zhǔn)的望遠(yuǎn)鏡 public void zoomOut(){ System.out.println("通過(guò)望遠(yuǎn)鏡察看敵人..."); } 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翻譯過(guò)來(lái)就是鷸,就是“鷸蚌相爭(zhēng),漁人得利”中的那只鳥,英國(guó)貴族到印度打獵,發(fā)現(xiàn)這個(gè)鷸很聰明,人一靠近就飛走了,沒(méi)辦法就開始偽裝、遠(yuǎn)程精準(zhǔn)射擊,于是乎Snipper就誕生了。
狙擊手使用狙擊槍來(lái)殺死敵人,業(yè)務(wù)場(chǎng)景Client類的源代碼如代碼清單2-9所示。
代碼清單2-9 狙擊手使用AUG殺死敵人
public class Client { public static void main(String[] args) { //產(chǎn)生三毛這個(gè)狙擊手 Snipper sanMao = new Snipper(); sanMao.setRifle(new AUG()); sanMao.killEnemy(); } }
狙擊手使用G3殺死敵人,運(yùn)行結(jié)果如下所示:
通過(guò)望遠(yuǎn)鏡察看敵人...
AUG射擊...
在這里,系統(tǒng)直接調(diào)用了子類,狙擊手是很依賴槍支的,別說(shuō)換一個(gè)型號(hào)的槍了,就是換一個(gè)同型號(hào)的槍也會(huì)影響射擊,所以這里就直接把子類傳遞了進(jìn)來(lái)。這個(gè)時(shí)候,我們能不能直接使用父類傳遞進(jìn)來(lái)呢?修改一下Client類,如代碼清單2-10所示。
代碼清單2-10 使用父類作為參數(shù)
public class Client { public static void main(String[] args) { //產(chǎn)生三毛這個(gè)狙擊手 Snipper sanMao = new Snipper(); sanMao.setRifle((AUG)(new Rifle())); sanMao.killEnemy(); } }
顯示是不行的,會(huì)在運(yùn)行期拋出java.lang.ClassCastException異常,這也是大家經(jīng)常說(shuō)的向下轉(zhuǎn)型(downcast)是不安全的,從里氏替換原則來(lái)看,就是有子類出現(xiàn)的地方父類未必就可以出現(xiàn)。
3.覆蓋或?qū)崿F(xiàn)父類的方法時(shí)輸入?yún)?shù)可以被放大
方法中的輸入?yún)?shù)稱為前置條件,這是什么意思呢?大家做過(guò)Web Service開發(fā)就應(yīng)該知道有一個(gè)“契約優(yōu)先”的原則,也就是先定義出WSDL接口,制定好雙方的開發(fā)協(xié)議,然后再各自實(shí)現(xiàn)。里氏替換原則也要求制定一個(gè)契約,就是父類或接口,這種設(shè)計(jì)方法也叫做Design by Contract(契約設(shè)計(jì)),與里氏替換原則有著異曲同工之妙。契約制定了,也就同時(shí)制定了前置條件和后置條件,前置條件就是你要讓我執(zhí)行,就必須滿足我的條件;后置條件就是我執(zhí)行完了需要反饋,標(biāo)準(zhǔn)是什么。這個(gè)比較難理解,我們來(lái)看一個(gè)例子,我們先定義一個(gè)Father類,如代碼清單2-11所示。
代碼清單2-11 Father類源代碼
public class Father { public Collection doSomething(HashMap map){ System.out.println("父類被執(zhí)行..."); return map.values(); } }
這個(gè)類非常簡(jiǎn)單,就是把HashMap轉(zhuǎn)換為Collection集合類型,然后再定義一個(gè)子類,源代碼如代碼清單2-12所示。
代碼清單2-12 子類源代碼
public class Son extends Father { //放大輸入?yún)?shù)類型 public Collection doSomething(Map map){ System.out.println("子類被執(zhí)行..."); return map.values(); } }
請(qǐng)注意粗體部分,與父類的方法名相同,但又不是覆寫(Override)父類的方法。你加個(gè)@Override試試看,會(huì)報(bào)錯(cuò)的,為什么呢?方法名雖然相同,但方法的輸入?yún)?shù)不同,就不是覆寫,那這是什么呢?是重載(Overload)!不用大驚小怪的,不在一個(gè)類就不能是重載了?繼承是什么意思,子類擁有父類的所有屬性和方法,方法名相同,輸入?yún)?shù)類型又不相同,當(dāng)然是重載了。父類和子類都已經(jīng)聲明了,場(chǎng)景類的調(diào)用如代碼清單2-13所示。
代碼清單2-13 場(chǎng)景類源代碼
public class Client { public static void invoker(){ //父類存在的地方,子類就應(yīng)該能夠存在 Father f = new Father(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
代碼運(yùn)行后的結(jié)果如下所示:
父類被執(zhí)行...
根據(jù)里氏替換原則,父類出現(xiàn)的地方子類就可以出現(xiàn),我們把上面的粗體部分修改為子類,如代碼清單2-14所示。
代碼清單2-14 子類替換父類后的源代碼
public class Client { public static void invoker(){ //父類存在的地方,子類就應(yīng)該能夠存在 Son f =new Son(); HashMap map = new HashMap(); f.doSomething(map); } public static void main(String[] args) { invoker(); } }
運(yùn)行結(jié)果還是一樣,看明白是怎么回事了嗎?父類方法的輸入?yún)?shù)是HashMap類型,子類的輸入?yún)?shù)是Map類型,也就是說(shuō)子類的輸入?yún)?shù)類型的范圍擴(kuò)大了,子類代替父類傳遞到調(diào)用者中,子類的方法永遠(yuǎn)都不會(huì)被執(zhí)行。這是正確的,如果你想讓子類的方法運(yùn)行,就必須覆寫父類的方法。大家可以這樣想,在一個(gè)Invoker類中關(guān)聯(lián)了一個(gè)父類,調(diào)用了一個(gè)父類的方法,子類可以覆寫這個(gè)方法,也可以重載這個(gè)方法,前提是要擴(kuò)大這個(gè)前置條件,就是輸入?yún)?shù)的類型寬于父類的類型覆蓋范圍。這樣說(shuō)可能比較難理解,我們?cè)俜催^(guò)來(lái)想一下,如果Father類的輸入?yún)?shù)類型寬于子類的輸入?yún)?shù)類型,會(huì)出現(xiàn)什么問(wèn)題呢?會(huì)出現(xiàn)父類存在的地方,子類就未必可以存在,因?yàn)橐坏┌炎宇愖鳛閰?shù)傳入,調(diào)用者就很可能進(jìn)入子類的方法范疇。我們把上面的例子修改一下,擴(kuò)大父類的前置條件,源代碼如代碼清單2-15所示。
代碼清單2-15 父類的前置條件較大
public class Father { public Collection doSomething(Map map){ System.out.println("父類被執(zhí)行..."); return map.values(); } }
把父類的前置條件修改為Map類型,我們?cè)傩薷囊幌伦宇惙椒ǖ妮斎雲(yún)?shù),相對(duì)父類縮小輸入?yún)?shù)的類型范圍,也就是縮小前置條件,源代碼如代碼清單2-16所示。
代碼清單2-16 子類的前置條件較小
public class Son extends Father { //縮小輸入?yún)?shù)范圍 public Collection doSomething(HashMap map){ System.out.println("子類被執(zhí)行..."); return map.values(); } }
在父類的前置條件大于子類的前置條件的情況下,業(yè)務(wù)場(chǎng)景的源代碼如代碼清單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(); } }
代碼運(yùn)行結(jié)果如下所示:
父類被執(zhí)行...
那我們?cè)侔牙锸咸鎿Q原則引入進(jìn)來(lái)會(huì)有什么問(wèn)題?有父類的地方子類就可以使用,好,我們把這個(gè)Client類修改一下,源代碼如代碼清單2-18所示。
代碼清單2-18 采用里氏替換原則后的業(yè)務(wù)場(chǎng)景類
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(); } }
代碼運(yùn)行后的結(jié)果如下所示:
子類被執(zhí)行...
完蛋了吧?!子類在沒(méi)有覆寫父類的方法的前提下,子類方法被執(zhí)行了,這會(huì)引起業(yè)務(wù)邏輯混亂,因?yàn)樵趯?shí)際應(yīng)用中父類一般都是抽象類,子類是實(shí)現(xiàn)類,你傳遞一個(gè)這樣的實(shí)現(xiàn)類就會(huì)“歪曲”了父類的意圖,引起一堆意想不到的業(yè)務(wù)邏輯混亂,所以子類中方法的前置條件必須與超類中被覆寫的方法的前置條件相同或者更寬松。
4.覆寫或?qū)崿F(xiàn)父類的方法時(shí)輸出結(jié)果可以被縮小
這是什么意思呢,父類的一個(gè)方法的返回值是一個(gè)類型T,子類的相同方法(重載或覆寫)的返回值為S,那么里氏替換原則就要求S必須小于等于T,也就是說(shuō),要么S和T是同一個(gè)類型,要么S是T的子類,為什么呢?分兩種情況,如果是覆寫,父類和子類的同名方法的輸入?yún)?shù)是相同的,兩個(gè)方法的范圍值S小于等于T,這是覆寫的要求,這才是重中之重,子類覆寫父類的方法,天經(jīng)地義。如果是重載,則要求方法的輸入?yún)?shù)類型或數(shù)量不相同,在里氏替換原則要求下,就是子類的輸入?yún)?shù)寬于或等于父類的輸入?yún)?shù),也就是說(shuō)你寫的這個(gè)方法是不會(huì)被調(diào)用的,參考上面講的前置條件。
采用里氏替換原則的目的就是增強(qiáng)程序的健壯性,版本升級(jí)時(shí)也可以保持非常好的兼容性。即使增加子類,原有的子類還可以繼續(xù)運(yùn)行。在實(shí)際項(xiàng)目中,每個(gè)子類對(duì)應(yīng)不同的業(yè)務(wù)含義,使用父類作為參數(shù),傳遞不同的子類完成不同的業(yè)務(wù)邏輯,非常完美!
2.3 最佳實(shí)踐
在項(xiàng)目中,采用里氏替換原則時(shí),盡量避免子類的“個(gè)性”,一旦子類有“個(gè)性”,這個(gè)子類和父類之間的關(guān)系就很難調(diào)和了,把子類當(dāng)做父類使用,子類的“個(gè)性”被抹殺——委屈了點(diǎn);把子類單獨(dú)作為一個(gè)業(yè)務(wù)來(lái)使用,則會(huì)讓代碼間的耦合關(guān)系變得撲朔迷離——缺乏類替換的標(biāo)準(zhǔn)。
- AutoCAD 2022快速入門、進(jìn)階與精通
- MATLAB計(jì)算機(jī)視覺(jué)經(jīng)典應(yīng)用
- Flash CC中文版動(dòng)畫設(shè)計(jì)與制作/微課堂學(xué)電腦
- Getting Started with Microsoft Application Virtualization 4.6
- Solid Works 2021產(chǎn)品設(shè)計(jì)標(biāo)準(zhǔn)教程
- Inkscape 0.48 Illustrator's Cookbook
- Creo 6.0快速入門、進(jìn)階與精通(升級(jí)版)
- UG NX 12.0實(shí)例寶典
- Moodle JavaScript Cookbook
- 印象筆記留給你的空間2.0:個(gè)人知識(shí)管理實(shí)踐指南
- Joomla! 1.5 Site Blueprints: LITE
- Object/Oriented JavaScript
- Adobe創(chuàng)意大學(xué)InDesign CS5 產(chǎn)品專家認(rèn)證標(biāo)準(zhǔn)教材
- 巧學(xué)巧用Flash CS6制作動(dòng)畫
- UG NX 12.0中文版從入門到精通