Java 中的數據流和函數式編程

https://linux.cn/article-11857-1.htmlhtml


學習如何使用 Java 8 中的流 API 和函數式編程結構。java

當 Java SE 8(又名核心 Java 8)在 2014 年被推出時,它引入了一些更改,從根本上影響了用它進行的編程。這些更改中有兩個緊密相連的部分:流 API 和函數式編程構造。本文使用代碼示例,從基礎到高級特性,介紹每一個部分並說明它們之間的相互做用。linux

基礎特性

流 API 是在數據序列中迭代元素的簡潔而高級的方法。包 java.util.streamjava.util.function 包含了用於流 API 和相關函數式編程構造的新庫。固然,代碼示例賽過千言萬語。git

下面的代碼段用大約 2,000 個隨機整數值填充了一個 List程序員

Random rand = new Random2();
List<Integer> list = new ArrayList<Integer>();           // 空 list
for (int i = 0; i < 2048; i++) list.add(rand.nextInt()); // 填充它

另外用一個 for 循環可用於遍歷填充列表,以將偶數值收集到另外一個列表中。github

流 API 提供了一種更簡潔的方法來執行此操做:編程

List <Integer> evens = list
    .stream()                      // 流化 list
    .filter(n -> (n & 0x1) == 0)   // 過濾出奇數值
    .collect(Collectors.toList()); // 收集偶數值

這個例子有三個來自流 API 的函數:小程序

  • stream 函數能夠將集合轉換爲流,而流是一個每次可訪問一個值的傳送帶。流化是惰性的(所以也是高效的),由於值是根據須要產生的,而不是一次性產生的。
  • filter 函數肯定哪些流的值(若是有的話)經過了處理管道中的下一個階段,即 collect 階段。filter 函數是 高階的higher-order,由於它的參數是一個函數 —— 在這個例子中是一個 lambda 表達式,它是一個未命名的函數,而且是 Java 新的函數式編程結構的核心。

lambda 語法與傳統的 Java 徹底不一樣:數組

n -> (n & 0x1) == 0

箭頭(一個減號後面緊跟着一個大於號)將左邊的參數列表與右邊的函數體分隔開。參數 n 雖未明確類型,但也能夠明確。在任何狀況下,編譯器都會發現 n 是個 Integer。若是有多個參數,這些參數將被括在括號中,並用逗號分隔。緩存

在本例中,函數體檢查一個整數的最低位(最右)是否爲零,這用來表示偶數。過濾器應返回一個布爾值。儘管能夠,但該函數的主體中沒有顯式的 return。若是主體沒有顯式的 return,則主體的最後一個表達式便是返回值。在這個例子中,主體按照 lambda 編程的思想編寫,由一個簡單的布爾表達式 (n & 0x1) == 0 組成。

  • collect 函數將偶數值收集到引用爲 evens 的列表中。以下例所示,collect 函數是線程安全的,所以,即便在多個線程之間共享了過濾操做,該函數也能夠正常工做。

方便的功能和輕鬆實現多線程

在生產環境中,數據流的源多是文件或網絡鏈接。爲了學習流 API, Java 提供了諸如 IntStream 這樣的類型,它能夠用各類類型的元素生成流。這裏有一個 IntStream 的例子:

IntStream                          // 整型流
    .range(1, 2048)                // 生成此範圍內的整型流
    .parallel()                    // 爲多個線程分區數據
    .filter(i -> ((i & 0x1) > 0))  // 奇偶校驗 - 只容許奇數經過
    .forEach(System.out::println); // 打印每一個值

IntStream 類型包括一個 range 函數,該函數在指定的範圍內生成一個整數值流,在本例中,以 1 爲增量,從 1 遞增到 2048。parallel 函數自動劃分該工做到多個線程中,在各個線程中進行過濾和打印。(線程數一般與主機系統上的 CPU 數量匹配。)函數 forEach 參數是一個方法引用,在本例中是對封裝在 System.out 中的 println 方法的引用,方法輸出類型爲 PrintStream。方法和構造器引用的語法將在稍後討論。

