讀古今文學網 > Java 8實戰 > 第3章 Lambda表達式 >

第3章 Lambda表達式

本章內容

  • Lambda管中窺豹

  • 在哪裡以及如何使用Lambda

  • 環繞執行模式

  • 函數式接口,類型推斷

  • 方法引用

  • Lambda復合

在上一章中,你瞭解了利用行為參數化來傳遞代碼有助於應對不斷變化的需求。它允許你定義一個代碼塊來表示一個行為,然後傳遞它。你可以決定在某一事件發生時(例如單擊一個按鈕)或在算法中的某個特定時刻(例如篩選算法中類似於“重量超過150克的蘋果”的謂詞,或排序中的自定義比較操作)運行該代碼塊。一般來說,利用這個概念,你就可以編寫更為靈活且可重複使用的代碼了。

但你也看到,使用匿名類來表示不同的行為並不令人滿意:代碼十分囉嗦,這會影響程序員在實踐中使用行為參數化的積極性。在本章中,我們會教給你Java 8中解決這個問題的新工具——Lambda表達式。它可以讓你很簡潔地表示一個行為或傳遞代碼。現在你可以把Lambda表達式看作匿名功能,它基本上就是沒有聲明名稱的方法,但和匿名類一樣,它也可以作為參數傳遞給一個方法。

我們會展示如何構建Lambda,它的使用場合,以及如何利用它使代碼更簡潔。我們還會介紹一些新的東西,如類型推斷和Java 8 API中重要的新接口。最後,我們將介紹方法引用(method reference),這是一個常常和Lambda表達式聯用的有用的新功能。

本章的行文思想就是教你如何一步一步地寫出更簡潔、更靈活的代碼。在本章結束時,我們會把所有教過的概念融合在一個具體的例子裡:我們會用Lambda表達式和方法引用逐步改進第2章中的排序例子,使之更加簡明易讀。這一章很重要,而且你將在本書中大量使用Lambda。

3.1 Lambda管中窺豹

可以把Lambda表達式理解為簡潔地表示可傳遞的匿名函數的一種方式:它沒有名稱,但它有參數列表、函數主體、返回類型,可能還有一個可以拋出的異常列表。這個定義夠大的,讓我們慢慢道來。

  • 匿名——我們說匿名,是因為它不像普通的方法那樣有一個明確的名稱:寫得少而想得多!

  • 函數——我們說它是函數,是因為Lambda函數不像方法那樣屬於某個特定的類。但和方法一樣,Lambda有參數列表、函數主體、返回類型,還可能有可以拋出的異常列表。

  • 傳遞——Lambda表達式可以作為參數傳遞給方法或存儲在變量中。

  • 簡潔——無需像匿名類那樣寫很多模板代碼。

你是不是好奇Lambda這個詞是從哪兒來的?其實它來自於學術界開發出來的一套用來描述計算的λ演算法。 你為什麼應該關心Lambda表達式呢?你在上一章中看到了,在Java中傳遞代碼十分繁瑣和冗長。那麼,現在有了好消息!Lambda解決了這個問題:它可以讓你十分簡明地傳遞代碼。理論上來說,你在Java 8之前做不了的事情,Lambda也做不了。但是,現在你用不著再用匿名類寫一堆笨重的代碼,來體驗行為參數化的好處了!Lambda表達式鼓勵你採用我們上一章中提到的行為參數化風格。最終結果就是你的代碼變得更清晰、更靈活。比如,利用Lambda表達式,你可以更為簡潔地自定義一個Comparator對象。

圖 3-1 Lambda表達式由參數、箭頭和主體組成

先前:

Comparator<Apple> byWeight = new Comparator<Apple> {
    public int compare(Apple a1, Apple a2){
        return a1.getWeight.compareTo(a2.getWeight);
    }
};

  

之後(用了Lambda表達式):

Comparator<Apple> byWeight =
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);

  

不得不承認,代碼看起來更清晰了!要是現在你覺得Lambda表達式看起來一頭霧水的話也沒關係,我們很快會一點點解釋清楚的。現在,請注意你基本上只傳遞了比較兩個蘋果重量所真正需要的代碼。看起來就像是只傳遞了compare方法的主體。你很快就會學到,你甚至還可以進一步簡化代碼。我們將在下一節解釋在哪裡以及如何使用Lambda表達式。

我們剛剛展示給你的Lambda表達式有三個部分,如圖3-1所示。

  • 參數列表——這裡它採用了Comparatorcompare方法的參數,兩個Apple

  • 箭頭——箭頭->把參數列表與Lambda主體分隔開。

  • Lambda主體——比較兩個Apple的重量。表達式就是Lambda的返回值了。

為了進一步說明,下面給出了Java 8中五個有效的Lambda表達式的例子。

代碼清單3-1 Java 8中有效的Lambda表達式

(String s) -> s.length           ←─第一個Lambda表達式具有一個String類型的參數並返回一個int。Lambda沒有return語句,因為已經隱含了return
(Apple a) -> a.getWeight > 150        ←─第二個Lambda表達式有一個Apple 類型的參數並返回一個boolean(蘋果的重量是否超過150克)
(int x, int y) -> {
    System.out.println("Result:");
    System.out.println(x+y);            ←─第三個Lambda表達式具有兩個int類型的參數而沒有返回值(void返回)。注意Lambda表達式可以包含多行語句,這裡是兩行
}

 -> 42            ←─第四個Lambda表達式沒有參數, 返回一個int
(Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight)    ←─第五個Lambda表達式具有兩個Apple類型的參數,返回一個int:比較兩個Apple的重量

  

Java語言設計者選擇這樣的語法,是因為C#和Scala等語言中的類似功能廣受歡迎。Lambda的基本語法是

(parameters) -> expression

  

或(請注意語句的花括號)

(parameters) -> { statements; }

  

