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】公众号,未经授权,不得转载。
(加入我的知识星球,免费获取账号,解锁所有文章。)

 

阅读剩余
THE END