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 — 計算元素數量
- anyMatch、allMatch、noneMatch — 條件檢查
範例:處理鏈
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 有兩類操作。第一類是中介操作,例如 map、filter 或 sorted。它們會回傳新的串流,像是在「承諾」要做些什麼,但實際上還沒有開始執行。第二類是終端操作,例如 forEach、collect 或 reduce,這些才會真正觸發整個處理流程。重點是,在你呼叫終端操作之前,串流是「惰性的」——計算不會開始。
範例:
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
可以看到,map 在 forEach 觸發之前並不會執行。
為什麼這很棒?
這種做法讓你可以不浪費資源地組裝任意長度的轉換鏈。串流只會在確實需要時才開始工作。這使得處理龐大資料量,甚至無限序列,都不是問題。而且惰性還能節省記憶體並讓計算更有效率。
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();
操作的組合
你可以組合出很長的操作鏈,把 map、filter、sorted 等方法串在一起。重點是別過度:如果鏈長到超出一個畫面,可能就該拆成幾段。
惰性計算
在到達終端操作之前,Stream API 什麼也不做。這能節省資源並建立高效率的資料處理管線。
來源集合的不變性
Stream 不會修改來源集合!所有轉換都會回傳新的串流/集合。
7. 何時使用 Stream API
Stream API 很適合在以下情況:
- 需要快速處理集合(過濾、轉換、排序)。
- 想要簡潔、可讀性高的程式碼。
- 不想手動建立中介集合。
- 想輕鬆加入平行化(透過 parallelStream())。
命令式風格 有時更合適,若:
- 需要複雜邏輯,包含多層巢狀迴圈與條件。
- 在關鍵路徑上需保留極致效能(Stream API 偶爾會稍慢)。
- 需要處理可變狀態(例如就地更新元素)。
8. 使用 Stream API 時的常見錯誤
錯誤 1:使用 forEach 來收集合。 很多新手會用 forEach 將元素加入新的集合。這不是函數式風格!請改用 collect 或 toList()。
錯誤 2:過早最佳化。 不要一開始就使用 parallelStream()——平行化只適用於真的很大的集合與 CPU 密集的任務。
錯誤 3:將 Stream API 與可變集合混用。 Stream API 預設是以不變資料進行作業。不該在 lambda 中修改集合元素。
錯誤 4:遺失結果。 忘了呼叫終端操作——什麼也不會發生。
錯誤 5:過度複雜的 lambda。 如果 lambda 超過一兩行——請將其抽成具名方法。
GO TO FULL VERSION