一文帶你深入了解 Lambda 表達式和方法引用

前言

儘管目前很多公司已經使用 Java8 作為項目開發語言,但是仍然有一部分開發者只是將其設置到 pom 文件中,並未真正開始使用。而項目中如果有8新特性的寫法,例如λ表達式。也只是 Idea Alt+Enter 生成的。最近天氣非常熱,出門曬太陽不如和我一起系統的學習一下 Java8 的新特性。提高開發效率也可、享受同事羨慕的眼神也可,讓我們開始吧

聲明:本文首發於部落格園,作者:後青春期的Keats;地址://www.cnblogs.com/keatsCoder/ 轉載請註明,謝謝!

新特性

函數式編程:Lambda表達式、流式編程

其他特性:默認方法、新的Optional類、CompletableFutrue、LocalDate/LocalTime

這篇文章重點討論 Lambda 及某些情況下更易讀、更自然的方法引用。

Lambda表達式

行為參數化

行為參數化就是一個方法接受多個不同的行為作為參數,並在內部使用他們,完成不同行為的能力。其實說白了就是將一段程式碼作為另一個方法的形參,使該方法更加的靈活、可以應對多變的需求。

舉個關於蘋果的例子

例如老師安排張三這麼一個任務(“法外狂徒”張三改行做程式設計師了):籃子有很多蘋果 List ,需要篩選出這些蘋果中的綠色蘋果

根據具象篩選蘋果

這個需求很簡單,張三兩下就搞定了:

public static List<Apple> filterGreenApples(List<Apple> appleList){
    List<Apple> result = new ArrayList<>();
    for (Apple apple : appleList) {
        if("green".equals(apple.getColor())){
            result.add(apple);
        }
    }
    return result;
}

可是這個時候老師改主意了。說綠色的不好吃想吃紅色的蘋果,張三隻好複製這個方法進行修改,將green改成red並修改方法名為 filterRedApples。然而如果老師又讓他篩選多種其他顏色的蘋果,例如:淺綠色、暗紅色、黃色等。這種複製、修改的方法就顯得有些難應付。一個良好的原則是嘗試抽象其共性

對於篩選蘋果的需求,可以嘗試給方法添加一個參數 color。非常簡單的就可以應對老師對不同顏色蘋果的需求。

public static List<Apple> filterApplesByColor(List<Apple> appleList, String color){
    List<Apple> result = new ArrayList<>();
    for (Apple apple : appleList) {
        if(color.equals(apple.getColor())){
            result.add(apple);
        }
    }
    return result;
}

張三滿意的提交了程式碼。但是這時老師又對張三說:我想要一些重一點的蘋果,一般大於150g的蘋果就是比較重的。作為程式設計師,張三早就想好老師可能會改重量。因此提前定義一個參數作為蘋果的重量:

public static List<Apple> filterApplesByWeight(List<Apple> appleList, int weight){
    List<Apple> result = new ArrayList<>();
    for (Apple apple : appleList) {
        if(apple.getWeight() > weight){
            result.add(apple);
        }
    }
    return result;
}

解決方案不錯。可是張三複制了大量的方法用於遍歷庫存。並對每個蘋果應用篩選條件。他打破了DRY(Dont repeat youselt 不要重複自己)的軟體設計原則。試想一下,如果張三想換一種遍歷的方式,那麼每個方法都需要再改一次,工作量很大。那有沒有一種方法能將顏色和品質組合成一個方法呢?可以嘗試加一個 flag,然後根據 flag 的值來確定使用哪個判斷條件。但這種方法十分差勁!試想如果以後有了更多的條件:蘋果的大小、產地、品種等等。這個程式碼應該怎麼維護?因此張三需要一種更加靈活的方式來實現篩選蘋果的方法。

根據抽象條件篩選

不管使用什麼條件篩選,他們都有共性:

  • 需要一個蘋果
  • 執行一段程式碼
  • 返回一個 boolean 的值

其中執行一段程式碼這一步是不確定的,而參數和返回值是確定的,因此我們可以定義一個介面:

public interface ApplePredicate {
    boolean test(Apple apple);
}

及不同條件篩選的實現:

public class AppleHeavyWeightPredicate implements ApplePredicate{
    @Override
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}

篩選的方法也可以改成這樣:

public static List<Apple> filterApples(List<Apple> appleList, ApplePredicate applePredicate){
    List<Apple> result = new ArrayList<>();

    for (Apple apple : appleList) {
        if(applePredicate.test(apple)){
            result.add(apple);
        }
    }
    return result;
}

這時無論應對怎樣的需求,張三隻需要重新實現 test 方法,然後通過 filterApples 方法傳遞 test 方法的行為。這表示 filterApples 方法的行為參數化了!

但是張三又覺得這樣的實現太麻煩了,每次新來一個需求他都需要創建一個類實現 ApplePredicate 介面。有沒有更好的辦法呢?答案是肯定的。在 Java8 之前可以通過匿名類來實現:

public static void main(String[] args) {
    List<Apple> appleList = new ArrayList<>();
    appleList.add(new Apple("red", 150));

    List<Apple> result = filterApples(appleList, new ApplePredicate() {
        @Override
        public boolean test(Apple apple) {
            return "green".equals(apple.getColor()) && apple.getWeight() > 150;
        }
    });
}

