在 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(1, 2, "hello", "world"); // 输出:1 2 hello world
return 0;
}
这里 typename... Args 就是模板参数包,Args... rest 是函数参数包。
... 出现的位置决定了它是在“声明包”还是在“展开包”。
定义就这么简单,但真正的奥妙全在怎么展开上。
2. 参数包的展开:C++ 早期唯一的方式——递归展开
变参模板刚出来那会儿,我们要想遍历参数包里的每一个参数,最直观的办法就是递归 + 函数重载/特化。
通常三步走:
- 递归函数模板:每次剥离第一个参数,把剩下的包继续传下去。
- 终止重载:当参数包为空时,调用一个无参版本(或特化版本)停止递归。
- 用参数包名... 在调用点展开剩余的包。
上面 print 就是典型。
另一个常见场景:构建 tuple 或者给每个元素做相同操作,也是这种递归模式。
#include <iostream>
#include <tuple>
#include <string>
template<size_t I = 0, typename... 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<int, double, std::string> t(1, 3.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(false, heavy_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(false, expensive()); // expensive() 不会被调用
展开后的表达式是 (false && expensive()),由于 false && anything 恒为 false,所以 expensive() 不会执行。
3.2 || 的短路
template<typename... Args>
bool any(Args... args)
{
return (args || ...);
}
any(true, dangerous()); // 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(1, 2, 3); // OK,C++17 支持变参泛型 lambda
C++17 的泛型 lambda 已经支持参数包,所以直接写变参 lambda 是合法的。
但如果我们想要更复杂的控制,比如在 lambda 内部对每个参数单独操作,那就需要折叠表达式来帮忙:
auto print_all = [](auto... args)
{
(std::cout << ... << args) << std::endl;
};
print_all(1, 2.5, "hello");
这里 lambda 内部用了折叠表达式,简洁明了。
2.2 与 std::apply 配合:展开元组后调用 lambda
std::apply 接受一个元组和一个可调用对象,把元组的元素作为参数传递给可调用对象。
配合折叠表达式,可以轻松实现“对元组的每个元素分别调用 lambda”:
std::tuple<int, double, std::string> tup{ 1, 3.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 << ' '; }, 1, 2.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, 1, 2.5, 3, "hello"); // 输出:1 3
return 0;
}
这里:
- 折叠表达式遍历每个参数。
- 对每个参数,立即调用一个 lambda,lambda 内部用 if constexpr 根据 is_integral_v 的结果决定是否调用 f。
- 完美转发保证了参数的值类别。
这些技巧的共同点:把“遍历”和“操作”分离,用折叠表达式作为遍历引擎,操作部分用 lambda 表达,编译期条件用 if constexpr 控制。
这样写出来的代码既通用又高效。
最后还是要说一下:这些技巧啥的还是不要写的太复杂了,不然到时候就是你和编译器大眼瞪小眼了(´◉‿◉`)。
结尾
聊了这么多,从递归展开的痛苦,到折叠表达式的清爽,再到那些让人拍大腿的小技巧,我们应该已经感受到了:
“折叠表达式不是锦上添花的语法糖,而是一口能甜到你心里的变参模板编程革命。”
整篇文章写下来,我想说一下:
- 代码应该表达意图,而不是实现细节
以前写 print(rest...) 递归,我们是在教编译器“怎么遍历”。
现在写 (cout << ... << args),我们是在告诉编译器“我要打印所有参数”(能让我解放双手的就是好东西)。 - 编译期计算也可以很方便
很多人觉得模板元编程是噩梦,因为代码长得不像正常 C++。
折叠表达式证明了:编译期计算也可以用自然的语法表达,甚至比运行时代码还简洁。 - 组合的力量大于单个特性
折叠表达式单独看只是个小语法,但和完美转发、if constexpr、std::apply 组合起来,就能写出非常高效的代码。
最后还是要插一嘴:
折叠表达式虽好,但当代码可读性明显下降时(如果一行折叠表达式需要花 5 分钟理解)拆开写也许更好。