1. 程式人生 > >Java 8 學習筆記4——流的概念

Java 8 學習筆記4——流的概念

流是什麼

流是Java API的新成員,它允許你以宣告性方式處理資料集合(通過查詢語句來表達,而不是臨時編寫一個實現)。

就現在來說,你可以把它們看成遍歷資料集的高階迭代器。此外,流還可以透明地並行處理,你無需寫任何多執行緒程式碼了!我們簡單看看使用流的好處吧。

下面兩段程式碼都是用來返回低熱量的菜餚名稱的,並按照卡路里排序,一個是用Java 7寫的,另一個是用Java 8的流寫的。

Java 7

List<Dish> lowCaloricDishes=new ArrayList<>();
for(Dish d:menu){
	if(d.
getCalories() < 400){ //用累加器篩選元素 lowCaloricDishes.add(d); } } Collections.sort(lowCaloricDishes,new Comparator<Dish>(){ //用匿名類對菜餚排序 public int compare(Dish d1,Dish d2){ return Integer.compare(d1.getCalories(),d2.getCalories()); } }); List<String> lowCaloricDishesName=new ArrayList<
>(); for(Dish d:lowCaloricDishes){ lowCaloricDishesName.add(d.getName()); //處理排序後的菜名列表 }

在這段程式碼中,你用了一個“垃圾變數”lowCaloricDishes。它唯一的作用就是作為一次性的中間容器。在Java 8中,實現的細節被放在它本該歸屬的庫裡了。

Java 8

import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
List<String>
lowCaloricDishesName =menu.stream() .filter(d -> d.getCalories() < 400) //選出400卡路里以下的菜餚 .sorted(comparing(Dish:: getCalories)) //按照卡路里排序 .map(Dish:: getName) //提取菜餚的名稱 .collect(toList()); //將所有名稱儲存在List中

為了利用多核架構並行執行這段程式碼,你只需要把stream()換成parallelStream()

List<String> lowCaloricDishesName
	=menu.parallelStream()
		.filter(d -> d.getCalories() < 400)
		.sorted(comparing(Dishes:: getCalories))
		.map(Dish:: getName)
		.collect(toList());

從軟體工程師的角度來看,新的方法有幾個顯而易見的好處:

  • 程式碼是以宣告性方式寫的:說明想要完成什麼(篩選熱量低的菜餚)而不是說明如何實現一個操作(利用迴圈和if條件等控制流語句)。這種方法加上行為引數化讓你可以輕鬆應對變化的需求:你很容易再建立一個程式碼版本,利用Lambda表示式來篩選高卡路里的菜餚,而用不著去複製貼上程式碼。
  • 你可以把幾個基礎操作連結起來,來表達複雜的資料處理流水線(在filter後面接上sortedmapcollect操作,如下圖所示),同時保持程式碼清晰可讀。filter的結果被傳給了sorted方法,再傳給map方法,最後傳給collect方法。
    在這裡插入圖片描述
    因為filtersortedmapcollect等操作是與具體執行緒模型無關的高層次構件,所以它們的內部實現可以是單執行緒的,也可能透明地充分利用你的多核架構!在實踐中,這意味著你用不著為了讓某些資料處理任務並行而去操心執行緒和鎖了,Stream API都替你做好了!

Java 8中的Stream API可以讓你寫出這樣的程式碼:

  • 宣告性——更簡潔,更易讀
  • 可複合——更靈活
  • 可並行——效能更好

下面要使用的例子是一個menu,它是一張菜餚列表:

List< Dish> menu = Arrays. asList( 
	new Dish("pork", false, 800, Dish.Type.MEAT), 
	new Dish("beef", false, 700, Dish.Type.MEAT), 
	new Dish("chicken", false, 400, Dish.Type.MEAT), 
	new Dish("french fries", true, 530, Dish.Type.OTHER), 
	new Dish("rice", true, 350, Dish.Type.OTHER), 
	new Dish("season fruit", true, 120, Dish.Type.OTHER), 
	new Dish("pizza", true, 550, Dish.Type.OTHER), 
	new Dish("prawns", false, 300, Dish.Type.FISH), 
	new Dish("salmon", false, 450, Dish.Type.FISH) 
);

Dish類的定義是:

public class Dish {

	private final String name;
	private final boolean vegetarian;
	private final int calories;
	private final Type type;

	public Dish(String name, boolean vegetarian, int calories, Type type) {
		this.name = name;
		this.vegetarian = vegetarian;
		this.calories = calories;
		this.type = type;
	}

	public String getName() {
		return name;
	}

	public boolean isVegetarian() {
		return vegetarian;
	}

	public int getCalories() {
		return calories;
	}

	public Type getType() {
		return type;
	}

	@Override
	public String toString() {
		return name;
	}

	public enum Type{
		MEAT,FISH,OTHER
	}

}

流簡介

Java 8中的集合支援一個新的stream方法,它會返回一個流(介面定義在java.util.stream.Stream裡)。

到底是什麼?簡短的定義就是“從支援資料處理操作的源生成的元素序列”,下面一步步剖析這個定義:

  • 元素序列——就像集合一樣,流也提供了一個介面,可以訪問特定元素型別的一組有序值。因為集合是資料機構,所以它的主要目的是以特定的時間/空間複雜度儲存和訪問元素(如ArrayListLinkedList)。但流的目的在於表達計算,比如前面見到的filtersortedmap集合講的是資料,流講的是計算
  • ——流會使用一個提供資料的源,如集合、陣列或輸入/輸出資源。注意從有序集合生成流時會保留原有的順序。由列表生成的流,其元素順序與列表一致。
  • 資料處理操作——流的資料處理功能支援類似於資料庫的操作,以及函數語言程式設計語言中的常用操作,如filtermapreducefindmatchsort等。流操作可以順序執行,也可並行執行

此外,流操作有兩個重要的特點:

  • 流水線——很多流操作本身會返回一個流,這樣多個操作就可以連線起來,形成一個大的流水線。
  • 內部迭代——與使用迭代器顯式迭代的集合不同,流的迭代操作是在背後進行的。

下面來看一段能夠體現所有這些概念的程式碼:

import static java.util.stream.Collectors.toList;
List<String> threeHighCaloricDishNames
	=menu.stream()	//從menu獲得流(菜餚列表)
		.filter(d -> d.getCalories() > 300)	//建立操作流水線:首先選出高熱量的菜餚
		.map(Dish:: getName)	//獲取菜名
		.limit(3)	//只選擇頭三個
		.collect(toList());	//將結果儲存在另一個List中
System.out.println(threeHighCaloricDishNames);	//結果是[pork,beef,chicken]

在本例中,我們先是對menu呼叫stream方法,由選單得到一個流。資料來源是菜餚列表(選單),它給流提供一個元素序列。接下來,對流應用一系列資料處理操作filtermaplimitcollect。除了collect之外,所有這些操作都會返回另一個流,這樣它們就可以接成一條流水線,於是就可以看作對源的一個查詢。最後,collect操作開始處理流水線,並返回結果(它和別的操作不一樣,因為它返回的不是流,在這裡是一個List)。在呼叫collect之前,沒有任何結果產生,實際上根本就沒有從menu裡選擇元素。你可以這麼理解:鏈中的方法呼叫都在排隊等待,直到呼叫collect
在這裡插入圖片描述
上圖顯示了流操作的順序:filtermaplimitcollect,每個操作簡介如下。

  • filter——接受Lambda,從流中排除某些元素。在本例中,通過傳遞lambda d -> d.getCalories() > 300選擇出熱量超過300卡路里的菜餚。
  • map——接受一個Lambda,將元素轉換成其他形式或提取資訊。在本例中,通過傳遞方法引用Dish:: getName,相當於Lambdad -> d.getName(),提取了每道菜的菜名。
  • limit——截斷流,使其元素不超過給定數量。
  • collect——將流轉換為其他形式。在本例中,流被轉換為一個列表。現在可以把collect看作能夠接受各種方案作為引數,並將流中的元素累積成為一個彙總結果的操作。這裡的toList()就是將流轉換為列表的方案。

剛剛解釋的這段程式碼,與逐項處理選單列表的程式碼有很大不同。首先,我們使用了宣告性的方式來處理選單資料,即你說的對這些資料需要做什麼:“查詢熱量最高的三道菜的菜名。”你並沒有去實現篩選(filter)、提取(map)或截斷(limit)功能,Streams庫已經自帶了。因此,Stream API在決定如何優化這條流水線時更為靈活。例如,篩選、提取和截斷操作可以一次進行,並在找到這三道菜後立即停止。

流與集合

Java現有的集合概念和新的概念都提供了介面,來配合代表元素型有序值的資料介面。所謂有序,就是說我們一般是按順序取用值,而不是隨機取用的。那這兩者有什麼區別呢?

比如說存在DVD裡的電影,這就是一個集合(也許是位元組,也許是幀,這個無所謂),因為它包含了整個資料結構。現在再來想想在網際網路上通過視訊流看同樣的電影。現在這是一個(位元組流或幀流)。流媒體視訊播放器只要提前下載使用者觀看位置的那幾幀就可以了,這樣不用等到流中大部分值計算出來,你就可以顯示流的開始部分了(想想觀看直播足球賽)。特別要注意,視訊播放器可能沒有將整個流作為集合,儲存所需要的記憶體緩衝區——而且要是非得等到最後一幀出現才能開始看,那等待的時間就太長了。出於實現的考慮,你也可以讓視訊播放器把流的一部分快取在集合裡,但和概念上的差異不是一回事。

粗略地說,集合與流之間的差異就在於什麼時候進行計算

集合是一個記憶體中的資料結構,它包含資料結構中目前所有的值——集合中的每個元素都得先算出來才能新增到集合中。(你可以往集合里加東西或者刪東西,但是不管什麼時候,集合中的每個元素都是放在記憶體裡的,元素都得先算出來才能成為集合的一部分。)

相比之下,**流則是在概念上固定的資料結構(你不能新增或刪除元素),其元素則是按需計算的。**這對程式設計有很大的好處。使用者僅僅從流中提取需要的值,而這些值——在使用者看不見的地方——只會按需生成。這是一種生產者-消費者的關係。從另一個角度來說,流就像是一個延遲建立的集合:只有在消費者要求的時候才會計算值(用管理學的話說這就是需求驅動,甚至是實時製造)。

與此相反,集合則是急切建立的(供應商驅動:先把倉庫裝滿,再開始賣,就像那些曇花一現的聖誕新玩意兒一樣)。以質數為例,要是想建立一個包含所有質數的集合,那這個程式算起來就沒完沒了了,因為總有新的質數要算,然後把它加到集合裡面。當然這個集合是永遠也建立不完的,消費者這輩子都見不著了。

下圖用DVD對比線上流媒體的例子展示了流和集合之間的差異。
在這裡插入圖片描述
另一個例子是用瀏覽器進行網際網路搜尋。假設你搜索的短語在Google或是網店裡面有很多匹配項。你用不著等到所有結果和照片的集合下載完,而是得到一個流,裡面有最好的10個或20個匹配項,還有一個按鈕來檢視下面10個或20個。當你作為消費者點選“下面10個”的時候,供應商就按需計算這些結果,然後再送回你的瀏覽器上顯示。

只能遍歷一次

和迭代器類似,流只能遍歷一次。遍歷完之後,我們就說這個流已經被消費掉了。你可以從原始資料來源那裡再獲得一個新的流來重新遍歷一遍,就像迭代器一樣(這裡假設它是集合之類的可重複的源,如果是I/O通道就沒戲了)。例如,以下程式碼會丟擲一個異常,說流已被消費掉了:

List<String> title=Arrays.asList("Java8","In","Action");
Stream<String> s=title.stream();
s.forEach(System.out:: println);	//列印標題中的每個單詞
s.forEach(System.out:: println);	//java.lang.IllegalStateException:流已被操作或關閉

所以要記得,流只能消費一次!

集合和流的另一個關鍵區別在於它們遍歷資料的方式

外部迭代與內部迭代

流利用內部迭代:迭代通過filtermapsorted等操作被抽象掉了。

使用Collection介面需要使用者去做迭代(比如用for-each),這稱為外部迭代。相反,Streams庫使用內部迭代——它幫你把迭代做了,還把得到的流值存在了某個地方,你只要給出一個函式說要幹什麼就可以了。下面的3段程式碼說明了這種區別。

  1. 集合:用for-each迴圈外部迭代
List<String> names=new ArrayList<>();
for(Dish d:menu){	//顯式順序迭代選單列表
	names.add(d.getName());	//提取名稱並將其新增到累加器
}

for-each還隱藏了迭代中的一些複雜性。for-each結構是一個語法糖,它背後的東西用Iterator物件表達出來更要醜陋得多。

  1. 集合:用背後的迭代器做外部迭代
List< String> names = new ArrayList<>(); 
Iterator<String> iterator = menu.iterator(); 
while(iterator.hasNext()) { 	//顯式迭代 
	Dish d = iterator.next(); 
	names.add(d.getName()); 
}
  1. 流:內部迭代
List<String> names=menu.stream()
					.map(Dish:: getName)	//用getName方法引數化map,提取菜名
					.collect(toList());	//開始執行操作流水線;沒有迭代!

用一個比喻來解釋內部迭代的差異和好處。比方說你在和你兩歲的女兒索菲亞說話,希望她能把玩具收起來。
在這裡插入圖片描述
這正是你每天都要對Java集合做的。你外部迭代一個集合,顯式地取出每個專案再加以處理。如果你只需跟索菲亞說“把地上所有的玩具都放進盒子裡”就好了。

內部迭代比較好的原因有二:

第一,索非亞可以選擇一隻手拿娃娃,另一隻手拿球;

第二,她可以決定先拿離盒子最近的那個東西,然後再拿別的。同樣的道理,內部迭代時,專案可以透明地並行處理,或者用更優化的順序進行處理。要是用Java過去的那種外部迭代方法,這些優化都是很困難的。

這似乎有點兒雞蛋裡挑骨頭,但這差不多就是Java 8引入流的理由了——Streams庫的內部迭代可以自動選擇一種適合你硬體的資料表示和並行實現。與此相反,一旦通過寫for-each而選擇了外部迭代,那你基本上就要自己管理所有的並行問題了(自己管理實際上意味著“某個良辰吉日我們會把它並行化”或“開始了關於任務和synchronized的漫長而艱苦的鬥爭”)。Java 8需要一個類似於Collection卻沒有迭代器的介面,於是就有了Stream

下圖說明了內部迭代)與集合外部迭代)之間的差異。
在這裡插入圖片描述
前面已經說過了集合與流在概念上的差異,特別是流利用了內部迭代:替你把迭代做了。但是,只有你已經預先定義好了能夠隱藏迭代的操作列表,例如filtermap,這個才有用。大多數這類操作都接受Lambda表示式作為引數,因此你可以用前面介紹的方法來引數化其行為。Java語言的設計者給Stream API配上了一大套可以用來表達複雜資料處理查詢的操作。

流操作

java.util.stream.Stream中的Stream介面定義了許多操作。它們可以分為兩大類。我們再來看一下前面的例子:

List<String> names=menu.stream()		//從選單獲得流
                        .filter(d -> d.getCalories() > 300)		//中間操作
                        .map(Dish:: getName)		//中間操作
                        .limit(3)		//中間操作
                        .collect(toList());		//將Stream轉換為List

你可以看到兩類操作:

  • filtermaplimit可以連成一條流水線
  • collect觸發流水線執行並關閉它

可以連線起來的流操作稱為中間操作,關閉流的操作稱為終端操作。下圖展示了這兩類操作。
在這裡插入圖片描述

中間操作

諸如filtersorted中間操作會返回另一個流。這讓多個操作可以連線起來形成一個查詢。重要的是,除非流水線上觸發一個終端操作,否則中間操作不會執行任何處理——它們很懶。這是因為中間操作一般都可以合併起來,在終端操作時一次性全部處理。

filtermap等中間操作會返回一個流,並可以連結在一起。可以用它們來設定一條流水線,但並不會生成任何結果。

為了搞清楚流水線中到底發生了什麼,我們把程式碼改一改,讓每個Lambda都打印出當前處理的菜餚:

List<String> names=menu.stream()
                        .filter(d -> {
                            			System.out.println("filtering"+d.getName());
                            			return d.getCalories() > 300;
                       				 })		//列印當前篩選的菜餚
                        .map(d -> {
                                    	System.out.println("mapping"+d.getName());
                                    	return d.getName();
                                   })		//提取菜名時打印出來
                        .limit(3)
                        .collect(toList());
System.out.println(names);

此程式碼執行時將列印:

filtering pork 
mapping pork 
filtering beef 
mapping beef 
filtering chicken 
mapping chicken 
[pork, beef, chicken]

你會發現,有好幾種優化利用了流的延遲性質。第一,儘管很多菜的熱量都高於300卡路里,但只選出了前三個!這是因為limit操作和一種稱為短路的技巧。第二,儘管filtermap是兩個獨立的操作,但它們合併到同一次遍歷中了(我們把這種技術叫作迴圈合併)。

終端操作

forEachcount等終端操作會返回一個非流的值,並處理流水線以返回結果。

終端操作會從流的流水線生成結果其結果是任何不是流的值,比如ListInteger,甚至void。例如,在下面的流水線中,forEach是一個返回void的終端操作,它會對源中的每道菜應用一個Lambda。把System.out.println傳遞給forEach,並要求它打印出由menu生成的流中的每一個Dish

menu.stream().forEach(System.out:: println);

使用流

流的使用一般包括三件事:

  • 一個資料來源(如集合)來執行一個查詢;
  • 一個中間操作鏈,形成一條流的流水線;
  • 一個終端操作,執行流水線,並能生成結果。

流的流水線背後的理念類似於構建器模式。在構建器模式中有一個呼叫鏈用來設定一套配置(對流來說這就是一箇中間操作鏈),接著是呼叫built方法(對流來說就是終端操作)。

為方便起見,下面兩個表總結了前面在程式碼例子中看到的中間流操作終端流操作。但這並不能涵蓋Stream API提供的操作,後面還會看到更多。

  • 中間操作:
操作 型別 返回型別 操作引數 函式描述符
filter 中間 Stream< T> Predicate< T> T -> boolean
map 中間 Stream< T> Function< T, R> T -> R
limit 中間 Stream< T>
sorted 中間 Stream< T> Comparator< T> (T, T) -> int
distinct 中間 Stream< T>
  • 終端操作:
操作 型別 目的
forEach 終端 消費流中的每個元素並對其應用Lambda,這一操作返回void
count 終端 返回流中元素的個數,這一操作返回long
collect 終端 把流歸約成一個集合,比如List、Map甚至是Integer