CodeGym /課程 /JAVA 25 SELF /使用 Stream API 的函數式風格

使用 Stream API 的函數式風格

JAVA 25 SELF
等級 49 , 課堂 3
開放

1. 命令式 vs 函數式風格

先從一個簡單的問題開始:為什麼需要所謂的「函數式風格」?它比透過迴圈的傳統做法好在哪裡?而「函數式風格」在 Java 中到底是什麼意思?

命令式風格

命令式風格——就是你告訴電腦如何一步一步完成任務。比方說,若要從字串清單得到各字串的長度,只保留奇數長度,並依遞減排序,你大概會這樣寫:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

在這裡我們明確建立了中介清單、手動加入元素、進行排序——一切都逐步完成。

函數式風格

函數式風格——則是描述你想要什麼結果,而不是怎麼做。在 Java 中,這是透過 Stream API 來實現的:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");

List<Integer> result = words.stream()
    .map(String::length)
    .filter(len -> len % 2 != 0)
    .sorted(Comparator.reverseOrder())
    .toList();

System.out.println(result); // [7, 5, 3]

在這裡就像在搭一條「資料處理輸送帶」:先把單字轉為長度(map),接著過濾奇數(filter),再排序(sorted)。所有這些都在同一條鏈上完成,沒有明確的中介集合與迴圈。

比較:迭代式 vs 函數式風格

在習慣的命令式做法裡,我們寫一個迴圈,並在裡面一步步告訴電腦要做什麼:走訪每個元素、檢查條件、加入新清單或輸出到螢幕。程式能運作,但行數較多;問題越複雜,越難閱讀與維護。

函數式風格允許描述的是我們想要的結果,而不是過程本身。取代冗長的迴圈,我們建立一條操作鏈:過濾、轉換、收集結果。這更簡短、直觀,也降低出錯風險,因為更少「手動」操作可變集合。

當然也有另一面。對新手來說,這樣的鏈可能顯得難懂:幾個 lambda 連在一起,有時比單純的迴圈更難讀。因此函數式風格在精簡與表達力上更勝一籌,但需要時間習慣與累積經驗。

2. Stream API 的基本操作

Stream API 不只是「新型態的迴圈」,而是一整套用函數式風格處理集合的工具。讓我們先了解基本操作。

如何取得 Stream?

List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();

中介操作

  • map — 轉換串流中的元素
  • filter — 依條件過濾元素
  • flatMap — 將每個元素變成一條子串流並「攤平」
  • sorted — 排序
  • distinct — 去除重複
  • limit / skip — 限制/略過元素

終端操作

  • forEach — 對每個元素執行動作
  • collect — 將結果收集成集合
  • reduce — 將串流歸約為單一值(例如總和)
  • count — 計算元素數量
  • anyMatchallMatchnoneMatch — 條件檢查

範例:處理鏈

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

List<String> filtered = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList();

System.out.println(filtered); // [BORIS, VIKA, DASHA]

「流水線」視覺化示意:

[Anna, Boris, Vika, Gleb, Dasha]
   | filter (length>3)
[Boris, Vika, Dasha]
   | map (toUpperCase)
[BORIS, VIKA, DASHA]
   | sorted
[BORIS, VIKA, DASHA]
   | toList

每個操作都不會修改原始集合——會建立新的串流。

3. 串流式執行與惰性

中介與終端操作

Stream 有兩類操作。第一類是中介操作,例如 mapfiltersorted。它們會回傳新的串流,像是在「承諾」要做些什麼,但實際上還沒有開始執行。第二類是終端操作,例如 forEachcollectreduce,這些才會真正觸發整個處理流程。重點是,在你呼叫終端操作之前,串流是「惰性的」——計算不會開始。

範例:

Stream<String> stream = List.of("a", "bb", "ccc").stream()
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    });

System.out.println("在 forEach 之前");
stream.forEach(System.out::println);

輸出:

在 forEach 之前
map: a
A
map: bb
BB
map: ccc
CCC

可以看到,mapforEach 觸發之前並不會執行。

為什麼這很棒?

