尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言_Generic泛型选择:用编译期类型分派实现打印宏与函数重载

C语言_Generic泛型选择:用编译期类型分派实现打印宏与函数重载 在C语言里想写一个“能打印任意类型变量”的工具函数几乎是每个C程序员的执念。C语言没有函数重载也没有模板同一个printf逻辑int写一遍double写一遍char*再写一遍等遇到结构体指针又要另想办法代码很快就变得又长又碎。C11标准推出的_Generic关键字正是用来解决这个问题的官方文档里管它叫Generic selection也就是泛型选择它能在编译期根据表达式的类型从一组代码分支中挑出对应的那一个相当于给C语言补上了“类型分派”这块短板。这篇文章我会先把_Generic的语法原理讲透然后带大家一起做一个实战打印宏再演示怎么用它模拟重载、做项目里的类型分派最后把我在实际工程里踩过的坑全部整理出来。不管你是正在学C语言的入门者还是想把老项目从C89/C99往C11靠拢的开发者都可以把这篇文章当一份能直接抄作业的参考。1. 先搞懂_Generic的语法和编译期执行逻辑1.1 为什么C语言需要“泛型选择”先说痛点。C语言没有C那样的函数重载所以同样的逻辑遇到不同类型最朴素的做法就是复制粘贴print_int、print_double、print_string一个个写。代码多倒也罢了更要命的是维护成本假设某天打印格式要统一加个前缀你得挨个改一遍漏一个就出乱子。有人会说用void*不就能通用了确实void*可以接任何指针但代价是丢类型信息。进函数之后想按float还是double处理只能靠调用者额外传一个类型枚举里外里又回到if-else或者switch分派。而且void*对整型、浮点型这类非指针类型无能为力你想写一个对int和double都通用的函数还是得靠宏或者写两遍。宏确实可以用来做通用工具比如#define MAX(a,b) ((a)(b)?(a):(b))短小精悍但是没有任何类型约束传入int和float混着比较也能编译过结果往往是警告刷屏、行为诡异。更坑的是宏参数会被多次求值MAX(i, j)这种表达式会边比较边自增结果完全不是你以为的那样。那C语言到底缺什么它缺的是一个能在编译期拿到“表达式类型”的机制然后用这个类型去做分派。这正是_Generic出现的背景。官方术语叫Generic selection直译就是“泛型选择”。它不是模板系统那么庞大的东西本质就是一个小而美的编译期类型开关——就像一个宿管阿姨看一眼你的门禁卡是哪种类型就把你放进对应的房间。1.2 语法拆解一个“编译期switch”_Generic的语法很短_Generic( 控制表达式, 关联列表 )关联列表里每项长这样类型: 表达式最后可以跟一个可选的default: 表达式。例如#include stdio.h int main(void) { int v 3; printf(%d\n, _Generic(v, int: 100, default: -1)); printf(%d\n, _Generic(3.14, int: 100, default: -1)); return 0; }第一行输出100因为v的类型是int命中int分支第二行输出-1因为3.14是double没有匹配分支落到default。整个过程在编译期完成没有任何运行时开销。用的时候必须记住五件事第一控制表达式不会被求值。它只是用来给编译器“看类型”的所以_Generic(1/0, int: 10, default: 0)不会触发除零错误非常安全。这也是_Generic和sizeof一样属于编译期操作的原因所在。第二控制表达式会经历“lvalue conversions”。数组名会被转换成指针函数名会被转换成函数指针。所以字符串字面量hello在_Generic眼里就是char *这一点后面实战会用到。第三匹配规则是“类型兼容”不会做隐式类型转换。float变量不会去匹配double分支const int变量也不会匹配int分支。这种严格性既是优点也是坑后面我专门整理了一节。第四整个_Generic表达式的结果就是被选中分支的表达式结果类型和值都跟着走。分支里可以是数值、宏、函数名甚至可以嵌套另一个_Generic。第五所有分支类型都不匹配而且没有default编译期直接报错。错误信息在不同编译器里不完全一样但哪怕闭着眼也能猜到大概意思就是“控制表达式类型与任何泛型关联类型都不兼容”。把上面这些规则收成一张表方便你随时回看规则说明不求值控制表达式只用来判定类型运行时不会执行自动转换数组和函数会退化为指针后再判断严格匹配不做隐式类型转换qualified类型严格区分表达式语义_Generic是一个表达式不是语句缺失default全部不匹配且无default时报编译错误2. 上手实操做一个能自动识别类型的DEBUG打印宏2.1 第一步用TYPE_NAME宏识别基础类型现在进入实操环节。最经典的用法之一是做一个能回传类型名字符串的宏比如调试日志里想顺便看一眼变量到底是个什么类型。#include stdio.h #define TYPE_NAME(x) _Generic((x), \ _Bool: _Bool, \ char: char, \ signed char: signed char, \ unsigned char: unsigned char, \ short: short, \ unsigned short: unsigned short, \ int: int, \ unsigned int: unsigned int, \ long: long, \ unsigned long: unsigned long, \ long long: long long, \ unsigned long long: unsigned long long, \ float: float, \ double: double, \ long double: long double, \ char *: char *, \ default: other) int main(void) { int i 42; double d 3.14; float f 2.5f; char *s hello; printf(%s\n, TYPE_NAME(i)); // int printf(%s\n, TYPE_NAME(d)); // double printf(%s\n, TYPE_NAME(f)); // float printf(%s\n, TYPE_NAME(s)); // char * printf(%s\n, TYPE_NAME(str)); // char *字符串字面量自动退化 return 0; }这里我故意把整数类型拆得很细signed char和unsigned char分开short和unsigned short分开目的就是验证一个点在_Generic看来int、unsigned int、long这些都是完全不同的类型不写对应分支就匹配不上。这也是它和C重载决议最大的区别C会按转换代价做评级_Generic只认字面类型。有读者可能问printf(%d, TYPE_NAME(i))这种写法行不行不行因为分支里返回的是const char *字符串指针必须用%s。这个小问题我在初学时也栽过看到输出乱码才发现是格式串用错了。2.2 第二步根据类型自动调用对应打印函数能识别类型了下一步就是让不同类型走不同的输出逻辑。这里有个很自然的思路_Generic每个分支放一个函数名选中了就直接调用它。#include stdio.h static void print_int(int v) { printf(int : %d\n, v); } static void print_double(double v) { printf(double : %.6f\n, v); } static void print_string(const char *s) { printf(string : %s\n, s); } #define PRINT_VALUE(x) _Generic((x), \ int: print_int, \ double: print_double, \ char *: print_string, \ const char *: print_string \ )(x) int main(void) { int n 42; double d 3.14159; char buf[] hello; const char *msg world; PRINT_VALUE(n); PRINT_VALUE(d); PRINT_VALUE(hello); PRINT_VALUE(msg); return 0; }注意看PRINT_VALUE展开后就是_Generic(...)(x)前半段选择函数名后半段把参数传进去非常巧妙地组合出了“重载”效果。这里有两个关键细节。第一我同时写了char *和const char *两个分支因为字符串字面量在C里类型是char[N]退化后是char *而const char *是一个带限定符的指针类型两者不能互相匹配。如果一个接口同时接收这两种变量漏掉任何一个分支都会落到default或者直接编译失败。第二分支里放的是函数名所以被选中的分支自然返回该函数的调用结果整个_Generic表达式实际等价于一次函数调用。如果你在vscode里配置了C语言环境把这段代码用gcc -stdc11 -Wall test.c -o test编译运行会输出五条带类型前缀的日志。实际调试时我会把这个宏再包一层顺便把变量名也打印出来#define DEBUG_PRINT(x) do { \ printf(%s , #x); \ PRINT_VALUE(x); \ } while (0)#x是预处理字符串化可以把n变成字符串n。配合_Generic一个调试日志宏就成型了完全是编译期选路不发散、不if-else、不损失性能。3. 进阶用_Generic模拟重载与做类型分派3.1 模拟加法重载以及更安全的SWAPC语言没有重载但业务逻辑里经常会出现“同一个语义操作不同类型实现不同”的情况比如加法、序列化、拷贝。_Generic可以做到外观上的重载。#include stdio.h static int add_int(int a, int b) { return a b; } static double add_double(double a, double b) { return a b; } static float add_float(float a, float b) { return a b; } #define ADD(a, b) _Generic((a), \ int: add_int, \ double: add_double, \ float: add_float \ )((a), (b)) int main(void) { printf(%d\n, ADD(1, 2)); printf(%f\n, ADD(1.5, 2.5)); printf(%f\n, ADD(2.5f, 1.5f)); return 0; }这个宏按(a)的类型选函数。ADD(1,2)命中add_intADD(1.5,2.5)命中add_doubleADD(2.5f,1.5f)命中add_float外观上确实和C的函数重载差不多。但必须说清楚这里的“重载”只是表象_Generic不会为你做参数隐式转换。ADD(1, 2.5)会命中add_int第二个参数从double隐式转成int编译器给出警告精度丢掉也没人负责。所以这种重载本质上是“按第一个参数类型精确分派”调用时得保证参数类型匹配或者把常用的类型分支全列出来。除了加法SWAP也是经典案例。传统宏版SWAP长这样#define SWAP(a, b) do { \ __typeof__(a) _tmp (a); \ (a) (b); \ (b) _tmp; \ } while (0)__typeof__是GNU扩展能自动推导类型非常适合交换两个同类型变量。但它的类型检查很弱SWAP(foo, str)如果两个变量类型不匹配_tmp会跟着第一个参数走赋值时可能有截断或者警告。如果非要严格限定“只能交换特定类型”可以改成_Generic分派static void swap_int(int *a, int *b) { int t *a; *a *b; *b t; } static void swap_double(double *a, double *b) { double t *a; *a *b; *b t; } static void swap_str(char **a, char **b) { char *t *a; *a *b; *b t; } #define SWAP_TYPED(a, b) _Generic((a), \ int: swap_int, \ double: swap_double, \ char *: swap_str \ )((a), (b))这样传入不支持的类型的变量编译期就报错而不是等运行时报出诡异的数值错误。当然具体用哪个版本取决于你更想要“通用性”还是“类型约束”没有标准答案工程上我会按调用方的可控程度来选择。3.2 与typeof组合类型安全的LOG封装和MAX/MIN_Generic的单点分派能力配合__typeof__推导能写出比普通宏更安全的工具集。实际项目里用得最多的组合是一个带变量名的日志宏#define LOG_PRINT(x) do { \ printf(%s , #x); \ _Generic((x), \ int: printf(%d\n, (x)), \ unsigned int: printf(%u\n, (x)), \ long: printf(%ld\n, (x)), \ float: printf(%f\n, (x)), \ double: printf(%f\n, (x)), \ char *: printf(%s\n, (x)), \ const char *: printf(%s\n, (x)) \ ); \ } while (0)注意这里有个容易忽略的点printf本身就是个变参函数float在传给变参时会被自动转成double。但_Generic的匹配发生在宏展开阶段它拿到的是x的原始类型所以float变量依然会选中float分支。换句话说_Generic不会受到变参提升的干扰这比在函数内部判断类型要灵活得多。我维护过一个给嵌入式设备用的简易Logger最初打印传感器数据是写一串if-else按类型分派后来用_Generic把uint8_t、int16_t、float、const char *统一成一个接口调用侧只写LOG_PRINT(temp)就够了。代码行数并没有少特别多但改格式、加类型、查遗漏都舒服多了少了那种“复制粘贴一整片”的窒息感。MAX/MIN这类工具宏也可以走类似思路。比如#define MAX(a, b) ({ \ __typeof__(a) _a (a); \ __typeof__(b) _b (b); \ _a _b ? _a : _b; \ })这是GNU statement expression的写法最大的好处是每个参数只求值一次避免了传统宏MAX(i, j)的副作用问题。如果你不想依赖GNU扩展也可以写成严格的C11加_Generic版本前提是你能接受对每种要支持的类型写一个比较函数。这种取舍没有绝对的对错主要是根据项目的可移植性目标来定。3.3 在嵌入式与序列化项目里的真实用法嵌入式C项目恰恰是最缺“类型友好”编码方式的场景。很多从老单片机转过来的朋友一开始写C11代码时还习惯用一堆switch-case处理类型映射其实_Generic可以做得更集中。比如我处理过这么一个问题从不同的传感器拿到不同基础类型的数据有的通道返回uint16_t有的返回float有的返回字符串如果要统一上报到协议层最常见的写法就是一堆switch-case把每个数据类型手工映射一遍。用_Generic可以把这个映射集中到一个宏里#define SENSOR_PACK(buf, val) _Generic((val), \ uint8_t: pack_u8, \ uint16_t: pack_u16, \ int16_t: pack_i16, \ float: pack_f32, \ const char *: pack_str \ )((buf), (val))这样做的好处非常直接新增一种类型时只需要在这个宏里加一行分支并实现对应的pack函数调用侧完全不用动。对于协议解析、JSON序列化这类场景这个模式几乎可以套用一切“类型到处理方式”的映射。我个人的体会是_Generic最适合的工程位置就是“站在宏和函数之间”。它保留了宏的源代码免形和调用精简又把类型判断交给了编译器比人肉switch-case可靠太多。不过也不要过度设计如果你只有一个类型需要分派直接写个普通函数更清爽。4. 常见问题与调试技巧实录4.1 编译错误速查表用_Generic写代码编译器的报错往往不像运行时错误那样好查。我把最常碰到的几类问题整理成一张速查表报错/现象原因解决办法controlling expression type xxx not compatible with any generic association type传入类型没有对应分支且没有default补上该类型分支或加default兜底duplicate defaultdefault写了多次一个_Generic里只能有一个defaultexpected expression把_Generic当语句用了比如放在声明位置确认它是表达式只能出现在能放表达式的地方某些类型分支里的标识符未声明_Generic所有分支都会做基本语义检查即使不会执行保证每个分支里的函数、变量都已声明或包含头文件分支类型重复关联列表里同一类型重复出现删除重复类型区分int与const int可同时存在type name in generic association must represent a complete object type关联列表中写了不完整的类型或不允许的类型使用完整类型函数类型、变长数组不可以这里要特别说明一下第三行之前那条_Generic虽然运行时只执行选中的分支但编译器在语义检查阶段会检查所有分支未选中分支里的函数如果没声明照样报错。这个坑我在给宏里放一个“待实现函数”时踩过当时还以为编译期短路能帮我跳过不存在的函数。实际不行每个分支的表达式都必须是一个合法的表达式。4.2 类型匹配的细节坑const、枚举、字符串类型匹配是_Generic最容易踩坑的地方我单独列一节。第一个坑是字符串字面量。hello在C里的类型是char[6]经过_Generic的lvalue conversion之后变成char *所以能命中char *分支。但如果你传递的是一个指向const字符的指针变量比如const char *q它就需要const char *分支因为char *和const char *在类型匹配里是两种完全不同的指针类型不存在“自动放宽 const”这回事。第二个坑是const和volatile。_Generic区分qualified类型变量声明为const int你想匹配int分支是匹配不上的。所以接口如果可能同时接收int和const int得把两个分支都写上。我一度觉得这个设计多余后来想想反而是好事它逼你在类型分派时显式表达意图避免无意识的限定符丢失。第三个坑是枚举类型。枚举的底层类型由编译器决定可能是int也可能是有符号或无符号整型。所以在枚举变量上做_Generic匹配时不要直接用int分支去赌很多人在不同编译器上得到不同结果。稳妥做法是给枚举单独设计分支或者干脆用default兜底然后内部做二次判断。第四个坑是指针类型不通用。void *分支不会自动匹配int *unsigned int *也不会匹配int *。_Generic的匹配严格到指针指向的类型也必须一致指望泛型指针自动适配是不现实的。把典型匹配行为列一个表方便对照表达式类型可以匹配的分支hello字符串字面量char *分支const char *q变量const char *分支const int v变量const int分支不会匹配intint *p变量int *分支不会匹配void *float f变量float分支不会因为变参提升去匹配double枚举变量根据底层类型建议用default兜底4.3 编译环境与C11兼容性配置_Generic是C11标准特性所以首先得让编译器开启C11模式。这件事在vscode里写C语言的新手最容易忽略默认GCC的gnu17或gnu11一般没问题但如果你的构建脚本写的是c99编译_Generic就会报一堆看不懂的错。命令行编译很简单gcc -stdc11 -Wall -Wextra -o test test.cVSCode里如果使用Code Runner可以在settings.json里配置code-runner.executorMap: { c: cd $dir gcc -stdc11 -Wall $fileName -o $fileNameWithoutExt $dir$fileNameWithoutExt }如果用的是tasks.json则在args里加上-stdc11即可。MSVC用户注意VS2019 16.8及之后的版本才支持/std:c11旧版本对_Generic的支持不稳定最好先升级工具链。嵌入式也是同样的道理GCC 4.9及以上才对_Generic提供了良好支持ARM公司的armclangAC6可以老牌的AC5编译器默认只支持到C99用不了。现在主流的ESP-IDF使用的xtensa工具链版本都比较新默认gnu11左右直接能用但如果你维护的是给老8位单片机用的代码编译器可能还停留在C89时代那就别想了老老实实用传统分支。如果代码需要在C99和C11之间保持双兼容可以用预处理宏做个开关#if defined(__STDC_VERSION__) __STDC_VERSION__ 201112L #define HAS_GENERIC 1 #else #define HAS_GENERIC 0 #endif在C11环境下走_Generic方案在旧环境里回退到老宏这样升级老项目时风险会小很多。4.4 什么时候不应该用_Generic最后说点反过来的经验。_Generic很强大但并非万能更不是所有场景用了都加分。如果你还在维护只支持C89/C99的旧工程或者工具链版本很老那_Generic根本没机会上场强行引入只会换来一堆环境适配工作。如果分支数量超过十几个比如想覆盖整型、浮点、指针、结构体十几种类型一个宏里堆满分支可读性和维护性会迅速恶化。这时候不如回到函数指针表或者用宏生成代码把逻辑拆清晰。另一个明显不适合的场景是运行时动态类型。_Generic的一切推断都发生在编译期它不知道也不关心变量运行时可能是什么。如果你处理的是反序列化后的数据类型要到运行时才能确定那应该用结构体加函数指针做虚表式的多态而不是在编译期写死分支。还要提醒一点_Generic不能替代C的模板。它没有推导、没有实例化、没有编译期递归展开它只会根据类型“挑”一个预先写好的结果。所以如果你脑子里想的泛型是“写一个通用算法自动适用于任何类型”那_Generic帮不了你它就是类型分派开关定位完全不同。我个人的习惯是类型种类少且稳定面向编译期分派调用特别频繁想省掉运行时开销——用_Generic类型种类多、扩展频繁、需要运行时决策——用函数指针表或设计一个更结构化的方案。写到这里把_Generic从语法、原理到实战和避坑都过了一遍。坦白说我最早看到这个关键字时觉得它不过是个语法糖直到在一个需要频繁打印多类型传感器数据的项目里用它把一大串if-else压缩成一个宏才真正体会到“编译期类型分派”带来的安全感类型匹配不到就编译失败总比运行时打出一堆乱码好查得多。如果你正打算在项目里引入_Generic我建议从小范围开始先拿日志打印、序列化这种类型稳定且分支清晰的场景练手等熟悉了匹配规则再往更复杂的地方扩散。最后再分享一个实用小技巧分支列表里把最常用的类型放前面default放最后每次新增类型时顺手加一行分支并确保对应函数声明在前这个宏会一直保持很好用。
返回列表