1. 程式人生 > >深入理解Java 8 Lambda 語言篇 類庫篇

深入理解Java 8 Lambda 語言篇 類庫篇

http://zh.lucida.me/blog/java-8-lambdas-insideout-language-features/

http://zh.lucida.me/blog/java-8-lambdas-inside-out-library-features/



深入理解Java 8 Lambda(語言篇——lambda,方法引用,目標型別和預設方法)

關於

  1. 深入理解 Java 8 Lambda(語言篇——lambda,方法引用,目標型別和預設方法)
  2. 深入理解 Java 8 Lambda(類庫篇——Streams API,Collector 和並行)
  3. 深入理解 Java 8 Lambda(原理篇——Java 編譯器如何處理 lambda)

本文是深入理解 Java 8 Lambda 系列的第一篇,主要介紹 Java 8 新增的語言特性(比如 lambda 和方法引用),語言概念(比如目標型別和變數捕獲)以及設計思路。

本文是對 Brian Goetz 的 State of 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()));
      

這些改進進一步展示了我們的設計目標:“不要把高度問題轉化成寬度問題。