跳到内容

7.2 可替换契约、接口隔离与组合

新实现替换旧接口后,编译没有报错,调用方的行为却变了;你们必须把抽象背后的契约写清楚。

有了接口不代表实现可以安全替换。调用者依赖的除了方法名,还有前置条件、结果保证、失败语义和状态演化。LSP 与 ISP 用来检查抽象是否诚实;组合与最少知识原则帮助控制对象之间暴露的结构。

LSP:子类型必须保留调用者能依赖的性质

Liskov Substitution Principle 的核心是行为子类型:如果调用者只知道父类型/接口,对象换成任何子类型后,原先能够成立的正确性推理仍应成立。

假设支付端口承诺:相同幂等键的重复扣款不会产生第二笔资金变化。

java
public interface PaymentGateway {
    /**
     * 对同一 idempotencyKey 重复调用时,返回同一业务结果,
     * 且最多产生一次资金扣减。
     */
    PaymentResult charge(
            String idempotencyKey,
            Money amount,
            PaymentMethod method);
}

一个实现即使类型检查通过,只要忽略幂等键,就不能替换其他实现:调用方的安全重试假设会被破坏。

常用检查方式是:

  • 子类型不能要求更严格的前置条件;
  • 子类型不能给出更弱的后置保证;
  • 必须维护父类型的不变量;
  • 失败类型、幂等性、顺序和状态历史等可观察性质不能意外改变。

“所有实现都抛 UnsupportedOperationException 处理自己不支持的参数”通常说明接口承诺过宽,或这些实现并不属于同一个可替换类型族。

用共享契约测试验证实现族

java
interface PaymentGatewayContract {
    PaymentGateway gateway();

    @org.junit.jupiter.api.Test
    default void repeatedKeyDoesNotChargeTwice() {
        PaymentGateway gateway = gateway();
        Money amount = Money.usd("20.00");

        PaymentResult first = gateway.charge("order-7", amount, testMethod());
        PaymentResult second = gateway.charge("order-7", amount, testMethod());

        org.junit.jupiter.api.Assertions.assertEquals(first, second);
        assertSingleDebit("order-7", amount);
    }

    PaymentMethod testMethod();
    void assertSingleDebit(String key, Money amount);
}

内存 Fake、沙箱适配器和真实提供者测试都可以复用同一契约。测试不能形式化证明全部 LSP,但能把重要语义从注释变成持续验证。

ISP:接口按调用者需要切分

Interface Segregation Principle 不是“接口方法越少越好”,而是调用者不应被迫依赖自己不需要、也无法合理实现的能力。

java
public interface TournamentQueries {
    TournamentView find(TournamentId id);
    Page<TournamentView> search(TournamentFilter filter, PageRequest page);
}

public interface TournamentCommands {
    TournamentId create(CreateTournament command);
    void close(TournamentId id);
}

public interface TournamentAudit {
    List<AuditEntry> history(TournamentId id);
}

公开查询客户端不需要依赖管理员命令;普通仓库适配器也不必为了一个巨大接口实现审计导出。接口边界应围绕客户端角色与一致契约,不是机械地把每个方法放进独立文件。

过度切分会把一次用例需要的概念散落成大量浅接口,让调用方承担组合责任。若一组操作始终由同一客户端共同使用、维护同一不变量,保留在一个“深”接口里通常更清晰。

组合优先,是为了独立替换行为

继承同时承担代码复用和类型关系时,容易把两个目的绑在一起。组合允许对象在运行或装配时选择协作者:

java
public final class RewardService {
    private final RewardPolicy rewardPolicy;
    private final EligibilityPolicy eligibilityPolicy;
    private final RewardLedger ledger;

    // constructor omitted
}

RewardService 奖励策略和资格策略,这些行为可以独立测试与替换。它不需要继承 HolidayRewardServiceRankedRewardServiceHolidayRankedRewardService 形成组合爆炸。

组合也有成本:更多对象、装配与间接调用。如果继承关系确实表达稳定的可替换类型、父类契约清晰、扩展点受控,继承仍然合适。关键问题不是语法上的 “is-a”,而是子类是否完整遵守行为契约。

实现继承与接口继承要分开看

实现接口声明“我满足这份契约”;继承具体基类还会复用其状态和模板流程。后者耦合更强:父类的 protected API、调用顺序和可覆盖方法都可能成为子类依赖。

设计可扩展基类时,应明确:

  • 哪些方法允许覆盖,哪些不允许;
  • 构造期间是否调用可覆盖方法;
  • 模板方法的顺序与错误语义;
  • 子类必须维护哪些不变量;
  • 二进制和源码兼容承诺。

如果无法写清这些内容,优先使用 final 类加显式协作者。

最少知识原则不是禁止链式调用

危险导航通常把外部对象图结构泄露给调用者:

java
player.getAccount().getWallet().getCurrency().getCode();

调用者同时知道 Player、Account、Wallet、Currency 的结构,任何中间关系变化都会传播。可以把真正的业务问题交给拥有相关知识的对象:

java
player.canReceive(reward);

builder.withName("arena").withRegion("ap").build() 并不必然违反最少知识原则;链条上的方法如果持续操作同一个 fluent abstraction,没有逐层穿透陌生对象图,耦合性质不同。

也不要为消除点号创建大量转发方法。目标是把业务决策放到拥有数据和不变量的边界,而不是追求最短调用链。

封装的是决策,不只是字段

java
public final class Registration {
    private Status status;

    public void confirm(PaymentReceipt receipt) {
        if (status != Status.PENDING) {
            throw new IllegalStateException("registration is not pending");
        }
        if (!receipt.registrationId().equals(id())) {
            throw new IllegalArgumentException("receipt belongs to another registration");
        }
        status = Status.CONFIRMED;
    }
}

把字段设为 private 再生成任意 setter,只隐藏了内存布局。让对象控制合法状态转换,才把领域决策封装起来。

原则冲突时回到变化与风险

SOLID 可能产生张力:

  • 为 OCP 增加策略接口,会提高当前理解成本;
  • 为 ISP 拆分接口,可能增加装配和跨接口一致性问题;
  • 为 DIP 增加端口,对一个稳定小脚本可能毫无收益;
  • 为 SRP 分模块,跨模块事务和协作反而更复杂。

决策依据应是实际变化频率、失败成本、团队边界和测试反馈。先写最简单且清楚的实现,在变化证据出现时重构出边界,比把五条原则当静态检查规则更可靠。

设计评审问题

  1. 哪些角色会推动这段代码变化?
  2. 哪个变化轴值得稳定扩展点?
  3. 业务策略是否导入了具体技术细节?
  4. 每个实现是否遵守同一失败、幂等与状态契约?
  5. 调用者是否依赖不需要的操作?
  6. 继承是在表达可替换类型,还是只为复用代码?
  7. 调用链是否泄露了内部对象图?
  8. 新抽象解决了已知风险,还是只增加文件数量?

参考资料

Built with VitePress | Software Systems Atlas