Java 8 Lambda表示式一看就會

liuyatao發表於2019-03-04

匿名內部類的一個問題是:當一個匿名內部類的實現非常簡單,比如說介面只有一個抽象函式,那麼匿名內部類的語法有點笨拙且不清晰。我們經常會有傳遞一個函式作為引數給另一個函式的實際需求,比如當點選一個按鈕時,我們需要給按鈕物件設定按鈕響應函式。lambda表示式就可以把函式當做函式的引數,程式碼(函式)當做資料(形參),這種特性滿足上述需求。當要實現只有一個抽象函式的介面時,使用lambda表示式能夠更靈活。

使用Lambda表示式的一個用例

假設你正在建立一個社交網路應用。你現在要開發一個可以讓管理員對使用者做各種操作的功能,比如搜尋、列印、獲取郵件等操作。假設社交網路應用的使用者都通過Person類表示:

public class Person {

    public enum Sex {
        MALE, FEMALE
    }

    private String name;
    
    private LocalDate birthday;

    private Sex gender;
    
    private String emailAddress;

    public int getAge() {
        // ...
    }

    public void printPerson() {
        // ...
    }
}
複製程式碼

假設社交網路應用的所有使用者都儲存在一個 List<Person>的例項中。

我們先使用一個簡單的方法來實現這個用例,再通過使用本地類、匿名內部類實現,最終通過lambda表示式做一個高效且簡潔的實現。

方法1:建立一個根據某一特性查詢匹配使用者的方法

最簡單的方式是建立幾個函式,每個函式搜尋指定的使用者特徵,比如searchByAge()這種方法,下面的方法列印了年齡大於某特定值的所有使用者:

public static void printPersonsOlderThan(List<Person> roster, int age) {
    for (Person p : roster) {
        if (p.getAge() >= age) {
            p.printPerson();
        }
    }
}
複製程式碼

這個方法是有潛在的問題的,如果引入一些變動(比如新的資料型別)這個程式會出錯。假設更新了應用且變化了Person類,比如使用出生年月代替了年齡;也有可能搜尋年齡的演算法不同。這樣你將不到不再寫許多API來適應這些變化。

方法2:建立一個更加通用的搜尋方法

這個方法比起printPersonsOlderThan更加通用;它提供了可以列印某個年齡區間的使用者:

public static void printPersonsWithinAgeRange(
    List<Person> roster, int low, int high) {
    for (Person p : roster) {
        if (low <= p.getAge() && p.getAge() < high) {
            p.printPerson();
        }
    }
}
複製程式碼

如果想列印特定的性別或者列印同時滿足特定性別和某年齡區間的使用者呢?如果要改動Person類,新增其他屬性,比如戀愛狀態、地理位置呢?儘管這個方法比printPersonsOlderThan方法更加通用,但是每個查詢都建立特定的函式都是有可以導致程式不夠健壯。你可以使用介面將特定的搜尋轉交給需要搜尋的特定類中(面向介面程式設計的思想——簡單工廠模式)。

方法3:在本地類中設定特定的搜尋條件

下面的方法可以列印出符合搜尋條件的所有使用者資訊

