面向?qū)ο缶幊?鴕鳥到底是不是鳥?企鵝是不是鳥?

1.什么是里氏替換原則

里氏替換原則是由麻省理工學院(MIT)計算機科學實驗室的Liskov女士,在1987年的OOPSLA大會上發(fā)表的一篇文章《Data Abstraction and Hierarchy》里面提出來的,主要闡述了有關(guān)繼承的一些原則,也就是什么時候應(yīng)該使用繼承,什么時候不應(yīng)該使用繼承,以及其中的蘊涵的原理。
2002年,我們前面單一職責原則中提到的軟件工程大師Robert C. Martin,出版了一本《Agile Software Development Principles Patterns and Practices》,在文中他把里氏替換原則最終簡化為一句話:“Subtypes must be substitutable for their base types”。也就是,子類必須能夠替換成它們的基類。
我們把里氏替換原則解釋得更完整一些:在一個軟件系統(tǒng)中,子類應(yīng)該可以替換任何基類能夠出現(xiàn)的地方,并且經(jīng)過替換以后,代碼還能正常工作。

2.第一個例子:正方形不是長方形

“正方形不是長方形”是一個理解里氏替換原則的最經(jīng)典的例子。
在數(shù)學領(lǐng)域里,正方形毫無疑問是長方形,它是一個長寬相等的長方形。所以,我們開發(fā)的一個與幾何圖形相關(guān)的軟件系統(tǒng)中,讓正方形繼承自長方形是順利成章的事情。現(xiàn)在,我們截取該系統(tǒng)的一個代碼片段進行分析:

//長方形類
Rectangle:
class Rectangle {

  double length;
  double width;

  public double getLength() { return length; } 
  public void setLength(double height) { this.length = length; }   
  public double getWidth() { return width; }
  public void setWidth(double width) { this.width = width; } 
}

//正方形類Square:
class Square extends Rectangle {
  public void setWidth(double width) {
    super.setLength(width);
    super.setWidth(width);   
 }
  public void setLength(double length) { 
    super.setLength(length);
    super.setWidth(length);   
  } 
}

由于正方形的度和寬度必須相等,所以在方法setLength和setWidth中,對長度和寬度賦值相同。類TestRectangle是我們的軟件系統(tǒng)中的一個組件,它有一個resize方法要用到基類Rectangle,resize方法的功能是模擬長方形寬度逐步增長的效果:

