4.1 纯函数与不可变数据:先控制变化
前面的并发模型给出三种控制冲突的方案:锁(共享内存)、消息传递(Actor)和事务(STM)。它们有一个共同点:先承认数据会变化,再约束变化的方式。
一个价格函数可能逐渐依赖会员等级、活动时间、税率、远程优惠和可变开关。这些输入如果藏在函数外,即使传入同一订单,结果也未必相同。先别急着怀疑计算器,看看函数还偷偷读了些什么。
函数式编程一个实用的起点,是让数据依赖和状态变化变得可见。
学完要能回答
- 纯函数和“没有修改参数”有什么区别;
- 引用透明为何能简化测试、缓存和并行计算;
- 不可变对象与持久化数据结构怎样避免全量复制;
- 副作用无法消失时,应当放在哪里。
从隐藏输入开始排查
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。活动倍率更新后,同一订单可能得到不同结果。若多个线程并发更新,还需同步协议;把访问改成线程安全,也不会自动消除这个隐藏依赖。
把依赖放回参数:
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 也必须确定且没有外部可见副作用。若传入对象仍会读取时钟或内部变化,只是把隐藏依赖换了个地方。
纯函数的判断标准
一个函数若要称为纯函数,通常需要同时满足:
- 相同输入产生相同输出;
- 求值过程不改变函数外可观察的状态。
因此,下列操作都属于副作用或外部依赖:
- 修改全局变量、对象字段或传入的可变集合;
- 写文件、数据库、网络和消息队列;
- 读取当前时间、随机数、环境变量;
- 打日志,或依赖普通结果模型之外的异常退出;
- 依赖调用次数或线程调度顺序。
异常怎样归类取决于语义模型。确定性的异常不同于数据库写入,但在普通 Java 控制流里会使表达式替换和重排更复杂;对可预期的领域失败,显式结果通常更便于推理。
“副作用”不是贬义。保存订单必须写数据库,发送通知必须访问外部系统。问题在于副作用若散落在计算内部,调用方就很难知道这段代码会读什么、改什么、失败在哪里。
引用透明带来的推理能力
若表达式可以用它的结果替换,而程序行为不变,就具有引用透明性:
discount(100, VIP) = 80在任何位置都可以把 discount(100, VIP) 换成 80。这使得以下优化和工程实践更容易成立:
- 独立测试,无需搭建数据库或重置全局状态;
- 安全缓存,因为旧输入对应的结果不会突然失效;
- 重排或并行计算互不依赖的表达式;
- 局部推理,只看参数和返回值就能理解行为。
但缓存并非“纯函数自动变快”。缓存本身需要空间、淘汰策略和并发控制;只有输入空间合适、重复调用足够多时才值得使用。
不可变不是 final 的同义词
final List<String> tags = new ArrayList<>();
tags.add("paid"); // 合法:final 只禁止变量重新指向别处final 固定的是引用绑定,不保证引用指向的对象不可变。真正的不可变值需要:
- 构造完成后,逻辑状态不再变化;
- 不暴露内部可变对象;
- 嵌套字段也满足相应约束,或在边界处防御性复制。
record OrderSnapshot(String id, List<LineItem> items) {
OrderSnapshot {
items = List.copyOf(items);
}
}List.copyOf 产生不可修改的列表,但只是浅复制元素引用。LineItem 本身也必须不可变,否则外部修改元素仍会透过快照被观察到;这个复制边界还会拒绝 null 元素。
不可变数据让共享变得简单:多个线程可以读取同一个值,不必协调“谁正在修改”。更新则产生新值:
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);
}
}持久化数据结构不是把整棵树复制一遍
上例每次复制整个列表,数据量大时成本明显。持久化数据结构会让新旧版本共享没有变化的内部节点,只复制从根到修改位置的一小段路径:
旧版本 root ─┬─ A
├─ B
└─ C
新版本 root'─┬─ A (共享)
├─ B' (只复制修改路径)
└─ C (共享)“持久化”在这里指旧版本仍可访问,不是写入磁盘。Clojure 的集合使用结构共享,并为持久化操作给出性能保证。具体复杂度取决于数据结构,不能笼统说所有不可变更新都是 O(log n)。
不可变也有代价:短命对象、缓存局部性、序列化体积和构造成本都需要测量。局部、独占且性能敏感的构建过程可以使用受控可变状态,完成后再发布不可变结果。
Functional Core, Imperative Shell
实际应用不需要把 I/O 假装成纯函数。更清楚的组织方式是把系统分成两层:
命令式外壳:读取数据库、时间和配置
↓ 组装显式输入
函数式核心:校验、计算、生成决策
↓ 返回值或待执行命令
命令式外壳:提交事务、发送消息、记录日志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:
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 规则,本例没有按币种最小单位舍入。
完成检查
选择一个“根据会员、活动和商品计算价格”的方法:
- 列出它实际读取的所有输入;
- 找出会修改的外部状态;
- 把时间、随机数和配置改为显式依赖;
- 让核心函数返回决策,而不是直接写库;
- 写三个不需要 Mock 框架的测试。
如果测试仍必须启动容器或重置全局变量,纯计算边界还不够清楚。