7.2 可替换契约、接口隔离与组合
新实现替换旧接口后,编译没有报错,调用方的行为却变了;你们必须把抽象背后的契约写清楚。
有了接口不代表实现可以安全替换。调用者依赖的除了方法名,还有前置条件、结果保证、失败语义和状态演化。LSP 与 ISP 用来检查抽象是否诚实;组合与最少知识原则帮助控制对象之间暴露的结构。
LSP:子类型必须保留调用者能依赖的性质
Liskov Substitution Principle 的核心是行为子类型:如果调用者只知道父类型/接口,对象换成任何子类型后,原先能够成立的正确性推理仍应成立。
假设支付端口承诺:相同幂等键的重复扣款不会产生第二笔资金变化。
public interface PaymentGateway {
/**
* 在约定的账户/操作范围和保留窗口内,键与参数相同的重复调用
* 返回同一业务结果,且最多产生一次资金扣减。
*/
PaymentResult charge(
String idempotencyKey,
Money amount,
PaymentMethod method);
}一个实现即使类型检查通过,只要忽略幂等键,就不能替换其他实现:调用方的安全重试假设会被破坏。
这里是项目接口片段,Money、PaymentMethod、PaymentResult 由支付模型提供,不能直接把前面的 Money 练习原样套进来。契约还应定义金额等价、租户/商户作用域、键保留期、过期后行为与并发请求处理。本教学契约约定:保留期内以同一键换参数,抛 IllegalArgumentException 且不再扣款。提供者适配器需要把实际 API 语义映射到这份契约;把键放进 HTTP 头还不够。
常用检查方式是:
- 子类型不能要求更严格的前置条件;
- 子类型不能给出更弱的后置保证;
- 必须维护父类型的不变量;
- 失败类型、幂等性、顺序和状态历史等可观察性质不能意外改变。
“所有实现都抛 UnsupportedOperationException 处理自己不支持的参数”通常说明接口承诺过宽,或这些实现并不属于同一个可替换类型族。
若父契约已明确某项操作可选,抛异常可能完全合法。例如 Java Collection 把某些修改操作标为 optional,允许抛 UnsupportedOperationException。LSP 违约发生在子类型撤回父类型确实作出的保证时,不能只看到这个异常名就定案。
用共享契约测试验证实现族
interface PaymentGatewayContract {
PaymentGateway gateway();
PaymentMethod testMethod();
String freshKey();
void assertSingleDebit(String key, Money amount);
@org.junit.jupiter.api.Test
default void repeatedKeyDoesNotChargeTwice() {
PaymentGateway gateway = gateway();
Money amount = Money.usd("20.00");
PaymentMethod method = testMethod();
String key = freshKey();
PaymentResult first = gateway.charge(key, amount, method);
PaymentResult second = gateway.charge(key, amount, method);
org.junit.jupiter.api.Assertions.assertEquals(first, second);
assertSingleDebit(key, amount);
}
@org.junit.jupiter.api.Test
default void repeatedKeyWithDifferentAmountIsRejected() {
PaymentGateway gateway = gateway();
Money amount = Money.usd("20.00");
PaymentMethod method = testMethod();
String key = freshKey();
gateway.charge(key, amount, method);
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> gateway.charge(key, Money.usd("21.00"), method));
assertSingleDebit(key, amount);
}
}内存 Fake 和提供者沙箱适配器可以复用同一契约。测试不能形式化证明全部 LSP,但能把重要语义从注释变成持续验证。
JUnit 5 需要具体测试类实现该接口后,才会发现其中的 default 测试方法。gateway 须使用隔离沙箱状态,freshKey 每例生成新键,assertSingleDebit 则检查同一网关的账本或提供者沙箱,不能只把返回值再读一遍。测试复用同一个 PaymentMethod,覆盖顺序重放和金额变更拒绝;并发、崩溃、键过期与真实资金结算还需相应验证。
ISP:接口按调用者需要切分
Interface Segregation Principle 不是“接口方法越少越好”,而是调用者不应被迫依赖自己不需要、也无法合理实现的能力。
下面的 Java 接口声明依赖签名中的项目 ID、视图、分页和命令类型;public 接口分别保存,TournamentAudit 另需导入 java.util.List。
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);
}公开查询客户端不需要依赖管理员命令;普通仓库适配器也不必为了一个巨大接口实现审计导出。接口边界应围绕客户端角色与一致契约,不是机械地把每个方法放进独立文件。
过度切分会把一次用例需要的概念散落成大量浅接口,让调用方承担组合责任。若一组操作始终由同一客户端共同使用、维护同一不变量,保留在一个“深”接口里通常更清晰。
组合优先,是为了独立替换行为
继承同时承担代码复用和类型关系时,容易把两个目的绑在一起。组合允许对象在运行或装配时选择协作者:
这里是替代 7.1 的 RewardService 类声明,并依赖项目定义的 EligibilityPolicy。构造器展示组合关系,尚未实现发奖用例。
public final class RewardService {
private final RewardPolicy rewardPolicy;
private final EligibilityPolicy eligibilityPolicy;
private final RewardLedger ledger;
public RewardService(RewardPolicy rewardPolicy,
EligibilityPolicy eligibilityPolicy, RewardLedger ledger) {
this.rewardPolicy = java.util.Objects.requireNonNull(rewardPolicy, "rewardPolicy");
this.eligibilityPolicy = java.util.Objects.requireNonNull(eligibilityPolicy, "eligibilityPolicy");
this.ledger = java.util.Objects.requireNonNull(ledger, "ledger");
}
}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,没有逐层穿透陌生对象图,耦合性质不同。
也不要为消除点号创建大量转发方法。目标是把业务决策放到拥有数据和不变量的边界,而不是追求最短调用链。
封装的是决策,不只是字段
import java.util.Objects;
public final class Registration {
public enum Status { PENDING, CONFIRMED }
public record PaymentReceipt(String registrationId) {
public PaymentReceipt {
Objects.requireNonNull(registrationId, "registrationId");
}
}
private final String id;
private Status status = Status.PENDING;
public Registration(String id) {
this.id = Objects.requireNonNull(id, "id");
}
public String id() { return id; }
public Status status() { return status; }
public void confirm(PaymentReceipt receipt) {
if (status != Status.PENDING) {
throw new IllegalStateException("registration is not pending");
}
Objects.requireNonNull(receipt, "receipt");
if (!receipt.registrationId().equals(id)) {
throw new IllegalArgumentException("receipt belongs to another registration");
}
status = Status.CONFIRMED;
}
}完整 Java 21 示例可保存为 Registration.java。错误报名 ID 或空回执会让 PENDING 对象保持原状;匹配回执使其确认,本地契约拒绝第二次确认。这里的 PaymentReceipt 只是展示转换的结构,回执来源、支付成功、金额和币种须已在可信边界验证。该对象限于单线程,并发持久化与回调幂等还需要额外协调。
把字段设为 private 再生成任意 setter,只隐藏了内存布局。让对象控制合法状态转换,才把领域决策封装起来。
原则冲突时回到变化与风险
SOLID 可能产生张力:
- 为 OCP 增加策略接口,会提高当前理解成本;
- 为 ISP 拆分接口,可能增加装配和跨接口一致性问题;
- 为 DIP 增加端口,对一个稳定小脚本可能毫无收益;
- 为 SRP 分模块,跨模块事务和协作反而更复杂。
决策依据应是实际变化频率、失败成本、团队边界和测试反馈。先写最简单且清楚的实现,在变化证据出现时重构出边界,比把五条原则当静态检查规则更可靠。
设计评审问题
- 哪些角色会推动这段代码变化?
- 哪个变化轴值得稳定扩展点?
- 业务策略是否导入了具体技术细节?
- 每个实现是否遵守同一失败、幂等与状态契约?
- 调用者是否依赖不需要的操作?
- 继承是在表达可替换类型,还是只为复用代码?
- 调用链是否泄露了内部对象图?
- 新抽象解决了已知风险,还是只增加文件数量?