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

用 _Generic 寫型別泛型程式碼

C 一向頑固地單型:一個名字、一種型別。C11 悄悄給了它一個新工具——_Generic——讓同一個呼叫能依引數的型別挑選不同的程式碼,全在編譯期決定,且零執行期成本。本篇把這個工具拆開,把它究竟怎麼運作講清楚。

問題所在:一個名字,一種型別

第一篇走過了 C11、C17、C23 各加了什麼;本篇把鏡頭一路推近到其中一項,把它拆開來看。先從它搔的那個癢處說起。在 C 裡,一個函式名字「精確地」對應一個函式、一組固定的簽章。如果你寫了 `int max_i(int a, int b)`,後來又需要對 `double` 用同樣的想法,你就得整個再寫第二個函式——`double max_d(double a, double b)`——而且要記得依名字呼叫對的那一個。C 沒有多載:名字 `max` 不能像在 C++ 或 Python 裡那樣,悄悄同時代表兩個函式。從 C89 起這就是個生活事實,而標準函式庫帶著傷疤——`abs()` 給 `int`、`labs()` 給 `long`、`fabs()` 給 `double`,一個想法卻有三個名字。

比較老的逃生口是函式式巨集:`#define MAX(a,b) ((a)>(b)?(a):(b))`。它對任何型別都能用,因為——如你在工具鏈那一階學過的——巨集是盲目的文字替換,根本不看型別。但盲目是雙面刃。這個巨集會同樣愉快地把 `MAX(p, "hello")` 展開成胡言亂語,不給你任何真正的型別檢查,還會重複求值引數,於是 `MAX(i++, j)` 就咬人。我們真正想要的,是一個名字能檢視引數的型別並挑出對的程式碼——而且這個決定要在編譯期做,不是執行期。這正是 C11 補上的那個缺口。

_Generic 究竟是什麼

C11 為此加了一個關鍵字,拼作 `_Generic`,這個特性叫做泛型選擇。把它想成一個依「型別」而非依值分支的 `switch`。你交給它一個控制運算式,再給一串 `type : result` 配對。編譯器看那個運算式的型別,找出匹配的配對,整個 `_Generic(...)` 就被替換成——就在編譯當下——那一個被選中的結果。其他分支不是在執行期被跳過而已;它們被完全丟棄,根本不會編進你的程式。它是個編譯期的選擇器,在執行期不留任何痕跡:零分支、零成本。

_Generic( EXPR ,
    int    : "it is an int",
    double : "it is a double",
    char * : "it is a char pointer",
    default: "something else"
)

/* the compiler examines the TYPE of EXPR,
   then replaces the whole thing with exactly
   ONE of the four string results -- at compile time. */
泛型選擇的解剖:一個控制運算式,接著是 type:result 配對與一個選用的 default。

有三個細節要緊、卻容易漏看。第一,控制運算式只被檢視「型別」——它從不被求值,就跟 sizeof 的運算元一樣。所以 `_Generic(i++, ...)` 不會遞增 `i`;它只問「`i` 是什麼型別?」光這一點,就已治好了巨集 `MAX` 那個重複求值的詛咒。第二,每個 `result` 可以是任意運算式——一個字串、一個數字,或如我們將看到的,一個函式名字。第三,型別標籤匹配的是 C 對值運算式套用慣常轉換「之後」的型別,這個微妙之處我們待會再回來談。少了它,你一半的選擇都會落到錯的分支上。

用巨集包起來:真正的用法

單獨使用 `_Generic` 很彆扭——你得把控制運算式寫兩遍,一遍用來選,一遍用來真正傳遞。所以實務上它幾乎總是住在一個函式式巨集「裡面」,這兩個特性終於各展所長。巨集把引數當成參數捕捉一次;`_Generic` 檢視那個參數的型別,選出要呼叫哪一個真正的函式。下面是按型別分派的經典 `print`:

void print_int(int v);
void print_dbl(double v);
void print_str(char *v);

#define print(x) _Generic((x),       \
        int    : print_int,          \
        double : print_dbl,          \
        char * : print_str           \
    )(x)

/* print(42)      becomes  print_int(42)
   print(3.14)    becomes  print_dbl(3.14)
   print("hello") becomes  print_str("hello")   */
