避免意外的驚訝#

程式碼通常以分層方式建構,高層依賴低層。工程師會根據程式碼契約(code contract)中顯而易見的部分——例如名稱、參數型別、回傳型別——建立對程式碼行為的心智模型。如果這個心智模型與程式碼的實際行為不符,就會產生令人不快的驚訝(surprise)。輕則浪費工程時間,重則引發災難性的 bug。

避免驚訝的核心原則是明確(explicit):如果函式有時不回傳值、有特殊情境需要處理,就應該讓其他工程師清楚知道這件事。

flowchart TD
A["工程師看到程式碼"] --> B["建立心理模型"]
B --> C["依據: 名稱, 型別, 慣例"]
C --> D{"實際行為符合心理模型?"}
D -- "Yes" --> E["正確使用程式碼"]
D -- "No" --> F["產生意外"]
F --> G["引入 Bug"]
G --> H["可能很久後才被發現"]

6.1 避免回傳 Magic Value#

Magic value 是一個符合函式正常回傳型別、但帶有特殊含義的值。最常見的例子是回傳 -1 來表示值不存在或發生錯誤。

問題:Magic value 容易導致 bug#

因為 magic value 屬於正常的回傳型別,呼叫者很容易在不知情的情況下把它當作正常值來使用。

  • 例如 User.getAge() 在使用者未提供年齡時回傳 -1,但函式簽名是 Int getAge(),看起來永遠會回傳有效年齡
  • 計算使用者平均年齡的 getMeanAge() 會將 -1 納入計算,產生看似合理但錯誤的結果
  • 即使有單元測試也可能抓不到問題——因為撰寫 getMeanAge() 的工程師根本不知道年齡可能不存在,自然不會測試這個情境

Magic value 的危害往往不易察覺:它不會造成程式崩潰,而是產生一個看起來合理但實際上錯誤的結果。如果被拿去做年度報告、財務計算,後果可能非常嚴重。

解法:回傳 null、Optional 或錯誤#

將值可能不存在這件事放進程式碼契約中顯而易見的部分

  • 使用 nullable 型別(如 Int? getAge())或 Optional<Int>
  • 這樣呼叫者在編譯時期就會被迫處理值不存在的情況
  • 如果需要區分「值不存在」和「發生錯誤」,可以使用第 4 章介紹的錯誤信號技術

nullable 回傳型別會增加呼叫者的負擔嗎? 答案通常是「會」,但代價是幾行額外的 null 檢查程式碼。相較於因為 magic value 導致的 bug,修復 bug 所需的時間與成本往往高出數個數量級。

Magic value 也可能意外產生#

工程師未必是刻意回傳 magic value,有時是因為沒有充分考慮所有輸入情境:

  • minValue(List<Int>) 使用 Int.MAX_VALUE 作為初始值,當輸入為空 list 時就會回傳 Int.MAX_VALUE
  • 在 maximin 演算法中,空分數清單的關卡反而被判為「最高最低分」,結果完全錯誤
  • Int.MAX_VALUE 在不同語言中數值不同(Java 的 Integer.MAX_VALUE vs JavaScript 的 Number.MAX_SAFE_INTEGER),跨系統時更容易造成混亂

更好的做法:對空 list 回傳 null 或 Optional,讓呼叫者明確知道結果可能無法計算。

flowchart TD
Q{"需要表示無值或失敗?"}
Q --> MV["Magic Value"]
Q --> NL["null"]
Q --> OP["Optional"]
Q --> EX["明確錯誤型別"]

    MV -.- MV_L["隱式, 容易被忽略"]
    NL -.- NL_L["顯式但易忘記檢查"]
    OP -.- OP_L["強制處理, 推薦"]
    EX -.- EX_L["最安全, 適合複雜情境"]

    style MV fill:#f4cccc,stroke:#cc0000
    style NL fill:#fce5cd,stroke:#e69138
    style OP fill:#d9ead3,stroke:#6aa84f
    style EX fill:#c9daf8,stroke:#3c78d8

    MV_L -.- SAFE["安全程度由低到高"]
    NL_L -.- SAFE
    OP_L -.- SAFE
    EX_L -.- SAFE


6.2 適當使用 Null Object Pattern#

Null object pattern 是回傳 null 的替代方案:不回傳 null,而是回傳一個會讓下游邏輯以無害方式執行的合法值。最簡單的形式是回傳空字串或空集合,更複雜的形式則是實作一個完整的類別。

回傳空集合可以改善程式碼#

當函式回傳集合(list、set、array)時,回傳空集合通常優於回傳 null:

  • 例如 getClassNames() 在元素沒有 class 屬性時回傳空 Set 而非 null
  • 呼叫者不需要做 null 檢查,程式碼更簡潔
  • 區分「屬性未設定」和「屬性為空字串」在多數情況下並無實質意義

回傳空字串有時會有問題#

空字串是否適當,取決於字串的語義用途

