讓程式碼不易被誤用#
撰寫文件和使用說明可以幫助減少誤用,但正如第三章所述,這些就像程式碼契約中的「附屬細則」(small print),容易被忽略且可能過時。因此,更重要的是在設計和撰寫程式碼時,就讓它難以被誤用。
防呆設計(Poka-Yoke):讓東西難以(或不可能)被誤用是設計與製造中的成熟原則。例如食物調理機只有在蓋子正確安裝時才能運作、不同形狀的插頭無法插入錯誤的插座。在軟體工程中,這個原則常被表述為「API 和介面應該易於使用、難以誤用」(Easy to Use, Hard to Misuse, EUHM)。
7.1 考慮讓事物不可變(Immutable)#
不可變(immutable)意味著物件在建立後就無法改變其狀態。可變性(mutability)會帶來以下問題:
- 更難推理:如果物件是不可變的,就像有一個無法破壞的防竄改封印,你可以放心地將物件傳遞到任何地方,確信沒有人會篡改它
- 多執行緒問題:如果一個執行緒正在讀取物件,而另一個執行緒正在修改它,就可能發生錯誤
- 容易被設定成無效狀態:如第三章所述,有 setter 函式的可變類別容易被錯誤設定
- 改變輸入參數的副作用:如第六章所述,修改傳入參數可能造成意外
預設立場:不可能總是讓所有東西都不可變——某些程式碼必須追蹤變化中的狀態。但應該預設讓事物盡可能不可變,只在必要時才使用可變結構。
7.1.1 可變類別容易被誤用#
以 TextOptions 類別為例,如果它提供 setFont() 和 setFontSize() 等 setter 函式,任何持有該實例的程式碼都能隨時改變其值。當一個函式將 TextOptions 實例傳給多個函式時,其中一個函式可能意外修改了字型大小,導致後續函式收到被竄改的設定值——這正是可變性造成的 bug。
7.1.2 解決方案:僅在建構時設定值#
最簡單的做法是移除所有 setter 函式,所有值只在建構時透過 constructor 提供,並將成員變數標記為 final(或 const、readonly):
- 外部程式碼無法修改物件狀態
final關鍵字明確表示這些值不會也不應該改變- 防止類別內部程式碼意外重新賦值
7.1.3 解決方案:使用不可變性設計模式#
當某些值是可選的,或需要建立物件的修改版本時,單純移除 setter 可能不夠實用。兩種常用模式:
Builder Pattern(建造者模式)
將類別拆分為兩個:
- Builder 類別:可變的,允許逐一設定值
- 目標類別:不可變的,從 builder 建構出來
重點是:必要值(required)透過 builder 的 constructor 傳入,可選值透過 setter 設定,最後呼叫 build() 產生不可變物件。這讓建構時就能做到編譯期檢查,避免產生無效物件。

Figure 7.1: The builder pattern effectively splits a class into two. The builder class can be mutated in order to set values.
Copy-on-Write Pattern(寫入時複製模式)
當需要取得物件的「修改版本」時,不是直接修改原物件,而是建立一個新的副本:
- 提供
withFont()、withFontSize()等函式 - 每個函式回傳一個新的實例,只改變指定的值
- 原始物件保持不變

Figure 7.2: With the copy-on-write pattern, any change to a value results in a new instance of the class being created.
例如 renderTitle() 函式需要修改字型大小,可以這樣寫:
void renderTitle(String title, TextOptions baseStyle) {
titleField.display(title, baseStyle.withFontSize(18.0));
}原始的 baseStyle 完全不受影響。
classDiagram
class MessageBuilder {
-String sender
-String content
+setSender(String) MessageBuilder
+setContent(String) MessageBuilder
+build() Message
}
class Message_Builder 版 {
<<immutable>>
-String sender
-String content
+getSender() String
+getContent() String
}
MessageBuilder ..> Message_Builder 版 : build() 建立不可變物件
class Message_CopyOnWrite版 {
<<immutable>>
-String sender
-String content
+getSender() String
+getContent() String
+withSender(String) Message_CopyOnWrite版
+withContent(String) Message_CopyOnWrite版
}
Message_CopyOnWrite版 ..> Message_CopyOnWrite版 : withX() 回傳新實例
note for MessageBuilder "Builder Pattern\n可變的建造者\n逐步設定值後呼叫 build()"
note for Message_CopyOnWrite版 "Copy-on-Write Pattern\n每次修改回傳新物件\n原始物件保持不變"
7.2 考慮深層不可變性(Deep Immutability)#
即使類別本身的 setter 已移除,如果成員變數是可變型別(如 List),且外部程式碼持有該物件的參照(reference),仍可能導致深層可變性(deep mutability)。

