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

編譯期安全:_Static_assert、alignas、restrict

三個小小的關鍵字,讓你陳述一個事實,並在程式還沒執行之前就被檢查或被信任。_Static_assert 把一個假設變成建置錯誤,alignas 控制資料落在記憶體何處,而 restrict 把一個最佳化器原本無法做出的承諾交到它手上。

把檢查從執行期搬回建置期

到現在你已經從第一卷認識了 assert():它在你的程式執行時檢查一個條件,若條件為假就中止。這很有用,但有兩個代價。壞情況只有在那一行真的在某個倒楣的輸入上執行到時才會被逮到,而檢查本身每次都得花掉幾道指令。這篇講的是三個現代 C 工具,它們把工作往相反的方向推——推向編譯器,在任何一道指令執行之前。第一個,_Static_assert,是最乾淨的例子:它是一個語言標準要求編譯器在編譯期求值的斷言,若它為假,你的建置就直接失敗。

外型很小:`_Static_assert(constant_expression, "message")`。那個運算式必須是編譯器能自己完全求值的——一個常數運算式——通常由 sizeof、_Alignof、列舉值,或對字面值的算術構成。若它非零(真),這一行就消失、不產生任何程式碼;它的執行期足跡是零。若它為零(假),編譯就停下並印出你的訊息。在 C23 你也可以不加底線寫成 `static_assert`,甚至在 C23 連訊息都可以省略,但帶底線、附訊息的寫法從 C11 起到處都能用。

把它具體化。假設你有一個 `struct packet_hdr`,你把它直接寫進一個通訊端,而你的程式碼依賴它剛好是 8 個位元組、沒有填充。你可以用 `_Static_assert(sizeof(struct packet_hdr) == 8, "packet_hdr must be exactly 8 bytes")` 把它釘死,並用 `_Static_assert(sizeof(int) == 4, "assumes 32-bit int")` 守住一個 32 位元整數的假設。這兩個運算式都是純粹的 sizeof 算術,編譯器在建置時就會求值,所以一個為假的會當場被回報,而不是在執行期。

讀一讀剛剛發生了什麼,因為這正是 _Static_assert 的全部重點。一個過去只活在註解裡的假設,現在成了一個被檢查的事實。如果有人重排了欄位、編譯器插入了一個填充位元組,建置就會在他們的機器上中斷,附上一條指名問題的訊息,而不是在正式環境裡默默送出格式錯誤的封包。一個執行期 assert 只有在對的程式路徑跑在對的輸入上時才能逮到這件事;靜態的這個則對每一次建置、每一回都逮到,而且免費。

資料落在哪裡:alignas 與 alignof

第二個工具需要把第一卷的一個觀念磨利。每個型別都有一個對齊需求:一個數 A(永遠是 2 的次方),使得物件的位址必須是 A 的倍數。一個 `uint32_t` 通常對齊為 4,所以它只能住在像 0x1000 或 0x1004 這樣的位址,絕不會在 0x1001。CPU 偏好這樣;在某些架構上未對齊的存取很慢,在少數架構上則直接觸發例外。你已經看過它的後果——結構內的填充存在的原因,正是為了讓每個欄位都待在它所需的邊界上。新的關鍵字 _Alignof(C23 也讓你寫 `alignof`)只是去問一個型別它的對齊量、回傳一個常數運算式:在典型機器上 `_Alignof(uint32_t)` 是 4。

搭檔的 _Alignas(C23:`alignas`)走的是反方向:它為一個變數或結構欄位請求一個對齊量,通常是比該型別自己會得到的更強的對齊。這就是過度對齊,它解決真實的問題。一個你要餵給 SIMD 指令的緩衝區,可能需要從一個 16 或 32 位元組的邊界開始。一個在執行緒間共享的結構,會因為獨佔一條 快取線(通常 64 位元組)而受益——這樣兩個核心更新兩個不同欄位時,就不會一直讓彼此的快取失效。你就在宣告那個東西的地方寫下這個請求。

寫成程式碼很自然。`alignas(64) unsigned char buf[1024]` 宣告了一個 1 KiB 的緩衝區,保證從一個 64 位元組的邊界開始;在一個結構裡兩個熱門的 `_Atomic` 計數欄位各自寫上 `alignas(64)`,會把每一個放到自己的快取線上,讓鄰近的更新不會一直讓彼此失效。而這兩個想法搭配得很漂亮:你可以用 `_Static_assert(_Alignof(struct counters) == 64, "counters must be 64-byte aligned")` 去驗證那個請求確實成立,把一個你要求的對齊,變成一個建置缺了它就拒絕出貨的對齊。被檢查與被強制,攜手合作。

restrict:一個最佳化器無法自己做出的承諾

第三個工具最微妙,而且它是另一種東西。_Static_assert 與 alignas 會被檢查或被強制執行——搞錯了編譯器會告訴你。restrict 則相反:它是一個你對編譯器做出、且不被檢查的承諾,很像未定義行為背後那些契約。它要解決的問題是別名(aliasing)——兩個不同的指標指向同一塊記憶體的可能性。當編譯器無法排除這件事,它就必須產出謹慎、較慢的程式碼,把它本可留在暫存器裡的值重新載入。

