讓程式碼可重用和可泛化#
本章與第 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 解決方案:若假設不可避免,則強制執行它#
有時假設是必要的,或者帶來的簡化足以超過其成本。此時應確保其他工程師不會在不知情的情況下踩到陷阱。兩種主要方法:
- 讓假設「不可能被打破」 — 透過型別系統或編譯期檢查,確保若假設被違反則無法編譯(參見第 3、7 章)
- 使用錯誤訊號機制 — 若無法在編譯期防止,則在執行期偵測違規並使用錯誤訊號技術快速失敗(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)取代全域狀態:
- 將
static改為實例變數 — 移除ShoppingBasket中的static關鍵字,讓每個實例擁有自己獨立的items - 透過建構式注入 — 將
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)#
預設值能讓軟體更易於使用(例如文字處理器的預設字型、字級等),但提供預設值通常意味著兩個假設:
- 什麼預設值是合理的
- 上層程式碼不需要區分「明確設定的值」和「預設值」
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 解決方案:在高層程式碼提供預設值#
將預設值的決策從低層移至高層:
- 低層回傳
null—UserDocumentSettings.getPreferredFont()在無使用者偏好時回傳null - 建立專責的預設值類別 —
DefaultDocumentSettings封裝預設值邏輯 - 在高層組合兩者 —
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 本章摘要#
- 相同的子問題反覆出現 — 讓程式碼可重用,能為未來的自己和隊友節省大量時間
- 辨識基礎子問題 — 結構化程式碼,使其他人即使在解決不同的高層問題時也能重用特定子問題的解法
- 乾淨的抽象層 + 模組化 = 更容易安全地重用和泛化
- 假設有代價 — 使程式碼更脆弱、更難重用
- 確保假設的效益大於成本
- 若必須做出假設,確保它在適當的層級,並盡可能強制執行
- 全域狀態是特別昂貴的假設 — 使程式碼完全不安全地重用,大多數情境下應避免使用
- 預設值應在高層定義 — 低層程式碼不應替上層做預設值的決策
- 函式參數應聚焦 — 只接收真正需要的資料,避免過度耦合
- 善用泛型 — 當解法不依賴特定型別時,使用泛型幾乎不費力卻能大幅提升泛化能力