public static void printPersons(
    List<Person> roster, CheckPerson tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

複製程式碼

這個方法通過呼叫tester.test方法檢測每個roster列表中的元素是否滿足搜尋條件。如果tester.test返回true,則列印符合條件的Person例項。

通過實現CheckPerson介面實現搜尋。

interface CheckPerson {
    boolean test(Person p);
}
複製程式碼

下面的類實現了CheckPerson介面的test方法。如果Person的屬性是男性並且年齡在18到25歲之間將會返回true

class CheckPersonEligibleForSelectiveService implements CheckPerson {
    public boolean test(Person p) {
        return p.gender == Person.Sex.MALE &&
            p.getAge() >= 18 &&
            p.getAge() <= 25;
    }
}
複製程式碼

當要使用這個類的時候,只需要例項化一個例項,並將例項以引數的形式傳遞給printPersons方法。

printPersons(roster, new CheckPersonEligibleForSelectiveService());
複製程式碼

儘管這個方式不那麼脆弱——當Person發生變化時你不需要重新更多方法,但是你仍然需要在新增一些程式碼:要為每個搜尋標準建立一個本地類來實現介面。CheckPersonEligibleForSelectiveService類實現了一個介面,你可以使用一個匿內部類替代本地類,通過宣告一個新的內部類來滿足不同的搜尋。

方法4:在匿名內部類中指定搜尋條件

下面的printPersons函式呼叫的第二個引數是一個匿名內部類,這個匿名內部類過濾滿足性別為男性並且年齡在18到25歲之間的使用者:

printPersons(
    roster,
    new CheckPerson() {
        public boolean test(Person p) {
            return p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25;
        }
    }
);

複製程式碼

這個方法減少了很多程式碼量,因為你不必為每個搜尋標準建立一個新類。但是,考慮到CheckPerson介面只有一個函式,匿名內部類的語法有顯得有點笨重。在這種情況下,可以考慮使用lambda表示式替換匿名內部類,像下面介紹的這種。

方法5:通過Lambda表示式實搜尋介面

CheckPerson介面是一個函式式介面。介面中只有一個抽象方法的介面屬於函式式介面(一個函式式介面也可能包換一個活多個預設方法或者靜態方法)。由於函式式介面只包含一個抽象方法,你可以在實現該方法的時候省略方法的名字。因此你可以使用lambda表示式取代匿名內部類表示式,像下面這樣呼叫:

printPersons(
    roster,
    (Person p) -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25
);
複製程式碼

lambda表示式的語法後面會做詳細介紹。你還可以使用標準的函式式介面取代CheckPerson介面,這樣會進一步減少程式碼量。

方法6:使用標準的函式式介面和Lambda表示式

CheckPerson介面是一個非常簡單的介面:

interface CheckPerson {
    boolean test(Person p);
}
複製程式碼

它只有一個抽象方法,因此它是一個函式式介面。這個函式有個一個引數和一個返回值。它太過簡單以至於沒有必要在你應用中定義它。因此JDK中定義了一些標準的函式式介面,可以在java.util.function包中找到。比如,你可以使用Predicate<T>取代CheckPerson。這個介面中只包含boolean test(T t)方法。

interface Predicate<T> {
    boolean test(T t);
}
複製程式碼

Predicate<T>是一個泛型介面,泛型需要在尖括號(<>)指定一個或者多個引數。這個介面中只包換一個引數T。當你宣告或者通過一個真實的型別引數例項化泛型後,你將得到一個引數化的型別。比如,引數化後的型別Predicate<Person>像下面程式碼所示:

interface Predicate<Person> {
    boolean test(Person t);
}
複製程式碼

引數化後的的介面包含一個介面,這和 CheckPerson.boolean test(Person p)完全一樣。因此,你可以像下面的程式碼一樣使用Predicate<T> 取代CheckPerson

public static void printPersonsWithPredicate(
    List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}
複製程式碼

那麼,可以這樣呼叫這個函式:

printPersonsWithPredicate(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25
);
複製程式碼

這個不是使用lamdba表示式的唯一的方式。建議使用下面的其他方式使用lambda表達。

方法7:在應用中全都使用Lambda表示式

再來看看方法printPersonsWithPredicate哪裡還可以使用lambda表示式:

public static void printPersonsWithPredicate(
    List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}
複製程式碼

這個方法檢測roster中的每個Person例項是否滿足tester的標準。如果Person例項滿足tester中設定的標準,那麼Person例項的資訊將會被列印出來。

你可以指定一個不同的動作來執行列印滿足tester中定義的搜尋條件的Person例項。你可以指定這個動作是一個lambda表示式。假設你想要一個功能和printPerson一樣的lambda表示式(一個引數、返回void),你需要實現一個函式式介面。在這種情況下,你需要一個包含一個只有一個Person型別引數和返回void的函式式介面。Consumer<T>介面包換一個void accept(T t)函式,它符合上述需求。下面的函式使用 Consumer<Person> 呼叫accept()從而取代了p.printPerson()的呼叫。

public static void processPersons(
    List<Person> roster,
    Predicate<Person> tester,
    Consumer<Person> block) {
        for (Person p : roster) {
            if (tester.test(p)) {
                block.accept(p);
            }
        }
}
複製程式碼

那麼可以這樣呼叫processPersons函式:

processPersons(
     roster,
     p -> p.getGender() == Person.Sex.MALE
         && p.getAge() >= 18
         && p.getAge() <= 25,
     p -> p.printPerson()
);

複製程式碼

如果你想對使用者的資訊進行更多處理而不止列印出來,那該怎麼辦呢?假設你想驗證成員的個人資訊或者獲取他們的聯絡人的資訊呢?在這種情況下,你需要一個有返回值的抽象函式的函式式介面。Function<T,R>介面包含了R apply(T t)方法,有一個引數和一個返回值。下面的方法獲取引數匹配到的資料,然後根據lambda表示式程式碼塊做相應的處理:

public static void processPersonsWithFunction(
    List<Person> roster,
    Predicate<Person> tester,
    Function<Person, String> mapper,
    Consumer<String> block) {
    for (Person p : roster) {
        if (tester.test(p)) {
            String data = mapper.apply(p);
            block.accept(data);
        }
    }
}
複製程式碼

下面的函式從roster中獲取符合搜尋條件的使用者的郵箱地址,並將地址列印出來。

processPersonsWithFunction(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25,
    p -> p.getEmailAddress(),
    email -> System.out.println(email)
);
複製程式碼

方法8:使用泛型使之更加通用

再處理processPersonsWithFunction函式,下面的函式可以接受包含任何資料型別的集合:

public static <X, Y> void processElements(
    Iterable<X> source,
    Predicate<X> tester,
    Function <X, Y> mapper,
    Consumer<Y> block) {
    for (X p : source) {
        if (tester.test(p)) {
            Y data = mapper.apply(p);
            block.accept(data);
        }
    }
}
複製程式碼

可以這樣呼叫上述函式來實現列印符合搜尋條件的使用者的郵箱:

processElements(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25,
    p -> p.getEmailAddress(),
    email -> System.out.println(email)
);
複製程式碼

該方法的呼叫只要執行了下面動作:

  1. 從集合中獲取物件,在這個例子中它是包換Person例項的roster集合。roster是一個List型別,同時也是一個Iterable型別。
  2. 過濾符合Predicate資料型別的tester的物件。在這個例子中,Predicate物件是一個指定了符合搜尋條件的lambda表示式。
  3. 使用Function型別的mapper對映每個符合過濾條件的物件。在這個例子中,Function物件時要給返回使用者的郵箱地址。
  4. 對每個對映到的物件執行一個在Consumer物件塊中定義的的動作。在這個例子中,Consumer物件時一個列印Function物件返回的電子郵箱的lamdba表示式。

你可以通過一個聚合操作取代上述操作。

方法9:使用lambda表示式作為引數的合併操作

下面的例子使用了聚合操作,列印出了符合搜尋條件的使用者的電子郵箱:

roster
    .stream()
    .filter(
        p -> p.getGender() == Person.Sex.MALE
            && p.getAge() >= 18
            && p.getAge() <= 25)
    .map(p -> p.getEmailAddress())
    .forEach(email -> System.out.println(email));
複製程式碼

下面的表對映了processElements函式執行操作和與之對應的聚合操作

processElements動作 聚合操作
獲取物件源 Stream stream()
過濾符合Predicate物件(lambda表示式)的例項 Stream filter(Predicate<? super T> predicate)
使用Function物件對映符合過濾標準的物件到一個值 Stream map(Function<? super T,? extends R> mapper)
執行Consumer物件(lambda表示式)設定的動作 void forEach(Consumer<? super T> action)

filter,mapforEach是聚合操作。聚合操作是從stream中處理各個元素的,而不是直接從集合中(這就是為什麼第一個呼叫的函式是stream())。steam是對各個元素進行序列化操作。和集合不同,它不是一個儲存資料的資料結構。相反地,stream載入了源中的值,比如集合通過pipeline將資料載入到stream中。pipeline是stream的一種序列化操作,這個例子中的就是filter- map-forEach。還有,聚合操作通常可以接收一個lambda表示式作為引數,這樣你可自定義需要的動作。

在GUI程式中使用lambda表示式

為了處理一個圖形使用者介面(GUI)應用中的事件,比如鍵盤輸入事件,滑鼠移動事件,滾動事件,你通常是實現一個特定的介面來建立一個事件處理。通常,時間處理介面就是一個函式式介面,它們通常只有一個函式。

之前使用匿名內部類實現的時間相應:

 btn.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent event) {
                System.out.println("Hello World!");
            }
        });
複製程式碼

可以使用如下程式碼替代:

 btn.setOnAction(
          event -> System.out.println("Hello World!")
        );
複製程式碼

Lambda表示式語法

一個lambda表示式由一下結構組成:

  • ()括起來引數,如果有多個引數就使用逗號分開。CheckPerson.test函式有一個引數p,代表Person的一個例項。

    注意: 你可以省略lambda表示式中的引數型別。另外,如果只有一個引數也可以省略括號。比如下面的lambda表示式也是合法的:

p -> p.getGender() == Person.Sex.MALE 
    && p.getAge() >= 18
    && p.getAge() <= 25
複製程式碼
  • 箭頭符號:->
  • 主體:有一個表示式或者一個宣告塊組成。例子中使用這樣的表示式:
p.getGender() == Person.Sex.MALE 
    && p.getAge() >= 18
    && p.getAge() <= 25
複製程式碼

如果設定的是一個表示式,java執行時將會計算表示式並最終返回結果。同時,你可以使用一個返回宣告:

p -> {
    return p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25;
}
複製程式碼

在lambda表示式中返回的不是一個表示式,那麼就必須使用{}將程式碼塊括起來。但是,當返回的是一個void型別時則不需要括號。比如,下面的也是一個合法的lambda表示式:

email -> System.out.println(email)
複製程式碼

lambda表示式看起來有點像宣告函式,可以把lambda表示式看做是一個匿名函式(沒有名稱的函式)。

下面是一個有多個形參的lambda表示式的例子:

public class Calculator {
  
    interface IntegerMath {
        int operation(int a, int b);   
    }
  
    public int operateBinary(int a, int b, IntegerMath op) {
        return op.operation(a, b);
    }
 
    public static void main(String... args) {
    
        Calculator myApp = new Calculator();
        IntegerMath addition = (a, b) -> a + b;
        IntegerMath subtraction = (a, b) -> a - b;
        System.out.println("40 + 2 = " +
            myApp.operateBinary(40, 2, addition));
        System.out.println("20 - 10 = " +
            myApp.operateBinary(20, 10, subtraction));    
    }
}
複製程式碼

方法operateBinary執行兩個數的數學操作。操作本身是對IntegerMath類的例項化。例項中通過lambda表示式定義了兩種操作,加法和減法。例子輸出結果如下:

40 + 2 = 42
20 - 10 = 10
複製程式碼

獲取閉包中的本地變數

像本地類和匿名類一樣,lambda表示式也可以訪問本地變數;它們有訪問本地變數的許可權。lambda表示式也是屬於當前作用域的,也就是說它不從父級作用域中繼承任何命名名稱,或者引入新一級的作用域。lambda表示式的作用域就是宣告它所在的作用域。下面的這個例子說明了這一點:

import java.util.function.Consumer;

public class LambdaScopeTest {

    public int x = 0;

    class FirstLevel {

        public int x = 1;

        void methodInFirstLevel(int x) {
            
            Consumer<Integer> myConsumer = (y) -> 
            {
                System.out.println("x = " + x); 
                System.out.println("y = " + y);
                System.out.println("this.x = " + this.x);
                System.out.println("LambdaScopeTest.this.x = " +
                    LambdaScopeTest.this.x);
            };
            myConsumer.accept(x);
        }
    }

    public static void main(String... args) {
        LambdaScopeTest st = new LambdaScopeTest();
        LambdaScopeTest.FirstLevel fl = st.new FirstLevel();
        fl.methodInFirstLevel(23);
    }
}

複製程式碼

將會輸出如下資訊:

x = 23
y = 23
this.x = 1
LambdaScopeTest.this.x = 0
複製程式碼

如果像下面這樣在lambda表示式myConsumer中使用x取代引數y,那麼編譯將會出錯。

Consumer<Integer> myConsumer = (x) -> {
}
複製程式碼

編譯會出現"variable x is already defined in method methodInFirstLevel(int)",因為lambda表示式不引入新的作用域(lambda表示式所在作用域已經有x被定義了)。因此,可以直接訪問lambda表示式所在的閉包的成員變數、函式和閉包中的本地變數。比如,lambda表示式可以直接訪問方法methodInFirstLevel的引數x。可以使用this關鍵字訪問類級別的作用域。在這個例子中this.x對成員變數FirstLevel.x的值。

然而,像本地和匿名類一樣,lambda表示式值可以訪問被修飾成final或者effectively final的本地變數和形參。比如,假設在methodInFirstLevel中新增定義宣告如下:

Effectively Final:一個變數或者引數的值在初始化後就不在發生變化,那麼這個變數或者引數就是effectively final型別的。

void methodInFirstLevel(int x) {
    x = 99;
}
複製程式碼

由於x =99的宣告使methodInFirstLevel的形參x不再是effectively final型別。結果java編譯器就會報類似"local variables referenced from a lambda expression must be final or effectively final"的錯誤。

目標型別

在執行時java是怎麼判斷lambda表示式的資料型別的?再看一下那個要選擇性別是男性,年齡在18到25歲之間的lambda表示式:

p -> p.getGender() == Person.Sex.MALE
    && p.getAge() >= 18
    && p.getAge() <= 25
複製程式碼

這個lambda表示式已引數的形式傳遞到如下兩個函式:

  • public static void printPersons(List roster, CheckPerson tester)
  • public void printPersonsWithPredicate(List roster, Predicate tester)

當java執行時呼叫方法printPersons時,它期望一個CheckPerson型別的資料,因此lambda表示式就是這種型別。當java執行時呼叫方法printPersonsWithPredicate時,它期望一個Predicate<Person>型別的資料,因此lambda表示式就是這樣一個型別。這些方法期望的資料型別就叫目標型別。為了確定lambda表示式的型別,java編譯器會在lambda表示式的的上下文中判斷它的目標型別。只有java編譯器可推測出來了目標型別,lambda表示式才可以被執行。

目標型別和函式引數

對於函式引數,java編譯器可以確定目標型別通過兩種其他語言特性:過載解析和型別引數推斷。看下面兩個函式式介面( java.lang.Runnable and java.util.concurrent.Callable):

public interface Runnable {
    void run();
}

public interface Callable<V> {
    V call();
}
複製程式碼

方法 Runnable.run 不返回任何值,但是 Callable<V>.call 有返回值。假設你像下面一樣過載了方法invoke

void invoke(Runnable r) {
    r.run();
}

<T> T invoke(Callable<T> c) {
    return c.call();
}
複製程式碼

那麼執行下面程式哪個方法將會被呼叫呢?

String s = invoke(() -> "done");
複製程式碼

方法invoke(Callable<T>)會被呼叫,因為這個方法有返回一個值;方法invoke(Runnable)沒有返回值。在這種情況下,lambda表示式() -> "done"的型別是Callable<T>

最後

感謝閱讀,有興趣可以關注微信公眾賬號獲取最新推送文章。

微信二維碼

相關文章