我們逐漸對於軟件構造有了更深的要求。
設計良好的代碼需要做到可以不通過修改而擴展,新的功能通過添加新的代碼來實現,而不需要更改已有的可工作的代碼。
抽象(Abstraction)和多態(Polymorphism)是實現這一原則的主要機制,而繼承(Inheritance)則是實現抽象和多態的主要方法。而提高這一質量指標,打下構建可維護性和可重用性代碼的基礎,我們首先需要知道一個重要的原則。
本文參考了一些資料和課內內容,將着重解釋,1)什麼是里氏替換原則 2)里氏替換原則保持行爲一致的動機與目的
什麼是里氏替換原則
里氏替換原則(Liskov Substitution Principle),由Barbara Liskov 在 1988 年提出。
一句話概括:就是基類指針可以在任何不知道衍生類的條件下使用衍生類的對象。
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
詳細說明:就是要確保在父類使用的時候,(無論這時候的父類實際上是哪一種衍生類),表現都是一樣的。也就是說,使用者在書寫代碼使用該類時無需關注與考慮有什麼衍生類,以及本次是什麼衍生類。
What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.
違背LSP思想的一個生動的例子
在運行時查看其所屬於的子類型,我們在之前的學習中就曾經遇見過這種風格的代碼。其顯然破壞了多項原則,其中由於我們需要了解其中的的子類的信息,所以其違背了LSP原則
void DrawShape(const Shape& s)
{
if (typeid(s) == typeid(Square))
DrawSquare(static_cast<Square&>(s));
else if (typeid(s) == typeid(Circle))
DrawCircle(static_cast<Circle&>(s));
}
一個從矩形與正方形的繼承引發的LSP的動機思考
我們來看下面這個面向對象設計中違背LSP原則經典的例子
public class Rectangle
{
private double _width;
private double _height;
public void SetWidth(double w) { _width = w; }
public void SetHeight(double w) { _height = w; }
public double GetWidth() { return _width; }
public double GetHeight() { return _height; }
}
這個程序正常工作,沒有問題。我們不妨設想,它被成功地部署到了各個位置,發起作用。然後,假如某一天,我們需要一個正方形類型類來處理正方形。
通常來講,繼承關係是一種is-a的關係。換句話來講,如果一種新的對象與一種已有對象滿足 is-a 的關係,那麼新的對象的類應該是從已有對象的類繼承來的。
而這裏面由於很明顯正方形是一個長方形,所以其顯而易見可以從長方形衍生。
然而,當我們什麼也不考慮時,我們可能就會直接寫出如下代碼:
public class Square extends Rectangle
{
//Require width == height
}
進而,我們在使用rectangle的set方法時,如果輸入的其實是square類型變量,那麼該方法就會直接破壞square的RI。
那麼我們單純的避免這種情況,在square中複寫一下的set方法呢?
public class Square extends Rectangle
{
public void SetWidth(double w)
{
base.SetWidth(w);
base.SetHeight(w);
}
public void SetHeight(double w)
{
base.SetWidth(w);
base.SetHeight(w);
}
}
現在,只要設置 Square 對象的 Width,那麼它的 Height 也會相應跟着變化。而當設置其Height 時,Width 也同樣會改變。這樣做之後,Square 仍然可以保持其RI,Square 對象仍然是一個看起來很合理的數學中的正方形,看起來很完美。那麼現在我們擁有了兩個類,無論對於square做什麼,它都保持着對於正方形的定義。對於retangle也是會保持着對於長方形的定義。那麼現在我們可以放心的使用繼承出來的square了嗎?
並沒有…
設想存在下面的情況
void Test1(Rectangle r)
{
r.SetWidth(5);
r.SetHeight(4);
Assert.AreEqual(r.GetWidth() * r.GetHeight(), 20);
}
我們可以理解這段代碼的期待。顯然,這個函數做了一個合理的假設,唯獨在我們書寫了square,並且square傳遞進來之後,纔會出現並不符合的問題。因此我們還是不能放心的使用繼承的square。
總結來看,因爲我們要從使用者的合理假設的角度來分析,所以所有的衍生類必須符合使用者所期待的基類的行爲。
那不禁想問,我們已經思考過is-a了啊,那爲什麼既然正方形是長方形,那麼到底哪裏有問題呢?
所以哪錯了?
實際上,到底什麼是is-a,怎麼算做一個is-a。其實,我們的behavioural subtyping概念就此應運而生。其實,我們之前的設計,錯就錯在,一個正方形是一個長方形沒有錯,但是一個square對象並不是一個ractangle對象。因爲我們除了邏輯上的繼承關係,變量上的繼承關係,我們還需要考慮並且滿足子類的行爲繼承關係。
LSP原則其實讓我們意識到了,通過is-a進行繼承的思考與實現是與對象的公有的行爲息息相關的,滿足這個纔算真正實現了is-a。
在每個方法中保證行爲一致性
當我們讓客戶使用基類/接口使用對象,客戶知道的僅僅是其基類的前置條件和後置條件。也就是說其一定只會從這兩個條件做出自己的假設。
那麼我們從這個基類中延展出來的衍生類,就必須要支持基類中的條件,也就是說,其前置條件一定要弱於基類的前置條件,其後置條件一定要強於基類的後置條件。
只有這樣我們纔可以保證基類和衍生類的行爲等價性。放心的重用基類的函數,放心使用或修改衍生類的函數。
LSP:The Liskov Substitution Principle by Robert C. Martin “Uncle Bob”