函数式思想
什么是函数式?
命令式风格的代码通常按照程序的执行顺序编写,描述程序步骤的先后执行顺序——声明变量、修改变量、遇到错误就提前返回。这样的风格在操作系统接口、性能敏感的部分和有众多副作用的业务中极为常见。函数式思想则提供了一种解决方案,其定义了数据的某些变化方式(类似数学函数),然后决定再哪个数据的边界执行具有副作用的步骤。
比如,下面的命令式风格的代码将筛选、转换和收集混合在一起,按照程序运行的逻辑编写:
std::vector<int> result;
for (int value : input) {
if (value > 0) {
result.push_back(value * 2);
}
}而函数式编程则会将这样的操作拆分、抽象为可以复用的操作(类似于数学函数):
auto is_positive = [](int value) { return value > 0; };
auto double_value = [](int value) { return value * 2; };在遵循函数式思想的代码中,函数尽量不依赖隐藏的全局状态(接近纯函数),也就是说无论何时,在确认函数输入的情况下输出是一定的;数据尽量通过参数和返回值流动,而不是到处修改共享变量;程序的每一步都保持一个清晰的输入输出契约(便于测试和组合)。C++ 是多范式语言,不需要为了遵守这些原则而牺牲 RAII、面向对象或必要的副作用。对于现代 C++ 程序编写而言,实用的目标是把副作用集中在边界,把可推理的计算留在中间。这种组合优先的思维也解释了本文后面会出现的 transform 和 and_then:它们规定了如何把一个具有副作用的函数接到已有的确定性计算之后。
下面的部分对涉及函数式编程的语言特性进行介绍。
Lambda
Lambda 表达式一般被用作写在使用位置附近的匿名函数。他允许将一段行为(就像一般的函数那样)保存到变量中,作为参数传递给其他函数,或者从其他函数中返回。
在传统写法中,一段判断正数的代码可能是这样写的:
bool is_positive(int value) {
return value > 0;
}我们需要单独设立这样的代码块,然后在需要的位置使用 is_positive(x) 调用。使用 Lambda,我们可以直接将这段逻辑写在需要的地方:
auto is_positive = [](int value) {
return value > 0;
};调用方式与普通函数基本相同:
bool result = is_positive(10);Lambda 的基本语法如下:
[captures](parameters) specifiers -> return_type {
function_body
}例如:
int threshold = 10;
auto is_greater = [threshold](int value) -> bool {
return value > threshold;
};其中,[threshold] 部分为捕获列表,(int value) 为参数列表,-> bool 为返回值类型,{} 中为函数体。
其中,捕获列表决定了函数体中是否可以使用外部作用域中的变量。[threashold] 这种为按值捕获,其会把 threshold 的值复制一份,保存到 Lambda 对象内部。所以,外部的该值变化时,Lambda 内部的不会。如果使用 [&threshold],Lambda 保存的是外部变量的引用,这里 Lambda 每次执行时,都会访问外部的 threshold。因此外部变量改变后,Lambda 看到的也是新值。但是,引用捕获要求外部变量必须一直存在。如果这个 Lambda 被函数返回,那么返回后局部变量 threshold 已经被销毁,但返回的 Lambda 仍然保存着它的引用。之后调用该 Lambda 就会产生悬空引用,属于未定义行为。
同时,C++ 还提供了默认捕获。总结来说,可以使用 = 让 Lambda 按值捕获用到的外部变量,使用& 让 Lambda 按引用捕获用到的外部变量。在成员函数中,Lambda 还可以使用 [this] 捕获当前对象(的指针),或者在 C++ 20 以后使用 [*this] 复制整个对象。另外,Lambda 还可以在捕获列表中创建新的成员。
在实现上,Lambda 实际上创建了一个匿名类,比如:
auto add = [](int left, int right) {
return left + right;
};
// equal to
class CompilerGeneratedLambda {
public:
int operator()(int left, int right) const {
return left + right;
}
};变量 add 保存的不是普通函数指针,而是这个匿名类的对象。调用 add(a,b) 本质上是调用 add.operator()(a,b)。
高阶函数
高阶函数是指满足以下至少一个条件的函数:
- 接收函数作为参数;
- 返回一个函数。
用另一句话说,就是将函数作为值进行传递,即利用函数指针、lambda、模板参数等来传递这样的可调用对象。比如,下面的函数接受一个操作,并将其用于值上:
template <typename Function>
int apply(int value, Function function) {
return function(value);
}
// callback
int result = apply(10, [](int value) {
return value * 2;
});当然,高阶函数也可以返回一个新的函数,比如:
auto greater_than(int threshold) {
return [threshold](int value) {
return value > threshold;
};
}在不需要固定传入函数接口的类型时,我们可以利用 template <typename Function> 模板接受可调用对象,但是当程序需要一个明确、稳定的回调类型时,就可以使用 std::function:
void process_numbers(
const std::vector<int>& numbers,
const std::function<void(int)>& action
) {
for (int value : numbers) {
action(value);
}
}std::function 通过类型擦除隐藏了具体可调用对象的类型。调用方只需要知道统一的函数签名 void(int) 即可。
在 C++ 26 中,标准引入了 std::function_ref。它是一种轻量、非拥有型的可调用对象视图。与 std::function 不同的是,function 拥有或保存可调用对象,function_ref 只引用已经存在的可调用对象,就像 string 和 string_view 的区别。他们均提供了一种观察的视角。
#include <functional>
void process_values(
std::span<const int> values,
std::function_ref<void(int)> operation
) {
for (int value : values) {
operation(value);
}
}需要注意的是,它不适合用于保存一个可能很快被销毁的临时可调用对象。非拥有类型始终伴随着生命周期约束。
Monadic 链
很多时候,我们在工程代码中可能会遇到连续执行多步时的错误管理问题。假设我们需要完成下面的过程:读取字符串-解析整数-验证端口范围-转换成配置对象-生成链接地址,其中具有多个环节可能失败。传统命令式写法通常需要不断检查结果,但是当流水线越来越长时,大量重复的错误检查会把真正的业务逻辑分散开,极大降低了可读性。
Monadic 风格的核心价值,就是把“结果是否成功”的分支处理交给统一的组合操作。也就是说,一个包装器不仅保存值或错误,还提供了一套规则,用于安全地连接后续计算。我们在 错误处理 中提到的 expected 就是实现这种风格的重要特性。他保存的是 Error 和 int 两种状态之一,从而方便通过链式操作的逻辑,根据当前状态决定是否继续执行下一步。比如:
enum class PortError {
invalid_number,
out_of_range
};
std::expected<int, PortError> parse_port(std::string_view text) {
int port = 0;
const char* begin = text.data();
const char* end = text.data() + text.size();
auto [next, error] = std::from_chars(begin, end, port);
if (error != std::errc{} || next != end) {
return std::unexpected(PortError::invalid_number);
}
return port;
}
std::expected<int, PortError> validate_port(int port) {
if (port < 1 || port > 65535) {
return std::unexpected(PortError::out_of_range);
}
return port;
}
// Call
int port = parse_port(text)
.and_then(validate_port)
.value_or(80);同样的,还有 or_else 可以使用:
int port = parse_port(text)
.and_then(validate_port)
.or_else([](PortError error) {
std::cerr << "端口处理失败:"
<< error_message(error)
<< '\n';
return std::expected<int, PortError>{
std::unexpected(error)
};
})
.value_or(80);通过这种方式,我们就实现了 parse -> validate -> transform -> error_process 的流水线逻辑。
Monadic 风格没有消灭错误判断。它只是把重复的判断规则封装进了类型和组合操作中。在传统写法中,人们需要将判断逻辑和错误处理穿插在执行步骤之间,而通过 Monadic 风格的链式逻辑,我们成功将这些步骤集中在一起。同时,他通过传递错误的形式,让错误也集中在一起。但是,如果对于每一步错误都需要大量不同的处理逻辑的情况,命令式的写法可能是更好的选择。
Ranges 管道
在 Monadic 风格的编程中,我们发现了一个非常常见的应用场景:对一组数据连续执行多个操作,例如,过滤无效数据-转换数据格式-保留前几个结果-求和或收集,等等。传统 C++ 通常使用一个或多个 for 循环完成这些工作。现代 C++ 通过效仿 Unix shell 中的管道操作,引入了类似的 Ranges 特性,允许我们把这些操作组合成一条清晰的数据管道。
auto result = input
| std::views::filter(...)
| std::views::transform(...)
| std::views::take(...);在这个过程中,数据从左向右流动,每个步骤只关心自己的输入和输出。
在 C++ 20 以前,标准库对数据的迭代处理主要依赖迭代器,比如:
std::vector<int> values{1, 2, 3, 4, 5};
std::for_each(
values.begin(),
values.end(),
[](int value) {
std::cout << value << '\n';
}
);但是在这种写法中,算法、容器和迭代器边界被拆开了,数据的迭代边界和数据本身并不存在形式上的关联。而 Ranges 的基本目标,是把“可以遍历的一段数据”作为一个整体来处理:
std::vector<int> values{1, 2, 3, 4, 5};
std::ranges::for_each(
values,
[](int value) {
std::cout << value << '\n';
}
);这里直接把 values 传给算法,不再手工传入 begin() 和 end()。所以,我们可以将 Ranges 理解为一段可以遍历的数据,以及描述如何访问这段数据的接口。他是按照数据范围组织起来的抽象。
在上面的代码中,容器 vector<int> 真正拥有数据,真正管理着内存,其中包含了五个整数,而迭代器类似于指向某个元素的位置的指针(从实现上讲,他就是真正的指针)。而 views 视图通常不拥有数据,只是保存了对数据的操作和计算逻辑。比如当一下代码运行时,其不会创建一个新的容器,而是保存“从 values 中寻找下一个满足条件的元素“这样的计算说明:
auto even_values =
values | std::views::filter([](int value) {
return value % 2 == 0;
});通过 views 特性,我们引出了函数式编程界最重要的特性之一:惰性求值。惰性求值是指表达式创建时并不立即计算全部结果,而是在真正需要元素时才执行相应操作。例如:
std::vector<int> values{1, 2, 3, 4, 5};
auto result =
values
| std::views::filter([](int value) {
std::cout << "filter: " << value << '\n';
return value % 2 == 0;
})
| std::views::transform([](int value) {
std::cout << "transform: " << value << '\n';
return value * 10;
});执行到这里时,通常并没有打印任何内容。因为我们只是创建了一个 View,并没有真正访问其中的元素。同样的,筛选逻辑也并没有被执行。只有当遍历它时,计算才会发生:
for (int value : result) {
std::cout << "result: " << value << '\n';
}通过惰性计算,Ranges 管道可以直接把多个步骤连接起来,减少中间结果的分配和复制。
下面给出几个常见的 views 函数:
views::filter
views::filter 根据谓词保留满足条件的元素。
std::vector<int> values{
-3, -1, 0, 2, 4, 7, 8
};
auto positive_values =
values
| std::views::filter([](int value) {
return value > 0;
});filter 并没有修改原始容器,它只是提供了一种“只观察满足条件元素”的方式。在 filter 后面的管道元素只可以接收到来自 filter 的满足条件的输入。
views::transform
views::transform 将每个元素映射为另一个值。
std::vector<int> values{1, 2, 3, 4};
auto squares =
values
| std::views::transform([](int value) {
return value * value;
});同理,其只是在管道环节中对值进行映射,不改变原始容器的值。
views::take
views::take(n) 限制最多遍历前 n 个元素:
std::vector<int> values{1, 2, 3, 4, 5, 6};
auto first_three =
values | std::views::take(3);同理,还有 views::drop 用于跳过前 n 个元素。
views::zip
C++23 引入了 std::views::zip,用于并行遍历多个范围,例如:
std::vector<int> left{1, 2, 3};
std::vector<int> right{10, 20, 30};
auto sums =
std::views::zip(left, right)
| std::views::transform([](auto pair) {
auto [left_value, right_value] = pair;
return left_value + right_value;
});管道组合
Ranges 的核心表达形式是使用管道运算符 | 连接多个 View:
auto pipeline =
input
| std::views::filter(predicate)
| std::views::transform(operation)
| std::views::take(10);从左到右阅读即可:
input
→ filter
→ transform
→ takeranges::to
View 默认是惰性的,不拥有结果。如果需要获得真正的容器,可以使用 C++23 标准提供的 std::ranges::to。
std::vector<int> values{1, 2, 3, 4, 5, 6};
auto result =
values
| std::views::filter([](int value) {
return value % 2 == 0;
})
| std::views::transform([](int value) {
return value * value;
})
| std::ranges::to<std::vector>();通过这种方式,result 真正变成了一个 vector<int>。这被称作 views 的物化。在 C++ 23 以前,这样的操作只能通过重新调用迭代器实现。
由于 View 通常不拥有底层数据,因此必须确保底层数据在 View 使用期间仍然有效。所以,上文出现的利用其作为返回值之类的可能导致悬垂引用的场景依旧存在。
同时,现代 C++ 标准还提供了一系列处理函数,供读者自行探索:
// C++ 20
std::ranges
std::views::filter
std::views::transform
std::views::take
std::views::drop
std::ranges::sort
std::ranges::find
// C++ 23
std::ranges::to
std::views::zip
std::views::zip_transform
std::views::adjacent
std::views::enumerate
std::views::join_with
// ...