深入理解Java 8 Lambda 方法引用,目標類型和默認方法 [復制鏈接]

2019-8-28 09:53
XGBS 閱讀:555 評論:0 贊:0
Tag:  Lambda

為什么要翻譯這個系列?

  1. 工作之后,我開始大量使用 Java
  2. 公司將會在不久的未來使用 Java 8
  3. 作為資質平庸的開發者,我需要打一點提前量,以免到時拙計
  4. 為了學習Java 8(主要是其中的 lambda 及相關庫),我先后閱讀了Oracle的 官方文檔Cay HorstmannCore Java的作者)的 Java 8 for the Really Impatient 和Richard Warburton的 Java 8 Lambdas
  5. 但我感到并沒有多大收獲,Oracle的官方文檔涉及了 lambda 表達式的每一個概念,但都是點到輒止;后兩本書(尤其是Java 8 Lambdas)花了大量篇幅介紹 Java lambda 及其類庫,但實質內容不多,讀完了還是沒有對Java lambda產生一個清晰的認識
  6. 關鍵在于這些文章和書都沒有解決我對Java lambda的困惑,比如:
    • Java 8 中的 lambda 為什么要設計成這樣?(為什么要一個 lambda 對應一個接口?而不是 Structural Typing?)
    • lambda 和匿名類型的關系是什么?lambda 是匿名對象的語法糖嗎?
    • Java 8 是如何對 lambda 進行類型推導的?它的類型推導做到了什么程度?
    • Java 8 為什么要引入默認方法?
    • Java 編譯器如何處理 lambda?
    • 等等……
  7. 之后我在 Google 搜索這些問題,然后就找到 Brian Goetz 的三篇關于Java lambda的文章(State of LambdaState of Lambda libraries version 和 Translation of lambda),讀完之后上面的問題都得到了解決
  8. 為了加深理解,我決定翻譯這一系列文章

警告(Caveats)

如果你不知道什么是函數式編程,或者不了解 mapfilterreduce 這些常用的高階函數,那么你不適合閱讀本文,請先學習函數式編程基礎(比如 這本書)。


State of Lambda by Brian Goetz

The high-level goal of Project Lambda is to enable programming patterns that require modeling code as data to be convenient and idiomatic in Java.

關于

本文介紹了 Java SE 8 中新引入的 lambda 語言特性以及這些特性背后的設計思想。這些特性包括:

  • lambda 表達式(又被成為“閉包”或“匿名方法”)
  • 方法引用和構造方法引用
  • 擴展的目標類型和類型推導
  • 接口中的默認方法和靜態方法

1. 背景

Java 是一門面向對象編程語言。面向對象編程語言和函數式編程語言中的基本元素(Basic Values)都可以動態封裝程序行為:面向對象編程語言使用帶有方法的對象封裝行為,函數式編程語言使用函數封裝行為。但這個相同點并不明顯,因為Java 對象往往比較“重量級”:實例化一個類型往往會涉及不同的類,并需要初始化類里的字段和方法。

不過有些 Java 對象只是對單個函數的封裝。例如下面這個典型用例:Java API 中定義了一個接口(一般被稱為回調接口),用戶通過提供這個接口的實例來傳入指定行為,例如:

1
2
3
public interface ActionListener {
void actionPerformed(ActionEvent e);
}

這里并不需要專門定義一個類來實現 ActionListener,因為它只會在調用處被使用一次。用戶一般會使用匿名類型把行為內聯(inline):

1
2
3
4
5
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
ui.dazzle(e.getModifiers());
}
});

很多庫都依賴于上面的模式。對于并行 API 更是如此,因為我們需要把待執行的代碼提供給并行 API,并行編程是一個非常值得研究的領域,因為在這里摩爾定律得到了重生:盡管我們沒有更快的 CPU 核心(core),但是我們有更多的 CPU 核心。而串行 API 就只能使用有限的計算能力。

隨著回調模式和函數式編程風格的日益流行,我們需要在Java中提供一種盡可能輕量級的將代碼封裝為數據(Model code as data)的方法。匿名內部類并不是一個好的 選擇,因為:

  1. 語法過于冗余
  2. 匿名類中的 this 和變量名容易使人產生誤解
  3. 類型載入和實例創建語義不夠靈活
  4. 無法捕獲非 final 的局部變量
  5. 無法對控制流進行抽象

