JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

寫出能優雅失敗的程式

在此之前的四篇,各交給你一件工具。這最後一篇談的是品味:何時該彎、何時該斷,以及如何打造一支即使周遭世界崩塌、仍然值得信任的程式。

從工具走向品味

你來到這一篇時,整套工具箱早已在手。你會讀回傳碼與 errno,也會檢查每一次呼叫。你能用 goto 清理在每一條路徑上善後,也認識了把清理綁進作用域的 RAII 觀念。你能用斷言守住自己的假設,而最重要的是,你能分辨可復原錯誤與臭蟲。這最後一篇不再交給你第五件工具。它教的是決定該伸手拿哪一件的判斷力,因為優雅地失敗不是某一種技巧——它是為每個情境挑出正確失敗方式的藝術。

讓這篇變得必要的,是一個令人不安的事實:永不失敗的程式並不存在。磁碟會滿、網路會斷、檔案會在讀到一半時消失、配置器會回傳 NULL、使用者會打進一堆胡言亂語。問題從來不是「會不會出錯?」——而是「當出了錯,我的程式做什麼?」脆弱的程式是用意外來回答這個問題:它崩潰、把檔案弄壞、或悄悄產出一個錯誤答案。穩健的程式則是刻意回答它,用一種你事先選定的方式。這份刻意的選擇,正是這裡的全部主題。

光譜:快速失敗 vs 優雅降級

每一個錯誤都把你帶到一個有兩條具名岔路的分叉口,你已在 快速失敗 vs 優雅降級這條光譜上見過它們。快速失敗的意思是:一發現不對勁就停下,盡可能貼近根因,趕在壞狀態擴散之前。優雅降級的意思是:以縮減的功能繼續運轉——資料庫掛了就送出快取頁面、退回較低解析度的影像、丟掉一幀而不是讓影片凍住。兩者都不是放諸四海皆準。技藝在於判斷情境指向哪一邊,而那個關鍵問題你早就學過:這是可復原錯誤,還是臭蟲?

把它清楚地對應起來。臭蟲——一條被違反的不變式、一個本不可能出現的空指標、一個越過陣列尾端的索引——代表你對現實的模型已經壞了;繼續下去只會讓毀損蔓延,所以你快速失敗:斷言、中止、傾印核心、修程式碼。可復原錯誤——open() 因檔案不存在而回傳 -1、malloc() 因記憶體吃緊而回傳 NULL、recv() 收到連線重置——是世界在對你說不,而不是你的邏輯出錯;這時你可以處理它、重試、或降級。對臭蟲而言正確的那次崩潰,對一個不存在的檔案而言卻是錯誤的回應。

  recoverable error (the world said no)        bug (your model is broken)
  ----------------------------------           ---------------------------
  open() == -1, errno == ENOENT                 invariant violated
  malloc() == NULL                              array index out of range
  recv() == 0 (peer closed)                     impossible enum value
         |                                              |
         v                                              v
  handle / retry / degrade  --------------->   assert -> abort -> fix code
  (keep running, tell the user)                (fail fast, protect data)
兩欄、兩種回應。把每個失敗分進正確的那一欄,是這篇裡最重要的一個判斷。

處理、向上傳遞、或中止

當一個可復原錯誤落到你手上,你恰好有三個誠實的選擇,而把它們命名出來,就能把模糊的焦慮變成一張檢查清單。這就是處理、向上傳遞、或中止的抉擇,而你會在錯誤經過的每一層做這個決定。處理它:你在此處就握有足夠的脈絡來修正情境——檔案不存在,那就建立它;連線斷了,那就重連。向上傳遞它:你的脈絡不夠,於是把錯誤往上回傳給一個握有脈絡的呼叫者,並在離開時清理掉你自己的資源。中止:這個錯誤揭示了繼續下去不可能或不安全,於是你停下整支程式。

深層的洞見是:這是一個逐層的決定——同一個錯誤,由第一個看見它的低階函式向上傳遞,再由握有脈絡的高階函式來處理。一個讀取設定檔的函式無從得知檔案不存在是否致命——也許呼叫者有預設值——所以它向上傳遞。呼叫它的頂層命令決定,因為它知道使用者的意圖。這正是錯誤傳遞,而做對它,意味著每一層只做它有知識去做的事,不多做。「吞掉」錯誤這個臭蟲——把它接住、再假裝它沒發生——正是下一則提示要警告的對象。

清理在每一條路徑上都沒得商量

