17、C++17 折叠表达式:左折叠与右折叠、一元与二元折叠、在可变参数模板中的应用

可变参数模板是 C++11 引入的利器,但处理参数包一直有点麻烦。你想想看,要对一堆参数做求和、逻辑与、逗号分隔这些操作,以前得写递归模板或者用初始化列表技巧。C++17 的折叠表达式,说白了就是给参数包配了个「一键处理」的语法糖。

我个人习惯把折叠表达式理解为:把参数包里的元素,用同一个操作符挨个「折叠」起来。就像叠被子,一层一层叠上去,最后变成一个整体。

左折叠 vs 右折叠:方向决定结果

先看最基础的概念。折叠表达式分左折叠和右折叠,区别在于参数包从哪边开始「叠」。

左折叠:从左往右依次结合。语法是 ( ... op pack )( init op ... op pack )

右折叠:从右往左依次结合。语法是 ( pack op ... )( pack op ... op init )

举个例子,求和操作:

template<typename... Args>
auto leftSum(Args... args) {
    return ( ... + args );  // 左折叠:((1+2)+3)+4
}

template<typename... Args>
auto rightSum(Args... args) {
    return ( args + ... );  // 右折叠:1+(2+(3+4))
}

// 调用
auto result1 = leftSum(1, 2, 3, 4);   // 结果 10
auto result2 = rightSum(1, 2, 3, 4);  // 结果也是 10

嗯,这里要注意:对于加法这种满足结合律的操作,左右折叠结果一样。但换成减法试试?

auto r1 = leftSum(1, 2, 3, 4);   // ((1-2)-3)-4 = -8
auto r2 = rightSum(1, 2, 3, 4);  // 1-(2-(3-4)) = -2

结果完全不同!我在项目中遇到过这种坑——写日志格式化时用了左折叠拼接字符串,后来改成右折叠才发现顺序反了。所以一定要根据操作符的结合性选择方向

核心原则:左折叠适合从左到右处理(如打印、拼接),右折叠适合从右到左处理(如函数组合、右结合操作符)。

一元折叠 vs 二元折叠:有无初始值

折叠表达式还分一元和二元,区别在于有没有初始值。

一元折叠:直接对参数包操作,没有初始值。语法就是 ( ... op pack )( pack op ... )

二元折叠:带一个初始值,参数包和初始值一起折叠。语法是 ( init op ... op pack )( pack op ... op init )

看代码更清楚:

// 一元左折叠
template<typename... Args>
auto sum(Args... args) {
    return ( ... + args );  // 参数包不能为空!
}

// 二元左折叠(带初始值 0)
template<typename... Args>
auto sumWithInit(Args... args) {
    return ( 0 + ... + args );  // 参数包可以为空,返回 0
}

// 调用
auto s1 = sum(1, 2, 3);        // 6
// auto s2 = sum();            // 编译错误!一元折叠参数包不能为空
auto s3 = sumWithInit();       // 0,安全
auto s4 = sumWithInit(1, 2);   // 3

我曾经在写配置解析器时,用一元折叠处理参数列表,结果用户传了空参数包,编译直接报错。后来改成二元折叠加个默认值,世界清净了。

避坑指南:一元折叠要求参数包至少有一个元素。如果可能为空,务必使用二元折叠并提供初始值。

在可变参数模板中的实战应用

折叠表达式最爽的地方,就是让可变参数模板的代码变得极其简洁。我挑几个常用场景说说。

场景一:打印所有参数

template<typename... Args>
void printAll(Args... args) {
    (std::cout << ... << args) << std::endl;
}

// 调用
printAll(1, "hello", 3.14, 'A');  // 输出:1hello3.14A

注意这里用了二元左折叠,std::cout 是初始值。我以前用递归模板实现这个功能,代码量翻三倍不止。

场景二:检查所有条件是否成立

template<typename... Args>
bool allTrue(Args... args) {
    return ( ... && args );  // 一元左折叠,逻辑与
}

// 调用
bool result = allTrue(true, 1, "hello");  // true,非零值和非空指针都转 true

逻辑与操作符有短路特性——一旦遇到 false 就停止求值。折叠表达式完美保留了这一点。

场景三:任意一个条件成立

template<typename... Args>
bool anyTrue(Args... args) {
    return ( ... || args );  // 一元左折叠,逻辑或
}

同样支持短路求值。我在写参数校验时经常用这个,比手写一堆 if 优雅多了。

场景四:逗号表达式批量操作

template<typename... Args>
void processEach(Args... args) {
    (process(args), ...);  // 一元右折叠,逗号表达式
}

// 或者带初始值的版本
template<typename... Args>
void processEachWithInit(Args... args) {
    (void(std::cout << args << " "), ...);  // 二元左折叠
}

逗号表达式是折叠表达式的「瑞士军刀」——你可以对每个参数执行任意操作,然后丢弃返回值。我在日志系统里用这个批量格式化参数,一行代码搞定以前十几行的循环。

注意:逗号折叠时,操作顺序取决于左/右折叠方向。左折叠从左到右执行,右折叠从右到左执行。大部分场景下我们想要从左到右,所以用左折叠。

知识体系图

下面这张图帮你理清折叠表达式的核心脉络:

C++17 折叠表达式知识体系 折叠表达式 左折叠 ( ... op pack ) 右折叠 ( pack op ... ) 一元左折叠 二元左折叠 (init op ... op pack) 一元右折叠 二元右折叠 (pack op ... op init) 求和/求积 逻辑与/或 逗号表达式 选择左/右折叠取决于操作符结合性,选择一元/二元取决于参数包是否可能为空

总结几个实用技巧

  • 优先用二元折叠:除非你确定参数包永远不为空,否则给个初始值更安全。
  • 左折叠是默认选择:大部分场景下我们期望从左到右处理,左折叠更符合直觉。
  • 逗号折叠是万能工具:想对每个参数执行任意操作?用逗号折叠加 lambda 搞定。
  • 注意操作符重载:如果自定义类型重载了操作符,折叠表达式会自动使用重载版本。

折叠表达式让可变参数模板从「能用」变成了「好用」。我个人觉得这是 C++17 最实用的特性之一,值得花时间掌握。

我的建议:从最简单的求和、打印开始练手,然后逐步应用到实际项目中。一旦用习惯了,你会发现自己再也回不去递归模板了。

公众号:蓝海资料掘金营,微信 deep3321