[2]動機、原則與模式——OO設計之我見

weixin_34138377發表於2015-07-05

OO設計的三寶
在講具體的原則之前,我想先明確一下物件導向語言的三個特性。所有的面嚮物件語言都首先必須支援這三個特性,才能稱之為OO語言。也只有這三個特性的支援,才有了後面的各種原則和模式之說。所以這是OO設計的“三寶”。

首先是封裝,封裝的本質的是將行為寓於資料之中。注意到這是面嚮物件語言與程式導向語言最大的區別,不用贅述。其次是繼承,繼承對於老的教學方式,總是強調擴充屬性,即具體到更具體。這是欠妥當的。相應的,我們應該更多的認為繼承是一種橋樑,把抽象和具體連線起來。

多型是三寶中最重要的一個。封裝和繼承都是為了多型做鋪墊。正因為有了多型,才有了物件導向設計的那些原則和模式,才有可能產生高內聚低耦合的軟體系統。所以說,對於軟體開發,多型就像是普羅米修斯帶給人類的聖火。這種評價是毫不誇張的,越懂OO的設計,越能理解多型的重要性。Java成為OO語言的翹楚,我個人認為與其天然的支援多型是有一定關係的。

我用一個形象的例子總結一下OO語言的這三個特性。假設我們有一個異質連結串列,型別為OfficeTool,這個抽象類物件代表一種Office工具。它會有很多的方法,例如有一個方法叫getYourBestOutput,意即“返回自己最好的輸出”。(方法寓於物件之中,這就是封裝。)這個連結串列中有不同的物件,它們都是OfficeTool這種物件的子類,其中三個就是Word,Excel和PowerPoint。(子物件擁有父物件的方法,可以以父物件的名字進行引用,這就是繼承。)如果遍歷這個異質連結串列,訪問剛才提到的方法時,我們知道,這三個工具各有所長,所以Word會輸出一部精心排版的書稿,Excel會輸出一份內容詳實的財報,而PowerPoint會輸出一個製作精美的簡報。(不同子類的相同方法,表現出不同的結果或輸出,這就是多型!)

迪米特法則
迪米特法則有多種表述:

Each unit should have only limited knowledge about other units: only units "closely" related to the current unit.(每一個軟體單位對其他的單位都只有最少的知識,而且侷限於那些與本單位密切相關的軟體單位。)
Each unit should only talk to its friends; don't talk to strangers.(每個軟體單位都只與自己的朋友對話,不要和陌生人說話。)
Only talk to your immediate friends.(只與你最親近的朋友通訊。)

作為法律,法則,它強調了一種對軟體系統普世的原則,即“高內聚,低耦合”。儘量減少通訊,保持內部高度統一。實際上你去網上搜,該法則也不是完美的,但是它傳遞了一種思想,我認為OO設計原則的源頭在此。因為你要遵循迪米特法則,你就要考慮整個軟體系統哪些元素應該聚合在一起,能夠產生什麼行為才是高內聚的,如何進行互動才是低耦合的。這本身不就是設計的過程麼?而且我認為如果能考慮這些問題,這還很有可能是一個優秀的設計。

S.O.L.I.D.原則
Robert Martin有一本非常著名的書,《敏捷軟體開發:原則、模式與實踐》。他在這裡提到SOLID是最初的五個原則,我感覺這就像是說亞當和夏娃是最初的2個人一樣。其實還是強調原則重於模式。我下面會談談單一職責和介面隔離,因為它們有一定的相似性,也容易掌握。後三個是OO設計的精髓,體現了延遲實現和針對介面程式設計的核心思想。

單一職責原則
Every context (class, function, variable, etc.) should have a single responsibility, and that responsibility should be entirely encapsulated by the context.(每個實體都應該只有一種職責,且這種職責被完全的包裹在該實體內。)

這個原則相對簡單,只要你多想想是不是把2個以上無關的事情放到了一個單位裡,就可以避免過大而冗餘的類。記住,10000行程式碼的類,不是你的榮譽,而是你的恥辱。如果10000行的類需要複用,請問有複用的可能和切實可行的辦法麼?就一個類而言,應該只有一個引起它變化的原因。我們經常會遇到User一改需求,就要改同一個類,即使需求之間沒多大關聯,這就說明我們違背了單一職責原則,賦予了一個類太多的職責。

介面隔離原則
Once an interface has become too 'fat' it needs to be split into smaller and more specific interfaces so that any clients of the interface will only know about the methods that pertain to them.(一旦一個介面過於“臃腫”,需要把它拆分成更小和更專一的介面,為的是實現介面的類,只需要知道和自己相關的方法。)

