你所不知道的 C/C++ 宏知识
发布时间:2026-09-18 | 浏览:1
刚学 C++ 的时候,就知道它糅合了四种编程模式:基于预处理器的宏、基于 C 语言的面向过程、基于类的面向对象、以及基于模板的泛型编程。其中,宏和模板元编程因为是在编译期出结果,能有效提升程序运行期性能,有着独特的价值。
之前了解的宏编程,大多数在数说它的缺陷,以及如何避免,以下面的宏为例
参数要用括号包围,例如 max(2+1,2)
宏表达式要用括号包围,例如 max(1,2)*3
多次求值,例如 max(n++,2)
多个语句的宏应该用 do{}while(0) 包含,例如 if (x>y) SWAP(x,y),其中 SWAP 宏的实现至少需要三条语句
不受命名空间限制,命名易冲突,例如参数名也使用 max 时会被展开
总而言之就是少用宏、不用宏,为此想出了各种方式来替代宏:
typedef 与 using 定义类型别名
inline 函数内联短小函数提升执行效率
即使是使用宏,也加入很多改进,例如 GNU C 引入了 typeof 关键字,来解决参数多次求值、未被括号包围等问题
宏很像结构化编程中的 goto 语句,不能说过街老鼠,也是日暮西山了。
直到我看了一篇文章:《 C/C++ 宏编程的艺术 》,才发现宏原来还可以这么玩。
作者 BOT Man 主要是谈 GMOCK_PP 库中各个宏的依赖关系,我整理如下:
复杂的如 PP_WHILE->PP_ADD/PP_SUB->PP_MUL->PP_EQUAL/PP_CMP->PP_LESS->PP_DIV/PP_MOD,就没画了,主要是没看懂~
另外,使用宏实现 256 以内的算术运算,有什么实际意义吗?我是持保留态度的。
本文不会鹦鹉学舌再重复一遍 BOT Man 论证过的逻辑,而是梳理下预处理器的工作原理与宏编程遵循的规则,有一些是之前没注意到的,总结出来自己都感觉新鲜,呵呵。
宏虽然只进行文本替换,没有类型的概念,但也有以下基本的语法规则
宏参数使用逗号分隔,因此参数不能再包含逗号,除非使用元组
非可变参数的宏函数,参数个数必需严格匹配声明
对于规则 I,有些读者可能觉得没必要,毕竟参数名中也不可能有逗号,但是别忘了参数也可能是模板的实例,像下面这样:
第二个模板参数中的逗号会使 FOO 的参数个数变为 3,从而导致预处理器报错:
这里使用了 BOT Man 也推荐的 Compile Explorer 在线编译环境,编译器选择的是 x86-64 gcc trunk 、编译参数是 -E -P -std=c++20 ,后面统一使用这个设置进行测试。
上例中如果使用元组,就不会报错了:
结果多了一对儿括号,追求完美的人,可以使用 PP_REMOVE_PARENS 去除,这个宏的实现,后面还会涉及,现在先不展开。
对于规则 III,简单补充下宏的 4 种形态、以及规则的应用情况:
宏运行时遵循的规则先都列出来:
预处理器会对代码进行多遍扫描,展开所有遇到的宏,直到触发以下条件 到达展开次数上限 遇到自参照宏,即宏曾经展开过的模样
遇到自参照宏,即宏曾经展开过的模样
宏函数展开前先对所有参数进行一次预扫描并展开,除非遇到以下条件 用于拼接的参数不展开 (##) 用于宏字面量的参数不展开 (#)
用于拼接的参数不展开 (##)
用于宏字面量的参数不展开 (#)
在宏函数展开后,替换后的文本会进行后扫描 (二次扫描),对遇到的宏继续进行展开
预扫描和后扫描都遵循条件 a,会进行多遍扫描
每次扫描前后,都会进行宏的语法检查
这是一个循环定义,X0->X1->X2->X3->X0,输出已列在代码注释, 貌似什么也没发生,以 X0 为准,看下整个替换过程:
这时 X0 的定义变为为 X1,整个过程列表如下:
换句话说,预处理器会 记录每个宏每次展开的历史值 ,避免与之重复,从而产生无限循环。
这个特性,导致 宏无法进行任何递归或重入 ,要进行任何推导,必需辛辛苦苦写 MACRO_1 / MACRO_2 ... MACRO_N 的代码,且 N 一般有上限。这是和模板元编程区别最大的地方,后者可以重载,进行模板偏特化,从而直接指定推导的结束条件。
上面的例子中预处理器扫描了 4~5 次,为了考察它的扫描次数上限,使用下面的 shell 脚本批量制造测试代码:
mac 上的 pbcopy 负责将代码复制到剪贴板,也可以直接重定向到文件再复制出来。在 Compiler Explorer 中运行:
居然没问题,看起来 10000 并不是上限。切换编译器为 x86-64 clang (trunk) 也正常,直到 x64 msvc v19.latest 时失败:
经过多次探测,msvc 最终的最大宏定义是 X255,也就是说扫描次数最大为 256。
不过 Compiler Explorer 上提供的 msvc 最新只到 VS2017,不过用实体机上的 VS2019 测试,结果也是一样的,看起来 msvc 对宏编程支持一般,怪不道 BOT Man 说 BOOST_PP 里有很多对 msvc 的兼容和 work around,汗~
规则 b 使用 BOT Man 的例子就不错:
如果参数是用于拼接或取字面量的,预扫描将不会对它进行展开。
注意,这里预处理器对宏参数的预扫描,也遵循规则 a,即在没有 ## 和 # 干扰时,它会一直展开直到 1) 达到最大展开次数 2)遇到自参照宏 时结束,并不是字面意思只扫描一次,这条就是规则 d。
如果希望预处理器忽略 ## & # 操作展开所有传入的宏函数参数,则需借助延迟拼接技术,这个技术听起来很高大上,其实原理很简单,直白说就是不直接实现宏函数而是调用另一个宏函数实现之:
一般命名此类宏函数的惯例是: MACRO & MACRO_IMPL ,后者就是真正干活的宏了。下面列表推理下展开过程:
另外一个有实战意义的例子是在 Windows 上常用的宽字符前缀 L,如果定义一个 TO_UNICODE 的宏为任意窄字符串增加 L 前缀,可以这样做:
使用 TO_UNICODE 宏为字符常量添加 L 前缀时,如果作用于常量字符串,是正常的;如果作用于经过宏定义的字符串 ( PRODUCT_NAME ),基于规则 b.i 就会出现非预期结果 ( LPRODUCT_NAME ),为了解决 L 拼接时宏不展开的问题,就需要借助延迟拼接技术:
关于这个例子,具体可参考附录 7。
惰性求值与后处理相关,技术不难理解,难的是场景不好说明,先来看一个宏语法错误:
第一个表达式出错,是错在预扫描前进行的语法检查,此时 PP_COMMA() 还未替换为 ',' 整个是一个参数: x PP_COMMA() y , PP_CONCAT 要求 2 个参数而只提供了 1 个;
第二个表达式出错,是错在预扫描后进行的语法检查,此时 PP_COMMA() 替换为了 ',' 整个是三个参数: x 、 空 、 空 , PP_CONCAT 要求 2 个参数而提供了 3 个。
也就是说,宏的语法检查是时刻进行的,在每次替换前后都会进行,这就是规则 e。 有了这个基础,再看下面这个例子,它更贴近真实场景:
重点是 log 宏函数的实现,委托给了 printf,在格式 format 与参数 __VA_ARGS__ 之间,要不要加个逗号做分隔,完全看用户传递的参数个数,大于 0 则需要,否则不需要,不然后期会出现编译期语法错误。
这里为了简化例子,参数个数是用户手动传递的,参考上面的两个 case,分别有 2 个参数和 0 个参数。
现在焦点就集中在 PP_COMMA_IF 宏的实现上了,它根据 n 的值,决定输出 PP_COMMA(),还是 PP_EMPTY(),这之前那一堆宏都是为了实现 PP_IF,看不懂也没关系。
到这里似乎没有什么问题,然而编译却报错:
错误有点不知所云,列个表推理下宏展开过程,先看第一个 case:
推导显示是在 PP_IF 处报错,实际报错信息显示是 PP_IF_1 ,预处理器似乎走的更远,切换为 clang 看得更明白:
PP_COMMA() 似乎是延迟到 PP_IF_1 中后才展开,这个和我的理解有 gap,有了解的大神还望不吝指点。
不管怎么说,预扫描展开宏函数参数导致参数个数不匹配,导致了这个问题,而我们又不打算拼接参数或取字面量,所以无法通过规则 b 解决问题。
好在传递给 PP_IF 的这两个参数,都是宏函数,这种场景下可以只传递宏函数名,括号放在 PP_IF 后,让宏函数在后扫描中再生效:
这就是规则 c,现在能得到正确的结果了:
下面仍为第一个 case 为例,推理下整个展开过程:
出于练习目的,再看下第二个 case:
其实主要区别就是 PP_IF_1 与 PP_IF_0 选择 PP_COMMA 还是 PP_EMPTY 的问题。将预扫描变为后扫描,是惰性求值的关键。
与模板元编程相比,由于规则 a.ii 宏无法递归和重入,要想支持多个参数,必需老老实实先写 N 个 #define,比较笨。反过来的好处是,他不会生成编译实体,对于控制代码体积有帮助。
最后借用 BOT Man 的话对两者做个总结:
C++ 模板元编程 (template metaprogramming) 虽然功能强大,但也有 局限性 :
不能通过 模板展开 生成新的 标识符 (identifier) 例如 生成新的 函数名、类名、名字空间名 等 使用者 只能使用 预先定义的标识符
例如 生成新的 函数名、类名、名字空间名 等
使用者 只能使用 预先定义的标识符
不能通过 模板参数 获取 符号/标记 (token) 的 字面量 (literal) 例如 在反射中获取 实参参数名的字面量,在断言中获取 表达式的字面量 使用者 只能通过 传递字符串参数 绕开
例如 在反射中获取 实参参数名的字面量,在断言中获取 表达式的字面量
使用者 只能通过 传递字符串参数 绕开
所以,在需要直接 操作标识符 的情况下,还需要借助 宏 ,进行 预处理阶段的元编程 :
和 编译时 (compile-time) 的 模板 展开不同, 宏 在编译前的 预处理 (preprocess) 阶段全部展开 —— 狭义上,编译器 看不到且不处理 宏代码
通过 #define / TOKEN1##TOKEN2 / #TOKEN 定义 宏对象 (object-like macro) 和 宏函数 (function-like macro) ,可以实现 替换文本、拼接标识符、获取字面量 等功能
总结一下就是 :各有所长、结合使用 。
BOT Man 主要介绍的是 Mock PP 库,它是 Boost PP 库的精减版,后者有更为强大的代码生成能力,感兴趣的读者可以进一步探索,这里只举几个例子:
看看有没有满足你需求的 (我也没看懂原理,库嘛,拿来用就好了)。
[1]. 360 安全规则集合
[2]. C++ 下 typeof 的实现
[3]. Replacing text macros
[4]. C/C++ 宏编程的艺术
[5]. 《产生式元编程》第一章 宏编程计数引原理
[6]. Self-Referential Macros
[7]. 使用C++宏嵌套实现窄字符转换为宽字符
[8]. Boost Preprocessor (PP库) 中的奇技淫巧
本文来自博客园,作者: goodcitizen ,转载请注明原文链接: https://www.cnblogs.com/goodcitizen/p/18655303/macro_knowledges_what_you_dont_know