qinghuan
← Java SE / 全部笔记

单元 22 / Java SE

P1 · 重点提升L1 · 基础

枚举与 record:写清状态和数据

用枚举限定可选状态,用 record 减少数据类的重复代码,再检查状态变化和内部可变对象的限制。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计带验证的 record 与稳定枚举映射,并识别可变组件的影响。

学习理由、准备要求与知识点

为什么学它

有限状态和值对象可以减少含义不明的数字,也能减少数据被随意修改的机会,让数据模型更清楚。

先读这些内容

知识点清单

  • 枚举独立类型
  • 稳定状态编码
  • ordinal 持久化风险
  • 状态转换合法性
  • record 组件访问器
  • 紧凑构造器验证
  • record 相等与哈希
  • 浅不可变与框架适配

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

订单状态只有 NEW、PAID、SHIPPED、CANCELLED 这些选项时,用枚举比随意填写整数或字符串更清楚。在正常的类型安全代码中,拼错的状态不能直接传进去。枚举也可以带字段和方法,但它只限制有哪些可选值,不会自动判断“未付款能否直接发货”;状态之间允许怎样变化,还要另写规则。

需要把几个相关数据放在一起时,可以考虑 record。例如 record Point(int x,int y) 会提供读取 x、y 的方法、构造器,以及按组件生成的 equals、hashCode 和 toString。Java17 已正式支持 record,不需要预览参数。记录类不能被继承,但能实现接口,也能声明静态成员和实例方法,适合表达以数据值为主的对象。

记录类的组件字段不可重新赋值,却不保证组件指向的对象也不可变。record Order(List<String> items) 若直接保存外部可变列表,列表内容仍可能变化。可以在紧凑构造器中校验并创建 List.copyOf 快照,但这仍是浅复制,元素如果可变还需另行设计。选 record 的依据是值建模,而不是看到任何 DTO 都机械替换。

文中这几个词,先认一认

枚举常量
枚举中提前列好的具名对象,例如订单状态 PAID,调用方从这些选项里选择。
规范构造器
参数与 record 的全部组件一一对应,用来创建记录对象的构造器。
紧凑构造器
record 的一种简写构造器,不重复写参数列表,适合在创建时统一校验和整理输入。
状态迁移
收到某个事件后,状态能否从当前值变成下一值,例如付款后由 NEW 变成 PAID。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

枚举有 name 与 ordinal 两种常见信息。name 是声明的常量名字,ordinal 是声明位置,从零开始;插入或重排常量会改变序号,因此不要把 ordinal 当作长期数据库或接口编码。对外协议需要稳定编码时显式定义 code 字段,并为新增、未知和历史值制定兼容策略。

record 自动生成的方法遵循组件语义。两个组件内容相等的记录可以作为值键,但数组组件仍按数组自身的相等规则,集合组件也可能因为外部修改影响哈希。紧凑构造器适合在对象公开前完成校验,如拒绝负金额和空标识;不能把后续业务状态变化藏在看起来不可变的数据载体里。

状态机把枚举值与合法迁移连接起来。输入事件先与当前状态组合,只有存在合法转移时才更新状态;没有转移就停止并报告失败位置。图中的教学订单只允许未付款取消,付款后不再直接取消,退款流程属于另一套业务规则。把这一边界写出来,才能避免读者把示例当作所有订单系统的通用流程。

正在加载流程图…

有限教学流程只允许图中的转移;退款、超时和重复消息处理需要额外契约。
查看流程图源码(Mermaid)
stateDiagram-v2
 state "未付款 NEW" as NEW
 state "已付款 PAID" as PAID
 state "已发货 SHIPPED" as SHIPPED
 state "已取消 CANCELLED" as CANCELLED
 [*] --> NEW
 NEW --> PAID: PAY
 NEW --> CANCELLED: CANCEL
 PAID --> SHIPPED: SHIP
 SHIPPED --> [*]
 CANCELLED --> [*]

按顺序看,每一步做了什么

  1. 列出有限状态和外部稳定编码。
  2. 定义每个事件允许的起止状态。
  3. 用 record 表达不可变结果快照。
  4. 在构造边界验证组件并决定复制深度。
  5. 测试未知值、终态和重复事件。

03对照实现代码

枚举名字查找为何不能用显示文本替代 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Enum.java · java.lang.Enum#valueOf(Class,String)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    private SourceModel() {}
    public static <E extends Enum<E>> E lookup(Class<E> type, String name) {
        if (type == null || name == null) throw new NullPointerException();
        // 教学模型线性扫描常量,真实 Enum.valueOf 使用名字目录。
        for (E value : type.getEnumConstants())
            if (value.name().equals(name)) return value;
        throw new IllegalArgumentException("unknown enum name");
    }
}

