这期开始将写一些关于宏编程的内容,讲解宏作为代码生成工具在产生式元编程中的运用。

有人可能会有疑问,已有模板作为元编程工具,为何还需要使用宏这种古老的代码生成工具?自然是因为如今模板元编程的代码生成能力仍有不足,在第三阶段 C++ 元编程的核心工具「源码生成」进入标准之前,宏依旧是一种常用的代码生成工具。

文章定位是 TGS,所以每 Part 只会讲一个单独的技术点,利于循序渐进地吸收。

宏编程得从 Variadic Macros 开始谈起,可变模板参数的个数可以通过 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\n", COUNT_VARARGS());
    printf("one arg: %d\n", COUNT_VARARGS(1));
    printf("two args: %d\n", COUNT_VARARGS(1, 2));
    printf("three args: %d\n", COUNT_VARARGS(1, 2, 3));
    printf("four args: %d\n", COUNT_VARARGS(1, 2, 3, 4));
}

若要支持 C++20 之前的版本,那么只需要再寻找办法,增加预处理即可。

更多内容,请等下篇讲解。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.