1.6、折叠表达式
折叠表达式是什么?
在C++11中,我们有了变参模板,可以接受任意数量的模板参数,但处理这些参数包时,往往需要递归展开,代码复杂且效率不高。C++17的折叠表达式,提供了一种非递归、编译期展开参数包的语法糖,让你可以用一行表达式就把所有参数“折叠”成一个值。
简单来说,折叠表达式就是把参数包里的所有元素,用一个二元操作符(比如加号+、逻辑与&&、逗号,等)连接起来,形成一个整体表达式。它在编译时展开,不会带来运行时开销。
折叠表达式的四种语法形式
折叠表达式的语法有四种,区分依据是:
- • 是否带有初始值(称为二元折叠,否则为一元折叠)
- • 折叠方向(左折叠还是右折叠)
语法形式 | 语法示例 | 展开方式说明 |
一元左折叠 | ( ... op pack ) |
((E1 op E2) op ...) op EN ,从左往右折叠 |
一元右折叠 | ( pack op ... ) |
E1 op (... op (EN-1 op EN)) ,从右往左折叠 |
二元左折叠 | ( init op ... op pack ) |
(((init op E1) op E2) op ...) op EN ,从左往右折叠 |
二元右折叠 | ( pack op ... op init ) |
E1 op (... op (EN op init)) ,从右往左折叠 |
这里pack
是参数包,op
是二元操作符,init
是初始值。
简洁、高效、类型安全
折叠表达式的设计哲学在于:
- • 简洁:用一行表达式代替递归模板,代码更短更易读。
- • 高效:编译器直接展开,不产生递归调用,避免编译时深度限制。
- • 类型安全:编译期类型检查,确保操作符适用参数类型。
- • 灵活:支持32种操作符,包括算术、逻辑、位运算、逗号等,适用范围广。
深度案例讲解:从入门到高级
1. 基础案例:求和函数
template<typename... Args>
auto sum(Args... args) {
return (args + ...); // 一元右折叠
}
调用sum(1, 2, 3, 4)
时,折叠展开为:
1 + (2 + (3 + 4))
这就是一元右折叠的展开方式。
如果改成一元左折叠:
return (... + args);
展开为:
((1 + 2) + 3) + 4
对于加法这种交换律操作符,结果相同。但对于减法,结果就不同了:
template<typename... Args>
auto left_sub(Args... args) {
return (... - args); // 一元左折叠
}
template<typename... Args>
auto right_sub(Args... args) {
return (args - ...); // 一元右折叠
}
调用left_sub(1,2,3,4)
结果为((1 - 2) - 3) - 4 = -8
调用right_sub(1,2,3,4)
结果为1 - (2 - (3 - 4)) = -2
这说明折叠方向对非交换操作符结果有影响,必须根据需求选择。
2. 带初始值的二元折叠
假设你想写一个求和函数,空参数时返回0:
template<typename... Args>
auto sum_with_init(Args... args) {
return (0 + ... + args); // 二元左折叠,初始值0
}
空参数时返回0,参数不空时正常求和。
3. 结合逗号运算符实现多参数调用
逗号运算符的特性是先执行左边表达式,再执行右边表达式,返回右边结果。折叠表达式中用逗号运算符可以实现对每个参数执行某操作:
template<typename... Args>
void printAll(Args&&... args) {
(std::cout << ... << args) << std::endl; // 左折叠打印所有参数
}
template<typename Func, typename... Args>
void forEach(Func&& f, Args&&... args) {
(f(args), ...); // 一元右折叠,依次调用f(args)
}
forEach([](auto x){ std::cout << x << ' '; }, 1, 2, 3);
会依次调用lambda打印1 2 3
。
4. 高级案例:通过折叠表达式遍历二叉树路径
假设有如下二叉树节点:
struct Node {
int value;
Node* left;
Node* right;
};
我们定义路径成员指针:
auto left = &Node::left;
auto right = &Node::right;
用折叠表达式实现路径遍历:
template<typename... Paths>
Node* traverse(Node* start, Paths... paths) {
return (start->* ... ->* paths);
}
调用traverse(root, left, right, left)
相当于:
((root->*left)->*right)->*left
这样利用折叠表达式简洁地实现了多级成员指针访问。
底层细节解析
折叠表达式在编译期展开为一串二元操作符连接的表达式,避免了递归模板实例化的深度限制。编译器根据折叠方向和是否有初始值,生成对应的表达式树。
一元折叠没有初始值,参数包为空时,只有&&、||和逗号运算符有默认值(分别为true、false和void()),其他操作符空包会导致编译错误。
二元折叠有初始值,空包时返回初始值,保证了安全性。
折叠表达式的类型安全体现在编译期,操作符必须对所有参数类型有效,否则编译失败。
常见错误及误区
- • 折叠方向不当:对非交换操作符如减法、除法,左右折叠结果不同,使用时需谨慎。
- • 空参数包处理不当:一元折叠空包时,除&&、||、逗号外会编译失败,需用二元折叠加初始值规避。
- • 操作符限制:折叠表达式只支持特定32种操作符,不能用自定义操作符。
- • 类型不匹配:参数类型不支持指定操作符时,编译报错。
总结
折叠表达式是C++17对变参模板的重大改进,设计简洁高效,消除了递归展开的繁琐和限制。掌握折叠表达式,能让你写出更简洁、类型安全且性能优异的模板代码。理解折叠方向、初始值、空包处理等细节,是深入运用的关键。结合实际案例练习,能帮助你在日常开发和面试中脱颖而出。
本文首发于【讳疾忌医-note】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)