Java8-Collect收集Stream

Java基础

浏览数:84

2019-6-18

Collection, Collections, collect, Collector, Collectos

Collection是Java集合的祖先接口。
Collectionsjava.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接口来定义的。

预定义的收集器

下面简单演示基本的内置收集器。模拟数据源如下:

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

最大值,最小值,平均值

// 为啥返回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的元素拼起来?

//直接连接
String join1 = dishes.stream().map(Dish::getName).collect(Collectors.joining());

//逗号
String join2 = dishes.stream().map(Dish::getName).collect(Collectors.joining(", "));

toList

List<String> names = dishes.stream().map(Dish::getName).collect(toList());

将原来的Stream映射为一个单元素流,然后收集为List。

toSet

Set<Type> types = dishes.stream().map(Dish::getType).collect(Collectors.toSet());

将Type收集为一个set,可以去重复。

toMap

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创建收集器。比如,求和

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

Optional<Integer> totalCalories3 = dishes.stream().map(Dish::getCalories).reduce(Integer::sum);

虽然都可以,但考量效率的话,还是要选择下面这种

int sum = dishes.stream().mapToInt(Dish::getCalories).sum();

根据情况选择最佳方案

上面的demo说明,函数式编程通常提供了多种方法来执行同一个操作,使用收集器collect比直接使用stream的api用起来更加复杂,好处是collect能提供更高水平的抽象和概括,也更容易重用和自定义。

我们的建议是,尽可能为手头的问题探索不同的解决方案,始终选择最专业的一个,无论从可读性还是性能来看,这一般都是最好的决定。

reducing除了接收一个初始值,还可以把第一项当作初始值

Optional<Dish> mostCalorieDish = dishes.stream()
                .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

reducing

关于reducing的用法比较复杂,目标在于把两个值合并成一个值。

public static <T, U>
    Collector<T, ?, U> reducing(U identity,
                                Function<? super T, ? extends U> mapper,
                                BinaryOperator<U> op) 

首先看到3个泛型,

  1. U是返回值的类型,比如上述demo中计算热量的,U就是Integer
  2. 关于T,T是Stream里的元素类型。由Function的函数可以知道,mapper的作用就是接收一个参数T,然后返回一个结果U。对应demo中Dish
  3. ?在返回值Collector的泛型列表的中间,这个表示容器类型,一个收集器当然需要一个容器来存放数据。这里的?则表示容器类型不确定。事实上,在这里的容器就是U[]

关于参数:

  1. identity是返回值类型的初始值,可以理解为累加器的起点。
  2. mapper则是map的作用,意义在于将Stream流转换成你想要的类型流。
  3. op则是核心函数,作用是如何处理两个变量。其中,第一个变量是累积值,可以理解为sum,第二个变量则是下一个要计算的元素。从而实现了累加。

reducing还有一个重载的方法,可以省略第一个参数,意义在于把Stream里的第一个参数当做初始值。

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效果:

//手动实现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;
                }
        );

关于上述做法解释一下。

<U> U reduce(U identity,
                 BiFunction<U, ? super T, U> accumulator,
                 BinaryOperator<U> combiner);
  1. U是返回值类型,这里就是List
  2. BiFunction<U, ? super T, U> accumulator是是累加器,目标在于累加值和单个元素的计算规则。这里就是List和元素做运算,最终返回List。即,添加一个元素到list。
  3. 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 。

Map<Type, List<Dish>> dishesByType = dishes.stream().collect(groupingBy(Dish::getType));

这里

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,那么可以自定义分类器为:

private CaloricLevel getCaloricLevel(Dish d) {
    if (d.getCalories() <= 400) {
      return CaloricLevel.DIET;
    } else if (d.getCalories() <= 700) {
      return CaloricLevel.NORMAL;
    } else {
      return CaloricLevel.FAT;
    }
}

再传入参数即可

Map<CaloricLevel, List<Dish>> dishesByLevel = dishes.stream()
        .collect(groupingBy(this::getCaloricLevel));

多级分组

groupingBy还重载了其他几个方法,比如

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的源码为:

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。

//多级分组
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);
  });
});

验证结果为:

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

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

Map<Type, Optional<Dish>> mostCaloricByType = dishes.stream()
        .collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories))));

这里的Optional没有意义,因为肯定不是null。那么只好取出来了。使用collectingAndThen

Map<Type, Dish> mostCaloricByType = dishes.stream()
    .collect(groupingBy(Dish::getType,
        collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));

到这里似乎结果出来了,但IDEA不同意,编译黄色报警,按提示修改后变为:

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则直接抛出异常:

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

Map<Type, Integer> totalCaloriesByType = dishes.stream()
    .collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));

然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接收两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接收特定类型元素的收集器适应不同类型的对象。我么来看一个使用这个收集器的实际例子。比如你想得到,对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来,如下所示:

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. 例如,如果你是素食者,你可能想要把菜单按照素食和非素食分开:

Map<Boolean, List<Dish>> partitionedMenu = dishes.stream().collect(partitioningBy(Dish::isVegetarian));

当然,使用filter可以达到同样的效果:

List<Dish> vegetarianDishes = dishes.stream().filter(Dish::isVegetarian).collect(Collectors.toList());

分区相对来说,优势就是保存了两个副本,当你想要对一个list分类时挺有用的。同时,和groupingBy一样,partitioningBy一样有重载方法,可以指定分组value的类型。

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收集器的最后一个例子,我们把菜单数据模型放在一边,来看一个更加复杂也更为有趣的例子:将数组分为质数和非质数。

首先,定义个质数分区函数:

private boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

然后找出1到100的质数和非质数

Map<Boolean, List<Integer>> partitionPrimes = IntStream.rangeClosed(2, 100).boxed()
    .collect(partitioningBy(this::isPrime));

参考

  • Java8 In Action

作者:Ryan.Miao