gpt4 book ai didi

再聊JavaStream的一些实战技能与注意点

转载 作者:我是一只小鸟 更新时间:2023-09-13 15:07:02 36 4
gpt4 key购买 nike

大家好,又见面了.

在此前我的文章中,曾分2篇详细探讨了下JAVA中Stream流的相关操作,2篇文章收获了累计 10w+ 阅读、 2k+ 点赞以及 5k+ 收藏的记录。能够得到众多小伙伴的认可,是技术分享过程中最开心的事情.

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

不少小伙伴在评论中提出了一些的疑问或自己的独到见解,也在评论区中进行了热烈的互动讨论。梳理了下相关评论内容,针对此前文章中没有提及的一些典型讨论点拿出来聊一聊,也是作为对此前两篇Java Stream相关文章内容的补充完善.

Stream处理时列表到底循环了多少次

看下面这段Stream使用的常见场景:

                        
                          Stream.of(17, 22, 35, 12, 37)
        .filter(age -> age > 18)
        .filter(age -> age < 35)
        .map(age -> age + "岁")
        .collect(Collectors.toList());

                        
                      

在这段代码里面,同时有2个 filter 操作和1个 map 操作以及1个 collect 操作,那么这段代码执行的时候,究竟是对这个list执行了几次循环操作呢?是每一个Stream步骤都会进行一次遍历操作吗?为了验证这个问题,我们将上述代码改写一下,打印下每个步骤的结果:

                        
                                  List<String> ages = Stream.of(17,22,35,12,37)
                .filter(age -> {
                    System.out.println("filter1 处理:" + age);
                    return age > 18;
                })
                .filter(age -> {
                    System.out.println("filter2 处理:" + age);
                    return age < 35;
                })
                .map(age -> {
                    System.out.println("map 处理:" + age);
                    return age + "岁";
                })
                .collect(Collectors.toList());

                        
                      

先执行,得到如下的执行结果。其实结果已经很明显的可以看出,stream流处理的时候,是对列表进行了 一次 循环,然后顺序的执行给定的stream执行语句.

按照上述输出的结果,可以看出其处理的过程可以等价于如下的常规写法:

                        
                                  List<Integer> ages = Arrays.asList(17,22,35,12,37);
        List<String> results = new ArrayList<>();
        for (Integer age : ages) {
            if (age > 18) {
                if (age < 35) {
                    results.add(age + "岁");
                }
            }
        }
        System.out.println(results);

                        
                      

所以,Stream并不会去遍历很多次。其实上述逻辑也符合Stream 流水线 加工的整体模式,试想一下,一条流水线上分环节加工一件商品,同一件产品也不会在流水线上加工2次的吧~ 。

img

Stream究竟是让代码更易读还是更难懂

自 Java8 引入了 Lambda 、 函数式接口 、 Stream 等新鲜内容以来,针对使用Stream或Lambda语法究竟是让代码更易懂还是更复杂的争议,一直就没有停止过。有的同学会觉得Stream语法的方式,一眼就可以看出业务逻辑本身的含义,也有一些同学认为使用了Stream之后代码的可读性降低了很多.

其实,这是个人编码模式与理念上的不同感知而已。Stream主打的就是让代码更聚焦自身逻辑,省去其余繁文缛节对代码逻辑的干扰,整体编码上会更加的简洁。但是刚接触的时候,难免会需要一定的适应期。技术总是在不断迭代、不断拥抱新技术、不去刻意排斥新技术,或许是一个更好的选项.

那么,话说回来,如何让自己能够一眼看懂Stream代码、感受到Stream的简洁之美呢?分享个人的一个经验:

  1. 先了解几个常见的Stream的api的功能含义(Stream的API封装的很优秀,很多都是字面意义就可以理解)
  2. 改变意识, 聚焦纯粹的业务逻辑本身 ,不要在乎具体写法细节

下面举了个例子,如何用上述的2条方法,快速的让自己理解一段Stream代码表达的意思.

那么上面这段代码的含义就是,先根据员工子公司过滤所有上海公司的人员,再获取员工工资最高的那个人信息。怎么样?按照这个方法,是不是可以发现,Stream的方式,确实更加容易理解了呢~ 。

在IDEA中debug调试Stream代码段

技术分享其实是一个双向的过程,分享的同时,也是自我学习与提升的机会,除了可以梳理发现一些自己之前忽略的知识点并加以巩固,还可以在互动的时候get到新的技能.

比如,我在此前的 Java Stream 介绍的文章中,有提过基于Stream进行编码的时候会导致代码 debug调试 的时候会比较困难,尤其是那种只有一行Lambda表达式的情况(因为如果代码逻辑多行编写的时候,可以在代码块内部打断点,这样其实也可以进行debug调试).

关于这一点,很多小伙伴也有相同的感受,比如下面这个评论:

