1. 程式人生 > >Java 8 學習筆記5——使用流

Java 8 學習筆記5——使用流

Streams API可以表達複雜的資料處理查詢。

流讓你從外部迭代轉向內部迭代。這樣,你就用不著寫下面這樣的程式碼來顯式地管理資料集合的迭代(外部迭代)了:

List<Dish> vegetarianDishes=new ArrayList<>();
for(Dish d: menu){
	if(d.isVegetarian()){
		vegetarianDishes.add(d);
	}
}

你可以使用支援filtercollect操作的Stream API(內部迭代)管理對集合資料的迭代。你只需要將篩選行為作為引數傳遞給filter方法就行了。

import static java.util.stream.Collectors.toList;
List<Dish> vegetarianDishes=menu.stream()
    							.filter(Dish:: isVegetarian)
    							.collect(toList());

這種處理資料的方式很有用,因為你讓Stream API管理如何處理資料。這樣Stream API就可以在背後進行多種優化。此外,使用內部迭代的話,Stream API可以決定並行執行你的程式碼。這要是用外部迭代的話就辦不到了,因為你只能用單一執行緒挨個迭代。

篩選和切片

你可以使用filterdistinctskiplimit對流做篩選和切片。

現在來看看如何選擇流中的元素:用謂詞篩選,篩選出各不相同的元素,忽略流中的頭幾個元素,或將流截短至指定長度。

用謂詞篩選

Streams介面支援filter方法。該操作會接受一個謂詞(一個返回boolean的函式)作為引數,並返回一個包括所有符合謂詞的元素的流。例如,你可以像下圖所示的這樣,篩選出所有素菜,建立一張素食選單:

List<Dish> vegetarianMenu=menu.stream()
							.filter(Dish:: isVegetarian)
//方法引用檢查菜餚是否適合素食者 .collect(toList());

在這裡插入圖片描述

篩選各異的元素

流還支援一個叫作distinct的方法,它會返回一個元素各異(根據流所生成元素的hashCodeequals方法實現)的流。例如,以下程式碼會篩選出列表中所有的偶數,並確保沒有重複。

List<Integer> numbers=Arrays.asList(1,2,1,3,3,2,4);
numbers.stream().filter(i -> i % 2 == 0).distinct().forEach(System.out:: println);

下圖直觀地顯示了這個過程。
在這裡插入圖片描述

截短流

流支援limit(n)方法,該方法會返回一個不超過給定長度的流。所需的長度作為引數傳遞給limit。如果流是有序的,則最多會返回前n個元素。比如,你可以建立一個List,選出熱量超過300卡路里的頭三道菜:

List<Dish> dishes=menu.stream()
					.filter(d -> d.getCalories() > 300)
					.limit(3)
					.collect(toList());

下圖展示了filterlimit的組合。可以看到,該方法只選出了符合謂詞的頭三個元素,然後就立即返回了結果。
在這裡插入圖片描述
注意limit也可以用在無序流上,比如源是一個Set。這種情況下,limit結果不會以任何順序排列

跳過元素

流還支援skip(n)方法,返回一個扔掉了前n個元素的流,如果流中元素不足n個,則返回一個空流。注意,limit(n)和skip(n)是互補的!例如,下面的程式碼將跳過超過300卡路里的頭兩道菜,並返回剩下的。

List<Dish> dishes=menu.stream()
					.filter(d -> d.getCalories() > 300)
					.skip(2)
					.collect(toList());

下圖展示了這個查詢。
在這裡插入圖片描述

對映

你可以使用mapflatMap提取或轉換流中的元素。

一個非常常見的資料處理套路就是從某些物件中選擇資訊。比如在SQL裡,你可以從表中選擇一列。Stream API也通過mapflatMap方法提供了類似的工具。

對流中的每一個元素應用函式

流支援map方法,它會接受一個函式作為引數,這個函式會被應用到每個元素上,並將其對映成一個新的元素(使用對映一詞,是因為它和轉換類似,但其中的細微差別在於它是“建立一個新版本”而不是去“修改”)。例如,下面的程式碼把方法引用Dish:: getName傳給了map方法,來提取流中菜餚的名稱:

List<String> dishNames=menu.stream()
    						.map(Dish:: getName)
    						.collect(toList());

因為getName方法返回一個String,所以map方法輸出的流的型別就是Stream<String>

再看一個稍微不同的例子來鞏固一下對map的理解。給定一個單詞列表,你想要返回另一個列表,顯示每個單詞中有幾個字母。怎麼做呢?你需要對列表中的每個元素應用一個函式。這聽起來正好該用map方法去做!應用的函式應該接受一個單詞,並返回其長度。你可以像下面這樣,給map傳遞一個方法引用String:: length來解決這個問題:

List<String> words=Arrays.asList("Java8","Lambdas","In","Action");
List<Integer> wordLengths=words.stream()
								.map(String:: length)
								.collect(toList());

現在再回到提取菜名的例子。如果你要找出每道菜的名稱有多長,怎麼做?可以像下面這樣,再連結上一個map

List<Integer> dishNameLengths=menu.stream()
								.map(Dish:: getName)
								.map(String:: length)
								.collect(toList());

流的扁平化

你已經看到如何使用map方法返回列表中每個單詞的長度了。現在再拓展一下:對於一張單詞表,如何返回一張列表,列出裡面各不相同的字元呢?例如,給定單詞列表["Hello","World"],你想要返回列表["H","e","l","o","W","r","d"]

你可能會認為這很容易,你可以把每個單詞對映成一張字元表,然後呼叫distinct來過濾重複的字元。第一個版本可能是這樣的:

words.stream()
	.map(word -> word.split(""))
	.distinct()
	.collect(toList());

這個方法的問題在於,傳遞給map方法的Lambda為每個單詞返回了一個String[]String列表)。因此,map返回的流實際上是Stream<String[]>型別的。你真正想要的是用Stream<String>來表示一個字元流。下圖說明了這個問題。
在這裡插入圖片描述
幸好可以用flatMap來解決這個問題!讓我們一步步看看怎麼解決它。

  1. 嘗試使用mapArrays.stream()

    首先,你需要一個字元流,而不是陣列流。有一個叫作Arrays.stream()的方法可以接受一個數組併產生一個流,例如:

    String[] arrayOfWords={"Goodbye","World"};
    Stream<String> streamOfwords=Arrays.stream(arrayOfWords);
    

    把它用在前面的那個流水線裡,看看會發生什麼:

    words.stream()
    	.map(word -> word.split(""))	//將每個單詞轉換為由其字母構成的陣列
    	.map(Arrays:: stream)	//讓每個陣列變成一個單獨的流
    	.distinct()
    	.collect(toList());
    

    當前的解決方案仍然搞不定!這是因為,你現在得到的是一個流的列表(更準確地說是Stream<String>)!的確,你先是把每個單詞轉換成一個字母陣列,然後把每個陣列變成了一個獨立的流。

  2. 使用flatMap

    你可以像下面這樣使用flatMap來解決這個問題:

    List<String> uniqueCharacters
     					=words.stream()
     						.map(w -> w.split(""))	//將每個單詞轉換為由其字母構成的陣列
     						.flatMap(Arrays:: stream)	//將各個生成流扁平化為單個流
     						.distinct()
     						.collect(Collectors.toList());
    

使用flatMap方法的效果是,各個陣列並不是分別對映成一個流,而是對映成流的內容所有使用map(Arrays:: stream)時生成的單個流都被合併起來,即扁平化為一個流。下圖說明了使用flatMap方法的效果。可以把它和上圖中map的效果比較一下。

在這裡插入圖片描述
一言以蔽之,flatmap方法讓你把一個流中的每個值都換成另一個流,然後把所有的流連線起來成為一個流

查詢和匹配

你可以使用findFirstfindAny方法查詢流中的元素。你可以用allMatchnoneMatchanyMatch方法讓流匹配給定的謂詞。這些方法都利用了短路:找到結果就立即停止計算;沒有必要處理整個流。

另一個常見的資料處理套路是看看資料集中的某些元素是否匹配一個給定的屬性Stream API通過allMatchanyMatchnoneMatchfindFirstfindAny方法提供了這樣的工具。

檢查謂詞是否至少匹配一個元素(anyMatch)

anyMatch方法可以回答“流中是否有一個元素能匹配給定的謂詞”。比如,你可以用它來看看選單裡面是否有素食可選擇:

if(menu.stream().anyMatch(Dish:: isVegetarian)){
	System.out.println("The menu is (somewhat) vegetarian friendly!!");
}

anyMatch方法返回一個boolean,因此是一個終端操作

檢查謂詞是否匹配所有元素(allMatch)

allMatch方法的工作原理和anyMatch類似,但它會看看流中的元素是否都能匹配給定的謂詞。比如,你可以用它來看看菜品是否有利健康(即所有菜的熱量都低於1000卡路里):

boolean isHealthy=menu.stream().allMatch(d -> d.getCalories() < 1000);

noneMatch

allMatch相對的是noneMatch。它可以確保流中沒有任何元素與給定的謂詞匹配。比如,你可以用noneMatch重寫前面的例子:

boolean isHealthy=menu.stream().noneMatch(d -> d.getCalories() >= 1000);

短路

anyMatchallMatchnoneMatch這三個操作都用到了我們所謂的短路,這就是大家熟悉的Java&&||運算子短路在流中的版本。

有些操作不需要處理整個流就能得到結果。例如,假設你需要對一個用and連起來的大布爾表示式求值。不管表示式有多長,你只需找到一個表示式為false,就可以推斷整個表示式將返回false,所以用不著計算整個表示式。這就是短路

對於流而言,某些操作(例如allMatchanyMatchnoneMatchfindFirstfindAny不用處理整個流就能得到結果。只要找到一個元素,就可以有結果了。同樣,limit也是一個短路操作:它只需要建立一個給定大小的流,而用不著處理流中所有的元素。在碰到無限大小的流的時候,這種操作就有用了:它們可以把無限流變成有限流

查詢元素

findAny方法將返回當前流中的任意元素。它可以與其他流操作結合使用。比如,你可能想找到一道素食菜餚。你可以結合使用filterfindAny方法來實現這個查詢:

Optional<Dish> dish=menu.stream()
    					.filter(Dish:: isVegetarian)
    					.findAny();

流水線將在後臺進行優化使其只需走一遍,並在利用短路找到結果時立即結束。

Optional<T>類(java.util.Optional)是一個容器類代表一個值存在或不存在。在上面的程式碼中,findAny可能什麼元素都沒找到。Java 8的庫設計人員引入了Optional<T>,這樣就不用返回眾所周知容易出問題的null了。下面是Optional裡面幾種可以迫使你顯式地檢查值是否存在處理值不存在的情形的方法:

  • isPresent()將在Optional包含值的時候返回true,否則返回false
  • ifPresent(Consumer<T> block)會在值存在的時候執行給定的程式碼塊。之前介紹的Consumer函式式介面,它讓你傳遞一個接收T型別引數,並返回voidLambda表示式。
  • T get()會在值存在時返回值,否則丟擲一個NoSuchElement異常。
  • T orElse(T other)會在值存在時返回值,否則返回一個預設值。

例如,在前面的程式碼中你需要顯式地檢查Optional物件中是否存在一道菜可以訪問其名稱:

menu.stream()
    .filter(Dish:: isVegetarian)
    .findAny()	//返回一個Optional<Dish>
   	.ifPresent(d -> System.out.println(d.getName()));	//如果包含一個值就列印它,否則什麼都不做

查詢第一個元素

有些流有一個出現順序encounter order)來指定流中專案出現的邏輯順序(比如由List或排序好的資料列生成的流)。對於這種流,你可能想要找到第一個元素。為此有一個findFirst方法,它的工作方式類似於findany。例如,給定一個數字列表,下面的程式碼能找出第一個平方能被3整除的數:

List<Integer> someNumbers=Arrays.asList(1,2,3,4,5);
Optional<Integer> firstSquareDivisibleByThree
								=someNumbers.stream()
    										.map(x -> x * x)
    										.filter(x -> x % 3 == 0)
    										.findFirst();		//9

為什麼會同時有findFirstfindAny呢?答案是並行找到第一個元素在並行上限制更多。如果你不關心返回的元素是哪個,請使用findAny,因為它在使用並行流時限制較少。

歸約

到目前為止,見到的終端操作都是返回一個booleanallMatch之類的)、voidforEach)或Optional物件(findAny等)。也見過了使用collect來將流中的所有元素組合成一個List

接下來將看到如何把一個流中的元素組合起來,使用reduce操作來表達更復雜的查詢,比如“計算選單中的總卡路里”或“選單中卡路里最高的菜是哪一個”。此類查詢需要將流中所有元素反覆結合起來,得到一個值,比如一個Integer。這樣的查詢可以被歸類為歸約操作(將流歸約成一個值)。用函數語言程式設計語言的術語來說,這稱為摺疊fold),因為你可以將這個操作看成把一張長長的紙(你的流)反覆摺疊成一個小方塊,而這就是摺疊操作的結果。

元素求和

你可以利用reduce方法將流中所有的元素迭代合併成一個結果,例如求和或查詢最大元素。

在我們研究如何使用reduce方法之前,先來看看如何使用for-each迴圈來對數字列表中的元素求和:

int sum=0;
for(int x: numbers){
	sum += x;
}

