深入理解 Java 函數語言程式設計,第 3 部分: Java 8 的 Lambda 表示式和流處理
深入理解 Java 函數語言程式設計,第 3 部分
Java 8 的 Lambda 表示式和流處理
成 富
2018 年 12 月 03 日釋出
系列內容:
此內容是該系列 5 部分中的第 # 部分: 深入理解 Java 函數語言程式設計,第 3 部分
https://www.ibm.com/developerworks/cn/views/global/libraryview.jsp?sort_by=&show_abstract=true&show_all=&search_flag=&contentarea_by=%E6%89%80%E6%9C%89%E4%B8%93%E5%8C%BA&search_by=%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3+Java+%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BC%96%E7%A8%8B&product_by=-1&topic_by=-1&type_by=%E6%89%80%E6%9C%89%E7%B1%BB%E5%88%AB&ibm-search=%E6%90%9C%E7%B4%A2
敬請期待該系列的後續內容。
此內容是該系列的一部分: 深入理解 Java 函數語言程式設計,第 3 部分
敬請期待該系列的後續內容。
在本系列的前兩篇文章中,已經對函數語言程式設計的思想和函數語言程式設計的重要概念做了介紹。本文將介紹 Java 平臺本身對函數語言程式設計的支援,著重介紹 Lambda 表示式和流(Stream)。
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)。在編譯器的幫助下,可以讓開發人員用更少的程式碼來完成工作。
函式式介面
在對的程式碼進行簡化時,我們定義了兩個前提條件。第一個前提是要求介面型別,如示例中的 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> 是表示流的介面,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 文件。