讓程式碼不易被誤用#

撰寫文件和使用說明可以幫助減少誤用,但正如第三章所述,這些就像程式碼契約中的「附屬細則」(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(或 constreadonly):

  • 外部程式碼無法修改物件狀態
  • 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.ImmutableImmutableList
  • 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 解決方案:使用適當的時間資料結構#

各語言都有成熟的時間處理函式庫:

語言推薦方案
Javajava.time 套件
C#Noda Time
C++chrono 函式庫
JavaScriptjs-joda 等第三方函式庫

這些函式庫提供:

  • Instant vs Duration:型別本身就區分了時間點與時間量,不可能搞混
  • 單位封裝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) { ... }
}

DataLoggerDataLoader 都使用 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
  • 不僅要淺層不可變,還要深層不可變(使用防禦性複製或不可變資料結構)
  • 使用專屬型別取代過度泛化的基礎型別,讓型別系統為你把關
  • 資料和邏輯都應該有單一事實來源,避免重複定義導致不同步