Java8-Collect收集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 | final ArrayList<Dish> dishes = Lists.newArrayList( |
最大值,最小值,平均值
1 | // 为啥返回Optional? 如果stream为null怎么办, 这时候Optinal就很有意义了 |
这几个简单的统计指标都有Collectors内置的收集器函数,尤其是针对数字类型拆箱函数,将会比直接操作包装类型开销小很多。
连接收集器
想要把Stream的元素拼起来?
1 | //直接连接 |
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 | Integer totalCalories = dishes.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j)); |
当然也可以直接使用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 | Optional<Dish> mostCalorieDish = dishes.stream() |
reducing
关于reducing的用法比较复杂,目标在于把两个值合并成一个值。
1 | public static <T, U> |
首先看到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 | public static <T> Collector<T, ?, Optional<T>> |
先看返回值的区别,T表示输入值和返回值类型,即输入值类型和输出值类型相同。还有不同的就是Optional了。这是因为没有初始值,而第一个参数有可能是null,当Stream的元素是null的时候,返回Optional就很意义了。
再看参数列表,只剩下BinaryOperator
。BinaryOperator是一个三元组函数接口,目标是将两个同类型参数做计算后返回同类型的值。可以按照1>2? 1:2
来理解,即求两个数的最大值。求最大值是比较好理解的一种说法,你可以自定义lambda表达式来选择返回值。那么,在这里,就是接收两个Stream的元素类型T,返回T类型的返回值。用sum累加来理解也可以。
上述的demo中发现reduce和collect的作用几乎一样,都是返回一个最终的结果,比如,我们可以使用reduce实现toList效果:
1 | //手动实现toListCollector --- 滥用reduce, 不可变的规约---不可以并行 |
关于上述做法解释一下。
1 | <U> U reduce(U identity, |
- 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 | public static <T, K> Collector<T, ?, Map<K, List<T>>> |
参数分类器为Function,旨在接收一个参数,转换为另一个类型。上面的demo就是把stream的元素dish转成类型Type,然后根据Type将stream分组。其内部是通过HashMap来实现分组的。groupingBy(classifier, HashMap::new, downstream);
除了按照stream元素自身的属性函数去分组,还可以自定义分组依据,比如根据热量范围分组。
既然已经知道groupingBy的参数为Function, 并且Function的参数类型为Dish,那么可以自定义分类器为:
1 | private CaloricLevel getCaloricLevel(Dish d) { |
再传入参数即可
1 | Map<CaloricLevel, List<Dish>> dishesByLevel = dishes.stream() |
多级分组
groupingBy还重载了其他几个方法,比如
1 | public static <T, K, A, D> |
泛型多的恐怖。简单的认识一下。classifier还是分类器,就是接收stream的元素类型,返回一个你想要分组的依据,也就是提供分组依据的基数的。所以T
表示stream当前的元素类型,K
表示分组依据的元素类型。第二个参数downstream,下游是一个收集器Collector. 这个收集器元素类型是T的子类,容器类型container为A,reduction返回值类型为D。也就是说分组的K通过分类器提供,分组的value则通过第二个参数的收集器reduce出来。正好,上个demo的源码为:
1 | public static <T, K> Collector<T, ?, Map<K, List<T>>> |
将toList
当作reduce收集器,最终收集的结果是一个List<Dish>
, 所以分组结束的value类型是List<Dish>
。那么,可以类推value类型取决于reduce收集器,而reduce收集器则有千千万。比如,我想对value再次分组,分组也是一种reduce。
1 | //多级分组 |
验证结果为:
1 | ---------------------------------- |
总结:groupingBy的核心参数为K生成器,V生成器。V生成器可以是任意类型的收集器Collector。
比如,V生成器可以是计算数目的, 从而实现了sql语句中的select count(*) from table A group by Type
1 | Map<Type, Long> typesCount = dishes.stream().collect(groupingBy(Dish::getType, counting())); |
sql查找分组最高分select MAX(id) from table A group by Type
1 | Map<Type, Optional<Dish>> mostCaloricByType = dishes.stream() |
这里的Optional没有意义,因为肯定不是null。那么只好取出来了。使用collectingAndThen
1 | Map<Type, Dish> mostCaloricByType = dishes.stream() |
到这里似乎结果出来了,但IDEA不同意,编译黄色报警,按提示修改后变为:
1 | Map<Type, Dish> mostCaloricByType = dishes.stream() |
是的,groupingBy就变成toMap了,key还是Type,value还是Dish,但多了一个参数!!这里回应开头的坑,开头的toMap演示是为了容易理解,真那么用则会被搞死。我们知道把一个List重组为Map必然会面临k相同的问题。当K相同时,v是覆盖还是不管呢?前面的demo的做法是当k存在时,再次插入k则直接抛出异常:
1 | java.lang.IllegalStateException: Duplicate key Dish(name=pork, vegetarian=false, calories=800, type=MEAT) |
正确的做法是提供处理冲突的函数,在本demo中,处理冲突的原则就是找出最大的,正好符合我们分组求最大的要求。(真的不想搞Java8函数式学习了,感觉到处都是性能问题的坑)
继续数据库sql映射,分组求和select sum(score) from table a group by Type
1 | Map<Type, Integer> totalCaloriesByType = dishes.stream() |
然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接收两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接收特定类型元素的收集器适应不同类型的对象。我么来看一个使用这个收集器的实际例子。比如你想得到,对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来,如下所示:
1 | Map<Type, Set<CaloricLevel>> caloricLevelsByType = dishes.stream() |
这里的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 | Map<Boolean, Map<Type, List<Dish>>> vegetarianDishesByType = dishes.stream() |
作为使用partitioningBy收集器的最后一个例子,我们把菜单数据模型放在一边,来看一个更加复杂也更为有趣的例子:将数组分为质数和非质数。
首先,定义个质数分区函数:
1 |
|
然后找出1到100的质数和非质数
1 | Map<Boolean, List<Integer>> partitionPrimes = IntStream.rangeClosed(2, 100).boxed() |
(未完待续, 下一节自定义收集器)
参考
- Java8 In Action