numbers中的每個元素都用加法運算子反覆迭代來得到結果。通過反覆使用加法,你把一個數字列表歸約成了一個數字。這段程式碼中有兩個引數:

  • 總和變數的初始值,在這裡是0
  • 將列表中所有元素結合在一起的操作,在這裡是+

要是還能把所有的數字相乘,而不必去複製貼上這段程式碼,豈不是很好?這正是reduce操作的用武之地,它對這種重複應用的模式做了抽象。你可以像下面這樣對流中所有的元素求和:

int sum=numbers.stream().reduce(0,(a,b) -> a+b);

reduce接受兩個引數:

  • 一個初始值,這裡是0
  • 一個BinaryOperator<T>來將兩個元素結合起來產生一個新值,這裡我們用的是lambda(a,b) -> a+b

你也很容易把所有的元素相乘,只需要將另一個Lambda(a,b) -> a*b傳遞給reduce操作就可以了:

int product=numbers.stream().reduce(1,(a,b) -> a*b);

下圖展示了reduce操作是如何作用於一個流的:Lambda反覆結合每個元素,直到流被歸約成一個值。

在這裡插入圖片描述
reduce操作對一個數字流求和的過程如下。首先,0作為Lambda(a)的第一個引數,從流中獲得4作為第二個引數(b)0+4得到4,它成了新的累積值。然後再用累積值和流中下一個元素5呼叫Lambda,產生新的累積值9。接下來,再用累積值和下一個元素3呼叫Lambda,得到12。最後,用12和流中最後一個元素9呼叫Lambda,得到最終結果21

你可以使用方法引用讓這段程式碼更簡潔。在Java 8中,Integer類現在有了一個靜態的sum方法來對兩個數求和,這恰好是我們想要的,用不著反覆用Lambda寫同一段程式碼了:

int sum=numbers.stream().reduce(0,Integer:: sum);

reduce還有一個過載的變體,它不接受初始值,但是會返回一個Optional物件:

Optional<Integer> sum=numbers.stream().reduce((a,b) -> (a+b));

為什麼它返回一個Optional<Integer>呢?考慮流中沒有任何元素的情況。reduce操作無法返回其和,因為它沒有初始值。這就是為什麼結果被包裹在一個Optional物件裡,以表明和可能不存在。

怎樣用mapreduce方法數一數流中有多少個菜呢?要解決這個問題,你可以把流中每個元素都對映成數字1,然後用reduce求和。這相當於按順序數流中的元素個數。

int count=menu.stream()
				.map(d -> 1)
				.reduce(0,(a,b) -> a+b);

mapreduce的連線通常稱為map-reduce模式,因Google用它來進行網路搜尋而出名,因為它很容易並行化。

最大值和最小值

只要用歸約就可以計算最大值和最小值了。讓我們來看看如何利用reduce來計算流中最大或最小的元素reduce接受兩個引數:

  • 一個初始值
  • 一個Lambda來把兩個流元素結合起來併產生一個新值

Lambda是一步步用加法運算子應用到流中每個元素上的,如上圖所示。因此,你需要一個給定兩個元素能夠返回最大值的Lambdareduce操作會考慮新值和流中下一個元素,併產生一個新的最大值,直到整個流消耗完!你可以像下面這樣使用reduce來計算流中的最大值,如下圖所示。

Optional<Integer> max=numbers.stream().reduce(Integer:: max);

在這裡插入圖片描述
要計算最小值,你需要把Integer.min傳給reduce來替換Integer.max

Optional<Integer> min=numbers.stream().reduce(Integer:: min);

你當然也可以寫成Lambda(x,y) -> x<y ? x : y而不是Integer:: min,不過後者比較易讀。

相比於前面寫的逐步迭代求和,使用reduce的好處在於,這裡的迭代被內部迭代抽象掉了,這讓內部實現得以選擇並行執行reduce操作。

而迭代式求和例子要更新共享變數sum,這不是那麼容易並行化的。如果你加入了同步,很可能會發現執行緒競爭抵消了並行本應帶來的效能提升!這種計算的並行化需要另一種辦法:將輸入分塊,分塊求和,最後再合併起來。但這樣的話程式碼看起來就完全不一樣了。

可變的累加器模式對於並行化來說是死路一條。你需要一種新的模式,這正是reduce所提供的。

使用流來對所有的元素並行求和時,你的程式碼幾乎不用修改:stream()換成了parallelStream()

int sum=numbers.parallelStream().reduce(0,Integer:: sum);

但要並行執行這段程式碼也要付一定代價,傳遞給reduceLambda不能更改狀態(如例項變數),而且操作必須滿足結合律才可以按任意順序執行

