Java 的 if 和循环看起来和 C 一样,但有几条规则能拦住真实的 bug。旧 switch 默认会贯穿,switch 表达式修掉了这个问题,还让编译器检查每个枚举值。
控制流决定程序下一步执行什么、执行几次。Java 的 if、while 和 for 看起来和 C 几乎一模一样,但有几条规则不同,每一条都是为了拦住一个你本来会带上线的 bug。变化最大的是 switch,它现在有两种截然不同的写法。
本文会讲 if 和三元运算符、短路的 && 和 ||、各种循环、带标签的 break 和 continue、旧的 switch 语句和它的贯穿 bug,以及带 -> 和 yield 的 switch 表达式。最后用一个小小的成绩计算器把这些串起来。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,把它保存为 Main.java,再执行 java Main.java。
if 需要真正的 boolean
Java 的 if 在条件为真时执行它的代码块,而这个条件的类型必须是 boolean。下面是完整的 if / else if / else 写法:
String describe(int celsius) {
if (celsius < 0) {
return "freezing";
} else if (celsius < 20) {
return "cool";
} else {
return "warm";
}
}
void main() {
IO.println(describe(-5));
IO.println(describe(12));
IO.println(describe(31));
}
输出:
freezing
cool
warm
判断从上往下进行,第一个为真的分支胜出。12 不小于 0,所以跳过第一个分支。它小于 20,于是返回 "cool",else 根本不会执行。
在 C 里,任何数字都能当条件,零表示假。Java 不允许这样。C 里那个经典笔误,想写 == 却写成了 =,过不了编译器这一关:
void main() {
int x = 3;
if (x = 5) {
IO.println("x is five");
}
}
构建失败,报错:
Main.java:3: error: incompatible types: int cannot be converted to boolean
if (x = 5) {
^
x = 5 是赋值,它的值是 int 5。int 不是 boolean,所以 javac 停了下来。在 C 里,这一行能编译通过,把 x 设成 5,而且每次都会进入分支。
有一种情况这条规则拦不住。如果变量本身就是 boolean,赋值表达式的类型也是 boolean,于是能编译通过:
void main() {
boolean done = false;
if (done = true) {
IO.println("the branch ran, and done is now " + done);
}
}
输出:
the branch ran, and done is now true
我们原以为这里至少会有一条 lint 警告,结果 javac -Xlint:all 什么也没报。解决办法是干脆不去比较布尔值。写 if (done) 或 if (!done),就没有 = 可以写错了。
三元运算符从两个值里挑一个
三元运算符 condition ? a : b 是一个会产生值的 if。当两个分支都很短,而你想把结果赋值或返回时,就用它:
void main() {
int items = 1;
String label = items == 1 ? "item" : "items";
IO.println(items + " " + label);
items = 3;
IO.println(items + " " + (items == 1 ? "item" : "items"));
}
输出:
1 item
3 items
条件仍然必须是 boolean。三元运算符不要嵌套。三元里再套三元是合法的,但换成 if 或 switch 表达式读起来更清楚。
&& 和 || 会提前停下,& 和 | 不会
当左边已经决定了结果,&& 和 || 就会跳过右边。这叫短路。& 和 | 也能用在布尔值上,但它们总是两边都求值。右边有副作用时,区别就显出来了:
boolean check(String name, boolean result) {
IO.println(" checked " + name);
return result;
}
void main() {
IO.println("&&:");
boolean a = check("left", false) && check("right", true);
IO.println("&:");
boolean b = check("left", false) & check("right", true);
IO.println("||:");
boolean c = check("left", true) || check("right", false);
IO.println(a + " " + b + " " + c);
}
输出:
&&:
checked left
&:
checked left
checked right
||:
checked left
false false true
用 && 时,左边一旦为假,整个表达式就为假,所以 right 从来没被检查。& 得到同样的结果,但两边都执行了。|| 在左边为真时立刻停下。
正是短路,才让“先判空再调用方法”变得安全。把 && 换成 &,这道防护就失效了:
String name = null;
void main() {
if (name != null && name.length() > 3) {
IO.println("long name");
}
IO.println("the && version is fine");
if (name != null & name.length() > 3) {
IO.println("long name");
}
}
输出后停止:
the && version is fine
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "this.name" is null
尽管 name != null 为假,& 版本还是调用了 name.length()。日常代码里用 && 和 ||。& 和 | 留给整数的位运算。
while、do-while 和经典 for
Java 有三种在条件成立时重复执行的循环,区别在于什么时候检查条件。while 在每一轮之前检查,do-while 在之后检查,for 则把初始化、检查和步进写在同一行:
void main() {
int n = 3;
while (n > 0) {
IO.println("while: " + n);
n--;
}
int tries = 10;
do {
IO.println("do-while ran with tries = " + tries);
tries++;
} while (tries < 5);
for (int i = 0; i < 3; i++) {
IO.println("for: i = " + i);
}
}
输出:
while: 3
while: 2
while: 1
do-while ran with tries = 10
for: i = 0
for: i = 1
for: i = 2
尽管 tries < 5 一开始就是假,do-while 的循环体还是执行了一次。这是 do-while 唯一的不同之处。它适合“先做一遍,再问要不要继续”的场景,比如反复提示输入,直到输入合法为止。
for 循环里的 i 只在循环内部存在。在右花括号之后使用它,javac 会报 cannot find symbol。
增强 for 逐个访问每个元素
增强 for 写作 for (var x : things),它按顺序访问数组或集合里的每个元素,不用自己管理索引:
void main() {
int[] scores = {72, 95, 88};
int total = 0;
for (int score : scores) {
total += score;
}
IO.println("total: " + total);
var names = List.of("Ana", "Ben", "Chen");
for (var name : names) {
IO.println("hello, " + name);
}
}
输出:
total: 255
hello, Ana
hello, Ben
hello, Chen
把冒号读成“在……中”:对 scores 中的每个 score。它适用于数组,以及任何实现了 Iterable 的东西,包括 List、Set 和其他集合。如果既要值又要位置,就回到经典 for。
一个差一错误,以及修法
差一错误是指循环多跑了一次或少跑了一次。对数组来说,常见原因是该写 < 的地方写成了 <=:
void main() {
String[] days = {"Mon", "Tue", "Wed"};
for (int i = 0; i <= days.length; i++) {
IO.println(i + ": " + days[i]);
}
}
输出后停止:
0: Mon
1: Tue
2: Wed
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
长度为 3 的数组,索引是 0、1 和 2。条件 i <= days.length 让 i 能到达 3,而 Java 会检查每一次数组访问,所以它抛出异常,而不是去读数组末尾之后的那块内存。C 不做检查,同样的循环在 C 里可能悄无声息地读到垃圾数据。
修法是用 <:
void main() {
String[] days = {"Mon", "Tue", "Wed"};
for (int i = 0; i < days.length; i++) {
IO.println(i + ": " + days[i]);
}
}
输出:
0: Mon
1: Tue
2: Wed
从 0 开始,在长度之前停下。如果用不到 i,增强 for 就不可能犯这个错,因为它根本没有索引。
break、continue 和带标签的 break
break 立刻离开循环,continue 跳过本轮剩下的部分,开始下一轮。两者都只作用于最内层的循环,当你在两层循环里面时,这就成了问题。标签给外层循环起个名字,让 break 能直接离开那一层:
void main() {
for (int i = 1; i <= 6; i++) {
if (i % 2 == 0) {
continue;
}
if (i == 5) {
break;
}
IO.println("odd: " + i);
}
int[][] grid = {{1, 2, 3}, {4, 42, 6}, {7, 8, 9}};
for (int[] row : grid) {
for (int value : row) {
if (value == 42) {
IO.println("plain break: found 42");
break;
}
}
IO.println("plain break: still scanning after a row");
}
search:
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[r].length; c++) {
if (grid[r][c] == 42) {
IO.println("labeled break: found 42 at row " + r + ", column " + c);
break search;
}
}
}
IO.println("done");
}
输出:
odd: 1
odd: 3
plain break: still scanning after a row
plain break: found 42
plain break: still scanning after a row
plain break: still scanning after a row
labeled break: found 42 at row 1, column 1
done
第一个循环用 continue 跳过偶数,用 break 在 5 处停下,所以只打印了 1 和 3。
普通 break 在中间那一行找到了 42,但只离开了内层循环。外层循环继续往下走,打印了它那一行,又白白扫描了最后一行。普通 break 最多只能跳出一层循环。
search: 给外层循环加上了标签,所以 break search 一次离开两层循环。接下来执行的是外层循环之后的那一行,它打印 done。continue 也可以带标签:continue search 会跳到下一行。
实际代码里很少用标签。如果真需要,通常更干净的做法是把嵌套循环移到一个方法里,找到值就 return。
旧的 switch 语句会贯穿
旧的 switch 语句跳到匹配的 case,然后继续执行它下面的每一行,穿过后面的 case,直到遇到 break。这叫贯穿(fall-through),漏写一个 break,它就成了 bug:
@SuppressWarnings("fallthrough")
String dayType(int day) {
String result = "";
switch (day) {
case 6:
result += "Saturday ";
case 7:
result += "Sunday ";
break;
default:
result += "weekday ";
}
return result;
}
void main() {
IO.println("6 -> " + dayType(6).strip());
IO.println("7 -> " + dayType(7).strip());
IO.println("2 -> " + dayType(2).strip());
}
输出:
6 -> Saturday Sunday
7 -> Sunday
2 -> weekday
第 6 天匹配 case 6,加上 "Saturday ",然后径直冲进 case 7,又加上了 "Sunday "。直到 break 之前,什么都拦不住它。第 7 天的起点更靠下,所以只拿到了 "Sunday "。
那行 @SuppressWarnings("fallthrough") 之所以存在,是因为本系列的每个程序都用 javac -Xlint:all -Werror 构建。去掉它,这样的构建就会抓住这个 bug:
String dayType(int day) {
String result = "";
switch (day) {
case 6:
result += "Saturday ";
case 7:
result += "Sunday ";
break;
default:
result += "weekday ";
}
return result;
}
void main() {
IO.println(dayType(6));
}
构建失败,报错:
Main.java:6: warning: [fallthrough] possible fall-through into case
case 7:
^
error: warnings found and -Werror specified
这条警告默认是关闭的。直接 javac Main.java 什么也不打印,java Main.java 也一样,上面那个有 bug 的版本就是这样一声不吭地跑完的。如果你维护着用旧 switch 的代码,打开 -Xlint:fallthrough。
贯穿偶尔正是你想要的。case 6: case 7: 中间什么都不写,是让两个标签共用一个代码块的老办法。新 switch 有更干净的写法。
switch 表达式:->,没有贯穿
带 -> 的 switch 只执行一个 case,永远不会掉进下一个。它还可以是一个表达式:整个 switch 产生一个值,你可以拿去赋值或返回。switch 表达式在 Java 14 中正式定稿。用 javac --release 13 编译,javac 会以 switch expressions are not supported in -source 13 拒绝它。
enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }
String dayType(Day day) {
return switch (day) {
case SATURDAY, SUNDAY -> "weekend";
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
};
}
void main() {
IO.println("SATURDAY -> " + dayType(Day.SATURDAY));
IO.println("SUNDAY -> " + dayType(Day.SUNDAY));
IO.println("TUESDAY -> " + dayType(Day.TUESDAY));
}
输出:
SATURDAY -> weekend
SUNDAY -> weekend
TUESDAY -> weekday
和旧版本比一比:
- 没有
break。->右边执行完,switch 就结束了。没有什么可忘的。 - 一个 case 里放多个标签。
case SATURDAY, SUNDAY取代了旧的case 6: case 7:堆叠写法。 - 会产生一个值。
return switch (...) { ... };返回被选中的 case 产生的值。注意右花括号后面的分号,因为这个 switch 是return语句的一部分。 - 没有
default。 这些 case 列出了全部七天,编译器也检查过了。下文还会细讲。
两种写法不能混用。同一个 switch 里同时出现 case 6: 和 case 7 ->,会报 different case kinds used in the switch。
箭头也能用在 switch 语句里,这时不产生值,每个 case 只是做一件事。同样不会贯穿。
yield 从代码块里返回一个值
如果一个 case 需要不止一个表达式,就给它一个花括号代码块,并用 yield 结束这个代码块:
enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }
int openingHour(Day day) {
return switch (day) {
case SATURDAY -> 10;
case SUNDAY -> {
IO.println(" (Sunday: checking the holiday rota)");
yield 12;
}
default -> 9;
};
}
void main() {
IO.println("MONDAY opens at " + openingHour(Day.MONDAY));
IO.println("SUNDAY opens at " + openingHour(Day.SUNDAY));
}
输出:
MONDAY opens at 9
(Sunday: checking the holiday rota)
SUNDAY opens at 12
yield 把一个值交还给 switch,就像 return 从方法里交还一个值。这里不能用 return,因为 return 会直接离开 openingHour 本身。代码块里漏掉 yield,javac 会报 switch rule completes without providing a value。
这里用 default 没问题,因为“其他日子都 9 点开门”本来就是规则。下一节会讲什么时候 default 反而会藏住 bug。
用十岁孩子能懂的话说
把旧 switch 想象成一座高高的滑梯,每一层都有一个平台:一层给星期六,下面一层给星期天,再下面一层给工作日。你从自己那一层爬上去。然后开始往下滑,而且会一路滑过下面的每一层,把每层上的东西都收进口袋,直到 break 像一张网一样把你接住。忘了挂网,你就一直滑到底。
新 switch 是一条走廊,两边是一扇扇独立的门。有星期六的门、星期天的门和工作日的门。你打开写着你名字的那扇门,走进那个房间。房间地板上没有洞,所以你不可能掉进隔壁房间。
准确的说法
在旧的 switch 语句里,case 标签只是进入同一个语句块的入口。switch 跳到匹配的标签,从那里开始按顺序执行语句。break 会跳出这个语句块。如果没有 break,执行会继续进入下一个标签下面的语句,因为在编译器眼里,那些只是接下来的几行代码。
在带 -> 的 switch 里,每个 case 是一条独立的规则。右边是一个表达式、一个代码块或一个 throw,执行完后控制流就离开 switch。在 switch 表达式里,每条规则还必须产生一个值,要么直接给出,要么通过 yield,否则就得抛出异常。
这个比喻的局限:你可以故意造一座滑梯,让两层共用一个平台,新 switch 用 case SATURDAY, SUNDAY 表达这件事,也就是一扇门上写着两个名字。这些门还有一条比喻里看不出来的规则:在 switch 表达式里,编译器会在程序运行之前检查,每一个可能的值都有一扇门。
对字符串和枚举做 switch
不用模式时,switch 适用于 int、short、byte、char、它们的包装类、String 和枚举。long 不在其中:在 Java 25 里,对 long 做 switch,javac 会报 primitive patterns are a preview feature and are disabled by default. 字符串用 equals 比较,所以 case 匹配的是文本内容,而不是对象本身:
String run(String command) {
return switch (command) {
case "start", "go" -> "starting";
case "stop" -> "stopping";
default -> "unknown command: " + command;
};
}
void main() {
IO.println(run("go"));
IO.println(run("stop"));
IO.println(run("jump"));
IO.println(run(null));
}
输出后停止:
starting
stopping
unknown command: jump
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null
前三次调用和预期一样。null 到不了 default。它直接抛出异常,而错误信息暴露了字符串 switch 的工作方式。javac 把 command 复制到一个隐藏变量里,对它调用 hashCode() 来挑出候选 case,再用 equals 确认匹配。这个隐藏变量没有名字,所以错误信息管它叫 <local2>。如果 null 是真实可能出现的输入,就在 switch 之前先检查。讲密封类型和模式匹配的那一篇会介绍模式 switch 里的 case null。
枚举上的 switch 表达式必须覆盖每个常量
对枚举做 switch 表达式时,如果没有 default,就必须列出每一个常量。少一个,代码就构建不了。下面漏掉了 SUNDAY:
enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }
String dayType(Day day) {
return switch (day) {
case SATURDAY -> "weekend";
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
};
}
void main() {
IO.println(dayType(Day.SUNDAY));
}
构建失败,报错:
Main.java:4: error: the switch expression does not cover all possible input values
return switch (day) {
^
switch 表达式必须为每个输入都产生一个值,而编译器知道 Day 的全部常量,所以它看得出 SUNDAY 无处可去。这就是前面的 dayType 不需要 default 的原因。这也是为什么在枚举的 switch 表达式里,通常应该省掉 default。明年加一个 HOLIDAY 常量,所有漏掉它的 switch 都会编译失败。要是有 default,HOLIDAY 就会悄悄拿到默认结果。
这项检查只针对 switch 表达式。对枚举使用的旧式 switch 语句,无论用 -> 还是 :,都仍然可以漏掉常量,漏掉的值什么也不执行。讲密封类型和模式匹配的那一篇会介绍按类型做的 switch,在那里,同样的穷尽性检查能发挥更大的作用。
完整示例:成绩计算器
成绩计算器把本文的大部分内容放进了一个程序。它遍历一组分数,用 switch 表达式把每个分数转成字母等级,拒绝 0 到 100 之外的分数,并统计每个字母出现了几次:
char grade(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("score out of range: " + score);
}
return switch (score / 10) {
case 10, 9 -> 'A';
case 8 -> 'B';
case 7 -> 'C';
case 6 -> 'D';
default -> 'F';
};
}
void main() {
var scores = List.of(91, 78, 100, 59, 84, 67, 88, 73, 45, 95);
var counts = new TreeMap<Character, Integer>();
int passed = 0;
for (int score : scores) {
char letter = grade(score);
counts.put(letter, counts.getOrDefault(letter, 0) + 1);
if (letter != 'F') {
passed++;
}
}
for (var entry : counts.entrySet()) {
IO.println(entry.getKey() + ": " + "#".repeat(entry.getValue()));
}
IO.println(passed + " of " + scores.size() + " passed");
try {
grade(101);
} catch (IllegalArgumentException e) {
IO.println("rejected: " + e.getMessage());
}
}
输出:
A: ###
B: ##
C: ##
D: #
F: ##
8 of 10 passed
rejected: score out of range: 101
逐个来看:
score / 10是整数除法,所以 91 变成 9,78 变成 7。这样 101 种可能的分数就变成了 11 个 case。case 10, 9 -> 'A'让 100 分也得到 A,不用单独写一个if。default -> 'F'在这里是对的。int有几十亿个取值,“60 以下的全部”是真正的兜底。switch 上面的范围检查保证了离谱的值进不来。- 第一个
for循环把计数存进TreeMap,它会让键保持有序。所以字母按 A 到 F 的顺序打印。 - 第二个
for循环遍历这个 map,用String.repeat为每个分数画一个#。 grade(101)没通过范围检查,抛出异常,由catch打印错误信息。如果没有这项检查,101 / 10 等于 10,101 分就会悄悄拿到 A。
顺序重要时如何遍历 Map
遍历 map 用 for (var entry : map.entrySet()),而得到的顺序取决于 map 的种类。HashMap 按哈希码决定的顺序返回条目,这不是你插入的顺序。Map.of(...) 用来打印更糟:它的顺序每次 JVM 运行都可能不同,我们试的时候,同一个程序跑四次,打印出了四种不同的顺序。顺序重要时,就选一个承诺了顺序的 map。LinkedHashMap 保持插入顺序,TreeMap 让键保持有序:
void main() {
var byInsertion = new LinkedHashMap<String, Integer>();
byInsertion.put("pears", 3);
byInsertion.put("apples", 5);
byInsertion.put("figs", 1);
for (var entry : byInsertion.entrySet()) {
IO.println("inserted: " + entry.getKey() + " = " + entry.getValue());
}
var sorted = new TreeMap<>(byInsertion);
for (var entry : sorted.entrySet()) {
IO.println("sorted: " + entry.getKey() + " = " + entry.getValue());
}
}
输出:
inserted: pears = 3
inserted: apples = 5
inserted: figs = 1
sorted: apples = 5
sorted: figs = 1
sorted: pears = 3
讲 equals、hashCode 和集合的那一篇会解释,为什么 HashMap 没有可用的顺序。
要点
if的条件必须是boolean。if (x = 5)编译不过,但if (done = true)能编译,所以要写if (done)。- 当左边已经决定结果时,
&&和||会跳过右边。&和|总是两边都执行。 - 遍历数组用
i < array.length,不要用<=,或者用增强for,干脆不要索引。 - 普通
break只离开最内层循环。给外层循环加标签,才能一次离开两层。 - 旧的 switch 语句没有
break就会贯穿到下一个 case,除非打开-Xlint:fallthrough,否则没有任何警告。 - 带
->的 switch 从不贯穿。作为表达式,它返回一个值,在代码块里用yield,而且必须覆盖每个枚举常量,所以当枚举就是完整列表时,省掉default。 - 如果遍历 map 的顺序很重要,用
LinkedHashMap或TreeMap。
优先使用带
->的 switch:每个 case 单独执行,编译器还会检查有没有漏掉的。