一、简介引言
C++ 模板元编程 (template metaprogramming) 虽然功能强大,但也有 局限性:
- 不能通过 模板展开 生成新的 标识符(identifier)。例如:生成新的 函数名、类名、名字空间名 等
- 使用者 只能使用 预先定义的标识符,不能通过 模板参数 获取 符号/标记(token) 的 字面量(literal)
- 例如 在反射中获取 实参参数名的字面量,在断言中获取 表达式的字面量。
所以,在需要直接 操作标识符 的情况下,还需要借助 宏,进行 预处理阶段的元编程:
- 和 编译时(compile-time) 的 模板 展开不同,宏 在编译前的预处理(preprocess) 阶段全部展开 —— 狭义上,编译器 看不到且不处理 宏代码。
- 通过
#define
/TOKEN1##TOKEN2
/#TOKEN
定义 宏对象(object-like macro) 和 宏函数(function-like macro),可以实现替换文本、拼接标识符、获取字面量等功能。
1.1 关于C++宏编程调试
很多人因为 “宏编程” 无法调试,而直接 “从入门到放弃” —— 不经意的 符号拼写错误、参数个数错误,导致文本 不能正确替换,从而带来 满屏的编译错误,最后 难以定位 问题所在 ——
- 最坏的情况下,编译器 只会告诉你 cpp 文件 编译时出现 语法错误
- 最好的情况下,编译器 可能告诉你 XXX 宏 展开结果里包含 语法错误
- 而永远 不会告诉你 是因为 XXX 宏展开成什么样,导致 YYY 宏展开失败
- 最后 只能看到 ZZZ 宏展开错误
由于宏代码会 在编译前全部展开,我们可以:
- 让编译器 仅输出预处理结果
gcc -E
让编译器 在预处理结束后停止,不进行 编译、链接gcc -P
屏蔽编译器 输出预处理结果的 行标记 (linemarker),减少干扰。并结合 __LINE__;;; 宏实现代码行数和位置的定位。- 另外,由于输出结果没有格式化,建议先传给
clang-format
格式化后再输出 - 屏蔽 无关的 头文件
二、宏编程的常见使用模式
C
(和C++
)中的宏(Macro
)属于编译器预处理的范畴,属于编译期概念(而非运行期概念)。下面对常遇到的宏的使用问题做了简单总结。
2.1 关于#和## (# 符号拼接)
在C语言的宏中,#
的功能是将其后面的宏参数进行字符串化操作(Stringfication),简单说就是在对它所引用的宏变量通过替换后在其左右各加上一个双引号。比如下面代码中的宏:
#define WARN_IF(EXP) \
do { \
if (EXP) \
fprintf(stderr, "Warning: " #EXP "\n"); \
}while(0)
那么实际使用中会出现下面所示的替换过程:
WARN_IF (divider == 0);
被替换为:
do {
if (divider == 0)
fprintf(stderr, "Warning" "divider == 0" "\n");
} while(0);
这样每次divider
(除数)为0的时候便会在标准错误流上输出一个提示信息。
而##
被称为连接符(concatenator),用来将两个Token
连接为一个Token
。注意这里连接的对象是Token
就行,而不一定是宏的变量。比如你要做一个菜单项命令名和函数指针组成的结构体的数组,并且希望在函数名和菜单项命令名之间有直观的,名字上的关系。那么下面的代码就非常实用:
struct command
{
char * name;
void (*function) (void);
};
#define COMMAND(NAME) { NAME, NAME ## _command }
然后你就用一些预先定义好的命令来方便的初始化一个command
结构的数组了:
struct command commands[] = {
COMMAND(quit),
COMMAND(help),
...
}
COMMAND
宏在这里充当一个代码生成器的作用,这样可以在一定程度上减少代码密度,间接地也可以减少不留心所造成的错误。我们还可以n
个##
符号连接 n+1
个Token
,这个特性也是#
符号所不具备的。比如:
#define LINK_MULTIPLE(a,b,c,d) a##_##b##_##c##_##d
typedef struct _record_type LINK_MULTIPLE(name,company,position,salary);
这里这个语句将展开为:
typedef struct _record_type name_company_position_salary;
2.2 关于...的使用(## 变长参数)
...
在C
宏中称为Variadic Macro
,也就是变参宏。
在GNU C中,从C99开始,宏可以接受可变数目的参数,就象可变参数函数一样。和函数一样,宏也用三个点…来表示可变参数
__VA_ARGS__ 宏用来表示可变参数的内容,简单来说就是将左边宏中 … 的内容原样抄写在右边__VA_ARGS__ 所在的位置。如下例代码:
#include <stdio.h>
#define debug(...) printf(__VA_ARGS__)
int main(void)
{
int year = 2018;
debug("this year is %d\n", year); //效果同printf("this year is %d\n", year);
}
另外,通过一些语法,你可以给可变参数起一个名字,而不是使用__VA_ARGS__ ,如下例中的args:
#include <stdio.h>
#define debug(format, args...) printf(format, args)
int main(void)
{
int year = 2018;
debug("this year is %d\n", year); //效果同printf("this year is %d\n", year);
}
与可变参数函数不同的是,可变参数宏中的可变参数必须至少有一个参数传入,不然会报错,为了解决这个问题,需要一个特殊的“##”操作,如果可变参数被忽略或为空,“##”操作将使预处理器(preprocessor)去除掉它前面的那个逗号。如下例所示
#include <stdio.h>
#define debug(format, args...) printf(format, ##args)
int main(void)
{
int year = 2018;
debug("hello, world"); //只有format参数,没有args可变参数
}
举个例子:宏定义为#define XNAME(n) x##n,代码为:XNAME(4),则在预编译时,宏发现XNAME(4)与XNAME(n)匹配,则令 n 为 4,然后将右边的n的内容也变为4,然后将整个XNAME(4)替换为 x##n,亦即 x4,故最终结果为 XNAME(4) 变为 x4。如下例所示:
#include <stdio.h>
#define XNAME(n) x##n
#define PRINT_XN(n) printf("x" #n " = %d\n", x##n);
int main(void)
{
int XNAME(1) = 14; // becomes int x1 = 14;
int XNAME(2) = 20; // becomes int x2 = 20;
PRINT_XN(1); // becomes printf("x1 = %d\n", x1);
PRINT_XN(2); // becomes printf("x2 = %d\n", x2);
return 0;
}
2.3 特殊符号
和模板元编程不一样,宏编程 没有类型 的概念,输入和输出都是 符号 —— 不涉及编译时的 C++ 语法,只进行编译前的 文本替换:
- 一个 宏参数 是一个任意的 符号序列(token sequence),不同宏参数之间 用逗号分隔
- 每个参数可以是 空序列,且空白字符会被忽略(例如
a + 1
和a+1
相同) - 在一个参数内,不能出现 逗号(comma) 或 不配对的 括号(parenthesis)(例如
FOO(bool, std::pair<int, int>)
被认为是FOO()
有三个参数:bool
/std::pair<int
/int>
)
如果需要把 std::pair<int, int>
作为一个参数,一种方法是使用 C++ 的 类型别名 (type alias)(例如 using IntPair = std::pair<int, int>;
),避免 参数中出现逗号(即 FOO(bool, IntPair)
只有两个参数)。
更通用的方法是使用 括号对 封装每个参数(下文称为 元组),并在最终展开时 移除括号(元组解包)即可:
#define PP_REMOVE_PARENS(T) PP_REMOVE_PARENS_IMPL T
#define PP_REMOVE_PARENS_IMPL(...) __VA_ARGS__
#define FOO(A, B) int foo(A x, B y)
#define BAR(A, B) FOO(PP_REMOVE_PARENS(A), PP_REMOVE_PARENS(B))
FOO(bool, IntPair) // -> int foo(bool x, IntPair y)
BAR((bool), (std::pair<int, int>)) // -> int foo(bool x, std::pair<int, int> y)
PP_REMOVE_PARENS(T)
展开为PP_REMOVE_PARENS_IMPL T
的形式- 如果参数
T
是一个 括号对,那么展开结果会变成 调用宏函数PP_REMOVE_PARENS_IMPL (...)
的形式 - 接着,
PP_REMOVE_PARENS_IMPL(...)
再展开为参数本身__VA_ARGS__
(下文提到的 变长参数),即元组T
的内容
另外,常用宏函数 代替 特殊符号,用于下文提到的 惰性求值:
#define PP_COMMA() ,
#define PP_LPAREN() (
#define PP_RPAREN() )
#define PP_EMPTY()
2.4 其他
宏编程也具有强大的计算能力,是否是图灵完备型。笔者尚不确定。不过如 BOOST_PP:目前流行的 预处理库(preprocessor library) 里利用宏编程实现了很多计算语句的基础结构,如:自增自减、逻辑运算、布尔转换、条件选择、惰性求值、下标访问、参数长度判空,长度计算、遍历访问、符号匹配、以及提供了常见的数据结构(如 元组、序列、列表、数组等)、此外还有数值运算、数值比较等等。可以参见知乎一篇文章:C/C++ 宏编程的艺术。
三、使用宏时需要注意的点
3.1 错误的嵌套-Misnesting
宏的定义不一定要有完整的、配对的括号,但是为了避免出错并且提高可读性,最好避免这样使用。
3.2 由操作符优先级引起的问题-Operator Precedence Problem
由于宏只是简单的替换,宏的参数如果是复合结构,那么通过替换之后可能由于各个参数之间的操作符优先级高于单个参数内部各部分之间相互作用的操作符优先级,如果我们不用括号保护各个宏参数,可能会产生预想不到的情形。比如:
#define ceil_div(x, y) (x + y - 1) / y
那么
a = ceil_div( b & c, sizeof(int) );
将被转化为:
a = ( b & c + sizeof(int) - 1) / sizeof(int);
由于+/-
的优先级高于&
的优先级,那么上面式子等同于:
a = ( b & (c + sizeof(int) - 1)) / sizeof(int);
这显然不是调用者的初衷。为了避免这种情况发生,应当多写几个括号:
#define ceil_div(x, y) (((x) + (y) - 1) / (y))
3.3 消除多余的分号-Semicolon Swallowing
通常情况下,为了使函数模样的宏在表面上看起来像一个通常的C
语言调用一样,通常情况下我们在宏的后面加上一个分号,比如下面的带参宏:
MY_MACRO(x);
但是如果是下面的情况:
#define MY_MACRO(x) { \
/* line 1 */ \
/* line 2 */ \
/* line 3 */ }
//...
if (condition())
MY_MACRO(a);
else {
...
}
这样会由于多出的那个分号产生编译错误。为了避免这种情况出现同时保持MY_MACRO(x);
的这种写法,我们需要把宏定义为这种形式:
#define MY_MACRO(x) do {
/* line 1 */ \
/* line 2 */ \
/* line 3 */ } while(0)
这样只要保证总是使用分号,就不会有任何问题。
3.4 Duplication of Side Effects
这里的Side Effect
是指宏在展开的时候对其参数可能进行多次Evaluation
(也就是取值),但是如果这个宏参数是一个函数,那么就有可能被调用多次从而达到不一致的结果,甚至会发生更严重的错误。比如:
#define min(X,Y) ((X) > (Y) ? (Y) : (X))
//...
c = min(a,foo(b));
这时foo()
函数就被调用了两次。为了解决这个潜在的问题,我们应当这样写min(X,Y)
这个宏:
#define min(X,Y) ({ \
typeof (X) x_ = (X); \
typeof (Y) y_ = (Y); \
(x_ < y_) ? x_ : y_; })
({...})
的作用是将内部的几条语句中最后一条的值返回,它也允许在内部声明变量(因为它通过大括号组成了一个局部Scope
)。
一些有意思的问题
- 下面的代码:
#define display(name) printf(""#name"")
int main() {
display(name);
}
运行结果是name
,为什么不是"#name"
呢?
#
在这里是字符串化的意思,printf(""#name"")
相当于 printf("" "name" "")
.
printf("" #name "")
<1>
相当于printf("" "name" "")
<2>
而<2>中的第2,3个“中间时空格 等价于("空+name+空')
##
连接符号由两个井号组成,其功能是在带参数的宏定义中将两个子串(token
)联接起来,从而形成一个新的子串。但它不可以是第一个或者最后一个子串。所谓的子串 (token
)就是指编译器能够识别的最小语法单元。具体的定义在编译原理里有详尽的解释,但不知道也无所谓。同时值得注意的是#
符是把传递过来的参数当成字符串进行替代。下面来看看它们是怎样工作的。这是MSDN
上的一个例子。
假设程序中已经定义了这样一个带参数的宏
#define paster( n ) printf( "token" #n " = %d", token##n )
同时又定义了一个整形变量:
int token9 = 9;
现在在主程序中以下面的方式调用这个宏:
paster(9);
那么在编译时,上面的这句话被扩展为:
printf( "token" "9" " = %d", token9 );
注意到在这个例子中,paster(9);
中的这个”9
”被原封不动的当成了一个字符串,与”token
”连接在了一起,从而成为了token9
。而#n
也被”9
”所替代。
可想而知,上面程序运行的结果就是在屏幕上打印出token9=9
.
#define display(name) printf(""#name"")
int main() {
display(name);
}
特殊性就在于它是个宏,宏里面处理#
号就如LS所说!
处理后就是一个附加的字符串!
但printf(""#name"");
就不行了!
#define display(name) printf(""#name"")
该定义字符串化name
,得到结果其实就是printf("name")
(前后的空字符串拿掉), 这样输出来的自然是 name
.
从另外一个角度讲, #
是一个连接符号, 参与运算了, 自然不会输出了.