斷言、不變式與契約概念(an assertion, invariants, and the contract idea)
/ assert: "uh-SERT" /
想像一座橋上立著一塊牌子:「限重 10 噸」。那塊牌子是雙向的承諾——橋承諾能撐住最多 10 噸,而你承諾不開更重的卡車過去。斷言就是「立這樣一塊牌子、外加一個一旦規則被破壞就讓一切停下的守衛」的程式碼版本。assert(x) 說的是「我主張此處 x 為真;若不是,程式就有臭蟲,所以立刻而大聲地停下,而不是跛著腳繼續往前」。
精確的機制如下。不變式是某個位置應永遠成立的條件——例如「這個指標在這裡永不為 NULL」或「這個串列的長度永遠等於它所記錄的計數」。前置條件是函式在能正確運作之前對呼叫者的要求(例如「傳進來的陣列必須非 NULL 且至少有 n 個元素」)。後置條件是函式回過頭承諾交付的東西(例如「回傳的串列是已排序的」)。三者合起來形成一份契約:若呼叫者滿足前置條件,函式就保證後置條件。assert(condition) 在執行期檢查這樣一個主張;若條件為假,標準函式庫會印出失敗的運算式、檔案與行號,然後呼叫 abort() 終止程式。關鍵在於:當巨集 NDEBUG 有定義時(通常在發行版建構中),斷言會被編譯掉,所以它是除錯期的檢查,而非正式環境的執行期成本——這意味著你絕不能把有副作用的程式碼放進 assert 裡。
為何重要,以及核心的誤解:斷言用來抓臭蟲——抓那些你相信永遠為真的事被違反——而不是用來處理預期之內的錯誤。檔案是否存在、使用者是否輸入了數字、網路是否暢通:這些是來自外部世界的可復原錯誤,必須用正常的錯誤檢查程式碼來處理,因為它們合理地會發生,你的程式應該應付得了。斷言失敗則代表程式自身的邏輯壞了:某件你向自己證明過絕不可能發生的事,剛剛發生了,所以繼續下去只會搞壞更多狀態。誠實的提醒是:因為斷言在 NDEBUG 下會消失,絕不要倚賴斷言去驗證不可信的輸入或實施安全防護——那種檢查必須是真正、永遠開著的程式碼。
對的:int *p = malloc(n * sizeof *p); assert(n > 0); /* 我自己的程式碼絕不該用 n <= 0 來呼叫這裡——若發生就是臭蟲 */。錯的:assert(fopen(path, "r") != NULL);——檔案不存在是個預期之內的錯誤,不是臭蟲,而且在 NDEBUG 下,那個 fopen() 呼叫會整個消失,於是檔案根本沒被開啟。
對你自己的不變式下斷言;用真正的錯誤碼去處理外部世界的失敗。
斷言在定義了 NDEBUG 時會被編譯掉,所以(a)絕不要把副作用放進 assert() 裡,且(b)絕不要用它們來驗證不可信的輸入或實施安全防護——那些檢查必須永遠執行。斷言失敗示意的是臭蟲,不是可復原的錯誤。