9、宏的副作用与陷阱:参数多次求值问题,宏与分号,宏与优先级,宏与类型安全
宏这个东西,用好了是神器,用不好就是定时炸弹。我见过太多项目因为一个宏的坑,排查了整整两天。今天咱们就把这些坑一个个踩一遍,以后你看到宏,心里就有数了。
9.1 参数多次求值问题——最常见的坑
先看个例子。你写了个宏,想求两个数的最大值:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
看起来没问题对吧?但你看这个调用:
int x = 5, y = 10;
int result = MAX(x++, y++);
你期望的结果是什么?x++ 和 y++ 各执行一次?错!宏展开后是这样的:
int result = ((x++) > (y++) ? (x++) : (y++));
看到了吗?x++ 和 y++ 被求值了两次!x 从5变成7,y从10变成12。结果呢?result 是11,但 x=7, y=12。跟你预期的完全不一样。
怎么解决?其实很简单:
- 用内联函数代替宏——C99 以后的内联函数是类型安全的,参数只求值一次
- 如果非要用宏,用临时变量——GCC 的 ({...}) 扩展可以做到
// GCC 扩展写法,参数只求值一次
#define MAX_SAFE(a, b) ({ \
__typeof__(a) _a = (a); \
__typeof__(b) _b = (b); \
_a > _b ? _a : _b; \
})
我个人习惯是:能不用宏就不用宏。尤其是涉及运算的,直接写函数更省心。
9.2 宏与分号——少一个多一个都是事
你写过这样的宏吗?
#define PRINT_ERR(msg) printf("Error: %s\n", msg);
注意最后有个分号。用的时候:
if (condition)
PRINT_ERR("something wrong");
else
// 正常处理
展开后变成:
if (condition)
printf("Error: %s\n", "something wrong");
;
else
// 正常处理
看到那个孤零零的分号了吗?else 前面有个空语句,编译直接报错!
#define PRINT_ERR(msg) printf("Error: %s\n", msg)
// 使用时
if (condition)
PRINT_ERR("something wrong"); // 这里加分号
else
// ...
但还有另一种情况。如果你想让宏像函数一样使用,可以这样:
#define DO_SOMETHING() do { \
printf("doing...\n"); \
fflush(stdout); \
} while(0)
这个 do-while(0) 技巧,说白了就是让宏可以安全地加分号,而且不会跟 if-else 打架。我几乎所有的多语句宏都用这个模式。
9.3 宏与优先级——括号不能省
这个坑太经典了。写个平方宏:
#define SQUARE(x) x * x
然后你调用:
int a = SQUARE(3 + 2);
你以为是 5*5=25?错!展开后是:
int a = 3 + 2 * 3 + 2; // 结果是 3 + 6 + 2 = 11
为什么会这样?因为宏只是文本替换,不是函数调用。运算符优先级在这里搞鬼了。
#define SQUARE(x) ((x) * (x))
再比如:
#define CUBE(x) ((x) * (x) * (x))
你可能会想,这样总安全了吧?但注意:
int b = 100 / CUBE(5);
展开后:
int b = 100 / ((5) * (5) * (5)); // 结果是 100 / 125 = 0
嗯,整数除法又给你上了一课。所以我说,宏的坑是连环的,一个接一个。
9.4 宏与类型安全——宏不认识类型
宏就是文本替换,它根本不关心你传进去的是什么类型。看这个:
#define IS_EVEN(n) ((n) % 2 == 0)
你传个浮点数试试:
double d = 3.14;
if (IS_EVEN(d)) { // 浮点数取模?编译警告或错误!
// ...
}
或者传个指针:
char *p = "hello";
if (IS_EVEN(p)) { // 指针取模?未定义行为!
// ...
}
函数就不一样了。函数有参数类型检查,编译器会帮你拦住这些错误。
- 简单常量——用宏(如 #define PI 3.14159)
- 带参数的运算——用内联函数或普通函数
- 需要类型泛化的——C11 的 _Generic 或者 C++ 的模板
举个例子,用 _Generic 实现类型安全的宏:
#define IS_EVEN(x) _Generic((x), \
int: ((x) % 2 == 0), \
unsigned int: ((x) % 2 == 0), \
long: ((x) % 2 == 0), \
default: 0 \
)
这样传浮点数或指针时,会走 default 分支返回 0,至少不会编译报错。但说实话,这种写法可读性差,我一般不用。真要类型安全,直接写函数。
9.5 知识体系总览
下面这张图把宏的陷阱和应对策略梳理了一下,方便你记忆:
9.6 避坑指南总结
说了这么多,我总结几条实战经验:
- 带参数的宏,参数和整体都要加括号——这是最低要求,没得商量
- 多语句宏用 do-while(0) 包裹——解决分号问题,兼容 if-else
- 不要传带副作用的参数给宏——比如 x++, func() 这种
- 能用内联函数就别用宏——C99 的 inline 关键字就是干这个的
- 宏名全大写——这是约定,提醒别人「这是个宏,小心点」
宏这个东西,说白了就是文本替换。它不知道类型,不关心作用域,也不管你传了什么表达式。你把它当函数用,它就会给你挖坑。记住一句话:宏是刀,函数是盾。刀能切菜也能伤手,盾虽然笨重但安全。
公众号:蓝海资料掘金营,微信deep3321