上一篇介绍了 stream 的一些基础知识点,包括创建、过滤、转换、归约等等。本文将详细介绍 collect 的能力。
收集器
和 reduce 一样,collect 是一个归约操作,同时也是一个终端操作,数据经过一系列的处理到此即可返回结果。collect 还有一个名称叫收集器,在我看来它应该叫超级收集器。它功能过于强大,甚至可以省去很多中间操作,直接使用 collect 处理。首先看方法定义(或者有人叫方法签名):
<R, A> R collect(Collector<? super T, A, R> collector);
<R> R collect(Supplier<R> supplier,
BiConsumer<R, ? super T> accumulator,
BiConsumer<R, R> combiner);
复制代码
第一个我们用的最多,需要传入一个 Collector,下面会详细介绍。第二个定义和 reduce 类似,其实是使用 Collector 的完整版,不过多了一个 combiner 用于在多线程的环境下告诉多个线程如何组合结果。那么所谓的 Collector 完整版具体代表什么呢?supplier 表示基础数据,类似上一篇分析 reduce 的初始值,accumulator 表示累加算法。但总是这么写,繁琐且可读性较差,所以 jdk 又包装了一层 collector,大部分情况下我们使用第一种写法,语义更明确。
收集器的超能力
说超能力其实不夸张,相比没有这些能力的时候,光写一个分组的代码就会嵌套多个循环,代码逻辑复杂,可读性可维护性差。因为项目里面经常充斥着大量的此类的代码,很多人因此对 Java 嗤之以鼻,也间接推动了编程语言鄙视链。jdk 中提供的收集功能大致分为以下三个大类:
这三种可以相互组合,可以覆盖大部分的使用场景。说一点题外话,我会经常问自己:我们在编程时到底在做什么呢?其实就是数据转换,这个数据可能是内部产生的,也可能是外部接收到的,我们要做的就是把数据按照一定业务规则转换成我们想要的。为了提升数据转换过程中的效率、安全等问题,又演化出更多的语言、算法、并发等操作,一切归根结底都是在处理数据。而现在要分析的 Collector 就是数据处理的利器。
reducing
几乎所有的 Collector 汇总计算都可以用 reducing 实现,但可能会让代码看起来比较繁琐。先来看一个例子,计算所有用户的年龄之和:
List<User> users = Arrays.asList(
new User("John", 30),
new User("Julie", 35),
new User("Jack", 28));
int totalAge1 = users.stream().collect(reducing(0,User::getAge,Integer::sum));
int totalAge2 = users.stream().map(User::getAge).reduce(0, Integer::sum);
int totalAge3 = users.stream().collect(summingInt(User::getAge));
int totalAge4 = users.stream().mapToInt(User::getAge).sum();
复制代码
以上 4 种写法结果完全一致,但一般情况下我推荐选择 4,毕竟相同功能下代码越短越好。继续看一个例子,拼接字符串:
String name1 = users.stream().collect(reducing("", User::getName, (s1, s2) -> s1 + s2));
String name2 = users.stream().map(User::getName).collect(joining());
复制代码
以上也是一样的功能,但 2 明显要言简意赅很多。
上面两个例子说明 reducing 本身只能作为一个汇总归约的概念描述,不太适合做日常的数据转换,官方已经预设了很多方法供使用(官方没有提供的的极少数场景可能会需要使用),可读性和性能都会高于使用 reducing,主要的方法详细见下表:
把流中所有数据收集到一个 List 中 toSet 把流中所有数据收集到一个 Set 中 toMap 把流中所有数据收集到一个 Map 中 counting 计算流中的元素的个数 summarizingInt 对元素中的 Integer 进行统计:最大,最小,平均值,求和 joining 对流中的字符串元素进行拼接 maxBy/minBy 求最大/最小值,返回的是 Optional
这里是不完全列举,未列出的功能上大同小异,几乎可以覆盖所有的归约计算,官方不支持还可以自行通过 reducing 实现。
groupingBy
这个顾名思义,是分组的语义。很多人首先是通过 SQL 的 group by 认识到这个分组,其实也是日程业务数据转换过程中的一个重要功能。想象一下,如果用 for 和 if 来做分组这个功能是多么的啰嗦,细节的逻辑还容易出错,可读性也不会太高。可能有人会说了,封装的太多,提供了太多的功能,大家就不会写代码了,跟拼积木一样就把业务实现了,出了问题都不懂怎么回事。首先,group by 这样的能力 SQL 和其他函数式语言在很久之前都支持了,属于一个基础能力;然后开发也有细分领域,做业务的更关注代码可读性和开发效率,做中间件或底层的关注性能与稳定性,没有谁可以高人一等,各自都有需要解决的问题,对于业务来说,能像堆积木一样做业务简直是做梦都想实现的!(扯远了)
首先还是看方法的签名,为了减少视觉干扰我去掉了范型那些菱形计算符:
//1
Collector groupingBy(Function classifier);
//2
Collector groupingBy(Function classifier, Collector downstream);
//3
Collector groupingBy(Function classifier, Supplier mapFactory, Collector downstream);
复制代码
groupingBy 一般需要配合 collect 使用,collect 方法如上文所述,接受一个 Collector 接口作为参数,这个接口我们稍后再说,现在只需要知道它也是个容器,用来装归约后的对象。
那么上面三种签名怎么理解呢?
直接传入 function,默认返回 Collector<T, ?, Map<K, List<T>>>,重点在于 Map<K, List<T>>,即组装成 Key-List 的格式
第二种入参多了一个 Collector,可以做套娃:算总数,继续分组都可以。返回 Collector<T, ?, Map<K, D>>,第一种的 List 变成了一个 D,就是归约成了单个值,最后组装成了 K-V
和第二种类似,但多了一个 Supplier,表示指定一个容器来存放归约后的值,返回值和第二种也一样
具体看完代码就好理解了:
//准备数据
public class User {
private String name;
private int age;
private String city;
}
List<User> users = Arrays.asList(
new User("John", 30, "Shanghai"),
new User("Julie", 30, "Beijing"),
new User("Jack", 28, "Beijing"),
new User("Jimmy", 28, "Beijing"),
new User("Mike", 20, "Nanjing"));
复制代码
按照年龄分组,执行结果是:
//第一种
Map<Integer, List<User>> collect = users.stream().collect(Collectors.groupingBy(User::getAge));
{
20=[{name = Mike, age = 20, city =Nanjing}],
28=[{name = Jack, age = 28, city =Beijing}, {name = Jimmy, age = 28, city =Beijing}],
30=[{name = John, age = 30, city =Shanghai}, {name = Julie, age = 30, city =Beijing}]
}
复制代码
增加一些计算进来,按照年龄分组,统计这个年龄段下多少人:
//这里返回值有变化
Map<Integer, Long> collect =
users.stream().collect(Collectors.groupingBy(User::getAge, Collectors.counting()));
{20=1, 28=2, 30=2}
复制代码
进一步做转换,按照年龄分组且结果中我只想保留姓名:
Map<Integer, List<String>> collect = users.stream()
.collect(Collectors.groupingBy(User::getAge,
Collectors.mapping(User::getName,Collectors.toList())));
{
20=[Mike],
28=[Jack, Jimmy],
30=[John, Julie]
}
复制代码
第三种,指定容器存放数据,这一次我们按照城市分组,为了排序,结果放到一个 TreeMap 里面:
Map<String, List<User>> collect =
users.stream()
.collect(Collectors.groupingBy(User::getCity, TreeMap::new, Collectors.toList()));
{
Beijing=[{name = Julie, age = 30, city =Beijing}, {name = Jack, age = 28, city =Beijing}, {name = Jimmy, age = 28, city =Beijing}],
Nanjing=[{name = Mike, age = 20, city =Nanjing}],
Shanghai=[{name = John, age = 30, city =Shanghai}]
}
复制代码
最后,我们来一次套娃——多级分组
Map<String, Map<Integer, List<User>>> collect = users.stream().collect(
Collectors.groupingBy(User::getCity,Collectors.groupingBy(User::getAge)));
{
Beijing=
{
28=[{name = Jack, age = 28, city =Beijing}, {name = Jimmy, age = 28, city =Beijing}],
30=[{name = Julie, age = 30, city =Beijing}]
},
Shanghai=
{
30=[{name = John, age = 30, city =Shanghai}]
},
Nanjing=
{
20=[{name = Mike, age = 20, city =Nanjing}]
}
}
复制代码
虽然代码看起来比较长,但结构足够清晰,也容易理解。
partitioningBy
顾名思义,分区。第一眼看过来就陷入了疑惑,分区、分组不是一个意思吗?确实是一个意思。但 Java 的这两个方法还是存在一些区别的。看签名就知道了。
Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate);
Collector<T, ?, Map<Boolean, D>> partitioningBy(Predicate<? super T> predicate,
Collector<? super T, A, D> downstream);
复制代码
partitioningBy 返回的结果集里面的 key 永远是 Boolean,因为它的入参是一个 predicate,和 filter 类似,是一个断言。然后另外一个大的区别是,分区即使数据为空,也会有一个空集合节点,分组如果没有数据就没有这个。
//其实没有30岁以上的记录
Map<Boolean, List<User>> collect =
users.stream().collect(Collectors.partitioningBy(x -> x.getAge() > 30));
{
false=[{name = John, age = 30, city =Shanghai}, {name = Julie, age = 30, city =Beijing}, {name = Jack, age = 28, city =Beijing}, {name = Jimmy, age = 28, city =Beijing}, {name = Mike, age = 20, city =Nanjing}],
true=[]
}
复制代码
可以看到即使没有符合条件的,还会有一个空的 true 节点。比如一个极端场景下,我用一个空 list 来做 collect:
List<User> users = Lists.newArrayList();
Map<String, List<User>> collect1 = users.stream().collect(
Collectors.groupingBy(User::getCity));
Map<Boolean, List<User>> collect2 = users.stream().collect(
Collectors.partitioningBy(x -> x.getAge() > 28));
返回值是有区别的
groupingBy:
{}
partitioningBy:
{false=[], true=[]}
复制代码
小结
为什么说 collect 有超能力呢?首先提供了非常多实用的计算方法,然后还支持‘套娃’的功能,可以无限扩展。因为这个方法最终都是返回 Collectors,各个方法都可以相结合使用,这也是做这层包装的原因之一,不然在多个类型之间的转换就会非常的繁琐复杂。Stream 这些方法从组合到计算极大的方便了业务数据的转换,也极大的提升了开发效率,但具备了这些能力之后,也需要合理使用才能最大化它的价值,下一篇文章就来介绍我的日常开发过程中总结的最佳实践。
评论