這種做法讓你可以不浪費資源地組裝任意長度的轉換鏈。串流只會在確實需要時才開始工作。這使得處理龐大資料量,甚至無限序列,都不是問題。而且惰性還能節省記憶體並讓計算更有效率。

4. 實作:任務「字串 → 長度 → 奇數 → 由大到小」

讓我們一步步解題:「從字串清單得到字串長度,只保留奇數長度,並依遞減排序。」

命令式解法

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

使用 Stream API 的函數式解法

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");

List<Integer> result = words.stream()
    .map(String::length) // 將字串轉成其長度
    .filter(len -> len % 2 != 0) // 只保留奇數長度
    .sorted(Comparator.reverseOrder()) // 依遞減排序
    .toList(); // 收集成 List(Java 16+)

System.out.println(result); // [7, 5, 3]

說明:

  • map(String::length) — 對每個字串取得其長度。
  • filter(len -> len % 2 != 0) — 只保留奇數長度。
  • sorted(Comparator.reverseOrder()) — 依遞減排序。
  • toList() — 將串流收集到新清單。
類比

就像你在工廠搭建一條「輸送帶」:每個站點都以新的方式處理零件,並且只在最後把一切裝進盒子。

5. 更多範例:map、filter、forEach、collect

範例 1:過濾並列印

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

names.stream()
    .filter(name -> name.contains("a"))
    .forEach(System.out::println);
// 輸出:Anna, Dasha

範例 2:轉換並收集成 Set

Set<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toSet());

System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]

範例 3:取得所有字串長度的總和

int totalLength = names.stream()
    .mapToInt(String::length)
    .sum();

System.out.println("總長度: " + totalLength);

範例 4:使用 Predicate 與 Function

Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "哈囉," + name + "!";

names.stream()
    .filter(longName)
    .map(greet)
    .forEach(System.out::println);
// 哈囉,Boris!
// 哈囉,Dasha!

6. 使用 Stream API 的函數式風格特性

避免可變狀態

Stream API 鼓勵「純」函數——沒有副作用。也就是說,最好不要在 lambda 中修改外部變數。

不佳:

List<String> result = new ArrayList<>();
names.stream()
    .filter(name -> name.startsWith("A"))
    .forEach(result::add); // side-effect!

較佳:

List<String> result = names.stream()
    .filter(name -> name.startsWith("A"))
    .toList();

操作的組合

你可以組合出很長的操作鏈,把 mapfiltersorted 等方法串在一起。重點是別過度:如果鏈長到超出一個畫面,可能就該拆成幾段。

惰性計算

在到達終端操作之前,Stream API 什麼也不做。這能節省資源並建立高效率的資料處理管線。

來源集合的不變性

Stream 不會修改來源集合!所有轉換都會回傳新的串流/集合。

7. 何時使用 Stream API

Stream API 很適合在以下情況:

  • 需要快速處理集合(過濾、轉換、排序)。
  • 想要簡潔、可讀性高的程式碼。
  • 不想手動建立中介集合。
  • 想輕鬆加入平行化(透過 parallelStream())。

命令式風格 有時更合適,若:

  • 需要複雜邏輯,包含多層巢狀迴圈與條件。
  • 在關鍵路徑上需保留極致效能(Stream API 偶爾會稍慢)。
  • 需要處理可變狀態(例如就地更新元素)。

8. 使用 Stream API 時的常見錯誤

錯誤 1:使用 forEach 來收集合。 很多新手會用 forEach 將元素加入新的集合。這不是函數式風格!請改用 collecttoList()

錯誤 2:過早最佳化。 不要一開始就使用 parallelStream()——平行化只適用於真的很大的集合與 CPU 密集的任務。

錯誤 3:將 Stream API 與可變集合混用。 Stream API 預設是以不變資料進行作業。不該在 lambda 中修改集合元素。

錯誤 4:遺失結果。 忘了呼叫終端操作——什麼也不會發生。

錯誤 5:過度複雜的 lambda。 如果 lambda 超過一兩行——請將其抽成具名方法。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION