Java 8 习惯用语: 函数纯度

栏目: Java · 发布时间: 6年前

内容简介:Java 8 习惯用语: 函数纯度

Java 8 习惯用语

函数纯度

理解函数管道中的流和可变变量

Java 8 习惯用语: 函数纯度

Venkat Subramaniam

2018 年 1 月 24 日发布

系列内容:

此内容是该系列 10 部分中的第 # 部分: Java 8 习惯用语

https://www.ibm.com/developerworks/cn/library/?series_title_by=**auto**

敬请期待该系列的后续内容。

此内容是该系列的一部分: Java 8 习惯用语

敬请期待该系列的后续内容。

关于本系列

Java 8 是自 Java 语言诞生以来进行的一次最重大更新 — 包含了非常丰富的新功能,您可能想知道从何处开始着手了解它。在本系列中,作家兼教师 Venkat Subramaniam 提供了一种惯用的 Java 8 编程方法:这些简短的探索会激发您反思您认为理所当然的 Java 约定,同时逐步将新技术和语法集成到您的程序中。

在本系列前面的部分中,我介绍了 函数组合和集合管道模式 。在本文中,我们将再次介绍函数管道的好处和构建块。您将进一步了解如何使用 java.util.stream 构建函数管道,以及为什么在管道中保持函数纯度是有益的。

函数管道和 Stream API

我们使用 Stream 在 Java™ 中构建函数管道。在函数式代码中使用 Stream 有 3 个好处:

  • Stream 简洁、富于表达、非常优雅,而且代码读起来就像是问题陈述。
  • Stream 采用了惰性计算,这使得它在您的程序中非常高效。
  • 它可以并行使用。

在本系列中,您已详细了解了优雅和简洁的代码的好处。在本文中,我们将重点介绍其他两个好处。效率是您在使用函数管道时寻求的主要好处之一,所以我们首先从这里开始介绍。

惰性计算

下面的命令式代码非常高效:它仅执行绝对必要的工作。

List<Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);
          
Integer result = null;
for(int e : numbers) {
  if(e > 10 && e % 2 == 0) {
    result = e * 2;
    break;
  }
}

if(result != null)
  System.out.println("The value is " + result);
else
  System.out.println("No value found");

该代码迭代 numbers 集合中的元素,但仅迭代至找到满足它的两个要求(大于 10 且是偶数)的元素。找到第一个数字后,就不会再处理其他值。

现在让我们使用函数管道重写上述代码:

List<Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);
System.out.println(
  numbers.stream()
    .filter(e -> e > 10)
    .filter(e -> e % 2 == 0)
    .map(e -> e * 2)
    .findFirst()
    .map(e -> "The value is " + e)
    .orElse("No value found"));

这个函数式版本生成的结果与命令式版本相同。在给出的示例中,命令式版本不会处理任何超过 12 的值,函数版本也是如此。不同之处在于代码处理给定变量的方式。

流处理

Java Stream 基本上是惰性的,就像我十几岁的孩子一样。下面是我家里的一个场景,可能有助于您理解流的行为。

我的妻子对儿子说:“关掉电视”。

跟没说一样。

妻子说:“把垃圾倒掉”。

没有任何动作。

她再说:“做你的家庭作业”。

铅笔没被拿起过。

妻子说:“我要叫你爸爸了。”

孩子马上行动起来,按下电视遥控器上的关闭按钮……

像十几岁的孩子一样, Stream 只有两种方法:中间和最终。根据家中每位家长扮演的角色,后一个方法等效于 callDaddy()callMommy() 方法。

Stream 累积并 组合融合 中间操作,然后执行它们。但是像十几岁的孩子一样,它仅执行满足最终操作所必需的工作。因为中间操作被融合,所以流对管道中数据的处理方式存在一个重要区别: Stream 不会像命令式代码一样执行数据集合上的每个函数,而是执行每个元素上的函数的融合集合,但仅在需要时执行。

为了验证此行为,我们可以稍微更改一下最初的函数式代码:

List<Integer> numbers = Arrays.asList(2, 5, 8, 15, 12, 19, 50, 23);
System.out.println(
  numbers.stream()
    .peek(e -> System.out.println("processing " + e))
    .filter(e -> e > 10)
    .filter(e -> e % 2 == 0)
    .map(e -> e * 2)
    .findFirst()
    .map(e -> "The value is " + e)
    .orElse("No value found"));

在这里,我们在函数管道中的第一个 filter 的前面添加了对 peek 的调用。 peek 方法对调试很有用,使我们能在执行期间 留意到 Stream 。这是新代码的输出:

processing 2
processing 5
processing 8
processing 15
processing 12
The value is 24

该代码处理了直到 12(包含 12)的所有值,但它没有触及超过目标值的任何值。这是因为最终操作 findFirst 会触发流处理的终止。此外,两个 filtermap 调用中的操作融合在一起,然后在序列中的每个元素上执行计算。超过 findFirst 中的内部终止信号后,就不会再计算元素。

在本例中,惰性显然提高了效率,因为函数管道只执行必要的工作。它是效率与优雅结合的典范。

并行化

在您有一个大型集合或者需要执行消耗大量时间的任务的情况下,并行化可能非常有用。下面的代码将模拟一个耗时的操作。

import java.util.*;

class Sample {
  public static int simulateTimeConsumingComputation(int number) {
    try { Thread.sleep(1000); } catch(Exception ex) {}
    return number * 2;
  } 
  
  public static void main(String[] args) { 
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
                         
    numbers.stream()
      .map(Sample::simulateTimeConsumingComputation)
      .forEachOrdered(System.out::println);
  }
}

如果正常运行此代码,您会发现所用时间约为 10 秒。这太长了。 我们可以使用一个并行流来提高速度,如下所示:

...
    numbers.stream()
      .parallel()
      .map(Sample::simulateTimeConsumingComputation)
...

并行流使执行速度变得快得多。新代码在 16 核处理器上运行所用的时间约为 1 秒,在 8 核处理器上所用时间约为 2 秒。这是因为在默认情况下,并行流使用了与系统上的核心数一样多的线程。

您还会注意到,并行化此代码只需相对较少的工作。按顺序执行的函数管道的结构与并行运行的函数管道没什么不同,这使得函数管道非常容易并行化。

函数纯度的规则

目前您可能很喜欢这些技术给您的印象:惰性能提高效率,并行化的编写与顺序处理一样容易 — 使用它们吧!但是有一个陷阱:这些技术的成功取决于代码的 纯度 。您的函数管道中的所有 lambda 表达式和闭包都必须是纯的。

在继续后面的学习之前,您应该了解纯函数的一些知识。首先,纯函数是 幂等的 — 这意味着对纯函数的调用次数没有限制。其次,无论调用纯函数多少次,只要给定相同的输入,它都会产生相同的结果。第三,纯函数没有副作用:无论您使用它做什么,纯函数都不会更改您的程序中的其他任何元素。

如果您想编写纯函数,请记住,最后这个特征最为重要。实质上,函数纯度有两个规则:

  • 函数不会更改任何元素。
  • 函数不依赖于任何可能更改的元素。

纯函数绝不会在执行期间引起更改或发生更改。

为什么函数纯度至关重要

惰性计算 意味着一个函数可以在现在或以后计算,或者可以完全跳过计算。无论采用何种方式,只要得到想要的结果就行。但是,如果函数有副作用,惰性计算就不会生效。下一个示例将展示在函数管道包含不纯函数时会发生什么。

List<Integer> numbers = Arrays.asList(1, 2, 3);
                        
int[] factor = new int[] { 2 };
Stream<Integer> stream = numbers.stream()
  .map(e -> e * factor[0]);

factor[0] = 0;

stream.forEach(System.out::println);

Java 假设提供给操作的 lambda 表达式和闭包是纯的。如果您的代码不满足这一要求,您将承担相应的后果。

为了增添乐趣,可以询问一些同事他们预计此代码的输出是什么。您不可能获得一致的回答。更可能的情况是,您会看到许多人感到困惑和不确定。

在这个示例中,传递给 map 的闭包是不纯的。它违背了纯度的第二个规则,因为该闭包依赖的变量可能发生改变(而且事实上它确实发生了改变)。由于惰性计算,作为参数传递给 map 的闭包只在调用 forEach 后才会计算。

因为 factor[0] 是可变的,从创建闭包到最终计算它的过程中,该值可以是任何值。这个可变变量让代码变得很难理解。很难理解的代码也就很难维护,而且这通常是出现错误的一个原因。

并行流也是如此:如果传递给操作的状态不纯,结果将是不可预测的。

避免共享可变性

传递给操作的 lambda 表达式和闭包应该是纯的。它们不应修改任何外部状态,也不应依赖于任何可变的外部状态。

开发人员常常询问他们是否应完全避免可变性。答案很简单:不要使用可变性。相反,应 避免共享可变性 。在中间和最终操作中,如果修改了一个共享可变变量,代码会变得难以推断。共享可变性还使得通过并行和/或惰性计算无法获得正确的结果。您可以选择不使用并行化,但您无法控制惰性计算,因为它是流的一种隐式行为。

尽管共享可变性会花费一些成本,但您可以通过小心地改变隔离变量来获得不错的结果,隔离变量是严格禁止被多个线程共享的变量。在处理的数据量非常大时,改变隔离变量可以提高性能。在一个处理包含数百万个对象的集合的最新项目中,我的团队使用了隔离可变性将性能提高到对数据负载合理的水平。这样做能够奏效是因为我们仔细验证了该项目中不存在共享可变性。我们还验证了我们的结果不仅快,而且正确。

对于小型或中等规模的集合,或者您无需可变性就能实现合理性能的情况,最明智的做法是避免 lambda 表达式和闭包中的可变性。如果您在其中一个元素中采用了可变性,请确保您正在改变一个隔离变量,而且永远不要改变共享变量。从函数管道的开始到结束,闭包所依赖的状态绝不应被多个线程修改。

结束语

惰性计算和轻松的并行执行是使用函数管道的两个重要好处。两个特性都取决于 函数纯度 ,这意味着 lambda 表达式和闭包不得在您的程序中产生任何副作用。在本文中,您了解了这个规则和它存在的原因,还探索了可以安全改变隔离变量的一个例外情况。

理解函数纯度很重要,因为如果您违背了 lambda 表达式和闭包中的纯度要求,Java 不会产生错误,甚至不会生成警告。所以您需要验证您的 lambda 表达式不依赖于共享可变状态,而且执行的结果既高效又正确。


以上所述就是小编给大家介绍的《Java 8 习惯用语: 函数纯度》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

算法导论

算法导论

[美] Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest、Clifford Stein / 高等教育出版社 / 2002-5 / 68.00元

《算法导论》自第一版出版以来,已经成为世界范围内广泛使用的大学教材和专业人员的标准参考手册。 这本书全面论述了算法的内容,从一定深度上涵盖了算法的诸多方面,同时其讲授和分析方法又兼顾了各个层次读者的接受能力。各章内容自成体系,可作为独立单元学习。所有算法都用英文和伪码描述,使具备初步编程经验的人也可读懂。全书讲解通俗易懂,且不失深度和数学上的严谨性。第二版增加了新的章节,如算法作用、概率分析......一起来看看 《算法导论》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具