你可以看到,Lambda表達式的語法很簡單。做一下測驗3.1,看看自己是不是理解了這個模式。

測驗3.1:Lambda語法

根據上述語法規則,以下哪個不是有效的Lambda表達式?

(1) -> {}

(2) -> "Raoul"

(3) -> {return "Mario";}

(4) (Integer i) -> return "Alan" + i;

(5) (String s) -> {"IronMan";}

答案:只有4和5是無效的Lambda。

(1) 這個Lambda沒有參數,並返回void。它類似於主體為空的方法:public void run {}

(2) 這個Lambda沒有參數,並返回String作為表達式。

(3) 這個Lambda沒有參數,並返回String(利用顯式返回語句)。

(4) return是一個控制流語句。要使此Lambda有效,需要使花括號,如下所示:(Integer i) -> {return "Alan" + i;}

(5)“Iron Man”是一個表達式,不是一個語句。要使此Lambda有效,你可以去除花括號和分號,如下所示:(String s) -> "Iron Man"。或者如果你喜歡,可以使用顯式返回語句,如下所示:(String s)->{return "IronMan";}

表3-1提供了一些Lambda的例子和使用案例。

表3-1 Lambda示例

使用案例

Lambda示例

布爾表達式

(List<String> list) -> list.isEmpty

創建對像

-> new Apple(10)

消費一個對像

(Apple a) -> {    System.out.println(a.getWeight);}  

從一個對像中選擇/抽取

(String s) -> s.length

組合兩個值

(int a, int b) -> a * b

比較兩個對像

(Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight)

3.2 在哪裡以及如何使用Lambda

現在你可能在想,在哪裡可以使用Lambda表達式。在上一個例子中,你把Lambda賦給了一個Comparator<Apple>類型的變量。你也可以在上一章中實現的filter方法中使用Lambda:

List<Apple> greenApples =
    filter(inventory, (Apple a) -> "green".equals(a.getColor));

  

那到底在哪裡可以使用Lambda呢?你可以在函數式接口上使用Lambda表達式。在上面的代碼中,你可以把Lambda表達式作為第二個參數傳給filter方法,因為它這裡需要Predicate<T>,而這是一個函數式接口。如果這聽起來太抽像,不要擔心,現在我們就來詳細解釋這是什麼意思,以及函數式接口是什麼。

3.2.1 函數式接口

還記得你在第2章裡,為了參數化filter方法的行為而創建的Predicate<T>接口嗎?它就是一個函數式接口!為什麼呢?因為Predicate僅僅定義了一個抽像方法:

public interface Predicate<T>{
    boolean test (T t);
}

  

一言以蔽之,函數式接口就是只定義一個抽像方法的接口。你已經知道了Java API中的一些其他函數式接口,如我們在第2章中談到的ComparatorRunnable

public interface Comparator<T> {    ←─java.util.Comparator
    int compare(T o1, T o2);
}

public interface Runnable{    ←─java.lang.Runnable
    void run;
}

public interface ActionListener extends EventListener{    ←─java.awt.event.ActionListener
    void actionPerformed(ActionEvent e);
}

public interface Callable<V>{    ←─java.util.concurrent.Callable
    V call;
}

public interface PrivilegedAction<V>{    ←─java.security.PrivilegedAction
    V run;
}

  

注意 你將會在第9章中看到,接口現在還可以擁有默認方法(即在類沒有對方法進行實現時,其主體為方法提供默認實現的方法)。哪怕有很多默認方法,只要接口只定義了一個抽像方法,它就仍然是一個函數式接口。

為了檢查你的理解程度,測驗3.2將幫助你測試自己是否掌握了函數式接口的概念。

測驗3.2:函數式接口

下面哪些接口是函數式接口?

public interface Adder{
    int add(int a, int b);
}
public interface SmartAdder extends Adder{
    int add(double a, double b);
}
public interface Nothing{
}

  

答案:只有Adder是函數式接口。

SmartAdder不是函數式接口,因為它定義了兩個叫作add的抽像方法(其中一個是從Adder那裡繼承來的)。

Nothing也不是函數式接口,因為它沒有聲明抽像方法。

用函數式接口可以幹什麼呢?Lambda表達式允許你直接以內聯的形式為函數式接口的抽像方法提供實現,並把整個表達式作為函數式接口的實例(具體說來,是函數式接口一個具體實現的實例)。你用匿名內部類也可以完成同樣的事情,只不過比較笨拙:需要提供一個實現,然後再直接內聯將它實例化。下面的代碼是有效的,因為Runnable是一個只定義了一個抽像方法run的函數式接口:

Runnable r1 =  -> System.out.println("Hello World 1");    ←─使用Lambda

Runnable r2 = new Runnable{    ←─使用匿名類
    public void run{
        System.out.println("Hello World 2");
    }
};

public static void process(Runnable r){
    r.run;
}
process(r1);    ←─打印“Hello World 1”
process(r2);    ←─打印“Hello World 2”
process( -> System.out.println("Hello World 3"));    ←─利用直接傳遞的Lambda打印“Hello World 3”

  

3.2.2 函數描述符

函數式接口的抽像方法的簽名基本上就是Lambda表達式的簽名。我們將這種抽像方法叫作函數描述符。例如,Runnable接口可以看作一個什麼也不接受什麼也不返回(void)的函數的簽名,因為它只有一個叫作run的抽像方法,這個方法什麼也不接受,什麼也不返回(void)。1

1Scala等語言的類型系統提供顯式類型標注,可以描述函數的類型(稱為“函數類型”)。Java重用了函數式接口提供的標準類型,並將其映射成一種形式的函數類型。

我們在本章中使用了一個特殊表示法來描述Lambda和函數式接口的簽名。 -> void代表了參數列表為空,且返回void的函數。這正是Runnable接口所代表的。 舉另一個例子,(Apple, Apple) -> int代表接受兩個Apple作為參數且返回int的函數。我們會在3.4節和本章後面的表3-2中提供關於函數描述符的更多信息。

你可能已經在想,Lambda表達式是怎麼做類型檢查的。我們會在3.5節中詳細介紹,編譯器是如何檢查Lambda在給定上下文中是否有效的。現在,只要知道Lambda表達式可以被賦給一個變量,或傳遞給一個接受函數式接口作為參數的方法就好了,當然這個Lambda表達式的簽名要和函數式接口的抽像方法一樣。比如,在我們之前的例子裡,你可以像下面這樣直接把一個Lambda傳給 process方法:

public void process(Runnable r){
    r.run;
}

process( -> System.out.println("This is awesome!!"));

  

此代碼執行時將打印“This is awesome!!”。Lambda表達式-> System.out.println ("This is awesome!!")不接受參數且返回void。 這恰恰是Runnable接口中run方法的簽名。

你可能會想:“為什麼只有在需要函數式接口的時候才可以傳遞Lambda呢?”語言的設計者也考慮過其他辦法,例如給Java添加函數類型(有點兒像我們介紹的描述Lambda表達式簽名的特殊表示法,我們會在第15章和第16章回過來討論這個問題)。但是他們選擇了現在這種方式,因為這種方式自然且能避免語言變得更複雜。此外,大多數Java程序員都已經熟悉了具有一個抽像方法的接口的理念(例如事件處理)。試試看測驗3.3,測試一下你對哪裡可以使用Lambda這個知識點的掌握情況。

測驗3.3:在哪裡可以使用Lambda?

以下哪些是使用Lambda表達式的有效方式?

(1)

execute( -> {});public void execute(Runnable r){    r.run;}  

(2)

public Callable<String> fetch {    return  -> "Tricky example  ;-)";}  

(3)

Predicate<Apple> p = (Apple a) -> a.getWeight;  

答案:只有1和2是有效的。

第一個例子有效,是因為Lambda -> {}具有簽名 -> void,這和Runnable中的抽像方法run的簽名相匹配。請注意,此代碼運行後什麼都不會做,因為Lambda是空的!

第二個例子也是有效的。事實上,fetch方法的返回類型是Callable<String>Callable<String>基本上就定義了一個方法,簽名是 -> String,其中TString代替了。因為Lambda -> "Trickyexample;-)"的簽名是 -> String,所以在這個上下文中可以使用Lambda。

第三個例子無效,因為Lambda表達式(Apple a) -> a.getWeight的簽名是(Apple) -> Integer,這和Predicate<Apple>:(Apple) -> boolean中定義的test方法的簽名不同。

@FunctionalInterface又是怎麼回事?

如果你去看看新的Java API,會發現函數式接口帶有@FunctionalInterface的標注(3.4節中會深入研究函數式接口,並會給出一個長長的列表)。這個標注用於表示該接口會設計成一個函數式接口。如果你用@FunctionalInterface定義了一個接口,而它卻不是函數式接口的話,編譯器將返回一個提示原因的錯誤。例如,錯誤消息可能是“Multiple non-overriding abstract methods found in interface Foo”,表明存在多個抽像方法。請注意,@FunctionalInterface不是必需的,但對於為此設計的接口而言,使用它是比較好的做法。它就像是@Override標注表示方法被重寫了。

3.3 把Lambda付諸實踐:環繞執行模式

讓我們通過一個例子,看看在實踐中如何利用Lambda和行為參數化來讓代碼更為靈活,更為簡潔。資源處理(例如處理文件或數據庫)時一個常見的模式就是打開一個資源,做一些處理,然後關閉資源。這個設置和清理階段總是很類似,並且會圍繞著執行處理的那些重要代碼。這就是所謂的環繞執行(execute around)模式,如圖3-2所示。例如,在以下代碼中,高亮顯示的就是從一個文件中讀取一行所需的模板代碼(注意你使用了Java 7中的帶資源的try語句,它已經簡化了代碼,因為你不需要顯式地關閉資源了):

public static String processFile throws IOException {
    try (BufferedReader br =
            new BufferedReader(new FileReader("data.txt"))) {
        return br.readLine;    ←─這就是做有用工作的那行代碼
    }
}

  

圖 3-2 任務A和任務B周圍都環繞著進行準備/清理的同一段冗余代碼

3.3.1 第1步:記得行為參數化

現在這段代碼是有局限的。你只能讀文件的第一行。如果你想要返回頭兩行,甚至是返回使用最頻繁的詞,該怎麼辦呢?在理想的情況下,你要重用執行設置和清理的代碼,並告訴processFile方法對文件執行不同的操作。這聽起來是不是很耳熟?是的,你需要把processFile的行為參數化。你需要一種方法把行為傳遞給processFile,以便它可以利用BufferedReader執行不同的行為。

傳遞行為正是Lambda的拿手好戲。那要是想一次讀兩行,這個新的processFile方法看起來又該是什麼樣的呢?基本上,你需要一個接收BufferedReader並返回String的Lambda。例如,下面就是從BufferedReader中打印兩行的寫法:

String result = processFile((BufferedReader br) ->
                             br.readLine + br.readLine);

  

3.3.2 第2步:使用函數式接口來傳遞行為

我們前面解釋過了,Lambda僅可用於上下文是函數式接口的情況。你需要創建一個能匹配BufferedReader -> String,還可以拋出IOException異常的接口。讓我們把這一接口叫作BufferedReaderProcessor吧。

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}

  

現在你就可以把這個接口作為新的processFile方法的參數了:

public static String processFile(BufferedReaderProcessor p) throws
        IOException {
   …
}

  

3.3.3 第3步:執行一個行為

任何BufferedReader -> String形式的Lambda都可以作為參數來傳遞,因為它們符合BufferedReaderProcessor接口中定義的process方法的簽名。現在你只需要一種方法在processFile主體內執行Lambda所代表的代碼。請記住,Lambda表達式允許你直接內聯,為函數式接口的抽像方法提供實現,並且將整個表達式作為函數式接口的一個實例。因此,你可以在processFile主體內,對得到的BufferedReaderProcessor對像調用process方法執行處理:

