錯誤處理與穩健性

錯誤傳播與錯誤碼(error propagation and an error code)

想像一排人沿著一架高梯把訊息往上傳。底部的工人發現一個他修不了的問題——一根裂掉的樑——於是往上喊。上面每個人把它往上轉達,直到傳到工頭那裡,工頭才是擁有權限與宏觀視野、能決定該怎麼辦的那個人。錯誤傳播正是如此:當一個函式碰到一個它無法、或不該自己處理的失敗時,它把失敗向上傳給呼叫它的人,以此類推,直到傳到真能做出合理決定的程式碼為止。

在 C 裡,這個機制建立在回傳值上。一個失敗的低階函式回傳一個示意失敗的值——像 -1 或 NULL 這樣的哨符,或一個特定的錯誤碼。它的呼叫者檢查那個回傳值,若無法在這裡處理問題,就轉而向它自己的呼叫者回傳一個失敗。失敗便沿著呼叫鏈一路往上漣漪。錯誤碼正是你用來攜帶「是哪一種失敗」、而不只是「發生了一個失敗」的方式:與其回傳一個光禿禿的 -1,函式可以回傳一個來自具名錯誤碼列舉的值——例如一個 enum,含 OK = 0、ERR_NOT_FOUND、ERR_PERMISSION、ERR_OUT_OF_MEMORY——這樣每一層都能要麼對某個特定種類做反應、要麼原封不動地把它傳下去。POSIX 的 errno 是這個想法的一種共享變體;回傳一個列舉或一個輸出參數是另一種;較豐富的語言用一個攜帶「值或結構化錯誤」的 Result 型別,並提供語法(Rust 的 ? 運算子)在你不處理時自動傳播錯誤。關鍵的紀律是:傳播是一個刻意的選擇,不是忘記:你看了那個失敗、判斷這一層不是處理它的合適地方、然後乾淨地把它往上傳——關鍵是,在退出途中釋放這一層取得的任何資源,好讓傳播不會洩漏。

為何重要:傳播讓「對一個錯誤的決定」能在對的層級做出。那個開設定檔的函式知道怎麼開檔,卻完全不曉得一個缺失的設定該讓程式中止、退回預設值、還是提示使用者——只有更高層的程式碼知道。傳播把失敗帶到那份知識所在之處。誠實的提醒:一條很長的傳播鏈可能丟失脈絡(在十二層之上的一個光禿禿的「錯誤碼 5」幾乎沒告訴你它從哪來),這正是為什麼好的錯誤型別會在旅途中附上脈絡或訊息;而傳播也並非沒有義務——每一層把錯誤往上傳的同時,仍必須在途中清理自己的資源,否則你就在錯誤路徑上得到一個洩漏,而那是 C 裡最常見的健壯性臭蟲之一。

enum err { OK = 0, ERR_NOMEM, ERR_IO }; enum err load(const char *path, struct doc out) { FILE *f = fopen(path, "r"); if (f == NULL) return ERR_IO; struct doc *d = malloc(sizeof *d); if (d == NULL) { fclose(f); return ERR_NOMEM; } /* ...; */ *out = d; fclose(f); return OK; }——每個失敗都回傳一個具名的碼,並注意 ERR_NOMEM 回傳前的 fclose(f),好讓錯誤路徑不會把檔案洩漏掉。

一個具名的錯誤碼沿呼叫鏈往上傳——而每一層在回傳時都做了清理。

傳播一個錯誤並不免除你的清理責任:一個回傳失敗的層仍必須釋放它取得的資源,否則你就在錯誤路徑上洩漏。而一個遠離其來源的光禿禿的碼會丟失脈絡,所以在有幫助之處替它附上(哪個檔案、哪次呼叫)。

又稱
passing errors up the call chainerror codes and enums錯誤傳播