Blog

Java 的控制流:循环、if 和 switch 表达式

Java 的 if 和循环看起来和 C 一样,但有几条规则能拦住真实的 bug。旧 switch 默认会贯穿,switch 表达式修掉了这个问题,还让编译器检查每个枚举值。

控制流决定程序下一步执行什么、执行几次。Java 的 ifwhilefor 看起来和 C 几乎一模一样,但有几条规则不同,每一条都是为了拦住一个你本来会带上线的 bug。变化最大的是 switch,它现在有两种截然不同的写法。

本文会讲 if 和三元运算符、短路的 &&||、各种循环、带标签的 breakcontinue、旧的 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()。日常代码里用 &&||&| 留给整数的位运算。

whiledo-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 的东西,包括 ListSet 和其他集合。如果既要值又要位置,就回到经典 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.lengthi 能到达 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 就不可能犯这个错,因为它根本没有索引。

breakcontinue 和带标签的 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 一次离开两层循环。接下来执行的是外层循环之后的那一行,它打印 donecontinue 也可以带标签: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 适用于 intshortbytechar、它们的包装类、String 和枚举。long 不在其中:在 Java 25 里,对 longswitch,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 都会编译失败。要是有 defaultHOLIDAY 就会悄悄拿到默认结果。

这项检查只针对 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

equalshashCode 和集合的那一篇会解释,为什么 HashMap 没有可用的顺序。

要点

  • if 的条件必须是 booleanif (x = 5) 编译不过,但 if (done = true) 能编译,所以要写 if (done)
  • 当左边已经决定结果时,&&|| 会跳过右边。&| 总是两边都执行。
  • 遍历数组用 i < array.length,不要用 <=,或者用增强 for,干脆不要索引。
  • 普通 break 只离开最内层循环。给外层循环加标签,才能一次离开两层。
  • 旧的 switch 语句没有 break 就会贯穿到下一个 case,除非打开 -Xlint:fallthrough,否则没有任何警告。
  • -> 的 switch 从不贯穿。作为表达式,它返回一个值,在代码块里用 yield,而且必须覆盖每个枚举常量,所以当枚举就是完整列表时,省掉 default
  • 如果遍历 map 的顺序很重要,用 LinkedHashMapTreeMap

优先使用带 -> 的 switch:每个 case 单独执行,编译器还会检查有没有漏掉的。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。