Stream收集器

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));

發佈了25 篇原創文章 · 獲贊 20 · 訪問量 6520
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章