National Yang Ming Chiao Tung 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 69 24°C PM2.5 26
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 17

Lambda 表示式

函式介面、lambda 語法、方法參照與 Comparator
Interaction實作 25 minute(s)

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 行用到的區塊寫法會一行一行執行。

想一想:什麼時候執行、哪些介面可以用

1.

下面的程式會印出什麼?

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

下面的程式會怎樣?

interface Shape {
    double area();
    double perimeter();
}

public class Main {
    public static void main(String[] args) {
        Shape s = () -> 1.0;
        System.out.println(s.area());
    }
}
3.

下面的程式會怎樣?

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

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

1.

下面的程式會印出什麼?

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

下面的程式會怎樣?

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

下面的程式會印出什麼?

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

s -> s.toUpperCase() 可以改寫成哪一個方法參照?

第 1 次作答

動手寫:整理城市名單

用本課學到的 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,否則編譯錯誤
這一課完成了!
Next lesson:Stream API
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。