想像那個經典案例。一個函式把兩個陣列逐元素相加進第三個:`void add(int *dst, int *a, int *b, size_t n)`。在迴圈內編譯器寫進 `dst[i]`。那個寫入會不會改到 `a` 或 `b`?如果 `dst` 剛好與 `a` 重疊,那就會——所以嚴格地說,編譯器在每一次迭代都必須從記憶體重新讀取 `a[i]` 與 `b[i]`,因為前一次儲存可能碰過它們。它沒辦法把一個基底值跨迴圈留在暫存器裡。這份謹慎是正確的,卻昂貴,而且它是隱形的:程式碼看起來很快,但最佳化器的手,被一個你明知絕不會發生的可能性綁住了。

/* Without restrict: dst may alias a or b, so the compiler must
   reload a[i] and b[i] every iteration -- it cannot prove safety. */
void add(int *dst, int *a, int *b, size_t n) {
    for (size_t i = 0; i < n; i++)
        dst[i] = a[i] + b[i];
}

/* With restrict: YOU promise dst, a, b never overlap. The compiler
   may now keep base pointers in registers, reorder, and vectorize. */
void add_r(int * restrict dst,
           int * restrict a,
           int * restrict b, size_t n) {
    for (size_t i = 0; i < n; i++)
        dst[i] = a[i] + b[i];
}

/* The promise is UNCHECKED. If you call add_r with overlapping
   arrays, the result is undefined behavior -- not a diagnostic. */
restrict 是一個指標限定詞,承諾沒有別名;它解開最佳化,但永遠不被驗證。

當你用 restrict 限定一個指標,你是在告訴編譯器:在這個指標的存活期間,它所能觸及的任何物件,都只會透過它(或由它衍生的指標)來觸及。有了那個承諾,編譯器就知道對 `dst[i]` 的儲存不可能擾動 `a` 或 `b`,於是它可以把陣列基底提升到暫存器、重排載入與儲存,並且常常把迴圈自動向量化成寬的 SIMD 指令。在緊湊的數值核心上,這加速可能很可觀。這就是為什麼你會在 C 標準函式庫裡到處看到 restrict——memcpy() 就是這樣宣告的,這也是它為何比容忍重疊的 memmove() 快的部分原因。

如何使用 restrict 而不被燙到

因為這個承諾不被檢查,一個壞掉的 restrict 是一顆貨真價實的地雷——而且比大多數更糟,因為它遵循與所有 UB 相同的規則。如果你用重疊的陣列呼叫 `add_r`,編譯器不會警告你,也不會把結果夾到某個合理的值;它產生的程式碼假設了你說的是實話,而抽象機器對這個違規沒有任何已定義的行為。毀損可能只在 `-O2` 下出現、只在某些輸入上出現、只在最佳化器選擇向量化之後出現。把 restrict 完全當成上一篇那些 UB 契約:一個信守時回報豐厚、毀約時無聲懲罰的強大工具。

  1. 只在剖析器顯示有代價的熱迴圈上動用 restrict——絕不要到處亂撒。絕大多數程式碼的瓶頸並不在別名造成的重新載入,那份風險不值得用猜的。
  2. 在加上它之前,先向自己證明:對任何呼叫者而言這些指標都絕不可能重疊——橫跨整個指標的存活期,包括透過由另一個引數衍生而來的指標所造成的別名。
  3. 在呼叫處或註解裡把這個契約寫下來,因為對讀著某個呼叫的人來說,這個承諾在型別中是隱形的。未來某個呼叫者傳入重疊的緩衝區,正是這東西反咬人的經典方式。
  4. 驗證收益與正確性:比較加了與沒加 restrict 的反組譯,確認最佳化器真的有向量化,並在位址消毒器下跑測試,以逮住真實輸入上意外的重疊。

把 restrict 跟一個你將會遇到的鄰近觀念在腦中分開會很有幫助,那就是嚴格別名。嚴格別名是標準預設加在身上的一條規則:粗略地說,你不可以透過一個型別不相容的指標去存取一個物件,而編譯器假設你會遵守。restrict 則相反——一個你選擇加上的承諾,講的是兩個相同型別的指標不重疊。兩者都跟別名有關、都餵養最佳化器,但一個是預設的義務,另一個是選擇性的主張。把它們搞混很常見;乾淨的界線是型別(嚴格別名)對上重疊(restrict)。

一個主題,三個關鍵字

退一步看,這三個工具落在同一條光譜上:每一個都讓你陳述一個編譯器原本必須往最壞處假設的事實,而每一個都把那個事實在執行期之前就兌現掉。_Static_assert 陳述一個事實,編譯器替你檢查它,你錯了就讓建置失敗。alignas 陳述一個關於擺放位置的事實,編譯器強制它、拒絕不可能的請求。restrict 陳述一個關於別名的事實,編譯器信任它、不加驗證就採信你的話。被檢查、被強制、被信任——三筆不同的交易,目標卻相同:把你的程式所假設的東西釘死。

留意這如何改變寫 C 的手感。那些過去只活在你腦袋裡的假設——「這個結構是 8 個位元組」「這個緩衝區是快取對齊的」「這些陣列絕不重疊」——現在都能被寫進程式碼本身,而其中兩個還會被機械式地驗證。這正是你整個這一階都在看到的現代 C 招式,與上一篇 _Generic 同樣的精神:把語言過去放任為隱含又危險的東西,給它一個精確、編譯器看得見的寫法。下一篇轉向初始化——指定初始器、複合字面值,以及彈性陣列成員——它讓你能把資料是什麼講得,像這些關鍵字讓你把假設講得那樣清楚。