Java 8 的 Lambda 表達式和流處理


原文地址

Lambda 表達式

當提到 Java 8 的時候,Lambda 表達式總是第一個提到的新特性。Lambda 表達式把函數式編程風格引入到了 Java 平臺上,可以極大的提高 Java 開發人員的效率。這也是 Java 社區期待已久的功能,已經有很多的文章和圖書討論過 Lambda 表達式。本文則是基於官方的 JSR 335(Lambda Expressions for the Java Programming Language)來從另外一個角度介紹 Lambda 表達式。

引入 Lambda 表達式的動機

我們先從清單 1 中的代碼開始談起。該示例的功能非常簡單,只是啓動一個線程並輸出文本到控制檯。雖然該 Java 程序一共有 9 行代碼,但真正有價值的只有其中的第 5 行。剩下的代碼全部都是爲了滿足語法要求而必須添加的冗餘代碼。代碼中的第 3 到第 7 行,使用 java.lang.Runnable 接口的實現創建了一個新的 java.lang.Thread 對象,並調用 Thread 對象的 start 方法來啓動它。Runnable 接口是通過一個匿名內部類實現的。

清單 1. 傳統的啓動線程的方式

public class OldThread {
 public static void main(String[] args) {
   new Thread(new Runnable() {
     public void run() {
       System.out.println("Hello World!");
     }
   }).start();
 }
}

從簡化代碼的角度出發,第 3 行和第 7 行的 new Runnable() 可以被刪除,因爲接口類型 Runnable 可以從類 Thread 的構造方法中推斷出來。第 4 和第 6 行同樣可以被刪除,因爲方法 run 是接口 Runnable 中的唯一方法。把第 5 行代碼作爲 run 方法的實現不會出現歧義。把第 3,4,6 和 7 行的代碼刪除掉之後,就得到了使用 Lambda 表達式的實現方式,如清單 2 所示。只用一行代碼就完成了清單 1 中 5 行代碼完成的工作。這是令人興奮的變化。更少的代碼意味着更高的開發效率和更低的維護成本。這也是 Lambda 表達式深受歡迎的原因。

清單 2. 使用 Lambda 表 達式啓動線程

public class LambdaThread {
  public static void main(String[] args) {
    new Thread(() -> System.out.println("Hello World!")).start();
  }
}

簡單來說,Lambda 表達式是創建匿名內部類的語法糖(syntax sugar)。在編譯器的幫助下,可以讓開發人員用更少的代碼來完成工作。

函數式接口

在對清單 1 的代碼進行簡化時,我們定義了兩個前提條件。==第一個前提是要求接口類型,如示例中的 Runnable,可以從當前上下文中推斷出來;第二個前提是要求接口中只有一個抽象方法。如果一個接口僅有一個抽象方法(除了來自 Object 的方法之外),它被稱爲函數式接口(functional interface)。==函數式接口的特別之處在於其實例可以通過 Lambda 表達式或方法引用來創建。Java 8 的 java.util.function 包中添加了很多新的函數式接口。如果一個接口被設計爲函數式接口,應該添加@FunctionalInterface 註解。編譯器會確保該接口確實是函數式接口。當嘗試往該接口中添加新的方法時,編譯器會報錯。

目標類型

Lambda 表達式沒有類型信息。一個 Lambda 表達式的類型由編譯器根據其上下文環境在編譯時刻推斷得來。舉例來說,Lambda 表達式 () -> System.out.println("Hello World!") 可以出現在任何要求一個函數式接口實例的上下文中,只要該函數式接口的唯一方法不接受任何參數,並且返回值是 void。這可能是 Runnable 接口,也可能是來自第三方庫或應用代碼的其他函數式接口。由上下文環境所確定的類型稱爲目標類型。Lambda 表達式在不同的上下文環境中可以有不同的類型。類似 Lambda 表達式這樣,類型由目標類型確定的表達式稱爲多態表達式(poly expression)。

