設計的聖杯:簡單#

設計是軟體工藝的聖杯與終極目標——我們追求的是一種完美到可以毫不費力地添加功能、經歷數月甚至數年的維護後仍然保持靈活的設計。

然而本章的重點不在設計原則、設計模式或架構——這些值得深入研究,但設計的關鍵本質可以用一個詞來概括:簡單(Simplicity)

正如 Chet Hendrickson 所說:

Uncle Bob wrote thousands of pages on clean code. Kent Beck wrote four lines.

本章聚焦的就是 Kent Beck 的四條簡單設計規則

簡單的定義#

  • 簡單不等於容易——簡單意味著未糾纏(untangled),而解開糾纏是困難的
  • 軟體系統中最昂貴、最嚴重的糾纏,是高階策略(high-level policies)與低階細節(low-level details)的交纏
    • SQL 與 HTML 混在一起
    • 框架與核心價值混在一起
    • 報告格式與計算業務規則混在一起
  • 這些糾纏容易寫出來,卻使得新增功能、修復 bug、改善設計都變得困難

簡單設計是指高階策略對低階細節一無所知的設計。高階策略被隔離、保護,使得低階細節的變更對高階策略毫無影響。

抽象與多型#

實現隔離的主要手段是抽象(Abstraction)——放大本質(高階策略),消除無關(低階細節)。

物理實現手段則是多型(Polymorphism)

  • 高階策略透過多型介面來管理低階細節
  • 低階細節作為多型介面的實作
  • 所有原始碼依賴方向從低階細節指向高階策略
  • 低階細節可以被變更而不影響高階策略

Figure 6.1: Polymorphism

最佳設計應具有最少的抽象,同時仍能有效隔離高階策略與低階細節。

YAGNI#

從過度設計到 YAGNI#

1980-1990 年代的策略恰好相反——程式設計師痴迷於為預期的未來變更預先放置掛鉤(hooks)。這是因為當時軟體難以修改:

  • 小型系統可能需要一小時以上來建置
  • 測試全是手動的,因此極度不充分
  • 程式設計師越來越害怕做出改變,導致過度設計

1990 年代末 Extreme Programming 和 Agile 的出現改變了一切——機器變得強大到建置時間可以縮短到秒級,自動化測試也變得可負擔。

YAGNI 的真正含義#

1999 年,Kent Beck 提出了 YAGNI 的真正問題:

What if you aren’t gonna need it?(如果你不需要它呢?)

  • 每次想放入掛鉤時,問自己:如果不放會怎樣?
  • 如果不放的成本可以接受,就可能不應該放
  • 如果長年維護掛鉤的成本高,但最終需要它的機率低,也可能不應該放
  • 掛鉤的問題在於我們很少猜對——我們不擅長預測客戶實際會做什麼

YAGNI 不代表永遠不要預先思考。有時放入特定掛鉤確實是好主意。但取捨已經發生了劇烈變化——現在通常更好的做法是留下大多數掛鉤不放,在需求變化時重構設計。

作者指出一個巨大的諷刺:Moore’s Law 的指數增長驅動我們建造越來越複雜的軟體系統,同時也使我們能夠大幅簡化這些系統的設計。

四條簡單設計規則#

規則的演進#

這四條規則隨時間不斷演進:

#1999 年(Beck 原版)2015 年(Fowler 版)
1系統(程式碼和測試)必須傳達你想傳達的一切Passes the tests
2系統不得包含重複程式碼Reveals intention
3系統應有最少的 classNo duplication
4系統應有最少的 methodFewest elements

注意規則的演進方向:第一條規則從「溝通」分裂為測試與表達兩個面向,最後兩條合併為一條。而且隨著時間推移,測試的重要性從溝通提升到了覆蓋率

規則一:Covered by Tests(被測試覆蓋)#

覆蓋率的歷史#

程式碼覆蓋率的概念非常古老——最早可追溯至 1963 年,距第一台電子計算機上執行第一個程式僅 17 年。Miller 和 Maloney 在論文中就已指出:

要對任何程式有信心,不能只知道它大多數時候運作正常。真正的問題是,每一次都能成功完成功能規格嗎?程式的每個部分都必須在測試中被使用。

覆蓋率的目標#

什麼是好的覆蓋率數字?作者的答案:100%

  • 如果你滿足於 80% 的覆蓋率,代表你不知道 20% 的程式碼是否正常運作
  • 100% 行覆蓋率和 100% 分支覆蓋率是目標
  • 這可能是一個漸近目標(asymptotic goal)——也許永遠無法達到,但這不是不嘗試的藉口
  • 作者親身參與過成長到數萬行程式碼的專案,始終將覆蓋率維持在接近 100% 的水準

覆蓋率與設計的關係#

可測試的程式碼就是解耦的程式碼。

  • 要達到高覆蓋率,程式碼的每個部分都必須能被測試存取
  • 這意味著每個部分必須與其他程式碼充分解耦,才能被隔離並從測試中呼叫
  • 因此,測試不僅是行為的測試,也是解耦的測試
  • 撰寫隔離測試的行為本身就是一種設計行為

