1. 现实问题:增加一种通知方式不应该改遍订单代码
订单支付成功后可能发邮件、短信或站内信。如果订单服务直接 new EmailSender(),以后增加短信就要修改业务分支、测试和配置。继承可以复用实现,但真正让调用方稳定的是接口:订单只需要知道“能发送通知”的能力,不需要知道具体渠道。
多态也很容易被讲成一句“父类引用指向子类对象”。这句话只有在追踪调用时才有意义:变量的编译期类型决定能调用哪些方法,堆中对象的运行时类型决定重写方法最终执行哪一份代码。
2. 最小可运行示例:同一调用,不同实现
public class PaymentNoticeDemo {
public static void main(String[] args) {
NoticeSender sender = new ConsoleNoticeSender();
PaymentService service = new PaymentService(sender);
service.paid("order-1001", 19900);
}
interface NoticeSender {
void send(String orderId, int cents);
}
static final class ConsoleNoticeSender implements NoticeSender {
@Override public void send(String orderId, int cents) {
System.out.printf("通知:%s 已支付 %d 分%n", orderId, cents);
}
}
static final class PaymentService {
private final NoticeSender sender;
PaymentService(NoticeSender sender) { this.sender = sender; }
void paid(String orderId, int cents) {
if (cents <= 0) throw new IllegalArgumentException("金额必须为正");
sender.send(orderId, cents);
}
}
}
把 new ConsoleNoticeSender() 换成 EmailNoticeSender,PaymentService 不需要改变。示例用分做金额,避免第一课已经说明过的浮点问题;真实项目可以用 BigDecimal 或明确的最小货币单位。
3. 调用链与对象变化
new ConsoleNoticeSender() 在堆上创建具体对象,返回的引用被保存到 NoticeSender sender 中。编译器只允许通过接口声明的方法调用;执行 sender.send 时,JVM 根据对象的实际类型找到 ConsoleNoticeSender.send,这叫动态分派。PaymentService 的 sender 字段保存同一引用副本,调用链不会因为换实现而改变。
如果子类新增了接口里没有的方法,接口引用不能直接调用它;需要重新设计能力边界,而不是到处强制类型转换。static 方法按声明类型解析,不参与普通实例方法的动态分派;字段也不会像重写方法一样动态选择,这些细节在调试继承代码时很重要。
4. 为什么这样设计
继承表达“是一个”且共享稳定的替换关系;接口表达“能做什么”,组合表达“由哪些协作者完成”。业务代码通常更适合接口加组合,因为渠道变化不会把层次结构拉深。继承可以复用受保护状态,但也把父类实现细节暴露给子类,改父类可能影响多个子类。
接口不是为了把所有类都抽象成五个方法,而是为了在变化点留下稳定端口。若只有一个实现且不会被替换,直接类依赖未必错误;当测试需要假的发送器、配置需要切换渠道或未来存在多个实现时,接口就有现实价值。
5. 项目落点:把外部能力接在适配器边界
博客发布后的邮件、对象存储和搜索索引都可以通过端口接入:ArticlePublisher 依赖 SearchIndexer,具体的 ES、数据库或测试内存实现位于基础设施层。Spring IoC 会负责把实现注入 Service,但多态的原理不依赖 Spring,先用构造器手动传入 fake 实现把业务测通。
练习:为通知增加 RetryingNoticeSender,它组合另一个 NoticeSender,失败时最多重试两次并记录结果。你会发现装饰器比继承一个具体邮件类更容易组合,也能自然引出第二阶段的设计模式。
6. 易错排查
ClassCastException:调用方依赖了具体实现,说明接口能力边界不足或类型判断分散。@Override报错:方法签名没有真正重写父类/接口,保留注解让编译器帮助检查。- 父类构造器调用了可重写方法:子类字段尚未初始化,可能读到默认值;构造期间只调用 private/final 方法。
- 接口数量过多:先观察是否有实际替换点,避免为了“面向接口”制造空洞抽象。
7. 一页复习
声明类型决定“能调用什么”,运行时类型决定“重写方法执行谁”。接口提供能力边界,组合承载变化,继承用于真正稳定的 is-a 关系。看到 new 出现在业务深处时,问它是不是应该交给构造器、工厂或 IoC;看到强制类型转换时,问接口是否没有表达真实协作。
把一次多态调用写成四列:变量声明类型是 NoticeSender,对象运行时类型是 ConsoleNoticeSender,编译器允许的方法是 send,最终执行的方法也是 Console 实现。换成 Email 实现后前三列只有运行时类型变化,PaymentService 的源代码和输入输出契约不变。这就是多态在项目中的可验证收益,而不是一个抽象类图。
若两种实现只有少量共享校验,可以用组合的 ValidatingNoticeSender 包住真正发送器;若它们共享稳定的生命周期和受保护状态,才考虑抽象父类。接口放在 application/port,邮件、短信适配器放在 infrastructure/notification,Service 只依赖 port。测试用 RecordingNoticeSender 记录调用,不需要启动 SMTP。
易错排查要观察声明类型和运行时类型:编译时报找不到方法,说明接口没有暴露该能力;运行时报 ClassCastException,说明调用方把实现细节泄露出来;父类构造时字段为默认值,说明构造阶段调用了可重写方法;换实现后通知重复,说明装饰器和原实现都被直接注入。每一类错误都指向接口边界、构造生命周期或对象组装的位置。
练习是增加“失败后重试”的装饰器,并规定输入订单号、金额、调用次数和最终异常。用 fake 实现第一次抛异常、第二次成功,验证调用两次;再测试永久失败只重试有限次数。记录为什么组合比复制三份 if 更容易增加渠道,也记录什么时候应该停止增加抽象。
可以把替换实验扩展成输入、调用和输出三行:PaymentService 输入 orderId/cents,调用接口 send,输出是通知副作用;FakeSender 只记录参数,EmailSender 还会访问网络。Service 的测试只关心接口调用和错误,不应因为 Email 实现需要 SMTP 就变慢。这个差异说明依赖倒置不是为了好看,而是为了让业务和外部世界各自可验证。
接口方法应表达最小能力,NoticeSender 不应该顺便暴露重试、模板和连接管理;这些能力可由另一个端口或装饰器承担。抽象类适合共享稳定模板和受保护步骤,但当两个实现的生命周期、失败模型不同,组合更安全。配置层按 key 选择实现,业务层永远拿到接口引用。
排查多态问题时先查看容器注入的实际类名,再检查是否有重复 Bean、错误 Qualifier 和代理包装;强转失败时回到调用方依赖的能力,不要给接口塞一个“getConcrete”逃生口。若重试导致重复通知,要给装饰器增加幂等 id 和最大次数。练习是让 fake 记录每次调用的输入和异常,画出实现替换前后的调用链。
替换实现的验收标准是 PaymentService 的输入输出不变、fake 能记录调用、真实适配器可单独集成测试。若新增渠道必须改 Service 的条件分支,说明接口或工厂边界还没有真正承接变化。
验证清单:用 ConsoleNoticeSender、EmailNoticeSender 和 RecordingNoticeSender 依次注入同一个 PaymentService,固定 orderId 与 cents,比较调用参数、返回结果和异常契约;Service 源码不应因运行时实现变化而增加分支。再包一层重试装饰器,分别测试首次成功、第一次失败后成功、连续失败三条路径,记录每次调用和最终错误。若出现 ClassCastException、重复 Bean 或通知重复,先检查声明类型、Qualifier、对象组装和幂等 id,不要直接增加一个更宽的父类。
复盘项目落点:application/port 只声明最小通知能力,infrastructure/notification 实现外部渠道,application/service 负责用例,装饰器负责横切重试或指标,配置/工厂负责选择运行时对象。验收条件是输入输出不变、fake 可单独测试、真实适配器可独立集成;如果新增渠道仍必须改 PaymentService 的 if/else,说明变化还没有被接口或工厂承接。
进阶附录:密封类与模式匹配
JDK 17 的 sealed class 可以限制继承者,适合状态集合有限且需要穷尽处理的模型。JDK 21 的 switch 模式匹配能减少显式 instanceof,但它并不会让不合理的层次结构变合理。先明确替换关系,再选择普通接口、sealed hierarchy 或记录模式。
本课按「Java 21 面向对象、接口与动态分派」的学习范围组织,正文与示例均为本站原创整理。