Lambda 表達式的語法很靈活。它們的聲明方式類似 Java 中的方法,有形式參數列表和主體。參數的類型是可選的。在不指定類型時,由編譯器通過上下文環境來推斷。Lambda 表達式的主體可以返回值或 void。返回值的類型必須與目標類型相匹配。當 Lambda 表達式的主體拋出異常時,異常的類型必須與目標類型的 throws 聲明相匹配。

由於 Lambda 表達式的類型由目標類型確定,在可能出現歧義的情況下,可能有多個類型滿足要求,編譯器無法獨自完成類型推斷。這個時候需要對代碼進行改寫,以幫助編譯器完成類型推斷。一個常見的做法是顯式地把 Lambda 表達式賦值給一個類型確定的變量。另外一種做法是顯示的指定類型。

在清單 3 中,函數式接口 A 和 B 分別有方法 a 和 b。兩個方法 a 和 b 的類型是相同的。類 UseAB 的 use 方法有兩個重載形式,分別接受類 A 和 B 的對象作爲參數。在方法 targetType 中,如果直接使用 () -> System.out.println("Use") 來調用 use 方法,會出現編譯錯誤。這是因爲編譯器無法推斷該 Lambda 表達式的類型,類型可能是 A 或 B。這裏通過顯式的賦值操作爲 Lambda 表達式指定了類型 A,從而可以編譯通過。

清單 3. 可能出現歧義的目標類型

public class LambdaTargetType {
 
  @FunctionalInterface
  interface A {
    void a();
  }
 
  @FunctionalInterface
  interface B {
    void b();
  }
 
  class UseAB {
    void use(A a) {
      System.out.println("Use A");
    }
 
    void use(B b) {
      System.out.println("Use B");
    }
  }
 
  void targetType() {
    UseAB useAB = new UseAB();
    A a = () -> System.out.println("Use");
    useAB.use(a);
  }
}

名稱解析

在 Lambda 表達式的主體中,經常需要引用來自包圍它的上下文環境中的變量。Lambda 表達式使用一個簡單的策略來處理主體中的名稱解析問題。Lambda 表達式並沒有引入新的命名域(scope)。Lambda 表達式中的名稱與其所在上下文環境在同一個詞法域中。Lambda 表達式在執行時,就相當於是在包圍它的代碼中。在 Lambda 表達式中的 this 也與包圍它的代碼中的含義相同。在清單 4 中,Lambda 表達式的主體中引用了來自包圍它的上下文環境中的變量 name。

清單 4. Lambda 表 達式中的名稱解析

public void run() {
  String name = "Alex";
  new Thread(() -> System.out.println("Hello, " + name)).start();
}

需要注意的是,可以在 Lambda 表達式中引用的變量必須是聲明爲 final 或是實際上 final(effectively final)的。實際上 final 的意思是變量雖然沒有聲明爲 final,但是在初始化之後沒有被賦值。因此變量的值沒有改變。

Java 8 中的流表示的是元素的序列。流中的元素可能是對象、int、long 或 double 類型。流作爲一個高層次的抽象,並不關注流中元素的來源或是管理方式。流只關注對流中元素所進行的操作。當流與函數式接口和 Lambda 表達式一同使用時,可以寫出簡潔高效的數據處理代碼。下面介紹幾個與流相關的基本概念。

順序執行和 並行執行

流的操作可以順序執行或並行執行, 後者可以獲得比前者更好的性能。但是如果實現不當,可能由於數據競爭或無用的線程同步,導致並行執行時的性能更差。一個流是否會並行執行,可以通過其方法 isParallel() 來判斷。根據流的創建方式,一個流有其默認的執行方式。可以使用方法 sequential() 或 parallel() 來將其執行方式設置爲順序或並行。

相遇順序

一個流的相遇順序(encounter order)是流中的元素被處理時的順序。流根據其特徵可能有,也可能沒有一個確定的相遇順序。舉例來說,從 ArrayList 創建的流有確定的相遇順序;從 HashSet 創建的流沒有確定的相遇順序。大部分的流操作會按照流的相遇順序來依次處理元素。如果一個流是無序的,同一個流處理流水線在多次執行時可能產生不一樣的結果。比如 Stream 的 findFirst() 方法獲取到流中的第一個元素。如果在從 ArrayList 創建的流上應用該操作,返回的總是第一個元素;如果是從 HashSet 創建的流,則返回的結果是不確定的。對於一個無序的流,可以使用 sorted 操作來排序;對於一個有序的流,可以使用 unordered() 方法來使其無序。

