讓程式碼可重用和可泛化#

本章與第 2 章(乾淨的抽象層)和第 8 章(模組化)高度相關。乾淨的抽象層和模組化通常能讓子問題的解法彼此鬆耦合,從而更容易安全地重用和泛化。但除此之外,還有一些額外的考量值得注意。


9.1 小心隱含假設(Assumptions)#

在程式中做出假設有時能讓程式碼更簡潔或更有效率,但假設也會讓程式碼更脆弱、更不通用,進而降低重用的安全性。隱含假設很難被追蹤,容易在程式碼被重用時變成難以察覺的陷阱。

在決定是否在程式碼中加入假設前,務必權衡成本與效益。如果簡化或效能提升微乎其微,那麼因假設帶來的脆弱性可能得不償失。

9.1.1 假設在程式碼重用時容易導致 Bug#

書中以 Article 類別為例,其中 getAllImages() 函式假設文章中最多只有一個包含圖片的區段(section),因此找到第一個後就直接 return

  • 這帶來極微小的效能提升(提早跳出迴圈)
  • getAllImages() 的名稱暗示它會回傳「全部」圖片,任何呼叫者都會如此預期
  • 一旦文章結構改變(多個區段包含圖片),函式會靜默地只回傳部分圖片,製造出難以追蹤的 Bug

9.1.2 解決方案:避免不必要的假設#

修正後的版本遍歷所有區段,收集並回傳全部圖片:

List<Image> getAllImages() {
    List<Image> images = [];
    for (Section section in sections) {
        images.addAll(section.getImages());
    }
    return images;
}
  • 程式碼更通用、更健壯,適用於不同使用情境
  • 代價僅是迴圈多跑幾次迭代,對效能幾乎沒有可察覺的影響

避免過早優化(Premature Optimization):優化通常伴隨著可讀性降低、維護困難、健壯性下降等成本。除非程式碼會被執行成千上萬次,否則應優先考慮可讀性、可維護性和健壯性,等到確實需要時再進行優化。

9.1.3 解決方案:若假設不可避免,則強制執行它#

有時假設是必要的,或者帶來的簡化足以超過其成本。此時應確保其他工程師不會在不知情的情況下踩到陷阱。兩種主要方法:

  1. 讓假設「不可能被打破」 — 透過型別系統或編譯期檢查,確保若假設被違反則無法編譯(參見第 3、7 章)
  2. 使用錯誤訊號機制 — 若無法在編譯期防止,則在執行期偵測違規並使用錯誤訊號技術快速失敗(fail fast)(參見第 4 章)

書中示範了一個 getOnlyImageSection() 函式:

  • 函式名稱明確傳達了「只有一個圖片區段」的假設,讓不認同此假設的呼叫者不會誤用
  • 內部使用 assert 來強制驗證假設,若文章包含多個圖片區段則立即失敗

假設的成本在於增加脆弱性。當成本超過效益時,應避免做出假設。若假設不可避免,則透過命名和錯誤訊號機制來強制執行,保護其他工程師不會無意間踩雷。


9.2 小心全域狀態(Global State)#

全域狀態(全域變數)是在程式的所有上下文之間共享的狀態。常見的定義方式:

  • Java / C# 中的 static 變數
  • C++ 中的檔案層級變數(在類別或函式外定義)
  • JavaScript 中的 window 物件屬性

不要混淆「全域性」與「可見性」。一個變數可以是 private 同時也是 static(全域的)。全域性指的是變數在所有上下文間共享,而非它是否能被外部存取。

9.2.1 全域狀態讓重用變得不安全#

書中以線上購物應用的 ShoppingBasket 類別為例:

  • items 被宣告為 static,使其成為全域變數
  • addItem()getItems() 也是 static,可從任何地方直接呼叫
  • 隱含假設:每個程式執行期間只需要一個購物籃

這個假設在多種合理情境下會被打破:

  • 將購物籃資料備份到伺服器端(一個伺服器實例處理多個使用者)
  • 新增「稍後購買」功能(需要管理多個購物籃)
  • 新增生鮮品類(使用不同供應商和配送機制,需要獨立購物籃)

