C++11 C++17 C++20 可变参数模板

1,935 阅读4分钟

C++98

有一天,我想写一个可变参数的求和函数,于是我兴冲冲的写下了测试用例:

        TEST_METHOD(TestSum)
        {
            Assert::AreEqual(1, sum(1));
            Assert::AreEqual(3, sum(1, 2));
            Assert::AreEqual(6, sum(1, 2, 3));
            Assert::AreEqual(10, sum(1, 2, 3, 4));
        }

我以为这个求和函数大概应该是这个样子的:

        int sum(int args...)
        {
            int result = 0;
            for (int arg : args)
            {
                result += arg;
            }
​
            return result;
        }

很不幸,C++98编译器无法正确的处理这里的可变参数args。

但是这个需求却是实实在在的,所以各种奇技淫巧就应运而生了,在vs中可以这样:

        int sum(int num, int args...)
        {
            int result = 0;
            va_list args_p;
            va_start(args_p, num);
            for (int i = 0; i < num; i++)
            {
                int p = va_arg(args_p, int);
                result += p;
            }
            va_end(args_p);
​
            return result;
        }
​
        TEST_METHOD(TestSum)
        {
            Assert::AreEqual(1, sum(1, 1));
            Assert::AreEqual(3, sum(2, 1, 2));
            Assert::AreEqual(6, sum(3, 1, 2, 3));
            Assert::AreEqual(10, sum(4, 1, 2, 3, 4));
        }

这个实现怎么说呢?一个字:”丑“。两个字:“真丑”。

  • 你需要告诉编译器可变参数有几个(num)
  • 你需要告诉编译器可变参数从什么地方开始(va_start)
  • 你需要告诉编译器每次取的可变参数到底是什么类型的(va_arg)
  • 你需要告诉编译器可变参数在什么地方结束(va_end)

好吧,无论如何,你还是实现了需求,至于丑不丑的,关了灯还不是一样的。

C++11

基于范围的for循环

听说C++11支持基于范围的for循环了,那么最初那个简洁的实现是不是就可以了?你再次兴冲冲的敲下了这段代码:

        int sum(int args...)
        {
            int result = 0;
            for (int arg : args)
            {
                result += arg;
            }
​
            return result;
        }

不幸的是,编译不过去,错误信息是:基于范围的for语句需要适合的begin函数。

没错,基于范围的for语句实际上仅仅是begin和end迭代器的语法糖,C++11编译还是不认识可变参数args。

包展开

正当你无比沮丧之时,那些搞模板元编程的家伙们却表示,这完全不是个事儿,毕竟模板元编程是图灵完备的。

        template<typename T>
        T sum(T arg)
        {
            return arg;
        }
​
        template<typename T, typename ... Ts>
        T sum(T arg, Ts ... args)
        {
            return arg + sum(args ...);
        }
​
        TEST_METHOD(TestSum)
        {
            Assert::AreEqual(1, sum(1));
            Assert::AreEqual(3, sum(1, 2));
            Assert::AreEqual(6, sum(1, 2, 3));
            Assert::AreEqual(10, sum(1, 2, 3, 4));
        }

好吧,这就是C++11的解决方案,它将可变参数看做了一个包,然后让编译器将这个包展开即可。

所以,为了一个可变参数,我们不得不:

  • 用递归的思想
  • 实现递归结束函数(第一个sum)
  • 实现递归函数(第二个sum)

当然,毕竟递归思想是符合人类思维的,所以你接受了这个解决方案。而且,更多的复杂性也并非全无好处,比起将可变参数仅仅当做数组的解决方案,模板元编程的解决方案更具有扩展性,它可以处理可变参数类型不同的情况:

        template<typename T>
        void print(stringstream& ss, const T& arg)
        {
            ss << arg << " ";
        }
​
        template<typename T, typename ... Ts>
        void print(stringstream& ss, T arg, Ts ... args)
        {
            print(ss, arg);
            print(ss, args ...);
        }