public static String processFile(BufferedReaderProcessor p) throws
        IOException {
    try (BufferedReader br =
            new BufferedReader(new FileReader("data.txt"))) {
        return p.process(br);    ←─處理BufferedReader對像
    }
}

  

3.3.4 第4步:傳遞Lambda

現在你就可以通過傳遞不同的Lambda重用processFile方法,並以不同的方式處理文件了。

處理一行:

String oneLine =
    processFile((BufferedReader br) -> br.readLine);

  

處理兩行:

String twoLines =
    processFile((BufferedReader br) -> br.readLine + br.readLine);

  

圖3-3總結了所採取的使pocessFile方法更靈活的四個步驟。

圖 3-3 應用環繞執行模式所採取的四個步驟

我們已經展示了如何利用函數式接口來傳遞Lambda,但你還是得定義你自己的接口。在下一節中,我們會探討Java 8中加入的新接口,你可以重用它來傳遞多個不同的Lambda。

3.4 使用函數式接口

就像你在3.2.1節中學到的,函數式接口定義且只定義了一個抽像方法。函數式接口很有用,因為抽像方法的簽名可以描述Lambda表達式的簽名。函數式接口的抽像方法的簽名稱為函數描述符。所以為了應用不同的Lambda表達式,你需要一套能夠描述常見函數描述符的函數式接口。Java API中已經有了幾個函數式接口,比如你在3.2節中見到的ComparableRunnableCallable

Java 8的庫設計師幫你在java.util.function包中引入了幾個新的函數式接口。我們接下來會介紹PredicateConsumerFunction,更完整的列表可見本節結尾處的表3-2。

3.4.1 Predicate

java.util.function.Predicate<T>接口定義了一個名叫test的抽像方法,它接受泛型T對象,並返回一個boolean。這恰恰和你先前創建的一樣,現在就可以直接使用了。在你需要表示一個涉及類型T的布爾表達式時,就可以使用這個接口。比如,你可以定義一個接受String對象的Lambda表達式,如下所示。

代碼清單3-2 使用Predicate

@FunctionalInterface
public interface Predicate<T>{
    boolean test(T t);
}

public static <T> List<T> filter(List<T> list, Predicate<T> p) {
    List<T> results = new ArrayList<>;
    for(T s: list){
        if(p.test(s)){
            results.add(s);
        }
    }
    return results;
}


Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty;
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

  

如果你去查Predicate接口的Javadoc說明,可能會注意到諸如andor等其他方法。現在你不用太計較這些,我們會在3.8節討論。

3.4.2 Consumer

java.util.function.Consumer<T>定義了一個名叫accept的抽像方法,它接受泛型T的對象,沒有返回(void)。你如果需要訪問類型T的對象,並對其執行某些操作,就可以使用這個接口。比如,你可以用它來創建一個forEach方法,接受一個Integers的列表,並對其中每個元素執行操作。在下面的代碼中,你就可以使用這個forEach方法,並配合Lambda來打印列表中的所有元素。

代碼清單3-3 使用Consumer

@FunctionalInterface
public interface Consumer<T>{
    void accept(T t);
}
public static <T> void forEach(List<T> list, Consumer<T> c){
    for(T i: list){
        c.accept(i);
    }
}

forEach(
         Arrays.asList(1,2,3,4,5),
        (Integer i) -> System.out.println(i)    ←─Lambda是Consumer中accept方法的實現
       );

  

3.4.3 Function

java.util.function.Function<T, R>接口定義了一個叫作apply的方法,它接受一個泛型T的對象,並返回一個泛型R的對象。如果你需要定義一個Lambda,將輸入對象的信息映射到輸出,就可以使用這個接口(比如提取蘋果的重量,或把字符串映射為它的長度)。在下面的代碼中,我們向你展示如何利用它來創建一個map方法,以將一個String列表映射到包含每個String長度的Integer列表。

代碼清單3-4 使用Function

@FunctionalInterface
public interface Function<T, R>{
    R apply(T t);
}
public static <T, R> List<R> map(List<T> list,
                                 Function<T, R> f) {
    List<R> result = new ArrayList<>;
    for(T s: list){
        result.add(f.apply(s));
    }
    return result;
}
// [7, 2, 6]
List<Integer> l = map(
                       Arrays.asList("lambdas","in","action"),
                       (String s) -> s.length    ←─Lambda是Function接口的apply方法的實現
               );

  

原始類型特化

我們介紹了三個泛型函數式接口:Predicate<T>Consumer<T>Function<T,R>。還有些函數式接口專為某些類型而設計。

回顧一下:Java類型要麼是引用類型(比如ByteIntegerObjectList),要麼是原始類型(比如intdoublebytechar)。但是泛型(比如Consumer<T>中的T)只能綁定到引用類型。這是由泛型內部的實現方式造成的。2因此,在Java裡有一個將原始類型轉換為對應的引用類型的機制。這個機制叫作裝箱(boxing)。相反的操作,也就是將引用類型轉換為對應的原始類型,叫作拆箱(unboxing)。Java還有一個自動裝箱機制來幫助程序員執行這一任務:裝箱和拆箱操作是自動完成的。比如,這就是為什麼下面的代碼是有效的(一個int被裝箱成為Integer):

2C#等其他語言沒有這一限制。Scala等語言只有引用類型。我們會在第16章再次探討這個問題。

List<Integer> list = new ArrayList<>;
for (int i = 300; i < 400; i++){
    list.add(i);
}

  

但這在性能方面是要付出代價的。裝箱後的值本質上就是把原始類型包裹起來,並保存在堆裡。因此,裝箱後的值需要更多的內存,並需要額外的內存搜索來獲取被包裹的原始值。

