https://www.jb51.net/article/138519.htm
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 |
|
最大值,最小值,平均值
?
1 2 3 4 5 6 7 8 9 10 |
|
这几个简单的统计指标都有Collectors内置的收集器函数,尤其是针对数字类型拆箱函数,将会比直接操作包装类型开销小很多。
连接收集器
想要把Stream的元素拼起来?
?
1 2 3 4 |
|
toList
?
1 |
|
将原来的Stream映射为一个单元素流,然后收集为List。
toSet
?
1 |
|
将Type收集为一个set,可以去重复。
toMap
?
1 |
|
有时候可能需要将一个数组转为map,做缓存,方便多次计算获取。toMap提供的方法k和v的生成函数。(注意,上述demo是一个坑,不可以这样用!!!, 请使用toMap(Function, Function, BinaryOperator))
上面几个几乎是最常用的收集器了,也基本够用了。但作为初学者来说,理解需要时间。想要真正明白为什么这样可以做到收集,就必须查看内部实现,可以看到,这几个收集器都是基于java.util.stream.Collectors.CollectorImpl,也就是开头提到过了Collector的一个实现类。后面自定义收集器会学习具体用法。
自定义归约reducing
前面几个都是reducing工厂方法定义的归约过程的特殊情况,其实可以用Collectors.reducing创建收集器。比如,求和
?
1 2 3 |
|
当然也可以直接使用reduce
?
1 |
|
虽然都可以,但考量效率的话,还是要选择下面这种
?
1 |
|
根据情况选择最佳方案
上面的demo说明,函数式编程通常提供了多种方法来执行同一个操作,使用收集器collect比直接使用stream的api用起来更加复杂,好处是collect能提供更高水平的抽象和概括,也更容易重用和自定义。
我们的建议是,尽可能为手头的问题探索不同的解决方案,始终选择最专业的一个,无论从可读性还是性能来看,这一般都是最好的决定。
reducing除了接收一个初始值,还可以把第一项当作初始值
?
1 2 |
|
reducing
关于reducing的用法比较复杂,目标在于把两个值合并成一个值。
?
1 2 3 4 |
|
首先看到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 |
|
先看返回值的区别,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 |
|
关于上述做法解释一下。
?
1 2 3 |
|
U是返回值类型,这里就是List
BiFunction accumulator是是累加器,目标在于累加值和单个元素的计算规则。这里就是List和元素做运算,最终返回List。即,添加一个元素到list。
BinaryOperator combiner是组合器,目标在于把两个返回值类型的变量合并成一个。这里就是两个list合并。
这个解决方案有两个问题:一个是语义问题,一个是实际问题。语义问题在于,reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变归约。相反,collect方法的设计就是要改变容器,从而累积要输出的结果。这意味着,上面的代码片段是在滥用reduce方法,因为它在原地改变了作为累加器的List。错误的语义来使用reduce方法还会造成一个实际问题:这个归约不能并行工作,因为由多个线程并发修改同一个数据结构可能会破坏List本身。在这种情况下,如果你想要线程安全,就需要每次分配一个新的List,而对象分配又会影响性能。这就是collect适合表达可变容器上的归约的原因,更关键的是它适合并行操作。
总结:reduce适合不可变容器归约,collect适合可变容器归约。collect适合并行。
分组
数据库中经常遇到分组求和的需求,提供了group by原语。在Java里, 如果按照指令式风格(手动写循环)的方式,将会非常繁琐,容易出错。而Java8则提供了函数式解法。
比如,将dish按照type分组。和前面的toMap类似,但分组的value却不是一个dish,而是一个List。
?
1 |
|
这里
?
1 2 |
|
参数分类器为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 |
|
再传入参数即可
?
1 2 |
|
多级分组
groupingBy还重载了其他几个方法,比如
?
1 2 3 |
|
泛型多的恐怖。简单的认识一下。classifier还是分类器,就是接收stream的元素类型,返回一个你想要分组的依据,也就是提供分组依据的基数的。所以T表示stream当前的元素类型,K表示分组依据的元素类型。第二个参数downstream,下游是一个收集器Collector. 这个收集器元素类型是T的子类,容器类型container为A,reduction返回值类型为D。也就是说分组的K通过分类器提供,分组的value则通过第二个参数的收集器reduce出来。正好,上个demo的源码为:
?
1 2 3 4 |
|
将toList当作reduce收集器,最终收集的结果是一个List
?
1 2 3 4 5 6 7 8 9 10 11 |
|
验证结果为:
?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
总结:groupingBy的核心参数为K生成器,V生成器。V生成器可以是任意类型的收集器Collector。
比如,V生成器可以是计算数目的, 从而实现了sql语句中的select count(*) from table A group by Type
?
1 2 3 4 |
|
sql查找分组最高分select MAX(id) from table A group by Type
?
1 2 |
|
这里的Optional没有意义,因为肯定不是null。那么只好取出来了。使用collectingAndThen
?
1 2 3 |
|
到这里似乎结果出来了,但IDEA不同意,编译黄色报警,按提示修改后变为:
?
1 2 3 |
|
是的,groupingBy就变成toMap了,key还是Type,value还是Dish,但多了一个参数!!这里回应开头的坑,开头的toMap演示是为了容易理解,真那么用则会被搞死。我们知道把一个List重组为Map必然会面临k相同的问题。当K相同时,v是覆盖还是不管呢?前面的demo的做法是当k存在时,再次插入k则直接抛出异常:
?
1 2 |
|
正确的做法是提供处理冲突的函数,在本demo中,处理冲突的原则就是找出最大的,正好符合我们分组求最大的要求。(真的不想搞Java8函数式学习了,感觉到处都是性能问题的坑)
继续数据库sql映射,分组求和select sum(score) from table a group by Type
?
1 2 |
|
然而常常和groupingBy联合使用的另一个收集器是mapping方法生成的。这个方法接收两个参数:一个函数对流中的元素做变换,另一个则将变换的结果对象收集起来。其目的是在累加之前对每个输入元素应用一个映射函数,这样就可以让接收特定类型元素的收集器适应不同类型的对象。我么来看一个使用这个收集器的实际例子。比如你想得到,对于每种类型的Dish,菜单中都有哪些CaloricLevel。我们可以把groupingBy和mapping收集器结合起来,如下所示:
?
1 2 |
|
这里的toSet默认采用的HashSet,也可以手动指定具体实现toCollection(HashSet::new)
分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称为分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组:true or false. 例如,如果你是素食者,你可能想要把菜单按照素食和非素食分开:
?
1 |
|
当然,使用filter可以达到同样的效果:
?
1 |
|
分区相对来说,优势就是保存了两个副本,当你想要对一个list分类时挺有用的。同时,和groupingBy一样,partitioningBy一样有重载方法,可以指定分组value的类型。
?
1 2 3 4 5 6 7 |
|
作为使用partitioningBy收集器的最后一个例子,我们把菜单数据模型放在一边,来看一个更加复杂也更为有趣的例子:将数组分为质数和非质数。
首先,定义个质数分区函数:
?
1 2 3 4 |
|
然后找出1到100的质数和非质数
?
1 2 |
|