錯誤處理很重要,但如果它讓主要邏輯變得模糊不清,那就是錯誤的做法。整潔的程式碼應該把「處理錯誤的邏輯」和「正常的業務邏輯」分成兩件事——用例外取代錯誤碼,讓主流程讀起來仍像一段乾淨的故事,而不是一連串
if (err != OK)的巢狀檢查。
🧠 Why It's a Problem
- 錯誤碼會污染主流程:早期語言用回傳錯誤碼來報告異常,逼呼叫端在每次呼叫後立即檢查,主邏輯於是塞滿
if (err != OK)的巢狀結構——業務意圖被錯誤檢查淹沒。用例外把這些檢查移到專門的catch區塊,主邏輯才能保持乾淨。 - 錯誤處理本身就是「一件事」:一個函式若同時做正事又處理錯誤,就做了不只一件事——這正是 只做一件事 的一個切面。把錯誤處理隔離成獨立的關注點,兩邊都更好讀、好改。
- try 是一種交易:
try區塊某種程度上像一筆交易——無論裡面發生什麼,catch都應讓程式回到一致的狀態。所以撰寫可能拋例外的程式碼時,先寫try-catch-finally,它會逼你先定義「出錯時呼叫端該預期什麼狀態」。 - null 是給呼叫端製造工作:回傳 null 等於強迫每個呼叫端寫 if 檢查,只要漏掉一個,應用程式就失控;把 null 當參數傳進去更糟,會讓函式內部長滿無意義的防禦性檢查。
NOTE
這頁講的是 Clean Code 的立場:用例外、特例物件與空集合,把錯誤處理寫得乾淨。另有一條互補但不同的思路——把錯誤設計到不存在(Ousterhout),主張從介面設計上根本減少會拋出的例外數量。兩者角度不同,可對照參看。
⚖️ Case Study
前 — 錯誤碼把主流程壓成巢狀檢查
// 每個回傳值都可能是錯誤碼,呼叫端被迫層層檢查
function sendShutdown(deviceId) {
const handle = getHandle(deviceId);
if (handle !== DEVICE_HANDLE.INVALID) {
const record = retrieveDeviceRecord(handle);
if (record.status !== DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.error("Device suspended. Unable to shut down");
}
} else {
logger.error("Invalid handle for: " + deviceId);
}
}正事(暫停、清佇列、關閉)被埋在錯誤檢查的縮排裡,讀者得撥開一層層 if/else 才找得到主流程。
後 — 例外把錯誤處理抽離,主邏輯浮上來
// 主流程:只講「做什麼」,一眼讀完
function sendShutdown(deviceId) {
try {
tryToShutDown(deviceId);
} catch (e) {
logger.error(e.message);
}
}
// 正事獨立成一件事,該失敗就拋
function tryToShutDown(deviceId) {
const handle = getHandle(deviceId);
const record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}錯誤處理與正常邏輯各自成段:tryToShutDown 只管關機的三步,catch 只管出錯後的收拾。這正是「只做一件事」——把錯誤處理當成獨立的關注點。
TIP
拋例外時要帶上情境:不要只丟一個 stack trace,訊息裡應包含「失敗的操作意圖」與「失敗時的相關數據」,讓維護者能判斷錯在哪、為什麼錯。
在封裝第三方 API 時,還要依呼叫端的需求定義例外類別:別把底層的 SocketTimeout、ConnectionRefused 一股腦拋給上層,而是包裝(wrap)成對呼叫端有意義的通用例外(例如 PortDeviceFailure)。這既簡化了呼叫端要依賴的例外種類,也讓日後替換底層 library 更容易。
前 — 回傳 null,呼叫端布滿防禦性檢查
// 找不到就回 null,並把 null 傳給下游
function getMeals(employeeId) {
return mealRepository.find(employeeId); // 可能是 null
}
let total = 0;
const meal = getMeals(employeeId);
// 每個呼叫端都得記得檢查——漏一個就爆
if (meal !== null) {
total += meal.getTotal();
} else {
total += getPerDiemDefault();
}只要有一個呼叫端忘了那個 if (meal !== null),執行期就會炸。null 把「處理缺席」這件事外包給了每一個呼叫端。
後 — 特例物件 / 空集合,讓正常流程順下去
// 找不到就回一個「特例物件」,它自己知道預設值
function getMeals(employeeId) {
return mealRepository.find(employeeId) ?? new PerDiemMeal();
}
// 呼叫端不必知道有沒有例外,流程一路順下去
const total = getMeals(employeeId).getTotal();
// 回傳集合時,永遠回空陣列而非 null
function getEmployees(dept) {
const found = employeeRepository.findByDept(dept);
return found ?? []; // 呼叫端可以直接 for...of,不需先判 null
}用特例模式(Special Case Pattern) 把「缺席」封裝成一個行為正常的物件,用空集合取代 null 回傳——呼叫端不再需要特判,主流程也不再被 null 檢查切碎。
WARNING
如果程式碼裡布滿 if (item != null),通常代表 API 設計有問題,或這段程式的錯誤處理規劃不足——別急著再補一個 null 檢查,該回頭看介面。
🔑 Takeaways
- 例外優於錯誤碼:把錯誤檢查移到
catch,主邏輯才不會被if (err != OK)的巢狀壓垮。 - 先寫
try-catch-finally:try像一筆交易,先寫它能逼你定義出錯時該維持的一致狀態;在 Java 這類語言優先用 unchecked exceptions,避免 checked exception 破壞封裝與開放閉合原則。 - 給例外情境、依呼叫端定義例外:訊息帶上失敗意圖與數據;封裝第三方 API 時把底層例外包裝成對呼叫端有意義的類別。
- 別回傳 null、別傳遞 null:用特例模式與空集合取代,把「處理缺席」收在一處,而不是外包給每個呼叫端。
- 本質上都是 只做一件事——把錯誤處理當成獨立的關注點隔離出去;若想從設計端根本減少例外,可對照 把錯誤設計到不存在。
No notes yet — jot your takeaways or Q&A here.