因爲具備多線程,所以整數值總體上以任意順序打印,但在給定線程中是按順序打印的。例如,若是線程 T1 打印 409 和 411,那麼 T1 將按照順序 409-411 打印,可是其它某個線程可能會預先打印 2045。parallel 調用後面的線程是併發執行的,所以它們的輸出順序是不肯定的。

map/reduce 模式

map/reduce 模式在處理大型數據集方面變得很流行。一個 map/reduce 宏操做由兩個微操做構成。首先,將數據分散(映射mapped)到各個工做程序中,而後將單獨的結果收集在一塊兒 —— 也可能收集統計起來成爲一個值,即歸約reduction。歸約能夠採用不一樣的形式,如如下示例所示。

下面 Number 類的實例用 EVENODD 表示有奇偶校驗的整數值:

public class Number {
    enum Parity { EVEN, ODD }
    private int value;
    public Number(int n) { setValue(n); }
    public void setValue(int value) { this.value = value; }
    public int getValue() { return this.value; }
    public Parity getParity() {
        return ((value & 0x1) == 0) ? Parity.EVEN : Parity.ODD;
    }
    public void dump() {
        System.out.format("Value: %2d (parity: %s)\n", getValue(),
                          (getParity() == Parity.ODD ? "odd" : "even"));
    }
}

下面的代碼演示了用 Number 流進行 map/reduce 的情形,從而代表流 API 不只能夠處理 intfloat 等基本類型,還能夠處理程序員自定義的類類型。

在下面的代碼段中,使用了 parallelStream 而不是 stream 函數對隨機整數值列表進行流化處理。與前面介紹的 parallel 函數同樣,parallelStream 變體也能夠自動執行多線程。

final int howMany = 200;
Random r = new Random();
Number[] nums = new Number[howMany];
for (int i = 0; i < howMany; i++) nums[i] = new Number(r.nextInt(100));
List<Number> listOfNums = Arrays.asList(nums);  // 將數組轉化爲 list

Integer sum4All = listOfNums
    .parallelStream()           // 自動執行多線程
    .mapToInt(Number::getValue) // 使用方法引用,而不是 lambda
    .sum();                     // 將流值計算出和值
System.out.println("The sum of the randomly generated values is: " + sum4All);

高階的 mapToInt 函數能夠接受一個 lambda 做爲參數,但在本例中,它接受一個方法引用,即 Number::getValuegetValue 方法不須要參數,它返回給定的 Number 實例的 int 值。語法並不複雜:類名 Number 後跟一個雙冒號和方法名。回想一下先前的例子 System.out::println,它在 System 類中的 static 屬性 out 後面有一個雙冒號。

方法引用 Number::getValue 能夠用下面的 lambda 表達式替換。參數 n 是流中的 Number 實例中的之一:

mapToInt(n -> n.getValue())

一般,lambda 表達式和方法引用是可互換的:若是像 mapToInt 這樣的高階函數能夠採用一種形式做爲參數,那麼這個函數也能夠採用另外一種形式。這兩個函數式編程結構具備相同的目的 —— 對做爲參數傳入的數據執行一些自定義操做。在二者之間進行選擇一般是爲了方便。例如,lambda 能夠在沒有封裝類的狀況下編寫,而方法則不能。個人習慣是使用 lambda,除非已經有了適當的封裝方法。

當前示例末尾的 sum 函數經過結合來自 parallelStream 線程的部分和,以線程安全的方式進行歸約。可是,程序員有責任確保在 parallelStream 調用引起的多線程過程當中,程序員本身的函數調用(在本例中爲 getValue)是線程安全的。

最後一點值得強調。lambda 語法鼓勵編寫純函數pure function,即函數的返回值僅取決於傳入的參數(若是有);純函數沒有反作用,例如更新一個類中的 static 字段。所以,純函數是線程安全的,而且若是傳遞給高階函數的函數參數(例如 filtermap )是純函數,則流 API 效果最佳。

對於更細粒度的控制,有另外一個流 API 函數,名爲 reduce,可用於對 Number 流中的值求和:

