本节目标:能判断一个差异该用继承表达还是用字段、组合表达;能正确写出
extends、super(...)和@Override,并说清构造器与方法的调用顺序。
适用版本:JDK 25 LTS(Temurin 25.0.5);本节语法在 JDK 21 上同样成立。
从「会员只有一种」开始
第 7 章结束时,library 的 Book 和 Member 都做成了字段私有、没有 setter 的不可变对象。Member 有 id、name 和 emails 三个字段,借阅规则也简单:每人最多借 3 本,借期 14 天。7.3 还把 Member 声明为 final;本节为了讲继承,会暂时打开它,这个代价在 9.1 会收回来。
现在需求变了。图书馆的员工也要借书,而且规则不同:员工最多借 10 本,借期 30 天。这个差异看起来很小,却会立刻影响 LibraryService.borrow:它需要知道当前会员能借几天。
先不考虑 7.3 已有的 emails 和构造器,只看角色差异,最直接的写法是加一个布尔字段:
public final class Member {
private final String id;
private final String name;
private final boolean staff;
public Member(String id, String name, boolean staff) {
this.id = id;
this.name = name;
this.staff = staff;
}
public int loanDays() {
return staff ? 30 : 14;
}
public int borrowLimit() {
return staff ? 10 : 3;
}
}
它在只有两种角色时能工作,但问题会随着角色增加而放大:如果再来一个「学生会员借 5 本、借期 21 天」,就要再加一个布尔值;如果员工里又分「兼职」和「全职」,条件判断会散落在每个方法里。更麻烦的是,调用方无法从类型上看出「这是一个员工」,只能读字段或读文档。
继承提供了另一种表达:员工是一种会员,只是行为和额度不同。 这句话是否成立,取决于员工是否能在任何需要 Member 的地方替换普通会员而不破坏语义。如果答案是肯定的,继承就是合适的;如果只是几个方法碰巧一样,继承就会把两个本不相关的概念绑死。
extends 与 super:父类先构造
先把普通会员写成可被继承的类。字段仍然是 private final,子类通过方法读取,而不是直接碰字段:
package com.plume.library.domain;
import java.util.List;
import java.util.Objects;
public class Member {
private final String id;
private final String name;
private final List<String> emails;
public Member(String id, String name) {
this(id, name, List.of());
}
public Member(String id, String name, List<String> emails) {
this.id = Objects.requireNonNull(id, "id");
this.name = Objects.requireNonNull(name, "name");
this.emails = List.copyOf(emails);
}
public String id() {
return id;
}
public String name() {
return name;
}
public List<String> emails() {
return emails;
}
public int borrowLimit() {
return 3;
}
public int loanDays() {
return 14;
}
public String describe() {
return name + "(" + id + ")";
}
}
Member 保留了 7.3 的 emails,构造时用 List.copyOf 做防御性复制;为了让 StaffMember 能继承,本节暂时去掉了 7.3 的 final。构造器这里只做 requireNonNull,7.3 的 requireText 空串校验可以原样保留。本节只展开和借阅相关的方法,withEmail、contactLine 的写法见 7.3。
子类用 extends 声明继承关系,用 super(...) 调用父类构造器:
package com.plume.library.domain;
public final class StaffMember extends Member {
private final String department;
public StaffMember(String id, String name, String department) {
super(id, name);
if (department == null || department.isBlank()) {
throw new IllegalArgumentException("department 不能为空");
}
this.department = department;
}
public String department() {
return department;
}
@Override
public int borrowLimit() {
return 10;
}
@Override
public int loanDays() {
return 30;
}
@Override
public String describe() {
return super.describe() + " [" + department + "]";
}
}
这段代码里有三件事值得逐条看。
第一,super(id, name) 必须是子类构造器的第一条语句。Java 不允许先给子类字段赋值再调用父类构造器,因为父类的初始化必须先完成,子类对象才算完整。即使你不写 super(...),编译器也会插入一个无参的 super();父类没有无参构造器时,就会编译失败。
第二,父类的 private 字段子类看不见。StaffMember 不能写 this.name,只能通过 name() 或 super.describe() 间接使用。这不是限制,而是封装:父类可以改字段名、改存储方式,只要方法契约不变,子类不用跟着改。
第三,super.describe() 调用的是父类版本,不是递归。如果写成 describe(),就会调用子类自己,直接栈溢出。super 只能指向直接父类,没有 super.super 这种写法。
把两个对象放进同一个循环,按 Member 类型使用:
Member reader = new Member("M-001", "林一");
Member staff = new StaffMember("S-001", "周岚", "采编部");
for (Member member : new Member[]{reader, staff}) {
System.out.printf("%s limit=%d days=%d%n",
member.describe(), member.borrowLimit(), member.loanDays());
}
本机 JDK 25 输出:
林一(M-001) limit=3 days=14
周岚(S-001) [采编部] limit=10 days=30
注意变量类型都是 Member,但 borrowLimit()、loanDays()、describe() 都执行了子类版本。这个现象叫动态分派,8.3 会拆开讲它背后的方法表;本节先记住结论:方法看运行时类型,字段和静态方法看编译时类型。
覆写不是重载:签名必须对得上
子类重新定义父类方法叫覆写(override),它有一组硬性规则:
- 方法名和参数列表必须完全一致,否则只是重载,不是覆写。
- 返回类型可以协变,也就是可以返回父类返回类型的子类型。
- 访问权限不能收窄,父类是
public,子类不能改成protected或包私有。 - 不能覆写
final、static、private方法。static方法是隐藏,private方法对子类不可见。
@Override 不是必需的,但强烈建议写。它让编译器帮你检查「你以为在覆写,其实只是新写了一个方法」。把 loanDays 拼成 loanDay:
@Override
public int loanDay() {
return 7;
}
本机 javac 25.0.5 的报错是:
错误: 方法不会覆盖或实现超类型的方法
@Override
^
如果没有 @Override,这段代码会安静地编译通过,然后调用方继续拿到 14 天。这类错误不会在编译期暴露,只会在业务对账时出现,所以注解值得写。
还有一个常见误会:覆写发生在实例方法上,字段不会覆写。 如果父类和子类都声明了 String name,子类只是把父类字段藏起来,通过 Member 类型访问仍然拿到父类那份。8.3 会用字节码展示这个差异。
继承的边界:is-a 与可替换性
继承最容易被误用的地方,是把「我想复用这段代码」当成「它们是同一种东西」。判断标准不是代码像不像,而是子类对象能不能在父类出现的任何位置替换父类,而调用方不需要知道差异。这条原则通常叫里氏替换原则(Liskov Substitution Principle)。
StaffMember extends Member 能成立,是因为员工确实要借书、确实有 id 和姓名、确实遵守借阅规则,只是额度不同。调用方拿到 Member 后调用 loanDays(),无论实际是哪种会员都能得到合理结果。
反过来,如果需求是「给借阅算罚金」,不要写 class LoanWithFine extends Loan。罚金不是借阅的一种,它是对借阅的计算;继承会让 LoanWithFine 继承所有 Loan 的字段和方法,还会把「借阅」和「计费」两个变化方向绑在一起。用组合:
package com.plume.library.service;
import com.plume.library.domain.Loan;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public final class FineCalculator {
private final long dailyRateCents;
public FineCalculator(long dailyRateCents) {
if (dailyRateCents < 0) {
throw new IllegalArgumentException("dailyRateCents 不能为负");
}
this.dailyRateCents = dailyRateCents;
}
public long fineCents(Loan loan, LocalDate returnedOn) {
long overdueDays = Math.max(0, ChronoUnit.DAYS.between(loan.getDueOn(), returnedOn));
return overdueDays * dailyRateCents;
}
}
调用方持有一个 FineCalculator,而不是继承它:
FineCalculator calculator = new FineCalculator(50);
long fine = calculator.fineCents(loan, LocalDate.of(2026, 11, 15));
System.out.println("fineCents = " + fine);
本机 JDK 25 输出:
fineCents = 1000
这里的 1000 是分,也就是 10 元。金额用 long 存分,避免 double 的舍入误差;这条约定会贯穿整个 library 项目。
再看 Book。第 7 章把它写成了 final class,这不是随手加的:一本书的标识、标题、作者、出版年构成它的身份,电子书、有声书如果有不同字段,更适合各自建模或用组合持有 Book,而不是让 Ebook extends Book 去继承一堆用不上的字段。把类声明为 final,是在明确告诉调用方「这里没有继承点」。
在 library 里落地
现在把会员差异接进借阅流程。7.3 的借出状态是 onLoan + loans 两个集合;本节把它抽成 Loan 对象(isbn、memberId、borrowedOn、dueOn),由 8.2 的仓储保存。这里先假设 LibraryService 已经持有一个 LibraryRepository 字段;接口和实现是 8.2 的主题。LibraryService.borrow 只依赖 Member 的公开方法,不关心具体是哪种会员:
public Loan borrow(String isbn, Member member, LocalDate today) {
Book book = repository.findByIsbn(isbn)
.orElseThrow(() -> new IllegalArgumentException("没有这本书: " + isbn));
if (repository.findActiveLoanByIsbn(isbn).isPresent()) {
throw new IllegalStateException("这本书已被借出: " + book.title());
}
Loan loan = new Loan(isbn, member.id(), today, today.plusDays(member.loanDays()));
repository.saveLoan(loan);
return loan;
}
member.loanDays() 这一行就是多态的入口:传入普通会员得到 14,传入员工得到 30。LibraryService 不需要 if (member instanceof StaffMember),新增会员类型时也不用改这里。
本机跑一遍完整流程,输出是:
== 继承与动态分派 ==
林一(M-001) limit=3 days=14
周岚(S-001) [采编部] limit=10 days=30
== 仓储查询 ==
总数: 3
曹雪芹: 红楼梦 / 曹雪芹 (1791)
借出: 9787536692930 -> M-001, 2026-10-12 ~ 2026-10-26
重复借出: 这本书已被借出: 三体
逾期 6 天罚金(分): 300
这份输出同时验证了三件事:子类覆写生效、仓储能按作者查到书、借阅规则拒绝重复借出。仓储接口本身是 8.2 的主题,这里先把它当成一个已经存在的协作对象。
需要提前说明一个后续变化:第 9 章会把 Member 改成 record,而 record 不能被继承。到时 StaffMember 这个层次会被拆掉,员工身份改成 Member 里的一个 MemberType 字段;8.1 暂时去掉的 final 也会在 record 里重新回来。这不是说本节讲错了,而是说明「用继承表达角色」有代价:一旦角色只是数据差异,继承会把类型层次锁死。8.1 先用继承把差异讲清楚,9.1 再讨论为什么记录类型更适合承载这种数据。
常见错误与边界
忘记调用 super(...)。 父类只有带参构造器时,子类构造器必须显式调用:
public NoSuper(String id, String name) {
}
本机 javac 25.0.5 报:
错误: 对于Member(没有参数), 找不到合适的构造器
构造器 Member.Member(String,String)不适用
(实际参数列表和形式参数列表长度不同)
构造器 Member.Member(String,String,List<String>)不适用
(实际参数列表和形式参数列表长度不同)
在子类里直接访问父类私有字段。 下面这样写会编译失败:
public String raw() {
return name;
}
本机 javac 25.0.5 报:
错误: name 在 Member 中是 private 访问控制
正确做法是通过 name() 或 super.describe()。如果确实需要给子类访问,优先考虑 protected 方法而不是 protected 字段;字段一旦开放,子类就和父类的内部结构绑死了。
把重载当成覆写。 参数列表不同(比如 loanDays(int extra))不会覆写父类方法,只会新增一个重载。加上 @Override 能让编译器立刻指出问题。
在父类构造器里调用可覆盖方法。 父类构造器执行时,子类字段还没初始化;如果构造器调用了子类覆写的方法,方法看到的是默认值。这个坑 8.3 会用真实输出演示,现在先记住:构造器里只调用 final 或 private 方法。
小结
- 继承表达的是 is-a 和可替换性,不是「代码看起来能复用」。
super(...)必须是子类构造器的第一条语句;父类先初始化,子类字段后初始化。- 覆写要求方法名、参数列表一致,返回类型可协变,访问权限不能收窄;
@Override让编译器兜底。 - 字段没有多态,静态方法只是隐藏;只有实例方法参与动态分派。
- 角色只是数据差异时,继承会把类型层次锁死;第 9 章的 record 会证明这一点。
- 罚金计算这类「对对象做计算」的需求,用组合注入
FineCalculator,不要让Loan去继承它。
会员类型有了层次,但 LibraryService 还直接依赖具体存储。下一节把「存哪里」抽成接口,顺便讲清 default 方法和抽象类各自适合什么场景。
阅读导航:上一节:7.3 封装与不可变 · 下一节:8.2 接口、default 方法与抽象类 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。