變化驅動:正交設計
一個出發點
當談起軟體設計的目的時,能夠獲得所有人認同的答案只有一個:功能實現。 因為這是一個軟體存在的根本原因。
而在計算機軟體發展的初期,這一點也正是所有人做軟體設計的唯一動機。因而,很自然的,整個軟體都被放在單一過程中,然後用到處存在的goto語句控制流程。
儘管理論上講,任意複雜的系統都可以被放入同一個函式裡。但隨著軟體越來複雜,即便是智商最為發達的程式設計師也發現,單一過程的複雜度已經超出他的掌控極限。這逼迫人們必須對大問題進行分解,分而治之。
時至今日,儘管超大函式,上帝類依然並不罕見,但當大到一定程度,上帝類的創造者最終也會發現自己終究沒有上帝般的掌控力。因而,哪怕是軟體設計素養為負值的開發者,或多或少也會對一個複雜系統進行一定程度的拆分。
這就是模組化設計的最初動機。
兩個問題
一旦人們開始進行進行模組化拆分,就必須解決如下兩個問題:
- 究竟軟體模組該怎樣劃分才是合理的?
- 將一個大單元劃分為多個小單元之後,它們之間必然要通過銜接點進行合作。如果我們把這些銜接點看作API,那麼問題就變為:怎樣定義API才是合理的?
更簡單的說:怎麼分?然後再怎麼合?
△ 分工與合作
而這兩個問題的答案,正是現代軟體設計的核心關注點。
三方關係
為了找到這兩個問題的答案,我們需要重新回到最初的問題:為何要做軟體設計?
Kent Beck給出的答案是:軟體設計是為了在讓軟體在長期範圍內容易應對變化。
在這個精煉的定義中,包含著三個關鍵詞:長期,容易
- 越是需要長期維護的專案,變化更多,也更難預測變化的方式;
- 軟體設計,事關成本;
- 如何在難以預測的千變萬化中,保持低廉的變更成本,正是軟體設計要解決的問題。
對此,Kent Beck提出了一個更為精煉的原則:區域性化影響。意思是說:我們希望,任何一個變化,對於我們當前的軟體設計影響範圍都可以控制在一個儘量小的區域性。
這當然是所有嚴肅的軟體從業者都夢寐以求的。
可問題在於,如何才能做到?
內聚與耦合
每個讀過基礎軟體工程教程的人都知道:一個易於應對變化的軟體設計應該遵從高內聚,低耦合原則。
所謂內聚性,關注的是一個軟體單位內部的關聯緊密程度。因而高內聚追求的是關聯緊密的事物應該被放在一起
Do One Thing, Do It Well。
而耦合性,則是強調兩個或多個軟體單位之間的關聯緊密程度。因而低耦合追求的是,軟體單位之間儘可能不要相互影響。
這樣的解釋,對於很多人而言,依然會感到過於抽象。但如果我們進一步思考,就會意識到:看似神祕的內聚與耦合,正好對應最初的兩個問題:
- 當我們劃分模組時,要讓每個模組都儘可能高內聚;
- 而當我們定義模組之間的API時,需要讓雙方儘可能低耦合。
如果用圖來展現,就是下面的過程與關係:
△ 高內聚,低耦合
這幅圖揭示了模組化設計的全部:首先將一個低內聚的模組首先拆分為多個高內聚的模組;然後再考慮這多個模組之間的API設計,以降低這些高內聚的軟體單元之間的耦合度。
除了內聚與耦合之外,上面這幅圖還揭示了另外一種關係:正交。具備正交關係的兩個模組,可以做到一方的變化不會影響另外一方的變化。換句話說,雙方各自獨自變化,互不影響。
而這幅圖的右側,正是我們模組化的目標。它描述了永恆的三方關係:客戶,API,實現,以及它們之間的關係。這個三方關係圖清晰的指出了我們應該關注的內聚性,耦合性,以及正交性都發生在何處。
四個策略
相對於區域性化影響,高內聚,低耦合原則已經清晰和具體許多。但依然更像是在描述目標或結果,而沒有指明該如何達成的方法。雖然《程式碼大全》列舉了那麼多的內聚性和耦合性的分類,但對於想應用它們的軟體設計人員,依然感覺如隔靴撓癢,不得要領。
因而,我們需要從它推匯出更為明確,更具指導性和操作性的設計原則。
為了做到這一點,我們必須首先搞清楚:內聚與耦合,和變化之間的關係是怎樣的,以至於高內聚、低耦合的模組化方式能夠更容易應對變化?
我們再次回顧內聚與耦合的定義:它們是用來衡量程式碼元素之間的關聯緊密程度。很容易得知:元素之間的關聯緊密程度越高,一個變化引起它們相互之間都發生變化的可能性就越高。反之,關聯程度越弱,變化引起的連鎖變化的概率就越低。
因而,我們要把容易互相影響的、關聯程度緊密的元素,都封裝在一個模組內部(而這正是我們老生常談的封裝變化的動機);同時讓模組之間的關聯緊密程度儘可能降低,以讓模組間儘可能不要相互影響。從而最終做到區域性化影響。
因而,Uncle Bob說:一個類只應該有一個變化原因。他進一步談到:所謂一個變化原因,指一個變化會導致整個類所包含的各個元素都要發生變化。為何會如此?因為它們的關聯程度太緊密(因而高內聚),以至於牽一髮而動全身。
因此,Uncle Bob將職責定義為變化原因。
在一些時候,我們可以直接判定一個模組是否包含多重職責。因為它們確實包含著明顯沒有什麼關聯的兩組程式碼元素。
但在另外一些場景下,我們則無法清晰的判定:一個模組是否真的包含多重變化原因,或多重職責。比如如下程式碼:
struct Student { char name[MAX_NAME_LEN]; unsigned int height; };void sort_students_by_height(Student students[], size_t num_of_students) { for(size_t y=0; y < num_of_students-1; y++) { for(size_t x=1; x < num_of_students - y; x++) { if(students[x].height > students[x-1].height) { SWAP(students[x], students[x-1]); } } } }
這是一個對學生按照身高從低到高進行排序的演算法。對於這段程式碼,如果我們進行猜測,會發現很多點都有變化的可能,如果對這些變化都進行分離和管理,確實會提高系統的內聚度。但如果我們現在就將整個系統每個可能的變化點都分離出來,無疑會讓整個系統陷入無邊無際的不必要的複雜度。
破解這類難題的方法是:既然我們知道高內聚,低耦合的設計是為了軟體更容易應對變化的,那麼我們為何不反過來,讓實際發生的需求變化來驅動我們識別變化,管理變化,從而讓我們的系統達到恰如其分的內聚度和耦合度?
策略一:消除重複
首先進入我們射程的就是重複程式碼。編寫重複程式碼不僅僅會讓有追求的程式設計師感到乏味。真正致命的是:“重複”極度違背高內聚、低耦合原則,從而會大幅提升軟體的長期維護成本。
我們之前已經討論過,所謂高內聚,指的是關聯緊密的事物應該被放在一起。沒有比兩段完全相同的程式碼關聯更為緊密。因而重複程式碼意味著低內聚。
而更為糟糕的是,本質重複的程式碼,其實都在表達(即依賴)同一項知識。如果它們表達(即依賴)的知識發生了變化,這些重複的程式碼統統都要修改。因而, 重複程式碼也意味著高耦合。
△ 重複程式碼意味著耦合
因而,對於完全重複的程式碼進行消除,合二為一,會讓系統更加高內聚、低耦合。
而更為關鍵的是:如果兩個模組之間是部分重複的,則發出了一個重要的訊號:這兩個模組都至少存在兩個變化原因,或兩重職責。
如下圖所示,兩個模組存在著部分重複。站在系統的角度看,它們之間存在著不變的部分(即重複的部分);也存在變化的部分(即差異的部分)。這意味著這兩個模組都存在兩個變化原因。
△ 變化與不變:多重職責
對於這一型別的重複,比較典型的情況有兩種:呼叫型重複,以及回撥型重複。它們的命名來源於:在重複消除後,重複與差異之間的關係是呼叫,還是回撥。
△ 呼叫型重複
△ 回撥型重複
由此,我們得到了第一個策略:消除重複。
這個策略,非常明確,極具可操作性:當你看到重複時,盡力消除它。
這個策略,明顯提高系統的內聚性,降低了耦合性。除此之外,還得到一個重大收益:可重用性。事實上,消除重複的過程,正是一個提高系統可重用性的過程。
另外對於回撥型重複的消除,也是一個提高系統可擴充套件性的過程。
策略二:分離不同的變化方向
除了重複程式碼外,另外一個驅動系統朝向高內聚方向演進的訊號是:我們經常需要因為同一類原因,修改某個模組。而這個模組的其它部分卻保持不變。
比如,在之前我們對學生按照身高從低到高排序的例子中,如果現在我們需要增加對老師按照身高從低到高排序的需求,我們就知道,排序物件是一個新的變化方向。於是,我們將程式碼重構為:
template void bulb_sort(T objects[], size_t num_of_objects) { for(size_t y=0; y < num_of_objects - 1; y++) { for(size_t x=1; x < num_of_objects - y; x++) { if(objects[x].height > objects[x-1].height) { SWAP(objects[x], objects[x-1]); } } }}
如果隨後又出現一個新的需求:按照學生身高從高到低排序(原來為從低到高)。此時我們知道排序規則也是一個變化的方向。因此,我們將這個變化方向也從現有程式碼中分離出去。然後得到:
template void bulb_sort(T objects[], size_t num_of_objects) { for(size_t y=0; y < num_of_objects - 1; y++) { for(size_t x=1; x < num_of_objects - y; x++) { if(objects[x] > objects[x-1]) { SWAP(objects[x], objects[x-1]); } } }}
分離不同變化方向,目標在於提高內聚度。因為多個變化方向,意味著一個模組存在多重職責。將不同的變化方向進行分離,也意味著各個變化方向職責的單一化。
從這個例子可以看出,此策略的應用時機也非常明確:當你發現需求導致一個變化方向出現時,將其從原有的設計中分離出去。
△ 分離新的變化方向
對於變化方向的分離,也得到了另外一個我們追求的目標:可擴充套件性。
如果我們足夠細心,會發現策略消除重複和分離不同變化方向是兩個高度相似和關聯的策略:
它們都是關注於如何對原有模組進行拆分,以提高系統的內聚性。(雖然同時也往往伴隨著耦合度的降低,但這些耦合度的降低都發生在別處,並未觸及該如何定義API以降低客戶與API之間耦合度)。
另外,如果兩個模組有部分程式碼是重複的,往往意味著不同變化方向。
儘管如此,我們依然需要兩個不同的策略。這是因為:變化方向,並不總是以重複程式碼的形式出現的(其典型症狀是霰彈式修改,或者if-else、switch-case、模式匹配);儘管其背後往往存在一個以重複程式碼形式表現的等價形式(這也是為何copy-paste-modify如此流行的原因)。
策略三:縮小依賴範圍
前面兩個策略解決了軟體單元該如何劃分的問題。現在我們需要關注模組之間的粘合點——即API——的定義問題。
需要強調的是:兩個模組之間並不存在耦合,它們的都共同耦合在API上。因而 API如何定義才能降低耦合度,才是我們應該關注的重點。
△ 耦合點:API
從這幅圖可以看出,對於API定義所帶來的耦合度影響,需要遵循如下原則:
- 首先,客戶和實現模組的數量,會對耦合度產生重大的影響。它們數量越多,意味著 API 變更的成本越高,越需要花更大的精力來仔細斟酌。
- 其次,對於影響面大的API(也意味著耦合度高),需要使用更加彈性的API定義框架,以有利於向前相容性。
而具體到策略縮小依賴範圍,它強調:
- API 應包含儘可能少的知識。因為任何一項知識的變化都會導致雙方的變化;
- API 也應該高內聚,而不應該強迫API的客戶依賴它不需要的東西。
策略四:向著穩定的方向依賴
但是,無論我們如何縮小依賴範圍,如果兩個模組需要協作,它們之間必然存在耦合點(即API)。降低耦合度的努力似乎已經走到了盡頭。
我們知道,耦合的最大問題在於:耦合點的變化,會導致依賴方跟著變化。但這也意味著,如果耦合點從來不會變化,那麼依賴方也就不會因此而變化。換句話說,耦合點越穩定,依賴方受耦合變化影響的概率就越低。
由此,我們得到最後一個策略:向著穩定的方向依賴。
那麼,究竟什麼樣的API更傾向於穩定?不難知道,站在What,而不是How的角度;即
站在需求的角度,而不是實現方式的角度定義API,會讓其更加穩定。
而需求的提出方,一定是客戶端,而不是實現側。這就意味著,我們在定義介面時,應該站在客戶的角度,思考使用者的本質需要,由此來定義API。而不是站在技術實現的方便程度角度來思考API定義。
而這正是封裝或資訊隱藏的關鍵。
小結
這四個策略,前兩者聚焦於如何劃分模組,後兩個聚焦於如何定義模組間的API。換句話說,前兩者關注於“如何分”,後兩條聚焦於“怎麼合”。
這四個策略的背後動力非常明確:變化。前兩者,都是在明確的變化方向被第一次識別之後(所謂第一顆子彈),進行策略運用,以讓模組在變化面前越來越高內聚。而後兩者,則是在模組職責分離之後,需要定義模組間API時,儘可能考慮不同的API定義方式對於依賴雙方的影響,以達到低耦合。
由於這四個策略致力於讓系統朝著更具正交性的方向演進,因而它們也被稱做正交策略,或者正交四原則。
總結
本文首先從一個出發點出發:為了降低軟體複雜度,提升可重用性,我們需要模組化。
由此得到了兩個問題:模組劃分必然要解決如何劃分,以及模組間如何協作(API 定義)的問題。
基於軟體易於應對變化的角度出發。高內聚、低耦合原則是最為核心和關鍵的高層原則。基於此我們得到了在模組化過程中,我們真正需要關注的三方關係。
為了讓高內聚、低耦合更具指導性和操作性,我們提出了四個策略。它們以變化驅動,讓系統逐步向更好的正交性演進的策略,因此也被稱做正交策略或正交原則。
我們已經在多個系統的設計和開發中,以這四個原則來驅動我們的軟體設計,不僅讓我們的系統在保持簡單的同時,具備所有必要的靈活性。也讓設計和開發活動變得高度有章可循,讓團隊生產率得以大幅提升。
最後,推薦劉光聰的文章《實戰正交設計》。這篇文章通過一個例子,來展示了正交策略是如何驅動出更加正交的設計的。
而正交設計與SOLID的關係,可參閱《正交設計,OO與SOLID》。
附錄
而我的朋友及前同事李光磊對此精煉的總結道:
變化導致的修改有兩類:
- 一個變化導致多處修改(重複);
- 多個變化導致一處修改(多個變化方向);
由此得到前兩個策略:消除重複;分離不同變化方向。 除此之外,我們要努力消除變化發生時不必要的修改,也有兩種方式:
- 不依賴不必要的依賴;
- 不依賴不穩定的依賴;
這就是後面兩個策略:縮小依賴範圍,向著穩定的方向依賴。
從光磊這個精彩總結中可以清晰的看出:
- 一切圍繞著變化:由變化驅動,反過來讓系統演進的更容易應對變化;
- 這四個策略都是在讓系統更加區域性化影響。
- 這四個策略的完備性。