内容


Java 8 习惯用语

函数纯度

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

Comments

系列内容:

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

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

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

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

在本系列前面的部分中,我介绍了函数组合和集合管道模式。在本文中,我们将再次介绍函数管道的好处和构建块。您将进一步了解如何使用 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 表达式不依赖于共享可变状态,而且执行的结果既高效又正确。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=1057252
ArticleTitle=Java 8 习惯用语: 函数纯度
publish-date=01242018