7.2 可替换契约、接口隔离与组合
新实现替换旧接口后,编译没有报错,调用方的行为却变了;你们必须把抽象背后的契约写清楚。
有了接口不代表实现可以安全替换。调用者依赖的除了方法名,还有前置条件、结果保证、失败语义和状态演化。LSP 与 ISP 用来检查抽象是否诚实;组合与最少知识原则帮助控制对象之间暴露的结构。
LSP:子类型必须保留调用者能依赖的性质
Liskov Substitution Principle 的核心是行为子类型:如果调用者只知道父类型/接口,对象换成任何子类型后,原先能够成立的正确性推理仍应成立。
假设支付端口承诺:相同幂等键的重复扣款不会产生第二笔资金变化。
public interface PaymentGateway {
/**
* 对同一 idempotencyKey 重复调用时,返回同一业务结果,
* 且最多产生一次资金扣减。
*/
PaymentResult charge(
String idempotencyKey,
Money amount,
PaymentMethod method);
}一个实现即使类型检查通过,只要忽略幂等键,就不能替换其他实现:调用方的安全重试假设会被破坏。
常用检查方式是:
- 子类型不能要求更严格的前置条件;
- 子类型不能给出更弱的后置保证;
- 必须维护父类型的不变量;
- 失败类型、幂等性、顺序和状态历史等可观察性质不能意外改变。
“所有实现都抛 UnsupportedOperationException 处理自己不支持的参数”通常说明接口承诺过宽,或这些实现并不属于同一个可替换类型族。
用共享契约测试验证实现族
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 不是“接口方法越少越好”,而是调用者不应被迫依赖自己不需要、也无法合理实现的能力。
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);
}公开查询客户端不需要依赖管理员命令;普通仓库适配器也不必为了一个巨大接口实现审计导出。接口边界应围绕客户端角色与一致契约,不是机械地把每个方法放进独立文件。
过度切分会把一次用例需要的概念散落成大量浅接口,让调用方承担组合责任。若一组操作始终由同一客户端共同使用、维护同一不变量,保留在一个“深”接口里通常更清晰。
组合优先,是为了独立替换行为
继承同时承担代码复用和类型关系时,容易把两个目的绑在一起。组合允许对象在运行或装配时选择协作者:
public final class RewardService {
private final RewardPolicy rewardPolicy;
private final EligibilityPolicy eligibilityPolicy;
private final RewardLedger ledger;
// constructor omitted
}RewardService 有奖励策略和资格策略,这些行为可以独立测试与替换。它不需要继承 HolidayRewardService、RankedRewardService、HolidayRankedRewardService 形成组合爆炸。
组合也有成本:更多对象、装配与间接调用。如果继承关系确实表达稳定的可替换类型、父类契约清晰、扩展点受控,继承仍然合适。关键问题不是语法上的 “is-a”,而是子类是否完整遵守行为契约。
实现继承与接口继承要分开看
实现接口声明“我满足这份契约”;继承具体基类还会复用其状态和模板流程。后者耦合更强:父类的 protected API、调用顺序和可覆盖方法都可能成为子类依赖。
设计可扩展基类时,应明确:
- 哪些方法允许覆盖,哪些不允许;
- 构造期间是否调用可覆盖方法;
- 模板方法的顺序与错误语义;
- 子类必须维护哪些不变量;
- 二进制和源码兼容承诺。
如果无法写清这些内容,优先使用 final 类加显式协作者。
最少知识原则不是禁止链式调用
危险导航通常把外部对象图结构泄露给调用者:
player.getAccount().getWallet().getCurrency().getCode();调用者同时知道 Player、Account、Wallet、Currency 的结构,任何中间关系变化都会传播。可以把真正的业务问题交给拥有相关知识的对象:
player.canReceive(reward);但 builder.withName("arena").withRegion("ap").build() 并不必然违反最少知识原则;链条上的方法如果持续操作同一个 fluent abstraction,没有逐层穿透陌生对象图,耦合性质不同。
也不要为消除点号创建大量转发方法。目标是把业务决策放到拥有数据和不变量的边界,而不是追求最短调用链。
封装的是决策,不只是字段
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 分模块,跨模块事务和协作反而更复杂。
决策依据应是实际变化频率、失败成本、团队边界和测试反馈。先写最简单且清楚的实现,在变化证据出现时重构出边界,比把五条原则当静态检查规则更可靠。
设计评审问题
- 哪些角色会推动这段代码变化?
- 哪个变化轴值得稳定扩展点?
- 业务策略是否导入了具体技术细节?
- 每个实现是否遵守同一失败、幂等与状态契约?
- 调用者是否依赖不需要的操作?
- 继承是在表达可替换类型,还是只为复用代码?
- 调用链是否泄露了内部对象图?
- 新抽象解决了已知风险,还是只增加文件数量?