讀古今文學網 > Java 8實戰 > 第6章 用流收集數據 >

第6章 用流收集數據

本章內容

  • Collectors類創建和使用收集器

  • 將數據流歸約為一個值

  • 匯總:歸約的特殊情況

  • 數據分組和分區

  • 開發自己的自定義收集器

我們在前一章中學到,流可以用類似於數據庫的操作幫助你處理集合。你可以把Java 8的流看作花哨又懶惰的數據集迭代器。它們支持兩種類型的操作:中間操作(如filtermap)和終端操作(如countfindFirstforEachreduce)。中間操作可以鏈接起來,將一個流轉換為另一個流。這些操作不會消耗流,其目的是建立一個流水線。與此相反,終端操作會消耗流,以產生一個最終結果,例如返回流中的最大元素。它們通常可以通過優化流水線來縮短計算時間。

我們已經在第4章和第5章中用過collect終端操作了,當時主要是用來把Stream中所有的元素結合成一個List。在本章中,你會發現collect是一個歸約操作,就像reduce一樣可以接受各種做法作為參數,將流中的元素累積成一個匯總結果。具體的做法是通過定義新的Collector接口來定義的,因此區分CollectionCollectorcollect是很重要的。

下面是一些查詢的例子,看看你用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程序員,寫這種東西可能挺順手的,不過你必須承認,做這麼簡單的一件事就得寫很多代碼。更糟糕的是,讀起來比寫起來更費勁!代碼的目的並不容易看出來,儘管換作白話的話是很直截了當的:“把列表中的交易按貨幣分組。”你在本章中會學到,用Streamcollect方法的一個更通用的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.maxByCollectors.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.summingLongCollectors.summingDouble方法的作用完全一樣,可以用於求和字段為longdouble的情況。

圖 6-2 summingInt收集器的累積過程

但匯總不僅僅是求和;還有Collectors.averagingInt,連同對應的averagingLongaveragingDouble可以計算數值的平均數:

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}

  

同樣,相應的summarizingLongsummarizingDouble工廠方法有相關的LongSummaryStatisticsDoubleSummaryStatistics類型,適用於收集的屬性是原始類型longdouble的情況。

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接口的collectreduce方法有何不同,因為兩種方法通常會獲得相同的結果。例如,你可以像下面這樣使用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工廠方法實現的。它把流中的每個元素都轉換成一個值為1Long型對象,然後再把它們相加:

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章還會進一步瞭解到,一般來說,使用允許提供默認值的方法,如orElseorElseGet來解開Optional中包含的值更為安全。最後,更簡潔的方法是把流映射到一個IntStream,然後調用sum方法,你也可以得到相同的結果:

int totalCalories = menu.stream.mapToInt(Dish::getCalories).sum;

  

2. 根據情況選擇最佳解決方案

這再次說明了,函數式編程(特別是Java 8的Collections框架中加入的基於函數式風格原理設計的新API)通常提供了多種方法來執行同一個操作。這個例子還說明,收集器在某種程度上比Stream接口上直接提供的方法用起來更複雜,但好處在於它們能提供更高水平的抽像和概括,也更容易重用和自定義。

我們的建議是,盡可能為手頭的問題探索不同的解決方案,但在通用的方案裡面,始終選擇最專門化的一個。無論是從可讀性還是性能上看,這一般都是最好的決定。例如,要計菜單的總熱量,我們更傾向於最後一個解決方案(使用IntStream),因為它最簡明,也很可能最易讀。同時,它也是性能最好的一個,因為IntStream可以讓我們避免自動拆箱操作,也就是從Integerint的隱式轉換,它在這裡毫無用處。

接下來,請看看測驗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(以方法引用的形式),它提取了流中每一道DishDish.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 級樹形結構的 nMap

圖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的類型作為鍵,以包裝了該類型中熱量最高的DishOptional<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。我們可以把groupingBymapping收集器結合起來,如下所示:

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 分區的優勢

分區的好處在於保留了分區函數返回truefalse的兩套流元素列表。在上一個例子中,要得到非素食DishList,你可以使用兩個篩選操作來訪問partitionedMenu這個Mapfalse鍵的值:一個利用謂詞,一個利用該謂詞的非。而且就像你在分組中看到的,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}

  

我們在本節開始時說過,你可以把分區看作分組一種特殊情況。groupingBypartitioningBy收集器之間的相似之處並不止於此;你在下一個測驗中會看到,還可以按照和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>上返回的類型,以及它們用於一個叫作menuStreamStream<Dish>上的實際例子。

表6-1 Collectors類的靜態工廠方法

工廠方法返回類型用於 toListList<T>把流中所有項目收集到一個List 使用示例:
List<Dish> dishes = menuStream.collect(toList);  
toSetSet<T>把流中所有項目收集到一個Set,刪除重複項 使用示例:
Set<Dish> dishes = menuStream.collect(toSet);  
toCollectionCollection<T>把流中所有項目收集到給定的供應源創建的集合 使用示例:
Collection<Dish> dishes = menuStream.collect(toCollection,                                            ArrayList::new);  
countingLong計算流中元素的個數 使用示例:
long howManyDishes = menuStream.collect(counting);  
summingIntInteger對流中項目的一個整數屬性求和 使用示例:
int totalCalories =    menuStream.collect(summingInt(Dish::getCalories));  
averagingIntDouble計算流中項目Integer屬性的平均值 使用示例:
double avgCalories =    menuStream.collect(averagingInt(Dish::getCalories));  
summarizingIntIntSummaryStatistics收集關於流中項目Integer屬性的統計值,例如最大、最小、總和與平均值 使用示例:
IntSummaryStatistics menuStatistics =    menuStream.collect(summarizingInt(Dish::getCalories));  
joining\`String連接對流中每個項目調用toString方法所生成的字符串 使用示例:
String shortMenu =    menuStream.map(Dish::getName).collect(joining(", "));  
maxByOptional<T>一個包裹了流中按照給定比較器選出的最大元素的Optional,或如果流為空則為Optional.empty 使用示例:
Optional<Dish> fattest =    menuStream.collect(maxBy(comparingInt(Dish::getCalories)));  
minByOptional<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));  
groupingByMap<K, List<T>>根據項目的一個屬性的值對流中的項目作問組,並將屬性值作為結果`Map`的鍵 使用示例:
Map<Dish.Type,List<Dish>> dishesByType =    menuStream.collect(groupingBy(Dish::getType));  
partitioningByMap<Boolean,List<T>>根據對流中每個項目應用謂詞的結果來對項目進行分區 使用示例:
Map<Boolean,List<Dish>> vegetarianDishes =    menuStream.collect(partitioningBy(Dish::isVegetarian));  

本章開頭提到過,所有這些收集器都是對Collector接口的實現,因此我們會在本章剩餘部分中詳細討論這個接口。我們會看看這個接口中的方法,然後探討如何實現你自己的收集器。

6.5 收集器接口

Collector接口包含了一系列方法,為實現具體的歸約操作(即收集器)提供了範本。我們已經看過了Collector接口中實現的許多收集器,例如toListgroupingBy。這也意味著,你可以為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函數可以從多個線程同時調用,且該收集器可以並行歸約流。如果收集器沒有標為