你以为这就结束了?接下来一个小伙伴的提示,“震惊”了众人!纳尼?原来Stream代码段也是可以debug单步调试的?

跟踪Stream中单步处理过程的操作入口按钮长这样:

并且,另一个小伙伴补充说这是 IDEA 从 2019.03 版本开始有的功能:

嗯?难怪呢,我一直用的2019.02版本的,所以才没用上这个功能(强行给自己找了个台阶、哈哈哈)。于是,我悄悄的将自己的idea升级到了最新的2023.02版本(PS:新版本的UI挺好看,就是bug贼多)。好啦,言归正传,那么究竟应该如何利用IDEA来实现单步DEBUG呢?一一起来感受下吧.

在代码行前面添加断点的时候,如果要打断点的这行代码里面包含Stream 中间方法 ( map\filter\sort 之类的)的时候,会提示让选择 断点的具体类型 .

一共有 三种 类型断点可供选择:

  • Line :断点打在这一行上,不会进入到具体的Stream执行函数块中
  • Lambda :代码打在内部的lambda代码块上
  • Line and Lambda :代码走到这行或者执行这一行具体的函数块内容的时候,都会进入断点

下面这个图可以更清晰的解释清楚上述三者的区别。一般来说,我们debug的时候,更多的是关注自身的业务具体逻辑,而不会过多去关注Stream执行框架的运转逻辑,所以 大部分情况下,我们选择第二个Lambda选项即可 .

按照上面所述,我们在代码行前面添加一个Lambda类型断点,然后debug模式启动程序执行,等到断点进入的时候便可以正常的进行debug并查看内部的处理逻辑了.

如果遇到图中这种只有一行的lambda形式代码,想要看下返回值到底是什么的,可以选中执行的片段,然后 ALT+F8 打开Evaluate界面(或者右键选择 Evaluate Expression ),点击 Evaludate 按钮执行查看具体结果.

大部分情况下,掌握这一点,已经可以应付日常的开发过程中对Stream代码逻辑的debug诉求了。但是上述过程偏向于细节,如果需要看下整个Stream代码段整体层面的执行与数据变化过程,就需要上面提到的 Stream Trace 功能。要想使用该功能, 断点的位置也是有讲究的 ,必须要将断点打在stream 开流 的地方,否则看不到任何内容。另外,对于一些新版本的IDEA而言,这个入口也比较隐蔽,藏在了下拉菜单中,就像下面这个样子.

我们找到 Trace Current Stream Chain 并点击,可以打开 Stream Trace 界面,这里以chain链的方式,和stream代码块逻辑对应,分步骤展示了每个stream处理环节的执行结果。比如我们以 filter 环节为例,窗口中以左右视图的形式,左侧显示了原始输入的内容,右侧是经过filter处理后符合条件并保留下来的数据内容,并且还有连接线进行指引,一眼就可以看出哪些元素是被过滤舍弃了的:

不止于此,Stream Trace除了提供上述分步查看结果的能力,还支持直接显示整体的链路执行全貌。点击Stream Trace窗口左下角的 Flat Mode 按钮即可切换到 全貌模式 ,可以看到最初原始数据,如何一步步被处理并得到最终的结果.

看到这里,以后还会说Stream不好调试吗?至少我不会了.

小心Collectors.toMap出现key值重复报错

在我们常规的HashMap的 put(key,value) 操作中,一般很少会关注key是否已经在map中存在,因为put方法的策略是存在会覆盖已有的数据。但是在Stream中,使用 Collectors.toMap 方法来实现的时候,可能稍不留神就会踩坑。所以,有小伙伴在评论区热心的提示,在使用此方法的时候需要手动加上 mergeFunction 以防止key冲突.

这个究竟是怎么回事呢?我们看下面的这段代码:

                        
                          public void testCollectStopOptions() {
    List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
    // collect成HashMap,key为id,value为Dept对象
    Map<Integer, Dept> collectMap = ids.stream()
            .collect(Collectors.toMap(Dept::getId, dept -> dept));
    System.out.println("collectMap:" + collectMap);
}


                        
                      

执行上述代码,不出意外的话会出意外。如下结果:

                        
                          Exception in thread "main" java.lang.IllegalStateException: Duplicate key Dept{id=22}
	at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)
	at java.util.HashMap.merge(HashMap.java:1254)
	at java.util.stream.Collectors.lambda$toMap$58(Collectors.java:1320)
	at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
	at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
	at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
	at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
	at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)

                        
                      

因为在收集器进行map转换的时候,由于 出现了重复的key ,所以抛出异常了。 为什么会出现异常呢?为什么不是以为的覆盖呢?我们看下源码的实现逻辑:

可以看出,默认情况下如果出现重复key值,会对外抛出 IllegalStateException 异常。同时,我们看到,它其实也有提供重载方法,可以由使用者自行指定key值重复的时候的执行策略:

所以,我们的目标是出现重复值的时候,使用新的值覆盖已有的值而非抛出异常,那我们直接手动指定下让toMap按照我们的要求进行处理,就可以啦。改造下前面的那段代码,传入自行实现的 mergeFunction 函数块,即指定下如果key重复的时候,以新一份的数据为准:

                        
                              public void testCollectStopOptions() {
        List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
        // collect成HashMap,key为id,value为Dept对象
        Map<Integer, Dept> collectMap = ids.stream()
                .collect(Collectors.toMap(
                        Dept::getId,
                        dept -> dept,
                        (exist, newOne) -> newOne));
        System.out.println("collectMap:" + collectMap);
    }

                        
                      

再次执行,终于看到我们预期中的结果了:

                        
                          collectMap:{17=Dept{id=17}, 22=Dept{id=22}}

                        
                      

By The Way,个人感觉JDK在这块的默认实现逻辑有点不合理。虽然现在默认的抛异常方式,可以强制让使用端感知并去指定自己的逻辑,但这默认逻辑与map的put操作默认逻辑不一致,也让很多人都会无辜踩坑。如果将默认值改为有则覆盖的方式,或许会更符合常理一些 —— 毕竟被广泛使用的HashMap的源码里,put操作默认就是覆盖的,不信可以看HashMap源码的实现逻辑:

慎用peek承载业务处理逻辑

peek 和 foreach 在Stream流操作中,都可以实现对元素的遍历操作。区别点在与peek属于 中间方法 ,而foreach属于 终止方法 。这也就意味着peek只能作为管道中途的一个处理步骤,而没法直接执行得到结果,其后面必须还要有其它终止操作的时候才会被执行;而foreach作为无返回值的终止方法,则可以直接执行相关操作.

那么,只要有终止方法一起,peek方法就一定会被执行吗? 非也 ! 看版本、看场景! 比如在 JDK1.8 版本中,下面这段代码中的peek方法会正常执行,但是到了 JDK17 中就会 被自动优化掉而不执行 peek中的逻辑:

                        
                              public void testPeekAndforeach() {
        List<String> sentences = Arrays.asList("hello world", "Jia Gou Wu Dao");
        sentences.stream().peek(sentence -> System.out.println(sentence)).count();
    }

                        
                      

至于原因,可以看下JDK17官方API文档中的描述:

因为对于 findFirst 、 count 之类的方法,peek操作被视为 与结果无关联的操作 ,直接被优化掉不执行了。所以说最好按照API设计时预期的场景去使用API,避免自己给自己埋坑.

我们从peek的源码的注释上可以看出,peek的推荐使用场景是用于一些调试场景,可以借助peek来将各个元素的信息打印出来,便于开发过程中的调试与问题定位分析.

我们再看下peek这个词的含义解释:

既然开发者给它起了这么个名字,似乎确实仅是为了窥视执行过程中数据的变化情况。为了避免让自己踩坑,最好按照设计者推荐的用途用法进行使用,否则即使现在没问题,也不能保证后续版本中不会出问题.

img

字符串拼接明明有join,那么Stream中Collectors.join存在意义是啥

在介绍Stream流的 收集器 时,有介绍过使用 Collectors.joining 来实现多个字符串元素之间按照要求进行拼接的实现。比如将给定的一堆字符串用逗号分隔拼接起来,可以这么写:

                        
                              public void testCollectJoinStrings() {
        List<String> ids = Arrays.asList("AAA", "BBB", "CCC");
        String joinResult = ids.stream().collect(Collectors.joining(","));
        System.out.println(joinResult);
    }

                        
                      

有很多同学就提出字符串元素拼接直接用 String.join 就可以了,完全没必要搞这么复杂.

如果是纯字符串简单拼接的场景,确实直接String.join会更简单一些,这种情况下使用Stream进行拼接的确有些 大材小用 了。 但是 joining 的方法优势要体现在Stream体系中,也就是与其余Stream操作可以结合起来综合处理。 String.join 对于简单的字符串拼接是OK的,但是如果是一个Object对象列表,要求将Object某一个字段按照指定的拼接符去拼接的时候,就力不从心了——而这就是使用 Collectors.joining 的时机了。比如下面的实例:

小结

好啦,关于Java Stream相关的内容点的补充,就聊到这里啦。如果需要全面了解Java Stream的相关内容,可以看我此前分享的文档。那么,你对 Java Stream 是否还有哪些疑问或者自己的独特理解呢?欢迎一起交流下.

传送门:

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

我是悟道,聊技术、又不仅仅聊技术~ 。

如果觉得有用,请 点赞 + 关注 让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新.

期待与你一起探讨,一起成长为更好的自己.

最后此篇关于再聊JavaStream的一些实战技能与注意点的文章就讲到这里了,如果你想了解更多关于再聊JavaStream的一些实战技能与注意点的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。

36 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com