一段代码用 switch 表达式替换了一长串 if-else,写得很清爽。而编译时它报"not exhaustive"——因为枚举多了两个值,而 switch 表达式要求穷尽所有分支(除非有 default)。这个要求在 if-else 里不存在,于是"迁移到 switch"时最容易漏。
switch 表达式(Java 14+)带来的不只是"更短的写法",还有三条新的编译期约束。理解它们才知道"什么时候该用表达式,什么时候该用语句"。
1. 表达式与语句的四点差别
| 维度 | 语句(旧) | 表达式(新) |
|---|---|---|
| 返回值 | 无 | 必须有 |
| 穷尽性 | 不要求 | 要求(或 default) |
| 穿透 | 默认穿透 | 不穿透 |
| 分支写法 | case X: |
case X -> |
第三项是"安全"的改进:-> 形式不会穿透,于是"忘了 break"这类错误从语法上消失。第三项的"历史"是 switch 最著名的坑:忘了 break 会"继续往下执行",而这个错误在代码评审里极难看出——因为"缩进看起来是对的"。
第二项是"新约束":表达式必须"有值",于是"某个分支没覆盖"会编译失败——而 if-else 里"漏一个分支"只是"返回 null"。
2. 三种"穷尽性"的写法
| 写法 | 适用 |
|---|---|
| 覆盖所有枚举值 | 枚举(推荐,改动时编译器会提醒) |
default 兜底 |
值域开放(字符串/数字) |
case null 显式处理 |
允许 null 输入时 |
第一项的"前提"是"枚举是唯一的输入来源":如果入参是 String 或 int,那"穷尽"无从定义——于是只能用 default。
第一项的价值在于"将来加枚举值时,编译器会报错"——于是"忘记处理新值"变成编译期问题,而不是线上 NPE。
第二项的代价就在于此:default 会吸收新枚举值,于是"加了新值但走了默认分支",而编译器不报错。第二项的"折中"是"default -> throw new IllegalStateException(...)":它仍然编译通过,但"新值"会在运行期立刻炸——而不会"安静地走默认"。
// 反例:default 把"未来新增的枚举值"吞掉
String label(Status s) {
return switch (s) {
case CREATED -> "待支付";
case PAID -> "已支付";
default -> "未知"; // 新增 REFUNDED 后 → "未知"
};
}
// 正解:显式覆盖,让编译器提醒
String label2(Status s) {
return switch (s) {
case CREATED -> "待支付";
case PAID -> "已支付";
case REFUNDED -> "已退款"; // 新增值时这里会编译报错
};
}
3. yield 与代码块
| 写法 | 说明 |
|---|---|
case X -> expr |
单表达式 |
case X -> { ... yield v; } |
代码块 + yield |
yield 在语句里 |
不合法 |
第二项的"另一种坑"是"代码块里有多条 return 与一个 yield":编译能过,但"到底哪个生效"要看路径——而它比"单表达式"难读。
第二项的"坑"是"忘了 yield":代码块里没有 yield 会编译失败(这反而是好事)。而"写了 yield 又在外面写 return"会造成"两套返回路径",容易看错。
// 代码块里必须 yield
int n = switch (type) {
case "a" -> 1;
case "b" -> {
int extra = compute();
yield 2 + extra; // 必须 yield
}
default -> throw new IllegalArgumentException(type);
};
4. 模式匹配的三种用法(Java 17–21)
| 用法 | 例子 |
|---|---|
| 类型模式 | case String s -> s.length() |
| 守卫(when) | case String s when s.isEmpty() -> ... |
| 记录模式 | case Point(int x, int y) -> ... |
第二项(守卫)解决"类型匹配后还要判断"的问题:它把"类型 + 条件"写在同一行第二项的"顺序"要注意:守卫写在"类型模式之后",而它的条件可以引用绑定变量(case String s when s.isEmpty())——于是"类型与条件"共享同一个变量。
,于是"分支顺序"更清楚。
第三项的"代价"是"需要 record":普通类没有"解构"能力,于是它只适用于"值对象用 record 建模"的项目。
第三项(记录解构)在"值对象"上很实用:case Point(int x, int y) 直接拿到字段,不必再调 x()。
// 守卫 + 记录模式(Java 21)
String describe(Object o) {
return switch (o) {
case Point(int x, int y) when x == 0 && y == 0 -> "原点";
case Point(int x, int y) -> "点(" + x + "," + y + ")";
case null -> "空";
default -> "其它";
};
}
"分支数量多"的那条边界可以用"查表"落地,而它比 switch 更好维护:
// 用 Map 替代长 switch(分支多、且"值 → 行为")
static final Map<Status, Handler> HANDLERS = Map.of(
CREATED, Handler::onCreated,
PAID, Handler::onPaid,
REFUNDED,Handler::onRefunded
);
Handler h = HANDLERS.get(status); // 未知值 → null
if (h == null) throw new IllegalStateException("未处理的状态: " + status);
// 收益:新增状态只改 Map(一处),且"漏注册"在启动或首个请求就暴露
5. 什么时候用表达式、什么时候用语句
| 情形 | 选择 |
|---|---|
| 每个分支"产生一个值" | 表达式 |
| 分支里有"副作用为主"的逻辑 | 语句(更清楚) |
| 分支数量多(> 8) | 考虑"查表"或"多态" |
| 需要 fall-through | 语句(表达式不支持) |
第三项是"边界":switch 超过八个分支,"值映射"往往比"分支"更清楚(如用 Map<Status, Handler>)。
第四项提醒"两者不能混":表达式不支持穿透,所以"多个 case 共享一段逻辑"要写成 case A, B -> ...。第四项的"写法"很干净:case A, B -> ... 比"两个 case 穿透"更明确——而它顺便消除了"漏 break"的可能。
"迁移"时的等价性检查可以很机械:列出原 if-else 的所有条件,逐条对照新 switch 的 case。
// 一次迁移的检查表(四行)
1 原 if 链的每个条件 → 新 case 是否存在(含"多个条件合并"的情况)
2 原"漏写"的兜底分支(else / 最后 return)→ 新 default 是否存在
3 原"穿透"语义(多个 case 共享逻辑)→ 新 `case A, B ->` 是否等价
4 原"副作用顺序" → 新分支内的顺序是否一致
// 缺第 3 项时,最常出现"行为悄悄变了"
6. 排错:五种"switch 迁移问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 编译报"not exhaustive" | 漏枚举值/无 default | 补分支 |
| 新增枚举值走了"未知" | 用了 default | 去掉 default |
yield 报错 |
代码块缺 yield | 补 yield |
| 行为变了(原来穿透) | -> 不穿透 |
合并 case 或用语句 |
| 分支顺序导致误匹配 | 类型模式从上到下 | 具体的放前面 |
第五项的"编译器行为"值得知道:它会报"pattern dominated"类警告(部分版本),而不总是错误——所以要开全警告并当成错误。
第五项在"类型模式"里很关键:case Object o 写在最前面会吞掉所有分支——而它编译通过,只是"永远走第一个"。
最后一个"版本"边界值得写清:switch 表达式在 Java 14 转正,模式匹配在 21 转正。跨版本项目要按"最低支持的 JDK"决定可用语法——而不是"我本地能编过"。
// 三个版本分界(决定能否用)
Java 14:switch 表达式(-> / yield)
Java 17:类型模式(instanceof 模式 + switch 预览)
Java 21:switch 模式匹配 + 记录模式(正式)
// 判据:项目 pom 里的 <release> 与运行环境实际版本
7. 边界:什么情况不必用新语法
| 场景 | 为什么 | 做法 |
|---|---|---|
| 老项目(Java 8/11) | 语法不支持 | 用 if-else |
| 分支很少(2–3 个) | 表达式收益小 | 随意 |
| 需要穿透的旧逻辑 | 语义不同 | 保留语句 |
| 团队不熟 | 可读性风险 | 先小范围用 |
判断口径一句话:"每个分支是不是在'算一个值'"。最后一条与"可读性"有关的边界:"表达式"的优势在"不可穿透 + 可嵌入"——它能直接放进赋值/参数位置,而语句不行。所以"要一个值"时,表达式省掉一个临时变量。
是(映射、转换、分类),表达式更清楚且更安全;不是(执行一串动作),语句更好。
8. 自测
- 表达式与语句的四点差别里,哪一条是"新约束"?哪一条是"安全改进"?
- 为什么"用
default兜底"会在"新增枚举值"时出问题? yield用在什么地方?"代码块里忘了 yield"会怎样?- 模式匹配的三种用法里,哪一种解决了"类型匹配后还要判断"?举一个例子。
- 为什么"
case Object o写在最前面"会让所有分支失效? - 判断"用表达式还是语句"的那个问题是什么?