跳到内容

第 9 卷 / 第 4 章 / 第 1 课

4.1 纯函数与不可变数据:先控制变化 ​

前面的并发模型给出三种控制冲突的方案:锁(共享内存)、消息传递(Actor)和事务(STM)。它们有一个共同点:先承认数据会变化,再约束变化的方式。

一个价格函数可能逐渐依赖会员等级、活动时间、税率、远程优惠和可变开关。这些输入如果藏在函数外,即使传入同一订单,结果也未必相同。先别急着怀疑计算器,看看函数还偷偷读了些什么。

函数式编程一个实用的起点,是让数据依赖和状态变化变得可见。

学完要能回答 ​

  • 纯函数和“没有修改参数”有什么区别;
  • 引用透明为何能简化测试、缓存和并行计算;
  • 不可变对象与持久化数据结构怎样避免全量复制;
  • 副作用无法消失时,应当放在哪里。

从隐藏输入开始排查 ​

java
final class PricingService {
    private BigDecimal campaignRate;

    PricingService(BigDecimal rate) { campaignRate = rate; }

    BigDecimal finalPrice(Order order) {
        var subtotal = order.items().stream()
            .map(LineItem::price)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        return subtotal.multiply(campaignRate);
    }
}

finalPrice(order) 看似只接收订单,实际还读取可变字段 campaignRate。活动倍率更新后,同一订单可能得到不同结果。若多个线程并发更新,还需同步协议;把访问改成线程安全,也不会自动消除这个隐藏依赖。

把依赖放回参数:

java
static BigDecimal finalPrice(Order order, PricingPolicy policy) {
    var subtotal = order.items().stream()
        .map(LineItem::price)
        .reduce(BigDecimal.ZERO, BigDecimal::add);
    return policy.apply(subtotal);
}

这样暴露了 policy 输入,但签名本身还不能保证纯度。Order 与 PricingPolicy 必须表示稳定的值,policy.apply 也必须确定且没有外部可见副作用。若传入对象仍会读取时钟或内部变化,只是把隐藏依赖换了个地方。

纯函数的判断标准 ​

一个函数若要称为纯函数,通常需要同时满足:

  1. 相同输入产生相同输出;
  2. 求值过程不改变函数外可观察的状态。

因此,下列操作都属于副作用或外部依赖:

  • 修改全局变量、对象字段或传入的可变集合;
  • 写文件、数据库、网络和消息队列;
  • 读取当前时间、随机数、环境变量;
  • 打日志,或依赖普通结果模型之外的异常退出;
  • 依赖调用次数或线程调度顺序。

异常怎样归类取决于语义模型。确定性的异常不同于数据库写入,但在普通 Java 控制流里会使表达式替换和重排更复杂;对可预期的领域失败,显式结果通常更便于推理。

“副作用”不是贬义。保存订单必须写数据库,发送通知必须访问外部系统。问题在于副作用若散落在计算内部,调用方就很难知道这段代码会读什么、改什么、失败在哪里。

引用透明带来的推理能力 ​

若表达式可以用它的结果替换,而程序行为不变,就具有引用透明性:

text
discount(100, VIP) = 80

在任何位置都可以把 discount(100, VIP) 换成 80。这使得以下优化和工程实践更容易成立:

  • 独立测试,无需搭建数据库或重置全局状态;
  • 安全缓存,因为旧输入对应的结果不会突然失效;
  • 重排或并行计算互不依赖的表达式;
  • 局部推理,只看参数和返回值就能理解行为。

但缓存并非“纯函数自动变快”。缓存本身需要空间、淘汰策略和并发控制;只有输入空间合适、重复调用足够多时才值得使用。

不可变不是 final 的同义词 ​

java
final List<String> tags = new ArrayList<>();
tags.add("paid"); // 合法:final 只禁止变量重新指向别处

final 固定的是引用绑定,不保证引用指向的对象不可变。真正的不可变值需要:

  • 构造完成后,逻辑状态不再变化;
  • 不暴露内部可变对象;
  • 嵌套字段也满足相应约束,或在边界处防御性复制。
java
record OrderSnapshot(String id, List<LineItem> items) {
    OrderSnapshot {
        items = List.copyOf(items);
    }
}

List.copyOf 产生不可修改的列表,但只是浅复制元素引用。LineItem 本身也必须不可变,否则外部修改元素仍会透过快照被观察到;这个复制边界还会拒绝 null 元素。

