C语言宏函数的优先级陷阱:一个 MAX 宏的六级进化
最近研究了一下 MAX 宏的进化史:从青铜到王者共 6 个版本(define0.c ~ define5.c),完整记录了一个三目运算符宏是怎么被优先级、副作用、类型问题一步步逼着进化的。
先上本篇的理论基础——C 语言运算符优先级表(16 级,本篇 MAX 宏的悲剧就发生在第 7 级 > 和第 8 级 != 之间):
| 优先级 | 运算符 | 结合律 |
|---|---|---|
| 1 | 后缀运算符:[] () . -> ++ -- (类型名称){列表} |
从左到右 |
| 2 | 一元运算符:++ -- ! ~ + - * & sizeof _Alignof |
从右到左 |
| 3 | 类型转换运算符:(类型名称) |
从右到左 |
| 4 | 乘除法运算符:* / % |
从左到右 |
| 5 | 加减法运算符:+ - |
从左到右 |
| 6 | 移位运算符:<< >> |
从左到右 |
| 7 | 关系运算符:< <= > >= |
从左到右 |
| 8 | 相等运算符:== != |
从左到右 |
| 9 | 位运算符 AND:& |
从左到右 |
| 10 | 位运算符 XOR:^ |
从左到右 |
| 11 | 位运算符 OR:| |
从左到右 |
| 12 | 逻辑运算符 AND:&& |
从左到右 |
| 13 | 逻辑运算符 OR:|| |
从左到右 |
| 14 | 条件运算符:?: |
从右到左 |
| 15 | 赋值运算符:= += -= *= /= %= &= ^= |= <<= >>= |
从右到左 |
| 16 | 逗号运算符:, |
从左到右 |
青铜:define0.c——裸奔的参数
1 |
看起来没问题?调用 MAX(1 != 1, 1 != 2),先做纯文本替换:
1 | int result = 1 != 1 > 1 != 2 ? 1 != 1 : 1 != 2; |
然后按优先级推理:> 高于 !=,所以先算 1 > 1 得 0:
1 | 1 != 0 != 2 ? 0 : 1 |
明明想比较两个表达式的大小,结果完全是另一回事。
白银:define1.c——参数加括号
1 |
再调用 3 + MAX(1, 2),展开:
1 | int result = 3 + (1) > (2) ? (1) : (2); |
参数括号解决了参数内部的优先级问题,但整个宏表达式没有括号,和外层运算结合时照样出错。
黄金:define2.c——整体括号与新坑:副作用
1 |
整体加括号后,优先级问题彻底解决。但新的坑出现了——带副作用的参数:
1 | int i = 1, j = 0; |
i++ 在展开后被执行了两次,算出来的结果总是偏大。参数带副作用时,宏不管怎么加括号都救不了——这是宏的天生缺陷。
铂金:define3.c——GNU 语句表达式
1 |
这是 GCC 的 语句表达式(statement expression) 扩展:({ ... }) 里可以写多条语句,最后一行的值就是表达式的值。先把参数存进中间变量 _x、_y,副作用参数只求值一次,问题解决。
代价有两个:类型写死成了 int(传 float 直接出错,MAX(3.65, 3.68) 按整数比较);而且这是 GNU 扩展,牺牲了可移植性。
钻石:define4.c——把类型也做成参数
1 |
|
把类型提为宏的第一个参数,调用方自己声明比较的类型——泛型的雏形出现了。当年写到这里的时候还开玩笑说”某天公司来了个 Python 程序员一起 review 代码逻辑”。
王者:define5.c——typeof 自动推导
1 |
终极形态:typeof(x) 自动获取传入值的类型,连类型参数都不用传了。倒数第二行的 (void)(&_x == &_y) 是一个经典技巧——当 _x 和 _y 类型不同时,比较两个不同类型指针的地址会触发编译警告,相当于内建了一道类型检查。
注:原始笔记里这一段有个笔误(两处都写成了
(y)),整理时已修正为(x)/(y)。
复盘:六级进化各解决了什么
| 版本 | 写法 | 解决的问题 | 遗留问题 |
|---|---|---|---|
| 青铜 | x > y ? x : y |
—— | 优先级灾难 |
| 白银 | (x) > (y) ? (x) : (y) |
参数优先级 | 整体优先级 |
| 黄金 | ((x) > (y) ? (x) : (y)) |
整体优先级 | 参数副作用 |
| 铂金 | 语句表达式 + 中间变量 | 参数副作用 | 类型写死 |
| 钻石 | MAX(type, x, y) |
类型可指定 | 调用变啰嗦 |
| 王者 | typeof(x) |
类型自动推导 | —— |
宏和函数的本质区别就藏在这次进化里:宏是文本替换,函数是值传递。理解了这条,预处理器的一切怪行为都有了解释。
