第24章:纯函数与副作用
聊到函数式编程,有两个概念你绕不开——纯函数和副作用。说实话,我刚接触C++函数式编程那会儿,觉得这俩词儿挺玄乎的。什么纯不纯的,函数能跑不就行了?后来在项目里踩了几次坑,才明白这玩意儿有多重要。
今天咱们就把这事儿掰扯清楚。
纯函数的定义
纯函数,说白了就是两个铁律:
- 相同的输入,永远返回相同的输出
- 没有可观察的副作用
听起来简单吧?但实际写代码时,很多人不知不觉就破了戒。
// 纯函数
int add(int a, int b) {
return a + b;
}
// 非纯函数 - 依赖全局变量
int global = 10;
int impure_add(int a) {
return a + global; // 依赖外部状态
}
// 非纯函数 - 修改外部状态
int counter = 0;
int increment() {
return ++counter; // 修改了外部变量
}
你看,add 函数不管在哪儿调用,传 2 和 3 进去,永远返回 5。但 impure_add 就不一定了——万一谁改了 global 的值呢?
核心要点:纯函数就像数学公式。sin(π/2) 永远等于 1,不会因为今天是周一就变成 0.5。
为什么纯函数这么香?
我在项目里维护过一个遗留系统,里面全是非纯函数。改一个地方,不知道会影响到哪儿。调试起来简直要命。
纯函数的好处,我总结了几点:
- 可测试性极强——不用 mock 任何外部状态,传参进去,断言返回值就行
- 可缓存——相同的输入必然有相同的输出,放心大胆做 memoization
- 可并行——没有共享状态,线程安全天然保证
- 可推理——看函数签名就知道它干什么,不用猜
我的习惯:写新函数时,我会先问自己——这个函数能不能做成纯函数?能的话,绝不犹豫。
副作用管理
现实世界不是乌托邦。程序总要读写文件、打印日志、操作数据库。这些都有副作用。怎么办?
我的做法是:把副作用推到系统的边界。
// 坏做法:副作用混在业务逻辑里
void process_order(const Order& order) {
// 业务逻辑
double total = order.price * order.quantity;
// 副作用混进来了
save_to_db(order); // 数据库写入
send_email(order.email); // 发邮件
log("Order processed"); // 日志
}
// 好做法:分离纯逻辑和副作用
double calculate_total(const Order& order) {
return order.price * order.quantity; // 纯函数
}
// 副作用在外部处理
void process_order_pure(const Order& order) {
double total = calculate_total(order);
save_to_db(order, total); // 副作用
send_email(order.email); // 副作用
}
你想想看,calculate_total 这个函数多干净。测试它,我只需要传个 Order 进去,检查返回值就行。不用启动数据库,不用 mock 邮件服务。
我曾经踩过的坑:在一个支付系统里,我把日志打印混在了金额计算函数里。结果有一天日志系统挂了,整个支付流程都崩了。从那以后,我发誓要把副作用和纯逻辑彻底分开。
constexpr 函数——编译期的纯函数
C++11 引入了 constexpr,C++14 放宽了限制,C++17 和 C++20 又做了增强。这东西,说白了就是让函数能在编译期执行。
// constexpr 函数 - 编译期求值
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
// 使用
constexpr int result = factorial(5); // 编译期就算好了,结果是 120
int runtime_val = factorial(10); // 也可以在运行时调用
constexpr 函数天然就是纯函数。为什么?因为编译器要求在编译期能确定结果,你搞个全局变量依赖,编译器直接报错。
constexpr 的限制与演进
| C++ 版本 | constexpr 能力 |
|---|---|
| C++11 | 只能包含一条 return 语句,不能有循环、局部变量 |
| C++14 | 可以包含循环、分支、局部变量,基本像个普通函数了 |
| C++17 | 支持 if constexpr,lambda 也可以是 constexpr |
| C++20 | 支持 constexpr 虚函数、try-catch、动态内存分配 |
// C++14 之后的 constexpr 就灵活多了
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
// 编译期计算斐波那契数列第20项
constexpr auto fib20 = fibonacci(20); // 编译期就确定了
我建议:能用 constexpr 的地方尽量用。它不仅是性能优化,更是一种契约——告诉读者和编译器,这个函数是纯的,没有副作用。
知识体系总览
下面这张图,把本章的核心逻辑串起来了:
实际项目中的应用
我记得有一次做量化交易系统,核心策略计算必须快,还得保证正确性。我们是怎么做的?
// 策略计算核心 - 纯函数 + constexpr
struct TradeSignal {
double price;
double volume;
double momentum;
};
constexpr double calculate_signal(const TradeSignal& ts) noexcept {
// 纯计算,没有 I/O,没有状态修改
double raw = ts.price * ts.volume * ts.momentum;
return raw > 0 ? raw : 0.0;
}
// 外部包装 - 处理副作用
void execute_trade(const TradeSignal& ts) {
double signal = calculate_signal(ts); // 纯函数调用
if (signal > threshold) {
send_order_to_exchange(ts); // 副作用:发单
log_trade(ts, signal); // 副作用:日志
}
}
你看,calculate_signal 是纯的,测试它只需要传参数。而 execute_trade 处理副作用,但逻辑也很清晰。
一句话总结:纯函数让你的代码可预测,副作用管理让你的代码可控。两者结合,写出来的程序才经得起折腾。
嗯,关于纯函数和副作用,今天就聊到这儿。记住一点:写代码时多问问自己——这个函数能不能做成纯的?能的话,别犹豫。