讓錯誤不存在 (Define Errors Out of Existence)

★★★★Draft

FromA Philosophy of Software Design

📚 From the Books

讓錯誤不存在 (Define Errors Out of Existence)Complexity

例外處理是軟體最大的複雜性來源之一——真正貴的不是「拋」,而是「處理」。Ousterhout 的核心藥方:減少必須處理例外的位置數量。而其中最徹底的一招,是重新定義 API 的語意,讓那個「錯誤情境」根本不存在,也就沒有東西需要回報了。

🧠 Why It's a Problem

NOTE

作者的自省:Tcl 的 unset 最初定義成「變數不存在就拋例外」,因為他認為刪一個不存在的變數一定是 bug。但 unset 最常見的用途之一,正是「清理可能存在的暫存狀態」——結果開發者被迫在每個 unset 外面包 catch 吞掉錯誤。他稱這是 Tcl 設計中最大的失誤之一。

⚖️ 設計對照(會拋 vs. 把錯誤消除)

關鍵是調整操作的語意:把「義務」重寫成一個「正常情況已涵蓋所有情境」的定義,那個錯誤條件就自己消失了。

unset — 「刪除變數」 vs. 「確保變數不存在」
// 舊定義:unset = 「刪除一個變數」。變數不存在 → 工作做不了 → 必須報錯。
function unset(scope, name) {
  if (!(name in scope)) {
    throw new Error(`can't unset "${name}": no such variable`);
  }
  delete scope[name];
}

// 呼叫端被迫在每個清理點吞掉錯誤
for (const name of maybeCreated) {
  try {
    unset(scope, name);
  } catch (_) {
    /* 只是想清乾淨,錯誤要吞掉 */
  }
}

只是把可能存在的暫存變數清一遍,卻要處處包 catch。錯誤是設計出來的。

unset — 語意改成「確保不存在」,錯誤情境消失
// 新定義:unset = 「確保某變數不存在」。
// 變數不存在 → 工作已經完成 → 直接回傳,什麼都不做。
function unset(scope, name) {
  delete scope[name]; // 不存在也無妨
}

// 呼叫端變乾淨:沒有任何錯誤情境要處理
for (const name of maybeCreated) {
  unset(scope, name);
}

一個字都沒改實作邏輯的難度,只調整了「定義的義務」,那個「錯誤」就不再存在——無需任何 catch

substring — 索引越界就拋 vs. 定義成「重疊的部分」
// 會拋版:任一索引越界 → 例外。呼叫端想抓「與範圍重疊的部分」時,
// 得自己先 clamp 索引,一行 API 膨脹成 5~10 行。
function substring(str, begin, end) {
  if (begin < 0 || end > str.length || begin > end) {
    throw new RangeError("index out of bounds");
  }
  return str.slice(begin, end);
}

// 呼叫端被迫防禦
const b = Math.max(0, Math.min(begin, str.length));
const e = Math.max(b, Math.min(end, str.length));
const part = substring(str, b, e);

因為 API 會拋,防禦責任被推給每一個呼叫端。

substring — 「回傳範圍內存在的字元」,越界不再是錯誤
// 定義成:「回傳所有索引 >= begin 且 < end 的字元(如果有)。」
// 無論索引是否為負、begin > end,行為都已定義良好。
function substring(str, begin, end) {
  const b = Math.max(0, begin);
  const e = Math.min(str.length, end);
  return b < e ? str.slice(b, e) : "";
}

const part = substring(str, begin, end); // 呼叫端不必 clamp

API 同時變簡單又更——方法變得更深。Python 的 list slicing 早就是這個模式:越界只回空。

TIP

除了「重新定義語意」,本章還有另外三招減少處理位置,效果都是把處理碼放在「能抓到最多例外」的地方:

  • 屏蔽例外(exception masking):在低層攔截並處理,讓上層完全不必知道。如 TCP 內部重送遺失封包、NFS 重試到伺服器恢復。這是「把複雜性往下拉」的範例——介面變窄、功能加深。
  • 例外聚合(exception aggregation):用一份程式碼處理大量例外。如 web 伺服器讓缺參數的例外一路傳到 dispatcher,由單一 handler 產生錯誤回應,而不是每個 getParameter 各包一個 try/catch。相當於用一個通用機制取代多個專用機制。
  • 直接當掉:對困難、罕見、且處理多半也會失敗的錯誤(如記憶體耗盡),印診斷訊息後 abort。C 的做法是全部走 ckalloc(失敗即 abort),應用永不直接呼叫 malloc

CAUTION

別過度應用。消除或屏蔽例外,只在「該例外資訊在模組外不被需要」時才合理。曾有學生團隊把所有網路例外都吞掉,結果應用無法察覺訊息遺失或對端失效——這種重要資訊必須暴露,即使會增加介面複雜度。

🔑 Takeaways

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading