关于Java8新特性Streams的思考

设计Streams的目的

无疑这一篇是讲Java8新引入的Streams特性,但是由于Java8发布已久,各种tutorial已经遍布网络,所以不准备再炒冷饭似的讲其语法和使用。这里只想分享一下,通过学习Streams,对为什么会设计并实现Streams这个特性做了深入的思考。

个人认为,Streams的出现有以下三个目的

  1. 对集合元素操作的简化和效率提升;
    Java语言的集合操作也是有历史的,其实任何一种历久弥新的语言在语法糖上总会有不断的改进以适应时代(C/C++这一代语言变化似乎较小,不是很了解,勿喷)。集合操作从最开始的类C语言,到1.5引入增强型的for,到Java8有了革命性的变化(至少从外观上看),这就是Streams!同时应该看到,后来者居上的那些“时髦”语言,在语法上的简洁性和效率上会成为各自的优势和卖点,作为业界最流行的编程语言之一,Java怎么不会在这方面博采众长,持续改进呢?
    从简洁性上看,Streams实现了类单行编程,以前大大的for循环完全可以改造成一行语句加一个Lamda表达式,至少从外观上更像一个21世纪的语言。流的概念,不论中间操作还是终结操作,都会让实践者仔细思考如何合理的安排迭代的处理方式(后面会具体展开),这样有助于提高集合操作的效率。
    同时,我相信,新的Streams设计不仅仅是样子货,在底层的迭代处理上会有很大的效率提升,流的概念,本身就是用来处理无限数据的,通过“流计算”,过大的集合导致的计算缓慢或内存耗尽或可彻底解决。

  2. Lamda表达式的最佳实践;
    Lamda表达式让Java语言第一次拥有了和JS类似的传递方法(代码块)的能力。随着越来越多的需求产生,以及技术的发展和前后端界限的模糊,各种前/后端语言,编译时/运行时语言,强类型/弱类型语言相互融合。Lamda表达式正是顺应这一潮流的产物,这是一个革命性的变化,对Java的编程模式会产生不小的影响。无疑这会简化代码的实现,更好的体现编程者的思想,当然和“老派”的Java语言编程似乎有了那么一些差距(Java10甚至出现了var关键字)。
    Lamda表达式虽然在Java8中正式引入,但不可能马上获得大量的实践,因为这是一种编程方式,而不是一个具体的解决问题的方法。那么怎么能快速的应用Lamda呢?这时Streams就派上用场了。因为后者就是解决集合操作这个具体问题的方法。如果想使用Streams,利用它的性能优势与简洁,那么Lamda表达式是绝对绕不开的,无形中就推广了这种新的编程模式,甚至是编程思想。
    这就是为什么Streams和Lamda在同一个版本中推出,这也是为什么说Lamda是一种时髦技术,Streams就是其落地的最佳实践。

  3. 顺应大数据时代的要求。
    Java8应该是2016年左右发布,当时大数据的研究和应用正如火如荼的展开,那么在Java的新设计中,加入满足大数据时代编程要求的特性,就毫不奇怪了。
    支撑我做出如此判断的,主要是Streams的reduce操作,和大数据的MapReduce操作有异曲同工之妙。前者的并行操作,简直就是利用内存中的数据做MapReduce。最近在学习Spark过程中,发现大量的使用Streams进行开发,也从侧面印证了自己的这个观点。如果有理解偏差的地方,还请指出。

根据以上的分析,既然Streams基于这些目的进行设计,那么在具体的理念上会有什么独特之处,又应该如何理解呢?

Streams的设计理念

这里有一段话,给我很大启发,或可说明Streams的设计理念。

原文:... streams were designed to provide an ability to apply a finite sequence of operations to the source of elements in a functional style, but not to store elements.
翻译:streams的设计初衷,是以一种函数的方式,给每个元素应用一个有限步骤的操作,而并不是存储元素。
出处:The Java 8 Stream API Tutorial:3. Referencing a Stream

从以上描述中,提炼了几个对掌握Streams至关重要的关键点:

  1. streams一定是基于集合的,不论是List,还是Array,其包含0个或多个元素(Elements);
  2. 其本质是施加在元素上的迭代器,通过迭代施加某种影响(执行某个函数),获得某种结果(这个结果不限于改变单个元素,可以是元素之间的运算结果);
  3. 这种影响有两类结果,如果生成一个新的streams,就是中间操作(intermediate operations),例如filter、map、flatMap等;如果变成了其它类型,就是终结操作( terminal operations),例如findAny、count、collect等;
  4. 由于streams不是用于存储元素的,所以streams是不能复用的,操作过后,原来的streams就没有了(即使新生成的streams也不是原来的streams),再次调用原来的streams,就会发生IllegalStateException异常