Spliterator

所有的流都是從 Spliterator 創建出來的。Spliterator 的名稱來源於它所支持的兩種操作:split 和 iterator。Spliterator 可以看成是 Iterator 的並行版本,允許通過對流中元素分片的方式來切分數據源。使用其 tryAdvance 方法來順序遍歷元素,也可以使用 trySplit 方法來創建一個新的 Spliterator 對象在新劃分的數據集上工作。Spliterator 還提供了 forEachRemaining 方法進行批量順序遍歷。可以使用 estimateSize 方法來查詢可能會遍歷的元素數量。一般的做法是先使用 trySplit 切分數據源。當元素數量足夠小時,使用 forEachRemaining 來對分片中的全部元素進行處理。這也是典型的分治法的思路。

每個 Spliterator 可以有一系列不同的特徵,可以通過 characteristics 方法來查詢。一個 Spliterator 具備的特徵取決於其數據源和元素。所有可用的特徵如下所示:

  • CONCURRENT:表明數據源可以安全地由多個線程進行修改,而無需額外的同步機制。
  • DISTINCT:表明數據源中的元素是唯一的,不存在重複元素。
  • IMMUTABLE:表明數據源是不可變的, 無法進行修改操作。
  • NONNULL:表明數據源中不存在 null 元素。
  • ORDERED:表明數據源中的元素有確定的相遇順序。
  • SIZED:表明數據源中的元素的數量是確定的。
  • SORTED:表明數據源中的元素是有序的。
  • SUBSIZED:表明使用 trySplit 切分出來的子數據源也有 SIZED 和 SUBSIZED 的特徵。

Spliterator 需要綁定到流之後才能遍歷其中的元素。不同的 Spliterator 實現可能有不同的綁定時機。如果一個 Spliterator 是延遲綁定的,那麼只有在進行首次遍歷、首次切分或首次查詢大小時,纔會綁定到流上;反之,它會在創建時或首次調用任何方法時綁定到流上。綁定時機的重要性在於,在綁定之前對流所做的修改,在 Spliterator 遍歷時是可見的。延遲綁定可以提供最大限度的靈活性。

有狀態和無狀態操作

流操作可以是有狀態或無狀態的。當一個有狀態的操作在處理一個元素時,它可能需要使用處理之前的元素時保留的信息;無狀態的操作可以獨立處理每個元素,舉例來說:

  • distinct 和 sorted 是有狀態操作的例子。distinct 操作從流中刪除重複元素,它需要記錄下之前已經遇到過的元素來確定當前元素是否應該被刪除。sorted 操作對流進行排序,它需要知道所有元素來確定當前元素在排序之後的所在位置。
  • filter 和 map 是無狀態操作的例子。filter 操作在進行過濾時只需要看當前元素即可。map 操作可以獨立轉換當前元素。一般來說,有狀態操作的運行代價要高於無狀態操作,因爲需要額外的空間保存中間狀態信息。

Stream 是表示流的接口,T 是流中元素的類型。對於原始類型的流,可以使用專門的類 IntStream、LongStream 和 DoubleStream。

流水線

在對流進行處理時,不同的流操作以級聯的方式形成處理流水線。一個流水線由一個源(source),0 到多箇中間操作(intermediate operation)和一個終結操作(terminal operation)完成。

  • 源:源是流中元素的來源。Java 提供了很多內置的源,包括數組、集合、生成函數和 I/O 通道等。
  • 中間操作:中間操作在一個流上進行操作,返回結果是一個新的流。這些操作是延遲執行的。
  • 終結操作:終結操作遍歷流來產生一個結果或是副作用。在一個流上執行終結操作之後,該流被消費,無法再次被消費。