Integer sum4AllHarder = listOfNums
    .parallelStream()                           // 多線程
    .map(Number::getValue)                      // 每一個 Number 的值
    .reduce(0, (sofar, next) -> sofar + next);  // 求和

此版本的 reduce 函數帶有兩個參數,第二個參數是一個函數:

  • 第一個參數(在這種狀況下爲零)是特徵值,該值用做求和操做的初始值,而且在求和過程當中流結束時用做默認值。
  • 第二個參數是累加器,在本例中,這個 lambda 表達式有兩個參數:第一個參數(sofar)是正在運行的和,第二個參數(next)是來自流的下一個值。運行的和以及下一個值相加,而後更新累加器。請記住,因爲開始時調用了 parallelStream,所以 mapreduce 函數如今都在多線程上下文中執行。

在到目前爲止的示例中,流值被收集,而後被規約,可是,一般狀況下,流 API 中的 Collectors 能夠累積值,而不須要將它們規約到單個值。正以下一個代碼段所示,收集活動能夠生成任意豐富的數據結構。該示例使用與前面示例相同的 listOfNums

Map<Number.Parity, List<Number>> numMap = listOfNums
    .parallelStream()
    .collect(Collectors.groupingBy(Number::getParity));

List<Number> evens = numMap.get(Number.Parity.EVEN);
List<Number> odds = numMap.get(Number.Parity.ODD);

第一行中的 numMap 指的是一個 Map,它的鍵是一個 Number 奇偶校驗位(ODDEVEN),其值是一個具備指定奇偶校驗位值的 Number 實例的 List。一樣,經過 parallelStream 調用進行多線程處理,而後 collect 調用(以線程安全的方式)將部分結果組裝到 numMap 引用的 Map 中。而後,在 numMap 上調用 get 方法兩次,一次獲取 evens,第二次獲取 odds

實用函數 dumpList 再次使用來自流 API 的高階 forEach 函數:

private void dumpList(String msg, List<Number> list) {
    System.out.println("\n" + msg);
    list.stream().forEach(n -> n.dump()); // 或者使用 forEach(Number::dump)
}

這是示例運行中程序輸出的一部分:

The sum of the randomly generated values is: 3322
The sum again, using a different method:     3322

Evens:

Value: 72 (parity: even)
Value: 54 (parity: even)
...
Value: 92 (parity: even)

Odds:

Value: 35 (parity: odd)
Value: 37 (parity: odd)
...
Value: 41 (parity: odd)

用於代碼簡化的函數式結構

函數式結構(如方法引用和 lambda 表達式)很是適合在流 API 中使用。這些構造表明了 Java 中對高階函數的主要簡化。即便在糟糕的過去,Java 也經過 MethodConstructor 類型在技術上支持高階函數,這些類型的實例能夠做爲參數傳遞給其它函數。因爲其複雜性,這些類型在生產級 Java 中不多使用。例如,調用 Method 須要對象引用(若是方法是非靜態的)或至少一個類標識符(若是方法是靜態的)。而後,被調用的 Method 的參數做爲對象實例傳遞給它,若是沒有發生多態(那會出現另外一種複雜性!),則可能須要顯式向下轉換。相比之下,lambda 和方法引用很容易做爲參數傳遞給其它函數。

可是,新的函數式結構在流 API 以外具備其它用途。考慮一個 Java GUI 程序,該程序帶有一個供用戶按下的按鈕,例如,按下以獲取當前時間。按鈕按下的事件處理程序可能編寫以下:

JButton updateCurrentTime = new JButton("Update current time");
updateCurrentTime.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        currentTime.setText(new Date().toString());
    }
});

這個簡短的代碼段很難解釋。關注第二行,其中方法 addActionListener 的參數開始以下:

