良好設計原則備忘錄
相信絕大多數人第一次接觸程式時,腦海都是想著如何讓電腦理解我們的指令,也就是俗稱的能動就好。
這種態度對於初學者來說 (包括我)是非常合理的,畢竟要從人類的語言、想法轉換成電腦的語言、想法,這中間的差距 (Gap)是非常大的,這也是為什麼程式對於初學著來說這麼難的原因。例如簡單的排序、元素交換都能讓初學者吃盡苦頭。因為電腦指令的表達與它要解決的問題的差距是非常大的。
拿簡單的交換來說,初學者可能會寫出下面的程式:
int a = 2; int b = 5; a = b; b = a;
了解程式的人都知道,這樣的寫法,會導致變數a的值被蓋掉 (從2變成5),那這樣變數b就沒辦法正確得到變數a的值了 (期望為2,但已經被改成5)。
正確的寫法如下:
int a = 2; int b = 5; int temp = a; a = b; b = temp;
透過一個變數temp紀錄a被修改之前的值,所以當變數a的值被蓋掉時,變數b能夠使用變數temp的值 (變數a變動前的值)來得到正確的值。
但隨著我們對於程式的理解逐漸深入到能夠自主完成一個專案時,這時候再繼續追求能動就好了,可以說是自欺欺人的說法了,有經驗的工程師都知道,隨著專案的週期逐漸拉長,如果沒有處理好的話,程式的複雜度會指數的上升,到最後整個團隊沒有人可以繼續維護這份專案。
專案複雜度會對於公司造成哪些問題呢:
- 專案進度無限延期,客戶不滿意。
- 專案太過複雜,導致bug不間斷,客戶不滿意。
- 專案太過複雜,導致員工加班,員工加班疲累的狀況下,容易做出錯誤的決策,導致後續成本增加。
- 團隊成員失去耐心維護因而跳巢,導致公司流動率高。
- 因為團隊的流動率高,導致專案的知識沒有被紀錄而逐漸消失,最後專案胎死腹中。
- 公司因為專案胎死腹中,沒有賺到錢,最終倒閉。
上述簡單的邏輯推理,能夠知道專案的複雜度對於一間軟體公司的影響是非常巨大的,所以透過好的設計原則來降低複雜度是不可或缺的。
在學習寫程式,從一開始變數型態、迴圈都不會、到Clean Code、到物件導向語言、到基本物件導向、到物件導向分析與設計、到設計模式、到Clean Architecture、到Domain Driven Design、到敏捷開發流程,中間的過程累積了許許多多從書上、經驗、失敗淬煉出來的設計訣竅,這些都能夠用來控制軟體的複雜度,今天在這邊做一個紀錄,也算是對自己的學習過程做一個統整歸納吧!
Process
不管使用什麼流程,需要讓符合軟體開發容易變異的特性,因此需要滿足下列要求:
- 快速回饋:流程要能夠快速獲得回饋,以便快速發現問題、快速解決。
- 持續改善與檢討:流程要有固定的時間點讓整個團隊檢討目前的狀況、目前遇到哪些問題、哪些問題最棘手、如何解決。
- 共同目標:團隊要有明確的專案目標、專案的價值、專案需要解決什麼問題。
- 公開透明:專案進度越透明越好,讓團隊了解目前近況,當遇到問題時,才能在第一時間改進。
- 完全合作、同一條船:團隊要能真。合作,而不是分工不合作、彼此遇到了哪些問題、彼此正在解決什麼問題。
- 明確需求下自由發揮:專案經理不應該插手團隊的開發方式,只需要真正了解團隊目前的實力、目前的進度、目前遇到的困境,指導團隊做最有價值的工作。
- 價值導向:流程不應該加入對於專案毫無價值的活動,例如:撰寫大量文件、撰寫大量工作日誌。
- 系統性思考:當團隊隊員遇到問題卡住時,團隊要停下手邊工作,快速解決發生的問題,以系統優化為優先、而不是局部優化。
- 評估:評估要透過相對大小,而不是準確時間,研究顯示,人在預估時間有將近4倍的誤差,堅持要開發者評估時間是不可靠的行為。
- 持續溝通:每天團隊需要互相sync彼此的狀況,如果遇到問題快速商討解決辦法。團隊需要跟專案經理密切合作,了解工作的優先權重。
- 定義做完的標準:每項工作需要定義真正做完的標準,不能打迷糊仗,做完就是真正的做完,沒有部份做完的工作。
- 完整的團隊:團隊隊員要能夠專心的處理目前專案,而不是每個人手上負責一堆案子,過度增加Context Switch會導致工作產能大量下降。
- 團隊共享同個工作空間:團隊隊員要能夠共享能樣的工作空間,避免溝通的負擔。
- 客戶定時回饋:流程要能確保客戶以固定的日期了解專案的近況,並且操作目前的軟體來給予適當的回饋,確保團隊Do the right things。
Requirement Checking
- 使用者動機:只有了解使用者動機與問題,才能站在使用者的立場思考出對使用者最合適的解決辦法。
- 使用者的角色:了解角色能讓開發者融入開發功能的情境,進而產生較為合理的使用模式與設計。
- 使用者期望行為:確認期望行為,確保最後結果不會偏離太多。
- 需求大小 (Scope):確認專案要做到什麼樣的程度,需要穩定到什麼程度、需要能處理多大量的資料、需要能處理多大量的工作
- 需求價值:此需求在專案中價值如何?是高還是低?
Decomposition Problem Domain
當需求確認時,我們需要透過有效手段來切割需求,大部分有兩種方式如下:
- Decomposition By Knowledge:透過領域知識來切割模組,通常使用物件導向來Model,能夠有效的隱藏實作細節,適合較複雜的專案。
- Decomposition By Steps:透過步驟來切割程式,通常使用程序導向來Model,適合較簡單的專案。需要注意的是:透過步驟切割容易造成Information Leakage,導致多個模組因為共享資料強烈耦合,導致Temporal Coupling。俗稱Temporal Decomposition。長期維護性會越來越低。
Domain Driven Design
切割模組時,通常會產生下列角色:
- Entity:用來記錄領域的資料,擁有Identity,隨著時間狀態會不斷更新。
- Value Object:用來描述領域物件的特性,沒有Identity,狀態為Immutable。
- Aggregate:根據transaction boundary來封裝Entity與Value Object,確保invariant不會被破壞。
- Service:通常沒有狀態,當特定行為放在Entity與Value Object不合適時,透過Service來Model。
Design Principle
當大致切割完模組後,通常會使用大量的Principle來確保實作的維護性,透過Principle能不被語言、框架所限制,進而設計出較好的解決方式。
Kent Beck Implementation Pattern Principle
- Local Consequence Principle:修改限制在單一區塊,不會影響其他模組。
- Logic & Data Together:資料與邏輯封裝在一起,提高聚合性。
- Remove Duplication:避免重複帶來的耦合,造成Cascade Change。
- Symmetry:程式碼的表達需要一致,不管是函數、物件介面。
- Rate of Change:根據狀態變化來切割模組,能達到Local Consequence Principle
- Declarative Design:透過較為高階的API,而不是實作細節來增加可讀性。
SOLID Principle
- Single Responsibility Principle:確保物件的行為只有一種改變的因素,降低行為互相耦合的問題。
- Open Closed Principle:透過新增程式碼來增加行為,而不是改變既有程式碼。
- Liskov substitution principle:確保能夠在不改變程式整體的行為下替換子類別。
- Interface Segregation Principle:確保沒有強迫客戶端使用他不需要的介面,避免多個客戶端耦合的問題。
- Dependency Inversion Principle:確保針對客戶端的需求定義抽象化,讓高階模組、與低階模組耦合此抽象化。
GRASP
- Information Expert:確保擁有資訊的物件負責需要的行為。
- Creator:確保有用資訊的物件去產生特定物件。
- Coupling:確保物件與物件之間沒有不必要的耦合。
- Cohesion:確保物件負責特定相關的責任。
- Controller:確保提供一個物件當作外部客戶端 (UI)的進入點。
- Pure fabrication:增加人工物件來降低實作細節的複雜度,不屬於Problem Domain的物件。
- Polymorphism:確保訊息傳送者不用知道訊息接收者是誰。
- Indirection:當sender與receiver都遵守SRP卻又需要增加功能時,加上indirection object去負責此責任。
- Protected variations:透過不同手段讓程式需求修改時,能夠以有效的方式增加功能。
Design Pattern
- Template method:封裝抽象化邏輯到父類別,讓子類別實作特定功能。
- Strategy pattern:封裝演算法到此物件,達到替換演算法的效果。
- Observer:讓Subject通知任何對他狀態有興趣的物件。加入新的物件不需要Subject。
- Decorator:動態增加責任到物件上面而不是類別,遵守SRP。
- Abstraction Factory:新增一個物件用來產生一系列相關物件。能夠在不修改程式的狀態下,替換掉整組相關的物件。
Implementation
Readability
- Intention over Implementation:所有程式的名稱需要根據解決的問題來 命名。不管是類別、函數、變數,命名不應該包含技術的實作細節。
- Clear Specific Context: 程式碼要有足夠的Context讓讀者了解要解決的問題,不應該過度抽象化或是過度實作細節。
Clean Function
- SRP:函數名稱需要比函數的實作細節高出一個抽象化程度。
- Story:函數讀起來需要像故事一樣,從大綱、劇情、到劇情細節。高階函數組合數個中階函數、中階函數組合數個低階函數。
- Body Symmetry:函數的實作需要保持同樣的抽象化程度。
Variable Naming
- Single Role:變數名稱只能代表一種角色,不能使用同一個變數來模擬多個角色。
- Precise:變數名稱要足夠精確,不能使用摸稜兩可的名稱,像是:Manager、Processor、Information、Data、Object、Map等等。
- Context Information:變數名稱要根據所屬的類別、函數提供足夠的Context,或是使用Prefix、Postfix來增加Context的資訊。
- Remove Redundant Information:當變數所屬的類別、函數提供明確的Context時,變數不需要特定重複一樣的資訊,例如:Student類別的成員變數name,不應該使用studentName,因為已經在Student類別這個Context底下。
API Design
- Behavior over Implementation:提供越完整的行為越好,完整的行為應該從客戶端的角度定義。
- Deep API:API的介面透露的資訊應該越紹越好,而封裝的實作細節應該越多越好。可以大幅度降低客戶端複雜度。
- Few Public:Public API應該越少越好,代表提供的行為越完整,如果Public API非常多,可能抽象化程度不夠,應該重新思考API預期提供的行為。或是客戶端取得太多控制權,應該讓客戶端放棄過多的控制權。
Context
物件的Dependency、要求runtime的環境構成物件的Context,良好的物件應該要滿足以下要求:
- Minimize:物件需要的Context要越少越好,如此一來才能簡單的被其他物件reuse。例如:entity使用database物件,當測試時,需要提供entity database的環境,會降低可測試性。
- General or Specific:Context足夠General讓物件容易被reuse、Context足夠Specific讓物件要解決的問題非常明確。上述狀況只能二擇一,不應該讓物件同時General和Specific,最後容易變成義大利麵程式碼。
- No Mixed:不同Context的行為,不應該被封裝在同一個物件,會導致這些行為造成不必要的耦合。
Final Check
當設計完成後,我們需要考慮以下問題,透過這些問題的回饋,在重新根據上述步驟修正:
- 是否能夠限制預期的行為在一個模組?
- 是否有Temporal Coupling的產生?
- 是否有責任過於集中的問題?
- 抽象化是否做對?
- 抽象化的層級是不是剛剛好比實作細節高一個層級?
- 有沒有充分利用compile time回饋而不是runtime回饋?