Java 8為我們前面所說的函數式接口帶來了一個專門的版本,以便在輸入和輸出都是原始類型時避免自動裝箱的操作。比如,在下面的代碼中,使用IntPredicate就避免了對值 1000進行裝箱操作,但要是用Predicate<Integer>就會把參數1000裝箱到一個Integer對像中:

public interface IntPredicate{
    boolean test(int t);
}

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000);                                 ←─true(無裝箱)

Predicate<Integer> oddNumbers = (Integer i) -> i % 2 == 1;
oddNumbers.test(1000);                                           ←─false(裝箱)

  

一般來說,針對專門的輸入參數類型的函數式接口的名稱都要加上對應的原始類型前綴,比如DoublePredicateIntConsumerLongBinaryOperatorIntFunction等。Function接口還有針對輸出參數類型的變種:ToIntFunction<T>IntToDoubleFunction等。

表3-2總結了Java API中提供的最常用的函數式接口及其函數描述符。請記得這只是一個起點。如果有需要,你可以自己設計一個。請記住,(T,U) -> R的表達方式展示了應當如何思考一個函數描述符。表的左側代表了參數類型。這裡它代表一個函數,具有兩個參數,分別為泛型TU,返回類型為R

表3-2 Java 8中的常用函數式接口

函數式接口

函數描述符

原始類型特化

Predicate<T>

T->boolean

IntPredicate,LongPredicate,DoublePredicate

Consumer<T>

T->void

IntConsumer,LongConsumer, DoubleConsumer

Function<T,R>

T->R

IntFunction<R>,IntToDoubleFunction,IntToLongFunction,LongFunction<R>,LongToDoubleFunction,LongToIntFunction,DoubleFunction<R>,ToIntFunction<T>,ToDoubleFunction<T>,ToLongFunction<T>

Supplier<T>

->T

BooleanSupplier,IntSupplier,LongSupplier, DoubleSupplier

UnaryOperator<T>

T->T

IntUnaryOperator,LongUnaryOperator,DoubleUnaryOperator

BinaryOperator<T>

(T,T)->T

IntBinaryOperator,LongBinaryOperator,DoubleBinaryOperator

BiPredicate<L,R>

(L,R)->boolean

BiConsumer<T,U>

(T,U)->void

ObjIntConsumer<T>,ObjLongConsumer<T>,ObjDoubleConsumer<T>

BiFunction<T,U,R>

(T,U)->R

ToIntBiFunction<T,U>,ToLongBiFunction<T,U>,ToDoubleBiFunction<T,U>

你現在已經看到了很多函數式接口,可以用於描述各種Lambda表達式的簽名。為了檢驗你的理解程度,試試測驗3.4。

測驗3.4:函數式接口

對於下列函數描述符(即Lambda表達式的簽名),你會使用哪些函數式接口?在表3-2中可以找到大部分答案。作為進一步練習,請構造一個可以利用這些函數式接口的有效Lambda表達式:

(1) T->R

(2) (int, int)->int

(3) T->void

(4) ->T

(5) (T, U)->R

答案如下。

(1) Function<T,R>不錯。它一般用於將類型T的對象轉換為類型R的對象(比如Function<Apple, Integer>用來提取蘋果的重量)。

(2) IntBinaryOperator具有唯一一個抽像方法,叫作applyAsInt,它代表的函數描述符是(int, int) -> int

(3) Consumer<T>具有唯一一個抽像方法叫作accept,代表的函數描述符是T -> void

(4) Supplier<T>具有唯一一個抽像方法叫作get,代表的函數描述符是-> T。或者, Callable<T>具有唯一一個抽像方法叫作call,代表的函數描述符是 -> T

(5) BiFunction<T, U, R>具有唯一一個抽像方法叫作apply,代表的函數描述符是(T, U) -> R

為了總結關於函數式接口和Lambda的討論,表3-3總結了一些使用案例、Lambda的例子,以及可以使用的函數式接口。

表3-3 Lambdas及函數式接口的例子

使用案例

Lambda的例子

對應的函數式接口

布爾表達式

(List<String> list) -> list.isEmpty

Predicate<List<String>>

創建對像

-> new Apple(10)

Supplier<Apple>

消費一個對像

(Apple a) ->System.out.println(a.getWeight)

Consumer<Apple>

從一個對像中選擇/提取

(String s) -> s.length

Function<String, Integer>ToIntFunction<String>

合併兩個值

(int a, int b) -> a * b

IntBinaryOperator

比較兩個對像

(Apple a1, Apple a2) ->a1.getWeight.compareTo(a2.getWeight)

Comparator<Apple>BiFunction<Apple, Apple, Integer>ToIntBiFunction<Apple, Apple>

異常、Lambda,還有函數式接口又是怎麼回事呢?

請注意,任何函數式接口都不允許拋出受檢異常(checked exception)。如果你需要Lambda表達式來拋出異常,有兩種辦法:定義一個自己的函數式接口,並聲明受檢異常,或者把Lambda包在一個try/catch塊中。

比如,在3.3節我們介紹了一個新的函數式接口BufferedReaderProcessor,它顯式聲明了一個IOException

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}
BufferedReaderProcessor p = (BufferedReader br) -> br.readLine;

  

但是你可能是在使用一個接受函數式接口的API,比如Function<T, R>,沒有辦法自己創建一個(你會在下一章看到,Stream API中大量使用表3-2中的函數式接口)。這種情況下,你可以顯式捕捉受檢異常:

Function<BufferedReader, String> f = (BufferedReader b) -> {
    try {
        return b.readLine;
    }
    catch(IOException e) {
        throw new RuntimeException(e);
    }
};

  

