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 與 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 應該用在什麼地方?
- 輸出到螢幕(例如列印報表)。
- 寫入日誌。
- 呼叫外部服務(例如傳送電子郵件)。
- 蒐集統計(例如遞增計數器)。
5. 再談 peek:只用於除錯!
很多人會想用 peek 來修改元素,例如把使用者的年齡加一:
users.stream()
.peek(user -> user.setAge(user.getAge() + 1)) // 不建議!
.collect(Collectors.toList());
為什麼不好?
- 這違反了宣告式與 Stream API 的純粹性。
- 這樣的程式碼難以維護與測試。
- 中間操作中的副作用可能導致隱晦的錯誤。
更好的作法是用 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