第 6 / 11 部 待錄

把錯誤從定義中消除

《A Philosophy of Software Design 軟體設計的哲學》Podcast 準備稿:把錯誤從定義中消除

書名: 軟體設計的哲學 A Philosophy of Software Design 作者: John Ousterhout 系列: 旗艦評書(恩普拉氏) 公開標題建議: 評書|A Philosophy of Software Design.把錯誤設計掉:本書最反直覺、也最強的一招 (6/11) 涵蓋範圍: 第10章 把錯誤從定義中消除(例外、屏蔽、聚合、直接當掉、設計掉特殊案例)

背景速覽

這是全書最反直覺、也最容易讓聽眾「一聽就上癮」的一章。我們從小被教「要偵測並回報所有錯誤」,於是寫了一堆 try-catch、一堆 if err != nil。Ousterhout 卻說:降低例外複雜性最好的方法,是讓錯誤根本不存在——不是去處理它,而是重新定義 API,讓那個錯誤情境壓根不會發生。這聽起來像褻瀆,但他用 Tcl、Unix、Java、自己親手設計過的系統,證明它極其有效。這章也是「把複雜性往下拉」原則在錯誤處理上的集大成。

一句話重點

例外的複雜性不在「拋」,而在「處理」——而拋例外容易、處理例外難;所以對付它的最佳策略不是寫更多 handler,而是減少需要處理例外的地方:能把錯誤從 API 定義裡消除掉的就消除(讓錯誤情境變成「已完成」)、能在低層屏蔽的就屏蔽、能在高層聚合成一處的就聚合、實在處理不了又罕見的就乾脆當掉。

值得討論的重點

1. 為什麼例外處理特別難——而且是災難的主因

Ousterhout 先把問題講重:在大型、分散式、容錯系統裡,例外處理可能佔一大半的程式碼。難在哪?例外打斷正常流程,處理時系統狀態可能不一致(資料結構初始化到一半)、處理本身會引發二次例外(重送的封包變成重複封包)、語法笨重(光 try-catch 樣板比正常邏輯還多)。最致命的是他最愛的一句格言:「沒被執行過的程式碼,就是不會動的程式碼」——例外 handler 平常根本不跑,真到要用那天往往早就壞了。他引一份研究:分散式資料密集系統的災難性失敗,逾 90% 由錯誤的錯誤處理引起

2. 太多例外=逃避思考,而且例外是介面的一部分

很多人把「要偵測錯誤」誤讀成「偵測越多越好」,寫出過度防禦的 code。Ousterhout 拿自己開刀:他設計 Tcl 的 unset(刪變數)時,讓「變數不存在」拋例外,結果這是他「最大的失誤之一」——因為 unset 最常見的用途就是清理可能存在的暫存狀態,逼得大家在每個 unset 外面包 catch。金句兩條:「如果連你都不知道怎麼處理,呼叫端多半也不知道」(拋例外只是把難題推給別人);以及例外屬於介面——例外多、介面就複雜、類別就變淺,而且它還沿呼叫堆疊往上污染每一層。

3. 四種降低處理位置的技巧

這是本章骨幹,四招建議一招一招演:

  • 讓錯誤不存在(最佳):改 API 定義,把錯誤情境變成正常情境。unset 的修正版——從「刪除一個變數」改成「確保某變數不存在」,變數本來就不在?那工作已經完成,直接 return。Unix 的延遲刪檔(檔案開著時 delete 標記待刪、不報錯)一次消掉兩種錯誤。Java substring 越界就拋 IndexOutOfBoundsException,逼呼叫端自己 clamp;改成「回傳範圍內存在的字元(若有)」,API 同時變簡單又變強,方法更深(Python slice 早就這樣)。
  • 屏蔽例外:在低層偵測並處理,讓上層完全不知道。TCP 內部重送遺失封包,客戶端根本不知道丟過包。屏蔽=把複雜性往下拉(介面變窄、功能加深、類別變深)。
  • 例外聚合:用一份 code 處理一大堆例外,而不是每個都寫 handler。Web 伺服器讓 getParameter 的缺參數例外一路往上傳到 dispatcher,單一 handler 處理所有缺參數。聚合=用一個通用機制取代多個專用機制。
  • 直接當掉:罕見、難處理、處理也多半會失敗的錯誤,印診斷訊息然後 abort。記憶體耗盡是經典——malloc 失敗在現代系統幾乎都代表 bug,與其每次 check(漏一個就 null pointer 掩蓋真因),不如全走 ckalloc 統一當掉。

4. 設計掉特殊案例:不只錯誤,特例也能消失

既然錯誤能設計掉,特殊情況也能。編輯器的「無選取」案例最漂亮:多數學生用一個布林表示「目前有沒有選取」,結果每段處理選取的 code 都得先 check「沒有選取」這個特例,if 散滿地。正解是讓選取永遠存在,螢幕看不到時用「空選取」(起點=終點)表示——選取管理 code 完全不需要任何「無選取」檢查,複製空選取=插入 0 byte、刪除空選取自然重生原行。通則:能消除的特殊情況就消除,讓「正常情況」的設計自動涵蓋它。 這同時也是「不同層不同抽象」——「無選取」這概念在 UI 合理,但不必在內部也存在。

