整理 Effective Java 書中 Item 46: Prefer side-effect-free functions in streams 心得筆記

主旨:Streams 核心原則就是「無副作用」

Java 的 streams API 雖然語法簡潔、功能強大,但若沒有掌握其「函數式編程」的核心精神,寫出來的程式碼可能又臭又長、難讀又難維護。本篇要講的,就是 streams 最重要的設計原則:偏好無副作用的函式(side-effect-free functions)

簡單來說,「副作用」是指函式除了回傳值之外,還會修改外部狀態。例如把某個值加進 map 或 list,就是副作用。真正的函數式風格應該避免這種做法,讓每個階段的處理都只是資料的轉換,不牽涉修改其他東西。

點出問題:用 forEach 做事是錯誤的用法

來看一個寫得不好的範例,這段程式碼是為了統計文字檔中每個單字出現的次數:

// 壞例子:用了 stream 但沒用對風格
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word -> {
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    });
}

雖然語法上用了 streams、lambdas、method reference,但實際上這段完全是用迴圈的思維在操作。整個計算是靠外部的 freq map 來實作,這樣的寫法 有副作用,而且不能平行處理、可讀性也不佳。

範例:正確的寫法用 collect

來看一個正確的版本,只用一行就能做出一樣的事情,還更清楚易讀:

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

這裡用 Collectors.groupingByCollectors.counting(),不但沒有副作用,還讓意圖更清晰:我要把所有單字轉小寫後分組,然後統計每組的數量。

劃重點:什麼情況下要避免副作用?

在 stream 中,只要你在 lambda 裡面修改外部變數、收集資料進外部集合、操作外部資源,幾乎都是副作用。最常見的就是錯把 forEach 拿來做邏輯處理,而不是純輸出。以下是正確用法和錯誤用法的區別:

功能錯誤用法(有副作用)正確用法(無副作用)
統計單字出現次數forEach + mergegroupingBy + counting
累積字串在 lambda 中修改 StringBuilderCollectors.joining()
對集合做加總外部定義 sum 然後在 lambda 裡加reduce()mapToInt().sum()

真實世界範例:熱門單字前十名

假設你已經算出每個單字的出現次數,想印出出現頻率最高的十個單字:

List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

這邊用 comparing(freq::get).reversed() 來排序,然後取前十個。全程無副作用,清楚易懂,而且能輕鬆平行化。

小結:Streams 是風格,不只是語法糖

  • 不要把 streams 當成只是另一種 for-each 寫法。
  • Lambda 裡面不要修改外部狀態,保持函式純粹。
  • 把複雜的計算邏輯交給 Collectors
  • forEach 只適合最後印資料,不要用來做邏輯處理
  • 需要學會用 groupingBytoMapjoining 等 collector,它們是讓 streams 發揮最大效益的關鍵。

學會了這個原則之後,你會發現 stream 不只是語法漂亮,更是寫出簡潔、安全、高效 Java 程式碼的最佳工具。