嚴格別名規則(the strict-aliasing rule)
當兩個指標可能指向同一塊記憶體時,它們就「別名」(alias)。如果編譯器無法排除這點,它就必須悲觀:透過一個指標寫入之後,它不能信任從另一個指標快取下來的任何值,所以它重新載入、重新計算。嚴格別名規則是 C 與 C++ 標準「對編譯器」做出的承諾,讓它在許多情況下能排除別名——對系統程式設計師來說,它是最令人意外的規則之一。
這條規則大致說:你只能透過「相容型別」的左值來存取記憶體中的物件(物件自身的型別,或少數受祝福的例外,最著名的是 char,它可以與任何型別別名)。實際後果是:編譯器被允許「假設」兩個不相關型別的指標——例如 int* 與 float*——「不」指向同一個物件。這就是以型別為基礎的別名分析(type-based alias analysis,TBAA)。因為有這個假設,透過 float* 儲存之後,編譯器可以繼續使用它稍早透過 int* 載入的值、不必重新載入,因為標準承諾這兩者不會重疊。那個假設使它能積極地把值快取在暫存器、把載入外提出迴圈、重排記憶體操作。但如果你「真的」讓一個 int* 與一個 float* 指向同一批位元組、並透過一個寫入、期望透過另一個讀到改變,你就破壞了承諾——那是未定義行為,最佳化器的假設可能產生看似不可能的結果。
它重要在於:這是一個經典的、真實世界的「在 -O0 正常、在 -O2 壞掉」bug 來源,尤其是型別雙關(type punning)的慣用法——把指標轉型來重新詮釋位元組(把 float* 轉成 int* 並解參考,以把一個 float 的位元當成 int 對待)。那個轉型並解參考正是嚴格別名違規。誠實的指引是:重新詮釋位元組的合法方式是 memcpy()(編譯器最佳化得很好),或在 C 中用 union;char* 也是豁免的,可以合法地與任何型別別名。逃生口是 -fno-strict-aliasing,它告訴編譯器放棄這個假設、更保守地對待記憶體(付出一些效能代價)——對依賴型別雙關的舊程式碼有用,但更好的修法是停止跨型別別名。
// UB:透過轉型做型別雙關違反嚴格別名 float f = 1.0f; unsigned bits = *(unsigned *)&f; // 透過 unsigned* 讀一個 float 的位元組 -> UB // 正確、行為明確的重新詮釋位元組方式: unsigned bits; memcpy(&bits, &f, sizeof bits); // 編譯器會合法地把它最佳化成相同的程式碼
把 float* 轉成 unsigned* 並解參考是嚴格別名違規;memcpy() 是合法、同樣快的方式。
char*(與 unsigned char*)可合法地與任何型別別名,而 union 或 memcpy() 是重新詮釋位元組的有定義方式;在不相關的指標型別間轉型並解參考是未定義行為——-fno-strict-aliasing 只是遮蔽症狀。