如何获取可变参数宏实参的数量

在阅读一些开源代码的时候,可能会看到类似下面这种神秘的宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define ARG_N(                                     \ 
_1, _2, _3, _4, _5, _6, _7, _8, _9, _10, \
_11, _12, _13, _14, _15, _16, _17, _18, _19, _20, \
_21, _22, _23, _24, _25, _26, _27, _28, _29, _30, \
_31, _32, _33, _34, _35, _36, _37, _38, _39, _40, \
_41, _42, _43, _44, _45, _46, _47, _48, _49, _50, \
_51, _52, _53, _54, _55, _56, _57, _58, _59, _60, \
_61, _62, _63, N, ...) \
N

#define RSEQ_N \
63, 62, 61, 60, \
59, 58, 57, 56, 55, 54, 53, 52, 51, 50, \
49, 48, 47, 46, 45, 44, 43, 42, 41, 40, \
39, 38, 37, 36, 35, 34, 33, 32, 31, 30, \
29, 28, 27, 26, 25, 24, 23, 22, 21, 20, \
19, 18, 17, 16, 15, 14, 13, 12, 11, 10, \
9, 8, 7, 6, 5, 4, 3, 2, 1, 0

#define NARG_(...) ARG_N(__VA_ARGS__)
#define NARG(...) NARG_(__VA_ARGS__, RSEQ_N)

这些宏的作用是获取可变参数宏的实参数量。在调用 NARG 时,传入若干个参数,就会返回这些参数的数量,例如:

1
2
3
int i = NARG(a);        //i = 1
int j = NARG(a, b); //j = 2
int k = NARG(a, b, c); //k = 3

这些宏使用了精妙的技巧来实现这个功能。了解其实现原理的最好方法是将宏一步步展开,在这个过程中观察它究竟施展了什么样的“魔法”。为了方便理解,这里将 ARG_NRSEQ_N 定义的数字数量精简成这样:

1
2
#define ARG_N(_1, _2, _3, N, ...) N
#define RSEQ_N 3, 2, 1, 0

NARG(a, b, c) 为例,这个宏调用会被替换成:

1
NARG_(a, b, c, RSEQ_N)

再把 RSEQ_N 展开,变成:

1
NARG_(a, b, c, 3, 2, 1, 0)

再被替换成:

1
ARG_N(a, b, c, 3, 2, 1, 0)

对照参考 ARG_N 的定义,它总是会被替换成第4个参数,所以这个宏被替换成了3:

1
2
#define ARG_N(_1, _2, _3, N, ...    ) N
// ARG_N( a, b, c, 3, 2, 1, 0) 3

再以两个参数的调用 NARG(a, b) 为例,这个宏会被替换成2:

1
2
#define ARG_N(_1, _2, _3, N, ... ) N
// ARG_N( a, b, 3, 2, 1, 0) 2

以此类推,可以总结出它的实现原理:将输入参数与一个倒序的数字序列拼接成新的参数序列,传递给 ARG_N 宏,这个宏输出固定位置的参数,这个位置上的数字刚好等于输入参数的个数。

这种方法有一定的局限性,比如它支持的最大参数数量是有限的,依赖 ARG_NRSEQ_N 中定义的数量。在本文开头的例子中,它最多支持64个宏参数,假如要支持更多的参数,就需要在这两个宏里添加更多的数字。ARG_NRSEQ_N 里的数字数量一定要精确匹配,否则这套机制就失效了。另外也顺带一提,ARG_N 里的 _1_2_3 等标示符只是一个参数占位符,没有其它作用,即使写成别的名字也是可以的,写成数字是为了更方便与RSEQ_N 里的数字对齐。

另外一个局限是,它不能支持0个参数,即使在RSEQ_N 里定义了0这个数字。原因是宏展开时只是单纯的文本替换,例如 NARG() 这个宏调用,会被展开成:

1
NARG_(, 3, 2, 1, 0)

即使没有输入参数,在它后面的逗号还是保留着的,所以实际上相当于有1个参数。在最后调用 ARG_N 的时候,会被替换成1:

1
2
#define ARG_N(_1, _2, _3, N, ... ) N
// ARG_N( , 3, 2, 1, 0 ) 1

最后,在使用MSVC编译器时,如果使用默认的编译选项,这套方法是不生效的,因为MSVC的宏参数展开规则不符合标准。简单地说,在MSVC中,当一个宏将它的可变参数传递给另外一个宏时,这些参数被作为一个整体来传递,而不是拆分成多个传递,所以对于另外一个宏来说,只接收到一个参数。例如, NARG(a, b, c) 这个宏调用,虽然从字面上它也会被展开成 ARG_N(a, b, c, 3, 2, 1, 0) ,但从语义上它却是这样的:

1
2
#define ARG_N(                  _1, _2, _3, N, ... ) N
// ARG_N(a, b, c, 3, 2, 1, 0 )

传递给 ARG_N 的参数,都一起被视为第一个参数了。所以在MSVC下编译会出现ARG_N 参数不足的警告,并且得到空的结果。

在MSVC添加 /Zc:preprocessor 编译选项,可以解决这个问题。这个编译选项改变了编译器展开宏的规则,使其更符合标准。在Visual Studio的项目设置中也可以修改这个选项:

除了修改编译器选项,还有另外一种取巧的方法,即在调用 ARG_N 之前,增加一个中间层调用,如下所示:

1
2
3
#define EXPAND(x) x
#define NARG_(...) EXPAND( ARG_N(__VA_ARGS__) )
#define NARG(...) NARG_(__VA_ARGS__, RSEQ_N)

这里增加了一个新的 EXPAND 宏,这个宏从字面上没有实际用途,只是把参数原样保留。但是从语义上来说,这个宏改变了展开的行为。

仍然以 NARG(a, b, c) 为例,它的展开过程如下所示:

1
2
3
NARG_(a, b, c, 3, 2, 1, 0)            // 1
EXPAND( ARG_N(a, b, c, 3, 2, 1, 0) ) // 2
ARG_N(a, b, c, 3, 2, 1, 0) // 3

从第2步到第3步这个过程可以看出来,宏调用跟函数调用不一样,宏调用只是文本替换, EXPAND 只把 ARG_N(a, b, c, 3, 2, 1, 0) 原样保留,而不是先展开 ARG_N 再调用 EXPAND。这是关键的地方,在第2步的时候,虽然 a, b, c, 3, 2, 1, 0 这些参数还是一个整体,但由于这时候还没有去展开 ARG_N ,所以不会像之前那样全部都当作第一个参数,它们只作为文本被保留下来。到了第3步,这时候就是正常的宏展开,参数可以一一对应。

增加 EXPAND 中间层对其它编译器没有影响,所以如果要编写跨平台的代码,应该使用这个方法。