跳到内容

第 1 卷 / 第 8 章 / 第 1 课

8.1 继承与可替换关系 ​

前置知识:类、构造方法、封装和访问修饰符

预计时间:30 分钟

完成标志:能判断一个子类型是否可替换父类型,并能在继承与组合之间作出有依据的选择

你把相似代码复制了三次:修完 EmailChannel,却忘了同步 SmsChannel。重复代码的麻烦就是这样,它复制得很快,修复却从不自动组团。

继承能共享实现,但这不是使用继承的充分理由。更重要的问题是:调用方期待一个父类型时,子类型能否遵守同一行为契约。

1. extends 建立类的子类型关系 ​

先把公共状态和操作放进基类。这里的发送器只向控制台打印演示信息,不连接邮件或短信服务:

java
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 扩展它:

java
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) 构造方法。创建子类时必须先完成父类部分的初始化:

  1. 为整个新对象的字段设置默认值。
  2. 执行父类构造链。
  3. 执行当前类字段初始化器和初始化块。
  4. 执行当前子类构造体中父类调用之后的部分。

Java 17 中显式 super(...) 必须是构造体第一条语句。若既没写 super(...),也没用 this(...) 委托给本类其他构造方法,编译器尝试插入 super();父类没有可访问无参构造时就会编译失败。

父类构造期间不要调用可被子类重写的方法。动态分派可能进入子类实现,而子类字段尚未初始化完成。

3. “is-a” 只是提问起点 ​

常见口诀说继承表示 is-a,组合表示 has-a。但自然语言说得通不等于行为可替换。

判断 EmailSender extends MessageSender 是否合理,要检查父类型契约:

  • 所有合法消息对子类仍合法吗?
  • 子类会不会加强前置条件,让原本可发送的消息失败?
  • 子类会不会削弱承诺,例如静默丢弃消息却报告成功?
  • 父类型公开的不变量,子类是否始终保持?

这就是替换原则的实用版本:任何只依赖 MessageSender 契约的代码,接收 EmailSender 后仍应正确。

若只是想复用一个工具方法,而对象并不满足父类型契约,应使用组合或委托。

4. 组合表达“拥有并使用” ​

java
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 关闭不需要的扩展点 ​

java
final class MessageId {
    // 该值类型不允许出现子类
}

class Sender {
    public final void validate() {
        // 子类不能替换这段不变量检查
    }
}

final class 不能被继承,final 实例方法不能被重写。不要默认让每个 public/protected 方法都成为永久扩展协议;关闭不需要的扩展点能减少未来兼容负担。

专业速查 ​

决策检查问题
使用继承子类能否完整遵守父类型契约
使用组合只是拥有、调用或复用另一个对象
protected 成员是否是稳定且有文档的扩展点
构造链父类是否有匹配且可访问的构造
final是否确实需要外部重写或扩展

离开这一页前 ​

先写出 MessageSender 对合法输入、成功和失败的承诺,再逐条检查 EmailSender 是否会加强前置条件或削弱结果。若只是想复用格式化器,把它改成组合并比较公开 API。构造链还要实际验证父类无可访问无参构造时,子类构造链必须最终显式调用匹配的 super(...)。

→ 下一步:多态与动态分派

规范入口:JLS §8.1.4:类的直接父类与子类型。

Built with VitePress | Software Systems Atlas