真实 Enum.valueOf 从枚举类的常量名字目录中查找精确名称,找不到时区分 null 名字与未知名字并抛对应异常。模型用 getEnumConstants 线性扫描,保留按 name 精确匹配的核心契约,省略缓存和内部目录接口,不把教学扫描的复杂度冒称真实实现。

toString 可以被枚举重写为友好显示文本,但 valueOf 使用的仍是声明名字。因此把中文显示标签直接传给 valueOf 不会自动匹配。大小写与空白也不自动规范化,输入层若希望接受其它写法,应显式映射并说明冲突策略,而不是依赖显示文本不变。

ordinal 是枚举常量的位置,name 是声明名字,valueOf 按名字查找,并不先转成位置。若把位置存进数据库,之后调整代码顺序就可能让历史数字对应错状态。可以另外定义稳定 code,把外部协议与内部名字分开,同时检查 code 不重复,并明确未知 code 怎么处理。

泛型边界 E extends Enum<E> 使返回值保持具体枚举类型,调用者不必接收普通 Object 后再猜类型。这里没有构造新的枚举实例,而是返回该枚举已定义的常量。枚举实例本身可以声明可变字段,因此也不应向全局共享常量随意写请求专属状态。

代码先调用谁,再调用谁

  1. Enum.valueOf 接收枚举 Class 与名称
  2. 读取枚举常量名字目录
  3. 按精确 name 返回已有常量
  4. 空或未知名称走明确异常路径

04动手写一个例子

审计订单事件序列并返回不可变快照

从 NEW 开始处理 PAY、SHIP、CANCEL。只允许 NEW→PAID、PAID→SHIPPED、NEW→CANCELLED;首个非法事件停止。返回最终合法状态、已接受事件数及错误位置,错误位置为零表示无错误。

输入 1
[PAY,SHIP]
输出 1
SHIPPED,accepted=2,failedAt=0
输入 2
[SHIP]
输出 2
NEW,accepted=0,failedAt=1
输入 3
[]
输出 3
NEW,accepted=0,failedAt=0

用 enum 表示状态与事件,使非法拼写在进入算法前就被解析层拒绝。迁移函数只负责一个当前状态与一个事件的结果,不依赖全局字段。返回 null 表示没有合法转移,这是私有辅助方法的明确约定;公开返回则使用 record,把多个结果字段作为一个快照交付。

主循环保持当前状态和已接受数量。先计算候选下一状态,确认合法后才更新当前状态与计数;失败时返回最后合法状态,而不是把错误事件产生的猜测状态写进去。错误位置使用一开始的序号,方便与输入记录对应;内部下标仍从零开始,两者转换必须一致。

重复 PAY 在本题属于非法迁移,不自动视为幂等成功。这个选择便于观察严格状态机,但生产消息系统可能需要按事件标识去重,不能直接把教学规则用于消息重试。测试同时覆盖正常终态、未付款取消、跳步和重复事件,验证状态更新前置检查的顺序。

为什么这样写是对的每轮开始时 state 是已接受前缀执行后的合法状态,accepted 恰好等于该前缀长度。

时间 O(k),k 为实际检查事件数量,最坏 O(n)。额外空间 O(1) 额外空间。

