← 返回 Java 后端知识路线
阶段 01Java 基础

继承、多态与接口:让调用方依赖能力而不是具体实现

用通知发送器比较继承和组合,追踪接口引用在运行时选择哪个对象,理解多态带来的扩展边界。

第 06 / 35 篇
继承多态接口组合

先看这一课值不值得学

学完后,你手里多了哪些代码积木

让调用方依赖共同能力,而不是写死某个实现类。

本课正式新增

语法 / API / 命令你必须会到什么程度
extends / super建立继承关系并复用父类构造
@Override明确重写父类或接口方法
interface / implements声明能力契约并提供实现
父类型 ref = new 子类型()用多态引用触发运行时方法选择

本课只借用,先别硬背

  • 依赖注入是接口解耦的框架化用法,第 25 课再学

学完必须能独立写

  • 替换短信/邮件通知实现而不改调用方
  • 解释编译期类型与运行时对象的区别
本课目录
  1. 1. 现实问题:增加一种通知方式不应该改遍订单代码
  2. 2. 最小可运行示例:同一调用,不同实现
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:把外部能力接在适配器边界
  6. 6. 易错排查
  7. 7. 一页复习

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() 换成 EmailNoticeSenderPaymentService 不需要改变。示例用分做金额,避免第一课已经说明过的浮点问题;真实项目可以用 BigDecimal 或明确的最小货币单位。

3. 调用链与对象变化

new ConsoleNoticeSender() 在堆上创建具体对象,返回的引用被保存到 NoticeSender sender 中。编译器只允许通过接口声明的方法调用;执行 sender.send 时,JVM 根据对象的实际类型找到 ConsoleNoticeSender.send,这叫动态分派。PaymentServicesender 字段保存同一引用副本,调用链不会因为换实现而改变。

如果子类新增了接口里没有的方法,接口引用不能直接调用它;需要重新设计能力边界,而不是到处强制类型转换。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 面向对象、接口与动态分派」的学习范围组织,正文与示例均为本站原创整理。