Figure 7.3: Objects are often held by reference, meaning multiple pieces of code can all be referring to the same object.
深層可變性的兩種情境#
以 TextOptions 儲存 List<Font> fontFamily 為例:
- 情境 A:建構者在建構後仍持有清單的參照,後續修改清單會影響
TextOptions內部 - 情境 B:呼叫者透過
getFontFamily()取得清單的參照後直接修改,同樣影響內部狀態
解決方案一:防禦性複製(Defensive Copying)#
在 constructor 中複製傳入的集合,在 getter 中回傳複製品:
TextOptions(List<Font> fontFamily, Double fontSize) {
this.fontFamily = List.copyOf(fontFamily); // 複製
this.fontSize = fontSize;
}
List<Font> getFontFamily() {
return List.copyOf(fontFamily); // 回傳複製品
}缺點:
- 複製可能很昂貴:如果集合很大或函式頻繁被呼叫,效能會受影響
- 無法防止類別內部的修改:
final只防止重新賦值,不防止深層修改
解決方案二:使用不可變資料結構#
更好的做法是使用語言或第三方函式庫提供的不可變資料結構:
- Java:Guava 的
ImmutableList - C#:
System.Collections.Immutable的ImmutableList - JavaScript:Immutable.js 或 Immer
使用 ImmutableList 後,不需要防禦性複製,因為沒有人能修改其內容,即使是類別內部的程式碼也不行。
7.3 避免過度泛化的資料型別#
整數、字串、清單等基礎型別非常通用,但用來表示特定概念時會缺乏描述性且過於寬容,容易導致誤用。
7.3.1 過度泛化型別的問題#
以地圖座標為例,用 List<Double> 表示經緯度,多個座標就成了 List<List<Double>>:
- 型別本身完全無法自我解釋
- 很容易搞混緯度和經度的順序
- 缺乏型別安全:編譯器無法保證清單內元素數量正確(可能是 0 個、1 個或 6 個值)

Figure 7.4: A very general data type like a list can be used to represent a location on a map, but that doesn't mean it's a good way to represent it.

Figure 7.5: Representing something specific like a latitude-longitude pair using a list of doubles can make code very easy to misuse.
範式會蔓延:用稍微 hacky 的方式做一件事,往往會迫使更多程式碼也以 hacky 的方式運作。如果
markLocationsOnMap()使用List<Double>表示座標,其他需要與它互動的類別也會被迫採用同樣的表示法,hacky 的做法會快速蔓延且極難移除。
7.3.2 Pair 型別同樣容易被誤用#
Pair<Double, Double> 比 List<Double> 好一些(保證恰好兩個值),但仍有問題:
- 型別仍然缺乏自我描述性
- 依然容易搞混緯度和經度的順序
7.3.3 解決方案:使用專屬型別#
定義專屬的類別(如 LatLong),花費的時間很少,但好處巨大:
class LatLong {
private final Double latitude;
private final Double longitude;
LatLong(Double latitude, Double longitude) { ... }
Double getLatitude() { ... }
Double getLongitude() { ... }
}使用後,函式簽名變成 markLocationsOnMap(List<LatLong> locations)——完全自我解釋,不需要額外文件,且幾乎不可能搞混緯度和經度。
Data Objects 工具支援:許多語言提供簡潔的資料物件定義方式:Kotlin 的
data class、Java 的record、C++/C#/Swift/Rust 的struct、TypeScript 的interface。
7.4 處理時間#
時間看似簡單,實際上相當複雜:時間點(instant)、時間量(duration)、時區(time zone)、日光節約時間、閏年、閏秒等。
7.4.1 用整數表示時間的問題#
用整數(Int64)表示時間會產生三種常見誤用:
無法區分時間點與時間量
sendMessage(String message, Int64 deadline) 中的 deadline 是 Unix 時間戳還是等待秒數?文件如果沒說清楚,很容易用錯。
單位不匹配
一處程式碼回傳秒,另一處期望毫秒。呼叫看起來完全正確 showMessage("Warning", uiSettings.getMessageTimeout()),但訊息只顯示 5 毫秒而非 5 秒。
時區處理不當
將日期(如生日)轉換為時間戳時,如果沒有正確處理時區,不同時區的使用者可能看到不同的日期。

