關於軟體的設計原則有很多,對於設計原則的掌握、理解、實踐及昇華是架構師的一項極為之必要的修煉。 記得在12年前第一次閱讀《敏捷開發》時,五大基本設計原則就深深地植入到我的腦海中一直影響至今,我也由此獲益良多。設計原則當然不止只有五種,最主要的物件導向的設計原則有以下這些:
當然物件導向的設計原則遠遠不止這些,設計原則是伴隨著開發語言的發展應用和軟體開發經驗的累加總結得出的經驗彙總,隨著語言的演變、開發方法的進步還會不斷地衍生和進化出更多的的設計原則。應用設計原則可以避開很多的設計中的陷阱與誤區,但在應用設計原則的同時需要緊記一點:設計原則本質上是一些經驗條框,是設計的導盲杖而不要讓它們成為束縛設計思想的牢籠。
每個架構師在經歷長期的實踐後也會慢慢建立屬於自己的設計原則。多年來我也總結出了一些設計原則,並將上面這些這種可用於程式碼設計的原則歸納為:“程式碼設計原則”,另外一些應用於意識與設計過程中的原則稱為“意識-行為原則”。以下我將會分別講述我對這些設計原則的理解與運用的經驗。
意識 - 行為原則
意識決定行為,很多的設計失誤並不單純源自於對設計原則的把握不足,而更多可能源自於架構師在意識指導上的錯誤, 所以在開始設計之前應該先建立正確的思想與意識引導。以下的這些意識-行為原則是我從很多次的跌倒中總結出的一些心得,將期作為原則是為了時刻引導自己不會在類似問題中犯錯。
堅持創新原則
首先談談模板式設計,我相信模板對於每一位開發人員和設計人員來說都是非常好的東西,因為它可以“快速”構建出“成熟”的程式碼、結構或UI。“拿來主義”在業界盛極不衰,對於架構師而言模板也有這種功效,在設計的過程中我們會經常遇到很多必須而不重要的“雞肋”模組,沒有它們系統會變得不完整,而它們的存在並不能為系統增加任何的“特色功能”,如:使用者管理、角色管理或系統設定等。常見做法是,直接採用第三方模組或是從已有的其它專案中複用類似的模組,你是這樣的嗎 ?至少我是經常這樣做的,因為我們的中國式專案通常是“驗收驅動”,能通過驗收、成熟可用就好。如果整個專案都只是由各類别範本化的模組所構成,那麼這個專案其實不需要架構師,因為不存在任何設計,所有的工作只是一種“融合”(Fusion)。可能這樣說會有很多人會吐槽說這是一種“資源整合”能力,從“趕專案”的角度來說這無可口非,但從技術含量與本質上說確實不存在任何設計成分,這類拼裝性或是“複製”性的專案只需要專案經理配備幾個高階程式設計師就能完成了。
我曾在“表達思維與駕馭方法論”一文中提到與銷售的溝通方法,其中就有一條:“至少說出系統的三個特色”,這個表述對銷售具有市場意義以外 , 其實對於架構師是起到一個重要的提醒作用同時也是在建立一種設計原則:
固守本質原則
看到這兩句經典是不是猜到我想就“變化”二字來一次老生常談 ?其實不然,這兩個字在業內估計也討論了20多年了,也說爛了。我之所以引用這兩位大師的名言只是想無時無刻提醒自己要了解身邊的每一個變化,對他們的源頭產生興趣,從而深入瞭解。世界上不會有無緣無故的愛,也沒有無緣無故的恨一切皆有根源,那是 “本質”。我們來將 “本質” 與 “變化” 這兩個哲學性的問題應用到軟體開發的範疇內來看一個軟體產品的迭代:
而唯一不變的是:軟體的核心。正如:Windows 變了N個版本最後還是操作平臺,Office 衍生了多代後若然在處理文件檔案 。
架構應從本質入手,一切複雜的事物都應可被分解為簡單的原理和構成,本質之外的內容皆可變化。我們來舉例說明,設計一個電子商務網站,其核心就可被分解為 “購物車” 與 “訂單狀態跟蹤”這是不可變的除非大眾的整體購物行為發生了本質上的改變,為了增加使用者體驗我們選用美觀舒適的介面套件如BootStrap,如果進一步提升使用者體驗則可以採用SPA的架構讓客戶在Web上獲得Native式的使用體驗;為了讓使用者使用不同的支付方式,我們就需要定義支付閘道器介面(引入變化)支援已有的支付平臺,也為將來“可能”出現的支付平臺留有擴充套件。為了增強網站對使用者的粘性,我們就需要增加社群模組,並採用雲端儲存或是其它的BigData技術以支撐大資料量的運轉;.... 最後,一切的本質仍然不變,電商網站,變的是擴充套件性、易用性、伸縮性等等。架構師可以向其中新增的功能太多太多,但必須固守本質才能讓整個產品不會成為一個由高技術打造出來的怪物,在增加新功能時參考 “程式碼商人”原則的指引。
“程式碼商人” 原則
衡量標準的尺子掌握在架構師手中,如果設計中出現林林總總的這些“未來功能”您會如何來對待呢 ?是直接砍掉還是將其包裝成為“特色”呢 ?此時架構師不單單是需要作為一名技術人員的角度考慮這個功能是否在將來可用,而更多的是需要考慮“成本”。每個功能甚至每行程式碼都需要付出“人-月”成本,一旦成本失控,軟體就會化身“人狼”吞掉你的專案,而最後也只能後悔沒有找到“銀彈”。每個“未來”功能如何不能對現有專案帶來即時性的回報,必須砍掉!即使這個功能有如何的美妙、高深或是在將來具有非凡的意義,還是將它放入“研究室”成為其它專案的技術儲備吧。站在商人的立場:每一分錢的成本投入,都需要有足夠的利益回報。
未來永遠是美好的、豐滿的同時也是浮雲,而現實卻往往是充滿骨感。在架構或程式碼中透支未來極少數可獲得回報,因為這些“投資”都具有不可預見性只是一些嘗試,在產品中除了“市場策略”需要外的這類過分投資就得有陷入“維護未來”的心理覺悟。新的功能、未來的特色更應該收集起來,作為一下版本中可選項,通過詳細的市場研究再考慮加入到產品中。當然,對於大型軟體企業這個原則基本上是多餘的,因為很多成熟的軟體企業對需求的控制極其嚴格與規範。但如果你所在的企業還沒有這樣的管理意識,或具有超脫性的設計自由,那麼這條原則是非常重要的,我們是用程式碼換錢的人,更少的程式碼換更多的錢才是我們最基本的生存需要。
重構優先原則
在沒有程式碼的時候就應該重構,重構是寫出優雅程式碼的方法而不單純是修改程式碼的理論。
這也可以說是一個團隊性的開發原則,在專案之始就得有統一的編碼規範(直接使用官方規範),並將重構中的基本程式碼重構方法也納入規範中,在開發過程中強制執行規範,對任何可能“腐化”的程式碼絕對的“零”容忍,痛苦只是一時,但好處卻是長久的。
程式碼設計原則
開放-封閉原則
開放封閉原則又稱 開-閉原則 Open-Closed Principle (OCP)
我想分幾個方面來詮釋這個原則:
從類設計的角度
在類設計的應用中開-閉原則是一種對類的“多型”控制原則。開閉原則在基類或超類的設計中由為重要, 可以簡單地理為對 成員物件的作用域 和 可“過載”成員 的控制指引原則。按 “里氏替換原則” 基類成員通常對於子類都應該可見,也就是說基類成員的作用域的最小作用範圍應該是 protect , 如果出現大量的 private 成員時就應該考慮將private 成員們分離成其它的類,因為些成員都不適用於其子代而違反了“替換原則”,而更適用“合成/聚合原則“。
在運用 virtual 關鍵字時需甚重考慮,除了針對某些特殊的設計模式如 ”裝飾“模式需要大量 virtual 的支援以外,在沒有必要的情況下儘量避免。定義可重寫的成員為子類預留了”改變行為“的餘地,但同時也是為子類違反”替換原則“埋下了地雷。當子類中出現大量重寫成員的時候就得考慮該子類是否還應該繼承於此類族,因為子類在大量地違反”替換原則“時就意味著它滿足了被分離出類族的條件。同理,在C#內一但需要在子類內部實現基類介面時也需要作出同樣的考慮。
注:里氏替換原則是開-閉原則的一種重要補充,在類設計中一般是同時使用。
從模組設計的角度
模組設計的“開-閉原則”是側重於對介面的控制。而這個在整個架構中也尤為重要,因為模組間的“開-閉”是直接影響系統級的耦合度。模組間的開閉需要“衡量成本”,並不是將所有的細節都開放使用模組具有極強的可擴充套件性就會有很高的重用度。首先要看了解幾點:
開放性與維護成本成正比關係
介面的開放必須帶有使用說明,這會增加團隊開放的溝通成本同時一但介面發生改變將可能帶來額外的“說明性重構”成本。在某些情況下我們很容易被“高擴充套件性”所引誘將很多“可能”被複用的功能通過擴充套件介面暴露出來。當這種高擴充套件性的誘惑主導了設計師的思維,隨著模組的增多專案的變大、慢慢地設計師就會進入自己所建立的“註釋惡夢”中。
開放性與耦合度成正比關係
模組的開放性介面是具有耦合傳導效應的,控制模組間的耦合度就能在很大程度上控制了系統的耦合度。模組間的依賴性越小,耦合度越低才更易於變化儘量將耦合度集中在某一兩個模組中(如:Facade 模式),而不是分散在各模組間。耦合度高的模組自然而然地成為“核心”模組,而其實的“外部”模組則需要保持自身的封閉性,這樣的設計就很多容易適對未知的變化。
由這兩個正比關係結合對實現成本的控制上我們做出兩個最為簡單可行的推論:
推論1:“正常情況下請保持封閉,沒有必要的情況下絕不開放”。
推論2:“集中開放性,讓模組間保持陌生”
開-閉原則從理論上來談會有很多內容,但實現起來卻很簡單, 就以C#為例控制模組開放性的最簡單辦法就是控制作用域:internal , public。
3.從函式/方法設計的角度
我為認為OCP用到極至的情況就是應用於方法級,眾所周知:引數越少的方法越好用。開-閉原則可以簡單地理解為引數的多寡與返會值的控制
在此我更想談談“開-閉原則”在C#中的應用。首先在方法設計上,C# 給了設計人員與開發人員一個極大的空間,到了4.5我們甚至可以使用async 方法來簡單控非同步方法,那麼先來總結一下C#的方法引數的種類。
“程式碼注入”就是向方法傳入“代理”類就是在方法內部開闢出某一“可擴充套件”的部分以執行未知、可變的功能 ,那麼我們就可以對相對“封閉”的方法增強其“開放”性。
通過泛型方法的使用,我們可以在對型別“開放”的情況下對型別的通用操作相對地“封閉”起來,這樣可以在很大程度上利用泛型複合取代類繼承,降低類的多型耦合度。
里氏替換原則(LSP)
里氏代換原則 (Liskov Substitution Principle LSP)物件導向設計的基本原則之一。 里氏代換原則中說,任何基類可以出現的地方,子類一定可以出現。 LSP是繼承複用的基石,只有當衍生類可以替換掉基類,軟體單位的功能不受到影響時,基類才能真正被複用,而衍生類也能夠在基類的基礎上增加新的行為。里氏代換原則是對“開-閉”原則的補充。實現“開-閉”原則的關鍵步驟就是抽象化。而基類與子類的繼承關係就是抽象化的具體實現,所以里氏代換原則是對實現抽象化的具體步驟的規範。 在前文”開-閉原則“關於類設計應用部分已經基本敘述過”替換原則“的用法。 這個原則,我一直是反向理解的,這樣就非常容易運用,我是這樣使用的:
依賴倒轉原則(DIP)
DIP 就像LSP一樣,原文與譯文其實都非常坑爹,這裡我就不直接引入原文了,因為我希望每個讀這篇文章的朋友都能理解並應用這些原則而不是在玩文字遊戲。DIP 用最為簡單的表述就是:“面向介面程式設計”。子類可以引用父類方法或成員,而父類則絕對不能呼叫任何的子類方法或成員。一但上層類調的方法呼叫了子類的方法就會形成依賴環,一般上編譯器會“放過”依賴環認為這不屬於邏輯錯誤,但具有依賴環的類結構是無法序列化的(在C#中會直接丟擲環狀引用的異常)。
通俗點:“規矩是祖宗定的,子孫只能執行和完善”,用這個口決就可以完全掌握此原則 。
在過去(10年前)開發工具還比較落後,這是原則十分重要,而如今可以藉助VS.net去找到出這種設計錯誤,也可以直接使用IoC 和 DI 就會自然而充分地尊守此原則 。
介面隔離原則 (ISP)
架構師在邏輯世界就是神,設計軟體的過程就是創造邏輯世界,每一個介面就是這個世界中的一種規則,類則是實現規則的做法,例項就是執行規則的人。 在實現工作中,我們會經常遇到這樣的現象:一個PM可能同時在跟進好幾個專案,或是一個PM要同時充當架構師、PM、程式設計師甚至售前的角色,這些苦B們是公司內最累的人,同時也是失敗率最高的群體,為什麼? 答案顯而易見:人的精力是有限的,專注於某一件事才能真正有成果。同理,在邏輯世界也是一樣的,當介面要承載多種的任務,被眾多不同的類所呼叫時就會出現“介面過載”或者”介面汙染“,實現這些介面的類將會產生很高的耦合度,從而程式碼會變得難以閱讀,難以理解,也難以變化。分離介面就是隔離了客戶(介面的使用者),隔離客戶就自然降低耦合度。
一個完美的世界就應該是專人專項,讓擅長的人做其擅長的事,在現實不可能但邏輯世界卻可以。那麼在設計中如何來把握這種原則呢 ?很簡單,當一個介面上的方法被多個類呼叫時就要警覺了,如果這些方法間沒有依賴關係,甚至是不同類別(在做不同的事)的方法那麼就得考慮使用ISP原則將介面分離成兩個獨立的介面,使介面的耦合度從1..n 降低至 1..1.
合成/聚合 複用原則(CARP)
複用原則是一個很容易被忽略而又極其重要的原則,這個原則具有非常深遠的架構意義。對於小型專案(類庫規模小)即使違反此原則也不會帶來什麼危害,但當構建大規模的類庫(數百甚至數千個類)時,這個原則就可以防止出現“繼承失控”、過度膨脹、無法重構等的風險,也決定了整個結構的可重用性和可維護性。在定義中它只是一句簡單的話,但從“繼承”、“合成”與“聚合”就引出了一系列的內容,涵蓋多種設計模式和附帶多個更小層級的應用原則。
(注:關於合成/聚合的好處請去百度吧,關於“白箱複用”與“黑箱複用”都被轉爛了)
首先要正確的選擇合成/複用和繼承,必須透徹地理解里氏替換原則和Coad法則。Coad法則由Peter Coad提出,總結了一些什麼時候使用繼承作為複用工具的條件。
Coad法則:
只有當以下Coad條件全部被滿足時,才應當使用繼承關係:
對於一個老手Coad法則只是一種總結,很容易理解與運用,但如果你是一個架構新手Coad法則就很是坑爹(我的理解力很低,當年我就被坑了很久)!所以我想另闢蹊徑從其它角度來嘗試解釋這個原則。
繼承控制
繼承是物件導向的一種重要構型,複用原則只告訴我們“儘量不使用繼承”而不是將繼承魔鬼化,在很多場景下,小結構繼承是非常常見與易讀的。只是,我們需要了解繼承的子代的增加是以整個類結構的複雜度增加n次方在遞增,隨著子代層級的增多“類家族”結構的變化就越來越難。其實,我們可以找一些自已手上的例子來看看,如果有3代以上繼承關係的類,看看最小的子孫類與基類之間是否已經有點“面目全非”?這一點與人類的繁衍與繼承是很類似的。再深入一點就是如果向最頂層的基類進行擴充套件,是則能完全適用“替換原則”呢 ?更改高層級結構時是否有“揮舞大刀”般的沉重感 ? 對是否有勇氣對穩定的祖代類重構 ?
推論:“儘可能避免出現三代以外的繼承關係,否則應考慮合成/聚合”
“合成”與“聚合”從字面意義上去理解是我一直以來都無法正確理解的內容。可能是我語文水平實在太低的緣故吧,對 Composite 和 Aggregation 兩個單詞我反而能在維基百科上找到準確的定義。
合成 ( Composite ) - 值聚合 (Aggregation by value)
我的通俗定義:合成的過程是在類的構造過程中(建構函式或外部的構造方法)在執行期將值或其它類例項組裝到合成類內(通過變數或屬性Hold住)
如:
思考:試試用Builder模式改寫上面的例子,會有不同的效果。
這裡只是有3個部件,但如果將部件變成30個或者更多時改變的也只是 “合成的構造者” ,應對再複雜的場景:樹型合成結構也只是將構造者演變為遞迴式構造。由此可見到“合成”原則的執行對大量類組合的強大之處。
聚合 ( Aggregation ) - 引用聚合(Aggregation by reference)
聚合在物件導向的實現上是一個極為簡單的程式碼,說白了就是:物件屬性。以上面第一個範例說明 (不繼承Item基類)
小結
如果你是一位.net 體系的開發人員,只要你開啟vs.net的程式碼檢查規則你就會發現一個新的世界,一個基於原則/規範 的世界,如果你的程式碼能80%地通過vs.net中最高階別的程式碼檢查準則,那麼事實上你的程式碼已經是非常優質。內中的每一條程式碼檢查準則都值得我們細細地去品味與學習。
- 單一職責原則 (SRP) - 就一個類而言,應該僅有一個引起它變化的原因
- 開-閉原則 (OCP)- 軟體實體(類,模組,函式等)應該是可以擴充套件的,但是不可以修改
- 里氏替換原則 (LSP)- 子類必須能夠替換它們的基型別
- 依賴倒置原則 (DIP)- 抽象不應該依賴於細節。細節應該依賴於抽象。
- 介面隔離原則 (ISP)- 不應該強迫客戶依賴於它們不用的方法。介面屬於客戶,不屬於它所在的類層次結構。
- 重用釋出等階原則 (REP)- 重用的粒度就是釋出的粒度。
- 共同封閉原則 (CCP)- 包中的所有類對於同一類性質的變化應該是共同封閉的。一個變化若對一個包產生影響,則將對該包中的所有類產生影響,而對於其他的包不造成影響。
- 共同重用原則(CRP)- 一個包中所有類應該是共同重用的。如果重用了包中的一個類,那麼就要重用包中的所有類。
- 無環依賴原則(ADP)- 在包的依賴關係圖中不允許存在環。
- 穩定依賴原則 (SDP)- 朝著穩定的方向進行依賴。
- 穩定抽象原則(SAP)- 包的抽象程度應該和其穩定程度一致。
- 合成/聚合 複用原則(CARP)- 要儘量使用合成/聚合 ,儘量不要使用繼承
- …..
當然物件導向的設計原則遠遠不止這些,設計原則是伴隨著開發語言的發展應用和軟體開發經驗的累加總結得出的經驗彙總,隨著語言的演變、開發方法的進步還會不斷地衍生和進化出更多的的設計原則。應用設計原則可以避開很多的設計中的陷阱與誤區,但在應用設計原則的同時需要緊記一點:設計原則本質上是一些經驗條框,是設計的導盲杖而不要讓它們成為束縛設計思想的牢籠。
每個架構師在經歷長期的實踐後也會慢慢建立屬於自己的設計原則。多年來我也總結出了一些設計原則,並將上面這些這種可用於程式碼設計的原則歸納為:“程式碼設計原則”,另外一些應用於意識與設計過程中的原則稱為“意識-行為原則”。以下我將會分別講述我對這些設計原則的理解與運用的經驗。
意識 - 行為原則
意識決定行為,很多的設計失誤並不單純源自於對設計原則的把握不足,而更多可能源自於架構師在意識指導上的錯誤, 所以在開始設計之前應該先建立正確的思想與意識引導。以下的這些意識-行為原則是我從很多次的跌倒中總結出的一些心得,將期作為原則是為了時刻引導自己不會在類似問題中犯錯。
堅持創新原則
首先談談模板式設計,我相信模板對於每一位開發人員和設計人員來說都是非常好的東西,因為它可以“快速”構建出“成熟”的程式碼、結構或UI。“拿來主義”在業界盛極不衰,對於架構師而言模板也有這種功效,在設計的過程中我們會經常遇到很多必須而不重要的“雞肋”模組,沒有它們系統會變得不完整,而它們的存在並不能為系統增加任何的“特色功能”,如:使用者管理、角色管理或系統設定等。常見做法是,直接採用第三方模組或是從已有的其它專案中複用類似的模組,你是這樣的嗎 ?至少我是經常這樣做的,因為我們的中國式專案通常是“驗收驅動”,能通過驗收、成熟可用就好。如果整個專案都只是由各類别範本化的模組所構成,那麼這個專案其實不需要架構師,因為不存在任何設計,所有的工作只是一種“融合”(Fusion)。可能這樣說會有很多人會吐槽說這是一種“資源整合”能力,從“趕專案”的角度來說這無可口非,但從技術含量與本質上說確實不存在任何設計成分,這類拼裝性或是“複製”性的專案只需要專案經理配備幾個高階程式設計師就能完成了。
我曾在“表達思維與駕馭方法論”一文中提到與銷售的溝通方法,其中就有一條:“至少說出系統的三個特色”,這個表述對銷售具有市場意義以外 , 其實對於架構師是起到一個重要的提醒作用同時也是在建立一種設計原則:
架構設計中模板的拼裝是不可避免的,重要的是必須加入屬於你的特色設計很難有人記得住整個軟體的設計師,而卻很容易記住某項極具特色功能的設計者。“特色” 是架構師在軟體中所留下的一種重要的印記,也是在團隊中配備架構師的意義所在。設計出完全可被模板化重用的設計是一功力,而當中小型企業內出現這樣的設計之日就是架構師離開企業之時,或許這也是當下中國架構師之殤。保持特色保住飯碗,你懂的。
固守本質原則
唯一不變的就是變化本身 — Jerry Marktos《人月神話》
不變只是願望,變化才是永恆 — Swift
看到這兩句經典是不是猜到我想就“變化”二字來一次老生常談 ?其實不然,這兩個字在業內估計也討論了20多年了,也說爛了。我之所以引用這兩位大師的名言只是想無時無刻提醒自己要了解身邊的每一個變化,對他們的源頭產生興趣,從而深入瞭解。世界上不會有無緣無故的愛,也沒有無緣無故的恨一切皆有根源,那是 “本質”。我們來將 “本質” 與 “變化” 這兩個哲學性的問題應用到軟體開發的範疇內來看一個軟體產品的迭代:
- 使用者的需求在變 - 他們需要增加更多的功能,要求更高質量的使用者體驗。
- 程式碼在變 - 不斷的重構、測試,持續整合,讓程式碼變得容讀,穩定。
- 老闆的想法在變 - 因為市場需求在變,需要為軟體加入更多的特色滿足市場。
- 架構在變 - 採用更新式的技術體系,獲得更高效的生產力,更為穩定、安全的執行環境。
而唯一不變的是:軟體的核心。正如:Windows 變了N個版本最後還是操作平臺,Office 衍生了多代後若然在處理文件檔案 。
變化是表像,不穩定且可定製的;本質是核心,必須穩定,可擴充套件而不可修改;被固定的變化則可納入核心。
架構應從本質入手,一切複雜的事物都應可被分解為簡單的原理和構成,本質之外的內容皆可變化。我們來舉例說明,設計一個電子商務網站,其核心就可被分解為 “購物車” 與 “訂單狀態跟蹤”這是不可變的除非大眾的整體購物行為發生了本質上的改變,為了增加使用者體驗我們選用美觀舒適的介面套件如BootStrap,如果進一步提升使用者體驗則可以採用SPA的架構讓客戶在Web上獲得Native式的使用體驗;為了讓使用者使用不同的支付方式,我們就需要定義支付閘道器介面(引入變化)支援已有的支付平臺,也為將來“可能”出現的支付平臺留有擴充套件。為了增強網站對使用者的粘性,我們就需要增加社群模組,並採用雲端儲存或是其它的BigData技術以支撐大資料量的運轉;.... 最後,一切的本質仍然不變,電商網站,變的是擴充套件性、易用性、伸縮性等等。架構師可以向其中新增的功能太多太多,但必須固守本質才能讓整個產品不會成為一個由高技術打造出來的怪物,在增加新功能時參考 “程式碼商人”原則的指引。
“程式碼商人” 原則
永遠不要投資未來,絕不設計沒有回報的功能不知道你是否擁有類似的經歷:
- 在與客戶的交流中,你的老闆和經理在不斷地向客戶描繪“未來”圖景,而在其中包含了很多幾乎是客戶沒有需要的特色 ?
- 在你設計整體架構時,有一種衝動讓你很想將某項由靈感觸發對於系統“將來”的擴充套件需要很有用的功能或模組加入其中呢 ?
- 在你的程式碼裡面有多少個方法或類是可以被刪除,但你認為他們可以用於“以後”擴充套件而“手下留碼”的呢 ?
- 你是否曾與經理或專案組長為了是否增加某個很有可能成為特色且被你實現出來的功能爭論不休呢 ?
衡量標準的尺子掌握在架構師手中,如果設計中出現林林總總的這些“未來功能”您會如何來對待呢 ?是直接砍掉還是將其包裝成為“特色”呢 ?此時架構師不單單是需要作為一名技術人員的角度考慮這個功能是否在將來可用,而更多的是需要考慮“成本”。每個功能甚至每行程式碼都需要付出“人-月”成本,一旦成本失控,軟體就會化身“人狼”吞掉你的專案,而最後也只能後悔沒有找到“銀彈”。每個“未來”功能如何不能對現有專案帶來即時性的回報,必須砍掉!即使這個功能有如何的美妙、高深或是在將來具有非凡的意義,還是將它放入“研究室”成為其它專案的技術儲備吧。站在商人的立場:每一分錢的成本投入,都需要有足夠的利益回報。
未來永遠是美好的、豐滿的同時也是浮雲,而現實卻往往是充滿骨感。在架構或程式碼中透支未來極少數可獲得回報,因為這些“投資”都具有不可預見性只是一些嘗試,在產品中除了“市場策略”需要外的這類過分投資就得有陷入“維護未來”的心理覺悟。新的功能、未來的特色更應該收集起來,作為一下版本中可選項,通過詳細的市場研究再考慮加入到產品中。當然,對於大型軟體企業這個原則基本上是多餘的,因為很多成熟的軟體企業對需求的控制極其嚴格與規範。但如果你所在的企業還沒有這樣的管理意識,或具有超脫性的設計自由,那麼這條原則是非常重要的,我們是用程式碼換錢的人,更少的程式碼換更多的錢才是我們最基本的生存需要。
重構優先原則
在沒有程式碼的時候就應該重構,重構是寫出優雅程式碼的方法而不單純是修改程式碼的理論。
駱駝與帳篷的故事這是一個很有寓意故事,如果將其比喻為開發過程也很有意思。對於“發臭”甚至“腐爛”程式碼我們會馬上說“重構”,但重構是否能解決一切問題 ?你是否試過重構失敗呢 ?重構在什麼情況下是不可用的呢 ?如果這些問題在你心中是沒有準確答案的話, 我建議可以重新去閱讀一次《程式碼重構》一書。我認為重構不單純是一種開發期與程式碼回顧期所使用的方法,而是一種設計與編碼的思想指導!在設計期就應運用重構中的原則,那是否就可以“防腐”呢 ?答案顯然是確定的。重構的往往不單純是程式碼,而是開發人員、設計人員的思想,不執行甚至沒有程式碼規範、隨意命名、隨意複製/貼上、隨意呼叫這些都必須被杜絕。我並不是指在設計重構就不需要重構,只是這樣做的意義可以大量減少由於發現“臭”程式碼而去重構的成本 。
在風沙彌漫的大沙漠,駱駝在四處尋找溫暖的家。後來它終於找到一頂帳篷,可是,帳篷是別人的(也許你的處境跟它一樣)!
最初,駱駝哀求說,主人,我的頭都凍僵了,讓我把頭伸進來緩和暖和吧!主人可憐它,答應了。過了一陣子,駱駝又說,主人,我的肩膀都凍麻了,讓我再進來一點吧!主人可憐它,又答應了。接著,駱駝不斷的提出要求,想把整個身體都放進來。
主人有點猶豫,一方面,他害怕駱駝粗大的鼻孔;另一方面,外面的風沙那麼大,他好像也需要這樣一位夥伴,和他共同抵禦風寒和危險。於是,他有些無奈地背轉身去,給駱駝騰出更多的位子。等到駱駝完全精神並可以掌握帳篷的控制權的時候,它很不耐煩地說,主人,這頂帳篷是如此狹小以致連我轉身都很困難,你就給我出去吧
這也可以說是一個團隊性的開發原則,在專案之始就得有統一的編碼規範(直接使用官方規範),並將重構中的基本程式碼重構方法也納入規範中,在開發過程中強制執行規範,對任何可能“腐化”的程式碼絕對的“零”容忍,痛苦只是一時,但好處卻是長久的。
程式碼設計原則
開放-封閉原則
開放封閉原則又稱 開-閉原則 Open-Closed Principle (OCP)
軟體實體(如類,模組,函式等)應該是可以擴充套件的,但是不可以修改。OCP是一個極為之出名的設計原則,簡單的一句話就概括了可時該“開放”可時該“封閉”。這句話看起來很簡單,一看似乎也會覺得自己領悟了什麼,仔細咀嚼卻覺得內中深意無限,到底應怎樣理解這句話且將其應用於設計中呢 ? 我參考了不少國內的資料對此原則的總結,感覺就是霧裡看花,沒有辦法找到最為貼切的解釋。
我想分幾個方面來詮釋這個原則:
從類設計的角度
在類設計的應用中開-閉原則是一種對類的“多型”控制原則。開閉原則在基類或超類的設計中由為重要, 可以簡單地理為對 成員物件的作用域 和 可“過載”成員 的控制指引原則。按 “里氏替換原則” 基類成員通常對於子類都應該可見,也就是說基類成員的作用域的最小作用範圍應該是 protect , 如果出現大量的 private 成員時就應該考慮將private 成員們分離成其它的類,因為些成員都不適用於其子代而違反了“替換原則”,而更適用“合成/聚合原則“。
在運用 virtual 關鍵字時需甚重考慮,除了針對某些特殊的設計模式如 ”裝飾“模式需要大量 virtual 的支援以外,在沒有必要的情況下儘量避免。定義可重寫的成員為子類預留了”改變行為“的餘地,但同時也是為子類違反”替換原則“埋下了地雷。當子類中出現大量重寫成員的時候就得考慮該子類是否還應該繼承於此類族,因為子類在大量地違反”替換原則“時就意味著它滿足了被分離出類族的條件。同理,在C#內一但需要在子類內部實現基類介面時也需要作出同樣的考慮。
注:里氏替換原則是開-閉原則的一種重要補充,在類設計中一般是同時使用。
從模組設計的角度
模組設計的“開-閉原則”是側重於對介面的控制。而這個在整個架構中也尤為重要,因為模組間的“開-閉”是直接影響系統級的耦合度。模組間的開閉需要“衡量成本”,並不是將所有的細節都開放使用模組具有極強的可擴充套件性就會有很高的重用度。首先要看了解幾點:
開放性與維護成本成正比關係
介面的開放必須帶有使用說明,這會增加團隊開放的溝通成本同時一但介面發生改變將可能帶來額外的“說明性重構”成本。在某些情況下我們很容易被“高擴充套件性”所引誘將很多“可能”被複用的功能通過擴充套件介面暴露出來。當這種高擴充套件性的誘惑主導了設計師的思維,隨著模組的增多專案的變大、慢慢地設計師就會進入自己所建立的“註釋惡夢”中。
開放性與耦合度成正比關係
模組的開放性介面是具有耦合傳導效應的,控制模組間的耦合度就能在很大程度上控制了系統的耦合度。模組間的依賴性越小,耦合度越低才更易於變化儘量將耦合度集中在某一兩個模組中(如:Facade 模式),而不是分散在各模組間。耦合度高的模組自然而然地成為“核心”模組,而其實的“外部”模組則需要保持自身的封閉性,這樣的設計就很多容易適對未知的變化。
由這兩個正比關係結合對實現成本的控制上我們做出兩個最為簡單可行的推論:
推論1:“正常情況下請保持封閉,沒有必要的情況下絕不開放”。
推論2:“集中開放性,讓模組間保持陌生”
開-閉原則從理論上來談會有很多內容,但實現起來卻很簡單, 就以C#為例控制模組開放性的最簡單辦法就是控制作用域:internal , public。
3.從函式/方法設計的角度
我為認為OCP用到極至的情況就是應用於方法級,眾所周知:引數越少的方法越好用。開-閉原則可以簡單地理解為引數的多寡與返會值的控制
在此我更想談談“開-閉原則”在C#中的應用。首先在方法設計上,C# 給了設計人員與開發人員一個極大的空間,到了4.5我們甚至可以使用async 方法來簡單控非同步方法,那麼先來總結一下C#的方法引數的種類。
- 固定引數:public void methodName(string a, out b, ref c);
- 動態引數:public void methodName(string a, string b=“defautlString”)
- 可變引數:public void methodName(params string [] a);
- 表示式引數(方法注入):public void methodName(Func<string> func, Action act);
- 泛型引數:public void methodName<T>( T a) where a : class;
“程式碼注入”就是向方法傳入“代理”類就是在方法內部開闢出某一“可擴充套件”的部分以執行未知、可變的功能 ,那麼我們就可以對相對“封閉”的方法增強其“開放”性。
通過泛型方法的使用,我們可以在對型別“開放”的情況下對型別的通用操作相對地“封閉”起來,這樣可以在很大程度上利用泛型複合取代類繼承,降低類的多型耦合度。
里氏替換原則(LSP)
凡是基類適用的地方,子類一定適用
里氏代換原則 (Liskov Substitution Principle LSP)物件導向設計的基本原則之一。 里氏代換原則中說,任何基類可以出現的地方,子類一定可以出現。 LSP是繼承複用的基石,只有當衍生類可以替換掉基類,軟體單位的功能不受到影響時,基類才能真正被複用,而衍生類也能夠在基類的基礎上增加新的行為。里氏代換原則是對“開-閉”原則的補充。實現“開-閉”原則的關鍵步驟就是抽象化。而基類與子類的繼承關係就是抽象化的具體實現,所以里氏代換原則是對實現抽象化的具體步驟的規範。 在前文”開-閉原則“關於類設計應用部分已經基本敘述過”替換原則“的用法。 這個原則,我一直是反向理解的,這樣就非常容易運用,我是這樣使用的:
- 凡是出現大量子類不適用的成員,子類就應該脫離繼承關係
- 基類中凡是出現大量虛成員,該類就失去成為基類的條件
依賴倒轉原則(DIP)
要依賴抽象,不要依賴具體。
DIP 就像LSP一樣,原文與譯文其實都非常坑爹,這裡我就不直接引入原文了,因為我希望每個讀這篇文章的朋友都能理解並應用這些原則而不是在玩文字遊戲。DIP 用最為簡單的表述就是:“面向介面程式設計”。子類可以引用父類方法或成員,而父類則絕對不能呼叫任何的子類方法或成員。一但上層類調的方法呼叫了子類的方法就會形成依賴環,一般上編譯器會“放過”依賴環認為這不屬於邏輯錯誤,但具有依賴環的類結構是無法序列化的(在C#中會直接丟擲環狀引用的異常)。
通俗點:“規矩是祖宗定的,子孫只能執行和完善”,用這個口決就可以完全掌握此原則 。
在過去(10年前)開發工具還比較落後,這是原則十分重要,而如今可以藉助VS.net去找到出這種設計錯誤,也可以直接使用IoC 和 DI 就會自然而充分地尊守此原則 。
介面隔離原則 (ISP)
使用多個專門的介面比適用單一的介面要好
架構師在邏輯世界就是神,設計軟體的過程就是創造邏輯世界,每一個介面就是這個世界中的一種規則,類則是實現規則的做法,例項就是執行規則的人。 在實現工作中,我們會經常遇到這樣的現象:一個PM可能同時在跟進好幾個專案,或是一個PM要同時充當架構師、PM、程式設計師甚至售前的角色,這些苦B們是公司內最累的人,同時也是失敗率最高的群體,為什麼? 答案顯而易見:人的精力是有限的,專注於某一件事才能真正有成果。同理,在邏輯世界也是一樣的,當介面要承載多種的任務,被眾多不同的類所呼叫時就會出現“介面過載”或者”介面汙染“,實現這些介面的類將會產生很高的耦合度,從而程式碼會變得難以閱讀,難以理解,也難以變化。分離介面就是隔離了客戶(介面的使用者),隔離客戶就自然降低耦合度。
一個完美的世界就應該是專人專項,讓擅長的人做其擅長的事,在現實不可能但邏輯世界卻可以。那麼在設計中如何來把握這種原則呢 ?很簡單,當一個介面上的方法被多個類呼叫時就要警覺了,如果這些方法間沒有依賴關係,甚至是不同類別(在做不同的事)的方法那麼就得考慮使用ISP原則將介面分離成兩個獨立的介面,使介面的耦合度從1..n 降低至 1..1.
合成/聚合 複用原則(CARP)
要儘量使用合成/聚合 ,儘量不要使用繼承
複用原則是一個很容易被忽略而又極其重要的原則,這個原則具有非常深遠的架構意義。對於小型專案(類庫規模小)即使違反此原則也不會帶來什麼危害,但當構建大規模的類庫(數百甚至數千個類)時,這個原則就可以防止出現“繼承失控”、過度膨脹、無法重構等的風險,也決定了整個結構的可重用性和可維護性。在定義中它只是一句簡單的話,但從“繼承”、“合成”與“聚合”就引出了一系列的內容,涵蓋多種設計模式和附帶多個更小層級的應用原則。
(注:關於合成/聚合的好處請去百度吧,關於“白箱複用”與“黑箱複用”都被轉爛了)
首先要正確的選擇合成/複用和繼承,必須透徹地理解里氏替換原則和Coad法則。Coad法則由Peter Coad提出,總結了一些什麼時候使用繼承作為複用工具的條件。
Coad法則:
只有當以下Coad條件全部被滿足時,才應當使用繼承關係:
- 子類是超類的一個特殊種類,而不是超類的一個角色。區分“Has-A”和“Is-A”。只有“Is-A”關係才符合繼承關係,“Has-A”關係應當用聚合來描述。
- “Is-A”代表一個類是另外一個類的一種;
- “Has-A”代表一個類是另外一個類的一個角色,而不是另外一個類的特殊種類。
- 永遠不會出現需要將子類換成另外一個類的子類的情況。如果不能肯定將來是否會變成另外一個子類的話,就不要使用繼承。
- 子類具有擴充套件超類的責任,而不是具有置換掉(override)或登出掉(Nullify)超類的責任。如果一個子類需要大量的置換掉超類的行為,那麼這個類就不應該是這個超類的子類。 (注:在C# 中 含有 new 的方法、屬性和內部實現單一基類介面就相當於Nullify)
- 只有在分類學角度上有意義時,才可以使用繼承。不要從工具類繼承。
對於一個老手Coad法則只是一種總結,很容易理解與運用,但如果你是一個架構新手Coad法則就很是坑爹(我的理解力很低,當年我就被坑了很久)!所以我想另闢蹊徑從其它角度來嘗試解釋這個原則。
繼承控制
繼承是物件導向的一種重要構型,複用原則只告訴我們“儘量不使用繼承”而不是將繼承魔鬼化,在很多場景下,小結構繼承是非常常見與易讀的。只是,我們需要了解繼承的子代的增加是以整個類結構的複雜度增加n次方在遞增,隨著子代層級的增多“類家族”結構的變化就越來越難。其實,我們可以找一些自已手上的例子來看看,如果有3代以上繼承關係的類,看看最小的子孫類與基類之間是否已經有點“面目全非”?這一點與人類的繁衍與繼承是很類似的。再深入一點就是如果向最頂層的基類進行擴充套件,是則能完全適用“替換原則”呢 ?更改高層級結構時是否有“揮舞大刀”般的沉重感 ? 對是否有勇氣對穩定的祖代類重構 ?
推論:“儘可能避免出現三代以外的繼承關係,否則應考慮合成/聚合”
“合成”與“聚合”從字面意義上去理解是我一直以來都無法正確理解的內容。可能是我語文水平實在太低的緣故吧,對 Composite 和 Aggregation 兩個單詞我反而能在維基百科上找到準確的定義。
合成 ( Composite ) - 值聚合 (Aggregation by value)
我的通俗定義:合成的過程是在類的構造過程中(建構函式或外部的構造方法)在執行期將值或其它類例項組裝到合成類內(通過變數或屬性Hold住)
如:
public class Keyboard{} public class Mouse{} public class Monitor{} public class Computer { private Keyboard keyboard; private Mouse mouse; private Monitor monitor; public Computer() { this.keyboard=new Keyboard(); this.mouse=new Mouse(); this.monitor=new Monitor(); } }由這個例子可見,所謂的“值(Value)”通過建構函式合成為 “Computer”的內部成員,有如將各個功能單一的部件裝配成為一個功能強大的產品。所有的依賴都被“關在”建構函式內,如果將依賴外接就可以運用工廠(Factory Pattern)和合成模式(Composite Pattern)進行演變。
public class Item{}; public class Keyboard:Item{} public class Mouse:Item {} public class Monitor:Item{} public ComputerFactory { public Item Keyboard() { return new Keyboard(); } public Item Monitor() { return new Monitor(); } public Item Mouse() { return new Mouse(); } } public class Computer { public List<Item> Items{get;set;} public Computer(ComputerFactory factory) { this.Items.Add(factory.Keyboard()); this.Items.Add(factory.Mouse()); this.Items.Add(factory.Monitor()); } }通過簡單的演變,就可以將Computer 1-3的耦合變成 1-1 的耦合,所有的依賴都集中到ComputerFactory上,只需要繼承ComputerFactory建立更多的工廠傳入Computer類就可以生產出各種各樣的Computer例項,而無需更改Computer的任何程式碼,這就是所謂的“黑箱複用”。
思考:試試用Builder模式改寫上面的例子,會有不同的效果。
這裡只是有3個部件,但如果將部件變成30個或者更多時改變的也只是 “合成的構造者” ,應對再複雜的場景:樹型合成結構也只是將構造者演變為遞迴式構造。由此可見到“合成”原則的執行對大量類組合的強大之處。
聚合 ( Aggregation ) - 引用聚合(Aggregation by reference)
聚合在物件導向的實現上是一個極為簡單的程式碼,說白了就是:物件屬性。以上面第一個範例說明 (不繼承Item基類)
public class Computer { public Mouse Mouse{ get;set; } public Monitor Monitor{ get; set; } public Keyboard Keyboard {get;set;} } public class Host { public static void Main() { var computer=new Computer() { Mouse=new Mouse(), Monitor=new Monitor(), KeyBoard=new KeyBoard() }; } }聚合類中Hold住的是例項化類的引用,不單是值。聚合類的意義在於將引用依賴集中一處,從某意義上說這個Computer類也是一個Facade 模式。這種方式常見於大規模物件模型的入口類,如Office的 Application 物件,這樣的設計可以便於開發者“尋找”類的引用。同時也可以用作 上下文的設計 如:.net中的System.Web.HttpContext。值得注意的是:聚合類是需要慎用的,對於類本身是收斂類引用耦合,同時聚合類也具有耦合傳導的特性,由其是建構函式。就拿EF說事吧,我們用EF訪問資料庫都需要這樣的程式碼:
public void OrderManager { public List<Order> GetOrder() { using (var ctx=new DbContext( ) { //… } } }當這個new 在程式碼各處出現時就壞菜了!構造引用的耦合度每呼叫一次就增加一分,當遍佈整個訪問層甚至系統時 DBContext就是一個不可變更的超巨型耦合腫瘤!要解決這個問題可以採用單件模式自構造或是用IoC、DI將構造移到一個集中的地方,防止構造耦合散播。
小結
如果你是一位.net 體系的開發人員,只要你開啟vs.net的程式碼檢查規則你就會發現一個新的世界,一個基於原則/規範 的世界,如果你的程式碼能80%地通過vs.net中最高階別的程式碼檢查準則,那麼事實上你的程式碼已經是非常優質。內中的每一條程式碼檢查準則都值得我們細細地去品味與學習。