防禦性程式設計、輸入驗證與邊界檢查
謹慎的廚師不會假設蛋是新鮮的、烤箱真的是刻度宣稱的溫度、或食譜的份量合理——他們會檢查,因為一個壞假設就毀了整道菜。防禦性程式設計就是把同樣的態度用在寫程式上:把每一段程式碼寫成能挺過壞輸入、意外狀況、以及呼叫它的程式碼所犯的錯,而不是假設一切都會乖乖的。在 C 裡,一個未檢查的值就能觸發未定義行為,所以這種態度不是可有可無的潤飾——它正是你避開這整個領域所述災難的方法。
三個習慣構成核心。輸入驗證指的是在動手處理資料之前,先檢查它是否可接受:這個長度在範圍內嗎、這個指標非空嗎、這個數字為正嗎、這個字串塞得進緩衝區嗎?在你程式的邊緣、資料從外部抵達之處,你要驗證得特別嚴。邊界檢查指的是確保每個陣列索引、每次複製都停在緩衝區的真實大小之內——使用前先拿索引和長度比對,並用目的地大小限制複製(用 snprintf 而非 sprintf)。而檢查回傳值指的是絕不假設一個操作成功了:malloc 可能回傳 NULL、open 可能失敗、read 可能回傳比要求少的位元組,忽略這些結果就等於把房子蓋在可能不存在的地基上。具體而言,驗證通常表現為提早返回或錯誤路徑:在寫入「之前」寫 'if (n > capacity) return -1;',而不是事後補救。
為什麼重要:防禦性程式碼用一點點囉嗦換來大量的穩健,而在 C 裡這份回報很直接——大多數可被利用的臭蟲,距離一個未檢查的長度、一個未檢查的指標、或一個未檢查的回傳值,就只差那麼一步。要誠實拿捏的平衡:在你程式的「邊界」(信任邊界,不受信任的輸入進入之處)嚴格驗證,而在受信任的內部則倚賴斷言在開發期抓出程式設計師的錯誤。別把兩者搞混——斷言記載一個你相信永遠為真的不變式、在發行建構裡可能被編譯掉,而驗證處理你「預期」有時會是壞的、且必須永遠執行的輸入。兩者都在你的工具箱裡;在對的地方用對的那一個,就是手藝。
int copy_into(char *dst, size_t cap, const char *src) { if (dst == NULL || src == NULL) return -1; /* 驗證指標 */ if (strlen(src) + 1 > cap) return -1; /* 複製前先邊界檢查 */ strcpy(dst, src); /* 現在可證明是安全的 */ return 0; } char *buf = malloc(64); if (buf == NULL) return -1; /* 檢查回傳值 */
驗證指標、在複製前拿長度對容量做邊界檢查、並檢查 malloc 的回傳——三個習慣把不安全的複製變成安全的。
驗證與斷言不是同一回事。驗證處理你預期可能是壞的、且必須永遠執行的輸入;斷言記載你相信永遠成立的不變式、在發行建構裡可能被編譯掉。在信任邊界用驗證,對內部不變式用斷言。