敏捷軟件開發 OO原則

1.SRP 單一職責原則

 

      一點說明:OO的五大原則是指SRP、OCP、LSP、DIP、ISP。這五個原則是書中所提到的。除此之外,書中還提到一些高層次的原則用於組織高層的設計元素。

      在學習和使用OO設計的時候,我們應該明白:OO的出現使得軟件工程師們能夠用更接近真實世界的方法描述軟件系統。然而,軟件畢竟是建立在抽象層次上的東西,再怎麼接近真實,也不能替代真實或被真實替代。

      OO設計的五大原則之間並不是相互孤立的。彼此間存在着一定關聯,一個可以是另一個原則的加強或是基礎。違反其中的某一個,可能同時違反了其餘的原則。因此應該把這些原則融會貫通,牢記在心!

1. SRP(Single Responsibility Principle 單一職責原則)
      單一職責很容易理解,也很容 易實現。所謂單一職責,就是一個設計元素只做一件事。什麼是“只做一件事”?簡單說就是少管閒事。現實中就是如此,如果要你專心做一件事情,任何人都有信 心可以做得很出色。但如果,你整天被亂七八糟的事所累,還有心思和精力把每件事都作好麼?
fig-1.JPG
     “ 單一職責”就是要在設計中爲每種職責設計一個類,彼此保持正交,互不干涉。這個雕塑(二重奏)就是正交的一個例子,鋼琴家和小提琴家各自演奏自己的樂譜, 而結果就是一個和諧的交響樂。當然,真實世界中,演奏小提琴和彈鋼琴的必須是兩個人,但是在軟件中,我們往往會把兩者甚至更多攪和到一起,很多時候只是爲 了方便或是最初設計的時候沒有想到。 

      這樣的例子在設計中很常見,書中就給了一個很好的例子:調制解調器。這是一個調製解調 器最基本的功能。但是這個類事實上完成了兩個職責:連接的建立和中斷、數據的發送和接收。顯然,這違反了SRP。這樣做會有潛在的問題:當僅需要改變數據 連接方式時,必須修改Modem類,而修改Modem類的結果就是使得任何依賴Modem類的元素都需要重新編譯,不管它是不是用到了數據連接功能。解決 的辦法,書中也已經給出:重構Modem類,從中抽出兩個接口,一個專門負責連接、另一個專門負責數據發送。依賴Modem類的元素也要做相應的細化,根 據職責的不同分別依賴不同的接口。最後由ModemImplementation類實現這兩個接口。
fig-2.JPG

      從這個例子中,我們不難發現,違反SRP通常是由於過於“真實”地設計了一個類所造成的。因此,解決辦法是往更高一層進行抽象 化提取,將對某個具體類的依賴改變爲對一組接口或抽象類的依賴。當然,這個抽象化的提取應該根據需要設計,而不是盲目提取。比如剛纔這個Modem的例子 中,如果有必要,還可以把DataChannel抽象爲DataSender和DataReceiver兩個接口。

 

 

 

2.OCP——開閉原則

開閉原則很簡單,一句話:“Closed for Modification; Open for Extension”——“對變更關閉;對擴展開放”。開閉原則其實沒什麼好講的,我將其歸結爲一個高層次的設計總則。就這一點來講,OCP的地位應該比SRP優先。

OCP的動機很簡單:軟件是變化的。不論是優質的設計還是低劣的設計都無法迴避這一問題。OCP說明了軟件設計應該儘可能地使架構穩定而又容易滿足不同的需求。

爲什麼要OCP?答案也很簡單——重用。

“重用”,並不是什麼軟件工程的專業詞彙,它是工程界所共用的詞彙。早在軟件出現前,工程師們就在實踐“重用”了。比如機械產品,通過零部 件的組裝得到最終的能夠使用的工具。由於機械部件的設計和製造過程是極其複雜的,所以互換性是一個重要的特性。一輛車可以用不同的發動機、不同的變速箱、 不同的輪胎……很多東西我們直接買來裝上就可以了。這也是一個OCP的例子。

如何在OO中引入OCP原則?把對實體的依賴改爲對抽象的依賴就行了。下面的例子說明了這個過程:

05賽季的時候,一輛F1賽車有一臺V10引擎。但是到了06賽季,國際汽聯修改了規則,一輛F1賽車只能安裝一臺V8引擎。車隊很快投入了新賽車 的研發,不幸的是,從工程師那裏得到消息,舊車身的設計不能夠裝進新研發的引擎。我們不得不爲新的引擎重新打造車身,於是一輛新的賽車誕生了。但是,麻煩 的事接踵而來,國際汽聯頻頻修改規則,搞得設計師在“賽車”上改了又改,最終變得不成樣子,只能把它廢棄。

OCP-fig1.JPG

爲了能夠重用這輛昂貴的賽車,工程師們提出瞭解決方案:首先,在車身的設計上預留出安裝引擎的位置和管線。然後,根據這些設計好的規範設計引擎(或是引擎的適配器)。於是,新的賽車設計方案就這樣誕生了。

 OCP-fig2.JPG

顯然,通過重構,這裏應用的是一個典型的Bridge模式。這個實現的關鍵之處在於我們預先給引擎留出了位置!我們不必因爲對引擎的規則的頻頻變更而製造相當多的車身,而是儘可能地沿用和改良現有的車身。
說到這裏,想說一說OO設計的一個誤區。
學 習OO語言的時候,爲了能夠說明“繼承”(或者說“is-a”)這個概念,教科書上經常用實際生活中的例子來解釋。比如汽車是車,電車是車,F1賽車是汽 車,所以車是汽車、電車、F1賽車的上層抽象。這個例子並沒有錯。問題是,這樣的例子過於“形象”了!如果OO設計直接就可以將現實生活中的概念引用過 來,那也就不需要什麼軟件工程師了!OO設計的關鍵概念是抽象。如果沒有抽象,那所有的軟件工程師的努力都是徒勞的。因爲如果沒有抽象,我們只能去構造世 界中每一個對象。上面這個例子中,我們應該看到“引擎”這個抽象的存在,因爲車隊的工程師們爲它預留了位置,爲它制定了設計規範。
上面這個設計也 實現了後面要說的DIP(依賴倒置原則)。但是請記住,OCP是OO設計原則中高層次的原則,其餘的原則對OCP提供了不同程度的支持。爲了實現OCP, 我們會自覺或者不自覺地用到其它原則或是諸如Bridge、Decorator等設計模式。然而,對於一個應用系統而言,實現OCP並不是設計目的,我們 所希望的只是一個穩定的架構。所以對OCP的追求也應該適可而止,不要陷入過渡設計。正如Martin本人所說:“No significant program can be 100% closed.”“Closure not complete but strategic”

 

 

 

3.LSP——里氏替換原則

OCP作爲OO的高層原則,主張使用“抽象(Abstraction)”和“多態(Polymorphism)”將設計中的靜態結構改爲動態結構,維持設計的封閉性。

“抽象”是語言提供的功能。“多態”由繼承語義實現。

如此,問題產生了:“我們如何去度量繼承關係的質量?”

Liskov於1987年提出了一個關於繼承的原則“Inheritance should ensure that any property proved about supertype objects also holds for subtype objects.”——“繼承必須確保超類所擁有的性質在子類中仍然成立。”也就是說,當一個子類的實例應該能夠替換任何其超類的實例時,它們之間才具有 is-A關係。

該原則稱爲Liskov Substitution Principle——里氏替換原則。林先生在上課時風趣地稱之爲“老鼠的兒子會打洞”。^_^

我們來研究一下LSP的實質。學習OO的時候,我們知道,一個對象是一組狀態和一系列行爲的組合體。狀態是對象的內在特性,行爲是對象的外在特性。LSP所表述的就是在同一個繼承體系中的對象應該有共同的行爲特徵。

這一點上,表明了OO的繼承與日常生活中的繼承的本質區別。舉一個例子:生物學的分類體系中把企鵝歸屬爲鳥類。我們模仿這個體系,設計出這樣的類和關係。

 lsp-fig1.jpg

類“鳥”中有個方法fly,企鵝自然也繼承了這個方法,可是企鵝不能飛阿,於是,我們在企鵝的類中覆蓋了fly方法,告訴方法的調用者:企 鵝是不會飛的。這完全符合常理。但是,這違反了LSP,企鵝是鳥的子類,可是企鵝卻不能飛!需要注意的是,此處的“鳥”已經不再是生物學中的鳥了,它是軟 件中的一個類、一個抽象。