匿名類雖然可以解決創建新類的問題,但是他太長了。那要如何簡化呢? Java8 提供的 Lambda 就是專門用來簡化它的。且看程式碼:

public static void main(String[] args) {
    List<Apple> appleList = new ArrayList<>();
    appleList.add(new Apple("red", 150));

    List<Apple> result = filterApples(appleList, apple -> "green".equals(apple.getColor()) && apple.getWeight() > 150);
}

從蘋果的例子可以看到,行為參數化是一種很有用的模式,它能夠輕鬆應對多變的需求,它通過把一個行為(一段程式碼)封裝起來,並通過傳遞和使用創建的行為將其參數化。這種做法類似於策略設計模式。而JavaAPI中已經在多出實踐過這個模式了,例如 Comparator 排序、Runnable執行程式碼塊等等

Lambda管中窺豹

Lambda是一種簡潔的傳遞一個行為的匿名函數,它沒有名稱,卻有參數列表、函數主體、返回值、甚至還可以拋出異常。基本語法像這樣:

(parameters) -> {statements;}

(parameters) -> expression

在哪裡及如何使用Lambda

函數式介面

函數式介面就是只定義一個抽象方法的介面(如果介面中定義了默認方法實現,無論有多少個。只要它只有一個抽象方法,它仍然是函數式介面)

前面我們在 ApplePredicate 介面中只定義了一個抽象方法 test,所以 ApplePredicate 介面就是函數式介面。類似的還有 Comparator 和 Runnable 等。 Lambda 可以代替匿名類來作為函數式介面的實例。

public static void main(String[] args) {
    Runnable r1 = () -> System.out.println("Hello World 1");
    Runnable r2 = new Runnable() {
        @Override
        public void run() {
            System.out.println("Hello World 2");
        }
    };
    process(r1);
    process(r2);
    process(() -> System.out.println("Hello World 3"));
}

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

@FunctionalInterface

該註解可以用來聲明一個介面是函數式介面,如果介面上有聲明,但程式設計師又為介面寫了其他抽象方法,編譯器會報錯

環繞執行模式

資源處理(處理文件、資料庫)常見的操作方法就是:打開一個資源、做一些處理、關閉/釋放資源。這個打開和關閉階段總是很相似,並且會圍繞執行處理的哪些重要程式碼。這就是所謂的環繞執行模式。例如:

public static String readLine() throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader("a.txt"))){
        return br.readLine();
    }
}

這個寫法是有局限性的,因為你無法靈活的修改處理邏輯的程式碼。那就跟著我來將他改造成 lambda 可用的形式吧

  1. 行為參數化

    首先我們要做的行為定義為 processFile。以下是從文件中讀取兩行的參數化寫法

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

  2. 使用函數式介面來傳遞行為

    processFile 這個方法需要匹配的函數描述符長這樣: BufferedReader -> String 。那我們可以照著它定義介面

    @FunctionalInterface
    public interface BufferedReaderProcesser {
        String profess(BufferedReader br);
    }
    
  3. 執行一個行為

    改造 processFile 方法,讓 BufferedReaderProcesser 介面作為它所執行行為的載體

    public static String processFile(BufferedReaderProcesser brf) throws IOException {
        try(BufferedReader br = new BufferedReader(new FileReader("a.txt"))){
            return brf.professFile(br);
        }
    }
    
  4. 傳遞Lambda

    接下來就可以使用 Lambda 來傳遞不同的行為來以不同的方式處理文件了:

    public static void main(String[] args) throws IOException {
        // 讀一行
        String str1 = processFile(br -> br.readLine());
        // 讀兩行
        String str2 = processFile(br -> br.readLine() + " " + br.readLine());
        // 找到第一個包含 lambda 的行
        String str3 = processFile(br ->
                                  {
                                      String s;
                                      while ((s = br.readLine()).length() > 0) {
                                          if (s.contains("lambda")) {
                                              return s;
                                          }
                                      }
                                      return null;
                                  }
                                 );
        System.out.println(str1);
        System.out.println(str2);
        System.out.println(str3);
    }
    

    且看控制台的輸出:

    1588507834876

Java提供的函數式介面

Java8 的設計師們在 java.util.function 包中引入了很多新的函數式介面,以下是幾個常用的

Predicate

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

布爾類型介面:在需要將一個任意類型的對象處理成布爾表達式時,可能需要它。例如我們之前處理的蘋果,當然 T 也可以是學生對象(篩選出身高大於多少的)、用戶對象(篩選具有某特徵的用戶)等等

Consumer

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

消費類型介面:Consumer 是一個消費型方法,他接收一個泛型 然後處理掉。不返回任何東西。

Function

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

如果你需要定義一個 Lambda 將輸入的對象資訊映射到輸出,那 Function 是再合適不過的了。

function 包下還有需要類似的函數式介面,讀者可以自行去關注一下介面中方法的參數和返回值。來決定使用哪個。

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