_Generic 選出一個函式「名字」;尾端的 (x) 再呼叫被選中的那一個。

把那個巨集慢慢讀,因為訣竅藏在排版裡。`_Generic(...)` 本身求值成三個函式名字之一——那是每個分支的結果。接著寫在收尾括號「之後」的 `(x)`,是個普通的函式呼叫,套在被選中的那個名字上。所以 `print(3.14)` 先展開成 `_Generic((3.14), ...)(3.14)`,選擇塌縮成 `print_dbl`,你就剩下 `print_dbl(3.14)`。每個分支的結果是一個函式名字,正是為了讓這個最後的呼叫成立;這個設計仰賴一個事實:函式名字會退化成你能立刻呼叫的指標。注意巨集仍然把 `x` 寫了兩次——一次給選擇(無害,從不求值),一次在呼叫中(正常求值)——所以工具鏈那一階講的「無副作用」紀律,對你傳入的引數仍然適用。

陷阱:轉換、指標與沒匹配到

現在來談誠實的部分,因為 `_Generic` 有些尖角,每個人第一次都會被嚇到。最重要的是:控制運算式的型別取的是值經過慣常轉換「之後」的型別——就是你在資料那一階見過的那組整數提升與轉換,外加陣列轉指標、函式轉指標的退化。有兩個後果常常咬人。`char` 或 `short` 引數在匹配之前會被提升成 `int`,所以一個 `char : ...` 分支可能根本到不了。還有,字串字面值或陣列引數會退化,所以 `"hello"` 匹配的是 `char *`,不是 `char[6]`。如果你的標籤沒考慮到這點,你的值就落到 `default` 上、或乾脆編不過,而你只能對著它一臉茫然。

指標規則比 C++ 程式設計師預期的更嚴。匹配是按「精確型別」,沒有隱含的修飾詞舞步:`char *` 與 `const char *` 是兩個不同的標籤,一個的引數不會匹配到另一個。同樣地 `int *` 與 `void *` 也是有別的。這份精確是一個優點——它正是 `_Generic` 給你那種老 `MAX` 巨集永遠給不了的型別檢查的方式——但這也意味著你有時得寫出比你猜想更多的分支,包括在你的程式碼混用 `const` 時,要為它另寫一個帶 `const` 的分支。

最後是沒匹配到的規則。如果控制運算式的型別不匹配你任何一個標籤、而你又沒寫 `default`,那是個編譯期錯誤,不是默默地掉下去——老實說,這正是你要的。它意味著一個你忘了處理的型別會被編譯器逮到,而不是在執行期亂來。只有在你真的有一個合理的萬用情況時,才加上 `default :` 分支;否則就讓那個硬性錯誤保護你。這讓 `_Generic` 比它取代的那個對型別盲目的巨集安全得多:錯的型別在建置時就被拒絕,正合錯誤處理那一階所主張的「快速失敗」檢查精神。

C23 的打磨,以及該怎麼想它

C23 把兩個粗糙的邊角磨平了。第一是拼法。如第一篇所提,C23 預設讓你寫比較友善的關鍵字 `generic`(以及其他底線大寫關鍵字的小寫形式),而不必寫那個大喊大叫的 `_Generic`——那個醜陋的底線名字當初只是為了不破壞可能把 `generic` 當識別字用的舊程式碼。第二是個真正有用的新夥伴:typeof,在 C23 標準化,它給你某個運算式的型別,供宣告使用。把 `typeof` 跟 `_Generic` 配在一起,讓你既能「依」型別選擇、又能「指名」同一個型別,例如在一個泛型巨集裡,用恰好是引數型別的型別宣告一個暫時變數。

把 `_Generic` 誠實地放在它的親戚之間會有幫助。它不是 C++ 的樣板(template):樣板為每個型別產生整段全新的程式碼、能深度推導型別,而 `_Generic` 是一張扁平、有限、你得自己列出來的手寫型別菜單。它也不是多載;它是一個在編譯期被選定的運算式。把它想成一個精確、低階、依型別的 switch——比起任何有真正泛型的語言裡的東西,它更接近一個終於尊重型別的聰明巨集。正是這個謙遜的範圍,讓它在執行期不花任何成本,也讓你能在一個螢幕內讀完它將處理的每一種型別。