//測試類TestRectangle:
class TestRectangle {
    public void resize(Rectangle objRect) {
        while(objRect.getWidth() <= objRect.getLength()) {
        objRect.setWidth(  objRect.getWidth () + 1 );
        }
    }

我們運行一下這段代碼就會發(fā)現(xiàn),假如我們把一個普通長方形作為參數(shù)傳入resize方法,就會看到長方形寬度逐漸增長的效果,當寬度大于長度,代碼就會停止,這種行為的結(jié)果符合我們的預(yù)期;假如我們再把一個正方形作為參數(shù)傳入resize方法后,就會看到正方形的寬度和長度都在不斷增長,代碼會一直運行下去,直至系統(tǒng)產(chǎn)生溢出錯誤。所以,普通的長方形是適合這段代碼的,正方形不適合。

我們得出結(jié)論:在resize方法中,Rectangle類型的參數(shù)是不能被Square類型的參數(shù)所代替,如果進行了替換就得不到預(yù)期結(jié)果。
因此,Square類和Rectangle類之間的繼承關(guān)系違反了里氏替換原則,它們之間的繼承關(guān)系不成立,正方形不是長方形。

3.第二個例子:鴕鳥不是鳥

“鴕鳥非鳥”也是一個理解里氏替換原則的經(jīng)典的例子。
“鴕鳥非鳥”的另一個版本是“企鵝非鳥”,這兩種說法本質(zhì)上沒有區(qū)別,前提條件都是這種鳥不會飛。
生物學中對于鳥類的定義:
“恒溫動物,卵生,全身披有羽毛,身體呈流線形,有角質(zhì)的喙,眼在頭的兩側(cè)。前肢退化成翼,后肢有鱗狀外皮,有四趾”。
所以,從生物學角度來看,鴕鳥肯定是一種鳥。

我們設(shè)計一個與鳥有關(guān)的系統(tǒng),鴕鳥類順理成章地由鳥類派生,鳥類所有的特性和行為都被鴕鳥類繼承。大多數(shù)的鳥類在人們的印象中都是會飛的,所以,我們給鳥類設(shè)計了一個名字為fly的方法,還給出了與飛行相關(guān)的一些屬性,比如飛行速度(velocity)。

//鳥類Bird:
class Bird {
   double velocity;
   public fly() { //I am flying; };
 
   public setVelocity(double velocity) { this.velocity = velocity; };
   public getVelocity() { return this.velocity; };
}
//鴕鳥不會飛怎么辦?
//我們就讓它扇扇翅膀表示一下吧,在fly方法里什么都不做。
//至于它的飛行速度,不會飛就只能設(shè)定為0了,于是我們就有了鴕鳥類的設(shè)計。

//鴕鳥類Ostrich:
class Ostrich extends Bird {
    public fly() { //I do nothing; };
    public setVelocity(double velocity) { this.velocity = 0; };
   public getVelocity() { return 0; };
}

好了,所有的類都設(shè)計完成,我們把類Bird提供給了其它的代碼(消費者)使用。
現(xiàn)在,消費者使用Bird類完成這樣一個需求:計算鳥飛越黃河所需的時間。
對于Bird類的消費者而言,它只看到了Bird類中有fly和getVelocity兩個方法,至于里面的實現(xiàn)細節(jié),它不關(guān)心,而且也無需關(guān)心,于是給出了實現(xiàn)代碼:

//測試類TestBird:
class TestBird {
   public calcFlyTime(Bird bird) {
       try{
         double riverWidth = 3000;
         System.out.println(riverWidth / bird.getVelocity());
       }catch(Exception err){
         System.out.println("An error occured!");
       }
   }
} 

如果我們拿一種飛鳥來測試這段代碼,沒有問題,結(jié)果正確,符合我們的預(yù)期,系統(tǒng)輸出了飛鳥飛越黃河的所需要的時間;如果我們再拿鴕鳥來測試這段代碼,結(jié)果代碼發(fā)生了系統(tǒng)除零的異常,明顯不符合我們的預(yù)期。

對于TestBird類而言,它只是Bird類的一個消費者,它在使用Bird類的時候,只需要根據(jù)Bird類提供的方法進行相應(yīng)的使用,根本不會關(guān)心鴕鳥會不會飛這樣的問題,而且也無須知道。它就是要按照“所需時間 = 黃河的寬度 / 鳥的飛行速度”的規(guī)則來計算鳥飛越黃河所需要的時間。

結(jié)論:在calcFlyTime方法中,Bird類型的參數(shù)是不能被Ostrich類型的參數(shù)所代替,如果進行了替換就得不到預(yù)期結(jié)果。
因此,Ostrich類和Bird類之間的繼承關(guān)系違反了里氏替換原則,它們之間的繼承關(guān)系不成立,鴕鳥不是鳥。

4.鴕鳥到底是不是鳥?

“鴕鳥到底是不是鳥”,鴕鳥是鳥也不是鳥,這個結(jié)論似乎就是個悖論。產(chǎn)生這種混亂有兩方面的原因:

原因一:對類的繼承關(guān)系的定義沒有搞清楚。

面向?qū)ο蟮脑O(shè)計關(guān)注的是對象的行為,它是使用“行為”來對對象進行分類的,只有行為一致的對象才能抽象出一個類來。
我經(jīng)常說類的繼承關(guān)系就是一種“Is-A”關(guān)系,實際上指的是行為上的“Is-A”關(guān)系,可以把它描述為“Act-As”

再來看“正方形不是長方形”這個例子,正方形在設(shè)置長度和寬度這兩個行為上,與長方形顯然是不同的。
長方形的行為:
設(shè)置長方形的長度的時候,它的寬度保持不變,設(shè)置寬度的時候,長度保持不變。
正方形的行為:
設(shè)置正方形的長度的時候,寬度隨之改變;
設(shè)置寬度的時候,長度隨之改變。

所以,如果我們把這種行為加到基類長方形的時候,就導(dǎo)致了正方形無法繼承這種行為。我們“強行”把正方形從長方形繼承過來,就造成無法達到預(yù)期的結(jié)果。

“鴕鳥非鳥”基本上也是同樣的道理。
我們一講到鳥,就認為它能飛,有的鳥確實能飛,但不是所有的鳥都能飛。問題就是出在這里。
如果以“飛”的行為作為衡量“鳥”的標準的話,鴕鳥顯然不是鳥;
如果按照生物學的劃分標準:有翅膀、有羽毛等特性作為衡量“鳥”的標準的話,鴕鳥理所當然就是鳥了。
鴕鳥沒有“飛”的行為,我們強行給它加上了這個行為,所以在面對“飛越黃河”的需求時,代碼就會出現(xiàn)運行期故障。

原因二:設(shè)計要依賴于用戶要求和具體環(huán)境。

繼承關(guān)系要求子類要具有基類全部的行為。
這里的行為是指落在需求范圍內(nèi)的行為.
A需求期望鳥類提供與飛翔有關(guān)的行為,即使鴕鳥跟普通的鳥在外觀上就是100%的相像,但在A需求范圍內(nèi),鴕鳥在飛翔這一點上跟其它普通的鳥是不一致的,它沒有這個能力,所以,鴕鳥類無法從鳥類派生,鴕鳥不是鳥。
需求期望鳥類提供與羽毛有關(guān)的行為,那么鴕鳥在這一點上跟其它普通的鳥一致的。雖然它不會飛,但是這一點不在B需求范圍內(nèi),所以,它具備了鳥類全部的行為特征,鴕鳥類就能夠從鳥類派生,鴕鳥就是鳥。

所有派生類的行為功能必須和使用者對其基類的期望保持一致,如果派生類達不到這一點,那么必然違反里氏替換原則。在實際的開發(fā)過程中,不正確的派生關(guān)系是非常有害的。伴隨著軟件開發(fā)規(guī)模的擴大,參與的開發(fā)人員也越來越多,每個人都在使用別人提供的組件,也會為別人提供組件。最終,所有人的開發(fā)的組件經(jīng)過層層包裝和不斷組合,被集成為一個完整的系統(tǒng)。每個開發(fā)人員在使用別人的組件時,只需知道組件的對外裸露的接口,那就是它全部行為的集合,至于內(nèi)部到底是怎么實現(xiàn)的,無法知道,也無須知道。所以,對于使用者而言,它只能通過接口實現(xiàn)自己的預(yù)期,如果組件接口提供的行為與使用者的預(yù)期不符,錯誤便產(chǎn)生了。里氏替換原則就是在設(shè)計時避免出現(xiàn)派生類與基類不一致的行為。

5.如何正確地運用里氏替換原則

里氏替換原則目的就是要保證繼承關(guān)系的正確性。
我們在實際的項目中,是不是對于每一個繼承關(guān)系都得費這么大勁去斟酌?
不需要,大多數(shù)情況下按照“Is-A”去設(shè)計繼承關(guān)系是沒有問題的,只有極少的情況下,需要你仔細處理一下,這類情況對于有點開發(fā)經(jīng)驗的人,一般都會覺察到,是有規(guī)律可循的。
最典型的就是使用者的代碼中必須包含依據(jù)子類類型執(zhí)行相應(yīng)的動作的代碼:

//動物類Animal:
public class Animal{
  String name;
  public Animal(String name) {
    this.name = name;
 }
  public void printName(){
  try{
     System.out.println("I am a " + name + "!");
  }catch(Exception err){
     System.out.println("An error occured!");
  }
}
}

//貓類Cat:
public class Cat extends Animal{
  public Cat(String name){
    super(name);
  }
  public void Mew(){
  try{
       System.out.println("Mew~~~ ");
  }catch(Exception err){
       System.out.println("An error occured!");
  }
 }
}

//狗類Dog:
public class Dog extends Animal {
  public Dog(String name) {
    super(name);
 }
 public void Bark(){
 try{
     System.out.println("Bark~~~ ");
 }catch(Exception err){
     System.out.println("An error occured!");
 }
}
}

//測試類:TestAnimal
public class TestAnimal {
   public void TestLSP(Animal animal){
     if (animal instanceof Cat ){
         Cat cat = (Cat)animal;
         cat.printName();
         cat.Mew();
     }
     if (animal instanceof Dog ){
 
       Dog dog = (Dog)animal;
       dog.printName();
       dog.Bark();
     }
 }
}

象這種代碼是明顯不符合里氏替換原則的,它給使用者使用造成很大的麻煩,甚至無法使用,對于以后的維護和擴展帶來巨大的隱患。實現(xiàn)開閉原則的關(guān)鍵步驟是抽象化,基類與子類之間的繼承關(guān)系就是一種抽象化的體現(xiàn)。因此,里氏替換原則是實現(xiàn)抽象化的一種規(guī)范。違反里氏替換原則意味著違反了開閉原則,反之未必。里氏替換原則是使代碼符合開閉原則的一個重要保證。

我們常見這樣的代碼,至少我以前的Java和php項目中就出現(xiàn)過。比如有一個網(wǎng)頁,要實現(xiàn)對于客戶資料的查看、增加、修改、刪除功能,一般Server端對應(yīng)的處理類中都有這么一段:

if(action.Equals(“add”)){
  //do add action
}
else if(action.Equals(“view”)){
  //do view action
}
else if(action.Equals(“delete”)){
  //do delete action
}
else if(action.Equals(“modify”)){
  //do modify action
}

大家都很熟悉吧,其實這是違背里氏替換原則的,結(jié)果就是可維護性和可擴展性會變差。
有人說:我這么用,效果好像不錯,干嘛講究那么多呢,實現(xiàn)需求是第一位的。
另外,這種寫法看起來很很直觀的,有利于維護。
其實,每個人所處的環(huán)境不同,對具體問題的理解不同,難免局限在自己的領(lǐng)域內(nèi)思考問題。
對于這個說法,我覺得應(yīng)該這么解釋:作為一個設(shè)計原則,是人們經(jīng)過很多的項目實踐,最終提煉出來的指導(dǎo)性的內(nèi)容。
如果對于你的項目來講,顯著增加了工作量和復(fù)雜度,那我覺得適度的違反并不為過。做任何事情都是個度的問題,過猶不及都不好。
在大中型的項目中,是一定要講究軟件工程的思想,講究規(guī)范和流程的,否則人員協(xié)作和后期維護將會是非常困難的。
對于小型的項目可能相應(yīng)的要簡化很多,可能取決于時間、資源、商業(yè)等各種因素,但是多從軟件工程的角度去思考問題,對于系統(tǒng)的健壯性、可維護性等性能指標的提高是非常有益的。
像生命周期只有一個月的系統(tǒng),你還去考慮一大堆原則,除非腦袋被驢踢了。

實現(xiàn)開閉原則的關(guān)鍵步驟是抽象化,基類與子類之間的繼承關(guān)系就是一種抽象化的體現(xiàn)。因此,里氏替換原則是實現(xiàn)抽象化的一種規(guī)范。違反里氏替換原則意味著違反了開閉原則,反之未必。里氏替換原則是使代碼符合開閉原則的一個重要保證。

6.通過里氏替換原則的啟示?

類的繼承原則:
如果一個繼承類的對象可能會在基類出現(xiàn)的地方出現(xiàn)運行錯誤,則該子類不應(yīng)該從該基類繼承,或者說,應(yīng)該重新設(shè)計它們之間的關(guān)系。

動作正確性保證:
符合里氏替換原則的類擴展不會給已有的系統(tǒng)引入新的錯誤。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容