設計模式—六大原則—里氏代換原則

里氏代換原則(Liskow-Substitution-Principle)

定義:子類對象能夠替換父類對象,而程序邏輯不變

​ 里氏替換原則是確保繼承正確使用的方法(繼承使用的要求條件)。
​ Liskov替代原理(LSP)指出, 子類型必須可以替代其基本類型。違反此原理時,爲了檢查對象的特定類型,它往往導致大量額外的條件邏輯散佈在整個應用程序中。隨着應用程序的增長,這些重複的,分散的代碼成爲了滋生錯誤的溫牀。

里氏替換原則有至少兩種含義

  1. 里氏替換原則是針對繼承而言的,如果繼承是爲了實現代碼重用,也就是爲了共享方法,那麼共享的父類方法就應該保持不變,不能被子類重新定義
    。子類只能通過新添加方法來擴展功能,父類和子類都可以實例化,而子類繼承的方法和父類是一樣的,父類調用方法的地方,子類也可以調用同一個繼承得來的,邏輯和父類一致的方法,這時用子類對象將父類對象替換掉時,當然邏輯一致,相安無事。
  2. 如果繼承的目的是爲了多態,而多態的前提就是子類覆蓋並重新定義父類的方法,爲了符合LSP,我們應該將父類定義爲抽象類,並定義抽象方法,讓子類重新定義這些方法,當父類是抽象類時,父類就是不能實例化,所以也不存在可實例化的父類對象在程序裏。也就不存在子類替換父類實例(根本不存在父類實例了)時邏輯不一致的可能。

​ 簡而言之:就是儘量不要從可實例化的父類中繼承,而是要使用基於抽象類(也可以是抽象方法。里氏轉換原則要避免重寫父類的非抽象方法,而多態的實現是通過重寫抽象方法實現的,所以並不衝突)和接口的繼承。基於抽象編程,而不是基於具體。這樣也就可以實現:對擴展(基於抽象)是開放的,對變更(基於具體)是禁止的。里氏轉換原則和多態是相輔相成的!

《墨子:小取》

《墨子:小取》:“白馬,馬也;乘白馬,乘馬也。驪馬,馬也;乘驪馬,乘馬也”。
​ 文中的驪馬是黑的馬。意思就是白馬和黑馬都是馬,乘白馬或者乘黑馬就是乘馬。在面向對象中我們可以這樣理解,馬是一個父類,白馬和黑馬都是馬的子類,我們說乘馬是沒有問題的,那麼我們把父類換成具體的子類,也就是乘白馬和乘黑馬也是沒有問題的,這就是我們上邊說的里氏替換原則。

《墨子:小取》:“娣,美人也,愛娣,非愛美人也”。
​ 娣是指妹妹,也就是說我的妹妹是美人,我愛我的妹妹(出於兄妹感情),但是不等於我愛美人。在面向對象裏就是,美人是一個父類,妹妹是美人的一個子類。哥哥作爲一個類有“喜愛()”方法,可以接受妹妹作爲參量。那麼這個“喜愛()”不能接受美人類的實例,這也就說明了反過來是不能成立的。

本文所涉及的信息

  1. 開閉原則
  2. 面向對象-多態
  3. 抽象類
  4. 契約優先

wikipedia

​ 可替代性處於原理編程的面向對象的說明的是,在一個計算機程序中,如果S是T的子類,那麼對象 T可以被替換爲S的對象(即類型T的對象可被取代的任何子類型S的對象),而不會更改程序的任何所需屬性(正確性,執行的任務等)。更正式地講,Liskov替換原理(LSP)是子類型關係的一種特殊定義,稱爲(強)行爲子類型,最初由Barbara Liskov引入。在1987年會議的主題演講中,主題爲數據抽象與層次。這是一個語義,而不僅僅是語法關係,因爲它打算以保證語義互操作性類型的層次結構,尤其是對象類型。芭芭拉·里斯科夫(Barbara Liskov)和珍妮特·溫(Jeannette Wing)在1994年的一篇論文中簡要地描述了這一原理。

原理

​ Liskov的行爲子類型概念定義了對象的可替換性概念。也就是說,如果S是T的子類型,則可以將程序中類型T的對象替換爲類型S的對象,而無需更改該程序的任何所需屬性(例如正確性)。
行爲子類型是比類型理論中定義的函數的典型子類型更強的概念,後者僅依賴於實參類型和返回類型的協方差。