Streams的入和出

在掌握了Streams的设计理念后,让我们看看如何将这种设计落地。我们可以将基于Streams的集合操作理解成一个管道,那么最重要的两点就是

  1. 如何将集合送入管道,即生成Streams对象;
  2. 对象如何在管道中被处理,并被送出,即中间操作和终结操作。

针对第一点,通过查看Java8的新API,会发现大量的和集合相关的类都有输出,这里列举一些

  • 数组:Arrays.stream(arr)可将数组arr转换为Stream对象
  • 集合:Collection.stream()可以让任意集合类(注意是实例,不是静态方法)生成Stream对象
  • 按对象添加:Stream.of(T... obj)可以将若干个对象加入到Stream对象

针对第二点,终结操作比中间操作更为重要,它会把流的结果以合适的数据结构返回,这个过程就叫做reduce。Java8既给出了几个内置的标准reduce操作,同时又提供了强大的reduce和collect函数,相当于提供了无限的reduce能力。

  1. 内置标准reduce操作
    • allMatch, anyMatch
    • count
    • findAny, findFirst
    • min, max
  2. reduce函数和collect函数
    • collect函数配合Collectors提供Stream到普通数据集合的转换,例如List,Set等,这其中还可以有groupBy等操作;
    • reduce函数完全接收一个Lamda表达式做缩减操作,可以说reduce函数是最终极的终结函数,几乎可以对集合做任何处理,并返回结果。

除此之外,Streams对集合操作的效率提升,表现在不同于传统for循环的有趣的特性中。

中间操作的Lazy调用

Stream不仅是代码层面的简化,在实现中会有一些特殊的处理以优化迭代。例如这个例子

Stream.of("abc1","abc2","abc3","abc22").stream().filter(element -> {
    log.info("filter() was called");
    return element.contains("2");
}).map(element -> {
    log.info("map() was called");
    return element.toUpperCase();
})

这个例子在jshell中运行后,不会打印任何信息,只会生成一个临时变量,这就说明中间操作是Lazy执行的,在没有终结操作时,其是不会执行的。

下面再看增加了终结操作以后的有意思的结果。

Stream.of("abc1","abc2","abc3","abc22").stream().filter(element -> {
    log.info("filter() was called");
    return element.contains("2");
}).map(element -> {
    log.info("map() was called");
    return element.toUpperCase();
}).findFirst();

运行结果是只会打印两次filter()...和一次map()...,为了清楚起见,可以在日志中加入element变量,结果如下

运行结果

这就说明,streams的pipeline执行是垂直的,只要能获得每一步的结果,就继续往下执行,如果是到终结操作,则直接返回结果。这就有点类似数据结构中,深度遍历树,而不是广度遍历树。

当终结操作必须“流”过所有元素才能生效时,打印就会完整。例如我们把findFirst()改为count(),结果就变成这样了

运行结果

注意到后两个元素也被调用了filter,请特别注意,先做了abc2的map,才做的abc3的filter,典型的深度遍历模式

最后再深入思考一个问题,对于这种设计,某些结果可能会产生不是我们希望的结果。例如findAny这个终结操作,尽管abc2和abc22都符合,但我测试了多次该代码片段,返回总是ABC2,说明就是存在无法“find”出ABC22的缺陷。findAnyfindFirst等价了,除非stream的源会产生变化,否则不会有随机的findAny结果出现。

中间操作执行的顺序

中间操作的顺序决定了程序的开销,所以有如下的rule

原文:...intermediate operations which reduce the size of the stream should be placed before operations which are applying to each element. So, keep such methods as skip(), filter(), distinct() at the top of your stream pipeline.
翻译:那些缩减了stream的容量(run完会去掉若干elements)的中间操作,应该放在那些需要应用在每一个元素上的中间操作,的前面。所以skip、filter、distinct这些操作,必须放到你的stream pipeline的最前面。
出处:同上 6. Order of Execution

结论

实际上,我们在这里没有讨论Java8的Streams特性的具体内容,而是通过尝试分析其设计目标和设计理念,分析其如何和现有的集合类进行对接(包括对Streams的入和出)。同时通过一些令人惊异的例子,发现Streams如何能够提高集合操作的效率和性能(也许我还需要补充一些并行Streams的内容)。尽管并没有告诉你怎么写一个Streams,这可以从github上找到很好很强大的例子。

你可能感兴趣的:(关于Java8新特性Streams的思考)