Skip to content

枚举 ​

S1-010必学入门约 45 分钟JDK 21编程语言基础 · 面向对象

1. 本节定位 ​

枚举解决的问题很具体:当一个值的取值只有有限的几种时,如何保证不会传错。

它常被当成"几个常量的集合"而低估——实际上枚举可以带字段、带方法、实现接口,是 Java 里"用类型表达业务规则"最顺手的工具。

2. 学习目标 ​

完成本节后,你应该能够:

  • 写出带字段与方法的枚举,并把业务规则放进去;
  • 正确使用 values()、valueOf()、name()、ordinal();
  • 解释为什么枚举常量可以直接用 == 比较;
  • 用 switch 表达式处理枚举,并知道编译器会帮你检查遗漏;
  • 用 EnumMap 做以枚举为键的统计;
  • 说出"用 int 常量代替枚举"的两个具体风险。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看没有枚举时的写法:

java
public class OrderStatus {
    public static final int CREATED = 0;
    public static final int PAID = 1;
    public static final int SHIPPED = 2;
}

void updateStatus(int status) { ... }

updateStatus(99);      // 编译通过!运行时才发现"未知状态"
updateStatus(PAID);    // 看起来对,但打印出来是 1,日志里看不懂

两个问题很致命:

  1. 类型不安全:int 能表示 21 亿个值,其中绝大多数是非法状态,编译器帮不上忙;
  2. 没有语义:日志里只有 1,排查问题时得回去翻常量定义。

枚举的第一行就把这两个问题一起解决:

java
public enum OrderStatus { CREATED, PAID, SHIPPED }

5. 核心概念 ​

5.1 最简单的枚举 ​

java
public enum OrderStatus {
    CREATED, PAID, SHIPPED, COMPLETED, CANCELED
}

枚举常量实际上是 public static final 的单例对象——这一点决定了后面所有特性。

5.2 带字段与方法的枚举 ​

java
public enum OrderStatus {
    CREATED("已创建", true),
    PAID("已支付", true),
    SHIPPED("已发货", false);

    private final String label;
    private final boolean cancelable;

    OrderStatus(String label, boolean cancelable) {     // 构造方法只能是 private
        this.label = label;
        this.cancelable = cancelable;
    }

    public String label() { return label; }
    public boolean cancelable() { return cancelable; }
}

关键点:

  • 常量列表必须在最前面,末尾用分号;
  • 构造方法只能是 private(写不写都是 private),因为常量是固定的那几个,外部不能 new;
  • 给常量加数据,就是把"业务属性"和"状态"绑在一起,避免散落在别处的 Map 里。

5.3 常用方法 ​

方法作用注意
values()返回所有常量的数组每次调用都会新建数组(有轻微开销)
valueOf("PAID")按名称还原名称不存在抛 IllegalArgumentException
name()返回常量名与 toString() 的区别:name() 不可重写
ordinal()返回序号(从 0 开始)不要用于持久化,见下文

ordinal 的陷阱

ordinal() 是常量在声明列表中的位置。一旦有人调整了声明顺序,或者插入了新常量,所有历史数据里的序号就全错了。

需要持久化时,用 name() 或自定义的业务编码,不要用 ordinal()。

5.4 枚举的比较与 switch ​

java
OrderStatus status = OrderStatus.PAID;

status == OrderStatus.PAID;              // 推荐:枚举是单例,== 安全且更快
status.equals(OrderStatus.PAID);         // 也行,但没必要

String hint = switch (status) {           // switch 表达式 + 枚举
    case CREATED -> "等待支付";
    case PAID -> "等待发货";
    case SHIPPED -> "等待收货";
    case COMPLETED, CANCELED -> "流程结束";
};

switch 与枚举配合时,编译器会检查是否覆盖了全部常量——如果新增一个枚举常量却忘了在 switch 里处理,编译就会失败(表达式形式没有任何 default 时)。这是一道非常有价值的编译期防线。

5.5 EnumMap 与 EnumSet ​

它们是专为枚举设计的高效容器:

java
Map<OrderStatus, Integer> counter = new EnumMap<>(OrderStatus.class);
counter.merge(OrderStatus.PAID, 1, Integer::sum);

优势:内部用数组实现,比 HashMap 更快更省内存,且遍历顺序就是枚举的声明顺序(可预测)。

EnumSet 同理,适合表达"一组权限"这类场景:EnumSet.of(READ, WRITE)。

