1.2 契约、不变式与失败原子性
一个方法真正的接口不止是参数和返回类型,还包括允许的输入、成功后的保证、失败时的状态,以及对象始终必须满足的不变量。
三类契约
- 前置条件:调用者在调用前必须提供什么;
- 后置条件:方法成功返回后保证什么;
- 表示不变式:任何对外可观察的稳定时刻,对象内部必须满足什么。
以库存批次为例:
抽象值:某 SKU 当前可用数量
前置条件:reserve(quantity) 的 quantity > 0
后置条件:成功则 available 减 quantity,并返回 reservation
失败语义:库存不足时状态不变
表示不变式:available >= 0在边界验证,在内部依赖不变量
public final class Stock {
private final String sku;
private int available;
public Stock(String sku, int available) {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku is blank");
}
if (available < 0) {
throw new IllegalArgumentException("available is negative");
}
this.sku = sku;
this.available = available;
}
public Reservation reserve(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
if (quantity > available) {
throw new InsufficientStock(sku, quantity, available);
}
available -= quantity;
return new Reservation(sku, quantity);
}
public int available() {
return available;
}
}对象入口验证一次后,内部方法可以依赖 available >= 0,不必在每一行重复防御。防御式编程不是到处写 null check,而是决定哪个边界负责拒绝非法状态。
assert 不能承担外部输入验证
Java assertion 默认可能关闭,因此不能用于请求参数、权限或业务约束。它适合检查“如果程序其他部分正确,这里必然成立”的内部假设:
assert available >= 0 : "representation invariant broken";外部输入使用显式异常或类型化结果;公开 API 的失败行为应写入契约并测试。
失败原子性:先验证,再改变
危险写法会先改一半状态,再发现错误:
available -= quantity;
audit.append(reservation); // 抛异常后 available 已经变了对象内可通过“检查—计算—一次提交”减少部分更新:
int next = Math.subtractExact(available, quantity);
if (next < 0) {
throw new InsufficientStock(sku, quantity, available);
}
var reservation = new Reservation(sku, quantity); // 构造也先完成
available = next; // 最后提交内存状态
return reservation;跨数据库、消息和远程服务时,内存赋值顺序不够。应使用数据库事务、outbox、幂等键或补偿状态机,并明确哪些外部副作用不可回滚。
异常分类要帮助调用者决定下一步
至少区分:
- 调用契约错误:非法参数、缺失必填值;通常不重试;
- 预期业务拒绝:库存不足、状态冲突;返回领域错误;
- 暂时基础设施故障:超时、可用连接耗尽;可能有界重试;
- 程序缺陷:不变式破坏、不可达分支;应暴露、告警并修复。
不要捕获 Exception 后返回 null,那会丢失“没有结果”和“系统坏了”的差别。也不要在底层自动重试非幂等操作,让上层不知道副作用是否重复。
sealed interface ReserveResult {
record Accepted(Reservation reservation) implements ReserveResult {}
record Rejected(int requested, int available) implements ReserveResult {}
}预期业务分支可用类型表达;异常保留给契约违反和无法正常完成的故障。是否采用这种风格取决于接口层次,不必把所有异常机械改成结果对象。
不可变对象缩小状态空间
public record Reservation(String sku, int quantity) {
public Reservation {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku is blank");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
}
}Record 的组件引用是 final,但若组件指向可变集合,对象并不会自动深度不可变:
public record OrderLines(java.util.List<Reservation> values) {
public OrderLines {
values = java.util.List.copyOf(values);
}
}构造时防御性复制,访问器返回的不可修改列表又阻止表示泄露。若元素本身可变,还需复制元素或重新设计。
数值与资源也有不变式
整数溢出不会总是抛错:Java 基本整数运算会按固定位宽回绕。计数和金额相关边界可使用 Math.addExact/subtractExact、范围检查或合适的大数类型。
资源必须有清晰所有权:
try (var stream = java.nio.file.Files.lines(path)) {
return stream.filter(line -> !line.isBlank()).count();
}“谁创建谁关闭”是常见默认,但若方法把资源所有权转交给调用者,必须在 API 中明确。
并发下对象不变式需要原子边界
上面的 Stock 在单线程中正确,但两个线程并发调用 reserve 时,检查与扣减可能交错。可选择:
- 把对象限制为线程封闭;
- 用同步/锁保护整个检查—修改;
- 使用原子 compare-and-set 循环;
- 把权威不变量交给数据库条件更新。
volatile 只能提供可见性与部分顺序保证,不能让复合的检查—修改自动原子。
契约测试清单
为每个公开操作覆盖:
- 正常最小值、典型值和上界附近;
- 每个前置条件被违反;
- 业务拒绝后对象状态不变;
- 算术溢出与空集合;
- 可变参数传入后再被调用者修改;
- 并发契约若声称线程安全,则做竞争测试并结合代码层同步证明。
测试能发现例子中的破坏,设计说明和代码结构负责解释为何所有路径都保持不变式。
本节检查点
- 能写出前置条件、后置条件、表示不变式和失败状态。
- 能区分外部验证、内部 assertion 与预期业务拒绝。
- 能让失败前完成验证与计算,避免对象停在半更新状态。
- 能识别浅不可变、表示泄露和并发复合操作。
下一章把“对象内部正确”提升为“抽象不依赖某个表示”:同一个 ADT 如何允许不同实现而不破坏调用者。