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肯定是不严格的吧?所以,似乎问题还是无解。