兩種不同的「出事了」
走到這一階的此刻,你已經學會了檢查每一次呼叫、讀懂每個函式庫函式交還的回傳碼與 errno、並在每一條離開路徑上都做好清理。這些技能讓你擅長「察覺」某件事失敗了。而這篇要談的,是你一察覺就立刻浮現的那個問題:這是一個我「該處理」的失敗,還是一個代表「我的程式壞了」的失敗?這真的是兩件不同的事,而把它們當成同一件來對待,正是看起來穩健的程式最常出錯的方式之一。
可復原的錯誤(recoverable error),是世界因為某個「誰也沒錯」的理由,拒絕了你的請求。你呼叫 `open("/data/log.txt", O_RDONLY)`,它回傳 -1、把 errno 設成 ENOENT——那個檔案就是不在。你呼叫 `malloc(n)`,它因為機器記憶體耗盡而回傳 NULL。你想連上一個通訊端(socket),但網路斷了。這些都不代表你的程式碼錯了;它們代表「環境」沒有配合。你的程式早就預期這可能發生,所以它能合理地回應:換一個檔案、釋放一些記憶體再重試、告訴使用者伺服器連不上。整段期間,程式都停在一個神智清醒的狀態裡。
臭蟲(bug)則完全是另一回事。臭蟲是「你自己的邏輯內部」一個被違反的假設——是你的程式對自己許下、然後又毀棄的承諾。你解參考了一個某函式發誓絕不會回傳 NULL 的指標。你存取 `a[i]`,而 `i` 不知怎地長到超過了陣列長度。你解鎖了一個你從未上鎖的互斥鎖。當這些事之一發生時,那個失敗不是環境在說「不行」;它證明了程式對現實的模型「已經是假的」。而且關鍵在於,你通常「無法」合理地從中復原,因為你已經不知道自己其他的假設還有哪些成立。這兩者之間的界線,就是這整篇的主題:可復原的錯誤與臭蟲之分。
界線為何重要:處理、往上傳、或中止
這個區分不是為了哲學而哲學——它告訴你該「做什麼」。面對任何一個失敗,你恰好有三種回應,而哪一種才對,完全取決於它是哪一類失敗。這就是處理、往上傳、或中止的抉擇。當你正是那個「知道怎麼修」的層級時(重試、退而求其次、替換成預設值),你就處理這個錯誤。當你修不了、但你上面的呼叫者也許可以時,你就往上傳——你把一個乾淨的錯誤碼沿著堆疊往上傳,讓一個擁有更多脈絡的人去決定。而當「繼續下去就是一場謊言」時,你就中止:程式的不變式已經壞了,於是最安全的舉動,是趁傷害擴散之前,當下就大聲地停下來。
把這一切綁在一起的拇指法則是這樣:可復原的錯誤要「處理或往上傳」;臭蟲要「中止」。為什麼遇到臭蟲是中止、而不是一瘸一拐地走下去——這是這整階裡最反直覺的想法,所以讓它好好落地:一旦某個不變式被打破,你程式之後的每一行,都跑在「可能為假」的假設之上。一個在偵測到自己已被汙染後還繼續跑下去的程式,不會變得更穩健——它會變成一台「自信地產出錯誤答案」、或「無聲地把垃圾寫進你資料庫」的機器。停下來之所以是負責任的選擇,正是因為你已經失去了「推理接下來會發生什麼」的能力。
一個實作例子:同一個 NULL,兩種意義
陷阱在於:「同一個值」在某處是可復原的錯誤,在另一處卻是臭蟲。從 `malloc()` 回來的 NULL,是教科書式的可復原錯誤:堆積耗盡是一個環境事實,所以你檢查它並往上傳。但若是 NULL 作為一個「你的合約說過絕不會是 NULL」的函式「引數」傳進來,那就是呼叫者的臭蟲。把這兩個檢查並排來看,並注意它們是刻意寫成不一樣的。
// Recoverable error: out of memory is the world's no.
// We propagate a clean failure up to the caller.
char *dup_line(const char *src) {
char *buf = malloc(strlen(src) + 1);
if (buf == NULL) // expected: heap can be exhausted
return NULL; // PROPAGATE - caller decides what to do
strcpy(buf, src);
return buf;
}
// Bug: our contract says src is never NULL.
// If it IS, a caller broke the promise -> abort, do not 'handle'.
size_t safe_len(const char *src) {
assert(src != NULL); // invariant; firing here means a BUG
return strlen(src);
}那為什麼不乾脆在 `safe_len()` 裡寫 `if (src == NULL) return 0;`,圖個「安全」?因為那是在「藏起一個臭蟲」,而不是在「處理一個錯誤」。如果某個呼叫者傳了 NULL 進來,你的程式裡早就含有一個邏輯錯誤;回傳 0 會讓那個錯誤不被偵測地繼續走下去,在很遠的地方產出一個微妙地錯誤的答案,而那裡要找出它難上一百倍。這又是症狀與根本原因的鴻溝。那個會中止的斷言,給你一次「就在被破壞的合約處」的崩潰,附上一段指向有罪呼叫者的回溯——比一個「無聲地往下游流去」的錯誤長度,有用太多了。
未定義行為:那種連崩潰都不肯的臭蟲
在 C 裡有一個特別、危險的臭蟲類別,你必須在心裡把它單獨擺開:未定義行為(undefined behavior),簡稱 UB。讀過陣列尾端、解參考一個懸空指標、有號整數溢位——這些都「不是」程式會偵測並回報的那種錯誤。C 標準乾脆就不說會發生什麼,而那份「不說」並不等於「崩潰」。有時你得到一次乾淨的區段錯誤(segfault)。但更常見的是:什麼可見的事都沒有,程式讀到一個過時的值、繼續跑下去,汙染了某個你好幾小時都不會注意到的狀態。
把「UB 為何如此奸詐」說精確,因為關於它的那些溫和口號全是錯的。UB「不」代表「平台碰巧做什麼就是什麼」。它代表編譯器「被允許假設 UB 永不發生」,並依這個假設來最佳化。所以你寫的一個檢查,比方說一個放在你「已經」解參考 `*p` 「之後」的 `if (p != NULL) ...`,可能被最佳化器整個刪掉:它的推理是,既然當 `p` 為 NULL 時解參考 `*p` 會是 UB,那 `p` 就「必定」不是 NULL,於是那個檢查是死碼。這正是為什麼「同一份原始碼」的一個臭蟲,能在 `-O0` 下表現完美、卻在 `-O2` 下汙染記憶體——那個令人聞之色變的在我機器上明明就好好的臭蟲。UB 永遠是臭蟲;它從不是可復原的錯誤。
實務上的分類,以及 Rust 如何把界線寫死
當一個失敗跨進你的程式碼時,把它過一遍這個快速分流,來判定它是哪一類、該怎麼做:
- 問:就算程式碼完全正確,這也可能僅僅因為「環境說不行」而發生嗎?(檔案不存在、記憶體耗盡、網路斷線、使用者輸入有問題)。若是,它就是可復原的錯誤。
- 若它可復原:在這裡若你有「能修它」的脈絡就處理掉;否則就把一個乾淨的錯誤碼或哨兵值沿堆疊往上傳給「有脈絡」的呼叫者。
- 反過來問:這只有在「我自己的某個假設早已為假」時才可能發生嗎?(合約禁止的 NULL、超出尾端的索引、卡在不可能狀態的狀態機)。若是,它就是臭蟲。
- 若它是臭蟲:別試圖「處理」它。用斷言守護那個不變式,好讓它在源頭中止,並把這次中止當成一個「要在程式碼裡修掉的缺陷」,而不是一個「在執行期去復原」的狀況。
這整套紀律,在 C 裡是你必須親手強加的——語言不會提醒你。值得用對比來看看 Rust 是怎麼把這條界線直接烤進型別系統裡的。Rust 在語言層級就把這兩類分開:可復原的錯誤是 Result 型別的值,`Result<T, E>`,由函式回傳、且呼叫者被編譯器「強制」去處理;而臭蟲則觸發一個 `panic!`,它會展開(unwind)並中止——這是它版本的「快速失敗」。你沒辦法像在 C 裡忘了檢查回傳碼那樣,不小心忽略一個 `Result`、讓一個錯誤憑空消失。這是個真實、具體的好處。
但要誠實地把它捧穩,一如這整座階梯始終堅持的那樣。Rust 強制了「可復原與臭蟲」的這個形狀、並在安全程式碼裡讓你免於許多記憶體錯誤,但它並沒有廢除臭蟲——一個 `panic!` 正是 Rust 在承認「你的程式撞上了一個它無法復原的臭蟲」,而你照樣可以寫出一個「回傳了錯誤 Result」的邏輯錯誤。你在這裡學的這套分類,不是 Rust 的功能;它是一種「思考方式」,好的 C 程式設計師靠紀律去套用它,而 Rust 只是讓它「更難被忽略」罷了。學會這套思考,你在任何語言裡都能寫出更穩健的程式。