8.1 继承与可替换关系
前置知识:类、构造方法、封装和访问修饰符
预计时间:30 分钟
完成标志:能判断一个子类型是否可替换父类型,并能在继承与组合之间作出有依据的选择
你把相似代码复制了三次:修完 EmailChannel,却忘了同步 SmsChannel。重复代码的麻烦就是这样,它复制得很快,修复却从不自动组团。
继承能共享实现,但这不是使用继承的充分理由。更重要的问题是:调用方期待一个父类型时,子类型能否遵守同一行为契约。
1. extends 建立类的子类型关系
先把公共状态和操作放进基类。这里的发送器只向控制台打印演示信息,不连接邮件或短信服务:
import java.util.Objects;
class MessageSender {
private final String message;
MessageSender(String message) {
this.message = Objects.requireNonNull(message, "message");
if (message.isBlank()) {
throw new IllegalArgumentException("blank message");
}
}
protected final String message() {
return message;
}
protected final void logMessage() {
System.out.println("消息:" + message);
}
public void send() {
logMessage();
}
}EmailSender 扩展它:
class EmailSender extends MessageSender {
private final String recipient;
EmailSender(String message, String recipient) {
super(message);
if (recipient == null || recipient.isBlank()) {
throw new IllegalArgumentException("blank recipient");
}
this.recipient = recipient;
}
public String recipient() { return recipient; }
@Override
public void send() {
logMessage();
System.out.println("演示邮件接收方:" + recipient);
}
}子类对象包含父类声明的实例字段,也有自己的字段;但 private 成员本身不被子类继承,也不能由子类源码直接访问,因此基类通过 protected final 查询和操作暴露受控扩展点。直接公开 protected 可变字段会让所有子类依赖内部表示,降低封装性。
发送操作需要记录发生时间时,不要用对象创建时间代替发送时间。生产实现可在 send 时读取注入的 Clock,再交给日志组件;这样测试能使用固定时钟,业务代码也不依赖机器时区格式。
2. 构造方法不继承,但构造链必须完成
EmailSender 不会继承 MessageSender(String) 构造方法。创建子类时必须先完成父类部分的初始化:
- 为整个新对象的字段设置默认值。
- 执行父类构造链。
- 执行当前类字段初始化器和初始化块。
- 执行当前子类构造体中父类调用之后的部分。
Java 17 中显式 super(...) 必须是构造体第一条语句。若既没写 super(...),也没用 this(...) 委托给本类其他构造方法,编译器尝试插入 super();父类没有可访问无参构造时就会编译失败。
父类构造期间不要调用可被子类重写的方法。动态分派可能进入子类实现,而子类字段尚未初始化完成。
3. “is-a” 只是提问起点
常见口诀说继承表示 is-a,组合表示 has-a。但自然语言说得通不等于行为可替换。
判断 EmailSender extends MessageSender 是否合理,要检查父类型契约:
- 所有合法消息对子类仍合法吗?
- 子类会不会加强前置条件,让原本可发送的消息失败?
- 子类会不会削弱承诺,例如静默丢弃消息却报告成功?
- 父类型公开的不变量,子类是否始终保持?
这就是替换原则的实用版本:任何只依赖 MessageSender 契约的代码,接收 EmailSender 后仍应正确。
若只是想复用一个工具方法,而对象并不满足父类型契约,应使用组合或委托。
4. 组合表达“拥有并使用”
final class Dog {
private final java.util.List<Toy> toys = new java.util.ArrayList<>();
void addToy(Toy toy) {
toys.add(java.util.Objects.requireNonNull(toy));
}
}
final class Toy {}Dog extends ArrayList<Toy> 会宣称狗可替代任意玩具列表,调用方甚至能执行 clear、sort 等与狗的业务契约无关的操作。组合只暴露 Dog 允许的行为,也能在以后更换内部集合。
优先组合不是禁止继承。框架扩展点、稳定的模板方法和确实存在的分类层次仍可使用继承,前提是父类为扩展而设计,并记录哪些方法可重写。
5. final 关闭不需要的扩展点
final class MessageId {
// 该值类型不允许出现子类
}
class Sender {
public final void validate() {
// 子类不能替换这段不变量检查
}
}final class 不能被继承,final 实例方法不能被重写。不要默认让每个 public/protected 方法都成为永久扩展协议;关闭不需要的扩展点能减少未来兼容负担。
专业速查
| 决策 | 检查问题 |
|---|---|
| 使用继承 | 子类能否完整遵守父类型契约 |
| 使用组合 | 只是拥有、调用或复用另一个对象 |
| protected 成员 | 是否是稳定且有文档的扩展点 |
| 构造链 | 父类是否有匹配且可访问的构造 |
| final | 是否确实需要外部重写或扩展 |
离开这一页前
先写出 MessageSender 对合法输入、成功和失败的承诺,再逐条检查 EmailSender 是否会加强前置条件或削弱结果。若只是想复用格式化器,把它改成组合并比较公开 API。构造链还要实际验证父类无可访问无参构造时,子类构造链必须最终显式调用匹配的 super(...)。
规范入口:JLS §8.1.4:类的直接父类与子类型。