skip to content
BlogZzz

[Learn] Java Stream:filter、map、reduce 與 collect

/ 6 min read

Updated:
Table of Contents

處理 collection 時,傳統 loop 經常同時負責走訪、判斷、轉換與保存結果

Stream API 把這些動作拆成可以依序組合的 operation,讓程式直接描述資料要經過哪些步驟

這篇開始前,建議先理解 Lambda 與 functional interface

這篇會專注整理以下內容

  • Stream pipeline 的組成方式
  • filter 如何篩選資料
  • map 與 flatMap 如何轉換資料
  • reduce 與 collect 如何產生結果
  • lazy evaluation 與 single-use Stream
  • 什麼情況繼續使用一般 loop 會更清楚

Stream 是資料處理流程

先準備一組學生資料

import java.util.List;
record Student(String name, int score) {
}
List<Student> students = List.of(
new Student("Amy", 82),
new Student("Ben", 55),
new Student("Cara", 91),
new Student("Dan", 60));

Stream 不會保存另一份資料,也不會直接修改原本的 collection

它描述元素要經過哪些步驟

資料來源 → intermediate operation → intermediate operation → terminal operation

例如找出及格學生的姓名

List<String> passedNames = students.stream()
.filter(student -> student.score() >= 60)
.map(Student::name)
.sorted()
.toList();
System.out.println(passedNames); // [Amy, Cara, Dan]
  • stream() 從 collection 建立 Stream
  • filter 只留下符合條件的元素
  • map 把 Student 轉成姓名 String
  • sorted 排序元素
  • toList 結束 pipeline 並產生新 List

Stream.toList() 回傳的 List 不能新增、刪除或替換元素

需要指定 collection 實作時,可以使用 collect

import java.util.ArrayList;
import java.util.stream.Collectors;
ArrayList<String> mutableNames = students.stream()
.map(Student::name)
.collect(Collectors.toCollection(ArrayList::new));

filter 留下符合條件的元素

filter 接收 Predicate<T>,每個元素都會得到 true 或 false

List<Student> passedStudents = students.stream()
.filter(student -> student.score() >= 60)
.toList();

可以把條件先命名,讓 pipeline 更接近業務語意

Predicate<Student> hasPassed = student -> student.score() >= 60;
Predicate<Student> isExcellent = student -> student.score() >= 90;
List<Student> excellentStudents = students.stream()
.filter(hasPassed.and(isExcellent))
.toList();

多個 filter 也可以依序排列

List<Student> result = students.stream()
.filter(student -> student.score() >= 60)
.filter(student -> student.name().startsWith("A"))
.toList();

filter 不應修改元素,只需要回答這個元素是否保留

map 把元素轉成另一種資料

map 接收 Function<T, R>,輸入與輸出可以是不同型別

List<String> labels = students.stream()
.map(student -> student.name() + ":" + student.score())
.toList();

這段 pipeline 的型別變化如下

Stream<Student>
↓ map
Stream<String>
↓ toList
List<String>

如果轉換結果仍然是 collection,直接使用 map 會得到巢狀結構

List<List<String>> nested = List.of("java stream", "date time").stream()
.map(text -> List.of(text.split(" ")))
.toList();

需要把每一層元素攤平時使用 flatMap

import java.util.Arrays;
List<String> words = List.of("java stream", "date time").stream()
.flatMap(text -> Arrays.stream(text.split(" ")))
.toList();
System.out.println(words); // [java, stream, date, time]

reduce 把多個元素合成一個結果

reduce 會反覆把目前結果與下一個元素合併

int totalScore = students.stream()
.map(Student::score)
.reduce(0, Integer::sum);
System.out.println(totalScore); // 288

這裡的 0 是 identity value,空 Stream 也會回傳 0

0 + 82 → 82
82 + 55 → 137
137 + 91 → 228
228 + 60 → 288

沒有提供 identity value 時,Stream 可能沒有結果,所以回傳 Optional

int highestScore = students.stream()
.map(Student::score)
.reduce(Integer::max)
.orElse(0);

reduce function 應該符合 associative,才能在不同分組與 parallel stream 中得到一致結果

加法適合 reduce,減法則不符合這個條件

(10 - 5) - 2 = 3
10 - (5 - 2) = 7

要建立 List、Map 或分組結果時,通常使用 collect,不要用 reduce 不斷複製 mutable container

import java.util.Map;
import java.util.stream.Collectors;
Map<Boolean, List<Student>> studentsByPassStatus = students.stream()
.collect(Collectors.partitioningBy(student -> student.score() >= 60));

intermediate operation 是 lazy 的

filter、map 與 sorted 都是 intermediate operation

它們會回傳新的 Stream,但在 terminal operation 出現前不會開始走訪資料

var pipeline = students.stream()
.filter(student -> {
System.out.println("檢查 " + student.name());
return student.score() >= 60;
});
// 到這裡仍然不會輸出任何內容
long count = pipeline.count();

count、toList、collect、reduce 與 forEach 都是 terminal operation,會觸發 pipeline

Stream 完成 terminal operation 後就已經 consumed,不能再次使用

var stream = students.stream();
long count = stream.count();
// stream.toList(); // IllegalStateException

需要再次處理時,從 collection 建立新的 Stream

Stream pipeline 中也應避免修改外部狀態

List<String> names = new ArrayList<>();
// 不建議使用副作用收集結果
students.stream()
.map(Student::name)
.forEach(names::add);

直接使用 toList 或 collect 更能表達目的,也不會在 parallel stream 中遇到共享狀態問題

常見錯誤

忘記 terminal operation

只有 filter 與 map 不會開始處理資料

需要用 toList、count、reduce 或其他 terminal operation 取得結果

重複使用同一個 Stream

Stream 是 single-use pipeline

terminal operation 完成後應從原始 collection 建立新的 Stream

在 pipeline 中修改共享狀態

副作用會讓處理順序與 parallel execution 更難推理

優先透過 map 轉換,並用 toList、reduce 或 collect 產生結果

結論

  • Stream pipeline 由資料來源、intermediate operation 與 terminal operation 組成
  • filter 負責篩選,map 負責轉換,flatMap 負責攤平巢狀結果
  • reduce 適合把元素合併成單一結果,collect 適合建立 collection 或分組結果
  • intermediate operation 是 lazy,terminal operation 才會觸發資料走訪
  • Stream 完成 terminal operation 後不能重複使用
  • pipeline 中應避免修改共享狀態
  • Stream 讓簡單的資料轉換更容易閱讀,但不需要取代所有 loop

參考資料