軟件設計6原則

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

  1. package principle.liskovsubstitution;  
  2.  public class Square extends Rectangle {  
  3.     public void setWidth(int width) {    
  4.       super.setWidth(width);         
  5.       super.setHeight(width);      }     
  6.       public void setHeight(int height) {      
  7.       super.setWidth(height);        
  8.       super.setHeight(height);      
  9.  }   

由於正方形的長度和寬度必須相等,所以在方法setLength()和setWidth()中,對長度和寬度賦值相同。程序10-3所示的測試類中的函數zoom()用來增加長方形的長和寬。

程序10-3  測試類TestRectangle.java

  1. package principle.liskovsubstitution;  
  2.  public class TestRectangle {     
  3.  public void zoom(Rectangle rectangle, int width, int height) {   
  4.        rectangle.setWidth(rectangle.getWidth() + width);      
  5.     rectangle.setHeight(rectangle.getHeight() + height);    
  6.   }  
  7. }   

顯然,當增加的長度和寬度不同時,不能夠將其中的長方形換成其子類正方形。這就違反了里氏代換原則。

爲了符合里氏代換原則,我們可以爲長方形和正方形創建一個父類Base,並在其中定義好共有的屬性,並定義一個zoom()抽象函數,如程序10-4所示。

程序10-4  父類Base.java

  1. package principle.liskovsubstitution;  
  2.  public abstract class Base {    
  3.   private int height;    
  4.   private int width;     
  5.   public int getHeight() {  
  6.         return height;    
  7.   }      
  8. public void setHeight(int height) {      
  9.     this.height = height;    
  10.   }       
  11. public int getWidth() {       
  12.    return width;   
  13.    }       
  14. public void setWidth(int width) {   
  15.        this.width = width;  
  16.     }      
  17.  public abstract void zoom(int width, int height); 
  18.  }   

長方形類繼承自該父類,並編寫自己的zoom()實現函數,如程序10-5所示。

程序10-5  修改後的長方形類BaseRectangle.java

  1. package principle.liskovsubstitution;  
  2. public class BaseRectangle extends Base {    
  3.   public void zoom(int width, int height) {   
  4.        setWidth(getWidth() + width);         
  5.        setHeight(getHeight() + height);   
  6.    } 
  7.  }   

正方形類也繼承自該父類,並編寫自己的zoom()實現函數,如程序10-6所示。

程序10-6  修改後的正方形類BaseSquare.java

  1. package principle.liskovsubstitution;  
  2.  public class BaseSquare extends Base {   
  3.    public void setWidth(int width) {     
  4.      super.setWidth(width);        
  5.   super.setHeight(width);     
  6.  }   
  7.    public void setHeight(int height) {     
  8.      super.setWidth(height);     
  9.      super.setHeight(height);     
  10.  }           
  11.  public void zoom(int width, int height) {     
  12.      int length = (width + height) /2;       
  13.    setWidth(getWidth() + length);       
  14.    setHeight(getHeight() + length);     
  15.  } 
  16.  }   

編寫測試函數如程序10-7所示。

程序10-7  修改後的測試類BastTest.java

package principle.liskovsubstitution;  
public class BastTest {   
   public void zoom(Base base, int width, int height) {      
    base.zoom(width, height);    
  }  

  

此時的Base類可以被它的子類Rectangle和Square所替代,而不用改變測試代碼。這就是符合里氏代換原則的編寫方式。

由此可見,在進行設計的時候,我們儘量從抽象類繼承,而不是從具體類繼承。如果從繼承等級樹來看,所有葉子節點應當是具體類,而所有的樹枝節點應當是抽象類或者接口。當然這只是一個一般性的指導原則,使用的時候還要具體情況具體分析。

10.3.3  依賴倒轉原則(Dependence Inversion Principle)

開閉原則的主要機制就是依賴倒轉原則,這個原則的內容是:要依賴於抽象,不要依賴於具體,即要針對接口編程,不針對實現編程。

依賴也就是耦合,共分爲下面3種。

零耦合(Nil Coupling)關係:兩個類沒有依賴關係。

具體耦合(Concrete Coupling)關係:兩個具體的類之間有依賴關係,如果一個具體類直接引用另外一個具體類,就是這種關係。

抽象耦合(Abstract Coupling)關係:這種關係發生在一個具體類和一個抽象類之間,這樣就使必須發生關係的類之間保持最大的靈活性。

依賴倒轉原則要求客戶端依賴於抽象耦合,抽象不應當依賴於細節,細節應當依賴於抽象。這個原則的另外一個表述就是:要針對接口編程,不要對實現編程。程序在需要引用一個對象時,應當儘可能地使用抽象類型作爲變量的靜態類型,這就是針對接口編程的含義。依賴倒轉原則是達到開閉原則的途徑。

要做到依賴倒轉原則,使用抽象方式耦合是關鍵。由於一個抽象耦合總要涉及具體類從抽象類繼承,並且需要保證在任何引用到某類的地方都可以改換成其子類,因此,里氏代換原則是依賴倒轉原則的基礎,依賴倒轉原則是OOD的核心原則,設計模式的研究和應用都是用它作爲指導原則的。

再拿上一節的正方形和長方形爲例,在最後的測試函數中,正確的方式是使用抽象類作爲函數參數:

  1. public class BastTest {   
  2.    public void zoom(Base base, int width, int height) {    
  3.       base.zoom(width, height);   
  4.    }  }   

即針對抽象類編程,如果將它換成如下的針對具體類的操作:

  1. 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所示。

 

 




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章