深入理解Java 8 Lambda(語言篇)
原文連結:http://lucida.me/blog/java-8-lambdas-insideout-language-features/
關於
本文介紹了 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)的方法。匿名內部類並不是一個好的 選擇,因為:
- 語法過於冗餘
- 匿名類中的
this
和變數名容易使人產生誤解 - 型別載入和例項建立語義不夠靈活
- 無法捕獲非
final
的區域性變數 - 無法對控制流進行抽象
上面的多數問題均在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
。我們仔細考慮了這個方式,但出於下面的原因,最終將其否定:
- 它會導致類庫風格的分歧——一些類庫會繼續使用回撥介面,而另一些類庫會使用結構化函式型別
- 它的語法會變得十分笨拙,尤其在包含受檢異常(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
。(我們只為 int
、long
和 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
包提供了各種通用的和專用的規約操作(例如 sum
、min
和 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 表示式,方法引用,預設方法和靜態介面方法,以及範圍更廣的型別推導。但是把它們結合在一起之後,開發者可以編寫出更加清晰簡潔的程式碼,類庫編寫者可以編寫更加強大易用的並行類庫。