收集器简介

收集器非常有用,用它来可以简洁而灵活的定义collect用来生成结果集合的标准。对流调用collect方法会对流中的每个元素触发规约操作。一般来说,收集器会对元素应用一个转换函数,并将结果累积在一个数据结构中,从而产生这一过程的最终输出。

规约和汇总

在将流中的项目合并成一个结果时,一般会使用收集器(Stream的collect)。这个结果可以是任何类型,可以复杂如一棵树的多级映射,或是简单如一个整数。

假定有如下菜单:

1
2
3
4
5
6
7
8
9
10
11
List<Dish> menu = Arrays.asList(
    new Dish("pork", false, 800, Dish.Type.MEAT),
    new Dish("beef", false, 700, Dish.Type.MEAT),
    new Dish("chicken", false, 400, Dish.Type.MEAT),
    new Dish("french fries", true, 530, Dish.Type.OTHER),
    new Dish("rice", true, 300, Dish.Type.OTHER),
    new Dish("season fruit", true, 120, Dish.Type.OTHER),
    new Dish("pizza", false, 300, Dish.Type.OTHER),
    new Dish("prawns", false, 300, Dish.Type.FISH),
    new Dish("salmon", false, 450, Dish.Type.FISH)
);

在下面的所有示例中,假定你已经导入了Collectors的所有静态工厂方法

1
import static java.util.stream.Collectors.*;

查找流中的最大值和最小值

假如你想找出菜中热量最高的菜和热量最低的菜,可以使用Collectors.maxBy和Collectors.minBy这2个收集器。它们接收一个Comparator参数来比较流中的元素。
比如找出菜单中热量最多的菜

1
2
3
4
5
// 创建比较器
Comparator<Dish> dishComparator = Comparator.comparingInt(Dish::getCalories);
// 根据热量比较,找出最大热量的菜
Optional<Dish> maxCaloriesDish = menu.stream().collect(maxBy(dishComparator));
maxCaloriesDish.ifPresent(System.out::println);

汇总

求和
Collectors类专门为汇总提供了一个工厂方法:Collectors.summingInt。它接收一个把对象映射为求和所需的int的函数,并返回一个收集器。该收集器在传递给普通的collect方法后即自行我们需要的汇总操作。

比如计算菜的总热量

1
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

在遍历流时,会将每道菜都映射为其热量,然后把数字累加到一个累加器(这里初始值为0)。

Collectors的summingLong和summingDouble方法与summingInt用法一样,用于处理long和double的情况。

求平均值
使用Collectors.averageInt/averageLong,averageDouble来求平均数。

比如,求所有菜平均的热量。

1
double avgCalories = menu.stream().collect(averagingDouble(Dish::getCalories));

一次性获得多个汇总值
如果你想一次性获取到多个汇总的结果,比如一次性获取到最大值、最小值,平均值,求和的结果等等,可以使用Collectors.summarizingInt方法。

1
2
IntSummaryStatistics summaryStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
System.out.println(summaryStatistics);

输出结果如下:

1
IntSummaryStatistics{count=9, sum=3900, min=120, average=433.333333, max=800}

连接字符串

Collectors.joining方法会将流中的每个元素应用toString()方法,然后将所得到的字符串连接在一起。

比如将菜单中所有菜名连接起来:

1
2
String menuNames = menu.stream().map(Dish::getName).collect(joining());
System.out.println(menuNames); // 使用joining()无参数的方法只会将结果连接在一起

输出结果:

1
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

可以发现,可读性并不好,幸好提供了重载方法。我们可以提供一个分隔符。

1
2
menuNames = menu.stream().map(Dish::getName).collect(joining(","));
System.out.println(menuNames); // 用逗号分隔的菜肴名称

输出结果:

1
pork,beef,chicken,french fries,rice,season fruit,pizza,prawns,salmon

广义的规约汇总

我们之前讨论的所有收集器,都是一个可以用reducing工厂方法定义的规约过程的特殊情况而已。
比如使用reducing方法来计算菜单的总热量:

1
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));

它需要3个参数:

  • 第一个参数是规约操作的起始值,也是流中没有元素时的返回值;
  • 第二个参数是将菜肴转换为一个表示其所含热量的int。
  • 第三个参数是一个BinaryOpeartor,将2个项目累积成一个同类型的值。在这里是对2个int求和。

也可以使用下面的单参数的reducing方法来计算菜品的总热量。

