内容简介:Java 8 习惯用语: 函数纯度
Java 8 习惯用语
函数纯度
理解函数管道中的流和可变变量
系列内容:
此内容是该系列 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
会触发流处理的终止。此外,两个 filter
和 map
调用中的操作融合在一起,然后在序列中的每个元素上执行计算。超过 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 习惯用语: 函数纯度》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 用语言模型命名恐龙
- 如果编译一个不输入的程序会发生什么? (Haskell IO纯度问题(再次))
- Java 8 习惯用语,第 2 部分: 函数组合与集合管道模式
- Java 8 习惯用语,第 3 部分: 传统 for 循环的函数式替代方案
- Java 8 习惯用语,第 1 部分: Java 中的一种更轻松的函数式编程途径
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。