折叠表达式通过四种形式(一元/二元左/右折叠)简化可变参数模板,支持求和、打印、逻辑判断等聚合操作,避免递归和晦涩技巧,提升代码清晰度与编译期处理能力。
c++17的折叠表达式(Fold Expressions)是我个人认为语言在简化可变参数模板方面迈出的一大步,它把原本需要递归、辅助函数或者一些巧妙但晦涩的技巧才能完成的任务,浓缩成了一行简洁的代码。简单来说,它提供了一种在参数包上应用二元操作的优雅方式。
解决方案
折叠表达式的核心思想,就是将一个二元操作符(比如
+
,
-
,
*
,
/
,
&&
,
||
,
,
等)“折叠”到参数包中的所有元素上。这玩意儿极大地简化了可变参数模板的编写,让代码变得异常清晰。
想象一下,你有一堆数字,想把它们加起来。在C++17之前,你可能得写个递归函数:
template<typename T> auto sum(T t) { return t; } template<typename T, typename... Rest> auto sum(T t, Rest... rest) { return t + sum(rest...); } // 调用 sum(1, 2, 3, 4)
而有了折叠表达式,这事儿就变得简单粗暴:
立即学习“C++免费学习笔记(深入)”;
template<typename... Args> auto sum(Args... args) { return (args + ...); // Unary left fold: (((arg1 + arg2) + arg3) + ...) } // 调用 sum(1, 2, 3, 4)
是不是瞬间感觉清爽多了?
除了求和,它还能做很多事,比如打印:
#include <iostream> template<typename... Args> void print(Args... args) { // 使用逗号运算符实现序列化打印 // 注意:这里的逗号运算符是表达式逗号,它会按顺序执行左侧表达式并丢弃其结果,然后评估右侧表达式。 // 整个折叠表达式的值是最后一个表达式的值,但我们主要利用其副作用(打印)。 (std::cout << args << " ", ...); std::cout << std::endl; } // 调用 print(1, "hello", 3.14) 会输出 "1 hello 3.14 "
或者进行逻辑判断:
template<typename... Bools> bool all_true(Bools... b) { return (true && ... && b); // Binary left fold with initial value 'true' } // all_true(true, true, false) 返回 false
折叠表达式的强大之处在于,它将参数包的展开和操作结合在一起,省去了我们手动处理递归基线和中间状态的麻烦。它本质上就是编译器在编译期帮你把那个“递归”或者“循环”给展开了。
C++17折叠表达式解决了哪些痛点?
说实话,在我看来,折叠表达式的引入,主要解决了可变参数模板在处理“聚合操作”时冗余和晦涩的问题。过去,我们面对一个参数包,如果想对所有元素执行某个操作并聚合结果(比如求和、求积、逻辑与/或、字符串拼接),通常有几种选择:
- 递归函数/模板特化: 这是最常见的模式。你得写一个处理单个参数的基准模板,再写一个处理多个参数并递归调用自身的模板。这不仅代码量翻倍,而且对于初学者来说,理解其递归展开过程也需要一点时间。比如上面的
sum
例子,就是典型的递归模式。
- 逗号运算符技巧: 对于像打印这种不需要聚合最终结果,只需要按顺序执行副作用的操作,我们有时会利用逗号运算符的特性,结合初始化列表或者其他方式来“展开”参数包。这种方法虽然能在一行内完成,但语法上往往比较“黑魔法”,可读性不高,尤其是对不熟悉这种技巧的人来说。例如
int arr[] = {(print(args), 0)...};
这种写法,虽然能达到目的,但看起来总有点别扭。
- 辅助结构或宏: 有些更复杂的场景,可能需要引入额外的结构体、类或者宏来辅助处理参数包,以避免直接的递归。这无疑增加了设计的复杂性。
折叠表达式的出现,直接把这些“痛点”变成了“甜点”。它用一种声明式、直观的方式表达了“对参数包中的每个元素应用这个操作符”的意图。你不需要再考虑递归的基线是什么,也不用担心逗号运算符的副作用和优先级问题,编译器都帮你处理好了。代码变得更短、更清晰,也更不容易出错。它就像是给可变参数模板加了一个“一键聚合”的功能,极大地提升了开发效率和代码的可维护性。
折叠表达式的四种基本形式及应用场景是什么?
折叠表达式实际上有四种基本形式,它们在语法和行为上略有不同,但都围绕着一个核心:如何将一个二元操作符应用于参数包。理解这四种形式,能帮助你更灵活地运用它。
-
一元左折叠 (Unary Left Fold):
(... op pack)
- 语法:
(... op pack)
- 展开方式:
((pack_1 op pack_2) op pack_3) ... op pack_N
- 特点: 从左到右结合,没有初始值。如果参数包为空,则会导致编译错误。
- 应用场景: 适用于需要从左到右累积计算的场景,例如求和
(args + ...)
、求积
(args * ...)
、逻辑与
(args && ...)
。
- 示例:
template<typename... Nums> auto product(Nums... nums) { return (nums * ...); // (n1 * n2) * n3 ... } // product(2, 3, 4) -> (2*3)*4 = 24
- 语法:
-
一元右折叠 (Unary Right Fold):
(pack op ...)
- 语法:
(pack op ...)
- 展开方式:
pack_1 op (pack_2 op (... op pack_N))
- 特点: 从右到左结合,没有初始值。如果参数包为空,则会导致编译错误。
- 应用场景: 相对较少,但对于某些右结合的操作符或特定算法可能有用,例如函数链式调用或者某些自定义类型操作。
- 示例:
template<typename... Funcs> void call_in_order_right_to_left(Funcs... funcs) { // 假设funcs是可调用对象,这里用逗号运算符实现右结合调用 (funcs(), ...); // func1(), (func2(), (... funcN())) // 实际效果是func1先执行,然后是func2,以此类推。 // 但如果操作符是其他右结合的,比如自定义的>>操作符,就会体现出右结合的特性。 }
请注意,对于逗号运算符,无论是左折叠还是右折叠,其执行顺序都是从左到右。这里主要展示的是语法形式。
- 语法:
-
二元左折叠 (Binary Left Fold):
(init op ... op pack)
- 语法:
(init op ... op pack)
- 展开方式:
(((init op pack_1) op pack_2) op pack_3) ... op pack_N
- 特点: 从左到右结合,有一个初始值
init
。即使参数包为空,表达式也能计算出结果(即
init
的值)。
- 应用场景: 这是最常用的一种形式,尤其适合需要一个累积起始值的操作。
- 示例:
template<typename... Args> auto sum_with_initial(int initial_value, Args... args) { return (initial_value + ... + args); // ((((initial_value + arg1) + arg2) + ...) } // sum_with_initial(10, 1, 2, 3) -> 10 + 1 + 2 + 3 = 16 // sum_with_initial(10) -> 10 (当参数包为空时)
- 语法:
-
二元右折叠 (Binary Right Fold):
(pack op ... op init)
-
语法:
(pack op ... op init)
-
展开方式:
pack_1 op (pack_2 op (... op (pack_N op init)))
-
特点: 从右到左结合,有一个初始值
init
。即使参数包为空,表达式也能计算出结果(即
init
的值)。
-
应用场景: 对于需要从右向左处理的场景,比如链式比较或某些函数组合。
-
示例:
template<typename T, typename... Args> bool is_less_than_all(T val, Args... args) { return (val < ... < args); // val < (arg1 < (arg2 < ...)) // 这是一个链式比较的例子,但实际行为依赖于操作符的定义。 // 对于内置的`<`,它不是链式比较,而是先计算右侧,再用val与结果比较。 // 真正有用的场景可能是自定义的右结合操作符。 } // 举个更实际的例子,用逗号运算符实现从右到左的初始化 template<typename T, typename... Values> void assign_from_right(T& target, Values... vals) { (target = vals, ...); // target = val1, (target = val2, ...) // 实际赋值顺序是 val1, val2, ...。但如果用 (vals = target, ...),则会是 valN = target, ... val1 = target // 这里更恰当的例子是函数组合: auto compose = [](auto f, auto g){ return [=](auto x){ return f(g(x)); }; }; auto f_composed = (compose(funcs, ...)); // 从右到左组合函数 }
二元右折叠在处理函数组合、管道操作(如果操作符设计得当)时能展现出其优势。
-
理解这四种形式的关键在于“初始值”的存在与否,以及“结合方向”。它们为处理可变参数模板提供了极大的灵活性和表达力。
除了简化求和,折叠表达式还能实现哪些高级技巧?
折叠表达式的威力远不止于简单的求和或打印。它能深入到更复杂的类型操作、函数调用、甚至编译期检查中,极大地提升了可变参数模板的实用性和简洁性。
-
构建异构容器或元组: 一个常见的需求是,将参数包中的元素直接“塞进”一个
std::tuple
或其他异构容器。有了折叠表达式,这变得非常直接:
#include <tuple> #include <string> template<typename... Args> auto make_tuple_from_pack(Args&&... args) { // 使用逗号运算符和std::forward,将所有参数完美转发到tuple的构造函数中 // 实际上,std::make_tuple已经做了类似的事情,这里只是展示折叠表达式的能力 return std::tuple<Args...>(std::forward<Args>(args)...); // 这是tuple本身的构造,不是折叠表达式直接构建 // 更好的例子是,如果你想在构建tuple时对每个元素做一些预处理: // return std::make_tuple((process(args))...); // 假设process是个函数 } // 尽管如此,直接构造std::tuple<Args...>(args...) 已经很简洁。 // 折叠表达式在构建容器时,更多体现在对每个元素进行操作后收集结果,例如: template<typename... Args> std::vector<int> get_lengths(const Args&... args) { std::vector<int> lengths; // 这里的逗号运算符折叠,每次push_back一个元素的长度 (lengths.push_back(args.length()), ...); return lengths; } // std::string s1 = "hello", s2 = "world"; // auto len_vec = get_lengths(s1, s2); // len_vec = {5, 5}
这个例子展示了如何利用逗号运算符的副作用,将参数包中的元素逐一处理并添加到容器中,而不需要显式的循环或递归。
-
通用函数调用与转发: 当需要对参数包中的每个元素执行一个函数,或者将参数包转发给另一个函数时,折叠表达式能提供非常简洁的方案。
#include <functional> // For std::invoke template<typename Func, typename... Args> void apply_to_each(Func f, Args&&... args) { // 使用逗号运算符,对每个参数调用函数f // std::invoke 确保了成员函数指针、普通函数指针、lambda等都能正确调用 (std::invoke(f, std::forward<Args>(args)), ...); } // 示例: void print_val(int x) { std::cout << "Val: " << x << std::endl; } struct MyClass { void print_member(int x) { std::cout << "Member Val: " << x << std::endl; } }; // apply_to_each(print_val, 1, 2, 3); // MyClass obj; // apply_to_each(&MyClass::print_member, &obj, 10, 20); // 错误:成员函数需要对象实例 // 应该这样写: template<typename Func, typename T, typename... Args> void apply_member_to_each(Func f, T& obj, Args&&... args) { (std::invoke(f, obj, std::forward<Args>(args)), ...); } // MyClass obj; // apply_member_to_each(&MyClass::print_member, obj, 10, 20);
这比手动循环或者递归调用要清晰得多。
-
编译期类型检查与属性聚合: 结合
std::is_same_v
、
std::is_convertible_v
等类型特征,折叠表达式可以在编译期对参数包的类型进行检查或聚合其属性。
#include <type_traits> // For std::is_integral_v template<typename... T> constexpr bool all_are_integral() { // 检查参数包中所有类型是否都是整型 return (std::is_integral_v<T> && ...); } // static_assert(all_are_integral<int, long, short>()); // 编译通过 // static_assert(all_are_integral<int, double>()); // 编译失败,因为double不是整型
这种方式在编写通用模板库时非常有用,可以用于静态断言,确保模板参数符合预期。
-
实现自定义的“管道”操作符: 虽然C++没有内置的管道操作符(
|>
),但我们可以利用折叠表达式和函数对象来模拟这种行为,实现函数链式调用。
// 假设我们有这样的函数: auto add_one = [](int x){ return x + 1; }; auto multiply_two = [](int x){ return x * 2; }; auto subtract_three = [](int x){ return x - 3; }; // 我们可以设计一个“管道”辅助函数 template<typename T, typename... Funcs> auto pipe(T initial_value, Funcs... funcs) { // 这是一个二元左折叠,初始值是initial_value,操作符是函数调用 // 这里的f(val)是自定义的“操作符”,实际是lambda return (initial_value | ... | funcs); // 语法错误,不能直接用 | 模拟函数调用 // 正确的实现需要一个辅助的lambda或者操作符重载 } // 更实际的实现可能是这样: template<typename T, typename Func> T apply_func(T val, Func f) { return f(val); } template<typename T, typename... Funcs> auto pipe_chain(T initial_value, Funcs... funcs) { // 使用二元左折叠,每次将当前值和下一个函数传递给apply_func return (initial_value | ... | [](auto val, auto f){ return f(val); }); // 语法错误,lambda不能直接作为操作符 // 实际应用中,通常会利用操作符重载或更复杂的技巧。 // 最直接的模拟是利用逗号运算符的副作用,但那不是“管道” } // 一个简单的链式调用模拟: template<typename T, typename... Funcs> T chain_calls(T val, Funcs... funcs) { // 依次将val传递给每个函数,并更新val // 这是二元左折叠的经典应用,其中操作符是 lambda 表达式 return (((val = funcs(val)), ...), val); // 解释: (val = f1(val)), (val = f2(val)), ... // 整个表达式的结果是最后一个逗号表达式的值,也就是最终的val } // int result = chain_calls(5, add_one, multiply_two, subtract_three); // 5 -> 6 -> 12 -> 9 // std::cout << result << std::endl; // 输出 9
这个
chain_calls
例子就非常巧妙地利用了折叠表达式和逗号运算符的特性,实现了函数链式调用。
总的来说,折叠表达式极大地提升了C++在处理可变参数模板时的表达能力和代码简洁性。它不仅仅是语法糖,更是对编译器优化能力的释放,让开发者能以更声明式的方式编写高度泛化的代码。