我第一次真用上折叠表达式,是在写日志的时候。
线上出了点事故。
我得把一串变量一次性吐出来。
在 C 里你会写 printf。
但 C 的 ... 是运行时的。
类型信息靠格式串兜着。
格式串一写错,崩的就是你自己。
我想要的是另一种东西。
编译期就知道每个参数的类型。
然后对每个参数做同一件事。
先补一块底:参数包到底是什么
参数包不是数组。
也没有下标。
你可以把它理解成:编译器手里的一叠形参。
template <class... Ts>
void f(Ts... xs) {
static_assert(sizeof...(Ts) == sizeof...(xs));
}
Ts... 是类型包。
xs... 是值包。
sizeof... 只告诉你“有几个”。
它不会把参数包变成数组。
如果你写过 C 的变参函数,直觉很容易跑偏。
C 的 ... 的确能用 va_list 遍历。
但类型信息没了,得靠格式串或约定来解释那串字节。
所以别指望“我有一包参数,那我就 for 一下”。
这里根本没有下标。
当年我们怎么“展开”参数包
那几年展开参数包,大概就两条路。
一条是模板递归。
另一条是把表达式塞进初始化列表里,靠副作用把它“跑”一遍。
旧写法 1:模板递归
先看一个最小能跑的版本。
#include <iostream>
inline void log() {}
template <class T, class... Ts>
void log(const T& x, const Ts&... xs) {
std::cout << x << ' ';
log(xs...);
}
每次打印一个。
剩下的继续递归。
代价也很直白。
你必须手写那个空参数的“终止版本”。
参数一多,实例化一多,编译时间和报错栈都会变长。
最常见的坑就是忘了终止版本。
你得到的不是一个清爽的错误。
而是一整堵模板回溯墙。
旧写法 2:初始化列表“强行展开”
再看一个当年很流行的技巧。
#include <initializer_list>
#include <iostream>
template <class... Ts>
void log(const Ts&... xs) {
(void)std::initializer_list<int>{((std::cout << xs << ' '), 0)...};
}
关键点是。
{ expr... } 会对包做展开。
逗号表达式最后给个 0,把整个表达式变成 int。
这样才能放进 initializer_list<int>。
它确实不递归。
但读起来很绕。
你为了“打印”引入了一堆跟打印无关的语法道具。
这种技巧一多,代码库就会变得难读。
折叠表达式:给参数包一个 reduce 的写法
折叠表达式做的事很像 fold/reduce。
把一串东西按某个运算符“叠”成一个表达式。
C++98 的 std::accumulate 是运行时版本。
它吃迭代器区间。
折叠表达式就是参数包的版本。
形态先别背:先看它到底会展开成什么
你写的 (xs + ...),编译器最后会把它展开成一串带括号的 +。
template <class... Ts>
auto sum(Ts... xs) {
return (xs + ...);
}
sum(1, 2, 3) 会展开成 ((1 + 2) + 3)。
你可以把它理解成:括号从左往右堆。
用递归也能做到。
但你得写终止版本,还得面对更长的报错。
这里也有个边角。
空参数包上没有东西可以 +。
打印一串参数:为什么经常写成“逗号折叠”
打印这种事,重点不是“算出一个值”。
而是按顺序做一串动作。
#include <iostream>
template <class... Ts>
void log(const Ts&... xs) {
((std::cout << xs << ' '), ...);
}
std::cout << xs << ' ' 是一个动作。
逗号运算符把这些动作串起来。
它看着有点别扭。
但至少不需要你理解 initializer_list<int> 那套。
如果你想用 << 直接折叠,还顺便处理分隔符。
你很快就会把自己绕晕。
这类需求要么用逗号折叠。
要么单独维护一个“是不是第一个”的状态。
左折叠、右折叠:什么时候你必须在意
当运算不满足结合律时,左折叠和右折叠会给出不同结果。
template <class... Ts>
auto left_minus(Ts... xs) { return (xs - ...); }
template <class... Ts>
auto right_minus(Ts... xs) { return (... - xs); }
left_minus(1, 2, 3) 是 ((1 - 2) - 3)。
right_minus(1, 2, 3) 是 (1 - (2 - 3))。
对 + 这种你直觉里“差不多”的运算,你可能不在意。
但一旦你换成 -、/,或者任何带状态的运算符重载。
你就不能装作没看见。
你如果把折叠表达式当成“无脑替换递归”。
迟早会在某一天发现结果对了又不对。
因为你其实换了括号。
空参数包:单位元不是语法细节,是你的默认语义
空参数包时返回什么,不是语法细节。
这就是你要的默认行为。
template <class... Ts>
auto sum_or_zero(Ts... xs) {
return (0 + ... + xs);
}
这里的 0 是起始值。
参数包为空时,它就直接返回 0。
你用递归也能写出同样的默认行为,只是要手写空版本。
还有个很容易写错的地方。
起始值不是永远都能用 0。
你在拼 std::string,单位元就该是空串。
你在合并 std::vector,单位元就该是空 vector。
这块别偷懒。
另一个更容易误会的:逻辑折叠的空包结果
再看一个更容易踩坑的。
template <class... Bs>
bool all(Bs... bs) { return (... && bs); }
template <class... Bs>
bool any(Bs... bs) { return (... || bs); }
空包时 all() 会得到 true。
空包时 any() 会得到 false。
这在逻辑上是合理的。
但在业务语义上未必合理。
你用 all(valid(x)...) 做校验。
如果上游不小心传了空包。
你的校验会“自动通过”。
副作用与顺序:别在折叠里藏小动作
折叠表达式在“纯计算”时很舒服。
但你一旦塞了副作用,就得想清楚求值顺序。
template <class... Ts>
int bad(Ts... xs) {
int i = 0;
return (i++ + ... + xs);
}
这段代码能编。
但读起来很别扭。
你把“累加”跟“改状态”揉在一起了。
别人读到这里会停下来想:你到底想表达什么。
还得提醒一句,+ 并不保证你以为的那种从左到右求值。
如果你真的是要“按顺序做动作”。
用逗号折叠,或者老老实实写循环。
别把 ++i、i++、改全局状态这种东西塞进 + 折叠里。
它不一定马上出错,但肯定会让代码更难维护。
我最后留给自己的两条规则
第一条。
我把折叠表达式当成“把递归模板样板删掉”的工具。
不是当成“更酷的宏”。
第二条。
我一旦要处理空包。
我会把单位元当成接口语义的一部分认真写出来。
因为为空包时返回什么,往往比语法本身更影响 bug 会藏在哪儿。