本章內容
篩選、切片和匹配
查找、匹配和歸約
使用數值範圍等數值流
從多個源創建流
無限流
在上一章中你已看到了,流讓你從外部迭代轉向內部迭代。 這樣,你就用不著寫下面這樣的代碼來顯式地管理數據集合的迭代(外部迭代)了:
List<Dish> vegetarianDishes = new ArrayList<>;
for(Dish d: menu){
if(d.isVegetarian){
vegetarianDishes.add(d);
}
}
你可以使用支持filter
和collect
操作的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可以決定並行運行你的代碼。這要是用外部迭代的話就辦不到了,因為你只能用單一線程挨個迭代。
在本章中,你將會看到Stream API支持的許多操作。這些操作能讓你快速完成複雜的數據查詢,如篩選、切片、映射、查找、匹配和歸約。接下來,我們會看看一些特殊的流:數值流、來自文件和數組等多種來源的流,最後是無限流。
5.1 篩選和切片
在本節中,我們來看看如何選擇流中的元素:用謂詞篩選,篩選出各不相同的元素,忽略流中的頭幾個元素,或將流截短至指定長度。
5.1.1 用謂詞篩選
Streams
接口支持filter
方法(你現在應該很熟悉了)。該操作會接受一個謂詞(一個返回boolean
的函數)作為參數,並返回一個包括所有符合謂詞的元素的流。例如,你可以像圖5-1所示的這樣,篩選出所有素菜,創建一張素食菜單:
List<Dish> vegetarianMenu = menu.stream
.filter(Dish::isVegetarian) ←─方法引用檢查菜餚是否適合素食者
.collect(toList);
圖 5-1 用謂詞篩選一個流
5.1.2 篩選各異的元素
流還支持一個叫作distinct
的方法,它會返回一個元素各異(根據流所生成元素的hashCode
和equals
方法實現)的流。例如,以下代碼會篩選出列表中所有的偶數,並確保沒有重複。圖5-2直觀地顯示了這個過程。
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream
.filter(i -> i % 2 == 0)
.distinct
.forEach(System.out::println);
圖 5-2 篩選流中各異的元素
5.1.3 截短流
流支持limit(n)
方法,該方法會返回一個不超過給定長度的流。所需的長度作為參數傳遞給limit
。如果流是有序的,則最多會返回前n
個元素。比如,你可以建立一個List
,選出熱量超過300卡路里的頭三道菜:
List<Dish> dishes = menu.stream
.filter(d -> d.getCalories > 300)
.limit(3)
.collect(toList);
圖5-3展示了filter
和limit
的組合。你可以看到,該方法只選出了符合謂詞的頭三個元素,然後就立即返回了結果。
圖 5-3 截短流
請注意limit
也可以用在無序流上,比如源是一個Set
。這種情況下,limit
的結果不會以任何順序排列。
5.1.4 跳過元素
流還支持skip(n)
方法,返回一個扔掉了前n
個元素的流。如果流中元素不足n
個,則返回一個空流。請注意,limit(n)
和skip(n)
是互補的!例如,下面的代碼將跳過超過300卡路里的頭兩道菜,並返回剩下的。圖5-4展示了這個查詢。
List<Dish> dishes = menu.stream
.filter(d -> d.getCalories > 300)
.skip(2)
.collect(toList);
圖 5-4 在流中跳過元素
在我們討論映射操作之前,在測驗5.1上試試本節學過的內容吧。
測驗5.1:篩選
你將如何利用流來篩選前兩個葷菜呢?
答案:你可以把
filter
和limit
復合在一起來解決這個問題,並用collect(toList)
將流轉換成一個列表。List<Dish> dishes = menu.stream .filter(d -> d.getType == Dish.Type.MEAT) .limit(2) .collect(toList);
5.2 映射
一個非常常見的數據處理套路就是從某些對像中選擇信息。比如在SQL裡,你可以從表中選擇一列。Stream API也通過map
和flatMap
方法提供了類似的工具。
5.2.1 對流中每一個元素應用函數
流支持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("Java 8", "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);
5.2.2 流的扁平化
你已經看到如何使用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>
來表示一個字符流。圖5-5說明了這個問題。
圖 5-5 不正確地使用map
找出單詞列表中各不相同的字符
幸好可以用flatMap
來解決這個問題!讓我們一步步看看怎麼解決它。
1. 嘗試使用map
和Arrays.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)
時生成的單個流都被合併起來,即扁平化為一個流。圖5-6說明了使用flatMap
方法的效果。把它和圖5-5中map
的效果比較一下。
圖 5-6 使用flatMap
找出單詞列表中各不相同的字符
一言以蔽之,flatmap
方法讓你把一個流中的每個值都換成另一個流,然後把所有的流連接起來成為一個流。
在第10章,我們會討論更高級的Java 8模式,比如使用新的Optional
類進行null
檢查時會再來看看flatMap
。為鞏固你對於map
和flatMap
的理解,試試測驗5.2吧。
測驗5.2:映射
(1) 給定一個數字列表,如何返回一個由每個數的平方構成的列表呢?例如,給定[1, 2, 3, 4, 5],應該返回[1, 4, 9, 16, 25]。
答案:你可以利用
map
方法的Lambda,接受一個數字,並返回該數字平方的Lambda來解決這個問題。List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); List<Integer> squares = numbers.stream .map(n -> n * n) .collect(toList);
(2) 給定兩個數字列表,如何返回所有的數對呢?例如,給定列表[1, 2, 3]和列表[3, 4],應該返回[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。為簡單起見,你可以用有兩個元素的數組來代表數對。
答案:你可以使用兩個
map
來迭代這兩個列表,並生成數對。但這樣會返回一個Stream<Stream<Integer>>
。你需要讓生成的流扁平化,以得到一個Stream<Integer>
。這正是flatMap
所做的:List<Integer> numbers1 = Arrays.asList(1, 2, 3); List<Integer> numbers2 = Arrays.asList(3, 4); List<int> pairs = numbers1.stream .flatMap(i -> numbers2.stream .map(j -> new int{i, j}) ) .collect(toList);
(3) 如何擴展前一個例子,只返回總和能被3整除的數對呢?例如(2, 4)和(3, 3)是可以的。
答案:你在前面看到了,
filter
可以配合謂詞使用來篩選流中的元素。因為在flatMap
操作後,你有了一個代表數對的int
流,所以你只需要一個謂詞來檢查總和是否能被3整除就可以了:List<Integer> numbers1 = Arrays.asList(1, 2, 3); List<Integer> numbers2 = Arrays.asList(3, 4); List<int> pairs = numbers1.stream .flatMap(i -> numbers2.stream .filter(j -> (i + j) % 3 == 0) .map(j -> new int{i, j}) ) .collect(toList);
其結果是[(2, 4), (3, 3)]。
5.3 查找和匹配
另一個常見的數據處理套路是看看數據集中的某些元素是否匹配一個給定的屬性。Stream API通過allMatch
、anyMatch
、noneMatch
、findFirst
和findAny
方法提供了這樣的工具。
5.3.1 檢查謂詞是否至少匹配一個元素
anyMatch
方法可以回答“流中是否有一個元素能匹配給定的謂詞”。比如,你可以用它來看看菜單裡面是否有素食可選擇:
if(menu.stream.anyMatch(Dish::isVegetarian)){
System.out.println("The menu is (somewhat) vegetarian friendly!!");
}
anyMatch
方法返回一個boolean
,因此是一個終端操作。
5.3.2 檢查謂詞是否匹配所有元素
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);
anyMatch
、allMatch
和noneMatch
這三個操作都用到了我們所謂的短路,這就是大家熟悉的Java中&&
和||
運算符短路在流中的版本。
短路求值
有些操作不需要處理整個流就能得到結果。例如,假設你需要對一個用
and
連起來的大布爾表達式求值。不管表達式有多長,你只需找到一個表達式為false
,就可以推斷整個表達式將返回false
,所以用不著計算整個表達式。這就是短路。對於流而言,某些操作(例如
allMatch
、anyMatch
、noneMatch
、findFirst
和findAny
)不用處理整個流就能得到結果。只要找到一個元素,就可以有結果了。同樣,limit
也是一個短路操作:它只需要創建一個給定大小的流,而用不著處理流中所有的元素。在碰到無限大小的流的時候,這種操作就有用了:它們可以把無限流變成有限流。我們會在5.7節中介紹無限流的例子。
5.3.3 查找元素
findAny
方法將返回當前流中的任意元素。它可以與其他流操作結合使用。比如,你可能想找到一道素食菜餚。你可以結合使用filter
和findAny
方法來實現這個查詢:
Optional<Dish> dish =
menu.stream
.filter(Dish::isVegetarian)
.findAny;
流水線將在後台進行優化使其只需走一遍,並在利用短路找到結果時立即結束。不過慢著,代碼裡面的Optional
是個什麼玩意兒?
Optional
簡介
Optional<T>
類(java.util.Optional
)是一個容器類,代表一個值存在或不存在。在上面的代碼中,findAny
可能什麼元素都沒找到。Java 8的庫設計人員引入了Optional<T>
,這樣就不用返回眾所周知容易出問題的null
了。我們在這裡不會詳細討論Optional
,因為第10章會詳細解釋你的代碼如何利用Optional
,避免和null
檢查相關的bug。不過現在,瞭解一下Optional
裡面幾種可以迫使你顯式地檢查值是否存在或處理值不存在的情形的方法也不錯。
-
isPresent
將在Optional
包含值的時候返回true,
否則返回false
。 -
ifPresent(Consumer<T> block)
會在值存在的時候執行給定的代碼塊。我們在第3章介紹了Consumer
函數式接口;它讓你傳遞一個接收T
類型參數,並返回void
的Lambda表達式。 -
T get
會在值存在時返回值,否則拋出一個NoSuchElement
異常。 -
T orElse(T other)
會在值存在時返回值,否則返回一個默認值。
例如,在前面的代碼中你需要顯式地檢查Optional
對像中是否存在一道菜可以訪問其名稱:
menu.stream
.filter(Dish::isVegetarian)
.findAny ←─返回一個Optional<Dish>
.ifPresent(d -> System.out.println(d.getName); ←─如果包含一個值就打印它,否則什麼都不做
5.3.4 查找第一個元素
有些流有一個出現順序(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
何時使用
findFirst
和findAny
你可能會想,為什麼會同時有
findFirst
和findAny
呢?答案是並行。找到第一個元素在並行上限制更多。如果你不關心返回的元素是哪個,請使用findAny
,因為它在使用並行流時限制較少。
5.4 歸約
到目前為止,你見到過的終端操作都是返回一個boolean
(allMatch
之類的)、void
(forEach
)或Optional
對像(findAny
等)。你也見過了使用collect
來將流中的所有元素組合成一個List
。
在本節中,你將看到如何把一個流中的元素組合起來,使用reduce
操作來表達更複雜的查詢,比如“計算菜單中的總卡路里”或“菜單中卡路里最高的菜是哪一個”。此類查詢需要將流中所有元素反覆結合起來,得到一個值,比如一個Integer
。這樣的查詢可以被歸類為歸約操作(將流歸約成一個值)。用函數式編程語言的術語來說,這稱為折疊(fold),因為你可以將這個操作看成把一張長長的紙(你的流)反覆折疊成一個小方塊,而這就是折疊操作的結果。
5.4.1 元素求和
在我們研究如何使用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);
圖5-7展示了reduce
操作是如何作用於一個流的:Lambda反覆結合每個元素,直到流被歸約成一個值。
讓我們深入研究一下reduce
操作是如何對一個數字流求和的。首先,0
作為Lambda(a
)的第一個參數,從流中獲得4
作為第二個參數(b
)。0 + 4
得到4
,它成了新的累積值。然後再用累積值和流中下一個元素5
調用Lambda,產生新的累積值9
。接下來,再用累積值和下一個元素3
調用Lambda,得到12
。最後,用12
和流中最後一個元素9
調用Lambda,得到最終結果21
。
圖 5-7 使用reduce
來對流中的數字求和
你可以使用方法引用讓這段代碼更簡潔。在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
對像裡,以表明和可能不存在。現在看看用reduce
還能做什麼。
5.4.2 最大值和最小值
原來,只要用歸約就可以計算最大值和最小值了!讓我們來看看如何利用剛剛學到的reduce
來計算流中最大或最小的元素。正如你前面看到的,reduce
接受兩個參數:
-
一個初始值
-
一個Lambda來把兩個流元素結合起來並產生一個新值
Lambda是一步步用加法運算符應用到流中每個元素上的,如圖5-7所示。因此,你需要一個給定兩個元素能夠返回最大值的Lambda。reduce
操作會考慮新值和流中下一個元素,並產生一個新的最大值,直到整個流消耗完!你可以像下面這樣使用reduce
來計算流中的最大值,如圖5-8所示。
Optional<Integer> max = numbers.stream.reduce(Integer::max);
圖 5-8 一個歸約操作——計算最大值
要計算最小值,你需要把Integer.min
傳給reduce
來替換Integer.max
:
Optional<Integer> min = numbers.stream.reduce(Integer::min);
你當然也可以寫成Lambda (x, y) -> x < y ? x : y
而不是Integer::min
,不過後者比較易讀。
為了檢驗你對於reduce
操作的理解程度,試試測驗5.3吧!
測驗5.3:歸約
怎樣用
map
和reduce
方法數一數流中有多少個菜呢?答案:要解決這個問題,你可以把流中每個元素都映射成數字
1
,然後用reduce
求和。這相當於按順序數流中的元素個數。int count = menu.stream .map(d -> 1) .reduce(0, (a, b) -> a + b);
map
和reduce
的連接通常稱為map-reduce
模式,因Google用它來進行網絡搜索而出名,因為它很容易並行化。請注意,在第4章中我們也看到了內置count
方法可用來計算流中元素的個數:long count = menu.stream.count;
歸約方法的優勢與並行化
相比於前面寫的逐步迭代求和,使用
reduce
的好處在於,這裡的迭代被內部迭代抽像掉了,這讓內部實現得以選擇並行執行reduce
操作。而迭代式求和例子要更新共享變量sum
,這不是那麼容易並行化的。如果你加入了同步,很可能會發現線程競爭抵消了並行本應帶來的性能提升!這種計算的並行化需要另一種辦法:將輸入分塊,分塊求和,最後再合併起來。但這樣的話代碼看起來就完全不一樣了。你在第7章會看到使用分支/合併框架來做是什麼樣子。但現在重要的是要認識到,可變的累加器模式對於並行化來說是死路一條。你需要一種新的模式,這正是reduce
所提供的。你還將在第7章看到,使用流來對所有的元素並行求和時,你的代碼幾乎不用修改:stream
換成了parallelStream
。int sum = numbers.parallelStream.reduce(0, Integer::sum);
但要並行執行這段代碼也要付一定代價,我們稍後會向你解釋:傳遞給
reduce
的Lambda不能更改狀態(如實例變量),而且操作必須滿足結合律才可以按任意順序執行。
到目前為止,你看到了產生一個Integer
的歸約例子:對流求和、流中的最大值,或是流中元素的個數。你將會在5.6節看到,諸如sum
和max
等內置的方法可以讓常見歸約模式的代碼再簡潔一點兒。我們會在下一章中討論一種複雜的使用 collect
方法的歸約。例如,如果你想要按類型對菜餚分組,也可以把流歸約成一個Map
而不是Integer
。
流操作:無狀態和有狀態
你已經看到了很多的流操作。乍一看流操作簡直是靈丹妙藥,而且只要在從集合生成流的時候把
Stream
換成parallelStream
就可以實現並行。當然,對於許多應用來說確實是這樣,就像前面的那些例子。你可以把一張菜單變成流,用
filter
選出某一類的菜餚,然後對得到的流做map
來對卡路里求和,最後reduce
得到菜單的總熱量。這個流計算甚至可以並行進行。但這些操作的特性並不相同。它們需要操作的內部狀態還是有些問題的。諸如
map
或filter
等操作會從輸入流中獲取每一個元素,並在輸出流中得到0或1個結果。這些操作一般都是無狀態的:它們沒有內部狀態(假設用戶提供的Lambda或方法引用沒有內部可變狀態)。但諸如
reduce
、sum
、max
等操作需要內部狀態來累積結果。在上面的情況下,內部狀態很小。在我們的例子裡就是一個int
或double
。不管流中有多少元素要處理,內部狀態都是有界的。相反,諸如
sort
或distinct
等操作一開始都和filter
和map
差不多——都是接受一個流,再生成一個流(中間操作),但有一個關鍵的區別。從流中排序和刪除重複項時都需要知道先前的歷史。例如,排序要求所有元素都放入緩衝區後才能給輸出流加入一個項目,這一操作的存儲要求是無界的。要是流比較大或是無限的,就可能會有問題(把質數流倒序會做什麼呢?它應當返回最大的質數,但數學告訴我們它不存在)。我們把這些操作叫作有狀態操作。
你現在已經看到了很多流操作,可以用來表達複雜的數據處理查詢。表5-1總結了迄今講過的操作。你可以在下一節中通過一個練習來實踐一下。
表5-1 中間操作和終端操作
操作
類型
返回類型
使用的類型/函數式接口
函數描述符
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>
BinaryOperator<T>
(T, T) -> T
count
終端
long
5.5 付諸實踐
在本節中,你會將迄今學到的關於流的知識付諸實踐。我們來看一個不同的領域:執行交易的交易員。你的經理讓你為八個查詢找到答案。你能做到嗎?我們在5.5.2節給出了答案,但你應該自己先嘗試一下作為練習。
(1) 找出2011年發生的所有交易,並按交易額排序(從低到高)。
(2) 交易員都在哪些不同的城市工作過?
(3) 查找所有來自於劍橋的交易員,並按姓名排序。
(4) 返回所有交易員的姓名字符串,按字母順序排序。
(5) 有沒有交易員是在米蘭工作的?
(6) 打印生活在劍橋的交易員的所有交易額。
(7) 所有交易中,最高的交易額是多少?
(8) 找到交易額最小的交易。
5.5.1 領域:交易員和交易
以下是你要處理的領域,一個Traders
和Transactions
的列表:
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, 2011, 300),
new Transaction(raoul, 2012, 1000),
new Transaction(raoul, 2011, 400),
new Transaction(mario, 2012, 710),
new Transaction(mario, 2012, 700),
new Transaction(alan, 2012, 950)
);
Trader
和Transaction
類的定義如下:
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;
}
}
public class Transaction{
private final Trader trader;
private final int year;
private final int value;
public Transaction(Trader trader, int year, int value){
this.trader = trader;
this.year = year;
this.value = value;
}
public Trader getTrader{
return this.trader;
}
public int getYear{
return this.year;
}
public int getValue{
return this.value;
}
public String toString{
return "{" + this.trader + ", " +
"year: "+this.year+", " +
"value:" + this.value +"}";
}
}
5.5.2 解答
解答在下面的代碼清單中。你可以看看你對迄今所學知識的理解程度如何。幹得不錯!
代碼清單5-1 找出2011年的所有交易並按交易額排序(從低到高)
List<Transaction> tr2011 =
transactions.stream
.filter(transaction -> transaction.getYear == 2011) ←─給filter傳遞一個謂詞來選擇2011年的交易
.sorted(comparing(Transaction::getValue)) ←─按照交易額進行排序
.collect(toList); ←─將生成的Stream中的所有元素收集到一個List中
代碼清單5-2 交易員都在哪些不同的城市工作過
List<String> cities =
transactions.stream
.map(transaction -> transaction.getTrader.getCity) ←─提取與交易相關的每位交易員的所在城市
.distinct ←─只選擇互不相同的城市
.collect(toList);
這裡還有一個新招:你可以去掉distinct
,改用toSet
,這樣就會把流轉換為集合。你在第6章中會瞭解到更多相關內容。
Set<String> cities =
transactions.stream
.map(transaction -> transaction.getTrader.getCity)
.collect(toSet);
代碼清單5-3 查找所有來自於劍橋的交易員,並按姓名排序
List<Trader> traders =
transactions.stream
.map(Transaction::getTrader) ←─從交易中提取所有交易員
.filter(trader -> trader.getCity.equals("Cambridge")) ←─僅選擇位於劍橋的交易員
.distinct ←─確保沒有任何重複
.sorted(comparing(Trader::getName)) ←─對生成的交易員流按照姓名進行排序
.collect(toList);
代碼清單5-4 返回所有交易員的姓名字符串,按字母順序排序
String traderStr =
transactions.stream
.map(transaction -> transaction.getTrader.getName) ←─提取所有交易員姓名,生成一個Strings構成的Stream
.distinct ←─只選擇不相同的姓名
.sorted ←─對姓名按字母順序排序
.reduce("", (n1, n2) -> n1 + n2); ←─逐個拼接每個名字,得到一個將所有名字連接起來的String
請注意,此解決方案效率不高(所有字符串都被反覆連接,每次迭代的時候都要建立一個新的String
對像)。下一章中,你將看到一個更為高效的解決方案,它像下面這樣使用joining
(其內部會用到StringBuilder
):
String traderStr =
transactions.stream
.map(transaction -> transaction.getTrader.getName)
.distinct
.sorted
.collect(joining);
代碼清單5-5 有沒有交易員是在米蘭工作的
boolean milanBased =
transactions.stream
.anyMatch(transaction -> transaction.getTrader
.getCity
.equals("Milan")); ←─把一個謂詞傳遞給anyMatch,檢查是否有交易員在米蘭工作
代碼清單5-6 打印生活在劍橋的交易員的所有交易額
transactions.stream
.filter(t -> "Cambridge".equals(t.getTrader.getCity)) ←─選擇住在劍橋的交易員所進行的交易
.map(Transaction::getValue) ←─提取這些交易的交易額
.forEach(System.out::println); ←─打印每個值
代碼清單5-7 所有交易中,最高的交易額是多少
Optional<Integer> highestValue =
transactions.stream
.map(Transaction::getValue) ←─提取每項交易的交易額
.reduce(Integer::max); ←─計算生成的流中的最大值
代碼清單5-8 找到交易額最小的交易
Optional<Transaction> smallestTransaction =
transactions.stream
.reduce((t1, t2) ->
t1.getValue < t2.getValue ? t1 : t2); ←─通過反覆比較每個交易的交易額,找出最小的交易
你還可以做得更好。流支持min
和max
方法,它們可以接受一個Comparator
作為參數,指定計算最小或最大值時要比較哪個鍵值:
Optional<Transaction> smallestTransaction =
transactions.stream
.min(comparing(Transaction::getValue));
5.6 數值流
我們在前面看到了可以使用reduce
方法計算流中元素的總和。例如,你可以像下面這樣計算菜單的熱量:
int calories = menu.stream
.map(Dish::getCalories)
.reduce(0, Integer::sum);
這段代碼的問題是,它有一個暗含的裝箱成本。每個Integer
都必須拆箱成一個原始類型,再進行求和。要是可以直接像下面這樣調用sum
方法,豈不是更好?
int calories = menu.stream
.map(Dish::getCalories)
.sum;
但這是不可能的。問題在於map
方法會生成一個Stream<T>
。雖然流中的元素是Integer
類型,但Streams
接口沒有定義sum
方法。為什麼沒有呢?比方說,你只有一個像menu
那樣的Stream<Dish>
,把各種菜加起來是沒有任何意義的。但不要擔心,Stream API還提供了原始類型流特化,專門支持處理數值流的方法。
5.6.1 原始類型流特化
Java 8引入了三個原始類型特化流接口來解決這個問題:IntStream
、DoubleStream
和LongStream
,分別將流中的元素特化為int
、long
和double
,從而避免了暗含的裝箱成本。每個接口都帶來了進行常用數值歸約的新方法,比如對數值流求和的sum
,找到最大元素的max
。此外還有在必要時再把它們轉換回對像流的方法。要記住的是,這些特化的原因並不在於流的複雜性,而是裝箱造成的複雜性——即類似int
和Integer
之間的效率差異。
1. 映射到數值流
將流轉換為特化版本的常用方法是mapToInt
、mapToDouble
和mapToLong
。這些方法和前面說的map
方法的工作方式一樣,只是它們返回的是一個特化流,而不是Stream<T>
。例如,你可以像下面這樣用mapToInt
對menu
中的卡路里求和:
int calories = menu.stream ←─返回一個Stream<Dish>
.mapToInt(Dish::getCalories) ←─返回一個IntStream
.sum;
這裡,mapToInt
會從每道菜中提取熱量(用一個Integer
表示),並返回一個IntStream
(而不是一個Stream<Integer>
)。然後你就可以調用IntStream
接口中定義的sum
方法,對卡路里求和了!請注意,如果流是空的,sum
默認返回0
。IntStream
還支持其他的方便方法,如max
、min
、average
等。
2. 轉換回對像流
同樣,一旦有了數值流,你可能會想把它轉換回非特化流。例如,IntStream
上的操作只能產生原始整數:IntStream
的map
操作接受的Lambda必須接受int
並返回int
(一個IntUnaryOperator
)。但是你可能想要生成另一類值,比如Dish
。為此,你需要訪問Stream
接口中定義的那些更廣義的操作。要把原始流轉換成一般流(每個int
都會裝箱成一個Integer
),可以使用boxed
方法,如下所示:
IntStream intStream = menu.stream.mapToInt(Dish::getCalories); ←─將Stream 轉換為數值流
Stream<Integer> stream = intStream.boxed; ←─將數值流轉換為Stream
你在下一節中會看到,在需要將數值範圍裝箱成為一個一般流時,boxed
尤其有用。
3. 默認值OptionalInt
求和的那個例子很容易,因為它有一個默認值:0
。但是,如果你要計算IntStream
中的最大元素,就得換個法子了,因為0
是錯誤的結果。如何區分沒有元素的流和最大值真的是0
的流呢?前面我們介紹了Optional
類,這是一個可以表示值存在或不存在的容器。Optional
可以用Integer
、String
等參考類型來參數化。對於三種原始流特化,也分別有一個Optional
原始類型特化版本:OptionalInt
、OptionalDouble
和OptionalLong
。
例如,要找到IntStream
中的最大元素,可以調用max
方法,它會返回一個OptionalInt
:
OptionalInt maxCalories = menu.stream
.mapToInt(Dish::getCalories)
.max;
現在,如果沒有最大值的話,你就可以顯式處理OptionalInt
去定義一個默認值了:
int max = maxCalories.orElse(1); ←─如果沒有最大值的話,顯式提供一個默認最大值
5.6.2 數值範圍
和數字打交道時,有一個常用的東西就是數值範圍。比如,假設你想要生成1和100之間的所有數字。Java 8引入了兩個可以用於IntStream
和LongStream
的靜態方法,幫助生成這種範圍:range
和rangeClosed
。這兩個方法都是第一個參數接受起始值,第二個參數接受結束值。但range
是不包含結束值的,而rangeClosed
則包含結束值。讓我們來看一個例子:
IntStream evenNumbers = IntStream.rangeClosed(1, 100) ←─表示範圍[1, 100]
.filter(n -> n % 2 == 0); ←─一個從1到100的偶數流
System.out.println(evenNumbers.count); ←─從1 到100 有50個偶數
這裡我們用了rangeClosed
方法來生成1到100之間的所有數字。它會產生一個流,然後你可以鏈接filter
方法,只選出偶數。到目前為止還沒有進行任何計算。最後,你對生成的流調用count
。因為count
是一個終端操作,所以它會處理流,並返回結果50
,這正是1到100(包括兩端)中所有偶數的個數。請注意,比較一下,如果改用IntStream.range(1, 100)
,則結果將會是49
個偶數,因為range
是不包含結束值的。
5.6.3 數值流應用:勾股數
現在我們來看一個難一點兒的例子,讓你鞏固一下有關數值流以及到目前為止學過的所有流操作的知識。如果你接受這個挑戰,任務就是創建一個勾股數流。
1. 勾股數
那麼什麼是勾股數(畢達哥拉斯三元數)呢?我們得回到從前。在一堂激動人心的數學課上,你瞭解到,古希臘數學家畢達哥拉斯發現了某些三元數(a, b, c)
滿足公式a * a + b * b = c * c
,其中a
、b
、c
都是整數。例如,(3, 4, 5)就是一組有效的勾股數,因為3 * 3 + 4 * 4 = 5 * 5或9 + 16 = 25。這樣的三元數有無限組。例如,(5, 12, 13)、(6, 8, 10)和(7, 24, 25)都是有效的勾股數。勾股數很有用,因為它們描述的正好是直角三角形的三條邊長,如圖5-9所示。
圖 5-9 勾股定理(畢達哥拉斯定理)
2. 表示三元數
那麼,怎麼入手呢?第一步是定義一個三元數。雖然更恰當的做法是定義一個新的類來表示三元數,但這裡你可以使用具有三個元素的int
數組,比如new int{3, 4, 5}
,來表示勾股數(3, 4, 5)。現在你就可以用數組索引訪問每個元素了。
3. 篩選成立的組合
假定有人為你提供了三元數中的前兩個數字:a
和b
。怎麼知道它是否能形成一組勾股數呢?你需要測試a * a + b * b
的平方根是不是整數,也就是說它沒有小數部分——在Java裡可以使用expr % 1.0
表示。如果它不是整數,那就是說c
不是整數。你可以用filter
操作表達這個要求(你稍後會瞭解到如何將其連接起來成為有效代碼):
filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
假設周圍的代碼給a
提供了一個值,並且stream
提供了b
可能出現的值,filter
將只選出那些可以與a
組成勾股數的b
。你可能在想Math.sqrt(a * a + b * b) % 1 == 0
這一行是怎麼回事。簡單來說,這是一種測試Math.sqrt(a * a + b * b)
返回的結果是不是整數的方法。如果平方根的結果帶了小數,如9.1,這個條件就不成立(9.0是可以的)。
4. 生成三元組
在篩選之後,你知道a
和b
能夠組成一個正確的組合。現在需要創建一個三元組。你可以使用map
操作,像下面這樣把每個元素轉換成一個勾股數組:
stream.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.map(b -> new int{a, b, (int) Math.sqrt(a * a + b * b)});
5. 生成b
值
勝利在望!現在你需要生成b
的值。前面已經看到,Stream.rangeClosed
讓你可以在給定區間內生成一個數值流。你可以用它來給b
提供數值,這裡是1到100:
IntStream.rangeClosed(1, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.boxed
.map(b -> new int{a, b, (int) Math.sqrt(a * a + b * b)});
請注意,你在filter
之後調用boxed
,從rangeClosed
返回的IntStream
生成一個Stream<Integer>
。這是因為你的map
會為流中的每個元素返回一個int
數組。而IntStream
中的map
方法只能為流中的每個元素返回另一個int
,這可不是你想要的!你可以用IntStream
的mapToObj
方法改寫它,這個方法會返回一個對像值流:
IntStream.rangeClosed(1, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.mapToObj(b -> new int{a, b, (int) Math.sqrt(a * a + b * b)});
6. 生成值
這裡有一個關鍵的假設:給出了a
的值。 現在,只要已知a
的值,你就有了一個可以生成勾股數的流。如何解決這個問題呢?就像b
一樣,你需要為a
生成數值!最終的解決方案如下所示:
Stream<int> pythagoreanTriples =
IntStream.rangeClosed(1, 100).boxed
.flatMap(a ->
IntStream.rangeClosed(a, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.mapToObj(b ->
new int{a, b, (int)Math.sqrt(a * a + b * b)})
);
好的,flatMap
又是怎麼回事呢?首先,創建一個從1到100的數值範圍來生成a
的值。對每個給定的a
值,創建一個三元數流。要是把a
的值映射到三元數流的話,就會得到一個由流構成的流。flatMap
方法在做映射的同時,還會把所有生成的三元數流扁平化成一個流。這樣你就得到了一個三元數流。還要注意,我們把b
的範圍改成了a
到100。沒有必要再從1開始了,否則就會造成重複的三元數,例如(3,4,5)和(4,3,5)。
7. 運行代碼
現在你可以運行解決方案,並且可以利用我們前面看到的limit
命令,明確限定從生成的流中要返回多少組勾股數了:
pythagoreanTriples.limit(5)
.forEach(t ->
System.out.println(t[0] + ", " + t[1] + ", " + t[2]));
這會打印:
3, 4, 5
5, 12, 13
6, 8, 10
7, 24, 25
8, 15, 17
8. 你還能做得更好嗎?
目前的解決辦法並不是最優的,因為你要求兩次平方根。讓代碼更為緊湊的一種可能的方法是,先生成所有的三元數(a*a, b*b, a*a+b*b)
,然後再篩選符合條件的:
Stream<double> pythagoreanTriples2 =
IntStream.rangeClosed(1, 100).boxed
.flatMap(a ->
IntStream.rangeClosed(a, 100)
.mapToObj(
b -> new double{a, b, Math.sqrt(a*a + b*b)}) ←─產生三元數
.filter(t -> t[2] % 1 == 0)); ←─元組中的第三個元素必須是整數
5.7 構建流
希望到現在,我們已經讓你相信,流對於表達數據處理查詢是非常強大而有用的。到目前為止,你已經能夠使用stream
方法從集合生成流了。此外,我們還介紹了如何根據數值範圍創建數值流。但創建流的方法還有許多!本節將介紹如何從值序列、數組、文件來創建流,甚至由生成函數來創建無限流!
5.7.1 由值創建流
你可以使用靜態方法Stream.of
,通過顯式值創建一個流。它可以接受任意數量的參數。例如,以下代碼直接使用Stream.of
創建了一個字符串流。然後,你可以將字符串轉換為大寫,再一個個打印出來:
Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);
你可以使用empty
得到一個空流,如下所示:
Stream<String> emptyStream = Stream.empty;
5.7.2 由數組創建流
你可以使用靜態方法Arrays.stream
從數組創建一個流。它接受一個數組作為參數。例如,你可以將一個原始類型int
的數組轉換成一個IntStream
,如下所示:
int numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum; ←─總和是41
5.7.3 由文件生成流
Java中用於處理文件等I/O操作的NIO API(非阻塞 I/O)已更新,以便利用Stream API。java.nio.file.Files
中的很多靜態方法都會返回一個流。例如,一個很有用的方法是Files.lines
,它會返回一個由指定文件中的各行構成的字符串流。使用你迄今所學的內容,你可以用這個方法看看一個文件中有多少各不相同的詞:
long uniqueWords = 0;
try(Stream<String> lines =
Files.lines(Paths.get("data.txt"), Charset.defaultCharset)){ ←─流會自動關閉
uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" "))) ←─生成單詞流
.distinct ←─刪除重複項
.count; ←─數一數有多少各不相同的單詞
}
catch(IOException e){
←─如果打開文件時出現異常則加以處理
}
你可以使用Files.lines
得到一個流,其中的每個元素都是給定文件中的一行。然後,你可以對line
調用split
方法將行拆分成單詞。應該注意的是,你該如何使用flatMap
產生一個扁平的單詞流,而不是給每一行生成一個單詞流。最後,把distinct
和count
方法鏈接起來,數數流中有多少各不相同的單詞。
5.7.4 由函數生成流:創建無限流
Stream API提供了兩個靜態方法來從函數生成流:Stream.iterate
和Stream.generate
。這兩個操作可以創建所謂的