10.3 設計模式六大原則
我們已經瞭解到,設計模式體現的是軟件設計的思想,而不是軟件技術,它重在使用接口與抽象類來解決各種問題。在使用這些設計模式時,應該首先遵守如表10-1所示的六大原則。
表10-1 設計模式六大原則
原 則 |
含 義 |
具 體 方 法 |
開閉原則 |
對擴展開放,對修改關閉 |
多使用抽象類和接口 |
里氏代換原則 |
基類可以被子類替換 |
使用抽象類繼承,不使用具體類繼承 |
合成複用原則 |
要依賴於抽象,不要依賴於具體 |
針對接口編程,不針對實現編程 |
接口隔離原則 |
使用多個隔離的接口,比使用單個接口好 |
建立最小的接口 |
迪米特法則 |
一個軟件實體應當儘可能少地與其他實體發生相互作用 |
通過中間類建立聯繫 |
依賴倒轉原則 |
儘量使用合成/聚合,而不是使用繼承 |
儘量使用合成/聚合,而不是使用繼承 |
下面我們通過簡單的實例來講解這六大原則的具體含義。
10.3.1 開閉原則(Open Closed Principle)
通常,對於開發完的代碼都需要多種測試才能夠投入使用,這包括:
首先要經過開發人員的單元測試、集成測試。
然後再到測試人員的白盒測試、黑盒測試。
最後還要由用戶進行一定的測試。
經過漫長的測試,代碼才能夠投入使用。但是軟件產品的維護和升級又是一個永恆的話題,在維護的過程中,你可能要不斷地增加一些小功能;在升級的過程中,你要增加一些較大的功能。
因此,軟件產品隨時都有擴展功能的要求。這種功能的擴展,就要求我們改變原有的代碼。但是,對原代碼的修改就會深刻地影響到原來的功能的方方面面:
可能對舊代碼引入了新的錯誤,使你不得不對舊代碼進行大規模的修改。
可能引起你不得不重新構造系統的架構。
即使新增的代碼對舊代碼沒有影響,你也不得不對原來的系統做一個全面的測試。
所有上述列出來的問題,都是對系統功能進行擴展所不能承受的代價。換句話說,我們設計出來的系統,一定要是擴展性良好的系統。如何才能夠設計出擴展性良好的系統呢?這就需要在軟件系統設計時遵守開閉原則:
軟件系統必須對擴展開放,對修改關閉。
換句話說,我們的系統必須是可擴展的系統,而不是可修改的系統。
做到開閉原則,就注意以下兩點。
1)多使用抽象類
在設計類時,對於擁有共同功能的相似類進行抽象化處理,將公用的功能部分放到抽象類中,所有的操作都調用子類。這樣,在需要對系統進行功能擴展時,只需要依據抽象類實現新的子類即可。如圖10-1所示,在擴展子類時,不僅可以擁有抽象類的共有屬性和共有函數,還可以擁有自定義的屬性和函數。
2)多使用接口
與抽象類不同,接口只定義子類應該實現的接口函數,而不實現公有的功能。在現在大多數的軟件開發中,都會爲實現類定義接口,這樣在擴展子類時實現該接口。如果要改換原有的實現,只需要改換一個實現類即可。如圖10-2所示,各子類由接口類定義了接口函數,只需要在不同的子類中編寫不同的實現即可,當然也可以實現自有的函數。
以上兩點將會在後續的各個設計模式中得到充分的體現。
10.3.2 里氏代換原則(Liskov Substitution Principle)
里氏代換原則是由麻省理工學院(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",也就是說,子類必須能夠替換成它們的基類。
我們把里氏代換原則解釋得更完整一些:在一個軟件系統中,子類應該可以替換任何基類能夠出現的地方,並且經過替換以後,代碼還能正常工作。子類也能夠在基類的基礎上增加新的行爲。
里氏代換原則是對開閉原則的補充,它講的是基類和子類的關係。只有當這種關係存在時,里氏代換關係才存在。
"正方形是長方形"是一個理解里氏代換原則的最經典的例子。在數學領域裏,正方形毫無疑問是長方形,它是一個長寬相等的長方形。所以,應該讓正方形繼承自長方形。
長方形類如程序10-1所示。
程序10-1 長方形類Rectangle.java
package principle.liskovsubstitution;public class Rectangle {
private int height;
private int width;
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
}
繼承了長方形的正方形類如程序10-2所示。
程序10-2 正方形類Square.java
- package principle.liskovsubstitution;
- public class Square extends Rectangle {
- public void setWidth(int width) {
- super.setWidth(width);
- super.setHeight(width); }
- public void setHeight(int height) {
- super.setWidth(height);
- super.setHeight(height);
- }
- }
由於正方形的長度和寬度必須相等,所以在方法setLength()和setWidth()中,對長度和寬度賦值相同。程序10-3所示的測試類中的函數zoom()用來增加長方形的長和寬。
程序10-3 測試類TestRectangle.java
- package principle.liskovsubstitution;
- public class TestRectangle {
- public void zoom(Rectangle rectangle, int width, int height) {
- rectangle.setWidth(rectangle.getWidth() + width);
- rectangle.setHeight(rectangle.getHeight() + height);
- }
- }
顯然,當增加的長度和寬度不同時,不能夠將其中的長方形換成其子類正方形。這就違反了里氏代換原則。
爲了符合里氏代換原則,我們可以爲長方形和正方形創建一個父類Base,並在其中定義好共有的屬性,並定義一個zoom()抽象函數,如程序10-4所示。
程序10-4 父類Base.java
- package principle.liskovsubstitution;
- public abstract class Base {
- private int height;
- private int width;
- public int getHeight() {
- return height;
- }
- public void setHeight(int height) {
- this.height = height;
- }
- public int getWidth() {
- return width;
- }
- public void setWidth(int width) {
- this.width = width;
- }
- public abstract void zoom(int width, int height);
- }
長方形類繼承自該父類,並編寫自己的zoom()實現函數,如程序10-5所示。
程序10-5 修改後的長方形類BaseRectangle.java
- package principle.liskovsubstitution;
- public class BaseRectangle extends Base {
- public void zoom(int width, int height) {
- setWidth(getWidth() + width);
- setHeight(getHeight() + height);
- }
- }
正方形類也繼承自該父類,並編寫自己的zoom()實現函數,如程序10-6所示。
程序10-6 修改後的正方形類BaseSquare.java
- package principle.liskovsubstitution;
- public class BaseSquare extends Base {
- public void setWidth(int width) {
- super.setWidth(width);
- super.setHeight(width);
- }
- public void setHeight(int height) {
- super.setWidth(height);
- super.setHeight(height);
- }
- public void zoom(int width, int height) {
- int length = (width + height) /2;
- setWidth(getWidth() + length);
- setHeight(getHeight() + length);
- }
- }
編寫測試函數如程序10-7所示。
程序10-7 修改後的測試類BastTest.java
此時的Base類可以被它的子類Rectangle和Square所替代,而不用改變測試代碼。這就是符合里氏代換原則的編寫方式。
由此可見,在進行設計的時候,我們儘量從抽象類繼承,而不是從具體類繼承。如果從繼承等級樹來看,所有葉子節點應當是具體類,而所有的樹枝節點應當是抽象類或者接口。當然這只是一個一般性的指導原則,使用的時候還要具體情況具體分析。
10.3.3 依賴倒轉原則(Dependence Inversion Principle)
開閉原則的主要機制就是依賴倒轉原則,這個原則的內容是:要依賴於抽象,不要依賴於具體,即要針對接口編程,不針對實現編程。
依賴也就是耦合,共分爲下面3種。
零耦合(Nil Coupling)關係:兩個類沒有依賴關係。
具體耦合(Concrete Coupling)關係:兩個具體的類之間有依賴關係,如果一個具體類直接引用另外一個具體類,就是這種關係。
抽象耦合(Abstract Coupling)關係:這種關係發生在一個具體類和一個抽象類之間,這樣就使必須發生關係的類之間保持最大的靈活性。
依賴倒轉原則要求客戶端依賴於抽象耦合,抽象不應當依賴於細節,細節應當依賴於抽象。這個原則的另外一個表述就是:要針對接口編程,不要對實現編程。程序在需要引用一個對象時,應當儘可能地使用抽象類型作爲變量的靜態類型,這就是針對接口編程的含義。依賴倒轉原則是達到開閉原則的途徑。
要做到依賴倒轉原則,使用抽象方式耦合是關鍵。由於一個抽象耦合總要涉及具體類從抽象類繼承,並且需要保證在任何引用到某類的地方都可以改換成其子類,因此,里氏代換原則是依賴倒轉原則的基礎,依賴倒轉原則是OOD的核心原則,設計模式的研究和應用都是用它作爲指導原則的。
再拿上一節的正方形和長方形爲例,在最後的測試函數中,正確的方式是使用抽象類作爲函數參數:
- public class BastTest {
- public void zoom(Base base, int width, int height) {
- base.zoom(width, height);
- } }
即針對抽象類編程,如果將它換成如下的針對具體類的操作:
- public class BastTest { public void zoom(Rectangle rectangle, int width, int height) { rectangle.zoom(width, height); } }
這樣該類就違反了依賴倒轉原則,就不能夠複用做正方形的操作了。
依賴倒轉原則雖然強大,但是也很難實現。另外,依賴倒轉原則是假定所有的具體類都會變化,這也不是全對,有些具體類就相當穩定。使用這個類的客戶端就完全可以依賴這個具體類,而不用再弄一個抽象類。
10.3.4 接口隔離原則(Interface Segregation Principle)
接口隔離原則的意思是:使用多個隔離的接口,比使用單個接口好。也就是說,一個類對另外一個類的依賴性應當是建立在最小的接口上的。
在我們進行設計的時候,一個重要的工作就是恰當地劃分角色和角色對應的接口。因此,這裏的接口往往有兩種不同的含義。
1.接口對應的角色
指一個類型所具有的方法特徵的集合,僅僅是一種邏輯上的抽象,接口的劃分就直接帶來類型的劃分。這裏,我們可以把接口理解成角色,一個接口只是代表一個角色,每個角色都有它特定的一個接口,這裏的這個原則可以叫做角色隔離原則。
例如,我們將電腦的所有功能角色集合爲一起,構建了一個接口,如圖10-3所示。
此時,我的電腦和你的電腦要實現該接口,就必須實現所有的接口函數,顯然接口混亂,並不能夠滿足實際的需求:我的電腦可能是用來工作和學習的,你的電腦可能是用來看電影、上網和打遊戲等娛樂活動的,那我們就可以將電腦的角色劃分爲兩類,如圖10-4所示。
2.角色對應的接口
指某種語言具體的接口定義,有嚴格的定義和結構。比如Java語言裏面的Interface結構。對不同的客戶端,同一個角色提供寬窄不同的接口,也就是定製服務,僅僅提供客戶端需要的行爲,客戶端不需要的行爲則隱藏起來。
對於圖10-4中的接口定義,如果我的電腦除了工作和學習之外,還想上網,那就沒辦法了,必須實現娛樂電腦的接口,這樣就必須實現它的所有接口函數了。此時我們需要將對應角色中的接口再進行劃分,如圖10-5所示。
這樣,經過以上的劃分,如果我的電腦想增加某一項功能,只需要繼承不同的接口類即可。
由此可見,對接口角色的劃分,是從大的類上進行劃分的;對角色的接口進行的劃分,是對類的接口函數的劃分。它們兩者由粗到細,實現了接口的完全分離。
10.3.5 迪米特法則(最少知道原則)(Demeter Principle)
迪米特法則(Law of Demeter)又叫最少知道原則(Least Knowledge Principle),1987年秋天由美國Northeastern University的Ian Holland提出,被UML的創始者之一Booch等普及。後來,因爲在經典著作《 The Pragmatic Programmer》中提出而廣爲人知。
迪米特法則可以簡單說成:talk only to your immediate friends。 對於面向OOD來說,又被解釋爲下面幾種方式:
一個軟件實體應當儘可能少地與其他實體發生相互作用。
每一個軟件單位對其他的單位都只有最少的知識,而且侷限於那些與本單位密切相關的軟件單位。
迪米特法則的初衷在於降低類之間的耦合。由於每個類儘量減少對其他類的依賴,因此,很容易使得系統的功能模塊功能獨立,相互之間不存在(或很少有)依賴關係。
迪米特法則不希望類直接建立直接的接觸。如果真的有需要建立聯繫,也希望能通過它的友元類來轉達。因此,應用迪米特法則有可能造成的一個後果就是:系統中存在大量的中介類,這些類之所以存在完全是爲了傳遞類之間的相互調用關係-這在一定程度上增加了系統的複雜度。
例如,購房者要購買樓盤A、B、C中的樓,他不必直接到樓盤去買樓,而是可以通過一個售樓處去了解情況,這樣就減少了購房者與樓盤之間的耦合,如圖10-6所示。
後文中的外觀模式(Facade)和中介者模式(Mediator),都是如上這種迪米特法則應用的例子。
10.3.6 合成複用原則(Composite Reuse Principle)
合成(Composition)和聚合(Aggregation)都是關聯(Association)的特殊種類。聚合表示整體和部分的關係,表示"擁有";合成則是一種更強的"擁有",部分和整體的生命週期一樣。合成的新的對象完全支配其組成部分,包括它們的創建和銷燬等。一個合成關係的成分對象是不能與另一個合成關係共享的。
在面向對象設計中,有兩種基本的辦法可以實現複用:
第一種是通過合成/聚合,即合成複用原則,含義是指,儘量使用合成/聚合,而不是使用繼承。
第二種就是通過繼承。
要正確地選擇合成/複用和繼承的方法是,只有當以下的條件全部被滿足時,才應當使用繼承關係:
子類是超類的一個特殊種類,而不是超類的一個角色,也就是區分"Has-A"和"Is-A"。只有"Is-A"關係才符合繼承關係,"Has-A"關係應當用聚合來描述。
永遠不會出現需要將子類換成另外一個類的子類的情況。如果不能肯定將來是否會變成另外一個子類的話,就不要使用繼承。
子類具有擴展超類的責任,而不是具有置換掉(override)或註銷掉(Nullify)超類的責任。如果一個子類需要大量的置換掉超類的行爲,那麼這個類就不應該是這個超類的子類。
只有在分類學角度上有意義時,纔可以使用繼承。不要從工具類繼承。
錯誤的使用繼承而不是合成/聚合的一個常見原因是錯誤地把"Has-A"當成了"Is-A"。"Is-A"代表一個類是另外一個類的一種;"Has-A"代表一個類是另外一個類的一個角色,而不是另外一個類的特殊種類。
例如,我們需要辦理一張銀行卡,如果銀行卡默認都擁有了存款、取款和透支的功能,那麼我們辦理的卡都將具有這個功能,此時使用了繼承關係,如圖10-7所示。
爲了靈活地擁有各種功能,此時可以分別設立儲蓄卡和信用卡兩種,並有銀行卡來對它們進行聚合使用。此時採用了合成複用原則,如圖10-8所示。