5.6 枚举可以实现接口 ​

java
public interface Describable {
    String describe();
}

public enum OrderStatus implements Describable {
    CREATED("已创建") {                     // 每个常量可以有自己的实现
        @Override
        public String describe() { return "订单刚创建,等待支付"; }
    },
    PAID("已支付") {
        @Override
        public String describe() { return "已收到款项"; }
    };

    // ...
}

这种"每个常量一个实现"的写法叫常量特定方法体,适合规则差异较大的场景。但要注意:常量多时代码会变得很长,多数情况下带字段 + switch 更易读。

6. 递进示例 ​

6.1 最小示例 ​

java
enum Season { SPRING, SUMMER, AUTUMN, WINTER }

for (Season season : Season.values()) {
    System.out.println(season.name() + " / " + season.ordinal());
}

6.2 业务示例:状态机 ​

java
public boolean canTransitionTo(OrderStatus next) {
    return switch (this) {
        case CREATED -> next == PAID || next == CANCELED;
        case PAID -> next == SHIPPED || next == CANCELED;
        case SHIPPED -> next == COMPLETED;
        case COMPLETED, CANCELED -> false;
    };
}

把"允许的流转"写进枚举,好处是规则只有一处:任何地方要判断状态能否变更,都调这个方法,而不是各写一套 if。

6.3 反例:用字符串表示状态 ​

java
if ("PAID".equals(status)) { ... }        // 拼写错误编译器不管
if ("paid".equals(status)) { ... }        // 大小写不一致,逻辑走不进去
order.setStatus("已支付");                 // 中文、英文混着来,没人拦得住

枚举的写法:

java
if (status == OrderStatus.PAID) { ... }   // 拼错编译不过
order.setStatus(OrderStatus.PAID);        // 只能传合法值

判断标准:如果一个字段的取值是"人定的、有限的几个"(状态、类型、级别、渠道),就该用枚举。

7. 完整可运行代码 ​

文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/OrderStatus.java 与 EnumLesson.java

java
package cn.kb.learn.oop;

import java.util.Arrays;

public enum OrderStatus {
    CREATED("已创建", true),
    PAID("已支付", true),
    SHIPPED("已发货", false),
    COMPLETED("已完成", false),
    CANCELED("已取消", false);

    private final String label;
    private final boolean cancelable;

    OrderStatus(String label, boolean cancelable) {
        this.label = label;
        this.cancelable = cancelable;
    }

    public String label() {
        return label;
    }

    public boolean cancelable() {
        return cancelable;
    }

    /** 允许的状态流转 */
    public boolean canTransitionTo(OrderStatus next) {
        return switch (this) {
            case CREATED -> next == PAID || next == CANCELED;
            case PAID -> next == SHIPPED || next == CANCELED;
            case SHIPPED -> next == COMPLETED;
            case COMPLETED, CANCELED -> false;
        };
    }

    public static OrderStatus fromLabel(String label) {
        return Arrays.stream(values())
                .filter(status -> status.label.equals(label))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("未知状态: " + label));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.EnumLesson

预期输出:

text
全部状态: [CREATED, PAID, SHIPPED, COMPLETED, CANCELED]
name = PAID,ordinal = 1
是同一常量: true
中文名: 已支付
是否可取消: true
valueOf: SHIPPED
按中文名查找: SHIPPED
CREATED → PAID: true
COMPLETED → PAID: false
状态统计: {CREATED=1, PAID=2, SHIPPED=1, CANCELED=1}
已创建 的下一步: 等待支付
已发货 的下一步: 等待收货
已取消 的下一步: 流程结束

注意倒数第 4 行:EnumMap 的输出顺序是 CREATED → PAID → SHIPPED → CANCELED,与枚举声明顺序一致——这正是 EnumMap 相比 HashMap 的额外好处:输出可预测。

8. Debug 观察点 ​

