1. 程式人生 > >JAVA8流式程式設計【1】——函式純度

JAVA8流式程式設計【1】——函式純度

函式管道和 Stream API

我們使用 Stream 在 Java™ 中構建函式管道。在函式式程式碼中使用 Stream 有 3 個好處:

  • Stream 簡潔、富於表達、非常優雅,而且程式碼讀起來就像是問題陳述。
  • Stream 採用了惰性計算,這使得它在您的程式中非常高效。
  • 它可以並行使用。

在本系列中,您已詳細瞭解了優雅和簡潔的程式碼的好處。在本文中,我們將重點介紹其他兩個好處。效率是您在使用函式管道時尋求的主要好處之一,所以我們首先從這裡開始介紹。

惰性計算

下面的命令式程式碼非常高效:它僅執行絕對必要的工作。

List<Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);
Integer result = null;for(int e : numbers) {if(e > 10 && e % 2 == 0) {result = e * 2;break;}}if(result != null)System.out.println("The value is " + result);elseSystem.out.println("No value found");

該程式碼迭代 numbers 集合中的元素,但僅迭代至找到滿足它的兩個要求(大於 10 且是偶數)的元素。找到第一個數字後,就不會再處理其他值。

現在讓我們使用函式管道重寫上述程式碼:

List<
Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);System.out.println(numbers.stream().filter(e -> e > 10).filter(e -> e % 2 == 0).map(e -> e * 2).findFirst().map(e -> "The value is " + e).orElse("No value found"));

這個函式式版本生成的結果與命令式版本相同。在給出的示例中,命令式版本不會處理任何超過 12 的值,函式版本也是如此。不同之處在於程式碼處理給定變數的方式。

流處理

Java Stream 基本上是惰性的,就像我十幾歲的孩子一樣。下面是我家裡的一個場景,可能有助於您理解流的行為。

我的妻子對兒子說:“關掉電視”。

跟沒說一樣。

妻子說:“把垃圾倒掉”。

沒有任何動作。

她再說:“做你的家庭作業”。

鉛筆沒被拿起過。

妻子說:“我要叫你爸爸了。”

孩子馬上行動起來,按下電視遙控器上的關閉按鈕……

像十幾歲的孩子一樣,Stream 只有兩種方法:中間和最終。根據家中每位家長扮演的角色,後一個方法等效於 callDaddy()或 callMommy() 方法。

Stream 累積並組合融合中間操作,然後執行它們。但是像十幾歲的孩子一樣,它僅執行滿足最終操作所必需的工作。因為中間操作被融合,所以流對管道中資料的處理方式存在一個重要區別:Stream 不會像命令式程式碼一樣執行資料集合上的每個函式,而是執行每個元素上的函式的融合集合,但僅在需要時執行。

為了驗證此行為,我們可以稍微更改一下最初的函式式程式碼:

List<Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);System.out.println(numbers.stream().peek(e -> System.out.println("processing " + e)).filter(e -> e > 10).filter(e -> e % 2 == 0).map(e -> e * 2).findFirst().map(e -> "The value is " + e).orElse("No value found"));

在這裡,我們在函式管道中的第一個 filter 的前面添加了對 peek 的呼叫。peek 方法對除錯很有用,使我們能在執行期間留意到Stream。這是新程式碼的輸出:

processing 2processing 5processing 8processing 15processing 12The value is 24

該程式碼處理了直到 12(包含 12)的所有值,但它沒有觸及超過目標值的任何值。這是因為最終操作 findFirst 會觸發流處理的終止。此外,兩個 filter 和 map 呼叫中的操作融合在一起,然後在序列中的每個元素上執行計算。超過 findFirst 中的內部終止訊號後,就不會再計算元素。

在本例中,惰性顯然提高了效率,因為函式管道只執行必要的工作。它是效率與優雅結合的典範。

並行化

在您有一個大型集合或者需要執行消耗大量時間的任務的情況下,並行化可能非常有用。下面的程式碼將模擬一個耗時的操作。

import java.util.*;class Sample {public static int simulateTimeConsumingComputation(int number) {try { Thread.sleep(1000); } catch(Exception ex) {}return number * 2;} public static void main(String[] args) { List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);numbers.stream().map(Sample::simulateTimeConsumingComputation).forEachOrdered(System.out::println);}}

如果正常執行此程式碼,您會發現所用時間約為 10 秒。這太長了。 我們可以使用一個並行流來提高速度,如下所示:

