所谓的宏扩展就是代码替换,这部分内容也是我想表达的主要内容。宏扩展最大的好处有如下几点:
- 减少重复的代码;
- 完成一些通过 C 语法无法实现的功能(字符串拼接);
- 动态定义数据类型,实现类似 C++ 中模板的功能;
- 程序更容易理解、修改(例如:数字、字符串常量);
我们在写代码的时候,所有使用宏名称的地方,都可以理解为一个占位符。在编译程序的预处理环节,这些宏名将会被替换成宏定义中的那些代码段,注意:仅仅是单纯的文本替换。
1. 最常见的宏
为了方便后面的描述,先来看几个常见的宏定义:
(1) 数据类型的定义
在数据类型定义中,需要注意的一点是:如果你的程序需要用不同平台下的编译器来编译,那么你要去查一下所使用的编译器对这些宏定义控制的数据类型是否已经定义了。例如:在 gcc 中没有 BOOL 类型,但是在 MSVC 中,把 BOOL 类型定义为 int 型。
(2) 获取最大、最小值
详解
先给结论
MAX/MIN 这两个宏的写法不是审美选择,是被两个硬约束逼出来的:宏是纯文本替换(不是函数调用),而调用者传入的实参可以是任意表达式。只要接受这两个前提,括号加在哪、为什么不能是别的写法,都能一步步推出来,不需要死记。第一步:拆解结构,数清楚这里一共有几层括号
以
MAX(a, b) 为例,展开体里有 3 层括号,各管各的事:层级 | 写法 | 保护对象 |
内层 | (a)、(b) | 每个参数各自的完整性 |
中层 | (a) > (b) | 比较结果作为整体 |
外层 | ((...)?(...):(...)) | 整个宏展开结果作为整体 |
去掉任何一层,都会在某种调用场景下出错。逐层验证:
第 1 层:为什么参数 a、b 自己要加括号
宏展开是纯文本替换,编译器不知道你"意图上"想把
a 当作一个整体,它只会照抄文本。C 语言的运算符优先级里,关系运算符 > 的优先级比按位运算符 & 高——这是个反直觉但真实存在的规则。假设去掉内层括号:
调用
MAX(x & mask, y),文本替换后变成:因为
> 优先级高于 &,这会被解析成 x & (mask > y),跟你想表达的 (x & mask) > y完全是两回事——宏在悄悄改变你的语义,而且不报错,运行期才会发现结果不对。加上 (a)、(b) 之后,无论调用者传进来什么表达式(哪怕是 x & mask、x = 1、逗号表达式),它都被强制封装成一个不可分割的原子,外部运算符再也插不进来干扰它内部的结合方式。第 2 层:为什么比较结果 (a) > (b) 本身不需要额外处理
这一层其实不需要单独加括号来"保护",因为
?: 的条件部分天然就是一个完整子表达式,> 的两个操作数已经被第 1 层括号锁死了。真正需要额外括起来的是下一层。第 3 层:为什么整个三元表达式外面还要包一层括号
三元运算符
?: 在 C 里优先级极低,比几乎所有算术、比较、位运算符都低。如果最外层不加括号:调用
2 * MAX(a, b),展开后是:乘法
* 优先级高于三元运算符的条件判断部分,于是解析成:也就是说,
2 * 只乘到了条件判断里的 a,根本没有乘到最终返回值上。这是个典型的"宏在没有语法错误的情况下悄悄算错"的陷阱——编译器不会报错,因为它是完全合法的表达式,只是不是你想要的那个。加上最外层括号后,宏展开结果作为一个原子值,外部任何运算符都只能整体作用于它,不能伸进内部拆解。所以这三层括号不是冗余,是分别防御三种不同的入侵路径:调用者的参数、宏内部的比较、宏外部的上下文。少一层就少一道防线。
第二步:为什么用三元表达式 ?:,不能写成 if-else
if-else 是语句,不是表达式,没有值。而宏的使用场景要求它必须能出现在任何"需要一个值"的位置:用
if-else 替换后这两处直接编译失败。三元表达式是 C 语言里唯一能做条件分支同时产出值的语法结构,这不是风格选择,是类型系统逼的。第三步:这个写法解决不了的问题——它天生的代价
把括号都补齐之后,这个宏依然有一个结构性缺陷,且无法通过加括号修复:
展开成:
i++ 和 j++ 各自出现了两次——一次在比较里,一次在返回值里。这意味着无论走哪个分支,都会有一个变量被自增两次。这是参数被文本复制导致的重复求值,和括号加不加没关系,是"宏 = 文本替换"这个机制本身的代价。函数调用不会有这个问题,因为参数只求值一次、传入栈帧。同理,宏也不做类型检查:
MAX(int_val, pointer_val) 这种类型不匹配的调用,宏毫无异议地展开,能不能编译过取决于 > 运算符本身能不能比较这两个类型,而不是宏帮你把关。如果真的要同时解决重复求值和类型安全,标准 C 宏做不到,得用 GCC 扩展(语句表达式 +
typeof):用局部变量把
a、b 先求值一次存起来,规避重复求值,typeof 自动推导类型。代价是牺牲了可移植性——({...}) 语句表达式是 GCC/Clang 扩展,标准 C 里没有,MSVC 编译不过。这就是页面里"宏 vs 函数"那段提到的取舍在这里的具体体现:三层括号版本是可移植但有重复求值风险的方案,typeof 版本是安全但绑定编译器的方案,没有免费的午餐。[1]一句话验证公式
以后看到任何形如
#define F(a,b) 表达式(a, b) 的宏,自检三件事:- 每个参数单独有没有被括号包住?(防外部运算符插入参数内部)
- 整个替换体外面有没有被括号包住?(防外部运算符插入宏结果外部)
- 参数在展开体里出现几次?出现≥2次,就意味着传入带副作用的表达式(
++、-、函数调用)会出问题。
(3) 计算数组中的元素个数
(4) 位操作
2. 与函数的区别
从上面这几个宏来看,所有的这些操作都可以通过函数来实现,那么他们各有什么优缺点呢?
通过函数来实现:
- 形参的类型需要确定,调用时对参数进行检查;
- 调用函数时需要额外的开销:操作函数栈中的形参、返回值等;
通过宏来实现:
- 不需要检查参数,更灵活的传参;
- 直接对宏进行代码扩展,执行时不需要函数调用;
- 如果同一个宏在多处调用,会增加代码体积;
还是举一个例子来说明比较好,就拿上面的比较大小来说吧:
(1) 使用宏来实现
(2) 使用函数来实现
除了函数调用的开销,其它看起来没有差别。这里比较的是 2 个整型数据,那么如果还需要比较 2 个浮点型数据呢?
- 使用宏来调用:
MAX(1.1, 2.2);一切 OK;
- 使用函数调用:
max(1.1, 2.2);编译报错:类型不匹配。
此时,使用宏来实现的优势就体现出来了:因为宏中没有类型的概念,调用者传入任何数据类型都可以,然后在后面的比较操作中,大于或小于操作都是利用了 C 语言本身的语法来执行。
如果使用函数来实现,那么就必须再定义一个用来操作浮点型的函数,以后还有可能比较:char 型、long 型数据等等。
在 C++ 中,这样的操作可以通过参数模板来实现,所谓的模板也是一种代码动态生成机制。当定义了一个函数模板后,根据调用者的实参,来动态产生多个函数。例如定义下面这个函数模板:
当编译器看到 max(1, 2) 时,就会动态生成一个函数 int max(int a, int b) { ... };
当编译器看到 max(1.1, 2.2) 时,又会动态生成另一个函数 float max(float a, float b) { ... }。
所以,从代码的动态生成角度看,宏定义和 C++ 中的模板参数有点神似,只不过宏定义仅仅是代码扩展而已。
下面这个例子也比较不错,利用宏的类型无关,来动态生成结构体:
这个例子中用到了 ##,下面会解释这个知识点。在前面的例子中,宏的参数传递的都是一些变量,而这里传递的宏参数是数据类型,通过宏的类型无关性,达到了“动态”创建结构体的目的:
这里有一个陷阱需要注意:传递的数据类型中不能有空格,如果这样使用:VEC(long long),那替换之后得到:
四、符号:# 与 ##
这两个符号在编程中的作用也是非常巧妙,夸张的说一句:在任何框架性代码中,都能见到它们的身影!作用如下:
#:把参数转换成字符串;
##:连接参数。
1. #: 字符串化
直接看最简单的例子:
传入的是一个数字 123,输出的结果是字符串 “123”,这就是字符串化。
2. ##:参数连接
把宏中的参数按照字符进行拼接,从而得到一个新的标识符,例如:
当调用宏 MAKE_VAR(a, 1) 后,符号 ## 把两侧的 name 和 no 首先替换为 a 和 1,然后连接得到 a1。然后在调用语句中前面的 int 数据类型就说明了 a1 是一个整型数据,最后初始化为 1。
五、可变参数的处理
1. 参数名的定义和使用
宏定义的参数个数可以是不确定的,就像调用 printf 打印函数一样,在定义的时候,可以使用三个点(...)来表示可变参数,也可以在三个点的前面加上可变参数的名称。
如果使用三个点(...)来接收可变参数,那么在使用的时候就需要使用
__VA_ARGS__ 来表示可变参数,如下:如果在三个点(...)的前面加上了一个参数名,那么在使用时就一定要使用这个参数名,而不能使用
__VA_ARGS__ 来表示可变参数,如下:2. 可变参数个数为零的处理
看一下这个宏:
编译、执行都没有问题。但是如果这样来使用宏:
编译的时候,会出现错误: error: expected expression before ‘)’ token。为什么呢?
看一下宏扩展之后的代码(
__VA_ARGS__ 为空):看出问题了吧?在格式化字符串的后面多了一个逗号!为了解决问题,预处理器给我们提供了一个方法:通过 ## 符号把这个多余的逗号给自动删掉。于是宏定义改成下面这样就没有问题了。
类似的,如果自己定义了可变参数的名字,也在前面加上 ##,如下:
六、奇思妙想的宏
宏扩展的本质就是文本替换,但是一旦加上可变参数(
__VA_ARGS__)和 ## 的连接功能,就能够变化出无穷的想象力。我一直坚信,模仿是成为高手的第一步,只有见多识广、多看、多学习别人是怎么来使用宏的,然后拿来为己所用,按照“先僵化-再优化-最后固化”这个步骤来训练,总有一天你也能成为高手。
这里我们就来看几个利用宏定义的巧妙实现。
1. 日志功能
在代码中添加日志功能,几乎是每个产品的标配了,一般见到最普遍的是下面这样的用法:
在编译的时候,如果需要输出日志功能就传入宏定义 DEBUG,这样就能打印输出调试信息,当然实际的产品中需要写入到文件中。如果不需要打印语句,通过把打印日志信息那条语句定义为空语句来达到目的。
换个思路,我们还可以通过条件判断语句来控制打印信息,如下:
这样控制日志信息的看到的不多,但是也能达到目的,放在这里只是给大家开阔一下思路。
2. 利用宏来迭代每个参数
主要的思想就是:每次把可变参数
__VA_ARGS__ 中的第一个参数给分离出来,然后把后面的参数再递归处理,这样就可以分离出每一个参数了。我记得侯杰老师在 C++ 的视屏中,利用可变参数模板这个语法,也实现了类似的功能。刚才在有道笔记中居然找到了侯杰老师演示的代码,熟悉 C++ 的小伙伴可以研究下下面这段代码:
在这个例子中,核心在于 TEST 宏定义,通过 ## 拼接功能,构造出 case 分支的比较目标,然后动态拼接得到对应的函数,最后调用这个函数。
4. 动态创建错误编码与对应的错误字符串
这也是一个非常巧妙的例子,利用了 #(字符串化) 和 ##(拼接) 这 2 个功能来动态生成错误编码码和相应的错误字符串:
我们把宏展开之后,得到一个枚举类型和一个字符串常量数组:
宏扩展之后的代码是不是很简单啊。编译、执行结果如下:
七、总结
有些人对宏爱之要死,多到滥用的程度;而有些人对宏恨之入骨,甚至用上了邪恶(evil)这个词!其实宏对于 C 来说,就像菜刀对于厨师和歹徒一样:用的好,可以让代码结构简洁、后期维护特别方便;用的不好,就会引入晦涩的语法、难以调试的 Bug。
对于我们开发人员来说,只要在程序的执行效率、代码的可维护性上做好平衡就可以了。
参考文章
- Author:felixfixit
- URL:http://www.felixmicrospace.top/article/macro_usage_tips
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!