  1. 在 paid == OrderStatus.PAID 处断点,把 paid 与 OrderStatus.PAID 同时加入 Watch,对比两者的 id 是否相同——这就是 == 能用的原因(单例);
  2. 在 OrderStatus.fromLabel("已发货") 处按 F7 进入,观察 Arrays.stream(values()) 遍历的过程,看 filter 在哪个常量上匹配成功;
  3. 给 switch 增删一个 case,观察编译器的反应(删掉 CANCELED 分支会直接编译失败);
  4. 在 OrderStatus.values() 处看返回的数组长度,理解枚举常量在内存里就是几个单例对象。

9. 常见错误 ​

错误 1:用 ordinal() 持久化 ​

java
int code = status.ordinal();          // 存进数据库
// 后来有人在 CREATED 后面插入了一个新状态
// 所有历史数据的含义全变了

修复:持久化用 name(),或给枚举加一个显式的业务编码字段(如 code)。

错误 2:valueOf 传入不存在的名称 ​

java
OrderStatus.valueOf("UNKNOWN");       // IllegalArgumentException: No enum constant

修复:需要容错时自己写一个 fromLabel / fromName,返回 Optional 或抛带业务说明的异常(第 7 节的 fromLabel 就是模板)。

错误 3:忘了常量列表后的分号 ​

java
public enum Status {
    A, B                    // 缺少分号
    private final String x; // 编译错误
}

规则:只要枚举里还有别的东西(字段、方法),常量列表后必须加分号。

错误 4:用 equals 比较枚举与字符串 ​

java
if (status.equals("PAID")) { }        // 永远 false

类型不同,直接不相等。正确写法是 status == OrderStatus.PAID,或用 status.name().equals("PAID")(但不推荐用字符串驱动业务逻辑)。

10. 动手实验 ​

目标:把一段"用字符串表示状态"的代码改成枚举驱动。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 写一个 TicketStatus 枚举(待处理、处理中、已解决、已关闭),带中文名与"是否可编辑"两个属性;
  3. 实现 canTransitionTo 表示允许的流转:待处理 → 处理中/已关闭,处理中 → 已解决/待处理,已解决 → 已关闭,已关闭 → 无;
  4. 用 EnumMap 统计一组工单的状态分布;
  5. 给 switch 表达式加一个新状态,删除一个 case 分支,观察编译错误并解释原因。

验收方式:能解释"为什么 switch 表达式对枚举会检查覆盖完整性"。

扩展任务:给 TicketStatus 加一个 fromLabel 方法(参考第 7 节),并处理"标签不存在"的情况——你选择抛异常还是返回 Optional?说明理由。

11. 练习题 ​

基础题

  1. 判断:枚举常量之间用 == 比较是安全的。(对 / 错)
  2. 填空:枚举的构造方法只能是 ______,常量列表必须在最前面并以 ______ 结束(如果有其他成员)。
  3. 简答:为什么 ordinal() 不适合持久化到数据库?

代码阅读题

java
enum Level {
    LOW(1), MID(5), HIGH(9);

    private final int weight;

    Level(int weight) {
        this.weight = weight;
    }

    int weight() {
        return weight;
    }
}

System.out.println(Level.MID.weight());
System.out.println(Level.values()[2]);
System.out.println(Level.valueOf("LOW").ordinal());
  1. 三行输出分别是什么?

编程题

  1. 写一个 HttpMethod 枚举(GET、POST、PUT、DELETE),带两个属性:是否需要请求体、是否幂等。再写一个方法 String describe(HttpMethod method),用 switch 表达式返回描述。

综合任务

  1. 设计一个"审批流程":
    • 枚举 ApprovalStatus:草稿、待审批、已通过、已驳回,每个状态带中文名;
    • canTransitionTo 表示合法流转:草稿 → 待审批,待审批 → 已通过/已驳回,已驳回 → 草稿,已通过 → 无;
    • 加一个 isFinal() 方法判断是否为终态;
    • 用 EnumMap 统计一批单据的状态分布;
    • 写一个 main 演示:合法流转成功、非法流转被拒绝。
分级提示

提示 1(方向):第 4 题注意 values()[2] 是第三个常量。

提示 2(关键点):第 5 题的"是否幂等"可以按 HTTP 规范:GET、PUT、DELETE 幂等,POST 不幂等。

提示 3(接近答案):第 6 题的终态可以用 switch 表达式实现:已通过 与 已驳回 返回 true(驳回后还能回到草稿,所以它不是终态——请你判断)。

12. 参考答案 ​

基础题
  1. 对。枚举常量是单例对象,== 比较的是同一个引用,安全且比 equals 更快。
  2. private、分号(;)。
  3. 因为 ordinal() 是常量在声明列表中的位置。只要有人调整顺序或插入新常量,历史数据的含义就会整体错位。持久化应该用 name() 或自定义的业务编码。
代码阅读题(第 4 题)
text
5          ← Level.MID.weight()
HIGH       ← Level.values()[2](第三个常量,转字符串用 name())
0          ← Level.valueOf("LOW").ordinal()
编程题(第 5 题)
java
package cn.kb.learn.oop;

public enum HttpMethod {
    GET(false, true),
    POST(true, false),
    PUT(true, true),
    DELETE(false, true);

