六大設計原則--里氏替換原則【Liskov Substitution Principle】

聲明:本文內容是從網絡書籍整理而來,並非原創。

定義

  • 最正宗的定義:

    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.

    如果對每一個類型爲 T1的對象 o1,都有類型爲 T2 的對象o2,使得以 T1定義的所有程序 P 在所有的對象 o1 都代換成 o2 時,程序 P 的行爲沒有發生變化,那麼類型 T2 是類型 T1 的子類型。

  • 第二個定義

    functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

    所有引用基類的地方必須能透明地使用其子類的對象。

第二個定義是最清晰明確的,通俗點講只要父類能出現的地方我子類就可以出現,而且調用子類還不產生任何的錯誤或異常,調用者可能根本就不需要知道是父類還是子類。但是反過來就不成了,有子類出現的地方,父類未必就能適應。

里氏替換法則包含了四層意思:

  1. 子類必須完全的實現父類的方法。我們在做系統設計時,經常會定義一個接口或者抽象類,然後編寫實現,調用類則直接傳入接口或抽象類,其實這裏已經使用了里氏替換法則。舉個CS打槍的例子:
    這裏寫圖片描述
    槍的主要職責就是射擊,怎麼射擊就是在各個具體的子類中定義了,手槍是單發射程比較近,步槍威力大射程遠,機槍用於掃射,然後在士兵類中定義了一個方法 killEnemy 殺敵人,使用槍來殺,具體使用什麼槍來殺敵人,調用的時候才知道,我先看 AbstractGun類的程序:

    public abstract class AbstractGun { 
    //槍用來幹什麼的?射擊殺戮! 
    public abstract void shoot(); 
    } 

    以下是三個具體的槍械的實現類:

    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("機槍掃射..."); 
        } 
    }
    
    

    再來看士兵類的源碼:

    public class Soldier { 
        public void killEnemy(AbstractGun gun){ 
            System.out.println("士兵開始殺人..."); 
            gun.shoot(); 
        } 
    } 
    
    

    注意看這裏的構造方法,我們要求傳入進來的是一個抽象的槍,具體是手槍還是步槍需要在調用的時候傳入,我們來看 Client 類:

    public class Client { 
    public static void main(String[] args) { 
        //產生三毛這個士兵 
        Soldier sanMao = new Soldier(); 
        sanMao.killEnemy(new Rifle()); 
    } 
    }

    運行結果:

    士兵開始殺人... 
    步槍射擊... 

    在這個程序中,我們給三毛這個士兵一把步槍,然後就開始殺敵了,如果三毛要使用機槍當然也可以,直接把 sanMao.killEnemy(new Rifle()) 修改爲 sanMao.killEnemy(new MachineGun())就可以了,Soldier根本就不用知道是哪個子類。我們在類中調用其他類是務必要使用父類或接口,如果不能使用父類或接口,則說明類的設計已經違背了LSP原則。

    我們再來想想,如果我們有一個玩具手槍,該怎麼去定義呢?我們先在類圖上增加一個類:
    這裏寫圖片描述
    增加了一個 ToyGun 這個類,繼承於 AbstractGun 抽象類。首先我們想,玩具槍是不能用來射擊的,殺不死人的, 當然你要把玩具槍往讓頭上砸也能砸死人, 這個不算是在 shoot 方法中的功能。 我們來看ToyGun類:

    public class ToyGun extends AbstractGun { 
    //玩具槍式不能射擊的,但是編譯器又要求實現這個方法,怎麼辦?虛假一個唄! 
    @Override 
    public void shoot() { 
        //玩具槍不能射擊,這個方法就不能實現了 
    } 
    }

    然後我們來看這個場景:

    public class Client {
    public static void main(String[] args) { 
        //產生三毛這個士兵 
        Soldier sanMao = new Soldier(); 
        sanMao.killEnemy(new ToyGun()); 
    } 
    }

    士兵使用玩具槍來殺人了,看運行結果:

    士兵開始殺人...

    壞了,士兵拿着玩具槍來殺人,射擊不出子彈呀!如果在 CS 遊戲中有這種事情發生,那你就等着被人爆頭吧,然後看着自己淒涼的倒地。在這種情況下,我們已經發現業務調用類已經出現了問題,這個是業務已經不能運行了,那怎麼辦?有兩種解決辦法:

    1. 在 Soldier中增加 instanceof 的判斷,如果是玩具槍,就不用來殺敵人。這個方法可以解決問題,但是你要知道在項目中,特別是產品,增加一個類,我要讓所有與這個父類有關係的類都需要修改,你覺的可行嗎?你要是在產品中出現這個問題,因爲修正了這樣一個 BUG,就要求所有與這個父類有關係的類都增加一個判斷?客戶非跳起來跟你幹!你還想要你的客戶忠誠你嗎?這個方案否定。
    2. ToyGun 脫離繼承,建立獨立的父類,爲了做到代碼可以服用,可以與 AbastractGun建立關聯委託關係,如下圖:
      這裏寫圖片描述
      比如可以在 AbstractToy中定義聲音、形狀都都委託給 AbstractGun,仿真槍嘛當然要讓形狀、聲音和真實的槍都一樣了,然後兩個父類下的子類各自發展,互不影響。

    在 Java 的基礎知識中,每位老師都會講繼承,Java 的三大特徵嘛,繼承、封裝、多態,繼承就是告訴你擁有父類的方法和屬性,然後你就可以重寫父類的方法。按照類的繼承原則,我們上面的玩具槍繼承AbstractGun也是沒有問題的呀,畢竟也是槍嘛,但是到我們的具體項目中就要考慮這個問題了:子類是否完整的實現了父類的業務,否則就會出現像上面的拿槍殺敵人時卻發現是把玩具槍的笑話。

  2. 子類可以有自己的個性。子類當然可以有自己的行爲和外觀了,也就是方法和屬性,那這裏爲什麼要再提呢?是因爲里氏替換法則可以正着用,但是不能反過來用。在子類出現的地方,父類未必就可以勝任。還是以剛纔的那個槍械的例子爲來說明,步槍有幾個比較響亮的型號比如 AK47、G3 狙擊步槍等,我們來看類圖:
    這裏寫圖片描述
    很簡單,G3繼承了 Rifle 類,狙擊手(Snipper)則直接使用 G3狙擊步槍,我們來看一下程序:

    public class G3 extends Rifle { 
    //狙擊槍都是攜帶一個精準的望遠鏡 
    public void zoomOut(){ 
        System.out.println("通過望遠鏡觀看敵人..."); 
    } 
    
    public void shoot(){ 
        System.out.println("G3射擊..."); 
    } 
    }

    然後我們再聲明一個狙擊手類:

    public class Snipper {
    public void killEnemy(G3 g3){ 
        //首先看看敵人的情況,別殺死敵人,自己也被人幹掉 
        g3.zoomOut(); 
        //開始射擊 
        g3.shoot(); 
    } 
    }

    狙擊手,爲什麼叫 Snipper?snipe 翻譯過來就是鷸,就是鷸蚌相爭,漁翁得利中的那個動物,英國貴族到印度打獵, 發現這個鷸很聰明, 人一靠近就飛走了, 沒辦法就開始僞裝、 遠程精準射擊, 於是乎 snipper就誕生了。
    我們來看一下業務業務場景是怎麼調用的:

    public class Client { 
    public static void main(String[] args) { 
        //產生三毛這個狙擊手 
        Snipper sanMao = new Snipper(); 
        sanMao.killEnemy(new G3()); 
    } 
    }

    運行結果如下:

    通過望遠鏡觀看敵人... 
    G3射擊...

    在這裏我們直接調用了子類,一個狙擊手是很依賴槍支的,別說換一個型號的槍了,就是換一個同型號的槍也會影響射擊的,所以這裏就直接傳遞進來了子類。那這個時候,我們能不能直接使用父類傳遞進來呢?修改一下 Client類:

    public class Client { 
    public static void main(String[] args) { 
        //產生三毛這個狙擊手 
        Snipper sanMao = new Snipper(); 
        Rifle rifle = new Rifle(); 
        sanMao.killEnemy((G3)rifle); 
    } 
    }

    顯示是不行的,會在運行期報java.lang.ClassCastException異常,這也是大家經常說的向下轉型(downcast)是不安全的,從里氏替換法則來看,就是有子類出現的地方父類未必就可以出現。

  3. 覆蓋或實現父類的方法時輸入參數可以被放大。方法中的輸入參數叫做前置條件,這是什麼意思呢?大家做過 Web Service 開發就應該知道有一個“契約優先”的原則,也就是先定義出 WSDL 接口,制定好雙方的開發協議,然後再各自實現。里氏替換法則也要求制定了一個契約,就是父類或接口,這種設計方法也叫做 Design by Contract,契約優先設計,是和里氏替換法則融合在一起的。契約制定了,但是契約有前置條件和後置條件,前置條件就是你要讓我執行,就必須滿足我的條件;後置條件就是我執行完了,必須符合規定的契約。這個比較難理解,我們來看一個例子,我們先定義個 Father類:

    public class Father {   
      public Collection doSomething(HashMap map){ 
          System.out.println("父類被執行...");    
          return map.values(); 
     } 
    }

    這個類非常簡單,就是把 HashMap轉換爲 Collection 集合類型,然後我們來看子類:

    public class Son extends Father {
    //放大輸入參數類型 
    public Collection doSomething(Map map){ 
        System.out.println("子類被執行..."); 
        return map.values(); 
    }  
    }

    大家注意看子類的方法,和父類同樣的一個方法名稱,但是又不是重寫(Override)父類的方法,你加個@Override 試試看,報錯的,爲什麼呢?是輸入參數類型不同,編譯器就不認爲是重寫父類的方法了,那這是什麼?是重載(Overload) !不用大驚小怪的,不在一個類就不能是重載了?繼承是什麼意思,子類擁有父類的所有屬性和方法,那方法名重複輸入參數類型又不相同當然是重載了。我們再來看業務調用類:

    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(); 
    } 
    } 

    運行結果如下:

    父類被執行... 

    里氏替換法則說是父類出現的地方子類就能出現,我們把上面的父類部分修改爲子類,程序如下:

    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 類的輸入參數類型大於子類的輸入參數類型, 會出現什麼問題?就會出現父類存在的地方,子類就未必可以存在,因爲一旦把子類作爲參數傳入進去,調用者就很可能進入子類的方法範疇。我們把上面的例子修改一下,先看父類:

    public class Father { 
    public Collection doSomething(Map map){ 
        System.out.println("Map 轉Collection被執行");    
        return map.values(); 
    } 
    }

    把父類的前置條件修改爲 Map 類型,我們再修改一下子類方法的輸入參數,相對父類縮小輸入參數的類型範圍,也就是縮小前置條件:

    public class Son extends Father { 
    //縮小輸入參數範圍 
    public Collection doSomething(HashMap map){ 
        System.out.println("HashMap轉Collection被執行..."); 
        return map.values(); 
    } 
    }

    再來看業務場景類:

    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 類修改一下,程序如下:

    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,這個是重寫的要求,這個纔是重中之重,子類重寫父類的方法,天經地義;如果是重載,則要求方法的輸入參數不相同,在里氏替換法則要求下就是子類的輸入參數大於等於父類的輸入參數,那就是說你寫的這個方法是不會被調用到的,參考上面講的前置條件。

總結

里氏替換法則誕生的目的就是加強程序的健壯性,同時版本升級也可以做到非常好的兼容性,增加子類,原有的子類還可以繼續運行。在我們項目實施中就是每個子類對應了不同的業務含義,使用父類作爲參數,傳遞不同的子類完成不同的業務邏輯,非常完美!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章