現在你知道如何創建Lambda,在哪裡以及如何使用它們了。接下來我們會介紹一些更高級的細節:編譯器如何對Lambda做類型檢查,以及你應當瞭解的規則,諸如Lambda在自身內部引用局部變量,還有和void兼容的Lambda等。你無需立即就充分理解下一節的內容,可以留待日後再看,現在可繼續看3.6節講的方法引用。

3.5 類型檢查、類型推斷以及限制

當我們第一次提到Lambda表達式時,說它可以為函數式接口生成一個實例。然而,Lambda表達式本身並不包含它在實現哪個函數式接口的信息。為了全面瞭解Lambda表達式,你應該知道Lambda的實際類型是什麼。

3.5.1 類型檢查

Lambda的類型是從使用Lambda的上下文推斷出來的。上下文(比如,接受它傳遞的方法的參數,或接受它的值的局部變量)中Lambda表達式需要的類型稱為目標類型。讓我們通過一個例子,看看當你使用Lambda表達式時背後發生了什麼。圖3-4概述了下列代碼的類型檢查過程。

List<Apple> heavierThan150g =
        filter(inventory, (Apple a) -> a.getWeight > 150);

  

圖 3-4 解讀Lambda表達式的類型檢查過程

類型檢查過程可以分解為如下所示。

  • 首先,你要找出filter方法的聲明。

  • 第二,要求它是Predicate<Apple>(目標類型)對象的第二個正式參數。

  • 第三,Predicate<Apple>是一個函數式接口,定義了一個叫作test的抽像方法。

  • 第四,test方法描述了一個函數描述符,它可以接受一個Apple,並返回一個boolean

  • 最後,filter的任何實際參數都必須匹配這個要求。

這段代碼是有效的,因為我們所傳遞的Lambda表達式也同樣接受Apple為參數,並返回一個boolean。請注意,如果Lambda表達式拋出一個異常,那麼抽像方法所聲明的throws語句也必須與之匹配。

3.5.2 同樣的Lambda,不同的函數式接口

有了目標類型的概念,同一個Lambda表達式就可以與不同的函數式接口聯繫起來,只要它們的抽像方法簽名能夠兼容。比如,前面提到的CallablePrivilegedAction,這兩個接口都代表著什麼也不接受且返回一個泛型T的函數。 因此,下面兩個賦值是有效的:

Callable<Integer> c =  -> 42;
PrivilegedAction<Integer> p =  -> 42;

  

這裡,第一個賦值的目標類型是Callable<Integer>,第二個賦值的目標類型是PrivilegedAction<Integer>

在表3-3中我們展示了一個類似的例子;同一個Lambda可用於多個不同的函數式接口:

Comparator<Apple> c1 =
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);
ToIntBiFunction<Apple, Apple> c2 =
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);
BiFunction<Apple, Apple, Integer> c3 =
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);

  

菱形運算符

那些熟悉Java的演變的人會記得,Java 7中已經引入了菱形運算符(<>),利用泛型推斷從上下文推斷類型的思想(這一思想甚至可以追溯到更早的泛型方法)。一個類實例表達式可以出現在兩個或更多不同的上下文中,並會像下面這樣推斷出適當的類型參數:

List<String> listOfStrings = new ArrayList<>;
List<Integer> listOfIntegers = new ArrayList<>;

  

特殊的void兼容規則

如果一個Lambda的主體是一個語句表達式, 它就和一個返回void的函數描述符兼容(當然需要參數列表也兼容)。例如,以下兩行都是合法的,儘管Listadd方法返回了一個boolean,而不是Consumer上下文(T -> void)所要求的void

// Predicate返回了一個boolean
Predicate<String> p = s -> list.add(s);
// Consumer返回了一個void
Consumer<String> b = s -> list.add(s);

  

到現在為止,你應該能夠很好地理解在什麼時候以及在哪裡可以使用Lambda表達式了。它們可以從賦值的上下文、方法調用的上下文(參數和返回值),以及類型轉換的上下文中獲得目標類型。為了檢驗你的掌握情況,請試試測驗3.5。

測驗3.5:類型檢查——為什麼下面的代碼不能編譯呢?

你該如何解決這個問題呢?

Object o =  -> {System.out.println("Tricky example"); };

  

答案:Lambda表達式的上下文是Object(目標類型)。但Object不是一個函數式接口。為了解決這個問題,你可以把目標類型改成Runnable,它的函數描述符是 -> void

Runnable r =  -> {System.out.println("Tricky example"); };

  

你已經見過如何利用目標類型來檢查一個Lambda是否可以用於某個特定的上下文。其實,它也可以用來做一些略有不同的事:推斷Lambda參數的類型。

3.5.3 類型推斷

你還可以進一步簡化你的代碼。Java編譯器會從上下文(目標類型)推斷出用什麼函數式接口來配合Lambda表達式,這意味著它也可以推斷出適合Lambda的簽名,因為函數描述符可以通過目標類型來得到。這樣做的好處在於,編譯器可以瞭解Lambda表達式的參數類型,這樣就可以在Lambda語法中省去標注參數類型。換句話說,Java編譯器會像下面這樣推斷Lambda的參數類型:3

3請注意,當Lambda僅有一個類型需要推斷的參數時,參數名稱兩邊的括號也可以省略。

List<Apple> greenApples =
    filter(inventory, a -> "green".equals(a.getColor));    ←─參數a沒有顯式類型

  

Lambda表達式有多個參數,代碼可讀性的好處就更為明顯。例如,你可以這樣來創建一個Comparator對像:

Comparator<Apple> c =
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight);    ←─沒有類型推斷

Comparator<Apple> c =
    (a1, a2) -> a1.getWeight.compareTo(a2.getWeight);    ←─有類型推斷

  

請注意,有時候顯式寫出類型更易讀,有時候去掉它們更易讀。沒有什麼法則說哪種更好;對於如何讓代碼更易讀,程序員必須做出自己的選擇。

3.5.4 使用局部變量