上面的多數問題均在Java SE 8中得以解決:

  • 通過提供更簡潔的語法和局部作用域規則,Java SE 8 徹底解決了問題 1 和問題 2
  • 通過提供更加靈活而且便于優化的表達式語義,Java SE 8 繞開了問題 3
  • 通過允許編譯器推斷變量的“常量性”(finality),Java SE 8 減輕了問題 4 帶來的困擾

不過,Java SE 8 的目標并非解決所有上述問題。因此捕獲可變變量(問題 4)和非局部控制流(問題 5)并不在 Java SE 8的范疇之內。(盡管我們可能會在未來提供對這些特性的支持)

2. 函數式接口(Functional interfaces)

盡管匿名內部類有著種種限制和問題,但是它有一個良好的特性,它和Java類型系統結合的十分緊密:每一個函數對象都對應一個接口類型。之所以說這個特性是良好的,是因為:

  • 接口是 Java 類型系統的一部分
  • 接口天然就擁有其運行時表示(Runtime representation)
  • 接口可以通過 Javadoc 注釋來表達一些非正式的協定(contract),例如,通過注釋說明該操作應可交換(commutative)

上面提到的 ActionListener 接口只有一個方法,大多數回調接口都擁有這個特征:比如 Runnable接口和 Comparator 接口。我們把這些只擁有一個方法的接口稱為 函數式接口。(之前它們被稱為 SAM類型,即 單抽象方法類型(Single Abstract Method))

我們并不需要額外的工作來聲明一個接口是函數式接口:編譯器會根據接口的結構自行判斷(判斷過程并非簡單的對接口方法計數:一個接口可能冗余的定義了一個 Object 已經提供的方法,比如 toString(),或者定義了靜態方法或默認方法,這些都不屬于函數式接口方法的范疇)。不過API作者們可以通過 @FunctionalInterface 注解來顯式指定一個接口是函數式接口(以避免無意聲明了一個符合函數式標準的接口),加上這個注解之后,編譯器就會驗證該接口是否滿足函數式接口的要求。

實現函數式類型的另一種方式是引入一個全新的 結構化 函數類型,我們也稱其為“箭頭”類型。例如,一個接收 String 和 Object 并返回 int 的函數類型可以被表示為 (String, Object) -> int。我們仔細考慮了這個方式,但出于下面的原因,最終將其否定:

  • 它會為Java類型系統引入額外的復雜度,并帶來 結構類型(Structural Type) 和 指名類型(Nominal Type) 的混用。(Java 幾乎全部使用指名類型)
  • 它會導致類庫風格的分歧——一些類庫會繼續使用回調接口,而另一些類庫會使用結構化函數類型
  • 它的語法會變得十分笨拙,尤其在包含受檢異常(checked exception)之后
  • 每個函數類型很難擁有其運行時表示,這意味著開發者會受到 類型擦除(erasure) 的困擾和局限。比如說,我們無法對方法 m(T->U) 和 m(X->Y) 進行重載(Overload)

所以我們選擇了“使用已知類型”這條路——因為現有的類庫大量使用了函數式接口,通過沿用這種模式,我們使得現有類庫能夠直接使用 lambda 表達式。例如下面是 Java SE 7 中已經存在的函數式接口:

除此之外,Java SE 8中增加了一個新的包:java.util.function,它里面包含了常用的函數式接口,例如:

  • Predicate<T>——接收 T 并返回 boolean
  • Consumer<T>——接收 T,不返回值
  • Function<T, R>——接收 T,返回 R
  • Supplier<T>——提供 T 對象(例如工廠),不接收值
  • UnaryOperator<T>——接收 T 對象,返回 T
  • BinaryOperator<T>——接收兩個 T,返回 T

除了上面的這些基本的函數式接口,我們還提供了一些針對原始類型(Primitive type)的特化(Specialization)函數式接口,例如 IntSupplier 和 LongBinaryOperator。(我們只為 intlong 和 double 提供了特化函數式接口,如果需要使用其它原始類型則需要進行類型轉換)同樣的我們也提供了一些針對多個參數的函數式接口,例如 BiFunction<T, U, R>,它接收 T 對象和 U對象,返回 R 對象。

3. lambda表達式(lambda expressions)

匿名類型最大的問題就在于其冗余的語法。有人戲稱匿名類型導致了“高度問題”(height problem):比如前面 ActionListener 的例子里的五行代碼中僅有一行在做實際工作。

