C++ 折叠表达式:“我写递归你写折叠,咱俩代码差十年”

0 阅读21分钟

在 C++11 引入变参模板后,我们可以编写接受任意数量参数的函数或类模板。

但如何对参数包中的每个元素执行操作(如求和、逻辑与、拼接字符串)?

传统的做法是使用递归展开或初始化列表技巧,但代码冗长且易错。

我曾经为了展开一个简单的 print,写了整整 8 行代码,还被别人吐槽“你这递归深度怕不是在编译期练举重”。ヽ(#`Д´)ノ

直到有一天一束光从窗外照射到了我的电脑上——它就是 C++17 的折叠表达式

是它,是它,就是它,让我再也回不到以前开手动拖拉机在田野里奔跑的时光了,现在只能枯燥的单手开着法拉利(´~`)。

好了,正经一点,今天我们就来介绍一下如何单手开法拉利(不是),应该是折叠表达式的原理、用法和那些“坑”。

基础:变参模板

老样子,我们先介绍一下变参模板这个玩意,把它的定义、参数包怎么折腾、以及递归展开那让人又爱又恨的地方先捋一遍。

1. 变参模板的定义:论大胃袋是怎么形成的

C++11 引入的变参模板,本质就是让模板可以接受任意数量、任意类型的参数。语法上靠一个 ... 来标记“参数包”。

举个最经典的例子——打印任意多个东西:

void print() { std::cout << std::endl; }  // 递归终点

template<typename T, typename... Args>
void print(T first, Args... rest) 
{
    std::cout << first << " ";
    print(rest...);  // 递归展开
}

int main()
{
    print(12"hello""world"); // 输出:1 2 hello world

    return 0;
}

这里 typename... Args 就是模板参数包,Args... rest 是函数参数包。

... 出现的位置决定了它是在“声明包”还是在“展开包”。

定义就这么简单,但真正的奥妙全在怎么展开上。

2. 参数包的展开:C++ 早期唯一的方式——递归展开

变参模板刚出来那会儿,我们要想遍历参数包里的每一个参数,最直观的办法就是递归 + 函数重载/特化

通常三步走:

  1. 递归函数模板:每次剥离第一个参数,把剩下的包继续传下去。
  2. 终止重载:当参数包为空时,调用一个无参版本(或特化版本)停止递归。
  3. 用参数包名... 在调用点展开剩余的包。

上面 print 就是典型。

另一个常见场景:构建 tuple 或者给每个元素做相同操作,也是这种递归模式。

#include <iostream>
#include <tuple>
#include <string>

template<size_t I 0typename... Args>
void foreach_in_tuple(const std::tuple<Args...>& t) 
{
    if constexpr (I < sizeof...(Args)) 
    {
        std::cout << std::get<I>(t) << " ";
        foreach_in_tuple<I + 1>(t);
    }
}

int main()
{
    std::tuple<intdouble, std::string> t(13.14"hello");
    foreach_in_tuple(t); // 输出:1 3.14 hello 

    return 0;
}

在上述代码中我们使用了一个 std::tuple,这个东西可以类比一下 std::pair,只不过 pair 只能支持两个参数,而它能存储任意数量、任意类型的数据,在这里就先不细讲了。

在不使用折叠表达式的情况下,这种递归写法几乎统治了所有变参模板的遍历逻辑。

3. 递归展开的缺点:看着不赖,用着头疼

这篇文章主要是写折叠表达式的,所以我要狠狠吐槽一下递归展开这玩意<( ̄︶ ̄)>。

因为递归展开虽然能工作,但有几个“坑”是每个新手(包括我当年)都踩过的。

3.1 编译膨胀与递归深度

  • 每次递归都会实例化一个不同签名的模板函数/类。
    假设我们传了 100 个参数,编译器会生成 print<T1>、print<T1,T2>、print<T1,T2,T3> …… 一直到 print<T1,...,T100> 这 100 个不同的函数。
    编译时间膨胀,二进制体积变大,虽然现代编译器会做一定优化,但理论上这是 O(N) 的模板实例化。
  • 递归深度受限于编译器限制(虽然通常几千层没问题,但总有心理阴影)。

3.2 终止条件“不够自然”

我们必须额外写一个无参版本或者用 if constexpr(C++17 才有的)来截断递归。

要是忘记写终止重载,哦豁!导致编译错误,而且报错信息长得像一篇专门写给我们的小作文。

// 忘了这个就会炸
void print() {}

这种“递归 + 重载”的方式,把简单的遍历逻辑拆散在两个地方,可读性不好。

3.3 代码冗余且难写复杂逻辑

如果我们要对参数包里的每个元素做“某种操作并返回一个结果”,递归写法就要把操作逻辑嵌在模板参数里,代码往往会变得很绕。

比如实现一个 sum 变参函数:

// 递归版求和
template<typename T>
T sum(T v) 
{
    return v;
}

template<typename T, typename... Args>
T sum(T first, Args... rest) 
{
    return first + sum(rest...);
}

虽然也能跑,但为了一个简单的加法,得写两个函数,而且类型推导上还得小心(比如返回类型推导不够完美,需要 decltype 之类)。

3.4 无法直接对包做批量运算

比如我们想“对包里的每个参数都调用某个函数,并且把这些返回值放进一个数组”。

用递归写,要么写一堆辅助结构体,要么用 std::initializer_list(例如 {(f(args),0)...} 这种逗号表达式展开)。

这个其实已经接近折叠表达式的雏形了,但 C++17 之前我们只能用这种“伪折叠”手法,不够直观。

4. 递归展开的“光辉岁月”与折叠表达式的到来

其实递归展开在 C++11/14 时代是唯一的正经手段,大家靠它实现了 tuple、function、bind 等。

但它最大的痛点就是把一个简单的遍历操作强行变成了递归定义,让代码看起来像是“为了用模板而用模板”。

后来 C++17 引入了折叠表达式,直接允许我们用一个二元运算符把参数包“折叠”起来,不需要递归,不需要终止函数,一行搞定。

比如上面的 sum 用折叠表达式写:

template<typename... Args>
auto sum(Args... args) 
{
    return (args + ...); // 右折叠
}

清爽到令人感动。

递归展开不再是唯一解,而且大部分场景下都可以被折叠表达式替代。

但理解递归展开依然重要,因为它能帮我们深入理解参数包的本质模板实例化机制,碰到老代码或者需要更复杂控制流时,递归依然有用武之地。

折叠表达式基本语法

我们来正式进入今天的主题——折叠表达式。

先把基本语法、一元折叠、二元折叠和运算符支持掰扯清楚再来谈其它的。

1. 什么是折叠表达式?

折叠表达式的核心思想:用一个二元运算符,把一个参数包里的所有参数“折叠”成一个值。

听起来抽象?看个例子就懂了。之前递归求和的模板:

template<typename... Args>
auto sum(Args... args) 
{
    return (args + ...); // 右折叠
}

(args + ...) 就是折叠表达式。

它会把 args 包里的所有参数用 + 串起来,比如调用 sum(1, 2, 3, 4),展开后等价于 1 + (2 + (3 + 4))。

这不是比我们写递归函数舒服多了?

2. 一元折叠

一元折叠就是只对参数包本身进行折叠,没有额外的初始值。分两种方向:

2.1 右折叠

语法:( pack op ... )

展开逻辑:( arg1 op ( arg2 op ( arg3 op ... ( argN-1 op argN ))))

举个栗子:

template<typename... Args>
auto rfold(Args... args) 
{
    return (args * ...);
}
// rfold(1, 2, 3, 4) -> 1 * (2 * (3 * 4))

2.2 左折叠

语法:( ... op pack )

展开逻辑:((( arg1 op arg2 ) op arg3 ) ... op argN )

栗子:

template<typename... Args>
auto lfold(Args... args) 

{
    return (... + args);
}
// lfold(1, 2, 3, 4) -> ((1 + 2) + 3) + 4

注意事项:对于大多数满足结合律的运算符(如 +、*、&&、|| 等),左折叠和右折叠结果相同。

但对于减法、除法这种不满足结合律的,结果可能天差地别。比如:

template<typename... Args>
auto sub_left(Args... args) return (... - args); } // ((1-2)-3)-4 = -8

template<typename... Args>
auto sub_right(Args... args) return (args - ...); } // 1-(2-(3-4)) = 1-3 = -2

所以用的时候要心里有数,别到时候 debug 半天( ´・◡・`)。

3. 二元折叠

二元折叠就是带初始值的折叠,语法里多了一个初始值 init。

3.1 右折叠带初始值

语法:( pack op ... op init )

展开:( arg1 op ( arg2 op ( ... ( argN op init ))))

栗子:

template<typename... Args>
auto sum_with_init(Args... args) 
{
    return (args + ... + 0); // 初始值 0 放在最后
}
// 相当于 (1 + (2 + (3 + 0)))

3.2 左折叠带初始值

语法:( init op ... op pack )

展开:((( init op arg1 ) op arg2 ) ... op argN )

栗子:

template<typename... Args>
auto mul_with_init(Args... args) 
{
    return (1 * ... * args); // 初始值 1 放在最前
}
// 相当于 ((1 * arg1) * arg2) * arg3 ...

初始值的类型会影响整个表达式的类型,要注意类型推导。

4. 支持的运算符

折叠表达式支持绝大多数 C++ 的二元运算符,我把它们分成几类:

  • 算术:+ - * / %
  • 位运算:& | ^ << >>
  • 逻辑:&& ||
  • 比较:== != < > <= >=
  • 赋值:= += -= *= /= %= &= |= ^= <<= >>=
  • 逗号:, (对,逗号也能折叠!常用于批量执行表达式)

注意:.、->、[] 这些运算符不行,因为它们需要左操作数是对象或指针,不能直接用于参数包。

另外,空参数包在一元折叠中是不允许的(编译错误),但二元折叠允许——因为初始值的存在,空包时就直接返回初始值。

5. 折叠表达式 vs 递归展开

来个实际对比,用折叠表达式实现一个“打印所有参数并返回最后一个参数”的功能:

// 递归展开版
void print_last() {}

template<typename T, typename... Args>
T print_last(T first, Args... rest) 
{
    std::cout << first << " ";
    if constexpr (sizeof...(rest) == 0) 
        return first;
    else 
        return print_last(rest...);
}

// 折叠表达式版(真香)
template<typename... Args>
auto print_last(Args... args) 
{
    (std::cout << ... << args) << std::endl; // 左折叠,依次打印
    return (args, ...); // 逗号折叠,取最后一个值
}

这里用了两个折叠表达式:

  • (std::cout << ... << args) 是左折叠,等价于 (((cout << arg1) << arg2) << ...),把所有参数打印出来。
  • (args, ...) 是右折叠,等价于 (arg1, (arg2, (arg3, ...))),逗号运算符的特性是取右侧的值,所以整个折叠的结果就是最后一个参数的值。

干净利落,再也不用写递归终点了。

折叠表达式的使用场景

我们之前已经顺带介绍了一些简单的使用场景,比如 +/-/*。

那么下面再例举几个场景,熟悉熟悉怎么使用折叠表达式吧。

1. 逻辑运算(与、或)

先来个开胃小菜。

如果我们想判断参数包里的所有值是否都满足某个条件,或者至少有一个满足,我们得写递归。

现在?一行折叠搞定。

1.1 全满足(&& 折叠)

template<typename... Args>
bool all_true(Args... args) 
{
    return (args && ...);
}

这里用左折叠或右折叠结果一样,因为 && 满足结合律。

假设我们要这样调用呢:

all_true(falseheavy_computation());

其实它不会调用 heavy_computation,因为第一个 false 就让整个 && 短路了。这跟直接用 && 表达式的行为一致。

小贴士:空参数包时,一元 && 折叠的结果是 true,一元 || 折叠是 false。这个行为是标准规定的,省去了我们写终止条件的麻烦。

1.2 任一满足(|| 折叠)

template<typename... Args>
bool any_true(Args... args) 
{
    return (args || ...);
}
// 空包返回 false

和 && 折叠差不多,就不过多说明了( ¯•ω•¯ )。

2.构造容器(批量插入)

我们经常需要把变参模板的参数塞进 vector、set 之类的容器里。

以前要写递归,现在折叠表达式让这事变得很方便。

2.1 用逗号折叠批量插入

template<typename T, typename... Args>
std::vector<T> make_vector(Args&&... args) 
{
    std::vector<T> vec;
    (vec.push_back(std::forward<Args>(args)), ...); // 依次 push_back
    return vec;
}

这里用了逗号运算符,从左到右求值,然后返回表达式的结果。

我们只关心 push_back 的副作用,所以把 vec.push_back(...) 用逗号串起来,每个都执行一遍。

2.2 配合 emplace_back 完美转发

因为 push_back() 会先创建一个临时对象,然后将该对象拷贝或移动到容器中。

这种方式可能会引入额外的拷贝或移动构造函数调用,增加性能开销。

所以我们可以使用 emplace_back(),它直接在容器尾部构造对象,避免了临时对象的创建和拷贝或移动操作。

template<typename... Args>
auto make_vector(Args&&... args) 
{
    std::vector<std::common_type_t<Args...>> vec;
    (vec.emplace_back(std::forward<Args>(args)), ...);
    return vec;
}

3. 批量调用任意函数

有时候我们想对参数包里的每个参数都调用同一个函数,比如打印、日志、或者调用某个回调。

折叠表达式能轻松实现挨个调用。

template<typename Func, typename... Args>
void call_for_each(Func&& f, Args&&... args) 
{
    (std::forward<Func>(f)(std::forward<Args>(args)), ...);
}

这里我们还是用了逗号运算符,参数会按传入的顺序依次调用 f。

说明

  • std::forward<Func>(f):将 f 以原始的值类别传递给调用运算符 operator()。
  • std::forward<Args>(args):同样将每个参数保持其原始类型传递给 f。

我们可以对每个元素调用成员函数:

struct Widget 
{
    void print() const { std::cout << "widget" << std::endl; }
};

int main()
{
    Widget w1, w2, w3;
    auto touch = [](const Widget& w) { w.print(); };
    call_for_each(touch, w1, w2, w3); // 输出:widget widget widget

    return 0;
}

这用起来非常简洁高效。

折叠表达式的细节与特性

在我们之前的内容或多或少都涉及到了它的细节和特性,比如:空包、短路等。

现在我们来详细的介绍一下。

1. 空包的处理

折叠表达式遇到空参数包(sizeof...(args) == 0)时,行为取决于是一元折叠还是二元折叠。

1.1 一元折叠:空包是编译错误(除非逻辑运算符)

  • 对于 ( args + ... ) 或 ( ... + args ) 这种一元折叠,空包直接编译失败。因为编译器不知道该返回什么类型的值。
template<typename... Args>
auto sum(Args... args) 
{
    return (args + ...);
}
sum(); // 报错,空包不能做一元折叠
  • 例外:&& 和 || 运算符的一元折叠允许空包,且有明确定义:

    • (args && ...) 空包 → true
    • (args || ...) 空包 → false

1.2 二元折叠:空包安全,返回初始值

二元折叠因为带了初始值,空包时直接返回初始值,不报错。

template<typename... Args>
auto sum(Args... args) 
{
    return (args + ... + 0); // 二元右折叠,空包 -> 0
}
sum(); // OK,返回 0

不过要注意一下:二元折叠的初始值如果类型不对,空包时依然会有类型问题。

比如 (args + ... + "") 空包返回 "",可能不是我们想要的。

2. 类型推导:折叠表达式的结果类型怎么定?

折叠表达式的结果类型由运算符和操作数的类型共同决定,遵循 C++ 的常规算术转换和重载决议规则。

2.1 一元折叠:结果类型是参数包展开后的类型

例如 (args + ...):假设 args 包里有 int, double, int,那么展开成 (int + (double + int)),结果类型是 double(因为 int + double 提升为 double)。

如果参数包里的类型不能一起运算,编译后可能会出现问题。

比如 (string + ... + int),因为 string + int 不一定合法。

2.2 二元折叠:初始值类型会影响结果

// 为了直观些,先这么写了
auto result = (args + ... + 0); // 0 是 int,整个表达式结果类型是 int
auto result2 = (args + ... + 0.0); // 0.0 是 double,结果类型是 double

我们为了让结果类型“最宽”,通常用 decltype 或者直接给初始值为 0.0 或 0L。

3. 运算符的短路行为

C++ 的逻辑运算符 && 和 || 具有短路特性:一旦能确定整个表达式的结果,就不再计算后面的子表达式。

折叠表达式完美保留了这一特性。

3.1 && 的短路

template<typename... Args>
bool all(Args... args) 
{
    return (args && ...);
}
all(falseexpensive()); // expensive() 不会被调用

展开后的表达式是 (false && expensive()),由于 false && anything 恒为 false,所以 expensive() 不会执行。

3.2 || 的短路

template<typename... Args>
bool any(Args... args) 
{
    return (args || ...);
}
any(truedangerous()); // dangerous() 不会被调用

4. 折叠表达式与优先级

折叠表达式本身已经隐含了括号,不需要我们再手动加括号来指定结合顺序。

但一旦我们把折叠表达式和其他表达式混在一起,优先级问题就来了。

4.1 折叠表达式自带括号

return (args + ...); // 等价于 (arg1 + (arg2 + (arg3 + ...)))
return (... + args); // 等价于 (((arg1 + arg2) + arg3) + ...)

4.2 混用时的陷阱

template<typename... Args>
bool all_and_extra(Args... args, bool extra) 
{
    return (args && ...) && extra; // 没问题,折叠表达式先求值,然后与 extra 做 &&
}

template<typename... Args>
bool all_and_extra_wrong(Args... args, bool extra) {
    return args && ... && extra; // 错误,折叠表达式语法要求 ... 必须在包名旁边
}

正确的写法是:折叠表达式作为一个整体,外面再加运算符。

4.3 折叠表达式内的运算符优先级

折叠表达式只保证结合顺序,但不改变运算符内部的优先级。

比如 (args + ... * 2) 这种写法看起来没啥问题:

auto r = (args + ... * 2); // 报错

因为折叠表达式的形式必须是 ( pack op ... ) 或 ( ... op pack ),不能有其他东西。

所以 (args + ... * 2) 是不合法的,编译器会报错。

因此请不要在折叠表达式里夹带额外的高优先级运算符,老老实实用括号把折叠部分包起来,再跟外部运算(ㅍ_ㅍ)。

一些技巧

我们来介绍一下折叠表达式的使用技巧,当然,这些技巧可能不是天天用,但是遇到适合的场景,用起来会非常带感。

开始之前还是要提醒一下:切记切记!有时候我们写代码不要刻意的为了用而用,不然容易写出来让人眼前一黑的玩意,过个几天看当时自己写的东西还得在那思考半天。

1. 条件折叠:在折叠过程中加入逻辑判断

有时候我们并不想对所有参数都执行同一个操作,而是要根据某些条件选择性地折叠。

条件折叠通常结合 if constexpr 或三元运算符实现。

1.1 根据类型条件执行

假设我们想对参数包中的每个元素,如果是整数就累加,否则忽略。

用递归写会很啰嗦,但用折叠 + 三元运算符可以轻松做到:

template<typename... Args>
int sum_ints(Args... args) 
{
    return ((std::is_integral_v<Args> ? args : 0) + ...);
}

这里 (std::is_integral_v<Args> ? args : 0) 是一个包展开,每个参数都会经过这个三元表达式,返回自身或 0,然后所有结果用 + 折叠。

1.2 带条件的函数调用

如果我们想对满足某个参数的函数进行调用,而不满足的就调用另一个函数,可以这样:

template<typename Func1, typename Func2, typename... Args>
void conditional_call(Func1&& f1, Func2&& f2, Args&&... args) 
{
    ( (std::is_integral_v<Args> 
        ? std::forward<Func1>(f1)(std::forward<Args>(args))
        : std::forward<Func2>(f2)(std::forward<Args>(args))
      ), ...);
}

三元运算符的两个分支必须是同一类型,或者能隐式转换为同一类型。

如果 f1 和 f2 的返回类型不同,且我们关心返回值,就需要统一成 void。

1.3 用 if constexpr 在折叠内部做更复杂的控制

三元运算符在编译期求值,但对于更复杂的逻辑(比如根据参数类型执行不同代码块),可以用 if constexpr 配合 lambda:

template<typename... Args>
void process(Args&&... args) 
{
    ([&](auto&& arg) {
        if constexpr (std::is_integral_v<std::decay_t<decltype(arg)>>) 
        {
            // 整数处理
            std::cout << "int: " << arg << std::endl;
        }
        else 
        {
            // 其他处理
            std::cout << "other: " << arg << std::endl;
        }
        }(std::forward<Args>(args)), ...);
}

如果前面的批量调用函数那部分看的细心的话,就知道 std::forward<Args>(args) 会将每个参数保持其原始类型传递给 lambda。

然后每个 lambda 立即调用,内部用 if constexpr 根据实际类型分支。

那么再聊聊为什么这么写吧:

  • if constexpr 在编译期完成分支选择,没有运行时开销。
  • lambda 内部可以写任意复杂的代码,比三元运算符灵活得多。
  • 结合折叠表达式,实现了“对每个参数进行类型分发”的效果,相当于编译期多态。

2. 用折叠表达式简化变参 lambda 调用

lambda 本身不支持变参模板(C++20 才支持,但有限制),但我们可以利用折叠表达式和 std::apply 将参数包或元组展开后喂给 lambda。

这里就简单介绍一下 std::apply 的用法吧:

它是 C++17 引入的一个函数,用于将元组(也就是 Tuple)的元素解包并作为参数传递给指定的可调用对象(如函数、lambda 表达式等)。

这里的元组可以是 std::tuple、std::pair 或类似支持元组操作的类型(如 std::array)。

2.1 直接展开参数包调用 lambda

如果我们想对一组参数执行某个 lambda,并且 lambda 接受多个参数(比如求和、求最大等),可以直接用折叠表达式:

auto sum = [](auto... args) { return (args + ...); };
int result = sum(123); // OK,C++17 支持变参泛型 lambda

C++17 的泛型 lambda 已经支持参数包,所以直接写变参 lambda 是合法的。

但如果我们想要更复杂的控制,比如在 lambda 内部对每个参数单独操作,那就需要折叠表达式来帮忙:

auto print_all = [](auto... args) 
{
    (std::cout << ... << args) << std::endl;
};

print_all(12.5"hello");

这里 lambda 内部用了折叠表达式,简洁明了。

2.2 与 std::apply 配合:展开元组后调用 lambda

std::apply 接受一个元组和一个可调用对象,把元组的元素作为参数传递给可调用对象。

配合折叠表达式,可以轻松实现“对元组的每个元素分别调用 lambda”:

std::tuple<intdouble, std::string> tup{ 13.14"world" };

std::apply([](auto&&... args) {
    (std::cout << ... << args) << std::endl;
    }, tup);

这里的 lambda 是变参的,std::apply 把元组展开成参数包,然后 lambda 内部用折叠表达式依次输出。

2.3 简化“对每个元素执行 lambda”的语法

我们之前写过一个 call_for_each 函数,现在如果想让它接受一个 lambda 和一个参数包,然后对每个参数调用 lambda:

template<typename Func, typename... Args>
void call_for_each(Func&& f, Args&&... args) 
{
    (std::forward<Func>(f)(std::forward<Args>(args)), ...);
}

call_for_each([](auto x) { std::cout << x << ' '; }, 12.5"hi");

这个模板既是“与完美转发结合”的例子,也是“简化变参 lambda 调用”的例子。

3. 组合技:完美转发 + 条件折叠 + lambda 调用

最后来个综合例子,展示一下它们是如何一起工作。

我们来实现一个函数 invoke_if,接受某个元素(编译期判断)和一个可调用对象,对参数包中满足某个元素而调用该对象,不满足的则跳过,并且要完美转发。

template<typename Func, typename... Args>
void invoke_if(Func&& f, Args&&... args) 
{
    ([&](auto&& arg) {
        if constexpr (std::is_integral_v<std::decay_t<decltype(arg)>>)
        {
            std::forward<Func>(f)(std::forward<decltype(arg)>(arg));
        }
        }(std::forward<Args>(args)), ...);
}

使用:

int main()
{
    auto print = [](auto x) { std::cout << x << ' '; };
    invoke_if(print, 12.53"hello"); // 输出:1 3

    return 0;
}

这里:

  • 折叠表达式遍历每个参数。
  • 对每个参数,立即调用一个 lambda,lambda 内部用 if constexpr 根据 is_integral_v 的结果决定是否调用 f。
  • 完美转发保证了参数的值类别。

这些技巧的共同点:把“遍历”和“操作”分离,用折叠表达式作为遍历引擎,操作部分用 lambda 表达,编译期条件用 if constexpr 控制。

这样写出来的代码既通用又高效。

最后还是要说一下:这些技巧啥的还是不要写的太复杂了,不然到时候就是你和编译器大眼瞪小眼了(´◉‿◉`)。

结尾

聊了这么多,从递归展开的痛苦,到折叠表达式的清爽,再到那些让人拍大腿的小技巧,我们应该已经感受到了:

“折叠表达式不是锦上添花的语法糖,而是一口能甜到你心里的变参模板编程革命。”

整篇文章写下来,我想说一下:

  1. 代码应该表达意图,而不是实现细节
    以前写 print(rest...) 递归,我们是在教编译器“怎么遍历”。
    现在写 (cout << ... << args),我们是在告诉编译器“我要打印所有参数”(能让我解放双手的就是好东西)。
  2. 编译期计算也可以很方便
    很多人觉得模板元编程是噩梦,因为代码长得不像正常 C++。
    折叠表达式证明了:编译期计算也可以用自然的语法表达,甚至比运行时代码还简洁。
  3. 组合的力量大于单个特性
    折叠表达式单独看只是个小语法,但和完美转发、if constexpr、std::apply 组合起来,就能写出非常高效的代码。

最后还是要插一嘴:

折叠表达式虽好,但当代码可读性明显下降时(如果一行折叠表达式需要花 5 分钟理解)拆开写也许更好。