我們迄今為止所介紹的所有Lambda表達式都只用到了其主體裡面的參數。但Lambda表達式也允許使用自由變量(不是參數,而是在外層作用域中定義的變量),就像匿名類一樣。 它們被稱作捕獲Lambda。例如,下面的Lambda捕獲了portNumber變量:

int portNumber = 1337;
Runnable r =  -> System.out.println(portNumber);

  

儘管如此,還有一點點小麻煩:關於能對這些變量做什麼有一些限制。Lambda可以沒有限制地捕獲(也就是在其主體中引用)實例變量和靜態變量。但局部變量必須顯式聲明為final,或事實上是final。換句話說,Lambda表達式只能捕獲指派給它們的局部變量一次。(註:捕獲實例變量可以被看作捕獲最終局部變量this。) 例如,下面的代碼無法編譯,因為portNumber變量被賦值兩次:

int portNumber = 1337;
Runnable r =  -> System.out.println(portNumber);    ←─錯誤:Lambda表達式引用的局部變量必須是最終的(final)或事實上最終的
portNumber = 31337;

  

對局部變量的限制

你可能會問自己,為什麼局部變量有這些限制。第一,實例變量和局部變量背後的實現有一個關鍵不同。實例變量都存儲在堆中,而局部變量則保存在棧上。如果Lambda可以直接訪問局部變量,而且Lambda是在一個線程中使用的,則使用Lambda的線程,可能會在分配該變量的線程將這個變量收回之後,去訪問該變量。因此,Java在訪問自由局部變量時,實際上是在訪問它的副本,而不是訪問原始變量。如果局部變量僅僅賦值一次那就沒有什麼區別了——因此就有了這個限制。

第二,這一限制不鼓勵你使用改變外部變量的典型命令式編程模式(我們會在以後的各章中解釋,這種模式會阻礙很容易做到的並行處理)。

閉包

你可能已經聽說過閉包(closure,不要和Clojure編程語言混淆)這個詞,你可能會想Lambda是否滿足閉包的定義。用科學的說法來說,閉包就是一個函數的實例,且它可以無限制地訪問那個函數的非本地變量。例如,閉包可以作為參數傳遞給另一個函數。它也可以訪問和修改其作用域之外的變量。現在,Java 8的Lambda和匿名類可以做類似於閉包的事情:它們可以作為參數傳遞給方法,並且可以訪問其作用域之外的變量。但有一個限制:它們不能修改定義Lambda的方法的局部變量的內容。這些變量必須是隱式最終的。可以認為Lambda是對值封閉,而不是對變量封閉。如前所述,這種限制存在的原因在於局部變量保存在棧上,並且隱式表示它們僅限於其所在線程。如果允許捕獲可改變的局部變量,就會引發造成線程不安全的新的可能性,而這是我們不想看到的(實例變量可以,因為它們保存在堆中,而堆是在線程之間共享的)。

現在,我們來介紹你會在Java 8代碼中看到的另一個功能:方法引用。可以把它們視為某些Lambda的快捷寫法。

3.6 方法引用

方法引用讓你可以重複使用現有的方法定義,並像Lambda一樣傳遞它們。在一些情況下,比起使用Lambda表達式,它們似乎更易讀,感覺也更自然。下面就是我們借助更新的Java 8 API(我們會在3.7節中更詳細地討論),用方法引用寫的一個排序的例子:

先前:

inventory.sort((Apple a1, Apple a2)
                -> a1.getWeight.compareTo(a2.getWeight));

  

之後(使用方法引用和java.util.Comparator.comparing):

inventory.sort(comparing(Apple::getWeight));    ←─你的第一個方法引用

  

3.6.1 管中窺豹

你為什麼應該關心方法引用?方法引用可以被看作僅僅調用特定方法的Lambda的一種快捷寫法。它的基本思想是,如果一個Lambda代表的只是“直接調用這個方法”,那最好還是用名稱來調用它,而不是去描述如何調用它。事實上,方法引用就是讓你根據已有的方法實現來創建Lambda表達式。但是,顯式地指明方法的名稱,你的代碼的可讀性會更好。它是如何工作的呢?當你需要使用方法引用時,目標引用放在分隔符::前,方法的名稱放在後面。例如,Apple::getWeight就是引用了Apple類中定義的方法getWeight。請記住,不需要括號,因為你沒有實際調用這個方法。方法引用就是Lambda表達式(Apple a) -> a.getWeight的快捷寫法。表3-4給出了Java 8中方法引用的其他一些例子。

表3-4 Lambda及其等效方法引用的例子

Lambda

等效的方法引用

(Apple a) -> a.getWeight

Apple::getWeight

-> Thread.currentThread.dumpStack

Thread.currentThread::dumpStack

(str, i) -> str.substring(i)

String::substring

(String s) -> System.out.println(s)

System.out::println

你可以把方法引用看作針對僅僅涉及單一方法的Lambda的語法糖,因為你表達同樣的事情時要寫的代碼更少了。

如何構建方法引用

方法引用主要有三類。

(1) 指向靜態方法的方法引用(例如IntegerparseInt方法,寫作Integer::parseInt)。

(2) 指向任意類型實例方法的方法引用(例如Stringlength方法,寫作String::length)。

(3) 指向現有對象的實例方法的方法引用(假設你有一個局部變量expensiveTransaction用於存放Transaction類型的對象,它支持實例方法getValue,那麼你就可以寫expensiveTransaction::getValue)。

第二種和第三種方法引用可能乍看起來有點兒暈。類似於String::length的第二種方法引用的思想就是你在引用一個對象的方法,而這個對象本身是Lambda的一個參數。例如,Lambda表達式(String s) -> s.toUppeCase可以寫作String::toUpperCase。但第三種方法引用指的是,你在Lambda中調用一個已經存在的外部對像中的方法。例如,Lambda表達式->expensiveTransaction.getValue可以寫作expensiveTransaction::getValue

