《Java 编程入门》5.1 if、for、while:把流程写成可读的句子

从 library 的 CLI 命令分发出发,讲清 if/else 链、卫语句、for 与 while 各自适合的场景;再用真实运行结果说明循环边界和边遍历边删除,并点出字符串比较中常见的三个坑,最后落到可运行的 LibraryApp 骨架。

本节目标:能根据「要解决的是分支还是重复」选出合适的控制流写法,把命令分发写成可读、可维护的代码,并认出循环边界上的典型错误。
适用版本: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 这几条命令,需要两件事:

  1. 分支:同一条输入,根据命令名走不同的处理逻辑。
  2. 重复:一条命令处理完,继续读下一条,直到用户退出。

这两件事对应的就是 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发