Successfully reported this slideshow.
We use your LinkedIn profile and activity data to personalize ads and to show you more relevant ads. You can change your ad preferences anytime.

物件導向設計原理及設計樣式

972 views

Published on

  • Be the first to comment

  • Be the first to like this

物件導向設計原理及設計樣式

  1. 1. Design Principles and Design Patterns物件導向設計原理及設計樣式原作者:Robert C. Martin www.objectmentor.com翻譯:: Areca Chen甚麼是軟體架構(architecture)?答案是 『多層次(multitiered)』 在最高層,有所 。謂的架構樣式(architecture patterns),架構樣式定義軟體應用程式整個具體的表現形式(shape)及結構(structure)。往下一層,便是架構,架構說明軟體應用程式的意圖(purpose)。 在架構下面還有其它的層,其中有一些模組(modules)及模組之間的相互聯繫(interconnection)。這是設計樣式、包裹(packages)、元件(components)及類別(classes) 的領域。這是本文關心的層次。本文的範圍是相當有限的。我只有討論相關的原理及樣式。有興趣的讀者可以參考《Designing Object Oriented Applications using UML, 2d. ed., Robert C.Martin, Prentice Hall, 1999.》這本書。架構及相依性軟體出了甚麼問題?許多軟體應用程式的設計,一開始在軟體設計人員的腦中,有一個鮮明的影像。在初期這個影像是非常清晰、簡鍊、令人信服的。這個影像非常漂亮,因此設計人員及實作人員渴望看到這個影像運行的樣子。 有些這樣的應用程式,其乾淨的設計,終能透過初始的開發而第一次的發行。但此時會逐漸出現一些問題。 軟體開始腐敗了。一開始還不是那麼糟糕。這裡有一個醜陋的瘤(wart),那裡有一個不當的缺口。然而設計仍然完整的顯現其美麗。但是,隨著時間的進展,腐敗持續的發酵,腐爛的地方逐漸擴散累積,最後這些腐爛的程式碼控制了應用程式的設計。 這個時候程式變成腐敗的程式碼,而程式開發人員發現,已經難以維護這個程式。 最後,這種偏離的結果需要矯正,即使是最簡單的變動。 因此迫使工程師及最前線的管理人員要求一個重新設計的專案。這樣的重新設計很少是可以成功的。儘管設計人員開始的時候其目標是正確的,但他們發現他們的目標是移動的。舊有的系統持續發展及變動,而新的設計必須追上。在它可以第一次發行之前,瘤及潰爛在新的設計中已經累積。一般在你規劃完成不久,便會面臨這受詛咒的日子。陷入問題泥淖的新設計,可能已經爛到讓設計人員哭著要重新設計了。
  2. 2. 腐敗的設計呈現的徵兆這裡有四個徵兆可以告訴我們,我們的設計已經開始腐爛了。 這四個徵兆並不是等量齊觀的,但從彼此互相關連的方式來看是比較明顯的。這四個徵兆是:僵硬性(rigidity)、脆弱性(fragility)、不動性 (immobility)及黏滯性(viscosity)。僵硬性: 僵硬性表示軟體難以變動的傾向,既使是最簡單的變動。每一個變動都會連動一系列相關模組的變動。剛開始只是花一兩天變動一個模組,但隨著工程師追蹤應用程式中變動的連動,卻演變成花好幾個禮拜,馬拉松式的一個接這一個模組的變動。當軟體的情況是這樣的時候,管理人員便會畏懼讓工程師修復非關鍵的問題。管理人員之所以會有這種抗拒的情形,是因為他們無法確認工程師所可以信賴的完成時間,將會是甚麼時候。如果管理人員放任工程師不去處理這些問題,這些程式設計師將會有一段時間不見蹤影。此時軟體設計就像是『補蟑屋』--只看到工程師進去,不見出來。當管理人員的恐懼達到頂點,此時他們拒絕任何對軟體的變動,這便是官僚的僵硬性到來。至此,一開始只是設計的缺陷,最終變成了負面的管理策略。脆弱性: 與僵硬性相關的就是脆弱性。脆弱性是每當變動時,軟體傾向於在許多地方碎裂。往往發生碎裂的地方,在概念上並未與變動的地方相關連。管理人員心中充滿了這類錯誤的預感。每次他們被授權作修復,他們害怕軟體在某個不可預期的地方將會碎裂。當脆弱性變得更嚴重時,碎裂的可能性逐漸升高,沒有症狀但無聲無息達到最嚴重的程度。這樣的軟體是沒有辦法維護的。每一次修復都使它變得更脆弱,每一次解決問題都會帶入更多的問題。這樣的軟體讓管理人員及客戶懷疑,開發人員已經無法再掌控這個軟體。不信任感籠罩,彼此失去了信任感。不動性: 不動性是無法從其它專案或同一專案的其他部份的軟體再使用。工程師常常發現他所需的模組類似其他的工程師所撰寫的模組。然而,一個考慮中的模組往往也因被依賴而有太多的包袱。在進行一段時間的工作之後,工程師發現區隔軟體中想要的部分及不想要的部分,這個動作的風險太大難以忍受。因此,重寫軟體反而比再使用容易些。黏滯性: 黏滯性有兩種形式:設計的黏滯性及環境的黏滯性。當面對變動,工程師往往可以發現不止變動的方式不只一種。 有些方式可以保留設計,有的則否(即:亂砍(hacks))。若保留設計的方法比亂砍難以使用,這表示設計的黏滯性高。黏滯性高的設計容易作錯事,而不容易作正確的事。
  3. 3. 環境的黏滯性來自於開發環境的遲鈍及沒有效率。 舉例來說,如果編譯時間非常長,工程師傾向於僅對不需要大量編譯的部分作變動,既使這個變動從設計觀點不是最佳化的。 如果只為了一些檔案,在你的程式碼控制系統(source codecontrol system)中登錄,卻需要花幾個小時,那麼工程師傾向於只針對需要簡單登錄的部分進行修改;而不是考慮是否保持設計的穩固。以上四個徵兆顯示你的架構是有問題的。任何應用程式出現這些徵兆時,都是由於設計部分由內而外腐敗的結果。但,是甚麼原因導致這樣的腐敗呢?變動的需求導致設計沈淪的直接原因已經很明白。不斷變動的需求在初始的設計當中並未被預測到。而這些變動往往需要快速的處理,而且這些變動往往是那些對原始設計理念不是很熟悉的工程師所為。因此,既使設計的變動,對於應用程式還是可以運行的,這些變動或多或少違反原始的設計。一點一點的,當這些變動逐漸的注入,這些違反原始設計的結果累積變成了應用程式的腫瘤。不管如何,我們無法苛責需求的漂移導致設計的沈淪。身為軟體工程師,我們知道需求必然是會變動的。事實上,我們大多知道需求文件是專案中最反復無常的文件。如果我們的設計無法追上不斷變動的需求,這是我們設計的責任。我們應該想辦法找出,讓我們的設計回應這些變動;並且避免設計的腐敗。相依性的管理怎樣的變動會導致設計的腐敗?當變動是帶入新的且是未曾事先規劃的相依性。前述的四項徵兆,都可能直接或間接來自軟體中模組間不當的相依性。這就是逐漸沈淪的相依性架構,這樣的架構使得軟體逐漸喪失其可維護性。為了預先防止沈淪為相依性架構,模組間的相依性是應用程式中必須管理的項目。這項管理會持續建立相依性的防火牆。透過這樣的防火牆,可以避免相依性的繁殖。在物件導向設計中,有許多原理及技術可以建立這樣的防火牆,及用來管理模組之間的相依性。後面我們會討論這些原理及技術。首先我們會檢驗這些原理;然後是技術;或設計樣式,這些可以幫助我們維護應用程式的相依性架構。物件導向類別設計的法則開放關閉法則(The Open Closed Principle, OCP)模組應該易於擴充(開放),但避免被修改(關閉)。
  4. 4. 在物件導向設計法則中,這是最重要的一個。這個法則是來自 Bertrand Meyer的研究。簡單的說:當我們撰寫我們的模組時,應該讓它是可以擴充的(extended),同時無須要被修改。換言之,我們希望可以改變模組所為,而不用改變模組的原始碼。這樣說或許有些矛盾,但是有許多技術可以大規模的實現 OCP。所有這些技術的基礎都是抽象(abstractin)。事實上,抽象是 OCP 的關鍵。下面我們要說明一些這樣的技術:動態多型(Dynamic Polymorphism): 請參考 Listing 2-1。其中 LogOn 方法,每當在軟體中加入一種 modem 時,便需要修改。更慘的是,因為每一種不同型態的 Modem 依賴 Modem 類別 enumeration 型態的 Type 物件(Modom::Type)。每當一個新型態的 Modem 類別加入,便需要重新編譯 Modem類別。Listing 2-1:LogOn 方法,每次擴充都需要修改。 struct Modem { enum Type (hayes, courrier, ernie) type; }; struct Hayes { Modem::Type type; // Hayes related stuff }; struct Courrier { Modem::Type type; // Courrier related stuff
  5. 5. }; struct Ernie { Modem::Type type; // Ernie related stuff }; void LogOn(Modem& m,string& pno, string& user, string& pw) { if (m.type == Modem::hayes) DialHayes((Hayes&)m, pno); else if (m.type == Modem::courrier) DialCourrier((Courrier&)m, pno); else if (m.type == Modem::ernie) DialErnie((Ernie&)m, pno) // ...you get the idea }當然這不是最差的設計。這樣的程式設計方式,傾向於使用 if/else 或切換(switch)的陳述。每當需要對 Modem 作某些動作,便需要一串的切換陳述 if/else來選擇使用適當的功能。當加入一個新的 Modem,或 Modem 的策略變動,便需要審視所有選擇陳述的程式碼,而且每一個必須適當的修改。更慘的是,程式設計師可能只是採用部分程式碼的最佳化,這些動作隱藏選擇陳述的結構。例如,有可能當 Hayers 及 Courrier 等 Modem 的功能完全相同,此時我們看到的程式碼就像這樣: if (modem.type == Modem::ernie)
  6. 6. SendErnie((Ernie&)modem, c); else SendHayes((Hayes&)modem, c);很明顯的,這樣的結構讓系統很難維護,而且很容易埋下錯誤的伏筆。這裡有一個 OCP 的例子,如 Figure 2-13。其中 LogOn 方法只有依賴 Modem 介面。其餘的 Modems 並不會導致 LogOn 方法的變動。因此,我們建立一個可以擴充的模組。使用這個新的模組,就不需要再修改了。請參閱 Listing2-2。 Figure 2-13Listing 2-2:LogOn 方法可以避免被修改。 class Modem { public: virtual void Dial(const string& pno) = 0; virtual void Send(char) = 0; virtual char Recv() = 0; virtual void Hangup() = 0;
  7. 7. } void LogOn(Modem& m, String Pno, String& user, string pw) { m.Dial(pno); // you get the idea. }靜態多型(Static Polymorphism): 另一個符合 OCP 的技術是透過使用模版(templates)或一般化(generics)。Listing 2-3 展示這項作法。LogOn 方法可以使用各種不同的 Modems 型態來擴充,而無須修改。Listing 2-3:LogOn 方法透過靜態多型可以避免被修改。 template <typename MODEM> void LogOn(MODEM m, string pno, sring user, string pw) { m.Dial(pon); //you get the idea. }OCP 的架構目標: 透過使用符合 OCP 的技術,我們能夠建構可以擴充的模組,而不需要變動。這表示,應用一點點的事先的考量,我們可以在現有的程式碼中加入新的功能,而不用變動現有的程式碼,只需加入新的程式碼。這是一個理想,但可能很難達成,但你將在本書中後面的部分看到它的實現。既使 OCP 無法完全的達成,既使部分的 OCP 實現,可以戲劇性的改善應用程式的結構。 如果變動不再散播到現有已經可以運行的程式碼當中,總是一件好事如果你不用變動可以運行的程式碼,你就比較不會有破壞它的可能。Liskov 替換法則(The Liskov Substitution Principle, LSP)子類別應該可以使用其基礎類別替代。
  8. 8. 這個法則是由 Barbara Liskov,是他在考慮資料抽象及型別理論的研究時所創造。同時在 Bertrand Meyer 所提出的契約式設計(Design by Contract, DBC)概念中也有提到。這個概念如前所述,可以從 Figure 2-14 中表達。 Figure 2-14: LSP 概要換言之,如果在某些 User 的方法中,使用型別 Base(基礎型別)作為參數,如 Listing 2-4 所示,那麼它應該允許在方法中傳入 Derived(繼承型別)的物件實例。Listing 2-4 User, Base,Derived 範例 void User(Base& b) Derived d; User(d);這看起來似乎很明顯,但有些細節的部分需要考量。標準的範例是圓形及橢圓形的兩難推論。圓形及橢圓形的兩難推論: 如多數我們在高中數學所學的,圓形是橢圓形的一個特例。所有的圓形是橢圓形中碰巧其焦點是重疊的。 『是一個(is-a)』 這是的關係,因此我們使用繼承用來塑模圓形及橢圓形的關係,如 Figure 2-15 所示:
  9. 9. Figure 2-15:圓形及橢圓形的兩難推論雖然這個滿足我們的概念模型,但是這裡有些困難點。 我們仔細檢視橢圓形的類別宣告,如 Figure2-16 所示。Ellipse 有三個資料元素。前兩個是焦點,另一個是主軸的長度。如果 Circle 繼承 Ellipse,那麼將繼承所有的資料變數。很不幸的,Circle 只需要兩個資料元素。一個圓心及半徑。 Figure 2-16:Ellipse 類別的宣告除此之外,如果我們忽略在儲存空間些微的重疊,我們可以讓 Circle 改寫其SetFoci 方法,以便其行為表現的更恰當;即保持其兩個焦點的值相同。請參閱Listing 2-5。Listing 2-5 維持 Circle 的焦點重疊 void Circle::SetFoci(const Point& a, const Point& b) { itsFocusA = a;
  10. 10. itsFocusB = a; }客戶端摧毀所有的東西: 確實,我們建立的模組就其本身而言是一致性的。Circle 類別的實例物件遵循圓型的所有規則。任何你所做的都不會違背這些規則。同樣的 Ellipse 也一樣。這兩個類別形成一個完整一致性的模組,既使 Circle 有一個多餘的資料元素。但是,Circle 及 Ellipse 他們本身無法單獨存在於一個領域(universe)中。在這個領域中有許多的個體同時存在,而 Circle 及 Ellipse 類別提供這些個體的公開介面。 這些介面意味著契約。 這個契約可能不是很明確的說明,但還是存在的。 例如Ellipse 的使用者有權要求下列的程式碼片段是可行的: void f(Ellipse& e) { Point a(-1,0); Point b(1,0); e.SetFoci(a,b); e.SetMajorAxis(3); assert(e.GetFocusA() == a); assert(e.GetFocusB() == b); assert(e.GetMajorAxis() == 3); }此時這個方法是屬於 Ellipse 類別的。就其本身而論,這個方法預期可以設定焦點及主軸,並修改成為所設定的值。如果我們在這個方法中傳進的是一個Ellipse 物件,沒有問題。但是如果我們在這個方法中傳進的是一個 Circle 物件,便會死得很難看。如果我們要讓 Ellipse 的契約明確化,在 SetFoci 中應有一個後置條件(postcondition),以確保輸入值複製到成員變數中,而主軸的變數維持不變。很明顯的,Circle 違反這項保證,因為它忽略 SetFoci 方法的第二個變數。
  11. 11. 契約式設計: 再回到 LSP,我們可以說,為了可以替代;基礎類別的契約,在繼承類別中必須遵循。 因為 Circle 類別並未遵循 Ellipse 類別的契約,因此無法被替代,同時違反 LSP。如何讓契約明確化,是 Bertrand Meyer 研究的方向。他創造了一種語言稱為Eiffel,在這個語言之中,每一個方法明確的表達契約,並且在每一次呼叫時都會確實的檢查。 對於不是使用 Eiffel 語言的我們,必須作一些簡單的驗證及註釋。要說明一個方法的契約,我們宣告一個方法在呼叫前某些情況必須是確定的。 這稱為前置條件(precondition)。如果前置條件不成立,表示這個方法是未定義的(undefined),同時這個方法不應被呼叫。 我們同時宣告這個方法一旦完成,這個方法的某些條件保證是成立的。這個稱為後置條件。如果這個方法的後置條件不成立,則不應傳回值。再次回到 LSP,這次我們要看契約的部分,繼承類別如果有下列情況存在,則可以替代其基礎類別:1.其前置條件不比其基礎類別的方法強。2.其後置條件不比其基礎類別的方法弱。或,換言之,繼承的方法應該沒有更多的預期及提供的更多(expect no moreand provide no less)。重新檢討是否違反 LSP: 很不幸的,違反 LSP 的情況除非在後期,否則不是很容易察覺。在圓形/橢圓形的例子中,所有的都運作的很好,直到客戶端介入,才能發現某些契約已被違反。如果強烈的使用設計,補救違反 LSP 的成本可能高到難以承受。回頭改變設計;並且重新建構及重新測試所有已存在的客戶端,可能不是很經濟的。 因此可能的解決方式是,在客戶端中加入 if/else 的陳述,以發覺可能違反的部分。這個if/else 的陳述是檢查 Ellipse 確實就是 Ellipse,而不是一個 Circle。參閱 Listing2-6:Listing 2-6 使用很醜陋的方式修正違反 LSP void f(Ellipse& e) { if (typeid(e) == typeid(Ellipse))
  12. 12. { Point a(-1,0); Point b(1,0); e.SetFoci(a,b); e.SetMajorAxis(3); assert(e.GetFocusA() == a); assert(e.GetFocusB() == b); assert(e.GetMajorAxis() == 3); } else throw NotAnEllipse(e); }仔細的檢查 Listing2-6 中的程式碼,你會發現它違反 OCP。因為,任何時候建立Ellipse 新的繼承類別,這個方法便需要檢查這個新的子類別,是否允許操作這個方法。因此違反 LSP 倒變成 了違反 OCP。相依反轉法則(The Dependency Inversion Principle, DIP)依賴抽象的。而不要依賴具體的。如過 OCP 陳述的是 OO 架構的目標,那麼 DIP 陳述的是主要的機制。相依反轉的策略是依賴介面或抽象方法及類別,而不是具體方法或類別。 這個法則是賦於元件設計背後的能力,如 COM、CORBA、EJB 等等。循序式的設計呈現出特定種類的相依結構。如 Figure 2-17 所示,這個結構從頂部開始,然後向下發展出細節的部分。高階模組依賴低階的模組,然後再依賴更低階的模組,如此延續下去。
  13. 13. Figure 2-17 循序式架構的相依結構過去有些想法認為,應該揭露這個相依結構本質上的弱點。高階模組處理應用程式高階的策略。這些高階策略一般很少觸及實作的細節。過去為什麼這些高階的模組,必須直接依賴那些實作的模組?一個物件導向的架構,顯示出一個非常不同的相依結構。其中之一就是,大多數依賴的是抽象。更甚者,不再依賴含有實作細節的模組,而是依賴抽象。因此這種依賴已被反轉。參閱 Figure 2-18。 Figure 2-18 物件導向結架構的相依結構
  14. 14. 依賴抽象: 這個法則所蘊含意涵的相當簡單。設計中的每一個依賴關係,應該是朝向介面,或一個抽象類別。而不是依賴一個具體類別。很明顯的這樣的約制太嚴酷了,應該有些轉圜的餘地。我們將隨時探索可能的變通方法。但是,在可能的情況下,這個法則應該盡可能遵循。原因很簡單,具體的東西常常改變,而抽象的東西比較少變動。更甚者,抽象是『樞紐點』,它們代表設計中可以彎折或擴充的地方,而且在變動時不至於被修改(OCP)。如 COM 的本質,就堅持這個法則,至少元件間一定要遵循這個法則。COM 元件唯一可以看到的部分就是其抽象介面。因此在 COM 之中,很少逾越 DIP。緩和壓力: DIP 的動機之一是防止你依賴易變的模組。DIP 假設所有具體的東西都是易變的。雖然常常是這樣,尤其是早期開發的程式,但還是有例外。例如標準 C 程式庫的 string.h 是非常具體,但一點都不會變動。在 ANSI 字串環境中依賴它不至於受傷害的。 類似的,如果你一些一試辨真偽(tried and true)的模組非常具體,但不會變動,依賴這些模組也不是太壞的。 因為這些模組不容易變動這些模組不至於在你的設計中注入變動性。但不管如何小心為要。依賴 string.h的專案,如果迫使你改用 UNICODE 字碼時,可能會變得非常難看。 不易變動不是取代抽象介面的替代品。物件建構: 在設計中最常發生依賴具體類別的地方是建構物件。依據定義,你無法建構抽象類別的物件實例。因此,為了建構一個物件實例,你必須依賴一個具體類別。建構物件實例可能在設計的架構中任意的地方發生。 因此,似乎無法逃脫這種狀況,而整個架構會被這種依賴具體類別的事實弄亂。 但是,還是有一個簡單的解決方法,稱為抽象工廠(ABSTRACT FACTORY)--這是一個設計樣式,我們會在這一章當中更進一步的驗證。介面隔離法則(The Interface Segregation Principle, ISP)由客戶端指定的許多介面比一個一般用途的介面好。ISP 是另一個支援像 COM 元件本質的技術之一。沒有這個法則,元件及類別將缺乏效用及可攜性。這個法則的本質相當簡單。如果你的類別有許多客戶端呼叫,你不用載入類別客戶端所需的所有方法,你只需要為每一個客戶端建立特定的介面,然後在類別中多重繼承這些介面。Figure 2-19 所示,一個類別有許多客戶端,使用一個大的介面服務所有的客戶端。請注意,任何時候這些方法之一,ClientA 所呼叫的方法有了變動,ClientB
  15. 15. 及 ClientC 可能受到影響。這樣可能需要重新編譯並重新部署。這是很不適當的事。 Figure 2-19 太多的服務整合在一個介面之中在 Figure 2-20 中有一個比較好的技術。每一個客戶端所需的方法都放置在特定的介面中,這些介面都是個別客戶端專用的。 Figure 2-20 分離的介面如果服務 ClientA 的介面需要變動,ClientB 及 ClientC 則不受影響,也不用重新編譯或重新部署。
  16. 16. 客戶端特有的介面有甚麼意義? ISP 並不是建議每一個類別在使用一個服務時,每一個服務都有他自己的特定介面,而這些服務是從這個介面繼承。如果是如此,那麼每一個服務相互依賴,而且每一個客戶端都會陷入奇怪而不健康的狀況。相反的,客戶端應該依其型別分類,然後服務每一型別客戶端的介面應該個別建立。如果有兩個型別以上的客戶端需要相同的方法,這個方法應該加到兩個介面中。這樣既不會損傷也不會困擾客戶端。改變介面: 當維護物件導向的應用程式時候,現有的類別或元件的介面往往會變動。有時候當這些變動對設計有很大的衝擊,並迫使重新編譯及部署設計的每一部份。這個衝擊可以透過在現有物件上,加入新的介面而得到舒緩;而不是改變現有的介面。舊有介面的客戶端,想要存取新介面的方法,可以向物件查詢那個介面,如下的程式碼 void Client(Service* s) { if (NewService* ns = dynamic_cast<NewService*>(s)) { // use the new service interface } }就所有的法則而言,使用上必須小心不要過渡。一個擁有上百個不同介面的類別幽靈,有的可以由客戶端來區隔,有的可以使用版本來區隔,可能會讓你驚駭莫名。包裹架構的法則(Principles of PackageArchitecture)類別是組織設計的意涵所必要的,但仍有不足的。需要使用較大個體(granularity)的包裹來幫助組織條理。但我們如何選擇哪些類別屬於哪一個包裹。下面三個法則是已知的包裹凝聚法則(Package Cohesion Principles),這些法則可以幫助軟體架構師。
  17. 17. 發行-再使用等值法則(The Release-Reuse EquivalencyPrinciple, REP)再使用的個體大小就是發行的個體大小一個可再使用的元素,可以是元件、一個類別或一組類別,除非由某類的發行系統管理,否則無法再使用。如果每次開發人員作了變動,使用者便需要被迫升級那麼使用者使用的意願便會降低。因此,既使開發人員發行一個新版本,他必須支援並維護舊有的版本,因為他的客戶不是馬上可以隨之升級。如此客戶將拒絕使用,除非開發人員保證保留版本的編號,並維護舊版本一段時間。因此,將一群類別集合成包裹的規則便是再使用。因為包裹是發行的單元,也是再使用的單元。因此架構師同樣的應該將可再使用的類別集合成一個包裹。一般結束法則(The Common Closure Principle, CCP)一起變動的類別,屬於相同的群組一個大型的開發專案可以細分成相關包裹所形成的大型網路。 要管理、測試、及發行這些包裹的工作很繁重的。 在任何一次的發行中,愈多包裹作變動,則在這個發行中重新建構、測試、及部署的工作愈多。因此希望在產品任何一次的發行週期中,變動的包裹數量愈少愈好。要達到這個目標,我們將我們認為會一起變動的類別集合起來。這需要一定程度的先見之明,因為我們必須預測可能發生的變動種類。除此之外,當我們將一起變動的類別集合在相同的包裹下,那麼包裹在不斷的發行中所受的衝擊將會最小。一般再使用法則(The Common Reuse Principle, CRP)不是一起再使用的類別,不要集合在一起依賴一個包裹,是依賴包裹中的所有事物。當一個包裹變動,而其發行編號發生衝撞,這個包裹的所有客戶端,必須核對他們與新包裹的工作狀況--既使他們使用這個包裹的部分實際上並未變動。我們常常有這樣的經驗,當我們的作業系統廠商,發行了一個新的作業系統。我們或早或慢總是要隨之升級,因為廠商將永遠不再支援舊版本。因此既使我們對新版本變動的部分,一點都沒有興趣,我們必須經歷升級及再評估的工作。如果未將一起再使用的類別集合在一起,同樣事的也會在包裹上面發生。一個類別的變動,雖然我不在意,仍然會迫使包裹發行新的版本,同樣的迫使我經歷升級及再評估的工作。
  18. 18. 包裹間張力的凝聚法則(Tension between thePackage Cohesion Principles)上面討論的三個法則是相互排斥的。他們無法同時滿足。這是因為每一個法則對於不同的人有不同的利益。REP 及 CRP 提供易於再使用性,而 CCP 提供易於維護性。CCP 致力於讓包裹盡可能的大(畢竟,如果所有的類別都包含在一個包裹之中,那麼只有一個包裹會變動)。而 CRP 嘗試讓包裹變小。很幸運的,包裹不像石頭一樣是一成不變的。 確實,包裹的本質在開發期間不斷的變動。在專案的初期,架構師建構的包裹結構可能是依循 CCP,而在開發期及維護期都有所助益。 隨後,當架構穩定之後,架構師可能重整包裹結構,以符合 REP 及 CRP 的要求,以便提升其再使用性。包裹耦合法則(The Package Coupling Principles)下面三個法則指導包裹之間的內部關連。應用程式傾向於是,相互關連的包裹所形成的大型網路。指導這些內部關連的規則,是物件導向架構中最重要的規則。非循環性依賴法則(The Acyclic Dependiences Principle, ADP)包裹間的依賴關係不得有循環依賴由於包裹是整個個體的發行,同時也傾向於以人力為主。工程師一般只在單一個包裹中工作,而不是同時處理好幾個包裹。這個傾向是包裹凝聚法則強調的,因為這些法則傾向於將相關的類別集合在一起。因此,工程師將發現,他們的變動只是指向少數幾個包裹。一旦作了這些變動,他們可以發行這些包裹給專案中其餘的工程師。在他們可以發行之前,他們必須測試這些包裹,以確保這些包裹是可以運行的。因此,他們必須將這個包裹與其它相依的包裹編譯及建構。當然,希望相依的包裹數量不多。參閱 Figure 2-21。聰明的讀者可以發現,這個架構中有許多瑕疵。DIP 似乎已被放棄,只是遵循 OCP。GUI 直接依賴 Comm 的包裹,同時顯示的介面(GUI)負責將資料傳送到 Analysis 包裹。真爛!
  19. 19. Figure 2-21 非循環包裹網路我們還是使用這個相當醜陋的結構作為範例。 如果要發行這個 Protocol 包裹,想想看還需要甚麼。工程師必須與最後發行的 CommError 包裹一併建構,及執行測試。Protocol 沒有再依賴其它的包裹,所以無須伴隨其它的包裹發行。這是不錯的。我們可以測試然後發行,只需少量的工作。加入了循環依賴: 假如現在我是一個工程師,正在處理 CommError 包裹。我決定我需要在畫面中顯示一個訊息。因為畫面是有 GUI 控制,我傳送一個訊息到 GUI 物件之一,以顯示這個訊息。這表示我讓 CommError 依賴 GUI。參閱Figure 2-22。
  20. 20. Figure 2-22 加入一個循環依賴關係現在當某個處理 Protocol 的人想要發行他們的包裹會發生甚麼事。 他們必須建立包含 CommError、GUI、Comm、ModemControl、Analysis、 Database 的測試系 及列!這真是一場災難啊。 工程師的工作量一下子暴增,只因為單一個相依關係,使得情況失控。這代表必須有人定期的檢查包裹的相依結構,任何時候發現有循環相依的結構時,就要將其打斷。否則模組間的轉移相依關係,會使得每一個模組依賴其它的每一個模組。打斷一個循環: 打斷循環有兩種方式。第一種方式可以建立一個新的包裹,第二種方式是使用 DIP 及 ISP。Figure 2-23 展示如何加入一個新包裹來打斷這個循環。 先將 CommError 所需的類別從 GUI 取出,然後放在新的包裹 MessageManager 中。然後讓 GUI 及CommError 都依賴這個新的包裹。
  21. 21. Figure 2-23這是一個簡單的範例,可以看出在開發階段,包裹結構不是很穩定不斷的變動。因此會有新的包裹參與,而有些類別會從舊有的包裹中,移至新的包裹中,以幫助打斷循環依賴。Frigure 2-24 展示一個使用其他技術,打斷循環依賴的前後對照圖形。其中,我們可以看到兩個包裹,被綁在一個循環依賴中。 類別 A 依賴類別 X,而類別 Y 依賴類別 B。我們透過反轉 Y 與 B 的依賴關係。作法是,在 B 上面加入一個介面BY。這個介面有 Y 所需的所有方法,Y 使用這個介面,而 B 實作這個介面。
  22. 22. Figure 2-24請注意,放置 BY 的作法。那是在使用這個介面的類別所存在的包裹中,放置這個介面。這是 一個樣式,你可以在許多處理包裹的案例研究中,看到這個樣式。介面常常包含在使用它們的包裹中,而不是包含在實作它們的包裹中。穩定依賴法則(The Stable Dependencies Principle, SDP)朝穩定的方向依賴這個法則看起來好似再明顯不過了,我們還是有些地方需要說明的。穩定往往不是那麼容易瞭解。穩定性: 穩定性的意義是甚麼?讓一個硬幣站立,這是個穩定的位置嗎? 我想你的答案是否定的。但是,除非受到干擾,這個硬幣還是可以維持這個狀況好一段時間。因此,若沒有不斷的變動,沒有任何事與穩定性是有直接的關係。硬幣維持這個狀態沒有變動,但你你很難接受這是穩定的。穩定性與需要變動的工作量有關。硬幣不穩定是因為,它只需要一點點動作便會動搖。從另一方面而言,一張桌子是非常穩定的,因為要翻倒它,需要相當的使力才有辦法翻倒它。
  23. 23. 這與軟體的穩定性有甚麼關係?讓軟體包裹難以變動的因素有很多。它的大小、複雜度、清晰度等等。 我們暫時忽略這些因素,而專注於某些特定的不同的因素。有一個讓軟體包裹難以變動的確定方式,是讓許多其它的軟體包裹依賴它。 一個有許多向內(incomming)依賴關係的包裹,意味著它是非常穩定的,因為對於每一個依賴它的包裹,需要一堆的處理來調解任何的變動。Figure 2-25 中的 X 是一個穩定的包裹。這個包裹有三個包裹依賴它,因此他有三個很好不作變動的理由。我們說這是對其他三個包裹負責。從另一方面而言,X 並不依賴其它的包裹,因此它沒有外在的影響力促使它作變動。 我們說它是獨立的。 Figure 2-25 X 是穩定的包裹Figure 2-26 從另一個觀點來看,顯示 Y 是一個不穩定的包裹。Y 沒有其它的包裹依賴它,我們說它是不負責任的。 也依賴其他三個包裹,因此變動可能三個 Y外部來源產生。我們說 Y 是有依賴性的 Figure 2-26 Y 是不穩定的包裹穩定性的量距(metrics):我們可以使用一個簡單的 量距,來計算一個包裹的穩定性。
  24. 24. Ca:匯集耦合(Afferent Coupling)。依賴包裹內部類別的外部類別數量(即向內的依賴關係)。Ce:匯出耦合(Efferent Coupling)。依賴包裹外部類別的內部類別數量(即向外的依賴關係)I:不穩定性。 。這個量 距的範圍是從 0 到 1。如果沒有向外的依賴關係,那麼 I 便是 0,表示這個包裹是穩定的。如果沒有向內的依賴關係,I 量距是 1,這個包裹是不穩定的。現在我們可以重述 SDP 如下:「依賴 I 量 距低於你的包裹。」合理性: 是否所有的軟體都該穩定?我們設計軟體最重要的一個特性是易於變動。所謂軟體的彈性,代表變動的需求之前已經充分的考慮過。但這是我們所定義的不穩定。確實,我們非常渴望我們軟體是不穩定的。我們確實希望模組是易於變動,以便當需求不定時,設計可以很方便的回應。Figure 2-27 顯示 SDP 如何可以不被遵循。Flexible 表示我們希望這個包裹是容易變動的。 我們希望 Flexible 是不穩定的。但是有些工程師在處理名為 Stable 的包裹時,卻是依賴 Flexible。這是違反 SDP,因為 Stable 的 I 量 距遠比 Flexible的 I 量距低。 因此,Flexible 將不再容易變動。 Flexible 的變動,將迫使我們處 對理 Stable 及所有依賴 Stable 的包裹。 Figure 2-27 違反 SDP穩定抽象法則(The Stable Abstractions Principle, SAP)
  25. 25. 穩定的包裹應該是抽象的包裹我們可以預見,我們的應用系統的包裹結構,是一組內在互動的包裹,其中不穩定的包裹在頂層,而穩定的包裹在底層。從這個觀點中,所有的依賴都是指向下。這些位於頂層的包裹,是不穩定而且是彈性的。但是位於底層的包裹是非常難以變動的。這使得我們進退兩難:我們是否要讓設計中的包裹難以變動嗎?很明顯得,愈多包裹難以變動,我們整體的設計則愈沒有彈性。還好,我們還是有迴旋的餘地。位於相依網路底層高穩定的包裹,可能是難以變動,但依據OCP,他們不一定是難以擴充!如果底層穩定的包裹也是高度抽象,那他們可以很容易的擴充。這表示從不穩定且易於變動的包裹,及穩定且易於擴充的包裹,組成應用程式是可能的。這真是一件好事情。因此,SAP 只是 DIP 的重申。SAP 說明愈被依賴的包裹(即穩定),應該同時愈抽象。但我們如何衡量抽象?抽象的量距: 我們可以使用另一個簡單的量 距,來計算一個包裹的穩定性。Nc:包裹中的類別數量。Na:包裹中抽象類別的數量。請記住,一個抽象類別,是一個類別中至少有一個純介面,同時無法被實例化。A:抽象的程度。 。A 量距的範圍從 0 到 1,與 I 量距一樣。A 量距 0 表示包裹中沒有抽象類別。A 量距 1 表示包裹中只有抽象類別。I 與 A 的圖形: SAP 可以使用 A 量距及 I 量距來表達:I 量距應隨著 A 值遞減而遞增。 亦即,具體包裹應該不穩定,而抽象包裹應該穩定。我們可以繪出 A 量距對 V 量距的圖形。參閱 Figure 2-28。
  26. 26. Figure 2-28 A 值相對 I 值的圖形很明顯的,包裹應該位於 Figure 2-28 的兩個黑點上。位於左上側的包裹是完全抽象且非常穩定。而位於右下的包裹是具體且不穩定的。這是我們比較喜歡的方式。但是在 Figure 2-29 中的 X 包裹視甚麼情況呢?它應放在哪裡呢? Figure 2-29 X 在 A 值對 I 值圖中應放在何處我們可以決定我們希望包裹 X 的位置在哪裡,只要看看哪裡是我們不希望它朝哪裡去。 圖的右上側代表包裹是,高度抽象而且沒有包裹依賴它。 AI 這是代表無效用(uselessness)的區域。確實我們不希望 X 跑到那邊。從另一方面而言,AI 圖的左下側代表包裹是具體且有許多向內的依賴。這是一個包裹最不好的位置。因為位於其中的元素是具體的,它們無法像抽象的包裹那樣擴充;同時因為它們有許多向內的依賴,要作變動是非常痛苦的。這是痛苦(pain)的區域,所以我們不希望我們的包裹跑到這裡去。
  27. 27. 介於上述兩個區域之間有一條線,這條線稱為主序列(main sequence)。我們希望我們的包裹盡可能位於這條線上。位於這條線上的包裹,代表抽象與向內相依對稱,及具體與向外相依對稱。換言之,這些包裹中的類別符合 DIP 的要求。差距量距:從上面的討論,我們還可以發現一個衡量的尺度。對於任意包裹的 A 量 距及 I 量距,我們希望知道,這個包裹距離主序列有多遠。D:差距。 ,D 值從 0 到 0.707。D:常態化(normalized)差距 D=|A+I-1|,這個量距的指標比 D 方便,因為它是從 0 到 1。 代表這個包裹正好位於主序列線上。 0 相反的 ;1 代表包裹遠離主序列線。這些量距衡量物件導向的架構。它們並不是很完美的,同時如果以它們為依據作為決定穩固架構的指標,則不是很恰當的。但是,它們可以是,也已經是,用來幫助衡量一個應用系統的相依結構。物件導向架構的樣式當依循上述的法則,建立物件導向架構,我們往往發現,我們一再重複使用相同的結構。這些重複出現的設計架構及結構,就是週知的設計樣式。設計樣式的基本定義,是對於常見的問題,提出一種良好的格式且週知的好的解決方式。設計樣式不是甚麼新的東西。而是相當老舊的技術,在過去一段時間中,設計樣式已經展現它的用途。下面會說明一些常用的設計樣式。有些樣式在本書的研究案例中會不斷出現。我要特別強調的,設計樣式的主題是無法在一本書的單一章節中說明清楚。強烈建議讀者去閱讀 GOF 這本書。抽象服務者(abstract server)當客戶端直接依賴一個服務者,那便違反了 DIP。.服務者的變動將波及客戶端,而客戶端將無法很方便的使用類似的服務者。 這可以透過在服務者及客戶端間,插入一個抽象介面,而改善這種情況。參閱 Figure 2-30。
  28. 28. Figure 2-30 抽象服務者這個抽象介面便形成了一個『樞紐點(hinge point)』,可以讓設計變得柔曲。服務者不同的實作,可以結合到信任的客戶端。調適器(Adapter)若使用插入抽象介面,因服務者是其他軟體供應商(third party)所提供,以致不可行時;或因依賴度太高,而難以變動時。 使用調適器可以用來將抽象介面結合服務者。參閱 Figure 2-31。 Figure 2-31 調適器調適器是一個物件,他實作抽象介面,其實作是委託服務者。調適器中每一個方法,只是轉換然後委託。觀察者(Observer)如果設計中常常有下列的情形,即設計中一個元素感知到某一個事件發生,便需要另一個元素產生某一類的行為。但是,我們往往不希望事件偵測者,得知行動者。
  29. 29. 舉例來說,有一個儀表,可以顯示感應器的狀態。每當感應器的讀數變動,我們希望這個儀表顯示這新的值。但是我們不希望感應器得知這個儀表。我們可以使用觀察者來處理這種狀況。參閱 Figure 2-32。Sensor(感應器)從Subject 類別繼承,而 Meter(儀表)繼承介面 Oboserver(觀察者)。 Subject 中有一個 Observers 的列表。這個列表由 Subject 的 Register 方法載入。為了讓事件的發生被告知,我們的 Meter 必須向 Sensor 的基礎類別 Subject 註冊。 Figure 2-32 觀察者Figure 2-33 描述這個合作的動態關係。某些決定其讀數變的的實體,將控制權轉給 Sensor。 Sensor 呼叫其 Subject 的 Notify 方法。然後 Subject 在每一個向它註冊的 Observers 中,呼叫 Update 方法。Update 的訊息將會被 Meter 所攔截,Meter 便用它讀取 Sensor 新的值,並顯示這新的值。 Figure 2-33橋接(Bridge)
  30. 30. 當使用繼承的方式,實作一個抽象類別,其中有一個問題,就是這個子類別與基礎類別緊密的耦合。這個問題是當其它的客戶端,要去使用這個子類別的功能時,而不希望背負基礎類別的包袱。舉例來說,音樂合成器(music synthesizer)類別。其基礎類別將 MIDI 輸入轉成一組基本的 EmitVoice 呼叫,這個方法是由其子類別所實作。其中子類別的EmitVoice 方法(譯註:發出聲音)其本身將非常有用。很不幸的,它無可避免的綁在 Mosicsynthesizer 類別及 PlayMidi 方法。你沒有辦法取得 PlayMidi 方法,而不用同時帶入整個基礎類別。同時,也沒有辦法建立 PlayMidi 方法不同的實作,且使用相同的 EmitVoice 方法。簡單的說,這個層級架構是耦合的。 Figure 2-34 不良的耦合層級架構橋接樣式解決這個問題的方式是,在介面及實作之間建立一個強力的中間者(strong seperation)。Figure 2-35 展示這個方式這是如何達成的 。MusicSynthesizer 類別包含一個抽象的 PlayMidi 方法,這個方法是由MusicSynthesizer_I 所實作。PlayMidi 方法呼叫由 MusicSynthesizer 所實作EmitVoice 方法,以委託 VoiceEmitter 介面。這個介面是由 VoiceEmitter_I 所實作,以發出所需的聲音。如此便可以各別實作 EmitVoice 及 PlayMidi。這兩個方法便不再耦合了 。EmitVoice 便可以單獨呼叫,而不用背負 MusicSynthesizer 的包袱,而PlayMidi 可以以各種不同的方式實作,而仍可以使用相同的 EmitVoice 方法。
  31. 31. Figure 2-35 使用橋接解除層級架構的耦合性抽象工廠(Abstract favctory)在 DIP 中強烈建議,模組不應依賴具體類別。但為了建立一個類別的實例物件,你必須依賴具體類別。 抽象工廠是一種樣式,它允許你在一個地方,且只有一個地方,依賴具體類別。Figure 2-36 展示一個 Modem 成熟的例子。所有想建立 modem 的使用者,都是使用一個介面,稱為 ModemFactory。 指向這個介面的指標,是一個整體變數稱為 GtheFactory。使用者呼叫 Make 方法,並傳進一個字串,這個字串定義使用者所需唯一的特定子類別。Make 方法傳回一個指標,指向 Modem 介面。 Figure 2-35 抽象工廠
  32. 32. ModemFactory 介面是由 ModemFactory_I 實作。這個類別是由 main 建構,而且有一個指向它的指標載入 GtheFactory 整體變數中。 因此,系統中沒有一個模組,知道關於 modem 的具體類別,除了 ModemFactroy_I,而且沒有一個模組知道 ModemFactory_I,除了 main。總結這一章介紹物件導向架構的概念,架構的定義是類別與包裹的結構,這個結構可以使得軟體應用程式具有彈性、堅固、可再使用、及發展性。我們使用一些法則及樣式,以支援這樣的架構,而且已經證明這些法則及樣式,可以有效的輔助軟體架構設計。這只是一個概觀。對於 OO 架構的主題,是沒有辦法在這章短短幾頁中可以完整說明,確實這樣的檢要說明,我們冒著可能造成讀者傷害的風險。 有人說,只有淺薄的知識是一件危險的事,而這一章只是提供淺薄的知識。 我們強烈的建議你應該進一步研究這章所列出的書籍及文章。

×