Collection, Collections, collect, Collector, Collectos
Collection是Java集合的祖先接口。
Collections是java.util包下的一個工具類,內涵各種處理集合的靜態方法。
java.util.stream.Stream#collect(java.util.stream.Collector<? super T,A,R>)是Stream的一個函數,負責收集流。
java.util.stream.Collector 是一個收集函數的接口, 聲明瞭一個收集器的功能。
java.util.Comparators則是一個收集器的工具類,內置了一系列收集器實現。
收集器的作用
你可以把Java8的流看做花哨又懶惰的數據集迭代器。他們支持兩種類型的操作:中間操作(e.g. filter, map)和終端操作(如count, findFirst, forEach, reduce). 中間操作可以連接起來,將一個流轉換爲另一個流。這些操作不會消耗流,其目的是建立一個流水線。與此相反,終端操作會消耗類,產生一個最終結果。collect就是一個歸約操作,就像reduce一樣可以接受各種做法作爲參數,將流中的元素累積成一個彙總結果。具體的做法是通過定義新的Collector接口來定義的。
預定義的收集器
下面簡單演示基本的內置收集器。模擬數據源如下:
?
1
2
3
4
5
6
7
8
9
10
11
|
final ArrayList<Dish> dishes = Lists.newArrayList(
new Dish( "pork" , false , 800, Type.MEAT),
new Dish( "beef" , false , 700, Type.MEAT),
new Dish( "chicken" , false , 400, Type.MEAT),
new Dish( "french fries" , true , 530, Type.OTHER),
new Dish( "rice" , true , 350, Type.OTHER),
new Dish( "season fruit" , true , 120, Type.OTHER),
new Dish( "pizza" , true , 550, Type.OTHER),
new Dish( "prawns" , false , 300, Type.FISH),
new Dish( "salmon" , false , 450, Type.FISH)
);
|
最大值,最小值,平均值
?
1
2
3
4
5
6
7
8
9
10
|
// 爲啥返回Optional? 如果stream爲null怎麼辦, 這時候Optinal就很有意義了
Optional<Dish> mostCalorieDish = dishes.stream().max(Comparator.comparingInt(Dish::getCalories));
Optional<Dish> minCalorieDish = dishes.stream().min(Comparator.comparingInt(Dish::getCalories));
Double avgCalories = dishes.stream().collect(Collectors.averagingInt(Dish::getCalories));
IntSummaryStatistics summaryStatistics = dishes.stream().collect(Collectors.summarizingInt(Dish::getCalories));
double average = summaryStatistics.getAverage();
long count = summaryStatistics.getCount();
int max = summaryStatistics.getMax();
int min = summaryStatistics.getMin();
long sum = summaryStatistics.getSum();
|
這幾個簡單的統計指標都有Collectors內置的收集器函數,尤其是針對數字類型拆箱函數,將會比直接操作包裝類型開銷小很多。
連接收集器
想要把Stream的元素拼起來?
?
1
2
3
4
|
//直接連接
String join1 = dishes.stream().map(Dish::getName).collect(Collectors.joining());
//逗號
String join2 = dishes.stream().map(Dish::getName).collect(Collectors.joining( ", " ));
|
toList
?
1
|
List<String> names = dishes.stream().map(Dish::getName).collect(toList());
|
將原來的Stream映射爲一個單元素流,然後收集爲List。
toSet
?
1
|
Set<Type> types = dishes.stream().map(Dish::getType).collect(Collectors.toSet());
|
將Type收集爲一個set,可以去重複。
toMap
?
1
|
Map<Type, Dish> byType = dishes.stream().collect(toMap(Dish::getType, d -> d));
|
有時候可能需要將一個數組轉爲map,做緩存,方便多次計算獲取。toMap提供的方法k和v的生成函數。(注意,上述demo是一個坑,不可以這樣用!!!, 請使用toMap(Function, Function, BinaryOperator))
上面幾個幾乎是最常用的收集器了,也基本夠用了。但作爲初學者來說,理解需要時間。想要真正明白爲什麼這樣可以做到收集,就必須查看內部實現,可以看到,這幾個收集器都是基於java.util.stream.Collectors.CollectorImpl,也就是開頭提到過了Collector的一個實現類。後面自定義收集器會學習具體用法。
自定義歸約reducing
前面幾個都是reducing工廠方法定義的歸約過程的特殊情況,其實可以用Collectors.reducing創建收集器。比如,求和
?
1
2
3
|
Integer totalCalories = dishes.stream().collect(reducing( 0 , Dish::getCalories, (i, j) -> i + j));
//使用內置函數代替箭頭函數
Integer totalCalories2 = dishes.stream().collect(reducing( 0 , Dish::getCalories, Integer::sum));
|
當然也可以直接使用reduce
?
1
|
Optional<Integer> totalCalories3 = dishes.stream().map(Dish::getCalories).reduce(Integer:: sum );
|
雖然都可以,但考量效率的話,還是要選擇下面這種
?
1
|
int sum = dishes.stream().mapToInt(Dish::getCalories).sum();
|
根據情況選擇最佳方案
上面的demo說明,函數式編程通常提供了多種方法來執行同一個操作,使用收集器collect比直接使用stream的api用起來更加複雜,好處是collect能提供更高水平的抽象和概括,也更容易重用和自定義。
我們的建議是,儘可能爲手頭的問題探索不同的解決方案,始終選擇最專業的一個,無論從可讀性還是性能來看,這一般都是最好的決定。
reducing除了接收一個初始值,還可以把第一項當作初始值
?
1
2
|
Optional<Dish> mostCalorieDish = dishes.stream()
.collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
|
reducing
關於reducing的用法比較複雜,目標在於把兩個值合併成一個值。
?
1
2
3
4
|
public static <T, U>
Collector<T, ?, U> reducing(U identity,
Function<? super T, ? extends U> mapper,
BinaryOperator<U> op)
|
首先看到3個泛型,
U是返回值的類型,比如上述demo中計算熱量的,U就是Integer。
關於T,T是Stream裏的元素類型。由Function的函數可以知道,mapper的作用就是接收一個參數T,然後返回一個結果U。對應demo中Dish。
?在返回值Collector的泛型列表的中間,這個表示容器類型,一個收集器當然需要一個容器來存放數據。這裏的?則表示容器類型不確定。事實上,在這裏的容器就是U[]。
關於參數:
identity是返回值類型的初始值,可以理解爲累加器的起點。
mapper則是map的作用,意義在於將Stream流轉換成你想要的類型流。
op則是核心函數,作用是如何處理兩個變量。其中,第一個變量是累積值,可以理解爲sum,第二個變量則是下一個要計算的元素。從而實現了累加。
reducing還有一個重載的方法,可以省略第一個參數,意義在於把Stream裏的第一個參數當做初始值。
?
1
2
|
public static <T> Collector<T, ?, Optional<T>>
reducing(BinaryOperator<T> op)
|
先看返回值的區別,T表示輸入值和返回值類型,即輸入值類型和輸出值類型相同。還有不同的就是Optional了。這是因爲沒有初始值,而第一個參數有可能是null,當Stream的元素是null的時候,返回Optional就很意義了。
再看參數列表,只剩下BinaryOperator。BinaryOperator是一個三元組函數接口,目標是將兩個同類型參數做計算後返回同類型的值。可以按照1>2? 1:2來理解,即求兩個數的最大值。求最大值是比較好理解的一種說法,你可以自定義lambda表達式來選擇返回值。那麼,在這裏,就是接收兩個Stream的元素類型T,返回T類型的返回值。用sum累加來理解也可以。
上述的demo中發現reduce和collect的作用幾乎一樣,都是返回一個最終的結果,比如,我們可以使用reduce實現toList效果:
?
1
2
3
4
5
6
7
8
9
10
11
12
|
//手動實現toListCollector --- 濫用reduce, 不可變的規約---不可以並行
List<Integer> calories = dishes.stream().map(Dish::getCalories)
.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;
}
);
|
關於上述做法解釋一下。
?
1
2
3
|
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
|
U是返回值類型,這裏就是List
BiFunction<U, ? super T, U> accumulator是是累加器,目標在於累加值和單個元素的計算規則。這裏就是List和元素做運算,最終返回List。即,添加一個元素到list。
BinaryOperator<U> combiner是組合器,目標在於把兩個返回值類型的變量合併成一個。這裏就是兩個list合併。
這個解決方案有兩個問題:一個是語義問題,一個是實際問題。語義問題在於,reduce方法旨在把兩個值結合起來生成一個新值,它是一個不可變歸約。相反,collect方法的設計就是要改變容器,從而累積要輸出的結果。這意味着,上面的代碼片段是在濫用reduce方法,因爲它在原地改變了作爲累加器的List。錯誤的語義來使用reduce方法還會造成一個實際問題:這個歸約不能並行工作,因爲由多個線程併發修改同一個數據結構可能會破壞List本身。在這種情況下,如果你想要線程安全,就需要每次分配一個新的List,而對象分配又會影響性能。這就是collect適合表達可變容器上的歸約的原因,更關鍵的是它適合並行操作。
總結:reduce適合不可變容器歸約,collect適合可變容器歸約。collect適合並行。
分組
數據庫中經常遇到分組求和的需求,提供了group by原語。在Java裏, 如果按照指令式風格(手動寫循環)的方式,將會非常繁瑣,容易出錯。而Java8則提供了函數式解法。
比如,將dish按照type分組。和前面的toMap類似,但分組的value卻不是一個dish,而是一個List。
?
1
|
Map<Type, List<Dish>> dishesByType = dishes.stream().collect(groupingBy(Dish::getType));
|
這裏
?
1
2
|
public static <T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier)
|
參數分類器爲Function,旨在接收一個參數,轉換爲另一個類型。上面的demo就是把stream的元素dish轉成類型Type,然後根據Type將stream分組。其內部是通過HashMap來實現分組的。groupingBy(classifier, HashMap::new, downstream);
除了按照stream元素自身的屬性函數去分組,還可以自定義分組依據,比如根據熱量範圍分組。
既然已經知道groupingBy的參數爲Function, 並且Function的參數類型爲Dish,那麼可以自定義分類器爲:
?
1
2
3
4
5
6
7
8
9
|
private CaloricLevel getCaloricLevel(Dish d) {
if (d.getCalories() <= 400 ) {
return CaloricLevel.DIET;
} else if (d.getCalories() <= 700 ) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}
|
再傳入參數即可
?
1
2
|
Map<CaloricLevel, List<Dish>> dishesByLevel = dishes.stream()
.collect(groupingBy( this ::getCaloricLevel));
|
多級分組
groupingBy還重載了其他幾個方法,比如
?
1
2
3
|
public static <T, K, A, D>
Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
Collector<? super T, A, D> downstream)
|
泛型多的恐怖。簡單的認識一下。classifier還是分類器,就是接收stream的元素類型,返回一個你想要分組的依據,也就是提供分組依據的基數的。所以T表示stream當前的元素類型,K表示分組依據的元素類型。第二個參數downstream,下游是一個收集器Collector. 這個收集器元素類型是T的子類,容器類型container爲A,reduction返回值類型爲D。也就是說分組的K通過分類器提供,分組的value則通過第二個參數的收集器reduce出來。正好,上個demo的源碼爲:
?
1
2
3
4
|
public static <T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier) {
return groupingBy(classifier, toList());
}
|
將toList當作reduce收集器,最終收集的結果是一個List<Dish>, 所以分組結束的value類型是List<Dish>。那麼,可以類推value類型取決於reduce收集器,而reduce收集器則有千千萬。比如,我想對value再次分組,分組也是一種reduce。
?
1
2
3
4
5
6
7
8
9
10
11
|
//多級分組
Map<Type, Map<CaloricLevel, List<Dish>>> byTypeAndCalory = dishes.stream().collect(
groupingBy(Dish::getType, groupingBy( this ::getCaloricLevel)));
byTypeAndCalory.forEach((type, byCalory) -> {
System.out.println( "----------------------------------" );
System.out.println(type);
byCalory.forEach((level, dishList) -> {
System.out.println( "\t" + level);
System.out.println( "\t\t" + dishList);
});
});
|
驗證結果爲:
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
----------------------------------
FISH
DIET
[Dish(name=prawns, vegetarian= false , calories= 300 , type=FISH)]
NORMAL
[Dish(name=salmon, vegetarian= false , calories= 450 , type=FISH)]
----------------------------------
MEAT
FAT
[Dish(name=pork, vegetarian= false , calories= 800 , type=MEAT)]
DIET
[Dish(name=chicken, vegetarian= false , calories= 400 , type=MEAT)]
NORMAL
[Dish(name=beef, vegetarian= false , calories= 700 , type=MEAT)]
----------------------------------
OTHER
DIET
[Dish(name=rice, vegetarian= true , calories= 350 , type=OTHER), Dish(name=season fruit, vegetarian= true , calories= 120 , type=OTHER)]
NORMAL
[Dish(name=french fries, vegetarian= true , calories= 530 , type=OTHER), Dish(name=pizza, vegetarian= true , calories= 550 , type=OTHER)]
|
總結:groupingBy的核心參數爲K生成器,V生成器。V生成器可以是任意類型的收集器Collector。
比如,V生成器可以是計算數目的, 從而實現了sql語句中的select count(*) from table A group by Type
?
1
2
3
4
|
Map<Type, Long> typesCount = dishes.stream().collect(groupingBy(Dish::getType, counting()));
System.out.println(typesCount);
-----------
{FISH= 2 , MEAT= 3 , OTHER= 4 }
|
sql查找分組最高分select MAX(id) from table A group by Type
?
1
2
|
Map<Type, Optional<Dish>> mostCaloricByType = dishes.stream()
.collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories))));
|
這裏的Optional沒有意義,因爲肯定不是null。那麼只好取出來了。使用collectingAndThen
?
1
2
3
|
Map<Type, Dish> mostCaloricByType = dishes.stream()
.collect(groupingBy(Dish::getType,
collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
|
到這裏似乎結果出來了,但IDEA不同意,編譯黃色報警,按提示修改後變爲:
?
1
2
3
|
Map<Type, Dish> mostCaloricByType = dishes.stream()
.collect(toMap(Dish::getType, Function.identity(),
BinaryOperator.maxBy(comparingInt(Dish::getCalories))));
|
是的,groupingBy就變成toMap了,key還是Type,value還是Dish,但多了一個參數!!這裏迴應開頭的坑,開頭的toMap演示是爲了容易理解,真那麼用則會被搞死。我們知道把一個List重組爲Map必然會面臨k相同的問題。當K相同時,v是覆蓋還是不管呢?前面的demo的做法是當k存在時,再次插入k則直接拋出異常:
?
1
2
|
java.lang.IllegalStateException: Duplicate key Dish(name=pork, vegetarian= false , calories= 800 , type=MEAT)
at java.util.stream.Collectors.lambda$throwingMerger$ 0 (Collectors.java: 133 )
|
正確的做法是提供處理衝突的函數,在本demo中,處理衝突的原則就是找出最大的,正好符合我們分組求最大的要求。(真的不想搞Java8函數式學習了,感覺到處都是性能問題的坑)
繼續數據庫sql映射,分組求和select sum(score) from table a group by Type
?
1
2
|
Map<Type, Integer> totalCaloriesByType = dishes.stream()
.collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));
|
然而常常和groupingBy聯合使用的另一個收集器是mapping方法生成的。這個方法接收兩個參數:一個函數對流中的元素做變換,另一個則將變換的結果對象收集起來。其目的是在累加之前對每個輸入元素應用一個映射函數,這樣就可以讓接收特定類型元素的收集器適應不同類型的對象。我麼來看一個使用這個收集器的實際例子。比如你想得到,對於每種類型的Dish,菜單中都有哪些CaloricLevel。我們可以把groupingBy和mapping收集器結合起來,如下所示:
?
1
2
|
Map<Type, Set<CaloricLevel>> caloricLevelsByType = dishes.stream()
.collect(groupingBy(Dish::getType, mapping( this ::getCaloricLevel, toSet())));
|
這裏的toSet默認採用的HashSet,也可以手動指定具體實現toCollection(HashSet::new)
分區
分區是分組的特殊情況:由一個謂詞(返回一個布爾值的函數)作爲分類函數,它稱爲分區函數。分區函數返回一個布爾值,這意味着得到的分組Map的鍵類型是Boolean,於是它最多可以分爲兩組:true or false. 例如,如果你是素食者,你可能想要把菜單按照素食和非素食分開:
?
1
|
Map<Boolean, List<Dish>> partitionedMenu = dishes.stream().collect(partitioningBy(Dish::isVegetarian));
|
當然,使用filter可以達到同樣的效果:
?
1
|
List<Dish> vegetarianDishes = dishes.stream().filter(Dish::isVegetarian).collect(Collectors.toList());
|
分區相對來說,優勢就是保存了兩個副本,當你想要對一個list分類時挺有用的。同時,和groupingBy一樣,partitioningBy一樣有重載方法,可以指定分組value的類型。
?
1
2
3
4
5
6
7
|
Map<Boolean, Map<Type, List<Dish>>> vegetarianDishesByType = dishes.stream()
.collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));
Map<Boolean, Integer> vegetarianDishesTotalCalories = dishes.stream()
.collect(partitioningBy(Dish::isVegetarian, summingInt(Dish::getCalories)));
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = dishes.stream()
.collect(partitioningBy(Dish::isVegetarian,
collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));
|
作爲使用partitioningBy收集器的最後一個例子,我們把菜單數據模型放在一邊,來看一個更加複雜也更爲有趣的例子:將數組分爲質數和非質數。
首先,定義個質數分區函數:
?
1
2
3
4
|
private boolean isPrime( int candidate) {
int candidateRoot = ( int ) Math.sqrt(( double ) candidate);
return IntStream.rangeClosed( 2 , candidateRoot).noneMatch(i -> candidate % i == 0 );
}
|
然後找出1到100的質數和非質數
?
1
2
|
Map<Boolean, List<Integer>> partitionPrimes = IntStream.rangeClosed( 2 , 100 ).boxed()
.collect(partitioningBy( this ::isPrime));
|