認識 Lambda/Closure(7)JDK8 Lambda 語法


English

你可以在 Google Play 或 Pubu 購買 Java Lambda Tutorial 系列文章的電子書。
終於要來介紹 JDK8 Lambda 語法了。在 JDK8 中要表示 (x -> x * 2),基本上,可以寫為以下的形式:
(Integer x) -> x + 2
別忘了,Java 是靜態定型語言,所以在這邊型態宣告是必要的。在 認識 Lambda/Closure(4) 中我們談過,對靜態定型語言來說,類型推斷(Type inference)對採用 Lambda/Closure 時是很重要的特性。當然,JDK8 必然得提供更強的類型推斷,如此在某些場合中,就可以省略型態宣告。先別急,本文介紹才剛開始。即便如此,以上的語法比起 認識 Lambda/Closure(6) 中的匿名類別來說,語法上還是簡潔許多了。
有了 JDK8 Lambda,要寫個 compose 方法來做函數合成,就可以如下撰寫:
public static <A, B, C> Func<A, C> compose(Func<A, B> f, Func<B, C> g) {
    return x -> g.apply(f.apply(x));
}
我們仍然需要 認識 Lambda/Closure(6) 中定義的 Func 介面,然而函數本體的實作清楚多了。如果你不看 ".apply" 的部份,x -> g.apply(f.apply(x)) 就會是 x -> g(f(x)),可清楚地表達出我們的意圖。使用 compose 來做函數合成 g(f(x)),其中 f(x) = x + 2g(y) = y * 3 的話,則可以寫為:
compose((Integer x) -> x + 2, (Integer y) -> y * 3);
以下再次列出 認識 Lambda/Closure(6) 中使用匿名類別的寫法,比較一下兩個程式碼,你會選擇使用哪個呢?
compose(
    new Func<Integer, Integer>() {
        public Integer apply(Integer x) {
            return x + 2;
        }
    },
    new Func<Integer, Integer>() {
        public Integer apply(Integer y) {
            return y * 3;
        }
    }
);
JDK8 Lambda 的通用語法是由參數列、箭號 token -> 與函式本體組成。以下是 Lambda 表示式的兩個例子:
// 計算 x 與 y 的和
(int x, int y) -> x + y

// 不帶引數,直接傳回整數 42
() -> 42
在 JDK8 Lambda 中,本體可以是單一運算式或者是陳述區塊。例如:
// 取得字串並輸出至主控台,沒有傳回值
(String s) -> { out.println(s); }

// 取一個整數並傳回一個整數
(Integer x) -> {
    Integer result;
    ...other statements
    ...
    return result;
};
區塊可以由數個陳述句組成,不過基本上不建議如此使用。在運用 Lambda 時,儘量使用簡單的運算式會是比較好的。如果你的實作比較複雜,還有其他方式可以運用到 JDK8 Lambda 的好處,之後的文章就會介紹到。
在一些語言中,Lambda 表示式本身就具備型態,像是 JavaScript 中匿名函式會是 Function 的實例。在 JDK8 中,Lambda 表示式(或陳述句)本身是中性的。如果沒有目標型態(Target type),Lambda 表示式不代表任何一種物件。如何定義 Lambda 表示式的目標型態呢?Java 並不是天生就具備一級函式的語言。在 認識 Lambda/Closure(5) 中我們看過,避免增加一個複雜的型態系統,以及保持與過往 API 的相容性,是 Java 中採用 Lambda 時的兩個重要目標。JDK8 並沒有導入新的函式型態,而是定義函式介面(Functional interface)作為 Lambda 表示式的代表型態。函式介面是僅具單一抽象方法的介面,許多現存的介面都是這種介面,像是 RunnableCallableComparator 等。
public interface Runnable {
    void run();
}

public interface Callable<V> {
    V call() throws Exception;
}

