《产生式元编程》第一章 宏编程计数引原理

教育   科技   2023-09-27 21:28   美国  

引言

自 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
05
14
23
32
41

由此可知,参数个数和输出顺序相反且少 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(12));
    printf("three args: %d\n", COUNT_VARARGS(123));
    printf("four args: %d\n", COUNT_VARARGS(1234));
}

若要支持 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_1COUNT_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
11
,0
1,20

可以发现,空包和 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
11
,0
1,20
()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
10
,1
1,21
()1

随后分析新产生的问题,可以发现多参之间又无法区分是否为空包。解决这个问题的思路是多条件约束,从结果集中依次剔除不满足的元素。先把使用技巧前后的表格汇总起来,寻找规律。

HAS_COMMA() 参数 \ 输入
1,1,2()
__VA_ARGS__00110
_TRIGGER_PARENTHESIS __VA_ARGS__ ()10111

这两个条件组合起来,便可进一步排除包含 , 的情况。在此基础上,充分利用排列组合,再增加了两个条件,表格变为:

HAS_COMMA() 参数 \ 输入
1,1,2()
__VA_ARGS__00110
_TRIGGER_PARENTHESIS __VA_ARGS__00111
__VA_ARGS__ ()00110
_TRIGGER_PARENTHESIS __VA_ARGS__ ()10111

首先,使用 _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__001100
_TRIGGER_PARENTHESIS __VA_ARGS__001110
__VA_ARGS__ ()001101
_TRIGGER_PARENTHESIS __VA_ARGS__ ()101111

这四个条件组合起来,也就是说四个条件的结果为 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(12); // 替换为 1, 2 and

一种解决方案是强制宏展开,实现很简单:

#define EXPAND(x) x
#define A(x, ...) x and __VA_ARGS__
#define B(...) EXPAND( A(__VA_ARGS__) )

B(12); // 替换为 1 and 2

为了适配 MSVC,我们需要将所有传递的 __VA_ARGS__ 都强制展开。最终的实现为:

#include <cstdio>

#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\n",
        COUNT_VARARGS(), COUNT_VARARGS(1), COUNT_VARARGS('a''b'),
        COUNT_VARARGS('a''b''c'), COUNT_VARARGS('a''b'12));

}

这种解决方案不仅支持 C++ 所有版本,而且在 C 中也能使用。

合并方案

最后,将两种方案合并起来,便能得到一个通用的实现。借助预处理分语言版本选择不同的实现,C++20 以上使用新的简洁做法,C++20 以下使用这种通用但复杂的做法。

#include <cstdio>

#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\n", __cplusplus);

    // 0 1 2 3 4
    printf("%d %d %d %d %d\n",
        COUNT_VARARGS(), COUNT_VARARGS(1), COUNT_VARARGS('a''b'),
        COUNT_VARARGS('a''b''c'), COUNT_VARARGS('a''b'12));

}

总结

本章以可变宏参数计数为例,穿插讲解宏编程的核心原理,又涵盖问题分析思路与完善步骤,小步慢走,终是实现需求。

由此原理,宏以能表示基本逻辑,亦能演绎出诸多新工具,反过来简化如今的实现。其间细节,请俟后文。


CppMore
Dive deep into the C++ core, and discover more!
 最新文章