《Java8实战》笔记(06):用流收集数据
文章目錄
- 收集器簡介
- 收集器用作高級歸約
- 預定義收集器
- 歸約和匯總
- 查找流中的最大值和最小值
- 匯總
- 連接字符串
- 廣義的歸約匯總
- Stream接口的collect和reduce有何不同
- 收集框架的靈活性:以不同的方法執行同樣的操作
- 根據情況選擇最佳解決方案
- 用reducing連接字符串
- 分組
- 多級分組
- 按子組收集數組
- 把收集器的結果轉換為另一種類型
- 與groupingBy聯合使用的其他收集器的例子
- 分區
- 分區的優勢
- 將數字按質數和非質數分區
- Collectors類的靜態工廠方法
- 收集器接口
- 理解Collector 接口聲明的方法
- 建立新的結果容器:supplier方法
- 將元素添加到結果容器:accumulator方法
- 對結果容器應用最終轉換:finisher方法
- 合并兩個結果容器:combiner方法
- characteristics方法
- 全部融合到一起
- 進行自定義收集而不去實現Collector
- 開發你自己的收集器以獲得更好的性能
- 僅用質數做除數
- 第一步:定義Collector類的簽名
- 第二步:實現歸約過程
- 第三步:讓收集器并行工作(如果可能)
- 第四步:finisher方法和收集器的characteristics方法
- 比較收集器的性能
- 小結
你會發現collect是一個歸約操作,就像reduce一樣可以接受各種做法作為參數,將流中的元素累積成一個匯總結果。
具體的做法是通過定義新的Collector接口來定義的,因此區分Collection、Collector和collect是很重要的。
下面是一些查詢的例子,看看你用collect和收集器能夠做什么。
- 對一個交易列表按貨幣分組,獲得該貨幣的所有交易額總和(返回一個Map<Currency,
Integer>)。 - 將交易列表分成兩組:貴的和不貴的(返回一個Map<Boolean, List<Transaction>>)。
- 創建多級分組,比如按城市對交易分組,然后進一步按照貴或不貴分組(返回一個Map<Boolean, List<Transaction>>)。
Java8之前:用指令式風格對交易按照貨幣分組
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();for (Transaction transaction : transactions) {Currency currency = transaction.getCurrency();List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);if (transactionsForCurrency == null) {transactionsForCurrency = new ArrayList<>();transactionsByCurrencies.put(currency, transactionsForCurrency);}transactionsForCurrency.add(transaction); }Java8之后
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream().collect(groupingBy(Transaction::getCurrency));GroupingTransactions
收集器簡介
函數式編程相對于指令式編程的一個主要優勢:你只需指出希望的結果——“做什么”,而不用操心執行的步驟——“如何做”。
收集器用作高級歸約
優秀的函數式API設計的另一個好處:更易復合和重用。收集器非常有用,因為用它可以簡潔而靈活地定義collect用來生成結果集合的標準。更具體地說,對流調用collect方法將對流中的元素觸發一個歸約操作(由Collector來參數化)。
一般來說,Collector會對元素應用一個轉換函數(很多時候是不體現任何效果的恒等轉換,例如toList),并將結果累積在一個數據結構中,從而產生這一過程的最終輸出。例如,在前面所示的交易分組的例子中,轉換函數提取了每筆交易的貨幣,隨后使用貨幣作為鍵,將交易本身累積在生成的Map中
如貨幣的例子中所示,Collector接口中方法的實現決定了如何對流執行歸約操作。但Collectors實用類提供了很多靜態工廠方法,可以方便地創建常見收集器的實例,只要拿來用就可以了。
最直接和最常用的收集器是toList靜態方法,它會把流中所有的元素收集到一個List中:
List<Transaction> transactions = transactionStream.collect(Collectors.toList());預定義收集器
預定義收集器的功能,也就是那些可以從Collectors類提供的工廠方法(例如groupingBy)創建的收集器。它們主要提供了三大功能:
- 將流元素歸約和匯總為一個值
- 元素分組
- 元素分區
歸約和匯總
為了說明從Collectors工廠類中能創建出多少種收集器實例,我們重用一下前一章的例子:包含一張佳肴列表的菜單!
先來舉一個簡單的例子,利用counting工廠方法返回的收集器,數一數菜單里有多少種菜:數一數菜單里有多少種菜
long howManyDishes = menu.stream().collect(Collectors.counting());//orlong howManyDishes = menu.stream().count();假定你已導入了Collectors類的所有靜態工廠方法:
import static java.util.stream.Collectors.*;這樣你就可以寫counting()而用不著寫Collectors.counting()之類的了。
查找流中的最大值和最小值
假設你想要找出菜單中熱量最高的菜。
你可以使用兩個收集器,Collectors.maxBy和Collectors.minBy,來計算流中的最大或最小值。
這兩個收集器接收一個Comparator參數來比較流中的元素。你可以創建一個Comparator來根據所含熱量對菜肴進行比較,并把它傳遞給Collectors.maxBy:
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));匯總
Summarizing
Collectors.summingInt。它可接受一個把對象映射為求和所需int的函數,并返回一個收集器;該收集器在傳遞給普通的collect方法后即執行我們需要的匯總操作。
可以這樣求出菜單列表的總熱量
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));Collectors.summingLong和Collectors.summingDouble方法的作用完全一樣,可以用于求和字段為long或double的情況。
但匯總不僅僅是求和;還有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工廠方法有相關的LongSummary-Statistics和DoubleSummaryStatistics類型,適用于收集的屬性是原始類型long或double的情況。
連接字符串
joining工廠方法返回的收集器會把對流中每一個對象應用toString方法得到的所有字符串連接成一個字符串
String shortMenu = menu.stream().map(Dish::getName).collect(joining());請注意,joining在內部使用了StringBuilder來把生成的字符串逐個追加起來。此外還要注意,如果Dish類有一個toString方法來返回菜肴的名稱,那你無需用提取每一道菜名稱的函數來對原流做映射就能夠得到相同的結果:
String shortMenu = menu.stream().collect(joining());二者均可產生以下字符串:
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmonjoining工廠方法有一個重載版本可以接受元素之間的分界符,這樣你就可以得到一個逗號分隔的菜肴名稱列表:
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));它會生成:
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon廣義的歸約匯總
Reducing
事實上,我們已經討論的所有收集器,都是一個可以用reducing工廠方法定義的歸約過程的特殊情況而已。
Collectors.reducing工廠方法是所有這些特殊情況的一般化。
可以用reducing方法創建的收集器來計算你菜單的總熱量
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));同樣,你可以使用下面這樣單參數形式的reducing來找到熱量最高的菜
Optional<Dish> mostCalorieDish = menu.stream().collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));Stream接口的collect和reduce有何不同
你可能想知道,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方法特別適合表達可變容器上的歸約的原因,更關鍵的是它適合并行操作
收集框架的靈活性:以不同的方法執行同樣的操作
進一步簡化前面使用reducing收集器的求和例子
int totalCalories = menu.stream().collect(reducing(0,//初始值Dish::getCalories,//轉換函數Integer::sum));//累積函數之前提到的counting收集器也是類似地利用三參數reducing工廠方法實現的。它把流中的每個元素都轉換成一個值為1的Long型對象,然后再把它們相加:
public static <T> Collector<T, ?, Long> counting() {return reducing(0L, e -> 1L, Long::sum); }不使用收集器也能執行相同操作,使用上一章的reduce()
int totalCalories =menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();最后,更簡潔的方法是把流映射到一個IntStream,然后調用sum方法,你也可以得到相同的結果:
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();根據情況選擇最佳解決方案
從上面的例子,函數式編程通常提供了多種方法來執行同一個操作。
這個例子還說明,收集器在某種程度上比Stream接口上直接提供的方法用起來更復雜,但好處在于它們能提供更高水平的抽象和概括,也更容易重用和自定義。
我們的建議是,盡可能為手頭的問題探索不同的解決方案,但在通用的方案里面,始終選擇最專門化的一個。無論是從可讀性還是性能上看,這一般都是最好的決定。
例如,要計菜單的總熱量,我們更傾向于最后一個解決方案(使用IntStream),因為它最簡明,也很可能最易讀。同時,它也是性能最好的一個,因為IntStream可以讓我們避免自動拆箱操作。
用reducing連接字符串
String shortMenu = menu.stream().map(Dish::getName).collect(joining());String shortMenu = menu.stream().map(Dish::getName).collect( reducing ( (s1, s2) -> s1 + s2 ) ).get();
//這無法編譯,因為reducing接受的參數是一個BinaryOperator<t>,也就是一個BiFunction<T,T,T>。 //這就意味著它需要的函數必須能接受兩個參數, //然后返回一個相同類型的值, //但這里用的Lambda表達式接受的參數是兩個菜, //返回的卻是一個字符串String shortMenu = menu.stream().collect( reducing( (d1, d2) -> d1.getName() + d2.getName() ) ).get();
String shortMenu = menu.stream().collect( reducing( "",Dish::getName, (s1, s2) -> s1 + s2 ) );
然而就實際應用而言,不管是從可讀性還是性能方面考慮,我們始終建議使用joining收集器。
分組
Grouping
假設你要把菜單中的菜按照類型進行分類,有肉的放一組,有魚的放一組,其他的都放另一組。用Collectors.groupingBy工廠方法返回
的收集器就可以輕松地完成這項任務,如下所示:
其結果是下面的Map:
{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],MEAT=[pork, beef, chicken]}你給groupingBy方法傳遞了一個Function(以方法引用的形式),它提取了流中每一道Dish的Dish.Type。我們把這個Function叫作分類函數,因為它用來把流中的元素分成不同的組。
分類函數不一定像方法引用那樣可用,因為你想用以分類的條件可能比簡單的屬性訪問器要復雜。例如,你可能想把熱量不到400卡路里的菜劃分為“低熱量”(diet),熱量400到700卡路里的菜劃為“普通”(normal),高于700卡路里的劃為“高熱量”(fat)。
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;}));多級分組
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream().collect(groupingBy(Dish::getType,groupingBy((Dish 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]}}下圖顯示了為什么結構相當于n維表格,并強調了分組操作的分類目的。
按子組收集數組
例如,要數一數菜單中每類菜有多少個,可以傳遞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作為值:
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}這個Map中的值是Optional,因為這是maxBy工廠方法生成的收集器的類型,但實際上,如果菜單中沒有某一類型的Dish,這個類型就不會對應一個Optional. empty()值,而且根本不會出現在Map的鍵中。
groupingBy收集器只有在應用分組條件后,第一次在流中找到某個鍵對應的元素時才會把鍵加入分組Map中。這意味著Optional包裝器在這里不是很有用,因為它不會僅僅因為它是歸約收集器的返回類型而表達一個最終可能不存在卻意外存在的值。
把收集器的結果轉換為另一種類型
因為分組操作的Map結果中的每個值上包裝的Optional沒什么用,所以你可能想要把它們去掉。要做到這一點,或者更一般地來說,把收集器返回的結果轉換為另一種類型,你可以使用Collectors.collectingAndThen工廠方法返回的收集器
查找每個子組中熱量最高的Dish
Map<Dish.Type, Dish> mostCaloricByType = menu.stream().collect(groupingBy(Dish::getType,//分類函數collectingAndThen(maxBy(comparingInt(Dish::getCalories)),//包裝后的收集器Optional::get)));//轉換函數這個工廠方法接受兩個參數——要轉換的收集器以及轉換函數,并返回另一個收集器。這個收集器相當于舊收集器的一個包裝,collect操作的最后一步就是將返回值用轉換函數做一個映射。
其結果是下面的Map:
{FISH=salmon, OTHER=pizza, MEAT=pork}-
收集器用虛線表示,因此groupingBy是最外層,根據菜肴的類型把菜單流分組,得到三個子流。
-
groupingBy收集器包裹著collectingAndThen收集器,因此分組操作得到的每個子流都用這第二個收集器做進一步歸約。
-
collectingAndThen收集器又包裹著第三個收集器maxBy。
-
隨后由歸約收集器進行子流的歸約操作,然后包含它的collectingAndThen收集器會對其結果應用Optional:get轉換函數。
-
對三個子流分別執行這一過程并轉換而得到的三個值,也就是各個類型中熱量最高的Dish,將成為groupingBy收集器返回的Map中與各個分類鍵(Dish的類型)相關聯的值。
與groupingBy聯合使用的其他收集器的例子
你還重用求出所有菜肴熱量總和的收集器,不過這次是對每一組Dish求和:
Map<Dish.Type, Integer> totalCaloriesByType = menu.stream().collect(groupingBy(Dish::getType,summingInt(Dish::getCalories)));比方說你想要知道,對于每種類型的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() )));讓你得到這樣的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))));分區
Partitioning
分區是分組的特殊情況:由一個謂詞(返回一個布爾值的函數)作為分類函數,它稱分區函數。分區函數返回一個布爾值,這意味著得到的分組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());分區的優勢
分區的好處在于保留了分區函數返回true或false的兩套流元素列表。
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<Boolean, Dish> mostCaloricPartitionedByVegetarian = menu.stream().collect(partitioningBy(Dish::isVegetarian,collectingAndThen(maxBy(comparingInt(Dish::getCalories)),Optional::get)));這將產生以下結果:
{false=pork, true=pizza}更多例子
menu.stream().collect(partitioningBy(Dish::isVegetarian,partitioningBy (d -> d.getCalories() > 500)));這是一個有效的多級分區,產生以下二級Map:
{ false={false=[chicken, prawns, salmon], true=[pork, beef]},true={false=[rice, season fruit], true=[french fries, pizza]}}menu.stream().collect(partitioningBy(Dish::isVegetarian,partitioningBy (Dish::getType)));
不能編譯,Dish::getType不能用作謂詞
menu.stream().collect(partitioningBy(Dish::isVegetarian,counting()));
它會計算每個分區中項目的數目,得到以下Map:
{false=5, true=4}將數字按質數和非質數分區
假設你要寫一個方法,它接受參數int n,并將前n個自然數分為質數和非質數。但首先,找出能夠測試某一個待測數字是否是質數的謂詞會很有幫助:
public boolean isPrime(int candidate) {return IntStream.range(2, candidate).noneMatch(i -> candidate % i == 0); }一個簡單的優化是僅測試小于等于待測數平方根的因子:
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類的靜態工廠方法
| 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)); |
收集器接口
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類,將Stream中的所有元素收集到一個List里,它的簽名如下:
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>理解Collector 接口聲明的方法
建立新的結果容器:supplier方法
supplier方法必須返回一個結果為空的Supplier,也就是一個無參數函數,在調用時它會創建一個空的累加器實例,供數據收集過程使用。
在我們的ToListCollector中,supplier返回一個空的List,如下所示:
public Supplier<List<T>> supplier() {return () -> new ArrayList<T>(); }請注意你也可以只傳遞一個構造函數引用:
public Supplier<List<T>> supplier() {return ArrayList::new; }將元素添加到結果容器:accumulator方法
accumulator方法會返回執行歸約操作的函數。
對于ToListCollector,這個函數僅僅會把當前項目添加至已經遍歷過的項目的列表:
public BiConsumer<List<T>, T> accumulator() {return (list, item) -> list.add(item); }你也可以使用方法引用,這會更為簡潔:
public BiConsumer<List<T>, T> accumulator() {return List::add; }對結果容器應用最終轉換:finisher方法
finisher方法必須返回在累積過程的最后要調用的一個函數,以便將累加器對象轉換為整個集合操作的最終結果
public Function<List<T>, List<T>> finisher() {return Function.identity(); }這三個方法已經足以對流進行順序歸約,至少從邏輯上看可以按圖6-7進行。實踐中的實現細節可能還要復雜一點,一方面是因為流的延遲性質,可能在collect操作之前還需要完成其他中間操作的流水線,另一方面則是理論上可能要進行并行歸約。
合并兩個結果容器:combiner方法
四個方法中的最后一個——combiner方法會返回一個供歸約操作使用的函數,它定義了對流的各個子部分進行并行處理時,各個子部分歸約所得的累加器要如何合并。
public BinaryOperator<List<T>> combiner() {return (list1, list2) -> {list1.addAll(list2);return list1; } }有了這第四個方法,就可以對流進行并行歸約了。它會用到Java 7中引入的分支/合并框架和Spliterator抽象。
- 原始流會以遞歸方式拆分為子流,直到定義流是否需要進一步拆分的一個條件為非(如果分布式工作單位太小,并行計算往往比順序計算要慢,而且要是生成的并行任務比處理器內核數多很多的話就毫無意義了)。
- 現在,所有的子流都可以并行處理,即對每個子流應用圖所示(上上圖)的順序歸約算法。
- 最后,使用收集器combiner方法返回的函數,將所有的部分結果兩兩合并。這時會把原始流每次拆分時得到的子流對應的結果合并起來。
characteristics方法
最后一個方法——characteristics會返回一個不可變的Characteristics集合,它定義了收集器的行為——尤其是關于流是否可以并行歸約,以及可以使用哪些優化的提示。
Characteristics是一個包含三個項目的枚舉。
-
UNORDERED——歸約結果不受流中項目的遍歷和累積順序的影響。
-
CONCURRENT——accumulator函數可以從多個線程同時調用,且該收集器可以并行歸約流。如果收集器沒有標為UNORDERED,那它僅在用于無序數據源時才可以并行歸約。
-
IDENTITY_FINISH——這表明完成器方法返回的函數是一個恒等函數,可以跳過。這種情況下,累加器對象將會直接用作歸約過程的最終結果。這也意味著,將累加器A不加檢查地轉換為結果R是安全的。
全部融合到一起
前一小節中談到的五個方法足夠我們開發自己的ToListCollector了。
ToListCollector
ToListCollectorTest
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());List<Dish> dishes = menuStream.collect(toList());進行自定義收集而不去實現Collector
還有一種方法可以得到同樣的結果而無需從頭實現新的Collectors接口。
Stream有一個重載的collect方法可以接受另外三個函數——supplier、accumulator和combiner,其語義和Collector接口的相應方法返回的函數完全相同。所以比如說,我們可以像下面這樣把菜肴流中的項目收集到一個List中:
List<Dish> dishes = menuStream.collect(ArrayList::new,//供應源List::add,//累加器List::addAll);//組合器這第二種形式雖然比前一個寫法更為緊湊和簡潔,卻不那么易讀。此外,以恰當的類來實現自己的自定義收集器有助于重用并可避免代碼重復。另外值得注意的是,這第二個collect方法不能傳遞任何Characteristics,所以它永遠都是一個IDENTITY_FINISH和CONCURRENT但并非UNORDERED的收集器。
開發你自己的收集器以獲得更好的性能
PartitionPrimeNumbers
用Collectors類提供的一個方便的工廠方法創建了一個收集器,它將前n個自然數劃分為質數和非質數。
將前n個自然數按質數和非質數分區
public Map<Boolean, List<Integer>> partitionPrimes(int n) {return IntStream.rangeClosed(2, n).boxed().collect(partitioningBy(candidate -> isPrime(candidate)); }當時,通過限制除數不超過被測試數的平方根,我們對最初的isPrime方法做了一些改進:
public boolean isPrime(int candidate) {int candidateRoot = (int) Math.sqrt((double) candidate);return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0); }為了獲得更好的性能,開發一個自定義收集器。
僅用質數做除數
一個可能的優化是僅僅看看被測試數是不是能夠被質數整除。要是除數本身都不是質數就用不著測了。所以我們可以僅僅用被測試數之前的質數來測試。而我們目前所見的預定義收集器的問題,也就是必須自己開發一個收集器的原因在于,在收集過程中是沒有辦法訪問部分結果的。
這意味著,當測試某一個數字是否是質數的時候,你沒法訪問目前已經找到的其他質數的列表。
public static boolean isPrime(List<Integer> primes, int candidate) {return primes.stream().noneMatch(i -> candidate % i == 0); }而且還應該應用先前的優化,僅僅用小于被測數平方根的質數來測試。因此,你需要想辦法在下一個質數大于被測數平方根時立即停止測試。不幸的是,Stream API中沒有這樣一種方法。
你可以使用filter(p -> p <= candidateRoot)來篩選出小于被測數平方根的質數。
但filter要處理整個流才能返回恰當的結果。如果質數和非質數的列表都非常大,這就是個問題了。你用不著這樣做;你只需在質數大于被測數平方根的時候停下來就可以了。因此,我們會創建一個名為takeWhile的方法,給定一個排序列表和一個謂詞,它會返回元素滿足謂詞的最長前綴:
public static <A> List<A> takeWhile(List<A> list, Predicate<A> p) {int i = 0;for (A item : list) {if (!p.test(item)) {return list.subList(0, i);}i++;}return list; }利用這個方法,你就可以優化isPrime方法,只用不大于被測數平方根的質數去測試了:
public static boolean isPrime(List<Integer> primes, int candidate){int candidateRoot = (int) Math.sqrt((double) candidate);return takeWhile(primes, i -> i <= candidateRoot).stream().noneMatch(p -> candidate % p == 0); }請注意,這個takeWhile實現是即時的。理想情況下,我們會想要一個延遲求值的takeWhile,這樣就可以和noneMatch操作合并。不幸的是,這樣的實現超出了本章的范圍,你需要了解Stream API的實現才行。
有了這個新的isPrime方法在手,你就可以實現自己的自定義收集器了。首先要聲明一個實現Collector接口的新類,然后要開發Collector接口所需的五個方法。
第一步:定義Collector類的簽名
Collector接口的定義是:
public interface Collector<T, A, R>public class PrimeNumbersCollectorimplements Collector<Integer,//流中元素的類型Map<Boolean, List<Integer>>,//累加器類型Map<Boolean, List<Integer>>>//collect操作的 類型結果類型第二步:實現歸約過程
supplier方法會返回一個在調用時創建累加器的函數:
public Supplier<Map<Boolean, List<Integer>>> supplier() {return () -> new HashMap<Boolean, List<Integer>>() {{put(true, new ArrayList<Integer>());put(false, new ArrayList<Integer>());}}; }收集器中最重要的方法是accumulator,因為它定義了如何收集流中元素的邏輯。這里它也是實現前面所講的優化的關鍵。
現在在任何一次迭代中,都可以訪問收集過程的部分結果,也就是包含迄今找到的質數的累加器。
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {acc.get( isPrime(acc.get(true), candidate) ).add(candidate);}; }在這個方法中,你調用了isPrime方法,將待測試是否為質數的數以及迄今找到的質數列表(也就是累積Map中true鍵對應的值)傳遞給它。這次調用的結果隨后被用作獲取質數或非質數列表的鍵,這樣就可以把新的被測數添加到恰當的列表中。
第三步:讓收集器并行工作(如果可能)
下一個方法要在并行收集時把兩個部分累加器合并起來,這里,它只需要合并兩個Map,即將第二個Map中質數和非質數列表中的所有數字合并到第一個Map的對應列表中就行了:
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {return (Map<Boolean, List<Integer>> map1,Map<Boolean, List<Integer>> map2) -> {map1.get(true).addAll(map2.get(true));map1.get(false).addAll(map2.get(false));return map1;}; }請注意,實際上這個收集器是不能并行使用的,因為該算法本身是順序的。這意味著永遠都不會調用combiner方法,你可以把它的實現留空(更好的做法是拋出一個UnsupportedOperationException異常)。為了讓這個例子完整,我們還是決定實現它。
第四步:finisher方法和收集器的characteristics方法
前面說過,accumulator正好就是收集器的結果,用不著進一步轉換,那么finisher方法就返回identity函數:
public Function<Map<Boolean, List<Integer>>,Map<Boolean, List<Integer>>> finisher() {return Function.identity(); }就characteristics方法而言,我們已經說過,它既不是CONCURRENT也不是UNORDERED,但卻是IDENTITY_FINISH的:
public Set<Characteristics> characteristics() {return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH)); }最后實現的PrimeNumbersCollector。
比較收集器的性能
用partitioningBy工廠方法創建的收集器和你剛剛開發的自定義收集器在功能上是一樣的,但是我們有沒有實現用自定義收集器超越partitioningBy收集器性能的目標呢?現在讓我們寫個小測試框架來跑一下吧:
CollectorHarness
請注意,更為科學的測試方法是用一個諸如JMH的框架,但我們不想在這里把問題搞得更復雜。對這個例子而言,這個小小的測試類提供的結果足夠準確了。這個類會先把前一百萬個自然數分為質數和非質數,利用partitioningBy工廠方法創建的收集器調用方法10次,記下最快的
一次運行。
運行結果:
done in 1430 done in 1382 done in 1306 done in 1061 done in 1132 done in 1049 done in 1027 done in 1033 done in 1274 done in 1901 1.Partitioning done in: 1027 msecsdone in 1020 done in 860 done in 983 done in 977 done in 967 done in 957 done in 877 done in 900 done in 801 done in 850 2.Partitioning done in: 801 msecs還不錯!這意味著開發自定義收集器并不是白費工夫,原因有二:第一,你學會了如何在需要的時候實現自己的收集器;第二,你獲得了大約32%的性能提升。
可以通過把實現PrimeNumbersCollector核心邏輯的三個函數傳給collect方法的重載版本來獲得同樣的結果:
public Map<Boolean, List<Integer>> partitionPrimesWithInlineCollector(int n) {return Stream.iterate(2, i -> i + 1).limit(n)//IntStream.rangeClosed(2, n).boxed().collect(() -> new HashMap<Boolean, List<Integer>>() {{put(true, new ArrayList<Integer>());put(false, new ArrayList<Integer>());}},(acc, candidate) -> {acc.get( isPrime(acc.get(true), candidate) ).add(candidate);},(map1, map2) -> {map1.get(true).addAll(map2.get(true));map1.get(false).addAll(map2.get(false));}); }這樣就可以避免為實現Collector接口創建一個全新的類;得到的代碼更緊湊,雖然可能可讀性會差一點,可重用性會差一點。
小結
- collect是一個終端操作,它接受的參數是將流中元素累積到匯總結果的各種方式(稱為收集器)。
- 預定義收集器包括將流元素歸約和匯總到一個值,例如計算最小值、最大值或平均值。
- 預定義收集器可以用groupingBy對流中元素進行分組,或用partitioningBy進行分區。
- 收集器可以高效地復合起來,進行多級分組、分區和歸約。
- 你可以實現Collector接口中定義的方法來開發你自己的收集器。
總結
以上是生活随笔為你收集整理的《Java8实战》笔记(06):用流收集数据的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C++(1)--概况、开发工具、hell
- 下一篇: Python(25)-单例设计模式