延遲執行與不可變,系統講解 Java Stream 數據處理
最近在公司寫業務的時候,忽然想不起來Stream
中的累加應該怎麼寫?
無奈只能面向谷歌編程,花費了我寶貴的三分鐘之後,學會了,很簡單。
自從我用上 JDK8 以後,Stream 就是我最常用的特性,各種流式操作用的飛起,然而這次事以後我忽然覺得 Stream 對我真的很陌生。
可能大家都一樣,對最常用到的東西,也最容易將其忽略
,哪怕你要準備面試估計也肯定想不起來要看一下 Stream 這種東西。
不過我既然注意到了,就要重新梳理一遍它,也算是對我的整體知識體系的查漏補缺。
花了很多功夫來寫這篇 Stream,希望大家和我一塊重新認識並學習一下 Stream,瞭解 API 也好,瞭解內部特性也罷,怕什麼真理無窮,進一步有進一步的歡喜。
在本文中我將 Stream 的內容分爲以下幾個部分:
初看這個導圖大家可能對轉換流操作和終結流操作這兩個名詞有點蒙,其實這是我將 Stream 中的所有 API 分成兩類,每一類起了一個對應的名字 (參考自 Java8 相關書籍,見文末):
-
轉換流操作 :例如 filter 和 map 方法,將一個 Stream 轉換成另一個 Stream,返回值都是 Stream。
-
終結流操作 :例如 count 和 collect 方法,將一個 Stream 彙總爲我們需要的結果,返回值都不是 Stream。
其中轉換流操作的 API 我也分了兩類,文中會有詳細例子說明,這裏先看一下定義,有一個大概印象:
-
無狀態 :即此方法的執行無需依賴前面方法執行的結果集。
-
有狀態 :即此方法的執行需要依賴前面方法執行的結果集。
由於 Stream 內容過多,所以我將 Stream 拆成了上下兩篇,本篇是第一篇,內容翔實,用例簡單且豐富。
第二篇的主題雖然只有一個終結操作,但是終結操作 API 比較複雜,所以內容也翔實,用例也簡單且豐富,從篇幅上來看兩者差不多,敬請期待。
注 :由於我本機的電腦是 JDK11,而且寫的時候忘了切換到 JDK8,所以在用例中大量出現的List.of()
在 JDK8 是沒有的,它等同於 JDK8 中的Arrays.asList()
。
注 :寫作過程中翻讀了大量 Stream 源碼和 Java8 書籍 (文末),創作不易,點贊過百,馬上出第二篇。
- 爲什麼要使用 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 在設計之初就被設計爲不可變的
,它的不可變有兩重含義:
-
由於每次 Stream 操作都會生成一個新的 Stream,所以 Stream 是不可變的,就像 String。
-
在 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 中的無狀態方法進行名爲循環合併
的優化,具體例子詳見第三節。
- 創建 Stream
爲了文章的完整性,我思來想去還是加上了創建 Stream 這一節,這一節主要介紹一些創建 Stream 的常用方式,Stream 的創建一般可以分爲兩種情況:
-
使用 Steam 接口創建
-
通過集合類庫創建
同時還會講一講 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);
- Stream 轉換操作之無狀態方法
無狀態方法:即此方法的執行無需依賴前面方法執行的結果集。
在 Stream 中無狀態的 API 我們常用的大概有以下三個:
-
map()
方法:此方法的參數是一個 Function 對象,它可以使你對集合中的元素做自定義操作,並保留操作後的元素。 -
filter()
方法:此方法的參數是一個 Predicate 對象,Predicate 的執行結果是一個 Boolean 類型,所以此方法只保留返回值爲 true 的元素,正如其名我們可以使用此方法做一些篩選操作。 -
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() 對應的方法,它們分別是:
-
mapToInt
-
mapToLong
-
mapToDouble
-
flatMapToInt
-
flatMapToLong
-
flatMapToDouble
這六個方法首先從方法名中就可以看出來,它們只是在 map() 或者 flatMap() 的基礎上對返回值進行轉換操作,按理說沒必要單拎出來做成一個方法,實際上它們的關鍵在於返回值:
-
mapToInt 返回值爲 IntStream
-
mapToLong 返回值爲 LongStream
-
mapToDouble 返回值爲 DoubleStream
-
flatMapToInt 返回值爲 IntStream
-
flatMapToLong 返回值爲 LongStream
-
flatMapToDouble 返回值爲 DoubleStream
在 JDK5 中爲了使 Java 更加的面向對象,引入了包裝類的概念,八大基礎數據類型都對應着一個包裝類,這使你在使用基礎類型時可以無感的進行自動拆箱 / 裝箱,也就是自動使用包裝類的轉換方法。
比如,在最前文的示例中,我用了這樣一個例子:
Stream<Integer> integerStream = Stream.of(1, 2, 3);
我在創建 Stream 中使用了基本數據類型參數,其泛型則被自動包裝成了 Integer,但是我們有時可能忽略自動拆裝箱也是有代價的,如果我們想在使用 Stream 中忽略這個代價則可以使用 Stream 中轉爲基礎數據類型設計的 Stream:
-
IntStream:對應 基礎數據類型中的 int、short、char、boolean
-
LongStream:對應基礎數據類型中的 long
-
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 上執行。
- Stream 轉換操作之有狀態方法
前面說完了無狀態方法,有狀態方法就比較簡單了,只看名字就可以知道它的作用:
以上就是所有的有狀態方法,它們的方法執行都必須依賴前面方法執行的結果集才能執行,比如排序方法就需要依賴前面方法的結果集才能進行排序。
同時 limit 方法和 takeWhile 是兩個短路操作方法,這意味效率更高,因爲可能內部循環還沒有走完時就已經選出了我們想要的元素。
所以有狀態的方法不像無狀態方法那樣可以在一個循環內執行,每個有狀態方法都要經歷一個單獨的內部循環,所以編寫代碼時的順序會影響到程序的執行結果以及性能,希望各位讀者在開發過程中注意。
- 總結
本文主要是對 Stream 做了一個概覽,並講述了 Stream 的兩大特點:
-
不可變
:不影響原集合,每次調用都返回一個新的 Stream。 -
延遲執行
:在遇到終結操作之前,Stream 不會執行。
同時也將 Stream 的 API 分成了轉換操作和終結操作兩類,並講解了所有常用的轉換操作,下一章的主要內容將是終結操作。
在看 Stream 源碼的過程中發現了一個有趣的事情,在ReferencePipeline
類中 (Stream 的實現類),它的方法順序從上往下正好是:無狀態方法 → 有狀態方法 → 聚合方法。
好了,學完本篇後,我想大家對 Stream 的整體已經很清晰了,同時對轉換操作的 API 應該也已經掌握了,畢竟也不多😂,Java8 還有很多強大的特性,我們下次接着聊~
同時,本文在寫作過程中也參考了以下書籍:
-
寫給大忙人看的 Java SE 8
-
Java 8 函數式編程
-
Java 8 實戰
這三本書都非常好,第一本是 Java 核心技術的作者寫的,如果你想全面的瞭解 JDK8 的升級可以看這本。
第二本可以說是一個小冊子,只有一百多頁很短,主要講了一些函數式的思想。
如果你只能看一本,那麼我這裏推薦第三本,豆瓣評分高達 9.2,內容和質量都當屬上乘。
最後,創作不易,如果對大家有所幫助,希望大家點贊支持,有什麼問題也可以在評論區裏討論😄~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/oTwjO7psfRngJIUtyMivyw