穩健的程式正是在這裡悄悄與脆弱的程式分道揚鑣:它在錯誤路徑上也釋放它取得過的東西,不只在順利路徑上。當你把錯誤往上、往外傳遞時,你必須先交還你正握著的每一項資源——打開的檔案描述符、malloc 來的緩衝區、握著的互斥鎖——否則每一次失敗都會多漏一點點,直到程式餓死。這正是第二篇用 goto 清理來教資源清理的緣由:一條帶標籤的單一離開路徑,以取得的相反順序釋放,從每一個錯誤分支都通向它。

這裡也是 RAII 觀念作為更乾淨答案而展現價值之處。在 C 裡,你靠 goto 清理用手扛著這份紀律;一旦某個函式有三項資源、五個提早 return,記帳就變得容易出錯,而在某個罕見分支上漏掉的一個 free(),就是一個只在高負載下才現形的洩漏。RAII 把每項資源的釋放綁進一個作用域,於是不論以任何途徑離開該作用域——正常 return、錯誤 return、甚至 C++ 裡的例外——都會自動執行清理。語言不再仰賴你記得。記住這個對比:在 C 裡,清理是一個你絕不能丟掉的習慣;在 RAII 語言裡,它是編譯器強制的保證。

故障注入:證明錯誤路徑真的有效

錯誤處理有個不可告人的祕密:當 malloc() 回傳 NULL 或 open() 失敗時才會跑的程式碼,在測試中幾乎從不執行,因為那些失敗很罕見。於是錯誤路徑——正是你為了讓程式優雅而寫的那些路徑——成了整支程式裡最少被測到的程式碼,靜靜地長著你從未見它執行過的臭蟲。故障注入就是解藥:你刻意那些罕見失敗按需發生,好親眼看著你的復原程式碼真正跑起來。它把「我想這應該能處理磁碟滿了」變成「我把磁碟弄滿、看著它處理掉了」。

  1. 挑一個你聲稱有處理的失敗——比方說記憶體耗盡時 malloc() 回傳 NULL。找出你為它寫的復原程式碼。
  2. 刻意逼出那個失敗。把配置包進一個測試接縫,讓它在比如第 5 次呼叫時回傳 NULL;或在一個會讓某次配置失敗的工具下執行;或乾脆暫時改那個呼叫,讓它回傳錯誤值。
  3. 執行它、看真正發生了什麼——而不是你以為會發生的。復原分支有執行嗎?它有沒有漏掉你緊接在前面配置的緩衝區?它是回傳一個乾淨的錯誤,還是去讀了已釋放的記憶體?
  4. 修好任何壞掉的地方,然後把這個注入的失敗自動化成一個永久測試,好讓錯誤路徑在每一次建置都被操練到,而不只是你想起來試它的那一天。

故障注入與另一個值得刻意設計的特性天然相配:冪等性。一個操作若做兩次的效果與做一次相同,它就是冪等的——把一個旗標設為 true 是冪等的;往日誌追加一行則不是。這之所以重要,是因為優雅的復原往往意味著重試,而重試唯有在它所重複的操作可以安全重複時,才不再危險。如果 recv() 逾時、你重送一個已經刷過信用卡的請求,這次重試就把卡刷了兩次。把你打算重試的操作設計成「重複也無害」,而故障注入正是你用來證明那次重試真的如預期運作的方法。

全貌,以及關於 Rust 的一句老實話

退一步看,這五篇便扣合成一套單一的紀律。檢查每一次呼叫,讓沒有任何失敗被漏看。為每個失敗分類:可復原錯誤,還是臭蟲?臭蟲就快速失敗——斷言、中止、修正。可復原錯誤則被處理、傳遞、或中止,而無論你選哪一個,離開時都把每一項資源清理乾淨。注入那些罕見失敗,好讓復原程式碼是真的、而非一廂情願;並讓重試的操作冪等,好讓復原不會重複扣款。這條單一的鏈——察覺、分類、回應、清理、證明——就是「優雅地失敗」在實務上真正的意思。它不是一種感覺;它是套用在你寫的每一次呼叫上的五個具體習慣。

值得老實說:這當中有多少在 C 裡純粹是手工紀律。上面每一個習慣,都是語言任由你忘掉的——沒有任何編譯器檢查會確認你測過某條錯誤路徑、在每個分支都釋放了、甚至連看一眼回傳值都沒有。這正是促成像 Rust 這類語言的壓力,而你會在這座梯子的頂端遇見它。Rust 把這當中幾個習慣烤進了型別系統:它的錯誤故事逼你先承認一個可能失敗的結果,才能取用裡頭的值;而它的所有權模型在一個值離開作用域時自動執行清理——這是被提升為全語言層級保證的 RAII 觀念。