1
Optional<Dish> totalCaloriesDish = menu.stream().collect(reducing((d1,d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

你可以把单参数的reducing方法看做一个三参数的特殊情况。它把流中的第一个元素作为起点,把恒等函数(即一个函数仅仅返回你参数本身)作为一个转换函数。这也就意味着把单参数的reducing方法传递给一个collect方法,收集器就没有起点,所以会返回一个Optional对象。

分组

Collectors提供了groupingBy方法来对元素进行分组。

比如按照类别对菜肴进行分组:

1
2
Map<Dish.Type, List<Dish>> byTypeDishes = menu.stream().collect(groupingBy(Dish::getType));
System.out.println(byTypeDishes);

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
OTHER=[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}, 
       Dish{name='rice', vegetarian=true, calories=300, type=OTHER}, 
       Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}, 
       Dish{name='pizza', vegetarian=false, calories=300, type=OTHER}], 

FISH=[Dish{name='prawns', vegetarian=false, calories=300, type=FISH}, 
      Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], 

MEAT=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, 
      Dish{name='beef', vegetarian=false, calories=700, type=MEAT}, 
      Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}]
}

多级分组

要实现多级分组,可以使用Collectors.groupingBy的双参数的重载方法,它除了普通的分类函数外,还接受一个collector类型的第2个参数。那么进行二级分组的话,可以把一个内层的groupingBy传递给外层的groupingBy,并定义一个为流中项目分类的二级标准。

比如按类别和热量等级分组:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义400以下为低热量,700以下为一般,700以上为高热量
public enum CaloricLevel {DIET, NORMAL, FAT}
大分组为类别,小分组为热量
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloriesLevel = menu.stream().collect(groupingBy(Dish::getType, groupingBy(dish -> {
    if (dish.getCalories() <= 400) {
        return CaloricLevel.DIET;
    } else if (dish.getCalories() <= 700) {
        return CaloricLevel.NORMAL;
    }
    return CaloricLevel.FAT;
})));
System.out.println(dishesByTypeCaloriesLevel);

输出结果(格式化后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
OTHER={
    DIET=[Dish{name='rice', vegetarian=true, calories=300, type=OTHER}, 
          Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}, 
          Dish{name='pizza', vegetarian=false, calories=300, type=OTHER}], 
    NORMAL=[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}]}, 

FISH={
    DIET=[Dish{name='prawns', vegetarian=false, calories=300, type=FISH}], 
    NORMAL=[Dish{name='salmon', vegetarian=false, calories=450, type=FISH}]}, 

MEAT={
    DIET=[Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}], 
    NORMAL=[Dish{name='beef', vegetarian=false, calories=700, type=MEAT}], 
    FAT=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}]}
}

按子组收集数据

我们把第二个groupingBy传递给外层收集器来实现多级分组,但进一步讲,传递给第一个收集器的第二个收集器可以是任意类型,而不一定是一个groupingBy。
比如,要统计菜单中每种菜有多少种,可以传递counting收集器作为groupingBy收集器的第二个收集器。

1
2
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
System.out.println(typesCount);

输出结果:

1
{OTHER=4, FISH=2, MEAT=3}

再比如,查找每个菜品类型热量最高的菜

1
2
Map<Dish.Type, Optional<Dish>> maxCaloricByType = menu.stream().collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories))));
System.out.println(maxCaloricByType);

输出结果(格式化后):

1
2
3
4
5
6
7
{
OTHER=Optional[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}], 

FISH=Optional[Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], 

MEAT=Optional[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}]
}

将收集器的结果转换为另一种类型

分组操作的Map结果中的每个值上包装的Optional没什么用,你可以使用Collectors.collectingAndThen收集器去掉Optional。该方法接收2个参数——要转换的收集器和转换方法,并返回另一个收集器。
这个收集器相当于旧收集器的一个包装,collect操作的最后一步就是将返回值用转换方法做一个映射。