​ Liskov的原理對已在較新的面向對象編程語言中採用的簽名提出了一些標準要求(通常在類級別而不是類型上;有關區別,請參見名義與結構子類型):
• 子類型中方法參數的矛盾性。
• 子類型中返回類型的協方差。
• 子類型的方法不應拋出新的異常,除非這些異常本身是超類型的方法所拋出的異常的子類型

行爲條件

​ 除了簽名要求之外,該子類型還必須滿足許多行爲條件。這些術語在類似於按合同設計方法的術語中進行了詳細說明,從而對合同如何與繼承進行交互產生了一些限制:

  1. 前提條件不能在子類型中得到加強。
  2. 子條件不能弱化後置條件。
  3. 超類型的不變量必須保留在子類型中。
  4. 歷史記錄約束(“歷史記錄規則”)。對象只能通過其方法(封裝)被視爲可修改的。因爲子類型可能會引入父類型中不存在的方法,所以這些方法的引入可能會導致子類型中狀態不允許在父類型中發生變化。歷史記錄約束禁止這樣做。這是Liskov和Wing引入的新穎元素。可以通過將可變點定義爲不可變點的子類型來舉例說明違反此約束的情況。這違反了歷史記錄約束,因爲在不可變點的歷史記錄中,狀態在創建後始終是相同的,因此它不能包含可變點的歷史記錄一般來說。但是,可以安全地修改添加到子類型的字段,因爲無法通過超類型方法觀察到它們。因此,可以在不違反LSP的前提下從不變點得出具有固定中心但半徑可變的圓。

起源

​ 前置條件和後置條件的規則與Bertrand Meyer在1988年的《面向對象的軟件構造》一書中引入的規則相同。Meyer和後來的Pierre America(第一個使用行爲子類型的人)都給出了一些行爲子類型概念的證明理論定義,但是它們的定義沒有考慮支持引用或指針的編程語言中可能出現的別名。。考慮混疊是Liskov和Wing(1994)所做的重大改進,關鍵因素是歷史約束。根據Meyer和America的定義,MutablePoint將是ImmutablePoint的行爲子類型,而LSP禁止這樣做。

案例

圓形橢圓問題(又稱矩形正方形問題)

​ 在軟件開發中的圓形橢圓問題(有時稱爲矩形正方形問題)說明了在對象建模中使用子類型多態性時可能出現的一些陷阱。
​ 使用面向對象的編程(OOP)時最常遇到這些問題。根據定義,此問題違反了SOLID原則之一的Liskov替代原則。

/**
 * 定義一個長方形類,只有標準的get和set方法
 */
public class Rectangle {
    protected long width;
    protected long height;

    public void setWidth(long width) {
        this.width = width;
    }

    public long getWidth() {
        return this.width;
    }

    public void setHeight(long height) {
        this.height = height;
    }

    public long getHeight() {
        return this.height;
    }
}

/**
 * 定義一個正方形類繼承自長方形類,只有一個side
 */
public class Square extends Rectangle {
    public void setWidth(long width) {
        this.height = width;
        this.width = width;
    }

    public long getWidth() {
        return width;
    }

    public void setHeight(long height) {
        this.height = height;
        this.width = height;
    }

    public long getHeight() {
        return height;
    }
}
/**
 * 測試類
 */
public class Test
{
    /**
     * 長方形的長不短的增加直到超過寬
     */
    public void resize(Rectangle r)
    {
        while (r.getHeight() <= r.getWidth() )
        {
            r.setHeight(r.getHeight() + 1);
        }
    }
}

​ 在上邊的代碼中我們定義了一個長方形和一個繼承自長方形的正方形,看着是非常符合邏輯的,但是當我們調用Test類中的resize方法時,長方形是可以的,但是正方形就會一直增大,一直long溢出。
​ 但是我們按照里氏替換原則,父類可以的地方,換成子類一定也可以,所以上邊的這個例子是不符合里氏替換原則的。

案例

/**
 * 鳥
 */
class Bird{
    public static final int IS_OSTRICH = 1;//是鴕鳥
    public static final int IS_SPARROW = 2;//是麻雀 
    public int isType;
    public Bird(int isType) {
        this.isType = isType;
    }
}
/**
 * 鴕鳥
 */
class Ostrich extends Bird{
    public Ostrich() {
        super(Bird.IS_OSTRICH);
    }
    public void toBeiJing(){
        System.out.print("跑着去北京!");
    }
}

/**
 * 麻雀
 */
class Sparrow extends Bird{
    public Sparrow() {
        super(Bird.IS_SPARROW);
    }
    public void toBeiJing(){
        System.out.print("飛着去北京!");
    }
}


/**
 * 調用方
 */
