Java 函數式程式設計 FAQ(Lambda、Stream、Optional)
範圍 — Java 8 的函數式工具箱:lambda、方法參考、內建的函數式介面、Stream API、collector,以及
Optional。 另見:java_modern.md— Java 9–21 的語言功能;java_collection.md— stream 跑在上面的那些集合。
Stream 是 Java 8 最常被用到的功能,也是面試官探得最深的一個 —— 因為誤用(副作用、
在不對的工作負載上用 parallelStream()、明明 for 迴圈比較清楚卻硬要用 stream)
一眼就看得出來。
1) Lambda 與函數式介面 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
函數式介面是剛好只有一個抽象方法(SAM)的介面。lambda 就是它的一個實作。
// java
Runnable r = () -> System.out.println("hi"); // no args
Comparator<String> byLen = (a, b) -> a.length() - b.length();
Function<String, Integer> parse = s -> Integer.parseInt(s);
@FunctionalInterface // compiler enforces "exactly one abstract method"
interface Validator<T> { boolean test(T value); }
該認得的內建介面
| 介面 | 簽章 | 典型用途 |
|---|---|---|
Function<T,R> |
R apply(T) |
map |
BiFunction<T,U,R> |
R apply(T,U) |
merge、reduce |
Predicate<T> |
boolean test(T) |
filter、removeIf |
Consumer<T> |
void accept(T) |
forEach |
Supplier<T> |
T get() |
延遲取得預設值、orElseGet |
UnaryOperator<T> |
T apply(T) |
replaceAll |
BinaryOperator<T> |
T apply(T,T) |
reduce、Collectors.toMap 的合併函式 |
primitive 版本(IntPredicate、ToIntFunction、IntUnaryOperator……)存在的唯一
理由就是避免裝箱 —— 熱路徑上請用它們。
方法參考
四種形式,全都是 lambda 的語法糖:
// java
String::toUpperCase // unbound instance method -> s -> s.toUpperCase()
System.out::println // bound instance method -> x -> System.out.println(x)
Integer::parseInt // static method -> s -> Integer.parseInt(s)
ArrayList::new // constructor -> () -> new ArrayList<>()
Lambda 與匿名類別
| Lambda | 匿名類別 | |
|---|---|---|
this |
指向外層實例 | 指向匿名類別自己的實例 |
| 編譯成 | invokedynamic(不會每個實例多一個 class 檔) |
真的產生一個 Outer$1.class |
| 能宣告欄位嗎 | 不行 —— 不過有捕獲的 lambda 會帶著捕獲到的值,也能改動那些值指向的物件 | 可以 |
| 目標型別 | 只能是函數式介面 | 任何介面/抽象類別 |
兩者都只能捕獲**實質 final(effectively final)**的區域變數 —— lambda 用到的區域變數 不能再被指派,因為被捕獲的是值,不是變數。
2) Stream:管線 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
Stream 不是資料結構。它是跑在某個來源上的一次性管線,由延遲的中間操作與 一個急切的終端操作組成。
// java
List<String> names = people.stream() // source
.filter(p -> p.getAge() >= 18) // intermediate (lazy)
.map(Person::getName) // intermediate (lazy)
.sorted() // stateful intermediate
.limit(10) // short-circuiting
.collect(Collectors.toList()); // terminal — NOW it runs
在終端操作出現之前什麼都不會執行,而且元素是一個一個流過整條管線的(融合),
所以 filter → map → findFirst 可能只碰到一個元素。
| 類別 | 操作 |
|---|---|
| 中間(無狀態) | filter、map、flatMap、peek、mapToInt |
| 中間(有狀態) | sorted、distinct、limit、skip —— 可能要緩衝整條 stream |
| 終端 | collect、forEach、reduce、count、min/max、anyMatch、findFirst、toArray |
- 一條 stream 只能被消費一次:重複使用會丟
IllegalStateException。 map是一個變一個;flatMap則把一個攤平成很多個:
// java
List<String> allTags = posts.stream()
.flatMap(post -> post.getTags().stream()) // Stream<List<String>> -> Stream<String>
.distinct()
.toList(); // Java 16+, immutable
Reduce
// java
int total = nums.stream().reduce(0, Integer::sum); // identity + accumulator
Optional<Integer> max = nums.stream().reduce(Integer::max); // no identity -> Optional
累加函式必須符合結合律且沒有副作用,否則平行的結果會和循序的不一樣。
處理 primitive 時優先用 IntStream.sum() / average() /
summaryStatistics() —— 不用裝箱,而且 average() 回傳 OptionalDouble,
因為空的 stream 沒有平均值。
3) Collector 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
// java
import static java.util.stream.Collectors.*;
Map<Dept, List<Employee>> byDept = staff.stream().collect(groupingBy(Employee::dept));
Map<Dept, Long> headcount = staff.stream().collect(groupingBy(Employee::dept, counting()));
Map<Dept, Double> avgPay = staff.stream().collect(groupingBy(Employee::dept, averagingDouble(Employee::salary)));
Map<Boolean, List<Employee>> split = staff.stream().collect(partitioningBy(e -> e.salary() > 100_000));
String csv = staff.stream().map(Employee::name).collect(joining(", ", "[", "]"));
Map<String, Employee> byId = staff.stream().collect(toMap(Employee::id, e -> e));
兩個一直有人踩的陷阱:
- **key 重複時
toMap會丟IllegalStateException。**要傳合併函式:toMap(Employee::id, e -> e, (a, b) -> a)。 - 值是 null 時
toMap會丟NullPointerException(和HashMap.put不一樣)。 先把 null 濾掉,或自己收進一個HashMap。 Collectors.toList()對回傳清單的型別與可變性什麼都沒保證(今天它是ArrayList,但別依賴這件事)。需要可變清單用toCollection(ArrayList::new), 要明確不可修改的就用stream.toList()(16+)。
teeing(12+)讓兩個 collector 在同一趟裡跑完;mapping / filtering /
flatMapping 則可以當成 groupingBy 裡的下游 collector 組合起來。
4) 平行 Stream 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
list.parallelStream() 會把來源切開,丟到共用的 ForkJoinPool
(核心數 − 1 個 worker,整個 JVM 共用)。
只有以下條件全部成立時才用它:
- 每個元素的工作真的很吃 CPU(幾千奈秒等級),而且元素很多。
- 來源切得便宜 ——
ArrayList、陣列、IntStream.range。LinkedList或以Iterator為來源的切得很差。 - 那些 lambda 是無狀態、無副作用、符合結合律的。
- 管線裡沒有阻塞 I/O —— 阻塞會榨乾共用的池,甚至讓應用裡毫不相干的程式碼卡住。
// java
long primes = IntStream.rangeClosed(2, 5_000_000)
.parallel()
.filter(MathUtil::isPrime) // pure, CPU-bound -> a good fit
.count();
反模式:parallelStream().forEach(list::add)(沒同步的修改)、
對順序敏感的管線(forEachOrdered 會重新序列化,把好處還回去),
以及把任何會打資料庫的東西平行化。
5) Optional 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
Optional<T> 是在回傳型別上表達「這個東西合理地可能不存在」。它不是一個
通用的 null 包裝器。
// java
Optional<User> found = repo.findById(id);
String name = found.map(User::name)
.filter(n -> !n.isBlank())
.orElse("anonymous");
found.ifPresentOrElse(this::render, this::render404); // 9+
User user = found.orElseThrow(() -> new NotFoundException(id));
| 該做 | 不該做 |
|---|---|
從可能查不到東西的查找回傳 Optional |
拿它當欄位、參數或集合(集合請回傳空集合) |
預設值很貴時用 orElseGet(this::expensive) |
orElse(expensive()) —— 那個參數即使有值也會被算出來 |
orElseThrow(...) |
沒先 isPresent() 就 get() —— 同樣的 NPE,只是多繞了幾步 |
map / flatMap 串起來 |
if (o.isPresent()) { o.get() … },那只是換個樣子的 null 檢查 |
Optional 不是 Serializable,而且在熱迴圈裡包一層每次呼叫都多一次配置 ——
這就是為什麼 entity 的欄位維持原樣。
6) Stream 還是迴圈 —— 什麼時候不要用 Stream
以下情況請用迴圈:主體會改動外部狀態、需要帶副作用的 break、要用 checked
例外(lambda 丟不出來),或索引很重要。stream 真正划算的時候,是整條管線讀起來像
在講你要什麼而不是怎麼拿到,尤其是 groupBy 形狀的彙總。
// java
// Fine as a loop; a stream here is noise.
for (Order o : orders) {
if (!o.isValid()) { log.warn("skipping {}", o.id()); continue; }
process(o);
}
lambda 能丟出它的目標型別宣告的任何例外 —— Callable.call() 允許任何 checked
例外。問題在於 Stream API 用的那些介面(Function、Predicate、Consumer……)
一個都沒宣告,所以在 stream 裡面 checked 例外必須被包起來:
.map(f -> { try { return parse(f); } catch (IOException e) { throw new UncheckedIOException(e); } })。
7) 面試常見問答
Q:Stream 比迴圈快嗎? 單純的工作通常不會 —— stream 多了管線的開銷。它贏在可讀性,以及在可切分的來源上做 CPU 密集工作時的平行版本。要量測,不要想當然。
Q:延遲求值(lazy evaluation)換到什麼?
短路(findFirst、anyMatch、limit)與單趟融合:各階段之間不產生中間集合。
Q:map 和 flatMap 差在哪?
map 是 1→1;flatMap 是 1→多,然後攤平成一條 stream。
Q:lambda 可以修改區域變數嗎?
不行 —— 被捕獲的區域變數必須是實質 final。需要計數就用 AtomicInteger、陣列,
或改成正規的 reduction,不要用可變計數器。
Q:這裡的 invokedynamic 在做什麼?
編譯器產生一個呼叫點,由 JVM 在執行期把它連結到動態生成的 lambda 實作,
而不是為每個 lambda 產生一個匿名類別 —— class 檔更少,內聯也更好。
Q:對無窮 stream 用 Stream.iterate 可以嗎?
只要後面有東西把它短路掉就合法:
Stream.iterate(1, x -> x * 2).limit(10)。
Q:Stream 可以重複使用嗎? 不行。從來源重新建一條,或收集一次之後重複使用那個集合。
8) 重點檢查表
[ ] Functional interface = one abstract method; name the six built-ins
[ ] Four method-reference shapes
[ ] Lambda vs anonymous class: `this`, capture, effectively final
[ ] Lazy intermediates vs eager terminal; a stream is single-use
[ ] map vs flatMap; reduce needs associativity
[ ] groupingBy with a downstream collector; toMap's duplicate-key trap
[ ] When parallelStream helps — and the shared ForkJoinPool it borrows
[ ] Optional as a return type; orElse vs orElseGet
參考資料
- Java SE — the Stream package summary
java_modern.md— record、sealed 型別、虛擬執行緒java_collection.md— stream 背後的那些集合