Figure 7.6: Not handling time zones properly can easily lead to bugs.
7.4.2 解決方案:使用適當的時間資料結構#
各語言都有成熟的時間處理函式庫:
| 語言 | 推薦方案 |
|---|---|
| Java | java.time 套件 |
| C# | Noda Time |
| C++ | chrono 函式庫 |
| JavaScript | js-joda 等第三方函式庫 |
這些函式庫提供:
InstantvsDuration:型別本身就區分了時間點與時間量,不可能搞混- 單位封裝:
Duration.ofSeconds(5)和Duration.ofMinutes(2)在不同單位間安全轉換,消除單位不匹配的風險 LocalDateTime:表示不綁定特定時間點的日期時間(如生日),避免時區問題
7.5 資料應有單一事實來源(Single Source of Truth)#
7.5.1 第二個事實來源會導致無效狀態#
資料通常分為:
- 主要資料(Primary Data):必須提供給程式碼的資料,程式無法自行推算
- 衍生資料(Derived Data):可以根據主要資料計算得出
以銀行帳戶為例:存入金額(credit)和提取金額(debit)是主要資料,餘額(balance)是衍生資料。如果將三者都作為 constructor 參數,呼叫者可能提供邏輯上不一致的值——例如把餘額算成 debit - credit 而非 credit - debit。
7.5.2 解決方案:以主要資料作為單一事實來源#
不儲存衍生資料,改為即時計算:
class UserAccount {
private final Double credit;
private final Double debit;
Double getBalance() {
return credit - debit; // 即時計算
}
}當計算成本高昂時:可使用延遲計算(lazy evaluation)加上快取。只要類別和底層資料都是不可變的,快取的衍生值就不會與主要資料不一致。如果類別是可變的,則需在每次變更時清除快取,這既繁瑣又容易出錯——這也是支持不可變性的另一個理由。
7.6 邏輯應有單一事實來源#
7.6.1 多個邏輯來源會導致 bug#
DataLogger 將整數清單序列化(base-10、逗號分隔)後寫入檔案,DataLoader 從檔案讀取並反序列化。兩個類別各自獨立包含了「序列化格式」的邏輯。如果有人修改了其中一個的格式(例如改用十六進位或改用換行分隔),卻忘了修改另一個,就會出現 bug。

Figure 7.7: The format for storing serialized integers is a subproblem that is common to both the DataLogger and DataLoader classes.
7.6.2 解決方案:建立單一事實來源#
將共同的子問題抽取為獨立的類別:
class IntListFormat {
private const String DELIMITER = ",";
private const Radix RADIX = Radix.BASE_10;
String serialize(List<Int> values) { ... }
List<Int> deserialize(String serialized) { ... }
}DataLogger 和 DataLoader 都使用 IntListFormat,格式邏輯只存在一處。即使分隔符號和進位制也各只定義一次作為常數。

Figure 7.8: The IntListFormat class provides a single source of truth for the format for storing serialized integers.
不要寄望於巧合一致:當兩段程式碼的邏輯需要匹配時,不應讓它各自獨立存在。在一處修改程式碼的工程師可能完全不知道另一處有相同的假設。確保重要邏輯有單一事實來源,幾乎可以完全消除因程式碼不同步而產生的 bug。
flowchart LR
subgraph correct["正確做法: 單一事實來源"]
C1["存入金額\n(credit)\n 主要資料"] --> CALC["getBalance()\ncredit - debit\n 即時計算"]
D1["提取金額\n(debit)\n 主要資料"] --> CALC
CALC --> BAL1["餘額\n 衍生資料"]
end
subgraph wrong["反模式: 多重事實來源"]
C2["存入金額\n(credit)"] --> STORE["儲存三個值"]
D2["提取金額\n(debit)"] --> STORE
B2["餘額\n(另外傳入)"] --> STORE
STORE --> BUG["可能不一致\n例如誤算為\ndebit - credit"]
end
style correct fill:#d4edda,stroke:#28a745
style wrong fill:#f8d7da,stroke:#dc3545
style BUG fill:#dc3545,color:#fff
7.7 摘要#
- 程式碼若容易被誤用,遲早一定會被誤用,進而導致 bug
- 常見的誤用方式包括:
- 呼叫者提供無效輸入
- 其他程式碼造成的副作用
- 呼叫者未在正確的時機或順序呼叫函式
- 相關程式碼的修改破壞了某個假設
- 讓事物不可變可以避免許多因意外修改造成的 bug
- 不僅要淺層不可變,還要深層不可變(使用防禦性複製或不可變資料結構)
- 使用專屬型別取代過度泛化的基礎型別,讓型別系統為你把關
- 資料和邏輯都應該有單一事實來源,避免重複定義導致不同步