C++17新增了一个特性用于对参数列表中所有实参应用二元操作符并计算结果。
例如,以下函数返回所有传递过去的参数的和:
-
template<typename... T>
-
auto foldSum (T... args) {
-
return (... + args); // ((arg1 + arg2) + arg3) ...
-
}
注意,返回表达式两边的括号是折叠表达式的一部分,不能省略。
如下所示调用该函数:
-
foldSum(47, 11, val, -1);
将实例化模板为:
-
return 47 + 11 + val + -1;
如果如下调用函数:
-
foldSum(std::string("hello"), "world", "!");
则实例化模板为:
-
return std::string("hello") + "world" + "!";
还要注意折叠表达式参数顺序的不同有不同的效果:比如写成
-
(... + args)
则结果为
-
((arg1 + arg2) + arg3) ...
意味着它是重复地往后相加。还可以写成
-
(args + ...)
则是重复地向前相加,所以结果表达式为:
-
(arg1 + (arg2 + arg3)) ...
折叠表达式的动机
折叠表达式避免了需要递归实例化模板才能把操作应用到参数列表的所有参数上。C++17以前,要这么写:
-
template<typename T>
-
auto foldSumRec (T arg) {
-
return arg;
-
}
-
template<typename T1, typename... Ts>
-
auto foldSumRec (T1 arg1, Ts... otherArgs) {
-
return arg1 + foldSumRec(otherArgs...);
-
}
这种写法不但冗长累赘,还会增加C++编译器的负担。而如下写法
-
template<typename... T>
-
auto foldSum (T... args) {
-
return (... + args); // arg1 + arg2 + arg3 ...
-
}
无论是对程序员还是对编译器都极大地减轻了负担。
使用折叠表达式
给定参数 args
和操作符 op
,C++17中可以写成
-
一元左折叠
-
( ... op args )
扩展为: ((arg1 op arg2) op arg3) op . . .
-
一元右折叠
-
( args op ... )
扩展为: arg1 op (arg2 op . . . (argN-1 op argN))
括号是必需的。括号和省略号( ...
)之间的空格不是必需的。
左折叠和右折叠的区别要搞清楚。例如,当使用操作符 +
时,可能会有不同效果。当使用左折叠表达式:
-
template<typename... T>
-
auto foldSumL(T... args){
-
return (... + args); // ((arg1 + arg2) + arg3) ...
-
}
调用:
-
foldSumL(1, 2, 3)
等价于:
-
((1 + 2) + 3)
也就是说下面的例子可以编译通过:
-
std::cout << foldSumL(std::string("hello"), "world", "!") << '\n'; // OK
记住,标准字符串 std::string
定义的操作符 operator+
要求至少有一个操作数是 std::string
类型。
因为用了左折叠,调用先计算
-
std::string("hello") + "world"
返回一个 std::string
,因为再加一个字面量字符串 "!"
就没问题。
然而,如下调用
-
std::cout << foldSumL("hello", "world", std::string("!")) << '\n'; //错误
编译不过,因为它被按下列方式计算
-
("hello" + "world") + std::string("!")
两个字面量字符串相加是不行的。
但是,如果将实现修改为:
-
template<typename... T>
-
auto foldSumR(T... args){
-
return (args + ...); // (arg1 + (arg2 + arg3)) ...
-
}
调用
-
foldSumR(1, 2, 3)
计算为:
-
(1 + (2 + 3))
那么下面的例子就不再能编译过了:
-
std::cout << foldSumR(std::string("hello"), "world", "!") << '\n'; //错误
而这个例子却能编译过:
-
std::cout << foldSumR("hello", "world", std::string("!")) << '\n'; // OK
因为几乎所有场景中都是倾向于从左向右的计算顺序,一般应该优先对参数列表使用左折叠语法(除非不能用):
-
(... + args); //折叠表达式的优先语法
处理空参数列表
如果折叠表达式遇到了空参数列表,那么按以下规则进行:
-
如果使用了
operator&&
,值为true
-
如果使用了
operator||
,值为false
-
如果使用了
operator,
,值为void()
-
所有其他操作符,调用为非法
所有其他情况(一般而言)可以加一个初始值:给定一个参数列表 args
,一个初始值 value
和一个操作符 op
,C++17可以这样写:
-
二元左折叠
-
( value op ... op args )
扩展为: (((value op arg1) op arg2) op arg3) op . . .
-
二元右折叠
-
( args op ... op value )
扩展为: op (arg2 op . . . (argN op value)))
省略号( ...
)两边的操作符必须相同。
例如,下面的定义当加了初始值后可以传空参数列表:
-
template<typename... T>
-
auto foldSum (T... s){
-
return (0 + ... + s); // sizeof...(s)==0也没问题
-
}
从概念上讲,它不会管我们把 0
作为第一个还是最后一个操作数:
-
template<typename... T>
-
auto foldSum (T... s){
-
return (s + ... + 0); // sizeof...(s)==0也没问题
-
}
因为一元折叠表达式的计算顺序往往与想象的不同,所以二元左折叠应该优先这么做:
-
(val + ... + args); //二元折叠表达式的优先语法
而第一个参数可能是特殊的,比如:
-
template<typename... T>
-
void print (const T&... args)
-
{
-
(std::cout << ... << args) << '\n';
-
}
这里,输出的第一个调用是传给 print()
的第一个参数,它会返回stream以供其他输出调用。其他实现可能没法编译甚至出现未预期的行为,比如:
-
std::cout << (args << ... << '\n');
这个调用像 print(1)
被编译,但打印值 1
左移 \n
位,通常因为 \n
的ASCII码值是 10
,所以结果输出是 1024
。
注意这个 print()
例子中没有在参数列表中的每个元素间插入空格。像 print("hello", 42, "world")
会打印出:
-
hello42world
要用空格分隔每个传入的元素,就要提供一个助手来确保输出的所有除了第一个参数外都会在前面加一个空格。例如个加助手函数模板 spaceBefore()
:
tmpl/addspace.hpp
-
template<typename T>
-
const T& spaceBefore(const T& arg) {
-
std::cout << ' ';
-
return arg;
-
}
-
template <typename First, typename... Args>
-
void print (const First& firstarg, const Args&... args) {
-
std::cout << firstarg;
-
(std::cout << ... << spaceBefore(args)) << '\n';
-
}
这个
-
(std::cout << ... << spaceBefore(args))
折叠表达式将被扩展为:
-
std::cout << spaceBefore(arg1) << spaceBefore(arg2) << ...
因此,参数列表 args
的每个元素它都会调用助手函数,在返回传进来的实参前先打印一个空格字符,写到 std::cout
。要确保这个助手不会应用到第一个参数,就要加上第一个参数并且不用 spaceBefore()
。
注意参数列表的结果计算要求所有输出结果在左边的都先把 spaceBefore()
计算了。感谢 operator<<
和函数调用的自定义计算顺序,这在C++17开始就得到了保证。
我们可以使用lambda表达式在 print()
内部定义 spaceBefore()
:
-
template<typename First, typename... Args>
-
void print (const First& firstarg, const Args&... args) {
-
std::cout << firstarg;
-
auto spaceBefore = [](const auto& arg) {
-
std::cout << ' ';
-
return arg;
-
};
-
(std::cout << ... << spaceBefore(args)) << '\n';
-
}
然而还要注意lambda表达式默认按值返回对象,意味着这会导致参数传递时非必要的生成临时对象。解决办法是显式地声明lambda的返回值类型为 const auto&
或 decltype(auto)
:
-
template<typename First, typename... Args>
-
void print (const First& firstarg, const Args&... args) {
-
std::cout << firstarg;
-
auto spaceBefore = [](const auto& arg) -> const auto& {
-
std::cout << ' ';
-
return arg;
-
};
-
(std::cout << ... << spaceBefore(args)) << '\n';
-
}
如果你不把这些写到一条语句中,那么C++就不再是C++了:
-
template<typename First, typename... Args>
-
void print (const First& firstarg, const Args&... args) {
-
std::cout << firstarg;
-
(std::cout << ... << [](const auto& arg) -> decltype(auto) {
-
std::cout << ' ';
-
return arg;
-
}(args)) << '\n';
-
}
尽管如此,实现 print()
的简单办法是使用lambda表达式打印出空格和参数,并传给一元折叠:
-
template<typename First, typename... Args>
-
void print(First first, const Args&... args) {
-
std::cout << first;
-
auto outWithSpace = [](const auto& arg) {
-
std::cout << ' ' << arg;
-
};
-
(... , outWithSpace(args));
-
std::cout << '\n';
-
}
通过使用额外的使用 auto
声明模板参数,可以使得 print()
更灵活,可以参数化地定义分隔符为一个字符或一个字符串或其他任何可打印的类型。
支持的操作符
你可以使用除了 .
, ->
和 []
之外的所有二元操作符。
折叠函数调用
折叠表达式也可以用逗号操作符,并多个函数调用结合成一条语句。就是说可以这样写:
-
template<typename... Types>
-
void callFoo(const Types&... args)
-
{
-
...
-
(... , foo(args)); // 调用 foo(arg1), foo(arg2), foo(arg3), ...
-
}
为所有传过去的参数调用函数 foo()
。
或者如果支持移动语义:
-
template<typename... Types>
-
void callFoo(Types&&... args)
-
{
-
...
-
(... , foo(std::forward<Types>(args))); // calls foo(arg1), foo(arg2), ...
-
}
要注意的是,由于逗号操作符本身一般并不关心使用的是左折叠还是右折叠操作符。函数总是从左到右调用:
-
(foo(args) , ...);
括号只用于将调用分组,所以第一个 foo()
调用与后两个 foo()
调用结合起来:
-
foo(arg1) , (foo(arg2) , foo(arg3));
但是因为逗号表达式的计算顺序一般都是从左到右,括号内的第一个调用就会先于其他两个调用,中间的调用就会先于右边的调用。
尽管如此,因为左折叠表达式能匹配原本的计算顺序,再一次建议多个函数调用时使用左折叠表达式。
串联hash函数
另一个使用逗号操作符的折叠表达式的例子是串联hash值。如下所示:
-
template<typename T>
-
void hashCombine (std::size_t& seed, const T& val)
-
{
-
seed ^= std::hash<T>()(val) + 0x9e3779b9 + (seed<<6) + (seed>>2);
-
}
-
template<typename... Types>
-
std::size_t combinedHashValue (const Types&... args)
-
{
-
std::size_t seed = 0; // initial seed
-
(... , hashCombine(seed,args)); // chain of hashCombine() calls
-
return seed;
-
}
如下调用:
-
combinedHashValue ("Hi", "World", 42);
中间的语句被扩展为:
-
hashCombine(seed,"Hi"), (hashCombine(seed,"World"), hashCombine(seed,42));
使用该定义,我们可以很容易地定义新的hash函数对象给新的类型,比如 Customer
,用它定义无序集合或作为无论map中的键值:
-
struct CustomerHash
-
{
-
std::size_t operator() (const Customer& c) const {
-
return combinedHashValue(c.getFirstname(), c.getLastname(),
-
c.getValue());
-
}
-
};
-
std::unordered_set<Customer, CustomerHash> coll;
-
std::unordered_map<Customer, std::string, CustomerHash> map;
基类的折叠函数调用
折叠函数调用也可用于更复杂的表达式。例如,可以折叠逗号操作符调用多个基类的一组成员函数的函数调用:
tmpl/foldcalls.cpp
-
#include <iostream>
-
// 有多个基类的模板类
-
template<typename... Bases>
-
class MultiBase : private Bases...
-
{
-
public:
-
void print() {
-
// 调用所有基类的print()函数
-
(... , Bases::print());
-
}
-
};
-
struct A {
-
void print() { std::cout << "A::print()\n"; }
-
};
-
struct B {
-
void print() { std::cout << "B::print()\n"; }
-
};
-
struct C {
-
void print() { std::cout << "C::print()\n"; }
-
};
-
int main()
-
{
-
MultiBase<A,B,C> mb;
-
mb.print();
-
}
这里
-
template<typename... Bases>
-
class MultiBase : private Bases...
-
{
-
...
-
};
允许使用多个基类初始化对象:
-
MultiBase<A,B,C> mb;
同时
-
(... , Bases::print());
折叠表达式用于将其扩展为调用每个基类的 print
函数。这条折叠表达式语句被扩展为:
-
(A::print() , B::print()) , C::print();
折叠路径遍历
也可以用折叠表达式遍历一个二叉树的路径,使用 operator->*
:
tmpl/foldtraverse.cpp
-
// 定义二叉树结构和遍历助手
-
struct Node {
-
int value;
-
Node* left;
-
Node* right;
-
Node(int i=0) : value(i), left(nullptr), right(nullptr) {
-
}
-
...
-
};
-
auto left = &Node::left;
-
auto right = &Node::right;
-
// 使用折叠表达式遍历二叉树
-
template<typename T, typename... TP>
-
Node* traverse (T np, TP... paths) {
-
return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
-
}
-
int main()
-
{
-
// 初始化二叉树结构
-
Node* root = new Node{0};
-
root->left = new Node{1};
-
root->left->right = new Node{2};
-
...
-
// 遍历二叉树
-
Node* node = traverse(root, left, right);
-
...
-
}
这里
-
(np ->* ... ->* paths)
使用折叠表达式遍历从 np
开始的各个路径,当调用
-
traverse(root, left, right);
调用的折叠表达式被扩展为:
-
root -> left -> right
为类型使用折叠表达式
通过使用类型粹取,我们也可以用折叠表达式处理模板参数列表(任意个数类型传过去作为模板参数)。例如可以使用折叠表达式判断一个类型列表是否为同质化的:
tmpl/ishomogeneous.hpp
-
#include <type_traits>
-
// 检测传过去的类型是否同质化:
-
template<typename T1, typename... TN>
-
struct IsHomogeneous {
-
static constexpr bool value = (std::is_same_v<T1,TN> && ...);
-
};
-
// 检测传来的参数是否有相同类型:
-
template<typename T1, typename... TN>
-
constexpr bool isHomogeneous(T1, TN...)
-
{
-
return (std::is_same_v<T1,TN> && ...);
-
}
类型粹取 IsHomogeneous<>
使用如下:
-
IsHomogeneous<int, Size, decltype(42)>::value
在这个场景中折叠表达式初始化成员变量扩展为:
-
std::is_same_v<int,MyType> && std::is_same_v<int,decltype(42)>
函数模板 sHomogeneous<>()
使用如下:
-
isHomogeneous(43, -1, "hello", nullptr)
在这个场景中折叠表达式初始化成员变量扩展为:
-
std::is_same_v<int,int> && std::is_same_v<int,const char*>
-
&& std::is_same_v<int,std::nullptr_t>
通常, operator&&
会缩短回路(即遇到第一个false后不再继续计算后续表达式)。
标准库中的 std::array()
类型推导指南就使用了本特性。