里氏替換原則


我們都知道面向對象有三大特性:封裝、繼承、多態。所以我們在實際開發過程中,子類在繼承父類后,根據多態的特性,可能是圖一時方便,經常任意重寫父類的方法,那么這種方式會大大增加代碼出問題的幾率。比如下面場景:類C實現了某項功能F1。現在需要對功能F1作修改擴展,將功能F1擴展為F,其中F由原有的功能F1和新功能F2組成。新功能F由類C的子類C1來完成,則子類C1在完成功能F的同時,有可能會導致類C的原功能F1發生故障。這時候里氏替換原則就閃亮登場了。

什么是里氏替換原則

  前面說過的單一職責原則,從字面意思就很好理解,但是里氏替換原則就有點讓人摸不着頭腦。查過資料后發現原來這項原則最早是在1988年,由麻省理工學院一位姓里的女士(Liskov)提出來的。

  英文縮寫:LSP (Liskov Substitution Principle)。

  嚴格的定義:如果對每一個類型為T1的對象o1,都有類型為T2的對象o2,使得以T1定義的所有程序P在所有的對象o1都換成o2時,程序P的行為沒有變化,那么類型T2是類型T1的子類型。 

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

  更通俗的定義:子類可以擴展父類的功能,但不能改變父類原有的功能。

 

四層含義

  里氏替換原則包含以下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),因為方法的輸入參數不同。重寫和重載的區別在Java面向對象詳解一文中已作解釋,此處不再贅述。

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

代碼示例:

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=執行子類...}

總結

  繼承作為面向對象三大特性之一,在給程序設計帶來巨大便利的同時,也帶來了一些弊端,它增加了對象之間的耦合性。因此在系統設計時,遵循里氏替換原則,盡量避免子類重寫父類的方法,可以有效降低代碼出錯的可能性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM