「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),完全不用关心"怎么签名""网关地址怎么拼参数"这些细节------formatContent、sign、callGateway 全部是 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 里写一遍,SmsNotifier 和 EmailNotifier 直接继承拿到,自己只需要填一个"具体怎么发"的空。这也是 Java IO 体系的设计思路------FilterInputStream 把"包装、增强"这类公共逻辑放在父类,BufferedInputStream、DataInputStream 这些子类只需要各自实现自己独有的那部分。
三、多态:调用方不用关心到底是谁在干活
系统跑了一阵子,业务方又提需求:以后可能还要加站内信、加微信推送,而且经常要"同一条消息,多种方式一起发"。如果调用方的代码写成这样:
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", "订单已发货");
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:为什么说"面向接口编程"本质上是在利用多态?
因为面向接口编程的做法是,调用方的代码里只依赖一个接口或抽象类型(比如List、DataSource),不直接依赖某个具体实现类,具体传进来的是哪个实现,由创建对象的地方(或者依赖注入框架)决定。这样一来,替换底层实现(比如把ArrayList换成LinkedList,或者把一个数据库连接池实现换成另一个)时,调用方代码完全不需要改动------这正是多态"同一段调用代码能适配不同实际类型"这个特性带来的好处。
Q5:封装、继承、多态三者,写业务代码时最容易被滥用的是哪个?
继承最容易被滥用。很多人为了图省事复用几个方法,就让两个本质上并不是"同一类事物"的类强行继承,结果导致类的层级关系变得很奇怪,父类一改动就牵连一大片子类,后期维护成本很高。这也是"多用组合,少用继承"这条经验法则被反复强调的原因------继承应该只用在真正满足"is-a"(是一种)关系、且父子类行为高度一致的场景。
下一篇预告
Day18 讲 static、final、abstract 这三个关键字的辨析------分别修饰变量、方法、类的时候,各自的含义有什么微妙的区别,容易被搞混的地方在哪。