設計模式之設計原則-里氏替換原則
父類和子類
面嚮物件語言中的繼承,有以下優點:
- 程式碼共享,減少建立子類的工作量,只要繼承了父類就擁有父類的方法和屬性
- 提高程式碼重用性
- 子類可以完全繼承父類的方法,又可以對父類的方法進行重寫,既可以和父類保持一致,也可以有自身的特點
- 提高程式碼的擴充套件性,只要實現父類的方法就可以呼叫,許多開源框架都是繼承父類來實現的
- 提高專案和產品的開放性
但是缺點也有:
- 繼承具有侵入性,只要繼承,就必須擁有父類的方法和屬性,只能修改但是不能丟棄
- 降低程式碼靈活性,子類擁有的父類方法和屬性,成為了子類的約束
- 耦合性增強,父類的常量,變數以及方法被修改時,同樣會影響子類,或導致子類的實現發生變化,進而會導致實現程式碼需要重構
Java中類屬於單一繼承,多實現,介面是多繼承,多實現,使用里氏替換原則,可以發揮繼承的最大優勢,里氏替換原則的定義如下:
- 程式P中有類F的例項Fo和類S的例項So,如果將So都替換為Fo而程式P的行為沒發生變化的時候,就可以稱S是F的子類.
- 所有引用基類的地方必須能夠透明的使用其子類物件
第二種定義更簡單易懂,所有能使用父類的地方,子類的物件就可以使用,因為子類完全擁有父類的屬性和方法,所以使用子類不會產生任何錯誤或異常,使用者可能根本不知道使用的是子類還是父類,但是反過來,子類能夠出現的地方,父類物件在這裡可能就行不通。
規範定義
里氏替換原則為良好的繼承關係定義了一個規範,包括四層含義:
子類必須完全實現父類的方法
在系統設計時,常會定義一個介面或者抽象類,然後定義子類來實現它,定義呼叫介面時使用介面或者抽象類,呼叫介面時傳入其子類的物件,有這樣一個例子:
在該例中,槍的主要功能是射擊,所以我們定義一個抽象類AbstractGun,並定義一個抽象方法shoot,具體是如何射擊的在子類中各自實現,士兵中有一把槍,具體是什麼槍在例項化的時候由setGun傳入,士兵可以殺敵,所以定義killEnemy方法,程式碼如下:
// 抽象的槍類 public abstract class AbstractGun { void shoot(); } // 槍的實現類 public class HandGun extends AbstractGun { public void shoot() { System.out.println("pa~pa~pa~..."); } } public class Rifle extends AbstractGun { public void shoot() { System.out.println("tu~tu~tu~..."); } } public class MachineGun extends AbstractGun { public void shoot() { System.out.println("biu~biu~biu~..."); } } // 士兵類定義 public class Solider { private AbstractGun gun; public void setGun(AbstractGun gun) { this.gun = gun; } public void shoot() { gun.shoot(); } } // 戰鬥場景,例項化類並傳入引數 public class Battle { public static void main(String[] args){ Soldier xuSanDuo = new Solider(); // 發槍 xuSanDuo.setGun(new Rifle()); // 突突突 xuSanDuo.killEnemy(); } }
這個戰鬥場景中,setGun介面接受父類物件,那就可以接收任何子類的物件,但是我們在定義的時候不需要管是什麼子類,只要設定為父類即可,在實際場景中可以根據需要設定使用什麼子類物件.
進一步的,如果我們新增加了一種玩具槍,可以定義它繼承AbstractGun,這樣在戰場上就可以使用玩具槍物件了:
public class ToyGun extends AbstractGun {
public void shoot() {
System.out.println("噴水~噴水~噴水~...");
}
}
但是這樣我們就發現不對了,要是我們的戰士拿著這玩意兒上戰場,不用開槍就把敵人笑死了,這不是我們想要的效果,我們需要英勇戰鬥,拿真槍跟他們幹,問題出在哪裡?我們把玩具槍當成真槍玩兒了,可是這玩意兒畢竟不是槍,是個玩具,也就是說按照我們的業務要求,這兩個東西不能相提並論,玩具槍違背了我們當初定義槍是上戰場殺敵的初衷,那怎麼辦?分開,玩具是玩具,槍就是槍,我們定義一個玩具類AbstractToy:
public abstract class AbstractToy {
// 形狀和聲音委託給AbstractGun來處理,我們玩具主要供你娛樂
void play();
}
// 然後再用水槍了例項化玩具槍
public class WaterToyGun extends AbstractToy {
// 水槍玩起來會噴水
public void play() {
System.out.println("噴水~噴水~噴水~...");
}
}
這樣一來,我們就不會讓這個玩具影響我們的正常業務處理,鬧出笑話了,上面的處理告訴我們,在實際應用場景中,如果子類不能正確完整的實現父類的業務,則需要斷開繼承關係,否則就會影響我們正常的業務執行。
子類的個性
作為子類當然需要有自己的實現,而且還是與眾不同的實現,之所以這樣,是因為里氏替換原則可以正向使用,卻不能反向使用,在子類出現的地方,父類物件可能就不能夠勝任,我們把剛才的步槍和士兵擴充套件一下:
// 定義一把AK47
public class AK47 extends Rifle {
}
// 再來一把狙擊槍
public class AWM extends Rifle {
// 狙擊槍自帶瞄準鏡
public void zoomOut() {
System.out.println("敵人放大5倍");
}
public void shoot() {
// 狙擊槍一次只能打一發子彈
System.out.println("Boom~...");
}
}
// 定義一個狙擊手
public class Snipper {
// 狙擊手擁有一把狙擊槍
private AWM gun;
// 只接受狙擊槍
public void setGun(AWM gun) {
this.gun = gun;
}
// 殺敵方式也與眾不同
public void killEnemy() {
gun.zoomOut();
gun.shoot();
}
}
通過這樣的定義,我們有了一種特殊的士兵-狙擊手,他對武器的要求非常嚴格,只能接受和使用狙擊槍,所以我們在給他設定武器的時候就只能使用Rifle的子類AWM物件,而不能使用父類或者其他物件,如果一定要強制使用其他物件,就會在執行的時候丟擲java.lang.ClassCastException,也就是強制向下轉型會在執行時出現問題,這也符合里氏替換原則的要求,子類出現的地方未必父類物件就可以適用。
覆蓋或實現父類方法時可以放大輸入引數
在一個方法中,輸入引數屬於前置條件,表示如果想要執行該方法,就必須滿足該前置條件,傳入正確型別的例項方可,方法的返回值屬於後置條件,就是說如果想要接收我這個方法執行後的返回值,需要滿足我這個返回值型別,你需要使用和返回值型別一樣的變數或者物件來接收返回結果,這種方式叫做契約設計,同時定義了前置條件和後置條件並且需要滿足這兩個條件才可以執行該方法並獲取反饋結果,舉個例子:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父類被執行...");
return map.values();
}
}
// 子類
public class Son extends Father {
// 子類放大了輸入引數型別
public Collection doSomething(Map map) {
System.out.println("子類被執行...");
return map.values();
}
}
需要注意的是,這裡子類的方法沒有加上@override,並且方法引數不一樣,說明該方法屬於過載(@overload),但是由於是繼承關係,所以子類還是擁有父類的那個引數為HashMap的方法的,這也更加證明了子類中的方法屬於過載,我們在使用場景中應該是這樣的:
public class Client {
public static void main(String[] args) {
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
}
這個場景中,執行結果應該是”父類被執行…”,如果我們把場景中的Father替換為Son,執行結果還是一樣的,這是因為子類繼承了父類中引數為HashMap的方法,但是沒有重寫這個方法,所以還是執行的子類中的引數為HashMap的方法,但是由於沒有重寫,執行結果還是一樣,輸出的內容是”父類被執行…”,由於子類的輸入範圍比父類要大,所以如果引數是HashMap的話會永遠執行繼承父類的那個方法,不會執行子類的方法,如果想要執行子類方法的話,傳入的引數就不能是HashMap以及其子類物件,或者是重寫父類的這個引數為HashMap的方法才可以。
這給我們展示了一個現象,子類如果重寫或者過載了父類的方法,並將該方法的輸入引數範圍擴大了,這種情況下是正確的,因為我們可以明確預見傳入什麼樣的引數會執行哪個方法。但是如果倒過來,子類重寫或者過載父類的方法,並縮小了輸入引數的範圍,會是這樣的:
public class Father {
public Collection doSomething(Map map) {
System.out.println("父類被執行...");
return map.values();
}
}
// 子類,縮小了前置輸入引數型別
public class Son extends Father {
public Collection doSomething(HashMap map) {
System.out.println("子類被執行...");
return map.values();
}
}
在這個場景中,我們按照里氏替換原則,父類能夠出現的地方就可以使用子類物件:
public class Client {
public static void main(String[] args) {
// 可以使用子類物件
Son f = new Son();
HashMap map = new HashMap();
f.doSomething(map);
}
}
執行後的結果是”子類被執行…”,這和我們的預期不符,我們原本認為,子類繼承了父類的方法,並且這裡的HashMap也確實是Map的子類,按照正常的里氏替換原則,子類只有在重寫了父類的方法時才會執行子類方法,否則會執行父類中的引數為Map的方法,但是我們這裡沒有重寫父類方法,僅僅是過載了該方法並縮小了該方法的前置引數的範圍,就會導致執行子類的方法,在正常的業務邏輯中會引起混亂,實際應用中父類都是抽象類,子類為實現類,所以傳遞一個這樣的實現,會攔截父類中本應該本執行的方法反而去執行子類方法,所以我們在設計中應該避免使用這樣的設計方式,子類方法前置條件應該大於或者等於父類中被重寫或者過載的方法前置條件範圍。
覆寫或者實現父類方法時可以縮小輸出結果
里氏替換原則要求,子類中方法的返回值型別必須小於或者等於被重寫的父類方法的返回值型別:
- 如果是重寫方法,那麼父類和子類中有一個相同的名稱和輸入引數的方法,就會要求子類方法返回值型別小於等於父類方法返回值
- 如果是過載方法,那麼要求方法的輸入引數或者數量不同,但是里氏替換原則要求,子類的輸入引數要大於等於父類的輸入引數,所以這個被過載的方法不會被呼叫
里氏替換原則的目的是增強程式的健壯性,即便是版本升級時也可以保持很好的相容性,增加新的子類,原來的子類還可以使用,在實際專案中,每個子類對應不同的業務實現邏輯,定義時使用父類作為引數,使用時傳遞子類物件,就可以完成不同的業務實現邏輯。