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

可變參數巨集、C23 屬性,與更精緻的抽象機器觀

把你的 C 現代化收個尾:能接受任意數量引數的巨集、用可移植方式跟編譯器對話的 C23 屬性,以及對標準真正承諾給你的那台抽象機器更清楚的理解。

能接受任意數量引數的巨集

你已經把前置處理器當成一個粗糙的文字取代器來認識,而在第 4 篇你看到指定初始化與複合字面值如何在語言本體裡給你精準度。現在我們用前置處理器最銳利的工具來收尾。可變參數巨集(variadic macro)是最後一個參數寫成字面 `...` 的巨集,它會捕捉呼叫端傳入的任意數量引數;在巨集本體裡,那個魔法符號 `__VA_ARGS__` 會展開成那些引數本身,連逗號一起。最經典的用法是包一層記錄函式:`#define LOG(fmt, ...) fprintf(stderr, fmt, __VA_ARGS__)` 把格式字串和一串開放的引數列直接轉送進真正的呼叫。精確規則請見 可變參數巨集

這裡有個利刃:如果呼叫端只傳一個格式字串、其他什麼都不傳呢?那 `__VA_ARGS__` 會是空的,你就剩下一個多餘的逗號 `fprintf(stderr, fmt, )`,而在 C23 之前那是語法錯誤。C23 的解法(在它之前則是 GCC 與 Clang 長年的擴充)是 `__VA_OPT__(x)`:只有在可變引數非空時,它才展開成它的引數 x。所以 `#define LOG(fmt, ...) fprintf(stderr, fmt __VA_OPT__(,) __VA_ARGS__)` 會在格式字串後面什麼都沒有時乾淨地拿掉那個逗號。這才是誠實地寫一個同時對 `LOG("done\n")` 和 `LOG("x=%d\n", x)` 都成立的記錄巨集的方式。

X 巨集:一份清單,多種用途

這裡有個你第一次看到會覺得像障眼法、之後卻變得不可或缺的手法。X 巨集(X-macro)把一份項目清單只寫一次,放在標頭裡,寫成對一個尚未定義、名為 X 的巨集的一連串呼叫。接著你用不同方式去 `#define X(...)`,再把那份清單展開(或 `#include`)好幾次——每一遍都走訪同一份資料,卻吐出不同東西:一個列舉、一個名稱陣列、一個 switch。唯一的真相來源是那份清單;其他全是衍生出來的,所以新增一個項目,列舉、字串和分派會同時一起更新。請見 X 巨集

/* colors.def -- the single list */
X(RED,   0xFF0000)
X(GREEN, 0x00FF00)
X(BLUE,  0x0000FF)

/* pass 1: build an enum */
#define X(name, rgb) COLOR_##name,
enum color { 
#include "colors.def"
};
#undef X

/* pass 2: build a name table */
#define X(name, rgb) #name,
static const char *color_name[] = {
#include "colors.def"
};
#undef X
一份 .def 清單,展開兩次:第一遍產生 COLOR_RED、COLOR_GREEN、COLOR_BLUE;第二遍產生對應的字串。在一個地方加一個顏色,兩邊就同步更新。

要老實看待它的代價。X 巨集是用「第一次讀比較難懂」去換「並排的多份清單永遠不會走偏」的保證——而那是個真實又常見的臭蟲。它們最適合正好是這個形狀的問題(一個列舉加上它的名稱再加上它的處理函式),最糟的情況則是為了炫技而濫用。和所有前置處理器的工作一樣,它們不會留下任何符號給除錯器看,所以一個寫錯的條目會以一個指向被引入檔案、令人困惑的編譯錯誤現身。在重複是真實存在時才動用它們,而不是為了刺激感。

C23 屬性:用可移植的方式跟編譯器對話

