本章內容
用
Collectors
類創建和使用收集器將數據流歸約為一個值
匯總:歸約的特殊情況
數據分組和分區
開發自己的自定義收集器
我們在前一章中學到,流可以用類似於數據庫的操作幫助你處理集合。你可以把Java 8的流看作花哨又懶惰的數據集迭代器。它們支持兩種類型的操作:中間操作(如filter
或map
)和終端操作(如count
、findFirst
、forEach
和reduce
)。中間操作可以鏈接起來,將一個流轉換為另一個流。這些操作不會消耗流,其目的是建立一個流水線。與此相反,終端操作會消耗流,以產生一個最終結果,例如返回流中的最大元素。它們通常可以通過優化流水線來縮短計算時間。
我們已經在第4章和第5章中用過collect
終端操作了,當時主要是用來把Stream
中所有的元素結合成一個List
。在本章中,你會發現collect
是一個歸約操作,就像reduce
一樣可以接受各種做法作為參數,將流中的元素累積成一個匯總結果。具體的做法是通過定義新的Collector
接口來定義的,因此區分Collection
、Collector
和collect
是很重要的。
下面是一些查詢的例子,看看你用collect
和收集器能夠做什麼。
-
對一個交易列表按貨幣分組,獲得該貨幣的所有交易額總和(返回一個
Map<Currency, Integer>
)。 -
將交易列表分成兩組:貴的和不貴的(返回一個
Map<Boolean, List<Transaction>>
)。 -
創建多級分組,比如按城市對交易分組,然後進一步按照貴或不貴分組(返回一個
Map<Boolean, List<Transaction>>
)。
激動嗎?很好,我們先來看一個利用收集器的例子。想像一下,你有一個由Transaction
構成的List
,並且想按照名義貨幣進行分組。在沒有Lambda的Java裡,哪怕像這種簡單的用例實現起來都很囉嗦,就像下面這樣。
代碼清單6-1 用指令式風格對交易按照貨幣分組
Map<Currency, List<Transaction>> transactionsByCurrencies =
new HashMap<>; ←─建立累積交易分組的Map
for (Transaction transaction : transactions) { ←─迭代Transaction的List
Currency currency = transaction.getCurrency; ←─提取Transaction的貨幣
List<Transaction> transactionsForCurrency =
transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) { ←─如果分組Map中沒有這種貨幣的條目,就創建一個
transactionsForCurrency = new ArrayList<>;
transactionsByCurrencies
.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction); ←─將當前遍歷的Transaction加入同一貨幣的Transaction的List
}
如果你是一位經驗豐富的Java程序員,寫這種東西可能挺順手的,不過你必須承認,做這麼簡單的一件事就得寫很多代碼。更糟糕的是,讀起來比寫起來更費勁!代碼的目的並不容易看出來,儘管換作白話的話是很直截了當的:“把列表中的交易按貨幣分組。”你在本章中會學到,用Stream
中collect
方法的一個更通用的Collector
參數,你就可以用一句話實現完全相同的結果,而用不著使用上一章中那個toList
的特殊情況了:
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream.collect(groupingBy(Transaction::getCurrency));
這一比差得還真多,對吧?
6.1 收集器簡介
前一個例子清楚地展示了函數式編程相對於指令式編程的一個主要優勢:你只需指出希望的結果——“做什麼”,而不用操心執行的步驟——“如何做”。在上一個例子裡,傳遞給collect
方法的參數是Collector
接口的一個實現,也就是給Stream
中元素做匯總的方法。上一章裡的toList
只是說“按順序給每個元素生成一個列表”;在本例中,groupingBy
說的是“生成一個Map
,它的鍵是(貨幣)桶,值則是桶中那些元素的列表”。
要是做多級分組,指令式和函數式之間的區別就會更加明顯:由於需要好多層嵌套循環和條件,指令式代碼很快就變得更難閱讀、更難維護、更難修改。相比之下,函數式版本只要再加上一個收集器就可以輕鬆地增強功能了,你會在6.3節中看到它。
6.1.1 收集器用作高級歸約
剛剛的結論又引出了優秀的函數式API設計的另一個好處:更易復合和重用。收集器非常有用,因為用它可以簡潔而靈活地定義collect
用來生成結果集合的標準。更具體地說,對流調用collect
方法將對流中的元素觸發一個歸約操作(由Collector
來參數化)。圖6-1所示的歸約操作所做的工作和代碼清單6-1中的指令式代碼一樣。它遍歷流中的每個元素,並讓Collector
進行處理。
圖 6-1 按貨幣對交易分組的歸約過程
一般來說,Collector
會對元素應用一個轉換函數(很多時候是不體現任何效果的恆等轉換,例如toList
),並將結果累積在一個數據結構中,從而產生這一過程的最終輸出。例如,在前面所示的交易分組的例子中,轉換函數提取了每筆交易的貨幣,隨後使用貨幣作為鍵,將交易本身累積在生成的Map
中。
如貨幣的例子中所示,Collector
接口中方法的實現決定了如何對流執行歸約操作。我們會在6.5節和6.6節研究如何創建自定義收集器。但Collectors
實用類提供了很多靜態工廠方法,可以方便地創建常見收集器的實例,只要拿來用就可以了。最直接和最常用的收集器是toList
靜態方法,它會把流中所有的元素收集到一個List
中:
List<Transaction> transactions =
transactionStream.collect(Collectors.toList);
6.1.2 預定義收集器
在本章剩下的部分中,我們主要探討預定義收集器的功能,也就是那些可以從Collectors
類提供的工廠方法(例如groupingBy
)創建的收集器。它們主要提供了三大功能:
-
將流元素歸約和匯總為一個值
-
元素分組
-
元素分區
我們先來看看可以進行歸約和匯總的收集器。它們在很多場合下都很方便,比如前面例子中提到的求一系列交易的總交易額。
然後你將看到如何對流中的元素進行分組,同時把前一個例子推廣到多層次分組,或把不同的收集器結合起來,對每個子組進行進一步歸約操作。我們還將談到分組的特殊情況“分區”,即使用謂詞(返回一個布爾值的單參數函數)作為分組函數。
6.4節末有一張表,總結了本章中探討的所有預定義收集器。在6.5節你將瞭解更多有關Collector
接口的內容。在6.6節中你會學到如何創建自己的自定義收集器,用於Collectors
類的工廠方法無效的情況。
6.2 歸約和匯總
為了說明從Collectors
工廠類中能創建出多少種收集器實例,我們重用一下前一章的例子:包含一張佳餚列表的菜單!
就像你剛剛看到的,在需要將流項目重組成集合時,一般會使用收集器(Stream
方法collect
的參數)。再寬泛一點來說,但凡要把流中所有的項目合併成一個結果時就可以用。這個結果可以是任何類型,可以複雜如代表一棵樹的多級映射,或是簡單如一個整數——也許代表了菜單的熱量總和。這兩種結果類型我們都會討論:6.2.2節討論單個整數,6.3.1節討論多級分組。
我們先來舉一個簡單的例子,利用counting
工廠方法返回的收集器,數一數菜單裡有多少種菜:
long howManyDishes = menu.stream.collect(Collectors.counting);
這還可以寫得更為直接:
long howManyDishes = menu.stream.count;
counting
收集器在和其他收集器聯合使用的時候特別有用,後面會談到這一點。
在本章後面的部分,我們假定你已導入了Collectors
類的所有靜態工廠方法:
import static java.util.stream.Collectors.*;
這樣你就可以寫counting
而用不著寫Collectors.counting
之類的了。
讓我們來繼續探討簡單的預定義收集器,看看如何找到流中的最大值和最小值。
6.2.1 查找流中的最大值和最小值
假設你想要找出菜單中熱量最高的菜。你可以使用兩個收集器,Collectors.maxBy
和Collectors.minBy
,來計算流中的最大或最小值。這兩個收集器接收一個Comparator
參數來比較流中的元素。你可以創建一個Comparator
來根據所含熱量對菜餚進行比較,並把它傳遞給Collectors.maxBy
:
Comparator<Dish> dishCaloriesComparator =
Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish =
menu.stream
.collect(maxBy(dishCaloriesComparator));
你可能在想Optional<Dish>
是怎麼回事。要回答這個問題,我們需要問“要是menu
為空怎麼辦”。那就沒有要返回的菜了!Java 8引入了Optional
,它是一個容器,可以包含也可以不包含值。這裡它完美地代表了可能也可能不返回菜餚的情況。我們在第5章講findAny
方法的時候簡要提到過它。現在不用擔心,我們專門用第10章來研究Optional<T>
及其操作。
另一個常見的返回單個值的歸約操作是對流中對象的一個數值字段求和。或者你可能想要求平均數。這種操作被稱為匯總操作。讓我們來看看如何使用收集器來表達匯總操作。
6.2.2 匯總
Collectors
類專門為匯總提供了一個工廠方法:Collectors.summingInt
。它可接受一個把對像映射為求和所需int
的函數,並返回一個收集器;該收集器在傳遞給普通的collect
方法後即執行我們需要的匯總操作。舉個例子來說,你可以這樣求出菜單列表的總熱量:
int totalCalories = menu.stream.collect(summingInt(Dish::getCalories));
這裡的收集過程如圖6-2所示。在遍歷流時,會把每一道菜都映射為其熱量,然後把這個數字累加到一個累加器(這裡的初始值0
)。
Collectors.summingLong
和Collectors.summingDouble
方法的作用完全一樣,可以用於求和字段為long
或double
的情況。
圖 6-2 summingInt
收集器的累積過程
但匯總不僅僅是求和;還有Collectors.averagingInt
,連同對應的averagingLong
和averagingDouble
可以計算數值的平均數:
double avgCalories =
menu.stream.collect(averagingInt(Dish::getCalories));
到目前為止,你已經看到了如何使用收集器來給流中的元素計數,找到這些元素數值屬性的最大值和最小值,以及計算其總和和平均值。不過很多時候,你可能想要得到兩個或更多這樣的結果,而且你希望只需一次操作就可以完成。在這種情況下,你可以使用summarizingInt
工廠方法返回的收集器。例如,通過一次summarizing
操作你可以就數出菜單中元素的個數,並得到菜餚熱量總和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics =
menu.stream.collect(summarizingInt(Dish::getCalories));
這個收集器會把所有這些信息收集到一個叫作IntSummaryStatistics
的類裡,它提供了方便的取值(getter)方法來訪問結果。打印menuStatisticobject
會得到以下輸出:
IntSummaryStatistics{count=9, sum=4300, min=120,
average=477.777778, max=800}
同樣,相應的summarizingLong
和summarizingDouble
工廠方法有相關的LongSummaryStatistics
和DoubleSummaryStatistics
類型,適用於收集的屬性是原始類型long
或double
的情況。
6.2.3 連接字符串
joining
工廠方法返回的收集器會把對流中每一個對像應用toString
方法得到的所有字符串連接成一個字符串。這意味著你把菜單中所有菜餚的名稱連接起來,如下所示:
String shortMenu = menu.stream.map(Dish::getName).collect(joining);
請注意,joining
在內部使用了StringBuilder
來把生成的字符串逐個追加起來。此外還要注意,如果Dish
類有一個toString
方法來返回菜餚的名稱,那你無需用提取每一道菜名稱的函數來對原流做映射就能夠得到相同的結果:
String shortMenu = menu.stream.collect(joining);
二者均可產生以下字符串:
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
但該字符串的可讀性並不好。幸好,joining
工廠方法有一個重載版本可以接受元素之間的分界符,這樣你就可以得到一個逗號分隔的菜餚名稱列表:
String shortMenu = menu.stream.map(Dish::getName).collect(joining(", "));
正如我們預期的那樣,它會生成:
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
到目前為止,我們已經探討了各種將流歸約到一個值的收集器。在下一節中,我們會展示為什麼所有這種形式的歸約過程,其實都是Collectors.reducing
工廠方法提供的更廣義歸約收集器的特殊情況。
6.2.4 廣義的歸約匯總
事實上,我們已經討論的所有收集器,都是一個可以用reducing
工廠方法定義的歸約過程的特殊情況而已。Collectors.reducing
工廠方法是所有這些特殊情況的一般化。可以說,先前討論的案例僅僅是為了方便程序員而已。(但是,請記得方便程序員和可讀性是頭等大事!)例如,可以用reducing
方法創建的收集器來計算你菜單的總熱量,如下所示:
int totalCalories = menu.stream.collect(reducing(
0, Dish::getCalories, (i, j) -> i + j));
它需要三個參數。
-
第一個參數是歸約操作的起始值,也是流中沒有元素時的返回值,所以很顯然對於數值和而言
0
是一個合適的值。 -
第二個參數就是你在6.2.2節中使用的函數,將菜餚轉換成一個表示其所含熱量的
int
。 -
第三個參數是一個
BinaryOperator
,將兩個項目累積成一個同類型的值。這裡它就是對兩個int
求和。
同樣,你可以使用下面這樣單參數形式的reducing
來找到熱量最高的菜,如下所示:
Optional<Dish> mostCalorieDish =
menu.stream.collect(reducing(
(d1, d2) -> d1.getCalories > d2.getCalories ? d1 : d2));
你可以把單參數reducing
工廠方法創建的收集器看作三參數方法的特殊情況,它把流中的第一個項目作為起點,把恆等函數(即一個函數僅僅是返回其輸入參數)作為一個轉換函數。這也意味著,要是把單參數reducing
收集器傳遞給空流的collect
方法,收集器就沒有起點;正如我們在6.2.1節中所解釋的,它將因此而返回一個Optional<Dish>
對象。
收集與歸約
在上一章和本章中討論了很多有關歸約的內容。你可能想知道,
Stream
接口的collect
和reduce
方法有何不同,因為兩種方法通常會獲得相同的結果。例如,你可以像下面這樣使用reduce
方法來實現toListCollector
所做的工作:Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream; List<Integer> numbers = stream.reduce( new ArrayList<Integer>, (List<Integer> l, Integer e) -> { l.add(e); return l; }, (List<Integer> l1, List<Integer> l2) -> { l1.addAll(l2); return l1; });
這個解決方案有兩個問題:一個語義問題和一個實際問題。語義問題在於,
reduce
方法旨在把兩個值結合起來生成一個新值,它是一個不可變的歸約。與此相反,collect
方法的設計就是要改變容器,從而累積要輸出的結果。這意味著,上面的代碼片段是在濫用reduce
方法,因為它在原地改變了作為累加器的List
。你在下一章中會更詳細地看到,以錯誤的語義使用reduce
方法還會造成一個實際問題:這個歸約過程不能並行工作,因為由多個線程並發修改同一個數據結構可能會破壞List
本身。在這種情況下,如果你想要線程安全,就需要每次分配一個新的List
,而對像分配又會影響性能。這就是collect
方法特別適合表達可變容器上的歸約的原因,更關鍵的是它適合並行操作,本章後面會談到這一點。
1. 收集框架的靈活性:以不同的方法執行同樣的操作
你還可以進一步簡化前面使用reducing
收集器的求和例子——引用Integer
類的sum
方法,而不用去寫一個表達同一操作的Lambda表達式。這會得到以下程序:
int totalCalories = menu.stream.collect(reducing(0, ←─初始值
Dish::getCalories, ←─轉換函數
Integer::sum)); ←─累積函數
從邏輯上說,歸約操作的工作原理如圖6-3所示:利用累積函數,把一個初始化為起始值的累加器,和把轉換函數應用到流中每個元素上得到的結果不斷迭代合併起來。
圖 6-3 計算菜單總熱量的歸約過程
在現實中,我們在6.2節開始時提到的counting
收集器也是類似地利用三參數reducing
工廠方法實現的。它把流中的每個元素都轉換成一個值為1
的Long
型對象,然後再把它們相加:
public static <T> Collector<T, ?, Long> counting {
return reducing(0L, e -> 1L, Long::sum);
}
使用泛型
?
通配符在剛剛提到的代碼片段中,你可能已經注意到了
?
通配符,它用作counting
工廠方法返回的收集器簽名中的第二個泛型類型。對這種記法你應該已經很熟悉了,特別是如果你經常使用Java的集合框架的話。在這裡,它僅僅意味著收集器的累加器類型未知,換句話說,累加器本身可以是任何類型。我們在這裡原封不動地寫出了Collectors
類中原始定義的方法簽名,但在本章其餘部分我們將避免使用任何通配符表示法,以使討論盡可能簡單。
我們在第5章已經注意到,還有另一種方法不使用收集器也能執行相同操作——將菜餚流映射為每一道菜的熱量,然後用前一個版本中使用的方法引用來歸約得到的流:
int totalCalories =
menu.stream.map(Dish::getCalories).reduce(Integer::sum).get;
請注意,就像流的任何單參數reduce
操作一樣,reduce(Integer::sum)
返回的不是int
而是Optional<Integer>
,以便在空流的情況下安全地執行歸約操作。然後你只需用Optional
對像中的get
方法來提取裡面的值就行了。請注意,在這種情況下使用get
方法是安全的,只是因為你已經確定菜餚流不為空。你在第10章還會進一步瞭解到,一般來說,使用允許提供默認值的方法,如orElse
或orElseGet
來解開Optional
中包含的值更為安全。最後,更簡潔的方法是把流映射到一個IntStream
,然後調用sum
方法,你也可以得到相同的結果:
int totalCalories = menu.stream.mapToInt(Dish::getCalories).sum;
2. 根據情況選擇最佳解決方案
這再次說明了,函數式編程(特別是Java 8的Collections
框架中加入的基於函數式風格原理設計的新API)通常提供了多種方法來執行同一個操作。這個例子還說明,收集器在某種程度上比Stream
接口上直接提供的方法用起來更複雜,但好處在於它們能提供更高水平的抽像和概括,也更容易重用和自定義。
我們的建議是,盡可能為手頭的問題探索不同的解決方案,但在通用的方案裡面,始終選擇最專門化的一個。無論是從可讀性還是性能上看,這一般都是最好的決定。例如,要計菜單的總熱量,我們更傾向於最後一個解決方案(使用IntStream
),因為它最簡明,也很可能最易讀。同時,它也是性能最好的一個,因為IntStream
可以讓我們避免自動拆箱操作,也就是從Integer
到int
的隱式轉換,它在這裡毫無用處。
接下來,請看看測驗6.1,測試一下你對於reducing
作為其他收集器的概括的理解程度如何。
測驗6.1:用
reducing
連接字符串以下哪一種
reducing
收集器的用法能夠合法地替代joining
收集器(如6.2.3節用法)?String shortMenu = menu.stream.map(Dish::getName).collect(joining);
(1)
String shortMenu = menu.stream.map(Dish::getName) .collect( reducing ( (s1, s2) -> s1 + s2 ) ).get;(2)
String shortMenu = menu.stream.collect( reducing( (d1, d2) -> d1.getName + d2.getName ) ).get;(3)
String shortMenu = menu.stream.collect( reducing( "",Dish::getName, (s1, s2) -> s1 + s2 ) );答案:語句1和語句3是有效的,語句2無法編譯。
(1) 這會將每道菜轉換為菜名,就像原先使用
joining
收集器的語句一樣。然後用一個String
作為累加器歸約得到的字符串流,並將菜名逐個連接在它後面。(2) 這無法編譯,因為
reducing
接受的參數是一個BinaryOperator<t>
,也就是一個BiFunction<T,T,T>
。這就意味著它需要的函數必須能接受兩個參數,然後返回一個相同類型的值,但這裡用的Lambda表達式接受的參數是兩個菜,返回的卻是一個字符串。(3) 這會把一個空字符串作為累加器來進行歸約,在遍歷菜餚流時,它會把每道菜轉換成菜名,並追加到累加器上。請注意,我們前面講過,
reducing
要返回一個Optional
並不需要三個參數,因為如果是空流的話,它的返回值更有意義——也就是作為累加器初始值的空字符串。請注意,雖然語句1和語句3都能夠合法地替代
joining
收集器,它們在這裡是用來展示我們為何可以(至少在概念上)把reducing
看作本章中討論的所有其他收集器的概括。然而就實際應用而言,不管是從可讀性還是性能方面考慮,我們始終建議使用joining
收集器。
6.3 分組
一個常見的數據庫操作是根據一個或多個屬性對集合中的項目進行分組。就像前面講到按貨幣對交易進行分組的例子一樣,如果用指令式風格來實現的話,這個操作可能會很麻煩、囉嗦而且容易出錯。但是,如果用Java 8所推崇的函數式風格來重寫的話,就很容易轉化為一個非常容易看懂的語句。我們來看看這個功能的第二個例子:假設你要把菜單中的菜按照類型進行分類,有肉的放一組,有魚的放一組,其他的都放另一組。用Collectors.groupingBy
工廠方法返回的收集器就可以輕鬆地完成這項任務,如下所示:
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream.collect(groupingBy(Dish::getType));
其結果是下面的Map
:
{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],
MEAT=[pork, beef, chicken]}
這裡,你給groupingBy
方法傳遞了一個Function
(以方法引用的形式),它提取了流中每一道Dish
的Dish.Type
。我們把這個Function
叫作分類函數,因為它用來把流中的元素分成不同的組。如圖6-4所示,分組操作的結果是一個Map
,把分組函數返回的值作為映射的鍵,把流中所有具有這個分類值的項目的列表作為對應的映射值。在菜單分類的例子中,鍵就是菜的類型,值就是包含所有對應類型的菜餚的列表。
圖 6-4 在分組過程中對流中的項目進行分類
但是,分類函數不一定像方法引用那樣可用,因為你想用以分類的條件可能比簡單的屬性訪問器要複雜。例如,你可能想把熱量不到400卡路里的菜劃分為“低熱量”(diet),熱量400到700卡路里的菜劃為“普通”(normal),高於700卡路里的劃為“高熱量”(fat)。由於Dish
類的作者沒有把這個操作寫成一個方法,你無法使用方法引用,但你可以把這個邏輯寫成Lambda表達式:
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream.collect(
groupingBy(dish -> {
if (dish.getCalories <= 400) return CaloricLevel.DIET;
else if (dish.getCalories <= 700) return
CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} ));
現在,你已經看到了如何對菜單中的菜餚按照類型和熱量進行分組,但要是想同時按照這兩個標準分類怎麼辦呢?分組的強大之處就在於它可以有效地組合。讓我們來看看怎麼做。
6.3.1 多級分組
要實現多級分組,我們可以使用一個由雙參數版本的 Collectors.groupingBy
工廠方法創建的收集器,它除了普通的分類函數之外,還可以接受collector
類型的第二個參數。那麼要進行二級分組的話,我們可以把一個內層groupingBy
傳遞給外層groupingBy
,並定義一個為流中項目分類的二級標準,如代碼清單6-2所示。
代碼清單6-2 多級分組
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream.collect(
groupingBy(Dish::getType, ←─一級分類函數
groupingBy(dish -> { ←─二級分類函數
if (dish.getCalories <= 400) return CaloricLevel.DIET;
else if (dish.getCalories <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} )
)
);
這個二級分組的結果就是像下面這樣的兩級Map
:
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
這裡的外層Map
的鍵就是第一級分類函數生成的值:“fish, meat, other”,而這個Map
的值又是一個Map
,鍵是二級分類函數生成的值:“normal, diet, fat”。最後,第二級map
的值是流中元素構成的List
,是分別應用第一級和第二級分類函數所得到的對應第一級和第二級鍵的值:“salmon、pizza…” 這種多級分組操作可以擴展至任意層級,n 級分組就會得到一個代表 n 級樹形結構的 n 級Map
。
圖6-5顯示了為什麼結構相當於 n 維表格,並強調了分組操作的分類目的。
一般來說,把groupingBy
看作“桶”比較容易明白。第一個groupingBy
給每個鍵建立了一個桶。然後再用下游的收集器去收集每個桶中的元素,以此得到 n 級分組。
圖 6-5 n 層嵌套映射和 n 維分類表之間的等價關係
6.3.2 按子組收集數據
在上一節中,我們看到可以把第二個groupingBy
收集器傳遞給外層收集器來實現多級分組。但進一步說,傳遞給第一個groupingBy
的第二個收集器可以是任何類型,而不一定是另一個groupingBy
。例如,要數一數菜單中每類菜有多少個,可以傳遞counting
收集器作為groupingBy
收集器的第二個參數:
Map<Dish.Type, Long> typesCount = menu.stream.collect(
groupingBy(Dish::getType, counting));
其結果是下面的Map
:
{MEAT=3, FISH=2, OTHER=4}
還要注意,普通的單參數groupingBy(f)
(其中f
是分類函數)實際上是groupingBy(f, toList)
的簡便寫法。
再舉一個例子,你可以把前面用於查找菜單中熱量最高的菜餚的收集器改一改,按照菜的類型分類:
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream
.collect(groupingBy(Dish::getType,
maxBy(comparingInt(Dish::getCalories))));
這個分組的結果顯然是一個map
,以Dish
的類型作為鍵,以包裝了該類型中熱量最高的Dish
的Optional<Dish>
作為值:
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
注意 這個
Map
中的值是Optional
,因為這是maxBy
工廠方法生成的收集器的類型,但實際上,如果菜單中沒有某一類型的Dish
,這個類型就不會對應一個Optional. empty
值,而且根本不會出現在Map
的鍵中。groupingBy
收集器只有在應用分組條件後,第一次在流中找到某個鍵對應的元素時才會把鍵加入分組Map
中。這意味著Optional
包裝器在這裡不是很有用,因為它不會僅僅因為它是歸約收集器的返回類型而表達一個最終可能不存在卻意外存在的值。
1. 把收集器的結果轉換為另一種類型
因為分組操作的Map
結果中的每個值上包裝的Optional
沒什麼用,所以你可能想要把它們去掉。要做到這一點,或者更一般地來說,把收集器返回的結果轉換為另一種類型,你可以使用Collectors.collectingAndThen
工廠方法返回的收集器,如下所示。
代碼清單6-3 查找每個子組中熱量最高的Dish
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream
.collect(groupingBy(Dish::getType, ←─分類函數
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)), ←─包裝後的收集器
Optional::get))); ←─轉換函數
這個工廠方法接受兩個參數——要轉換的收集器以及轉換函數,並返回另一個收集器。這個收集器相當於舊收集器的一個包裝,collect
操作的最後一步就是將返回值用轉換函數做一個映射。在這裡,被包起來的收集器就是用maxBy
建立的那個,而轉換函數Optional::get
則把返回的Optional
中的值提取出來。前面已經說過,這個操作放在這裡是安全的,因為reducing
收集器永遠都不會返回Optional.empty
。其結果是下面的Map
:
{FISH=salmon, OTHER=pizza, MEAT=pork}
把好幾個收集器嵌套起來很常見,它們之間到底發生了什麼可能不那麼明顯。圖6-6可以直觀地展示它們是怎麼工作的。從最外層開始逐層向裡,注意以下幾點。
-
收集器用虛線表示,因此
groupingBy
是最外層,根據菜餚的類型把菜單流分組,得到三個子流。 -
groupingBy
收集器包裹著collectingAndThen
收集器,因此分組操作得到的每個子流都用這第二個收集器做進一步歸約。 -
collectingAndThen
收集器又包裹著第三個收集器maxBy
。 -
隨後由歸約收集器進行子流的歸約操作,然後包含它的
collectingAndThen
收集器會對其結果應用Optional:get
轉換函數。 -
對三個子流分別執行這一過程並轉換而得到的三個值,也就是各個類型中熱量最高的
Dish
,將成為groupingBy
收集器返回的Map
中與各個分類鍵(Dish
的類型)相關聯的值。
2. 與groupingBy
聯合使用的其他收集器的例子
一般來說,通過groupingBy
工廠方法的第二個參數傳遞的收集器將會對分到同一組中的所有流元素執行進一步歸約操作。例如,你還重用求出所有菜餚熱量總和的收集器,不過這次是對每一組Dish
求和:
Map<Dish.Type, Integer> totalCaloriesByType =
menu.stream.collect(groupingBy(Dish::getType,
summingInt(Dish::getCalories)));
然而常常和groupingBy
聯合使用的另一個收集器是mapping
方法生成的。這個方法接受兩個參數:一個函數對流中的元素做變換,另一個則將變換的結果對像收集起來。其目的是在累加之前對每個輸入元素應用一個映射函數,這樣就可以讓接受特定類型元素的收集器適應不同類型的對象。我們來看一個使用這個收集器的實際例子。比方說你想要知道,對於每種類型的Dish
,菜單中都有哪些CaloricLevel
。我們可以把groupingBy
和mapping
收集器結合起來,如下所示:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
menu.stream.collect(
groupingBy(Dish::getType, mapping(
dish -> { if (dish.getCalories <= 400) return CaloricLevel.DIET;
else if (dish.getCalories <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toSet )));
圖 6-6 嵌套收集器來獲得多重效果
這裡,就像我們前面見到過的,傳遞給映射方法的轉換函數將Dish
映射成了它的CaloricLevel
:生成的CaloricLevel
流傳遞給一個toSet
收集器,它和toList
類似,不過是把流中的元素累積到一個Set
而不是List
中,以便僅保留各不相同的值。如先前的示例所示,這個映射收集器將會收集分組函數生成的各個子流中的元素,讓你得到這樣的Map
結果:
{OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}
由此你就可以輕鬆地做出選擇了。如果你想吃魚並且在減肥,那很容易找到一道菜;同樣,如果你飢腸轆轆,想要很多熱量的話,菜單中肉類部分就可以滿足你的饕餮之欲了。請注意在上一個示例中,對於返回的Set
是什麼類型並沒有任何保證。但通過使用toCollection
,你就可以有更多的控制。例如,你可以給它傳遞一個構造函數引用來要求HashSet
:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
menu.stream.collect(
groupingBy(Dish::getType, mapping(
dish -> { if (dish.getCalories <= 400) return CaloricLevel.DIET;
else if (dish.getCalories <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toCollection(HashSet::new) )));
6.4 分區
分區是分組的特殊情況:由一個謂詞(返回一個布爾值的函數)作為分類函數,它稱分區函數。分區函數返回一個布爾值,這意味著得到的分組Map
的鍵類型是Boolean
,於是它最多可以分為兩組——true
是一組,false
是一組。例如,如果你是素食者或是請了一位素食的朋友來共進晚餐,可能會想要把菜單按照素食和非素食分開:
Map<Boolean, List<Dish>> partitionedMenu =
menu.stream.collect(partitioningBy(Dish::isVegetarian)); ←─分區函數
這會返回下面的Map
:
{false=[pork, beef, chicken, prawns, salmon],
true=[french fries, rice, season fruit, pizza]}
那麼通過Map
中鍵為true
的值,就可以找出所有的素食菜餚了:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
請注意,用同樣的分區謂詞,對菜單List
創建的流作篩選,然後把結果收集到另外一個List
中也可以獲得相同的結果:
List<Dish> vegetarianDishes =
menu.stream.filter(Dish::isVegetarian).collect(toList);
6.4.1 分區的優勢
分區的好處在於保留了分區函數返回true
或false
的兩套流元素列表。在上一個例子中,要得到非素食Dish
的List
,你可以使用兩個篩選操作來訪問partitionedMenu
這個Map
中false
鍵的值:一個利用謂詞,一個利用該謂詞的非。而且就像你在分組中看到的,partitioningBy
工廠方法有一個重載版本,可以像下面這樣傳遞第二個收集器:
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
menu.stream.collect(
partitioningBy(Dish::isVegetarian, ←─分區函數
groupingBy(Dish::getType))); ←─第二個收集器
這將產生一個二級Map
:
{false={FISH=[prawns, salmon], MEAT=[pork, beef, chicken]},
true={OTHER=[french fries, rice, season fruit, pizza]}}
這裡,對於分區產生的素食和非素食子流,分別按類型對菜餚分組,得到了一個二級Map
,和6.3.1節的二級分組得到的結果類似。再舉一個例子,你可以重用前面的代碼來找到素食和非素食中熱量最高的菜:
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
menu.stream.collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)),
Optional::get)));
這將產生以下結果:
{false=pork, true=pizza}
我們在本節開始時說過,你可以把分區看作分組一種特殊情況。groupingBy
和partitioningBy
收集器之間的相似之處並不止於此;你在下一個測驗中會看到,還可以按照和6.3.1節中分組類似的方式進行多級分區。
測驗6.2:使用
partitioningBy
我們已經看到,和
groupingBy
收集器類似,partitioningBy
收集器也可以結合其他收集器使用。尤其是它可以與第二個partitioningBy
收集器一起使用來實現多級分區。以下多級分區的結果會是什麼呢?(1)
menu.stream.collect(partitioningBy(Dish::isVegetarian, partitioningBy (d -> d.getCalories > 500)));(2)menu.stream.collect(partitioningBy(Dish::isVegetarian, partitioningBy (Dish::getType)));(3)menu.stream.collect(partitioningBy(Dish::isVegetarian, counting));答案如下。
(1) 這是一個有效的多級分區,產生以下二級
Map
:{ false={false=[chicken, prawns, salmon], true=[pork, beef]}, true={false=[rice, season fruit], true=[french fries, pizza]}}
(2) 這無法編譯,因為
partitioningBy
需要一個謂詞,也就是返回一個布爾值的函數。方法引用Dish::getType
不能用作謂詞。(3) 它會計算每個分區中項目的數目,得到以下
Map
:{false=5, true=4}
作為使用partitioningBy
收集器的最後一個例子,我們把菜單數據模型放在一邊,來看一個更為複雜也更為有趣的例子:將數字分為質數和非質數。
6.4.2 將數字按質數和非質數分區
假設你要寫一個方法,它接受參數int
n,並將前 n 個自然數分為質數和非質數。但首先,找出能夠測試某一個待測數字是否是質數的謂詞會很有幫助:
public boolean isPrime(int candidate) {
return IntStream.range(2, candidate) ←─產生一個自然數範圍,從2開始,直至但不包括待測數
.noneMatch(i -> candidate % i == 0); ←─如果待測數字不能被流中任何數字整除則返回true
}
一個簡單的優化是僅測試小於等於待測數平方根的因子:
public boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
現在最主要的一部分工作已經做好了。為了把前n個數字分為質數和非質數,只要創建一個包含這n個數的流,用剛剛寫的isPrime
方法作為謂詞,再給partitioningBy
收集器歸約就好了:
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed
.collect(
partitioningBy(candidate -> isPrime(candidate)));
}
現在我們已經討論過了Collectors
類的靜態工廠方法能夠創建的所有收集器,並介紹了使用它們的實際例子。表6-1將它們匯總到一起,給出了它們應用到Stream<T>
上返回的類型,以及它們用於一個叫作menuStream
的Stream<Dish>
上的實際例子。
表6-1 Collectors
類的靜態工廠方法
toList
List<T>
把流中所有項目收集到一個List
使用示例:
List<Dish> dishes = menuStream.collect(toList);
toSet
Set<T>
把流中所有項目收集到一個Set
,刪除重複項 使用示例:
Set<Dish> dishes = menuStream.collect(toSet);
toCollection
Collection<T>
把流中所有項目收集到給定的供應源創建的集合 使用示例:
Collection<Dish> dishes = menuStream.collect(toCollection, ArrayList::new);
counting
Long
計算流中元素的個數 使用示例:
long howManyDishes = menuStream.collect(counting);
summingInt
Integer
對流中項目的一個整數屬性求和 使用示例:
int totalCalories = menuStream.collect(summingInt(Dish::getCalories));
averagingInt
Double
計算流中項目Integer
屬性的平均值 使用示例:
double avgCalories = menuStream.collect(averagingInt(Dish::getCalories));
summarizingInt
IntSummaryStatistics
收集關於流中項目Integer
屬性的統計值,例如最大、最小、總和與平均值 使用示例:
IntSummaryStatistics menuStatistics = menuStream.collect(summarizingInt(Dish::getCalories));
joining\`
String
連接對流中每個項目調用toString
方法所生成的字符串 使用示例:
String shortMenu = menuStream.map(Dish::getName).collect(joining(", "));
maxBy
Optional<T>
一個包裹了流中按照給定比較器選出的最大元素的Optional
,或如果流為空則為Optional.empty
使用示例:
Optional<Dish> fattest = menuStream.collect(maxBy(comparingInt(Dish::getCalories)));
minBy
Optional<T>
一個包裹了流中按照給定比較器選出的最小元素的Optional
,或如果流為空則為Optional.empty
使用示例:
Optional<Dish> lightest = menuStream.collect(minBy(comparingInt(Dish::getCalories)));
reducing
歸約操作產生的類型從一個作為累加器的初始值開始,利用BinaryOperator
與流中的元素逐個結合,從而將流歸約為單個值 使用示例:
int totalCalories = menuStream.collect(reducing(0, Dish::getCalories, Integer::sum));
collectingAndThen
轉換函數返回的類型包裹另一個收集器,對其結果應用轉換函數 使用示例:
int howManyDishes = menuStream.collect(collectingAndThen(toList, List::size));
groupingBy
Map<K, List<T>>
根據項目的一個屬性的值對流中的項目作問組,並將屬性值作為結果`Map`的鍵 使用示例:
Map<Dish.Type,List<Dish>> dishesByType = menuStream.collect(groupingBy(Dish::getType));
partitioningBy
Map<Boolean,List<T>>
根據對流中每個項目應用謂詞的結果來對項目進行分區 使用示例:
Map<Boolean,List<Dish>> vegetarianDishes = menuStream.collect(partitioningBy(Dish::isVegetarian));
本章開頭提到過,所有這些收集器都是對Collector
接口的實現,因此我們會在本章剩餘部分中詳細討論這個接口。我們會看看這個接口中的方法,然後探討如何實現你自己的收集器。
6.5 收集器接口
Collector
接口包含了一系列方法,為實現具體的歸約操作(即收集器)提供了範本。我們已經看過了Collector
接口中實現的許多收集器,例如toList
或groupingBy
。這也意味著,你可以為Collector
接口提供自己的實現,從而自由地創建自定義歸約操作。在6.6節中,我們將展示如何實現Collector
接口來創建一個收集器,來比先前更高效地將數值流劃分為質數和非質數。
要開始使用Collector
接口,我們先看看本章開始時講到的一個收集器——toList
工廠方法,它會把流中的所有元素收集成一個List
。我們當時說在日常工作中經常會用到這個收集器,而且它也是寫起來比較直觀的一個,至少理論上如此。通過仔細研究這個收集器是怎麼實現的,我們可以很好地瞭解Collector
接口是怎麼定義的,以及它的方法所返回的函數在內部是如何為collect
方法所用的。
首先讓我們在下面的列表中看看Collector
接口的定義,它列出了接口的簽名以及聲明的五個方法。
代碼清單6-4 Collector
接口
public interface Collector<T, A, R> {
Supplier<A> supplier;
BiConsumer<A, T> accumulator;
Function<A, R> finisher;
BinaryOperator<A> combiner;
Set<Characteristics> characteristics;
}
本列表適用以下定義。
-
T
是流中要收集的項目的泛型。 -
A
是累加器的類型,累加器是在收集過程中用於累積部分結果的對象。 -
R
是收集操作得到的對象(通常但並不一定是集合)的類型。
例如,你可以實現一個ToListCollector<T>
類,將Stream<T>
中的所有元素收集到一個List<T>
裡,它的簽名如下:
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
我們很快就會澄清,這裡用於累積的對象也將是收集過程的最終結果。
6.5.1 理解Collector
接口聲明的方法
現在我們可以一個個來分析Collector
接口聲明的五個方法了。通過分析,你會注意到,前四個方法都會返回一個會被collect
方法調用的函數,而第五個方法characteristics
則提供了一系列特徵,也就是一個提示列表,告訴collect
方法在執行歸約操作的時候可以應用哪些優化(比如並行化)。
1. 建立新的結果容器:supplier
方法
supplier
方法必須返回一個結果為空的Supplier
,也就是一個無參數函數,在調用時它會創建一個空的累加器實例,供數據收集過程使用。很明顯,對於將累加器本身作為結果返回的收集器,比如我們的ToListCollector
,在對空流執行操作的時候,這個空的累加器也代表了收集過程的結果。在我們的ToListCollector
中,supplier
返回一個空的List
,如下所示:
public Supplier<List<T>> supplier {
return -> new ArrayList<T>;
}
請注意你也可以只傳遞一個構造函數引用:
public Supplier<List<T>> supplier {
return ArrayList::new;
}
2. 將元素添加到結果容器:accumulator
方法
accumulator方法會返回執行歸約操作的函數。當遍歷到流中第 n 個元素時,這個函數執行時會有兩個參數:保存歸約結果的累加器(已收集了流中的前 n-1 個項目),還有第 n 個元素本身。該函數將返回void
,因為累加器是原位更新,即函數的執行改變了它的內部狀態以體現遍歷的元素的效果。對於ToListCollector
,這個函數僅僅會把當前項目添加至已經遍歷過的項目的列表:
public BiConsumer<List<T>, T> accumulator {
return (list, item) -> list.add(item);
}
你也可以使用方法引用,這會更為簡潔:
public BiConsumer<List<T>, T> accumulator {
return List::add;
}
3. 對結果容器應用最終轉換:finisher
方法
在遍歷完流後,finisher
方法必須返回在累積過程的最後要調用的一個函數,以便將累加器對像轉換為整個集合操作的最終結果。通常,就像ToListCollector
的情況一樣,累加器對像恰好符合預期的最終結果,因此無需進行轉換。所以finisher
方法只需返回identity
函數:
public Function<List<T>, List<T>> finisher {
return Function.identity;
}
這三個方法已經足以對流進行順序歸約,至少從邏輯上看可以按圖6-7進行。實踐中的實現細節可能還要複雜一點,一方面是因為流的延遲性質,可能在collect
操作之前還需要完成其他中間操作的流水線,另一方面則是理論上可能要進行並行歸約。
圖 6-7 順序歸約過程的邏輯步驟
4. 合併兩個結果容器:combiner
方法
四個方法中的最後一個——combiner
方法會返回一個供歸約操作使用的函數,它定義了對流的各個子部分進行並行處理時,各個子部分歸約所得的累加器要如何合併。對於toList
而言,這個方法的實現非常簡單,只要把從流的第二個部分收集到的項目列表加到遍歷第一部分時得到的列表後面就行了:
public BinaryOperator<List<T>> combiner {
return (list1, list2) -> {
list1.addAll(list2);
return list1; }
}
有了這第四個方法,就可以對流進行並行歸約了。它會用到Java 7中引入的分支/合併框架和Spliterator
抽像,我們會在下一章中講到。這個過程類似於圖6-8所示,這裡會詳細介紹。
-
原始流會以遞歸方式拆分為子流,直到定義流是否需要進一步拆分的一個條件為非(如果分佈式工作單位太小,並行計算往往比順序計算要慢,而且要是生成的並行任務比處理器內核數多很多的話就毫無意義了)。
-
現在,所有的子流都可以並行處理,即對每個子流應用圖6-7所示的順序歸約算法。
-
最後,使用收集器
combiner
方法返回的函數,將所有的部分結果兩兩合併。這時會把原始流每次拆分時得到的子流對應的結果合併起來。
圖 6-8 使用combiner
方法來並行化歸約過程
5. characteristics
方法
最後一個方法——characteristics
會返回一個不可變的Characteristics
集合,它定義了收集器的行為——尤其是關於流是否可以並行歸約,以及可以使用哪些優化的提示。Characteristics
是一個包含三個項目的枚舉。
-
UNORDERED
——歸約結果不受流中項目的遍歷和累積順序的影響。 -
CONCURRENT
——accumulator
函數可以從多個線程同時調用,且該收集器可以並行歸約流。如果收集器沒有標為