1. 程式人生 > >里氏代換原則(Liskov Substitution Principle、LSP)

里氏代換原則(Liskov Substitution Principle、LSP)

一、概念

氏代換原則中說,任何基類可以出現的地方,子類一定可以出現。 LSP是繼承複用的基石,只有當衍生類可以替換掉基類,軟體單位的功能不受到影響時,基類才能真正被複用,而衍生類也能夠在基類的基礎上增加新的行為。里氏代換原則是對“開-閉”原則的補充。實現“開-閉”原則的關鍵步驟就是抽象化。而基類與子類的繼承關係就是抽象化的具體實現,所以里氏代換原則是對實現抽象化的具體步驟的規範。

  簡單的理解為一個軟體實體如果使用的是一個父類,那麼一定適用於其子類,而且它察覺不出父類物件和子類物件的區別。也就是說,軟體裡面,把父類都替換成它的子類,程式的行為沒有變化。

  子型別必須能夠替換掉它們的父型別。

 

二、繼承的優缺點

優點:

  • 程式碼共享,減少建立類的工作量,每個子類都擁有父類的方法和屬性
  • 提高程式碼的重用性
  • 子類可以形似父類,但是又異於父類。
  • 提高程式碼的可擴充套件性,實現父類的方法就可以了。許多開源框架的擴充套件介面都是通過繼承父類來完成。
  • 提高產品或專案的開放性

缺點:

  • 繼承是侵入性的,只要繼承,就必須擁有父類的所有方法和屬性
  • 降低了程式碼的靈活性,子類必須擁有父類的屬性和方法,讓子類有了一些約束
  • 增加了耦合性,當父類的常量,變數和方法被修改了,需要考慮子類的修改,這種修改可能帶來非常糟糕的結果,要重構大量的程式碼

 

三、四層含義

里氏替換原則包含以下4層含義:

  • 子類可以實現父類的抽象方法,但是不能覆蓋父類的非抽象方法。
  • 子類中可以增加自己特有的方法。
  • 當子類覆蓋或實現父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆。
  • 當子類的方法實現父類的抽象方法時,方法的後置條件(即方法的返回值)要比父類更嚴格。

 現在我們可以對以上四層含義逐個講解。

子類可以實現父類的抽象方法,但是不能覆蓋父類的非抽象方法

  在我們做系統設計時,經常會設計介面或抽象類,然後由子類來實現抽象方法,這裡使用的其實就是里氏替換原則。子類可以實現父類的抽象方法很好理解,事實上,子類也必須完全實現父類的抽象方法,哪怕寫一個空方法,否則會編譯報錯。

  里氏替換原則的關鍵點在於不能覆蓋父類的非抽象方法。父類中凡是已經實現好的方法,實際上是在設定一系列的規範和契約,雖然它不強制要求所有的子類必須遵從這些規範,但是如果子類對這些非抽象方法任意修改,就會對整個繼承體系造成破壞。而里氏替換原則就是表達了這一層含義。

  在面向物件的設計思想中,繼承這一特性為系統的設計帶來了極大的便利性,但是由之而來的也潛在著一些風險。就像開篇所提到的那一場景一樣,對於那種情況最好遵循里氏替換原則,類C1繼承類C時,可以新增新方法完成新增功能,儘量不要重寫父類C的方法。否則可能帶來難以預料的風險,比如下面一個簡單的例子的場景:

public class C {

    public int func(int a, int b){

        return a+b;

    }

}



public class C1 extends C{

    @Override

    public int func(int a, int b) {

        return a-b;

    }

}



public class Client{

    public static void main(String[] args) {

        C c = new C1();

        System.out.println("2+1=" + c.func(2, 1));

    }

}

// 執行結果:2+1=1

  上面的執行結果明顯是錯誤的。類C1繼承C,後來需要增加新功能,類C1並沒有新寫一個方法,而是直接重寫了父類C的func方法,違背里氏替換原則,引用父類的地方並不能透明的使用子類的物件,導致執行結果出錯。

子類中可以增加自己特有的方法

  在繼承父類屬性和方法的同時,每個子類也都可以有自己的個性,在父類的基礎上擴充套件自己的功能。前面其實已經提到,當功能擴充套件時,子類儘量不要重寫父類的方法,而是另寫一個方法,所以對上面的程式碼加以更改,使其符合里氏替換原則,程式碼如下:

public class C {
    public int func(int a, int b){
        return a+b;
    }
}
 
public class C1 extends C{
    public int func2(int a, int b) {
        return a-b;
    }
}
 
public class Client{
    public static void main(String[] args) {
        C1 c = new C1();
        System.out.println("2-1=" + c.func2(2, 1));
    }
}
//執行結果:2-1=1

當子類覆蓋或實現父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆

程式碼示例

import java.util.HashMap;
public class Father {
    public void func(HashMap m){
        System.out.println("執行父類...");
    }
}
 
import java.util.Map;
public class Son extends Father{
    public void func(Map m){//方法的形參比父類的更寬鬆
        System.out.println("執行子類...");
    }
}
 
import java.util.HashMap;
public class Client{
    public static void main(String[] args) {
        Father f = new Son();//引用基類的地方能透明地使用其子類的物件。
        HashMap h = new HashMap();
        f.func(h);
    }
}
// 執行結果:執行父類...

注意Son類的func方法前面是不能加@Override註解的,因為否則會編譯提示報錯,因為這並不是重寫(Override),而是過載(Overload),因為方法的輸入引數不同。

當子類的方法實現父類的抽象方法時,方法的後置條件(即方法的返回值)要比父類更嚴格

程式碼示例:

import java.util.Map;
public abstract class Father {
    public abstract Map func();
}
 
import java.util.HashMap;
public class Son extends Father{
     
    @Override
    public HashMap func(){//方法的返回值比父類的更嚴格
        HashMap h = new HashMap();
        h.put("h", "執行子類...");
        return h;
    }
}
 
public class Client{
    public static void main(String[] args) {
        Father f = new Son();//引用基類的地方能透明地使用其子類的物件。
        System.out.println(f.func());
    }
}
// 執行結果:{h=執行子類...}