情境適合回傳空字串?原因
純文字集合(如使用者回饋的自由文字)適合空字串與未輸入沒有實質差異
字串作為 ID(如交易編號)不適合空字串會讓呼叫者誤以為永遠存在有效 ID
  • 例如 Payment.getCardTransactionId() 回傳空字串,工程師可能認為每筆支付都有卡片交易,導致會計資料不準確
  • 應回傳 null 讓呼叫者知道該支付可能沒有卡片交易

複雜的 Null Object 更容易造成驚訝#

當 null object 不只是空集合,而是一個帶有預設值的完整物件時,風險會大幅上升:

  • 例如 CoffeeMugInventory.getRandomMug() 在庫存為空時建構一個直徑 0、高度 0 的咖啡杯回傳——呼叫者收到看似有效的物件,但其實不是
  • 如同到電子商店買手機,店員給你一個密封的空盒子,而不是告訴你缺貨

Null object pattern 就像賣空盒子:如果呼叫者有任何可能對收到空盒子感到驚訝或困擾,就不應該使用這個模式。直接回傳 null,讓程式碼契約清楚表達「可能沒有有效值」。

Null object 的實作類別同樣有問題#

定義專門的 null object 實作類別(如 NullCoffeeMug)並不會真正改善問題:

  • 呼叫者可以用 instanceof NullCoffeeMug 檢查,但這種方式笨拙且不直覺
  • 不如直接用 null 檢查——這是更常見、更不意外的範式

隨著 null safety 和 Optional 的普及,安全地表示值不存在變得容易許多。Null object pattern 的許多原始論點在今天已不再那麼有說服力。


6.3 避免非預期的副作用#

副作用(side effect) 是指函式在被呼叫時,修改了自身以外的任何狀態。常見類型包括:

  • 顯示輸出給使用者
  • 儲存資料到檔案或資料庫
  • 呼叫其他系統產生網路流量
  • 更新或失效快取(cache)

副作用是撰寫軟體不可避免的一部分,但當副作用是非預期的,就會造成驚訝並導致 bug。

明顯且有意的副作用沒問題#

如果類別叫 UserDisplay、函式叫 displayErrorMessage(),更新畫布(canvas)是完全可預期的副作用。

非預期副作用的三大問題#

getPixel() 函式為例——它在讀取像素前偷偷呼叫了 canvas.redraw()

  1. 副作用可能很昂貴

    • captureScreenshot() 對每個像素呼叫 getPixel(),每次都觸發一次 redraw
    • 400 x 700 像素的畫面 = 280,000 次 redraw,每次 10ms = 約 47 分鐘的凍結與閃爍
  2. 破壞呼叫者的假設

    • captureRedactedScreenshot() 先刪除隱私區域再截圖,但 captureScreenshot() 內部又觸發 redraw,導致隱私遮蔽失效
    • 結果:使用者的個人資訊被洩漏在回報截圖中,是嚴重的隱私漏洞
  3. 多執行緒環境的 bug

    • 螢幕分享功能在另一個執行緒週期性截圖
    • 一個執行緒在 redraw 的同時另一個執行緒在讀取像素,產生不一致的資料
    • 多執行緒 bug 極難偵錯和測試

Figure 6.1: Code with side effects can often be problematic if it's ever run in a multithreaded environment.

解法:消除副作用或讓副作用顯而易見#

  • 首選:如果 canvas.redraw() 不是必要的,就直接移除
  • 次選:重新命名函式以反映副作用,例如 redrawAndGetPixel()

改名是一個非常簡單但極有效的改變:

  • 工程師看到 redrawAndGetPixel() 就知道它會觸發 redraw,不會在 for-loop 中呼叫數千次
  • 截圖函式也會被命名為 redrawAndCaptureScreenshot(),讓多執行緒使用者立即意識到需要加鎖

命名的力量:大多數取得資訊的函式不會造成副作用,工程師的自然心智模型也是如此假設。因此,有副作用的函式作者有責任透過命名讓這件事不可能被忽略。


6.4 小心修改輸入參數#

修改(mutate)輸入參數是一種特別常見的副作用來源。

問題:修改輸入參數可能導致 bug#

將物件傳給函式就像把書借給朋友——你期望借出後書還是完好的。如果函式修改了輸入參數,就像朋友撕掉頁面、在空白處亂寫一樣。

  • 例如 getBillableInvoices()userInvoices map 中移除免費試用使用者的項目
  • 呼叫者之後還需要用 userInvoices 來啟用服務,但 map 已經被改過了
  • 結果:免費試用使用者的服務不會被啟用

解法:先複製再修改#

將需要修改的資料複製到新的資料結構中:

List<Invoice> getBillableInvoices(
    Map<User, Invoice> userInvoices,
    Set<User> usersWithFreeTrial) {
  return userInvoices
      .entries()
      .filter(entry ->
          !usersWithFreeTrial.contains(entry.getKey()))
      .map(entry -> entry.getValue());
}
  • 使用 filter() 會將符合條件的值複製到新 list,不會修改原始 map