通過上面的介紹,讀者已經對 Lambda 表達式的寫法有了一定的了解,那麼 Java 編譯器是如何識別 Lambda 的參數和返回值的呢?

類型檢查

Java 通過上下文(比如,接受他傳遞的方法的參數或是接受他值的局部變數)來推斷 Lambda 表達式需要的目標類型而這個目標類型一般是一個函數式介面,之後判斷表達式的參數和返回值是否與介面中唯一抽象方法的聲明相對應

類型推斷

Java 編譯器從上下文中推斷出表達式的目標類型後,表達式的參數類型也就被編譯器所知道。所以書寫表達式時可以省略參數類型,例如:

String str1 = processFile(br -> br.readLine());

processFile 方法的參數(Lambda的目標類型)是:BufferedReaderProcesser brf。BufferedReaderProcesser 介面唯一的抽象方法:String profess(BufferedReader br);方法聲明的參數類型是 BufferedReader 。Java 編譯器可以推斷到這裡。因此直接寫 br 是沒問題的。對於兩個參數的方法也可以省略參數類型。而一個參數的方法可以省略參數類型和參數兩邊的括弧

方法引用

方法引用讓你可以重複使用現有的方法定義,並像 Lambda 一樣傳遞它們。即提前寫好的,可復用的 Lambda 表達式。如果一個 Lambda 代表的只是「直接調用這個方法」,那最好還是用名稱調用它。方法引用的寫法如下:

目標引用::方法名 // 因為這裡沒有實際調用方法,故方法的 () 不用寫

三類方法引用

  • 指向靜態方法的方法引用

    (args) -> ClassName.staticMethod(args) 寫成 ClassName::staticMethod

  • 指向任意類型實例方法的方法引用,例如 T 類的實例 arg0

    (arg0, rest) -> arg0.instanceMethod(rest) 寫成 T::instanceMethod

  • 指向現有對象的實例方法的方法引用。

    (args) -> expr.instanceMethod(args) 寫成 expr::instanceMethod

第二類和第三類乍看有些迷糊,仔細分辨可以發現:如果方法的調用者是 Lambda 的參數,則目標引用是調用者的類。如果調用者是已經存在的實例對象,則目標引用是該對象

構造函數方法引用

方法引用還可以被用在構造函數上,寫法是這樣:ClassName::new

比如獲取對於獲取類型Supplier的介面,我分別用三種寫法寫出創建一個蘋果對象的方法:

// 方法引用寫法
Supplier<Apple> s1 = Apple::new;
// Lambda 寫法
Supplier<Apple> s2 = () -> new Apple();
// 普通寫法
Supplier<Apple> s3 = new Supplier<Apple>() {
    @Override
    public Apple get() {
        return new Apple();
    }
};

複合 Lambda 表達式

上面我們所討論的 Lambda 表達式都是單獨使用的,而 function 包中很多介面中還定義了額外的默認方法,用來複合 Lambda 表達式。

比較器複合

倒序

假如我們有一個給蘋果按指定重量排序的方法

List<Apple> appleList = new ArrayList<>();
// 構造一個按品質升序排序的比較器
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
appleList.sort(c);
// 按品質倒敘
appleList.sort(c.reversed());

其中,Comparator.comparing 方法是一個簡化版的 compare 方法的實現形式,源碼如下:

public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
    Function<? super T, ? extends U> keyExtractor)
{
    Objects.requireNonNull(keyExtractor);
    return (Comparator<T> & Serializable)
        (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

該方法接收一個 Function 介面的實現類作為參數,而我們的 Apple::getWeight 方法解析過來就是實現了 Function 介面,重寫 apply 方法,apply 方法的聲明解析為 int apply(Apple a) ,方法內通過調用 a.getWeight() 方法返回 int 類型的值。後來 return 語句中的 (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2)); 其實就是 Comparator 的 Lambda 表達式實現的匿名類中的方法體。重寫的是 int compare(T o1, T o2); 方法

比較器鏈

我們經常遇到這樣的問題,比較蘋果品質時,品質相同。那麼接下來就需要第二選擇條件了。Comparable 介面也提供了便於 Lambda 使用的比較器鏈方法 thenComparing。比如首先比較品質,當品質相同時按照價格降序

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
Comparator<Apple> compareByWeightThenPrice = c.thenComparing(Apple::getPrice).reversed();

appleList.sort(compareByWeightThenPrice);

謂詞複合

Predicate 謂詞介面中有三個可用的複合方法: and、or、negate 分別表示與或非。使用方法和比較器複合大同小異,讀者可以自行體驗

函數複合

Function 函數介面中有 andThen() 和 compose() 方法,參數都是 Function 的實現,區別如下

a.andThen(b) 是先執行 a 再執行 b

a.compose(b) 是先執行 b 再執行 a

總結

  1. Lambda 和方法引用本身並不難,理解行為參數化是使用 Lambda 和方法引用的前提
  2. 函數式介面是僅僅聲明了一個抽象方法的介面,只有在接受函數式介面的地方才能使用 Lambda 表達式
  3. 方法引用可以讓你復用現有的方法實現
  4. Comparator、Predicate、Function等函數式介面都提供了幾個用來結合 Lambda 表達式的默認方法