不可变数据让共享变得简单:多个线程可以读取同一个值,不必协调“谁正在修改”。更新则产生新值:

java
record Cart(List<LineItem> items) {
    Cart {
        items = List.copyOf(items);
    }

    Cart add(LineItem item) {
        var next = new ArrayList<>(items);
        next.add(item);
        return new Cart(next);
    }
}

持久化数据结构不是把整棵树复制一遍 ​

上例每次复制整个列表,数据量大时成本明显。持久化数据结构会让新旧版本共享没有变化的内部节点,只复制从根到修改位置的一小段路径:

text
旧版本 root ─┬─ A
             ├─ B
             └─ C

新版本 root'─┬─ A      (共享)
             ├─ B'     (只复制修改路径)
             └─ C      (共享)

“持久化”在这里指旧版本仍可访问,不是写入磁盘。Clojure 的集合使用结构共享,并为持久化操作给出性能保证。具体复杂度取决于数据结构,不能笼统说所有不可变更新都是 O(log n)。

不可变也有代价:短命对象、缓存局部性、序列化体积和构造成本都需要测量。局部、独占且性能敏感的构建过程可以使用受控可变状态,完成后再发布不可变结果。

Functional Core, Imperative Shell ​

实际应用不需要把 I/O 假装成纯函数。更清楚的组织方式是把系统分成两层:

text
命令式外壳:读取数据库、时间和配置
      ↓ 组装显式输入
函数式核心:校验、计算、生成决策
      ↓ 返回值或待执行命令
命令式外壳:提交事务、发送消息、记录日志
java
record PricingInput(Order order, PricingPolicy policy, Instant now) {}
record PricingDecision(BigDecimal total, List<String> appliedRules) {}

static PricingDecision decide(PricingInput input) {
    return new PricingDecision(
        finalPrice(input.order(), input.policy()), List.of("campaign-rate")
    );
}

PricingDecision price(String orderId) {
    var input = new PricingInput(
        repository.load(orderId),
        policyRepository.current(),
        clock.instant()
    );
    var decision = decide(input);
    auditLog.save(orderId, decision);
    return decision;
}

这是应用结构片段,import、领域记录、repository 和 clock 由外层应用提供。这里的核心只应用一个活动倍率;now 是为时间规则显式预留的输入,当前计算未使用它。税、币种和舍入策略不在本例范围内。核心规则可以用普通值测试,I/O 则由外壳负责。

运行值边界示例 ​

把下面代码保存为 PricingDemo.java,使用 JDK 17 或更高版本执行 java PricingDemo.java:

java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

class PricingDemo {
    record LineItem(BigDecimal price) {
        LineItem { Objects.requireNonNull(price); }
    }
    record Order(List<LineItem> items) {
        Order { items = List.copyOf(items); }
    }
    record PricingPolicy(BigDecimal rate) {
        PricingPolicy { Objects.requireNonNull(rate); }
        BigDecimal apply(BigDecimal subtotal) { return subtotal.multiply(rate); }
    }
    static BigDecimal finalPrice(Order order, PricingPolicy policy) {
        return policy.apply(order.items().stream().map(LineItem::price)
            .reduce(BigDecimal.ZERO, BigDecimal::add));
    }
    public static void main(String[] args) {
        var original = new ArrayList<>(List.of(new LineItem(new BigDecimal("12.00"))));
        var order = new Order(original);
        original.clear();
        var policy = new PricingPolicy(new BigDecimal("0.80"));
        System.out.println(finalPrice(order, policy));
        System.out.println(order.items().size());
        try {
            order.items().clear();
        } catch (UnsupportedOperationException expected) {
            System.out.println(expected.getClass().getSimpleName());
        }
    }
}

输出依次为 9.6000、1、UnsupportedOperationException。清空原列表不会改变订单列表,返回的列表也不能修改。这里的 LineItem 只包含不可变的 BigDecimal,没有藏着可变元素;多出的小数位来自 BigDecimal 的 scale 规则,本例没有按币种最小单位舍入。

完成检查 ​

选择一个“根据会员、活动和商品计算价格”的方法:

  1. 列出它实际读取的所有输入;
  2. 找出会修改的外部状态;
  3. 把时间、随机数和配置改为显式依赖;
  4. 让核心函数返回决策,而不是直接写库;
  5. 写三个不需要 Mock 框架的测试。

如果测试仍必须启动容器或重置全局变量,纯计算边界还不够清楚。

参考资料 ​

Built with VitePress | Software Systems Atlas