跳到内容

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

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

三类契约

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

以库存批次为例:

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

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

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

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

    public int available() {
        return available;
    }
}

对象入口验证一次后,内部方法可以依赖 available >= 0,不必在每一行重复防御。防御式编程不是到处写 null check,而是决定哪个边界负责拒绝非法状态。

assert 不能承担外部输入验证

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

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

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

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

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

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

对象内可通过“检查—计算—一次提交”减少部分更新:

java
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,那会丢失“没有结果”和“系统坏了”的差别。也不要在底层自动重试非幂等操作,让上层不知道副作用是否重复。

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

构造时防御性复制,访问器返回的不可修改列表又阻止表示泄露。若元素本身可变,还需复制元素或重新设计。

数值与资源也有不变式

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

资源必须有清晰所有权:

java
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 如何允许不同实现而不破坏调用者。

Built with VitePress | Software Systems Atlas