67、_Generic 关键字:C11 的泛型选择

说实话,C语言在泛型编程这块一直挺“原始”的。C++有模板,Java有泛型,而C呢?以前我们只能靠宏、void*指针、或者写一堆重名函数来凑合。直到C11标准引入了 _Generic 关键字,情况才有所改观。

我第一次接触 _Generic 是在一个嵌入式项目里。当时要写一个打印函数,能处理 int、float、char* 等多种类型。按老办法,我得写三个不同的函数名:print_intprint_floatprint_str。调用时还得自己记着用哪个,烦得很。后来翻C11标准发现了 _Generic,嗯,这玩意儿就是C语言自己的“轻量级泛型”。

什么是 _Generic?

_Generic 是C11引入的一个关键字。它能在编译期根据表达式的类型,选择不同的结果。说白了,就是“类型匹配,值替换”。

它的语法长这样:

_Generic(控制表达式, 类型1: 结果1, 类型2: 结果2, ..., default: 结果N)

控制表达式的类型在编译期确定。然后编译器会去匹配后面的类型列表。匹配上了,就返回对应的结果。如果都没匹配上,就走 default

核心要点:_Generic 是编译期行为,不是运行时。它不会产生任何额外的开销。你想想看,这比C++的模板还要轻量——模板好歹会实例化出多份代码,而 _Generic 只是简单的宏替换。

基本用法示例

先看一个最简单的例子。我想根据参数类型返回不同的字符串描述:

#include <stdio.h>

#define TYPE_DESC(x) _Generic((x), \
    int: "整数类型", \
    float: "浮点类型", \
    double: "双精度浮点", \
    char*: "字符串", \
    default: "未知类型" \
)

int main() {
    int a = 10;
    float b = 3.14f;
    double c = 2.718;
    char *d = "hello";

    printf("%s\n", TYPE_DESC(a));  // 输出: 整数类型
    printf("%s\n", TYPE_DESC(b));  // 输出: 浮点类型
    printf("%s\n", TYPE_DESC(c));  // 输出: 双精度浮点
    printf("%s\n", TYPE_DESC(d));  // 输出: 字符串

    return 0;
}

这里 _Generic 配合宏使用,效果非常自然。宏的参数 x 被传入 _Generic 的控制表达式,编译器根据 x 的实际类型选择对应的字符串。

我的习惯:我个人喜欢把 _Generic 包装在宏里使用。直接写 _Generic 的代码可读性差,宏能给它一个更友好的名字。比如上面我用了 TYPE_DESC,一看就知道是“类型描述”。

实现泛型函数

这才是 _Generic 的真正用武之地。我们可以用它实现一个“看起来像重载”的函数。比如一个通用的最大值函数:

#include <stdio.h>

int max_int(int a, int b) { return a > b ? a : b; }
float max_float(float a, float b) { return a > b ? a : b; }
double max_double(double a, double b) { return a > b ? a : b; }

#define MAX(x, y) _Generic((x), \
    int: max_int, \
    float: max_float, \
    double: max_double \
)(x, y)

int main() {
    int a = 5, b = 10;
    float c = 3.14f, d = 2.71f;
    double e = 1.618, f = 2.718;

    printf("max_int: %d\n", MAX(a, b));       // 输出: 10
    printf("max_float: %f\n", MAX(c, d));     // 输出: 3.140000
    printf("max_double: %f\n", MAX(e, f));    // 输出: 2.718000

    return 0;
}

注意看,_Generic 返回的是一个函数指针,然后紧接着用 (x, y) 调用它。这种写法非常紧凑,但也很容易让人看晕。我建议你拆开理解:

  1. _Generic((x), ...) 根据 x 的类型,返回对应的函数名(比如 max_int
  2. 然后 (x, y) 调用这个函数

合起来就是:根据第一个参数的类型,调用对应的重载函数。

注意:_Generic 只能根据控制表达式的类型做选择,不能根据多个参数的类型做组合匹配。比如你想根据两个参数的类型选择不同的函数,_Generic 做不到。它只能看一个表达式的类型。

类型限定符的处理

这里有个坑,我曾经踩过。看下面的代码:

#include <stdio.h>

#define TYPE_CHECK(x) _Generic((x), \
    int: "int", \
    const int: "const int", \
    volatile int: "volatile int", \
    default: "other" \
)

int main() {
    int a = 10;
    const int b = 20;
    volatile int c = 30;

    printf("%s\n", TYPE_CHECK(a));  // 输出: int
    printf("%s\n", TYPE_CHECK(b));  // 输出: const int
    printf("%s\n", TYPE_CHECK(c));  // 输出: volatile int

    return 0;
}

