例外處理是軟體最大的複雜性來源之一——真正貴的不是「拋」,而是「處理」。Ousterhout 的核心藥方:減少必須處理例外的位置數量。而其中最徹底的一招,是重新定義 API 的語意,讓那個「錯誤情境」根本不存在,也就沒有東西需要回報了。
🧠 Why It's a Problem
- 處理比拋出難得多:拋例外只是一行;處理例外要嘛往前繞過(重送、回復),要嘛終止並向上回報——後者還得先把可能半初始化的狀態還原(unwinding)。二次例外(cascading)通常比一次例外更微妙。
- 例外難以驗證:「沒被執行過的程式碼,就是不會動的程式碼。」I/O 錯誤這類例外在測試與運行中都極少發生,處理碼幾乎沒被跑過,真的需要時很可能早就壞了。
- 開發者傾向定義過多例外:把「應該偵測並回報錯誤」誤解成「偵測越多越好」,任何可疑的東西都拋。拋例外常是「逃避思考」——但若連你都不知道怎麼處理,呼叫端多半也不知道。
- 例外是介面的一部分:一個類別會拋的例外屬於它的介面,而且沿著呼叫堆疊向上傳播,不只影響直接呼叫者。例外越多,介面越複雜、類別越淺。
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); // 呼叫端不必 clampAPI 同時變簡單又更強——方法變得更深。Python 的 list slicing 早就是這個模式:越界只回空。
TIP
除了「重新定義語意」,本章還有另外三招減少處理位置,效果都是把處理碼放在「能抓到最多例外」的地方:
- 屏蔽例外(exception masking):在低層攔截並處理,讓上層完全不必知道。如 TCP 內部重送遺失封包、NFS 重試到伺服器恢復。這是「把複雜性往下拉」的範例——介面變窄、功能加深。
- 例外聚合(exception aggregation):用一份程式碼處理大量例外。如 web 伺服器讓缺參數的例外一路傳到 dispatcher,由單一 handler 產生錯誤回應,而不是每個
getParameter各包一個 try/catch。相當於用一個通用機制取代多個專用機制。 - 直接當掉:對困難、罕見、且處理多半也會失敗的錯誤(如記憶體耗盡),印診斷訊息後 abort。C 的做法是全部走
ckalloc(失敗即 abort),應用永不直接呼叫malloc。
CAUTION
別過度應用。消除或屏蔽例外,只在「該例外資訊在模組外不被需要」時才合理。曾有學生團隊把所有網路例外都吞掉,結果應用無法察覺訊息遺失或對端失效——這種重要資訊必須暴露,即使會增加介面複雜度。
🔑 Takeaways
- 例外的複雜性來自處理碼,不是拋出;降低傷害的總原則是減少必須處理例外的位置數量。
- 最徹底的一招是重新定義 API 語意,讓正常情況涵蓋所有情境,錯誤條件自然消失(
unset改成「確保不存在」、substring改成「回傳範圍內存在的字元」)。 - 消不掉的例外,就往「抓得最多」的地方集中:低層屏蔽、高層聚合;真的無解的罕見錯誤就直接當掉。
- 「消除錯誤」不會養出更多 bug——反而讓 API 更簡單、要寫的程式碼更少。減少 bug 的最佳辦法就是讓軟體更簡單。
- 判準永遠是「什麼重要、什麼不重要」:不重要的越藏越好,重要的(如網路失效)必須暴露。
No notes yet — jot your takeaways or Q&A here.