Learning Objectives
- 把匿名類別改寫成 Lambda,說明 Lambda 是一段可以傳來傳去、之後才執行的程式
- 寫出各種 Lambda 語法:沒有參數、一個參數、多個參數、單一運算式與區塊
- 說明函式介面是只有一個抽象方法的介面,並使用 java.util.function 的常用介面
- 把只轉呼叫現成方法的 Lambda 改寫成四種方法參照
- 用 Comparator.comparing、thenComparing、reversed 排序,用 forEach、removeIf、replaceAll 處理串列
- 說明 Lambda 用到的外部區域變數必須是實質上 final
為什麼要學 Lambda
很多時候,我們要交給方法的不是一筆資料,而是一段做法:排序時「怎麼比較兩個元素」、刪除時「哪些元素要刪」、按鈕被按下時「要做什麼」。
在 Java 8 之前,要把一段做法傳出去,只能寫一個類別(通常是匿名類別)把它包起來:
names.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
new Comparator<String>() { … } 叫做匿名類別(anonymous class):在需要物件的地方,當場寫一個沒有名字的類別來實作介面(或繼承類別),
並立刻 new 出它的一個物件。大括號裡就是這個類別的內容,這裡只覆寫了 compare 一個方法。
真正有意義的只有 a.length() - b.length() 這一小段,其他都是固定的格式。2014 年 3 月發表的 Java 8 加入了 Lambda 表示式(lambda expression),同一件事可以寫成:
names.sort((a, b) -> a.length() - b.length());
Lambda 可以看成「沒有名字的方法」:只寫參數和要做的事。它也是下一課 Stream API 的基礎。
本課的動畫裡,Lambda 被建立時會在右邊出現一個 lambda (a, b) -> … 物件;真正被呼叫時,左邊才會多出一個名為 lambda (a, b) -> 的框架。
請特別注意這兩個時間點的差別:建立 Lambda 不會執行它,要等到有人呼叫,它才會執行。
從匿名類別到 Lambda
同一個「依長度比較」的 Comparator 寫兩次。第 10 行的匿名類別和第 20 行的 Lambda 都在右邊建立一個物件;排序時 sort 一再呼叫它們,左邊就會一再出現 compare 或 lambda (a, b) -> 的框架。兩種寫法排出來的結果完全相同。
Lambda 的語法與函式介面
Lambda 由三個部分組成:參數列、箭頭 ->、本體。
| 寫法 | 意思 |
|---|---|
() -> 42 |
沒有參數,小括號不能省略 |
x -> x * 2 |
只有一個參數時,小括號可以省略 |
(a, b) -> a + b |
多個參數要用小括號 |
(int a, int b) -> a + b |
也可以寫出參數型別(要寫就全部都寫) |
(a, b) -> { int s = a + b; return s; } |
本體是區塊:用大括號,要回傳值時自己寫 return |
本體是單一運算式時,運算式的結果就是回傳值,不寫 return、也不加分號;本體是區塊時,寫法和一般方法一樣。
通常不必寫參數型別,因為編譯器會從 Lambda 被指定給的型別推斷出來(型別推斷)。例如 Comparator<String> c = (a, b) -> …,編譯器知道 compare 的兩個參數是 String,所以 a、b 就是 String。
那 Lambda 可以指定給哪些型別?答案是函式介面(functional interface):只有一個抽象方法的介面。Lambda 的本體就是那個抽象方法的實作。
@FunctionalInterface
interface Calc {
int apply(int a, int b); // 唯一的抽象方法
}
Calc add = (a, b) -> a + b; // 這個 Lambda 就是 apply 的實作
int r = add.apply(3, 4); // 呼叫 apply,才會執行 a + b
@FunctionalInterface可以不寫;寫了之後,如果介面不小心有了第二個抽象方法,編譯器會直接報錯default方法與static方法有實作,不算抽象方法,所以不影響是不是函式介面- 排序用的
Comparator、Comparable,以及Runnable,都是函式介面 - 把 Lambda 指定給有兩個以上抽象方法的介面,會編譯錯誤(javac:
… is not a functional interface)
把運算當成引數傳給方法
compute 不知道自己要做加法還是乘法,它只負責呼叫 op.apply(x, y)。第 14~21 行建立三個 Lambda 時都沒有執行;要等到第 9 行呼叫 op.apply,才會跳進 lambda (a, b) -> 框架。注意第 25 行用到的區塊寫法會一行一行執行。
想一想:什麼時候執行、哪些介面可以用
下面的程式會印出什麼?
import java.util.function.*;
public class Main {
public static void main(String[] args) {
Supplier<String> s = () -> {
System.out.print("B");
return "C";
};
System.out.print("A");
System.out.print(s.get());
}
}
下面的程式會怎樣?
interface Shape {
double area();
double perimeter();
}
public class Main {
public static void main(String[] args) {
Shape s = () -> 1.0;
System.out.println(s.area());
}
}
下面的程式會怎樣?
@FunctionalInterface
interface Greeter {
String greet(String name);
default String hello() { return greet("你"); }
}
public class Main {
public static void main(String[] args) {
Greeter g = n -> "嗨," + n;
System.out.println(g.hello());
}
}
java.util.function 的常用介面
每次都自己定義函式介面很麻煩。java.util.function 套件已經準備好各種常見的「形狀」,依照「輸入幾個、回傳什麼」挑選:
| 介面 | 抽象方法 | 意思 | 例子 |
|---|---|---|---|
Function<T, R> |
R apply(T t) |
輸入 T,回傳 R | s -> s.length() |
Predicate<T> |
boolean test(T t) |
判斷是不是 | n -> n % 2 == 0 |
Consumer<T> |
void accept(T t) |
只接收,不回傳 | s -> System.out.println(s) |
Supplier<T> |
T get() |
不接收,只回傳 | () -> "Hello" |
BiFunction<T, U, R> |
R apply(T t, U u) |
兩個輸入,回傳 R | (w, h) -> w * h |
UnaryOperator<T> |
T apply(T t) |
輸入、輸出同型別 | s -> s.toUpperCase() |
BinaryOperator<T> |
T apply(T a, T b) |
兩個同型別輸入,回傳同型別 | (a, b) -> a + b |
- 型別參數只能放類別,所以整數要寫
Integer,不能寫int。另外也有直接處理基本型別的版本,例如IntPredicate、IntFunction<R>、ToIntFunction<T> - 呼叫時用的方法名稱依介面而定:
apply、test、accept、get。看到介面名稱就知道要呼叫哪一個 - 這些介面還有組合用的
default方法,例如f.andThen(g)(先做 f 再做 g)、p.negate()(相反的判斷)、p.and(q)、p.or(q)
六個常用的函式介面
第 6~11 行建立六個 Lambda,右邊會出現六個 Lambda 物件,但左邊沒有任何 Lambda 框架。從第 13 行開始,每呼叫一次 apply、test、accept、get,就有一個 Lambda 被執行。第 24 行的 andThen 會連續執行兩個 Lambda。
方法參照
很多 Lambda 只做一件事:把參數原封不動交給一個現成的方法。這時可以改寫成更短的方法參照(method reference),格式是 類別或物件::方法名稱:
| 形式 | 例子 | 等同的 Lambda |
|---|---|---|
| 類別::static 方法 | Integer::parseInt |
s -> Integer.parseInt(s) |
| 物件::方法 | System.out::println |
s -> System.out.println(s) |
| 類別::實例方法 | String::length |
s -> s.length() |
| 類別::new(建構子) | ArrayList::new |
() -> new ArrayList<>() |
第三種最容易搞混:length() 不是 static 方法,所以第一個參數會變成「呼叫方法的那個物件」。
同理,String::compareTo 可以當成 Comparator<String>,等同 (a, b) -> a.compareTo(b)。
方法參照後面不加小括號:寫 String::length,不是 String::length()。
Lambda 除了轉呼叫還要多做事(例如 s -> s.length() + 1)時,就沒辦法改寫成方法參照,照樣寫 Lambda 即可。
四種方法參照
第 8~14 行建立四個方法參照,右邊的物件會直接顯示 Integer::parseInt 這樣的寫法。方法參照直接呼叫現成的方法,所以動畫裡不會出現 lambda 框架;用法和 Lambda 完全一樣。
Comparator 的組合與串列的 Lambda 方法
排序規則常常是「先比這個,一樣的話再比那個」。Comparator 提供幾個方法,讓你組合排序規則,不必自己寫 compare:
Comparator.comparing(String::length) // 取出長度來比較,由小到大
Comparator.comparing(String::length)
.thenComparing(s -> s) // 長度一樣時,再依字母順序
Comparator.comparing(String::length).reversed() // 整個順序反過來
Comparator.comparing(取出鍵的方法):比較時先用這個方法取出每個元素的「鍵」,再比較鍵。搭配第 11 課的 record,常見的寫法是Comparator.comparing(Student::score)thenComparing(…):只有前一個條件比出「一樣」時,才會用到它reversed():把目前組合好的順序整個反過來List.sort是穩定排序:比較結果一樣的元素,會維持原本的先後
自己寫比較時,常看到 (a, b) -> a - b。數字很大時相減可能溢位,算出錯的正負號,比較安全的寫法是 Integer.compare(a, b)。
List 也有幾個直接接受 Lambda 的方法,不必自己寫迴圈:
| 方法 | 參數型別 | 作用 |
|---|---|---|
list.forEach(x -> …) |
Consumer |
對每個元素做一件事 |
list.removeIf(x -> …) |
Predicate |
刪掉讓 Lambda 回傳 true 的元素 |
list.replaceAll(x -> …) |
UnaryOperator |
每個元素換成 Lambda 回傳的值 |
removeIf、replaceAll 會修改串列本身,所以只能用在可以修改的串列(例如 new ArrayList<>(…));對 List.of(…) 建立的串列使用會拋出 UnsupportedOperationException。
組合排序規則,再刪除、轉換、印出
看第 10、11 行:thenComparing 的 Lambda 只有在兩個字串長度相同時才會被呼叫。第 16~18 行的 removeIf、replaceAll、forEach 會對串列的每個元素各呼叫一次 Lambda。
Lambda 用到外面的變數:必須是實質上 final
Lambda 可以讀取外面的區域變數:
int base = 10;
Function<Integer, Integer> addBase = x -> x + base; // 用到外面的 base
System.out.println(addBase.apply(1)); // 11
但有一個規定:被 Lambda 用到的區域變數,必須是 final 或實質上 final(effectively final),也就是宣告並給值之後,再也沒有被修改過。 下面兩種寫法都會編譯錯誤:
int base = 10;
Function<Integer, Integer> addBase = x -> x + base;
base = 20; // 在 Lambda 外面改:base 不再是實質上 final
int count = 0;
list.forEach(s -> count++); // 在 Lambda 裡面改:同樣不行
javac 的錯誤訊息是 local variables referenced from a lambda expression must be final or effectively final。
Lambda 可能在很久以後、甚至在別的方法裡才被執行,它拿到的是變數當時的值,而不是變數本身;規定變數不能再改,就不會出現「Lambda 看到的到底是哪一個值」的混亂。
幾個相關的規則:
- 這個限制只針對區域變數(含方法的參數)。物件的欄位、static 欄位可以在 Lambda 裡讀寫
- 想在
forEach裡累加,通常代表該換一種寫法:用一般的 for-each 迴圈,或用下一課的 Stream(sum()、count()) - Lambda 的參數不能和外面已經存在的區域變數同名:
int x = 1;之後寫x -> x * 2會編譯錯誤(variable x is already defined) - 匿名類別用到外面的區域變數時,也有同樣的規定
想一想:方法參照、排序與實質上 final
下面的程式會印出什麼?
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("bb", "a", "ccc", "dd"));
words.sort(Comparator.comparing(String::length).reversed());
System.out.println(words);
}
}
下面的程式會怎樣?
import java.util.function.*;
public class Main {
public static void main(String[] args) {
int base = 10;
Function<Integer, Integer> f = x -> x + base;
base = 20;
System.out.println(f.apply(1));
}
}
下面的程式會印出什麼?
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> nums = new ArrayList<>(List.of(5, 12, 8, 3, 20));
nums.removeIf(n -> n < 10);
nums.replaceAll(n -> n / 2);
System.out.println(nums);
}
}
s -> s.toUpperCase() 可以改寫成哪一個方法參照?
動手寫:整理城市名單
用本課學到的 removeIf、sort、replaceAll、forEach 完成四個 TODO,不要自己寫迴圈。執行後的輸出要和預期相同。
重點回顧
- Lambda 是一段可以傳來傳去、之後才執行的程式:建立時不執行,被呼叫時才執行
- 語法是「參數 -> 本體」:一個參數可省略小括號,單一運算式的結果就是回傳值,區塊本體要自己寫
return;參數型別通常由編譯器推斷 - Lambda 只能指定給函式介面:只有一個抽象方法的介面(
default、static方法不算),可加@FunctionalInterface讓編譯器檢查 java.util.function的常用介面:Function(apply)、Predicate(test)、Consumer(accept)、Supplier(get)、BiFunction、UnaryOperator- 方法參照有四種:
Integer::parseInt、System.out::println、String::length、ArrayList::new Comparator.comparing取鍵比較,thenComparing處理平手,reversed反轉順序;List的forEach、removeIf、replaceAll直接接受 Lambda- Lambda 用到的外部區域變數必須是實質上 final,否則編譯錯誤