流的處理流水線在其終結操作運行時纔開始執行。

Java 8 支持從不同的源中創建流。Stream.of 方法可以使用給定的元素創建一個順序流。使用 java.util.Arrays 的靜態方法可以從數組中創建流,如清單5 所示。

清單 5. 從數組中創建流

Arrays.stream(new String[] {"Hello", "World"})
.forEach(System.out::println);
// 輸出"Hello\nWorld"到控制檯
 
int sum = Arrays.stream(new int[] {1, 2, 3})
.reduce((a, b) -> a + b)
.getAsInt();
// "sum"的值是"6"

接口 Collection 的默認方法 stream() 和 parallelStream() 可以分別從集合中創建順序流和並行流,如清單 6 所示。

清單 6. 從集合中創建流

List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
list.stream()
.forEach(System.out::println);
// 輸出 Hello 和 World

中間操作

流中間操作在應用到流上,返回一個新的流。下面列出了常用的流中間操作:

  • map:通過一個 Function 把一個元素類型爲 T 的流轉換成元素類型爲 R 的流。
  • flatMap:通過一個 Function 把一個元素類型爲 T 的流中的每個元素轉換成一個元素類型爲 R 的流,再把這些轉換之後的流合併。
  • filter:過濾流中的元素,只保留滿足由 Predicate 所指定的條件的元素。
  • distinct:使用 equals 方法來刪除流中的重複元素。
  • limit:截斷流使其最多隻包含指定數量的元素。
  • skip:返回一個新的流,並跳過原始流中的前 N 個元素。
  • sorted:對流進行排序。
  • peek:返回的流與原始流相同。當原始流中的元素被消費時,會首先調用 peek 方法中指定的 Consumer 實現對元素進行處理。
  • dropWhile:從原始流起始位置開始刪除滿足指定 Predicate 的元素,直到遇到第一個不滿足 Predicate 的元素。
  • takeWhile:從原始流起始位置開始保留滿足指定 Predicate 的元素,直到遇到第一個不滿足 Predicate 的元素。

在清單 7 中,第一段代碼展示了 flatMap 的用法,第二段代碼展示了 takeWhile 和 dropWhile 的用法。

清單 7. 中間操作示例

Stream.of(1, 2, 3)
    .map(v -> v + 1)
    .flatMap(v -> Stream.of(v * 5, v * 10))
    .forEach(System.out::println);
//輸出 10,20,15,30,20,40
 
Stream.of(1, 2, 3)
    .takeWhile(v -> v <  3)
    .dropWhile(v -> v <  2)
    .forEach(System.out::println);
//輸出 2

終結操作

終結操作產生最終的結果或副作用。下面是一些常見的終結操作。

forEach 和 forEachOrdered 對流中的每個元素執行由 Consumer 給定的實現。在使用 forEach 時,並沒有確定的處理元素的順序;forEachOrdered 則按照流的相遇順序來處理元素,如果流有確定的相遇順序的話。

reduce 操作把一個流約簡成單個結果。約簡操作可以有 3 個部分組成:

  • 初始值:在對元素爲空的流進行約簡操作時,返回值爲初始值。
  • 疊加器:接受 2 個參數的 BiFunction。第一個參數是當前的約簡值,第二個參數是當前元素,返回結果是新的約簡值。
  • 合併器:對於並行流來說,約簡操作可能在流的不同部分上並行執行。合併器用來把部分約簡結果合併爲最終的結果。

在清單 8 中,第一個 reduce 操作是最簡單的形式,只需要聲明疊加器即可。初始值是流的第一個元素;第二個 reduce 操作提供了初始值和疊加器;第三個 reduce 操作聲明瞭初始值、疊加器和合並器。

清單 8. reduce 操 作示例

Stream.of(1, 2, 3).reduce((v1, v2) -> v1 + v2)
    .ifPresent(System.out::println);
// 輸出 6
 
int result1 = Stream.of(1, 2, 3, 4, 5)
    .reduce(1, (v1, v2) -> v1 * v2);