每个事件只做固定状态与事件分支,遇到首个错误立即停止,快照字段数量固定。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 44 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.List;
public class Main {
    enum State { NEW, PAID, SHIPPED, CANCELLED }
    enum Event { PAY, SHIP, CANCEL }
    record Snapshot(State state, int accepted, int failedAt) {}
    static State next(State state, Event event) {
        return switch (state) {
            case NEW -> switch (event) {
                case PAY -> State.PAID;
                case CANCEL -> State.CANCELLED;
                default -> null;
            };
            case PAID -> event == Event.SHIP ? State.SHIPPED : null;
            default -> null;
        };
    }
    static Snapshot audit(List<Event> events) {
        if (events == null) throw new IllegalArgumentException("events");
        State state = State.NEW;
        int accepted = 0;
        for (Event event : events) {
            if (event == null) throw new IllegalArgumentException("event");
            State candidate = next(state,event);
            // 先验证转移,失败快照保留最后合法状态。
            if (candidate == null) return new Snapshot(state,accepted,accepted+1);
            state = candidate;
            accepted++;
        }
        return new Snapshot(state,accepted,0); // 零错误位置明确表示全部接受。
    }
    static void check(Snapshot wanted, Event... events) {
        if (!wanted.equals(audit(List.of(events)))) throw new AssertionError(wanted);
    }
    public static void main(String[] args) {
        // 正常、空、取消、跳步和重复事件覆盖不同转移边界。
        check(new Snapshot(State.SHIPPED,2,0),Event.PAY,Event.SHIP);
        check(new Snapshot(State.NEW,0,0));
        check(new Snapshot(State.CANCELLED,1,0),Event.CANCEL);
        check(new Snapshot(State.NEW,0,1),Event.SHIP);
        check(new Snapshot(State.PAID,1,2),Event.PAY,Event.PAY);
        check(new Snapshot(State.CANCELLED,1,2),Event.CANCEL,Event.PAY);
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 空事件序列保留初始状态。
  • 终态后任何新事件都非法。
  • 重复事件不会自动去重。
  • 失败快照不包含非法转移。
进阶选读 · 可选深入:事件幂等与版本化状态机

场景相同事件可能重投,历史订单又使用旧版本流程。

做法保存事件标识与处理结果,以状态版本或条件更新防止并发覆盖;把迁移规则版本和历史回放策略一起设计。

复杂度单次迁移计算可为常数,持久化去重与历史存储随事件规模增长。

好处与代价严格拒绝重复与幂等重放语义不同,迁移升级也需要兼容历史状态。

05项目里怎么用

对外传输枚举应避免只依赖 Java 常量名字。新增状态后旧客户端可能无法反序列化,重命名也可能改变协议;可以使用稳定代码和明确未知值处理。未知不是必然等于默认状态,盲目降级成 NEW 可能触发错误业务动作,接口应保留未识别信息或明确拒绝。

record 适合值、结果和配置快照,但某些框架实体需要代理、无参构造器或可变生命周期,具体支持要按框架版本核对。不要把所有持久化对象一键改成 record。即使记录类可用于序列化,也要审查自动 toString 是否包含敏感组件,避免日志顺手暴露凭证。

状态变化涉及数据库和外部动作时,不能只在内存里先判断再赋值。两个请求可能同时看到同一旧状态,都去付款或发货,需要数据库条件更新、事务和重复请求处理来控制。纯状态变化规则可以单独测试,实际执行仍要保证读取、写入和相关动作相互对应。

容易踩的坑

  • 把 ordinal 当作长期持久化编码。
  • 把 toString 显示标签交给 valueOf。
  • 认为 record 自动深度不可变。
  • 把严格状态机当成已解决消息幂等与并发事务。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
枚举状态范围有限且编译器可辅助检查新增状态需要兼容策略稳定有限业务状态
record 值快照值语义清晰且样板少浅不可变且不适合所有框架生命周期结果、配置与不可变键
普通类可控制继承和可变行为需要维护更多样板与不变量有身份和复杂生命周期的对象

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么不能用 ordinal 保存状态?

展开参考答案与推导

它取决于声明顺序,插入或重排常量会改变历史数值含义。应使用明确稳定代码,并检查唯一性和未知值策略,避免代码整理意外变成数据迁移。

02record 的列表组件为什么还会变化?

展开参考答案与推导

组件字段保存的是引用且不可重新赋值,但原列表对象可能仍可变。构造时可做容器快照,元素可变性再另行处理,不能仅凭 record 关键字推断整个对象图被冻结。

03枚举是否自动提供合法迁移?

展开参考答案与推导

没有。它只限定可用状态值,NEW 和 SHIPPED 都合法不代表可以直接从前者到后者。迁移需要按当前状态与事件组合定义,并由执行层保证原子更新。

换个情况,还会处理吗

01显示标签改为中文后 valueOf 失败,为什么?

展开参考答案与推导

valueOf 查找声明 name,不读取重写的 toString 显示文本。为外部编码与显示标签分别建立映射,避免界面翻译或文案修改影响协议解析。

02重复付款事件被审计算法拒绝,生产能直接报错吗?

展开参考答案与推导

要先看消息重投契约。真实系统可能需要按事件 ID 返回已处理结果,严格状态机只是规则核心;幂等识别应在执行边界与持久化结果共同实现。

03record 自动 toString 很方便,能直接记录所有请求吗?

展开参考答案与推导

先检查组件是否包含密码、令牌或个人敏感数据。自动生成方法不会替业务隐藏这些字段,应使用经过筛选的日志表示,并按用途控制数据范围。

写代码试一试

01给快照增加失败事件信息

输入 / 条件
[PAY,CANCEL]
输出 / 预期
PAID 与 CANCEL

提示 使用可空或 Optional 的明确约定。

展开参考答案与推导

扩展 Snapshot,失败时保存当前非法事件,成功时使用约定的缺失表示。不要把上一个成功事件写进去;测试错误位置与事件必须对应同一输入项。

02增加紧凑构造器校验

输入 / 条件
accepted=-1
输出 / 预期
拒绝

提示 快照字段也有不变量。

展开参考答案与推导

在 Snapshot 紧凑构造器中拒绝负计数和负错误位置,并要求 state 非空。进一步约束错误位置与 accepted 的关系时,要覆盖成功与失败两种合法形式。

03验证终态后的首个错误

输入 / 条件
[PAY,SHIP,CANCEL,PAY]
输出 / 预期
SHIPPED,accepted=2,failedAt=3

提示 首错即返回。

展开参考答案与推导

加入对应快照测试,确认第四项不再执行。若需要收集全部错误,应另建恢复规则,不能在状态未知的情况下继续猜测后续迁移。

读完之后,独立完成

课后作业:自己做一遍

本篇共 9 道作业,先完成 7 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能设计带验证的 record 与稳定枚举映射,并识别可变组件的影响。

本篇重点加练

重点加练稳定状态编码与构造校验,保证值模型在持久化和版本变化时仍有明确边界。

  • 稳定状态编码
  • 紧凑构造器验证

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    订单状态准备持久化为外部编码,给出 CREATED、PAID、CANCELLED 三个枚举常量;设计稳定编码与未知编码政策,并评审按声明位置保存的演进风险。

    提交内容
    编码合同和新增/重排状态验收案例。
    怎样算完成
    • 外部格式独立于展示顺序
    • 未知值处理明确

    练到的知识点枚举独立类型 · 稳定状态编码 · ordinal 持久化风险

  2. 作业 02编程实践

    实现订单状态转换检查器,限定支付、取消和重复请求的规则;先画状态图,再用输入事件序列验证,不让调用方任意赋值状态。

    提交内容
    Java 程序、自写合法/非法/重复事件测试及运行证据。
    怎样算完成
    • 状态转换政策完整
    • 失败后状态有明确合同

    练到的知识点状态转换合法性

综合应用

  1. 作业 03代码推演

    定义 record Lesson(String title,int minutes),要求标题非空白且时长在 1 到 180。测试紧凑构造器中的校验与参数规范化,并核对访问器返回的实际组件。

    提交内容
    record 代码、自写构造边界测试和运行记录。
    怎样算完成
    • 构造前置条件与最终组件对应
    • 不绕过验证暴露非法状态

    练到的知识点record 组件访问器 · 紧凑构造器验证

  2. 作业 04排错实验

    分别创建含 String、数组和 List 组件的 record 样例,检查相等、哈希及外部修改的影响;每组至少有同值不同实例和共享组件两种输入。

    提交内容
    最小 Java 实验、自写比较测试与观察表。
    怎样算完成
    • 组件语义逐类核验
    • 浅不可变边界有证据

    练到的知识点record 相等与哈希 · 浅不可变与框架适配

  3. 作业 05面试场景

    旧客户端收到新增状态,序列化框架需要无参构造器,团队希望所有 DTO 全换为 record。评审状态兼容性与框架适配各自需通过的测试。

    提交内容
    兼容性矩阵与迁移条件。
    怎样算完成
    • 语言特性和框架能力分开
    • 旧数据旧客户端有验收

    练到的知识点稳定状态编码 · 状态转换合法性 · 浅不可变与框架适配

  4. 作业 06编程实践

    实现 record Enrollment,包含稳定课程状态编码与课程编号;紧凑构造器按合同验证输入,编码解析不接受未知值但需能定位错误。

    提交内容
    Java 实现、自写未知编码/空编号/边界测试与运行证据。
    怎样算完成
    • 外部编码政策显式
    • 构造完成后的状态均符合约束

    练到的知识点稳定状态编码 · 紧凑构造器验证

  5. 作业 07设计评审

    历史数据包含已停用状态与缺失字段,新模型要求更严格。规定导入前怎样校验、满足什么条件才能创建正式对象,并说明拒绝记录如何保存诊断证据。

    提交内容
    历史输入矩阵和对象创建流程。
    怎样算完成
    • 旧数据与新不变量关系明确
    • 非法记录不会被含糊修补

    练到的知识点稳定状态编码 · 紧凑构造器验证

进阶挑战 · 掌握前置后选做

  1. 作业 08设计评审

    完成泛型后:设计类型化任务结果模型,表达成功、业务拒绝和执行失败;评审枚举附带字段与独立值类型两种表示的非法组合风险。

    提交内容
    类型设计、构造合同和调用方使用示例。
    怎样算完成
    • 无效状态组合得到处理
    • 相等规则符合领域语义

    练到的知识点枚举独立类型 · 状态转换合法性 · 紧凑构造器验证 · record 相等与哈希

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 09思维拓展 · 条件变化

    状态编码由服务独享变成供外部长期保存的协议,未来还会增加状态。比较严格拒绝和保留未知编码两种策略,对构造不变量及调用方能力分别评估。

    提交内容
    版本演进样本与取舍表。
    怎样算完成
    • 兼容目标明确
    • 方案包含未知状态验收

    练到的知识点稳定状态编码 · 紧凑构造器验证

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