設計原則---里氏替換原則
本文參考自設計模式之禪(第二版)第二章
1.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 substitution for o2 then S is a subtype of T.(如果對每一個型別為S的物件o1,都有型別為T的物件o2,使得以T定義的所有程式P在所有的物件o1都代換成o2時,程式P的行為沒有發生變化,那麼型別S是型別T的子型別。)
- 第二種定義:Function that use pointers or references to base classes must be able to use objects of derived classes without knowing it.(所有引用基類的地方必須能透明地使用其子類的物件。)
第二個定義是最清晰明確的,通俗點講,只要父類能出現的地方子類就可以出現,而且替換為子類也不會產生任何錯誤或異常,使用者可能根本就不需要知道是父類還是子類。但是,反過來就不行了,有子類出現的地方,父類未必就能適應。
1.2 糾紛不斷,規則壓制
里氏替換原則為良好的繼承定義了一個規範,一句簡單的定義包含了4層含義。
1. 子類必須完全實現父類的方法
我們在做系統設計時,經常會定義一個介面或抽象類,然後編碼實現,呼叫類則直接傳入介面或抽象類,其實這裡已經使用了里氏替換原則。我們舉個例子來說明這個原則,大家都打過CS吧,非常經典的FPS類遊戲,我們來描述一下里面用到的槍,類圖如下圖1.1所示:
槍的主要作用是射擊,如何射擊在各個具體的子類中定義,手槍是單發射程比較近,步槍威力大射程遠,機槍用於掃射。在士兵類中定義了一個方法killEnemy,使用槍來殺敵人,具體使用什麼槍來殺敵人,呼叫的時候才知道,AbstractGun類的源程式如下程式碼1.1所示:
public abstract class AbstractGun {
//槍用來殺敵
public abstract void shoot();
}
手槍、步槍、機槍的實現類如下程式碼1.2所示:
public class HandGun extends AbstractGun {
@override
public void shoot() {
System.out.println("手槍射擊...");
}
}
public class Rifle extends AbstractGun {
@override
public void shoot() {
System.out.println("步槍射擊...");
}
}
public class MachineGun extends AbstractGun {
@override
public void shoot() {
System.out.println("機槍射擊...");
}
}
有了槍支,還要有能夠使用這些槍支的士兵,其源程式diamante如下程式碼1.3所示:
public class Soldier {
//定義士兵的槍支
private AbstractGun gun;
//給士兵一把槍
public void setGun(Abstract _gun) {
this.gun = _gun;
}
public void killEnemy() {
System.out.println("士兵開始殺敵人...");
gun.shoot();
}
}
注意killEnemy這個方法,定義士兵使用槍來殺敵,但是這把槍是抽象的,具體是手槍還是步槍需要在上戰場前(也就是場景中)通過setGun方法確定。場景類Client的原始碼如下程式碼1.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());即可,在編寫程式時Soldier士兵類根本就不用知道是哪個型號的槍(子類)被傳入。
注意 在類中呼叫其他類時務必使用父類或介面,如果不能使用父類或介面,則說明類的設計已經違背了LSP原則。
我們再來想一想,如果我們有一個玩具手槍,該如何定義呢?我們先在類圖1.1上增加一個類ToyGun,然後繼承與AbstractGun類,修改後的類圖如下圖1.2所示:
首先我們想,玩具槍是不能用來射擊的,殺不死人的,這個不應該寫在shoot方法中。新增加的ToyGun的原始碼如下程式碼1.5所示:
public class ToyGun extends AbstractGun {
//玩具槍是不能射擊的,但是編譯器又要求實現這個方法,怎麼辦?虛構一個唄!
@override
public void shoot() {
//玩具槍不能射擊,這個方法就不實現了
}
}
由於引入了新的子類,場景類中也使用了該類,Client炒作修改,原始碼如下程式碼1.6所示:
public class Client {
public static void main(String[] args) {
//產生三毛這個士兵
Soldier sanMao = new Soldier();
sanMao.setGun(new ToyGun());
sanMao.killEnemy();
}
}
修改了setGun中傳入的引數,把玩具槍傳遞給三毛用來殺敵,程式碼執行結果如下所示:
士兵開始殺敵人...
壞了,士兵拿著玩具槍來殺敵人,射不出子彈呀!如果在CS遊戲中有這種事情發生,那你就等著被敵人爆頭吧,然後看著自己悽慘的倒地。在這種情況下,我們發現業務呼叫類已經出現了問題,正常的業務邏輯已經不能執行,那怎麼辦?好辦,有兩種解決辦法:
- 在Soldier類中增加instanceof的判斷,如果是玩具槍,就不用來殺敵人。這個方法可以解決這個問題,但是你要知道,在程式中,每增加一個類,所有與這個父類有關係的類都必須修改,你覺得可行嗎?如果你的產品出現了這個問題,因為修正了這樣一個Bug,就要求所有與這個父類有關係的類都增加一個判斷,客戶非得跳起來跟你幹架不可!你還想要客戶忠誠於你嗎?顯然,這個方案被否定了。
- ToyGun脫離繼承,建立一個獨立的父類,為了實現程式碼複用,可以與AbstractGun建立委託關係,如下圖1.3所示: 例如,可以在AbstractToy中宣告將聲音、形狀都委託給AbstractGun處理,模擬槍嘛,形狀和聲音都要和真實的槍一樣了,然後兩個基類下的子類自由延展,互不影響。 在Java的基礎知識中都會講到繼承,Java的三大特徵嘛,封裝、繼承、多型。繼承就是告訴你擁有父類的方法和屬性,然後你就可以重寫父類的方法。按照繼承原則,我們上面的玩具槍繼承AbstractGun是絕對沒有問題的,玩具槍也是槍嘛,但是在具體應用場景中就需要考慮下面這個問題了:子類是否能夠完整的實現父類的業務,否則就會像上面的拿槍殺敵人時卻發現是把玩具槍的笑話。
注意 如果子類不能完整地實現父類的方法,或者父類的某些方法在子類中已經發生“畸變”,則建議斷開父子繼承關係,採用依賴、聚合、組合等關係代替繼承。
2. 子類可以有自己的個性
子類當然可以有自己的行為和外觀了,也就是方法和屬性,那這裡為什麼要再提呢?因為里氏替換原則可以正著用,但是不能反過來用。在子類出現的地方,父類未必就能勝任。還是以剛才的關於槍支的離職為例,步槍有幾個比較“響亮”的型號,比如AK47、AUG狙擊步槍等,把這兩個型號的槍引入後的Rifle子類圖如下圖1.4所示: 很簡單,AUG繼承了Rifle類,狙擊手(Snipper)則直接使用AUG狙擊步槍,原始碼如下程式碼1.7所示:
public class AUG extends Rifle {
//狙擊槍都攜帶一個精準的望遠鏡
public void zoomOut() {
System.out.println("通過望遠鏡觀察敵人...");
}
public void shoot() {
System.out.printlv("AUG射擊...");
}
}
有狙擊槍就有狙擊手,狙擊手類的原始碼如下程式碼1.8所示:
public class Snipper {
public void killEnemy(Aug aug) {
//首先看看敵人的情況,別殺死敵人,自己也被敵人幹掉
aug.zoomOut();
//開始射擊
aug.shoot();
}
}
狙擊手,為什麼叫Snipper?Snipe翻譯過來就是鷸,就是“鷸蚌相爭,漁翁得利”中的那隻鳥,英國貴族到印度打獵,發現這個鷸很聰明,人一靠近就飛走了,沒辦法就開始偽裝、遠端精準射擊,於是乎Snipper就誕生了。 狙擊手使用狙擊槍來殺死敵人,業務場景類Client類的原始碼如下程式碼1.9所示:
public class Client {
public static void main(String[] args) {
//產生三毛這個狙擊手
Snipper sanMao = new Snipper();
sanMao.setRifle(new AUG());
sanMao.killEnemy();
}
}
狙擊手使用AUG殺死敵人,執行結果如下所示:
通過望遠鏡觀察敵人...
AUG射擊...
在這裡,系統直接呼叫了子類,狙擊手是很依賴槍支的,別說換一個型號了,就是換一個同型號的槍也會影響射擊,所以這裡就直接把子類傳遞了進來。這個時候,我們能不能直接使用父類傳遞進來呢?修改一下Client類,如下程式碼1.10所示:
public class Client {
public static void main(String[] args) {
//產生三毛這個狙擊手
Snipper sanMao = new Snipper();
sanMao.setGun((AUG)new Rifle());
sanMao.killEnemy();
}
}
顯示是不行的,會在執行期丟擲java.lang.ClassCastException異常,這也是大家經常說的向下轉型(downcast)是不安全的,從里氏替換原則來看,就是有子類出現的地方父類未必就可以出現。
3. 覆蓋或實現父類的方法時輸入引數可以被放大
方法中的輸入引數成為前置條件,這是什麼意思呢?大家做過Web Service開發就應該知道又一個“契約優先”的原則,也就是先定義出WSDL介面,制定好雙方的開發協議,然後再各自實現。里氏替換原則也要求制定一個契約,就是父類或介面,這種設計方法也叫作Design by Contract(契約設計),與里氏替換原則有著異曲同工之妙。契約制定了,也就同時制定了前置條和後置條件,前置條件就是你要讓我執行,就必須滿足我的條件;後置條件就是我執行完了需要反饋,標準是什麼。這個比較難理解,我們來看一個例子,我們先定義一個Father類,如下程式碼1.11所示:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父類被執行...");
return map.values();
}
}
這個類非常簡單,就是把HashMap轉換成Collection集合型別,然後再定義一個子類,原始碼如下程式碼1.12所示:
public class Son extends Father {
//放大輸入引數
public Collection doSomething(Map map) {
System.out.println("子類被執行...");
return map.values();
}
}
請注意doSomething這個方法,與父類方法名相同,但又不是覆寫(Override)父類的方法。你加個@Override試試看,會報錯的,為什麼呢?方法名雖然相同,但方法的輸入引數不同,就不是覆寫,那這是什麼呢?是過載(Overload)!不用大驚小怪的,不在一個類就不能過載了?繼承是什麼意思,子類擁有父類的所有屬性和方法,方法名相同,輸入引數型別又不相同,當然是過載了。父類和子類都已經聲明瞭,場景類的呼叫如下程式碼1.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();
}
}
程式碼執行後的結果如下所示:
父類被執行...
根據里氏替換原則,父類出現的地方子類就可以出現,我們把上面的程式碼修改一下,如下程式碼1.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類的輸入引數型別寬於類輸入引數型別,會出現什麼情況呢?會出現父類存在的地方,子類就未必存在,因為一旦把子類作為引數傳入,呼叫者就很可能進入子類的方法範疇。我們把上面的例子修改一下,擴大父類的前置條件,程式碼如下1.15所示:
public class Father {
public Collection doSomethind(Map map) {
System.out.println("父類被執行...");
return map.values();
}
}
把父類的前置條件修改為Map型別,我們再修改一下子類方法的輸入引數,相對父類縮小輸入引數的類型範圍,也就是縮小前置條件,程式碼如下1.6所示:
public class Son extends Father {
//縮小引數範圍
public Collection doSomething(HashMap map) {
System.out.println("子類被執行...");
return map.values();
}
}
在父類前置條件大於子類的情況下,業務場景的程式碼如下1.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修改一下,如下程式碼1.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,這是覆寫的要求,這才是重中之重,子類覆寫父類的方法,天經地義。如果是過載,則要求方法的輸入引數型別或數量不相同,在里氏替換原則要求下,就是子類的輸入引數寬於或等於父類的輸入引數,也就是說你寫的這個方法時不會被呼叫的,參考上面講的前置條件。 採用里氏替換原則的目的就是增搶程式的健壯性,版本升級也可以保持非常好的相容性。即使增加子類,原有的子類還是可以繼續執行。在實際的專案中,每個子類對應不同的業務含義,使用父類作為引數,傳遞不同的子類完成不同的業務邏輯,非常完美!
1.3 最佳實踐
在專案中,採用里氏替換原則時,儘量避免子類的“個性”,一旦子類有“個性”,這個子類和父類之間的關係就很難調和了,把子類當做父類使用,子類的“個性”被封殺—委屈了點;把子類單獨作為一個業務使用,則會讓程式碼間的耦合關係變得撲所迷離—缺乏類替換的標準。