本节目标:能区分 switch 语句与 switch 表达式,知道什么时候必须写
yield,并会用枚举 + 穷尽性检查把命令分发变成可维护的表。
适用版本:JDK 25 LTS(Temurin 25.0.5);箭头 switch、switch 表达式、yield在 JDK 21 上同样可用。
上一节留下的问题
5.1 的 dispatch 是一条 if/else if 链:
if (command.equals("add")) {
handleAdd(parts);
} else if (command.equals("list")) {
handleList();
} else if (command.equals("search")) {
handleSearch(parts);
} else if (command.equals("count")) {
System.out.println("books = " + books.size());
} else {
System.out.println("unknown command: " + command);
}
它能跑,但有两个明显问题。
第一,命令名是散落的字符串。"add" 在 dispatch 里出现一次,在帮助文本里出现一次,将来在测试里还要再出现。拼错一个字母,编译器不会报错,只会在运行时走进 else。
第二,新增命令要改一条长链。命令从 5 条涨到 15 条时,这条链会横跨两屏,读者很难一眼确认「quit 到底有没有被处理」。
switch 解决的正是「按一个值选一条路」这类问题。但 Java 的 switch 有两代写法,行为差别不小,先把它们分开。
冒号写法:能穿透,也能漏 break
最老的 switch 是语句,用冒号分隔标签:
static String shelfOf(int year) {
String shelf = "general";
switch (year / 10) {
case 202:
shelf = "new";
case 201:
shelf = "modern";
break;
default:
shelf = "archive";
}
return shelf;
}
本机 JDK 25 调用 shelfOf(2025),输出:
modern
按代码的意图,2025 年应该落在「新书」架,结果却是「现代」架。原因是 case 202 执行完没有 break,控制流穿透(fall through)到 case 201,把 shelf 又改了一次。
fall-through 不是 bug,它是 C 语言留下的设计,偶尔有用:多个标签共享同一段逻辑时,可以写 case 1: case 2: doSomething();。但在业务代码里,它的收益远小于代价——漏写一个 break 不会有任何编译警告,只会让结果悄悄错掉。 从 Java 14 起,箭头写法把这条路堵上了。
箭头写法:一个标签只做一件事
箭头形式的 switch 用 -> 代替冒号,标签后面只接一个表达式、一个代码块或一次 throw:
switch (command) {
case ADD -> handleAdd(parts);
case LIST -> handleList();
case SEARCH -> handleSearch(parts);
case COUNT -> System.out.println("books = " + books.size());
case QUIT -> System.out.println("bye");
case UNKNOWN -> System.out.println("unknown command: " + parts[0]);
}
箭头形式有三条确定的规则:
- 不穿透。 每个标签执行完自动结束,不需要
break,也就不会漏。 - 一个标签可以匹配多个值。 写成
case ADD, LIST -> ...,共享同一段处理。 - 代码块用花括号。 需要多条语句时写
case ADD -> { ... },块内正常用分号。
这段代码是 switch 语句:它执行动作,不产出值。语句形式不要求覆盖所有枚举值,少写一个 case 也能编译,运行时匹配不到就什么都不做。这正是命令分发想要的吗?未必——漏掉 QUIT 时,程序不会报错,只会忽略退出命令。
表达式写法:switch 产出值
把 switch 放在赋值号右边、return 后面或方法参数里,它就是表达式,整个结构产出一个值:
static int shelfNumber(int year) {
return switch (year / 10) {
case 202 -> 1;
case 201 -> 2;
default -> 9;
};
}
这里每个箭头右边是一个表达式,它的值就是整个 switch 的值。没有 break,没有中间变量,return 直接返回结果。
表达式形式多了一条硬性要求:必须穷尽所有可能。 对 int 这类值,编译器无法枚举全部输入,所以必须有 default;对枚举,编译器知道所有常量,只要每个常量都覆盖,就可以不写 default——这一点下一节会用到。
如果某个分支需要多条语句,就不能直接写表达式,要用代码块加 yield:
static String describe(Command command) {
return switch (command) {
case ADD -> "add a book";
case QUIT -> {
System.out.println("shutting down");
yield "quit";
}
default -> "other";
};
}
yield 的意思是「这个代码块产出的值」。它只能出现在 switch 表达式的块里,不能出现在普通方法里,也不是 return——yield 结束的是当前分支,return 结束的是整个方法。
yield 的三条边界
边界一:只在 switch 表达式里
把 yield 写进普通方法,编译直接失败。本机 JDK 25 的报错是:
错误: yield 在 switch 表达式外部
yield 1;
^
JDK 13 引入 switch 表达式时,yield 才成为受限标识符;JDK 14 随 switch 表达式一起转正。在此之前它不是关键字,有些老代码把它当变量名。受限标识符的意思是:大多数位置仍可作标识符,但在 switch 块里会被当成关键字。新代码不要再用它命名。
边界二:块里每条路径都要 yield
case ADD -> {
if (parts.length > 1) {
handleAdd(parts);
yield true;
}
// 缺少 yield
}
这段编译失败,本机 JDK 25 的报错是:
错误: switch 规则完结但没有提供值
(switch 表达式中的 switch 规则必须提供值或抛出)
编译器要求块的所有正常出口都 yield 一个同类型的值;抛异常的分支可以不 yield,因为它不会正常返回。
边界三:yield 与 break 不混用
箭头形式里不能用 break 跳出分支,也不能写 break value。需要提前结束时用 yield;如果只是想跳出循环,那属于外层循环的 break,和 switch 无关。在 switch 表达式的块里写 break;,本机 JDK 25 报「尝试从 switch 表达式中断」;写 break 1; 则直接报「需要’;’」。
把命令变成枚举
switch 最适合的搭档是枚举。把命令名收进一个类型,字符串只在一个地方出现:
package com.plume.library;
public enum Command {
ADD, LIST, SEARCH, COUNT, QUIT, UNKNOWN;
public static Command from(String token) {
return switch (token) {
case "add" -> ADD;
case "list" -> LIST;
case "search" -> SEARCH;
case "count" -> COUNT;
case "quit" -> QUIT;
default -> UNKNOWN;
};
}
}
这段代码有三层收益。
字符串只在 from 里出现一次。 其他代码只认 Command.ADD,拼写错误在编译期就被拦住——Command.AD 不存在,编译器直接报「找不到符号」。
未知命令有明确的归宿。 UNKNOWN 是一个真实常量,不是「什么都不做」。它可以被记录、被统计、被打印帮助,而不是悄悄消失。
外层 switch 可以穷尽检查。 因为枚举的常量集合是封闭的,编译器能确认每个常量都被处理。少写一个,编译失败:
static String label(Command c) {
return switch (c) {
case ADD -> "add";
case LIST -> "list";
};
}
本机 JDK 25 的报错:
错误: switch 表达式不包含所有可能的输入值
return switch (c) {
^
这条报错的价值在于:新增 Command.RETURN 时,所有没处理它的 switch 表达式会一起编译失败。 编译器替你列出了所有需要改的地方,比运行时发现「还书命令没反应」便宜得多。
from 里的 default -> UNKNOWN 是必要的,因为 switch 的选择器是 String,编译器不知道用户会输入什么。但这一层 default 把「未知输入」收敛成了一个值,外层就不需要再写 default 了。
在 library 里落地
Command 和 LibraryApp 的分发方法一起看。Book 仍沿用第 4 章的五个公开字段;这里只列分发相关的代码。
private boolean dispatch(Command command, String[] parts) {
return switch (command) {
case ADD -> {
handleAdd(parts);
yield true;
}
case LIST -> {
handleList();
yield true;
}
case SEARCH -> {
handleSearch(parts);
yield true;
}
case COUNT -> {
System.out.println("books = " + books.size());
yield true;
}
case QUIT -> {
System.out.println("bye");
yield false;
}
case UNKNOWN -> {
System.out.println("unknown command: " + parts[0]);
yield true;
}
};
}
返回 boolean 表示「是否继续」。QUIT 返回 false,主循环据此退出;其他命令返回 true。这样「退出」不再是循环体里一个特殊的 break,而是分发结果的一部分,测试时可以直接断言 dispatch(QUIT, ...) 返回 false,不需要真的跑一个循环。
主循环相应简化:
void run() throws IOException {
String line;
while ((line = in.readLine()) != null) {
line = line.strip();
if (line.isEmpty()) {
continue;
}
String[] parts = line.split("\\s+", 2);
if (!dispatch(Command.from(parts[0]), parts)) {
break;
}
}
}
把 Command.from 和 dispatch 放进同一个工程,编译并回放:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp
输入与输出(本机 JDK 25):
add 978-7-111-1|Effective Java|Joshua Bloch
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz
added: Java Concurrency in Practice
list
1. Effective Java / Joshua Bloch
2. Java Concurrency in Practice / Brian Goetz
search Java
hit: Effective Java
hit: Java Concurrency in Practice
hits = 2
count
books = 2
remove x
unknown command: remove
quit
bye
注意 remove x 那两行:remove 不在命令表里,Command.from 把它收敛成 UNKNOWN,dispatch 打印一行提示后继续循环,程序没有崩,也没有静默忽略。
与 5.1 的版本相比,这次改动带来两个可检验的差别:
- 新增命令时,编译器会点名。 在
Command里加一个RETURN,dispatch立刻编译失败,直到你补上对应分支。 - 命令名不再散落。
LibraryApp里没有任何"add"字面量,只有Command.ADD;字符串比较只发生在from这一个入口。
常见错误与边界
在冒号 switch 里漏 break。 前面 shelfOf(2025) 输出 modern 就是例子。箭头写法没有这个问题;如果维护的是老代码,看到冒号 case 就要逐个确认 break、return 或 throw 是否收尾。
重复的 case 标签。 两个标签匹配同一个值,编译失败:
错误: case 标签重复
case 1 -> 20;
^
枚举里两个常量不会相等,所以这条错误通常出现在 int、String 或模式匹配的重叠类型上。编译器只查「常量值相同」,查不出「逻辑上重叠」,那要靠测试。
对 null 做 switch。 传统 switch 在选择器为 null 时抛 NullPointerException。本机 JDK 25 对 String 选择器:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null
at NullSwitch.main(NullSwitch.java:4)
从 JDK 21 起,可以用 case null -> 显式处理,或者 case null, default -> 把 null 和默认分支合并:
static String describe(String token) {
return switch (token) {
case null -> "null-token";
case "add" -> "add";
default -> "other";
};
}
本机 JDK 25 依次传入 null、"add"、"list",输出 null-token、add、other。如果不写 case null,null 仍然会抛异常——case null 是显式选择,不是自动兜底。
在 switch 表达式里忘记 default。 对 String、int 这类开放类型,编译器要求穷尽,缺 default 会报「switch 表达式不包含所有可能的输入值」。对枚举则相反:覆盖了所有常量之后不要再写 default,否则新增常量时编译器不会提醒你,穷尽性检查就失效了。
把 yield 当成 return。 yield 只结束当前分支,方法继续往下走。yield 之后同一层的语句不可达,编译器会报「无法访问的语句」;如果 yield 在 if 里,if 之后的代码仍可能执行。需要提前结束整个方法时,用 return。
小结
- 冒号
switch会 fall-through,漏break不报错但结果会错;箭头switch不穿透,一个标签只做一件事。 switch语句执行动作,switch表达式产出值;表达式必须穷尽所有输入。yield只出现在 switch 表达式的块里,负责产出一个值;它不等于return,也不能和break value混用。- 把命令名收进枚举,字符串只在一个
from方法里出现,拼写错误变成编译错误。 - 对枚举的 switch 表达式覆盖全部常量后不要再写
default,这样新增常量时编译器会点名所有待改位置。 library的dispatch现在返回「是否继续」,退出逻辑从循环体移进了分发结果,更容易测试。
命令分发已经清楚了,但 handleAdd 里的参数校验还在用「先转成字符串再解析」的笨办法。5.3 会换一个角度:当输入的类型不确定时,如何用 instanceof 模式匹配安全地判断并取值。
阅读导航:上一节:5.1 if、for、while:把流程写成可读的句子 · 下一节:5.3 instanceof 模式匹配与守卫 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。