第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 的地方尽量用。它不仅是性能优化,更是一种契约——告诉读者和编译器,这个函数是纯的,没有副作用。

知识体系总览

下面这张图,把本章的核心逻辑串起来了:

纯函数与副作用 · 知识体系 纯函数 特征1:相同输入 → 相同输出 特征2:无副作用 优势 可测试 · 可缓存 优势 可并行 · 可推理 副作用管理策略 推到系统边界 · 分离纯逻辑与副作用 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 处理副作用,但逻辑也很清晰。

一句话总结:纯函数让你的代码可预测,副作用管理让你的代码可控。两者结合,写出来的程序才经得起折腾。

嗯,关于纯函数和副作用,今天就聊到这儿。记住一点:写代码时多问问自己——这个函数能不能做成纯的?能的话,别犹豫。


公众号:蓝海资料掘金营,微信deep3321