模板折叠(Fold Expressions)

1. 折叠表达式的概念与背景

在C++中,可变参数模板允许函数或类模板接受任意数量的模板参数。这在编写灵活且通用的代码时非常有用。然而,处理参数包中的每个参数往往需要递归模板技巧,这样的代码通常复杂且难以维护。

折叠表达式的引入显著简化了这一过程。它们允许开发者直接对参数包应用操作符,而无需手动展开或递归处理参数。这不仅使代码更加简洁,还提高了可读性和可维护性。

C++17 折叠表达式的分类依据是参与折叠的参数包数量,而非操作符的元数:

折叠表达式可分为:

  • 一元折叠(Unary Fold)仅针对单个参数包,通过一个二元操作符将参数包中的所有元素按指定结合方向 “串联” 成连续表达式,无需额外固定值参与运算。
  • 二元折叠(Binary Fold)针对 “一个参数包 + 一个额外固定值”,通过同一个二元操作符将固定值与参数包元素按指定结合方向串联成连续表达式,是一元折叠的扩展(解决带初始值的运算场景)。

一元操作符(!/~/++ 等)无法直接作为折叠表达式的 op,因为折叠的本质是 “将参数包的元素用操作符串联起来”,而串联需要二元操作符。

此外,一元折叠二元折叠都分左/右折叠,左 / 右折叠的核心是操作符的结合方向

对比维度 一元折叠(Unary Fold) 二元折叠(Binary Fold)
参与运算的元素 一个参数包(无额外固定值) 一个参数包 + 一个额外固定值(value)
语法格式 前置:(op ... pack)
后置:(pack ... op)
左折叠:(value op ... op pack)
右折叠:(pack op ... op value)
空参数包处理 依赖 C++ 标准规定的默认空值(如 + 折叠空包返回 0) 无默认空值,空包时直接返回 value(自定义可控)
结合方向 前置 = 右结合,后置 = 左结合 左折叠 = 左结合,右折叠 = 右结合
核心用途 纯参数包的批量运算(如所有参数求和 / 逻辑判断) 带初始值的批量运算(如从 100 开始求和)
示例(求和) (args + ...)a + b + c (100 + ... + args)((100+a)+b)+c

2. 一元折叠表达式(Unary Fold)

一元折叠表达式: 用一个二元操作符,将参数包中的所有元素 “串联” 成一个表达式,语法形式如下:

前置一元折叠(Unary Prefix Fold): 操作符在参数包前,如 (&& ... args)

(op … pack)

后置一元折叠(Unary Postfix Fold): 操作符在参数包后,如 (args ... &&)

(pack … op)

  • op 必须是二元操作符,支持的列表包括:+-*/%^&|=<><<>>&&||,->*.*
  • pack 是唯一的参数包,且折叠后必须是合法的表达式(如 args ... + 会展开为 a + b + c)。
    代码示例:
1
2
3
4
5
6
7
//对每个参数非操作,然后再将这些操作&&  
//(!args && ...)相当于(!args) && ...
//!a && !b && ...
template<typename... Args>
bool allNot(const Args&... args) {
return (!args && ... );
}

return (!args && ... );核心逻辑:

对参数包中的每个参数先单独取反,再将所有取反后的结果做逻辑与(&&)

  • 展开规则(以 args = (a, b, c) 为例):(!a) && (!b) && (!c)
  • 返回 true 的条件:所有参数本身都为「假」值(false/0 / 空字符串等),只要有一个参数为「真」,结果就为 false。

一元左折叠示例

1
2
3
4
template<typename... Args>  
auto sumLeftFold(const Args&... args) {
return (args + ...);
}

3.二元折叠表达式(Binary Fold)

在 C++ 中,一元折叠仅针对单个参数包运算,而二元折叠表达式则解决了 “参数包 + 固定值” 的联合运算场景。它同样基于二元操作符实现,但相比一元折叠,能更灵活地处理带初始值的批量运算(如 “从 1 开始累乘参数包”“给参数包所有元素加固定偏移量”),是对一元折叠的重要补充。

二元折叠表达式的核心特征:

  • 参与折叠的是一个参数包 + 一个额外的固定值(而非仅参数包);
  • 操作符 op 仍必须是二元操作符(与一元折叠的合法操作符列表完全一致);
  • 按操作符的结合方向,二元折叠可进一步细分为左折叠右折叠,这决定了固定值与参数包的运算顺序。

二元折叠表达式的语法与规则

二元折叠的语法核心是 “固定值 + 操作符 + 参数包” 的组合,左 / 右折叠的语法格式和结合方向如下:

类型 语法格式 结合方向 核心逻辑 展开示例(value=0, args=(a,b,c), op=+)
二元左折叠 (value op … op pack) 左结合 固定值先与第一个参数运算,结果再与下一个参数运算 ((value + a) + b) + c
二元右折叠 (pack op … op value) 右结合 最后一个参数先与固定值运算,结果再与前一个参数运算 a + (b + (c + value))

关键规则

  1. value 是编译期或运行期的固定值(如字面量、常量、变量),必须与参数包元素类型兼容;
  2. op 必须是二元操作符,支持的列表与一元折叠一致:+-*/%^&|=<><<>>&&||,->*.*
  3. pack 是唯一的参数包,折叠后需保证表达式合法(如数值类型才能做算术运算);
  4. 二元折叠无空参数包陷阱(区别于一元折叠):即使参数包为空,也会保留固定值参与运算(如 (0 + ... + args) 空包时结果为 0)。