public void birdLetGo(Bird bird) {
        if (bird.isType == Bird.IS_OSTRICH) {
            Ostrich ostrich = (Ostrich) bird;
            ostrich.toBeiJing();
        } else if (bird.isType == Bird.IS_SPARROW) {
            Sparrow sparrow = (Sparrow) bird;
            sparrow.toBeiJing();
        }
    }

birdLetGo方法明顯的違反了開閉原則,它必須要知道所有Bird的子類。並且每次創建一個Bird子類就得修改它一次.

行爲條件

//動物
public class Animal {
    private String food;
    public Animal(String food) {
        this.food = food;
    }
    public String getFood() {
        return food;
    }

}

//鳥
class Bird extends Animal{
    public Bird(String food) {
        super(food);
    }
}

//鴕鳥
class Ostrich extends Bird{
    public Ostrich() {
        super("草");
    }
}

//麻雀
class Sparrow extends Bird{
    public Sparrow() {
        super("蟲子");
    }
}


class Zoo {
    /**
     * 喫早餐
     */
    public String eatBreakfast(Animal animal){
    
        //錯誤的寫法爲new 子類
        return animal.getFood();
    }
}

前置條件就是你要讓我執行,就必須滿足我的條件;
後置條件就是我執行完了需要反饋,標準是什麼。

• 這裏的滿足前置條件就是調用方需滿足能接受String這個食物類型
• 滿足後置條件可以看做是參數和返回類型
• 前置條件不能更強,只能更弱,比如可以這樣調用:
Object food = new Zoo().eatBreakfast(new Animal(“肉”));
後置條件可以更強,比如可以這樣寫:
String food = new Zoo().eatBreakfast(new Ostrich());

前置條件

說明
​ 爲了方便說明情況,請忽略子類覆蓋父類具體方法的情況,理論中是不允許子類覆蓋父類中的方法的。

public class Father {      
     public Collection doSomething(HashMap map){
             System.out.println("父類被執行...");    
             return map.values();
     }
}
public class Son extends Father {
     //重載(Overload)父類方法
     //放大輸入參數類型
     public Collection doSomething(Map map){
             System.out.println("子類被執行...");
             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();
     }
}
代碼運行後的結果:
父類被執行...

根據里氏替換原則,父類出現的地方子類就可以出現,我們把上面的粗體部分修改爲子類.

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類型,也就是說子類的輸入參數類型的範圍擴大了,子類代替父類傳遞到調用者中,子類的方法永遠都不會被執行。
擴大父類的前置條件
public class Father {      
     public Collection doSomething(Map map){
             System.out.println("父類被執行...");    
             return map.values();
     }
}
public class Son extends Father {
     //重載(Overload)父類方法
     //縮小輸入參數範圍
     public Collection doSomething(HashMap map){
             System.out.println("子類被執行...");
             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();
     }
}
代碼運行後的結果:
父類被執行...

根據里氏替換原則,父類出現的地方子類就可以出現,我們把上面的粗體部分修改爲子類.

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類型,也就是說子類的輸入參數類型的範圍擴大了,子類代替父類傳遞到調用者中,子類的方法永遠都不會被執行。

​ 子類在沒有覆寫父類的方法的前提下,子類方法被執行了,這會引起業務邏輯混亂,因爲在實際應用中父類一般都是抽象類,子類是實現類,你傳遞一個這樣的實現類就會“歪曲”了父類的意圖,引起一堆意想不到的業務邏輯混亂,所以子類中方法的前置條件必須與超類中被覆寫的方法的前置條件相同或者更寬鬆(參數範圍更大)。

後置條件

覆寫或實現父類的方法時輸出結果可以被縮小。
​ 父類的一個方法的返回值是一個類型T,子類的相同方法(重載或覆寫)的返回值爲S,那麼里氏替換原則就要求S的範圍必須小於等於T,也就是說,要麼S和T是同一個類型,要麼S是T的子類,爲什麼呢?

  1. 如果是覆寫,父類和子類的同名方法的輸入參數是相同的,兩個方法的範圍值S小於等於T,這是覆寫的要求,這纔是重中之重,子類覆寫父類的方法,天經地義(抽象的)。
  2. 如果是重載,則要求方法的輸入參數類型或數量不相同,在里氏替換原則要求下,就是子類的輸入參數寬於或等於父類的輸入參數,也就是說你寫的這個方法是不會被調用的,參考上面講的前置條件。

總結

  1. 如果LSP有效運用,程序會具有更多的可維護性、可重用性和健壯性
  2. LSP是使OCP成爲可能的主要原則之一
  3. 正是因爲子類的可替換性,才使得父類模塊無須修改的情況就得以擴展

引用

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