National Chengchi University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

政治大學 AQI 58 25°C PM2.5 12
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 18

Stream API

filter、map、reduce、collect 與惰性求值
Interaction實作 30 minute(s)

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):

  1. 中間操作只是在組裝管線。呼叫 filter、map 時,傳進去的 Lambda 一次都不會執行
  2. 呼叫終端操作時,管線才開始跑。沒有終端操作的 Stream 什麼事都不會做
  3. 元素是一個一個穿過整條管線的。不是「先把所有元素都 filter 完,再全部 map」,而是第一個元素走完 filter、map,第二個元素才進來
  4. 能提早結束就提早結束。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 完全沒有被處理。

想一想:管線什麼時候執行

1.

下面的程式會印出什麼?

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("!");
    }
}
2.

下面的程式會印出什麼?

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 + " "));
    }
}
3.

下面的程式會印出什麼?

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);
    }
}
第 1 次作答

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

1.

下面的程式會怎樣?

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);
    }
}
2.

下面的程式會印出什麼?

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);
    }
}
3.

下面的程式會印出什麼?

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);
    }
}
4.

下面的程式會印出什麼?

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);
    }
}
第 1 次作答

動手寫:學生成績統計

用 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
這一課完成了!
看我的學習進度
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。