Learning Objectives
- 把「篩選、排序、轉換」的迴圈改寫成 Stream 管線,並說出兩種寫法的差別
- 從集合、陣列、Stream.of、IntStream.range 建立 Stream
- 分辨中間操作(filter、map、sorted、distinct、limit、skip、peek)與終端操作(forEach、collect、count、reduce、sum、average、min、max、anyMatch、findFirst)
- 說明惰性求值:沒有終端操作就不會執行,元素一個一個穿過整條管線
- 說明 Stream 只能使用一次、不會修改原本的集合,並用 Optional 處理可能沒有結果的情況
- 用 Collectors 的 toList、joining、groupingBy、counting、partitioningBy、toMap 收集結果
為什麼要學 Stream
處理一批資料時,常見的需求是「挑出符合條件的、排序、取出某個欄位、算總和」。用迴圈寫,要自己準備暫存的串列、自己控制每一步; 用 Java 8 加入的 Stream API,可以把這些步驟接成一條管線(pipeline):
List<String> names = students.stream() // 1. 資料來源
.filter(s -> s.score() >= 80) // 2. 中間操作:篩選
.sorted(Comparator.comparing(Student::score)) // 2. 中間操作:排序
.map(Student::name) // 2. 中間操作:轉換
.toList(); // 3. 終端操作:收集結果
一條管線分成三段:
| 部分 | 作用 | 例子 |
|---|---|---|
| 資料來源 | 從集合、陣列等產生 Stream | list.stream()、Arrays.stream(arr) |
| 中間操作 | 回傳新的 Stream,可以一直接下去 | filter、map、sorted |
| 終端操作 | 產生最後的結果,管線到此結束 | toList、count、forEach |
Stream 不是一種新的集合:它不存資料,只描述「資料要怎麼一步一步處理」。每個步驟要做的事用上一課的 Lambda 或方法參照傳進去。
本課的範例用 record 表示學生資料(第 11 課):record Student(String name, int score, String dept) {},
s.score() 取分數、Student::name 是取姓名的方法參照。
同一個需求:迴圈與 Stream
需求是「資工系 80 分以上的學生,依分數由高到低列出姓名」。第 13~24 行用迴圈,需要兩個暫存的串列;第 27~31 行用 Stream,一條管線就完成。注意 Stream 那段的執行順序:filter 的 Lambda 先對全部學生跑完,map 才開始,因為 sorted 必須看過全部元素才能排序。
資料來源、中間操作與終端操作
建立 Stream 的常見方式:
list.stream() // 集合(List、Set…)
Arrays.stream(new int[]{4, 9}) // 陣列;int[] 會得到 IntStream
Stream.of("a", "b", "c") // 直接列出元素
IntStream.range(1, 5) // 1, 2, 3, 4(不含 5);rangeClosed(1, 5) 含 5
中間操作(回傳新的 Stream,可以接下去):
| 操作 | 作用 |
|---|---|
filter(x -> 條件) |
只留下條件為 true 的元素 |
map(x -> 新值) |
每個元素換成新的值 |
sorted()、sorted(比較器) |
排序 |
distinct() |
去掉重複(用 equals 判斷) |
limit(n)、skip(n) |
只取前 n 個、跳過前 n 個 |
peek(x -> …) |
不改變元素,只「偷看」一下,常用來印出除錯訊息 |
終端操作(產生結果,管線結束):
| 操作 | 回傳 |
|---|---|
forEach(x -> …) |
沒有回傳值,對每個元素做事 |
toList()、collect(…) |
收集成串列、Map、字串等 |
count() |
元素個數(long) |
reduce(初始值, (累積, 元素) -> 新累積) |
把所有元素合併成一個值 |
min(比較器)、max(比較器)、findFirst() |
Optional(可能沒有結果) |
anyMatch、allMatch、noneMatch |
boolean |
IntStream:Stream<Integer> 沒有 sum()、average(),要先用 mapToInt 換成專門處理 int 的 IntStream:
int total = words.stream().mapToInt(String::length).sum();
double avg = words.stream().mapToInt(String::length).average().orElse(0);
average() 回傳 OptionalDouble:串流是空的時候沒有平均可言,所以要用 orElse 給一個預設值。
各種資料來源與終端操作
每一行都是一條完整的管線。注意第 19 行的 anyMatch 與第 20 行的 findFirst:找到答案就停止,後面的元素不會再被檢查。第 16 行的 reduce 會把「目前累積的結果」和「下一個元素」交給 Lambda。
惰性求值:元素一個一個穿過管線
Stream 最重要、也最容易誤會的特性是惰性求值(lazy evaluation):
- 中間操作只是在組裝管線。呼叫
filter、map時,傳進去的 Lambda 一次都不會執行 - 呼叫終端操作時,管線才開始跑。沒有終端操作的 Stream 什麼事都不會做
- 元素是一個一個穿過整條管線的。不是「先把所有元素都 filter 完,再全部 map」,而是第一個元素走完 filter、map,第二個元素才進來
- 能提早結束就提早結束。
limit(2)拿到兩個元素、findFirst找到一個、anyMatch遇到 true,後面的元素就不會被處理
例外是 sorted:它必須先收到全部元素才能排序,所以排在 sorted 前面的操作會先對所有元素跑完。
想觀察執行順序,可以用 peek 或在 filter、map 的 Lambda 裡印出訊息。下一個動畫就是這樣做的。
惰性求值
先看第 9 行:沒有終端操作,peek 的 Lambda 一次都沒有執行。第 11~19 行組好管線之後,第 20 行印出訊息時也還沒有任何元素經過。直到第 22 行的 toList(),元素才一個一個進入 filter、map;limit(2) 收滿兩個就停止,12 和 7 完全沒有被處理。
想一想:管線什麼時候執行
下面的程式會印出什麼?
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
Stream.of("a", "b", "c").map(s -> {
System.out.print(s);
return s.toUpperCase();
});
System.out.print("!");
}
}
下面的程式會印出什麼?
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
Stream.of(1, 2, 3)
.filter(n -> {
System.out.print("f" + n + " ");
return n != 2;
})
.forEach(n -> System.out.print("e" + n + " "));
}
}
下面的程式會印出什麼?
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
int r = Stream.of(5, 3, 8)
.filter(n -> n > 10)
.findFirst()
.orElse(-1);
System.out.println(r);
}
}
Stream 只能用一次,也不會修改來源
Stream 只能使用一次。 一個 Stream 執行過終端操作之後就用完了,再對它呼叫任何操作會拋出 IllegalStateException,
訊息是 stream has already been operated upon or closed。需要再處理一次,就從來源重新呼叫 list.stream()。
所以通常不會把 Stream 存進變數,而是從來源一路接到終端操作。
Stream 不會修改原本的集合。 sorted、filter、map 都是產生新的結果,來源的串列保持原樣。
這和上一課的 list.sort(…)、removeIf、replaceAll 不同,那些方法會直接修改串列本身。
toList() 與 collect(Collectors.toList())。 toList() 是 Java 16 加入的簡寫,得到的串列不能修改(呼叫 add 會拋出 UnsupportedOperationException);
需要之後再修改的串列,用 collect(Collectors.toList()),或 new ArrayList<>(…) 包起來。
只能用一次、來源不變
第 9~14 行的管線依序經過 distinct、sorted、skip、limit,第 15 行可以看到原本的 scores 完全沒變。第 17 行建立的 Stream s 在第 18 行用掉之後,第 20 行再用一次就拋出例外,由 catch 接住。
Collectors:收集成串列、字串與 Map
collect(…) 是最有彈性的終端操作,搭配 Collectors 類別提供的收集方式使用:
| 收集方式 | 結果 | 例子 |
|---|---|---|
Collectors.toList() |
List |
[Amy, Cara] |
Collectors.joining(", ") |
String |
Amy, Ben, Cara |
Collectors.groupingBy(取鍵) |
Map<鍵, List<元素>> |
依科系分組 |
Collectors.groupingBy(取鍵, Collectors.counting()) |
Map<鍵, Long> |
每個科系幾個人 |
Collectors.partitioningBy(條件) |
Map<Boolean, List<元素>> |
及格、不及格兩組 |
Collectors.toMap(取鍵, 取值) |
Map<鍵, 值> |
姓名對應分數 |
joining還可以加開頭與結尾:joining(", ", "[", "]")groupingBy的第二個參數決定「每一組要收集什麼」:counting()計數、mapping(Student::name, toList())只收集姓名、summingInt、averagingInt加總或平均groupingBy預設產生HashMap,印出的順序依鍵的hashCode決定,不是排序,也不是加入的順序。想要依鍵排序,寫成groupingBy(取鍵, TreeMap::new, 收集方式)toMap遇到重複的鍵會拋出IllegalStateException;它產生的也是HashMap
用 Collectors 整理學生資料
每個 collect 的結果都會出現在右邊:TreeMap 的鍵依字典順序排列,partitioningBy 一定有 false、true 兩組。最後一行印出的 HashMap,順序和學生加入的順序不同。
想一想:終端操作與 Collectors
下面的程式會怎樣?
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
Stream<Integer> s = List.of(1, 2, 3).stream();
long a = s.count();
long b = s.count();
System.out.println(a + b);
}
}
下面的程式會印出什麼?
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
int r = Stream.of(1, 2, 3, 4)
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.reduce(0, Integer::sum);
System.out.println(r);
}
}
下面的程式會印出什麼?
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<String> words = List.of("hi", "to", "java", "code", "a");
Map<Integer, Long> m = words.stream()
.collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.counting()));
System.out.println(m);
}
}
下面的程式會印出什麼?
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("b", "a", "c"));
List<String> sorted = names.stream().sorted().toList();
System.out.println(names + " " + sorted);
}
}
動手寫:學生成績統計
用 Stream 完成三個 TODO,每個都寫成一條管線,不要自己寫迴圈。執行後的輸出要和預期相同。
重點回顧
- Stream 管線=資料來源 → 零個或多個中間操作 → 一個終端操作;Stream 不存資料,只描述處理的步驟
- 來源:
list.stream()、Arrays.stream(arr)、Stream.of(…)、IntStream.range(a, b) - 中間操作:
filter、map、sorted、distinct、limit、skip、peek;終端操作:forEach、toList、collect、count、reduce、min、max、anyMatch、findFirst - 惰性求值:沒有終端操作就不會執行;元素一個一個穿過整條管線;
limit、findFirst、anyMatch能提早結束;sorted要先收到全部元素 - 要
sum、average先用mapToInt換成IntStream;可能沒有結果的操作回傳Optional,用orElse給預設值 - 一個 Stream 只能用一次(否則
IllegalStateException),而且不會修改原本的集合 Collectors:toList、joining、groupingBy(加counting、mapping)、partitioningBy、toMap;要排序的分組結果用TreeMap::new