兩種不同的「出事了」
這個階級的前兩篇導引,談的是世界丟給你的錯誤。你學會了讀回傳碼、在每次呼叫後查看 errno、以及用 goto-cleanup 或 RAII 的概念來清理。這一切處理的,都是一種「不幸但正當」的情況:磁碟滿了、檔案不見了、malloc() 回傳了 NULL。程式問了一件合理的事,而答案是「不行」。這篇導引談的,是一種根本不同的怪獸——當程式自己的邏輯出錯時、當一個你「確信」的假設原來是假的那一刻。
這裡有一條清晰的界線,也是這篇導引裡最重要的單一概念。可復原錯誤是一支正確的程式身上「正當會發生」的事:壞掉的輸入、不見的檔案、記憶體不足。你預期它、你處理它、你繼續走。臭蟲則是你自己程式碼裡一個破碎的假設:某個函式收到了一個它文件上寫明「絕不可能為 NULL」的 NULL 指標、一個計數器變成了負數、一個索引衝出了緩衝區的尾端。一支正確的程式「絕不會」做這些事,所以當其中一件發生時,你對這支程式的理解就已經失效了。這就是可復原錯誤對臭蟲的核心,而斷言,正是為了第二種而打造的工具。
assert() 實際上在做什麼
斷言是對「某個點上必須為真之事」的一條可執行宣告。在 C 裡它以巨集 assert() 的形式來自標準函式庫,宣告在標頭檔 assert.h 裡。你寫 assert(expr),其中 expr 是任何你相信在那裡永遠為真的條件。執行期間這個巨集會對 expr 求值。如果它非零(為真),什麼事也不會發生——執行就直接流過,彷彿那一行不存在。如果它是零(為假),這個斷言就「開火」了:程式印出一段訊息,指名那個失敗的運算式、原始檔、與行號,接著呼叫 abort() 立刻終止這個行程。
想像一個函式 `int divide(int a, int b)`,它引入了 `assert.h`、並在函式本體開頭、做 `return a / b;` 之前,先寫一行 `assert(b != 0);`。我們是在主張「除數為零是一個臭蟲」——它絕不該執行到這裡。只要 b 非零,那一行就是隱形的、幾乎不花任何成本。但 b 以零的身分抵達的那一天,程式立刻停住、印出類似 `a.out: prog.c:4: divide: Assertion 'b != 0' failed.` 的訊息,然後中止。一次本會是未定義的除以零,被轉換成了一次乾淨、有名字、有定位的停止。
注意那段訊息為了一行程式碼給了你多少東西。它指名了那個失敗的「確切運算式」(`b != 0`),所以你知道的不只是「有東西壞了」,而是「究竟哪一個信念是錯的」。它指名了檔案與行號,於是你直奔那個點。而因為 abort() 通常會留下一份核心傾印,你可以把那具屍體載入除錯器,檢視那個不可能發生的當下、每一個變數的值。這就是最純粹形式的快速失敗哲學:在你的世界模型破碎的第一個跡象出現時就停下,趁證據還溫熱,而不是跛著腳走下去、稍後在一個與真正成因毫無關係的堆疊框架裡崩潰。
不變量:一段程式碼賴以為生的真理
當你用斷言來釘住不變量時,它就變得強大——所謂不變量,是指「在某個特定地方、每一次都應該成立的條件」,無論程式是走哪條路徑到達那裡的。最乾淨的例子是一個「資料結構不變量」。假設你寫了一個動態陣列,有三個欄位:一個指向緩衝區的指標、一個長度(有幾個項目正在使用)、和一個容量(能裝下幾個)。有一條安靜的真理必須永遠成立:長度絕不大於容量。如果它哪天大於了,就有東西汙染了這個結構。你可以把那條真理,做成每一個碰觸這個陣列的函式頂端、一道可檢查、有名字的守衛。
typedef struct {
int *data;
size_t length; /* items in use */
size_t capacity; /* items the buffer can hold */
} Vec;
void vec_push(Vec *v, int x) {
assert(v != NULL); /* precondition */
assert(v->length <= v->capacity); /* invariant on entry */
if (v->length == v->capacity)
vec_grow(v); /* make room first */
v->data[v->length++] = x;
assert(v->length <= v->capacity); /* invariant on exit */
}在那個小小的函式裡出現了兩種口味的不變量,替它們命名能磨利你的思考。前置條件是「一個函式被『允許』執行所必須為真的事」——在這裡,是 v 不為 NULL、且結構完好地抵達。後置條件是「函式『承諾』它返回時會留下為真的事」——在這裡,是推入之後不變量依然成立。你也會遇到迴圈不變量:一個你斷言「在每一次迭代的頂端都成立」的條件,這是說服你自己「一個迴圈是正確的」的經典方法。而在並行那幾階,你已經遇過鎖不變量——也就是「你只在握著對應的互斥鎖時才碰觸某些共享狀態」的規則。這一切都是同一種直覺:把真理寫下來,然後讓機器替你檢查它。
契約:呼叫者與被呼叫者之間的承諾
從單一函式往外拉遠,一個模式浮現了。每一個函式都是兩造之間的一份小協議:呼叫者,承諾提供有效的輸入;被呼叫者,承諾回過頭來交付一個有效的結果。那份協議就是一份契約,而前置條件、後置條件與不變量,不過就是它寫下來的條款。那個深刻的概念,常被稱為「契約式設計」,是說只要兩邊都守住自己那一半,整件事就能運作——而斷言,就是每一方用來檢查「另一方守住了承諾」的方法。
這把斷言從零散的安全檢查,重新框定成某種帶有架構性的東西。當 divide() 斷言 `b != 0` 時,它是在公告一條前置條件:「我的契約說,你這個呼叫者,絕不可以傳給我一個零的除數;如果你傳了,那是你的臭蟲,不是我的,而我會大聲說出來。」這就是為什麼斷言屬於你自己程式「內部」、你所寫的模組之間的一道信任邊界。它畫出一條清楚的歸咎線:一個開火的前置條件斷言把矛頭指向上游的呼叫者,而一個開火的後置條件則把矛頭指向函式自身。關鍵在於,契約是「你自己程式碼之間」的承諾——它假設呼叫者也是你的程式碼、受同一份協議約束、並被期待去履行它。
那些誠實的陷阱:NDEBUG、副作用,以及斷言不是什麼
現在來到新手會被燙到的部分,因為 assert() 的設計裡內建了一道鋒利的刀刃。這個巨集是有條件的:如果編譯時定義了符號 NDEBUG(通常在最佳化的發行版本裡,常透過 `gcc -DNDEBUG -O2`),標準規定 assert() 會展開成「完全什麼都沒有」。你程式裡的每一個斷言都會無聲地消失。這是刻意的——它讓你在開發時把昂貴的檢查灑滿程式碼、卻在正式環境付出零執行期成本。但它有個殘酷的後果:絕不要把帶副作用的程式碼放進 assert 裡,因為那個副作用會在發行版裡蒸發。`assert((p = malloc(8)) != NULL);` 這一行看起來沒問題,直到 NDEBUG 被定義、那個 malloc() 就再也不會執行。
這直接導向所有規則裡最重要的一條:斷言「不是」輸入驗證,而你絕不可以拿它來檢查一個可復原錯誤。驗證「從外部世界跨過一道信任邊界而來」的資料——使用者的按鍵、一個網路封包、一個檔案的內容——是防禦性程式設計的主題,而它必須使用「真正的、永遠存在的」錯誤處理:一個 if 檢查、一個回傳碼、一段優雅的訊息。如果你反而寫了 assert(user_input_is_valid),那麼在一個定義了 NDEBUG 的發行版本裡,那個檢查「不見了」,而畸形的輸入就一路暢行地航進你的程式。更糟的是,就算在除錯版本裡,對壞掉的使用者輸入呼叫 abort(),也意味著「一個壞封包就能讓你的伺服器崩潰」。壞掉的外部輸入是可復原錯誤、不是臭蟲——去處理它,別去斷言它。
關於它與未定義行為的關係,最後一句誠實話。對於那些「造成」UB 的臭蟲——一次空指標解參考、一個越界索引、一個溢位的整數——斷言是一套了不起的早期警報系統。在你寫 data[i] 之前先抓到 `assert(i < n)`,能把一次無聲的記憶體汙染變成一次乾淨、有名字的中止。但斷言是一個「檢查」、不是一個「保證」:它只為你想到要寫的那些情況開火、只在你的測試實際走過的路徑上開火、也只在一個把它編譯了進去的版本裡開火。它把臭蟲能藏身的地方縮得極小;它沒辦法讓臭蟲變得不可能。那個更難的保證——把整類這樣的臭蟲變得「無法被表達」而不只是「被檢查」——正是當這座梯子走到 Rust 時,我們會誠實權衡的那個承諾。