...numbers.stream().parallel().map(Sample::simulateTimeConsumingComputation)...

並行流使執行速度變得快得多。新程式碼在 16 核處理器上執行所用的時間約為 1 秒,在 8 核處理器上所用時間約為 2 秒。這是因為在預設情況下,並行流使用了與系統上的核心數一樣多的執行緒。

您還會注意到,並行化此程式碼只需相對較少的工作。按順序執行的函式管道的結構與並行執行的函式管道沒什麼不同,這使得函式管道非常容易並行化。

函式純度的規則

目前您可能很喜歡這些技術給您的印象:惰效能提高效率,並行化的編寫與順序處理一樣容易 — 使用它們吧!但是有一個陷阱:這些技術的成功取決於程式碼的純度。您的函式管道中的所有 lambda 表示式和閉包都必須是純的。

在繼續後面的學習之前,您應該瞭解純函式的一些知識。首先,純函式是冪等的— 這意味著對純函式的呼叫次數沒有限制。其次,無論呼叫純函式多少次,只要給定相同的輸入,它都會產生相同的結果。第三,純函式沒有副作用:無論您使用它做什麼,純函式都不會更改您的程式中的其他任何元素。

如果您想編寫純函式,請記住,最後這個特徵最為重要。實質上,函式純度有兩個規則:

  • 函式不會更改任何元素。
  • 函式不依賴於任何可能更改的元素。

純函式絕不會在執行期間引起更改或發生更改。

為什麼函式純度至關重要

惰性計算意味著一個函式可以在現在或以後計算,或者可以完全跳過計算。無論採用何種方式,只要得到想要的結果就行。但是,如果函式有副作用,惰性計算就不會生效。下一個示例將展示在函式管道包含不純函式時會發生什麼。

List<Integer> numbers = Arrays.asList(1, 2, 3);int[] factor = new int[] { 2 };Stream<Integer> stream = numbers.stream().map(e -> e * factor[0]);factor[0] = 0;stream.forEach(System.out::println);

Java 假設提供給操作的 lambda 表示式和閉包是純的。如果您的程式碼不滿足這一要求,您將承擔相應的後果。

為了增添樂趣,可以詢問一些同事他們預計此程式碼的輸出是什麼。您不可能獲得一致的回答。更可能的情況是,您會看到許多人感到困惑和不確定。

在這個示例中,傳遞給 map 的閉包是不純的。它違背了純度的第二個規則,因為該閉包依賴的變數可能發生改變(而且事實上它確實發生了改變)。由於惰性計算,作為引數傳遞給 map 的閉包只在呼叫 forEach 後才會計算。

因為 factor[0] 是可變的,從建立閉包到最終計算它的過程中,該值可以是任何值。這個可變變數讓程式碼變得很難理解。很難理解的程式碼也就很難維護,而且這通常是出現錯誤的一個原因。

並行流也是如此:如果傳遞給操作的狀態不純,結果將是不可預測的。

避免共享可變性

傳遞給操作的 lambda 表示式和閉包應該是純的。它們不應修改任何外部狀態,也不應依賴於任何可變的外部狀態。

開發人員常常詢問他們是否應完全避免可變性。答案很簡單:不要使用可變性。相反,應避免共享可變性。在中間和最終操作中,如果修改了一個共享可變變數,程式碼會變得難以推斷。共享可變性還使得通過並行和/或惰性計算無法獲得正確的結果。您可以選擇不使用並行化,但您無法控制惰性計算,因為它是流的一種隱式行為。

儘管共享可變性會花費一些成本,但您可以通過小心地改變隔離變數來獲得不錯的結果,隔離變數是嚴格禁止被多個執行緒共享的變數。在處理的資料量非常大時,改變隔離變數可以提高效能。在一個處理包含數百萬個物件的集合的最新專案中,我的團隊使用了隔離可變性將效能提高到對資料負載合理的水平。這樣做能夠奏效是因為我們仔細驗證了該專案中不存在共享可變性。我們還驗證了我們的結果不僅快,而且正確。

對於小型或中等規模的集合,或者您無需可變性就能實現合理性能的情況,最明智的做法是避免 lambda 表示式和閉包中的可變性。如果您在其中一個元素中採用了可變性,請確保您正在改變一個隔離變數,而且永遠不要改變共享變數。從函式管道的開始到結束,閉包所依賴的狀