用流收集資料
我們在前一章中學到,流可以用類似於資料庫的操作幫助你處理集合。你可以把Java 8的流看作花哨又懶惰的資料集迭代器。它們支援兩種型別的操作:中間操作(如 filter 或 map )和終端操作(如 count 、 findFirst 、 forEach 和 reduce )。中間操作可以連結起來,將一個流轉換為另一個流。這些操作不會消耗流,其目的是建立一個流水線。與此相反,終端操作會消耗流,以產生一個最終結果,例如返回流中的最大元素。它們通常可以通過優化流水線來縮短計算時間。
我們已經在前面用過了 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裡,哪怕像這種簡單的用例實現起來都很囉嗦,就像下面這樣:
// 建立累積交易分組的Map
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>(16);
// 迭代 Transaction 的 List
for (Transaction transaction : transactions) {
// 提取 Transaction的貨幣
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
// 如果分組 Map 中沒有這種貨幣的條目,就建立一個
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
// 將當前遍歷的 Transaction加入同一貨幣的 Transaction 的 List
transactionsForCurrency.add(transaction);
}
System.out.println(transactionsByCurrencies);
如果你是一位經驗豐富的Java程式設計師,寫這種東西可能挺順手的,不過你必須承認,做這麼簡單的一件事就得寫很多程式碼。更糟糕的是,讀起來比寫起來更費勁!程式碼的目的並不容易看出來,儘管換作白話的話是很直截了當的:“把列表中的交易按貨幣分組。”你在本章中會學到,用Stream 中 collect 方法的一個更通用的 Collector 引數,你就可以用一句話實現完全相同的結果,而用不著使用上一章那個 toList 的特殊情況了:
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream().collect(groupingBy(Transaction::getCurrency));
這一比差得還真多,對吧?
收集器簡介
前一個例子清楚地展示了函數語言程式設計相對於指令式程式設計的一個主要優勢:你只需指出希望的結果——“做什麼”,而不用操心執行的步驟——“如何做”。在上一個例子裡,傳遞給 collect方法的引數是 Collector 介面的一個實現,也就是給 Stream 中元素做彙總的方法。上一章裡的toList 只是說“按順序給每個元素生成一個列表”;在本例中, groupingBy 說的是“生成一個Map ,它的鍵是(貨幣)桶,值則是桶中那些元素的列表”。要是做多級分組,指令式和函式式之間的區別就會更加明顯:由於需要好多層巢狀迴圈和條件,指令式程式碼很快就變得更難閱讀、更難維護、更難修改。
收集器用作高階歸約
剛剛的結論又引出了優秀的函式式API設計的另一個好處:更易複合和重用。收集器非常有用,因為用它可以簡潔而靈活地定義collect用來生成結果集合的標準。更具體地說,對流呼叫collect方法將對流中的元素觸發一個歸約操作(由Collector來引數化)。一般來說, Collector 會對元素應用一個轉換函式(很多時候是不體現任何效果的恆等轉換,例如 toList ),並將結果累積在一個資料結構中,從而產生這一過程的最終輸出。例如,在前面所示的交易分組的例子中,轉換函式提取了每筆交易的貨幣,隨後使用貨幣作為鍵,將交易本身累積在生成的 Map 中。
歸約和彙總
為了說明從 Collectors 工廠類中能建立出多少種收集器例項,我們重用一下前一章的例子:包含一張佳餚列表的選單!就像你剛剛看到的,在需要將流專案重組成集合時,一般會使用收集器( Stream 方法 collect的引數)。再寬泛一點來說,但凡要把流中所有的專案合併成一個結果時就可以用。這個結果可以是任何型別,可以複雜如代表一棵樹的多級對映,或是簡單如一個整數——也許代表了選單的熱量總和。
我們先來舉一個簡單的例子,利用 counting 工廠方法返回的收集器,數一數選單裡有多少
種菜:
long howManyDishes = menu.stream().collect(Collectors.counting());
這還可以寫得更為直接:
long howManyDishes = menu.stream().count();
counting 收集器在和其他收集器聯合使用的時候特別有用,後面會談到這一點。
查詢流中的最大值和最小值
假設你想要找出選單中熱量最高的菜。你可以使用兩個收集器, Collectors.maxBy和Collectors.minBy ,來計算流中的最大或最小值。這兩個收集器接收一個 Comparator 引數來
比較流中的元素。你可以建立一個 Comparator來根據所含熱量對菜餚進行比較,並把它傳遞給
Collectors.maxBy :
List<Dish> menu = Dish.MENU;
Comparator<Dish> dishCaloriesComparator =
Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish =
menu.stream().max(dishCaloriesComparator);
System.out.println(mostCalorieDish.get());
你可能在想 Optional<Dish> 是怎麼回事。要回答這個問題,我們需要問“要是 menu 為空怎麼辦”。那就沒有要返回的菜了!Java 8引入了 Optional ,它是一個容器,可以包含也可以不包含值。這裡它完美地代表了可能也可能不返回菜餚的情況。
另一個常見的返回單個值的歸約操作是對流中物件的一個數值欄位求和。或者你可能想要求平均數。這種操作被稱為彙總操作。讓我們來看看如何使用收集器來表達彙總操作。
彙總
Collectors 類專門為彙總提供了一個工廠方法: Collectors.summingInt 。它可接受一個把物件對映為求和所需 int 的函式,並返回一個收集器;該收集器在傳遞給普通的 collect 方法後即執行我們需要的彙總操作。舉個例子來說,你可以這樣求出選單列表的總熱量:
List<Dish> menu = Dish.MENU;
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
除了Collectors.summingInt,還有Collectors.summingLong 和Collectors.summingDouble 方法的作用完全一樣,可以用於求和欄位為 long 或 double 的情況。
但彙總不僅僅是求和;還有 Collectors.averagingInt ,連同對應的 averagingLong 和
averagingDouble 可以計算數值的平均數:
List<Dish> menu = Dish.MENU;
double avgCalories =
menu.stream().collect(averagingInt(Dish::getCalories));
到目前為止,你已經看到了如何使用收集器來給流中的元素計數,找到這些元素數值屬性的最大值和最小值,以及計算其總和和平均值。不過很多時候,你可能想要得到兩個或更多這樣的結果,而且你希望只需一次操作就可以完成。在這種情況下,你可以使用 summarizingInt 工廠方法返回的收集器。例如,通過一次 summarizing 操作你可以就數出選單中元素的個數,並得到菜餚熱量總和、平均值、最大值和最小值:
List<Dish> menu = Dish.MENU;
IntSummaryStatistics menuStatistics =
menu.stream().collect(summarizingInt(Dish::getCalories));
System.out.println(menuStatistics.getMax());
System.out.println(menuStatistics.getAverage());
System.out.println(menuStatistics.getMin());
System.out.println(menuStatistics.getCount());
System.out.println(menuStatistics.getSum());
同樣,相應的 summarizingLong 和 summarizingDouble 工廠方法有相關的LongSummaryStatistics 和 DoubleSummaryStatistics 型別,適用於收集的屬性是原始型別 long 或double 的情況。
連線字串
joining 工廠方法返回的收集器會把對流中每一個物件應用 toString 方法得到的所有字元
串連線成一個字串。這意味著你把選單中所有菜餚的名稱連線起來,如下所示:
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
請注意, joining 在內部使用了 StringBuilder 來把生成的字串逐個追加起來。結果:
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 工廠方法提供的更廣義歸約收集器的特殊情況。
廣義的歸約彙總
事實上,我們已經討論的所有收集器,都是一個可以用 reducing 工廠方法定義的歸約過程的特殊情況而已。 Collectors.reducing 工廠方法是所有這些特殊情況的一般化。可以說,先前討論的案例僅僅是為了方便程式設計師而已。(但是,請記得方便程式設計師和可讀性是頭等大事!)例如,可以用 reducing 方法建立的收集器來計算你選單的總熱量,如下所示:
List<Dish> menu = Dish.MENU;
int totalCalories = menu.stream().collect(reducing(
0, Dish::getCalories, (i, j) -> i + j));
System.out.println(totalCalories);
它需要三個引數:
- 第一個引數是歸約操作的起始值,也是流中沒有元素時的返回值,所以很顯然對於數值和而言0是一個合適的值。
- 第二個引數是Lambda的語法糖,將菜餚轉換成一個表示其所含熱量的 int 。
- 第三個引數是一個 BinaryOperator ,將兩個專案累積成一個同型別的值。這裡它就是
對兩個 int 求和。
同樣,你可以使用下面這樣單引數形式的 reducing 來找到熱量最高的菜,如下所示:
Optional<Dish> mostCalorieDish =
menu.stream().collect(reducing(
(d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
你可以把單引數 reducing 工廠方法建立的收集器看作三引數方法的特殊情況,它把流中的第一個專案作為起點,把恆等函式(即一個函式僅僅是返回其輸入引數)作為一個轉換函式。
收集框架的靈活性:以不同的方法執行同樣的操作
你還可以進一步簡化前面使用 reducing 收集器的求和例子——引用 Integer 類的 sum 方法,而不用去寫一個表達同一操作的Lambda表示式。這會得到以下程式:
int totalCalories2 = menu.stream()
.collect(reducing(0, // 初始值
Dish::getCalories, // 轉換函式
Integer::sum)); // 積累函式
使用語法糖,能幫助我們簡化一部分程式碼。
還有另外一種方法不使用收集器也能執行相同操作——將菜餚流對映為每一道菜的熱量,然後用前一個版本中使用的方法引用來歸約得到的流:
int totalCalories =
menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();
請注意,就像流的任何單引數 reduce 操作一樣, reduce(Integer::sum) 返回的不是 int而是 Optional<Integer> ,以便在空流的情況下安全地執行歸約操作。然後你只需用 Optional物件中的 get 方法來提取裡面的值就行了。請注意,在這種情況下使用 get 方法是安全的,只是因為你已經確定菜餚流不為空。一般來說,使用允許提供預設值的方法,如 orElse 或 orElseGet來解開Optional中包含的值更為安全。最後,更簡潔的方法是把流對映到一個 IntStream ,然後呼叫 sum 方法,你也可以得到相同的結果:
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();
根據情況選擇最佳解決方案
這再次說明了,函數語言程式設計(特別是Java 8的 Collections 框架中加入的基於函式式風格原理設計的新API)通常提供了多種方法來執行同一個操作。這個例子還說明,收集器在某種程度上比Stream 介面上直接提供的方法用起來更復雜,但好處在於它們能提供更高水平的抽象和概括,也更容易重用和自定義。在《Java8實戰》中的的建議是,儘可能為手頭的問題探索不同的解決方案,但在通用的方案裡面,始終選擇最專門化的一個。無論是從可讀性還是效能上看,這一般都是最好的決定。例如,要計選單的總熱量,我們更傾向於最後一個解決方案(使用 IntStream ),因為它最簡明,也很可能最易讀。同時,它也是效能最好的一個,因為 IntStream 可以讓我們避免自動拆箱操作,也就是從Integer到int的隱式轉換,它在這裡毫無用處。
分組
一個常見的資料庫操作是根據一個或多個屬性對集合中的專案進行分組。就像前面講到按貨幣對交易進行分組的例子一樣,如果用指令式風格來實現的話,這個操作可能會很麻煩、囉嗦而且容易出錯。但是,如果用Java 8所推崇的函式式風格來重寫的話,就很容易轉化為一個非常容易看懂的語句。我們來看看這個功能的第二個例子:假設你要把選單中的菜按照型別進行分類,有肉的放一組,有魚的放一組,其他的都放另一組。用 Collectors.groupingBy 工廠方法返回的收集器就可以輕鬆地完成這項任務,如下所示:
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(groupingBy(Dish::getType));
其結果是下面的 Map:
{OTHER=[Dish{name=`french fries`}, Dish{name=`rice`}, Dish{name=`season fruit`}, Dish{name=`pizza`}], MEAT=[Dish{name=`pork`}, Dish{name=`beef`}, Dish{name=`chicken`}], FISH=[Dish{name=`prawns`}, Dish{name=`salmon`}]}
這裡,你給 groupingBy 方法傳遞了一個 Function (以方法引用的形式),它提取了流中每一道 Dish 的 Dish.Type 。我們把這個 Function 叫作分類函式,因為它用來把流中的元素分成不同的組。分組操作的結果是一個 Map ,把分組函式返回的值作為對映的鍵,把流中所有具有這個分類值的專案的列表作為對應的對映值。在選單分類的例子中,鍵就是菜的型別,值就是包含所有對應型別的菜餚的列表。
但是,分類函式不一定像方法引用那樣可用,因為你想用以分類的條件可能比簡單的屬性訪問器要複雜。例如,你可能想把熱量不到400卡路里的菜劃分為“低熱量”(diet),熱量400到700卡路里的菜劃為“普通”(normal),高於700卡路里的劃為“高熱量”(fat)。由於 Dish 類的作者沒有把這個操作寫成一個方法,你無法使用方法引用,但你可以把這個邏輯寫成Lambda表示式:
public enum CaloricLevel {
/**
* 卡路里等級
*/
DIET, NORMAL, FAT
}
Map<Dish.CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) {
return Dish.CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return Dish.CaloricLevel.NORMAL;
} else {
return Dish.CaloricLevel.FAT;
}
}));
多級分組
要實現多級分組,我們可以使用一個由雙引數版本的 Collectors.groupingBy 工廠方法建立的收集器,它除了普通的分類函式之外,還可以接受 collector 型別的第二個引數。那麼要進行二級分組的話,我們可以把一個內層 groupingBy 傳遞給外層groupingBy ,並定義一個為流中專案分類的二級標準。
Map<Dish.Type, Map<Dish.CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType,
groupingBy(dish -> {
if (dish.getCalories() <= 400) {
return Dish.CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return Dish.CaloricLevel.NORMAL;
} else {
return Dish.CaloricLevel.FAT;
}
})
)
);
這個二級分組的結果就是像下面這樣的兩級 Map :
{OTHER={DIET=[Dish{name=`rice`}, Dish{name=`season fruit`}], NORMAL=[Dish{name=`french fries`}, Dish{name=`pizza`}]}, MEAT={DIET=[Dish{name=`chicken`}], FAT=[Dish{name=`pork`}], NORMAL=[Dish{name=`beef`}]}, FISH={DIET=[Dish{name=`prawns`}], NORMAL=[Dish{name=`salmon`}]}}
這裡的外層 Map 的鍵就是第一級分類函式生成的值:“fish, meat, other”,而這個 Map 的值又是一個 Map ,鍵是二級分類函式生成的值:“normal, diet, fat”。最後,第二級 map 的值是流中元素構成的 List ,是分別應用第一級和第二級分類函式所得到的對應第一級和第二級鍵的值:“salmon、pizza…” 這種多級分組操作可以擴充套件至任意層級,n級分組就會得到一個代表n級樹形結構的n級Map 。
一般來說,把 groupingBy 看作“桶”比較容易明白。第一個 groupingBy 給每個鍵建立了一個桶。然後再用下游的收集器去收集每個桶中的元素,以此得到n級分組。
按子組收集資料
在上一節中,我們看到可以把第二個 groupingBy 收集器傳遞給外層收集器來實現多級分組。但進一步說,傳遞給第一個 groupingBy 的第二個收集器可以是任何型別,而不一定是另一個 groupingBy 。例如,要數一數選單中每類菜有多少個,可以傳遞 counting 收集器作為groupingBy 收集器的第二個引數:
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
其結果是下面的 Map :
{OTHER=4, MEAT=3, FISH=2}
還要注意,普通的單引數 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> 作為值:
{OTHER=Optional[Dish{name=`pizza`}], MEAT=Optional[Dish{name=`pork`}], FISH=Optional[Dish{name=`salmon`}]}
把收集器的結果轉換為另一種型別
因為分組操作的 Map 結果中的每個值上包裝的 Optional 沒什麼用,所以你可能想要把它們去掉。要做到這一點,或者更一般地來說,把收集器返回的結果轉換為另一種型別,你可以使用Collectors.collectingAndThen 工廠方法返回的收集器,如下所示。
查詢每個子組中熱量最高的 Dish:
List<Dish> menu = Dish.MENU;
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 :
{OTHER=Dish{name=`pizza`}, MEAT=Dish{name=`pork`}, FISH=Dish{name=`salmon`}}
把好幾個收集器巢狀起來很常見,它們之間到底發生了什麼可能不那麼明顯。從最外層開始逐層向裡,注意以下幾點:
- 收集器用虛線表示,因此 groupingBy 是最外層,根據菜餚的型別把選單流分組,得到三個子流。
- groupingBy 收集器包裹著 collectingAndThen 收集器,因此分組操作得到的每個子流都用這第二個收集器做進一步歸約。
- collectingAndThen 收集器又包裹著第三個收集器 maxBy 。
- 隨後由歸約收集器進行子流的歸約操作,然後包含它的 collectingAndThen 收集器會對其結果應用 Optional:get 轉換函式。
- 對三個子流分別執行這一過程並轉換而得到的三個值,也就是各個型別中熱量最高的Dish ,將成為 groupingBy 收集器返回的 Map 中與各個分類鍵( Dish 的型別)相關聯的值。
與 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<Dish.CaloricLevel>> caloricLevelsByType =
menu.stream().collect(
groupingBy(Dish::getType, mapping(
dish -> {
if (dish.getCalories() <= 400) {
return Dish.CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return Dish.CaloricLevel.NORMAL;
} else {
return Dish.CaloricLevel.FAT;
}
},
toSet())));
傳遞給對映方法的轉換函式將 Dish 對映成了它的CaloricLevel :生成的CaloricLevel 流傳遞給一個 toSet 收集器,它和 toList 類似,不過是把流中的元素累積到一個 Set 而不是 List 中,以便僅保留各不相同的值。如先前的示例所示,這個對映收集器將會收集分組函式生成的各個子流中的元素,讓你得到這樣的 Map 結果:
{OTHER=[DIET, NORMAL], MEAT=[DIET, FAT, NORMAL], FISH=[DIET, NORMAL]}
由此你就可以輕鬆地做出選擇了。如果你想吃魚並且在減肥,那很容易找到一道菜;同樣,如果你飢腸轆轆,想要很多熱量的話,選單中肉類部分就可以滿足你的饕餮之慾了。請注意在上一個示例中,對於返回的 Set 是什麼型別並沒有任何保證。但通過使用 toCollection ,你就可以有更多的控制。例如,你可以給它傳遞一個建構函式引用來要求 HashSet :
Map<Dish.Type, Set<Dish.CaloricLevel>> caloricLevelsByType =
menu.stream().collect(
groupingBy(Dish::getType, mapping(
dish -> {
if (dish.getCalories() <= 400) {
return Dish.CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return Dish.CaloricLevel.NORMAL;
} else {
return Dish.CaloricLevel.FAT;
}
},
toCollection(HashSet::new))));
使用流收集資料這一章,內容是比較多的,使用分組等特效能幫助我們簡化很大一部分的工作,從而提高我們的開發效率。
程式碼
Github:chap6
Gitee:chap6