1. forEach 方法:对元素的最终操作
在深入细节之前,让我们再回顾一下什么是流的终端操作和中间操作。
- 中间操作(例如,filter、map、distinct、peek)— 返回新的流,并且通常在调用终端操作之前不会真正执行。
- 终端操作(例如,forEach、collect、count、anyMatch)— 触发对流中元素的处理,并返回结果(或者不返回任何结果,如 forEach)。
在执行终端操作后,流被视为关闭,不能再对其应用其他操作。这就像想把已经吃掉的冰淇淋再吃一遍——不可能:流已经“用过了”。
现在来认识两个与副作用相关的重要方法:forEach 和 peek。
forEach 做什么?
forEach 是一种终端操作,它会对流中的每个元素执行给定的动作。通常用于打印输出、写日志、统计等副作用(side effects)。
方法签名:
void forEach(Consumer<? super T> action)
Consumer<T> 是一个函数式接口,接收一个参数且不返回结果(例如,System.out::println)。
示例:输出列表中的所有元素
假设我们有一个用户姓名列表:
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
用 Stream API 把所有用户打印到屏幕非常简单:
users.stream().forEach(System.out::println);
输出:
Anna
Boris
Alex
Alina
Dmitry
也可以使用 Lambda 表达式:
users.stream().forEach(name -> System.out.println("用户:" + name));
输出:
用户: Anna
用户: Boris
用户: Alex
用户: Alina
用户: Dmitry
重要:forEach 会结束流
调用 forEach 之后,流就“关闭”了。不能继续链式调用:
users.stream()
.filter(name -> name.startsWith("A"))
.forEach(System.out::println)
.map(String::toUpperCase); // 错误!流已关闭。
在 forEach 之后再调用其他方法会导致编译错误:终端操作返回 void,而不是新的流.
2. peek 方法:只“偷看”,不干预
peek 是中间操作。它允许在处理流程的某个阶段对每个元素执行一个动作,但不会改变元素本身,也不会终止流。
方法签名:
Stream<T> peek(Consumer<? super T> action)
- peek 返回一个新的流,在该流中会对每个元素执行 action。
- 通常用于调试、日志记录或监控流的状态。
示例:过滤后进行日志记录
List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");
List<Integer> nameLengths = users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("通过过滤:" + name))
.map(String::length)
.collect(Collectors.toList());
控制台输出:
通过过滤:Anna
通过过滤:Alex
通过过滤:Alina
nameLengths 的内容:
[4, 4, 5]
哪些场景适合使用 peek?
- 用于调试操作链:观察每个阶段发生了什么。
- 用于收集统计信息(例如计数)。
- 用于在中间阶段记录日志。
重要:peek 不应用于修改流中的元素。需要转换请使用 map。peek 是“偷看”,不是“干预”。
3. forEach vs peek:有什么区别?
| 方法 | 操作类型 | 使用时机 | 能否继续链式调用? | 更适合用于 |
|---|---|---|---|---|
|
终端操作 | 在流处理的最后阶段 | 否 | 最终动作(打印、日志、写入数据库) |
|
中间操作 | 在操作链的中间 | 是 | 调试、阶段性日志、计数 |
示例:用法差异
// forEach 示例
users.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println); // 此处流被终止
// peek 示例
users.stream()
.filter(name -> name.startsWith("A"))
.peek(name -> System.out.println("通过过滤:" + name))
.map(String::toUpperCase)
.collect(Collectors.toList()); // 可以继续链式调用
务必记住
- forEach 是不可回头的终点:之后无法再对流做任何事情。
- peek 如果没有终端操作,不保证会执行。如果只写一串中间操作——什么也不会发生。
4. 一些不明显的点:forEach 并不总是最佳选择!
为什么不应该用 forEach 修改集合?
许多新手会尝试用 forEach 修改集合的元素或集合本身(例如删除元素)。但这是不好的实践:流并不是用来修改原始集合的。
错误用法示例:
List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
if (name.startsWith("A")) {
names.remove(name); // 可能导致 ConcurrentModificationException!
}
});
结果:运行时错误——在遍历流期间不能修改集合(ConcurrentModificationException)。
那么 forEach 该用来做什么?
- 用于打印到屏幕(例如打印报表)。
- 用于写入日志。
- 用于调用外部服务(例如发送 e-mail)。
- 用于收集统计信息(例如递增计数器)。
5. 再谈 peek:仅用于调试!
很多人会忍不住用 peek 来修改元素,比如给用户年龄加 1:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // 不好!
.collect(Collectors.toList());
为什么不推荐?
- 这破坏了声明式与 Stream API 的纯净性。
- 这样的代码难以维护与测试。
- 在中间操作中引入副作用可能导致隐蔽的 bug。
更好的做法是使用 map 来进行数据转换:
List<User> olderUsers = users.stream()
.map(user -> new User(user.getName(), user.getAge() + 1))
.collect(Collectors.toList());
示意:forEach 与 peek 的差异
users.stream()
.filter(...) // 中间操作
.peek(...) // 中间操作,“偷看”
.map(...) // 中间操作
.forEach(...) // 终端操作,“执行动作”
说明:
— 在 forEach 之前的一切都可以组合、重排、增删。
— 执行 forEach 之后,流就关闭了。
6. 使用 forEach 和 peek 的常见错误
错误 1:使用 peek 修改数据。 peek 仅用于观察,而非修改流中的元素。需要转换请使用 map。
错误 2:期望 peek 一定会执行。 peek 只有在其后有终端操作(collect、forEach、count 等)时才会执行。没有终端操作,什么也不会发生。
错误 3:在 forEach 之后继续调用流操作。 forEach 是终端操作,之后不能再调用其他流方法。
错误 4:在 forEach 中修改集合。 在通过 forEach 遍历时修改原始集合(删除或添加元素)会直接导致 ConcurrentModificationException。
错误 5:用 forEach 代替 collect 来收集结果。 如果你要将元素收集到新集合,请使用 collect(Collectors.toList()),不要用 forEach 手工添加。这会破坏声明式风格,并可能在多线程场景下引发错误。
GO TO FULL VERSION