​
        TEST_METHOD(TestPrint)
        {
            stringstream ss;
            print(ss, 1, 3.14, 'a', "test");
            Assert::AreEqual(string("1 3.14 a test "), ss.str());
        }

继承中的包展开

C++11的包展开不仅仅只针对于表达式列表,它还可以应用在更多的地方,当它应用于类的继承时,我们就有了下面这段极其风骚的代码:

        class base1
        {
        private:
            int m_n;
​
        public:
            explicit base1(int n)
                :m_n(n)
            {
            }
​
            int getN() const
            {
                return m_n;
            }
        };
​
        class base2
        {
        private:
            double m_d;
​
        public:
            explicit base2(double d)
                :m_d(d)
            {
            }
​
            double getD() const
            {
                return m_d;
            }
        };
​
        template<class ... Bases>
        class derived : public Bases ...
        {
        public:
            template<class ... Args>
            derived(Args ... args)
                :Bases(args) ...
            {
            }
        };
​
        TEST_METHOD(TestDerived)
        {
            derived<base1, base2> d(1, 3.14);
            Assert::AreEqual(1, d.getN());
            Assert::AreEqual(3.14, d.getD(), 0.001);
        }

sizeof...

为了简化可变参数个数的计数,C++11在sizeof运算符的基础上扩展了新的运算符sizeof...

        template<class ... T>
        int count(T ... args)
        {
            return sizeof...(args);
        }
​
        TEST_METHOD(TestSizeof)
        {
            Assert::AreEqual(0, count());
            Assert::AreEqual(1, count(1));
            Assert::AreEqual(2, count(1, 2));
        }

lambda捕获列表中的包展开

大同小异,略。

C++17

折叠表达式

还记得我们刚才是如何吐槽C++11的解决方案?

为了一个可变参数,我们不得不:

  • 用递归的思想
  • 实现递归结束函数(第一个sum)
  • 实现递归函数(第二个sum)

为了让我们闭嘴,在C++17中引入了更为诡异的折叠表达式的概念,现在我们的求和函数可以这样来写:

        template<class... Ts>
        auto sum17(Ts ... args)
        {
            return (args + ...);
        }
​
        TEST_METHOD(TestSum17)
        {
            Assert::AreEqual(1, sum17(1));
            Assert::AreEqual(3, sum17(1, 2));
            Assert::AreEqual(6, sum17(1, 2, 3));
            Assert::AreEqual(10, sum17(1, 2, 3, 4));
        }

这,这就是黑魔法吗?说实话,我不喜欢这玩意。

为了搞清楚这玩意背后到底玩的是什么把戏,我们这里以4个参数a,b,c,d为例:

C++17规定:

  • args + ...表示:(a + (b + (c + d))),这叫做右折叠
  • ... + args表示:(((a + b) + c) + d),这叫做左折叠

简单的说,你把这里的...想象成(((或)))就对了。

上面的规则叫做一元折叠表达式展开规则,还有二元折叠表达式展开规则:

  • args + ... + init表示:(a + (b + (c + (d + init)))),这叫做右折叠
  • init + ... + args表示:((((a + init) + b) + c) + d),这叫做左折叠

一个bug

对测试敏感的人可以在上面的折叠表达式测试代码中发现一个bug,即如果函数是空参数时怎么办?此时函数的返回值就是不确定了。所以严格说来,上面的的实现应该改为:

        template<class... Ts>
        auto sum170(Ts ... args)
        {
            return (args + ... + 0);
        }
​
        TEST_METHOD(TestSum170)
        {
            Assert::AreEqual(0, sum170());
            Assert::AreEqual(1, sum170(1));
            Assert::AreEqual(3, sum170(1, 2));
            Assert::AreEqual(6, sum170(1, 2, 3));
            Assert::AreEqual(10, sum170(1, 2, 3, 4));
        }

即用二元折叠表达式,用初始值的类型来推导返回值类型,但是我又如何知道那个初始值呢?这里直接写成0肯定是不严格的吧?所以,似乎问题还是无解。