跳到内容

第 6 卷 / 第 1 章 / 第 2 课

1.2 契约、不变式与失败原子性 ​

一个方法真正的接口不止是参数和返回类型,还包括允许的输入、成功后的保证、失败时的状态,以及对象始终必须满足的不变量。

三类契约 ​

  • 前置条件:调用者在调用前必须提供什么;
  • 后置条件:方法成功返回后保证什么;
  • 表示不变式:任何对外可观察的稳定时刻,对象内部必须满足什么。

以库存批次为例:

text
抽象值:某 SKU 当前可用数量
前置条件:reserve(quantity) 的 quantity > 0
后置条件:成功则 available 减 quantity,并返回 reservation
失败语义:库存不足时状态不变
表示不变式:available >= 0

在边界验证,在内部依赖不变量 ​

以下类使用 Java 21,与后面的 Reservation.java 一起放入上一节的 src/main/java。Stock.java 包含 public Stock 和包内可见的 InsufficientStock;其余方法体短片段用于比较实现,不应逐块追加到这个类中。

java
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);
        }

        var reservation = new Reservation(sku, quantity);
        available -= quantity;
        return reservation;
    }

    public int available() {
        return available;
    }
}

final class InsufficientStock extends RuntimeException {
    InsufficientStock(String sku, int requested, int available) {
        super(sku + ": requested=" + requested + ", available=" + available);
    }
}

构造器建立不变式,每次 reserve 再检查本次数量是否有效;这不是只在对象创建时检查一次。先构造返回值再扣减,可避免构造失败后库存已改变。内部方法在这些条件下可以依赖 available >= 0,不必在每一行重复防御。防御式编程不是到处写 null check,而是决定哪个边界负责拒绝非法状态。

assert 不能承担外部输入验证 ​

Java assertion 默认可能关闭,因此不能用于请求参数、权限或业务约束。它适合检查“如果程序其他部分正确,这里必然成立”的内部假设:

java
assert available >= 0 : "representation invariant broken";

外部输入使用显式异常或类型化结果;公开 API 的失败行为应写入契约并测试。

失败原子性:先验证,再改变 ​

危险写法会先改一半状态,再发现错误:

java
available -= quantity;
audit.append(reservation); // 抛异常后 available 已经变了

下面是 reserve 方法体的替代片段,仍保留 quantity > 0 的前置检查,用“检查、计算、一次提交”减少部分更新:

java
if (quantity <= 0) {
    throw new IllegalArgumentException("quantity must be positive");
}
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;

失败原子性也要限定故障模型:这里讨论正常 Java 控制流中的参数与业务异常,未声称 JVM 崩溃或外部副作用可回滚。若 audit.append 必须与扣减共同持久化,应把它们放进同一事务或可恢复协议。

跨数据库、消息和远程服务时,内存赋值顺序不够。应使用数据库事务、outbox、幂等键或补偿状态机,并明确哪些外部副作用不可回滚。

异常分类要帮助调用者决定下一步 ​

至少区分:

  • 调用契约错误:非法参数、缺失必填值;通常不重试;
  • 预期业务拒绝:库存不足、状态冲突;返回领域错误;
  • 暂时基础设施故障:超时、可用连接耗尽;可能有界重试;
  • 程序缺陷:不变式破坏、不可达分支;应暴露、告警并修复。

不要捕获 Exception 后返回 null,那会丢失“没有结果”和“系统坏了”的差别。也不要在底层自动重试非幂等操作,让上层不知道副作用是否重复。

java
sealed interface ReserveResult {
    record Accepted(Reservation reservation) implements ReserveResult {}
    record Rejected(int requested, int available) implements ReserveResult {}
}

预期业务分支可用类型表达;异常保留给契约违反和无法正常完成的故障。是否采用这种风格取决于接口层次,不必把所有异常机械改成结果对象。

不可变对象缩小状态空间 ​

java
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,但若组件指向可变集合,对象并不会自动深度不可变:

java
public record OrderLines(java.util.List<Reservation> values) {
    public OrderLines {
        values = java.util.List.copyOf(values);
    }
}

List.copyOf 保存不受调用者后续列表增删影响的不可修改快照,也拒绝 null 列表及 null 元素;它可能复用已经合适的不可修改列表,不保证分配新对象。访问器返回的列表限制了结构修改。若元素本身可变,还需复制元素或重新设计。

数值与资源也有不变式 ​

整数溢出不会总是抛错:Java 基本整数运算会按固定位宽回绕。计数和金额相关边界可使用 Math.addExact/subtractExact、范围检查或合适的大数类型。

下面是返回 long 的方法体片段,path 为已定义的 Path;方法需声明或处理 IOException。资源必须有清晰所有权:

java
try (var stream = java.nio.file.Files.lines(path)) {
    return stream.filter(line -> !line.isBlank()).count();
}

“谁创建谁关闭”是常见默认,但若方法把资源所有权转交给调用者,必须在 API 中明确。

并发下对象不变式需要原子边界 ​

上面的 Stock 在单线程中正确,但两个线程并发调用 reserve 时,检查与扣减可能交错。可选择:

  • 把对象限制为线程封闭;
  • 用同步/锁保护整个检查:修改;
  • 使用原子 compare-and-set 循环;
  • 把权威不变量交给数据库条件更新。

volatile 只能提供可见性与部分顺序保证,不能让复合的检查:修改自动原子。

契约测试清单 ​

在上一节的 JUnit 项目中,可以从以下状态序列开始检查 Stock;每次调用都观察返回/异常和 available,不要只检查异常类型:

操作结果available
new Stock("SKU-42", 2)创建成功2
reserve(3)InsufficientStock2
reserve(0)IllegalArgumentException2
reserve(2)Reservation("SKU-42", 2)0
reserve(1)InsufficientStock0

为每个公开操作按风险选择覆盖:

  • 正常最小值、典型值和上界附近;
  • 每个前置条件被违反;
  • 业务拒绝后对象状态不变;
  • 算术溢出与空集合;
  • 可变参数传入后再被调用者修改;
  • 并发契约若声称线程安全,则做竞争测试并结合代码层同步证明。

测试能发现例子中的破坏,设计说明和代码结构负责解释为何所有路径都保持不变式。

本节检查点 ​

  • 能写出前置条件、后置条件、表示不变式和失败状态。
  • 能区分外部验证、内部 assertion 与预期业务拒绝。
  • 能让失败前完成验证与计算,避免对象停在半更新状态。
  • 能识别浅不可变、表示泄露和并发复合操作。

下一章把“对象内部正确”提升为“抽象不依赖某个表示”:同一个 ADT 如何允许不同实现而不破坏调用者。

Built with VitePress | Software Systems Atlas