當假設被打破時,不同程式碼共用同一個全域狀態會互相干擾 — 一方加入的商品會出現在另一方的籃子裡,可能導致客戶訂到不想要的商品,甚至隱私洩露。

sequenceDiagram
participant A as 用戶 A
participant S as 全域購物車 (static items)
participant B as 用戶 B

    A->>S: addItem(筆電)
    Note over S: items = [筆電]
    B->>S: addItem(耳機)
    Note over S: items = [筆電, 耳機]
    A->>S: getItems()
    S-->>A: [筆電, 耳機]
    Note over A: 結帳時包含了用戶 B 的耳機!
    B->>S: getItems()
    S-->>B: [筆電, 耳機]
    Note over B: 也包含了用戶 A 的筆電!

Figure 9.1: Using global state can make code reuse unsafe.

9.2.2 解決方案:依賴注入共享狀態#

改用依賴注入(Dependency Injection)取代全域狀態:

  1. static 改為實例變數 — 移除 ShoppingBasket 中的 static 關鍵字,讓每個實例擁有自己獨立的 items
  2. 透過建構式注入 — 將 ShoppingBasket 實例透過建構式注入到需要使用它的類別中
ShoppingBasket normalBasket = new ShoppingBasket();
ViewBasketWidget normalBasketWidget = new ViewBasketWidget(normalBasket);

ShoppingBasket freshBasket = new ShoppingBasket();
ViewBasketWidget freshBasketWidget = new ViewBasketWidget(freshBasket);
  • 兩個購物籃完全獨立,永遠不會互相干擾
  • 可以精確控制哪些程式碼共享同一個購物籃、哪些使用不同的

Figure 9.2: By keeping state encapsulated within instances of classes, code reuse becomes safe.

全域狀態是最廣為人知的程式設計陷阱之一。它看似方便,但會讓程式碼重用完全不安全。若需要在程式的不同部分間共享狀態,應使用依賴注入以更可控的方式達成。


9.3 謹慎使用預設回傳值(Default Return Values)#

預設值能讓軟體更易於使用(例如文字處理器的預設字型、字級等),但提供預設值通常意味著兩個假設:

  1. 什麼預設值是合理的
  2. 上層程式碼不需要區分「明確設定的值」和「預設值」

9.3.1 低層程式碼的預設回傳值會損害可重用性#

書中以 UserDocumentSettings 類別為例:

  • getPreferredFont() 在使用者未設定字型時回傳 Font.ARIAL 作為預設值
  • 問題:無法區分「使用者刻意選擇 Arial」和「使用者未設定偏好」
  • 若之後需要支援「組織層級的預設字型」,這個設計就會造成障礙

在低層程式碼中定義預設值,等於對上方所有層級做出假設 — 層級越低,影響範圍越廣。這違反了乾淨抽象層的原則:「取得使用者偏好」和「定義合理預設值」是兩個不同的子問題,不應混在一起。

Figure 9.3: Assumptions affect layers of code above. Returning a default value in low-level code makes an assumption that will tend to affect many pieces of high-level code.

9.3.2 解決方案:在高層程式碼提供預設值#

將預設值的決策從低層移至高層:

  1. 低層回傳 nullUserDocumentSettings.getPreferredFont() 在無使用者偏好時回傳 null
  2. 建立專責的預設值類別DefaultDocumentSettings 封裝預設值邏輯
  3. 在高層組合兩者DocumentSettings 類別透過依賴注入接收兩者,負責判斷該使用使用者設定還是預設值
Font getFont() {
    return userSettings.getPreferredFont()
        ?? defaultSettings.getFont();
}
  • 使用 null coalescing operator(??)讓程式碼更簡潔
  • 不同呼叫者可以自行決定適合的預設值策略
  • 也可考慮 getOrDefault() 模式,讓呼叫者傳入預設值參數,避免處理 null

9.4 保持函式參數的聚焦(Keep Function Parameters Focused)#

當函式需要一個資料物件中的所有資訊時,以整個物件作為參數是合理的。但當函式只需要其中一兩個欄位時,傳入整個物件會損害可重用性。