依照一些簡單的方子,我們就可以將Lambda表達式重構為等價的方法引用,如圖3-5所示。

圖 3-5 為三種不同類型的Lambda表達式構建方法引用的辦法

請注意,還有針對構造函數、數組構造函數和父類調用(super-call)的一些特殊形式的方法引用。讓我們舉一個方法引用的具體例子吧。比方說你想要對一個字符串的List排序,忽略大小寫。Listsort方法需要一個Comparator作為參數。你在前面看到了,Comparator描述了一個具有(T, T) -> int簽名的函數描述符。你可以利用String類中的compareToIgnoreCase方法來定義一個Lambda表達式(注意compareToIgnoreCaseString類中預先定義的)。

List<String> str = Arrays.asList("a","b","A","B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));

  

Lambda表達式的簽名與Comparator的函數描述符兼容。利用前面所述的方法,這個例子可以用方法引用改寫成下面的樣子:

List<String> str = Arrays.asList("a","b","A","B");
str.sort(String::compareToIgnoreCase);

  

請注意,編譯器會進行一種與Lambda表達式類似的類型檢查過程,來確定對於給定的函數式接口,這個方法引用是否有效:方法引用的簽名必須和上下文類型匹配。

為了檢驗你對方法引用的理解程度,試試測驗3.6吧!

測驗3.6:方法引用

下列Lambda表達式的等效方法引用是什麼?

(1)

Function<String, Integer> stringToInteger = 
    (String s) -> Integer.parseInt(s);

  

(2)

BiPredicate<List<String>, String> contains = 
    (list, element) -> list.contains(element);

  

答案如下。

(1) 這個Lambda表達式將其參數傳給了Integer的靜態方法parseInt。這種方法接受一個需要解析的String,並返回一個Integer。因此,可以使用圖3-5中的辦法➊(Lambda表達式調用靜態方法)來重寫Lambda表達式,如下所示:

Function<String, Integer> stringToInteger = Integer::parseInt;

  

(2) 這個Lambda使用其第一個參數,調用其contains方法。由於第一個參數是List類型的,你可以使用圖3-5中的辦法➋,如下所示:

BiPredicate<List<String>, String> contains = List::contains;

  

這是因為,目標類型描述的函數描述符是 (List<String>,String) -> boolean,而List::contains可以被解包成這個函數描述符。

到目前為止,我們只展示了如何利用現有的方法實現和如何創建方法引用。但是你也可以對類的構造函數做類似的事情。

3.6.2 構造函數引用

對於一個現有構造函數,你可以利用它的名稱和關鍵字new來創建它的一個引用:ClassName::new。它的功能與指向靜態方法的引用類似。例如,假設有一個構造函數沒有參數。它適合Supplier的簽名 -> Apple。你可以這樣做:

Supplier<Apple> c1 = Apple::new;    ←─構造函數引用指向默認的Apple構造函數
Apple a1 = c1.get;    ←─調用Supplier的get方法將產生一個新的Apple

  

這就等價於:

Supplier<Apple> c1 =  -> new Apple;    ←─利用默認構造函數創建Apple的Lambda表達式
Apple a1 = c1.get;    ←─調用Supplier的get方法將產生一個新的Apple

  

如果你的構造函數的簽名是Apple(Integer weight),那麼它就適合Function接口的簽名,於是你可以這樣寫:

Function<Integer, Apple> c2 = Apple::new;    ←─指向Apple(Integer weight)的構造函數引用
Apple a2 = c2.apply(110);    ←─調用該Function函數的apply方法,並給出要求的重量,將產生一個Apple

  

這就等價於:

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);用要求的重量創建一個Apple的Lambda表達式
Apple a2 = c2.apply(110);調用該Function函數的apply方法,並給出要求的重量,將產生一個新的Apple對像

  

在下面的代碼中,一個由Integer構成的List中的每個元素都通過我們前面定義的類似的map方法傳遞給了Apple的構造函數,得到了一個具有不同重量蘋果的List

List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new);    ←─將構造函數引用傳遞給map方法

public static List<Apple> map(List<Integer> list,
                              Function<Integer, Apple> f){
    List<Apple> result = new ArrayList<>;
    for(Integer e: list){
        result.add(f.apply(e));
    }
    return result;
}

  

如果你有一個具有兩個參數的構造函數Apple(String color, Integer weight),那麼它就適合BiFunction接口的簽名,於是你可以這樣寫:

BiFunction<String, Integer, Apple> c3 = Apple::new;    ←─指向Apple(Stringcolor,Integer weight)的構造函數引用
Apple c3 = c3.apply("green", 110);    ←─調用該BiFunction函數的apply方法,並給出要求的顏色和重量,將產生一個新的Apple對像

  

這就等價於:

BiFunction<String, Integer, Apple> c3 =
    (color, weight) -> new Apple(color, weight);    ←─用要求的顏色和重量創建一個Apple的Lambda表達式
Apple c3 = c3.apply("green", 110);    ←─調用該BiFunction函數的apply方法,並給出要求的顏色和重量,將產生一個新的Apple對像

  

不將構造函數實例化卻能夠引用它,這個功能有一些有趣的應用。例如,你可以使用Map來將構造函數映射到字符串值。你可以創建一個giveMeFruit方法,給它一個String和一個Integer,它就可以創建出不同重量的各種水果:

static Map<String, Function<Integer, Fruit>> map = new HashMap<>;
static {
    map.put("apple", Apple::new);
    map.put("orange", Orange::new);
    // etc...
}
public static Fruit giveMeFruit(String fruit, Integer weight){
    return map.get(fruit.toLowerCase)    ←─你用map 得到了一個Function<Integer,Fruit>
              .apply(weight);    ←─用Integer類型的weight參數調用Function的apply方法將提供所要求的Fruit
}

  

為了檢驗你對方法和構造函數引用的理解程度,試試測驗3.7吧!

測驗3.7:構造函數引用