streams API是在Java 8中添加的,以简化按顺序或并行执行批量操作的任务。这个API提供了两个关键抽象:流表示有限或无限序列的数据元素,流管道表示对这些元素的多级计算。流中的元素可以来自任何地方。常见的源包括集合、数组、文件、正则表达式模式匹配器、伪随机数生成器和其他流。流中的数据元素可以是对象引用或基本值。支持三种基本类型:int、long和double。
流管道由源流、零个或多个中间操作和一个终端操作组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数,或者过滤掉不满足某些条件的所有元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同,也可能与输入流不同。终端操作对最后一个中间操作产生的流执行最后一次计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。
流管道的计算是延迟的:直到调用终端操作才开始计算,并且永远不会计算完成终端操作所需的数据元素。这种延迟计算使处理无限流成为可能。注意,没有终端操作的流管道是静默的no-op,所以不要忘记包含一个。
streams API是连贯的:它的设计允许将包含管道的所有调用都链接到一个表达式中。事实上,可以将多个管道链接到一个表达式中
默认情况下,流管道按顺序运行。使管道并行执行与在管道中的任何流上调用并行方法一样简单,但是很少适合这样做(item48 ).
streams API具有足够的通用性,实际上任何计算都可以使用streams执行,但不能因为可以就意味着应该这样做。如果使用得当,流可以使程序更短、更清晰;如果使用不当,它们会使程序难以阅读和维护,对于何时使用流没有硬性的规则,但是有启发式.
考虑下面的程序,它从字典文件中读取单词,并打印大小满足用户指定的最小值的所有字谜组.记住,如果两个单词由不同的字母组成,那么它们就是字谜顺序.程序从用户指定的字典文件中读取每个单词,并将这些单词放入映射中,map key是按字母顺序排列的单词,所以“staple”的key是“aelpst”,“petals”的key也是“aelpst”:staple.这两个单词是字谜,所有的字谜都有相同的字母排列形式(有时也称为字母组合)。map值是一个列表,其中包含所有共享字母格式的单词.在字典被处理之后,每个列表都是一个完整的字谜组.然后,程序遍历map的values()视图,并打印大小满足阈值的每个列表:
这个计划中的一个步骤值得注意.将每个单词插入映射(以粗体显示)使用computeIfAbsent方法,该方法是在Java 8中添加的,此方法在映射中查找键:如果键存在,则该方法只返回与其关联的值。如果没有,该方法将给定的函数对象应用于键来计算值,将该值与键关联,并返回计算值.computeIfAbsent方法简化了将多个值与每个键关联的映射的实现
现在考虑下面的程序,它解决了同样的问题,但是大量使用了流.注意整个程序,除了打开字典文件引发异常的代码,它只包含了单一表达式.字典以单独的表达式打开的惟一原因是允许使用try-with-resources语句,该语句确保字典文件是关闭的.
image.png
如果您发现这段代码很难读,不要担心;你不是一个人.它更短,但是可读性也更差,特别是对于那些不擅长使用流的程序员来说. 过度使用流使得程序难以阅读和维护.
幸运的是,有一个折中的办法.下面的程序解决了相同的问题,在不过度使用流的情况下使用流.结果是一个程序,比原来的更短,更清晰:
即使您以前很少接触流,这个程序也不难理解.它在try-with-resources块中打开字典文件,获得由文件中的所有行组成的流.流变量名为words,表示流中的每个元素都是一个单词.该流上的管道没有中间操作;它的终端操作将所有单词收集到一个地图中,然后按字母顺序对单词进行分组(item46 ).这与在程序的前两个版本中构造的映射完全相同.然后在map的values()视图上打开一个新的流<List<String>>.当然,这个流中的元素是字谜组.对流进行过滤,以便忽略大小小于minGroupSize的所有组,最后,通过终端操作forEach打印剩余的组.
注意,lambda参数名是经过仔细选择的.参数g实际上应该被命名为group,但是生成的代码行对于本书来说太宽了.在没有显式类型的情况下,仔细命名lambda参数对于流管道的可读性至关重要.
还要注意,单词的字母化是在单独的alphabetize 方法中完成的.通过为操作提供名称并将实现细节排除在主程序之外,这增强了可读性.对于流管道中的可读性,使用helper方法甚至比在迭代代码中更重要.因为管道缺少显式类型信息和命名的临时变量.
可以重新实现alphabetize方法来使用流,但是基于流的alphabetize方法不太清晰,更难于正确地编写,而且速度可能更慢。这些缺陷是由于Java缺乏对原始char流的支持(这并不意味着Java应该支持char流;这样做是不可能的)。要演示使用流处理char值的危害,请考虑以下代码:
"Hello world!".chars().forEach(System.out::print);
您可能期望它打印Hello world!,但是如果运行它,您会发现它打印了721011081081113211911111410810033,这是因为“Hello world!”.chars()返回的流的元素不是char值,而是int值,因此调用了print的int重载.一个名为chars的方法返回一个int值流,这确实令人困惑.您可以通过使用强制转换强制调用正确的重载来修复程序:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
但是理想情况下,您应该避免使用流来处理char值。
当您开始使用流时,您可能会有将所有循环转换为流的冲动,但是要抵制这种冲动.虽然这是可能的,但它可能会损害代码库的可读性和可维护性.通常,即使是中等复杂的任务,也最好使用流和迭代的组合来完成,如上面的字谜程序所示.因此,重构现有代码以使用流,并仅在有意义的地方在新代码中使用它们.
- 从代码块中,您可以读取或修改范围内的任何局部变量;从lambda中,您只能读取final或有效的final变量[JLS 4.12.4],并且不能修改任何局部变量。
-
从代码块中,可以从封闭方法返回、中断或继续封闭循环,或者抛出声明该方法要抛出的任何已检查异常;对于labmda,你什么都不能做。
如果使用这些技术最好地表达计算,那么它可能不适合流。相反,流使做一些事情变得非常容易:
-
一致变换元素序列
-
筛选元素的顺序
-
使用单个操作组合元素序列(例如添加、连接或计算它们的最小值)
-
将元素序列累积到一个集合中,可能根据某个公共属性对它们进行分组
-
搜索元素序列,寻找满足某种条件的元素
如果计算是用这些技术最好地表达的,那么它是流的一个很好的候选.
image.png
使用流很难做的一件事是同时访问来自管道的多个阶段的相应元素:一旦将一个值映射到其他值,原始值就会丢失.一种解决方法是将每个值映射到包含原始值和新值的pair对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段需要pair对象时.生成的代码混乱而冗长,这违背了流的主要目的.它适用时,更好的解决方法是在需要访问早期值时反转映射.
例如,让我们编写一个程序来打印前20个梅森素数。为了提醒你,梅森数是2^p - 1形式的数。如果p是质数,对应的梅森数可能是质数;如果是,那就是梅森素数.下面是返回(无限)流的方法。我们假设一个静态导入被用来方便地访问BigInteger的静态成员:
-
方法的名称(primes)是描述流元素的复数名词.对于所有返回流的方法,强烈推荐使用这种命名约定,因为它增强了流管道的可读性.该方法使用静态工厂流。iterate,它接受两个参数:流中的第一个元素,以及从前一个元素生成流中的下一个元素的函数。下面是打印前20个梅森素数的程序:
image.png
这个程序对上面的散文描述进行了简单的编码:它从质数开始,计算相应的梅森数,过滤除质数之外的所有数(魔法值50控制概率素数测试),将结果流限制为20个元素,并将它们打印出来。
现在假设我们想在每个Mersenne '之前加上它的指数(p)。这个值只出现在初始流中,所以在输出结果的终端操作中是不可访问的。幸运的是,通过反转第一个中间操作中发生的映射,可以很容易地计算梅森数的指数。指数只是二进制表示中的比特数,所以这个终端操作产生了想要的结果:
image.png
在许多任务中,是否使用流或迭代并不明显.例如,考虑初始化一副新纸牌的任务.假设Card是一个不可变的值类,它封装了一个Rank和一个Suit,它们都是enum类型.这个任务代表任何任务要求计算可从两个集合中选择的所有元素对.数学家把这叫做两个集合的笛卡尔积.这里是一个迭代实现嵌套的for-each循环,你应该非常熟悉:
image.png
这里是一个基于流的实现,它使用了中间操作flatMap。该操作将流中的每个元素映射到一个流,然后将所有这些新流连接到一个流中(或将它们压扁)。注意,这个实现包含一个嵌套的lambda,用粗体显示:
image.png
两个版本的newDeck中哪个更好?这可以归结为个人偏好和编程环境。第一个版本更简单,可能感觉更自然。大部分Java程序员将能够理解并维护它,但是有些程序员对第二个(基于流的)版本会感到更舒服。如果您对流和函数式编程相当精通,那么它会更简洁一些,也不会太难理解.如果您不确定您更喜欢哪个版本,迭代版本可能是更安全的选择,如果您更喜欢流版本,并且您相信其他使用该代码的程序员也会与您有相同的偏好,那么您应该使用它。
总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。许多任务最好通过结合这两种方法来完成。对于选择用于任务的方法没有硬性的规则,但是有一些有用的启发式.在许多情况下,使用哪种方法是很清楚的;在某些情况下,它不会. 如果您不确定流或迭代是否更好地服务于任务,请同时尝试这两种方法,看看哪种效果更好。
本文写于2019.7.12,历时3天
网友评论