乍一看流操作簡直是靈丹妙藥,而且只要在從集合生成流的時候把Stream換成parallelStream就可以實現並行。

當然,對於許多應用來說確實是這樣。你可以把一張選單變成流,用filter選出某一類的菜餚,然後對得到的流做map來對卡路里求和,最後reduce得到選單的總熱量。這個流計算甚至可以並行進行。但這些操作的特性並不相同。它們需要操作的內部狀態還是有些問題的。

諸如mapfilter等操作會從輸入流中獲取每一個元素,並在輸出流中得到01個結果。這些操作一般都是無狀態的:它們沒有內部狀態(假設使用者提供的Lambda或方法引用沒有內部可變狀態)。

但諸如reducesummax等操作需要內部狀態來累積結果。在上面的情況下,內部狀態很小。在我們的例子裡就是一個intdouble。不管流中有多少元素要處理,內部狀態都是有界的。

相反,諸如sortdistinct等操作一開始都和filtermap差不多——都是接受一個流,再生成一個流(中間操作),但有一個關鍵的區別。從流中排序和刪除重複項時都需要知道先前的歷史。例如,排序要求所有元素都放入緩衝區後才能給輸出流加入一個專案,這一操作的儲存要求是無界的。要是流比較大或是無限的,就可能會有問題(把質數流倒序會做什麼呢?它應當返回最大的質數,但數學告訴我們它不存在)。我們把這些操作叫作有狀態操作

filtermap等操作是無狀態的,它們並不儲存任何狀態。reduce等操作要儲存狀態才能計算出一個值。sorteddistinct等操作也要儲存狀態,因為它們需要把流中的所有元素快取起來才能返回一個新的流。這種操作稱為有狀態操作

現在已經看到了很多流操作,可以用來表達複雜的資料處理查詢。下表總結了迄今講過的操作。

操作 型別 返回型別 使用的型別/函式式介面 函式描述符
filter 中間 Stream< T> Predicate< T> T -> boolean
distinct 中間(有狀態–無界) Stream< T>
skip 中間(有狀態–有界) Stream< T> long
limit 中間(有狀態–有界) Stream< T> long
map 中間 Stream< R> Function< T, R> T -> R
flatMap 中間 Stream< R> Function< T, Stream< R>> T -> Stream< R>
sorted 中間(有狀態–無界) Stream< T> Comparator< T> (T, T) -> int
anyMatch 終端 boolean Predicate< T> T -> boolean
noneMatch 終端 boolean Predicate< T> T -> boolean
allMatch 終端 boolean Predicate< T> T -> boolean
findAny 終端 Optional< T>
findFirst 終端 Optional< T>
forEach 終端 void Consumer< T> T -> void
collect 終端 R Collector< T, A, R>
reduce 終端(有狀態–有界) Optional< T> BinaryOptional< T> (T, T) -> T
count 終端 long

付諸實踐

我們來看一個不同的領域:執行交易的交易員。你的經理讓你為八個查詢找到答案。運用迄今為止學到的關於流的知識。

  1. 找出2017年發生的所有交易,並按交易額排序(從低到高)。
  2. 交易員都在哪些不同的城市工作過?
  3. 查詢所有來自於劍橋的交易員,並按姓名排序。
  4. 返回所有交易員的姓名字串,按字母順序排序。
  5. 有沒有交易員是在米蘭工作的?
  6. 列印生活在劍橋的交易員的所有交易額。
  7. 所有交易中,最高的交易額是多少?
  8. 找到交易額最小的交易。

以下是要處理的領域,一個TradersTransactions的列表:

Trader raoul = new Trader(" Raoul", "Cambridge"); 
Trader mario = new Trader(" Mario"," Milan"); 
Trader alan = new Trader(" Alan"," Cambridge"); 
Trader brian = new Trader(" Brian"," Cambridge"); 

List< Transaction> transactions = Arrays. asList( 
	new Transaction( brian, 2017, 300), 
	new Transaction( raoul, 2018, 1000), 
	new Transaction( raoul, 2017, 400), 
	new Transaction( mario, 2018, 710), 
	new Transaction( mario, 2018, 700), 
	new Transaction( alan, 2018, 950) 
);

TraderTransaction類的定義如下:

public class Trader{ 
    private final String name; 
    private final String city; 
    
    public Trader( String n, String c){ 
        this. name = n; 
        this. city = c; 
    } 
    
    public String getName(){ 
        return this. name; 
    } 
    public String getCity(){ 
        return this. city; 
    } 
    
    public String toString(){ 
        return "Trader:"+ this. name + " in " + this. city; 
    }