1
2
3
4
5
6
把收集器的结果转换为另一种类型(比如去掉上面的Optional)
Map<Dish.Type, Dish> maxCaloricByTypeDishes = menu.stream()
        .collect(groupingBy(Dish::getType,
                collectingAndThen(
                        maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
System.out.println(maxCaloricByTypeDishes);

输出结果(格式化后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
OTHER=Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}, 
FISH=Dish{name='salmon', vegetarian=false, calories=450, type=FISH}, 
MEAT=Dish{name='pork', vegetarian=false, calories=800, type=MEAT}}
``

### Collectors.mapping
常常和groupingBy一起使用的另一个收集器是mapping方法生成的。它接收2个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让特定类型元素的收集器适应不同类型的对象。

比如想知道每种类型的Dish中有哪些CaloricLevel。
```java
Map<Dish.Type, Set<CaloricLevel>> byTypeCaloricDishes = menu.stream().collect(groupingBy(Dish::getType, mapping(dish -> {
    if (dish.getCalories() <= 400) {
        return CaloricLevel.DIET;
    } else if (dish.getCalories() <= 700) {
        return CaloricLevel.NORMAL;
    }
    return CaloricLevel.FAT;
}, toSet())));
System.out.println(byTypeCaloricDishes);

输出结果:

1
{OTHER=[DIET, NORMAL], FISH=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT]}

分区

分区是分组的特殊情况,由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,则意味着得到的分组Map的键类型是Boolean,于是它最多可以分为2组——true是一组,false是一组。

比如将菜单按照素食和非素食分开:

1
2
3
4
// 1.按素菜 OR 荤菜分组
// true表示素菜;false为非素菜
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));
System.out.println(partitionedMenu + "\n");

输出结果(格式化后):

1
2
3
4
5
6
7
8
9
10
11
12
{
false=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, 
       Dish{name='beef', vegetarian=false, calories=700, type=MEAT}, 
       Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}, 
       Dish{name='pizza', vegetarian=false, calories=300, type=OTHER}, 
       Dish{name='prawns', vegetarian=false, calories=300, type=FISH}, 
       Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], 

true=[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}, 
      Dish{name='rice', vegetarian=true, calories=300, type=OTHER}, 
      Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}]
}

分区的好处

分区的好处在于分区函数保留了true和false2套流元素列表。比如上面的例子中,要得到素菜列表,使用partitionedMenu.get(true)即可。

partitionBy方法还有一个重载版本,可以传递第2个收集器。

比如,按是否素菜分区,再根据Type分组:

1
2
3
4
// 分区+分组
// 先按是否素菜分区,然后将同类别的菜放到一起
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream().collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));
System.out.println(vegetarianDishesByType + "\n");

输出结果(格式化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
false={
    OTHER=[Dish{name='pizza', vegetarian=false, calories=300, type=OTHER}], 
    FISH=[Dish{name='prawns', vegetarian=false, calories=300, type=FISH}, 
          Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], 
    MEAT=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, 
          Dish{name='beef', vegetarian=false, calories=700, type=MEAT}, 
          Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}]}, 

true={
    OTHER=[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}, 
           Dish{name='rice', vegetarian=true, calories=300, type=OTHER}, 
           Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}]}
}

查找素菜和非素菜中热量最高的菜

1
2
3
4
5
6
// 找到素菜和非素菜卡路里含量最高的菜
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
        menu.stream()
                .collect(partitioningBy(Dish::isVegetarian,
                        collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));
System.out.println(mostCaloricPartitionedByVegetarian + "\n");

输出结果:

1
{false=Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, true=Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}}

找出素菜和非素菜的数量

1
2
Map<Boolean, Long> partitionedByVegetarianCount = menu.stream().collect(partitioningBy(Dish::isVegetarian, counting()));
System.out.println(partitionedByVegetarianCount + "\n");

输出结果:

1
{false=6, true=3}

按是否为素菜分组,找出热量在500以上和500一下的菜

1
2
Map<Boolean, Map<Boolean, List<Dish>>> twoPartitionedDishes = menu.stream().collect(partitioningBy(Dish::isVegetarian, partitioningBy(d -> d.getCalories() > 500)));
System.out.println(twoPartitionedDishes + "\n");

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
{
    false={
        false=[Dish{name='chicken', vegetarian=false, calories=400, type=MEAT}, Dish{name='pizza', vegetarian=false, calories=300, type=OTHER}, Dish{name='prawns', vegetarian=false, calories=300, type=FISH}, Dish{name='salmon', vegetarian=false, calories=450, type=FISH}], 

        true=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, Dish{name='beef', vegetarian=false, calories=700, type=MEAT}]
    }, 

    true={
        false=[Dish{name='rice', vegetarian=true, calories=300, type=OTHER}, Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}], 
        true=[Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}]
    }
}

找出给定数字范围内的质数和非质数

1
2
3
4
5
Map<Boolean, List<Integer>> primeNumbers = IntStream.rangeClosed(2, 10).boxed().collect(partitioningBy(i -> isPrime(i)));

private static boolean isPrime(int i) {
    return IntStream.range(2, i).noneMatch(n -> i % n == 0);
}

Collectors类的静态工厂方法

工厂方法 返回类型 用于
toList List 把流中所有项目收集到一个List
toSet Set 把流中所有项目收集到一个Set,删除重复项
toCollection Collection 把流中所有项目收集到给定的供应源创建的集合
counting Long 计算流中元素的个数
summingInt Integer 对流中项目的一个整数属性求和
averagingInt Double 计算流中项目Integer 属性的平均值
summarizingInt IntSummaryStatistics 收集关于流中项目Integer 属性的统计值,例如最大、最小、总和与平均值
joining` String 连接对流中每个项目调用toString 方法所生成的字符串
maxBy Optional 一个包裹了流中按照给定比较器选出的最大元素的Optional,或如果流为空则为Optional.empty()
minBy Optional 一个包裹了流中按照给定比较器选出的最小元素的Optional,或如果流为空则为Optional.empty()
reducing 归约操作产生的类型 从一个作为累加器的初始值开始,利用BinaryOperator 与流中的元素逐个结合,从而将流归约为单个值
collectingAndThen 转换函数返回的类型 包裹另一个收集器,对其结果应用转换函数
groupingBy Map> 根据项目的一个属性的值对流中的项目作问组,并将属性值作为结果Map 的键
partitioningBy Map> 根据对流中每个项目应用谓词的结果来对项进行分区

收集器接口

前面已经使用了很多Collector接口实现的收集器,像toList,groupingBy等。

下面看下Collector接口的定义:

1
2
3
4
5
6
7
public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    BinaryOperator<A> combiner();
    Function<A, R> finisher();
    Set<Characteristics> characteristics();
}

T是流中要手机的项目的类型。
A是累加器的类型,累加器是在收集过程中用于累积部分结果的对象。
R是收集操作得到的对象的类型。
例如,你可以实现一个ToListCollector类,将Stream中的所有元素收集到List中,它的签名如下:

1
class ToListCollector<T> implements Collector<T,List<T>,List<T>>

建立新的结果容器:supplier方法

supplier方法必须返回一个结果为空的Supplier,也就是一个无参数函数,在调用时它会创建一个空的累加器实例,供数据收集过程使用。

将元素天剑到结果容器:accumulator方法

accumulator方法会返回执行规约操作的函数。当遍历到流中第n个元素时,这个函数执行时会有2个参数:保存规约结果的累加器,还有第n个元素本身。

对结果容器应用最终转换:finisher方法

在遍历完流后,finisher方法必须返回在累积过程的最后要调用的一个函数,以便将累加器对象转换为整个集合操作的最终结果。

合并2个结果容器:combiner方法

combiner方法会返回一个供规约操作使用的函数,它定义了对流的各个部分进行并行处理时,各个子部分规约说的的累加器要如何合并。

characteristics方法

characteristics方法会返回一个不可变的Characteristics集合,他定义了收集器的行为,尤其是关于流是否可以并行规约,以及可以使用哪些优化的提示。
Characteristics是一个包含三个项目的枚举:

  • UNORDERED——规约结果不受流中项目的遍历和累积顺序的影响。
  • CONCURRENT——accumulator函数可以从多个线程同时调用,且该收集器可以并行规约流。如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可进行并行规约。
  • IDENTITY_FINISH——这表明完成器方法返回的函数时一个恒等函数,可以跳过。这种情况下,累加器对象会直接用做规约过程的最终结果。这也意味着,将累加器A不加检查的转换为R也是安全的。

我们开发的ToListCollector是IDENTIFY_FINISHE的,因为用来累积流中元素的List已经是我们要的最终结果,用不着进一步转换了。

实现自己的收集器

下面实现一个自己的ToListCollector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static class ToListCollector<T> implements Collector<T,List<T>,List<T>> {

    @Override
    public Supplier<List<T>> supplier() { // 建立新的结果容器
        return () -> new ArrayList<T>();
    }

    @Override
    public BiConsumer<List<T>, T> accumulator() { // 将元素追加到结果容器
        return (List,item) -> List.add(item);
    }

    @Override
    public BinaryOperator<List<T>> combiner() { // 合并2个结果容器
        return (list1,list2) -> {
            list1.addAll(list2);
            return list1;
        };
    }

    @Override
    public Function<List<T>, List<T>> finisher() { // 对结果容器进行最终转换
        return Function.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH,Characteristics.CONCURRENT));
    }
}

// 使用自定义的收集器
List<Dish> dishes = menu.stream().collect(new ToListCollector<Dish>());

参考《java8实战》