開放封閉原則(Open Closed Principle)是構建可維護性和可重用性代碼的基礎。它強調設計良好的代碼可以不通過修改而擴展,新的功能通過添加新的代碼來實現,而不需要更改已有的可工作的代碼。抽象(Abstraction)和多態(Polymorphism)是實現這一原則的主要機制,而繼承(Inheritance)則是實現抽象和多態的主要方法。
那么是什么設計規則在保證對繼承的使用呢?優秀的繼承層級設計都有哪些特征呢?是什么在誘使我們構建了不符合開放封閉原則的層級結構呢?這些就是本篇文章將要回答的問題。
里氏替換原則(LSP: The Liskov Substitution Principle)
使用基類對象指針或引用的函數必須能夠在不了解衍生類的條件下使用衍生類的對象。
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
Barbara Liskov 在 1988 年提出了這一原則:
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原則的示例就是使用 RTTI(Run Time Type Identification)來根據對象類型選擇函數執行。
1 void DrawShape(const Shape& s) 2 { 3 if (typeid(s) == typeid(Square)) 4 DrawSquare(static_cast<Square&>(s)); 5 else if (typeid(s) == typeid(Circle)) 6 DrawCircle(static_cast<Circle&>(s)); 7 }
顯然 DrawShape 函數的設計存在很多問題。它必須知道所有 Shape 基類的衍生子類,並且當有新的子類被創建時就必須修改這個函數。事實上,很多人看到這個函數的結構都認為是在詛咒面向對象設計。
正方形和長方形,違背原則的微妙之處
很多情況下對 LSP 原則的違背方式都十分微妙。設想在一個應用程序中使用了 Rectangle 類,描述如下:
1 public class Rectangle 2 { 3 private double _width; 4 private double _height; 5 6 public void SetWidth(double w) { _width = w; } 7 public void SetHeight(double w) { _height = w; } 8 public double GetWidth() { return _width; } 9 public double GetHeight() { return _height; } 10 }
試想這個應用程序可以良好地工作,並且已被部署到了多個位置。就像所有成功的軟件一樣,它的用戶提了新的需求。假設某一天用戶要求該應用程序除了能夠處理長方形(Rectangle)之外還要能夠處理正方形(Square)。
通常來說,繼承關系是 is-a 的關系。換句話講,如果一種新的對象與一種已有對象滿足 is-a 的關系,那么新的對象的類應該是從已有對象的類繼承來的。
很明顯一個正方形是一個長方形,可以滿足所有常規的目的和用途。因此這就建立了 is-a 的關系,Square 的邏輯模型可以從 Rectangle 衍生。
對 is-a 關系的使用是面向對象分析(Object Oriented Analysis)的基本技術之一。一個正方形是一個(is-a)長方形,所有 Square 類應當從 Rectangle 類衍生。然而這種思考方式將引起一些微妙的卻很嚴重的問題。通常在我們沒有實際使用這些代碼之前,這些問題是無法被預見的。
關於這個問題,我們的第一個線索可能是Square 類並不需要 _height 和 _width 成員變量,盡管無論如何它都繼承了它們。可以看出這是一種浪費,而且如果我們持續創建成百上千個 Square 對象,這種浪費就會表現的十分明顯。
盡管如此,我們也可以假設我們並不是十分關心內存的開銷。那還有什么問題嗎?當然!Square 類將繼承 SetWidth 和 SetHeight 方法。這些方法對於 Square 來說是完全不適當的,因為一個正方形的長和寬是一樣的。這就應該是另一個顯著的線索了。然而,有一種方法可以規避這個問題。我們可以覆寫SetWidth 和 SetHeight 方法。如下所示:
1 public class Square : Rectangle 2 { 3 public void SetWidth(double w) 4 { 5 base.SetWidth(w); 6 base.SetHeight(w); 7 } 8 public void SetHeight(double w) 9 { 10 base.SetWidth(w); 11 base.SetHeight(w); 12 } 13 }
現在,無論誰設置 Square 對象的 Width,它的 Height 也會相應跟着變化。而當設置 Height 時,Width 也同樣會改變。這樣做之后,Square 看起來很完美了。Square 對象仍然是一個看起來很合理的數學中的正方形。
1 public void TestCase1() 2 { 3 Square s = new Square(); 4 s.SetWidth(1); // Fortunately sets the height to 1 too. 5 s.SetHeight(2); // sets width and heigt to 2, good thing. 6 }
但現在看下下面這個方法:
1 void f(Rectangle r) 2 { 3 r.SetWidth(32); // calls Rectangle::SetWidth 4 }
如果我們傳遞一個 Square 對象的引用到這個方法中,則 Square 對象將被損壞,因為它的 Height 將不會被更改。這里明確地違背了 LSP 原則,此函數在衍生對象為參數的條件下無法正常工作。而失敗的原因是因為在父類 Rectangle 中沒有將 SetWidth 和 SetHeight 設置為 virtual 函數。
我們也能很容易的解決這個問題。但盡管這樣,當創建一個衍生類將導致對父類做出修改,通常意味着這個設計是有缺陷的,具體的說就是它違背了 OCP 原則。我們可能會認為真正的設計瑕疵是忘記了將SetWidth 和 SetHeight 設置為 virtual 函數,而且我們已經修正了這個問題。但是,其實也很難自圓其說,因為設置 Rectangle 的 Height 和 Width 已經不再是一個原子操作。無論是何種原因我們將它們設置為 virtual,我們都將無法預期 Square 的存在。
還有,假設我們接收了這個參數,並且解決了這些問題。我們最終得到了下面這段代碼:
1 public class Rectangle 2 { 3 private double _width; 4 private double _height; 5 6 public virtual void SetWidth(double w) { _width = w; } 7 public virtual void SetHeight(double w) { _height = w; } 8 public double GetWidth() { return _width; } 9 public double GetHeight() { return _height; } 10 } 11 12 public class Square : Rectangle 13 { 14 public override void SetWidth(double w) 15 { 16 base.SetWidth(w); 17 base.SetHeight(w); 18 } 19 public override void SetHeight(double w) 20 { 21 base.SetWidth(w); 22 base.SetHeight(w); 23 } 24 }
問題的根源
此時此刻我們有了兩個類,Square 和 Rectangle,而且看起來可以工作。無論你對 Square 做什么,它仍可以保持與數學中的正方形定義一致。而且也不管你對 Rectangle 對象做什么,它也將符合數學中長方形的定義。並且當你傳遞一個 Square 對象到一個可以接收 Rectangle 指針或引用的函數中時,Square 仍然可以保證正方形的一致性。
既然這樣,我們可能得出結論了,這個模型現在是自洽的(self-consistent)和正確的。但是,這個結論其實是錯誤的。一個自洽的模型不一定對它的所有用戶都保持一致!
(注:自洽性即邏輯自洽性和概念、觀點等的前后一貫性。首先是指建構一個科學理論的若干個基本假設之間,基本假設和由這些基本假設邏輯地導出的一系列結論之間,各個結論之間必須是相容的,不相互矛盾的。邏輯自洽性也要求構建理論過程中的所有邏輯推理和數學演算正確無誤。邏輯自洽性是一個理論能夠成立的必備條件。)
試想下面這個方法:
1 void g(Rectangle r) 2 { 3 r.SetWidth(5); 4 r.SetHeight(4); 5 Assert.AreEqual(r.GetWidth() * r.GetHeight(), 20); 6 }
這個函數調用了 SetWidth 和 SetHeight 方法,並且認為這些函數都是屬於同一個 Rectangle。這個函數對 Rectangle 是可以工作的,但是如果傳遞一個 Square 參數進去則會發生斷言錯誤。
所以這才是真正的問題所在:寫這個函數的程序員是否完全可以假設更改一個 Rectangle 的 Width 將不會改變 Height 的值?
很顯然,寫這個函數 g 的程序員做了一個非常合理的假設。而傳遞一個 Square 到這樣的函數中才會引發問題。因此,那些已存在的接收 Rectangle 對象指針或引用的函數也同樣是不能對 Square 對象正常操作的。這些函數揭示了對 LSP 原則的違背。此外,Square 從 Rectangle 衍生也破壞了這些函數,所以也違背了 OCP 原則。
有效性不是內在的
這引出了一個非常重要的結論。從孤立的角度看,一個模型無法自己進行有意義地驗證。模型的正確性僅能通過它的使用者來表達。例如,孤立地看 Square 和 Rectangle,我們發現它們是自洽的並且是有效的。但當我們從一個對基類做出合理假設的程序員的角度來看待它們時,這個模型就被打破了。
因此,當考慮一個特定的設計是否合理時,決不能簡單的從孤立的角度來看待它,而必須從該設計的使用者的合理假設的角度來分析。
到底哪錯了?
那么到底發生了什么呢?為什么看起來很合理的 Square 和 Rectangle模型變壞了呢?難道說一個 Square 是一個 Rectangle 不對嗎?is-a 的關系不存在嗎?
不!一個正方形可以是一個長方形,但一個 Square 對象絕對不是一個 Rectangle 對象。為什么呢?因為一個 Square 對象的行為與一個 Rectangle 對象的行為是不一致的。從行為的角度來看,一個 Square 不是一個 Rectangle !而軟件設計真正關注的就是行為(behavior)。
LSP 原則使我們了解了 OOD 中 is-a 關系是與行為有關的。不是內在的私有的行為,而是外在的公共的行為,是使用者依賴的行為。例如,上述函數 g 的作者依賴了一個基本事實,那就是 Rectangle 的 Width 和 Height 彼此之間的變化是無依賴關系的。而這種無依賴的關系就是一種外在的公共的行為,並且其他程序員有可能也會這么想。
為了仍然遵守 LSP 原則,並同時符合 OCP 原則,所有的衍生類必須符合使用者所期待的基類的行為。
契約式設計(Design by Contract)
Bertrand Meyer 在 1988 年闡述了 LSP 原則與契約式設計之間的關系。使用契約式設計,類中的方法需要聲明前置條件和后置條件。前置條件為真,則方法才能被執行。而在方法調用完成之前,方法本身將確保后置條件也成立。
我們可以看到 Rectangle 的 SetWidth 方法的后置條件是:
1 Contract.Ensures((_width == w) && (_height == Contract.OldValue<double>(_height)));
為衍生類設置前置條件和后置條件的規則是,Meyer 描述的是:
…when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
換句話說,當通過基類接口使用對象時,客戶類僅知道基類的前置條件和后置條件。因此,衍生類對象不能期待客戶類服從強於基類中的前置條件。也就是說,它們必須接受任何基類可以接受的條件。而且,衍生類必須符合基類中所定義的后置條件。也就是說,它們的行為和輸出不能違背任何已經與基類建立的限制。基類的客戶類絕不能對衍生類的輸出產生任何疑惑。
顯然,后置條件 Square::SetWidth(double w) 要弱於 Rectangle::SetWidth(double w),因為它不符合基類的中的條件子句 "(_height == Contract.OldValue<double>(_height))"。所以,Square::SetWidth(double w) 違背了基類定立的契約。
有些編程語言,對前置條件和后置條件有直接的支持。你可以直接定義這些條件,然后在運行時驗證系統。如果編程語言不能直接支持條件定義,我們也可以考慮手工定義這些條件。
總結
開放封閉原則(Open Closed Principle)是許多面向對象設計啟示思想的核心。符合該原則的應用程序在可維護性、可重用性和魯棒性等方面會表現的更好。里氏替換原則(Liskov Substitution Principle)則是實現 OCP 原則的重要方式。只有當衍生類能夠完全替代它們的基類時,使用基類的函數才能夠被安全的重用,然后衍生類也可以被放心的修改了。
面向對象設計的原則
參考資料
- LSP:The Liskov Substitution Principle by Robert C. Martin “Uncle Bob”
- The SOLID Principles, Explained with Motivational Posters
- Dangers of Violating SOLID Principles in C#
- An introduction to the SOLID principles of OO design
本文《里氏替換原則(Liskov Substitution Principle)》由 Dennis Gao 翻譯改編自 Robert Martin 的文章《LSP: The Liskov Substitution Principle》,未經作者本人同意禁止任何形式的轉載,任何自動或人為的爬蟲行為均為耍流氓。