有人會說,企鵝不能飛很正常啊,而且這樣編寫代碼也能正常編譯,只要在使用這個類的客戶代碼中加一句判斷就行了。但是,這就是問題所 在!首先,客戶代碼和“企鵝”的代碼很有可能不是同時設計的,在當今軟件外包一層又一層的開發模式下,你甚至根本不知道兩個模塊的原產地是哪裏,也就談不 上去修改客戶代碼了。客戶程序很可能是遺留系統的一部分,很可能已經不再維護,如果因爲設計出這麼一個“企鵝”而導致必須修改客戶代碼,誰應該承擔這部分 責任呢?(大概是上帝吧,誰叫他讓“企鵝”不能飛的。^_^)“修改客戶代碼”直接違反了OCP,這就是OCP的重要性。違反LSP將使既有的設計不能封 閉!

修正後的設計如下:

 lsp-fig2.jpg

但是,這就是LSP的全部了麼?書中給了一個經典的例子,這又是一個不符合常理的例子:正方形不是一個長方形。這個悖論的詳細內容能在網上找到,我就不多廢話了。

LSP並沒有提供解決這個問題的方案,而只是提出了這麼一個問題。

於是,工程師們開始關注如何確保對象的行爲。1988年,B. Meyer提出了Design by Contract(契約式設計)理論。DbC從形式化方法中借鑑了一套確保對象行爲和自身狀態的方法,其基本概念很簡單:

  1. 每個方法調用之前,該方法應該校驗傳入參數的正確性,只有正確才能執行該方法,否則認爲調用方違反契約,不予執行。這稱爲前置條件(Pre-condition)。
  2. 一旦通過前置條件的校驗,方法必須執行,並且必須確保執行結果符合契約,這稱之爲後置條件(Post-condition)。
  3. 對象本身有一套對自身狀態進行校驗的檢查條件,以確保該對象的本質不發生改變,這稱之爲不變式(Invariant)。

以上是單個對象的約束條件。爲了滿足LSP,當存在繼承關係時,子類中方法的前置條件必須與超類中被覆蓋的方法的前置條件相同或者更寬鬆;而子類中方法的後置條件必須與超類中被覆蓋的方法的後置條件相同或者更爲嚴格。

一些OO語言中的特性能夠說明這一問題:

  • 繼承並且覆蓋超類方法的時候,子類中的方法的可見性必須等於或者大於超類中的方法的可見性,子類中的方法所拋出的受檢異常只能是超類中對應方法所拋出的受檢異常的子類。
    public   class  SuperClass {
        
    public   void  methodA()  throws  IOException {}
    }


    public   class  SubClassA  extends  SuperClass {
        
    // this overriding is illegal.
         private   void  methodA()  throws  Exception {}
    }


    public   class  SubClassB  extends  SuperClass {
        
    // this overriding is OK.
         public   void  methodA()  throws  FileNotFoundException {}
    }

  • 從Java5開始,子類中的方法的返回值也可以是對應的超類方法的返回值的子類。這叫做“協變”(Covariant)
    public   class  SuperClass  {
        
    public  Number caculate() {
            
    return   null ;
        }

    }


    public   class  SubClass  extends  SuperClass {
        
    // only compiles in Java 5 or later.
         public  Integer caculate() {
            
    return   null ;
        }

    }

可以看出,以上這些特性都非常好地遵循了LSP。但是DbC呢?很遺憾,主流的面嚮對象語言(不論是動態語言還是靜態語言)還沒有加入對DbC的支持。但是隨着AOP概念的產生,相信不久DbC也將成爲OO語言的一個重要特性之一。

一些題外話:

前一陣子《敲響OO時代的喪鐘》和《喪鐘爲誰而鳴》 兩 篇文章引來了無數議論。其中提到了不少OO語言的不足。事實上,遵從LSP和OCP,不管是靜態類型還是動態類型系統,只要是OO的設計,就應該對對象的 行爲有嚴格的約束。這個約束並不僅僅體現在方法簽名上,而是這個具體行爲的本身。這纔是LSP和DbC的真諦。從這一點來說並不能說明“萬事萬物皆對象” 的動態語言和“C++,Java”這種“按接口編程”語言的優劣,兩類語言都有待於改進。
另外,接口的語義正被OCP、LSP、DbC這樣的概念不斷地強化,接口表達了對象行爲之間的“契約”關係。而不是簡單地作爲一種實現多繼承的語法糖。

 

 

 

 

 

 

 

 

 

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