Java 8中,最重要的一個改變讓代碼更快、更簡潔,并向FP(函數式編程)打開了方便之門。下面我們來看看,它是如何做到的。
上一篇中,你看到了Java中Lambda表達式的一種形式:參數 + “->” + 表達式。如果代碼實現的邏輯一條語句完成不了,你可以寫成類似方法的形式:代碼寫在“{}”中,再加上顯式的return語句。例如:
-
(String first, String second) -> {
-
if (first.length() < second.length()) return -1;
-
else if (first.length() > second.length()) return 1;
-
else return 0;
-
}
就算一個Lambda表達式沒有參數,你也需要保留空的小括號,就像沒有參數的方法一樣:
-
() -> { for (int i = 0; i < 1000; i ++) doWork(); }
如果一個Lambda表達式的參數類型,可以根據上下文推斷出來,你可以省略它們。例如:
-
Comparator< String > comp
-
= (first, second) // Same as (String first, String second)
-
-> Integer.compare(first.length(), second.length());
這里,編譯器能夠推斷出first和second肯定是字符串類型,因為,這個Lambda表達式被賦值給了字符串Comparator。
如果Lambda表達式只有一個單獨的、可以推斷出的參數,你甚至可以省略兩邊的小括號:
-
EventHandler< ActionEvent > listener = event ->
-
System.out.println("Thanks for clicking!");
-
// Instead of (event) -> or (ActionEvent event) ->
就像你可以給方法的參數加上注解或final修飾符一樣,Lambda表達式也可以:
-
(final String name) -> ...
-
(@NonNull String name) -> ...
你永遠不能指定Lambda表達式的返回值類型,它只能從上下文去推斷出來。例如,表達式
-
(String first, String second) -> Integer.compare(first.length(), second.length())
可以用在需要int類型的上下文中。
注意,只在部分分支中有返回值,而在其他分支中沒有返回值的Lambda表達式是非法的。例如,
-
(int x) -> { if (x >= 0) return 1; }
-
// invalid Lambda expression
函數式接口
正如我們討論過的,Java中存在很多只包含代碼塊的接口,例如Runnable或Comparator。Lambda表達式向后兼容這些接口。
任何在需要只包含一個抽象方法的接口的實例的時候,你都可以用Lambda表達式。這些接口被稱為“函數式接口”。
你可能會想,為什么一個函數式接口必須只包含一個抽象方法呢?接口中所有的方法不都是抽象的嗎?實際上,接口一直都是可以重新聲明Object類中包含的方法的,比如toString或者clone,而這樣的重新聲明并不會使這些方法變成抽象的。(有些接口,為了在生成的javadoc中添加自己的注釋,而重新聲明了Object中的方法,例如可以去翻翻Comparator接口的API。)更重要的是,你馬上就會看到,在Java 8中,接口可以聲明非抽象的方法。
為了展示到成函數式接口的轉換,看看Arrays.sort方法。它的第二個參數需要一個只包含一個方法的Comparator接口的實例。簡單的給它提供一個Lambda表達式:
-
Arrays.sort(words,
-
(first, second) -> Integer.compare(first.length(), second.length()));
在幕后,Arrays.sort方法會接收到一個實現了Comparator接口的某個類的實例,調用它的compare方法就會執行Lambda表達式。管理這些實例和類是完全依賴于實現的,它比使用傳統的內部類更加有效率。最好是把Lambda表達式當成函數來看,而不是對象,并認可,它可以被賦值給一個函數式接口。
這種到接口的轉換,令Lambda表達式如此的引人注目,語法很短,很簡單。下面是另外一個例子:
-
button.setOnAction(event ->
-
System.out.println("Thanks for clicking!"));
這讀起來太簡單了!
實際上,轉型成函數式接口,是你在Java中唯一可以對Lambda表達式做的事情。在其他支持函數字面量的語言里,你可以聲明函數類型,比如(String, String) -> int,聲明這種函數類型的變量,使用這些變量保存函數表達式。在Java中,你甚至不能把Lambda表達式賦值給一個Object類型的變量,因為Object不是一個函數式接口。Java的設計者們決定嚴格堅持熟悉的接口概念,而不是在語言中添加新的函數類型。
Java API的java.util.function中定義了幾個范型的函數式接口。其中一個接口,BiFunction,描述了擁有參數T和U,返回值是R的函數。你可以把我們字符串比較的Lambda表達式保存在這種類型的變量中:
-
BiFunction< String, String, Integer > comp
-
= (first, second) -> Integer.compare(first.length(), second.length());
但是,那樣并不能幫你做排序,因為Arrays.sort方法不接受BiFunction類型的變量作為參數。如果你以前使用過FP語言,你會發現這很奇怪。 但是對Java開發者來說,這很自然。一個接口,例如Comparator,擁有一個特定的目的,而不只是一個給定參數和返回值類型的方法。Java 8保留了這種特色。當你想用Lambda表達式做事情的時候,你依然要牢記表達式的目的,并給它一個特定的函數式接口。
幾個Java 8的API用到了java.util.function中的函數式接口,將來,你也許能看到,其他地方也會用到它們。但是,請要記住,你可以很好的把Lambda表達式轉型成函數式接口,這是現今你使用的API的一部分。你也可以給任何函數式接口加上@FunctionalInterface注解,這樣做有兩個好處。一是編譯器會去檢查被注解的接口,是不是只有一個抽象方法。另一個是,在生成的javadoc頁面中,會包含類似這樣的一句話:本接口是函數式接口。這個注解不是必須的,因為根據定義,任何只有一個抽象方法的接口都是函數式接口。但使用@FunctionalInterface注解會是個不錯的主意。
最后,檢查型異常,會影響Lambda表達式轉型成函數式接口實例。如果Lambda表達式語句體中拋出了檢查型異常,這個異常需要在目標接口中的抽象方法里聲明。例如,下面的代碼就有問題:
-
Runnable sleeper = () -> { System.out.println("Zzz"); Thread.sleep(1000); };
-
// Error: Thread.sleep can throw a checkedInterruptedException
這個賦值是非法的,因為Runnable.run方法不能拋出任何異常。要修改它,你有兩個選擇。你可以在Lambda表達式語句體中捕獲這個異常。或者,你可以把這個表達式,賦值給一個抽象方法能拋出異常的接口實例。例如,Callable的call方法可以拋出任何異常,因此,你可以把上面的表達式賦值給Callable(如果你增加一個返回null的return語句)。
方法引用
有時候,已經有方法實現了你想要傳遞給其他代碼的邏輯。比如,假定任何時候按鈕被點擊,你只是想要打印事件對象,你肯定會這樣做:
-
button.setOnAction(event -> System.out.println(event));
如果能夠只把println方法傳遞給setOnAction方法,那就更好了。下面就是這樣做的:
-
button.setOnAction(System.out::println);
表達式System.out::println就是一個方法引用,它等價于x -> System.out.println(x)。
另外一個例子,假如你想忽略大小寫的給字符串排序。你可以這樣:
-
Arrays.sort(strings, String::compareToIgnoreCase)
正如你看到的,“::”操作符把對象名或類名跟方法名分隔開來。主要有三種情況:
前兩種,方法引用等價于提供方法參數的Lambda表達式。正如上文提到的,System.out::println等價于x -> System.out.println(x)。同樣的,Math::pow等價于(x, y) -> Math.pow(x, y)。最后一種情況里,第一個參數為方法的調用目標。比如,String::compareToIgnoreCase跟(x,y) -> x.compareToIgnoreCase(y)等價。
當出現多個重載的同名方法時,編譯器會根據上下文,嘗試找出你實際想用的那一個。例如,Math.max方法有兩個版本,一個的參數類型是整型,一個是雙精度型。哪一個會被用到,取決于Math::max會轉型成擁有哪種方法參數的函數式接口。和Lambda表達式一樣,方法引用并不是單獨存在的,它們總是被轉型為函數式接口。
在方法引用中,可以使用this關鍵字。例如,this::equals等價于x -> this.equals(x)。super也一樣。表達式supper::instanceMethod使用this作為目標,調用指定方法的父類版本。下面的代碼故意寫成那樣,來展示工作機制:
-
class Greeter {
-
public void greet() {
-
System.out.println("Hello, world!");
-
}
-
}
-
-
class ConcurrentGreeter extends Greeter {
-
public void greet() {
-
Thread t = new Thread(super::greet);
-
t.start();
-
}
}
當線程啟動時,它的Runnable被調用,super::greet執行父類Greeter的greet方法。(注意在內部類中,你可以像這樣使用this來指代內部類的實例:EnclosingClosing.this::method或者EnclosingClass.super::method。)
構造方法引用
除了把方法名改成new以外,構造方法引用基本和方法引用一樣。例如,Button::new是一個Button的構造方法引用。哪一個構造方法被調用,取決于上下文。想象一下,你有一個字符串列表。那么通過用每一個字符串去調用Button的構造方法,你可把字符串列表轉換成一個按鈕數組。
-
List< String > labels = ...;
-
Stream< Button > stream = labels.stream().map(Button::new);
-
List< Button > buttons = stream.collect(Collectors.toList());
stream、map和collect方法的細節不在本文范圍之內。現在,重要的是,map方法為每一個字符串,調用構造方法Button(String)。Button類有很多構造方法,但是編譯器會選擇用字符串為參數的那一個,因為它從上下文中推斷出,構造方法會被使用一個字符串參數來調用。
你可以用數組類型來組成創建方法引用。例如,int[]::new就是構造方法引用,它有一個參數:數組長度。它等價于x -> new int[x]。
數組的構造方法引用,對克服Java的限制很有用。我們不能創建一個以范型類型T為元素的數組。表達式new T[n]是不對的,因為它在編譯時,被擦除為new Object[n]。對類庫的作者來說,這是一個問題。例如,我們想擁有一個按鈕的數組。Stream接口有一個返回Object數組的方法,toArray:
-
Object[] buttons = stream.toArray();
然而,這并不能令人滿意。我們想要的是按鈕數組,而不是Object數組。stream庫用構造方法引用解決了這個問題。把Button[]::new傳遞給toArray方法: