引言
自 C 以来,宏为代码生成工具。至 C++98 则有模板,泛型编程日益盛行。迄于 C++20,引编译期表达式,添 Concepts,元编程基础支持始渐完善。由此元编程之技稍简。而静态反射乃元编程系统之核心,却迟久未至,产生式元编程遂仍繁复。
所述元编程之书文,指不胜屈,其间也以编译期计算为主,奇技淫巧,小大靡遗。而于产生式元编程,言者寥寥,常见于库中直用。于是有此系列,略述浅见,供同道者读阅之。
产生式元编程,即为编译期代码生成的技术,各类系统,特性不侔,用法与能力亦有所殊。
问题
代码生成以宏为先,虽是旧工具,然方今模板元编程的能力尚有不足,在产生式元编程的核心特性「源码注入」进入标准之前,它仍是一种常用的代码生成工具。
宏编程得从可变宏参数开始谈起,可变模板参数的个数可以通过 sizeof...(args) 获取,宏里面如何操作呢?便是本章讨论的问题。
接着便逐步分析问题,实现需求,其间穿插宏编程之原理。
分析与实现
宏只有替换这一个功能,所谓的复杂代码生成功能,都是基于这一核心理念演绎出来的。故小步慢走,循序渐进,便可降低理解难度。
问题是获取宏参数包的个数,第一步应该规范过程。
过程的输入是一系列对象,对象类型和个数是变化的;过程的输出是一个值,值应该等于输入对象的个数。如果将输入替换为任何类型的其他对象,只要个数没变,结果也应保持不变。
于是通过宏函数表示出过程原型:
#define COUNT_VARARGS(...) N
根据宏的唯一功能可知,输出只能是一个值。依此便可否定遍历迭代之类的常规方式,可以考虑多加一层宏,通过由特殊到普遍的思想来不断分析,推出最终结果。
#define GET_VARARGS(a) 1 #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__)
目前这种实现只能支持一个参数的个数识别,可通过假设,在特殊的基础上逐次增加参数。于是得到:
#define GET_VARARGS(a, b) 2 #define GET_VARARGS(a) 1 #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__)
若假设成立,通过暴力法已能够将特殊推到普遍,问题遂即解决。但是宏并不支持重载,有没有可能实现呢?通过再封装一层,消除名称重复。得到:
#define GET_VARARGS_2(a, b) 2 #define GET_VARARGS_1(a) 1 #define GET_VARARGS(...) GET_VARARGS_X(__VA_ARGS__) #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__)
至此可知,若要实现宏重载效果,必须确定 GET_VARARGS_X 中的 X,而它又和参数个数相同,问题绕了一圈又回到起点,说明此路不通。
因此,回到特殊情况重新分析,函数名称已确定不能重复,唯一能够改变的就只剩下输入和输出。既然不能重载,那么输出也就不能直接写出,先同时改变输入和输出,满足特殊情况再说。
#define GET_VARARGS(N, ...) N #define COUNT_VARARGS(...) GET_VARARGS(1, __VA_ARGS__)
已经支持一个参数,尝试写出两个参数的情况。
#define GET_VARARGS(N1, N2, ...) N? #define COUNT_VARARGS(...) GET_VARARGS(1, 2, __VA_ARGS__)
输出是唯一确定的,这种尝试也以失败告终,于是排除改变输出的可能性,只余下输入是可以改变的。继续尝试:
#define GET_VARARGS(N1, N2, ...) N2 #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__, 1, 2)
稍微改变输入顺序,便有了新的发现:当 __VA_ARGS__ 个数为 1 时,N2 此时为 1;当为 0 时,N2 为 2。这表明间接层的输入参数之间具备着某种关系,接着扩大样本,寻找规律。
#define GET_VARARGS(N1, N2, N3, N4, N5, ...) N5 #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__, 1, 2, 3, 4, 5)
列出表格分析:
参数个数 | N5 |
---|---|
0 | 5 |
1 | 4 |
2 | 3 |
3 | 2 |
4 | 1 |
由此可知,参数个数和输出顺序相反且少 1。故修改实现为:
#define GET_VARARGS(N1, N2, N3, N4, N5, ...) N5 #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__, 4, 3, 2, 1, 0)
通过发现的规律,我们实现了由特殊到普遍的过程,函数的参数个数一般是有限的,只要再通过暴力法扩大数值范围,便能够为该需求实现一个通用的工具。
检验与优化
普遍性的解决方案还需要实践的检验,因为实现当中可能还会存在技术问题。这里的 __VA_ARGS__ 就是一个问题,当输入参数个数为 0 时,替换之后会留下一个 ,,这又打破了普遍性。
通过查阅资源,发现 ##__VA_ARGS__ 可以消除这种情况下的逗号,但是它不能写在参数的开头。根据这个约束,改变参数,进一步优化实现。得到:
#define GET_VARARGS(Ignored, N1, N2, N3, N4, N5, ...) N5 #define COUNT_VARARGS(...) GET_VARARGS("ignored", ##__VA_ARGS__, 4, 3, 2, 1, 0)
至此,该实现终于具备普遍性,接着整理接口名称,使其更加规范。变为:
#define GET_VARARGS(_0, _1, _2, _3, _4, N, ...) N #define COUNT_VARARGS(...) GET_VARARGS("ignored", ##__VA_ARGS__, 4, 3, 2, 1, 0)
在此基础上,将它由 4 推到 20、50、100…… 都不成问题,只要选择一个合适够用的数值就行。
再进一步检测,将其扩展到其他编译器进行编译,并尝试切换不同的语言版本编译,观察结果是否依旧一致。经过检测,发现 ##__VA_ARGS__ 的行为并不通用,不同语言版本和编译期的结果都不尽相同。此时就需要进一步查找解决方案,增强实现的通用性。
最终查找到 C++20 的 __VA_OPT__ 已经解决了这个问题,于是替换实现。
#define GET_VARARGS(_0, _1, _2, _3, _4, N, ...) N #define COUNT_VARARGS(...) GET_VARARGS("ignored" __VA_OPT__(,) __VA_ARGS__, 4, 3, 2, 1, 0) int main() { printf("zero arg: %d ", COUNT_VARARGS()); printf("one arg: %d ", COUNT_VARARGS(1)); printf("two args: %d ", COUNT_VARARGS(1, 2)); printf("three args: %d ", COUNT_VARARGS(1, 2, 3)); printf("four args: %d ", COUNT_VARARGS(1, 2, 3, 4)); }
若要支持 C++20 之前的版本,那么只需要再寻找办法,增加预处理即可。
通用性增强
经上述分析实现,「计算可变宏参数个数」的需求已基本由 COUNT_VARARGS 实现。
在此先总结一下用到的思想和技术,再往前行。
1.1 通过增加一个间接层,能够解决无法直接解决的问题。
1.2 小步快走,由特殊逐渐扩展到普遍,能够降低问题的解决难度。
1.3 规范过程,确认变与不变,逐步控制变量,能够全面分析问题。
1.4 尝试改变输入的顺序、大小、个数…… 也许能有新发现。
1.5 初步发现规律时,扩大样本验证,能够将特殊推到普遍。
1.6 可变宏参数作为输入和欲输出结果组合起来,其间规律可以表达条件逻辑。
1.7 扩展编译器及语言版本,能够更全面地测试解决方案的普遍性。
下面就来进一步完善解决方案,使 COUNT_VARARGS 支持 C++20 以下版本。
问题的关键在于 ##__VA_ARGS__ 不具备通用性,此时一种精妙的技术是分情况讨论,即零参和多参分别处理。考虑起初分析时的一条失败道路:
#define GET_VARARGS_2(a, b) 2 #define GET_VARARGS_1(a) 1 #define GET_VARARGS(...) GET_VARARGS_X(__VA_ARGS__) #define COUNT_VARARGS(...) GET_VARARGS(__VA_ARGS__)
这是函数重载的思路,因必须确定 GET_VARARGS_X 中的 X,而 X 又和可变宏参数相关,可变宏参数又属于无限集,遂无法确定这个 X,致此路不通。但若改变前提条件,使 X 的值属于有限集,这条路便可走通,这里便能够利用起来。只考虑零参和多参的情况,X 的取值范围就可以定在 0 和 1 之间。只需设法判断可变宏参数属于哪种情况,就可借此实现重载,分发处理逻辑。
根据假设,写出基本代码:
#define _COUNT_VARARGS_0(...) N #define _COUNT_VARARGS_1() 0 #define COUNT_VARARGS_0(...) _COUNT_VARARGS_1(__VA_ARGS__) #define COUNT_VARARGS_1(...) _COUNT_VARARGS_0() #define OVERLOAD_INVOKE(call, version) call ## version #define COUNT_VARARGS(...) OVERLOAD_INVOKE(COUNT_VARARGS_, IS_EMPTY(__VA_ARGS__))
定义两个重载宏函数 COUNT_VARARGS_1 和 COUNT_VARARGS_0,前者处理无参情况,后者处理多参情况。如此一来,空包时 IS_EMPTY 返回 1,调用分发到 COUNT_VARARGS_1,最终结果直接置为 0;多参时 IS_EMPTY 返回 0,调用分发到 COUNT_VARARGS_1,使用前面的方法处理即可。
于是主要问题就为如何实现 IS_EMPTY。根据结论 1.3 分析新的需求,其输入是可变宏参数,过程是判断空或非空,结果是 1 或 0。过程依旧是条件逻辑,而结论 1.6 正好可以表达条件逻辑,于是初步有了实现思路。
根据设想,继续写出基础代码:
#define HAS_COMMA(_0, _1, _2, ...) _2 #define IS_EMPTY(...) HAS_COMMA(__VA_ARGS__, 0, 1)
空包时,第一步替换结果为 HAS_COMMA(, 0, 1),第二步替换结果为 1。列出表格,分析更多样本。
IS_EMPTY() 输入 | 替换结果 |
---|---|
1 | |
1 | 1 |
, | 0 |
1,2 | 0 |
可以发现,空包和 1 个参数情况的结果相等,为 1;多参情况的结果相等,为 0。扩大 HAS_COMMA 的参数之后,结果依然成立。
#define HAS_COMMA(_0, _1, _2, _3, _4, _5, ...) _5 #define IS_EMPTY(...) HAS_COMMA(__VA_ARGS__, 0, 0, 0, 0, 1)
如今难题变成如何区分空包和 1 个参数,这个问题依旧不好处理。宏的唯一功能只有替换,可以尝试将空包替换成多参的同时,保持 1 个参数不变,这样就可以区分。下面的代码用于说明这个技巧:
#define _TRIGGER_PARENTHESIS(...) , #define TEST(...) _TRIGGER_PARENTHESIS __VA_ARGS__ ()
若参数为空,TEST() 第一步被替换为 _TRIGGER_PARENTHESIS (),第二步被替换为 ,。而 , 等价于 a, b,属于两个参数,便达到了将空包变为多参的需求。若是参数为 1,TEST 第一步被替换为 _TRIGGER_PARENTHESIS 1 (),宏不会继续展开,只要不使用该参数,它也不会报错,还是当作 1 个参数。如此一来,问题解决,开始将基础代码合并。
#define _TRIGGER_PARENTHESIS(...) , #define _COMMA_CHECK(_0, _1, _2, _3, _4, _5, ...) _5 #define HAS_COMMA(...) _COMMA_CHECK(__VA_ARGS__, 0, 0, 0, 0, 1) #define IS_EMPTY(...) HAS_COMMA( _TRIGGER_PARENTHESIS __VA_ARGS__ () )
重新列表格分析:
IS_EMPTY() 输入 | 替换结果 |
---|---|
0 | |
1 | 1 |
, | 0 |
1,2 | 0 |
() | 0 |
当前问题完美解决!暂停下来,调整接口,让它更加规范。一般 1 为真,0 为假,而现在 1 和 0 的意义完全相反,无法顾名思义,先把它改变过来。调整代码为:
#define _TRIGGER_PARENTHESIS(...) , #define _COMMA_CHECK(_0, _1, _2, _3, _4, _5, ...) _5 #define HAS_COMMA(...) _COMMA_CHECK(__VA_ARGS__, 1, 1, 1, 1, 0) #define IS_EMPTY(...) HAS_COMMA( _TRIGGER_PARENTHESIS __VA_ARGS__ () )
调整后的表格为:
IS_EMPTY() 输入 | 替换结果 |
---|---|
1 | |
1 | 0 |
, | 1 |
1,2 | 1 |
() | 1 |
随后分析新产生的问题,可以发现多参之间又无法区分是否为空包。解决这个问题的思路是多条件约束,从结果集中依次剔除不满足的元素。先把使用技巧前后的表格汇总起来,寻找规律。
HAS_COMMA() 参数 输入 | 1 | , | 1,2 | () | |
---|---|---|---|---|---|
__VA_ARGS__ | 0 | 0 | 1 | 1 | 0 |
_TRIGGER_PARENTHESIS __VA_ARGS__ () | 1 | 0 | 1 | 1 | 1 |
这两个条件组合起来,便可进一步排除包含 , 的情况。在此基础上,充分利用排列组合,再增加了两个条件,表格变为:
HAS_COMMA() 参数 输入 | 1 | , | 1,2 | () | |
---|---|---|---|---|---|
__VA_ARGS__ | 0 | 0 | 1 | 1 | 0 |
_TRIGGER_PARENTHESIS __VA_ARGS__ | 0 | 0 | 1 | 1 | 1 |
__VA_ARGS__ () | 0 | 0 | 1 | 1 | 0 |
_TRIGGER_PARENTHESIS __VA_ARGS__ () | 1 | 0 | 1 | 1 | 1 |
首先,使用 _TRIGGER_PARENTHESIS_ __VA_ARGS__ () 区分了空包和 1 参的情况,得到的结果集包含空包和多参。其次,借助 __VA_ARGS__ 区分了空包和 , (即为多参)的情况,两相组合,结果集便只剩下空包。
但还有一些特殊情况,比如第一个输入参数包含 (),此时 HAS_COMMA((1))展开为 HAS_COMMA(, ()) ,一个参数替换后变成两个参数会造成误判,以 _TRIGGER_PARENTHESIS __VA_ARGS__ 能够进一步排除这类结果。
最后,如果输入为一个宏或无参函数,例如 HAS_COMMA(Foo),替换后就变成了 HAS_COMMA(_TRIGGER_PARENTHESIS Foo ()),结果可能会出乎意料,通过 __VA_ARGS__ () 能够排除这类结果。
对于无参宏/函数,举个例子,#define Foo() 1, 2,结果加入表格中如下。
HAS_COMMA() 参数 输入 | 1 | , | 1,2 | () | Foo | |
---|---|---|---|---|---|---|
__VA_ARGS__ | 0 | 0 | 1 | 1 | 0 | 0 |
_TRIGGER_PARENTHESIS __VA_ARGS__ | 0 | 0 | 1 | 1 | 1 | 0 |
__VA_ARGS__ () | 0 | 0 | 1 | 1 | 0 | 1 |
_TRIGGER_PARENTHESIS __VA_ARGS__ () | 1 | 0 | 1 | 1 | 1 | 1 |
这四个条件组合起来,也就是说四个条件的结果为 0001,就可以唯一确认空包。现在便可运用老办法,添加重载,当重载为 0001 时,处理空包,返回 1,其他所有情况返回 0。实现如下:
#define _COMMA_CHECK(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, R, ...) R #define HAS_COMMA(...) _COMMA_CHECK(__VA_ARGS__, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0) #define _IS_EMPTY_CASE_0001 , #define _IS_EMPTY_CASE(_0, _1, _2, _3) _IS_EMPTY_CASE_ ## _0 ## _1 ## _2 ## _3 #define _IS_EMPTY_IMPL(_0, _1, _2, _3) HAS_COMMA(_IS_EMPTY_CASE(_0, _1, _2, _3)) #define IS_EMPTY(...) _IS_EMPTY_IMPL( HAS_COMMA(__VA_ARGS__), HAS_COMMA(_TRIGGER_PARENTHESIS_ __VA_ARGS__), HAS_COMMA(__VA_ARGS__ ()), HAS_COMMA(_TRIGGER_PARENTHESIS_ __VA_ARGS__ ()) )
四个条件的处理结果进一步传递到 _IS_EMPTY_IMPL,它又通过 _IS_EMPTY_CASE 组装成重载特化 _IS_EMPTY_CASE_0001。其他所有情况都没有定义相应的重载,因而经由 HAS_COMMA 调用 _COMMA_CHECK 的参数个数都相同,最终只会返回 0。而 _IS_EMPTY_CASE_0001 被替换为 ,,相当于参数个数加一,最终返回 1。
子问题解决,现在回到原问题,看看最初的实现:
#define _COUNT_VARARGS_0(...) N #define _COUNT_VARARGS_1() 0 #define COUNT_VARARGS_0(...) _COUNT_VARARGS_1(__VA_ARGS__) #define COUNT_VARARGS_1(...) _COUNT_VARARGS_0() #define OVERLOAD_INVOKE(call, version) call ## version #define COUNT_VARARGS(...) OVERLOAD_INVOKE(COUNT_VARARGS_, IS_EMPTY(__VA_ARGS__))
IS_EMPTY 只有空包时才会返回 1,其他情况都返回 0。因此当前的重载版本已经可以使用,空包时最终的参数直接由 _COUNT_VARARGS_1 将结果替换为 0。对于 _COUNT_VARARGS_0 多参版本,只要把上节的实现添加起来便可以:
#define _COUNT_VARARGS_0_IMPL(_0, _1, _2, _3, _4, N, ...) N #define _COUNT_VARARGS_0(...) _COUNT_VARARGS_0_IMPL(__VA_ARGS__, 5, 4, 3, 2, 1)
到这一步,这个问题就解决了。根据总结 1.7,进一步切换编译器检验一下方案的普遍性,发现 MSVC 结果错误。
原来是 MSVC 每次传递 __VA_ARGS__ 时,都会将其当作整体传递。例如:
#define A(x, ...) x and __VA_ARGS__ #define B(...) A(__VA_ARGS__) B(1, 2); // 替换为 1, 2 and
一种解决方案是强制宏展开,实现很简单:
#define EXPAND(x) x #define A(x, ...) x and __VA_ARGS__ #define B(...) EXPAND( A(__VA_ARGS__) ) B(1, 2); // 替换为 1 and 2
为了适配 MSVC,我们需要将所有传递的 __VA_ARGS__ 都强制展开。最终的实现为:
#include#define EXPAND(x) x #define _TRIGGER_PARENTHESIS(...) , #define _COMMA_CHECK(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, R, ...) R #define HAS_COMMA(...) EXPAND( _COMMA_CHECK(__VA_ARGS__, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0) ) #define _IS_EMPTY_CASE_0001 , #define _IS_EMPTY_CASE(_0, _1, _2, _3) _IS_EMPTY_CASE_ ## _0 ## _1 ## _2 ## _3 #define _IS_EMPTY_IMPL(_0, _1, _2, _3) HAS_COMMA(_IS_EMPTY_CASE(_0, _1, _2, _3)) #define IS_EMPTY(...) _IS_EMPTY_IMPL( EXPAND( HAS_COMMA(__VA_ARGS__) ), EXPAND( HAS_COMMA(_TRIGGER_PARENTHESIS __VA_ARGS__) ), EXPAND( HAS_COMMA(__VA_ARGS__ ()) ), EXPAND( HAS_COMMA(_TRIGGER_PARENTHESIS __VA_ARGS__ ()) ) ) #define _COUNT_VARARGS_0_IMPL(_0, _1, _2, _3, _4, N, ...) N #define _COUNT_VARARGS_0(...) EXPAND( _COUNT_VARARGS_0_IMPL(__VA_ARGS__, 5, 4, 3, 2, 1) ) #define _COUNT_VARARGS_1() 0 #define COUNT_VARARGS_0(...) EXPAND( _COUNT_VARARGS_0(__VA_ARGS__) ) #define COUNT_VARARGS_1(...) _COUNT_VARARGS_1() #define OVERLOAD_INVOKE(call, version) call ## version #define OVERLOAD_HELPER(call, version) OVERLOAD_INVOKE(call, version) #define COUNT_VARARGS(...) EXPAND( OVERLOAD_HELPER(COUNT_VARARGS_, IS_EMPTY(__VA_ARGS__))(__VA_ARGS__) ) int main() { // 0 1 2 3 4 printf("%d %d %d %d %d ", COUNT_VARARGS(), COUNT_VARARGS(1), COUNT_VARARGS('a', 'b'), COUNT_VARARGS('a', 'b', 'c'), COUNT_VARARGS('a', 'b', 1, 2)); }
这种解决方案不仅支持 C++ 所有版本,而且在 C 中也能使用。
合并方案
最后,将两种方案合并起来,便能得到一个通用的实现。借助预处理分语言版本选择不同的实现,C++20 以上使用新的简洁做法,C++20 以下使用这种通用但复杂的做法。
#include#if defined(__cplusplus) && __cplusplus >= 202002L #define GET_VARARGS(_0, _1, _2, _3, _4, N, ...) N #define COUNT_VARARGS(...) GET_VARARGS("ignored" __VA_OPT__(,) __VA_ARGS__, 4, 3, 2, 1, 0) #else #define EXPAND(x) x #define _TRIGGER_PARENTHESIS(...) , #define _COMMA_CHECK(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, R, ...) R #define HAS_COMMA(...) EXPAND( _COMMA_CHECK(__VA_ARGS__, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0) ) #define _IS_EMPTY_CASE_0001 , #define _IS_EMPTY_CASE(_0, _1, _2, _3) _IS_EMPTY_CASE_ ## _0 ## _1 ## _2 ## _3 #define _IS_EMPTY_IMPL(_0, _1, _2, _3) HAS_COMMA(_IS_EMPTY_CASE(_0, _1, _2, _3)) #define IS_EMPTY(...) _IS_EMPTY_IMPL( EXPAND( HAS_COMMA(__VA_ARGS__) ), EXPAND( HAS_COMMA(_TRIGGER_PARENTHESIS __VA_ARGS__) ), EXPAND( HAS_COMMA(__VA_ARGS__ ()) ), EXPAND( HAS_COMMA(_TRIGGER_PARENTHESIS __VA_ARGS__ ()) ) ) #define _COUNT_VARARGS_0_IMPL(_0, _1, _2, _3, _4, N, ...) N #define _COUNT_VARARGS_0(...) EXPAND( _COUNT_VARARGS_0_IMPL(__VA_ARGS__, 5, 4, 3, 2, 1) ) #define _COUNT_VARARGS_1() 0 #define COUNT_VARARGS_0(...) EXPAND( _COUNT_VARARGS_0(__VA_ARGS__) ) #define COUNT_VARARGS_1(...) _COUNT_VARARGS_1() #define OVERLOAD_INVOKE(call, version) call ## version #define OVERLOAD_HELPER(call, version) OVERLOAD_INVOKE(call, version) #define COUNT_VARARGS(...) EXPAND( OVERLOAD_HELPER(COUNT_VARARGS_, IS_EMPTY(__VA_ARGS__))(__VA_ARGS__) ) #endif int main() { printf("version: %ld ", __cplusplus); // 0 1 2 3 4 printf("%d %d %d %d %d ", COUNT_VARARGS(), COUNT_VARARGS(1), COUNT_VARARGS('a', 'b'), COUNT_VARARGS('a', 'b', 'c'), COUNT_VARARGS('a', 'b', 1, 2)); }
总结
本章以可变宏参数计数为例,穿插讲解宏编程的核心原理,又涵盖问题分析思路与完善步骤,小步慢走,终是实现需求。
由此原理,宏以能表示基本逻辑,亦能演绎出诸多新工具,反过来简化如今的实现。
评论
查看更多