延遲執行與不可變,系統講解JavaStream資料處理

語言: CN / TW / HK

最近在公司寫業務的時候,忽然想不起來Stream中的累加應該怎麼寫?

無奈只能面向谷歌程式設計,花費了我寶貴的三分鐘之後,學會了,很簡單。

自從我用上JDK8以後,Stream就是我最常用的特性,各種流式操作用的飛起,然而這次事以後我忽然覺得Stream對我真的很陌生。

可能大家都一樣,對最常用到的東西,也最容易將其忽略,哪怕你要準備面試估計也肯定想不起來要看一下Stream這種東西。

不過我既然注意到了,就要重新梳理一遍它,也算是對我的整體知識體系的查漏補缺。

花了很多功夫來寫這篇Stream,希望大家和我一塊重新認識並學習一下Stream,瞭解API也好,瞭解內部特性也罷,怕什麼真理無窮,進一步有進一步的歡喜。

在本文中我將Stream的內容分為以下幾個部分:

初看這個導圖大家可能對轉換流操作和終結流操作這兩個名詞有點蒙,其實這是我將Stream中的所有API分成兩類,每一類起了一個對應的名字(參考自Java8相關書籍,見文末):

  • 轉換流操作 :例如filter和map方法,將一個Stream轉換成另一個Stream,返回值都是Stream。

  • 終結流操作 :例如count和collect方法,將一個Stream彙總為我們需要的結果,返回值都不是Stream。

其中轉換流操作的API我也分了兩類,文中會有詳細例子說明,這裡先看一下定義,有一個大概印象:

  1. 無狀態 :即此方法的執行無需依賴前面方法執行的結果集。

  2. 有狀態 :即此方法的執行需要依賴前面方法執行的結果集。

由於Stream內容過多,所以我將Stream拆成了上下兩篇,本篇是第一篇,內容翔實,用例簡單且豐富。

第二篇的主題雖然只有一個終結操作,但是終結操作API比較複雜,所以內容也翔實,用例也簡單且豐富,從篇幅上來看兩者差不多,敬請期待。


:由於我本機的電腦是JDK11,而且寫的時候忘了切換到JDK8,所以在用例中大量出現的List.of()在JDK8是沒有的,它等同於JDK8中的Arrays.asList()

:寫作過程中翻讀了大量Stream原始碼和Java8書籍(文末),創作不易,點贊過百,馬上出第二篇。

1. 為什麼要使用Stream?

一切還要源於JDK8的釋出,在那個函數語言程式設計語言如火如荼的時代,Java由於它的臃腫而飽受詬病(強面向物件),社群迫切需要Java能加入函式式語言特點改善這種情況,終於在2014年Java釋出了JDK8。

在JDK8中,我認為最大的新特性就是加入了函式式介面和lambda表示式,這兩個特性取自函數語言程式設計。

這兩個特點的加入使Java變得更加簡單與優雅,用函式式對抗函式式,鞏固Java老大哥的地位,簡直是師夷長技以制夷。

而Stream,就是JDK8又依託於上面的兩個特性為集合類庫做的 一個類庫,它能讓我們通過lambda表示式更簡明扼要的以流水線的方式去處理集合內的資料,可以很輕鬆的完成諸如:過濾、分組、收集、歸約這類操作,所以我願將Stream稱為函式式介面的最佳實踐。

1.1 更清晰的程式碼結構

Stream擁有更清晰的程式碼結構,為了更好的講解Stream怎麼就讓程式碼變清晰了,這裡假設我們有一個非常簡單的需求:在一個集合中找到所有大於2的元素

先來看看沒使用Stream之前:

        List<Integer> list = List.of(1, 2, 3);
        
        List<Integer> filterList = new ArrayList<>();
        
        for (Integer i : list) {
            if (i > 2) {
                filterList.add(i);
            }
        }
        
        System.out.println(filterList);
複製程式碼

上面的程式碼很好理解,我就不過多解釋了,其實也還好了,因為我們的需求比較簡單,如果需求再多點呢?

每多一個要求,那麼if裡面就又要加一個條件了,而我們開發中往往物件上都有很多欄位,那麼條件可能有四五個,最後可能會變成這樣:

        List<Integer> list = List.of(1, 2, 3);

        List<Integer> filterList = new ArrayList<>();

        for (Integer i : list) {
            if (i > 2 && i < 10 && (i % 2 == 0)) {
                filterList.add(i);
            }
        }

        System.out.println(filterList);
複製程式碼

if裡面塞了很多條件,看起來就變得亂糟糟了,其實這也還好,最要命的是專案中往往有很多類似的需求,它們之間的區別只是某個條件不一樣,那麼你就需要複製一大坨程式碼,改吧改吧就上線了,這就導致程式碼裡有大量重複的程式碼。

如果你Stream,一切都會變得清晰易懂:

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製程式碼

這段程式碼你只需要關注我們最關注的東西:篩選條件就夠了,filter這個方法名能讓你清楚的知道它是個過濾條件,collect這個方法名也能看出來它是一個收集器,將最終結果收集到一個List裡面去。

同時你可能發現了,為什麼上面的程式碼中不用寫迴圈?

因為Stream會幫助我們進行隱式的迴圈,這被稱為:內部迭代,與之對應的就是我們常見的外部迭代了。

所以就算你不寫迴圈,它也會進行一遍迴圈。

1.2 不必關心變數狀態

Stream在設計之初就被設計為不可變的,它的不可變有兩重含義:

  1. 由於每次Stream操作都會生成一個新的Stream,所以Stream是不可變的,就像String。

  2. 在Stream中只儲存原集合的引用,所以在進行一些會修改元素的操作時,是通過原元素生成一份新的新元素,所以Stream 的任何操作都不會影響到原物件。

第一個含義可以幫助我們進行鏈式呼叫,實際上我們使用Stream的過程中往往會使用鏈式呼叫,而第二個含義則是函數語言程式設計中的一大特點:不修改狀態。

無論對Stream做怎麼樣的操作,它最終都不會影響到原集合,它的返回值也是在原集合的基礎上進行計算得來的。

所以在Stream中我們不必關心操作原物件集合帶來的種種副作用,用就完了。

關於函數語言程式設計可以查閱阮一峰的函數語言程式設計初探

1.3 延遲執行與優化

Stream只在遇到終結操作的時候才會執行,比如:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println);
複製程式碼

這麼一段程式碼是不會執行的,peek方法可以看作是forEach,這裡我用它來列印Stream中的元素。

因為filter方法和peek方法都是轉換流方法,所以不會觸發執行。

如果我們在後面加入一個count方法就能正常執行:

        List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .peek(System.out::println)
                .count();
複製程式碼

count方法是一個終結操作,用於計算出Stream中有多少個元素,它的返回值是一個long型。

Stream的這種沒有終結操作就不會執行的特性被稱為延遲執行

與此同時,Stream還會對API中的無狀態方法進行名為迴圈合併的優化,具體例子詳見第三節。

2. 建立Stream

為了文章的完整性,我思來想去還是加上了建立Stream這一節,這一節主要介紹一些建立Stream的常用方式,Stream的建立一般可以分為兩種情況:

  1. 使用Steam介面建立

  2. 通過集合類庫建立

同時還會講一講Stream的並行流與連線,都是建立Stream,卻具有不同的特點。

2.1 通過Stream介面建立

Stream作為一個介面,它在介面中定義了定義了幾個靜態方法為我們提供建立Stream的API:

    public static<T> Stream<T> of(T... values) {
        return Arrays.stream(values);
    }

複製程式碼

首先是of方法,它提供了一個泛型可變引數,為我們建立了帶有泛型的Stream流,同時在如果你的引數是基本型別的情況下會使用自動包裝對基本型別進行包裝:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<Double> doubleStream = Stream.of(1.1d, 2.2d, 3.3d);

        Stream<String> stringStream = Stream.of("1", "2", "3");
複製程式碼

當然,你也可以直接建立一個空的Stream,只需要呼叫另一個靜態方法——empty(),它的泛型是一個Object:

        Stream<Object> empty = Stream.empty();
複製程式碼

以上都是我們讓我們易於理解的建立方式,還有一種方式可以建立一個無限制元素數量的Stream——generate():

    public static<T> Stream<T> generate(Supplier<? extends T> s) {
        Objects.requireNonNull(s);
        return StreamSupport.stream(
                new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
    }
複製程式碼

從方法引數上來看,它接受一個函式式介面——Supplier作為引數,這個函式式介面是用來建立物件的介面,你可以將其類比為物件的建立工廠,Stream將從此工廠中建立的物件放入Stream中:

        Stream<String> generate = Stream.generate(() -> "Supplier");

        Stream<Integer> generateInteger = Stream.generate(() -> 123);
複製程式碼

我這裡是為了方便直接使用Lamdba構造了一個Supplier物件,你也可以直接傳入一個Supplier物件,它會通過Supplier介面的get() 方法來構造物件。

2.2 通過集合類庫進行建立

相較於上面一種來說,第二種方式更較為常用,我們常常對集合就行Stream流操作而非手動構建一個Stream:

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();
        
        Stream<String> stringStreamList = List.of("1", "2", "3").stream(); 
複製程式碼

在Java8中,集合的頂層介面Collection被加入了一個新的介面預設方法——stream(),通過這個方法我們可以方便的對所有集合子類進行建立Stream的操作:

        Stream<Integer> listStream = List.of(1, 2, 3).stream();
        
        Stream<Integer> setStream = Set.of(1, 2, 3).stream();
複製程式碼

通過查閱原始碼,可以發先 stream() 方法本質上還是通過呼叫一個Stream工具類來建立Stream:

    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
複製程式碼

2.3 建立並行流

在以上的示例中所有的Stream都是序列流,在某些場景下,為了最大化壓榨多核CPU的效能,我們可以使用並行流,它通過JDK7中引入的fork/join框架來執行並行操作,我們可以通過如下方式建立並行流:

        Stream<Integer> integerParallelStream = Stream.of(1, 2, 3).parallel();

        Stream<String> stringParallelStream = Stream.of("1", "2", "3").parallel();

        Stream<Integer> integerParallelStreamList = List.of(1, 2, 3).parallelStream();

        Stream<String> stringParallelStreamList = List.of("1", "2", "3").parallelStream();
複製程式碼

是的,在Stream的靜態方法中沒有直接建立並行流的方法,我們需要在構造Stream後再呼叫一次parallel()方法才能建立並行流,因為呼叫parallel()方法並不會重新建立一個並行流物件,而是在原有的Stream物件上面設定了一個並行引數。

當然,我們還可以看到,Collection介面中可以直接建立並行流,只需要呼叫與stream() 對應的parallelStream()方法,就像我剛才講到的,他們之間其實只有引數的不同:

    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }

    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }
複製程式碼

不過一般情況下我們並不需要用到並行流,在Stream中元素不過千的情況下效能並不會有太大提升,因為將元素分散到不同的CPU進行計算也是有成本的。

並行的好處是充分利用多核CPU的效能,但是使用中往往要對資料進行分割,然後分散到各個CPU上去處理,如果我們使用的資料是陣列結構則可以很輕易的進行分割,但是如果是連結串列結構的資料或者Hash結構的資料則分割起來很明顯不如陣列結構方便。

所以只有當Stream中元素過萬甚至更大時,選用並行流才能帶給你更明顯的效能提升。

最後,當你有一個並行流的時候,你也可以通過sequential() 將其方便的轉換成序列流:

        Stream.of(1, 2, 3).parallel().sequential();
複製程式碼

2.4 連線Stream

如果你在兩處構造了兩個Stream,在使用的時候希望組合在一起使用,可以使用concat():

        Stream<Integer> concat = Stream
                .concat(Stream.of(1, 2, 3), Stream.of(4, 5, 6));
複製程式碼

如果是兩種不同的泛型流進行組合,自動推斷會自動的推斷出兩種型別相同的父類:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);

        Stream<String> stringStream = Stream.of("1", "2", "3");

        Stream<? extends Serializable> stream = Stream.concat(integerStream, stringStream);
複製程式碼

3. Stream轉換操作之無狀態方法

無狀態方法:即此方法的執行無需依賴前面方法執行的結果集。

在Stream中無狀態的API我們常用的大概有以下三個:

  1. map()方法:此方法的引數是一個Function物件,它可以使你對集合中的元素做自定義操作,並保留操作後的元素。

  2. filter()方法:此方法的引數是一個Predicate物件,Predicate的執行結果是一個Boolean型別,所以此方法只保留返回值為true的元素,正如其名我們可以使用此方法做一些篩選操作。

  3. flatMap()方法:此方法和map()方法一樣引數是一個Function物件,但是此Function的返回值要求是一個Stream,該方法可以將多個Stream中的元素聚合在一起進行返回。

先來看看一個map()方法的示例:

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> mapStream = integerStreamList.map(i -> i * 10);
複製程式碼

我們擁有一個List,想要對其中的每個元素進行乘10 的操作,就可以採用如上寫法,其中的i是對List中元素的變數名, 後面的邏輯則是要對此元素進行的操作,以一種非常簡潔明瞭的方式傳入一段程式碼邏輯執行,這段程式碼最後會返回一個包含操作結果的新Stream。

這裡為了更好的幫助大家理解,我畫了一個簡圖:


接下來是filter()方法示例:

        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

        Stream<Integer> filterStream = integerStreamList.filter(i -> i >= 20);
複製程式碼

在這段程式碼中會執行i >= 20 這段邏輯,然後將返回值為true的結果儲存在一個新的Stream中並返回。

這裡我也有一個簡單的圖示:


flatMap() 方法的描述在上文我已經描述過,但是有點過於抽象,我在學習此方法中也是搜尋了很多示例才有了較好的理解。

根據官方文件的說法,此方法是為了進行一對多元素的平展操作:

        List<Order> orders = List.of(new Order(), new Order());

        Stream<Item> itemStream = orders.stream()
                .flatMap(order -> order.getItemList().stream());
複製程式碼

這裡我通過一個訂單示例來說明此方法,我們的每個訂單中都包含了一個商品List,如果我想要將兩個訂單中所有商品List組成一個新的商品List,就需要用到flatMap()方法。

在上面的程式碼示例中可以看到每個訂單都返回了一個商品List的Stream,我們在本例中只有兩個訂單,所以也就是最終會返回兩個商品List的Stream,flatMap()方法的作用就是將這兩個Stream中元素提取出來然後放到一個新的Stream中。

老規矩,放一個簡單的圖示來說明:

圖例中我使用青色代表Stream,在最終的輸出中可以看到flatMap()將兩個流變成了一個流進行輸出,這在某些場景中非常有用,比如我上面的訂單例子。


還有一個很不常用的無狀態方法peek()

    Stream<T> peek(Consumer<? super T> action);
複製程式碼

peek方法接受一個Consumer物件做引數,這是一個無返回值的引數,我們可以通過peek方法做些列印元素之類的操作:

        Stream<Integer> peekStream = integerStreamList.peek(i -> System.out.println(i));

複製程式碼

然而如果你不太熟悉的話,不建議使用,某些情況下它並不會生效,比如:

        List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .peek(System.out::println)
                .count();
複製程式碼

API文件上面也註明了此方法是用於Debug,通過我的經驗,只有當Stream最終需要重新生產元素時,peek才會執行。

上面的例子中,count只需要返回元素個數,所以peek沒有執行,如果換成collect方法就會執行。

或者如果Stream中存在過濾方法如filter方法和match相關方法,它也會執行。

3.1 基礎型別Stream

上一節提到了三個Stream中最常用的三個無狀態方法,在Stream的無狀態方法中還有幾個和map()與flatMap()對應的方法,它們分別是:

  1. mapToInt

  2. mapToLong

  3. mapToDouble

  4. flatMapToInt

  5. flatMapToLong

  6. flatMapToDouble

這六個方法首先從方法名中就可以看出來,它們只是在map()或者flatMap()的基礎上對返回值進行轉換操作,按理說沒必要單拎出來做成一個方法,實際上它們的關鍵在於返回值:

  1. mapToInt返回值為IntStream

  2. mapToLong返回值為LongStream

  3. mapToDouble返回值為DoubleStream

  4. flatMapToInt返回值為IntStream

  5. flatMapToLong返回值為LongStream

  6. flatMapToDouble返回值為DoubleStream

在JDK5中為了使Java更加的面向物件,引入了包裝類的概念,八大基礎資料型別都對應著一個包裝類,這使你在使用基礎型別時可以無感的進行自動拆箱/裝箱,也就是自動使用包裝類的轉換方法。

比如,在最前文的示例中,我用了這樣一個例子:

        Stream<Integer> integerStream = Stream.of(1, 2, 3);
複製程式碼

我在建立Stream中使用了基本資料型別引數,其泛型則被自動包裝成了Integer,但是我們有時可能忽略自動拆裝箱也是有代價的,如果我們想在使用Stream中忽略這個代價則可以使用Stream中轉為基礎資料型別設計的Stream:

  1. IntStream:對應 基礎資料型別中的int、short、char、boolean

  2. LongStream:對應基礎資料型別中的long

  3. DoubleStream:對應基礎資料型別中的double和float

在這些介面中都可以和上文的例子一樣通過of方法構造Stream,且不會自動拆裝箱。

所以上文中提到的那六個方法實際上就是將普通流轉換成這種基礎型別流,在我們需要的時候可以擁有更高的效率。

基礎型別流在API方面擁有Stream一樣的API,所以在使用方面只要明白了Stream,基礎型別流也都是一樣的。

:IntStream、LongStream和DoubleStream都是介面,但並非繼承自Stream介面。

3.2 無狀態方法的迴圈合併

說完無狀態的這幾個方法我們來看一個前文中的例子:

        List<Integer> list = List.of(1, 2, 3).stream()
                .filter(i -> i > 2)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製程式碼

在這個例子中我用了三次filter方法,那麼大家覺得Stream會迴圈三次進行過濾嗎?

如果換掉其中一個filter為map,大家覺得會迴圈幾次?

        List<Integer> list = List.of(1, 2, 3).stream()
                .map(i -> i * 10)
                .filter(i -> i < 10)
                .filter(i -> i % 2 == 0)
                .collect(toList());
複製程式碼

從我們的直覺來看,需要先使用map方法對所有元素做處理,然後再使用filter方法做過濾,所以需要執行三次迴圈。

但回顧無狀態方法的定義,你可以發現其他這三個條件可以放在一個迴圈裡面做,因為filter只依賴map的計算結果,而不必依賴map執行完後的結果集,所以只要保證先操作map再操作filter,它們就可以在一次迴圈內完成,這種優化方式被稱為迴圈合併

所有的無狀態方法都可以放在同一個迴圈內執行,它們也可以方便的使用並行流在多個CPU上執行。

4. Stream轉換操作之有狀態方法

前面說完了無狀態方法,有狀態方法就比較簡單了,只看名字就可以知道它的作用:

方法名 方法結果
distinct() 元素去重。
sorted() 元素排序,過載的兩個方法,需要的時候可以傳入一個排序物件。
limit(long maxSize) 傳入一個數字,代表只取前X個元素。
skip(long n) 傳入一個數字,代表跳過X個元素,取後面的元素。
takeWhile(Predicate predicate) JDK9新增,傳入一個斷言引數當第一次斷言為false時停止,返回前面斷言為true的元素。
dropWhile(Predicate predicate) JDK9新增,傳入一個斷言引數當第一次斷言為false時停止,刪除前面斷言為true的元素。

以上就是所有的有狀態方法,它們的方法執行都必須依賴前面方法執行的結果集才能執行,比如排序方法就需要依賴前面方法的結果集才能進行排序。

同時limit方法和takeWhile是兩個短路操作方法,這意味效率更高,因為可能內部迴圈還沒有走完時就已經選出了我們想要的元素。

所以有狀態的方法不像無狀態方法那樣可以在一個迴圈內執行,每個有狀態方法都要經歷一個單獨的內部迴圈,所以編寫程式碼時的順序會影響到程式的執行結果以及效能,希望各位讀者在開發過程中注意。

5. 總結

本文主要是對Stream做了一個概覽,並講述了Stream的兩大特點:

  1. 不可變:不影響原集合,每次呼叫都返回一個新的Stream。

  2. 延遲執行:在遇到終結操作之前,Stream不會執行。

同時也將Stream的API分成了轉換操作和終結操作兩類,並講解了所有常用的轉換操作,下一章的主要內容將是終結操作。

在看Stream原始碼的過程中發現了一個有趣的事情,在ReferencePipeline類中(Stream的實現類),它的方法順序從上往下正好是:無狀態方法 → 有狀態方法 → 聚合方法。

好了,學完本篇後,我想大家對Stream的整體已經很清晰了,同時對轉換操作的API應該也已經掌握了,畢竟也不多😂,Java8還有很多強大的特性,我們下次接著聊~


最後,創作不易,如果對大家有所幫助,希望大家點贊支援,有什麼問題也可以在評論區裡討論😄~

如果你覺得這篇文章對你有點用的話,麻煩請給我們的開源專案點點star: http://github.crmeb.net/u/defu 不勝感激 !
來自 “開源世界 ” ,連結: http://ym.baisou.ltd/post/773.html