System.out.println(result1);
// 輸出 120
 
int result2 = Stream.of(1, 2, 3, 4, 5)
    .parallel()
    .reduce(0, (v1, v2) -> v1 + v2, (v1, v2) -> v1 + v2);
System.out.println(result2);  
// 輸出 15

Max 和 min 是兩種特殊的約簡操作,分別求得流中元素的最大值和最小值。

對於一個流,操作 allMatch、anyMatch 和 nonMatch 分別用來檢查是否流中的全部元素、任意元素或沒有元素滿足給定的條件。判斷的條件由 Predicate 指定。

操作 findFirst 和 findAny 分別查找流中的第一個或任意一個元素。兩個方法的返回值都是 Optional 對象。當流爲空時,返回的是空的 Optional 對象。如果一個流沒有確定的相遇順序,那麼 findFirst 和 findAny 的行爲在本質上是相同的。

操作 collect 表示的是另外一類的約簡操作。與 reduce 不同在於,collect 會把結果收集到可變的容器中,如 List 或 Set。收集操作通過接口 java.util.stream.Collector 來實現。Java 已經在類 Collectors 中提供了很多常用的 Collector 實現。

第一類收集操作是收集到集合中,常見的方法有 toList()、toSet() 和 toMap() 等。第二類收集操作是分組收集,即使用 groupingBy 對流中元素進行分組。分組時對流中所有元素應用同一個 Function。具有相同結果的元素被分到同一組。分組之後的結果是一個 Map,Map 的鍵是應用 Function 之後的結果,而對應的值是屬於該組的所有元素的 List。在清單 9 中,流中的元素按照字符串的第一個字母分組,所得到的 Map 中的鍵是 A、B 和 D,而 A 對應的 List 值中包含了 Alex 和 Amy 兩個元素,B 和 D 所對應的 List 值則只包含一個元素。

清單 9. 收集器 groupingBy 示 例

final Map<Character, List<String>> names = Stream.of("Alex", "Bob", "David", "Amy")
    .collect(Collectors.groupingBy(v -> v.charAt(0)));
System.out.println(names);

第三類的 joining 操作只對元素類型爲 CharSequence 的流使用,其作用是把流中的字符串連接起來。清單 10 中把字符串流用", "進行連接。

清單 10. 收集器 joining 示 例

String str = Stream.of("a", "b", "c")
   .collect(Collectors.joining(", "));
System.out.println(str);

第四類的 partitioningBy 操作的作用類似於 groupingBy,只不過分組時使用的是 Predicate,也就是說元素最多分成兩組。所得到結果的 Map 的鍵的類型是 Boolean,而值的類型同樣是 List。

還有一些收集器可以進行數學計算,不過只對元素類型爲 int、long 或 double 的流可用。這些數學計算包括:

  • averagingDouble、averagingInt 和 averagingLong 計算流中元素的平均值。
  • summingDouble、summingInt 和 summingLong 計算流中元素的和。
  • summarizingDouble、summarizingInt 和 summarizingLong 對流中元素進行數學統計,可以得到平均值、數量、和、最大值和最小值。

清單 11 展示了這些數學計算相關的收集器的用法。

清單 11. 與數學計算相關的收集器

double avgLength = Stream.of("hello", "world", "a")
    .collect(Collectors.averagingInt(String::length));
System.out.println(avgLength);
 
final IntSummaryStatistics statistics = Stream.of("a", "b", "cd")
    .collect(Collectors.summarizingInt(String::length));
System.out.println(statistics.getAverage());
System.out.println(statistics.getCount());

Stream 中還有其他實用的操作,限於篇幅不能全部介紹。相關的用法可以查看 API 文檔。

總結

Java 8 引入的 Lambda 表達式和流處理是可以極大提高開發效率的重要特性。每個 Java 開發人員都應該熟練掌握它們的使用。本文從 JSR 335 出發對 Lambda 表達式進行了深入的介紹,同時也對流的特徵和操作進行了詳細說明。下一篇文章將對 Java 平臺上流行的函數式編程庫 Vavr 進行介紹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章