ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

可变参数模板与 C++17 折叠表达式:从递归展开到一行求和

可变参数模板与 C++17 折叠表达式:从递归展开到一行求和 写日志、写断言、写make_shared这类工具函数时总会遇到「参数个数不确定」的需求。C11 给了可变参数模板variadic template但用起来得手写递归代码长得难看C17 的折叠表达式fold expression把这件事压缩成了一行。这篇先讲清楚递归版为什么必须写成那两个函数再讲折叠表达式的四种形式、求值顺序和空参数包的行为最后把两者放在一起对比。1. 引子一个「收任意个参数」的求和函数需求很朴素sum(1, 2, 3, 4)得到 10sum(1.5, 2.5)得到 4.0参数个数随调用点变化。函数重载做不到——你得为每个参数个数写一份std::initializer_list又要求实参类型统一。可变参数模板的写法是template class... Ts void f(Ts... args)Ts叫模板参数包template parameter packargs叫函数参数包function parameter pack。问题是拿到「一包东西」之后怎么用——你没法用for遍历一个包因为包里的元素类型可以各不相同for需要同一种类型。接下来两节就是两种解法。官方文档Parameter pack — cppreference2.sizeof...包里有几个元素唯一能直接对包做的「查询」是sizeof...(pack)——注意它数的是元素个数不是字节数跟sizeof完全无关只是长得像。它是编译期常量可以直接进static_assert// variadic_sizeof.cpp — 编译: g -stdc17 -Wall -O2 variadic_sizeof.cpp -o vs#includecstdio// 参数包不给名字也行只关心个数时更清爽templateclass...Tsvoidhow_many(constTs...){std::printf(参数个数 %zu\n,sizeof...(Ts));static_assert(sizeof...(Ts)0,个数永远非负);}intmain(){how_many();// 空包how_many(1);how_many(1,two,3.0);// 类型可以完全不同int / const char* / double}参数个数 0 参数个数 1 参数个数 3sizeof...解决的是「有几个」但拿不到「分别是哪几个」。要真的处理每一个元素得靠展开。3. C11 的写法递归展开C17 之前标准做法是递归每次从包里摘掉第一个元素把剩下的继续递归直到包里只剩一个元素时停下来。这两个角色分别叫基准情形base case和递归步recursive step// variadic_recursive.cpp — 编译: g -stdc17 -Wall -O2 variadic_recursive.cpp -o vr#includecstdio// 基准情形包里只剩一个元素直接返回它 —— 递归到此为止templateclassTTsum(T v){returnv;}// 递归步摘出第一个 first剩下的 rest 继续递归templateclassT,class...Restautosum(T first,Rest...rest){returnfirstsum(rest...);}intmain(){std::printf(sum(1,2,3,4) %d\n,sum(1,2,3,4));std::printf(sum(1.5,2.5) %.1f\n,sum(1.5,2.5));}sum(1,2,3,4) 10 sum(1.5,2.5) 4.0调用sum(1,2,3,4)时编译器做的事是这样的sum(1, 2, 3, 4) ← 命中递归步Tint, Rest{int,int,int} └─ 1 sum(2, 3, 4) ← 又命中递归步Tint, Rest{int,int} └─ 2 sum(3, 4) ← 又命中递归步Tint, Rest{int} └─ 3 sum(4) ← 命中基准情形Tint └─ 4 ← 3 4 7 ← 2 7 9 ← 1 9 10为什么基准情形必须单独写因为递归步的Rest...允许是空包所以sum(4)两个模板都匹配得上。此时靠偏序规则partial orderingsum(T)比sum(T, Rest...)更特化编译器选它。于是每递归一层参数就少一个最后一层一定落在这个「只吃一个参数」的版本上。换句话说基准情形不是可有可无的收尾它就是递归的终止条件——少了它sum(4)里sum()找不到任何匹配。有个细节值得强调Rest... rest用的是按值传参每递归一层就多一次拷贝。参数是std::string这类重对象时这份拷贝是实打实的开销引用折叠 完美转发能省掉但代码会更长——这正是折叠表达式想解决的问题。4. C17 折叠表达式四种形式折叠表达式fold expression直接对包做二元运算展开不需要基准情形、不需要递归。它有四种形式取决于初值init放在哪一侧、以及包在运算符的哪一侧形式写法展开成空包时的结果一元右折叠(pack op ...)a1 op (a2 op (a3 op a4))只有||,合法取其单位元一元左折叠(... op pack)((a1 op a2) op a3) op a4同上二元右折叠(pack op ... op init)a1 op (a2 op (a3 op (a4 op init)))init二元左折叠(init op ... op pack)(((init op a1) op a2) op a3) op a4init注意二元形式必须左右两个运算符写同一件事(init op ... op pack)里前后两个op必须完全一致写错编译器直接拒绝。合法的运算符就是那些可结合的二元运算符 - * / %、 | ^、 、 ||、,、与复合赋值、比较运算符等。左右折叠的差别只有一件事——括号往哪边靠也就是求值顺序一元右折叠 (a op ...) 一元左折叠 (... op a) a1 op (a2 op (a3 op a4)) ((a1 op a2) op a3) op a4 a4 ← 最先参与 a1 ── op ── a2 ← 最先算 │ │ a3 ──┘ ... │ │ a2 ──┘ (…) op a3 │ │ a1 ──┘ (…) op a4 ▼ ▼ 结果 结果 二元右折叠 (a op ... op init) 二元左折叠 (init op ... op a) a1 op (a2 op (a3 op (a4 op init))) (((init op a1) op a2) op a3) op a4 init 埋在最内层、最先参与 init 在最外层、最先参与对和*这种可结合运算左右折叠结果一样选哪个都行。但对-和/这种不可结合的运算选错方向结果直接不同——这就是下面这段代码要演示的// fold_forms.cpp — 编译: g -stdc17 -Wall -O2 fold_forms.cpp -o ff#includecstdio// 一元右折叠(xs - ...) → x1 - (x2 - (x3 - x4))templateclass...Tsautounary_right(Ts...xs){return(xs-...);}// 一元左折叠(... - xs) → ((x1 - x2) - x3) - x4templateclass...Tsautounary_left(Ts...xs){return(...-xs);}// 二元左折叠(100 - ... - xs) → (((100 - x1) - x2) - x3) - x4templateclass...Tsautobin_left(Ts...xs){return(100-...-xs);}// 二元右折叠(xs - ... - 100) → x1 - (x2 - (x3 - (x4 - 100)))templateclass...Tsautobin_right(Ts...xs){return(xs-...-100);}intmain(){std::printf(一元右折叠 (xs - ...) %d\n,unary_right(1,2,3,4));std::printf(一元左折叠 (... - xs) %d\n,unary_left(1,2,3,4));std::printf(二元左折叠 (100 - ... -xs) %d\n,bin_left(1,2,3,4));std::printf(二元右折叠 (xs - ... -100) %d\n,bin_right(1,2,3,4));}一元右折叠 (xs - ...) -2 一元左折叠 (... - xs) -8 二元左折叠 (100 - ... -xs) 90 二元右折叠 (xs - ... -100) 98四个结果-2 / -8 / 90 / 98各不相同正好对应上表四行。手算验收一遍1-(2-(3-4)) 1-(2-(-1)) -2((1-2)-3)-4 -8(((100-1)-2)-3)-4 901-(2-(3-(4-100))) 1-(2-99) 98。同一包参数、同一个运算符括号方向不同结果就不同——所以写-/这类运算时方向不是风格问题是正确性问题。官方文档Fold expression — cppreference5. 用折叠表达式做求和、打印、逻辑判断折叠表达式真正的价值在于把「逐元素做一件事」的样板代码干掉。下面三种是日常最常用的// fold_use.cpp — 编译: g -stdc17 -Wall -O2 fold_use.cpp -o fu#includecstdio#includeiostream// ① 逗号折叠对每个元素各做一次动作展开成 (coutx1), (coutx2), (coutx3)templateclass...Tsvoidprint_all(constTs...xs){((std::coutxs ),...);std::cout\n;// 用 \n 而不是 std::endl}// ② 逻辑与折叠全真才真遇到第一个假就短路templateclass...Tsboolall_true(Ts...xs){return(xs...);}// ③ 逻辑或折叠任一为真即真templateclass...Tsboolany_true(Ts...xs){return(xs||...);}// ④ 二元左折叠 从初值 0 开始累加templateclass...Tsautosum_all(Ts...xs){return(0...xs);}// ⑤ 二元左折叠从初值 1 开始连乘templateclass...Tsautoproduct_all(Ts...xs){return(1*...*xs);}intmain(){print_all(1,2.5,three);// 类型各不相同也能逐个打印print_all();// 空包逗号折叠得 void只输出一个换行std::printf(all_true(1,2,3) %d\n,all_true(1,2,3));std::printf(all_true(1,0,3) %d\n,all_true(1,0,3));std::printf(any_true(0,0,2) %d\n,any_true(0,0,2));std::printf(sum_all(1,2,3,4) %d\n,sum_all(1,2,3,4));std::printf(product_all(2,3,4) %d\n,product_all(2,3,4));}1 2.5 three all_true(1,2,3) 1 all_true(1,0,3) 0 any_true(0,0,2) 1 sum_all(1,2,3,4) 10 product_all(2,3,4) 24print_all(1, 2.5, three)一行搞定三种类型混排——换成递归版得写基准情形 递归步两个函数而且递归版处理不了「零参数」这一档。all_true(1, 0, 3)是0因为折叠和普通一样短路第一个假之后不再求值0后面的3根本不参与。6. 空参数包的返回值三个单位元上一节最后调了print_all()——空包。这是折叠表达式和递归版差距最大的地方。递归版遇到空包直接编不过// variadic_empty_fail.cpp片段故意编不过—— 空包 递归 没有可用的重载templateclassTTsum(T v){returnv;}templateclassT,class...Restautosum(T first,Rest...rest){returnfirstsum(rest...);}// 反例不要这么写 —— 下面这行要求「零个参数求和」但基准情形至少要吃一个参数// auto z sum();//// 真实报错prog.cc: error: no matching function for call to sum()// note: candidate: templateclass T T sum(T)// note: candidate expects 1 argument, 0 provided// note: candidate: templateclass T, class... Rest auto sum(T, Rest...)// note: candidate expects at least 1 argument, 0 provided折叠表达式则天生支持空包代价是你要知道每种运算符「零个元素的运算结果」是什么。规则很克制只有三个运算符允许一元折叠作用于空包其余一律要求非空。// fold_empty.cpp — 编译: g -stdc17 -Wall -O2 fold_empty.cpp -o fe#includecstdiotemplateclass...Tsboolall_true(Ts...xs){return(xs...);}// 空包 → truetemplateclass...Tsboolany_true(Ts...xs){return(xs||...);}// 空包 → falsetemplateclass...Tsvoidtouch(constTs...xs){(static_castvoid(xs),...);}// 空包 → void什么都不做templateclass...Tsautosum_all(Ts...xs){return(0...xs);}// 空包 → 初值 0intmain(){std::printf(空包 折叠 all_true() %d\n,all_true());std::printf(空包 || 折叠 any_true() %d\n,any_true());std::printf(空包 , 折叠 touch() 合法返回值是 void\n);touch();std::printf(空包 折叠 sum_all() %d来自初值\n,sum_all());}空包 折叠 all_true() 1 空包 || 折叠 any_true() 0 空包 , 折叠 touch() 合法返回值是 void 空包 折叠 sum_all() 0来自初值运算符一元折叠对空包的结果直觉解释true「所有元素都为真」的单位元——没有元素时不该断言为假||false「存在一个元素为真」的单位元——没有元素时不存在反例,void空包时整个表达式不产生值也不产生任何副作用其它-*…编译错误没有定义「零项加法的结果」必须改用二元折叠给初值取true、||取false不是随便定的它跟数学上的空集约定完全一致空集上「所有元素都满足 P」恒真「存在元素满足 P」恒假。写泛型断言时务必记住这一点否则all_true()在参数为空时静默返回true会埋下逻辑洞。官方文档sizeof… operator — cppreference7. 递归版 vs 折叠版一篇对比维度C11 递归展开C17 折叠表达式需要的函数个数两个基准情形 递归步一个函数一行表达式空参数包编不过没有可用的基准情形合法取运算符的单位元实例化数量N 个实参 → 实例化 O(N) 个函数模板1 个实例包在实例化内部展开编译期开销随实参个数线性增长还受编译器的模板递归深度上限约束与实参个数基本无关不受递归深度约束实参传递常按值传参每层一次拷贝可配std::forward完美转发零拷贝求值顺序由递归结构决定写在代码里但不易一眼看出左 / 右折叠显式决定代码里直接可读报错可读性错误常指向递归栈最里层的那次实例化直接指向折叠表达式那一行为什么折叠版编译期开销更低递归版的每一层都是一个独立的函数模板特化sumint,int,int、sumint,int、sumint编译器要分别为它们做参数推导、重载解析、实例化N 个元素就是 N 次。折叠表达式只有一份实例化包在实例化内部按规则展开成一串二元运算编译器不需要为「少一个元素的包」再走一遍模板机制。参数个数上百时这个差距会体现在编译时间上。反过来递归版也并非一无是处基准情形给了你一个天然的收敛点做类型层面的递归变换比如递归展开嵌套类型、按类型逐个分派时if constexpr 递归仍是最清晰的写法。官方文档C Core Guidelines — T.1 用模板直接表达意图8. 完整示例把折叠表达式用到完美转发上把前面的东西合起来写一个make_vec()收一堆同类型实参塞进std::vector返回。用二元左折叠处理「第 2 个到第 N 个」参数配合完美转发perfect forwarding避免拷贝// fold_full.cpp — 编译: g -stdc17 -Wall -O2 fold_full.cpp -o ffull#includecstdio#includestring#includeutility#includevector// first 单独处理剩下的包用逗号折叠逐个转发进去templateclassT,class...Reststd::vectorTmake_vec(T first,Rest...rest){std::vectorTv{};v.reserve(1sizeof...(rest));// 一次分配不要靠扩容v.push_back(std::move(first));(v.push_back(std::forwardRest(rest)),...);returnv;}// 加上初值的二元左折叠版求和空包返回 0不需要基准情形templateclass...Tsautosum_all(Ts...xs){return(0...xs);}intmain(){conststd::vectorstd::stringvmake_vecstd::string(a,b,c);std::printf(make_vec 大小 %zu内容 ,v.size());for(constautos:v)std::printf(%s ,s.c_str());std::printf(\n);std::printf(sum_all(1,2,3,4) %d\n,sum_all(1,2,3,4));std::printf(sum_all() %d空包取初值\n,sum_all());}make_vec 大小 3内容 a b c sum_all(1,2,3,4) 10 sum_all() 0空包取初值v.reserve(1 sizeof...(rest))是关键的一行sizeof...在编译期算出总个数一次性把容量要够后面 N 次push_back都不会触发重新分配。如果不写它vector会按 1、2、4、8 的节奏反复扩容每次扩容都要把已有元素搬一次家——这就是「编译期算好的常数」直接换来的运行时收益。std::forwardRest(rest)保留每个实参的值类别右值直接移动进来不留拷贝。整个程序没有裸new/delete也没有using namespace std;。9. 延伸阅读Parameter pack — cppreference —— 包的定义、包展开pack expansion的全部规则Fold expression — cppreference —— 四种形式的准确定义与合法运算符清单细节以这页为准sizeof… operator — cppreference —— 数包元素个数的唯一手段std::forward — cppreference —— 折叠表达式配完美转发的标准搭档C Core Guidelines: Templates — isocpp.org —— 模板章节的总体指导思想10. 一句话总结可变参数模板让你收下任意个、任意类型的实参C11 靠「基准情形 递归步」两个函数逐层摘参数基准情形就是递归终止条件代价是 O(N) 次模板实例化且处理不了空包。C17 折叠表达式(... op pack)把同一件事压成一行四种形式只差初值位置和括号方向-/这类不可结合运算方向选错结果就错。空包时折叠得true、||得false、逗号折叠得void其余运算符一律编译失败、必须用二元折叠给初值。
返回列表