裡氏代換原則是由麻省理工學院(MIT)計算機科學實驗室的Liskov女士,在1987年的OOPSLA大會上發表的一篇文章《Data Abstraction and Hierarchy》裡面提出來的,主要闡述了有關繼承的一些原則,也就是什麼時候應該使用繼承,什麼時候不應該使用繼承,以及其中的蘊涵的原理。2002年,我們前面單一職責原則中提到的軟件工程大師Robert C. Martin,出版了一本《Agile Software Development Principles Patterns and Practices》,在文中他把裡氏代換原則最終簡化為一句話:“Subtypes must be substitutable for their base types”。也就是,子類必須能夠替換成它們的基類。
我們把裡氏代換原則解釋得更完整一些:在一個軟件系統中,子類應該可以替換任何基類能夠出現的地方,並且經過替換以後,代碼還能正常工作。
4.2 第一個例子:正方形不是長方形
“正方形不是長方形”是一個理解裡氏代換原則的最經典的例子。在數學領域裡,正方形毫無疑問是長方形,它是一個長寬相等的長方形。所以,我們開發的一個與幾何圖形相關的軟件系統中,讓正方形繼承自長方形是順利成章的事情。現在,我們截取該系統的一個代碼片段進行分析:
長方形類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是我們的軟件系統中的一個組件,它有一個resize方法要用到基類Rectangle,resize方法的功能是模擬長方形寬度逐步增長的效果:
測試類TestRectangle:
class TestRectangle {
public void resize(Rectangle objRect) {
while(objRect.getWidth() <= objRect.getLength() ) {
objRect.setWidth( objRect.getWidth () + 1 );
}
}
}
我們運行一下這段代碼就會發現,假如我們把一個普通長方形作為參數傳入resize方法,就會看到長方形寬度逐漸增長的效果,當寬度大於長度,代碼就會停止,這種行為的結果符合我們的預期;假如我們再把一個正方形作為參數傳入resize方法後,就會看到正方形的寬度和長度都在不斷增長,代碼會一直運行下去,直至系統產生溢出錯誤。所以,普通的長方形是適合這段代碼的,正方形不適合。
我們得出結論:在resize方法中,Rectangle類型的參數是不能被Square類型的參數所代替,如果進行了替換就得不到預期結果。因此,Square類和Rectangle類之間的繼承關系違反了裡氏代換原則,它們之間的繼承關系不成立,正方形不是長方形。
4.3 第二個例子:鴕鳥不是鳥
“鴕鳥非鳥”也是一個理解裡氏代換原則的經典的例子。“鴕鳥非鳥”的另一個版本是“企鵝非鳥”,這兩種說法本質上沒有區別,前提條件都是這種鳥不會飛。生物學中對於鳥類的定義:“恆溫動物,卵生,全身披有羽毛,身體呈流線形,有角質的喙,眼在頭的兩側。前肢退化成翼,後肢有鱗狀外皮,有四趾”。所以,從生物學角度來看,鴕鳥肯定是一種鳥。
我們設計一個與鳥有關的系統,鴕鳥類順理成章地由鳥類派生,鳥類所有的特性和行為都被鴕鳥類繼承。大多數的鳥類在人們的印象中都是會飛的,所以,我們給鳥類設計了一個名字為fly的方法,還給出了與飛行相關的一些屬性,比如飛行速度(velocity)。
鳥類Bird:
class Bird {
double velocity;
public fly() { //I am flying; };
public setVelocity(double velocity) { this.velocity = velocity; };
public getVelocity() { return this.velocity; };
}
鴕鳥不會飛怎麼辦?我們就讓它扇扇翅膀表示一下吧,在fly方法裡什麼都不做。至於它的飛行速度,不會飛就只能設定為0了,於是我們就有了鴕鳥類的設計。
鴕鳥類Ostrich:
class Ostrich extends Bird {
public fly() { //I do nothing; };
public setVelocity(double velocity) { this.velocity = 0; };
public getVelocity() { return 0; };
}
好了,所有的類都設計完成,我們把類Bird提供給了其它的代碼(消費者)使用。現在,消費者使用Bird類完成這樣一個需求:計算鳥飛越黃河所需的時間。
對於Bird類的消費者而言,它只看到了Bird類中有fly和getVelocity兩個方法,至於裡面的實現細節,它不關心,而且也無需關心,於是給出了實現代碼:
測試類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!");
}
};
}
如果我們拿一種飛鳥來測試這段代碼,沒有問題,結果正確,符合我們的預期,系統輸出了飛鳥飛越黃河的所需要的時間;如果我們再拿鴕鳥來測試這段代碼,結果代碼發生了系統除零的異常,明顯不符合我們的預期。
對於TestBird類而言,它只是Bird類的一個消費者,它在使用Bird類的時候,只需要根據Bird類提供的方法進行相應的使用,根本不會關心鴕鳥會不會飛這樣的問題,而且也無須知道。它就是要按照“所需時間 = 黃河的寬度 / 鳥的飛行速度”的規則來計算鳥飛越黃河所需要的時間。
我們得出結論:在calcFlyTime方法中,Bird類型的參數是不能被Ostrich類型的參數所代替,如果進行了替換就得不到預期結果。因此,Ostrich類和Bird類之間的繼承關系違反了裡氏代換原則,它們之間的繼承關系不成立,鴕鳥不是鳥。
4.4 鴕鳥到底是不是鳥?
“鴕鳥到底是不是鳥”,鴕鳥是鳥也不是鳥,這個結論似乎就是個悖論。產生這種混亂有兩方面的原因:
原因一:對類的繼承關系的定義沒有搞清楚。
面向對象的設計關注的是對象的行為,它是使用“行為”來對對象進行分類的,只有行為一致的對象才能抽象出一個類來。我經常說類的繼承關系就是一種“Is-A”關系,實際上指的是行為上的“Is-A”關系,可以把它描述為“Act-As”。關於類的繼承的細節,我們可以單獨再講。
我們再來看“正方形不是長方形”這個例子,正方形在設置長度和寬度這兩個行為上,與長方形顯然是不同的。長方形的行為:設置長方形的長度的時候,它的寬度保持不變,設置寬度的時候,長度保持不變。正方形的行為:設置正方形的長度的時候,寬度隨之改變;設置寬度的時候,長度隨之改變。所以,如果我們把這種行為加到基類長方形的時候,就導致了正方形無法繼承這種行為。我們“強行”把正方形從長方形繼承過來,就造成無法達到預期的結果。
“鴕鳥非鳥”基本上也是同樣的道理。我們一講到鳥,就認為它能飛,有的鳥確實能飛,但不是所有的鳥都能飛。問題就是出在這裡。如果以“飛”的行為作為衡量“鳥”的標准的話,鴕鳥顯然不是鳥;如果按照生物學的劃分標准:有翅膀、有羽毛等特性作為衡量“鳥”的標准的話,鴕鳥理所當然就是鳥了。鴕鳥沒有“飛”的行為,我們強行給它加上了這個行為,所以在面對“飛越黃河”的需求時,代碼就會出現運行期故障。
原因二:設計要依賴於用戶要求和具體環境。
繼承關系要求子類要具有基類全部的行為。這裡的行為是指落在需求范圍內的行為。圖中鳥類具有4個對外的行為,其中2個行為分別落在A和B系統需求中:
系統需求和對象關系示意圖
A需求期望鳥類提供與飛翔有關的行為,即使鴕鳥跟普通的鳥在外觀上就是100%的相像,但在A需求范圍內,鴕鳥在飛翔這一點上跟其它普通的鳥是不一致的,它沒有這個能力,所以,鴕鳥類無法從鳥類派生,鴕鳥不是鳥。
B需求期望鳥類提供與羽毛有關的行為,那麼鴕鳥在這一點上跟其它普通的鳥一致的。雖然它不會飛,但是這一點不在B需求范圍內,所以,它具備了鳥類全部的行為特征,鴕鳥類就能夠從鳥類派生,鴕鳥就是鳥。
所有派生類的行為功能必須和使用者對其基類的期望保持一致,如果派生類達不到這一點,那麼必然違反裡氏替換原則。在實際的開發過程中,不正確的派生關系是非常有害的。伴隨著軟件開發規模的擴大,參與的開發人員也越來越多,每個人都在使用別人提供的組件,也會為別人提供組件。最終,所有人的開發的組件經過層層包裝和不斷組合,被集成為一個完整的系統。每個開發人員在使用別人的組件時,只需知道組件的對外裸露的接口,那就是它全部行為的集合,至於內部到底是怎麼實現的,無法知道,也無須知道。所以,對於使用者而言,它只能通過接口實現自己的預期,如果組件接口提供的行為與使用者的預期不符,錯誤便產生了。裡氏代換原則就是在設計時避免出現派生類與基類不一致的行為。
4.5 如何正確地運用裡氏代換原則
裡氏代換原則目的就是要保證繼承關系的正確性。我們在實際的項目中,是不是對於每一個繼承關系都得費這麼大勁去斟酌?不需要,大多數情況下按照“Is-A”去設計繼承關系是沒有問題的,只有極少的情況下,需要你仔細處理一下,這類情況對於有點開發經驗的人,一般都會覺察到,是有規律可循的。最典型的就是使用者的代碼中必須包含依據子類類型執行相應的動作的代碼:
動物類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();
}
}
}
象這種代碼是明顯不符合裡氏代換原則的,它給使用者使用造成很大的麻煩,甚至無法使用,對於以後的維護和擴展帶來巨大的隱患。實現開閉原則的關鍵步驟是抽象化,基類與子類之間的繼承關系就是一種抽象化的體現。因此,裡氏代換原則是實現抽象化的一種規范。違反裡氏代換原則意味著違反了開閉原則,反之未必。裡氏代換原則是使代碼符合開閉原則的一個重要保證。
我們常見這樣的代碼,至少我以前的Java和php項目中就出現過。比如有一個網頁,要實現對於客戶資料的查看、增加、修改、刪除功能,一般Server端對應的處理類中都有這麼一段:
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
}
大家都很熟悉吧,其實這是違背裡氏代換原則的,結果就是可維護性和可擴展性會變差。有人說:我這麼用,效果好像不錯,干嘛講究那麼多呢,實現需求是第一位的。另外,這種寫法看起來很很直觀的,有利於維護。其實,每個人所處的環境不同,對具體問題的理解不同,難免局限在自己的領域內思考問題。對於這個說法,我覺得應該這麼解釋:作為一個設計原則,是人們經過很多的項目實踐,最終提煉出來的指導性的內容。如果對於你的項目來講,顯著增加了工作量和復雜度,那我覺得適度的違反並不為過。做任何事情都是個度的問題,過猶不及都不好。在大中型的項目中,是一定要講究軟件工程的思想,講究規范和流程的,否則人員協作和後期維護將會是非常困難的。對於小型的項目可能相應的要簡化很多,可能取決於時間、資源、商業等各種因素,但是多從軟件工程的角度去思考問題,對於系統的健壯性、可維護性等性能指標的提高是非常有益的。像生命周期只有一個月的系統,你還去考慮一大堆原則,除非腦袋被驢踢了。
實現開閉原則的關鍵步驟是抽象化,基類與子類之間的繼承關系就是一種抽象化的體現。因此,裡氏代換原則是實現抽象化的一種規范。違反裡氏代換原則意味著違反了開閉原則,反之未必。裡氏代換原則是使代碼符合開閉原則的一個重要保證。
通過裡氏代換原則給我們帶來了什麼樣的啟示?
類的繼承原則:如果一個繼承類的對象可能會在基類出現的地方出現運行錯誤,則該子類不應該從該基類繼承,或者說,應該重新設計它們之間的關系。
動作正確性保證:符合裡氏代換原則的類擴展不會給已有的系統引入新的錯誤。