写点什么

可能是 Java Stream 的最佳实践(二)

用户头像
ES_her0
关注
发布于: 2021 年 02 月 25 日

上一篇介绍了 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 和其他函数式语言在很久之前都支持了,属于一个基础能力;然后开发也有细分领域,做业务的更关注代码可读性和开发效率,做中间件或底层的关注性能与稳定性,没有谁可以高人一等,各自都有需要解决的问题,对于业务来说,能像堆积木一样做业务简直是做梦都想实现的!(扯远了)

首先还是看方法的签名,为了减少视觉干扰我去掉了范型那些菱形计算符:

//1Collector groupingBy(Function classifier);
//2Collector groupingBy(Function classifier, Collector downstream);
//3Collector groupingBy(Function classifier, Supplier mapFactory, Collector downstream);
复制代码

groupingBy 一般需要配合 collect 使用,collect 方法如上文所述,接受一个 Collector 接口作为参数,这个接口我们稍后再说,现在只需要知道它也是个容器,用来装归约后的对象。

那么上面三种签名怎么理解呢?

  1. 直接传入 function,默认返回 Collector<T, ?, Map<K, List<T>>>,重点在于 Map<K, List<T>>,即组装成 Key-List 的格式

  2. 第二种入参多了一个 Collector,可以做套娃:算总数,继续分组都可以。返回 Collector<T, ?, Map<K, D>>,第一种的 List 变成了一个 D,就是归约成了单个值,最后组装成了 K-V

  3. 和第二种类似,但多了一个 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 这些方法从组合到计算极大的方便了业务数据的转换,也极大的提升了开发效率,但具备了这些能力之后,也需要合理使用才能最大化它的价值,下一篇文章就来介绍我的日常开发过程中总结的最佳实践。


发布于: 2021 年 02 月 25 日阅读数: 18
用户头像

ES_her0

关注

还未添加个人签名 2018.03.21 加入

还未添加个人简介

评论

发布
暂无评论
可能是Java Stream的最佳实践(二)