lambda表達式是匿名方法,它提供了輕量級的語法,從而解決了匿名內部類帶來的“高度問題”。

下面是一些lambda表達式:

1
2
3
(int x, int y) -> x + y
() -> 42
(String s) -> { System.out.println(s); }

第一個 lambda 表達式接收 x 和 y 這兩個整形參數并返回它們的和;第二個 lambda 表達式不接收參數,返回整數 ‘42’;第三個 lambda 表達式接收一個字符串并把它打印到控制臺,不返回值。

lambda 表達式的語法由參數列表、箭頭符號 -> 和函數體組成。函數體既可以是一個表達式,也可以是一個語句塊:

  • 表達式:表達式會被執行然后返回執行結果。
  • 語句塊:語句塊中的語句會被依次執行,就像方法中的語句一樣——
    • return 語句會把控制權交給匿名方法的調用者
    • break 和 continue 只能在循環中使用
    • 如果函數體有返回值,那么函數體內部的每一條路徑都必須返回值

表達式函數體適合小型 lambda 表達式,它消除了 return 關鍵字,使得語法更加簡潔。

lambda 表達式也會經常出現在嵌套環境中,比如說作為方法的參數。為了使 lambda 表達式在這些場景下盡可能簡潔,我們去除了不必要的分隔符。不過在某些情況下我們也可以把它分為多行,然后用括號包起來,就像其它普通表達式一樣。

下面是一些出現在語句中的 lambda 表達式:

1
2
3
4
5
6
7
8
FileFilter java = (File f) -> f.getName().endsWith("*.java");
String user = doPrivileged(() -> System.getProperty("user.name"));
new Thread(() -> {
connectToService();
sendNotification();
}).start();

4. 目標類型(Target typing)

需要注意的是,函數式接口的名稱并不是 lambda 表達式的一部分。那么問題來了,對于給定的 lambda 表達式,它的類型是什么?答案是:它的類型是由其上下文推導而來。例如,下面代碼中的 lambda 表達式類型是 ActionListener

1
ActionListener l = (ActionEvent e) -> ui.dazzle(e.getModifiers());

這就意味著同樣的 lambda 表達式在不同上下文里可以擁有不同的類型:

1
2
3
Callable<String> c = () -> "done";
PrivilegedAction<String> a = () -> "done";

第一個 lambda 表達式 () -> "done" 是 Callable 的實例,而第二個 lambda 表達式則是 PrivilegedAction 的實例。

編譯器負責推導 lambda 表達式類型。它利用 lambda 表達式所在上下文 所期待的類型 進行推導,這個 被期待的類型 被稱為 目標類型。lambda 表達式只能出現在目標類型為函數式接口的上下文中。

當然,lambda 表達式對目標類型也是有要求的。編譯器會檢查 lambda 表達式的類型和目標類型的方法簽名(method signature)是否一致。當且僅當下面所有條件均滿足時,lambda 表達式才可以被賦給目標類型 T

  • T 是一個函數式接口
  • lambda 表達式的參數和 T 的方法參數在數量和類型上一一對應
  • lambda 表達式的返回值和 T 的方法返回值相兼容(Compatible)
  • lambda 表達式內所拋出的異常和 T 的方法 throws 類型相兼容

由于目標類型(函數式接口)已經“知道” lambda 表達式的形式參數(Formal parameter)類型,所以我們沒有必要把已知類型再重復一遍。也就是說,lambda 表達式的參數類型可以從目標類型中得出:

1
Comparator<String> c = (s1, s2) -> s1.compareToIgnoreCase(s2);

在上面的例子里,編譯器可以推導出 s1 和 s2 的類型是 String。此外,當 lambda 的參數只有一個而且它的類型可以被推導得知時,該參數列表外面的括號可以被省略:

1
2
3
FileFilter java = f -> f.getName().endsWith(".java");
button.addActionListener(e -> ui.dazzle(e.getModifiers()));

這些改進進一步展示了我們的設計目標:“不要把高度問題轉化成寬度問題。”我們希望語法元素能夠盡可能的少,以便代碼的讀者能夠直達 lambda 表達式的核心部分。

lambda 表達式并不是第一個擁有上下文相關類型的 Java 表達式:泛型方法調用和“菱形”構造器調用也通過目標類型來進行類型推導:

1
2
3
4
5
List<String> ls = Collections.emptyList();
List<Integer> li = Collections.emptyList();
Map<String, Integer> m1 = new HashMap<>();
Map<Integer, String> m2 = new HashMap<>();

5. 目標類型的上下文(Contexts for target typing)

之前我們提到 lambda 表達式智能出現在擁有目標類型的上下文中。下面給出了這些帶有目標類型的上下文:

  • 變量聲明
  • 賦值
  • 返回語句
  • 數組初始化器
  • 方法和構造方法的參數
  • lambda 表達式函數體
  • 條件表達式(? :
  • 轉型(Cast)表達式

在前三個上下文(變量聲明、賦值和返回語句)里,目標類型即是被賦值或被返回的類型:

1
2
3
4
5
6
7
8
Comparator<String> c;
c = (String s1, String s2) -> s1.compareToIgnoreCase(s2);
public Runnable toDoLater() {
return () -> {
System.out.println("later");
}
}

數組初始化器和賦值類似,只是這里的“變量”變成了數組元素,而類型是從數組類型中推導得知:

1
2
3
4
filterFiles(
new FileFilter[] {
f -> f.exists(), f -> f.canRead(), f -> f.getName().startsWith("q")
});

方法參數的類型推導要相對復雜些:目標類型的確認會涉及到其它兩個語言特性:重載解析(Overload resolution)和參數類型推導(Type argument inference)。

重載解析會為一個給定的方法調用(method invocation)尋找最合適的方法聲明(method declaration)。由于不同的聲明具有不同的簽名,當 lambda 表達式作為方法參數時,重載解析就會影響到 lambda 表達式的目標類型。編譯器會通過它所得之的信息來做出決定。如果 lambda 表達式具有 顯式類型(參數類型被顯式指定),編譯器就可以直接 使用lambda 表達式的返回類型;如果lambda表達式具有 隱式類型(參數類型被推導而知),重載解析則會忽略 lambda 表達式函數體而只依賴 lambda 表達式參數的數量。

如果在解析方法聲明時存在二義性(ambiguous),我們就需要利用轉型(cast)或顯式 lambda 表達式來提供更多的類型信息。如果 lambda 表達式的返回類型依賴于其參數的類型,那么 lambda 表達式函數體有可能可以給編譯器提供額外的信息,以便其推導參數類型。

1
2
List<Person> ps = ...
Stream<String> names = ps.stream().map(p -> p.getName());

在上面的代碼中,ps 的類型是 List<Person>,所以 ps.stream() 的返回類型是 Stream<Person>map() 方法接收一個類型為 Function<T, R> 的函數式接口,這里 T 的類型即是 Stream 元素的類型,也就是 Person,而 R 的類型未知。由于在重載解析之后 lambda 表達式的目標類型仍然未知,我們就需要推導 R 的類型:通過對 lambda 表達式函數體進行類型檢查,我們發現函數體返回 String,因此 R 的類型是 String,因而 map() 返回 Stream<String>。絕大多數情況下編譯器都能解析出正確的類型,但如果碰到無法解析的情況,我們則需要:

  • 使用顯式 lambda 表達式(為參數 p 提供顯式類型)以提供額外的類型信息
  • 把 lambda 表達式轉型為 Function<Person, String>
  • 為泛型參數 R 提供一個實際類型。(.<String>map(p -> p.getName())

lambda 表達式本身也可以為它自己的函數體提供目標類型,也就是說 lambda 表達式可以通過外部目標類型推導出其內部的返回類型,這意味著我們可以方便的編寫一個返回函數的函數:

1
Supplier<Runnable> c = () -> () -> { System.out.println("hi"); };

類似的,條件表達式可以把目標類型“分發”給其子表達式:

1
Callable<Integer> c = flag ? (() -> 23) : (() -> 42);

最后,轉型表達式(Cast expression)可以顯式提供 lambda 表達式的類型,這個特性在無法確認目標類型時非常有用:

1
2
// Object o = () -> { System.out.println("hi"); }; 這段代碼是非法的
Object o = (Runnable) () -> { System.out.println("hi"); };

除此之外,當重載的方法都擁有函數式接口時,轉型可以幫助解決重載解析時出現的二義性。

目標類型這個概念不僅僅適用于 lambda 表達式,泛型方法調用和“菱形”構造方法調用也可以從目標類型中受益,下面的代碼在 Java SE 7 是非法的,但在 Java SE 8 中是合法的:

1
2
3
List<String> ls = Collections.checkedList(new ArrayList<>(), String.class);
Set<Integer> si = flag ? Collections.singleton(23) : Collections.emptySet();

6. 詞法作用域(Lexical scoping)

在內部類中使用變量名(以及 this)非常容易出錯。內部類中通過繼承得到的成員(包括來自 Object 的方法)可能會把外部類的成員掩蓋(shadow),此外未限定(unqualified)的 this 引用會指向內部類自己而非外部類。

相對于內部類,lambda 表達式的語義就十分簡單:它不會從超類(supertype)中繼承任何變量名,也不會引入一個新的作用域。lambda 表達式基于詞法作用域,也就是說 lambda 表達式函數體里面的變量和它外部環境的變量具有相同的語義(也包括 lambda 表達式的形式參數)。此外,’this’ 關鍵字及其引用在 lambda 表達式內部和外部也擁有相同的語義。

為了進一步說明詞法作用域的優點,請參考下面的代碼,它會把 "Hello, world!" 打印兩遍:

1
2
3
4
5
6
7
8
9
10
11
public class Hello {
Runnable r1 = () -> { System.out.println(this); }
Runnable r2 = () -> { System.out.println(toString()); }
public String toString() { return "Hello, world"; }
public static void main(String... args) {
new Hello().r1.run();
new Hello().r2.run();
}
}

與之相類似的內部類實現則會打印出類似 [email protected] 和 [email protected] 之類的字符串,這往往會使開發者大吃一驚。

基于詞法作用域的理念,lambda 表達式不可以掩蓋任何其所在上下文中的局部變量,它的行為和那些擁有參數的控制流結構(例如 for 循環和 catch 從句)一致。

個人補充:這個說法很拗口,所以我在這里加一個例子以演示詞法作用域:

1
2
3
4
5
int i = 0;
int sum = 0;
for (int i = 1; i < 10; i += 1) { //這里會出現編譯錯誤,因為i已經在for循環外部聲明過了
sum += i;
}

7. 變量捕獲(Variable capture)

在 Java SE 7 中,編譯器對內部類中引用的外部變量(即捕獲的變量)要求非常嚴格:如果捕獲的變量沒有被聲明為 final 就會產生一個編譯錯誤。我們現在放寬了這個限制——對于 lambda 表達式和內部類,我們允許在其中捕獲那些符合 有效只讀(Effectively final)的局部變量。

簡單的說,如果一個局部變量在初始化后從未被修改過,那么它就符合有效只讀的要求,換句話說,加上 final 后也不會導致編譯錯誤的局部變量就是有效只讀變量。

1
2
3
4
Callable<String> helloCallable(String name) {
String hello = "Hello";
return () -> (hello + ", " + name);
}

對 this 的引用,以及通過 this 對未限定字段的引用和未限定方法的調用在本質上都屬于使用 final 局部變量。包含此類引用的 lambda 表達式相當于捕獲了 this 實例。在其它情況下,lambda 對象不會保留任何對 this 的引用。

這個特性對內存管理是一件好事:內部類實例會一直保留一個對其外部類實例的強引用,而那些沒有捕獲外部類成員的 lambda 表達式則不會保留對外部類實例的引用。要知道內部類的這個特性往往會造成內存泄露。

盡管我們放寬了對捕獲變量的語法限制,但試圖修改捕獲變量的行為仍然會被禁止,比如下面這個例子就是非法的:

1
2
int sum = 0;
list.forEach(e -> { sum += e.size(); });

為什么要禁止這種行為呢?因為這樣的 lambda 表達式很容易引起 race condition。除非我們能夠強制(最好是在編譯時)這樣的函數不能離開其當前線程,但如果這么做了可能會導致更多的問題。簡而言之,lambda 表達式對  封閉,對 變量 開放。

個人補充:lambda 表達式對  封閉,對 變量 開放的原文是:lambda expressions close over values, not variables,我在這里增加一個例子以說明這個特性:

1
2
3
4
5
int sum = 0;
list.forEach(e -> { sum += e.size(); }); // Illegal, close over values
List<Integer> aList = new List<>();
list.forEach(e -> { aList.add(e); }); // Legal, open over variables

lambda 表達式不支持修改捕獲變量的另一個原因是我們可以使用更好的方式來實現同樣的效果:使用規約(reduction)。java.util.stream 包提供了各種通用的和專用的規約操作(例如 summin 和 max),就上面的例子而言,我們可以使用規約操作(在串行和并行下都是安全的)來代替 forEach

1
2
3
4
int sum =
list.stream()
.mapToInt(e -> e.size())
.sum();

sum() 等價于下面的規約操作:

1
2
3
4
int sum =
list.stream()
.mapToInt(e -> e.size())
.reduce(0 , (x, y) -> x + y);

規約需要一個初始值(以防輸入為空)和一個操作符(在這里是加號),然后用下面的表達式計算結果:

1
0 + list[0] + list[1] + list[2] + ...

規約也可以完成其它操作,比如求最小值、最大值和乘積等等。如果操作符具有可結合性(associative),那么規約操作就可以容易的被并行化。所以,與其支持一個本質上是并行而且容易導致 race condition 的操作,我們選擇在庫中提供一個更加并行友好且不容易出錯的方式來進行累積(accumulation)。

8. 方法引用(Method references)

lambda 表達式允許我們定義一個匿名方法,并允許我們以函數式接口的方式使用它。我們也希望能夠在 已有的 方法上實現同樣的特性。

方法引用和 lambda 表達式擁有相同的特性(例如,它們都需要一個目標類型,并需要被轉化為函數式接口的實例),不過我們并不需要為方法引用提供方法體,我們可以直接通過方法名稱引用已有方法。

以下面的代碼為例,假設我們要按照 name 或 age 為 Person 數組進行排序:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
private final String name;
private final int age;
public int getAge() { return age; }
public String getName() {return name; }
...
}
Person[] people = ...
Comparator<Person> byName = Comparator.comparing(p -> p.getName());
Arrays.sort(people, byName);

在這里我們可以用方法引用代替lambda表達式:

1
Comparator<Person> byName = Comparator.comparing(Person::getName);

這里的 Person::getName 可以被看作為 lambda 表達式的簡寫形式。盡管方法引用不一定(比如在這個例子里)會把語法變的更緊湊,但它擁有更明確的語義——如果我們想要調用的方法擁有一個名字,我們就可以通過它的名字直接調用它。

因為函數式接口的方法參數對應于隱式方法調用時的參數,所以被引用方法簽名可以通過放寬類型,裝箱以及組織到參數數組中的方式對其參數進行操作,就像在調用實際方法一樣:

1
2
3
4
Consumer<Integer> b1 = System::exit; // void exit(int status)
Consumer<String[]> b2 = Arrays:sort; // void sort(Object[] a)
Consumer<String> b3 = MyProgram::main; // void main(String... args)
Runnable r = Myprogram::mapToInt // void main(String... args)

9. 方法引用的種類(Kinds of method references)

方法引用有很多種,它們的語法如下:

  • 靜態方法引用:ClassName::methodName
  • 實例上的實例方法引用:instanceReference::methodName
  • 超類上的實例方法引用:super::methodName
  • 類型上的實例方法引用:ClassName::methodName
  • 構造方法引用:Class::new
  • 數組構造方法引用:TypeName[]::new

對于靜態方法引用,我們需要在類名和方法名之間加入 :: 分隔符,例如 Integer::sum

對于具體對象上的實例方法引用,我們則需要在對象名和方法名之間加入分隔符:

1
2
Set<String> knownNames = ...
Predicate<String> isKnown = knownNames::contains;

這里的隱式 lambda 表達式(也就是實例方法引用)會從 knownNames 中捕獲 String 對象,而它的方法體則會通過Set.contains 使用該 String 對象。

有了實例方法引用,在不同函數式接口之間進行類型轉換就變的很方便:

1
2
Callable<Path> c = ...
Privileged<Path> a = c::call;

引用任意對象的實例方法則需要在實例方法名稱和其所屬類型名稱間加上分隔符:

1
Function<String, String> upperfier = String::toUpperCase;

這里的隱式 lambda 表達式(即 String::toUpperCase 實例方法引用)有一個 String 參數,這個參數會被 toUpperCase 方法使用。

如果類型的實例方法是泛型的,那么我們就需要在 :: 分隔符前提供類型參數,或者(多數情況下)利用目標類型推導出其類型。

需要注意的是,靜態方法引用和類型上的實例方法引用擁有一樣的語法。編譯器會根據實際情況做出決定。

一般我們不需要指定方法引用中的參數類型,因為編譯器往往可以推導出結果,但如果需要我們也可以顯式在 :: 分隔符之前提供參數類型信息。

和靜態方法引用類似,構造方法也可以通過 new 關鍵字被直接引用:

1
SocketImplFactory factory = MySocketImpl::new;

如果類型擁有多個構造方法,那么我們就會通過目標類型的方法參數來選擇最佳匹配,這里的選擇過程和調用構造方法時的選擇過程是一樣的。

如果待實例化的類型是泛型的,那么我們可以在類型名稱之后提供類型參數,否則編譯器則會依照”菱形”構造方法調用時的方式進行推導。

數組的構造方法引用的語法則比較特殊,為了便于理解,你可以假想存在一個接收 int 參數的數組構造方法。參考下面的代碼:

1
2
IntFunction<int[]> arrayMaker = int[]::new;
int[] array = arrayMaker.apply(10) // 創建數組 int[10]

10. 默認方法和靜態接口方法(Default and static interface methods)

lambda 表達式和方法引用大大提升了 Java 的表達能力(expressiveness),不過為了使把 代碼即數據 (code-as-data)變的更加容易,我們需要把這些特性融入到已有的庫之中,以便開發者使用。

Java SE 7 時代為一個已有的類庫增加功能是非常困難的。具體的說,接口在發布之后就已經被定型,除非我們能夠一次性更新所有該接口的實現,否則向接口添加方法就會破壞現有的接口實現。默認方法(之前被稱為 虛擬擴展方法 或 守護方法)的目標即是解決這個問題,使得接口在發布之后仍能被逐步演化。

這里給出一個例子,我們需要在標準集合 API 中增加針對 lambda 的方法。例如 removeAll 方法應該被泛化為接收一個函數式接口 Predicate,但這個新的方法應該被放在哪里呢?我們無法直接在 Collection 接口上新增方法——不然就會破壞現有的 Collection 實現。我們倒是可以在 Collections 工具類中增加對應的靜態方法,但這樣就會把這個方法置于“二等公民”的境地。

默認方法 利用面向對象的方式向接口增加新的行為。它是一種新的方法:接口方法可以是 抽象的 或是 默認的。默認方法擁有其默認實現,實現接口的類型通過繼承得到該默認實現(如果類型沒有覆蓋該默認實現)。此外,默認方法不是抽象方法,所以我們可以放心的向函數式接口里增加默認方法,而不用擔心函數式接口的單抽象方法限制。

下面的例子展示了如何向 Iterator 接口增加默認方法 skip

1
2
3
4
5
6
7
8
9
interface Iterator<E> {
boolean hasNext();
E next();
void remove();
default void skip(int i) {
for ( ; i > 0 && hasNext(); i -= 1) next();
}
}

根據上面的 Iterator 定義,所有實現 Iterator 的類型都會自動繼承 skip 方法。在使用者的眼里,skip 不過是接口新增的一個虛擬方法。在沒有覆蓋 skip 方法的 Iterator 子類實例上調用 skip 會執行 skip 的默認實現:調用 hasNext 和 next 若干次。子類可以通過覆蓋 skip 來提供更好的實現——比如直接移動游標(cursor),或是提供為操作提供原子性(Atomicity)等。

當接口繼承其它接口時,我們既可以為它所繼承而來的抽象方法提供一個默認實現,也可以為它繼承而來的默認方法提供一個新的實現,還可以把它繼承而來的默認方法重新抽象化。

除了默認方法,Java SE 8 還在允許在接口中定義 靜態 方法。這使得我們可以從接口直接調用和它相關的輔助方法(Helper method),而不是從其它的類中調用(之前這樣的類往往以對應接口的復數命名,例如 Collections)。比如,我們一般需要使用靜態輔助方法生成實現 Comparator 的比較器,在Java SE 8中我們可以直接把該靜態方法定義在 Comparator 接口中:

1
2
3
4
public static <T, U extends Comparable<? super U>>
Comparator<T> comparing(Function<T, U> keyExtractor) {
return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

11. 繼承默認方法(Inheritance of default methods)

和其它方法一樣,默認方法也可以被繼承,大多數情況下這種繼承行為和我們所期待的一致。不過,當類型或者接口的超類擁有多個具有相同簽名的方法時,我們就需要一套規則來解決這個沖突:

  • 類的方法(class method)聲明優先于接口默認方法。無論該方法是具體的還是抽象的。
  • 被其它類型所覆蓋的方法會被忽略。這條規則適用于超類型共享一個公共祖先的情況。

為了演示第二條規則,我們假設 Collection 和 List 接口均提供了 removeAll 的默認實現,然后 Queue 繼承并覆蓋了 Collection 中的默認方法。在下面的 implement 從句中,List 中的方法聲明會優先于 Queue 中的方法聲明:

1
class LinkedList<E> implements List<E>, Queue<E> { ... }

當兩個獨立的默認方法相沖突或是默認方法和抽象方法相沖突時會產生編譯錯誤。這時程序員需要顯式覆蓋超類方法。一般來說我們會定義一個默認方法,然后在其中顯式選擇超類方法:

1
2
3
interface Robot implements Artist, Gun {
default void draw() { Artist.super.draw(); }
}

super 前面的類型必須是有定義或繼承默認方法的類型。這種方法調用并不只限于消除命名沖突——我們也可以在其它場景中使用它。

最后,接口在 inherits 和 extends 從句中的聲明順序和它們被實現的順序無關。

12. 融會貫通(Putting it together)

我們在設計lambda時的一個重要目標就是新增的語言特性和庫特性能夠無縫結合(designed to work together)。接下來,我們通過一個實際例子(按照姓對名字列表進行排序)來演示這一點:

比如說下面的代碼:

1
2
3
4
5
6
List<Person> people = ...
Collections.sort(people, new Comparator<Person>() {
public int compare(Person x, Person y) {
return x.getLastName().compareTo(y.getLastName());
}
})

冗余代碼實在太多了!

有了lambda表達式,我們可以去掉冗余的匿名類:

1
2
Collections.sort(
people, (Person x, Person y) -> x.getLastName().compareTo(y.getLastName()));

盡管代碼簡潔了很多,但它的抽象程度依然很差:開發者仍然需要進行實際的比較操作(而且如果比較的值是原始類型那么情況會更糟),所以我們要借助 Comparator 里的 comparing 方法實現比較操作:

1
Collections.sort(people, Comparator.comparing((Person p) -> p.getLastName()));

在類型推導和靜態導入的幫助下,我們可以進一步簡化上面的代碼:

1
Collections.sort(people, comparing(p -> p.getLastName()));

我們注意到這里的 lambda 表達式實際上是 getLastName 的代理(forwarder),于是我們可以用方法引用代替它:

1
Collections.sort(people, comparing(Person::getLastName));

最后,使用 Collections.sort 這樣的輔助方法并不是一個好主意:它不但使代碼變的冗余,也無法為實現 List 接口的數據結構提供特定(specialized)的高效實現,而且由于 Collections.sort 方法不屬于 List 接口,用戶在閱讀 List 接口的文檔時不會察覺在另外的 Collections 類中還有一個針對 List 接口的排序(sort())方法。

默認方法可以有效的解決這個問題,我們為 List 增加默認方法 sort(),然后就可以這樣調用:

1
people.sort(comparing(Person::getLastName));;

此外,如果我們為 Comparator 接口增加一個默認方法 reversed()(產生一個逆序比較器),我們就可以非常容易的在前面代碼的基礎上實現降序排序。

1
people.sort(comparing(Person::getLastName).reversed());;

13. 小結(Summary)

Java SE 8 提供的新語言特性并不算多——lambda 表達式,方法引用,默認方法和靜態接口方法,以及范圍更廣的類型推導。但是把它們結合在一起之后,開發者可以編寫出更加清晰簡潔的代碼,類庫編寫者可以編寫更加強大易用的并行類庫。


我來說兩句
您需要登錄后才可以評論 登錄 | 立即注冊
facelist
所有評論(0)
領先的中文移動開發者社區
18620764416
7*24全天服務
意見反饋:[email protected]

掃一掃關注我們

Powered by Discuz! X3.2© 2001-2019 Comsenz Inc.( 粵ICP備15117877號 )

阿拉斯加垂钓APP下载
云南时时彩 七乐彩 聚宝盆配资 福建快3 网络推广玄家配资网 福建36选7 好用的股票配资平台 球探比分即时棒球比分直播 内蒙古快三 合肥天臣股票配资公司对股票配资认识工作 同花易配 辽宁十一选五 赢策配资 浙江快乐12 尚盈配资 忆融速配