从一个发短信的类到多态调用:封装、继承、多态到底是怎么长出来的

「Java 进阶之路」系列 Day17

写在前面

封装、继承、多态这三个词几乎是每个 Java 教程开篇必讲的内容,但大部分讲法都是三个孤立的概念定义,背完就忘。这篇换个方式------用一个"发通知"系统从简单到复杂的演进过程,把这三大特性串起来讲,讲清楚每一步为什么非加不可,而不是干巴巴地念定义。


一、封装:把细节关起来,只留一个门缝

假设最初的需求很简单:给用户发一条短信通知。

java 复制代码
public class SmsNotifier {
    private String gatewayUrl;
    private String appKey;

    public SmsNotifier(String gatewayUrl, String appKey) {
        this.gatewayUrl = gatewayUrl;
        this.appKey = appKey;
    }

    public void send(String phone, String content) {
        String formattedContent = formatContent(content);   // 内部细节:格式化文本
        String signature = sign(formattedContent, appKey);   // 内部细节:签名
        callGateway(gatewayUrl, phone, formattedContent, signature);  // 内部细节:调用网关
    }

    private String formatContent(String content) { /* ... */ return content; }
    private String sign(String content, String key) { /* ... */ return "sign"; }
    private void callGateway(String url, String phone, String content, String sign) { /* ... */ }
}

调用方只需要 new SmsNotifier(url, key).send(phone, content),完全不用关心"怎么签名""网关地址怎么拼参数"这些细节------formatContentsigncallGateway 全部是 private,被"关"在类内部,外部连看都看不到。

这就是封装的本质:把易变的实现细节隐藏起来,只暴露一个稳定的对外接口。 好处很直接------以后短信网关升级、签名算法调整,只要 send() 这个方法签名不变,调用方的代码一行都不用改。JDK 里的 ArrayList 就是这个思路的典型代表:内部用一个 Object[] 数组存数据,扩容、下标计算这些细节全部封装在类内部,外部只通过 add()get() 这些方法操作,完全不需要知道底层是数组还是别的什么结构。


二、继承:把公共的部分往上提一层

过了一段时间,业务方又提了个需求:除了短信,还要支持邮件通知。如果照抄一份 SmsNotifier 改成 EmailNotifier,会发现两个类里有大量重复逻辑------比如"发送失败要重试 3 次""每次发送都要记一条日志"这些逻辑,短信和邮件是完全一样的,只有"具体怎么发出去"这一步不一样。

这时候应该把公共部分往上提一层,抽象出一个父类:

java 复制代码
public abstract class Notifier {
    // 公共逻辑:重试机制,子类不需要重复写
    public final void send(String target, String content) {
        int retry = 3;
        while (retry-- > 0) {
            try {
                doSend(target, content);   // 具体怎么发,交给子类实现
                log(target, content, true);
                return;
            } catch (Exception e) {
                log(target, content, false);
            }
        }
    }

    protected abstract void doSend(String target, String content);   // 留给子类填空的部分

    private void log(String target, String content, boolean success) { /* 记录发送日志 */ }
}

public class SmsNotifier extends Notifier {
    @Override
    protected void doSend(String target, String content) {
        // 真正调用短信网关
    }
}

public class EmailNotifier extends Notifier {
    @Override
    protected void doSend(String target, String content) {
        // 真正调用邮件服务
    }
}

继承解决的问题是代码复用 :重试逻辑、日志记录这些"所有通知方式都要有"的行为,只在父类 Notifier 里写一遍,SmsNotifierEmailNotifier 直接继承拿到,自己只需要填一个"具体怎么发"的空。这也是 Java IO 体系的设计思路------FilterInputStream 把"包装、增强"这类公共逻辑放在父类,BufferedInputStreamDataInputStream 这些子类只需要各自实现自己独有的那部分。


三、多态:调用方不用关心到底是谁在干活

系统跑了一阵子,业务方又提需求:以后可能还要加站内信、加微信推送,而且经常要"同一条消息,多种方式一起发"。如果调用方的代码写成这样:

java 复制代码
// 反面写法:调用方要为每种类型写一个if分支,每加一种通知方式都要改这里
if (type.equals("sms")) {
    new SmsNotifier().send(target, content);
} else if (type.equals("email")) {
    new EmailNotifier().send(target, content);
}
// 以后加站内信、加微信推送,这里还要一直加else if

这种写法最大的问题是:每新增一种通知方式,调用方的代码都要跟着改。多态解决的正是这个问题:

java 复制代码
public void notifyAll(List<Notifier> notifiers, String target, String content) {
    for (Notifier notifier : notifiers) {
        notifier.send(target, content);   // 不关心notifier具体是SmsNotifier还是EmailNotifier
    }
}

// 调用方
List<Notifier> notifiers = List.of(new SmsNotifier(), new EmailNotifier());
notifyAll(notifiers, "13800001111", "订单已发货");
flowchart LR A[调用方持有<br/>Notifier类型的引用] --> B[调用send方法] B --> C[运行时实际指向<br/>SmsNotifier对象] B --> D[运行时实际指向<br/>EmailNotifier对象] C --> E[执行短信发送逻辑] D --> F[执行邮件发送逻辑]

notifyAll 方法参数声明的类型是父类 Notifier,但实际传进来的可能是 SmsNotifier,也可能是 EmailNotifier------同一段调用代码 notifier.send(...),在运行时会根据这个引用实际指向的对象类型,自动执行对应子类里的具体实现 ,这就是多态。以后新增一个 WechatNotifier,调用方的 notifyAll 代码完全不用改,只要让新类也继承 Notifier 就行。

这也是"面向接口编程"这句话的真正含义------JDK 里的 List<E> list = new ArrayList<>()、Spring 里到处可见的 JdbcTemplate 持有一个 DataSource 接口类型的引用(而不是具体某个数据库连接池的实现类),本质上都是在利用多态:调用方只依赖一个稳定的类型(接口或父类),具体是哪个实现在干活,运行时才决定,替换实现的时候不需要改调用方一行代码。


四、三者串起来看:一条完整的演进链

阶段 遇到的问题 用什么特性解决
只有一个 SmsNotifier 调用方不该关心签名、网关调用这些实现细节 封装:细节设为private,只留send这一个公开方法
加了 EmailNotifier 重试、日志这些逻辑在两个类里重复写了一遍 继承:公共逻辑上提到父类Notifier,子类只填具体发送方式
还要支持批量发送、以后还会加更多通知方式 调用方代码里堆满if-else,每加一种类型都要改 多态:调用方只认Notifier这个类型,具体实现运行时决定

一句话总结这条演进链:封装解决的是"别人不该管的细节别让别人看到";继承解决的是"大家都要做的事情别写好几遍";多态解决的是"调用方不该因为增加新类型而被迫跟着改代码"------三者不是孤立的知识点,而是随着系统复杂度增加,一层一层自然长出来的设计手段。


五、面试追问

Q1:封装的意义是什么,只是把字段设成 private 吗?

不只是。设 private 只是封装的手段之一,封装真正的意义是把容易变化的实现细节隐藏起来,只对外暴露一个稳定的接口。好处是当内部实现调整时(比如换了个短信网关服务商),只要对外的方法签名不变,所有调用方的代码都不需要跟着修改,降低了改动的影响范围。

Q2:继承和组合应该怎么选?

继承适合"确实是同一类事物、有大量可以直接复用的公共行为"的场景,比如短信通知和邮件通知都是"通知",都需要重试和日志逻辑。但继承是强耦合关系,父类的改动会直接影响所有子类,层级太深还会导致代码难以理解。如果两个类之间只是"包含"关系而不是"是一种"的关系(比如一个类需要用到另一个类的功能,但两者并不是同一类事物),应该优先用组合(把对方作为字段持有),而不是滥用继承。

Q3:多态在运行时是怎么知道该调用哪个子类的实现的?

Java 里方法调用在编译期只能确定"调用的是哪个方法签名",真正执行哪个子类的实现,是在运行时通过对象的实际类型去查找的(涉及虚方法表这类机制),这也是"多态"里"运行时才决定"这句话的具体含义。调用方持有的引用类型(比如父类Notifier)只是编译期的约束,不影响运行时到底调用谁的代码。

Q4:为什么说"面向接口编程"本质上是在利用多态?

因为面向接口编程的做法是,调用方的代码里只依赖一个接口或抽象类型(比如ListDataSource),不直接依赖某个具体实现类,具体传进来的是哪个实现,由创建对象的地方(或者依赖注入框架)决定。这样一来,替换底层实现(比如把ArrayList换成LinkedList,或者把一个数据库连接池实现换成另一个)时,调用方代码完全不需要改动------这正是多态"同一段调用代码能适配不同实际类型"这个特性带来的好处。

Q5:封装、继承、多态三者,写业务代码时最容易被滥用的是哪个?

继承最容易被滥用。很多人为了图省事复用几个方法,就让两个本质上并不是"同一类事物"的类强行继承,结果导致类的层级关系变得很奇怪,父类一改动就牵连一大片子类,后期维护成本很高。这也是"多用组合,少用继承"这条经验法则被反复强调的原因------继承应该只用在真正满足"is-a"(是一种)关系、且父子类行为高度一致的场景。


下一篇预告

Day18 讲 staticfinalabstract 这三个关键字的辨析------分别修饰变量、方法、类的时候,各自的含义有什么微妙的区别,容易被搞混的地方在哪。

相关推荐
MarkHD1 小时前
Stable Diffusion入门第19-20天:保存与分享你的工作流——第一阶段收官,从“能用”到“可复用”
java·开发语言·stable diffusion
爱敲键盘的猴子1 小时前
Spring MVC 详解(一):全注解开发与请求响应处理
java·spring·mvc
counting money2 小时前
SpringBoot 项目创建(IDEA不全,还需要修改)
java·spring boot·spring·maven
Dr.kangder2 小时前
嵌入式面试总结(二十二)——指针
java·面试·职场和发展·架构·嵌入式
MacroZheng2 小时前
几行代码给项目集成AI功能,Spring AI 2.0太香了!
java·人工智能·spring boot
vipxieliang2 小时前
ValidX 迁移指南:v1.0.0/v1.0.1 → v1.1.0
后端
Devin~Y2 小时前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
未秃头的程序猿2 小时前
从写CRUD到做AI Agent:我花了6个月转型,这是我的完整路线图
java·后端·ai编程
Java内核笔记2 小时前
万字长文剖析 Spring Boot 4.1.0 启动流程源码:从 main 到就绪
java·后端