示例一:带初始值的求和

1
2
3
4
5
6
7
8
9
10
11
12
13
// 二元左折叠:从base开始,累加参数包所有元素
// (base + ... + args) → ((base + a) + b) + c
template<typename... Args>
auto sum_with_base(int base, Args&&... args) {
return (base + ... + args);
}

// 二元右折叠:参数包累加后再加上base
// (args + ... + base) → a + (b + (c + base))
template<typename... Args>
auto sum_with_base_right(int base, Args&&... args) {
return (args + ... + base);
}

核心逻辑

  • 二元左折叠 (base + ... + args) 展开规则(以 base=10, args=(1,2,3) 为例):((10 + 1) + 2) + 3 = 16
  • 二元右折叠 (args + ... + base) 展开规则(同上参数):1 + (2 + (3 + 10)) = 16
  • 返回结果:对加法这类满足结合律的操作符,左 / 右折叠结果一致;
  • 空参数包场景:sum_with_base(10) → 直接返回 10(无参数参与,仅保留固定值)。

示例 2:带初始值的逻辑判断

1
2
3
4
5
6
// 二元左折叠:判断“固定值为真 且 参数包所有元素为真”
// (init && ... && args) → ((init && a) && b) && c
template<typename... Args>
bool all_true_with_init(bool init, Args&&... args) {
return (init && ... && args);
}

核心逻辑

  • 展开规则(以 init=true, args=(1, "hello") 为例):((true && 1) && "hello") = true
  • 展开规则(以 init=true, args=(0, "hello") 为例):((true && 0) && "hello") = false(短路运算,遇到 0 后停止);
  • 返回 true 的条件:固定值为真 参数包所有元素为真;只要固定值为假,或任意参数为假,结果为假;
  • 空参数包场景:all_true_with_init(false) → 直接返回 false(仅保留固定值)。

示例 3:非结合律操作符的左 / 右折叠差异

对减法、除法等不满足结合律的操作符,二元左 / 右折叠结果完全不同:

1
2
3
4
5
6
7
8
9
10
11
// 二元左折叠:(base - ... - args) → ((base - a) - b) - c
template<typename... Args>
auto sub_left(int base, Args&&... args) {
return (base - ... - args);
}

// 二元右折叠:(args - ... - base) → a - (b - (c - base))
template<typename... Args>
auto sub_right(int base, Args&&... args) {
return (args - ... - base);
}

展开验证(base=10, args=(2,3))

  • 二元左折叠:((10 - 2) - 3) = 5
  • 二元右折叠:2 - (3 - 10) = 9
  • 结论:使用非结合律操作符时,必须明确左 / 右折叠的结合方向,避免逻辑错误。

二元折叠的典型应用场景

  1. 带初始值的批量运算:如 “从 1 开始累乘参数包”((1 * ... * args))、“给所有参数加偏移量”((offset + ... + args));
  2. 安全处理空参数包:一元折叠空包有默认值(如 (args + ...) 空包返回 0),而二元折叠可通过固定值自定义空包行为(如 (100 + ... + args) 空包返回 100);
  3. 逻辑判断的初始条件:如 “默认允许操作(init=true),仅当所有参数验证通过才保留 true”((true && ... && args))。

几种折叠示例

使用左移操作符示例

1
2
3
4
template<typename... Args>  
void printAll(const Args&... args) {
(std::cout<<...<<args)<<std::endl;
}

折叠表达式 (std::cout<<...<<args)二元折叠的简化写法(也可理解为一元折叠的特殊形式),本质是将 std::cout 作为初始值,与参数包做连续的 << 运算。

==注意:== 错误写法:(std::cout << args << …) << std::endl;
<<二元操作符,且运算顺序是「左操作数是输出流,右操作数是待输出值」(std::ostream& operator<<(std::ostream&, const T&))。

  • 原正确写法 (std::cout<<...<<args):是二元右折叠,展开为 std::cout << arg1 << arg2 << arg3(符合 << 的运算规则);

  • 错误写法 (std::cout << args << ...):是一元左折叠,展开为 (std::cout << arg1) << arg2) << arg3?不 —— 实际编译器会解析为「把 std::cout << args 作为整体,试图用 ... 折叠」,但 args 是参数包,std::cout << args 本身不合法(无法直接输出参数包),因此编译报错:

    1
    2
    error: expected ')' before '...' token
    error: invalid operands to binary expression ('std::ostream' (aka 'basic_ostream<char>') and 'const Args&' [with Args = ...])

简单说:<< 的左操作数必须是 std::ostream 对象,而参数包 args 无法作为 << 的左操作数,因此直接换位置违反了 << 的运算规则。

如果想让 args 出现在 ... 左侧,需要把折叠表达式改成一元左折叠 + 逗号运算符的形式,间接实现等价效果:

1
( (std::cout << args), ... ) << std::endl;

逻辑解析

  • ( (std::cout << args), ... )一元左折叠,折叠的操作符是 ,(逗号运算符);
  • 展开规则(以 args=(10, " hello ", 3.14) 为例):(std::cout<<10), (std::cout<<" hello "), (std::cout<<3.14)
  • 逗号运算符的特性:按顺序执行每个表达式,最终返回最后一个表达式的结果(这里是 std::cout 对象),因此效果和原写法完全一致。