測試還有另一層好處:一套可信賴的測試套件大幅降低了對變更的恐懼。當需求改變時,你可以無所畏懼地調整設計。這就是為什麼這是四條規則中最重要的一條——沒有測試套件,其他三條規則變得不切實際,因為那些規則涉及重構,而重構離不開測試。

flowchart LR
    A["高覆蓋率"] --> B["程式碼可被測試存取"]
    B --> C["充分解耦"]
    C --> D["撰寫測試 = 設計行為"]
    D --> E["降低變更恐懼"]
    E --> F["安全重構"]
    F --> G["持續改善"]

規則二:Maximize Expression(最大化表達)#

程式碼的表達力#

在早期程式設計年代,「code」這個詞本身就暗示意圖是被掩蓋的。早期程式碼完全無法顯示意圖,因此需要大量註解。

Figure 6.2: An example of an early program

但現代語言具有強大的表達力。透過適當的紀律,我們可以產生讀起來像「well-written prose that never obscures the designer’s intent」的程式碼。作者以 Video Store 範例中的 RentalCalculator class 為例——變數、函式和型別的名稱具有深層描述性,演算法的結構清晰可見。

底層抽象(The Underlying Abstraction)#

表達力不只是漂亮的命名,還涉及層次的分離與底層抽象的揭示

作者以薪資系統為例——按小時計薪的員工、領佣金的員工、領固定薪資的員工各有不同的規則。容易想像一組帶有複雜 switch 或 if/else 鏈的函式來處理這些需求,但這樣會掩蓋底層抽象。真正的底層抽象是:

public List<Paycheck> run(Database db) {
  Calendar now = SystemTime.getCurrentDate();
  List<Paycheck> paychecks = new ArrayList<>();
  for (Employee e : db.getAllEmployees()) {
    if (e.isPayDay(now))
      paychecks.add(e.calculatePay());
  }
  return paychecks;
}

高階策略與低階細節分離,是讓設計簡單且富有表達力的最根本做法。

測試:問題的另一半#

回顧 Beck 最初的第一條規則:「系統(程式碼和測試)必須傳達你想傳達的一切。」

  • 無論生產程式碼多有表達力,它都無法傳達使用情境——這是測試的工作
  • 每個測試(尤其是隔離且解耦的測試)都是程式碼使用方式的示範
  • 寫得好的測試就是被測程式碼的使用案例範本
  • 程式碼和測試合在一起,表達了系統每個元素做什麼以及如何使用

規則三:Minimize Duplication(最小化重複)#

重複的起源與危害#

  • 早期程式設計沒有原始碼編輯器,用 #2 鉛筆寫在預印表格上,最好的編輯工具是橡皮擦——所以不會重複程式碼
  • 隨著原始碼編輯器和複製/貼上的出現,越來越多系統展現出大量程式碼重複
  • 重複的危害:兩段或多段相似的程式碼往往需要同時修改,找到它們很難,正確修改更難(因為它們存在於不同情境中)
  • 重複導致脆弱性(fragility)

消除重複的手段#

  • 一般做法:將相似程式碼抽象為新函式,提供適當參數以溝通情境差異
  • 當重複出現在遍歷複雜資料結構的程式碼中時,可使用 LambdaCommand 物件Strategy 模式Template Method 模式來封裝遍歷邏輯

意外重複(Accidental Duplication)#

並非所有重複都應被消除。 有時兩段程式碼可能非常相似甚至完全相同,但會因不同原因而改變——這稱為意外重複。意外重複應被保留,隨著需求變化,它們會自然地分別演化,重複也會自然消失。

判斷真正重複與意外重複的方法:

  • 意外重複的意圖是發散的
  • 真正重複的意圖是收斂的

管理重複並不簡單——識別哪些是真正重複、哪些是意外重複取決於程式碼的表達力,而封裝和隔離真正重複則需要大量的重構,重構又需要良好的測試套件

因此,消除重複在四條規則中排名第三——測試和表達力排在它前面。

規則四:Minimize Size(最小化大小)#

簡單設計由簡單元素組成,簡單元素是小的。最後一條規則:

  • 在所有測試通過後
  • 在程式碼盡可能有表達力後
  • 在重複最小化後
  • 在不違反前三條規則的前提下,努力縮小每個函式中的程式碼量

做法主要是擷取更多函式——持續擷取直到無法再擷取,最終得到漂亮的小函式、有意義的長名稱,使函式既小又有表達力。

flowchart TD
    R1["規則 1:被測試覆蓋"] --> R2["規則 2:富有表達力"]
    R2 --> R3["規則 3:消除重複"]
    R3 --> R4["規則 4:最小化程式碼量"]
    R4 -.->|"依賴"| R3
    R3 -.->|"依賴"| R2
    R2 -.->|"依賴"| R1

四條規則的終極承諾#

Kent Beck 曾對作者說:如果你盡可能認真地遵循這四件事——覆蓋率、表達力、單一化、精簡化——那麼所有其他設計原則都會被滿足。

作者坦言不確定這是否完全正確——不確定一個完美覆蓋、表達、去重、精簡的程式是否必然符合 Open-Closed Principle 或 Single Responsibility Principle。但他非常確定的是:了解和研究良好的設計原則與架構(例如 SOLID 原則)會使創建分區良好的簡單設計容易得多