1. 什麼是惰性處理?
惰性處理,或稱「惰性求值」(lazy evaluation),是一種原則:對資料的操作會被延後,直到結果真的需要時才執行。在 Stream API 的情境下,這表示:即使你寫好了對集合的一連串轉換,Java 也不會立刻執行——它會等到呼叫終端操作時才開始計算整個鏈。
為什麼需要這樣?其一,節省資源:最終用不到的元素根本不會被處理。其二,提升效能——可以建立很長的操作鏈,而不必建立大量的中間集合。最後,還能進行「短路運算」:一旦找到第一個符合條件的元素,後續處理就會停止。
打個比方:想像一位懶懶的服務生。你說:「把菜單拿來,然後咖啡,接著甜點。」他點頭,但什麼也不做……直到你補上一句:「好吧,現在真的去拿吧。」這時他才去執行點單——而且如果甜點已經沒有了,他可能只端來咖啡。串流的惰性處理大致也是這樣運作。
2. 中間與終端操作
中間操作(intermediate):
- filter
- map
- sorted
- distinct
- peek(用於除錯)
- 等等
中間操作會回傳新的 Stream,但不會觸發計算。它們只是在「建立處理計畫」。
終端操作(terminal):
- collect
- forEach
- reduce
- count
- findFirst、findAny
- anyMatch、allMatch、noneMatch
- 等等
只有終端操作會啟動整個鏈的執行。
範例:沒有終端操作就不會有任何動作
List<String> names = List.of("Alisa", "Bob", "Vasya");
names.stream()
.filter(name -> {
System.out.println("篩選中 " + name);
return name.startsWith("A");
});
// 不會有任何輸出!上面的程式碼只是在 "構建" 鏈。
現在加上一個終端操作:
names.stream()
.filter(name -> {
System.out.println("篩選中 " + name);
return name.startsWith("A");
})
.forEach(System.out::println);
// 現在你會在主控台看到輸出!
結果:
篩選中 Alisa
篩選中 Bob
篩選中 Vasya
Alisa
3. 惰性處理的優點
節省資源
惰性處理能避免把時間和記憶體浪費在不需要的元素上。例如,當你只需要找到第一個符合條件的物件時,處理會在第一次匹配後停止。
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
String firstA = names.stream()
.filter(name -> {
System.out.println("檢查:" + name);
return name.startsWith("A");
})
.findFirst()
.orElse("未找到");
System.out.println("結果:" + firstA);
輸出:
檢查:Alisa
結果:Alisa
請注意:其他元素根本不會被檢查!
不需建立中間集合的長鏈
你可以組合多個操作(filter、map、sorted 等),而不必在每一步建立集合。
List<String> names = List.of("Alisa", "Bob", "Vasya", "Anna");
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Java 16+,較早版本 — .collect(Collectors.toList())
短路運算
如果只需知道「元素中是否存在合適的」,其餘元素將不會被檢查:
boolean hasLongName = names.stream()
.anyMatch(name -> {
System.out.println("檢查:" + name);
return name.length() > 10;
});
// 如果第一個元素就很長 — 其餘的不會被檢查!
4. 範例:惰性處理如何運作
範例 1:沒有終端操作就不會有任何動作
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
numbers.stream()
.filter(n -> {
System.out.println("篩選中 " + n);
return n % 2 == 0;
});
// 沒有輸出!
範例 2:包含終端操作的鏈
numbers.stream()
.filter(n -> {
System.out.println("篩選中 " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("乘以 " + n);
return n * 10;
})
.forEach(System.out::println);
輸出:
篩選中 1
篩選中 2
乘以 2
20
篩選中 3
篩選中 4
乘以 4
40
篩選中 5
重要說明:操作是逐元素執行的:先 filter,再 map,再 forEach——對每個元素依序進行。這並不是兩個獨立的遍歷:「先把所有元素過濾完,再把所有元素轉換完」。
範例 3:使用 peek 來除錯
numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("通過篩選:" + n))
.map(n -> n * 10)
.peek(n -> System.out.println("map 之後:" + n))
.forEach(System.out::println);
5. 實用細節
避免在串流中使用副作用
惰性可能會捉弄你,如果你預期立即執行的話。在 map、filter 或 peek 中進行副作用(寫入檔案、修改外部狀態)可能不會按照你預期的順序執行、可能不會對所有元素執行,甚至在沒有終端操作時完全不會執行。
盡可能把過濾放在前面
盡量把 filter 放在鏈的前段,以更早排除多餘元素、減少後續工作量。
只需要第一個結果?請使用對應的終端操作
若只需要第一個符合條件的元素——請呼叫 findFirst 或 findAny。這能讓串流在找到結果後立刻停止。
串流不是用來修改原始集合
串流並非用於在原始集合上新增/刪除元素。若要修改集合結構,請使用其他機制。
惰性串流的工作流程視覺化
List<String> words = List.of("cat", "dog", "elephant", "fox", "giraffe");
words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
過程如下:
| 階段 | cat | dog | elephant | fox | giraffe |
|---|---|---|---|---|---|
|
✗ | ✗ | ✓ | ✗ | ✓ |
|
— | — | ELEPHANT | — | GIRAFFE |
|
— | — | 列印 | — | 列印 |
表格:Eager 與 Lazy 方法的比較
| 方法 | 何時執行處理? | 記憶體使用 | 效能 |
|---|---|---|---|
| Eager(貪婪) | 在呼叫時立即執行 | 可能較多 | 有時較慢 |
| Lazy(惰性) | 僅在需要時 | 最小 | 通常更快 |
貪婪方法——例如你手動對集合進行多次遍歷,並建立中間清單。
惰性方法——也就是串流:在需要最終結果之前什麼都不做。
6. 使用惰性串流時的常見錯誤
錯誤 1:期待立即得到結果。 新手常以為呼叫 filter 或 map 會立刻執行。但沒有終端操作(例如 collect、forEach)就什麼也不會發生——於是出現「除錯不起作用」、「沒有任何輸出」的情況。
錯誤 2:在中間操作中加入副作用。 在 map/filter/peek 中進行寫檔、修改外部變數等副作用是個壞習慣。由於惰性與最佳化,這些動作可能不會完整執行、執行順序不如預期,或者在沒有終端操作時根本不會執行。
錯誤 3:忘了呼叫終端操作。 寫了串流鏈但沒有以 collect、forEach 等方式收尾。結果就是「一片寂靜」。
錯誤 4:以為所有元素都會被處理。 像 findFirst 或 anyMatch 這類操作會在取得第一個結果時中止管線。其他元素不會被處理——於是讓人困惑「為什麼我的 println 沒有對所有元素執行?」。
錯誤 5:用串流來更改原始集合。 串流不適合用來修改原始集合(新增/刪除元素)。請使用集合的專用方法或迭代器。
GO TO FULL VERSION