對介面的設計同樣要遵循迪米特法則。一旦一個介面過於“臃腫”,需要把它拆分成更小和更專一的介面,為的是實現介面的類,只需要知道和自己相關的方法。最好的例子就是Java中的一些介面定義。比如Java類庫中提供的Comparable和Serializable介面。如果你通過compare方法給出了實現Comparable介面的類的兩個物件的比較結果,一個int值。你就可以在一些排序的資料結構中很好的承載這些物件,達到你比較他們的目的,比TreeTable;Serializable做法更絕,是一個沒有方法的介面,相當於僅僅是一個帽子,是一個標記,說明只要繼承這個介面的類才能被序列化,否則就丟擲異常。

開/閉原則
Software entities should be open for extension, but closed for modification.(軟體實體應該只做擴充套件,而不做修改。)

開閉原則是最簡單的但很難做到的。繼承應當被看做是封裝變化的方法,而不應當被認為是從一般的物件生成特殊的物件的方法。這是《Java與模式》那本書作者的原話。對於它的解讀是,完美的繼承是從抽象類到具體類的過程。即具體類通過繼承抽象類而封裝了不同的方法(方法介面在抽象類說明)。錯誤的繼承是在一般的物件基礎上,通過加入特殊的方法,而形成特殊的物件。

抽象化是開/閉原則的關鍵。在Java、C#等程式語言中,可以為系統定義一個相對穩定的抽象層,而將不同的實現行為移至具體的實現層中完成。這是第一次提出行為的延後實現,稍後會看到這個動作的最後落腳點。

里氏替換原則
In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e., objects of type S may substitute objects of type T) without altering any of the desirable properties of that program (correctness, task performed, etc.).(如果對每一個型別為T1的物件o1,都有型別為T2的物件o2,使得以T1定義的所有程式P在所有的物件o1都代換成o2時,程式P的行為沒有變化,那麼型別T2是型別T1的子型別。)

里氏替換原則是實現開/閉原則的重要方式之一,由於使用基類物件的地方都可以使用子類物件,因此在程式中儘量使用基類型別來對物件進行定義,而在執行時再確定其子類型別,用子類物件來替換父類物件。

依賴反轉原則
Abstractions should not depend upon details. Details should depend upon abstractions. Program to an interface, not an implementation.(抽象不應該依賴於具體,具體應該依賴於抽象。)

由於有了開/閉原則和里氏替換原則的鋪墊,這裡提出了最核心的原則,針對介面程式設計,延緩細節的實現。如果說開/閉原則是目標,里氏替換原則是行為保證,那麼按介面程式設計的依賴反轉原則就是有理論保證的,有實際目標的,真正的高質量OO設計。其實有人已經把該原則叫做OO設計的標誌,足見其重要性。

這三個重要的OO原則和三個OO語言特性的本質關係是這樣的:開/閉原則要求我們儘量在構造軟體實體的時候,應該使用擴充套件,而不是修改原來的物件;那麼繼承是一個很好的方式,繼承在理想化的使用場景中,應該是從抽象到具體(將行為封裝到一個具體物件之中),而不是從一種具體到另外一種具體。為什麼?因為里氏替換原則要求行為一致,才是繼承的關係。這和我們理解的加一個extends就定義了子類和父類的關係是不同的。由於抽象類沒有具體行為的實現,所以對抽象類的繼承,天然的是符合里氏替換原則的真正的繼承。而具體到具體的繼承,很難保證里氏替換原則的實現。

在滿足開/閉原則和里氏替換原則的基礎上,對同一個抽象類的行為,不同的實現了繼承的具體類表現了不同的行為,這就是多型。回想到前面我們提到的Office異質連結串列的例子,我們的遍歷操作,是針對抽象類的行為來進行程式設計的,這就是針對“介面/抽象類”程式設計的意義,這也是程式設計從依賴具體類(Word,Excel和PowerPoint)倒轉為依賴Office這個抽象類的過程。依賴倒轉原則的精髓就在於此。

再論重構
最後我想再絮叨兩句重構的話題。重構來源於那本著名的書。那些“壞味道”,也隨重構的概念被程式設計師所熟知。但如果掌握了以上所說的OO設計的原則並應用於設計和實現階段,那麼有些“壞味道”根本就不會發生,那麼重構也不會發生了。我把重構分為2種,一種是簡單的重構,就像修改文章中的改正錯別字,或者調整個別語句的順序;另一種是結構上的重構,是由於業務邏輯的變化或完善,導致設計方案的進化(注意我並沒有說完全推翻),這時的重構才是最有價值的。一個掌握了OO設計精髓和原則的程式設計師,應該著眼於結構上的重構,而在正常的編碼中就要注意避免,數百行的函式,隨意定義變數和分配記憶體,大量的重複程式碼等問題。賈島有時間在“推”和“敲”上反覆斟酌,而曹植只有七步的時間醞釀自己的詩篇,講究的就是一氣呵成。程式設計師的能力和效率往往就體現在這些不經意的地方。所以學無止境,以此共勉吧。

相關文章