你一直聽錯的那個詞
上一篇你認識了 C 抽象機器——C 標準所描述的那台理想化電腦,也是你的原始碼真正為之而寫的對象。你看到「彷彿規則」(as-if rule)讓真實編譯器可以對你的程式碼為所欲為,只要可見的行為符合那台抽象機器會產生的結果。未定義行為(undefined behavior),幾乎總是簡稱 UB,正是那紙契約撕裂的那一點所發生的事。它是一種標準完全不施加任何要求的構造或情境——標準就此走開,對程式從那一刻起代表什麼意思,一句話都不說。
以下這句話幾乎人人都搞錯,所以請慢慢讀:UB 並不代表「結果不可預測」或「視平台而定」。它代表標準徹底停止描述你的程式了。一支執行了 UB 的程式,在形式上沒有任何已定義的行為——不是錯的答案、不是隨平台而異的答案,而是根本沒有任何被要求的答案。編譯器沒有義務讓它崩潰、沒有義務讓它印出亂碼,也沒有義務做任何特定的事。最有用的心智模型是一個承諾:標準每一處說「這是未定義」的地方,語言標準其實都在說「你,程式設計師,承諾這件事絕不發生——而我們會把一切都建立在這個承諾之上。」
三個容易搞混的手足
UB 有兩個比較溫和的親戚,把這三者分清楚,是感受真正懸崖在哪裡最乾淨的方法。它們構成一道「標準承諾了多少」的階梯。安全那一端坐著實作定義行為(implementation-defined behavior):標準堅持實作必須挑一個一致的答案,並把它寫進自己的文件裡。例如 int 的寬度,或一個普通的 `char` 是否帶號——這些在不同平台間各異,但每個編譯器都必須做出選擇並告訴你。你的程式依然有意義;你只是得去翻手冊。
再下一階、安全性少一點的是未指定行為(unspecified behavior):標準提供一份允許結果的小菜單,但不要求實作把它選了哪一個寫進文件,甚至每次出現時還可能不同。經典例子是函式引數的求值順序:在 `f(g(), h())` 中,標準保證 g() 與 h() 都會在 f() 之前執行,卻對哪一個先跑隻字不提。未指定與實作定義這兩種行為都讓你的程式保有意義——結果是某個已知、有界集合中的一員。真正的危險,是把第三個手足錯認成這兩個。
未定義行為則屬於完全不同的類別。沒有菜單、沒有文件、沒有有界集合——什麼都沒有。前兩者最糟的情況是可移植性臭蟲:你的程式在不同編譯器上表現各異,但它還是有表現。碰上 UB,根本沒有「表現」可言。所以誠實的一行原則是:實作定義與未指定講的是你會拿到幾個有效答案中的哪一個;未定義講的則是你的程式到底還有沒有任何意義。前兩者去翻手冊;第三者,絕不明知故犯。
implementation-defined : standard says "pick one, and document it" -> int width, char signedness
unspecified : standard says "pick from this set, no docs" -> argument eval order
undefined (UB) : standard says NOTHING -> null deref, signed overflow
safer <-----------------------------------------------------------> catastrophic
(a portability bug) (your program means nothing)為什麼最佳化器獲准刪掉你的程式碼
這部分把 UB 從一條學究式的註腳,變成一個真正的險境,而它源自一段滴水不漏的邏輯。最佳化器的全部工作,就是做出有效的假設並加以利用。它最強大的假設是這個:程式絕不會執行未定義行為。它有資格這麼假設,是因為標準允許它——UB 按定義就是程式設計師的承諾。所以每當編譯器能證明某條路徑會導向 UB,它就有權斷定那條路徑永遠不會跑,並據此重塑你的程式碼。在一個小例子上看它發生,某位程式設計師把空指標檢查寫在解參考之後,一個常見的順序錯誤:
int value = *p; // (1) dereference p
if (p == NULL) { // (2) ...then check if p was NULL
return -1;
}
return value;
// The compiler reasons:
// line (1) dereferences p. If p were NULL, that is UB.
// I am allowed to assume UB never happens.
// Therefore p is NOT NULL at line (1).
// Therefore the check at line (2) is always false.
// => delete the entire if-block.把那段推理再讀一遍——每一步都是對的。第 (1) 行的解參考,若 p 是空的就是 UB,所以編譯器當下就假設 p 非空。但若 p 在第 (1) 行非空,那麼第 (2) 行的 `p == NULL` 檢查就必為假,於是程式設計師細心加上的安全網,被當成死碼刪掉了。結果是一支程式在 `-O2` 下會大喇喇地衝過一個它「檢查過」的空指標——而同一份原始碼在 `-O0` 下編譯卻保留了檢查、看似沒事。這臭蟲不是冒出來的;它一直都在。最佳化器不過是兌現了那個你不知道自己許下的承諾。
這正是「UB 不過是隨平台而異的行為」這句口號如此危險的原因。隨平台而異的行為是穩定的:同一個編譯器每次都給同一個答案。UB 比隨機還糟——它隨最佳化等級而變、隨編譯器版本而變、隨一百行外的程式碼而變,而且它能讓本來能跑的程式碼在原始碼一字未改的情況下停止運作。一個在 `-O0` 下消失、在 `-O2` 下毀損記憶體的臭蟲,正是 UB 的招牌氣味,你應該學會像聞氣味一樣認出它。
時間旅行:當毀損比起因更早抵達
還有一個後果,會在人們第一次遇到時真正令其不安,它甚至贏得了一個外號叫 UB 時間旅行。因為編譯器獲准假設 UB 永不發生,又因為它在「彷彿規則」下自由地重排指令,一段 UB 的效應可以看起來發生在觸發它那一行的之前。一個程式設計師安全地擺在出問題那行上方的 `printf`,可能根本不會執行,因為編譯器從 UB 往回推理,把它「證明」走不到的那條路徑上的一切都刪了。
讓畫面具體一點。想像一段程式碼先印出「starting」,然後在十行之後,去除以一個結果為零的變數——對整數而言,除以零是 UB。你或許預期「starting」會先印出來、然後才崩潰。但編譯器獲准把整個區段摺疊並重排,它可能產出一個二進位檔,讓程式在「starting」還沒上螢幕之前就出錯,或者讓「starting」整個消失。這就是為什麼你無法可靠地用一串列印敘述把 UB 夾在中間:UB 毒害的是最佳化器一併考量的整片區域,而非只有那行有罪的程式碼。因果不再按原始碼順序排隊了。
這個教訓不是要你對每一行都疑神疑鬼,而是要你更新「一支 C 程式究竟是什麼」的模型。你的原始碼不是一份機器由上而下逐行讀取的腳本;它是一份描述,編譯器有權以「彷彿規則」允許的任何方式去變換它——前提是你絕不觸發 UB。你一旦觸發,「彷彿規則」的保證就蒸發了,你原始碼的順序與實際執行的順序之間,再也不存在任何關係。UB 是唯一能讓你的程式碼,對自己的執行次序向你說謊的東西。
常見的嫌疑犯,以及如何逮住它們
日常多數 UB 來自一份很短的累犯名單,現在把它們點名,意味著下一篇近距離檢視每一個時你都能認出它。大宗有這幾類:解參考空指標或懸置指標;讀寫到陣列範圍之外,這是每一個緩衝區溢位的種子;帶號整數溢位(注意無號溢位被定義為回繞,但帶號溢位是 UB);讀取未初始化的變數;在 free() 之後仍使用該記憶體;以及幾個更隱微的,像破壞嚴格別名規則的型別轉換把戲,或把一個整數位移超過它的寬度。
留意這些共通之處:每一個都是標準把一個承諾交給編譯器去據以最佳化的地方。這也是好消息。正因為 UB 有精確的定義,工具才能被打造出來逮住它。你現在能養成最有價值的習慣,就是在開發時開著消毒器(sanitizer)編譯:`gcc -fsanitize=undefined,address -g main.c` 會武裝 未定義行為消毒器與位址消毒器,它們會插入正是最佳化器獲准省略掉的那些執行期檢查。你的程式不再悄悄被錯誤編譯,而是停在出問題的確切那一行、附上清楚的診斷——把一個隱形的承諾,變成一個大聲且可定位的錯誤。
把這個觀念握正
退一步看,形狀其實很簡單,儘管後果鋒利。C 標準在你與編譯器之間定義了一紙謹慎的契約。契約的大部分是堅實的保證。少數幾個角落被刻意留為未定義,而在每一個這樣的角落,編譯器都獲准假設——並在這個假設上建立最佳化——你的程式絕不會踏進那裡。信守契約,你得到一支快速且可預測的程式。違反它,哪怕只有一次、哪怕在一行你以為無害的程式碼上,編譯器的推理也能自由地向外漣漪擴散,重塑離違規處很遠的程式碼。
所以從這篇帶走這三條更正,因為它們正是執業程式設計師最常搞錯的那三條。UB 不是「未定義的輸出」——它是任何已定義意義的缺席。UB 不是「隨平台而異」——它更糟,因為它在最佳化器之下並不穩定,能隨一個旗標而變。而 UB 也不是「編譯器對你做的事」——它是你承諾過絕不會發生的事,編譯器不過是信了你的話。下一篇,常見的陷阱,會把我們剛點名的嫌疑犯一一拿來,仔細走過每一個是如何引爆、又如何拆除的。