大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
枚举与模式
这一课你将能做到
- 定义带字段、构造器与业务方法的 enum(如订单状态机),并用 switch 表达式写出强制穷尽的分支逻辑
- 用 EnumSet / EnumMap 高效存储枚举集合,说清它们比 HashSet/HashMap 快在哪
- 避开 ordinal 持久化、values() 性能、switch 漏分支、null 比较 四类高频坑
为什么要学
订单状态、支付渠道、课时发布状态……任何「取值有限」的字段,用字符串存就是运行时炸弹:拼写错误编译器不管,"paid" 与 "PAID" 混用靠人肉排查,脏数据直接写进 MySQL。enum 把这组值升级成类型——写错即编译报错,IDE 自动补全所有合法值,数据库里存的也是显式编码。
面试角度:枚举单例为什么线程安全、枚举比较用 == 还是 equals、EnumMap 原理,都是中高频题;「设计一下订单状态流转」更是业务设计题的常见开局。本课答案按能背的标准写。
先建立图像
String 状态: "paid" "PAID" "Paid" "payd" ← 编译器:都合法,祝你好运
enum 状态: OrderStatus.PAID ← 编译器:只此一个,写错即红
enum 本质是继承 java.lang.Enum 的 final 类,每个常量是它的一个静态单例实例:
// 编译器眼中,enum WAIT_PAY, PAID 大致等价于:
public final class OrderStatus extends Enum<OrderStatus> {
public static final OrderStatus WAIT_PAY = new OrderStatus("WAIT_PAY", 0);
public static final OrderStatus PAID = new OrderStatus("PAID", 1);
// name 与 ordinal 由父类字段持有
}
跟着做
步骤 1:定义带行为的订单状态 enum
新建 EnumDemo.java,完整可运行:
public class EnumDemo {
public enum OrderStatus {
WAIT_PAY(10, "待支付"),
PAID(20, "已支付"),
SHIPPED(30, "已发货"),
REFUNDED(40, "已退款");
private final int code; // 存数据库的显式编码
private final String desc; // 给前端展示的文案
OrderStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int code() { return code; }
public String desc() { return desc; }
/** 是否已付过钱:发货、退款都建立在已支付之上 */
public boolean paid() {
return this == PAID || this == SHIPPED || this == REFUNDED;
}
/** 由数据库编码反查枚举;查不到直接抛异常,绝不让脏数据溜过去 */
public static OrderStatus of(int code) {
for (OrderStatus s : values()) {
if (s.code == code) return s;
}
throw new IllegalArgumentException("未知订单状态编码: " + code);
}
}
public static void main(String[] args) {
OrderStatus s = OrderStatus.of(20);
System.out.println(s + " -> " + s.desc() + ", 已付款=" + s.paid());
System.out.println(s == OrderStatus.PAID); // true,枚举比较用 ==
System.out.println(s.equals(OrderStatus.PAID)); // true,但没必要
try {
OrderStatus.of(99);
} catch (IllegalArgumentException e) {
System.out.println("拦截脏数据: " + e.getMessage());
}
}
}
运行:java EnumDemo.java。
步骤 2:加入 switch 表达式,验证强制穷尽
在 EnumDemo 里加入以下方法,并把 main 换成调用它的版本:
static String nextAction(OrderStatus status) {
return switch (status) {
case WAIT_PAY -> "发送催付通知";
case PAID -> "通知仓库发货";
case SHIPPED -> "等待用户签收";
case REFUNDED -> "关闭工单";
};
}
public static void main(String[] args) {
for (OrderStatus s : OrderStatus.values()) {
System.out.println(s + " -> " + nextAction(s));
}
}
然后注释掉 case SHIPPED 一行再编译——直接失败,提示 switch 表达式未覆盖所有输入值。这就是「漏分支在编译期暴露」,if-else 链永远做不到。
步骤 3:EnumSet 与 EnumMap
再在 main 前面加上 import 与演示代码(import 放文件头部):
import java.util.EnumMap;
import java.util.EnumSet;
EnumSet<OrderStatus> visible = EnumSet.of(OrderStatus.WAIT_PAY, OrderStatus.PAID);
EnumMap<OrderStatus, Integer> count = new EnumMap<>(OrderStatus.class);
count.put(OrderStatus.PAID, 3);
System.out.println(visible.contains(OrderStatus.PAID)); // true
System.out.println(count); // {PAID=3}
步骤 4(选做):JUnit 验证状态机
把 OrderStatus 提为独立文件 OrderStatus.java(去掉 public enum 前的缩进层级不变),测试类放同包下即可运行 mvn test:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderStatusTest {
@Test
void ofShouldRejectUnknownCode() {
assertThrows(IllegalArgumentException.class, () -> OrderStatus.of(99));
}
@Test
void refundedShouldCountAsPaid() {
assertTrue(OrderStatus.REFUNDED.paid());
}
@Test
void switchShouldCoverAllValues() {
for (OrderStatus s : OrderStatus.values()) {
assertNotNull(EnumDemo.nextAction(s));
}
}
}
讲清楚要点
- 每个枚举常量是单例:JVM 类加载时创建且全局唯一,
==比较既正确又比 equals 快,还是天然的 NPE 防护(null == 常量 直接 false)。枚举是少数鼓励用==的场景。 - 构造器天然私有:enum 构造器只能是 private(不写修饰符也是),外部无法
new OrderStatus(...),取值集合封闭可控。 - switch 表达式三要素:箭头语法无 fall-through(不需要 break);对枚举必须穷尽所有常量(或写 default);它有返回值,可直接赋给变量。
- 别把 ordinal 存进数据库:ordinal 跟着声明顺序走,中间插入一个新常量,历史数据全部错位。持久化用显式
code字段,反查用of(code)工厂。 - EnumSet / EnumMap 是枚举专用高性能容器:EnumSet 底层是位向量(long 数组按 ordinal 置位),EnumMap 底层是按 ordinal 直接下标的数组——没有哈希计算与冲突处理,且元素按声明顺序排列。
- 值集封闭才用 enum:会动态增长的集合(用户自定义标签、运营可配置分类)用字典表 + String + 校验,不要用 enum 硬扛。
你可能会懵的地方
- enum 能继承别的类吗?
不能,它已隐式继承
java.lang.Enum;但可以实现接口。进阶玩法是「常量级多态」:enum 实现抽象方法,每个常量提供自己的实现。 - values() 每次调用有什么代价?
内部数组会被 clone 后返回新数组,高频调用产生分配压力;循环外缓存结果,或用
EnumSet.allOf(...)替代。 - 为什么枚举能做线程安全的单例?
实例在类加载时由 JVM 创建且只创建一次;反射调用枚举构造器会被 JVM 直接抛
IllegalArgumentException;序列化由 JVM 特殊处理,反序列化不会生成新实例。防反射、防序列化破坏,写法还最短。 - 数据库里的编码和枚举怎么双向映射?
显式 code 字段 + 静态
of(code)工厂;Spring 项目里用 JPA 的@Enumerated(EnumType.STRING)或 MyBatis 的 TypeHandler,思想相同:用名字或显式编码,绝不用 ordinal。 - 一个状态可以同时是两个值吗? 单个字段不行——那是状态机设计错误。一个订单既有「已支付」又有「已开票」这类正交维度时,拆成两个独立字段或两个 enum,不要往一个枚举里塞组合态。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
全链路传 "PAID" 字符串 | 拼写错误编译期无感知 | 业务层传 enum,序列化边界再转字符串 |
| 把 ordinal 存库或当对外编码 | 新增常量导致历史数据错位 | 定义显式 code 字段 + of() 反查 |
| switch 用旧语句式且漏 break | fall-through 静默串分支 | 箭头语法 switch 表达式 |
| 对可能为 null 的枚举调 equals | NPE | 用 == 比较 |
| 运营要加标签就改 enum | 每次改代码重发版 | 字典表 + String + 校验 |
卡点急救
- 编译报「the switch expression does not cover all possible input values」:枚举分支没写全。补齐所有常量;确实允许「未知值」时加
default,但要评估是否应该把未知值挡在入口。 of(code)返回 null 后到处 NPE:工厂方法查不到就抛IllegalArgumentException,不要返回 null 把炸弹往后传。- 枚举字段在运行时被改:字段一律
private final,只提供访问器;发现需要可变状态,说明这个概念不该建模成 enum。
面试常问
- 为什么说枚举单例是最佳实践? 可背版:① 类加载机制保证实例唯一且线程安全,无需 double-checked locking;② JVM 拦截反射创建枚举实例;③ 序列化机制对枚举特殊处理,反序列化不产生新实例。三条正好封死单例被破坏的常见路径。
- EnumMap 为什么比 HashMap 快? 内部按 ordinal 作为数组下标直接定位,无哈希函数计算、无冲突链表/红黑树,空间连续对 CPU 缓存友好;代价是只能装同一种枚举的键。
- switch 表达式与 switch 语句的区别? 表达式有值、可赋值;箭头语法无 fall-through;对枚举强制穷尽。语句只执行分支不产值、易漏 break。新代码一律用表达式,老代码重构优先级也高。
自测三问
- 为什么枚举字段要 private final?构造器为什么不写 private 也禁止外部调用?
- 订单状态编码存进数据库,用 ordinal 有什么风险?正确做法是什么?
EnumSet.of(A, B).contains(x)的底层复杂度是多少?为什么?
小结一句话
enum 把「有限取值」升级成编译期可检查的类型,配合 switch 表达式的强制穷尽,业务状态机从此不靠人肉谨慎。