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)引入新的錯誤。