9.4.1 接收過多參數的函式難以重用#

書中以 TextBox 類別為例:

  • setTextStyle(TextOptions options) 使用 TextOptions 的全部欄位 — 這是合理的
  • setTextColor(TextOptions options) 只使用其中的 textColor — 這就是接收過多了

當其他工程師想重用 setTextColor() 只設定顏色時,被迫建構一個完整的 TextOptions 物件並填入一堆無關的假值(字型、字級、行高等),程式碼既混亂又容易誤導。

9.4.2 解決方案:讓函式只接收它需要的#

setTextColor() 的參數從 TextOptions 改為 Color

void setTextColor(Color color) {
    textElement.setStyleProperty("color", color.asHexRgb());
}

呼叫端變得簡潔明瞭:

void styleAsWarning(TextBox textBox) {
    textBox.setTextColor(Color.RED);
}

判斷原則:如果一個物件封裝了 10 個欄位而函式需要其中 8 個,傳入整個物件可能仍是合理的(避免拆散封裝)。但如果只需要 1-2 個欄位,就應該只傳入需要的部分。重點在於意識到取捨並考慮其後果。

flowchart LR
A["List#lt;Double#gt;<br/>危險: 順序錯誤無法偵測"] -->|增加型別安全| B["Pair#lt;Double,Double#gt;<br/>稍好: 但語意不明"]
B -->|增加型別安全| C["LatLong class<br/>最佳: 語意明確, 型別安全"]

    style A fill:#f8d7da,stroke:#dc3545
    style B fill:#fff3cd,stroke:#ffc107
    style C fill:#d4edda,stroke:#28a745


9.5 考慮使用泛型(Generics)#

當程式碼引用其他類別但並不特別在意具體是什麼類別時,這通常是使用泛型的訊號。使用泛型往往只需極少額外工作,卻能顯著提升程式碼的泛化能力。

9.5.1 依賴特定型別會限制泛化性#

書中以猜字遊戲為例,實作了一個 RandomizedQueue 來儲存單詞(字串):

  • 類別中所有地方都硬編碼為 String 型別
  • 另一個團隊開發幾乎相同的遊戲但使用圖片,卻無法重用這段程式碼

9.5.2 解決方案:使用泛型#

String 替換為泛型參數 T

class RandomizedQueue<T> {
    private final List<T> values = [];

    void add(T value) { values.add(value); }

    T? getNext() {
        if (values.isEmpty()) { return null; }
        Int randomIndex = Math.randomInt(0, values.size());
        values.swap(randomIndex, values.size() - 1);
        return values.removeLast();
    }
}

現在可以用於任何型別:

RandomizedQueue<String> words = new RandomizedQueue<String>();
RandomizedQueue<Picture> pictures = new RandomizedQueue<Picture>();

泛型與可空型別:如果 getNext()null 表示佇列為空,而使用者建立 RandomizedQueue<String?>(允許存入 null),就無法區分「佇列為空」和「取出的值就是 null」。此時可考慮提供 hasNext() 函式作為替代方案。


9.6 本章摘要#

  • 相同的子問題反覆出現 — 讓程式碼可重用,能為未來的自己和隊友節省大量時間
  • 辨識基礎子問題 — 結構化程式碼,使其他人即使在解決不同的高層問題時也能重用特定子問題的解法
  • 乾淨的抽象層 + 模組化 = 更容易安全地重用和泛化
  • 假設有代價 — 使程式碼更脆弱、更難重用
    • 確保假設的效益大於成本
    • 若必須做出假設,確保它在適當的層級,並盡可能強制執行
  • 全域狀態是特別昂貴的假設 — 使程式碼完全不安全地重用,大多數情境下應避免使用
  • 預設值應在高層定義 — 低層程式碼不應替上層做預設值的決策
  • 函式參數應聚焦 — 只接收真正需要的資料,避免過度耦合
  • 善用泛型 — 當解法不依賴特定型別時,使用泛型幾乎不費力卻能大幅提升泛化能力