new ActionListener() {

這彷佛是錯誤的,由於 ActionListener 是一個抽象接口,而抽象類型不能經過調用 new 實例化。可是,事實證實,還有其它一些實例被實例化了:一個實現此接口的未命名內部類。若是上面的代碼封裝在名爲 OldJava 的類中,則該未命名的內部類將被編譯爲 OldJava$1.classactionPerformed 方法在這個未命名的內部類中被重寫。

如今考慮使用新的函數式結構進行這個使人耳目一新的更改:

updateCurrentTime.addActionListener(e -> currentTime.setText(new Date().toString()));

lambda 表達式中的參數 e 是一個 ActionEvent 實例,而 lambda 的主體是對按鈕上的 setText 的簡單調用。

函數式接口和函數組合

到目前爲止,使用的 lambda 已經寫好了。可是,爲了方便起見,咱們能夠像引用封裝方法同樣引用 lambda 表達式。如下一系列簡短示例說明了這一點。

考慮如下接口定義:

@FunctionalInterface // 可選,一般省略
interface BinaryIntOp {
    abstract int compute(int arg1, int arg2); // abstract 聲明能夠被刪除
}

註釋 @FunctionalInterface 適用於聲明惟一抽象方法的任何接口;在本例中,這個抽象接口是 compute。一些標準接口,(例如具備惟一聲明方法 runRunnable 接口)一樣符合這個要求。在此示例中,compute 是已聲明的方法。該接口可用做引用聲明中的目標類型:

BinaryIntOp div = (arg1, arg2) -> arg1 / arg2;
div.compute(12, 3); // 4

java.util.function 提供各類函數式接口。如下是一些示例。

下面的代碼段介紹了參數化的 Predicate 函數式接口。在此示例中,帶有參數 StringPredicate<String> 類型能夠引用具備 String 參數的 lambda 表達式或諸如 isEmpty 之類的 String 方法。一般狀況下,Predicate 是一個返回布爾值的函數。

Predicate<String> pred = String::isEmpty; // String 方法的 predicate 聲明
String[] strings = {"one", "two", "", "three", "four"};
Arrays.asList(strings)
   .stream()
   .filter(pred)                  // 過濾掉非空字符串
   .forEach(System.out::println); // 只打印空字符串

在字符串長度爲零的狀況下,isEmpty Predicate 斷定結果爲 true。 所以,只有空字符串才能進入管道的 forEach 階段。

下一段代碼將演示如何將簡單的 lambda 或方法引用組合成更豐富的 lambda 或方法引用。考慮這一系列對 IntUnaryOperator 類型的引用的賦值,它接受一個整型參數並返回一個整型值:

IntUnaryOperator doubled = n -> n * 2;
IntUnaryOperator tripled = n -> n * 3;
IntUnaryOperator squared = n -> n * n;

IntUnaryOperator 是一個 FunctionalInterface,其惟一聲明的方法爲 applyAsInt。如今能夠單獨使用或以各類組合形式使用這三個引用 doubledtripledsquared

int arg = 5;
doubled.applyAsInt(arg); // 10
tripled.applyAsInt(arg); // 15
squared.applyAsInt(arg); // 25

如下是一些函數組合的樣例:

int arg = 5;
doubled.compose(squared).applyAsInt(arg); // 5 求 2 次方後乘 2:50
tripled.compose(doubled).applyAsInt(arg); // 5 乘 2 後再乘 3:30
doubled.andThen(squared).applyAsInt(arg); // 5 乘 2 後求 2 次方:100
squared.andThen(tripled).applyAsInt(arg); // 5 求 2 次方後乘 3:75

函數組合能夠直接使用 lambda 表達式實現,可是引用使代碼更簡潔。

構造器引用

構造器引用是另外一種函數式編程構造,而這些引用在比 lambda 和方法引用更微妙的上下文中很是有用。再一次重申,代碼示例彷佛是最好的解釋方式。

考慮這個 POJO 類:

public class BedRocker { // 基岩的居民
    private String name;
    public BedRocker(String name) { this.name = name; }
    public String getName() { return this.name; }
    public void dump() { System.out.println(getName()); }
}

該類只有一個構造函數,它須要一個 String 參數。給定一個名字數組,目標是生成一個 BedRocker 元素數組,每一個名字表明一個元素。下面是使用了函數式結構的代碼段:

String[] names = {"Fred", "Wilma", "Peebles", "Dino", "Baby Puss"};

Stream<BedRocker> bedrockers = Arrays.asList(names).stream().map(BedRocker::new);
BedRocker[] arrayBR = bedrockers.toArray(BedRocker[]::new);

Arrays.asList(arrayBR).stream().forEach(BedRocker::dump);

在較高的層次上,這個代碼段將名字轉換爲 BedRocker 數組元素。具體來講,代碼以下所示。Stream 接口(在包 java.util.stream 中)能夠被參數化,而在本例中,生成了一個名爲 bedrockersBedRocker 流。

Arrays.asList 實用程序再次用於流化一個數組 names,而後將流的每一項傳遞給 map 函數,該函數的參數如今是構造器引用 BedRocker::new。這個構造器引用經過在每次調用時生成和初始化一個 BedRocker 實例來充當一個對象工廠。在第二行執行以後,名爲 bedrockers 的流由五項 BedRocker 組成。

這個例子能夠經過關注高階 map 函數來進一步闡明。在一般狀況下,一個映射將一個類型的值(例如,一個 int)轉換爲另外一個相同類型的值(例如,一個整數的後繼):

map(n -> n + 1) // 將 n 映射到其後繼

然而,在 BedRocker 這個例子中,轉換更加戲劇化,由於一個類型的值(表明一個名字的 String)被映射到一個不一樣類型的值,在這個例子中,就是一個 BedRocker 實例,這個字符串就是它的名字。轉換是經過一個構造器調用來完成的,它是由構造器引用來實現的:

map(BedRocker::new) // 將 String 映射到 BedRocker

傳遞給構造器的值是 names 數組中的其中一項。

此代碼示例的第二行還演示了一個你目前已經很是熟悉的轉換:先將數組先轉換成 List,而後再轉換成 Stream

Stream<BedRocker> bedrockers = Arrays.asList(names).stream().map(BedRocker::new);

第三行則是另外一種方式 —— 流 bedrockers 經過使用數組構造器引用 BedRocker[]::new 調用 toArray 方法:

BedRocker[ ] arrayBR = bedrockers.toArray(BedRocker[]::new);

該構造器引用不會建立單個 BedRocker 實例,而是建立這些實例的整個數組:該構造器引用如今爲 BedRocker[]:new,而不是 BedRocker::new。爲了進行確認,將 arrayBR 轉換爲 List,再次對其進行流式處理,以即可以使用 forEach 來打印 BedRocker 的名字。

Fred
Wilma
Peebles
Dino
Baby Puss

該示例對數據結構的微妙轉換僅用幾行代碼便可完成,從而突出了能夠將 lambda,方法引用或構造器引用做爲參數的各類高階函數的功能。

柯里化Currying

柯里化函數是指減小函數執行任何工做所需的顯式參數的數量(一般減小到一個)。(該術語是爲了記念邏輯學家 Haskell Curry。)通常來講,函數的參數越少,調用起來就越容易,也更健壯。(回想一下一些須要半打左右參數的噩夢般的函數!)所以,應將柯里化視爲簡化函數調用的一種嘗試。java.util.function 包中的接口類型適合於柯里化,如如下示例所示。

引用的 IntBinaryOperator 接口類型是爲函數接受兩個整型參數,並返回一個整型值:

IntBinaryOperator mult2 = (n1, n2) -> n1 * n2;
mult2.applyAsInt(10, 20); // 200
mult2.applyAsInt(10, 30); // 300

引用 mult2 強調了須要兩個顯式參數,在本例中是 10 和 20。

前面介紹的 IntUnaryOperatorIntBinaryOperator 簡單,由於前者只須要一個參數,然後者則須要兩個參數。二者均返回整數值。所以,目標是將名爲 mult2 的兩個參數 IntBinraryOperator 柯里化成一個單一的 IntUnaryOperator 版本 curriedMult2

考慮 IntFunction<R> 類型。此類型的函數採用整型參數,並返回類型爲 R 的結果,該結果能夠是另外一個函數 —— 更準確地說,是 IntBinaryOperator。讓一個 lambda 返回另外一個 lambda 很簡單:

arg1 -> (arg2 -> arg1 * arg2) // 括號能夠省略

完整的 lambda 以 arg1 開頭,而該 lambda 的主體以及返回的值是另外一個以 arg2 開頭的 lambda。返回的 lambda 僅接受一個參數(arg2),但返回了兩個數字的乘積(arg1arg2)。下面的概述,再加上代碼,應該能夠更好地進行說明。

如下是如何柯里化 mult2 的概述:

  • 類型爲 IntFunction<IntUnaryOperator> 的 lambda 被寫入並調用,其整型值爲 10。返回的 IntUnaryOperator 緩存了值 10,所以變成了已柯里化版本的 mult2,在本例中爲 curriedMult2
  • 而後使用單個顯式參數(例如,20)調用 curriedMult2 函數,該參數與緩存的參數(在本例中爲 10)相乘以生成返回的乘積。。

這是代碼的詳細信息:

// 建立一個接受一個參數 n1 並返回一個單參數 n2 -> n1 * n2 的函數,該函數返回一個(n1 * n2 乘積的)整型數。
IntFunction<IntUnaryOperator> curriedMult2Maker = n1 -> (n2 -> n1 * n2);

調用 curriedMult2Maker 生成所需的 IntUnaryOperator 函數:

// 使用 curriedMult2Maker 獲取已柯里化版本的 mult2。
// 參數 10 是上面的 lambda 的 n1。
IntUnaryOperator curriedMult2 = curriedMult2Maker2.apply(10);

10 如今緩存在 curriedMult2 函數中,以便 curriedMult2 調用中的顯式整型參數乘以 10:

curriedMult2.applyAsInt(20); // 200 = 10 * 20
curriedMult2.applyAsInt(80); // 800 = 10 * 80

緩存的值能夠隨意更改:

curriedMult2 = curriedMult2Maker.apply(50); // 緩存 50
curriedMult2.applyAsInt(101);               // 5050 = 101 * 50

固然,能夠經過這種方式建立多個已柯里化版本的 mult2,每一個版本都有一個 IntUnaryOperator

柯里化充分利用了 lambda 的強大功能:能夠很容易地編寫 lambda 表達式來返回須要的任何類型的值,包括另外一個 lambda。

總結

Java 仍然是基於類的面向對象的編程語言。可是,藉助流 API 及其支持的函數式構造,Java 向函數式語言(例如 Lisp)邁出了決定性的(同時也是受歡迎的)一步。結果是 Java 更適合處理現代編程中常見的海量數據流。在函數式方向上的這一步還使以在前面的代碼示例中突出顯示的管道的方式編寫清晰簡潔的 Java 代碼更加容易:

dataStream
   .parallelStream() // 多線程以提升效率
   .filter(...)      // 階段 1
   .map(...)         // 階段 2
   .filter(...)      // 階段 3
   ...
   .collect(...);    // 或者,也能夠進行歸約:階段 N

自動多線程,以 parallelparallelStream 調用爲例,創建在 Java 的 fork/join 框架上,該框架支持 任務竊取task stealing 以提升效率。假設 parallelStream 調用後面的線程池由八個線程組成,而且 dataStream 被八種方式分區。某個線程(例如,T1)可能比另外一個線程(例如,T7)工做更快,這意味着應該將 T7 的某些任務移到 T1 的工做隊列中。這會在運行時自動發生。

在這個簡單的多線程世界中,程序員的主要職責是編寫線程安全函數,這些函數做爲參數傳遞給在流 API 中占主導地位的高階函數。尤爲是 lambda 鼓勵編寫純函數(所以是線程安全的)函數。


via: https://opensource.com/article/20/1/javastream

做者:Marty Kalin 選題:lujun9972 譯者:laingke 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

訂閱「Linux 中國」官方小程序來查看

相關文章
相關標籤/搜索