本节目标:能根据「要解决的是分支还是重复」选出合适的控制流写法,把命令分发写成可读、可维护的代码,并认出循环边界上的典型错误。
适用版本:JDK 25 LTS(Temurin 25.0.5);本节语法在 JDK 21 上完全一致。
上一节留下的问题
第 4 章把 Book 的字段和类型定下来了:isbn、title、author 用 String,publishedYear 用 int,available 用 boolean。类型选对了,但程序还不会做事——LibraryApp 仍然只把 args[0] 打印出来。
要让 library 真正接受 add、list、search、count、quit 这几条命令,需要两件事:
- 分支:同一条输入,根据命令名走不同的处理逻辑。
- 重复:一条命令处理完,继续读下一条,直到用户退出。
这两件事对应的就是 if 和循环。它们是最早学会的语法,也是最容易写坏的语法——坏不在编译不过,而在读起来费劲、边界上出错。
if 是句子,不是括号堆
if 的语义很简单:条件为 true 就执行。但工程代码里的 if 有两个常见写法问题。
条件本身就是布尔值
Java 的 if 只接受 boolean,不像 C 那样把 0、null、空字符串当假。这条限制让代码少了一类歧义,但也带来一个坏习惯:把已经是布尔值的表达式再和 true 比较,比如 if (book.available == true)。它不改变行为,却让读者多花一次注意力去确认「这里是不是有别的含义」;否定形式 if (!book.available == false) 更糟,读三遍才能确定意思。直接写 if (book.available)。
卫语句:先处理例外,再写主逻辑
嵌套的 if 是可读性的头号杀手。三层校验套下来,真正做事的那一行被推到最里面,读者要在脑子里维护三个条件才敢确认它何时执行。改成卫语句(guard clause):每个失败条件单独一行,处理完立刻 return,主逻辑留在最外层。
private void handleAdd(String[] parts) {
if (parts.length < 2) {
System.out.println("usage: add <isbn>|<title>|<author>");
return;
}
String[] fields = parts[1].split("\\|", -1);
if (fields.length != 3) {
System.out.println("expected 3 fields, got " + fields.length);
return;
}
for (String field : fields) {
if (field.isBlank()) {
System.out.println("empty field in: " + parts[1]);
return;
}
}
books.add(new Book(fields[0], fields[1], fields[2]));
System.out.println("added: " + fields[1]);
}
和嵌套版本相比,这段代码的校验强度不低,但结构上的差别更值得注意:成功路径没有缩进。读代码的人不需要在脑子里维护「现在在第几层条件里」,只需要逐条排除失败情况,最后看到那三行真正的工作。
卫语句不是教条。如果分支两边都是正常业务(比如「有库存就走借出,没库存就走预约」),对称的 if/else 反而更清楚。判断标准是:其中一个分支是异常、边界或提前结束,就用卫语句;两个分支地位对等,就用 if/else。
else if 链的次序就是优先级
else if 从上往下匹配,第一个成立的分支执行,后面的不再看。所以顺序本身就是业务规则的一部分:把 if (book.available) 放在 else if (loans.containsKey(...)) 前面,一本「已借出但 available 仍为 true」的脏数据就会走到「可借」分支。这不是语法问题,是状态建模问题——第 7 章会把 available 从 Book 里拿掉,正是因为它容易被改错。
三种循环,各有各的位置
Java 有四类循环写法,日常用得最多的是前三种。它们的差别不只是语法,还有「什么时候停止」这个问题的表达方式。
| 写法 | 适合的场景 | 停止条件在哪 |
|---|---|---|
for (int i = 0; i < n; i++) | 需要下标,或需要按下标访问两个集合 | 循环头,一眼可见 |
for (T item : collection) | 只读遍历,不需要下标 | 集合耗尽 |
while (condition) | 次数未知,由外部状态决定 | 循环头,但状态在循环体里变 |
do { } while (condition) | 至少执行一次,例如「先读再判断」 | 循环尾 |
带下标的 for:只在真的需要下标时用
for (int i = 0; i < books.size(); i++) {
Book book = books.get(i);
System.out.printf("%d. %s / %s%n", i + 1, book.title, book.author);
}
这里需要 i 来打印序号,所以用下标循环合理。如果不需要序号,增强 for 更短,也不会写错边界:
for (Book book : books) {
System.out.println(book.title);
}
一个细节:books.size() 在每次循环时都会重新求值(对 ArrayList 是一次字段读取,成本可忽略;对某些并发集合则不是)。如果循环体里会增删集合,这个值会变,边界就不再稳定——这类循环要么改成固定次数,要么先复制一份。
增强 for:读起来最像「对每个元素做一件事」
for (Book book : books) 的语义是「把 books 里的每个元素依次绑定到 book」。它不暴露下标,也不允许在遍历时结构性修改集合,这既是限制也是保护。
for (Book book : books) {
if (!book.title.contains(keyword) && !book.author.contains(keyword)) {
continue;
}
hits++;
System.out.println("hit: " + book.title);
}
continue 跳过不匹配的元素,hits 只在匹配时自增。这段代码没有下标,读者不需要在脑子里跟踪 i 走到了哪;完整的 handleSearch 方法在后面的 LibraryApp 里。
while:次数未知时用
CLI 的主循环是 while 的典型场景:程序不知道用户会输入多少条命令,只知道「读到 quit 或输入结束就停」。
String line;
while ((line = in.readLine()) != null) {
line = line.strip();
if (line.isEmpty()) {
continue;
}
if (line.equals("quit")) {
System.out.println("bye");
break;
}
dispatch(line.split("\\s+", 2));
}
(line = in.readLine()) != null 把「读一行」和「判断是否读完」写在一起,是 while 的惯用法。它的可读性建立在「赋值表达式的值是赋进去的那个值」这条规则上;在其他位置这样写通常会被认为耍小聪明,这里则是标准写法。
break 跳出整个循环,continue 跳过本轮剩余部分。两者都只影响最内层循环;如果有多层嵌套,可以用标签(outer:)指定跳哪一层,但嵌套超过两层时,更好的做法是把内层抽成方法,用 return 表达「结束」。
do-while 先执行再判断,适合「必须先做一次」的场景,比如交互式菜单至少显示一次。CLI 主循环用 while 就够了,因为「有没有输入」在进入循环前就能判断。
循环边界:两个必须盯住的地方
边界一:< 还是 <=
for (int i = 0; i < books.size(); i++) 里,最后一个合法下标是 size() - 1。写成 <= 就会多访问一格:
for (int i = 0; i <= books.size(); i++) {
System.out.println(books.get(i).title);
}
用一个 List.of 装两本书,本机 JDK 25 运行结果:
Effective Java
Java Concurrency in Practice
Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 2 Size: 2
at java.base/java.util.ImmutableCollections$AbstractImmutableList.outOfBounds(ImmutableCollections.java:359)
at java.base/java.util.ImmutableCollections$List12.get(ImmutableCollections.java:612)
at com.plume.library.OffByOne.main(OffByOne.java:11)
注意异常信息里的 Index: 2 Size: 2:合法下标是 0 和 1,2 已经越界。这个报错很明确,真正难查的是那些「多循环一次但没有抛异常」的情况,比如多打印一行空数据、多发一次请求。
边界二:遍历时不要结构性修改集合
增强 for 背后是一个迭代器。遍历过程中调用 books.remove(...) 会让迭代器检测到「集合被改过」,下一次取元素时抛 ConcurrentModificationException:
for (Book book : books) {
if (book.author.equals("Joshua Bloch")) {
books.remove(book);
}
}
本机 JDK 25 运行结果(三本书,删除第一个匹配元素后,它后面还有元素):
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
at com.plume.library.RemoveWhileIterating.main(RemoveWhileIterating.java:12)
如果唯一匹配的元素恰好是最后一个,这段代码可能「碰巧不报错」——删除后 hasNext() 恰好返回 false,循环直接结束。这种「有时对有时错」比稳定报错更危险。正确做法是用 Iterator.remove(),或者先收集要删的元素再统一删:
List<Book> toRemove = new ArrayList<>();
for (Book book : books) {
if (book.author.equals("Joshua Bloch")) {
toRemove.add(book);
}
}
books.removeAll(toRemove);
第 10 章讲集合时会展开 Iterator 与 removeIf 的用法;现在先记住一条规则:遍历和修改不要放在同一个循环里。
在 library 里落地
把上面的规则拼起来,LibraryApp 的第一版长这样。Book 沿用第 4 章的写法:isbn、title、author、publishedYear、available 五个公开字段,加一个三参构造器。LibraryApp 负责读命令、分发、维护内存里的书单。
package com.plume.library;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
public final class LibraryApp {
private final List<Book> books = new ArrayList<>();
private final BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
public static void main(String[] args) throws IOException {
new LibraryApp().run();
}
void run() throws IOException {
String line;
while ((line = in.readLine()) != null) {
line = line.strip();
if (line.isEmpty()) {
continue;
}
if (line.equals("quit")) {
System.out.println("bye");
break;
}
dispatch(line.split("\\s+", 2));
}
}
private void dispatch(String[] parts) {
String command = parts[0];
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);
}
}
private void handleAdd(String[] parts) {
if (parts.length < 2) {
System.out.println("usage: add <isbn>|<title>|<author>");
return;
}
String[] fields = parts[1].split("\\|", -1);
if (fields.length != 3) {
System.out.println("expected 3 fields, got " + fields.length);
return;
}
for (String field : fields) {
if (field.isBlank()) {
System.out.println("empty field in: " + parts[1]);
return;
}
}
books.add(new Book(fields[0], fields[1], fields[2]));
System.out.println("added: " + fields[1]);
}
private void handleList() {
if (books.isEmpty()) {
System.out.println("(no books)");
return;
}
for (int i = 0; i < books.size(); i++) {
Book book = books.get(i);
System.out.printf("%d. %s / %s / %s%n", i + 1, book.isbn, book.title, book.author);
}
}
private void handleSearch(String[] parts) {
if (parts.length < 2) {
System.out.println("usage: search <keyword>");
return;
}
String keyword = parts[1];
int hits = 0;
for (Book book : books) {
if (!book.title.contains(keyword) && !book.author.contains(keyword)) {
continue;
}
hits++;
System.out.println("hit: " + book.title);
}
System.out.println("hits = " + hits);
}
}
编译并回放一段输入:
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
add bad
expected 3 fields, got 1
list
1. 978-7-111-1 / Effective Java / Joshua Bloch
2. 978-7-115-2 / Java Concurrency in Practice / Brian Goetz
search Java
hit: Effective Java
hit: Java Concurrency in Practice
hits = 2
count
books = 2
quit
bye
这段代码有三个刻意的选择,后面几节会继续改:
dispatch用if/else if链。 它能工作,但每加一条命令就要改一处长链,而且没有编译期检查。5.2 会换成switch表达式。- 参数用
|分隔,而不是空格。line.split("\\s+", 2)只切出「命令 + 剩余部分」,add再按|切字段。这样书名里的空格不会把参数切碎。第 15 章讲字符串与正则时,会给出更正式的参数切分方式。 available字段还没被用上。 借还逻辑属于服务层,6.1 会把业务逻辑从LibraryApp里拆出去,6.2 再接上借还命令。
常见错误与边界
用 == 比较字符串。 == 比较的是引用是否指向同一个对象,equals 才比较内容。"list" == new String("list") 的结果是 false,.equals(...) 是 true。"list" 字面量在字符串常量池里,new String("list") 在堆上另建一个对象,所以引用不同。命令行输入来自 readLine(),几乎一定会走新建对象的路径,用 == 判断命令会随机失效。字符串内容比较一律用 equals,忽略大小写用 equalsIgnoreCase。
if (x = 1) 这类赋值写成条件。 Java 要求条件必须是 boolean,int 赋值表达式不能当条件,这个 C 语言的老坑在编译期就会被拦住;但 boolean 变量的赋值仍然合法,if (found = true) 能编译,还会把 found 改成 true,编译器帮不了,只能靠「条件里不写赋值」的纪律。
省略花括号。 如下写法里,缩进让人以为 else 属于外层 if:
if (book.available)
if (book.publishedYear > 2000)
System.out.println("新书可借");
else
System.out.println("不可借");
else 匹配离它最近的未配对 if,所以「不可借」只会在「可借且出版年份不大于 2000」时打印,和缩进表达的意图完全不同。所有 if/for/while 都写花括号,成本只是两行。
在循环条件里调用有副作用的方法。 while (queue.poll() != null) 每次判断都会取走一个元素,循环体里再取一次就少处理一个。条件应该只读状态,副作用留在循环体里;readLine() 是例外,因为「读下一行」本身就是推进循环的唯一方式。
小结
if的条件必须是boolean,不要写== true;否定条件尽量改成正向表达。- 异常和边界用卫语句提前
return,让成功路径没有缩进;两个分支地位对等时才用if/else。 - 需要下标或按下标配对访问时用
for (int i = ...),只读遍历用增强for,次数未知用while。 - 循环边界用
<不用<=;遍历时不要结构性修改集合,先收集再删除。 - 字符串内容比较用
equals,==比较的是引用。 library的第一版命令分发已经能跑,但if/else if链会随命令增加而变长,这正是switch要解决的问题。
下一节把这条命令链换成 switch 表达式,让「每条命令对应什么处理」变成一张表,并让编译器帮忙检查漏掉的枚举值。
阅读导航:上一节:4.3 运算符、溢出与数值边界 · 下一节:5.2 switch 表达式与 yield 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。