幾十年來,給編譯器的提示都活在不可移植的寫法裡:GCC 的 `__attribute__((noreturn))`、MSVC 的 `__declspec`。C23 把一種從 C++ 借來的方括號語法標準化了,讓同一份原始碼到處都能編——一個屬性就是一個被一對方括號包住的名字,寫在它所修飾的東西的正前面。你真正會用到的有四個:deprecated 標記(有人呼叫這個就警告)、放在 switch 某個 case 結尾的 fallthrough 標記(「對,我是故意要落入下一個 case」)、放在一個回傳值不該被悄悄丟棄的函式上的 nodiscard 標記,以及消掉未使用參數警告的 maybe_unused 標記。請見 C23 屬性

有一個屬性值得自己一個詞條,因為它改變了編譯器的推理方式。把一個永不回傳的函式標記起來——`exit()`、`abort()`、一個 panic 例程——等於告訴最佳化器:控制流到這裡就停了。C23 用 noreturn 屬性來寫它(較舊的關鍵字是 `_Noreturn`);見 _Noreturn 與 noreturn 屬性。這不是裝飾用的:一個編譯器知道永遠不會回來的函式,讓它能修剪掉呼叫之後的死碼,並壓掉一個假的「缺少回傳值」警告。如果你說了謊——把一個函式標成 noreturn 卻又讓它回傳了——你就遞給最佳化器一個錯誤前提,那是另一種口味的 未定義行為

更精緻的抽象機器觀

這裡是把整個這一階串起來的核心觀念。C 標準描述的不是你的筆電;它描述的是一台虛構的抽象機器(abstract machine),而它只承諾:你程式的可觀察行為——它的輸入輸出、它的 volatile 存取、它的最終狀態——會跟那台機器會做的一致。其他一切,編譯器都可以在「彷彿規則」(as-if rule)下重排、融合或刪除。這就是為什麼 `-O2` 能讓程式變快卻不改變它的意義,也是為什麼一個倚賴未定義行為的程式可能在 `-O0` 表現正常、在 `-O2` 卻毀壞:對抽象機器來說兩者都合法,因為一個帶有未定義行為的程式根本沒有「要保留的既定行為」。請見 C 抽象機器

抽象機器也釘死了事情彼此之間「何時」發生,而這在副作用相撞的那一刻就要緊了。在 C11 之前,標準講的是模糊的「序列點」;現代 C 用更細緻的先序於(sequenced-before)關係。它是一個偏序:在 `a = b = c` 裡,右側先序於對 a 的賦值,但在 `f() + g()` 裡,兩個呼叫是無序的——編譯器可以用任一順序執行它們。像 `i = i++` 和 `a[i] = i++` 這類經典陷阱之所以未定義,正是因為它們在兩次寫入之間沒有任何排序的情況下對 `i` 寫了兩次。請見 先序於關係

把它組合起來

你現在握有完整的現代 C 工具組了。當你坐下來寫一個小而堅固的模組時,它們是這樣拼起來的——每一塊都靠它自己掙得位置,而不是為了趕流行才加進去。

  1. 把你的領域用一份 X 巨集 清單只定義一次(狀態碼、訊息型別),讓列舉、名稱表和分派永遠不會彼此走偏。
  2. 用靜態斷言(第 3 篇)守住那些能在建置期檢查的不變式,讓一個錯誤的結構大小變成編譯錯誤,而不是執行期才冒出來的驚嚇。
  3. 把那些不該悄悄失敗的函式標上 nodiscard 屬性,把那些永不回傳的函式標上 noreturn 屬性,讓編譯器替你強制契約,並對控制流做出正確推理。
  4. 把診斷訊息包進一個用了 __VA_OPT__ 的 可變參數記錄巨集 裡,讓每一行追蹤訊息都一致,而且零額外引數的情況也照樣乾淨地編過。
  5. 用 -O2 -Wall 建置,並把每一條警告當成抽象機器對你說的一句話來讀——然後把你的程式碼守在標準真正定義的行為範圍之內。

這就是整個這一階的弧線:同一個你早就在寫的 C 語言,被調教得能對編譯器說更多,好讓編譯器能替你做更多。這一切都不是魔法,也都沒有免除你檢查 `malloc()`、釋放你所擁有的、以及尊重 未定義行為規則 的必要。它為你換來的是精準——而在系統程式裡,精準就是安全。