JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

未定義行為究竟是什麼意思

未定義行為是 C 裡最被誤解的一個觀念——也是搞錯後最危險的一個。它不是「編譯器愛怎樣就怎樣」;它是你對編譯器許下的一個承諾,而編譯器獲准假設你信守了它。這篇把那個承諾講清楚,並說明為何毀約會讓你的程式碼被刪掉。

你一直聽錯的那個詞

上一篇你認識了 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 按定義就是程式設計師的承諾。所以每當編譯器能證明某條路徑會導向 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.
空指標檢查被悄悄移除:先解參考 p,等於向編譯器承諾了 p 不是空的。

把那段推理再讀一遍——每一步都是對的。第 (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 也不是「編譯器對你做的事」——它是承諾過絕不會發生的事,編譯器不過是信了你的話。下一篇,常見的陷阱,會把我們剛點名的嫌疑犯一一拿來,仔細走過每一個是如何引爆、又如何拆除的。