• 欢迎访问 winrains 的个人网站!
  • 本网站主要从互联网整理和收集了与Java、网络安全、Linux等技术相关的文章,供学习和研究使用。如有侵权,请留言告知,谢谢!

Java 8 习惯用语(0):函数纯度

Java技术 winrains 来源:Venkat Subramaniam 1年前 (2019-08-30) 46次浏览

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

作者:Venkat Subramaniam

来源:https://www.ibm.com/developerworks/cn/java/j-java8idioms11/index.html?ca=drs-


版权声明:文末如注明作者和来源,则表示本文系转载,版权为原作者所有 | 本文如有侵权,请及时联系,承诺在收到消息后第一时间删除 | 如转载本文,请注明原文链接。
喜欢 (1)