经过上篇分析实现,第一个需求「计算可变宏参数个数」已由 COUNT_VARARGS 基本实现。

让我们先总结一下用到的思想和发现的技术,再进入下一步。

2.1 通过增加一个间接层,能够解决无法直接解决的问题。
2.2 小步快走,由特殊逐渐扩展到普遍,能够降低问题的解决难度。
2.3 规范过程,确认变与不变,逐步控制变量,能够全面分析问题。
2.4 尝试改变输入的顺序、大小、个数…… 也许能有新发现。
2.5 初步发现规律时,扩大样本验证,能够将特殊推到普遍。
2.6 可变宏参数作为输入和欲输出结果组合起来,其间规律可以表达条件逻辑。
2.7 扩展编译器及语言版本,能够更全面地测试解决方案的普遍性。

那么本篇就来进一步完善解决方案,使它支持 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,根据结论 2.3,我们分析新的需求。它的输入是可变宏参数,过程是判断空或非空,结果是 1 或 0。过程依旧是条件逻辑,而结论 2.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,2
替换结果 1 1 0 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() 输入 1 , 1,2 ()
替换结果 0 1 0 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,2 ()
替换结果 1 0 1 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)

到这一步,这个问题就解决了。根据总结 2.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 <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', 1, 2));

}

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

下一步要做的就是使用预处理分语言版本选择不同的实现,C++20 以上使用新的简洁做法,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.