public interface Comparator<T> {
    int compare(T o1, T o2);
}
認識 Lambda/Closure(6) 中定義的 Func 介面也是函式介面。Lambda 表示式的目標型態,會從對應的函式介面推斷而來。例如,以下的 Lambda 運算式會是 Func 的實例:
Func<Integer, Integer> func = x -> x * 2;
在這邊型態推斷發揮了作用,參數 x 以及傳回值的型態而從泛型宣告與方法簽署(Method signature)推斷而來,所以在上面的 Lambda 表示式中不需要宣告型態。如果有個函式介面定義如下:
public interface Function<P, R> {
    R call(P p);
}
在以下的範例中,同樣是 (x -> x * 2) 這個表示式,實際上會成為 Function 的實例,參數與傳回值型態則都會是 Double
Function<Double, Double> f2 = x -> x * 2;
所以,Lambda 表示式本身是中性的,它本身無關乎函式介面的名稱,它只關心方法簽署,但忽略方法名稱。
函式介面是僅具單一抽象方法的介面,不過有時候會難以直接看出介面是否為函式介面。例如,介面可能有預設方法(JDK8 的新特性)、可能繼承其他介面、重新定義了某些方法等,這些都會使得確認介面是否為函式介面更為困難。有個新的標註 @FunctionalInterface 被引入,它可以這麼使用:
@FunctionalInterface
public interface Func<P, R> {
    R apply(P p);
}
如果介面使用了 @FunctinalInterface 來標註,而本身並非函式介面的話,就會引發編譯錯誤。例如:
@FunctionalInterface
public interface Function<P, R> {
    R call(P p);
    R call(P p1, P p2);
}
編譯器會對此介面產生以下編譯錯誤: >@FunctionalInterface ^ Function is not a functional interface multiple non-overriding abstract methods found in interface Function 看來來,Lambda 語法不過就是匿名類別的編譯器語法蜜糖嘛!真的嗎?來看一下接下來的程式,想想看結果會如何顯示?
import static java.lang.System.out;

public class Hello {
    Runnable r1 = new Runnable() {
        public void run() {
            out.println(this);
        }
    };
    Runnable r2 = new Runnable() {
        public void run() {
            out.println(toString());
        }
    };

    public String toString() { return "Hello, world!"; }

    public static void main(String[] args) {
        new Hello().r1.run();
        new Hello().r2.run();
    }
}
結果會顯示像是 Hello$1@103368e 與 Hello$2@1f2ae62,這是因為 this 以及 toString 代表的對象,實際上會來自匿名類別對應的實例。再來看看接下來的程式,它會顯示什麼?
import static java.lang.System.out;

public class Hello {
    Runnable r1 = () -> { out.println(this); };
    Runnable r2 = () -> { out.println(toString()); };

    public String toString() { return "Hello, world!"; }

    public static void main(String[] args) {
        new Hello().r1.run();
        new Hello().r2.run();
    }
}
結果會顯示兩次的 "Hello, world!",也就是說,Lambda 表示式本體中的 thistoString 實際參考對象,是來自當時包含它們的環境,也就是 Hello 實例。也注意到,先前定義的 compose 方法中,參數列上並不需要 final 關鍵字。在 認識 Lambda/Closure(5) 中我們看過,如果要在匿名類別中使用外在的區域變數,Java 的編譯器會強制你在區塊變數加上 final,即使變數實際上於匿名類別中並不會做任何修改。JDK8 放寬了這個限制,如果變數本身等效於 final 區域變數,也就是說,如果變數不會在 Lambda 表示式中有重新指定的重作,就可以不用加上 final 關鍵字。
不過,我們可以在 Lambda 表示式中改變被捕捉的變數值嗎?像是在 JavaScript 或 Scala 中可以做到的事情?因為可重新指定的閒置變數(Free variable)也代表著可變的狀態,而可變狀態代表著在並行程式設計(JDK8 會想要採用 Lambda 的理由之一)會有鎖定問題,JDK8 特意禁止你捕捉可變動的區域變數。你無法在 Lambda 表示式中改變被捕捉的變數值。
你已經看過 JDK8 Lambda 的基本語法了,如你到目前看到的,與現有 API 保持相容性也是 Java 中採用 Lambda 的目標之一。Java 是一門古老且具有一大堆 API 的語言,它會採用什麼策略來解決這個問題?這是下一篇文章所要討論的內容。