为什么会这样?因为 _Generic 会保留表达式的类型限定符。如果你传一个 const int 进去,它不会自动降级为 int。这一点和函数参数传递不同——函数参数传 const int 时,函数内部看到的是 int(因为传值会忽略顶层 const)。

_Generic 不这么做。它严格匹配类型,包括限定符。

避坑指南:我曾经在写一个日志宏时,因为没注意 const 限定符,导致 _Generic 匹配到了 default 分支。排查了半天才发现是 const 的问题。从那以后,我写 _Generic 时都会把带限定符的类型也列上,或者干脆用 typeof 去掉限定符(如果编译器支持 GNU 扩展的话)。

_Generic 与 typeof 配合

GCC 和 Clang 都支持 typeof 扩展。我们可以用它来“剥掉”类型限定符:

#include <stdio.h>

#define STRIP_QUALIFIERS(x) _Generic((x), \
    int: "int", \
    const int: "int", \
    volatile int: "int", \
    const volatile int: "int", \
    default: "other" \
)

int main() {
    const int a = 10;
    volatile int b = 20;
    const volatile int c = 30;

    printf("%s\n", STRIP_QUALIFIERS(a));  // 输出: int
    printf("%s\n", STRIP_QUALIFIERS(b));  // 输出: int
    printf("%s\n", STRIP_QUALIFIERS(c));  // 输出: int

    return 0;
}

这样写虽然啰嗦,但能保证匹配的准确性。如果你用的编译器支持 typeof,还可以更优雅:

#define TYPE_OF(x) _Generic((x), \
    typeof(int): "int", \
    typeof(float): "float", \
    typeof(double): "double", \
    default: "other" \
)

不过 typeof 不是标准C,移植性会差一些。嵌入式项目里如果只针对 GCC,那没问题。

知识体系图

下面这张图总结了 _Generic 的核心逻辑和使用场景:

_Generic 关键字知识体系 _Generic 泛型选择 语法结构 _Generic(expr, type1: result1, default: resultN) 编译期行为 零运行时开销 类型严格匹配 保留限定符 应用场景 泛型函数(宏包装) 类型描述/打印 类型安全检查 注意事项 只能匹配单一表达式类型 注意 const/volatile 限定符 最佳实践 配合宏使用,提升可读性 用 typeof 剥去限定符

实际项目中的用法

我在嵌入式项目中常用 _Generic 来做“类型安全”的寄存器操作。比如,有些寄存器是8位的,有些是16位的,有些是32位的。写一个通用的读写宏:

#include <stdint.h>

#define REG_READ(reg) _Generic((reg), \
    volatile uint8_t*:  (*(volatile uint8_t*)(reg)),  \
    volatile uint16_t*: (*(volatile uint16_t*)(reg)), \
    volatile uint32_t*: (*(volatile uint32_t*)(reg)), \
    default: (*(volatile uint32_t*)(reg)) \
)

#define REG_WRITE(reg, val) _Generic((reg), \
    volatile uint8_t*:  (*(volatile uint8_t*)(reg) = (val)),  \
    volatile uint16_t*: (*(volatile uint16_t*)(reg) = (val)), \
    volatile uint32_t*: (*(volatile uint32_t*)(reg) = (val)), \
    default: (*(volatile uint32_t*)(reg) = (val)) \
)

这样,不管传进来的是哪种指针类型,都能自动匹配正确的读写宽度。而且编译器会帮你检查类型——如果你传了一个 volatile uint64_t*,它会走 default 分支,至少不会编译出错。

我的建议:如果你在写跨平台代码,或者需要给不同架构的MCU提供统一接口,_Generic 是个好工具。但别滥用。它只适合“类型数量有限且固定”的场景。如果类型太多,_Generic 的匹配列表会变得又臭又长,不如用函数指针表或者代码生成器。

总结

_Generic 是C11给C语言带来的一股清流。它让C有了“编译期泛型”的能力,而且零开销。虽然功能有限(只能匹配单一表达式),但在很多场景下已经够用了。

记住几个要点:

  • 它是编译期行为,不是运行时
  • 类型匹配是严格的,包括 const/volatile 限定符
  • 最好配合宏使用,提升可读性
  • 适合类型数量有限且固定的场景

嗯,_Generic 就讲到这里。下次你写重名函数时,不妨想想能不能用 _Generic 一把梭了。


公众号:蓝海资料掘金营,微信deep3321