《Java 编程入门》9.2 enum:把常量写成类型

把「BORROWED」「RETURNED」这类字符串常量换成 LoanStatus 枚举:用字段和方法承载展示名与开放状态,用 switch 表达式把状态迁移写穷尽,把续借、归还规则放进 Loan record,并用 ordinal、valueOf 与持久化的真实边界说明枚举该怎么用、不该怎么用。

本节目标:能用枚举替掉散落的字符串状态,能给枚举加字段和方法,能用穷尽的 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 与模式匹配 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发