    private final boolean hasBody;
    private final boolean idempotent;

    HttpMethod(boolean hasBody, boolean idempotent) {
        this.hasBody = hasBody;
        this.idempotent = idempotent;
    }

    public boolean hasBody() {
        return hasBody;
    }

    public boolean idempotent() {
        return idempotent;
    }

    public static String describe(HttpMethod method) {
        return switch (method) {
            case GET -> "查询资源,无请求体,幂等";
            case POST -> "创建资源,有请求体,不幂等";
            case PUT -> "整体更新资源,有请求体,幂等";
            case DELETE -> "删除资源,无请求体,幂等";
        };
    }

    public static void main(String[] args) {
        for (HttpMethod method : values()) {
            System.out.println(method + ": " + describe(method));
        }
    }
}
综合任务(第 6 题)
java
package cn.kb.learn.oop;

import java.util.EnumMap;
import java.util.List;
import java.util.Map;

public enum ApprovalStatus {
    DRAFT("草稿"),
    PENDING("待审批"),
    APPROVED("已通过"),
    REJECTED("已驳回");

    private final String label;

    ApprovalStatus(String label) {
        this.label = label;
    }

    public String label() {
        return label;
    }

    public boolean canTransitionTo(ApprovalStatus next) {
        return switch (this) {
            case DRAFT -> next == PENDING;
            case PENDING -> next == APPROVED || next == REJECTED;
            case REJECTED -> next == DRAFT;
            case APPROVED -> false;
        };
    }

    /** 终态:不允许再流转 */
    public boolean isFinal() {
        return this == APPROVED;
    }

    public static void main(String[] args) {
        System.out.println("草稿 → 待审批: " + DRAFT.canTransitionTo(PENDING));
        System.out.println("待审批 → 已通过: " + PENDING.canTransitionTo(APPROVED));
        System.out.println("已通过 → 草稿: " + APPROVED.canTransitionTo(DRAFT) + "(终态不可变)");
        System.out.println("已驳回 → 草稿: " + REJECTED.canTransitionTo(DRAFT) + "(可以打回重写)");

        Map<ApprovalStatus, Integer> counter = new EnumMap<>(ApprovalStatus.class);
        List<ApprovalStatus> records = List.of(DRAFT, PENDING, PENDING, APPROVED, REJECTED, DRAFT);
        for (ApprovalStatus status : records) {
            counter.merge(status, 1, Integer::sum);
        }
        System.out.println("状态分布: " + counter);

        System.out.println("已通过是终态: " + APPROVED.isFinal());
        System.out.println("已驳回是终态: " + REJECTED.isFinal() + "(还能回到草稿,所以不是终态)");
    }
}

关于"驳回是不是终态":业务上"驳回"意味着单据还能被修改后重新提交,所以它不是终态。这正是枚举要把业务判断写成代码的意义——"终态"的定义一旦有歧义,代码里的 isFinal() 就是唯一的事实来源。

预期输出:

text
草稿 → 待审批: true
待审批 → 已通过: true
已通过 → 草稿: false(终态不可变)
已驳回 → 草稿: true(可以打回重写)
状态分布: {DRAFT=2, PENDING=2, APPROVED=1, REJECTED=1}
已通过是终态: true
已驳回是终态: false(还能回到草稿,所以不是终态)

13. 掌握标准 ​

  • [ ] 能写出带字段与方法的枚举,并把业务规则放进去;
  • [ ] 会用 switch 表达式处理枚举,并理解编译器的覆盖检查;
  • [ ] 知道 == 为什么安全,以及 ordinal() 为什么不能持久化;
  • [ ] 会用 EnumMap 做统计;
  • [ ] 能判断一个字段该不该用枚举,而不是 int / String 常量。

14. 扩展资源 ​

15. 下一步学习 ​

面向对象这条主线到这里完整了:类与对象 → 封装 → 继承 → 多态 → 抽象类与接口 → 枚举。

接下来按顺序推进 《String 与不可变性》 与 《equals、hashCode 与对象比较》,前者会解释"为什么字符串是不可变的",后者会讲清"怎么比较两个对象才正确"。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引