效能考量:複製會影響記憶體和 CPU 使用,但相較於修改輸入參數造成的驚訝和 bug,這通常是較輕的代價。如果必須就地修改(例如排序大量資料),應在函式名稱和文件中明確說明。


6.5 避免撰寫誤導性函式#

當程式碼契約中顯而易見的部分——尤其是函式名稱——與實際行為不符時,造成的驚訝比資訊遺漏更嚴重。

問題:關鍵輸入缺失時什麼都不做會造成驚訝#

如果函式接受 nullable 的關鍵參數,並在參數為 null 時靜默地什麼都不做,呼叫者會被誤導:

  • displayLegalDisclaimer(String? legalText)legalText 為 null 時直接 return
  • ensureLegalCompliance() 呼叫了 displayLegalDisclaimer(messages.getSignupDisclaimer())
  • 閱讀程式碼的工程師會認為「法律免責聲明一定會被顯示」
  • getSignupDisclaimer() 可能因為沒有當地語言翻譯而回傳 null
  • 結果:公司可能在未顯示法律聲明的情況下讓使用者註冊,違反法律

解法:讓關鍵輸入成為必要參數#

如果一個參數對函式的功能是關鍵的(沒有它函式就無法做到名稱所宣稱的事),就應該讓它成為非 nullable 的必要參數:

  • displayLegalDisclaimer(String legalText) 保證每次呼叫都會顯示免責聲明
  • 呼叫者被迫面對「可能沒有翻譯文字」的事實,必須自行處理
  • ensureLegalCompliance() 可以回傳 Boolean 表示是否成功確保合規,搭配 @CheckReturnValue 確保回傳值不被忽略

幾行額外的 null 檢查 vs 誤導性的 bug:將 null 檢查推給呼叫者會增加幾行程式碼,但消除了程式碼被誤解的風險。修復一個因誤導性程式碼產生的 bug,成本往往比多寫幾個 if-null 語句高出數個數量級。


6.6 面向未來的 Enum 處理#

先前的章節著重於確保依賴我們程式碼的人不會被驚訝。本節則關注我們依賴他人程式碼時可能遇到的驚訝——特別是 enum 可能在未來新增值。

問題:隱式處理未來 enum 值#

使用 if-statement 只明確處理部分 enum 值,會讓新增的值被隱式歸類:

  • isOutcomeSafe() 只明確處理 COMPANY_WILL_GO_BUST 為不安全,其他一律回傳 true
  • 日後 enum 新增 WORLD_WILL_END,由於不是 COMPANY_WILL_GO_BUST,會被判定為「安全」
  • 下游自動系統會執行一個預測會導致世界末日的商業策略

解法:使用窮舉式 switch 語句#

明確處理所有已知的 enum 值,並在遇到未處理的值時拋出例外:

Boolean isOutcomeSafe(PredictedOutcome prediction) {
  switch (prediction) {
    case COMPANY_WILL_GO_BUST:
      return false;
    case COMPANY_WILL_MAKE_A_PROFIT:
      return true;
  }
  throw new UncheckedException(
      "Unhandled prediction: " + prediction);
}

搭配遍歷所有 enum 值的單元測試,可以在新增 enum 值時立即發現需要更新的函式。

小心 default case#

switch 的 default case 會讓新 enum 值被隱式處理,應避免使用:

  • default: return false; 看似安全——但如果新增 COMPANY_WILL_AVOID_LAWSUIT,預設為不安全顯然不合理
  • default: throw new UncheckedException(...) 雖然會拋出例外,但在某些語言(如 C++)中會抑制編譯器對未窮舉 enum 的警告
  • 最佳做法:將 throw 放在 switch 語句之後而非 default case 中,保留編譯器警告作為額外保護層

依賴外部專案的 enum 時,可能需要更寬容的處理方式。如果外部專案可能不預先通知就新增 enum 值,而這會立即破壞我們的程式碼,則需要根據實際情況做判斷。


6.7 光靠測試就夠了嗎?#

有人主張只要測試做得好,就不需要在意「避免驚訝」。然而現實中,光靠測試不足以解決所有問題:

  • 其他工程師可能測試不夠徹底:他們可能不會測試到因為對你的程式碼有錯誤假設而產生的邊界情境
  • 測試不一定能精確模擬真實世界:使用 mock 時,工程師會按照自己認為的行為來設定 mock;如果真實程式碼有令人驚訝的行為,mock 就不會反映這一點
  • 某些問題極難測試:多執行緒相關的 bug 發生機率低,往往只在大規模執行時才會浮現

避免驚訝不只是確保自己的程式碼正確——更是要確保其他工程師基於你的程式碼所寫的程式碼也能正確運作。這是測試無法完全保證的事情。