注意事項

⚠️ 「讓錯誤消失會不會藏起 bug?」——這是最常見的反駁,要正面回應。Ousterhout 的回答:多錯誤的 API 確實可能抓到一些 bug,但它本身增加複雜度,而複雜度會製造新的 bug(要寫額外 code 避開錯誤、忘了寫就 runtime 爆)。減少 bug 的最佳辦法是讓軟體更簡單。但這不是「吞掉所有錯誤」的許可證——

⚠️ 屏蔽會過頭。書裡的反例:某學生把所有網路例外都吞掉,結果用該模組的應用無法察覺訊息遺失或對端失效,根本沒法寫穩健的應用。判準和資訊隱藏一樣:該例外資訊在模組外不被需要時,才消除/屏蔽;外部需要時,就算介面變複雜也得暴露。

⚠️ 「直接當掉」要看應用。Ousterhout 自己強調:複本式儲存系統不能因為 I/O 錯誤就 abort——它必須用副本復原,那個復原機制(複雜但)正是這類系統的核心價值。「該不該當掉」取決於應用,不是萬用解。NFS 那個「hang 住不報錯」的設計甚至有爭議,講的時候可以誠實帶過。

專家補充

💡 對照線在這集很有料。Uncle Bob《Clean Code》第七章「錯誤處理」主張「用例外取代回傳碼」「別回傳 null、別傳 null」——方向是把錯誤處理寫得乾淨。Ousterhout 更進一步、也更根本:最乾淨的錯誤處理,是不需要處理。兩者其實互補不衝突——「別回傳 null」和「把錯誤設計掉、回傳定義良好的結果」根本是同一個精神(Null Object / Special Case 模式)。可以對聽眾說:Uncle Bob 教你把垃圾分類好,Ousterhout 教你一開始就少製造垃圾。

💡 「例外聚合」其實就是現代 Web 框架的 middleware / global error handler——Spring 的 @ControllerAdvice、Express 的 error middleware,全是「讓例外往上傳、在一處統一轉成回應」。Ousterhout 從第一原理把它推導出來,而不是當成框架特性背下來。可接到「框架的好設計,背後都有設計原則」。

💡 RAMCloud 的「錯誤升級」是個很有膽識的設計:偵測到單一物件損壞,乾脆崩潰整台伺服器走既有的崩潰恢復流程——因為恢復機制反正要做、又複雜,重用它能少寫一堆 code,還因為被觸發得更頻繁而更容易發現 bug。這呼應 Netflix Chaos Monkey 的哲學:讓恢復路徑常被走,它才會真的可靠。但代價是恢復成本高,只適合罕見錯誤。

討論問題

🎙️ 開場鉤子:「我們從小被教:要偵測錯誤、要處理例外。但有沒有可能,你寫的那一大堆 try-catch,大部分根本不該存在?今天這位史丹佛教授要教你一招最反直覺的——不是去處理錯誤,是讓錯誤根本不會發生。」

🎙️ 自問自答:「Java 的 substring 越界就丟例外,逼你自己 clamp 索引;Python 的 slice 越界就回空。哪一個 API 更好用?」——用這個對比帶出「把錯誤從定義消除,API 同時變簡單又變強」。

🎙️ 帶走的一題:「翻一個你最近寫的、會拋例外的方法。問自己:這個『錯誤情境』,能不能透過重新定義這個方法該做什麼,讓它變成一個正常的、定義良好的結果?比如『刪除』改成『確保不存在』。」

更大範圍關聯

  • 與《Clean Code》對照:本集與 Uncle Bob 第七章「錯誤處理」互補——「把錯誤設計掉」是「別回傳 null/別傳 null」的升級版,對應 Null Object / Special Case 模式(Fowler《PoEAA》有完整型錄)。延伸 [軟體工程領域切分藍圖] §5。
  • 可靠性工程:「90% 災難來自錯誤處理」「讓恢復路徑常被走」呼應 SRE 與 Chaos Engineering(Netflix Chaos Monkey)的核心信念;TCP 屏蔽封包遺失是分散式系統的經典抽象。
  • 語言設計史:Ousterhout 是 Tcl 作者,他拿自己的設計失誤現身說法,這條「語言/API 設計者的反省」可接到 Go 的 error 哲學爭議、Rust 的 Result/Option——錯誤該怎麼進型別系統,是當代語言設計的大辯論。

錄製建議

  • 建議時長:約 24–26 分鐘(本章厚,內容多)。配比:為何例外難+太多例外約 7 分、四招(消除/屏蔽/聚合/當掉)約 13 分、設計掉特殊案例約 5 分。
  • 討論策略:開場用「你的 try-catch 大半不該存在」製造衝擊;四招按「消除、屏蔽、聚合、當掉」順序講,每招配一個鮮明案例(unset、TCP、Web dispatcher、malloc),讓聽眾記住「降低處理位置」這條主線;結尾用「無選取」案例把原則推廣到特殊情況,並提醒「該露就露」的平衡。
  • 這章金句密度高(「拋例外容易處理難」「沒跑過的 code 就是壞的 code」「連你都不會處理呼叫端也不會」),可以多引用,很適合做節目的記憶點。