Java中的函數式編程(六)流Stream基礎

寫在前面

如果說函數式介面和lambda表達式是Java中函數式編程的基石,那麼stream就是在基石上的最富麗堂皇的大廈。

只有熟悉了stream,你才能說熟悉了Java 的函數式編程。

本文主要介紹Stream的基礎概念和基本操作,讓大家對Stream有一個初步的理解。

本文的示例程式碼可從gitee上獲取://gitee.com/cnmemset/javafp

stream的概念

首先,看一個典型的stream例子:

public static void simpleStream() {
    List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
    int letterCount = words.stream()
            .filter(s -> s.length() > 3)  // 過濾掉長度小於等於3的單詞
            .mapToInt(String::length)     // 將每個單詞映射為單詞長度
            .sum();  // 計算總長度 5(hello) + 5(world) + 4(love) = 14
 
    // 輸出為 14
    System.out.println(letterCount);
}

在上述例子中,我們將字元串列表 words 作為stream的數據源,然後執行了 filter-map-reduce 的系列操作(sum方法屬於 reduce 操作),後面會詳細介紹map和reduce 操作。如果你有大數據的編程經驗,會更容易理解map和reduce的含義。

stream的定義比較晦澀,大致可以理解為是一個支援串列或並行操作的數據元素序列。它具備以下幾個特點:

  • 首先,stream不是一種數據結構,它並不存儲數據。stream是某個數據源之上的數據視圖。數據源可以是一個數組,或者是一個Collection類,甚至還可以是I/O channel。它通過一個計算管道(a pipeline of computational operations),對數據源的數據進行filter-map-reduce的操作。
  • 其次,stream天生支援函數式編程。函數式編程的一個重要特點就是不會修改變數的值(沒有「副作用」)。而對stream的任何操作,都不會修改數據源中的數據。例如,對一個數據源為Collection的stream進行filter操作,只會生成一個新的stream對象,而不會真的刪除底層數據源中的元素。
  • 第三,stream的許多操作都是惰性求值的(laziness-seeking)。惰性求值是指該操作只是對stream的一個描述,並不會馬上執行。這類惰性的操作在stream中被稱為中間操作(intermediate operations)。
  • 第四,stream呈現的數據可以是無限的。例如Stream.generate可以生成一個無限的流。我們可以通過 limit(n) 方法來將一個無限流轉換為有限流,或者通過 findFirst() 方法終止一個無限流。
  • 最後,stream中的元素只能被消費1次。和迭代器 Iterator 相似,當需要重複訪問某個元素時,需要重新生成一個新的stream。

stream的操作可以分成兩類,中間操作(intermediate operations)和終止操作(terminal operations)。一個stream管道(stream pipeline)是由一個數據源 + 0個或多個中間操作 + 1個終止操作組成的。

中間操作:
中間操作(intermediate operations)指的是將一個stream轉換為另一個stream的操作,譬如filter和map操作。中間操作都是惰性的,它們的作用僅僅是描述了一個新的stream,不會馬上被執行。

終止操作:
終止操作(terminal operations)則指的是那些會產生一個新值或副作用(side-effect)的操作,譬如count 和 forEach 操作。只有遇到終止操作時,之前定義的中間操作才會真正被執行。需要注意,當一個stream執行了一個終止操作後,它的狀態會變成「已消費」,不能再被使用。

為了證實「中間操作都是惰性的」,我們設計了一個實驗性的示例程式碼:

public static void intermediateOperations() {
    List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
 
    System.out.println("start: " + System.currentTimeMillis());
 
    Stream<String> interStream = words.stream()
            .filter(s -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // do nothing
                }
                return s.length() > 3;
            });
    IntStream intStream = interStream.mapToInt(s -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // do nothing
        }
        return s.length();
    });
 
    // 因為 filter 和 map 操作都屬於中間操作,並不會真正執行,
    // 所以它們不受 Thread.sleep 的影響,耗時很短
    System.out.println("after filter && map: " + System.currentTimeMillis());
 
    int letterCount = intStream.sum();
 
    // sum 屬於終止操作,會執行之前定義的中間操作,
    // Thread.sleep 被真正執行了,耗時為 5(filter) + 3(mapToInt) = 8秒
    System.out.println("after sum: " + System.currentTimeMillis());
 
    // 輸出為 14
    System.out.println(letterCount);
}

上述程式碼的輸出類似:

start: 1633438922526
after filter && map: 1633438922588
after sum: 1633438930620
14

可以看到,上述程式碼驗證了「中間操作都是惰性的」:列印「start」和列印「after filter && map」之間只隔了幾十毫秒,而列印「after sum」則在8秒之後,證明了只有在遇到 sum 操作後,filter 和 map 中定義的函數才真正被執行。

生成一個stream對象

Java 8中,引入了4個stream的介面:Stream、IntStream、LongStream、DoubleStream,分別對應Object類型,以及基礎類型int、long和double。如下圖所示:

在Java中,與stream相關的操作基本都是通過上述的4個介面來實現的,不會涉及到具體的stream實現類。要得到一個stream,通常不會手動創建,而是調用對應的工具方法。

常用的工具方法包括:

  1. Collection方法:Collection.stream() 或 Collection.parallelStream()
  2. 數組方法:Arrays.stream(Object[])
  3. 工廠方法:Stream.of(Object[]), IntStream.range(int, int) 或 Stream.iterate(Object, UnaryOperator) 等等
  4. 讀取文件方法:BufferedReader.lines()
  5. 類 java.nio.file.Files 中,也提供了Stream相關的API,例如 Files.list, Files.walk 等等

Stream的基本操作

我們以介面Stream為例,先介紹stream的一些基本操作。

forEach()

Stream中的forEach方法和Collection中的forEach方法相似,都是對每個元素執行指定的操作。

forEach方法簽名為:

void forEach(Consumer<? super T> action)

forEach方法是一個終止操作,意味著在它之前的所有中間操作都將會被執行,然後再馬上執行 action 。

filter()

filter方法的方法簽名是:

Stream<T> filter(Predicate<? super T> predicate)

filter方法是一個中間操作,它的作用是根據參數 predicate 過濾元素,返回一個只包含滿足predicate條件元素的Stream。

示例程式碼:

public static void filterStream() {
    List<String> words = Arrays.asList("hello", "world", "I", "love", "you");
    words.stream()
            .filter(s -> s.length() > 3)  // 過濾掉長度小於等於3的單詞
            .forEach(s -> System.out.println(s));
}

上述程式碼輸出為:

hello
world
love

limit()

limit方法簽名為:

Stream<T> limit(long maxSize);

limit方法是一個短路型(short-circuiting)的中間操作,作用是將當前的Stream截斷,只留下最多 maxSize 個元素組成一個新的Stream。短路型(short-circuiting)的含義是指將一個無限元素的Stream轉換為一個有限元素的Stream。

例如,Random.ints 可以生成一個近似無限的隨機整數流,我們可以通過limit方法限制生成隨機整數的個數。示例程式碼:

public static void limitStream() {
    Random random = new Random();
 
    // 列印左閉右開區間中 [1, 100) 中的 5 個隨機整數
    random.ints(1, 100)
            .limit(5)
            .forEach(System.out::println);
}

上述程式碼的輸出類似:

90
31
31
52
63

distinct()

distinct的方法簽名是:

Stream<T> distinct();

distinct是一個中間操作,作用是返回一個去除重複元素後的Stream。

作者曾遇到過一個有趣的場景:要生成10個不重複的隨機數字。可以結合Random.ints (Random.ints 可以生成一個近似無限的隨機整數流)方法來實現這個需求。示例程式碼如下:

public static void distinctStream() {
    Random random = new Random();
 
    // 在左閉右開區間中 [1, 100) 隨機生成 10 個不重複的數字
    random.ints(1, 100)
            .distinct()
            .limit(10)
            .forEach(System.out::println);
 
    /*
    // 一個有趣的問題,如果 limit 方法放在 distinct 前面,
    // 結果和上面的程式碼有什麼區別嗎?
    // 歡迎加群討論。
    random.ints(1, 100)
            .limit(10)
            .distinct()
            .forEach(System.out::println);
    */
}

sorted()

sorted的方法簽名有兩個,分別是:

Stream<T> sorted();

Stream<T> sorted(Comparator<? super T> comparator);

前者是按照自然順序排序,後者是根據指定的比較器進行排序。

sorted方法是一個中間操作,和Collection.sort方法作用相似。

示例程式碼如下:

public static void sortedStream() {
    List<String> list = Arrays.asList("Guangdong", "Fujian", "Hunan", "Guangxi");
 
    // 自然排序
    list.stream().sorted().forEach(System.out::println);
 
    System.out.println("===============");
 
    // 對省份進行排序,首先按照長度排序,如果長度一樣,則按照字母順序排序
    list.stream().sorted((first, second) -> {
        int lenDiff = first.length() - second.length();
        return lenDiff == 0 ? first.compareTo(second) : lenDiff;
    }).forEach(System.out::println);
}

上述程式碼的輸出為:

Fujian
Guangdong
Guangxi
Hunan
===============
Hunan
Fujian
Guangxi
Guangdong

結語

歡迎來到 Java 的函數式編程世界!!!

本文介紹了 Stream 的概念和基本操作。大家尤其要理解中間操作和終止操作的概念。

認真閱讀完本文後,你應該對 Stream 有了一個初步的認識,但這只是 Stream 編程的入門,更有趣更有挑戰性更有可玩性的還是隨後即將要介紹的 map-reduce 操作。