本节目标:能用枚举替掉散落的字符串状态,能给枚举加字段和方法,能用穷尽的
switch表达式处理状态迁移,并知道ordinal()、valueOf()在持久化和输入解析上的边界。
适用版本:JDK 25 LTS(Temurin 25.0.5);枚举与 switch 表达式在 JDK 21 上行为一致。
字符串状态的问题
9.1 的 Loan 只有借出日期和应还日期,没有「现在是什么状态」。调用方只能靠日期推断:today.isAfter(dueOn) 就是逾期,否则就是在借。这个推断在「已归还」的借阅上会出错——一本 11 月 1 日归还的书,到了 11 月 20 日仍然会被算成逾期。
加一个状态字段就能解决。最省事的写法是 String status:
loan.setStatus("RETURNED");
它能跑,但问题很多:
- 拼错
"RETURND"编译器不会报错,运行到业务分支才暴露。 - 调用方可以传任意字符串,
"returned"、"已归还"、null都能进来。 - 状态集合无法从类型上看出有几个,IDE 也给不出补全。
switch无法穷尽检查,新增状态时容易漏分支。
枚举(enum)把「有限个取值」变成类型:取值只有声明过的那几个,拼错是编译错误,switch 可以检查穷尽性。它不只是「一组常量」,而是一个可以带字段、方法、构造器的类。
枚举是类,不是常量表
把借阅状态写成枚举:
package com.plume.library.domain;
public enum LoanStatus {
BORROWED("借出中", true),
RENEWED("已续借", true),
RETURNED("已归还", false);
private final String label;
private final boolean open;
LoanStatus(String label, boolean open) {
this.label = label;
this.open = open;
}
public String label() {
return label;
}
public boolean isOpen() {
return open;
}
}
几个语法点:
- 常量列表必须放在最前面,后面才能声明字段、构造器和方法。
- 枚举构造器默认是
private的,不能写public;你也不能在别处new LoanStatus(...)。 - 每个常量都是
LoanStatus的一个实例,BORROWED、RENEWED、RETURNED是public static final字段。 - 枚举可以实现接口,但不能继承类(它已经隐式继承
java.lang.Enum)。
把三个常量打印出来:
for (LoanStatus status : LoanStatus.values()) {
System.out.printf("%s ordinal=%d label=%s open=%s%n",
status.name(), status.ordinal(), status.label(), status.isOpen());
}
本机 JDK 25 输出:
BORROWED ordinal=0 label=借出中 open=true
RENEWED ordinal=1 label=已续借 open=true
RETURNED ordinal=2 label=已归还 open=false
values() 按声明顺序返回数组,name() 返回常量名,ordinal() 返回声明位置(从 0 开始)。label() 和 isOpen() 是我们自己加的方法,用来承载展示名和业务判断。这样调用方不需要写 status == BORROWED || status == RENEWED,直接 status.isOpen() 即可。
用 switch 把状态迁移写穷尽
枚举最实用的地方是和 switch 表达式配合。JDK 21 起,枚举的 switch 表达式不写 default 也能通过穷尽检查;漏掉一个常量会编译失败。
for (LoanStatus status : LoanStatus.values()) {
String text = switch (status) {
case BORROWED -> "可以续借";
case RENEWED -> "已续借,等待归还";
case RETURNED -> "已结束";
};
System.out.println(status + ": " + text);
}
本机 JDK 25 输出:
BORROWED: 可以续借
RENEWED: 已续借,等待归还
RETURNED: 已结束
如果漏写 RETURNED:
return switch (status) {
case BORROWED -> "borrowed";
case RENEWED -> "renewed";
};
本机 javac 25.0.5 报:
错误: switch 表达式不包含所有可能的输入值
return switch (status) {
^
这正是枚举比字符串强的地方:新增一个状态,所有没处理它的 switch 会在编译期报错。 字符串做不到这一点,default 分支还会把新状态悄悄吞掉。所以处理枚举时,优先不写 default,让编译器帮你找全所有分支;只有确实要兜底未知值时,才显式写 default 并记录原因。
把状态迁移放进领域对象
状态不是孤立的字段,它和「借出、续借、归还」三个动作绑在一起。把这些规则放进 Loan record,调用方就不能绕过规则直接改状态:
package com.plume.library.domain;
import java.time.LocalDate;
import java.util.Objects;
public record Loan(String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn,
LoanStatus status,
LocalDate returnedOn) {
public Loan {
isbn = Objects.requireNonNull(isbn, "isbn");
memberId = Objects.requireNonNull(memberId, "memberId");
borrowedOn = Objects.requireNonNull(borrowedOn, "borrowedOn");
dueOn = Objects.requireNonNull(dueOn, "dueOn");
status = Objects.requireNonNull(status, "status");
if (!dueOn.isAfter(borrowedOn)) {
throw new IllegalArgumentException("dueOn 必须晚于 borrowedOn");
}
if (status == LoanStatus.RETURNED) {
if (returnedOn == null) {
throw new IllegalArgumentException("已归还的借阅必须有 returnedOn");
}
if (returnedOn.isBefore(borrowedOn)) {
throw new IllegalArgumentException("returnedOn 不能早于 borrowedOn");
}
} else if (returnedOn != null) {
throw new IllegalArgumentException("未结束的借阅不能有 returnedOn");
}
}
public static Loan borrow(String isbn, String memberId, LocalDate today, int loanDays) {
return new Loan(isbn, memberId, today, today.plusDays(loanDays), LoanStatus.BORROWED, null);
}
public Loan renew(int extraDays) {
if (status != LoanStatus.BORROWED) {
throw new IllegalStateException("只有 BORROWED 状态可以续借,当前是 " + status);
}
return new Loan(isbn, memberId, borrowedOn, dueOn.plusDays(extraDays), LoanStatus.RENEWED, null);
}
public Loan markReturned(LocalDate returnedOn) {
if (!status.isOpen()) {
throw new IllegalStateException("借阅已经结束: " + status);
}
return new Loan(isbn, memberId, borrowedOn, dueOn, LoanStatus.RETURNED, returnedOn);
}
public boolean isOverdue(LocalDate today) {
return status.isOpen() && today.isAfter(dueOn);
}
}
这段代码把三个不变量写进了紧凑构造器:
RETURNED必须有returnedOn,且不能早于借出日期。- 未结束的借阅不能有
returnedOn。 dueOn必须晚于borrowedOn。
renew 只允许从 BORROWED 出发,续借一次后变成 RENEWED;markReturned 只允许在 isOpen() 为真时调用。状态迁移因此只有三条合法路径:BORROWED -> RENEWED、BORROWED -> RETURNED、RENEWED -> RETURNED。
服务层只负责编排:
public Loan renew(String isbn, LocalDate today) {
Loan loan = repository.findActiveLoanByIsbn(isbn)
.orElseThrow(() -> new IllegalStateException("这本书没有被借出: " + isbn));
if (loan.isOverdue(today)) {
throw new IllegalStateException("逾期借阅不能续借: " + isbn);
}
Loan renewed = loan.renew(14);
repository.saveLoan(renewed);
return renewed;
}
public ReturnReceipt returnBook(String isbn, LocalDate returnedOn) {
Loan loan = repository.findActiveLoanByIsbn(isbn)
.orElseThrow(() -> new IllegalStateException("这本书没有被借出: " + isbn));
Loan returned = loan.markReturned(returnedOn);
repository.saveLoan(returned);
return new ReturnReceipt(returned, finePolicy.fineCents(loan, returnedOn));
}
本机跑完整的借出、续借、重复续借、归还:
借出: BORROWED due=2026-10-26
续借: RENEWED due=2026-11-09
再续借:
只有 BORROWED 状态可以续借,当前是 RENEWED
归还: RETURNED returnedOn=2026-11-15 fineCents=300
注意 saveLoan 的语义也变了:第 8 章的 removeLoan 是「归还时删除借阅」,现在归还是一次状态迁移,借阅记录要保留。仓储从「删除」改成「按 ISBN + 借出日期 upsert」,findActiveLoanByIsbn 只返回 status.isOpen() 的记录。这个变化会在第 10 章的 Map 实现里继续沿用。
valueOf、ordinal 与持久化
从用户输入解析枚举用 valueOf:
LoanStatus status = LoanStatus.valueOf("RENEWED");
System.out.println("valueOf: " + status);
System.out.println(LoanStatus.BORROWED == LoanStatus.valueOf("BORROWED"));
本机 JDK 25 输出:
valueOf: RENEWED
true
valueOf 是大小写敏感的,传 "renewed" 会抛异常;传不存在的名字:
本机 JDK 25 输出:
valueOf 失败: No enum constant com.plume.library.domain.LoanStatus.LOST
CLI 解析用户输入时,要么先 trim().toUpperCase(Locale.ROOT) 再 valueOf,要么用 try/catch 把 IllegalArgumentException 转成用户能读的错误。不要把 valueOf 的原始异常直接打到终端,它会把完整类名暴露给用户。
两个不要踩的坑:
- 不要把
ordinal()存进数据库或文件。ordinal是声明顺序,今天RENEWED是 1,明天在它前面插一个LOST,所有历史数据的含义就变了。持久化用name(),或者显式给每个常量一个稳定的code字段。 - 不要用
toString()做持久化。 我们没覆写toString,默认返回name();一旦为了展示覆写成「已续借」,持久化格式就跟着变。展示用label(),持久化用name()或code。
枚举还有一个性能上的小优势:EnumSet 和 EnumMap 是专门为枚举设计的集合,内部用位向量或数组,比 HashSet/HashMap 更紧凑。第 10 章讲集合时会用到:
EnumSet<LoanStatus> openStatuses = EnumSet.of(LoanStatus.BORROWED, LoanStatus.RENEWED);
Map<LoanStatus, Long> counts = new EnumMap<>(LoanStatus.class);
openStatuses.contains(status) 可以直接替代 status.isOpen(),但把「哪些状态算开放」集中到枚举方法里,语义更清楚,也不容易漏。
常见错误与边界
枚举常量之间用 == 还是 equals。 两者都可以,== 更直接。枚举常量在 JVM 里是单例,LoanStatus.BORROWED == LoanStatus.valueOf("BORROWED") 为 true;equals 默认也是身份比较。不要用 name().equals(...) 绕一圈。
在枚举里放可变静态状态。 枚举常量是全局单例,给它加一个可变的 static List 或计数器,会在整个进程里共享,测试之间互相污染。枚举适合放不可变配置,不适合放运行时状态。
switch 里写 default 吞掉新状态。 处理枚举时省略 default,让编译器检查穷尽;只有解析外部输入、确实要兜底时才写 default,并在分支里记录日志或抛异常。
用 ordinal 做数组下标。 除非数组就是按枚举声明顺序构建的,否则一旦调整常量顺序就会错位。用 EnumMap 替代「枚举 -> 值」的数组。
小结
- 枚举是类型,不是字符串常量表;拼错、传非法值都会在编译期或解析时被拦住。
- 枚举可以有字段、构造器和方法,用来承载展示名、业务判断等不可变数据。
switch表达式对枚举做穷尽检查,新增常量会让漏掉的分支编译失败。- 状态迁移应该放进
Loan这样的领域对象,用renew、markReturned保证只有合法路径。 valueOf大小写敏感且会抛IllegalArgumentException,CLI 要先规范化再解析。- 持久化用
name()或显式code,不要用ordinal()或展示用的toString()。
状态和迁移有了类型,但 CLI 还在用字符串命令和 if/else 分发。下一节用 sealed 接口把命令集合封闭起来,再用模式匹配的 switch 一次性处理所有分支,漏一个都编译不过。
阅读导航:上一节:9.1 record:数据载体的最短写法 · 下一节:9.3 sealed 与模式匹配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。