Java-函数式方法-全-

Java 函数式方法(全)

原文:zh.annas-archive.org/md5/2d56b06de0eb1bab8d2ee3a2be6278ca

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

一个通过新经验扩展的思想,永远无法回到它的旧维度。

奥利弗·温德尔·霍尔姆斯 Jr.

开发软件是一个相当复杂的工作。作为 Java 开发者,我们通常试图用面向对象编程(OOP)来驾驭这种复杂性,将其作为一种比喻来表示我们正在开发的事物,比如数据结构,并且使用主要以命令式为主的编码风格来处理程序的状态。虽然 OOP 是一种广为人知且经过实战验证的开发合理软件的方法,但并不是每个问题都适合它。如果我们在每个问题上都强行采用 OOP 原则,而不是使用更适当的工具和范式,可能会引入一定量不必要的复杂性。函数式编程(FP)范式提供了解决问题的另一种替代方法。

函数式编程并不是一个新概念。事实上,它甚至比面向对象编程还要古老!它最早出现在计算机发展的早期,1950 年代,最早在Lisp⁠¹编程语言中出现,并且在学术界和小众领域中被广泛使用。然而,近年来,函数式范式越来越受到关注。

许多新的函数式语言出现了,非函数式语言也在各种程度上包含函数式特性。FP 背后的思想和概念现在几乎在每一种主流多范式和通用编程语言中被采纳,无论上下文和选择的语言如何,都允许我们使用某种形式的函数式编程。没有什么能阻止我们从函数式编程中摘取最好的部分,并增强我们现有的编程方式和软件开发工具,这也是本书的主旨!

在这本书中,你将学习函数式编程的基础知识,并学习如何将这些知识应用到日常工作中使用 Java。

新硬件需要新的思维方式

硬件正在朝着新方向发展。相当长一段时间以来,单核性能的改进不如以往的处理器世代那样显著。摩尔定律⁠²似乎放缓,但这种放缓并不意味着硬件停止改进。而制造商们不再主要关注单核性能甚至更高的 GHz 数,而是更青睐越来越多的核心数目。³因此,为了让现代工作负载能够充分利用偏向更多核心而非更快处理器的新硬件带来的所有好处,我们需要采用能够有效利用更多核心的技术,而不会降低生产力或引入额外复杂性。

横向扩展你的软件通过并行处理在面向对象编程中并不是一项容易的任务。并非所有问题都适合并行处理。更多的画家可能会更快地涂完一个房间,但是你不能通过让更多的人参与来加快怀孕的速度。如果问题由串行或相互依赖的任务组成,那么并发比并行更可取。但是,如果一个问题可以分解为更小、不相关的子问题,那么并行处理就会大放异彩。这正是函数式编程发挥作用的地方。惯用的函数式编程的无状态和不可变性质提供了构建小型、可靠、可重复使用和高质量任务所需的所有工具,这些任务优雅地适应并行和并发环境。

采用函数式思维方式为你的工具箱增加了另一组工具,使你能够以一种新的方式解决日常开发中的问题,并比以往更轻松、更安全地扩展你的代码。

接下来,让我们看看 Java 为什么可以成为函数式编程的好选择。

Java 也可以是函数式的

有许多编程语言非常适合函数式编程。Haskell 是一个喜欢的选择,如果你喜欢几乎不支持命令式编程风格的纯函数式语言。Elixir 是另一个令人兴奋的选择,它利用了Erlang VM⁴。然而,你并不需要抛弃广阔的 JVM 生态系统来找到支持函数式编程的语言。Scala 在将面向对象和函数式编程范式结合成一种简洁、高级别语言方面表现出色。另一个受欢迎的选择,Clojure,从一开始就被设计为具有动态类型系统的函数式语言。

在理想的情况下,你可以选择对下一个项目最适合的函数式语言。然而,在现实中,你可能根本没有选择语言的余地,你必须使用手头现有的工具。

作为 Java 开发者,你可以使用 Java,尽管它在历史上被认为不太适合函数式编程。在我们继续之前,我需要强调的是,你可以在 Java 中实现大多数函数式原则,而不论语言级别是否深度集成支持⁵。然而,最终的代码不会像在允许在首次使用函数式方法的其他语言中那样简洁且易于推理。这一限制使许多开发者不敢尝试将函数式原则应用于 Java,尽管这可能会提供更高效的方法或更好的整体解决方案。

在过去,许多人认为 Java 是一个行动缓慢的庞然大物,一种“太大以至于无法灭绝”的企业语言,就像 COBOL 或Fortran的更现代版本一样。在我看来,至少在过去是部分正确的。直到 Java 9 和缩短的发布时间框架⁶才加快了步伐。Java 从版本 6 到 7 花了五年的时间(2006-2011)。即使有显著的新特性,比如try-with-resources,但都不是“突破性”的。过去的少量且缓慢的变化导致项目和开发者未采用“最新最好”的 Java 开发工具包(JDK),错失了许多语言改进。三年后的 2014 年,下一个版本 Java 8 发布了。这一次,它引入了 Java 未来最重要的变化之一:lambda 表达式

对于世界上最显著的面向对象编程语言之一,函数式编程终于提供了一个更好的基础,显著改变了语言及其习语:

Runnable runnable = () -> System.out.println("hello, functional world!");

函数式编程在 Java 中的整合语言和运行时特性上引入了lambda 表达式,这是一个里程碑式的改进,使得最终能够在 Java 中使用函数式编程成为可能。不仅如此,Java 开发者们也因此获得了一个全新的思想和概念世界。像 Streams、Optional类型或CompletableFuture等 JDK 的许多新特性,都得益于语言级别的 lambda 表达式和 Java 的其他函数式增强。

在 Java 中使用 FP 的这些新习惯和新方式可能看起来很奇怪,特别是如果你主要习惯于面向对象编程。在本书中,我将向你展示如何培养一种思维模式,帮助你将 FP 原则应用于你的代码,并使其变得更好,而无需完全转向函数式编程。

我为什么写这本书

在使用另一种多用途语言Swift并亲身体验其优势之后,我逐渐在基于 Java 的项目中引入了更多的函数式原则。多亏了 Java 8 及以后版本引入的 lambda 表达式和所有其他特性,所有必要的工具都随时可用。但在更频繁地使用这些工具并与同事讨论后,我意识到一点:学会如何使用 lambda 表达式、Streams 和 Java 提供的所有其他函数式好处是容易理解的。但如果不深入了解何时以及为何使用它们,以及何时不使用,就无法充分发挥它们的潜力,它们只会是“新酒装旧皮囊”。

因此,我决定写这本书来突出显示构成语言功能性的不同概念,以及如何将它们与你的 Java 代码结合起来,无论是使用 JDK 提供的工具还是自己创建。功能性地处理你的 Java 代码很可能会挑战现状,并违背你以前使用的最佳实践。但是,通过接受更功能性的做事方式,比如不可变性纯函数,你将能够编写更简洁、更合理和更具未来性的代码,减少错误的可能性。

谁应该读这本书

如果你对功能性编程感兴趣,并想知道这一切是怎么回事,并将其应用到你的 Java 代码中,那么这本书适合你。你可能已经在使用一些功能性 Java 类型,但希望更深入地了解为什么以及如何更有效地应用它们。

你无需成为面向对象编程(OOP)的专家,但这本书也不是 Java 或 OOP 的初学者指南。你应该已经熟悉 Java 标准库。不需要先前的功能性编程知识。每个概念都会有解释和示例介绍。

本书涵盖的 Java 17 是最新的长期支持(LTS)版本。考虑到许多开发人员需要支持较早版本的项目,一般基准线将是上一个 LTS 版本 Java 11。但即使你被困在 Java 8 上,讨论的许多主题也是相关的。尽管如此,一些章节将依赖于较新的功能,比如在 Java 14 中引入的Records

如果你正在寻找一个分隔式、食谱风格的“即用即实施”解决方案的书籍,那么这本书可能不适合你。它的主要目的是介绍功能性概念和习语,并教你如何将它们融入到你的 Java 代码中。

你将学到什么

通过本书结束时,你将对功能性编程及其基础概念有基础的了解,并学会如何将这些知识应用到日常工作中。每种 Java 功能性类型都将为你所用,必要时你也能够自己构建 JDK 中缺少的部分。

你将了解以下概念及其重要性:

  • 组合:构建模块化和易于组合的块。

  • 表达力:编写更简洁的代码,清晰表达其意图。

  • 更安全的代码:更安全的数据结构,无副作用,无需处理竞态条件或锁,这些很难使用而不引入错误。

  • 模块化:将较大的项目拆分为更易于管理的模块。

  • 可维护性:较小的功能块,少互连,使得更改和重构更安全,不会破坏代码的其他部分。

  • 数据操作:构建高效的数据操作流水线,减少复杂性。

  • 性能:不可变性和可预测性允许在水平方向上通过并行扩展,几乎不需要考虑。

即使没有完全采用全功能的方式,你的代码也会受益于本书中提出的概念和习语。而且不仅仅是你的 Java 代码。你将以功能思维解决开发挑战,无论使用的语言或范式如何,都能提升你的编程能力。

Android 是怎么样的?

没有提到 Android 就很难谈论 Java。即使你可以用 Java 编写 Android 应用程序,底层的 API 和运行时也不同。那么,对于在 Android 应用程序中采用功能方法编写 Java,这意味着什么呢?为了更好地理解这一点,我们首先需要看看是什么使得 Android 上的 Java 与“正常”的 Java 不同。

Android 并不直接在面向较小设备的Java 平台微版Java 平台微版概述)上运行 Java 字节码。而是对字节码进行重新编译。Dex-编译器创建Dalvik 字节码,然后在专门的运行时上运行:Android 运行时(ART),之前是在 Dalvik 虚拟机⁠⁷。

将 Java 字节码重新编译为Dalvik 字节码使设备能够运行高度优化的代码,充分利用其硬件限制。然而对于开发者来说,尽管你的代码在表面上看起来和感觉像 Java —— 大部分公共 API 是相同的 --⁠,JDK 和 Android SDK 之间并没有功能对等性可依赖。例如,本书的基石 —— lambda 表达式Streams —— 在 Android 上长时间缺失了这些特性。

Android Gradle 插件从 3.0.0 版本开始支持一些缺失的功能特性(lambda 表达式、方法引用、默认和静态接口方法),通过所谓的解糖使用字节码转换来复制特性在幕后,而不支持新语法或在运行时本身提供实现。下一个主要版本 4.0.0,增加了更多的功能特性:Streams、Optionals 和 java.util.function 包。这使得你作为 Android 开发者能够受益于本书讨论的功能范式和工具。

警告

尽管大多数 JDK 的功能特性在 Android 上也是可用的,它们并不是逐字复制⁸,可能具有不同的性能特征和边缘情况。可用功能列在Java 8+ 支持的官方文档中。

一种面向 Android 的功能方法

在 2019 年,Kotlin取代 Java 成为 Android 开发者首选的语言。它是一种多平台语言,主要面向 JVM,但也可以编译为 JavaScript 和多个本地平台。它旨在成为一个“现代和更简洁”的 Java,修复了 Java 多年来由于向后兼容性而积累的一些争议性缺陷和不必要的复杂性,同时保留了 Java 可用的所有框架和库。并且它是 100%可互操作的:你可以轻松地在同一个项目中混合使用 Java 和 Kotlin。

Kotlin 相对于 Java 的一个明显优势是,许多函数概念和习语已经融入到语言本身。但作为一种不同的语言,Kotlin 有其自己的习惯用法和最佳实践,这些与 Java 的不同。生成的字节码也可能不同,例如如何生成 lambda 表达式¹⁰。Kotlin 最显著的优势是试图创建一个比 Java 更简洁和可预测的语言。就像你可以在 Java 中更加函数式而不是完全函数式一样,你也可以在 Android 项目中仅使用 Kotlin 特有的功能,而不必全面使用 Kotlin。通过混合 Java 和 Kotlin,你可以从两种语言中选择最佳的功能。

请记住,本书的主要焦点是 Java 语言和 JDK。尽管如此,你所学到的大部分思想都可以转移到 Android 上,即使你使用 Kotlin。但本书中没有专门考虑 Android 或 Kotlin 的特别情况。

浏览本书

本书包括两个不同的部分:

  • 第一部分,功能基础,介绍了函数式编程的历史和核心概念,Java 如何实现这些概念以及作为开发人员已经可用的各种类型。

  • 第二部分,功能方法,是一个基于主题的深入探讨,涵盖了更广义的编程概念以及如何通过函数式原则和新提供的工具来增强它们。某些特性,如RecordsStreams,通过扩展示例和用例进行了突出。

按照各章节的顺序阅读将能让你最大程度地从中受益,因为它们通常是相互构建的。但是随时可以略读可能感兴趣的部分,并随意跳跃。如有需要,任何必要的联系都会进行交叉引用,以填补任何空白。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单以及段落内,用于引用诸如变量或函数名称、数据库、数据类型、环境变量、语句和关键字等程序元素。

常量宽度粗体

显示用户应该按照字面意义输入的命令或其他文本。

常量宽度斜体

显示应替换为用户提供的值或根据上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

这个元素表示一般性的备注。

警告

此元素指示警告或注意事项。

使用代码示例

本书的源代码在 GitHub 上可用:https://github.com/benweidig/a-functional-approach-to-java。除了可编译的 Java 代码外,还有JShell脚本可用于更轻松地运行代码。请查看README.md以获取有关如何使用它们的说明。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Java 函数式方法 由本·韦迪格(O’Reilly)著作权 2023 Ben Weidig,978-1-098-10992-9。”

如果您觉得您使用的代码示例超出了公平使用范围或以上给出的许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly Online Learning

注意

超过 40 年来,O’Reilly Media为公司成功提供技术和商业培训、知识和见解。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问实时培训课程、深度学习路径、交互式编码环境以及 O’Reilly 和其他 200 多个出版商的大量文本和视频。更多信息,请访问https://oreilly.com

请向出版商发送有关本书的评论和问题:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有一本关于这本书的网页,在那里我们列出勘误、示例和任何其他信息。您可以访问https://oreil.ly/functional-approach-to-java-1e

发送电子邮件至bookquestions@oreilly.com以评论或提出有关本书的技术问题。

要获取关于我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

关注我们的 Twitter:https://twitter.com/oreillymedia

观看我们的 YouTube 频道:https://www.youtube.com/oreillymedia

致谢

这本书是为 Alexander Neumer 准备的,他是我在职业早期最好的导师。没有他,我今天不会成为这样的开发者。

我特别要感谢 Zan McQuade,感谢她的鼓励,并首先建议将我关于 Java 函数式编程的碎碎念聚合成一本书。

还要特别感谢技术审查者:Dean Wampler、Venkat Subramaniam、Thiago H. de Paula Figueiredo 和 A.N.M. Bazlur Rahman。他们在书籍不同阶段的支持、建议和有时候的严厉批评,使这本书比我独自完成要好得多。

我还要感谢 Felix Gonschorek 和 Benjamin Quenzer,这两位朋友和同事从一开始就与我“同舟共济”,并提供了宝贵的反馈,直到最后。

最后但并非最不重要的是,我要感谢我的采购编辑 Brian Guerin 以及 O’Reilly 的所有人。我的编辑 Rita Fernando 总是找到办法去打磨一些粗糙的地方,并让我所写的东西发挥到极致。Ashley Stussy,这位制作编辑使我所有的布局请求都成为可能。O’Reilly 工具团队的 Nick 和 Theresa,他们耐心地帮助我解决了任何 Asciidoc 问题。还有所有在幕后参与其中的人。谢谢你们!

¹ Lisp 最初于 1958 年规定,是仍在常用的第二古老的高级编程语言。它还构建了各种编程语言的基础,如Emacs Lisp,或功能 JVM 语言Clojure

² 摩尔定律(Moore’s law)是在 1965 年提出的,观察到晶体管数量每两年翻倍,因此,我们可以获得每核心的性能。Edwards, Chris. 2021. “摩尔定律:下一步是什么?”《ACM 通讯》,2021 年 2 月,第 64 卷第 2 期,12–14 页

³ Thompson, N. C. 和 Svenja Spanuth 在 2021 年发表了“计算机作为通用技术的衰落”《ACM 通讯》,Vol. 64, No. 3, 64-72

Erlang是一种功能丰富且面向并发的编程语言,以构建低延迟、分布式和容错系统而闻名。

⁵ Dean Wampler 在他的书籍“Java 开发者的函数式编程”中详细展示了如何在 Java 中实现和促进缺失的函数式编程特性。他展示了在版本 8 之前很难实现的许多技术。但现在,JDK 中的许多缺陷和间隙都已被填补,提供了许多工具,可以更简洁、更直接地整合 FP。

⁶ Oracle 在 Java 9 版本发布时推出了更快的发布计划,不再像以前那样发布频率较低,而是固定为每六个月发布一次。为了符合如此紧张的时间表,并非每个版本都被视为“长期支持”,而是更倾向于比以往更快地发布功能。

Android 开源项目提供了对 Android 运行时特性及其背后原理的很好概述。

⁸ 著名的 Android 开发者 Jack Wharton 在他的详细解析中展示了 Android 如何处理现代 Java 代码。

⁹ 请参阅官方 Kotlin 文档,了解支持平台的概述

¹⁰ 每个 Lambda 都会编译为一个匿名类,该类扩展了kotlin.jvm.internal.FunctionImpl,详见函数类型规范

第一部分:函数基础

函数式编程并不比面向对象编程及其主要命令式编码风格更复杂。它只是解决同样问题的一种不同方式。每个你可以命令式解决的问题也可以函数式解决。

数学为函数式编程打下基础,使其比面向对象思维更难以接近。但就像学习一门新的外语一样,随着时间的推移,相似性和共享的根基变得更加明显,直到顿悟

你可以在不使用 Java lambda 表达式的情况下实现几乎所有即将介绍的概念。虽然与其他语言相比,结果可能不那么优雅和简洁。Java 中提供的函数式工具使得你对这些概念和函数习语的实现更加简洁高效。

第一章:函数式编程简介

要更好地理解如何在 Java 中加入更多函数式编程风格,首先需要理解语言被视为函数式的含义以及其基本概念。

本章将探讨需要将更多函数式编程风格融入工作流程的函数式编程根源。

何谓函数式语言?

编程范式 — 如面向对象、函数式或过程化 — 是对语言进行分类并提供在特定风格中结构化程序以及使用不同方法解决问题的综合概念。像大多数范式一样,函数式编程没有一个统一的定义,人们对是否语言实际上是函数式进行了许多争论。与其给出我自己的定义,我将讨论构成语言函数式的不同方面。

当一个语言可以通过创建和组合抽象函数来表达计算时,它被认为是函数式的。这个概念源于 20 世纪 30 年代逻辑学家阿隆佐·丘奇发明的形式化数学系统Lambda Calculus。¹ 它是一个用抽象函数表达计算及如何将变量应用于它们的系统。名称“lambda calculus”选自希腊字母“lambda”,因其符号而选择:λ

作为面向对象开发者,你习惯于命令式编程:通过定义一系列语句,告诉计算机如何通过一系列语句完成特定任务。

编程语言要被认为是函数式的,需要能够以声明式风格表达计算逻辑,而不描述其实际的控制流。在这样的声明式编程风格中,你描述的是结果以及程序应该如何工作,而不是语句应该做什么。

在 Java 中,表达式是由操作符、操作数和方法调用组成的序列,定义了一个计算并且求值为单一值:

x * x
2 * Math.PI * radius
value == null ? true : false

语句,另一方面,是代码执行的动作,形成一个完整的执行单元,包括没有返回值的方法调用。每当你分配或更改变量的值,调用一个void方法,或使用像if/else这样的控制流构造时,你正在使用语句。通常它们与表达式混合使用:

int totalTreasure = 0; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

int newTreasuresFound = findTreasure(6); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

totalTreasure = totalTreasure + newTreasuresFound; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

if (treasureCounter > 10) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  System.out.println("You have a lot of treasure!"); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
} else {
  System.out.println("You should look for more treasure!"); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
}

1

将初始值分配给变量,引入状态到程序中。

2

函数调用findTreasure(6)是一个函数式表达式,但newTreasuresFound的赋值则是一个语句。

3

totalTreasure 的重新分配是使用右侧表达式的结果作为语句。

4

控制流语句if/else根据表达式(treasureCounter > 10)的结果传达应采取的操作。

5

将内容打印到System.out是一种语句,因为调用没有返回结果。

表达式和语句的主要区别在于是否返回值。在像 Java 这样的通用多范式语言中,它们之间的界限经常是有争议的,并且很快就会变得模糊。

函数式编程概念

由于函数式编程主要基于抽象函数,其构成范式的许多概念可以以声明式风格集中于“如何解决”的方法,与命令式的“如何解决”方法形成对比。

我们将深入探讨函数式编程在其基础上使用的最常见和重要的方面。尽管这些并不专属于函数范式。但这些背后的许多思想也适用于其他编程范式。

纯函数和引用透明性

函数式编程将函数分为两类:不纯

纯函数具有两个基本保证:

相同的输入将总是生成相同的输出。

函数的返回值必须完全依赖于其输入参数。

它们是自包含的,没有任何副作用

代码不能影响全局状态,比如更改参数值或使用任何 I/O。

这两个保证允许纯函数在任何环境中都能安全使用,甚至可以并行执行。以下代码展示了一个方法作为纯函数,接受参数但不会影响其上下文之外的任何东西:

public String toLowercase(String str) {
  return str;
}

违反这两个保证的函数被认为是不纯的。以下代码是一个不纯函数的例子,因为它使用当前时间来执行其逻辑:

public String buildGreeting(String name) {
  var now = LocalTime.now();
  if (now.getHour() < 12) {
    return "Good morning " + name;
  } else {
    return "Hello " + name;
  }
}

符号“纯”和“不纯”可能会因其可能激起的内涵而不太合适。不纯函数在一般情况下并不比纯函数差。它们只是根据您想要遵循的编码风格和范式而采用不同的方式使用。

表达式纯函数无副作用的另一个方面是它们的确定性特性,这使它们引用透明。这意味着您可以将它们替换为其评估结果,而不会改变程序的行为。

抽象函数:

f ( x ) = x * x

替换评估表达式:

r e s u l t = f ( 5 ) + f ( 5 ) = 25 + f ( 5 ) = f ( 5 ) + f ( 5 ) = 25 + 25

所有这些变体是相等的,不会改变你的程序。纯度和引用透明度是相辅相成的,给你提供了一个强大的工具,因为理解和推理你的代码更容易。

不可变性

面向对象的代码通常基于可变的程序状态。对象可以并且通常会在创建后发生变化,使用 setters。但是,改变数据结构可能会产生意外的副作用。然而,可变性不仅限于数据结构和面向对象编程。方法中的局部变量也可能是可变的,并且可能会导致与对象的变化字段一样的问题。

使用 不可变性,数据结构在初始化后就无法再更改。通过永远不变化,它们始终一致,无副作用,可预测,并且更容易推理。与 纯函数 一样,在并发和并行环境中使用它们是安全的,而不会出现通常的未同步访问或超出范围状态更改的问题。

如果数据结构在初始化后从不改变,那么程序就不会很有用。这就是为什么你需要创建一个新的更新版本,包含了变异状态,而不是直接改变数据结构。

为每个更改创建新的数据结构可能会很麻烦,而且由于每次复制数据而变得相当低效。许多编程语言采用“结构共享”来提供高效的复制机制,以最小化要求每次更改都需要新数据结构的低效性。这样,不同实例的数据结构之间共享不可变数据。第 4 章将更详细地解释为什么具有无副作用的数据结构的优点胜过可能需要的额外工作。

递归

递归 是一种解决问题的技术,通过部分解决相同形式的问题,并将部分结果组合起来最终解决原始问题。简而言之,递归函数会调用自身,但其输入参数略有变化,直到达到结束条件并返回实际值。第 12 章将详细讨论递归的细节。

一个简单的例子是计算阶乘,即小于或等于输入参数的所有正整数的乘积。函数不是使用中间状态计算值,而是使用递减的输入变量调用自身,如图 1-1 所示。

使用递归计算阶乘

图 1-1。使用递归计算阶乘

纯函数式编程通常更喜欢使用递归而不是循环或迭代器。其中一些,如Haskell,更进一步,根本没有forwhile循环。

反复调用函数可能效率低下,甚至有可能由于堆栈溢出的风险而变得危险。这就是为什么许多函数式语言利用像“展开”递归成循环或尾递归优化之类的优化技术来减少所需的堆栈帧。Java 不支持这些优化技术中的任何一种,我将在第十二章中详细讨论。

头等和高阶函数

先前讨论的许多概念不必作为深度集成的语言特性出现,以支持在代码中更加函数式的编程风格。然而,头等和高阶函数的概念是绝对必不可少的。

要使函数成为所谓的“头等公民”,它们必须遵守语言的其他实体固有的所有属性。它们需要能够分配给变量,并在其他函数和表达式中作为参数和返回值使用。

高阶函数使用这种头等公民身份来接受函数作为参数或将函数作为它们的结果返回,或者两者兼而有之。这是下一个概念函数组合的重要特性。

函数组合

纯函数可以组合以创建更复杂的表达式。在数学术语中,这意味着两个函数 f ( x )g ( x ) 可以组合成一个函数 h ( x ) = g ( f ( x ) ) ,如图 1-2 所示。

将函数 f 和 g 组合成新函数 h

图 1-2. 组合函数

这样,函数可以尽可能地小而精确,因此更易于重用。为了创建更复杂和完整的任务,此类函数可以根据需要快速组合。

柯里化

函数柯里化意味着将一个接受多个参数的函数转换为一系列每个只接受单个参数的函数。

注意

柯里化技术借用了数学家和逻辑学家 Haskell Brook Curry(1900-1982)的名字。他不仅是被称为柯里化的函数技术的名字来源,还有三种不同的编程语言以他的名字命名:HaskellBrookCurry

想象一个接受三个参数的函数。可以如下柯里化它:

初始函数:

x = f ( a , b , c )

柯里化函数:

h = g ( a ) i = h ( b ) x = i ( c )

一系列柯里化函数:

x = g ( a ) ( b ) ( c )

一些功能性编程语言在其类型定义中反映了柯里化的一般概念,如 Haskell 如下所示。

add :: Integer -> Integer -> Integer ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
add x y =  x + y ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

1

函数add被声明为接受一个Integer并返回另一个接受另一个Integer的函数,其本身返回一个Integer

2

实际的定义反映了声明:两个输入参数和主体的结果作为返回值。

乍一看,这个概念可能对面向对象或命令式开发者感觉奇怪和陌生,就像许多基于数学的原则一样。但它完美地传达了一个多参数函数如何可以表示为函数的函数,这是支持下一个概念的重要认识。

部分函数应用

部分函数应用是通过为现有函数提供不完整的参数来创建新函数的过程。它经常与柯里化混淆,但对部分应用的函数的调用返回一个结果,而不是柯里化链中的另一个函数。

前一节的柯里化示例可以部分应用以创建更具体的函数:

add :: Integer -> Integer -> Integer ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
add x y =  x + y

add3 = add 3 ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

add3 5 ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

1

函数add与之前一样声明,接受两个参数。

2

调用函数add仅为第一个参数x的值返回类型为Integer → Integer的部分应用函数,其绑定到名称add3

3

调用add3 5等同于add 3 5

通过部分应用,您可以即时创建新的更简洁的函数或从更通用的池中创建专门的函数以匹配代码当前的上下文和要求。

惰性求值

惰性求值是一种评估策略,它延迟对表达式的评估,直到其结果确实被需要,通过将创建表达式的方式与实际使用它的时间或方式分开。这也是另一个不仅根植于功能性编程,而且是使用其他功能性概念和技术的必备概念。

许多非函数式语言,包括 Java,在本质上是严格急切评估的,意味着表达式会立即评估。这些语言仍然具有一些惰性构造,如控制流语句(如if-else语句或循环)或逻辑短路运算符。立即评估if-else结构的两个分支或所有可能的循环迭代都没有多大意义,是吧?因此,运行时只评估绝对需要的分支和迭代。

惰性使得某些否则不可能的构造成为可能,例如无限数据结构或某些算法的更有效实现。它还与引用透明性非常配合。如果表达式和其结果没有区别,你可以延迟评估而不影响结果。延迟评估可能仍会影响程序的性能,因为你可能不知道评估的确切时间。

在第十一章中,我将讨论如何使用你手头的工具在 Java 中实现惰性评估,并且如何创建你自己的方法。

函数式编程的优点

经过对函数式编程最常见和基本概念的深入了解后,你可以看到它们如何体现在更加函数化方法所提供的优势中:

简单性

没有可变状态和副作用,你的函数往往更小,只做“它们应该做的事情”。

一致性

不可变数据结构是可靠且一致的。不再担心意外或意外的程序状态。

(数学)正确性

使用一致的数据结构编写更简单的代码将自动导致“更正确”的代码,同时 bug 的表面更小。你的代码越“纯粹”,就越容易理解,从而更容易进行简化的调试和测试。

更安全的并发性

并发是“传统”Java 中最具挑战性的任务之一。函数式概念使你能够消除许多烦恼,并几乎免费地获得更安全的并行处理。

模块化

小而独立的函数使得更简单的可重用性和模块化成为可能。结合函数组合和部分应用,你可以轻松地从这些更小的部分构建更复杂的任务。

可测试性

许多函数式概念,如纯函数、引用透明性、不可变性和关注点分离,使得测试和验证变得更加容易。

函数式编程的缺点

虽然函数式编程有许多优点,但了解其可能的陷阱也很重要。

学习曲线

函数式编程所基于的高级数学术语和概念可能会让人望而生畏。然而,要增强你的 Java 代码,你绝对不需要知道“一个单子只是一个自函子类别中的幺半群²”然而,你会面对新的、通常是陌生的术语和概念。

更高的抽象水平

OOP 使用对象来建模其抽象,而 FP 使用更高级别的抽象来表示其数据结构,使它们变得相当优雅,但通常更难以识别。

处理状态

处理状态并非易事,无论选择何种范式。即使 FP 的不可变方法消除了许多可能的错误表面,但如果实际上需要更改数据结构,则更难以变异,特别是如果你习惯于在你的 OO 代码中有 setter。

性能影响

在并发环境中,函数式编程更易于使用且更安全。然而,这并不意味着与其他范式相比它本质上更快,特别是在单线程环境中。尽管有许多好处,但许多函数式技术,如不可变性或递归,可能会因所需的开销而受到影响。这就是为什么许多函数式编程语言利用大量优化来减轻负担,比如最小化复制的专用数据结构,或者用于递归等技术的编译器优化³。

最佳问题背景

并非所有问题背景都适合采用函数式方法。高性能计算(HPC)、I/O 密集问题或低级系统和嵌入式控制器等领域,需要对诸如数据局部性和显式内存管理等事物进行精细控制,与函数式编程不太相容。

作为程序员,我们必须在任何范式和编程方法的优势和劣势之间找到平衡。这就是为什么这本书向你展示如何选择 Java 函数式进化的最佳部分,并利用它们来增强你的面向对象的 Java 代码。

收获

  • 函数式编程是建立在 lambda 演算的数学原理之上的。

  • 基于表达式而不是语句的声明式编码风格对函数式编程至关重要。

  • 许多编程概念在本质上都感觉像是函数式的,但这并不是使语言或代码“函数式”的绝对要求。即使非函数式代码也从它们的基本思想和整体思维中受益。

  • 纯度、一致性和简单性是将这些属性应用到你的代码中以获得函数式方法最大优势的关键。

  • 函数式概念和它们在现实世界中的应用之间可能需要权衡。它们的优势通常会超过劣势,或者至少可以以某种形式加以减轻。

¹ Church, Alonzo. 1936. “初等数论中的一个不可解问题。”《美国数学杂志》, Vol. 58, 345-363.](https://doi.org/10.2307/2268571)

² James Iry 在他幽默的博客文章“编程语言简史:简洁、不完整且大部分错误”中使用这个短语,来说明 Haskell 的复杂性。这也是一个很好的例子,说明你不需要了解编程技术背后的所有数学细节就能享受其带来的好处。但如果你真的想知道它的含义,可以参考 Saunders Mac Lane 的书籍,《工作数学家的范畴》(Springer, 1998),这本书最初使用了这个短语。

³ 《Java Magazine》的文章“花括号 #6:递归与尾调用优化”详细介绍了递归代码中尾调用优化的重要性。

第二章:函数式 Java

毫不奇怪,lambda 表达式是在 Java 中采用函数式编程方法的关键。

在本章中,你将学习如何在 Java 中使用 lambda 表达式,它们为何如此重要,如何高效使用它们以及它们的内部工作原理。

什么是 Java Lambdas?

Lambda 表达式是一行或一块 Java 代码,它可能有零个或多个参数,并可能返回一个值。从简化的角度看,lambda 就像一个不属于任何对象的匿名方法

() -> System.out.println("Hello, lambda!")

让我们看看语法的详细信息以及如何在 Java 中实现 lambda。

Lambda 语法

Java 中 lambda 的语法与你在第一章中看到的 lambda 演算的数学符号非常相似:

(<parameters>) -> { <body> };

语法由三个不同部分组成:

参数

一个逗号分隔的参数列表,就像方法参数列表一样。不过,与方法参数不同的是,如果编译器能够推断出参数类型,你可以省略参数类型。不允许混合使用隐式和显式类型的参数。对于单个参数,你不需要括号,但如果没有参数或多个参数,则需要括号。

箭头

->(箭头)将参数与 lambda 体分隔开来。它相当于 lambda 演算中的λ

可以是单个表达式或代码块。单行表达式不需要花括号,并且它们的计算结果隐式返回,不需要return语句。如果体由多个表达式组成,则使用典型的 Java 代码块。如果需要返回值,则必须用花括号明确地使用return语句。

这就是 Java 中关于 lambda 的语法定义。通过多种声明 lambda 的方式,你可以以不同程度的冗长写出相同的 lambda 表达式,就像在示例 2-1 中所见。

示例 2-1. 写相同 lambda 的不同方法
(String input) -> { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  return input != null;
}

input -> { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  return input != null;
}

(String input) -> input != null; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

input -> input != null; ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

1

最冗长的变体:显式类型的参数在括号内,且有代码块体。

2

第一种混合变体:参数类型推断允许省略显式类型,并且单个参数不需要括号。这样稍微缩短了 lambda 声明,但由于周围的上下文,不会丢失信息。

3

第二种混合变体:显式类型的参数在括号内,但只有单个表达式体而不是代码块,不需要花括号或return语句。

4

最简洁的变体:由于体可以简化为单个表达式。

要选择哪种变体取决于上下文和个人偏好。通常,编译器可以推断类型,但这并不意味着人类读者能够像编译器一样理解最短的代码。

尽管您应始终力求编写清晰和更简洁的代码,但这并不意味着它必须尽可能地简洁。适当的冗长可能有助于任何读者(包括您自己)更好地理解代码背后的原因,并使您的代码的心智模型更易于掌握。

函数接口

到目前为止,我们只是孤立地看待 lambda 的一般概念。然而,它们仍然必须存在于 Java 及其概念和语言规则内部。

Java 以其向后兼容性而闻名。这就是为什么尽管 lambda 语法是对 Java 语法本身的一个破坏性改变,它们仍然基于普通接口以保持向后兼容,并且对任何 Java 开发者来说都感觉非常熟悉。

要实现它们的一流公民地位,Java 中的 lambda 需要与现有类型(如对象和原始类型)相比较的表示形式,如在“一级和高阶函数”中讨论的那样。因此,lambda 通过所谓的函数接口的专门子类型来表示。

函数接口没有明确的语法或语言关键字。它们看起来和感觉像任何其他接口,可以扩展或被其他接口扩展,类可以实现它们。如果它们就像“普通”接口一样,那么是什么使它们成为“函数”接口呢?这是它们强制要求只能定义一个单一抽象方法(SAM)。

正如名称所示,SAM 计数仅适用于abstract方法。对于任何额外的非abstract方法没有限制。defaultstatic方法都不是抽象的,因此对 SAM 计数不相关。这就是为什么它们经常用于补充 lambda 类型的功能。

提示

JDK 中的大多数函数接口为您提供与类型相关的额外defaultstatic方法。检查任何函数接口的接口声明可能会揭示许多功能上的隐匿宝藏。

考虑示例 2-2,展示了函数接口java.util.function.Predicate<T>的简化版本¹。Predicate是一个用于测试条件的函数接口,将在“四大函数接口类别详解”中详细解释。

示例 2-2. 简化的java.util.functional.Predicate<T>
package java.util.function;

@FunctionalInterface ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
public interface Predicate<T> {

  boolean test(T t); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  default Predicate<T> and(Predicate<? super T> other) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    // ...
  }

  default Predicate<T> negate() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    // ...
  }

  default Predicate<T> or(Predicate<? super T> other) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    // ...
  }

  static <T> Predicate<T> isEqual(Object targetRef) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    // ...
  }

  static <T> Predicate<T> not(Predicate<? super T> target) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    // ...
  }
}

1

类型有一个@FunctionalInterface注解,这不是强制要求的。

2

类型为Predicate<T>的单一抽象方法。

3

几个default方法提供了对函数组合的支持。

4

便捷的static方法被用来简化创建或封装已存在的 lambdas。

任何具有单一抽象方法的接口都自动成为函数式接口。因此,它们的任何实现也可以用 lambda 表示。

Java 8 添加了标记注解@FunctionalInterface以在编译器级别强制执行 SAM 要求。这不是强制性的,但它告诉编译器和可能其他基于注解的工具,一个接口应该是一个函数式接口,因此,单一抽象方法的要求必须被执行。如果你添加了另一个abstract方法,Java 编译器将拒绝编译你的代码。这就是为什么将注解添加到任何函数式接口都是有道理的,即使你并不明确需要它。它澄清了你的代码背后的原因和这种接口的意图,并且加强了你的代码,使其能够抵御未经意的更改,可能会在将来破坏它。

@FunctionalInterface注解的可选性也使现有接口的向后兼容性成为可能。只要一个接口满足 SAM 的要求,它就可以表示为一个 lambda。我将在本章稍后讨论 JDK 的函数式接口。

Lambda 函数和外部变量

“纯函数和引用透明度”介绍了  — 自包含且无副作用 — 不会影响任何外部状态且只依赖于其参数的函数的概念。尽管 lambdas 遵循相同的要点,但它们也允许一定程度的不纯度以获得更灵活性。它们可以在定义 lambda 的创建范围内“捕获”常量和变量,即使原始范围不再存在,也可以使这些变量对它们可用,如 示例 2-3 所示。

示例 2-3. Lambda 变量捕获
void capture() {

  var theAnswer = 42; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  Runnable printAnswer =
    () -> System.out.println("the answer is " + theAnswer); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  run(printAnswer); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

void run(Runnable r) {
  r.run();
}

capture();
// OUTPUT:
// the answer is 42

1

变量theAnswercapture()的范围内声明。

2

Lambda printAnswer 在其函数体内捕获了变量。

3

Lambda 可以在另一个方法和作用域中运行,但仍然可以访问 theAnswer

捕获非捕获 Lambda 之间的主要区别在于 JVM 的优化策略。根据它们的实际使用模式,JVM 会优化不同的 Lambda。如果没有变量被捕获,一个 Lambda 可能最终会成为幕后的一个简单的 static 方法,从而超越匿名类等替代方法的性能。尽管如此,捕获变量对性能的影响并不是那么明确。

JVM 可能以多种方式翻译你的代码,如果捕获变量,会导致额外的对象分配,影响性能和垃圾回收时间。这并不意味着捕获变量本质上是一个不好的设计选择。更函数化方法的主要目标应该是提高生产力、更简单的推理和更简洁的代码。然而,你应该避免不必要的捕获,特别是如果你需要尽量减少分配或者获得最佳性能。

避免捕获变量的另一个原因是它们必须是 实质上 final 的。

实质上是最终的

JVM 必须考虑安全地使用捕获的变量,并实现尽可能的最佳性能。这就是为什么有一个重要的要求:只有 实质上final 的变量才允许被捕获。

简而言之,任何被捕获的变量必须是一个不可变的引用,在初始化后不允许更改。它们必须是 final,要么通过显式使用 final 关键字,要么通过在初始化后 永不 更改,使其 实质上 成为 final

请注意,这个要求实际上是针对变量的 引用 而不是其底层数据结构本身。对 List<String> 的引用可能是 final 的,因此可以在 Lambda 中使用,但你仍然可以添加新的项目,如 示例 2-4 所示。只有重新分配变量是被禁止的。

示例 2-4. 更改 final 变量后的数据
final List<String> wordList = new ArrayList<>(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

// COMPILES FINE
Runnable addItemInLambda = () ->
  wordList.add("adding is fine"); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

// WON'T COMPILE
wordList = List.of("assigning", "another", "List", "is", "not"); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

1

变量 list 显式地是 final 的,使得引用是不可变的。

2

在 Lambda 中捕获和使用变量是没有问题的。然而,final 关键字并不影响 +List 本身,允许你添加额外的项目。

3

由于 final 关键字,重新分配变量是被禁止的,并且不会编译通过。

测试一个变量是否 effectively final 或者不是最简单的方法是将其显式声明为 final。如果你的代码在添加了 final 关键字后仍然能够编译通过,那么它本来也可以编译通过。那为什么不将每个变量都声明为 final 呢?因为编译器确保“out-of-body”引用是 effectively final 的,所以这个关键字在实际上并不会帮助不可变性。将每个变量声明为 final 只会在你的代码中增加更多视觉噪音,而没有太多实际好处。添加 final 这样的修饰符应该始终是有意识的决定。

警告

如果在 jshell 中运行任何显示的 effectively final 相关示例,它们可能不会如预期般运行。这是因为 jshell 对于顶级表达式和声明具有特殊的语义,这影响了顶级处的 final 或 effectively final 值²。即使你可以重新分配任何引用,使其不再是 effectively final,你仍然可以在 lambda 中使用它们,只要你不在顶级范围内。

重新 final 化一个引用

有时一个引用可能不是 effectively final,但你仍然需要它们在 lambda 中可用。如果重构你的代码不是一个选项,有一个简单的技巧可以 re-finalize 它们。记住,要求只是对引用而不是底层数据结构本身。

你可以通过简单引用原始变量并且不再更改它来创建一个新的 effectively final 引用,示例见 Example 2-5。

示例 2-5. 重新 final 化一个变量
var nonEffectivelyFinal = 1_000L; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
nonEffectivelyFinal = 9_000L; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

var finalAgain = nonEffectivelyFinal; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

Predicate<Long> isOver9000 = input -> input > finalAgain;

1

此时,nonEffectivelyFinal 仍然是 effectively final

2

在初始化后更改变量会使其在 lambda 中无法使用。

3

通过创建一个新变量,并且在初始化后不再更改它,你“重新 finalize”了对底层数据结构的引用。

请记住,重新 finalize 一个引用只是一个“创可贴”,而需要创可贴意味着你先擦伤了你的膝盖。因此,最好的方法是尽量避免需要它。重构或重新设计你的代码应该始终是首选,而不是通过重新 finalize 一个引用这样的技巧来弯曲你的代码意愿。

对于像 effectively final 要求这样在 lambda 中使用变量的保护措施可能一开始会感觉像是额外的负担。然而,你的 lambda 应该努力成为自给自足的,需要所有必要的数据作为参数。这会自动导致更加合理的代码,增加重用性,并且更容易进行重构和测试。

匿名类怎么办?

在了解 Lambda 和功能接口之后,您很可能会想起它们与匿名内部类的相似之处:类型的联合声明和实例化。可以“即时”实现接口或扩展类,而无需单独的 Java 类,因此 Lambda 表达式和匿名类之间有什么不同,如果它们都必须实现一个具体的接口呢?

从表面上看,由匿名类实现的功能接口看起来与其 Lambda 表示相当相似,除了额外的样板代码,如示例 2-6 所示。

示例 2-6. 匿名类与 Lambda 表达式
// FUNCTIONAL INTERFACE (implicit)

interface HelloWorld {
  String sayHello(String name);
}

// AS ANONYMOUS CLASS

var helloWorld = new HelloWorld() {

  @Override
  public String sayHello(String name) {
    return "hello, " + name + "!";
  }
};

// AS LAMBDA

HelloWorld helloWorldLambda = name -> "hello, " + name + "!";

那么,Lambda 表达式是否只是实现一个功能接口作为匿名类的语法糖

Lambda 表达式可能看起来像是语法糖,但实际上它们远不止于此。除了冗长之外,真正的区别在于生成的字节码,如示例 2-7 所示,以及运行时如何处理它。

示例 2-7. 匿名类和 Lambda 之间的字节码差异
// ANONYMOUS CLASS

0: new #7 // class HelloWorldAnonymous$1 ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
3: dup
4: invokespecial #9 // Method HelloWorldAnonymous$1."<init>":()V ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
7: astore_1
8: return

// LAMBDA

0: invokedynamic #7, 0 // InvokeDynamic #0:sayHello:()LHelloWorld; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
5: astore_1
6: return

1

在外部类HelloWorldAnonymous中创建了匿名内部类HelloWorldAnonymous$1的新对象。

2

调用匿名类的构造函数。对象创建在 JVM 中是一个两步骤的过程。

3

invokedynamic操作码隐藏了创建 Lambda 的整个逻辑。

两种变体都共同拥有astore_1调用,该调用将引用存储到一个局部变量中,以及return调用,因此两者都不会成为字节码分析的一部分。

匿名类版本创建了一个匿名类型Anonymous$1的新对象,导致三个操作码:

new

创建一个新的未初始化类型实例。

dup

通过复制将值放在堆栈顶部。

invokespecial

调用新创建对象的构造方法以完成其初始化。

另一方面,Lambda 版本不需要创建必须放在堆栈上的实例。相反,它将整个创建 Lambda 的任务委托给 JVM,使用单个操作码:invokedynamic

Lambda 和匿名内部类之间的另一个重大区别是它们各自的作用域。内部类创建了自己的作用域,隐藏了其局部变量,不让外部作用域看到。这就是为什么关键字this引用的是内部类实例本身,而不是外部作用域的原因。另一方面,Lambda 完全存在于其外部作用域中。变量不能以相同的名称重新声明,而且this引用的是创建 Lambda 的实例,如果不是static的话。

正如你所看到的,Lambda 表达式根本不是语法糖。

Lambda 实战

正如您在前一节中看到的那样,lambda 是 Java 中非凡的添加,以提高其函数式编程能力,远不止是先前可用方法的语法糖。它们作为一等公民的地位使它们可以被静态类型化,简洁且匿名,就像任何其他变量一样。虽然箭头语法可能是新的,但总体使用模式应该对任何程序员来说都是熟悉的。在本节中,我们将直接进入使用 lambda 并看到它们的实际应用。

创建 Lambda 表达式

要创建 lambda 表达式,它需要表示一个单一的函数接口。实际类型可能并不明显,因为接收方法参数决定所需的类型,或者如果可能的话,编译器将会推断它。

让我们再次看看Predicate<T>,以更好地说明这一点。

创建一个新实例需要在左侧定义类型:

Predicate<String> isNull = value -> value == null;

即使您为参数使用显式类型,函数接口类型仍然是必需的:

// WON'T COMPILE
var isNull = (String value) -> value == null;

Predicate<String>的 SAM 方法签名可能是可推断的:

boolean test(String input)

尽管如此,Java 编译器要求引用的具体类型,而不仅仅是方法签名。这一要求源自 Java 对向后兼容性的倾向,正如我之前提到的那样。通过使用现有的静态类型系统,lambda 可以完美地适应 Java,赋予它们与任何其他类型或方法相同的编译时安全性。

然而,遵循类型系统使得 Java 的 lambda 比其他语言中的 lambda 更少动态。仅仅因为两个 lambda 共享相同的 SAM 签名并不意味着它们可以互换使用。

以以下函数接口为例:

interface LikePredicate<T> {
  boolean test(T value);
}

即使它的 SAM 与Predicate<T>相同,这些类型也不能互换使用,如下面的代码所示:

LikePredicate<String> isNull = value -> value == null; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

Predicate<String> wontCompile = isNull; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
// Error:
// incompatible types: LikePredicate<java.lang.String> cannot be converted
// to java.util.function.Predicate<java.lang.String>

1

Lambda 表达式与以往创建的方式一样。

2

尝试将其分配给具有相同 SAM 的函数接口不会编译。

由于这种不兼容性,您应该尽量依赖于java.util.function包中的可用接口,这将在第三章中讨论以最大化互操作性。您仍然会遇到类似于java.util.concurrent.Callable<V>的 Java 8+之前的接口,与此情况相同,java.util.function.Supplier<T>。如果发生这种情况,有一个很好的快捷方式可以将 lambda 切换到另一个相同类型。您将在“桥接功能接口”中了解到这一点。

作为方法参数和返回类型的临时创建的 lambda 不会受到任何类型不兼容性的影响,如下所示的演示:

List<String> filter1(List<String> values,
                     Predicate<String> predicate) {
  // ...
}

List<String> filter2(List<String> values,
                     LikePredicate<String> predicate) {
  // ...
}

var values = Arrays.asList("a", null, "c");

var result1 = filter1(values,
                      value -> value != null);

var result2 = filter2(values,
                      value -> value != null);

编译器直接从方法签名推断出临时 lambda 的类型,因此您可以集中精力于想要使用 lambda 实现的内容。返回类型也是如此:

Predicate<Integer> isGreaterThan(int value) {
  return compareValue -> compareValue > value;
}

现在您知道如何创建 lambda 表达式了,接下来需要调用它们。

调用 Lambda

正如讨论的那样,lambda 表达式实际上是其相应功能接口的具体实现。其他更具功能性倾向的语言通常将 lambda 视为更动态的。这就是为什么 Java 的使用模式可能与这些语言不同的原因。

例如,在 JavaScript 中,您可以直接调用 lambda 并传递参数,如下面的代码所示:

let helloWorldJs = name => `hello, ${name}!`

let resultJs = helloWorldJs('Ben')

然而,在 Java 中,lambda 表现得像接口的任何其他实例一样,因此需要显式调用其 SAM,如以下示例所示:

Function<String, String> helloWorld = name -> "hello, " + name + "!";

var result = helloWorld.apply("Ben"); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

调用单一抽象方法可能不像其他语言那样简洁,但好处是 Java 保持了向后兼容性。

方法引用

除了 lambda 表达式,Java 8 还引入了另一种新特性,语言语法发生变化,作为创建 lambda 表达式的一种新方式:方法引用。这是一种简写的语法糖,使用新的::(双冒号)运算符来引用现有方法,而不是从现有方法创建 lambda 表达式,从而简化您的函数式代码。

示例 2-8 展示了如何通过将 lambda 表达式转换为方法引用来改善流式处理管道的可读性。不要担心细节!你将在第六章中学习有关流的知识,只需将其视为接受 lambda 的方法的流畅调用。

示例 2-8. 方法引用和流
List<Customer> customers = ...;

// LAMBDAS

customers.stream()
         .filter(customer -> customer.isActive())
         .map(customer -> customer.getName())
         .map(name -> name.toUpperCase())
         .peek(name -> System.out.println(name))
         .toArray(count -> new String[count]);

// METHOD-REFERENCES

customers.stream()
         .filter(Customer::isActive)
         .map(Customer::getName)
         .map(String::toUpperCase)
         .peek(System.out::println)
         .toArray(String[]::new);

用方法引用替换 lambda 表达式可以消除很多噪音,而不会过多地影响代码的可读性或可理解性。输入参数不需要具有实际名称或类型,也不需要显式调用引用方法。此外,现代 IDE 通常会提供自动重构功能,将 lambda 表达式转换为方法引用(如果适用)。

您可以使用四种类型的方法引用,具体取决于您想要替换的 lambda 表达式及需要引用的方法类型:

  • 静态方法引用

  • 已绑定的非static方法引用

  • 未绑定的非static方法引用

  • 构造函数引用

让我们来看看各种方法引用及其如何以及何时使用它们。

静态方法引用

静态方法引用指的是特定类型的static方法,例如Integer上可用的toHexString方法:

// EXCERPT FROM java.lang.Integer
public class Integer extends Number {

  public static String toHexString(int i) {
    // ..
  }
}

// LAMBDA
Function<Integer, String> asLambda = i -> Integer.toHexString(i);

// STATIC METHOD REFERENCE
Function<Integer, String> asRef = Integer::toHexString;

静态方法引用的一般语法是ClassName::staticMethodName

已绑定的非静态方法引用

如果要引用已存在对象的非static方法,则需要已绑定的非静态方法引用。lambda 参数作为特定对象的引用方法的方法参数传递:

var now = LocalDate.now();

// LAMBDA BASED ON EXISTING OBJECT
Predicate<LocalDate> isAfterNowAsLambda = date -> $.isAfter(now);

// BOUND NON-STATIC METHOD REFERENCE
Predicate<LocalDate> isAfterNowAsRef = now::isAfter;

您甚至不需要中间变量;您可以直接使用::运算符将另一个方法调用或字段访问的返回值与返回值组合:

// BIND RETURN VALUE
Predicate<LocalDate> isAfterNowAsRef = LocalDate.now()::isAfter;

// BIND STATIC FIELD
Function<Object, String> castToStr = String.class::cast;

您还可以通过this::引用当前实例的方法,或者通过super::引用super实现,如下所示:

public class SuperClass {

  public String doWork(String input) {
    return "super: " + input;
  }
}

public class SubClass extends SuperClass {

  @Override
  public String doWork(String input){
    return "this: " + input;
  }

  public void superAndThis(String input) {

    Function<String, String> thisWorker = this::doWork;
    var thisResult = thisWorker.apply(input);
    System.out.println(thisResult);

    Function<String, String> superWorker = SubClass.super::doWork;
    var superResult = superWorker.apply(input);
    System.out.println(superResult);
  }
}

new SubClass().superAndThis("hello, World!");
// OUTPUT:
// this: hello, World!
// super: hello, World!

绑定方法引用是在变量、当前实例或super上使用已经存在的方法的好方法。它还允许您将非平凡或更复杂的 lambda 重构为方法,并改用方法引用。特别是像第六章中的流或第九章中的 Optional 这样的流畅管道,因其短方法引用的改进可读性而受益匪浅。

绑定非静态方法引用的一般语法为objectName::instanceMethodName

未绑定非静态方法引用

未绑定的非静态方法引用正如其名称所示,不绑定到特定对象。而是指向类型的实例方法:

// EXCERPT FROM java.lang.String
public class String implements ... {

  public String toLowerCase() {
    // ...
  }
}

// LAMBDA
Function<String, String> toLowerCaseLambda = str -> str.toLowerCase();

// UNBOUND NON-STATIC METHOD REFERENCE
Function<String, String> toLowerCaseRef = String::toLowerCase;

未绑定非静态方法引用的一般语法为ClassName::instanceMethodName

这种类型的方法引用可能会与静态方法引用混淆。但对于未绑定的非静态方法引用ClassName表示引用实例方法所在的实例类型。它也是 lambda 表达式的第一个参数。因此,引用方法是在传入实例上调用,而不是显式引用该类型的实例。

构造方法引用

方法引用的最后一种类型是类型的构造方法。构造方法引用的形式如下:

// LAMBDA
Function<String, Locale> newLocaleLambda = language -> new Locale(language);

// CONSTRUCTOR REFERENCE
Function<String, Locale> newLocaleLambda = Locale::new;

乍一看,构造方法引用看起来像静态方法或未绑定的非静态方法引用。所引用的方法并非实际方法,而是通过new关键字引用的构造方法。

构造方法引用的一般语法为ClassName::new

Java 中的函数式编程概念

第一章从大多数理论角度讨论了使编程语言从功能上变得功能强大的核心概念。因此,让我们从 Java 开发者的角度再次审视它们。

纯函数和引用透明性

纯函数的概念基于两个不一定与函数式编程绑定的保证:

  • 函数逻辑是自包含的,没有任何副作用。

  • 相同的输入将始终产生相同的输出。因此,可以用初始结果替换重复调用,使调用具有引用透明性。

这两个原则即使在命令式代码中也是有意义的。使您的代码自包含使其可预测且更简单。从 Java 的角度来看,您如何实现这些有益的属性?

首先,检查不确定性。有没有不依赖于输入参数的非预测逻辑?主要示例是随机数生成器或当前日期。在函数中使用这些数据会降低函数的可预测性,使其不纯洁

接下来,查找副作用和可变状态。

  • 您的函数是否影响函数本身之外的任何状态,比如实例或全局变量?

  • 它是否更改其参数的内部数据,比如向集合中添加新元素或更改对象属性?

  • 是否还有其他不纯洁的工作,比如 I/O?

但是,副作用并不局限于可变状态。一个简单的System.out.println(…​)调用是一个副作用,即使它看起来可能是无害的。任何类型的 I/O,比如访问文件系统,进行网络请求或打印到System.out都是副作用。推理很简单:具有相同参数的重复调用不能用第一次评估的结果替换。不纯洁方法的一个很好的指标是void返回类型。如果一个方法不返回任何内容,它所做的就是副作用,或者根本什么都不做。

纯函数本质上是引用透明的。因此,您可以用先前计算的结果替换任何具有相同参数的后续调用。这种可互换性允许一种称为记忆化的优化技术。这种技术源自拉丁词“memorandum”——被记住——描述了“记住”以前评估的表达式。它交换内存空间以节省计算时间

您很可能已经在代码中使用引用透明性的一般思想,以缓存的形式。从专用缓存库,如 Ehcache⁶ 到简单的基于HashMap的查找表,都是关于针对一组输入参数“记住”值的。

Java 编译器不支持 lambda 表达式或方法调用的自动记忆化。一些框架提供了注释,如 Spring 中的@Cacheable⁷ 或 Apache Tapestry 中的@Cached⁸,并在幕后自动生成所需的代码。

由于 Java 8+ 的一些新添加,创建自己的 lambda 表达式缓存也不是太难。所以现在就让我们来做。

通过创建一个“按需”查找表来构建自己的记忆化需要回答两个问题:

  • 如何唯一标识函数及其输入参数?

  • 您如何存储评估的结果?

如果您的函数或方法调用只有一个带有常量hashCode或其他确定性值的参数,则可以创建一个简单的基于Map的查找表。对于多参数调用,必须首先定义如何创建查找键。

Java 8 为Map<K, V>类型引入了多个功能增强。其中一个增强功能,computeIfAbsent方法,是实现记忆化的良好辅助工具,正如示例 2-9 中所示。

示例 2-9. 使用Map#computeIfAbsent进行记忆化。
Map<String, Object> cache = new HashMap<>(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

<T> T memoize(String identifier, Supplier<T> fn) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  return (T) cache.computeIfAbschent(identifier,
                                   key -> fn.get());
}

Integer expensiveCall(String arg0, int arg1) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    // ...
}

Integer memoizedCall(String arg0, int arg1) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  var compoundKey = String.format("expensiveCall:%s-%d", arg0, arg1);

  return memoize(compoundKey,
                 () -> expensiveCall(arg0, arg1));
}

var calculated = memoizedCall("hello, world!", 42); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)

var cached = memoizedCall("hello, world!", 42); ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)

1

结果被缓存在简单的HashMap<String, Object>中,因此可以缓存基于标识符的任何调用结果。根据您的需求,可能需要特别考虑,例如在 Web 应用程序中根据请求缓存结果或需要“存活时间”概念。此示例旨在展示查找表的最简单形式。

2

memoize方法接受一个标识符和一个Supplier<T>,以防缓存中尚未有结果。

3

expensiveCall是被记忆化的方法。

4

为方便起见,存在一个专门的记忆化调用方法,因此每次调用memoize时都无需手动构建标识符。它与计算方法具有相同的参数,并委托实际的记忆化过程。

5

方便方法允许您替换调用的方法名,以使用记忆化版本而不是原始版本。

6

第二次调用将立即返回缓存的结果,无需进行任何额外的评估。

这种实现相当简单,并非一刀切的解决方案。然而,它确实传达了通过一个实际的记忆化方法存储调用结果的一般概念。

Map<K, V>的功能增强并不止于此。它提供了创建“即时”关联的工具,并提供更多细粒度控制的工具,用于判断值是否已经存在。你将在第十一章中了解更多相关内容。

不可变性

在面向对象编程的经典 Java 方法中,基于可变程序状态,最显著的代表是 JavaBeans 和 POJOs。关于如何处理程序状态在 OOP 中没有明确的定义,且不可变性不是 FP 的先决条件或唯一特性。尽管如此,可变状态仍然是许多函数编程概念的眼中钉,因为它们期望使用不可变数据结构来确保数据完整性和安全的整体使用。

注意

POJO(“plain old Java Objects”)不受特殊限制约束,只受 Java 语言的限制。JavaBeans 是 POJO 的一种特殊类型。你将在“面向对象编程中的可变性和数据结构”中了解更多信息。

Java 对不可变性的支持与其他语言相比相当有限。这就是为什么它必须强制执行类似于 effective final 的构造,如在 “Lambdas and Outside Variables” 中讨论的那样。为了支持“完全”不可变性,您需要从头开始设计您的数据结构为不可变,这可能会很麻烦且容易出错。第三方库通常是一种选择,可以最小化所需的样板代码并依赖经过测试的实现。最后,随着 Java 14+,引入了不可变数据类——Records——来弥合这一差距,我将在 第五章 中讨论。

不可变性是一个复杂的主题,您将在 第四章 中了解更多关于其重要性以及如何适当地利用它 — 无论是使用内置工具还是自己动手 — 。

一等与高阶

由于 Java lambas 是功能接口的具体实现,它们获得了 一等 公民身份,并且可用作变量、参数和返回值,如在 示例 2-10 中所见。

示例 2-10. 一等 Java Lambdas
// VARIABLE ASSIGNMENT

UnaryOperator<Integer> quadraticFn = x -> x * x; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

quadraticFn.apply(5); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
// => 25

// METHOD ARGUMENT

public Integer apply(Integer input,
                     UnaryOperator<Integer> operation) {
  return operation.apply(input); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

// RETURN VALUE

public UnaryOperator<Integer> multiplyWith(Integer multiplier) {
  return x -> multiplier * x; ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
}

UnaryOperator<Integer> multiplyWithFive = multiplyWith(5);

multiplyWithFive.apply(6);
// => 30

1

将 Java lambda 分配给变量 quadraticFn

2

它可以像任何其他“正常”的 Java 变量一样使用,调用其接口的 apply 方法。

3

Lambdas 可以像任何其他类型一样用于参数。

4

返回 lambda 就像返回任何其他 Java 变量一样。

接受 lambda 作为参数并返回 lambda 对于下一个概念——函数组合——至关重要。

函数组合

通过组合更小的组件创建复杂系统的想法是编程的基石,无论选择哪种范式来跟随。在面向对象编程中,对象可以由较小的对象组合而成,构建更复杂的 API。在函数编程中,两个函数被组合以构建一个新函数,然后可以进一步组合。

函数组合可以说是功能编程思维中的一个重要方面。它允许你通过将更小、可重用的函数组合成一个更大的链条来构建复杂系统,从而完成更复杂的任务,如在 图 2-1 中所示。

从多个函数组合复杂任务

图 2-1. 从多个函数组合复杂任务

Java 的函数组合能力高度依赖于涉及的具体类型。在 “函数组合” 中,我将讨论如何结合 JDK 提供的不同函数接口。

惰性求值

即使 Java 在原则上是一种非惰性——严格急切——语言,它支持多个惰性结构:

  • 逻辑短路运算符

  • if-else:?(三元)操作符

  • forwhile 循环

逻辑短路运算符是懒惰的一个简单示例:

var result1 = simple() && complex();

var result2 = simple() || complex();

评估complex()取决于simple()的结果和整体表达式中使用的逻辑运算符。这就是为什么 JVM 可以丢弃不需要评估的表达式的原因,详细内容将在第十一章中详细解释。

要点

  • 函数接口是 Java lambda 的具体类型和表示。

  • Java 的 lambda 语法接近底层的 lambda 演算数学符号。

  • 根据周围上下文和您的需求,lambda 可以以多个级别的冗长表达。较短的表达式并不总是像应该的那样具有表达力,特别是其他人正在阅读您的代码时。

  • 由于 JVM 使用操作码invokedynamic,lambda 表达式并非语法糖。这允许多种优化技术以获得更好的性能,作为匿名类的替代品。

  • 外部变量需要有效final才能在 lambda 中使用,但这仅使引用不可变,而不是底层数据结构。

  • 方法引用是匹配方法签名和 lambda 定义的简洁替代方式。它们甚至提供了一种简单的方法来使用“相同但不兼容”的函数接口类型。

¹ 简化版的java.util.function.Predicate基于撰写时最新 Git 标签的 LTS 版本的源代码:17+35. 您可以查看官方源代码库查看原始文件。

² 官方文档为顶层表达式和声明的特殊语义和要求提供了一些指导。

³ Landin, Peter J. (1964). “表达式的机械评估。”《计算机杂志》。计算机杂志。6 (4)

⁴ Java Magazine 有 Java 冠军本·埃文斯的一篇文章,详细解释了invokedynamic方法调用。

⁵ 类java.lang.invoke.LambdaMetaFactory负责创建“引导方法”。

Ehcache是广泛使用的 Java 缓存库。

@Cacheable 这样的官方文档 解释了其内部工作原理,包括键的机制。

Tapestry 注解 不支持基于键的缓存,但可以绑定到一个字段上。

第三章:JDK 的功能接口

许多功能性编程语言仅使用“函数”的单一和动态概念来描述它们的 lambda,而不管其参数、返回类型或实际用例如何。另一方面,Java 是一种严格类型的语言,需要对所有内容(包括 lambda)进行具体类型。这就是为什么 JDK 在其 java.util.functional 包中为您提供了 40 多个现成的功能接口,以启动您的功能工具集。

本章将向您展示最重要的功能接口,解释为什么会有这么多变体,并展示如何扩展自己的代码以变得更加功能性。

四大功能接口类别

java.util.functional 中的 40 多个功能接口分为四个主要类别,每个类别代表一个基本的功能用例:

  • 函数 接受参数并返回结果。

  • 消费者 只接受参数但不返回结果。

  • 供应商 不接受参数,只返回结果。

  • 断言 接受参数以测试表达式,并返回一个boolean基元作为结果。

这四个类别涵盖了许多用例,它们的名称与功能接口类型及其变体相关。

让我们看看四大功能接口的主要分类。

函数

函数及其对应的 java.util.functional.Function<T, R> 接口是最为核心的功能接口之一。它们代表了一个“经典”函数,具有单一的输入和输出,如 图 3-1 所示:

Function<T, R>

图 3-1. Function<T, R>

Function<T, R> 的单个抽象方法称为 apply,接受一个类型为 T 的参数,并产生类型为 R 的结果:

@FunctionalInterface
public interface Function<T, R> {

  R apply(T t);
}

以下代码展示了如何对 null 进行检查并将 String 转换为其长度作为 Integer

Function<String, Integer> stringLength = str -> str != null ? str.length() : 0;

Integer result = stringLength.apply("Hello, Function!");

输入类型 T 和输出类型 R 可以相同。但是,在 “函数元数” 中,我讨论了具有相同类型的专用功能接口变体。

消费者

如其名称所示,Consumer 只 消耗 一个输入参数,但不返回任何东西,如 图 3-2 所示。中心 Consumer 功能接口是 java.util.functional.Consumer<T>

Consumer

图 3-2. Consumer<T>

Consumer<T> 的单个抽象方法称为 accept,需要一个类型为 T 的参数:

@FunctionalInterface
public interface Consumer<T> {

  void accept(T t);
}

以下代码消耗一个 String 并将其打印出来:

Consumer<String> println = str -> System.out.println(str);

println.accept("Hello, Consumer!");

即使在表达式中仅对值进行消费可能不符合“纯”功能概念,但它是在 Java 中采用更功能性编码风格的重要组成部分,可以弥合非功能性代码与高阶函数之间的许多差距。

Consumer<T> 接口类似于 Java 5+ 中 java.util.concurrent 包中的 Callable<V>,但后者会抛出已检查异常。在 Java 中,已检查异常和未检查异常的概念及其对函数式代码的影响将在 第十章 中详细探讨。

Suppliers

Suppliers 是 Consumers 的反义词。基于中心函数接口 java.util.functional.Supplier<T>,不同的 Supplier 变体不接受任何输入参数,但返回类型为 T 的单个值,如 图 3-3 所示。

Supplier

图 3-3. Supplier<T>

Supplier<T> 的单一抽象方法被称为 get

@FunctionalInterface
public interface Supplier<T> {

  T get();
}

以下的供应商在调用 get() 时会提供一个新的随机值:

Supplier<Double> random = () -> Math.random();

Double result = random.get();

Suppliers 经常用于延迟执行,例如将昂贵的任务封装在其中,仅在需要时调用 get,正如我将在 第十一章 中讨论的那样。

谓词

谓词是接受单一参数并根据其逻辑进行测试并返回 truefalse 的函数。主要函数式接口 java.util.functional.Predicate<T> 的语法在 图 3-4 中说明。

Predicate

图 3-4. Predicate<T>

单抽象方法被称为 test,接受一个 T 类型的参数并返回一个 boolean 原始类型:

@FunctionalInterface
public interface Predicate<T> {

  boolean test(T t);
}

它是决策制定的首选函数接口,例如函数式模式 map/filter/reduce 中的 filter 方法,稍后您将在 第六章 中详细学习。

以下代码测试一个 Integer 是否超过 9000:

Predciate<Integer> over9000 = i -> i > 9_000;

Integer result = over9000.test(1_234);

为什么会有这么多函数接口的变体?

尽管大四类别及其主要函数式接口的表示已经涵盖了许多用例,但您也可以使用各种变体和更专业化的变体。所有这些不同类型都是为了在 Java 中使用 lambda 而不会影响向后兼容性。因此,使用 lambda 在 Java 中比其他语言稍微复杂一些。尽管如此,在不破坏庞大生态系统的情况下集成这样一个功能是值得的。

有多种方法可以在不同的函数接口之间进行桥接,每个变体都有自己的最佳问题环境可供使用。一开始处理这么多不同类型可能看起来令人生畏,但在使用更函数化的方法一段时间后,知道在什么场景使用哪种类型将变得几乎是第二天性。

函数元数

Arity 的概念描述了函数接受的操作数数量。例如,元数为一意味着 lambda 接受单一参数,如下所示:

Function<String, String> greeterFn = name -> "Hello " + name;

由于 Java 方法中的参数数量,如 SAM,是固定的¹,因此必须有一个显式的功能接口来表示每个所需的元数。为了支持大于一的元数,JDK 包括了接受参数的主要功能接口类别的专门变体,如表 3-1 所列。

表 3-1. 基于元数的函数接口

元数为一 元数为二
Function<T, R> BiFunction<T, U, R>
Consumer<T> BiConsumer<T, U>
Predicate<T> BiPredicate<T, U>

仅支持最多两个元数的函数接口。查看 Java 中的功能 API 和使用案例,一到两个元数覆盖了最常见的任务。这很可能是 Java 语言设计者决定停在那里并且没有在开箱即用时添加更高元数的原因。

添加更高的元数非常简单,就像下面的代码中所示:

@FunctionalInterface
public interface TriFunction<T, U, V, R> {

  R accept(T t, U u, V, v);
}

不过,除非绝对必要,我不建议这样做。正如您将在本章和本书中看到的那样,包含的函数接口通过staticdefault方法为您提供了大量额外的功能。因此,依赖它们确保了最佳的兼容性和被广泛理解的使用模式。

函数操作符

操作符的概念通过为您提供具有相同泛型类型的功能接口来简化两个最常用的元数。例如,如果您需要一个函数接受两个 String 参数以创建另一个 String 值,则BiFunction<String, String, String> 的类型定义将非常重复。相反,您可以使用BinaryOperator<String>,其定义如下:

@FunctionalInteface
interface BinaryOperator<T> extends BiFunction<T, T, T> {
  // ...
}

实现一个评论超级接口使您能够使用更有意义的类型编写更简洁的代码。

可用的操作符功能接口列在表 3-2 中。

表 3-2. 操作符功能接口

元数 操作符 超级接口
1 UnaryOperator<T> Function<T, T>
2 BinaryOperator<T> BiFunction<T, T, T>

请注意,操作符类型及其super接口不能互换使用。在设计 API 时特别重要。

想象一个方法签名需要一个 UnaryOperator<String> 作为参数,它将与 Function<String, String> 不兼容。然而,反过来可以,如示例 3-1 所示。

示例 3-1. Java 元数兼容性
UnaryOperator<String> unaryOp = String::toUpperCase;

Function<String, String> func = String::toUpperCase;

void acceptsUnary(UnaryOperator<String> unaryOp) { ... };

void acceptsFunction(Function<String, String> func) { ... };

acceptsUnary(unaryOp); // OK
acceptsUnary(func); // COMPILE-ERROR

acceptsFunction(func); // OK
acceptsFunction(unaryOp); // OK

这个例子突出了选择方法参数的最常见公分母的重要性,例如Function<String, String>,因为它们提供了最大的兼容性。尽管这增加了方法签名的冗长,但在我看来,这是一个可以接受的折衷,因为它最大化了可用性,并且不限制参数为专门的函数接口。另一方面,创建 lambda 时,专门的类型允许更简洁的代码,而不会在代码表达上失去任何表现力。

原始类型

到目前为止,您遇到的大多数函数接口都有通用类型定义,但并非总是如此。原始类型目前不能用作通用类型。这就是为什么有专门的原始类型函数接口的原因。

可以使用任何对象包装类型的通用函数接口,并让自动装箱来处理其余部分。但是,自动装箱并非免费,因此可能会影响性能。

注意

自动装箱和拆箱是原始值类型与基于对象的对应类型之间的自动转换,以便它们可以被无差别地使用。例如,将int自动装箱为Integer。反之被称为拆箱。

这就是为什么 JDK 提供的许多函数接口处理原始类型,以避免自动装箱的原因。这种原始函数接口,例如特定的数目专业化,虽然不是所有原始类型都有,但大多数集中在数值原始类型intlongdouble周围。

表 3-3 列出了int的可用函数接口,但对于longdouble也有等效接口。

表 3-3. 整数原始类型的函数接口

类别 函数接口 包装类型替代
函数 IntFunction<R> Function<Integer, R>
IntUnaryOperator UnaryOperator<Integer>
IntBinaryOperator BinaryOperator<Integer>
ToIntFunction<T> Function<T, Integer>
ToIntBiFunction<T, U> BiFunction<T, U, Integer>
IntToDoubleFunction Function<Integer, Double>
IntToLongFunction Function<Integer, Long>
消费者 IntConsumer Consumer<Integer>
ObjIntConsumer<T> BiConsumer<T, Integer>
供应者 IntSupplier Supplier<Integer>
断言 IntPredicate Predicate<Integer>

boolean原始类型只有一个专门的变体可用:BooleanSupplier

Java 新功能部分中,针对基本类型的函数接口并不是唯一需要考虑的特殊情况。正如您将在本书后面学到的那样,Streams 和 Optionals 也提供了专门的类型,以减少自动装箱带来的不必要开销。

连接函数接口

功能接口就是接口,而 lambda 表达式则是这些接口的具体实现。类型推断使人容易忘记你不能在它们之间互换使用,或者简单地在不相关的接口之间进行强制转换。即使它们的方法签名相同,异常也会被抛出,就像在“创建 Lambda”中看到的那样:

interface LikePredicate<T> {
  boolean test(T value); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
}

LikePredicate<String> isNull = str -> str == null;

Predicate<String> wontCompile = isNull;
// Error:
// incompatible types: LikePredicate<java.lang.String> cannot be
// converted to java.util.function.Predicate<java.lang.String>

Predicate<String> wontCompileEither = (Predicate<String>) isNull;
// Exception java.lang.ClassCastException: class LikePredicate
// cannot be cast to class java.util.function.Predicate

从基于 lambda 的角度看,这两个 SAM 是相同的。它们都接受一个String参数并返回一个boolean结果。然而,对于 Java 的类型系统来说,它们完全没有关联,这使得它们之间的强制转换变得不可能。不过,可以通过我在上一章中讨论过的特性来弥合“lambda 兼容但类型不兼容”的功能接口之间的差距:方法引用

通过使用方法引用而不是试图在“相同但不兼容”的功能接口之间进行强制转换,你可以引用 SAM 以使你的代码编译通过:

Predicate<String> thisIsFine = isNull::test;

使用方法引用创建一个新的动态调用点,由字节码操作码invokedynamic来调用,而不是试图隐式或显式地强制转换功能接口本身。

像重新为你学习的“重新定位引用”一样,使用方法引用来连接功能接口是另一种“临时措施”,用来处理无法重构或以其他方式重新设计的代码。但这是一个易于使用且有时必不可少的工具,尤其是在从传统代码库过渡到更功能化方法,或者与提供自己功能接口的第三方代码一起工作时。

功能组合

功能组合是功能方法的重要组成部分,它将小功能单元组合成更大、更复杂的任务,而 Java 则为你提供了支持。但是,它是用典型的 Java 方式完成的,以确保向后兼容性。Java 没有引入新的关键字或更改任何语言语义,而是直接在功能接口上实现“粘合”方法作为default方法。借助它们,你可以轻松地组合四大类功能接口。这些粘合方法通过返回一个具有组合功能的新接口来构建两个功能接口之间的桥梁。

对于Function<T, R>,有两个default方法可用:

  • <V> Function<V, R> compose(Function<? super V, ? extends T> before)

  • <V> Function<T, V> andThen(Function<? super R, ? extends V> after)

这两种方法的区别在于组合的方向,由参数名称和返回的Function及其泛型类型指示。第一个方法compose创建一个组合函数,它将before参数应用于其输入并将结果应用于this。第二个方法andThen则与compose相反,它先评估this,然后将after应用于前一个结果。

选择函数组合的方向,compose还是andThen,取决于上下文和个人偏好。调用fn1.compose(fn2)会导致等效调用fn1(fn2(input))。要使用andThen方法实现相同的流程,组合顺序必须反转为fn2.andThen(fn1(input))的调用,如图 3-5 所示。

Function<T, R> 组合顺序

图 3-5. Function<T, R> 组合顺序

就我个人而言,我更喜欢andThen(…​),因为生成的类似散文般的流畅方法调用链反映了函数的逻辑流程,对于不熟悉函数式编程命名约定的其他读者更易于理解。

想象一下通过删除任何小写“a”的出现并大写结果来操作String。整体任务由两个执行单一任务的Function<String, String>组成。通过适当的粘合方法,可以以任一方式进行组合而不影响最终结果,如示例 3-2 中所见。

示例 3-2. 函数组合方向
Function<String, String> removeLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;

var input = "abcd";

removeLowerCaseA.andThen(upperCase)
                .apply(input);
// => "BCD"

upperCase.compose(removeLowerCaseA)
         .apply(input);
// => "BCD"

请注意,并非每个功能接口都提供这种“粘合方法”以便轻松支持组合,即使这样做是合理的。以下列表总结了四大类主要接口如何在原生支持组合方面的支持情况:

Function<T, R>

Function<T, R>及其类似UnaryOperator<T>的专门继承,支持两个方向的组合。Bi…​变体仅支持andThen

Predicate<T>

谓词支持各种方法来组合具有常见操作的新谓词:andornegate

Consumer<T>

只支持andThen,它将两个Consumer组合以便按顺序接受值。

专门的原始功能接口

特定于原始类型的专门功能接口之间的功能组合支持与其通用兄弟接口不尽相同。即使在它们之间,对于原始类型的支持也有所不同。

但不要担心!编写自己的函数组合辅助工具很容易,下一节我会详细讨论。

扩展功能支持

大多数函数式接口通常不仅仅提供定义 lambda 签名的单个抽象方法。通常,它们提供额外的 default 方法来支持函数组合等概念,或者提供 static 辅助方法来简化该类型的常见用例。

由于你无法自己更改 JDK 的类型,但仍然可以使你自己的类型更加功能化。你可以选择 JDK 自身也在使用的三种方法之一:

  • 向接口添加 default 方法,使现有类型更具功能性。

  • 显式地实现一个函数式接口。

  • 创建 static 辅助方法来提供常见的函数操作。

添加默认方法

向接口添加新功能始终需要在所有实现上实现新方法。在处理小项目时,仅更新任何实现可能是可行的,但在更大和共享的项目中通常不太容易。在库代码中更糟糕,你可能会破坏任何使用你的库的人的代码。这就是 default 方法派上用场的地方。

而不仅仅是改变类型接口的契约,让任何实现它的人来处理其影响 —— 在任何实现该接口的类型上添加新方法 --⁠,你可以使用 default 方法来提供“常识”实现。这样的实现为所有其他类型提供了预期逻辑的一般变体,因此你不必抛出 UnsupportedOperationException。这种方式使得你的代码向后兼容,因为只有接口本身发生了变化,但是任何实现该接口的类型仍然有机会根据需要创建自己更合适的实现。这正是 JDK 如何向任何实现 java.util.Collection<E> 接口的类型添加 Stream 支持的方式。

以下代码展示了实际的 default 方法,使任何基于 Collection 的类型在没有额外(实现)成本的情况下立即具备了 Stream 功能:

public interface Collection<E> extends Iterable<E> {

  default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
  }

  default Stream<E> parallelStream() {
    return StreamSupport.stream(spliterator(), true);
  }

  // ...
}

这两个 default 方法通过调用 static 辅助方法 StreamSupport.stream(…​)default 方法 spliterator() 来创建新的 Stream<E> 实例。spliterator() 最初在 java.util.Iterable<E> 中定义,但根据需要进行了重写,如 示例 3-3 所示。

示例 3-3. 默认方法层次结构
public interface Iterable<T> { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  default Spliterator<T> spliterator() {
    return Spliterators.spliteratorUnknownSize(iterator(), 0); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  }

  // ...
}

public interface Collection<E> extends Iterable<E> {

  @Override
  default Spliterator<E> spliterator() {
    return Spliterators.spliterator(this, 0); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  }

  // ...
}

public class ArrayList<E> extends AbstractList<E>
  implements List<E>, ... {

  @Override
  public Spliterator<E> spliterator() {
      return new ArrayListSpliterator(0, -1, 0); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }

  // ...
}

1

spliterator() 的原始定义,基于类型的所有可用信息提供了一个常识实现。

2

Collection 接口可以使用更多信息来创建一个更具体的 Spliterator<E>,该 Spliterator<E> 可用于其所有实现。

3

具体实现 ArrayList<E> 实现了 Collection<E>,通过 List<E>,进一步提供了更专业化的 Spliterator<E>

一个default方法的层次结构赋予你向接口添加新功能的能力,而不会破坏任何实现,并且仍然提供新方法的常识变体。即使一个类型从未为自己实现更具体的变体,它也可以回退到default方法提供的逻辑。

显式实现功能接口

功能接口可以通过 lambda 或方法引用隐式实现,但当它们由你的类型之一显式实现时,它们也非常有用,因此它们可以在高阶函数中使用。你的一些类型可能已经实现了诸如java.util.Comparator<T>java.lang.Runnable之类的后续功能接口中的一个。

直接实现功能接口在以前的“非功能”类型和它们在功能代码中的易用性之间创建了一个桥梁。一个很好的例子是面向对象的命令设计模式⁠²。

注意

命令模式封装了一个动作或“命令”,以及执行它所需的所有数据。这种方法将命令的创建与消费分离开来。

通常,一个命令已经有了一个专用的接口。想象一个文本编辑器和其常见命令,如打开文件或保存文件。这些命令之间的共享命令接口可以简单地如下所示:

public interface TextEditorCommand {

  String execute();
}

具体的命令类将接受所需的参数,但执行的命令将简单地返回更新后的编辑器内容。如果你仔细观察,你会发现该接口匹配了一个Supplier<String>

正如我在“桥接功能接口”中讨论的那样,仅仅是功能接口之间的逻辑等价并不足以创建兼容性。然而,通过将TextEditorCommand扩展为Supplier<String>,你可以通过default方法弥合差距,如下所示:

public interface TextEditorCommand
  extends Supplier<T> {

  String execute();

  default String get() {
    return execute();
  }
}

接口允许多重继承,因此添加一个功能接口不应该成为问题。功能接口的 SAM 是一个简单的default方法,调用实际执行工作的方法。这种方式不需要更改任何单个命令,但它们都可以与任何接受Supplier<String>的高阶函数兼容,而无需方法引用作为桥接。

警告

注意方法签名冲突,如果现有接口实现一个功能接口,这样你就不会意外覆盖一个现有的接口。

实现一个或多个功能接口是给你的类型提供功能起点的好方法,包括功能接口上可用的所有额外的default方法。

创建静态帮助器

功能接口通常通过具有default方法和常见任务的static帮助器来扩展其多功能性。但是,如果你无法控制类型,比如由 JDK 本身提供的功能接口,你可以创建一个累积static方法的帮助器类型。

在“Functional Composition”中,我讨论了通过大四接口上的可用default方法进行功能组成的功能组成。尽管覆盖了最常见的用例,但某些不同的功能接口却未被覆盖。但是,您可以自己创建它们。

让我们看一下 Example3-4 中的Function<T, R>如何实现了其compose方法,以便我们可以开发一个辅助组合类型来接受其他类型。

示例 3-4。 简化的Function<T, R>接口
@FunctionalInterface
public interface Function<T, R> {

    default <V> Function<V, R> compose(Function<V, T> before) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
        Objects.requireNonNull(before); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

        return (V v) -> { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
          T result = before.apply(v); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
          return apply(result); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
        };
    }

    // ...
}

1

组合函数不再限制于原始类型 T,在其方法签名中引入了 V

2

一个null-check helper 来在组成上抛出一个NullPointerException而不仅仅是在返回 lambda 的第一次使用上。

3

返回的 lambda 接受新引入类型V的值。

4

首先评估 before 函数。

5

结果然后应用于原始Function<T, R>

要创建自己的组合方法,首先必须考虑你想要实现的具体目标。所涉及的功能接口及其组合顺序将决定方法签名必须反映的整体类型链:

Function<T, R>#compose(Function<V, T>)

VTR

Function<T, R>#andThen(Function<R, V)

TRV.

让我们开发一个组合器为Function<T, R>Supplier/ Consumer

由于 Supplier 不接受参数,因此只有两种组合是可能的;因此无法评估 Function<T, R> 的结果。 对于 Supplier,原因是反向的。 由于直接扩展 Function<T, R> 接口不可能,所以需要使用 static 辅助程序的间接组合器。 这导致了以下方法签名,其中参数顺序反映了组合顺序:

  • Supplier<R> compose(Supplier<T> before, Function<T, R> fn)

  • Consumer<T> compose(Function<T, R> fn, Consumer<R> after)

Example3-5 显示了一个简单的组合器实现,其与 JDK 的等效方法实现不会有太大的不同。

示例 3-5. 函数组合器
public final class Compositor {

  public static <T, R> Supplier<R> compose(Supplier<T> before,
                                           Function<T, R> fn) {
    Objects.requireNonNull(before);
    Objects.requireNonNull(fn);

    return () -> {
      T result = before.get();
      return fn.apply(result);
    };
  }

  public static <T, R> Consumer<T> compose(Function<T, R> fn,
                                           Consumer<R> after) {
    Objects.requireNonNull(fn);
    Objects.requireNonNull(after);

    return (T t) -> {
      R result = fn.apply(t);
      after.accept(result);
    };
  }

  private Compositor() {
    // disallows direct instantiation
  }
}

将以前的String操作与 Example3-2 与一个额外的Consumer<String>打印结果很容易进行组合,如 Example3-6 所示。

示例 3-6。 使用功能组合器
// SINGULAR STRING FUNCTIONS

Function<String, String> removeLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;

// COMPOSED STRING FUNCTIONS

Function<String, String> stringOperations =
  removeLowerCaseA.andThen(upperCase);

// COMPOSED STRING FUNCTIONS AND CONSUMER

Consumer<String> task = Compositor.compose(stringOperations,
                                           System.out::println);

// RUNNING TASK

task.accept("abcd");
// => BCD

在函数接口之间传递值的简单组合器是功能组合的明显用例。但它也适用于其他用例,例如引入某种程度的逻辑和决策制定。例如,您可以像在示例 3-7 中所示的那样使用Predicate来保护Consumer

示例 3-7. 改进的功能组合器
public final class Compositor {

  public static Consumer<T> acceptIf(Predicate<T> predicate,
                                     Consumer<T> consumer) {
    Objects.requireNonNull(predicate);
    Objects.requireNonNull(consumer);

    return (T t) -> {
      if (!predicate.test(t)) {
        return;
      }
      consumer.accept(t);
    }
  }

  // ...
}

您可以通过根据需要向您的类型添加新的static帮助程序来填补 JDK 留下的空白。从个人经验来看,我建议仅在需要时添加帮助程序,而不是试图积极填补空白。只实现当前需要的内容,因为很难预见未来需要什么。任何现在没有使用的额外代码行都需要随着时间的推移进行维护,并且如果您希望使用它并且实际需求变得明确,则可能需要更改或重构。

要点

  • JDK 提供了 40 多个功能接口,因为 Java 的类型系统需要不同用例的可触及接口。可用的功能接口分为四类:函数、消费者、供应者和断言。

  • 存在专门的功能接口变体,适用于高达两个的 arity。然而,方法签名应使用它们的等效super接口以最大化兼容性。

  • 通过使用自动装箱或用于intlongdoubleboolean的相应功能接口变体,支持原始类型。

  • 功能接口的行为类似于任何其他接口,并且需要一个共同的祖先才能互换使用。但是,通过使用 SAM 的方法引用,可以弥合“相同但不兼容”的功能接口之间的差距。

  • 将功能支持添加到您自己的类型很容易。使用您的接口上的default方法来覆盖功能用例,而无需更改任何实现。

  • 常见或缺失的功能任务可以在具有static方法的辅助类型中累积。

¹ 可变参数方法参数,如String…​,似乎具有动态的 arity,因为该方法接受非固定数量的参数。然而,在幕后,这些参数被转换为数组,使实际的 arity 为一。

² 命令模式是由 四人帮 描述的许多面向对象设计模式之一。Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design patterns: Elements of reusable object-oriented software. Boston, MA: Addison Wesley.

³ 所示的Function<T, R>接口是 JDK 中存在的源代码的简化变体,以增加可读性。

第二部分:函数式方法

尽管 Java 是一种多范式语言,它明确鼓励面向对象和命令式编程风格。然而,许多函数式习语、概念和技术仍然可供使用,即使没有深度集成的语言支持。

JDK 提供了大量工具,可以通过函数式方法解决常见问题,并在不完全转向函数式编程的情况下享受函数式编程的优势。

第四章:不可变性

处理数据结构 — 专门用于存储和组织数据值的结构 — 是几乎所有程序的核心任务。在面向对象编程中,这通常意味着处理可变的程序状态,通常封装在对象中。然而,在函数式方法中,不可变性是处理数据的首选方式,也是许多概念的先决条件。

在像 Haskell 这样的函数式编程语言,甚至是像 Scala 这样的多范式但更倾向于函数式编程的语言中,不可变性被视为一种突出的特性。在这些语言中,不可变性是必要的,并且通常严格执行,而不仅仅是设计时的附加思考。与本书介绍的大多数其他原则一样,不可变性并不限于函数式编程,并且无论你选择的范式如何,都提供许多好处。

在本章中,您将学习 JDK 中已经可用的不可变类型,以及如何通过 JDK 提供的工具或第三方库使您的数据结构不可变,以避免副作用。

注意

本章中使用的术语“数据结构”代表任何存储和组织数据的构造,例如集合或自定义对象。

面向对象编程中的可变性和数据结构

作为一个面向对象的倾向语言,典型的 Java 代码以可变形式封装对象的状态。通常使用“setter”方法使其状态可变。这种方法使得程序状态短暂,意味着对现有数据结构的任何更改都会原地更新其当前状态,这也会影响任何引用它的人,并且丢失了先前的状态。

让我们看一下在面向对象的 Java 代码中处理可变状态最常见的形式:JavaBeansPlain Old Java Objects (POJO)。关于这两种数据结构及其各自特性存在许多混淆。在某种意义上,它们都是普通的 Java 对象,旨在通过封装所有相关状态来创建组件之间的可重用性。它们有着相似的目标,尽管它们的设计哲学和规则有所不同。

POJOs 对其设计没有任何限制。它们被认为“只是”封装业务逻辑状态,并且您甚至可以设计它们为不可变的。您如何实现它们取决于您和什么最适合您的环境。它们通常为其字段提供“getter”和“setter”,以在面向对象的上下文中更灵活地处理可变状态。

另一方面,JavaBeans 是 POJO 的一种特殊类型,允许更容易地内省和重用,这要求它们遵守某些规则。这些规则是必要的,因为 JavaBeans 最初被设计为在组件之间共享标准化的可共享的机器可读状态,例如您 IDE 中的 UI 组件¹。POJOs 和 JavaBeans 之间的区别列在 表 4-1 中。

表 4-1. POJO vs JavaBeans

POJO JavaBean
一般限制 仅限于 Java 语言本身强加的限制 受 JavaBean API 规范强加的限制
序列化 可选 必须实现java.io.Serializable
字段可见性 无限制 仅限private
字段访问 无限制 只能通过 getter 和 setter 访问
构造函数 无限制 必须存在无参构造函数。

JDK 中许多可用的数据结构,如集合框架⁠²,大多围绕可变状态和原地修改的概念构建。以List<E>为例。它的变异方法,如add(E value)remove(E value),只返回一个boolean以指示发生了变化,并在原地修改集合,因此前一个状态丢失。在局部环境中,您可能不需要过多考虑,但一旦数据结构超出您的直接影响范围,就无法保证它将保持当前状态,只要您持有对它的引用。

可变状态孕育了复杂性和不确定性。您必须随时将所有可能的状态变化包含在您的思维模型中,以理解和推理您的代码。然而,这不仅限于单个组件。共享可变状态增加了覆盖任何访问此类共享状态的组件的生命周期的复杂性。特别是在并发编程中,共享状态的复杂性导致许多问题,这些问题源于可变性,并且需要复杂且常常被误用的解决方案,如访问同步和原子引用。

确保您的代码和共享状态的正确性成为一个无休止的任务,需要无数的单元测试和状态验证。当可变状态与更多可变组件交互时,所需的额外工作随之增加,进而需要对其行为进行更多的验证。

这就是不可变性提供另一种处理数据结构和恢复合理性的方法。

不可变性(不仅限于)在 FP 中

不可变性的核心思想很简单:数据结构在创建后不能再更改。许多函数式编程语言在其核心设计中支持此概念。该概念并不仅限于函数式编程范式,并且在任何范式中都具有许多优势。

注意

不可变性为许多问题提供了优雅的解决方案,即使在编程语言之外也是如此。例如,分布式版本控制系统Git基本上使用不可变 blob 和 diff 的指针树来提供历史变更的强大表示。

不可变数据结构是它们的数据的持久视图,没有直接选项来更改它。要“改变”这样的数据结构,你必须创建一个带有预期更改的新副本。在 Java 中无法“原地”改变数据可能一开始会感觉奇怪。与面向对象代码通常的可变性相比,为什么你应该采取额外的步骤来简单地改变一个值呢?通过复制数据创建新实例会导致特定的开销,这对不可变性的简单实现来说很快就会累积起来。

尽管不能在原地更改数据会带来一些开销和初始的不适,但不可变性的好处即使在没有更多对 Java 的函数式方法的支持的情况下也是值得的:

可预测性

数据结构不会在你不知情的情况下改变,因为它们根本无法改变。只要你引用一个数据结构,你就知道它与创建时是相同的。即使你分享了那个引用或以并发方式使用它,也没有人可以改变你的副本。

有效性

初始化后,数据结构就完整了。它只需要被验证一次,并且在此后保持有效(或无效)。如果你需要在多个步骤中构建一个数据结构,那么稍后在“逐步创建”中显示的构建器模式将分离数据结构的构建和初始化。

没有隐藏的副作用

处理副作用是编程中的一个非常棘手的问题 — 除了命名和缓存失效³。不可变数据结构的一个副产品是副作用的消除;它们总是保持不变。即使通过代码的不同部分频繁移动或在你无法控制的第三方库中使用它,它们也不会改变其值或用意外副作用来使你感到惊讶。

线程安全

没有副作用,不可变数据结构可以在线程边界之间自由移动。没有线程可以改变它们,因此由于没有意外更改或竞态条件,对程序的推理变得更加直观。

可缓存性和优化

因为它们在创建后就保持不变,所以你可以放心地缓存不可变数据结构。像记忆化这样的优化技术只有在不可变数据结构中才可能实现,正如第二章所讨论的那样。

变更跟踪

如果每次更改都会导致一个全新的数据结构,那么你可以通过存储先前的引用来跟踪它们的历史。你不再需要精心跟踪单个属性的变化以支持“撤销”功能。恢复先前的状态就像使用对数据结构的先前引用一样简单。

请记住,所有这些优点都与所选择的编程范式无关。即使你认为函数式方法可能不适合你的代码库,你的数据处理仍然可以从不可变性中获益良多。

Java 不可变性的现状

Java 最初的设计并没有将不可变性作为深度集成的语言特性或多种不可变数据结构。语言和其类型的某些方面始终是不可变的,但这与其他更功能丰富的语言中的支持水平相去甚远。一切都在 Java 14 发布并引入Records后改变了,这是一种内置的语言级不可变数据结构:Records

即使你可能还不知道,你在所有的 Java 程序中都已经在使用不可变类型。它们之所以不可变的原因可能不同,比如运行时优化或确保其正确使用,但不管其意图如何,它们都会使你的代码更安全,减少错误。

让我们看看当今 JDK 中提供的所有不可变部分。

java.lang.String

每个 Java 开发者都会学习的第一种类型之一是String类型。字符串无处不在!这就是为什么它需要是一个高度优化和安全的类型。其中一个优化是它是不可变的。

String并不是一个像intchar这样的基本值类型,但它支持使用+(加号)操作符将String与另一个值连接起来:

String first = "hello, ";
String second = "world!";
String result = first + second;
// => "hello, world!"

像任何其他表达式一样,连接字符串会产生一个结果,在这种情况下是一个新对象。这就是为什么 Java 开发者早就被教导不要过度使用手动的String连接。每次通过使用+(加号)操作符连接字符串时,都会在堆上创建一个新的String实例,占用内存,正如图 4-1 所示。这些新创建的实例可能会快速累积,特别是如果连接操作在像forwhile这样的循环语句中执行时。

字符串内存分配

图 4-1. 字符串内存分配

即使 JVM 会垃圾回收不再需要的实例,无限创建String会导致运行时的内存开销成为真正的负担。这就是为什么 JVM 在“幕后”使用多种优化技术来减少String的创建,比如用java.lang.StringBuilder替换连接,甚至使用invokedynamic操作码支持多种优化策略⁴。

因为String是如此基础的类型,所以将其设为不可变是合理的,原因有多个。通过设计使这样一个基本类型是线程安全的解决了与并发相关的问题,比如在问题出现之前就解决了同步问题。并发本身已经足够困难,不用担心String在不知情的情况下发生更改。不可变性消除了竞态条件、副作用或简单的意外更改的风险。

String 字面值在 JVM 中也得到特殊处理。由于字符串池的存在,相同的字面值只会被存储一次,并且重复使用以节省宝贵的堆空间。如果一个 String 可以改变,那么使用引用它的所有人都会受到影响。可以通过显式调用其构造函数之一而不是创建字面值来分配一个新的 String 以避免池化。反过来也是可能的。通过在任何实例上调用 intern() 方法,它将从字符串池返回具有相同内容的 String

字符串相等性

String 实例和字面值的专门处理是为什么你永远不应该使用等号 ==(双等号)来比较字符串的原因。这就是为什么你应该总是使用 equalsequalsIgnoreCase 方法来测试相等性。

然而,从技术角度来看,String 类型并非完全不可变的。由于性能考虑,它会延迟计算其 hashCode,因为它需要读取整个 String 来计算它。但它仍然是一个纯函数:相同的 String 总是得到相同的 hashCode

使用惰性评估来隐藏昂贵的即时计算以实现逻辑上的不可变性在类型的设计和实现过程中需要额外的注意,以确保它保持线程安全和可预测性。

所有这些特性使得 String 在可用性角度来看介于原始类型和对象类型之间。性能优化可能是其不可变性的主要原因,但不可变性的隐含优势仍然是这样一个基础类型的一个受欢迎的补充。

不可变集合

另一组从不可变性中显著受益的基本且无处不在的类型是集合,如 SetListMap 等。

尽管 Java 的集合框架并不是以不可变性作为核心原则设计的,但它仍然有三种方式来提供一定程度的不可变性:

  • 不可修改的集合

  • 不可变集合工厂方法(Java 9+)

  • 不可变副本(Java 10+)

所有这些选项都不是可以直接使用 new 关键字实例化的public 类型。相反,相关类型有 static 便利方法来创建必要的实例。此外,它们只是浅层不可变的,这意味着你不能添加或删除任何元素,但元素本身不能保证是不可变的。任何持有元素引用的人都可以在不知情的情况下更改它,而不知道它当前存在于哪个集合中。

浅不可变性

浅不可变数据结构只在其最顶层提供不可变性。这意味着数据结构本身的引用是不能改变的。然而,在集合的情况下,引用的数据结构的元素仍然可以被修改。

要拥有完全不可变的集合,您也需要仅使用完全不可变的元素。尽管如此,这三个选项仍为您提供了一种防止意外修改的有用工具。

不可修改集合

第一个选项,不可修改集合,是通过调用java.util.Collections的以下通用static方法之一从现有集合创建的:

  • Collection<T> unmodifiableCollection(Collection<? extends T> c))

  • Set<T> unmodifiableSet(Set<? extends T> s)

  • List<T> unmodifiableList(List<? extends T> list)

  • Map<K,V> unmodifiableMap(Map<? extends K, ? extends V> m)

  • SortedSet<T> unmodifiableSortedSet(SortedSet<T> s)

  • SortedMap<K, V> unmodifiableSortedMap(SortedMap<K, ? extends V> m)

  • NavigableSet<T> unmodifiableNavigableSet(NavigableSet<T> s)

  • NavigableMap<K, V> unmodifiableNavigableMap(NavigableMap<K, V> m)

正如您所看到的,每个方法返回与方法的单个参数提供的类型相同的类型。原始实例和返回实例之间的区别在于,任何修改返回的实例的尝试都将抛出UnsupportedOperationException,如以下代码所示:

List<String> modifiable = new ArrayList<>();
modifiable.add("blue");
modifiable.add("red");

List<String> unmodifiable = Collections.unmodifiableList(modifiable);
unmodifiable.clear();
// throws UnsupportedOperationException

“不可修改视图”的明显缺点是,它只是现有集合的一个抽象。以下代码显示了基础集合仍可修改且会影响不可修改视图的方式:

List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");

List<String> unmodifiable = Collections.unmodifiableList(original);

original.add("green");

System.out.println(unmodifiable.size());
// OUTPUT:
// 3

之所以仍然可以通过原始引用进行修改,是由于数据结构存储在内存中的方式,如图 4-2 所示。未修改版本仅是原始列表的视图,因此直接对原始列表的任何更改都会绕过视图的预期不可修改性。

不可修改集合的内存布局

图 4-2. 不可修改集合的内存布局

不可修改视图的常见用途是在将其用作返回值之前冻结集合以防止意外修改。

不可变集合工厂方法

第二个选择 — 不可变集合工厂方法 — 自 Java 9 起可用,不基于预先存在的集合。相反,元素必须直接提供给以下集合类型上的static便利方法:

  • List.of(E e1, …​)

  • Set.of(E e1, …​)

  • Map.of(K k1, V v1, …​)

每个of方法存在于零个或多个元素,并根据使用的元素数量使用优化的内部集合类型。

不可变副本

第三个选项,不可变副本,自 Java 10+ 起可用,并通过在以下三种类型上调用static copyOf方法提供了更深层次的不可变性:

  • Set<E> copyOf(Collection<? extends E> coll)

  • List<E> copyOf(Collection<? extends E> coll)

  • Map<K, V> copyOf(Map<? extends K, ? extends V> map)

而不仅仅是一个视图,copyOf创建一个持有自己元素引用的新列表:

// SETUP ORIGINAL LIST
List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");

// CREATE COPY
List<String> copiedList = List.copyOf(original);

// ADD NEW ITEM TO ORIGINAL LIST
original.add("green");

// CHECK CONTENT
System.out.println(original);
// [blue, red, green]
System.out.println(copiedList);
// [blue, red]

复制的集合阻止通过原始列表添加或删除任何元素,但实际元素仍然是共享的,如图 4-3 所示,并且可以更改。

复制集合的内存布局

图 4-3. 复制集合的内存布局

选择不可变集合的选项取决于您的上下文和意图。如果集合无法在单个调用中创建,例如在for循环中,则返回不可修改的视图或复制是一个明智的方法。在本地使用可变集合,并在数据离开当前作用域时“冻结”它,返回不可修改的视图或复制它。不可变集合工厂方法不支持可能被修改的中间集合,而要求您事先知道所有元素。

原始类型和原始包装器

到目前为止,您主要了解了不可变对象类型,但并非 Java 中的所有内容都是对象。Java 的原始类型 — bytecharshortintlongfloatdoubleboolean — 与对象类型处理方式不同。它们是由字面值或表达式初始化的简单值。它们只表示一个单一值,实际上是不可变的。

除了原始类型本身外,Java 还提供了相应的对象包装器类型。它们在具体对象类型中封装了它们各自的原始类型,使它们可以在不允许原始类型(尚未)的情况下使用,如泛型。否则,自动装箱 — 对象包装器类型与其对应的原始类型之间的自动转换 — 可能导致不一致的行为。

不可变数学

大多数 Java 中的简单计算依赖于原始类型,如intlong用于整数,以及floatdouble用于浮点计算。然而,java.math包提供了两个不可变的替代品,用于更安全和更精确的整数和十进制计算,它们分别是不可变的:java.math.BigIntegerjava.math.BigDecimal

注意

在这个背景下,“整数”意味着一个没有分数部分的数字,并不是 Java 的intInteger类型。整数一词源自拉丁语,在数学中作为一个口语术语表示从- + 的整数范围,包括零。

就像String一样,为什么要在代码中增加不可变性的开销?因为它们允许在更大范围内以更高精度进行无副作用的计算。

然而,使用不可变数学对象的一个陷阱是简单地忘记使用计算的实际结果。尽管像addsubtract这样的方法名称暗示了修改,在面向对象的上下文中,java.math类型返回一个新对象作为结果,如下所示:

var theAnswer = new BigDecimal(42);

var result = theAnswer.add(BigDecimal.ONE);

// RESULT OF THE CALCULATION
System.out.println(result);
// OUTPUT:
// 43

//
System.out.println(theAnswer);
// OUTPUT:
// 42

不可变数学类型仍然是具有通常开销的对象,并且使用更多内存来实现其精度。尽管如此,如果计算速度不是限制因素,你应该始终优先选择 BigDecimal 类型进行浮点运算,因为它具有任意精度⁵。

BigInteger 类型是整数版的 BigDecimal,同样具有内置的不可变性。另一个优点是其扩展的范围至少从 -2^(2,147,483,647) 到 2^(2,147,483,647)(两者都不包括),相比于 int 的范围从 -2³¹ 到 2³¹。

Java 时间 API(JSR-310)

Java 8 引入了 Java 时间 API(JSR-310),其设计以不可变性为核心原则。在其发布之前,你在 java.util 包中只有三种类型可供使用,涵盖了所有与日期和时间相关的需求:DateCalendarTimeZone。进行计算是一件费力且容易出错的事情。这就是为什么在 Java 8 之前,Joda Time 库 成为日期和时间类的事实标准,随后成为 JSR-310 的概念基础。

注意

就像不可变数学一样,使用 plusminus 等方法进行任何计算都不会影响调用它们的对象。相反,你必须使用返回值。

java.util 中的前三种类型不同,现在在 java.time 包中有多种具有不同精度的日期和时间相关类型,有和没有时区,它们都是不可变的,因此具有无副作用和在并发环境中安全使用的相关优势。

枚举

Java 枚举是由常量组成的特殊类型。常量是不变的,因此不可变。除了常量值外,枚举还可以包含其他字段,这些字段不是隐式常量。

通常情况下,final 的基本类型或字符串用于这些字段,但没有人会阻止你使用可变对象类型或基本类型的 setter。这很可能会导致问题,我强烈建议不要这样做。此外,这被认为是代码异味⁠⁸。

final 关键字

自从 Java 问世以来,final 关键字根据其上下文提供了某种形式的不可变性,但它并非一个魔法关键字,无法使任何数据结构都变成不可变的。那么对于引用、方法或类而言,具体意味着什么是final

final 关键字类似于编程语言 Cconst 关键字。如果应用于类、方法、字段或引用,它具有几个重要含义:

  • final 类不能被子类化。

  • final 方法不能被覆盖。

  • final 字段必须被构造函数或声明时精确地赋值一次,并且永远不能重新赋值。

  • final变量引用的行为类似于字段,只能在声明时被分配一次。它仅影响引用本身,而不是引用的变量内容。

final关键字为字段和变量提供了一种特殊形式的不可变性。然而,它们的不可变性可能不是您期望的,因为引用本身变为不可变,但底层数据结构并未变为不可变。这意味着您不能重新分配引用,但仍然可以更改数据结构,如示例 4-1 所示。

示例 4-1. 集合和final引用
final List<String> fruits = new ArrayList<>(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

System.out.println(fruits.isEmpty());
// => true

fruits.add("Apple"); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

System.out.println(fruits.isEmpty());
// => false

fruits = List.of("Mango", "Melon"); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
// => WON'T COMPILE

1

final关键字仅影响引用fruits,而不是实际引用的ArrayList

2

ArrayList本身并没有任何不可变性的概念,因此您可以自由地向其添加新项,即使其引用是final的。

3

禁止重新分配final引用。

正如我在“有效 final”中所讨论的,对于 lambda 表达式,具有有效final引用是必需的。将代码中的每个引用都设为final是一个选择,但我不推荐这样做。即使不添加显式关键字,编译器也会自动检测引用是否像final引用一样行为。由于缺乏不可变性所造成的大多数问题来自底层数据结构本身,而不是重新分配引用,因此,为了确保一个数据结构在使用过程中不会出现意外更改,您必须从一开始就选择一个不可变的数据结构。Java 为实现这一目标新增了最新的功能——Records

Records

2020 年,Java 14 引入了一种新类型的类,其具有自己的关键字,用于在某些情况下补充甚至取代 POJO 和 JavaBeans:Records

Records 是“纯数据”聚合,比 POJOs 或 Java bean 少些仪式感。它们的功能集被减少到绝对最小限度以满足这一目的,使它们既简洁又:

public record Address(String name,
                      String street,
                      String state,
                      String zipCode,
                      Country country) {
  // NO BODY
}

Records 是浅不可变的数据载体,主要由其状态的声明组成。没有任何额外的代码,Address记录提供了自动生成的 getter 方法用于命名组件,相等比较,toString()hashCode()等等。

第五章将深入探讨 Records 在不同场景下的创建和使用方法。

如何实现不可变性

现在您已经了解了 JVM 提供的不可变部分,是时候看看如何将它们结合起来实现程序状态的不可变性了。

使类型不可变的最简单方法是一开始就不给它改变的机会。没有任何 setter,一个具有final字段的数据结构在创建后不会改变,因为它不能。然而,对于真实世界的代码来说,解决方案可能并不像那么简单。

对于数据不可变性,需要一种新的数据创建思维方式,因为许多共享数据结构很少一次性创建。如果可能的话,不要随着时间的推移改变单个数据结构,而是应该一路使用不可变构造,并最终组合成“最终”的不可变数据结构。图 4-4 描述了不同数据组件贡献于“最终”不可变记录的一般概念。即使各个组件不是不可变的,你也应始终努力将它们包装在不可变的外壳中,记录或其他形式。

记录作为数据持有者

图 4-4. 记录作为数据持有者

在更复杂的数据结构中跟踪所需组件及其验证可能具有挑战性。在第五章中,我将讨论改进数据结构创建和减少认知复杂性所需的工具和技术。

常见做法

与一般的函数式方法一样,不可变性不必是全盘接受的方式。由于其优势,仅拥有不可变的数据结构听起来很吸引人,你的关键目标应该是将不可变的数据结构和引用作为默认的方法。然而,将现有的可变数据结构转换为不可变数据结构通常是一项相当复杂的任务,需要大量的重构或概念重设计。相反,你可以通过遵循常见做法逐步引入不可变性,并将数据视为已经不可变。

默认情况下是不可变的

任何新的数据结构,如数据传输对象、值对象或任何类型的状态,都应设计为不可变。如果 JDK 或其他框架或库提供了不可变的替代方案,应优先考虑使用它们而不是可变类型。从开始就处理不可变性对于新类型会影响并塑造任何将使用它的代码。

始终期待不可变性

假设除非你创建它们或明确声明否,则所有数据结构都是不可变的,尤其是在处理集合时。如果需要更改它们,最安全的方法是基于它们创建一个新的数据结构。

修改现有类型

即使预先存在的类型不是不可变的,如果可能的话,新的添加应该是不可变的。可能会有理由使其可变,但不必要的可变性会增加 bug 的风险,而不可变性的所有优势也会立即消失。

如有必要,打破不可变性

如果不适合,请不要强制,特别是在传统代码库中。不可变性的主要目标是提供更安全、更合理的数据结构,这要求它们的环境相应地支持它们。

将外部数据结构视为不可变的

始终将不在您控制范围内的任何数据结构视为不可变的。例如,将集合作为方法参数接收应视为不可变。而不是直接操作它,为任何更改创建可变的包装视图,并返回不可修改的集合类型。这种方法保持方法的纯净性,并防止调用方未预期的任何意外更改。

遵循这些常见做法将使从一开始就创建不可变数据结构或逐步过渡到更不可变程序状态变得更容易。

要点

  • 不可变性是一个简单的概念,但需要一种新的思维方式和处理数据和变更的方法。

  • JDK 中的许多类型已经设计成具备不可变性。

  • 记录提供了一种新的简洁方式来减少创建不可变数据结构时的样板代码,但故意缺少某些灵活性,以使其尽可能透明和直接。

  • 您可以通过 JDK 的内置工具实现不可变性,第三方库可以提供缺失部分的简单解决方案。

  • 在您的代码中引入不可变性并不一定是一刀切的方法。您可以逐步应用常见的不可变性实践到您现有的代码中,以减少与状态相关的错误并简化重构工作。

¹ JavaBeans 在官方的 JavaBeans API 规范 1.01 中进行了详细说明,该规范超过一百页。然而,在本书的范围内,你不需要了解其中所有内容,只需了解与其他数据结构的差异即可。

² 自 Java 1.2 以来,Java 集合框架提供了多种常用的可重用数据结构,如 List<E>Set<E> 等。Oracle Java 文档 概述了框架中包含的可用类型。

³ Phil Karton,一位成就卓越的软件工程师,在 Xerox PARC、Digital、Silicon Graphics 和 Netscape 担任首席开发人员多年,创造了“在计算机科学中只有两件难事:缓存失效和起名字”这句话。多年来,它在软件社区中成为一种主流笑话,并经常通过添加“一次性错误”来修正,但不改变两个难事的数量。

⁴ JDK 增强提案 (JEP) 280,“Indify String Concatenation”,更详细地描述了在更多场景中使用 invokedynamic 的原因。

⁵ 任意精度算术,也称为大数算术、多精度算术,有时是无限精度算术,可以对数字进行计算,其精度仅受可用内存限制,而不是固定数字。

BigInteger 的实际范围取决于所使用的 JDK 的实际实现,正如在官方文档的一个实现说明中所述。

⁷ 技术上还有第四种类型,java.sql.Date,它是一个薄包装器,用于改进 JDBC 的支持。

代码异味是一种已知的代码特征,可能表明存在更深层次的问题。这并不是一个明显的 bug 或错误,但长期来看可能会引起麻烦。这些 异味 是主观的,根据编程语言、开发者和编程范式而异。SonarSource,这家臭名昭著的公司开发了开源软件,用于持续代码质量和安全性,将可变枚举列为规则 RSPEC-3066

第五章:使用 Records

Java 14 引入了一种新的数据结构类型作为预览¹ 功能,两个版本后最终定型:Records。它们不仅仅是另一种典型的 Java 类型或技术,您可以使用。相反,Records 是一个全新的语言特性,为您提供了一个简单但功能丰富的数据聚合器,具有最少的样板代码。

数据聚合类型

从一般角度来看,数据聚合 是从多个来源收集数据并以更适合预期目的和更可取的用法组装成的格式的过程。也许最知名的数据聚合类型是 元组

元组

从数学角度来看,一个元组是一个“有限元素的有序序列”。在编程语言方面,一个元组是一个聚合多个值或对象的数据结构。

有两种元组。结构化 元组只依赖于包含元素的顺序,因此只能通过它们的索引访问,如下面的 Python 代码所示:

apple = ("apple", "green")
banana = ("banana", "yellow")
cherry = ("cherry", "red")

fruits = [apple, banana, cherry]

for fruit in fruits:
  print "The", fruit[0], "is", fruit[1]

名义 元组不使用索引来访问它们的数据,而是使用组件名称,如下面的 Swift 代码所示:

typealias Fruit = (name: String, color: String)

let fruits: [Fruit] = [
  (name: "apple", color: "green"),
  (name: "banana", color: "yellow"),
  (name: "cherry", color: "red")]

for fruit in fruits {
  println("The \(fruit.name) is \(fruit.color)")
}

为了展示 Records 提供了什么,你首先将看看如何从经典的 POJO 转变为一个不可变的 POJO,然后我将展示如何用 Record 来复制相同的功能。

一个简单的 POJO

首先,让我们看看 Java 中数据聚合的“Record 之前”的状态,以更好地把握 Records 提供了什么。举个例子,我们创建一个简单的“用户”类型作为“经典” POJO,演变为一个“不可变” POJO,最后变为一个 Record。它将是一个简单的类型,有一个用户名,一个活动状态,一个上次登录时间戳,以及在典型 Java 代码中常见的“典型”样板,如 示例 5-1 中所示。

示例 5-1。简单的用户 POJO
public final class User {

  private String        username;
  private boolean       active;
  private LocalDateTime lastLogin;

  public User() { } ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  public User(String username,
              boolean active,
              LocalDateTime lastLogin) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    this.username = username;
    this.active = active;
    this.lastLogin = lastLogin;
  }

  public String getUsername() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return this.username;
  }

  public void setUsername(String username) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    this.username = username;
  }

  public boolean isActive() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return this.active;
  }

  public void setActive(boolean active) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    this.active = active;
  }

  public LocalDateTime getLastLogin() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return this.lastLogin;
  }

  public void setLastLogin(LocalDateTime lastLogin) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    this.lastLogin = lastLogin;
  }

  @Override
  public int hashCode() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    return Objects.hash(this.username,
                        this.active,
                        this.lastLogin);
  }

  @Override
  public boolean equals(Object obj) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
    if (this == obj) {
      return true;
    }

    if (obj == null || getClass() != obj.getClass()) {
      return false;
    }

    User other = (User) obj;
    return Objects.equals(this.username, other.username)
           && this.active == other.active
           && Objects.equals(this.lastLogin, other.lastLogin);
  }

  @Override
  public String toString() { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
    return new StringBuilder().append("User [username=")
                              .append(this.username)
                              .append(", active=")
                              .append(this.active)
                              .append(", lastLogin=")
                              .append(this.lastLogin)
                              .append("]")
                              .toString();
  }
}

1

构造函数并非绝对必要,但出于便利起见会添加。如果存在带参数的任何构造函数,则还应添加一个显式的“空”构造函数。

2

POJOs 通常具有 getter 而不是公共字段。

3

第一种 User 类型的变体仍然是可变的,因为它具有 setter 方法。

4

hashCodeequals 的第一种变体需要根据类型的实际结构进行专门实现。对类型的任何更改都需要调整这两种方法。

5

toString 方法是另一个不是明确必需的便利添加。就像之前的方法一样,每当类型更改时都必须更新它。

包括空行和大括号,仅仅用于保存三个数据字段就有大约 75 行。难怪 Java 最常见的抱怨之一是其冗长性,以及完成标准任务时“过于繁琐”的感觉!

现在,让我们将其转换为一个不可变的 POJO。

从 POJO 到不可变性

使User POJO 不可变会稍微减少所需的样板代码,因为您不再需要任何 setter 方法,正如示例 5-2 中所示。

示例 5-2. 简单的不可变用户类型
public final class User {

  private final String username; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  private final boolean active;
  private final LocalDateTime lastLogin;

  public User(String username,
              boolean active,
              LocalDateTime lastLogin) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    this.username = username;
    this.active = active;
    this.lastLogin = lastLogin;
  }

  public String getUsername() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return this.username;
  }

  public boolean isActive() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return this.active;
  }

  public LocalDateTime getLastLogin() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return this.lastLogin;
  }

  @Override
  public int hashCode() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    // UNCHANGED
  }

  @Override
  public boolean equals(Object obj) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    // UNCHANGED
  }

  @Override
  public String toString() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    // UNCHANGED
  }
}

1

没有“setters”,字段可以声明为final

2

仅可能存在一个完全的“传递”构造函数,因为字段必须在对象创建时设置。

3

“getters”与可变变体保持不变。

4

与先前的实现相比,支持方法也未更改。

通过使类型本身不可变,只需删除 setter 和空构造函数的代码;其他所有内容仍然存在。对于仅包含三个public final字段和一个构造函数的类,这仍然是相当多的代码。当然,根据您的需求,这可能是“恰到好处”的选择。然而,额外的功能,如相等比较、正确的hashCode以便在SetHashMap中使用,或合理的toString输出,都是值得拥有的功能。

从 POJO 到 Record

最后,让我们看一下使用 Record 的更通用、不那么繁琐但功能丰富的解决方案:

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {
  // NO BODY
}

那就是这样。

User Record 具有与不可变 POJO 相同的功能。如何用如此少的代码实现如此多的功能将在接下来的章节中详细解释。

记录来解救

Records 是定义纯粹数据聚合器类型的一种方式,它们通过名称访问其数据组件,类似于命名元组。与命名元组一样,Records 聚合有序序列的值,并通过名称而不是索引提供访问。它们的数据是浅不可变的,并且透明地可访问。通过生成访问器和数据驱动方法如equalshashCode显著减少了其他数据类的典型样板。尽管JEP 395 的最终版本明确指出“反对样板代码”不是目标,但许多开发人员仍将其视为一个令人满意的巧合。

作为“plain”数据聚合器类型,与其他选项相比确实缺少一些功能。本章将覆盖每个缺失的功能以及如何缓解它们,将 Records 转变为更灵活的解决方案,以满足您的数据聚合需求。

如前一节所示,Records 使用一个新关键字 — record — 来将它们与其他类和枚举分隔开来。数据组件直接在 Record 名称后的一对括号中声明,类似于构造函数或方法参数:

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {
  // NO BODY
}

记录的一般语法分为两部分:一个标头定义了与其他类型相同的属性,以及其组件和一个可选的主体,以支持额外的构造函数和方法。

// HEADER
[visibility] record [Name]<optional generic types> {
  // BODY
}

标头类似于classinterface的标头,由多个部分组成:

可见性

classenuminterface定义类似,Record 支持 Java 的可见性关键字(publicprivateprotected)。

记录关键字

关键字record将标头与其他类型声明classenuminterface区分开来。

名称

命名规则与《Java 语言规范》中定义的任何其他标识符相同,如Java 语言规范⁠²所述。

泛型类型

泛型类型与 Java 中的其他类型声明一样受支持。

数据组件

名称后跟着一对括号,其中包含 Record 的组件。每个组件在幕后转换为一个private final字段和一个public访问器方法。组件列表还表示 Record 的构造函数。

主体

典型的 Java 主体,与任何其他classinterface类似。

编译器将一行代码有效地转换为与上一节中的示例 5-2 类似的类。它明确扩展了java.lang.Record而不是隐式地扩展java.lang.Object,就像枚举与java.lang.Enum一样。

背后的幕后

任何 Record 后面生成的类都会为您提供相当多的功能,而无需编写任何额外的代码。现在是时候深入了解实际发生的事情了。

JDK 包括命令javap,用于反汇编.class文件,并允许您查看字节码的 Java 对应 Java 代码。通过这种方式,很容易比较“数据聚合类型”中的User类型的 POJO 和 Record 版本之间的实际差异。两个变体的组合和清理后的输出显示在示例 5-3 中。

示例 5-3. Disassembled User.class POJO versus Record
// IMMUTABLE POJO

public final class User {
  public User(java.lang.String, boolean, java.time.LocalDateTime);
  public java.lang.String getUsername();
  public boolean isActive();
  public java.time.LocalDateTime getLastLogin();

  public int hashCode();
  public boolean equals(java.lang.Object);
  public java.lang.String toString();
}

// RECORD

public final class User extends java.lang.Record {
  public User(java.lang.String, boolean, java.time.LocalDateTime);
  public java.lang.String username();
  public boolean active();
  public java.time.LocalDateTime lastLogin();

  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public final java.lang.String toString();
}

如您所见,生成的类在功能上是相同的,只是访问器方法的命名不同。那么所有这些方法都来自哪里呢?嗯,这就是 Records 的“魔法”,让您获得了一个完整的数据聚合类型,而无需写更多的绝对必要的代码。

记录功能

Records 是透明的数据聚合器,具有特定的保证属性和明确定义的行为,通过自动³提供功能,无需重复编写以下琐碎的样板实现:

除了记录声明之外,这需要大量的功能而不需要任何额外的代码。任何缺失的部分都可以通过根据需要增加或重写这些功能来完成。

让我们来看看 Record 的自动特性以及其他典型的 Java 特性,比如泛型、注解和反射是如何适配的。

组件访问器

所有记录组件都存储在private字段中。在记录内部,其字段可以直接访问。从“外部”访问它们需要通过生成的public访问器方法。访问器方法的名称与其组件名称对应,不带典型的“getter”前缀get,就像以下代码示例中所示:

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {
  // NO BODY
}

var user = new User("ben", true, LocalDateTime.now());

var username = user.username();

访问器方法按原样返回对应字段的值。虽然可以重写它们,就像下面的代码所示,但我不建议这样做。

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {

  @Override
  public String username() {
    if (this.username == null) {
      return "n/a";
    }

    return this.username;
  }
}

var user = new User(null, true, LocalDateTime.now());

var username = user.username();
// => n/a

记录应该是不可变的数据持有者,因此在访问其数据时做出决策可能被视为代码异味。记录的创建定义了其数据,这就是任何验证或其他逻辑应该影响数据的地方,正如您将在下一节中了解的那样。

规范、紧凑和自定义构造函数

与记录组件定义相同的构造函数会自动可用,称为规范构造函数。记录的组件被直接分配给相应的字段“原样”。与组件访问器一样,规范构造函数可重写以验证输入,如null检查,甚至在必要时操作数据:

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {

  public User(String username,
              boolean active,
              LocalDateTime lastLogin) {

    Objects.requireNonNull(username);
    Objects.requireNonNull(lastLogin);

    this.username = username;
    this.active = active;
    this.lastLogin = lastLogin;
  }
}

为了进行两个实际的null检查,包括重新声明构造函数签名并将组件分配给不可见字段,这是相当多的额外代码行。

幸运的是,还提供了一种专门的紧凑形式,如下面的代码示例所示,并且如果不需要,它不会强制您重复任何样板代码。

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {

  public User { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

    Objects.requireNonNull(username);
    Objects.requireNonNull(lastLogin);

    username = username.toLowerCase(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }
}

1

构造函数省略了所有参数,包括括号。

2

在紧凑的规范构造函数中不允许字段分配,但可以在分配之前自定义或标准化数据。

3

组件将自动分配给其各自的字段。

虽然语法可能看起来不寻常,因为它省略了所有参数,包括括号。不过,这样做可以清楚地区分它与无参数构造函数。

紧凑的构造函数是放置任何验证的理想位置,正如我将在“记录验证和数据清理”中展示的那样。

与类一样,你可以声明额外的构造函数,但任何自定义构造函数必须以显式调用规范构造函数作为其第一条语句。相比于类,这是一个相当严格的要求。然而,这一要求对我即将讨论的一个重要特性至关重要,详情请见“组件默认值和便捷构造函数”。

对象标识和描述

记录提供了基于数据相等性的对象标识方法int hashCode()boolean equals(Object)的“标准”实现。如果没有显式实现这两个对象标识方法,当记录的组件发生变化时,您无需担心更新代码。如果两个 Record 类型的实例的组件数据相等,则它们被视为相等。

对象描述方法String toString()也是从组件自动生成的,为您提供了一个合理的默认输出,例如:

User[username=ben, active=true, lastLogin=2023-01-11T13:32:16.727249646]

对象标识和描述方法也是可重写的,就像组件访问器和构造函数一样。

通用类型

记录也支持通用类型,遵循“通常”的规则:

public record Container<T>(T content,
                           String identifier) {
  // NO BODY
}

Container<String> stringContainer = new Container<>("hello, String!",
                                                    "a String container");

String content = stringContainer.content();

就个人而言,我建议避免滥用通用 Records。使用更具体的 Records 更接近于它们所代表的领域模型,可以提供更多的表现力,并减少意外误用的可能性。

注解

如果用于记录的组件上,注解的行为与您预期的有些不同:

public record User(@NonNull String username,
                   boolean active,
                   LocalDateTime lastLogin) {
  // NO BODY
}

乍一看,username看起来像是一个参数,因此一个明智的结论是只有具有ElementType.PARAMETER目标的注解才可能存在⁴。但是对于 Records 及其自动生成的字段和组件访问器,必须考虑一些特殊情况。为了支持对这些特性进行注解,如果应用于组件,则任何具有FIELDPARAMETERMETHOD目标的注解将传播到相应的位置。

除了现有的目标外,还引入了新的目标ElementType.RECORD_COMPONENT,用于在记录中实现更精细的注解控制。

反射

为了补充 Java 的反射能力,Java 16 添加了getRecordComponents方法到java.lang.Class中。对于基于 Record 的类型,该调用将返回一个数组,其中包含java.lang.reflect.RecordComponent对象,对于其他类型的Class则返回null。这些组件按照在记录头中声明的顺序返回,允许您通过在 Record 类上调用getDeclaredConstructor()来查找规范构造函数。

您可以在书籍的代码存储库中找到一些基于反射的示例。

缺失的功能

Records 正是它们应该是的:简单的、透明的、浅不可变的数据聚合器。它们提供了大量功能,而无需编写除了它们的定义之外的任何代码。与其他可用的数据聚合器相比,它们缺少一些你可能习惯的功能,比如:

  • 附加状态

  • 继承

  • (简单的) 默认值

  • 逐步创建

本节将向你展示哪些功能是“失踪”的,以及如何在可能的情况下加以缓解。

附加状态

允许任何附加的不透明状态是 Records 显而易见的遗漏。它们应该是数据聚合器,代表着一个透明的状态。这就是为什么向其主体添加任何附加字段都会导致编译器错误。

提示

如果你需要的字段超过了 Record 组件本身所能提供的可能性,那么 Records 可能不是你要找的数据结构。

对于至少一些场景,你可以添加基于现有组件的派生状态,通过向 Records 添加方法:

public record User(String username,
                   boolean active,
                   LocalDateTime lastLogin) {

  public boolean hasLoggedInAtLeastOnce() {
    return this.lastLogin != null;
  }
}

可以添加方法,因为它们不会引入像字段一样的附加状态。它们可以访问 private 字段,即使组件访问器被重写,也能保证逐字数据访问。选择哪种 — 字段还是访问器 — 取决于你如何设计你的 Record 以及你个人的偏好。

继承

Records 是 final 类型,它们在幕后已经扩展了 java.lang.Record,就像之前在 示例 5-3 中看到的那样。因为 Java 不允许继承多个类型,所以 Records 不能使用继承。但这并不意味着它们不能实现任何接口。通过接口,你可以定义 Record 模板,并使用 default 方法共享公共功能。

示例 5-4 展示了如何为具有公共概念的多个形状创建 Records,即原点和表面积。

示例 5-4. 使用接口作为 Records 模板
public interface Origin {

  int x(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  int y(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  default String origin() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return String.format("(%d/%d)", x(), y());
  }
}

public interface Area {

  float area(); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

// DIFFERENT RECORDS IMPLEMENTING INTERFACES

public record Point(int x, int y) implements Origin {
  // NO BODY
}

public record Rectangle(int x, int y, int width, int height)
  implements Origin, Area {

  public float area() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return (float) (width() * height());
  }
}

public record Circle(int x, int y, int radius)
  implements Origin, Area {

  public float area() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return (float) Math.PI * radius() * radius();
  }
}

1

该接口将实现记录的组件定义为具有正确名称的简单方法

2

共享功能是通过 default 方法添加的。

3

接口中的方法签名不得与任何实现记录类型相冲突。

与接口和default方法共享行为是一种简单的方法,只要所有实现者共享接口合同。接口可以为缺失继承的几个部分提供补充,可能会引人创建复杂的记录层次结构和互相依赖。但是以这种方式构造记录类型将会产生它们之间的内聚性,并不符合记录作为简单数据聚合器定义的原始精神。此示例是为了更好地说明多个接口可能性而过度工程化的。在现实世界中,您很可能也会将Origin作为记录,并使用组合和额外的构造函数来实现相同的功能。

组件默认值和便捷构造函数

不同于许多其他语言,Java 不支持任何构造函数或方法参数的默认值。记录只提供其带有所有组件的标准构造函数,这可能会变得笨重,特别是在组合数据结构的情况下:

public record Origin(int x, int y) {
  // NO BODY
}

public record Rectangle(Origin origin, int width, int height) {
  // NO BODY
}

var rectangle = new Rectangle(new Origin(23, 42), 300, 400);

附加构造函数提供了一个简便的方法来获取合理的默认值:

public record Origin(int x, int y) {

  public Origin() {
    this(0, 0);
  }
}

public record Rectangle(Origin origin, int width, int height) {

  public Rectangle(int x, int y, int width, int height) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    this(new Origin(x, y), width, height);

  }

  public Rectangle(int width, int height) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    this(new Origin(), width, height);
  }

  // ...
}

var rectangle = new Rectangle(23, 42, 300, 400);
// => Rectangle[origin=Origin[x=23, y=42], width=300, height=400]

1

第一个附加构造函数模仿Origin的组件,以提供创建Rectangle的更便捷方式。

2

第二个便捷构造函数通过删除提供Origin的必要性来实现。

由于 Java 的命名语义,不是所有默认值组合都可能存在,比如Rectangle(int x, float width, float height)Rectangle(int y, float width, float height)具有相同的签名。在这种情况下,使用static工厂方法允许您创建所需的任何组合:

public record Rectangle(Origin origin, int width, int height) {

  public static Rectangle atX(int x, int width, int height) {
    return new Rectangle(x, 0, width, height);
  }

  public static Rectangle atY(int y, int width, int height) {
    return new Rectangle(0, y, width, height);
  }

  // ...
}

var xOnlyRectangle = Rectangle.atX(23, 300, 400);
// => Rectangle[origin=Origin[x=23, y=0], width=300, height=400]

使用static工厂方法是自定义构造函数的更具表现力的替代方案,并且是重叠签名的唯一选择。

对于无参数构造函数,使用常量更有意义:

public record Origin(int x, int y) {

    public static Origin ZERO = new Origin(0, 0);
}

首先,您的代码通过常量的有意义名称更具表现力。其次,只创建一个单一实例,因为底层数据结构是不可变的,所以在任何地方都是常量。

逐步创建

不可变数据结构的一个优点是缺乏“半初始化”的对象。然而,并非每个数据结构都能一次初始化。在这种情况下,您可以使用构建器模式来获取一个可变的中间变量,用于创建最终不可变的结果。尽管构建器模式起源于解决面向对象编程中重复对象创建问题的解决方案,但在更功能化的 Java 环境中,它对于创建不可变数据结构也是非常有益的。

通过将数据结构的构建与其表示分离,数据结构本身可以尽可能简单,使模式与 Records 完美匹配。任何必需的逻辑或验证都封装到一个(多步骤的)构建器中。

之前使用的 User 记录可以通过一个简单的构建器进行补充,如 Example 5-5 所示。

示例 5-5. 用户构建器
public final class UserBuilder {

  private final String username;

  private boolean       active;
  private LocalDateTime lastLogin;

  public UserBuilder(String username) {
    this.username = username;
    this.active = true; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  }

  public UserBuilder active(boolean isActive) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    if (this.active == false) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      throw new IllegalArgumentException("...");
    }

    this.active = isActive;
    return this; ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  }

  public UserBuilder lastLogin(LocalDateTime lastLogin) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
    this.lastLogin = lastLogin;
    return this;
  }

  public User build() { ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
    return new User(this.username, this.active, this.lastLogin);
  }
}

var builder = new UserBuilder("ben").active(false) ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/7.png)
                                    .lastLogin(LocalDateTime.now());

// ...

var user = builder.build(); ![8](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/8.png)

1

显式默认值是可能的,减少了创建所需的代码。

2

在构建期间可以更改的字段需要类似 setter 的方法。

3

验证逻辑绑定到特定的 setter 类型方法,而不是在任何构造函数中累积。

4

返回 this 创建了一个流畅的构建器 API。

5

Optional 字段可以使用它们的显式类型,并且仅在 build() 期间更改为 Optional

6

如果构建完成,调用 build() 将创建实际的不可变 User 记录。通常,构建器应根据需要验证其状态。

7

构建过程流畅,您可以像处理任何其他变量一样传递构建器。

8

最后,通过调用 build() 创建不可变对象。

将构建器类直接放置在相应类型中作为 static 嵌套类,可以增强类型与其构建器之间的黏合度,如在 Example 5-6 中所示。

示例 5-6. 嵌套构建器
public record User(long id,
                   String username,
                   boolean active,
                   Optional<LocalDateTime> lastLogin) {

  public static final class Builder {
    // ...
  }
}

var builder = new User.Builder("ben");

使用记录来实现简单性和不可变性似乎是没有意义的,但仍然引入了构建器的复杂性。为什么不使用一个完整的 bean 呢?因为即使有构建器的复杂性,创建和使用数据的关注点是分开的。即使没有构建器,记录仍然可用,但构建器提供了创建记录实例的额外灵活方式。

用例和常见实践

Records 节省了大量样板代码,只需稍作增加,即可将它们升级为更加灵活和多功能的工具。

记录验证和数据清理

如在 “规范、紧凑和自定义构造函数” 中所示,Records 支持与 普通 构造函数行为不同的 紧凑构造函数。您可以访问规范构造函数的所有组件,但它没有任何参数。它为初始化过程中需要放置的任何 附加 代码提供了一个位置,而无需自行分配组件。这使得它成为放置任何验证和数据清理逻辑的理想位置:

public record NeedsValidation(int x, int y) {

  public NeedsValidation {
    if (x < y) {
      throw new IllegalArgumentException("x must be equal or greater than y");
    }
  }
}

抛出异常是一种选择。另一种选项是清洗数据,并使用合理的替代方案调整组件值,以形成有效的记录:

public record Time(int minutes, int seconds) {

  public Time {
    if (seconds >= 60) {
      int additionalMinutes = seconds / 60;
      minutes += additionalMinutes;
      seconds -= additionalMinutes * 60;
    }
  }
}

var time = new Time(12, 67);
// => Time[minutes=13, seconds=7]

将某些逻辑(例如越界值的归一化)直接移入记录中可以提供更一致的数据表示,不受初始数据的影响。另一种方法是要求事先进行此类数据清洗,并通过抛出适当的异常来限制记录仅进行严格验证。

增加不可变性

在“不可变集合”中,您了解到集合中浅不可变性的问题。浅不可变数据结构具有不可变引用,但其引用的数据仍可变。必须考虑到与非固有不可变记录组件相同的意外更改问题。通过尝试通过复制或重新包装来增加不可变性的级别是减少记录组件中任何更改的简单方法。

您可以使用规范构造函数创建组件的不可变副本:

public record IncreaseImmutability(List<String> values) {

  public IncreaseImmutability {
    values = Collections.unmodifiableList(values);
  }
}

调用Collections.unmodifiableList创建了原始List的内存节省但不可修改的视图。这可以防止对记录组件的更改,但无法通过原始引用控制对底层List的更改。通过使用 Java 10+的方法List.copy(Collection<? extends E> coll)可以实现更高级别的不可变性,创建与原始引用独立的深层副本。

创建修改副本

即使记录的声明尽可能简化,创建稍作修改的副本仍然需要您自己动手,而无需依赖 JDK 的任何帮助。

如果您不想完全手动执行,创建修改副本有多种方法:

  • Wither 方法

  • 构建器模式

  • 工具辅助

  • 反射

Wither 方法

Wither 方法 遵循命名方案 withcomponentName。它们类似于设置器,但返回一个新实例而不是修改当前实例:

public record Point(int x, int y) {

  public Point withX(int newX) {
    return new Point(newX, y());
  }

  public Point withY(int newY) {
    return new Point(x(), newY);
  }
}

var point = new Point(23, 42);
// => Point[x=23, y=42]

var newPoint = point.withX(5);
// => Point[x=5, y=42]

嵌套记录是将修改逻辑与实际记录分离的便捷方式:

public record Point(int x, int y) {

  public With with() {
    return new With(this);
  }

  public record With(Point source) {

    public Point x(int x) {
      return new Point(x, source.y());
    }

    public Point y(int y) {
      return new Point(source.x(), y);
    }
  }
}

var sourcePoint = new Point(23, 42);

var modifiedPoint = sourcePoint.with().x(5);

原始记录只有一个额外的方法,并且所有的变异器/复制方法都封装在With类型中。

像“组件默认值和便捷构造函数”中的默认值一样,wither 方法的最明显缺点是需要为每个组件编写一个方法。将代码限制在最常见的场景中是明智的,只在需要时添加新方法。

构建器模式

构建器模式,如在“逐步创建”中介绍的,还允许更容易地管理更改。这样的构造函数允许您使用现有记录初始化构建器,进行适当的更改,并创建一个新的记录,如下所示:

public record Point(int x, int y) {

  public static final class Builder {

    private int x;
    private int y;

    public Builder(Point point) {
      this.x = point.x();
      this.y = point.y();
    }

    public Builder x(int x) {
      this.x = x;
      return this;
    }

    public Builder y(int y) {
      this.y = y;
      return this;
    }

    public Point build() {
      return new Point(this.x, this.y);
    }
  }
}

var original = new Point(23, 42);

var updated = new Point.Builder(original)
                       .x(5)
                       .build();

这种方法与“wither”方法存在相同的问题:组件之间和创建记录副本所需的代码之间存在强烈的凝聚性,使得重构变得更加困难。为了缓解这一问题,你可以使用辅助工具方法。

工具辅助生成器

而不是每次记录更改时更新记录生成器类,你可以使用注解处理器来为你完成这项工作。像RecordBuilder这样的工具会为任何记录生成灵活的生成器,你只需添加一个单一的注解:

@RecordBuilder
public record Point(int x, int y) {
  // NO BODY
}

// GENERAL BUILDER
var original = PointBuilder.builder()
                           .x(5)
                           .y(23)
                           .build();

// COPY BUILDER
var modified = PointBuilder.builder(original)
                           .x(12)
                           .build();

对记录组件的任何更改将自动在生成的生成器中生效。还可以使用基于“wither”的方法,但需要你的记录实现一个额外生成的接口:

@RecordBuilder
public record Point(int x, int y) implements PointBuilder.With {
  // NO BODY
}

var original = new Point(5, 23);

// SINGLE CHANGE
var modified1 = original.withX(12);

// MULTI-CHANGE VIA BUILDER
var modified2 = original.with()
                        .x(12)
                        .y(21)
                        .build()

// MULTI-CHANGE VIA CONSUMER (doesn't require calling build())
var modified3 = original.with(builder -> builder.x(12)
                                                .y(21));

尽管使用外部工具来补充你的记录或任何代码可以节省大量输入,但也存在一些缺点。依赖于一个必不可少的项目部分的工具,会在它们之间形成难以打破的凝聚性。任何错误、安全问题或破坏性变更都可能以无法预料的方式影响你的代码,通常没有可能自行修复。注解处理器集成到你的构建工具中,使它们现在也相互关联。因此,请确保在将它们添加到你的项目之前对这些依赖进行彻底评估⁷。

记录作为本地名义元组

许多函数式编程语言中普遍存在一种构造类型在 Java 中缺失:动态元组。编程语言通常使用这些作为动态数据聚合器,而无需显式定义类型。Java 记录是简单的数据聚合器,并且在某种意义上可以被视为名义元组。与大多数元组实现最显著的不同之处在于,由于 Java 类型系统,它们所包含的数据由一个整体类型组合在一起。记录不像其他语言的元组实现那样灵活或可互换。但由于 Java 15 中对记录的一个补充,你可以将其用作本地即时数据聚合器:本地记录

在上下文中本地化记录简化和规范化数据处理,并打包功能。想象一下,你有一个 90 年代的音乐专辑标题列表,按年份分组为Map<Integer, List<String>>,如下所示:

Map<Integer, List<String>> albumns =
  Map.of(1990, List.of("Bossanova", " Listen Without Prejudice"),
         1991, List.of("Nevermind", "Ten", "Blue lines"),
         1992, List.of("The Chronic", "Rage Against the Machine"),
         1993, List.of("Enter the Wu-Tang (36 Chambers)"),
         ...
         1999, List.of("The Slim Shady LP", "Californication", "Play"));

处理这样的嵌套和不具体的数据结构相当麻烦。迭代 Maps 需要使用entrySet()方法,在这种情况下返回Map.Entry<Integer, List<String>>实例。处理这些条目可能会让你访问所有数据,但表达方式不够直观。

以下代码使用流管道为音乐专辑标题创建了一个过滤方法。即使没有阅读第六章,该章将详细介绍流,大部分代码也应该很容易理解,但我会指导你完成。

public List<String> filterAlbums(Map<Integer, List<String>> albums,
                                 int minimumYear) {

  return albums.entrySet()
               .stream()
               .filter(entry -> entry.getKey() >= minimumYear) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
               .sorted(Comparator.comparing(Map.Entry::getKey)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
               .map(Map.Entry::getValue) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
               .flatMap(List::stream) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
               .toList(); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
}

1

过滤至少是最小年份的专辑条目。

2

按其相应年份对标题列表进行排序。

3

将条目转换为其实际值。

4

flatMap调用帮助“展平”包含年份'S 标题的List<String>元素到管道中的单一元素。

5

将元素收集到List<String>

每个流操作都必须处理getKey()getValue(),而不是在其上下文中表示实际数据的具有表达性的名称。这就是为什么引入本地 Record 作为中间类型允许您在复杂的数据处理任务(如流管道)中恢复表达能力的原因,但任何数据处理都可以从更多的表达性中受益。您甚至可以将部分逻辑移到 Record 中,以便为每个操作使用方法引用或单个调用。

考虑你拥有数据的形式,以及它应该如何表示,然后相应地设计你的 Record。接下来,你应该将复杂的数据处理任务重构为 Record 方法。可能的候选者包括:

  • Map.Entry实例创建 Record。

  • 按年份筛选

  • 按年份排序。

下面的 Record 代码展示了这些任务的实现:

public record AlbumsPerYear(int year, List<String> titles) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  public AlbumsPerYear(Map.Entry<Integer, List<String>> entry) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    this(entry.getKey(), entry.getValue());
  }

  public static Predicate<AlbumsPerYear> minimumYear(int year) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return albumsPerYear -> albumsPerYear.year() >= year;
  }

  public static Comparator<AlbumsPerYear> sortByYear() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    return Comparator.comparing(AlbumsPerYear::year);
  }
}

1

Record 组件反映了你希望如何使用更具表达性的名称访问数据。

2

另外的构造方法允许使用方法引用来创建新实例。

3

如果任务依赖于超出范围的变量,则应定义为static辅助程序。

4

排序应通过创建返回 Comparatorstatic辅助方法来完成,或者如果只需要支持单一排序,则你的 Record 可以实现Comparable接口。

Record AlbumsPerYear专门为filterAlbums方法的流管道设计,并且只应在其作用域内可用。本地上下文限制了 Record,阻止它通过周围类中的状态泄漏。所有嵌套的 Record 都是隐式static的,以防止状态通过周围类泄漏到其中。示例 5-7 展示了 Record 如何存在于方法中,以及 Record 如何改进整体代码。

示例 5-7. 本地化 Record 的流管道
public List<String> filterAlbums(Map<Integer, List<String>> albums,
                                 int minimumYear) {

  record AlbumsPerYear(int year, List<String> titles) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    // ...
  }

  return albums.entrySet()
               .stream()
               .map(AlbumsPerYear::new) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
               .filter(AlbumsPerYear.minimumYear(minimumYear)) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
               .sorted(AlbumsPerYear.sortByYear()) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
               .map(AlbumsPerYear::titles) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
               .flatMap(List::stream) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
               .toList();
}

1

本地化 Record 直接在方法中声明,限制了其作用域。出于可读性考虑,我没有重复实际的实现。

2

流管道的第一个操作是将Map.Entry实例转换为本地 Record 类型。

3

每个后续操作使用本地记录的表达式方法,可以直接使用,也可以作为方法引用使用,而不是显式的 lambda 表达式。

4

有些操作很难重构,比如 flatMap,因为流的整体处理逻辑决定了它们的使用。

如你所见,使用本地记录是改善声明性流水线的人机工程学和表现力的极好方法,而不会暴露类型超出其明显范围。

更好的可选数据处理

处理可选数据和可能的 null 值是每个 Java 开发人员的苦恼。一种选择是使用 Bean 验证 API,如 “记录验证和数据清洗” 中所示,并用 @NonNull@Nullable 注解每个组件,尽管这种方法需要依赖。如果你想留在 JDK 内部,Java 8 通过引入 Optional<T> 类型来简化了 null 处理的痛苦,你将在 第九章 中详细了解它。现在,你只需要知道它是一种可能包含 null 值的容器类型,因此即使值是 null,你仍然可以与容器交互,而不会引发 NullPointerException

Optional 类型清楚地表示一个组件是可选的,但它需要比仅仅更改类型更多的代码才能成为一个有效的工具。让我们在本章早些时候的 User 类型示例中添加一个可选组:

public record User(String username,
                   boolean active,
                   Optional<String> group,
                   LocalDateTime lastLogin) {
  // NO BODY
}

即使使用 Optional<String> 存储用户的组,你仍然必须处理可能为整个容器接收到 null 的可能性。更好的选择是接受值本身为 null,但仍然具有 Optional<String> 组件。随着记录反映它们的定义及其访问器 1:1 的定义,为了更安全和更方便地使用具有可选组件的记录,还需要进行两个额外的步骤。

确保非空容器

要使记录更安全和更方便使用可选组件的第一步是确保 Optional<String> 不会为 null,从而破坏它的设计理念。最简单的方法是使用紧凑的构造函数验证它:

public record User(String username,
                   boolean active,
                   Optional<String> group,
                   LocalDateTime lastLogin) {

  public User {
    Objects.requireNonNull(group, "Optional<String> group must not be null");
  }
}

最明显的问题是通过将可能的 NullPointerException 从组件访问器移到创建记录本身的时刻来避免,从而使其更安全地使用。

添加便利构造函数

使记录更安全和更方便使用的第二步是提供带有非可选参数的额外构造函数,并自己创建容器类型:

public record User(String username,
                   boolean active,
                   Optional<String> group,
                   LocalDateTime lastLogin) {

  public User(String username,
              boolean active,
              String group,
              LocalDateTime lastLogin) {
    this(username,
         active,
         Optional.ofNullable(group),
         lastLogin);
  }

  // ...
}

代码完成将显示两个构造函数,指示 group 组件的可选性。

在记录创建时进行验证,并提供便利的构造函数,既给记录的创建者带来了灵活性,也让任何消费者能够更安全地使用它。

序列化不断发展的记录

如果未显式提及,Records 与类一样,如果实现了空标记接口java.io.Serializable,则会自动可序列化。与类相比,Records 的序列化过程遵循更灵活和更安全的策略,无需任何额外的代码。

注意

完整的序列化过程包括序列化(将对象转换为字节流)和反序列化(从字节流中读取对象)。如果没有明确提到,序列化描述的是整个过程,而不仅仅是第一个方面。

对于普通的非 Record 对象,序列化依赖于昂贵的⁸ 反射来访问它们的私有状态。这个过程可以通过在类型中实现private方法readObjectwriteObject进行定制。这两个方法没有由任何接口提供,但仍然属于Java 对象序列化规范的一部分。它们难以正确实现,并且过去已经导致了许多漏洞⁹。

Record 仅由其不可变状态定义,由其组件表示。在创建后没有任何代码能够影响状态的情况下,序列化过程非常简单:

  • 序列化仅基于 Record 的组件。

  • 反序列化只需要使用规范构造函数,而不需要反射。

一旦 JVM 推导出 Record 的序列化形式,匹配的实例化程序就可以被缓存。无法定制这个过程,这实际上通过让 JVM 重新控制 Record 的序列化表示来实现更安全的序列化过程。这允许任何 Record 类型通过添加新组件继续演变,并且仍然可以成功地从之前序列化的数据中反序列化。在反序列化过程中遇到的任何未知组件,如果没有提供值,将自动使用其默认值(例如,对象类型为nullboolean类型为false等)。

警告

请注意,在使用 JShell 时,序列化的代码示例不会按预期工作。替换 Record 定义后,内部类名称将不会相同,因此类型将无法匹配。

假设你有一个二维的record Point(float x, float y)需要进行序列化。以下代码不会带来任何意外:

public record Point(int x, int y) implements Serializable {
  // NO BODY
}

var point = new Point(23, 42);
// => Point[x=23, y=42]

try (var out = new ObjectOutputStream(new FileOutputStream("point.data"))) {
  out.writeObject(point);
}

随着需求变化,你需要将 Record 添加第三个维度 z,如下面的代码所示。

public record Point(int x, int y, int z) implements Serializable {
  // NO BODY
}

如果尝试将point.data文件反序列化为已更改的 Record,会发生什么?我们来看看吧!

var in = new ObjectInputStream(new FileInputStream("point.data"));

var point = in.readObject();
// => Point[x=23, y=42, z=0]

它只是起作用。

新组件,在points.data的序列化表示中缺失,因此无法为 Record 的规范构造函数提供值,将会使用其类型的相应默认值进行初始化,在这种情况下,为int类型,初始化为0(零)。

如 “Records” 所述,Records 实际上是名义上的元组,仅基于其组件的名称和类型,而不是其确切的顺序。这就是为什么即使改变组件的顺序也不会破坏其反序列化能力。

public record Point(int z, int y, int x) implements Serializable {
  // NO BODY
}

var in = new ObjectInputStream(new FileInputStream("point.data"));

var point = in.readObject();
// => Point[z=0, y=42, x=23]

移除组件也是可能的,因为在反序列化过程中会忽略任何缺失的组件。

然而,还存在一个普遍的警告。

从单个 Record 的视角来看,它们仅由它们的组件定义。然而,对于 Java 序列化过程,被序列化的类型也很重要。这就是为什么即使两个 Records 有相同的组件,它们也不能互换。如果尝试将其反序列化为具有相同组件的其他类型,则会遇到 ClassCastException

public record Point(int x, int y) implements Serializable {
  // NO BODY
}

try (var out = new ObjectOutputStream(new FileOutputStream("point.data"))) {
  out.writeObject(new Point(23, 42));
}

public record IdenticalPoint(int x, int y) implements Serializable {
  // NO BODY
}

var in = new ObjectInputStream(new FileInputStream("point.data"));
IdenticalPoint point = in.readObject();
// Error:
// incompatible types: java.lang.Object cannot be converted to IdenticalPoint

不同类型的序列化不兼容性是 Records 使用的 “更简单但更安全” 序列化过程的副作用。由于不能像传统 Java 对象那样手动影响序列化过程,您可能需要迁移已经序列化的数据。最直接的方法是将旧数据反序列化为旧类型,将其转换为新类型,并将其序列化为新类型。

记录模式匹配 (Java 19+)

即使本书针对的是 Java 11,并试图帮助理解一些新添加的功能,我还是想告诉你一个在写作时仍在开发中的即将推出的功能:基于记录的模式匹配 (JEP 405)。

注意

JDK 预览功能是 Java 语言、JVM 或 Java API 的新功能,完全指定、实现但是暂时的。一般的想法是收集实际使用的反馈,以便该功能可能在将来的版本中成为永久性的。

Java 16 引入了 instanceof 操作符的模式匹配¹⁰,在使用操作符后不再需要强制转换:

// PREVIOUSLY

if (obj instanceof String) {
  String str = (String) obj;
  // ...
}

// JAVA 16+

if (obj instanceof String str) {
    // ...
}

Java 17 和 18 扩展了这个想法,通过启用 switch 表达式的模式匹配作为预览功能¹¹:

// WITHOUT SWITCH PATTERN MACTHING

String formatted = "unknown";
if (obj instanceof Integer i) {
  formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
  formatted = String.format("long %d", l);
} else if (obj instanceof String str) {
  formatted = String.format("String %s", str);
}

// WITH SWITCH PATTERN MATCHING

String formatted = switch (obj) {
  case Integer i -> String.format("int %d", i);
  case Long l    -> String.format("long %d", l);
  case String s  -> String.format("String %s", s);
  default        -> "unknown";
};

Java 19+ 也包括了这些 Records 的功能,包括解构¹²,这意味着 Record 的组件可以直接作为变量在作用域中使用:

record Point(int x, int y) {
  // NO BODY
};

var point = new Point(23, 42);

if (point instanceof Point(int x, int y)) {
  System.out.println(x + y);
  // => 65
}

int result = switch (anyObject) {
  case Point(var x, var y) -> x + y;
  case Point3D(var x, var y, var z) -> x + y + z;
  default -> 0.0;
};

正如你所看到的,Records 仍在演变,具有像模式匹配这样的新功能,这些功能增强了其功能集,使其成为一种更多才多艺、灵活的数据聚合类型,简化了你的代码。

关于 Records 的最终想法

Java 的新数据聚合类型 Records 通过尽可能少的代码提供了极大的简化。这是通过遵循特定的规则和限制来实现的,这些规则和限制最初可能看起来是武断和约束的,但它确实提供了更安全和一致的使用方式。Records 并不打算成为“一刀切”解决方案,完全替代所有 POJOs 或其他现有的数据聚合类型。它们仅仅提供了一个适合更功能化和不可变方法的新选项。

可用的功能集是有意选择的,以创建一种新类型的状态表示,仅限于状态。定义新记录的简单性不鼓励重用抽象类型,仅仅因为它可能比创建新的更合适的类型更方便。

记录可能不像 POJOs 或自定义类型那样灵活。但灵活性通常意味着更多复杂性,这往往会增加错误的可能性。处理复杂性的最佳方式是尽量减少其表面,并且如果记录的组件发生变化,它们不容易被破坏。

要点

  • 记录(Records)是由它们的组件定义的透明数据聚合类型。

  • 大多数类特有的功能,如实现接口、泛型或注解,也可以在 Records 中使用。

  • 任何记录类型都可以获得规范构造函数、组件访问器、对象标识和对象描述的典型样板代码,而无需额外的代码。如有必要,您可以覆盖每一个。

  • 记录具有某些限制,以确保其安全和简单的使用。与 POJOs 或 JavaBeans 等更灵活的解决方案相比,许多缺失的功能可以通过仅限于 JDK 的代码或注解处理工具来补充。

  • 遵循诸如验证和系统化修改副本的常见做法,可以创建一致的用户体验。

  • 记录提供了比基于类的兄弟更安全和更灵活的序列化解决方案。

¹ JDK 预览功能是设计、规范和实现完备,但不是永久性的功能。它旨在从社区中收集反馈以进一步发展。这样的功能可能在未来的版本中以不同的形式存在或完全不存在。

² 参见 Java 语言规范 章节 3.8,了解有效 Java 标识符的定义。

³ “自动魔法”一词描述了一种自动过程,对用户隐藏,因此类似于魔法。Records 提供它们的自动功能,无需额外的工具如注解处理器或额外的编译器插件。

⁴ 要了解更多关于注解的一般信息以及如何使用它们,你应该查看我的文章Java 注解解析

⁵ Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design patterns: Elements of reusable object-oriented software. Addison Wesley.

单一责任原则 是面向对象编程中 SOLID 原则的第一个。其五个原则旨在使 OO 设计更灵活、可维护和简单。

⁷ 我在我的个人博客上写了一篇关于如何评估依赖关系的文章。

⁸ 关于反射的“成本”一词与产生的性能开销和暴露于安全问题有关。反射使用动态解析的类型信息,这导致 JVM 无法利用其所有可能的优化。因此,反射比它们的非反射对应物具有更慢的性能。

⁹ 方法readObject可以执行任意代码,而不仅仅是读取对象。一些相关的 CVE 包括:CVE-2019-6503CVE-2019-12630CVE-2018-1851

¹⁰ 将instanceof运算符扩展为支持 模式匹配 的概述在 JEP 394 中。

¹¹ switch 的模式匹配总结在 JEP 406JEP 430 中。

¹² 记录的模式匹配总结在 JEP 405 中。

第六章:使用 Streams 进行数据处理

几乎任何程序都必须处理数据,通常以集合的形式出现。命令式方法使用循环按顺序迭代元素,每次处理一个元素。而函数式语言则更倾向于声明式方法,有时甚至没有传统的循环语句起始。

Streams API,引入于 Java 8,提供了一种完全声明式和惰性评估的数据处理方法,通过利用 Java 的函数式添加,利用高阶函数来执行大部分操作。

本章将教你区分命令式和声明式数据处理的差异。然后,你将通过视觉介绍 Streams,突出它们的基本概念,并展示如何充分利用它们的灵活性,实现更加功能化的数据处理方法。

使用迭代进行数据处理

处理数据是你可能之前已经遇到过并且将来也会继续遇到的日常任务。

从宏观角度来看,任何类型的数据处理都像是一个管道,数据结构如集合提供元素,一个或多个操作如过滤或转换元素,最终提供某种形式的结果。结果可以是另一个数据结构,甚至是用它来运行另一个任务。

让我们从一个简单的数据处理示例开始。

外部迭代

假设我们需要在 Book 实例集合中找到 1970 年之前的前三本科幻书籍按标题排序的例子。示例 6-1 展示了如何使用典型的命令式方法和 for 循环来实现这一点。

示例 6-1. 使用 for 循环查找书籍
record Book(String title, int year, Genre genre) {
  // NO BODY
}

// DATA PREPARATION

List<Book> books = ...; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

Collections.sort(books, Comparator.comparing(Book::title)); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

// FOR-LOOP

List<String> result = new ArrayList<>();

for (var book : books) {

    if (book.year() >= 1970) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
        continue;
    }

    if (book.genre() != Genre.SCIENCE_FICTION) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
        continue;
    }

    var title = book.title(); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    result.add(title);

    if (result.size() == 3) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
        break;
    }
}

1

一个未排序的书籍集合。它必须是可变的,以便可以在下一步中就地排序。

2

集合必须先排序,否则result中的元素将不是原始集合中按字母顺序排列的前三个标题。

3

忽略任何不需要的书籍,比如那些在 1970 年以后出版的或非科幻小说。

4

我们只对书名感兴趣。

5

将找到的标题限制为最多三个。

尽管该代码对需要完成的任务有效,但与其他方法相比存在多个缺点。最明显的缺点是基于迭代循环需要大量样板代码。

循环语句,无论是 for- 还是 while- 循环,都包含它们的数据处理逻辑在它们的主体中,为每次迭代创建了一个新的作用域。根据你的要求,循环的主体包含多个语句,包括关于迭代过程本身的决策,以 continuebreak 的形式。总的来说,数据处理代码被所有这些样板代码遮掩,并不流畅地呈现出来,尤其是对于一个比前面示例更复杂的循环来说。

这些问题的根源在于将“你正在做什么”(处理数据)和“它是如何完成的”(遍历元素)混为一谈。这种迭代叫做外部迭代。在幕后,for-循环,即 for-each 变体,使用 java.util.Iterator<E> 来遍历集合。遍历过程调用 hasNextnext 来控制迭代,如 图 6-1 中所示。

外部迭代

图 6-1. 外部迭代

在“传统”的 for-循环中,你必须自行管理遍历元素直到达到结束条件,这在某种程度上类似于 Iterator<E>hasNext 以及 next 方法。

如果你数一数关于“你正在做什么”和“它是如何完成的”的代码行数,你会注意到它花费了更多时间在遍历管理上,而不是数据处理上,如 表 6-1 中详细列出的那样。

表 6-1. 每个任务的数据处理代码行数

任务 代码行数
数据准备 对初始数据进行排序并准备结果收集 2
遍历过程 循环并使用 continuebreak 控制循环 4
数据处理 选择、转换和收集正确的元素和数据 4

然而,需要大量样板代码来进行遍历不是与外部迭代相关的唯一缺点。另一个缺点是固有的串行遍历过程。如果需要并行数据处理,并要处理所有相关的烦心事,比如可怕的 ConcurrentModificationException,那么你需要重新设计整个循环。

内部迭代

外部迭代相对的方法是,可预测的,内部迭代。使用内部迭代,你放弃了对遍历过程的显式控制,并让数据源本身处理“它是如何完成的”,如 图 6-2 所示。

内部迭代

图 6-2. 内部迭代

不使用迭代器来控制遍历,数据处理逻辑事先准备好以构建一个自行迭代的管道。迭代过程变得更加不透明,但逻辑影响哪些元素遍历管道。这样,你可以把精力和代码集中在“想要做什么”而不是“如何做”这些繁琐且常重复的细节上。

流是具有内部迭代的数据管道。

流作为功能性数据管道

流作为一种数据处理方法,像其他方法一样完成工作,但由于具有内部迭代器,具有特定优势。这些优势在功能上尤其有益。优势如下:

声明性方法

用一个流畅的调用链构建简洁而易懂的多步数据处理管道。

可组合性

流操作提供了一个由高阶函数组成的框架,用于填充数据处理逻辑。它们可以按需混合使用。如果以函数式的方式设计它们的逻辑,你就能自动获得它们的所有优势,如可组合性。

懒加载

它们不是迭代所有元素,而是在最后一个操作附加到它们后逐个拉入管道,将所需操作最小化。

性能优化

流根据它们的数据源和使用的不同操作自动优化遍历过程,包括可能的短路操作。

并行数据处理

内置支持并行处理,只需更改调用链中的一个调用即可使用。

在概念上,流可以被认为只是传统循环结构用于数据处理的另一种选择。然而,在现实中,流在如何提供这些数据处理能力方面是特殊的。

首先要考虑的是整体的流工作流程。流可以总结为惰性顺序数据管道。这样的管道是遍历顺序数据的更高级抽象。它们是处理其元素的高阶函数序列,以流畅、表达和函数式的方式。一般的工作流程可以通过三个步骤来表示,如在图 6-3 中所见。

Java Streams 的不同方面

图 6-3. Java Streams 的基本概念

(1) 创建一个流

第一步是从现有数据源创建一个流。流不仅限于类似集合的类型。任何能够提供连续元素的数据源都可以作为流的数据源。

(2) 进行工作

所谓的中间操作 —— 在 java.util.stream.Stream<T> 上作为方法可用的高阶函数 —— 在通过管道传递的元素上工作,执行不同的任务,如过滤、映射、排序等。每个操作都返回一个新的 Stream,可以连接到尽可能多的中间操作。

(3) 获取结果

要完成数据处理流水线,需要一个最终的 —— 终端 —— 操作,以获取结果而不是 Stream。这样的终端操作完成了 Stream 流水线的蓝图,并开始实际的数据处理。

要看到它的运行情况,让我们重新访问早期任务,找出 1999 年的三本科幻书的标题。这一次,我们将使用 示例 6-2 中的 Stream 流水线,而不是像在 示例 6-1 中使用 for 循环。现在先不要太担心 Stream 代码,我会很快解释各种方法。阅读一遍,你现在应该能够大致理解它。

示例 6-2. 使用 Stream 查找书籍
List<Book> books = ...; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

List<String> result =
  books.stream()
       .filter(book -> book.year() < 1970) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
       .filter(book -> book.genre() == Genre.SCIENCE_FICTION) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
       .map(Book::title) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
       .sorted() ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
       .limit(3) ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
       .collect(Collectors.toList()); ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/7.png)

1

一组未排序的书籍。

2

忽略任何非 1999 年出版的书籍。

3

忽略任何非科幻书籍。

4

将整个 Book 元素转换为其 title 值。

5

对标题进行排序。

6

限制找到的标题最多为三个。

7

将标题聚合成 List<String>

从高层次的角度来看,示例 6-1 和 示例 6-2 中展示的两种实现都代表了元素可以遍历的流水线,其中包含多个不需要的数据的退出点。但请注意,for 循环的功能及其多个语句现在被压缩成了一个单一的流畅 Stream 调用?

这引导我们了解 Stream 如何优化其元素的流动。您不必使用 continuebreak 明确管理遍历,因为元素将根据操作的结果在管道中遍历。图 6-4 阐明了不同的 Stream 操作如何影响 示例 6-2 的元素流。

书籍流的元素流动

图 6-4. 书籍流的元素流动

元素逐个通过 Stream 流动,并被漏斗到处理数据所需的最少量。

与需要预先准备数据并将处理逻辑包装在循环语句体中的方式不同,流是通过不同处理步骤的流畅类构建的。与其他函数式方法类似,流代码以更具表现力和声明性的方式反映了“发生了什么”,而不是典型的“如何实际完成”的冗长表述。

流特性

流是具有特定行为和预期的函数式 API。在某种程度上,这限制了它们的可能性,至少与传统循环的空白画布相比如此。但通过不是空白画布,它们为你提供了许多预定义的构建块和保证属性,这些属性如果使用其他方法,你将不得不自己实现。

惰性求值

流相对于循环最显著的优势是它们的惰性。每次在流上调用一个中间操作时,并不会立即应用它。相反,这个调用只是“扩展”了管道,并返回一个新的惰性评估的流。管道累积所有操作,直到你调用它的终端操作才会触发实际的元素遍历,就像在图 6-5 中所见。

流的惰性求值

图 6-5. 流的惰性求值

终端操作不是将所有元素提供给代码块(如循环),而是根据需要请求更多数据,并且流会尽力满足这些请求。作为数据源,如果没有人请求更多元素,流就不需要“过度提供”或缓冲任何元素。如果回顾图 6-4,这意味着并不是每个元素都会通过每个操作。

流元素的流动遵循“深度优先”的方法,减少了所需的 CPU 周期、内存占用和堆栈深度。因此,即使是无限数据源也是可能的,因为管道负责请求所需的元素并终止流。

你可以在第十一章中详细了解函数式编程中惰性求值的重要性。

(大部分)无状态且不干涉

正如你在第四章中学到的那样,不可变状态是函数式编程中的一个重要概念,而流(Streams)尽力遵循这一概念。几乎所有的中间操作都是无状态的,并且与管道的其余部分分离,只能访问它们当前正在处理的元素。然而,某些中间操作需要某种形式的状态来完成它们的目的,比如limitskip

使用流的另一个优势是它们将数据源与元素本身分离。这样一来,操作不会以任何方式影响底层数据源,流本身也不会存储任何元素。

警告

尽管你可以创建具有副作用的 Java 状态型 lambda 函数,但是你应该努力设计数据操作管道的行为参数为无状态且纯函数。任何依赖于超出范围状态的行为都可能严重影响安全性和性能,并使整个管道因意外副作用而变得不确定和不正确。其中一种例外是用于执行“仅副作用”的终端操作代码,这有助于在现有的命令式设计中极好地适配功能型流管道。

流是非干扰直通管道,将其元素尽可能自由地穿越,除非绝对必要。

包括优化

内部迭代和高阶函数的基本设计使得流能够相当高效地优化自身。它们利用多种技术来提升性能:

  • (无状态)操作的融合¹

  • 移除冗余操作

  • 短路管道路径

与流相关的迭代代码优化并不限于流。如果可能的话,传统循环也会被 JVM 优化²。

此外,像forwhile这样的循环是语言特性,因此可以被进一步优化。流是普通类型,具有相关的所有成本。它们仍然需要通过包装数据源来创建,并且管道是一个调用链,每次调用都需要一个新的堆栈帧。在大多数实际场景中,它们的总体优势超过了与内置语句forwhile相比可能的性能影响。

减少样板代码

如在示例 6-2 中所见,流将数据处理压缩为一个流畅的方法调用链。这个调用链被设计为由诸如filtermapfindFirst等小而精准的操作组成,为数据处理逻辑提供了一个富有表现力且直接的支架。调用链应该易于理解,无论是视觉上还是概念上。因此,流管道只消耗尽可能少的视觉空间和认知带宽。

不可重复使用

流管道仅限于单次使用。它们与其数据源绑定,在终端操作调用后遍历数据源一次。

如果尝试再次使用流,会抛出IllegalStateException。尽管如此,你无法检查流是否已经被消耗。

由于流不会改变或影响其底层数据源,你始终可以从同一数据源创建另一个流。

原始类型流

与第二章介绍的功能接口类似,流 API 包含了处理原始类型的专门变体,以最小化自动装箱的开销。

Stream及其专门的变体IntStreamLongStreamDoubleStream共享一个公共基础接口BaseStream,如图 6-6 所示。许多可用的原始流操作与其非原始对应物相似,但并非全部。

流类型层次结构

图 6-6. 流类型层次结构

这就是为什么我在第 7 章中讨论何时使用原始流以及如何使用单个操作在非原始和原始流之间进行切换的原因。

简单并行化

使用传统循环结构进行数据处理固有地是串行的。并发很难做到正确,很容易做错,特别是如果您必须自己处理它。流从根本上支持并行执行,利用了 Java 7 引入的Fork/Join 框架

通过在管道的任何点简单地调用parallel方法来并行化流。虽然并非每个流管道都适合并行处理。流源必须有足够的元素,并且操作必须足够昂贵,以证明多线程的开销是合理的。切换线程——所谓的上下文切换——是一项昂贵的任务。

在第 8 章中,您将更多地了解并行流处理和一般并发。

(缺乏)异常处理

流通过引入函数式数据处理方法极大地减少了代码的冗长。然而,这并不意味着它们在操作中免于处理异常。

Lambda 表达式以及因此流操作的逻辑,没有任何特殊的考虑或语法糖来比try-catch更简洁地处理异常。您可以在第 10 章中了解有关函数式 Java 代码中异常的一般问题以及如何以不同方式处理它们的更多信息。

流的骨干 Spliterator

就像“传统”的for-each循环围绕Iterator<T>类型构建以遍历元素序列一样,流有自己的迭代接口:java.util.Spliterator<T>

Iterator<T>接口仅基于“next”概念,具有少量方法,使其成为 Java 集合 API 的通用迭代器。然而,Spliterator<T>背后的概念是它具有根据某些特征将其元素的子序列拆分成另一个Spliterator<T>的能力。这种优势使其比Iterator<T>类型更适合成为流 API 的核心,并允许流以并行方式处理这些子序列,并仍然能够迭代 Java 集合 API 类型。

示例 6-3 展示了java.util.Spliterator的简化变体。

示例 6-3. java.util.Spliterator 接口
public interface Spliterator<T> {

    // CHARACTERISTICS
    int characteristics();
    default boolean hasCharacteristics(int characteristics) {
        // ...
    }

    // ITERATION

    boolean tryAdvance(Consumer<? super T> action);
    default void forEachRemaining(Consumer<? super T> action) {
        // ...
    }

    // SPLITTING
    Spliterator<T> trySplit();

    // SIZE
    long estimateSize();
    default long getExactSizeIfKnown() {
        // ...
    }

    // COMPARATOR
    default Comparator<? super T> getComparator() {
        // ...
    }
}

对于迭代过程,boolean tryAdvance(Consumer action)Spliterator<T> trySplit() 方法是最重要的。尽管如此,Spliterator 的特性决定了其所有操作的能力。

关于 Streams,Spliterator 的特性负责 Stream 内部迭代的方式以及它支持的优化。有八个可组合的特性,定义为 static int 常量在 Spliterator<T> 类型上,如 表 6-2 所列。尽管特性看起来符合预期的 Stream 行为,但并非所有特性在当前的 Stream 实现中都实际使用。

表 6-2. Spliterator 特性

特性 描述
CONCURRENT 基础数据源在遍历过程中可以安全地并发修改。只影响数据源本身,与 Stream 行为无关。
DISTINCT 数据源只包含唯一的元素,如 Set<T>。Stream 中的任何元素对保证 x.equals(y) == false
IMMUTABLE 数据源本身是不可变的。遍历期间不能添加、替换或删除任何元素。只影响数据源本身,与 Stream 行为无关。
NONNULL 基础数据源保证不包含任何 null 值。只影响数据源本身,与 Stream 行为无关。
ORDERED 数据源的元素有一个定义的顺序。在遍历过程中,遇到的元素将按特定顺序排列。
SORTED 如果 Spliterator<T>SORTED,则其 getComparator() 方法返回关联的 Comparator<T>,否则返回 null,如果源数据自然排序。SORTEDSpliterators 也必须是 ORDERED
SIZED 数据源知道其确切的大小。estimateSize() 返回实际大小,而不是估计值。
SUBSIZED 表示在调用 trySplit() 后所有拆分的块也都是 SIZED。只影响数据源本身,与 Stream 行为无关。

Stream 的特性不必固定,并且可以依赖于基础数据源。HashSet 是具有动态特性的 Spliterator 的一个例子。它使用嵌套的 HashMap.KeySpliterator 类,依赖于实际数据,如 示例 6-4 所示。

示例 6-4. HashSet 的 Spliterator 特性
public int characteristics() {
    return (fence < 0 || est == map.size ? Spliterator.SIZED : 0) |
                Spliterator.DISTINCT;
}

HashSet 创建其 KeySpliterator 的方式表明,Spliterator 可以利用其周围的上下文做出有关其能力的明智决定。

大多数情况下,你不需要过多考虑流的特性。通常情况下,数据源的基本能力不会因为使用流而神奇地改变。例如,Set<T>仍然会以无序方式提供不同的元素,无论是使用for循环还是流。因此,选择最适合任务的数据源,不管使用的是哪种遍历形式。

在使用流时,通常不需要手动创建Spliterator,因为我将在下一章中讨论的便利方法会在后台为你完成这些操作。但是,如果你需要为自定义数据结构创建Spliterator,也不一定需要自己实现接口。相反,你可以使用java.util.Spliterators的众多便利方法之一。最简单的变体如下方法:

<T> Spliterator<T> spliterator(Iterator<? extends T> iterator,
                               long size,
                               int characteristics)

生成的Spliterator可能不是最优化的Spliterator,只支持有限的并行处理,但这是在流中使用现有Iterator兼容数据结构的最简单方法,这些数据结构在原生不支持流的情况下使用。

查看官方文档,了解java.util.Spliterators类型提供的 20 多个便利方法的更多信息。

构建流管道

流 API 非常广泛,详细解释每个操作和可能的使用情况本身可能会填满一本书。我们可以从更高层次的角度来看待使用可用的高阶函数构建流管道。这个概述仍然会帮助你在代码中用流管道取代许多数据处理任务,尤其是那些遵循映射/过滤/归约哲学的任务。

流 API 实际上有名为mapfilterreduce的操作。但它提供的操作远不止这三个。大多数这些额外的操作的逻辑都可以通过map/filter/reduce来复制,并且在内部通常确实如此。这些额外的操作为你提供了一个方便的方式来避免自己实现常见用例,提供了许多不同的专门操作,可以轻松使用。

创建流

每个流管道都始于从现有数据源创建一个新的流实例。最常用的数据源是集合类型。这就是为什么在 Java 8 中引入流时,Stream<E> stream()Stream<E> parallelStream()Spliterator<E> spliterator()这三种方法被添加到java.util.Collection中,正如在示例 6-5 中所见。

示例 6-5. 简化集合类型的流创建
public interface Collection<E> extends Iterable<E> {

  default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
  }

  default Stream<E> parallelStream() {
    return StreamSupport.stream(spliterator(), true);
  }

  @Override
  default Spliterator<E> spliterator() {
    return Spliterators.spliterator(this, 0);
  }

  // ...
}

stream 方法是从任何基于 Collection 的数据结构(如 ListSet)创建新 Stream 实例的最简单方法。它利用了一个 IMMUTABLECONCURRENTSpliterator 作为其默认实现。然而,许多 Collection 类型提供了具有优化特性和行为的自定义实现。

即使在 Collection 上的 stream 方法可能是创建 Stream 最方便的方法,JDK 还提供了许多其他创建 Stream 的静态便捷方法,如 Stream.of(T…​ values)。在 第七章 中,您将学习更多创建不同用例的 Stream 的方法,如无限 Stream 或处理 I/O。

执行工作

现在您已经有了一个 Stream,下一步是处理其元素。

处理 Stream 元素是通过 中间操作 完成的,它们可以分为三类:转换(map)元素、选择(filter)元素或修改一般的 Stream 行为。

提示

所有 Stream 操作都有合适的命名,并且有充分的 文档 和示例。许多方法使用“尚未成为标准”的 JavaDoc³ @implSpec 来引用特定于实现的行为。因此,请确保在您的 IDE 无法正确呈现所有文档时,查看在线文档或 JavaDoc 本身。

在本节中,我将使用一个简单的 Shape 记录,如 示例 6-6 所示,来演示不同的操作。

示例 6-6. 一个简单的 Shape 类型
public record Shape(int corners) implements Comparable<Shape> {

  // HELPER METHODS

  public boolean hasCorners() {
    return corners() > 0;
  }

  public List<Shape> twice() {
    return List.of(this, this);
  }

  @Override
  public int compareTo(Shape o) {
    return Integer.compare(corners(), o.corners());
  }

  // FACTORY METHODS

  public static Shape circle() {
    return new Shape(0);
  }

  public static Shape triangle() {
    return new Shape(3);
  }

  public static Shape square() {
    return new Shape(4);
  }
}

并不会为每个操作提供专门的代码示例,因为实在太多了。不过,每个操作及其元素流都有详细说明。

选择元素

数据处理的第一个常见任务是选择正确的元素,可以通过 Predicate 进行过滤,也可以根据元素的数量进行选择。

Stream<T> filter(Predicate<? super T> predicate)

过滤元素的最简单方法。如果 Predicate 评估为 true,则将元素视为进一步处理的候选。静态方法 Predicate<T>.not(Predicate<T>) 允许轻松地否定一个 Predicate,而不会失去方法引用的优势。常见任务,如 null 检查,可通过 java.util.Objects 类作为方法引用使用。参见 图 6-7。

Stream filter(Predicate<? super T> predicate)

图 6-7. Stream<T> filter(Predicate<? super T> predicate)

Stream<T> dropWhile(Predicate<? super T> predicate)

丢弃 — 或者跳过 — 通过操作的任何元素,只要Predicate评估为true。该操作设计用于有序流。如果流不是有序的,则放弃的元素不是确定性的。对于顺序流,跳过元素是一种廉价的操作。然而,对于并行流,必须在底层线程之间协调,使得该操作非常昂贵。该操作在 Java 9 中引入。参见图 6-8。

Stream dropWhile(Predicate<? super T> predicate)

图 6-8. Stream<T> dropWhile(Predicate<? super T> predicate)

Stream<T> takeWhile(Predicate<? super T> predicate)

dropWhile相对,选择元素直到Predicate评估为false。该操作在 Java 9 中引入。参见图 6-9。

Stream takeWhile(Predicate<? super T> predicate)

图 6-9. Stream<T> takeWhile(Predicate<? super T> predicate)

Stream<T> limit(long maxSize)

将通过该操作的最大元素数量限制为maxSize。参见图 6-10。

Stream limit(long maxSize)

图 6-10. Stream<T> limit(long maxSize)

Stream<T> skip(long n)

limit的对立面,跳过n个元素,然后将所有剩余元素传递给后续的流操作。参见图 6-11。

Stream skip(long n)

图 6-11. Stream<T> skip(long n)

Stream<T> distinct()

通过Object#equals(Object)比较元素以返回唯一的元素。该操作需要缓冲通过的所有元素来进行比较。没有集成的方法来提供自定义的Comparator<T>来确定唯一性。参见图 6-12。

Stream distinct()

图 6-12. Stream<T> distinct()

Stream<T> sorted()

如果符合java.util.Comparable,则按照其自然顺序对元素进行排序。否则,在流消耗时将抛出java.lang.ClassCastException。图 6-13 假设按照形状的角数进行自然排序。该操作需要缓冲通过的所有元素来进行排序。参见图 6-13。

Stream sorted()

图 6-9. Stream<T> takeWhile(Predicate<? super T> predicate)

Stream<T> sorted(Comparator<? super T> comparator)

更灵活的版本的sorted,您可以提供一个自定义的comparator

映射元素

另一个重要的操作类别是映射——或转换——元素。并不是所有 Streams 及其元素都以所需的形式开始。有时你需要不同的表示形式,或者仅对元素属性的一个子集感兴趣。

起初,Streams 只有两种映射操作:

Stream<R> map(Function<? super T, ? extends R> mapper)

mapper函数应用于元素,并将新元素返回到流中。参见图 6-14。

Stream map(Function<? super T, ? extends R> mapper)

图 6-14. Stream<R> map(Function<? super T, ? extends R> mapper)

Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

mapper函数仍然应用于元素。然而,除了返回一个新元素,必须返回一个Stream<R>。如果使用map,结果将是一个嵌套的Stream<Stream<R>>,这很可能不是你想要的。flatMap操作“扁平化”一个类似容器的元素,如集合或 Optional,转换成一个新的 Stream,包含多个元素,这些元素在后续操作中使用。参见图 6-15。

Stream flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

图 6-15. Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

Java 16 引入了一个额外的映射方法(及其三个基本类型的对应方法),其作用与flatMap类似:

Stream<R> mapMulti(BiConsumer<? super T, ? super Consumer<R>> mapper)

mapMulti操作不要求 mapper 返回一个 Stream 实例。相反,一个Consumer<R>会将元素进一步传递到流中。

在当前形式下,使用mapMulti操作的Shape类型不会导致更干净的代码,如示例 6-7 所示。

示例 6-7. flatMapmapMulti的比较
// FLATMAP

Stream<Shape> flatMap =
  Stream.of(Shape.square(), Shape.triangle(), Shape.circle())
        .map(Shape::twice)
        .flatMap(List::stream);

// MAPMULTI

Stream<Shape> mapMulti =
  Stream.of(Shape.square(), Shape.triangle(), Shape.circle())
        .mapMulti((shape, downstream) -> shape.twice()
                                              .forEach(downstream::accept));

在简洁性和可读性方面,flatMap显然是胜出者。尽管如此,multiMap的主要优势在于它将两个操作mapflatMap合并为一个操作。

mapMulti的默认实现实际上使用flatMap来为你创建一个新的 Stream,因此你的映射元素不需要知道如何自己创建 Stream。通过自己调用下游的Consumer决定哪些映射元素属于新的 Stream,管道负责创建它。

mapMulti操作并不是为了替代flatMap操作。它们只是 Stream 操作集合中的一个补充。虽然有些情况下mapMultiflatMap更为合适:

  • 只有很少数的元素,甚至是零,才会在 Stream 管道中映射。使用mapMulti避免了为每组映射元素创建新 Stream 的开销,这正是flatMap所做的。

  • 当迭代方法提供映射结果比创建新的流实例更简单时。这使得在将元素提供给Consumer之前,你可以更自由地进行映射处理。

查看流

一个中间操作不符合map/filter/reduce的理念:peek

流的简洁性可以将大量功能打包到一个单一的流畅调用中。尽管这是它们的主要卖点之一,但调试它们比传统的命令式循环结构要困难得多。为了减轻这一痛点,流 API 包括一个特定的操作,peek(Consumer<? super T> action),用于在不干预元素的情况下“窥视”流,如示例 6-8 所示。

示例 6-8。查看流
List<Shape> result =
  Stream.of(Shape.square(), Shape.triangle(), Shape.circle())
        .map(Shape::twice)
        .flatMap(List::stream)
        .peek(shape -> System.out.println("current: " + shape))
        .filter(shape -> shape.corners() < 4)
        .collect(Collectors.toList());

// OUTPUT
// current: Shape[corners=4]
// current: Shape[corners=4]
// current: Shape[corners=3]
// current: Shape[corners=3]
// current: Shape[corners=0]
// current: Shape[corners=0]

peek操作主要用于支持调试。如果操作对最终结果并非必需,比如计算元素数量,并且流程可以被快速终止,那么它可能会被省略以优化流。

关于操作的短路将在“操作的成本”中更详细地解释。

终止流

终端操作是流管道的最后一步,它启动实际处理元素以产生结果或副作用。与中间操作及其延迟性不同,终端操作急切地进行评估。

可用的终端操作分为四组不同的类型:

  • 缩减

  • 聚合

  • 查找和匹配

  • 消耗

缩减元素

缩减操作,也称为折叠操作,通过重复应用累加器运算符将流的元素减少到单个结果。这样的运算符使用前一个结果与当前元素组合,生成新结果,如图 6-16 所示。累加器应该总是返回一个新值,而不需要中间数据结构。

通过将形状相邻组合来减少形状

图 6-16。通过将形状相邻组合来减少形状

像许多函数工具一样,初学者经常对缩减操作感到陌生,特别是如果你来自命令式编程背景。更好地理解这类工具背后的一般概念的最简单方法是查看涉及的部分以及它们在更熟悉的形式中如何工作。

在缩减的情况下,涉及三个部分:

元素

数据处理就是处理数据元素。流的熟悉等价物将是任何集合类型。

初始值

数据的累积必须从某处开始。有时这个初始值是显式的,但某些缩减变体会通过用第一个元素替换它或者允许在没有元素的情况下得到一个可选结果来省略它。

累加器函数

减少逻辑仅仅与当前元素和前一个结果或初始值一起工作。仅仅依靠其输入来创建新值使其成为一个纯函数。

以查找Collection<Integer>中的最大值为例。您必须遍历每个元素,并将其与下一个元素进行比较,在每个步骤返回较大的数字,如示例 6-9 所示。减少的所有三个部分都有所体现。

示例 6-9. 在Collection<Integer>中找到最大数
Integer max(Collection<Integer> numbers) {
  int result = Integer.MIN_VALUE; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  for (var value : numbers) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    result = Math.max(result, value); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }

  return result; ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
}

1

初始值取决于所需的任务。在这种情况下,与最小可能的int值进行比较是找到最大数的合理选择。

2

减少逻辑必须应用于每个元素。

3

实际的减少逻辑,代表累加器函数。

4

减少的值。

为了更好地反映一般的减少操作,前面的示例允许你像在示例 6-10 中展示的那样推导出通用的减少操作。

示例 6-10. 类似for循环的reduce
<T> T reduce(Collection<T> elements,
             T initialValue,
             BinaryOperator<T> accumulator) {

  T result = initialValue;

  for (T element : elements) {
    result = accumulator.apply(result, element);
  }

  return result;
}

通用变体再次强调了功能方法将任务的执行方式与任务实际执行的内容分离开来。通过使用通用变体,前面找到最大值的示例可以简化为单个方法调用:

Integer max(Collection<Integer> numbers) {
  return reduce(elements,
                Integer.MIN_VALUE,
                Math::max);
}

max方法也是为什么流 API 提供的不仅仅是reduce方法的示例:专门用于覆盖常见用例。

尽管所有特殊化的流操作都可以用三种可用的reduce方法之一来实现 — 实际上有些是这样的 --⁠,但特殊化的变体为典型的减少操作创建了更具表现力的流畅流调用。

流 API 有三种不同的显式reduce操作:

T reduce(T identity, BinaryOperator<T> accumulator)

identity是链式accumulator操作的种子 — 初始 — 值。虽然它等同于示例 6-10,但它不受for循环顺序性的约束。

Optional<T> reduce(BinaryOperator<T> accumulator)

这个操作不需要一个种子值,它会选择第一个遇到的元素作为初始值。这就是为什么它返回一个Optional<T>,你将在第九章学到更多关于它的内容。如果流不包含任何元素,它会返回一个空的Optional<T>

U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner)

如果流包含类型为T的元素,但所需的归约结果是类型为U的,那么这种变体结合了mapreduce操作是必需的。或者,您可以分别使用显式的mapreduce操作。这样的流管道可能比使用组合的reduce操作更直观,就像在示例 6-11 中看到的那样,用于对Stream<String>中的所有字符求和。

示例 6-11. 三参数的reduce操作与map + 两参数的reduce操作
var reduceOnly = Stream.of("apple", "orange", "banana")
                       .reduce(0,
                               (acc, str) -> acc + str.length(),
                               Integer::sum);

var mapReduce = Stream.of("apple", "orange", "banana")
                      .mapToInt(String::length)
                      .reduce(0, (acc, length) -> acc + length);

选择哪种方式 — 单个reduce还是分开的mapreduce — 取决于您的偏好以及 lambda 表达式是否可以被泛化或重构,因此您可以使用方法引用替代。

正如前面提到的,一些典型的归约任务作为专门的操作是可用的,包括原始流的任何变体,如表 6-3 中列出的。列出的方法属于IntStream,但也适用于LongStreamDoubleStream及其相关类型。

表 6-3. 典型的归约操作

方法 描述
Stream
Optional<T> min(Comparator<? super T> comparator) Optional<T> max(Comparator<? super T> comparator) 根据提供的comparator返回流中的最小/最大元素。如果没有元素达到操作,则返回一个空的Optional<T>
long count() 返回流管道末端的元素计数。请注意,如果流的特性包含SIZED并且管道中没有过滤操作,则某些流实现可以选择执行所有中间操作。
原始流
int sum() 求和流的元素。
OptionalDouble average() 计算流元素的算术平均值。如果在终端操作时流不包含任何元素,则返回一个空的OptionalDouble
IntSummaryStatistics summaryStatistics() 返回包含流元素的计数总和最小值最大值的摘要信息。

即使在将代码迁移到更功能化的方法后,归约操作可能并不是终止流的首选操作。这是因为还有另一种类型的归约操作可用,这种操作在您习惯的方式中更常见:聚合操作

使用收集器聚合元素

对于每一个数据处理任务,无论是使用流还是使用循环的命令式方法,一个普遍的步骤是将结果元素聚合到一个新的数据结构中。最常见的情况是,您希望结果元素在一个新的List、一个唯一的Set或某种形式的Map中。

将元素归约到一个新值,例如集合类型,在前一节中显示的归约操作中符合要求,如 示例 6-12 所示。

示例 6-12. 使用 reduce 操作聚合元素
var fruits = Stream.of("apple", "orange", "banana", "peach")
                   ...
                   .reduce(new ArrayList<>(), ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                           (acc, fruit) -> {
                             var list = new ArrayList<>(acc); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                             list.add(fruit);
                             return list;
                   },
                   (lhs, rhs) -> { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                     var list = new ArrayList<>(lhs);
                     list.addAll(rhs);
                     return list;
                   });

1

使用三参数 reduce 操作是因为结果类型与流元素类型不同。

2

归约操作应返回新值,因此在聚合元素时不使用共享的 ArrayList,而是为每个累积步骤创建一个新的 ArrayList

3

组合器通过在并行处理情况下创建新的 ArrayList 实例来合并多个 ArrayList 实例。

这相当于将 Stream 缩减为一个简单的 List 的冗长代码,每个元素都创建一个新的 ArrayList 实例,如果并行运行,则还会创建额外的 ArrayList 实例!

当然,你可以在聚合器函数中重复使用 ArrayList acc 变量,而不是创建并返回新的变量。然而,这与 reduce 的一般概念相违背,即不可变归约操作。这就是为什么有更好的解决方案可用:聚合操作

虽然在本章节中我称它们为“聚合操作”,但从技术上讲,它们被称为“可变归约操作”,以区别于被称为“不可变归约操作”的归约操作。

Stream<T> 类型的终端操作 collect 接受一个 Collector 来聚合元素。这些操作不是通过重复应用累加器运算符将流元素组合到单一结果中来减少元素,而是使用一个可变结果容器作为中间数据结构,如 图 6-17 中所示。

收集流元素

图 6-17. 收集流元素

利用 java.util.stream.Collector<T, A, R> 类型,流的元素被聚合或收集。接口的泛型类型代表了收集过程中涉及的不同部分:

  • T: 流元素的类型。

  • A: 可变结果容器类型。

  • R: 收集过程的最终结果类型,可能与中间容器类型不同。

Collector 由多个步骤组成,与其 接口定义 完美匹配,如 图 6-18 所示。

Collector<T,A,R> 的内部工作原理

图 6-18. Collector<T, A, R> 的内部工作原理

步骤 1: Supplier<A> supplier()

Supplier 返回在整个收集过程中使用的可变结果容器的新实例。

步骤 2:BiConsumer<A, T> 累加器

作为 Collector 的核心,这个 BiConsumer 负责通过接受结果容器和当前元素作为其参数,将类型为 T 的 Stream 元素累加到类型为 A 的容器中。

步骤 3:BinaryOperator<A> 合并器

在并行流处理的情况下,多个累加器可能会执行其工作,返回的合并器 BinaryOperator 将部分结果容器合并为一个单一的结果容器。

步骤 4:Function<A, R> 完成者

完成者将中间结果容器转换为实际返回类型为 R 的对象。这一步骤的必要性取决于 Collector 的实现。

步骤 5:最终结果

收集到的实例,例如 ListMap,甚至是单个值。

JDK 提供了 java.util.Collectors 实用程序类,为许多用例提供了各种 Collectors。列举并详细解释它们可能会填补另一整章的内容。这就是为什么我只在这里介绍它们的特定用例组。第七章 将有更多关于它们的示例和详细信息,以及如何创建自己的 Collectors。此外,您应该查看官方文档以获取更多详细信息,包括预期的用例和示例。

收集到 java.util.Collection 类型

最常用的变体,将 Stream 元素收集到新的 Collection 类型中包括:

  • toCollection(Supplier<C> collectionFactory)

  • toList()

  • toSet()

  • toUnmodifiableList()(Java 10+)

  • toUnmodifiableSet()(Java 10+)

原始的 toList() / toSet() 不保证返回集合的基础类型、可变性、可序列化性或线程安全性。这就是为什么 Java 10 中引入了 Unmodifiable 变体来弥补这一缺陷。

收集到 java.util.Map(键值)

另一个经常使用的 Collector 任务是通过映射键和值从 Stream 的元素创建 Map<K, V>。这就是为什么每个变体必须至少有一个键和值的映射函数:必须提供键和值的映射函数。

  • toMap(…​)(3 种变体)

  • toConcurrentMap(…​)(3 种变体)

  • toUnmodifiableMap(…​)(2 种变体,Java 10+)

与基于集合的 Collector 方法类似,原始的 toMap() 变体不保证返回的 Map 的基础类型、可变性、可序列化性或线程安全性。这就是为什么 Java 10 中引入了 Unmodifiable 变体来弥补这一缺陷。并发变体也可用于更高效地收集并行流。

收集到 java.util.Map(分组)

以下 Collector 不是简单的键值关系,而是按键对值进行分组,通常将基于集合的类型用作返回的 Map 的值:

  • groupingBy()(3 种变体)

  • groupingByConcurrent()(3 种变体)

收集到 java.util.Map(分区)

分区映射根据提供的Predicate对其元素进行分组。

  • partitionBy(…​)(2 种变体)

算术和比较操作

减少操作和收集器之间存在一定的重叠,如与算术和比较相关的收集器。

  • averagingInt(ToIntFunction<? super T> mapper)

  • summingInt(ToIntFunction<? super T> mapper)

  • summarizingInt(ToIntFunction<? super T> mapper)

  • counting()

  • minBy(Comparator<? super T> comparator)

  • maxBy(Comparator<? super T> comparator)

字符串操作

有三种变体将元素连接到单一的String中:

  • joining()(3 种变体)

高级用例

在更高级的用例中,如多级减少或复杂的分组/分区,需要多个收集步骤,借助“下游”收集器。

  • reducing(…​)(3 种变体)

  • collectingAndThen(Collector<T,A,R> downstream, Function<R,RR> finisher)

  • mapping(Function<? super T, ? extends U> mapper, Collector<? super U, A, R> downstream) (Java 9+)

  • filtering(Predicate<? super T> predicate, Collector<? super T, A, R> downstream) (Java 9+)

  • teeing(Collector<? super T, ?, R1> downstream1, Collector<? super T, ?, R2> downstream2, BiFunction<? super R1, ? super R2, R> merger)(Java 12+)

第七章 将详细介绍如何使用不同的收集器创建复杂的收集工作流程,包括下游收集。

减少与收集元素对比

终端操作reducecollect是同一个硬币的两面:都是减少——或折叠——操作。它们的区别在于重新组合结果的一般方法:不可变可变累积。这种差异导致非常不同的性能特性。

更抽象的不可变累积方法使用reduce操作,适用于子结果廉价创建的情况,比如像示例 6-13 中展示的数字求和。

示例 6-13. 使用流进行数字的不可变累积
var numbers = List.of(1, 2, 3, 4, 5, 6);

int total = numbers.stream()
                   .reduce(0, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                           Integer::sum); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

1

初始值——种子——用于每个并行减少操作。

2

方法引用转换为BiFunction<Integer, Integer, Integer>,用于累积前一个(或初始)值与当前流元素。

每个减少操作都建立在前一个操作的基础上,如图 6-19 中所示。

不可变数字累积

图 6-19. 不可变数字累积

这种方法并非适用于所有情况,特别是如果创建中间结果成本高的情况下。例如,String 类型。在 第四章 中,您已经了解到它的不可变性质以及为什么执行修改可能成本高昂。因此,通常建议使用优化的中间容器,如 StringBuilderStringBuffer,以减少所需的处理能力。

使用不可变归约连接 String 对象列表需要为每一步创建一个新的 String,导致运行时为 O ( n 2 ),其中 n 是字符数。让我们比较在 示例 6-14 中的不可变可变 String 连接。

示例 6-14. 使用 reduce 和 collect 连接 String 元素
var strings = List.of("a", "b", "c", "d", "e");

// STREAM REDUCE

var reduced = strings.stream()
                     .reduce("", ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                             String::concat); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

// STREAM COLLECT - CUSTOM

var joiner =strings.stream()
                   .collect(Collector.of(() -> new StringJoiner(""), ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                                         StringJoiner::add, ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
                                         StringJoiner::merge, ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
                                         StringJoiner::toString)); ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)

// STREAM COLLECT - PRE-DEFINED

var collectWithCollectors = strings.stream()
                                   .collect(Collectors.joining()); ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/7.png)

1

初始值是第一个 String 的创建。

2

每个归约步骤都会创建另一个新的 String,因此所需的处理能力和内存随元素数量而扩展。

3

第一个参数指定了一个供应商 Supplier<A> 用于可变容器。

4

第二个参数是归约 BiConsumer<A, T>,接受容器和当前元素。

5

第三个参数定义了在并行处理情况下如何合并多个容器的 BinaryOperator<A>

6

最后一个参数是 Function<A, R>,告诉 Collector 如何构建类型为 R 的最终结果。

7

java.util.stream.Collectors 实用类提供许多即用即得Collector,使流管道比内联创建 Collector 更合理。

Collector 需要比不可变归约更多的参数来完成其工作。尽管如此,这些额外的参数允许它使用可变容器,因此在首次减少流元素时采用了不同的方法。对于许多常见任务,比如连接字符串,在这种情况下,您可以使用 java.util.stream.Collectors 提供的预定义 Collector 之一。

选择哪种类型的归约 — 不可变还是可变 — 高度依赖于您的需求。我个人的经验法则很简单,源自实际方法的名称:如果结果是基于集合的类型,如 ListMap,则选择 collect;如果结果是累积的单个值,则选择 reduce。但不要忘记性能和内存考虑。

第七章详细介绍了收集器及其如何创建自定义收集器。

直接聚合元素

Collector类型是将元素收集到新数据结构中的强大且多功能的工具。但有时,简单的解决方案也足够了。Stream<T>类型提供了更多常用任务的终端聚合操作:

返回一个List<T>

Java 16 添加了终端操作toList()以简化创建新的List<T>的最常用聚合。它不使用基于收集器的工作流来聚合元素,从而减少了分配和内存需求。这使得在流大小预先知道且较简洁的情况下使用它成为最佳选择,而不是使用collect(Collectors.toList())。返回列表的实现类型或其可串行化性没有保证,与使用collect(Collectors.toList())一样。然而,返回的列表是不可修改的变体。

返回一个数组

将流的元素作为数组返回不需要缩减或收集器。您可以使用两个操作:

  • Object[] toArray()

  • A[] toArray(IntFunction<A[]> generator)

toArray的第二种变体允许您通过提供“数组生成器”来创建特定类型的数组,该生成器很可能是对构造函数的方法引用:

String[] fruits = Stream.of("apple", "orange", "banana", "peach")
                        ...
                        .toArray(String[]::new);

查找和匹配元素

除了将流元素聚合到新表示形式中外,查找特定元素是流的另一个常见任务。有多个终端操作可用于查找元素或确定其是否存在:

Optional<T> findFirst()

返回流的第一个遇到的元素。如果流是无序的,则可能返回任意元素。空流返回一个空的Optional<T>

Optional<T> findAny()

以非确定性方式返回流的任何元素。如果流本身为空,则返回一个空的Optional<T>

如您所见,这两个方法都没有参数,因此可能需要先进行filter操作以获取所需的元素。

如果不需要元素本身,则应使用其中一个匹配操作,该操作将元素与Predicate<T>匹配:

boolean anyMatch(Predicate<? super T> predicate)

返回true如果流的任何元素与predicate匹配。

boolean allMatch(Predicate<? super T> predicate)

返回true如果流的所有元素与predicate匹配。

boolean noneMatch(Predicate<? super T> predicate)

返回true如果没有元素匹配给定的predicate

消耗元素

最后一组仅副作用的终端操作。forEach方法不返回值,而是仅接受一个Consumer<T>

void forEach(Consumer<? super T> action)

对每个元素执行action。执行顺序是显式的不确定性,以最大化性能,特别是对于并行流。

void forEachOrdered(Consumer<? super T> action)

如果流是ORDERED,则按照遇到的顺序对每个元素执行action

从功能角度来看,这些操作似乎不合适。然而,作为试图将命令式代码转变为更功能化方向的开发人员,它们可能非常有用。

局部副作用本质上并不有害。并非所有代码都容易重构以防止它们,即使完全可以重构。就像所有其他操作一样,所包含逻辑的简洁性决定了流水线的直观性和可读性。如果需要的不仅仅是方法引用或简单的非阻塞 lambda,将逻辑提取/重构到一个新方法中并调用它,始终保持流水线的简洁性和可读性是一个好主意。

操作的成本

流的美妙之处在于它们能够将多个操作连接成一个单一的流水线,但你必须记住一件事:每个操作可能会被调用,直到向下游拒绝一个项目。

让我们看一下示例 6-15 中的简单流水线。

示例 6-15. 水果流水线(简单)
Stream.of("ananas", "oranges", "apple", "pear", "banana")
      .map(String::toUpperCase) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
      .sorted() ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      .filter(s -> s.startsWith("A")) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      .forEach(System.out::println); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

1

处理元素以期望的形式。

2

自然排序。

3

拒绝不需要的元素。

4

最后,处理剩余的元素。

在这个水果流水线示例中,你有三个中间操作和一个终端操作,用于处理五个元素。你猜这个简单代码执行了多少次操作调用?让我们来数数吧!

流水线调用map五次,sorted八次,filter五次,最后调用forEach两次。这就是进行20次操作来输出个值!尽管流水线执行了它应该执行的操作,但这太荒谬了!让我们重新排列操作,显著减少总调用,就像在示例 6-16 中看到的那样。

示例 6-16. 水果流水线(优化)
Stream.of("ananas", "oranges", "apple", "pear", "banana")
      .filter(s -> s.startsWith("a")) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
      .map(String::toUpperCase) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      .sorted() ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      .forEach(System.out::println); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

1

首先拒绝不需要的元素。

2

将元素转换为期望的形式。

3

自然排序。

4

最后,处理剩余的元素。

通过首先过滤,将map操作的调用和有状态的sorted操作的工作减少到最低限度:filter调用五次,map两次,sorted一次,forEach两次,总共节省了50%的操作而不改变结果。

请记住,Stream 元素直到达到终端操作才会被推送到 Stream 管道及其操作中。相反,终端操作通过管道拉取元素。流经管道的元素越少,性能就越好。这就是为什么一些操作被认为是短路性质的原因,意味着它们可以截断流。本质上,列在表 6-4 中的短路流操作是可能在不需要遍历所有元素的情况下执行其预期目的的操作。

表 6-4. 短路流操作

中间操作 终端操作
limit takeWhile findAny findFirst anyMatch allMatch noneMatch

这种行为允许它们甚至处理无限流,并且仍可能产生有限流(中间操作)或在有限时间内完成任务(终端操作)。

使用高度优化的非短路操作是终端count()操作。如果通过count()终止的 Stream 的整体元素计数可以从 Stream 本身派生,那么任何不影响计数的先前操作都可能被删除,就像下面的代码所示:

var result = Stream.of("apple", "orange", "banana", "melon")
                   .peek(str -> System.out.println("peek 1: " + str))
                   .map(str -> {
                     System.out.println("map: " + str);
                     return str.toUpperCase();
                   })
                   .peek(str -> System.out.println("peek 2: " + str))
                   .count();
// NO OUTPUT

即使管道中有三个带有System.out.println调用的操作,它们都被删除了。这种行为背后的推理很简单:mappeek操作不会在 Stream 管道中注入或移除任何元素,因此它们不会以任何方式影响最终计数,因此实际上它们并不是必需的。

如果流认为可能,它就会自行决定是否删除操作。例如,如果将filter操作添加到管道中,则前面的代码将运行所有操作,如下所示:

var result = Stream.of("apple", "orange", "banana", "melon")
                   .filter(str -> str.contains("e"))
                   .peek(str -> System.out.println("peek 1: " + str))
                   .map(str -> {
                     System.out.println("map: " + str);
                     return str.toUpperCase();
                   })
                   .peek(str -> System.out.println("peek 2: " + str))
                   .count();
// OUTPUT
// peek 1: apple
// map: apple
// peek 2: APPLE
// peek 1: orange
// map: orange
// peek 2: ORANGE
// peek 1: melon
// map: melon
// peek 2: MELON

这并不意味着每种类型的流管道都会删除可能不必要的操作。如果您的流管道需要“副作用”,则应使用两种forEach终端操作变体之一,这两种变体都旨在作为“仅副作用”的操作。

修改流行为

如“Spliterator, the Backbone of Streams”所述,流的特性在创建时最初设置。然而,并非每个流操作都适合每种特性。特别是在并行流中,元素的遇到顺序可能会显著影响性能。例如,使用filter操作选择元素是一项容易并行化的任务,但如果并行运行takeWhile,则需要在任务之间同步。这就是为什么可以通过表 6-5 中列出的中间操作切换特定流特性,这些操作返回具有改变特征的等效流。

表 6-5. 修改流行为

操作 描述
parallel() 启用并行处理。如果流已经是并行的,则可能返回this
sequential() 启用顺序处理。如果流已经是顺序的,则可能返回this
unordered() 返回遇到顺序无序的流。如果流已经是无序的,则可能返回this
onClose(Runnable closeHandler) 在流完成后添加额外的关闭处理程序。

切换流行为只需调用一个方法。然而,并不意味着这总是一个好主意。事实上,如果管道和底层流不是设计为首先并行运行的话,切换到并行处理通常是一个坏主意。

请参阅第八章以了解如何就使用流管道进行并行处理做出明智决策。

使用流,还是不使用?

流是使数据处理更具表现力并利用 Java 中许多函数特性的极佳方式。你可能会强烈倾向于(过度)使用流来处理各种数据。我一开始确实有过度使用的情况。但你必须记住,并不是每个数据处理管道都能同等受益于成为流。

您是否决定使用流——或者不使用——应始终是基于以下相互交织的因素做出的知情决策:

所需任务的复杂程度有多高?

几行代码的简单循环不会因为成为一个或两个小操作的流而受益太多。这取决于将整个任务和所需逻辑轻松适应到一个心理模型中的容易程度。

如果我能轻松掌握正在发生的事情,一个简单的for-each循环可能是更好的选择。另一方面,将多页长循环压缩成具有良好定义操作的更易访问的流管道,将提高其可读性和可维护性。

流管道的功能有多强大?

流管道只是要填充您的逻辑的脚手架。如果逻辑不适合功能性方法,比如带有副作用的代码,您将无法获得流可以提供的所有好处和安全保证。

重构或重新设计代码以使其更加函数化、纯净或不可变总是一个好主意,并使其更适配于流 API。然而,如果没有真正理解问题的需求,强行将代码适配到流管道中则是在没有必要的情况下做出解决方案的决定。适当调整代码以支持有利于生产力、合理性和可维护性的新功能是有益的。

然而,长远来看,应该是对代码和项目做出明智的决定,而不仅仅是使用某个功能的“要求”。

处理了多少个元素?

创建支持流管道的脚手架的开销随着处理元素的数量减少而减少。对于小数据源来说,所需实例、方法调用、堆栈帧和内存消耗之间的关系并不像处理更大数量元素那样微不足道。

在原始性能的直接比较中,“完全优化的”for循环胜过顺序流的一个简单原因。传统的 Java 循环结构是在语言级别实现的,这为 JVM 提供了更多的优化可能性,特别是对于小循环。另一方面,流是作为普通的 Java 类型实现的,这会产生不可避免的运行时开销。不过,这并不意味着它们的执行不会被优化!正如您在本章中学到的那样,流管道可以短路或合并操作以最大化管道吞吐量。

在孤立地考虑这些因素时,不应该影响您是否使用流的决定,只能是一起考虑。特别是许多开发人员最常见的担忧——性能——很少是设计代码和选择正确工具的最重要标准。

您的代码始终可以更高效。在测量和验证实际性能之前,因性能焦虑而排斥某个工具可能会使您失去解决实际问题的更好方案。

托尼·霍尔爵士⁴曾说过:“我们应该忘记小的效率,大约有 97%的时间:过早优化是一切邪恶的根源。”

在决定是否使用流或循环时,可以应用这些建议。大多数情况下——大约 97%的时间——您不需要关注原始性能,流可能是最简单和直接的解决方案,同时也能享受流 API 提供的所有好处。偶尔——那个 3%的时间——您需要关注原始性能以实现您的目标,而流可能不是最好的解决方案。尽管在第八章中,您将学习如何通过利用并行流来提高处理性能。

在决定是否使用流时,你可能会考虑你是否愿意使用新的和陌生的东西。当你第一次学习编程时,我敢打赌,所有你现在非常熟悉的循环结构似乎都很复杂。一开始似乎一切都很难,直到随着时间和重复使用,你变得熟悉和更舒适地使用那些循环结构。对于使用流也是如此。学习流 API 的各个方面会花费一些时间,但当何时以及如何高效地使用流来创建简洁和直接的数据处理管道时,这将变得更容易和更明显。

另一个需要记住的是,流的主要目标并非达到最佳的原始性能或替代所有其他循环结构。流被设计为处理数据的更声明性和表达性方式。它们为你提供了经典的映射-过滤-归约模式,支持 Java 强类型系统,同时还考虑了 Java 8 中引入的强大的函数技术。设计一个功能性的流管道是将功能代码应用于对象序列的最直接和简洁的方式。

最后,将纯函数与不可变数据结合的一般想法导致数据结构和其数据处理逻辑之间的松散耦合。每个操作只需要知道如何处理当前形式中的单个元素。这种解耦使得更小的领域特定操作可以更容易地重用和维护,并在必要时组合成更大、更复杂的任务。

主要观点

  • 流 API 提供了一种流畅且声明性的方式来创建类似于map/filter/reduce的数据处理管道,无需外部迭代。

  • 可连接的高阶函数是流管道的构建块。

  • 流使用内部迭代,这将更多的控制权委托给数据源本身来处理遍历过程。

  • 除了经典的map/filter/reduce操作之外,还提供了许多常见和专业化的操作。

  • 流是惰性的;直到调用终端操作之前都不会执行任何工作。

  • 顺序处理是默认的,但切换到并行处理很容易。

  • 并行处理可能不是所有数据处理问题的最佳方法,并且通常需要验证以更高效地解决问题。

¹ Oracle 的 Java 语言架构师 Brian Goetz 解释了在 StackOverflow 上的操作融合和有状态中间操作。

² Newland, Chris 和 Ben Evans. 2019. “循环展开:一种用于减少循环迭代次数的复杂机制可以提高性能,但可能会因无意编码而受阻。” Java magazine.

³ 虽然自 Java 8 发布以来 JavaDoc 中使用了几种新的注释,但截至本书撰写时它们还不是官方标准。非正式提案可在官方 OpenJDK 错误跟踪器上查看,编号为JDK-8068562

⁴ 英国计算机科学家查尔斯·安东尼·理查德·霍尔(Sir Charles Antony Richard Hoare)是图灵奖的获得者,这是计算机科学领域的最高荣誉,他在编程语言、算法、操作系统、形式验证和并发计算方面做出了基础性贡献。

第七章:使用流处理数据

流利用了 Java 8 引入的许多函数特性,提供了一种声明性的方式来处理数据。流 API 涵盖了许多用例,但你需要了解不同的操作和可用的辅助类,以充分利用它们。

第六章 着重介绍了流的基础知识。本章将在此基础上构建,并教你创建和处理流以解决各种用例。

原始流

在 Java 中,泛型仅适用于基于对象的类型(参见¹)。这就是为什么Stream<T>不能用于像int这样的原始值序列的原因。使用原始类型与流的唯一两种选项是:

  • 自动装箱

  • 专门的流变体

Java 的自动装箱支持 — 自动将原始类型与像intInteger之类的基于对象的对应物转换 — 可能看起来像是一个简单的解决方案,因为它自动地工作,如下所示:

Stream<Long> longStream = Stream.of(5L, 23L, 42L);

尽管自动装箱引入了多个问题。例如,与直接使用原始类型相比,从原始值到对象的转换会导致开销。通常情况下,这种开销可以忽略不计。然而,在数据处理管道中,频繁创建包装类型的开销会累积,并可能降低整体性能。

原始包装类型的另一个非问题是可能存在null元素的情况。从原始类型直接转换为对象类型永远不会产生null,但是如果在流水线操作中需要处理包装类型而不是原始类型,则任何操作可能返回null

为了减少这种情况,与 JDK 的其他函数特性一样,Stream API 为intlongdouble等原始类型提供了专门的变体,而不依赖于自动装箱,如表 7-1 中所列。

表 7-1. 原始流及其等价物

原始类型 原始流 装箱流
int IntStream Stream<Integer>
long LongStream Stream<Long>
double DoubleStream Stream<Double>

原始流上的可用操作与它们的通用对应物相似,但使用原始功能接口。例如,IntStream提供了一个map操作来转换元素,就像Stream<T>一样。不过,不同于Stream<T>,进行此操作所需的高阶函数是专门的变体IntUnaryOperator,它接受并返回int,如下简化的接口声明所示:

@FunctionalInterface
public interface IntUnaryOperator {

    int applyAsInt(int operand);

    // ...
}

在原始流上接受高阶函数的操作使用专门的功能接口,如IntConsumerIntPredicate,以保持在原始流的限制范围内。与Stream<T>相比,这减少了可用操作的数量。不过,你可以通过将其映射到另一种类型或将原始流转换为其装箱变体来轻松地在原始流和Stream<T>之间切换:

  • Stream<Integer> boxed()

  • Stream<U> mapToObj(IntFunction<? extends U> mapper)

反过来,从Stream<T>到原始流也是支持的,Stream<T>上可用mapTo...flatMapTo...操作:

  • IntStream mapToInt(ToIntFunction<? super T> mapper)

  • IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper)

除了常规的中间操作外,原始流还具有一组直观的算术终端操作用于常见任务:

  • int sum()

  • OptionalInt min()

  • OptionalInt max()

  • OptionalDouble average()

这些操作不需要任何参数,因为它们的行为对于数字是不可协商的。返回的类型是你从类似Stream<T>操作中期望的原始等价类型。

与一般的原始流一样,使用流进行算术运算有其用例,比如高度优化的并行处理大量数据。不过,对于更简单的用例,与现有的处理结构相比,切换到原始流通常不值得。

迭代流

流管道及其内部迭代通常处理现有元素序列或可轻松转换为元素序列的数据结构。与传统的循环结构相比,你必须放弃控制迭代过程,让流接管。然而,如果你需要更多控制,流 API 仍提供了在Stream<T>类型上可用的static iterate方法:

  • <T> Stream<T> iterate(T seed, UnaryOperator<T> f)

  • IntStream iterate(int seed, IntUnaryOperator f)

Java 9 添加了两种额外的方法,包括一个Predicate变体来设定结束条件:

  • <T> Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next)

  • IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)

对于它们对应的流变体,intlongdouble都有原始的iterate变体可用。

流的迭代方法产生通过将UnaryOperator应用于种子值而成为有序且可能无限的元素序列。换句话说,流元素将是[seed, f(seed), f(f(seed)), ...]等等。

如果这一般概念感觉熟悉,你是对的!它是for循环的流等价物:

// FOR-LOOP
for (int idx = 1; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
     idx < 5; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
     idx++) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  System.out.println(idx);
}

// EQUIVALENT STREAM (Java 8)
IntStream.iterate(1, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                  idx -> idx + 1) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
         .limit(4) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
         .forEachOrdered(System.out::println);

// EQUIVALENT STREAM (Java 9+)
IntStream.iterate(1, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                  idx -> idx < 5, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                  idx -> idx + 1) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
         .forEachOrdered(System.out::println);

1

种子,或初始迭代值。

2

终止条件。

3

迭代值的增加。for循环需要赋值,而流需要返回值。

循环和流的变体为循环体/后续流操作产生相同的元素。Java 9 引入了带有限制Predicateiterate变体,因此不需要额外的操作来限制总体元素。

迭代流相比for循环的最大优势在于,您仍然可以使用类似循环的迭代,但获得惰性函数流管道的好处。

不必在流创建时定义结束条件。相反,稍后的中间流操作,如limit,或终端条件,如anyMatch,可以提供它。

迭代流的特性为ORDEREDIMMUTABLE,对于原始流而言还包括NONNULL。如果迭代基于数字且范围已知,可以通过IntStreamLongStream上的静态range…​方法获得更多流的优化,如短路。

  • IntStream range(int startInclusive, int endExclusive)

  • IntStream rangeClosed(int startInclusive, int endInclusive)

  • LongStream range(long startInclusive, +long endExclusive)

  • LongStream rangeClosed(long startInclusive, long endInclusive)

即使可以通过iterate获得相同的结果,主要区别在于底层的Spliterator。返回的流的特性为ORDEREDSIZEDSUBSIZEDIMMUTABLENONNULLDISTINCTSORTED

选择迭代或范围流的创建取决于您的需求。迭代方法为迭代过程提供更多自由度,但失去了流特性,尤其是在并行流中提供最优化可能性。

无限流

流的惰性特性允许元素作为它们被按需处理,而不是一次性处理。

JDK 中所有可用的流接口 — Stream<T>及其原始衍生物IntStreamLongStreamDoubleStream — 都具有用于基于迭代方法或无序生成方法创建无限流的静态便捷方法。

虽然前面的iterate方法从一个种子开始,并依赖于在当前迭代值上应用它们的UnaryOperator,但static generate方法只依赖于Supplier生成它们的下一个流元素:

  • <T> Stream<T> generate(Supplier<T> s)

  • IntStream generate(IntSupplier s)

  • LongStream generate(LongSupplier s)

  • DoubleStream generate(DoubleSupplier s)

缺少起始种子值会影响流的特性,使其变为UNORDERED,这对并行使用很有益。通过Supplier创建的无序流对于常量、非相互依赖的元素序列(例如随机值)非常有帮助。例如,创建一个UUID流工厂非常简单:

Stream<UUID> createStream(int count) {
  return Stream.generate(UUID::randomUUID)
               .limit(count);
}

无序流的缺点在于,它们不能保证limit操作会在并行环境中选择前n个元素。这可能导致对元素生成Supplier的调用次数比实际结果流所需的次数多。

参考以下示例:

Stream.generate(new AtomicInteger()::incrementAndGet)
      .parallel()
      .limit(1_000)
      .mapToInt(Integer::valueOf)
      .max()
      .ifPresent(System.out::println);

流管道的预期输出是1000。但输出很可能大于1000

这种行为在并行执行环境中的无序流中是预期的。在大多数情况下,这并不重要,但它突显了选择具有良好特性的正确流类型以获得最大性能和尽可能少的调用的必要性。

随机数

流 API 对生成无限随机数流有特殊考虑。虽然可以使用Stream.generate来创建这样的流,例如使用Random#next(),但也有更简单的方法可用。

有三种不同的随机数生成类型能够创建流:

  • java.util.Random

  • java.util.concurrent.ThreadLocalRandom

  • java.util.SplittableRandom

所有这三种类型都提供了多种方法来创建随机元素的流:

IntStream ints()
IntStream ints(long streamSize)

IntStream ints(int randomNumberOrigin,
               int randomNumberBound)

IntStream ints(long streamSize,
               int randomNumberOrigin,
               int randomNumberBound)

LongStream longs()

LongStream longs(long streamSize)

LongStream longs(long randomNumberOrigin,
                 long randomNumberBound)

LongStream longs(long streamSize,
                 long randomNumberOrigin,
                 long randomNumberBound)

DoubleStream doubles()

DoubleStream doubles(long streamSize)

DoubleStream doubles(double randomNumberOrigin,
                     double randomNumberBound)

DoubleStream doubles(long streamSize,
                     double randomNumberOrigin,
                     double randomNumberBound)

技术上,这些流只是有效地无限,如它们文档中所述²。如果未提供streamSize,结果流将包含Long.MAX_VALUE个元素。上限和下限由randomNumberOrigin(包括)和randomNumberBound(不包括)设置。

将在 “示例:随机数” 中讨论一般使用和性能特征。

记忆并非无限

当使用无限流时最重要的一点是,你的内存是有限的。限制无限流不仅重要,而且是绝对必要的!忘记放置限制性的中间或终端操作将不可避免地使用完 JVM 可用的所有内存,并最终抛出OutOfMemoryError

列出了用于限制任何流的可用操作,见表 7-2。

表 7-2. 流限制操作

操作类型 操作 描述
中间操作 limit(long maxSize) 将流限制为maxSize个元素
takeWhile(Predicate<T> predicate) 取元素直到predicate评估为false(Java 9+)
终端操作(保证) Optional<T> findFirst() 返回流的第一个元素
Optional<T> findAny() 返回单个、非确定性的流元素
终端操作(不保证) boolean anyMatch(Predicate<T> predicate) 返回是否有任何流元素与predicate匹配
boolean allMatch(Predicate<T> predicate) 返回是否所有流元素与predicate匹配
boolean noneMatch(Predicate<T> predicate) 返回是否没有流元素与predicate匹配

最简单的选择是limit。像takeWhile这样使用Predicate<T>的选择操作必须谨慎制定,否则可能仍会导致流消耗比需要更多的内存。对于终端操作,只有find…​操作能保证终止流。

…​Match操作与takeWhile存在相同的问题。如果谓词不符合它们的目的,流管道将处理无限数量的元素,并因此消耗所有可用内存。

如“操作的成本”中讨论的,流中限制操作的位置也会影响通过的元素数量。即使最终结果可能相同,尽早限制流元素的流动将节省更多内存和 CPU 周期。

从数组到流,再从流到数组

数组是一种特殊类型的对象。它们是一种类似集合的结构,保存其基本类型的元素,并且仅提供一种通过索引访问特定元素以及数组的总长度的方法,除了从java.lang.Object继承的通常方法。它们也是未来直到Valhalla 项目变得可用之前,拥有原始类型集合的唯一方式³。

然而,数组具有两个特征使其非常适合基于流的处理。首先,它们在创建时就确定了长度并且不会改变。其次,它们是有序序列。这就是为什么java.util.Arrays上有多个便利方法来为不同的基本类型创建适当流的原因。使用适当的终端操作可以从流创建数组。

对象类型数组

创建典型的Stream<T>java.util.Arrays上的两个static便利方法支持:

  • <T> Stream<T> stream(T[] array)

  • <T> Stream<T> stream(T[] array, int startInclusive, int endExclusive)

如你所见,从数组创建Stream<T>非常简单明了。

另一种方式,从Stream<T>T[]是通过使用以下两种终端操作之一来完成的:

  • Object[] toArray()

  • <A> A[] toArray(IntFunction<A[]> generator)

第一种变体无论流的实际元素类型如何,都只能返回一个Object[]数组,这是由 JVM 创建数组的方式决定的。如果需要流元素类型的数组,必须为流提供一种创建适当数组的方法,这就是第二种变体的作用所在。

第二种变体需要一个IntFunction来创建指定大小的数组。最简单的方法是使用方法引用:

String[] fruits = new String[] {
    "Banana",
    "Melon",
    "Orange"
};

String[] result = Arrays.stream(fruits)
                        .filter(fruit -> fruit.contains("a"))
                        .toArray(String[]::new);
警告

toArray中使用创建的数组时没有静态类型检查。类型在元素存储在分配的数组中时在运行时检查,如果类型不兼容则抛出ArrayStoreException

原始数组

三种原始流专门化类型IntStreamLongStreamDoubleStream都有专用的static方法Arrays.stream变体:

  • IntStream stream(int[] array)

  • IntStream stream(int[] array, int startInclusive, int endExclusive)

LongStreamDoubleStream变体只在array类型和返回的原始流中有所不同。

因为原始流中的元素类型是固定的,它们只有一个不需要IntFunctiontoArray方法:

int[] fibonacci = new int[] {
    0, 1, 1, 2, 3, 5, 8, 13, 21, 34
};

int[] evenNumbers = Arrays.stream(fibonacci)
                          .filter(value -> value % 2 == 0)
                          .toArray();

低级流创建

到目前为止,我讨论的所有流创建方法都非常高级,从另一个数据源、迭代、生成或任意对象创建流。它们直接在各自的类型上可用,尽可能少地需要参数。辅助类型java.util.stream.StreamSupport还有几个低级static方便方法可用于直接从 Spliterator 创建流。这样,你可以为自己的自定义数据结构创建流表示。

以下两种方法接受Spliterator以创建新的流:

Stream<T> stream(Spliterator<T> spliterator, boolean parallel)

从任何可以由Spliterator<T>表示的源创建顺序或并行流的最简单方式。

Stream<T> stream(Supplier<? extends Spliterator<T>> supplier, int characteristics, boolean parallel)

不要立即使用 Spliterator,而是在 Stream 管道的终端操作调用之后调用 Supplier。这样可以将对源数据结构的任何可能干扰传递到更小的时间范围,使得对非IMMUTABLE或非CONCURRENT急切绑定流更安全。

强烈建议用于创建Stream<T>的 Spliterators 要么是IMMUTABLE,要么是CONCURRENT,以最小化可能的干扰或对底层数据源的更改。

另一个好的选择是使用延迟绑定 Spliterator,意味着元素在 Spliterator 创建时不是固定的。相反,它们在首次使用时绑定,即在调用终端操作后 Stream 管道开始处理其元素时。

注意

原始 Spliterator 变体也存在低级流创建方法。

如果你没有Spliterator<T>而是一个Iterator<T>,那么 JDK 已经为你准备好了。类型java.util.Spliterators有多个方便的方法用于创建 Spliterators,其中两种方法专门用于Iterator<T>

Spliterator<T> spliterator(Iterator<? extends T> iterator,
                                                 long size,
                                                 int characteristics)

Spliterator<T> spliteratorUnknownSize(Iterator<? extends T> iterator,
                                      int characteristics)

您可以使用先前讨论的 Stream<T> stream(Spliterator<T> spliterator, boolean parallel) 方法中创建的创建的 Spliterator<T> 实例来最终创建一个 Stream<T>

处理文件 I/O

流不仅用于基于集合的遍历。它们还提供了一个与 java.nio.file.Files 类一起遍历文件系统的绝佳方法。

本节将讨论文件 I/O 和 Streams 的几个用例。与其他 Streams 不同,I/O 相关的 Streams 必须在使用完毕后通过调用 Stream#close() 显式关闭。 Stream<T> 符合 java.lang.AutoCloseable 接口,因此示例将使用 try-with-resources 块进行处理,这将在 “文件 I/O 流的注意事项” 中详细解释。

本节中的所有示例都使用书籍的 代码存储库 中的文件作为它们的来源。以下文件系统树表示示例中使用的文件的整体结构:

├── README.md
├── assets
│   └── a-functional-approach-to-java.png
├── part-1
│   ├── 01-an-introduction-to-functional-programming
│   │   └── README.md
│   ├── 02-functional-java
│   │   ├── README.md
│   │   ├── java
|   |   └─ ...
└── part-2
    ├── 04-immutability
    │   ├── ...
    │   └── jshell
    │       ├── immutable-copy.java
    │       ├── immutable-math.java
    │       ├── unmodifiable-list-exception.java
    │       └── unmodifiable-list-modify-original.java
    ├─ ...

读取目录内容

通过调用方法 Files.list 来列出目录内容,以创建所提供 Path 的惰性填充的 Stream<Path>

static Stream<Path> list(Path dir) throws IOException

其参数必须是目录,否则将抛出 NotDirectoryException。示例 7-1 展示了如何列出一个目录。

示例 7-1. 列出一个目录
var dir = Paths.get("./part-2/04-immutability/jshell");

try (var stream = Files.list(dir)) {
  stream.map(Path::getFileName)
        .forEach(System.out::println);
} catch (IOException e) {
  // ...
}

输出列出了章节 4 的目录 jshell 中的文件:

unmodifiable-list-exception.java
unmodifiable-list-modify-original.java
immutable-copy.java
immutable-math.java

检索的内容顺序不能保证,我将在 “文件 I/O 流的注意事项” 中详细介绍。

深度优先目录遍历

两个 walk 方法如其名称所示,从特定起始点“遍历”整个文件树。惰性填充的 Stream<Path> 遵循深度优先,这意味着如果一个元素是目录,则会首先进入并遍历该目录,然后再遍历当前目录中的下一个元素。

java.nio.file.Files 中两个 walk 变体的区别在于它们将遍历的最大目录深度:

static Stream<Path> walk(Path start, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                         int maxDepth, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                         FileVisitOption... options) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                         throws IOException

static Stream<Path> walk(Path start, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                         FileVisitOption... options) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                         throws IOException

1

遍历的起始点。

2

要遍历的最大目录级别。 0(零)将流限制为起始级别。第二个没有 maxDepth 的变体没有深度限制。

3

零个或多个选项用于遍历文件系统。到目前为止,只有 FOLLOW_LINKS 存在。请注意,通过跟随链接,可能会发生可能的循环遍历。如果 JDK 检测到这一点,它会抛出 FileSystemLoopException

可以按照 示例 7-2 所示的方式遍历文件系统。

示例 7-2. 遍历文件系统
var start = Paths.get("./part-1");

try (var stream = Files.walk(start)) {
  stream.map(Path::toFile)
        .filter(Predicate.not(File::isFile))
        .sorted()
        .forEach(System.out::println);
} catch (IOException e) {
  // ...
}

遍历生成以下输出:

./part-1
./part-1/01-an-introduction-to-functional-programming
./part-1/02-functional-java
./part-1/02-functional-java/java
./part-1/02-functional-java/jshell
./part-1/02-functional-java/other
./part-1/03-functional-jdk
./part-1/03-functional-jdk/java
./part-1/03-functional-jdk/jshell

流至少包含一个元素,即起始点。如果不可访问,则会抛出IOException。与list类似,流元素的遇见顺序不被保证,我将在“文件 I/O 流的注意事项”中详细讨论这一点。

搜索文件系统

尽管可以使用walk搜索特定的Path,但也可以使用find方法。它将一个BiPredicate与当前元素的BasicFileAttribute直接集成到流创建中,使流更专注于您任务的需求:

static Stream<Path> find(Path start, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                         int maxDepth, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                         BiPredicate<Path, BasicFileAttributes> matcher, ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                         FileVisitOption... options) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
                         throws IOException

1

搜索的起始点。

2

遍历的最大目录级别。0(零)限制到起始级别。与Files.walk不同,不存在无需maxDepth的方法变体。

3

包含在流中的Path的条件。

4

文件系统遍历的零个或多个选项。目前只有FOLLOW_LINKS存在。请注意,通过跟踪链接可能会发生可能的循环遍历。如果 JDK 检测到这一点,它会抛出FileSystemLoopException

使用它,可以实现示例 7-2 而无需将Path映射到File,如示例 7-3 所示。

示例 7-3. 查找文件
var start = Paths.get("./part-1");

BiPredicate<Path, BasicFileAttributes> matcher =
  (path, attr) -> attr.isDirectory();

try (var stream = Files.find(start,
                             Integer.MAX_VALUE,
                             matcher)) {

    stream.sorted()
          .forEach(System.out::println);
} catch (IOException e) {
  // ...
}

输出与使用walk相当,同样的假设——深度优先和非保证遇见顺序——也适用于find。真正的区别在于对当前元素的BasicFileAttributes的访问,这可能影响性能。如果需要根据文件属性进行过滤或匹配,使用find将节省从Path元素显式读取文件属性的操作,这可能会略微提高性能。然而,如果只需要Path元素而不需要访问其文件属性,则walk方法同样是一个很好的选择。

逐行读取文件

使用 Streams 轻松处理文件逐行读取的常见任务,该任务提供了lines方法。根据文件的Charset,有两种变体:

static Stream<String> lines(Path path, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                            Charset cs) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                            throws IOException

static Stream<String> lines(Path path) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                      throws IOException

1

指向要读取的文件的Path

2

文件的字符集。第二个变体默认为StandardCharsets.UTF_8

小贴士

尽管你可以使用任何想要的Charset,但在并行处理中会有性能差异。lines方法经过优化,适用于UTF_8US_ASCIIISO_8859_1

让我们看一个简单的例子,统计托尔斯泰《战争与和平》中的单词数,如示例 7-4 所示。

示例 7-4. 统计《战争与和平》中的单词数
var location = Paths.get("war-and-peace.txt"); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

// CLEANUP PATTERNS ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
var punctuation = Pattern.compile("\\p{Punct}");
var whitespace  = Pattern.compile("\\s+");
var words       = Pattern.compile("\\w+");

try (Stream<String> stream = Files.lines(location)) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

  Map<String, Integer> wordCount =

           // CLEAN CONTENT ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    stream.map(punctuation::matcher)
          .map(matcher -> matcher.replaceAll(""))
          // SPLIT TO WORDS ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
          .map(whitespace::split)
          .flatMap(Arrays::stream)
          // ADDITIONAL CLEANUP ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
          .filter(word -> words.matcher(word).matches())
          // NORMALIZE ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/7.png)
          .map(String::toLowerCase)
          // COUNTING ![8](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/8.png)
          .collect(Collectors.toMap(Function.identity(),
                                    word -> 1,
                                    Integer::sum));
} catch (IOException e) {
  // ...
}

1

使用 Project Gutenberg 的《战争与和平》普通文本版本⁴,因此没有格式可能会影响单词计数。

2

正则表达式预编译以防止为每个元素重新编译。这种优化非常重要,因为为每个元素和map操作创建Pattern会迅速累积并影响整体性能。

3

lines调用返回文件的行作为元素的Stream<String>try-with-resources块是必需的,因为 I/O 操作必须显式关闭,您将在“文件 I/O 流的注意事项”中了解更多。

4

需要移除标点符号,否则直接跟在标点符号旁边的相同单词将被计为不同的单词。

5

现在清理后的行根据空白字符分割,从而创建了一个Stream<String[]>。为了实际计数单词,flatMap操作将流扁平化为Stream<String>

6

“word”匹配器是一个额外的清理和选择步骤,仅计数实际的单词。

7

将元素映射为小写确保不同大小写的单词被视为一个单词。

8

终端操作创建了一个Map<String, Integer>,以单词作为键,出现次数作为值。

流管道完成了既定任务,接管文件读取任务并逐行提供内容,使您可以集中精力处理代码的处理步骤。

我们将在第八章中重新讨论此特定示例,再次查看如何通过使用并行流显著改进这样一个常见任务。

文件 I/O 流的注意事项

使用流和文件 I/O 非常简单。然而,正如我之前提到的,有三个不寻常的方面。它们并不重要,不会减少使用基于流的文件 I/O 的可用性或有用性,尽管您需要注意它们:

  • 需要关闭流

  • 目录内容是弱一致的

  • 元素顺序不保证

这些方面源于一般的 I/O 处理,并且在大多数与 I/O 相关的代码中都能找到,不仅限于流管道。

明确关闭流

在 Java 中处理资源,如文件 I/O,通常需要在使用后关闭它们。未关闭的资源可能会泄漏,这意味着垃圾收集器在资源不再需要或使用后无法回收其内存。处理流的 I/O 也是如此。这就是为什么你需要显式关闭基于 I/O 的流,至少与非 I/O 流相比如此。

Stream<T>类型通过BaseStream扩展了java.io.AutoClosable,因此关闭它的最简单方法是使用try-with-resources块,正如在“使用文件 I/O”部分和下面的代码中所见:

try (Stream<String> stream = Files.lines(location)) {
  stream.map(...)
        ...
}

所有与java.nio.file.Files上的流相关的方法根据它们的签名都会抛出IOException,因此你需要以某种形式处理该异常。结合合适的try-with-resources块和适当的catch块可以一举解决这两个要求。

弱一致性的目录内容

java.nio.file.Files上的listwalkfind方法是弱一致性并且是惰性填充的。这意味着在流创建时并不会一次性扫描实际的目录内容以获得遍历期间的固定快照。在创建或遍历Stream<Path>后,文件系统的任何更新可能会或可能不会反映出来。

这种约束背后的推理很可能是出于性能和优化考虑。流管道应该是惰性顺序管道,没有区分它们的元素。文件树的固定快照将要求在流创建时收集所有可能的元素,而不是在由终端操作触发的实际流处理时进行惰性处理。

非保证元素顺序

流的惰性特性带来了文件 I/O 流的另一个方面,这可能是你不会预料到的。文件 I/O 流的遭遇顺序不能保证按照自然顺序(例如字母顺序)——因此,你可能需要额外的sorted中间操作来确保一致的元素顺序。这是因为流是由文件系统填充的,而文件系统不保证以有序的方式返回其文件和目录。

处理日期和时间

处理日期始终是具有许多边缘情况的挑战。幸运的是,Java 8 引入了一个日期和时间 API⁠⁵。其不可变性很好地适合于任何函数式代码,并且也提供了一些与流相关的方法。

查询时间类型

新的日期和时间 API 为任意属性提供了灵活和功能强大的查询接口。像大多数流操作一样,通过其参数将实际需要的逻辑注入到方法中,使方法本身成为具有更大通用性的更一般的支架:

<R> R query(TemporalQuery<R> query);

通用签名允许查询任何类型,使其非常灵活:

// TemporalQuery<Boolean> == Predicate<TemporalAccessor>

boolean isItTeaTime = LocalDateTime.now()
                                   .query(temporal -> {
                                     var time = LocalTime.from(temporal);
                                     return time.getHour() >= 16;
                                   });

// TemporalQuery<LocalTime> == Function<TemporalAccessor,Localtime>
LocalTime time = LocalDateTime.now().query(LocalTime::from);

实用类java.time.temporal.TemporalQueries提供了预定义查询,显示在 表 7-3 中,以消除自己创建常见查询的需求。

表 7-3. java.time.temporal.TemporalQueries 中预定义的 TemporalQuery<T>

static 方法 返回类型
chronology() Chronology
offset() ZoneOffset
localDate() LocalDate
localTime() LocalTime
precision() TemporalUnit
zoneId() ZoneId
zone() ZoneId

显然,并非所有的时间 API 类型都支持每一种查询类型。例如,你无法从Local…​类型中获取ZoneId/ZoneOffset。每种方法都有详细的文档说明⁶,说明了它们支持的类型和预期的使用情况。

LocalDate-Range Streams

Java 9 引入了 Stream 的能力,用于单个 JSR 310 类型 java.time.LocalDate,以创建 LocalDate 元素的连续范围。你不必担心不同日历系统的所有复杂性和边缘情况,以及日期计算实际上是如何执行的。日期和时间 API 将为你处理它们,通过提供一致且易于使用的抽象。

两个 LocalDate 实例方法创建了一个有序和连续的 Stream:

  • Stream<LocalDate> datesUntil(LocalDate endExclusive)

  • Stream<LocalDate> datesUntil(LocalDate endExclusive, Period step)

第一个变体相当于使用 Period.ofDays(1)。它们的实现不会溢出,这意味着任何元素加上 step 必须endExclusive 之前。日期的方向也不是仅限未来。如果 endExclusive 在过去,你必须提供一个负的 step 来创建一个朝过去的 Stream。

使用 JMH 测量 Stream 性能

在整本书中,我都提到了 Java 的函数式技术和工具,比如 Streams,与传统方法相比会产生一定的开销,你必须考虑到这一点。这就是为什么使用基准测试来衡量 Stream 流水线的性能至关重要的原因。Streams 不是一个容易测试的目标,因为它们是多个操作的复杂流水线,背后有许多优化,这些优化取决于它们的数据和操作。

JVM 及其即时编译器可能很难进行基准测试和确定实际性能。这就是Java 微基准测试工具包的用武之地。

JMH 负责 JVM 预热、迭代和代码优化,这可能会淡化结果,使其更可靠,因此更适合用作评估的基线。它是基准测试的事实标准,并在 JDK 版本 12 中被包含在内⁷。

可用于 IDE 和构建系统的插件,例如 GradleIntelliJJenkinsTeamCity

JMH GitHub 仓库的示例目录有很多文档完整的基准测试,解释了其使用的复杂性。

我不会进一步讨论如何在一般情况下对流或 lambda 进行基准测试,因为这超出了本章的范围,而且很容易占用整本书的空间。事实上,我建议你查看《Java 优化》(Optimizing Java),作者是本杰明·J·埃文斯(Benjamin J Evans)、詹姆斯·高夫(James Gough)、克里斯·纽兰德(Chris Newland)⁸,以及《Java 性能》(Java Performance),作者是斯科特·奥克斯(Scott Oaks)⁹,以了解更多关于基准测试和如何在 Java 中测量性能的信息。

更多关于收集器的信息

第六章介绍了收集器和相应的终端操作 collect,作为将流水线的元素聚合到新数据结构中的强大工具。实用类型 java.util.stream.Collectors 有大量的 static 工厂方法可以为几乎任何任务创建收集器,从简单的聚合到新的 Collection 类型,甚至更复杂的多步聚合流水线。这种更复杂的收集器是通过下游收集器的概念完成的。

收集器的一般思想很简单:将元素收集到一个新的数据结构中。如果你想要一个基于集合的类型,比如 List<T>Set<T>,这是一个非常直接的操作。然而,在 Map<K, V> 的情况下,通常需要复杂的逻辑来获取一个正确形成的数据结构,以满足你的目标。

将一系列元素收集到基于键值的数据结构(如 Map<K, V>)可以通过多种方式完成,每种方式都有其挑战。例如,即使是简单的键值映射,每个键只有一个值,也存在处理键冲突的问题。但是,如果你想进一步转换 Map 的值部分,比如分组、缩减或分区,你需要一种方法来操作已收集的值。这就是下游收集器发挥作用的地方。

下游收集器

java.util.stream.Collectors 工厂方法提供的一些预定义收集器可以接受一个额外的收集器来操作下游元素。基本上,这意味着在主要收集器完成其工作之后,下游收集器对收集的值进行进一步的更改。这几乎就像是在之前收集的元素上工作的二级流水线。

下游收集器的典型任务包括:

  • 转换

  • 缩减

  • 扁平化

  • 过滤

  • 组合收集器操作

本节的所有示例将使用以下 User 记录和 users 数据源:

record User(UUID id,
            String group,
            LocalDateTime lastLogin,
            List<String> logEntries) { }

List<User> users = ...;

转换元素

使用 Collectors.groupingBy 方法将流元素分组到简单的键值映射中非常容易。然而,键值映射的值部分可能不是您需要的形式,可能需要额外的转换。

例如,通过其 groupStream<User> 进行分组将创建一个 Map<String, List<User>>

Map<String, List<User>> lookup =
  users.stream()
       .collect(Collectors.groupingBy(User::group));

简单明了。

如果您不希望整个 User 而只是其 id 处于其中位置,该怎么办?您不能使用中间 map 操作在收集之前转换元素,因为您不再能真正访问 User 来实际分组它们。而是可以使用下游收集器来转换已收集的元素。这就是为什么有多个可用的 groupingBy 方法的原因,就像我们将在本节中使用的方法一样:

Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
                                      Collector<? super T, A, D> downstream)

尽管此方法签名中的不同通用类型可能看起来令人生畏,但不要担心!让我们分解签名以更好地理解发生的情况。

列出了涉及的四种类型在 Table 7-4。

表 7-4. groupingBy 的通用类型

通用类型 用于
T 收集之前流的元素类型。
K Map 结果的键类型。
D 由下游收集器创建的结果 Map 值部分的类型。
A 下游收集器的累加器类型。

如您所见,每种方法签名的类型表示整个过程的一部分。classifier 创建键,将类型为 T 的元素映射到键类型 K。下游收集器将类型为 T 的元素聚合到新的结果类型 D 中。因此,总体结果将是一个 Map<K, D>

提示

Java 的类型推断通常会为您匹配正确的类型,因此如果您只想使用这些复杂的通用方法而不是自己编写它们,您不必太在意实际的通用签名。如果发生类型不匹配并且编译器无法自动推导类型,请尝试使用 IDE 的帮助将操作逻辑重构为专用变量,以查看推断的类型。与一次性调整整个流水线相比,调整较小的代码块要容易得多。

本质上,每个接受额外下游收集器的收集器都包含原始逻辑 — 在本例中是键映射器 — 和下游收集器,影响映射到键的值。您可以将下游收集过程视为另一个被收集的流。不过,它只遇到主收集器关联的键的值。

让我们回到查找User组的查找映射。目标是创建一个Map<String, Set<UUID>>,将User组映射到一组不同的id实例。创建下游收集器的最佳方法是考虑实现目标所需的具体步骤,以及java.util.stream.Collectors的哪些工厂方法可以实现这些步骤。

首先,您需要User元素的id,这是一个映射操作。方法Collector<T, ?, R> mapping(Function<? super T, ? extends U> mapper, Collector<? super U, A, R> downstream)创建一个收集器,将收集的元素映射后传递给另一个收集器。需要另一个下游收集器的原因很简单;映射收集器的唯一目的是,您可能已经猜到的,映射元素。映射后的元素的实际收集超出了它的范围,因此委托给下游收集器。

其次,您希望将映射后的元素收集到一个Set中,可以通过Collectors.toSet()完成。

将收集器单独编写,可以使它们的意图和层次结构更加可见:

// COLLECT ELEMENTS TO SET
Collector<UUID, ?, Set<UUID>> collectToSet = Collectors.toSet();

// MAP FROM USER TO UUID
Collector<User, ?, Set<UUID>> mapToId =
  Collectors.mapping(User::id,
                     collectToSet);

// GROUPING BY GROUP
Collector<User, ?, Map<String, Set<UUID>>> groupingBy =
  Collectors.groupingBy(User::group, mapToId);

正如我之前所说,通常可以让编译器推断类型并直接使用Collectors工厂方法。如果静态导入该类,甚至可以省略重复的Collectors.前缀。将所有收集器组合并在流管道中使用会导致简单直接的收集管道:

import static java.util.stream.Collectors.*;

Map<String, Set<UUID>> lookup =
  users.stream()
       .collect(groupingBy(User::group,
                           mapping(User::id, toSet())));

结果类型也可以由编译器推断。尽管如此,我更喜欢显式声明,以便更好地传达由流管道返回的类型。

另一种方法是将主要的下游收集器保持为变量,以使collect调用更简单。这样做的缺点是,在使用 lambda 表达式而不是方法引用的情况下,必须帮助编译器推断出正确的类型。

var collectIdsToSet = Collectors.mapping(User::id, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                                         Collectors.toSet());

// LAMBDA ALTERNATIVE

var collectIdsToSetLambda = Collectors.mapping((User user) -> user.id(), ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                                               Collectors.toSet());

Map<String, Set<UUID>> lookup =
  users.stream()
       .collect(Collectors.groupingBy(User::group,
                                      collectIdsToSet)); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

1

方法引用告诉编译器流的元素类型,因此下游收集器也知道它。

2

mapper的 lambda 变体需要知道要处理的类型。您可以为 lambda 参数提供显式类型,也可以将var替换为更复杂的泛型Collector<T, A , R>签名。

3

由于变量名的存在,collect调用仍然很表达力。如果某些聚合操作经常使用,应考虑将它们重构为带有工厂方法的辅助类型,类似于java.util.stream.Collectors

减少元素

有时需要减少操作而不是聚合操作。设计减少的下游收集器的一般方法与前一节相同:定义您的总体目标,分解成必要的步骤,最后创建下游收集器。

对于这个示例,不要为groupid创建查找 Map,让我们统计每个UserlogEntries

总体目标是计算每个User元素的日志条目数。所需步骤是获取User的日志计数并将它们求和到最终的总数。

您可以使用Collectors.mapping工厂方法与另一个下游 Collector 来实现这个目标:

var summingUp = Collectors.reducing(0, Integer::sum);

var downstream =
  Collectors.mapping((User user) -> user.logEntries().size(),
                     summingUp);

Map<UUID, Integer> logCountPerUserId =
  users.stream()
       .collect(Collectors.groupingBy(User::id, downstream));

与要求映射和减少下游 Collector 齐头并进不同,您可以使用其他Collector.reduce变体之一,其中包括一个mapper

Collector<T, ?, U> reducing(U identity,
                            Function<? super T, ? extends U> mapper,
                            BinaryOperator<U> op)

这种reduce变体除了一个种子值(identity)和归约操作(op)外,还需要一个mapper来将User元素转换为所需的值:

var downstream =
  Collectors.reducing(0,                                       // identity
                      (User user) -> user.logEntries().size(), // mapper
                      Integer::sum);                           // op

Map<UUID, Integer> logCountPerUserId =
  users.stream()
       .collect(Collectors.groupingBy(User::id, downstream));

reduce中间操作一样,使用一个减少的 Collector 用于下游操作是一种非常灵活的工具,能够将多个步骤合并为单个操作。选择方法,是使用多个下游 Collectors 还是单一的归约,取决于个人偏好和收集过程的整体复杂性。然而,如果你只需要对数字求和,java.util.stream.Collectors类型还提供了更专门的变体:

var downstream =
  Collectors.summingInt((User user) -> user.logEntries().size());

Map<UUID, Integer> logCountPerUserId =
  users.stream()
       .collect(Collectors.groupingBy(User::id, downstream));

summing Collector 可用于通常的原始类型(intlongfloat)。除了对数字求和外,您还可以计算平均值(前缀为averaging)或仅计数元素使用Collectors.counting()

展开集合

在流中处理基于集合的元素通常需要一个flatMap中间操作将集合“展平”,以便将其返回到离散元素中以进一步处理管道,否则您将得到像List<List<String>>这样的嵌套集合。对流的收集过程也是如此。

通过它们的group将所有logEntries分组将导致一个Map<String, List<List<String>>>,这很可能不是你想要的。Java 9 添加了一个具有内置展平功能的新预定义 Collector:

static Collector<T, ?, R> flatMapping(Function<T, Stream<U>> mapper,
                                      Collector<U, A, R> downstream)

就像其他添加的 Collector,Collectors.filtering(…​),我在“过滤元素”中讨论过,如果作为唯一的 Collector 使用,与显式的flatMap中间操作没有任何优势。但是,用于多级减少,如groupingBypartitionBy,它让您可以访问原始流元素允许展平收集的元素:

var downstream =
  Collectors.flatMapping((User user) -> user.logEntries().stream(),
                         Collectors.toList());

Map<String, List<String>> result =
  users.stream()
       .collect(Collectors.groupingBy(User::group, downstream));

与转换和减少 Collectors 一样,当需要使用展平下游 Collector 时,您很快就会掌握它。如果流管道的结果类型不符合您的预期,您很可能需要一个下游 Collector 来解决问题,可以通过使用Collectors.mappingCollectors.flatMapping

过滤元素

过滤流元素是几乎任何流管道的重要部分,借助中间的filter操作完成。Java 9 增加了一个新的预定义收集器,具有内置的过滤能力,将元素过滤步骤直接移动到积累过程之前:

static <T, A, R> Collector<T,?,R> filtering(Predicate<T> predicate,
                                            Collector<T, A, R> downstream)

单独使用时,它与中间的filter操作没有区别。作为下游收集器,它的行为与filter大不相同,特别是在分组元素时很容易看出:

import static java.util.stream.Collectors.*;

var startOfDay = LocalDate.now().atStartOfDay();

Predicate<User> loggedInToday =
  Predicate.not(user -> user.lastLogin().isBefore(startOfDay));

// WITH INTERMEDIATE FILTER

Map<String, Set<UUID>> todaysLoginsByGroupWithFilterOp =
  users.stream()
       .filter(loggedInToday)
       .collect(groupingBy(User::group,
                           mapping(User::id, toSet())));

// WITH COLLECT FILTER

Map<String, Set<UUID>> todaysLoginsByGroupWithFilteringCollector =
  users.stream()
       .collect(groupingBy(User::group,
                           filtering(loggedInToday,
                                     mapping(User::id, toSet()))));

你可能期望有相同的结果,但操作顺序会导致不同的结果:

首先中间过滤,其次分组

使用中间的filter操作会在任何收集发生之前移除任何不需要的元素。因此,在生成的Map中不包括今天未登录的用户组,如图 7-1 所示。

使用“首先过滤,然后分组”来分组元素

图 7-1. 使用“首先过滤,然后分组”来分组元素

首先分组,然后向下过滤

如果没有中间的filter操作,groupingBy收集器将会遇到所有User元素,而不管它们的最后登录日期。下游收集器 — Collectors.filtering — 负责过滤元素,因此返回的Map仍包含所有用户组,而不考虑最后的登录情况。元素的流动如图 7-2 所示。

使用“首先分组,然后向下过滤”来分组元素

图 7-2. 使用“首先分组,然后向下过滤”来分组元素

哪种方法更可取取决于您的需求。首先进行过滤可以返回可能的最少键值对,但首先进行分组可以让您访问所有Map键及其(可能)为空的值。

组合收集器

我想讨论的最后一个收集器是Collectors.teeing,在 Java 12 中添加,与其他不同之处在于它一次接受两个下游收集器,并将两者的结果合并为一个。

注意

teeing 的名称来源于最常见的管道配件之一 — T 型管配件 — 其形状类似大写字母 T。

流的元素首先通过两个下游收集器,因此BiFunction可以将两个结果合并为第二步,如图 7-3 所示。

Teeing 收集器的元素流动

图 7-3. Teeing 收集器的元素流动

想象一下,您想知道有多少用户及其中多少从未登录。如果没有 teeing 操作,您将不得不遍历元素两次:一次用于总体计数,另一次用于计算从未登录的用户。这两个计数任务可以由专用的 Collectors countingfiltering 表示,因此您只需遍历元素一次,并让 teeing 在管道末端执行这两个计数任务。然后,使用 BiFunction<Long, Long> 将结果合并到新的数据结构 UserStats 中。示例 7-5 展示了如何实现它。

示例 7-5. 查找最小和最大登录日期
record UserStats(long total, long neverLoggedIn) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  // NO BODY
}

UserStats result =
  users.stream()
       .collect(Collectors.teeing(Collectors.counting(), ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                Collectors.filtering(user -> user.lastLogin() == null, ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                                     Collectors.counting()),
                UserStats::new)); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

1

由于 Java 缺乏动态元组,本地 Record 类型被用作结果类型。

2

第一个下游 Collector 用于计算所有元素的数量。

3

第二个下游 Collector 首先进行过滤,并使用附加的下游 Collector 计算剩余元素的数量。

4

UserStats 构造函数的方法引用用作两个下游 Collector 结果的合并函数。

与许多功能性添加类似,如果您主要来自面向对象的背景,teeing Collector 最初可能会显得有些奇怪。单独使用 for 循环以两个离散变量计数可以达到相同的结果。不同之处在于 teeing Collector 如何从 Stream 管道及其整体优势和功能可能性中受益,而不仅仅是终端操作本身。

创建您自己的 Collector

辅助类型 java.util.stream.Collectors 在撰写本书时的最新 LTS Java 版本 17 中提供了超过 44 个预定义的工厂方法。这些方法涵盖了大多数常见用例,特别是在联合使用时。有时您可能需要一个定制的、更具上下文特定性的 Collector,比预定义的更易于使用。这样一来,您还可以在类似 Collectors 的自定义辅助类中共享这些特定的 Collectors。

回顾第六章中提到,Collectors 借助四种方法聚合元素:

  • Supplier<A> supplier()

  • BiConsumer<A, T> accumulator()

  • BinaryOperator<A> combiner()

  • Function<A, R> finisher()

我之前未提及的 Collector 接口的一种方法是 Set<Characteristics> characteristics()。与 Streams 类似,Collectors 具有一组特性,允许不同的优化技术。当前提供的三个选项列在表 7-5 中。

表 7-5. 可用的 java.util.Collector.Characteristics

特征 描述
CONCURRENT 支持并行处理
IDENTITY_FINISH 完成器是恒等函数,返回累加器本身。在这种情况下,只需要进行类型转换,而不是调用完成器本身。
UNORDERED 表示流元素的顺序不一定会保留。

为了更好地理解这些部分是如何结合在一起的,我们将重现一个现有的收集器,Collectors.joining(CharSequence delimiter),它将 CharSequence 元素连接起来,以 delimiter 参数分隔。示例 7-6 显示了如何使用 java.util.StringJoiner 实现 Collector<T, A, R> 接口,以实现所需的功能。

示例 7-6。用于连接字符串元素的自定义 Collector
public class Joinector implements Collector<CharSequence, // T
                                            StringJoiner, // A
                                            String> {     // R

  private final CharSequence delimiter;

  public Joinector(CharSequence delimiter) {
    this.delimiter = delimiter;
  }

  @Override
  public Supplier<StringJoiner> supplier() {
    return () -> new StringJoiner(this.delimiter); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  }

  @Override
  public BiConsumer<StringJoiner, CharSequence> accumulator() {
    return StringJoiner::add; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  }

  @Override
  public BinaryOperator<StringJoiner> combiner() {
    return StringJoiner::merge; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }

  @Override
  public Function<StringJoiner, String> finisher() {
    return StringJoiner::toString; ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  }

  @Override
  public Set<Characteristics> characteristics() {
    return Collections.emptySet(); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
  }
}

1

StringJoiner 类型是完美的可变结果容器,因为它具有公共 API 和分隔符支持。

2

向容器中添加新元素的累加逻辑就像使用适当的方法引用一样简单。

3

通过方法引用也可以实现多个容器的组合逻辑。

4

最后一步,将结果容器转换为实际结果,是通过容器的 toString 方法完成的。

5

Joinector 不具有任何可用的 Collector 特性,因此返回一个空的 Set

足够简单,但这仍然是大量的代码,功能非常少,主要是返回方法引用。幸运的是,Collector 上有方便的工厂方法 of,可以简化代码:

Collector<CharSequence, StringJoiner, String> joinector =
  Collector.of(() -> new StringJoiner(delimiter), // supplier
               StringJoiner::add,                 // accumulator
               StringJoiner::merge,               // combiner
               StringJoiner::toString);           // finisher

这个简短版本相当于之前接口的完整实现。

注意

Collector.of(…​) 方法的最后一个参数并不总是可见,如果未设置,它是 Collector 特性的一组可变参数。

创建自己的收集器应保留用于自定义结果数据结构或简化特定领域任务。即便如此,你应该首先尝试使用可用的收集器和下游收集器的组合来实现结果。Java 团队投入了大量时间和知识,为你提供了安全且易于使用的通用解决方案,这些解决方案可以组合成相当复杂且强大的解决方案。然后,如果你有一个可用的收集器,你仍然可以将其重构为辅助类,使其可重用且易于阅读。

关于(顺序)流的最终想法

在我看来,Java Streams API 是一个绝对的游戏改变者,因此了解可用操作和使用流处理不同任务的方式非常重要。流提供了一种流畅、简洁且直接的数据处理方法,如果需要,还可以并行处理,正如你将在第八章中了解到的那样。然而,它们并不是设计来替代现有的循环结构,而是作为其补充。

作为 Java 开发者,你应该掌握的关于 Streams 最重要的技能是在使用足够的流管道来提高代码可读性和合理性之间找到平衡,同时不要通过忽略传统的循环结构来牺牲性能。

并非每个循环都需要成为流。然而,并非每个流都最好成为循环。当您习惯使用流进行数据处理时,您将更容易找到两种数据处理方法之间的健康平衡。

要点

  • Stream API 提供了多种可能性来创建流,从类似传统循环结构的迭代方法到特定类型的专门变体,如文件 I/O 或新的日期和时间 API。

  • 与功能接口类似,大多数流及其操作通过专门类型支持原始类型,以减少自动装箱的数量。这些专门的变体在需要时可以为您提供性能优势,但会限制可用的操作。但是,您可以在管道中在原始和非原始流之间随时切换,以获得两个世界的优势。

  • 下游收集器可以通过多种方式影响集合过程,例如转换或过滤,以将结果操作为任务所需的表示形式。

  • 如果一组下游收集器的组合无法满足您的任务需求,您可以退而自行创建收集器。

¹ 正如在“Project Valhalla 和专用泛型”中讨论的那样,Project Valhalla将允许像原始类型这样的值类型用作泛型类型边界。然而,截至本书撰写之时,尚不清楚其具体可用日期。

² 例如,Random#ints()的文档指出该方法被实现为Random.ints(Long.MAX_VALUE)的等效方法。

³ 有关Project Valhalla的更多信息,请参见侧边栏“Project Valhalla 和专用泛型”。

⁴ Project Gutenberg 提供多个免费版本的《战争与和平》

Java 日期与时间 API(JSR310) 的目标是用一套全面的类型替换 java.util.Date,以一种不可变的方式提供一致和完整的日期和时间处理方法。

官方文档 java.time.temporal.TemporalQueries 详细列出了每个预定义 TemporalQuery 支持的类型。

⁷ JMH 也支持 Java 12 之前的版本,但您需要手动包含其两个依赖项:JMH CoreJMH Generators/Annotation Processors

⁸ Evans, Benjamin J., Gough, James, Newland, Chris. 2018. “优化 Java。” O’Reilly Media. 978-1-492-02579-5

⁹ Oaks, Scott. 2020. “Java 性能,第二版。” O’Reilly Media. ISBN 978-1-492-05611-9.

第八章:使用流进行并行数据处理

我们的世界充满了并发和并行;我们几乎总是可以同时做更多事情。我们的程序需要解决越来越多的问题,这就是为什么数据处理通常也会从并行处理中受益的原因。

在第六章中,您已经了解了作为数据处理管道的流和函数操作。现在是并行处理的时候了!

在本章中,您将了解并发和并行性的重要性,以及何时以及如何使用并行流,以及何时不要使用。到目前为止,在前两章中学到的有关使用流进行数据处理的一切,也适用于使用它们进行并行处理。因此,本章将集中讨论并行流的差异和复杂性。

并发与并行

术语并行性并发性 经常被混淆,因为这些概念密切相关。罗布·派克,Go 语言的共同设计者之一,对这些术语进行了很好的定义:

并发是同时处理多件事情。并行是同时执行多件事情。显然,这些想法是相关的,但一个与结构紧密相关,另一个与执行相关。并发是以一种可能允许并行执行的方式组织事物。但并行不是并发的目标。并发的目标是良好的结构和实现并行执行等执行模式的可能性。

罗布·派克,在Waza 2012 年的“并发不等于并行”

并发性 是多个任务在重叠时间段内运行并竞争可用资源的一般概念。单个 CPU 核心通过调度和切换任务来交错它们。任务之间的切换相对容易和快速。这样,即使它们实际上不能,两个任务也可以象征性地在单个 CPU 核心上同时运行。可以将其想象成一个只用一只手(单个 CPU 核心)抛接多个球(任务)的杂技演员。他们每次只能抓住一个球(执行工作),但随着时间的推移,球的类型会改变(中断并切换到另一个任务)。即使只有两个球,他们也必须完成工作。

另一方面,并行性 不是管理交错任务,而是它们的同时执行。如果有多个 CPU 核心可用,任务可以在不同核心上并行运行。现在,杂技演员同时使用两只手(多个 CPU 核心),同时拿着两个球(同时执行工作)。

参见图 8-1,更直观地展示了线程调度在这两个概念之间的差异。

并行与并发线程执行

图 8-1. 并发与并行线程执行

Java 中的 并发并行 共享同一个目标:使用线程处理 多个 任务。它们的区别在于如何高效、轻松地以及正确和安全地执行这些任务。

多任务概念既不是互斥的,也经常一起使用。

使用多线程时需要考虑的一件事是,与单线程环境相比,您无法轻松地跟踪或调试应用程序的实际流程。要在并发环境中使用数据结构,它们必须是“线程安全”的,通常需要与锁、信号量等协调以正确工作并确保安全访问任何共享状态。并行执行的代码通常缺乏这种协调,因为它专注于执行本身。这使得并行执行更安全、更自然且更易于理解。

并行功能管道流

Java 提供了一个易于使用的数据处理管道,具有并行处理能力:。如我之前在 第六章 中讨论的那样,默认情况下它们按 顺序 处理操作。但是,通过单个方法调用,可以将管道切换到“并行模式”,要么是中间的 Stream 操作 parallel,要么是 java.util.Collection 类型上可用的 parallelStream 方法。也可以通过调用中间操作 sequential() 回到顺序处理的流。

警告

使用 parallel()sequential() 在执行模式之间切换影响整个 Stream 管道,无论在管道中的位置如何。在终端操作之前调用的最后一个方法决定整个管道的模式。无法使流的某个部分在与其余部分不同的执行模式下运行。

并行流使用 递归分解 的概念,意味着它们通过使用底层的 Spliterator 将数据源进行 分治,以便并行处理数据块。每个数据块由专用线程处理,甚至可以递归地再次分解,直到 Stream API 认为这些数据块和线程与可用资源相匹配。

你不必创建或管理这些线程,也不需要使用显式的 ExecutorService。相反,Stream API 内部使用 通用ForkJoinPool 来衍生和管理新线程。

这些数据块和它们的操作被分叉成多个线程。最后,线程的子结果再次连接以得出最终结果,如 图 8-2 所示。

并行流 Fork/Join

图 8-2. 并行流 Fork/Join

根据流的数据源底层Spliterator特性,块的大小会有所不同。"选择正确的数据源"讨论了不同的特性和数据源及其对元素分割效率的亲和性。

并行流实例

为了说明如何并行处理流,我们将再次计算托尔斯泰的《战争与和平》中不同单词的出现次数 ¹,就像前一章节所做的那样。

首先,应概述一个粗略的方法作为将需要翻译为流操作的必要步骤的蓝图:

  • 载入《战争与和平》的内容

  • 通过删除标点等来清理内容

  • 将内容分割以创建单词

  • 计算所有不同的单词

选择一个更为天真的顺序方法,而不是使用Files.lines方法,以更好地表现出正确数据源和并行流的改进,如第<<示例 8-1 章所示。

示例 8-1. 逐步计算《战争与和平》中的单词
var location = Paths.get("war-and-peace-text.txt");

// CLEANUP PATTERNS ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
var punctuation = Pattern.compile("\\p{Punct}");
var whitespace  = Pattern.compile("\\s+");
var words       = Pattern.compile("\\w+");

try {
  // LOAD CONTENT ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  var content = Files.readString(location);

  Map<String, Integer> wordCount =
    Stream.of(content)
          // CLEAN CONTENT ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
          .map(punctuation::matcher)
          .map(matcher -> matcher.replaceAll(""))
          // SPLIT TO WORDS ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
          .map(whitespace::split)
          .flatMap(Arrays::stream)
          .filter(word -> words.matcher(word).matches())
          // COUNTING ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
          .map(String::toLowerCase)
          .collect(Collectors.toMap(Function.identity(),
                                    word -> 1,
                                    Integer::sum));
} (IOException e) {
  // ...
}

1

多个预编译的Pattern实例用于清理内容。

2

内容一次性读取。

3

清理模式移除所有标点。

4

行按空格分割,结果为String[]数组被平面映射为String元素的流,进一步过滤以实际为“单词”。

5

以不区分大小写的方式计数单词只需将所有单词转换为小写,并让收集器完成实际工作。

使用Collectors.toMap进行计数,它通过调用Function.identity()以单词为键,这是一个创建返回其输入参数的Function<T, T>的快捷方式。如果发生键冲突,意味着遇到了多次出现的单词,收集器通过评估Integer::sum来合并现有值和新值1

在我计算机上,配备 6 核/12 线程 CPU,顺序版本运行时间约为~140ms。

注意

在 CPU 的情况下,线程指的是同时多线程(SMT),而不是 Java 线程。它通常被称为超线程,这是 Intel 对 SMT 的专有实现。

这个初始的 Stream 管道可能解决了在《战争与和平》中统计单词的问题,但还有很大的改进空间。将其并行化不会有太大改变,因为数据源只提供了一个单一元素,所以只有后续操作可以被分叉。那么如何重新设计管道以从并行方法中获得性能提升呢?

如果回想一下 图 8-2,并行流分叉操作管道,这些操作会合并在一起创建一个结果。目前,该管道计算一个整本书作为一个单一的 String。更好的方法可以轻松地在流经管道的任何 String 元素中计算单词,并让终端 collect 操作同样轻松地合并结果。

为了使所有操作都能有良好的并行性能,流管道需要具有多个元素的数据源。而不是使用 Files.readString,这个便捷类型还有一个创建 Stream 的方法,它逐行读取文件:static Stream<String> lines(Path path) throws IOException。尽管处理更多元素会导致总体上更多的清理操作调用,但任务被分布到多个线程并行运行,以最有效地利用可用资源。

另一个重要的变化必须对 collect 操作进行。为了确保不会发生 ConcurrentModificationException,使用线程安全的变体 Collectors.toConcurrentMap 与之前相同的参数。

在并行环境中使用 Collectors

由于 Collectors 共享一个可变的中间结果容器,它们容易受到多线程在 combiner 步骤期间的并发修改的影响。这就是为什么在并行管道中使用的 Collector 的文档总是应该检查其线程安全性,并在必要时选择合适的替代方法。

所有这些小的调整切换到并行方法在 示例 8-2 中的代码中累积起来。

示例 8-2. 在《战争与和平》中并行计算单词
// ...

// LOAD CONTENT ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
try (Stream<String> stream = Files.lines(location)) {

  Map<String, Integer> wordCount =
    stream.parallel()
          // CLEAN LINES ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
          .map(punctionaction::matcher)
          .map(matcher -> matcher.replaceAll(""))
          .map(whitespace::split)
          // SPLIT TO WORDS ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
          .flatMap(Arrays::stream)
          .filter(word -> words.matcher(word).matches())
          // COUNTING ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
          .map(String::toLowerCase)
          .collect(Collectors.toConcurrentMap(Function.identity(),
                                              word -> 1,
                                              Integer::sum));
}

1

Files.lines 调用要求你关闭 Stream。在 try-with-resources 块中使用它将工作委托给运行时,因此你不必手动关闭它。

2

所有先前的步骤 - 清理和拆分行 - 都没有改变。

3

计数方式相同,但使用了线程安全的 Collector 变体。

通过使用优化的数据源并在管道中添加一个 parallel() 调用,所需时间减少到约 25 毫秒。

这导致性能提升超过 5 倍!那么为什么我们不总是使用并行流呢?

何时使用并避免并行流

如果并行流能通过单一方法调用和对数据源以及终端操作的一些考虑来提升性能,那么为什么还要使用顺序流呢?简单来说:任何性能增益都不是保证的,并且受到许多因素的影响。使用并行流主要是性能优化,应始终是有意识和明智的决定,而不仅仅因为它通过一个方法调用就变得简单

选择并行还是顺序数据处理没有绝对规则。选择的标准取决于许多不同因素,如您的需求、当前任务、可用资源等等,所有这些因素都相互影响。因此,“何时使用并行流?”没有定量也没有定性的简单答案。尽管如此,有一些非正式的指导方针可以提供一个很好的起点来决定。

让我们按照流管道的构建顺序来看看它们,从创建流开始,添加中间操作,然后通过添加终端操作完成流管道。

选择正确的数据源

每个流(包括顺序和并行流)都始于由Spliterator处理的数据源。

在顺序流中,Spliterator的行为类似于简单的Iterator,一个接一个地向流提供元素。然而,对于并行流,数据源被分割成多个块。理想情况下,这些块的大小大致相等,因此工作可以均匀分布,但这并不总是可能的,这取决于数据源本身。这个分割过程称为分解数据源。这可以是廉价或有利于并行处理;也可以是复杂和昂贵的。

例如,基于数组的数据源,如 ArrayList,知道其确切大小,并且容易分解,因为所有元素的位置都是已知的,所以可以轻松获取同等大小的块。

另一方面,链表是一种基本的顺序数据源,每个元素只知道它们直接的邻居。查找特定位置意味着您必须遍历所有元素之前的内容。虽然 Java 的实现,LinkedList,通过跟踪大小来作弊,从而创建更有利的Spliterator特征 SIZEDSUBSIZED。尽管如此,它并不是并行流的首选数据源。

表 8-1 列出了不同常见数据源及其适合并行使用的可分解性能力。

表 8-1. 并行可分解性

数据源 并行可分解性
IntStream.range / .rangeClosed +++
Arrays.stream(原始类型) +++
ArrayList ++
Arrays.stream(对象) ++
HashSet +
TreeSet +
LinkedList --
Stream.iterate --

高效分解的程度并非唯一关乎数据源及其在并行流中可能性能的因素。一个容易被忽视的更技术性方面是 数据局部性

现代计算机除了更多核心外,还拥有许多缓存,以提高内存级 2 缓存则快约 25 倍。数据越接近实际处理,性能就会越好。

通常,JDK 实现将对象字段和数组存储在相邻的内存位置。这种设计允许预取“接近”数据并加快任何任务的速度。

引用类型的数组和列表,比如 List<Integer>Integer[],存储的是指向实际值的指针集合,与原始数据类型的数组 int[] 相比,后者将其值存储在相邻位置。如果由于缓存未命中而需要等待加载实际数据,CPU 就必须等待,因此 浪费 资源。但这并不意味着只有原始数据类型的数组适合并行处理。数据局部性 只是影响您选择正确数据源进行并行处理的众多标准之一。与其他标准相比,它是相当微小的一个,而且稍微超出了您直接控制运行时和 JDK 存储数据的范围。

元素数量

并不存在可以确切保证给出最佳并行性能的元素数量,但有一点是明确的:并行流处理的元素越多,其处理效率就越高,因此可以抵消协调多个线程的开销。

要并行处理元素,必须对其进行分区、处理,然后再将它们连接以获得最终结果。这些操作都是相关的,找到合理的平衡是 必不可少 的。这种平衡由 NQ 模型 表示。

N 代表元素的数量,Q 是单个任务的成本。它们的乘积 — N * Q — 表示并行处理获得加速的可能性。可以在 图 8-3 中看到对不同方面进行权衡的概述。

任务成本与任务数量关系图

图 8-3. NQ 模型

正如你所见,元素数量的增加总是可能通过并行处理获得加速的良好指标,与较少的元素相比。长时间运行的任务也会从并行运行中受益,甚至可能超过元素不足的影响。但最理想的情况是两者兼备:大量元素 非廉价任务。

流 流操作

在选择了正确的数据源之后,操作就是下一个难题。设计并行操作的主要目标是与顺序流获得相同的最终结果。这就是为什么大多数中间操作的设计选择都是通用的。

然而,在并行流的情况下,那些在顺序流中并不重要的问题会迅速积累。因此,遵循更多的功能性原则和并行友好操作是很重要的。

纯 lambda 表达式

在流操作中使用的 lambda 表达式应始终是的,这意味着它们不应依赖于非局部的可变状态或产生任何副作用。为了减轻最明显的非局部状态问题,任何被捕获的变量必须是有效地final,如“有效地 final”中所解释的,这仅影响引用本身。

读取不可变状态也不是问题。真正的问题是来自于改变非局部状态的线程,因此任何访问都需要在它们之间同步,否则就会出现非确定性行为,如竞争条件

防止任何非确定性行为的最简单方法是确保任何非局部状态都是深度不可变的。这样,lambda 函数保持纯净,并且不会受到其他线程运行相同 lambda 的影响。

并行友好操作

并非所有的流操作都适合并行处理。判断一个操作是否适合并行处理的最简单方法是它是否依赖于流元素的特定遇到顺序。

例如,limitskipdistinct中间操作严重依赖于遇到顺序,以提供有序流的确定性 — 或稳定 — 行为,这意味着它们总是选择或忽略相同的项。

然而,这种稳定性在并行流中是有代价的:需要跨所有线程同步和增加内存需求。例如,为了保证limit操作在并行使用时产生与顺序流相同的结果,必须等待所有前序操作按照遇到顺序完成并缓冲所有元素,直到知道它们是否被需要。

幸运的是,并非所有的流水线都需要固定的遇到顺序。在流水线上调用unordered()会改变生成的流的特征为UNORDERED,因此稳定的操作会变为不稳定。在许多情况下,选取哪些不同的元素并不重要,只要最终的结果中不包含重复项即可。对于limit,情况则有些棘手,这取决于你的需求。

还有两个稳定的终端操作依赖于数据源的遇到顺序,即findFirstforEach。它们也有一个不稳定的变体,如在表 8-2 中所列。如果你的需求允许,应优先选择它们用于并行流。

表 8-2. 稳定与不稳定的终端操作

稳定操作 不稳定操作
findFirst() findAny()
forEachOrdered(Consumer<? super T> action) forEach(Consumer<? super T> action)

即使在完全并行化的中间操作中,流管道中的最终适用终端操作是顺序的,以实现单一结果或发出副作用。就像不稳定的中间操作一样,终端操作findAny()forEach(…​)可以极大地受益于不受限制地进行遇到顺序,并且无需等待来自其他线程的其他元素。

减少与收集

终端操作reducecollect是同一个硬币的两面:两者都是减少 — 或折叠 — 操作。

在函数式编程中,fold操作通过将函数应用于元素并递归地重新组合结果来结合元素以建立一个返回值。区别在于如何重新组合结果的一般方法:不可变可变的累积。

正如我在“减少与收集元素”中所讨论的那样,可变的累积更类似于你在for循环中处理问题的方式,就像在示例 8-3 中所见。

示例 8-3. 使用 for 循环进行可变累积
var numbers = List.of(1, 2, 3, 4, 5, 6, ...);

int total = 0;

for (int value : numbers) {
  total += value;
}

对于顺序处理的问题,这是一种简单直接的方法。然而,使用非局部和可变状态却是并行处理的反指标。

函数式编程偏爱不可变值,因此积累仅依赖于先前的结果和当前的流元素来生成一个新的和不可变的结果。这种方式使得操作可以很容易地并行运行,正如在图 8-4 中所示。

数字的不可变累积

图 8-4. 数字的不可变累积

流仍然具有与之前相同的元素:每个求和值的初始值0。与在单个值中累积结果不同,每一步返回一个新值作为下一个求和的左操作数。最简单的流形式在示例 8-4 中显示。

示例 8-4. 使用流进行数字的不可变累积
int total = Stream.of(1, 2, 3, 4, 5, 6, ...)
                  .parallel()
                  .reduce(0, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                          Integer::sum); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

1

初始值 — 或身份 — 用于每个并行减少操作。

2

方法引用转换为一个BiFunction<Integer, Integer, Integer>,用于累积前一个(或初始)值与当前流元素。

如果它是可关联的且没有任何共享状态,则这种更抽象的减少形式很容易并行化。如果关联器参数的顺序或分组对最终结果无关紧要,则减少是可关联的。

尽管不可变的规约更适合并行处理,但这并不是唯一的规约选项。根据你的要求,可变规约可能是更合适的解决方案,因为为每个累积步骤创建一个新的不可变结果可能会很昂贵。随着元素的增加,这些成本会随时间累积,影响性能和内存需求。

可变规约通过使用可变结果容器来减轻这种开销。累积函数接收这个容器而不是只有先前的结果,并且不像reduce运算符那样返回任何值。为了创建最终结果,组合器合并所有容器。

在顺序和并行流之间使用reducecollect进行决策的因素归结为你拥有什么样的元素以及终端fold操作的可用性和直观性。有时,你可能需要利用所有可用的性能来改进数据处理,并且需要一个更复杂的fold操作。许多其他因素会影响性能,因此拥有一个更易于理解和可维护的终端操作可能会超过牺牲更多内存和 CPU 周期的缺点。

流的开销和可用资源

与传统的循环结构相比,无论是顺序还是并行流,都会产生不可避免的开销。它们的优势在于提供了一种声明性的方式来定义数据处理管道,并利用许多功能原理来最大化其易用性和性能。然而,在大多数实际情况下,与它们的简洁性和清晰性相比,开销都是可以忽略不计的。

在并行流的情况下,与顺序流相比,你将从更大的初始劣势开始。除了流脚手架本身的开销外,你还必须考虑数据源分解成本、ForkJoinPool的线程管理以及重新组合最终结果,以获得所有移动部件的全貌。并且所有这些部件都必须具有资源——CPU 核心和内存可用以实际并行运行它们。

由计算机科学家 Gene Amdahl 在 1967 年创造,阿姆达尔定律⁠²提供了一种计算并行执行中理论延迟速度提升的方法,适用于恒定工作负载。该定律考虑了单个任务的并行部分和并行运行的任务数量,如图 8-5 所示。

阿姆达尔定律

图 8-5. 阿姆达尔定律

正如你所看到的,最大的性能增益有一个上限,这取决于同时可以运行的并行任务的数量。如果运行时无法真正并行运行它们,因为缺乏足够的资源而被迫交替执行任务,那么容易并行化的任务就没有任何好处。

示例:《战争与和平》(重访)

考虑到并行流性能的所有这些标准,让我们再次分析之前的“战争与和平”词汇统计示例,以更好地理解为何这个流管道非常适合并行处理。

数据源特性

流是通过Files.lines方法从 UTF-8 纯文本文件创建的,根据文档,这个方法具有相当好的并行特性³。

元素数量

文本文件包含超过 60,000 行,因此通过管道流动的元素数量为 60,000。对于现代计算机来说并不多,但元素数量也不可忽视。

中间操作

每个流操作在单独的一行上工作,完全独立于其他操作,没有任何需要协调的共享或外部状态。正则表达式是预编译的且只读的。

终端操作

Collector可以独立地收集结果,并通过简单的算术操作合并它们。

可用资源

我的计算机最多有 12 个 CPU 线程可用,因此如果全部利用,每个线程约处理 5,000 行。

看起来这个例子中使用了并行化的奖池,即使并没有完全符合所有标准。这就是为什么即使对于这样一个简单的任务,性能提升也相当高,接近于高度可并行化操作的Amdahl 定律预期加速。回顾图 8-5,在我设置的 6 核/12 线程上,5 倍的提升表明其可并行性约为~90%。

示例:随机数

这个简单但故意选择的“战争与和平”词频统计的例子表明,并行流可以显著提升性能,其提升程度与可用资源成比例。但对于每种工作负载来说,并不总是如此。

让我们看另一个例子,处理随机数以及如何使用IntStream — 顺序和并行 — 与简单的for循环进行比较,如示例 8-5 所示。

示例 8-5. 随机数统计
var elementsCount = 100_000_000; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

IntUnaryOperator multiplyByTwo = in -> in * 2; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

var rnd = new Random(); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

// FOR-LOOP ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

var loopStats = new IntSummaryStatistics();

for(int idx = 0; idx < elementsCount; idx++) {
  var value = rnd.nextInt();
  var subResult = multiplyByTwo.applyAsInt(value);
  var finalResult = multiplyByTwo.applyAsInt(subResult);
  loopStats.accept(finalResult);
}

// SEQUENTIAL IntStream ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)

var seqStats = rnd.ints(elementsCount)
                  .map(multiplyByTwo)
                  .map(multiplyByTwo)
                  .summaryStatistics();

// PARALLEL IntStream ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)

var parallelStats = rnd.ints(elementsCount)
                       .parallel()
                       .map(multiplyByTwo)
                       .map(multiplyByTwo)
                       .summaryStatistics();

1

1 亿个元素应该足够达到(非确定性的)阈值,从而获得并行处理的性能提升。

2

为了至少完成一些工作,元素将通过共享的 lambda 表达式被乘以2两次。

3

默认的伪随机数源是:java.util.Random

4

for循环版本尝试尽可能模仿流的行为,包括使用相同的逻辑来收集结果。

5

顺序流尽可能直截了当:流的创建,两个映射函数,然后将结果收集为汇总统计数据。

6

并行变体只是在之前的顺序流上添加了一个parallel()调用。

随机数的总结是否符合并行处理的标准?让我们来分析一下!

数据源特性

即使Random是线程安全的,它在文档中明确提到⁴,从不同线程重复使用会对性能产生负面影响。相反,建议使用ThreadLocalRandom类型。

元素数量

1 亿个元素应该足够从并行处理中获得性能提升,不用担心。

中间操作

没有本地或共享状态。这对于可能的并行性能是一个正面因素。但是这个例子可能过于简单,无法抵消并行的开销。

终端操作

IntSummaryStatistics收集器只保存四个整数,并且可以通过简单的算术组合子结果。它不应该对并行性能产生负面影响。

并行处理的成绩单看起来还不错。最明显的问题是数据源本身。一个更合适的数据源可能会提高性能,相比于默认Random数生成器。

除了RandomThreadLocalRandom之外,还有专门为流设计的SplittableRandom。在将for循环的经过时间作为基准与其他选项比较后,选择一个有利的数据源并测量流的性能的必要性就显而易见了。不同数据源之间增加的时间因素列在表 8-3 中。

表 8-3. 不同随机数生成器的经过时间

数据源 for 循环 顺序流 并行流
Random 1.0x 1.05x 27.4x
SplittableRandom 1.0x 2.1x 4.1x
ThreadLocalRandom 1.0x 2.3x 0.6x

即使在流水线中应该有足够的元素,启用并行处理可能会事与愿违,使性能大幅降低。这就是为什么将流设为并行必须是一个审慎和知情的决定。

更好的性能是一个值得追求的目标,但是如果一个并行流是否比顺序数据处理更可取,这取决于上下文和你的需求。你应该始终从顺序流开始,只有在需求规定并且你已经测量了性能增益时才选择并行流。有时,“老式”的for循环可能效果同样好,甚至更好。

并行流检查表

示例 8-5 揭示了并行处理中不利数据源的问题。但这并不是非并行化工作流的唯一指标。根据 “何时使用并避免使用并行流” 中的标准,可以建立一个检查表作为快速指标,以支持并行流或不支持,如 表 8-4 所示。

表 8-4. 并行流检查表

标准 考虑因素
数据源
  • 可分解性的成本

  • 分割块的均匀性/可预测性

  • 元素的数据局部性

|

元素数量
  • 元素的总数

  • NQ 模型

|

中间操作
  • 操作之间的相互依赖性

  • 共享状态的必要性

  • 并行友好的操作

  • 遭遇顺序

|

终端操作
  • 合并最终结果的成本

  • 可变或不可变的减少

|

可用资源
  • CPU 数量

  • 内存

  • 常见的 ForkJoinPool 或定制的

|

这些标准中的任何一个都会影响并行流的性能,并应影响您的决策。然而,没有一个是绝对的破坏者。

您的代码总是可以更高效。在并行流中运行流添加了协调多个线程的复杂性和开销,可能会因不正确使用或在不利环境中使用而导致性能甚至下降。然而,如果用于适合的数据源和可并行化任务,则使用并行流是一种简单易用的优化技术,可以在流水线中引入更高效的数据处理方式。

要点

  • 硬件朝着更多核心的方向发展,而不一定是更快的核心。并发性和并行性在利用所有可用资源方面起着重要作用。

  • 顺序处理是由代码中的文本顺序定义的。并行代码执行可能重叠,使其更难以跟踪、分析和调试。

  • 使用流进行并行操作很容易,但它们固有的复杂性是隐藏的。

  • 并发和并行代码引入了一整套新的要求和可能的问题和注意事项。并行处理是一种优化技术,应该像这样对待:如果不需要,就不要使用;这是一个难题。

  • 大多数功能上首选的技术,如 纯函数不可变性,对于无错误和高性能的并行化代码是有利的,如果不是必须的话。从早期遵循这些技术,即使在顺序代码中,也可以更轻松地过渡到并行处理。

  • Kent Beck 的著名语录也适用于并行流:“先让它运行起来,然后把它做对,最后使它快。”⁠⁵ 从顺序流开始满足您的数据处理需求。通过优化其操作来改进它。只有在必要且被证明有益的情况下,才通过并行方式使其快速。

  • 阅读您的数据源、操作等的文档,以查看它们是否适合并行执行。它通常提供了实现细节背后的推理、性能指示、示例,有时甚至还提供了替代方法。

¹ 古腾堡计划免费提供多个版本的托尔斯泰的《战争与和平》。使用纯文本版本,以确保不会因额外的格式化而影响计数单词的过程。

² 维基百科关于Amdahl 定律详细描述了实际公式。

³ 调用委托给Files.lines(Path path, CharSet cs),其文档列出由于其Spliterator在正常情况下以最佳比例分割而可能获得良好的并行性能。

⁴ 通常,类型的文档,如java.util.Random,提供了在多线程环境中使用它们的指示。

⁵ 肯特·贝克是美国软件工程师,也是极限编程的创始人。尽管这句引述通常被归因于他,但其实这个思想早在 B·W·兰普森的《计算机系统设计提示》中就有描述,见IEEE Software, Vol. 1, No. 1, 11-28, Jan. 1984

第九章:使用 Optionals 处理 null

作为 Java 开发者,你很可能遇到过大量的NullPointerExceptions,甚至更多。许多人称null引用为亿美元错误。事实上,null本身的发明者最初就是这样称呼它的:

我称之为我的亿美元错误。

这是在 1965 年发明的null引用。当时,我正在设计第一个综合型对象导向语言(ALGOL W)的引用类型系统。我的目标是确保所有引用的使用都是绝对安全的,由编译器自动执行检查。但我无法抵挡诱惑,只是因为它太容易实现,就加入了一个null引用。

这导致了无数的错误、漏洞和系统崩溃,在过去的四十年里,这可能造成了数十亿美元的痛苦和损失。

查尔斯·安东尼·理查德·霍尔爵士,QCon 伦敦 2009

尽管如何处理这个“错误”还没有绝对的共识,但许多编程语言都有处理null引用的正确和惯用方法,通常直接集成到语言本身中。

本章将向您展示 Java 如何处理null引用,以及如何使用Optional<T>类型及其功能 API 在代码中改进它,并学习何时以及何时不使用 Optionals。

关于 null 引用的问题

Java 对于值的缺失的处理取决于类型。所有原始类型都有默认值,例如,数值类型的零等价物和boolean类型的false。非原始类型,如类、接口和数组,如果未分配,则使用null作为它们的默认值,这意味着变量不引用任何对象。

注意

引用类型的概念可能看起来与 C/C++指针相似,但 Java 引用是 JVM 内的一个专门类型,称为reference。JVM 严格控制它们以确保类型安全和内存访问的安全性。

一个null引用不只是“空”,它是一个特殊状态,因为null是一个广义类型,可以用于任何对象引用,无论实际类型如何。如果尝试访问这样的null引用,JVM 将抛出NullPointerException,如果不适当处理,当前线程将崩溃。通常通过防御性编程方法来缓解这个问题,在运行时随处需要null检查,如示例 9-1 中所示。

示例 9-1. 可能存在空指针的地雷区
record User(long id, String firstname, String lastname) {

  String fullname() {
    return String.format("%s %s", ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                         firstname(),
                         lastname());
  }

  String initials() {
    return String.format("%s%s",
                         firstname().substring(0, 1), ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                         lastname().substring(0, 1)); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  }
}

var user = new User(42L, "Ben", null);

var fullname = user.fullname();
// => Ben null ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

var initials = user.initials();
// => NullPointerException ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

1

String.format接受null值,只要它不是格式字符串之后的唯一值¹。它会翻译为字符串null,无论所选的格式说明符如何,甚至对于数值类型也是如此。

2

在方法调用中使用 null 作为参数可能不会导致当前线程崩溃。但是,在 null 引用上调用方法肯定会崩溃。

前面的例子突显了处理 null 中的两个主要问题。

首先,null 引用是变量、参数和返回值的有效值。这并不意味着 null 是每个情况下的预期、正确或甚至可接受的值,并且可能在处理过程中未被正确处理。

例如,在前面的例子中,对 user 调用 getFullname 函数时,使用 null 引用作为 lastname 是可以正常工作的,但输出“Ben null”很可能不是预期的结果。因此,即使您的代码和数据结构可以表面上处理 null 值,您仍然可能需要检查它们以确保正确的结果。

null 引用的第二个问题是它们的一个主要特性:类型模糊性。它们可以表示任何类型,而不实际成为该特定类型。这种独特的属性是必要的,因此可以在整个代码中使用单个关键字表示“缺少值”的泛化概念,而不需要为不同的对象类型使用不同的类型或关键字。尽管 null 引用可以像它表示的类型一样使用,但它仍然不是该类型本身,正如在示例 9-2 中所见。

示例 9-2. null 类型模糊性
// "TYPE-LESS" NULL AS AN ARGUMENT

methodAcceptingString(null); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

// ACCESSING A "TYPED" NULL

String name = null;

var lowerCaseName = name.toLowerCase(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
// => NullPointerException

// TEST TYPE OF NULL

var notString = name instanceof String; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
// => false

var stillNotString = ((String) name) instanceof String; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
// => false

1

null 可以表示任何对象类型,因此对于任何非原始类型的参数,它都是一个有效的值。

2

引用 null 的变量就像该类型的任何其他变量一样。但任何对它的调用都将导致 NullPointerException

3

使用 instanceof 测试变量将始终评估为 false,而不管类型如何。即使它被显式地强制转换为所需的类型,instanceof 运算符也会测试基础值本身。因此,它针对无类型值 null 进行测试。

这些是处理 null 的最明显的痛点。不要担心,有方法可以减轻这种痛苦。

如何处理 Java 中的空值(在使用 Optional 之前)

在 Java 中处理 null 是每个开发人员工作中必不可少的重要部分,尽管它可能很麻烦。遇到意外且未处理的 NullPointerException 是许多问题的根源,必须相应地处理。

其他语言,如Swift,提供了专用运算符和习语,如安全导航²或 null 合并运算符³,以便更轻松地处理 null。然而,Java 并没有提供这样的内置工具来处理 null 引用。

在使用 Optional 之前,有三种不同的方法来处理 null 引用:

  • 最佳实践

  • 工具辅助的 null 检查

  • Optional 等专门类型

正如您稍后将看到的那样,处理null引用不应仅依赖于 Optional。它们是前述技术的重要补充,通过在 JDK 中提供标准化和便捷的专用类型。然而,它们并不是如何在整个代码中管理null的最终想法,了解所有可用技术将是您技能工具包中的宝贵补充。

处理null的最佳实践

如果语言不提供集成的null处理,您必须依靠最佳实践非正式规则来使您的代码免于null。这就是为什么许多公司、团队和项目开发自己的编码风格或适应现有的风格以提供编写一致和更安全代码的指南,不仅限于null。通过遵循这些自我规定的实践和规则,他们能够始终编写更可预测和不易出错的代码。

您不必开发或适应全面的样式指南来定义您的 Java 代码的每个方面。相反,遵循这四条规则是处理null引用的良好起点:

不要将变量初始化为 null

变量应始终具有非null值。如果值取决于决策块(如if-else语句),您应考虑将其重构为方法,或者如果它是一个简单的决策,则使用三元运算符。

// DON'T

String value = null;

if (condition) {
  value = "Condition is true";
} else {
  value = "Fallback if false";
}

// DO

String asTernary = condition ? "Condition is true"
                             : "Fallback if false";

String asRefactored = refactoredMethod(condition);

其额外好处是如果稍后不再重新分配变量,则使变量实际上成为final,因此您可以将它们用作 lambda 表达式中的局部变量。

不要传递、接受或返回null

变量不应为null,因此任何参数和返回值也应避免为null。通过方法或构造函数的重载可以避免非必需参数为null的情况:

public record User(long id, String firstname, String lastname) {

  // DO: Additional constructor with default values to avoid null values
  public User(long id) {
    this(id, "n/a", "n/a");
  }

  // ...
}

如果由于相同的参数类型而导致方法签名冲突,您始终可以改用更明确名称的static方法。

提供了针对可选值的特定方法和构造函数后,如果适当的话,原始方法中就不应接受null。最简单的方法是使用java.util.Objects上可用的static requireNonNull方法:

public record User(long id, String firstname, String lastname) {

  // DO: Validate arguments against null
  public User {
    Objects.requireNonNull(firstname);
    Objects.requireNonNull(lastname);
  }

  // ...
}

requireNonNull调用会为您执行null检查,并在适当时抛出NullPointerException。自 Java 14 以来,任何NullPointerException都包含变量的名称,感谢JEP 358。如果要包含特定消息或针对较早的 Java 版本进行定位,可以将String作为调用的第二个参数添加。

检查一切不在您控制之外的事情

即使遵循自己的规则,也不能依赖他人也这样做。始终假设使用非熟悉代码(特别是如果文档中未明确说明)可能为null,并且需要进行检查。

null作为实现细节是可以接受的

避免null对于代码的public接口至关重要,但作为实现细节仍然是明智的。在内部,一个方法可以根据需要使用null,只要不将其返回给调用者即可。

何时遵循规则,何时不遵循规则

这些规则旨在尽可能减少null的一般使用,特别是在代码交集处,如 API 表面,因为更少的暴露会导致更少的必需null检查和可能的NullPointerExceptions。但这并不意味着你应该完全避免null。例如,在隔离的上下文中,如局部变量或非public的 API 中,使用null并不那么问题,甚至可能简化您的代码,只要谨慎使用即可。

你不能期望每个人都遵循与你相同的规则或者一样细心,因此你需要在代码中保持防御性,尤其是在你控制范围之外的情况下。这更加理由让你始终坚持最佳实践,并鼓励其他人也这样做。它们将提高你的整体代码质量,不论null如何。但这不是银弹,需要团队的纪律才能获得最大的好处。手动处理null并添加一些null检查优于因为假设某些东西“永远”不可能为null而导致意外的NullPointerException。即使是 JIT 编译器⁴也会执行“null检查消除”,从优化的汇编代码中删除许多显式的null检查,这要归功于其在运行时的更多知识。

工具辅助的空值检查

最佳实践和非正式规则方法的逻辑扩展是使用第三方工具自动强制执行它们。对于 Java 中的null引用,一个既定的最佳实践是使用注解将变量、参数和方法返回类型标记为@Nullable@NonNull

在此类注解之前,唯一可以记录空值的地方是 JavaDoc。通过这些注解,静态代码分析工具可以在编译时发现可能的null问题。更好的是,将这些注解添加到您的代码中,可以更明确地表达方法签名和类型定义的意图,如在示例 9-3 中所见。

示例 9-3. 带注解的空值处理
interface Example {

  @NonNull List<@Nullable String> getListOfNullableStrings(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  @Nullable List<@NonNull String> getNullableListOfNonNullStrings(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  void doWork(@Nullable String identifier); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

1

返回一个可能为nullList,其中包含非nullString对象。

2

返回可能为nullList,其中包含非nullString对象。

3

方法参数identifier允许为null

JDK 不包括这些注解,对应的 JSR 305 自 2012 年以来一直处于“休眠”状态。尽管如此,它仍然是事实上的社区标准,并被许多库、框架和 IDE 广泛采用。几个库⁵提供了缺失的注解,大多数工具支持多种变体。

警告

尽管 @NonNull@Nullable 的行为在表面上似乎显而易见,但实际实现可能因工具不同而有所不同,特别是在边缘情况下⁶。

工具辅助方法的一般问题在于对工具本身的依赖。如果它过于侵入式,你可能最终会得到无法运行的代码,特别是如果工具在“幕后”生成代码。然而,在涉及 null 相关注解的情况下,你不必过于担心。你的代码仍然可以在没有工具解释注解的情况下运行,并且你的变量和方法签名仍然能够清晰地传达它们的要求给任何使用它们的人,即使没有强制执行。

像 Optional 这样的专门类型

工具辅助方法给你编译时的 null 检查,而专门类型则在运行时提供更安全的 null 处理。在 Java 引入自己的 Optional 类型之前,不同的库填补了这个缺失功能,比如自 2011 年起由 Google Guava 框架 提供的基本的 Optional 类型。

尽管 JDK 现在提供了一个集成的解决方案,Guava 在可预见的未来不打算弃用这个类⁷。然而,他们温和建议你在可能的情况下优先选择新的、标准的 Java Optional<T>

Optional 来拯救

Java 8 新的 Optional<T> 不仅仅是一个专门处理 null 的类型,它还是一个功能类似管道,从 JDK 中所有可用的功能补充中受益。

什么是 Optional?

想象 Optional<T> 类型最简单的方法是把它看作一个可能包含 null 的值的盒子。你可以使用这个盒子来替代传递可能为 null 的引用,如在 图 9-1 中所见。

可变 vs 可选

图 9-1. 可变 vs 可选

这个盒子提供了一个安全的包装器围绕它的内部值。但是 Optional 不仅仅包装一个值。从这个盒子开始,你可以构建复杂的调用链,这些调用链依赖于值的存在或缺失。它们可以管理可能值的整个生命周期,直到盒子被解封,包括在这样一个调用链中没有值时的回退。

然而,使用包装器的缺点在于如果想要使用其内部值,必须实际查看和获取箱子。与流类似,额外的包装器还会在方法调用及其额外的栈帧方面产生无法避免的开销。另一方面,该箱提供了额外的功能,用于处理可能为null值的常见工作流的更简洁和直接的代码。

例如,让我们看一下通过标识符加载内容的工作流程。图 9-2 中的数字对应于示例 9-5 中即将出现的代码。

加载内容中

图 9-2. 加载内容的工作流程

该工作流程简化了,并且未处理所有边缘情况,但它是将多步工作流转换为可选调用链的简单示例。在示例 9-4 中,您可以先看到未使用可选的工作流程实现。

示例 9-4. 使用非可选加载内容
public Content loadFromDB(String contentId) {
  // ...
}

public Content get(String contentId) {

  if (contentId == null) {
    return null;
  }

  if (contentId.isBlank()) {
    return null;
  }

  var cacheKey = contentId.toLowerCase();

  var content = this.cache.get(cacheKey);
  if (content == null) {
    content = loadFromDB(contentId);
  }

  if (content == null) {
    return null;
  }

  if (!content.isPublished()) {
    return null;
  }

  return content;
}

例如是夸张的,但仍大多反映了防御性null处理的典型方法。

这里有三个明确的null检查,再加上关于当前值和两个临时变量的两个决策。尽管代码量不多,但总体流程由于其中许多if块和早期返回而不易理解。

让我们将代码转换为单个可选调用链,如示例 9-5 所示。别担心!接下来的章节将详细解释不同类型的操作。

示例 9-5. 使用可选调用链加载内容
public Optional<Content> loadFromDB(String contentId) {
  // ...
}

public Optional<Content> get(String contentId) {

  return Optional.ofNullable(contentId) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                 .filter(Predicate.not(String::isBlank)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                 .map(String::toLowerCase) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                 .map(this.cache::get); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
                 .or(() -> loadFromDB(contentId)) ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
                 .filter(Content::isPublished); ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
}

1

第一个可能的null检查是使用ofNullable创建方法完成的。

2

下一个if块被filter操作替换。

3

而不是使用临时变量,map操作将值转换为匹配下一个调用的形式。

4

该内容也可以通过map操作来检索。

5

如果管道中不存在值,则从数据库加载内容。此调用将返回另一个可选项,以便调用链可以继续。

6

确保只有发布的内容可用。

可选调用链将整体代码压缩到每行一个操作,使整体流程易于理解。它完美地突显了使用可选调用链和“传统”方式检查null之间的差异。

让我们看看创建和使用可选流水线的不同步骤。

构建可选流水线

截至 Java 17,Optional<T> 提供了三个 static 和 15 个属于四个组的实例方法,表示 Optional 管道的不同部分:

  • 创建一个新的 Optional<T> 实例

  • 检查值或对值的存在或不存在做出反应

  • 过滤和转换值

  • 获取值或制定备用计划

这些操作可以构建一个流畅的管道,类似于 Streams。与 Streams 不同的是,它们 被惰性连接,直到向管道添加了类似 terminal 的操作,正如我在 “作为函数式数据流的流” 中讨论的那样。每个操作一旦添加到流畅的调用中,就会立即解析。Optional 之所以看起来懒散,是因为它们可能返回一个空的 Optional 或一个备用值,并跳过转换或过滤步骤。但是,这并不会使调用链本身变得懒散。然而,如果遇到 null 值,无论操作数量如何,执行的工作都是尽可能少的。

您可以将 Optional 调用链想象成两条火车轨道,如 图 9-3 所示。

Optional 火车轨道

图 9-3. Optional 火车轨道

在这个类比中,我们有两条火车轨道:Optional 调用链轨道,通向返回具有内部值的 Optional<T>,和“空快车轨道”,通向空的 Optional<T>。火车始终在 Optional<T> 调用火车轨道上启动。当它遇到轨道切换(Optional 操作)时,它会寻找一个 null 值,这种情况下,火车将切换到空快车轨道。一旦进入快车轨道,就没有返回到 Optional 调用链轨道的机会,至少在 Java 9 之前是这样的,您将在 “获取(备用)值” 中看到。

从技术上讲,它仍然会在切换到空快车轨道后在 Optional 调用链上调用每个方法,但它只会验证参数并继续前进。如果火车在到达路线终点之前没有遇到 null 值,它将返回一个非空的 Optional<T>。如果它在路线上的任何时候遇到 null 值,它将返回一个空的 Optional<T>

为了让火车动起来,让我们创建一些 Optionals。

创建一个 Optional

Optional<T> 类型上没有public构造函数可用。相反,它为您提供了三个 static 工厂方法来创建新实例。使用哪一个取决于您的用例和对内部值的先验知识:

如果值可能是 null,则使用 Optional.ofNullable(T value)

如果您知道一个值可能是 null,或者不在乎它可能为空,可以使用方法 Optional.ofNullable(…​) 创建一个可能具有内部 null 值的新实例。这是创建 Optional<T> 的最简单、最可靠的形式。

String hasValue = "Optionals are awesome!";
Optional<String> maybeValue = Optional.ofNullable(hasValue);

String nullRef = null;
Optional<String> emptyOptional = Optional.ofNullable(nullRef);

如果值必须为非null,则使用 Optional.of(T value)

即使 Optionals 是处理 null 并防止 NullPointerException 的好方法,但如果你确保有一个值怎么办?例如,你已经处理了代码中的所有边缘情况,返回了空的 Optionals,并且现在你肯定有一个值。Optional.of(…​) 方法确保该值非null,否则会抛出 NullPointerException。这样,异常表明了代码中的真实问题。也许你忽略了一个边缘情况,或者某个外部方法调用现在返回 null。在这种情况下使用 Optional.of(…​) 可以使你的代码更加具有未来可扩展性,对行为不期而遇的变化更加鲁棒。

var value = "Optionals are awesome!";
Optional<String> mustHaveValue = Optional.of(value);

value = null;
Optional<String> emptyOptional = Optional.of(value);
// => throws NullPointerException

如果没有值,则为 Optional.empty()

如果你已经知道根本没有值,可以使用静态方法 Optional.empty()。调用 Optional.ofNullable(null) 是不必要的,因为在调用 empty() 本身之前会进行不必要的 null 检查。

Optional<String> noValue = Optional.empty();
警告

JDK 文档明确提到,由 static Optional.empty 方法返回的值不保证是单例对象。因此,你不应该使用 == (双等号)比较空的 Optionals,而是应该使用 equals(Object obj) 或比较 isEmpty 方法的结果。

使用 Optional.ofNullable(T value) 可能是最容忍 null 的创建方法,但你应该努力使用最适合表示你用例和上下文知识的方法。随着时间的推移,代码可能会被重构或重写,最好让你的代码为突然丢失的必需值抛出 NullPointerException,即使 API 本身正在使用 Optionals。

检查和响应值

Optionals 用于包装一个值并表示其存在或不存在。它们作为 Java 类型实现,并且因此在运行时级别具有不可避免的对象创建开销。为了补偿这一点,检查值应尽可能简单直接。

有四种方法可用于检查和响应值或其缺失。它们以“is”为前缀用于检查和以“if”为前缀用于响应的高阶函数:

  • boolean isPresent()

  • boolean isEmpty()(Java 11+)

仅仅检查值有其用途,但当你使用“is”方法时,检查、检索和使用值需要三个单独的步骤。

这就是为什么高阶“if”方法直接消耗一个值:

  • void ifPresent(Consumer<? super T> action)

  • void ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

两种方法只有在存在值时才执行给定的 action。第二种方法在没有值时运行 emptyAction。不允许使用 null 作为动作,并会抛出 NullPointerException。没有 ifEmpty…​ 的等效方法可用。

让我们看看如何在 示例 9-6 中使用这些方法。

示例 9-6. 检查 Optional 值的示例
Optional<String> maybeValue = ...;

// VERBOSE VERSION

if (maybeValue.isPresent()) {
  var value = maybeValue.orElseThrow();
  System.out.println(value);
} else {
  System.out.println("No value found!");
}

// CONCISE VERSION

maybeValue.ifPresentOrElse(System.out::println,
                           () -> System.out.println("No value found!"));

由于缺少返回类型,两个ifPresent方法仅执行副作用代码。尽管在功能性方法中通常更可取,但 Optionals 处于接受功能性代码和完全适合命令式代码之间。

过滤和映射

安全处理可能的null值已经从任何开发者身上卸下了相当大的负担,但 Optionals 不仅仅是用来检查值的存在或缺失。

类似于流,你可以使用中间操作构建一个管道。这里有三个操作用于过滤和映射 Optionals:

  • Optional<T> filter(Predicate<? super T> predicate)

  • <U> Optional<U> map(Function<? super T, ? extends U> mapper)

  • <U> Optional<U> flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)

filter操作在存在值并且符合给定条件的情况下返回this。如果不存在值或者条件不匹配,则返回一个空的 Optional。

map操作使用提供的映射函数转换现有值,返回一个包含映射值的新的可空 Optional。如果不存在值,则操作返回一个空的Optional<U>

如果映射函数返回的是Optional<U>而不是类型为U的具体值,则使用flatMap。在这种情况下如果使用map,返回值将是Optional<Optional<U>>。这就是为什么flatMap直接返回映射值而不是将其再次包装为另一个 Optional 的原因。

示例 9-7 展示了一个 Optional 调用链及其在假设的权限容器及其子类型中的非 Optional 等效形式。代码调用均附有对应的操作,但其描述适用于 Optional 版本。

示例 9-7。寻找活跃管理员的中间操作
public record Permissions(List<String> permissions, Group group) {
  public boolean isEmpty() {
    return permissions.isEmpty();
  }
}

public record Group(Optional<User> admin) {
  // NO BODY
}

public record User(boolean isActive) {
    // NO BODY
}

Permissions permissions = ...;

boolean isActiveAdmin =
  Optional.ofNullable(permissions) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
          .filter(Predicate.not(Permissions::isEmpty)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
          .map(Permissions::group) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
          .flatMap(Group::admin) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
          .map(User::isActive) ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
          .orElse(Boolean.FALSE); ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)

1

初始的null检查通过创建一个Optional<Permissions>来处理。

2

过滤非空权限。借助staticPredicate.not方法,lambda 表达式permissions → !permissions.isEmpty()被替换为一个更易读的包装方法引用。

3

获取权限对象的组。如果Permissions::group返回null,Optional 调用链将会在需要时跳过其取值操作。实际上,空的 Optional 会在流畅的调用中传递。

4

组可能没有管理员。这就是为什么它返回一个Optional<User>。如果简单地使用map(Group::admin),在下一步中会得到一个Optional<Optional<User>>。而借助于flatMap(Group::admin),不会创建不必要的嵌套 Optional。

5

使用 User 对象,可以过滤掉非活动用户。

6

如果调用链的任何方法返回空的 Optional,例如,组是 null,则最后一个操作返回回退值 Boolean.FALSE。下一节将解释不同类型的值检索操作。

解决底层问题的每个步骤都清晰、分离且直接连接。任何验证和决策,如 null 或空检查,都包含在基于方法引用的专用操作中。解决问题的意图和流程清晰可见,易于理解。

如果没有 Optional,做同样的事情会导致嵌套的代码混乱,如 示例 9-8 所示。

示例 9-8. 在没有 Optional 的情况下查找活动管理员
boolean isActiveAdmin = false;

if (permissions != null && !permissions.isEmpty()) {

  if (permissions.group() != null) {
    var group = permissions.group();
    var maybeAdmin = group.admin();

    if (maybeAdmin.isPresent()) {
      var admin = maybeAdmin.orElseThrow();
      isActiveAdmin = admin.isActive();
    }
  }
}

两个版本之间的差异非常明显。

非 Optional 版本无法委托任何条件或检查,并依赖显式的 if 语句。这会创建深度嵌套的流程结构,增加代码的 Cyclomatic Complexity。很难理解代码块的整体意图,并且不如使用 Optional 调用链那样简洁。

注意

Cyclomatic Complexity⁸ 是用于确定代码复杂性的度量标准。它基于代码中的分支路径数或决策。总体思想是,直接的、非嵌套的语句和表达式更容易跟踪,比深度嵌套的决策分支,如嵌套的 if 语句,更不容易出错。

获取(回退)值

Optional 可能为可能的 null 值提供了一个安全的包装器,但是在某些情况下,你可能需要一个实际的值。有多种方法可以检索 Optional 的内部值,从“蛮力”到提供回退值。

第一个方法不关心任何安全检查:

  • T get()

强制解包 Optional,并且如果没有值,会抛出 NoSuchElementException 异常,因此请确保在此之前检查值是否存在。

接下来的两种方法在没有值时提供一个回退值:

  • T orElse(T other)

  • T orElseGet(Supplier<? extends T> supplier)

基于 Supplier 的变体允许延迟获取回退值,如果创建它需要大量资源,则非常有用。

有两种方法可以抛出异常:

  • <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier)

  • T orElseThrow()(Java 10+)

尽管 Optional 的主要优点之一是防止 NullPointerException,但有时如果没有值,你仍然需要一个特定的域异常。通过 orElseThrow 操作,你可以对缺失值的处理和要抛出的异常进行精细控制。第二个方法 orElseThrow 被添加为语义上正确和首选的 get 操作的替代方法。尽管调用不那么简洁,但它更符合整体命名方案,并表明可能会抛出异常。

Java 9 为提供另一个 Optional<T>Stream<T> 作为回退添加了两个额外的方法。这些方法允许比以前更复杂的调用链:

第一个方法,Optional<T> or(Supplier<? extends Optional<? extends T>> supplier),在没有值时懒惰地返回另一个 Optional。这样,即使在调用 or 之前没有值,你也可以继续进行 Optional 调用链。回到“火车轨道”的比喻,or 操作是一种通过在 Optional 调用链轨道上创建一个新的起点来提供从空快速轨道返回的轨道切换的方法。

另一个方法,Stream<T> stream(),返回一个包含值作为其唯一元素的流,或者如果没有值,则返回一个空流。通常在中间流操作 flatMap 中使用,作为方法引用。Optional stream 操作在与我在 第七章 讨论的 Stream API 的互操作性中发挥更广泛的作用。

选项和流

如前几章所述,流是过滤和转换元素以获得所需结果的管道。Optional 作为可能的 null 引用的函数包装器正好适用,但在作为元素使用时必须遵循流管道的规则,并将其状态传递给管道。

选项作为流元素

使用流,通过使用过滤操作排除元素以进行进一步处理。基本上,Optional 本身表示一种过滤操作,尽管不直接与流期望元素的行为兼容。

如果一个流元素被 filter 操作排除,它将不会继续遍历流。这可以通过使用 Optional::isPresent 作为 filter 操作的参数来实现。然而,在内部值的情况下,结果流 Stream<Optional<User>> 并不是你想要的。

要恢复“正常”的 Stream 语义,需要将 Stream 从 Stream<Optional<User>> 映射到 Stream<User>,如 示例 9-9 所示。

示例 9-9. 选项作为流元素
List<Permissions> permissions = ...;

List<User> activeUsers =
  permissions.stream()
             .filter(Predicate.not(Permissions::isEmpty))
             .map(Permissions::group)
             .map(Group::admin) ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
             .filter(Optional::isPresent) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
             .map(Optional::orElseThrow) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
             .filter(User::isActive)
             .toList();

1

Group::admin 方法引用返回一个 Optional<User>。此时,Stream 变为 Stream<Optional<User>>

2

Stream 管道需要多个操作来检查值并安全地从其 Optional 中解包它。

在 Streams 中,对 Optional<T> 进行过滤和映射是 Optionals 的标准用例,以至于 Java 9 添加了 stream 方法到 Optional<T> 类型中。如果存在,它返回包含内部值的 Stream<T> 作为其唯一元素,否则返回空的 Stream<T>。这使得通过使用 Stream 的 flatMap 操作而不是专门的 filtermap 操作来结合 Optionals 和 Streams 的能力成为最简洁的方法,如在 Example 9-10 中所见。

Example 9-10. 可选项作为流元素与 flatMap
List<Permissions> permissions = ...;

List<User> activeUsers =
  permissions.stream()
             .filter(Predicate.not(Permissions::isEmpty))
             .map(Permissions::group)
             .map(Group::admin)
             .flatMap(Optional::stream)
             .filter(User::isActive)
             .toList();

单一的 flatMap 调用取代了之前的 filtermap 操作。即使你只节省了一个方法调用 —— 一个 flatMap 替代 filtermap 操作 —— 结果代码更易于理解,并更好地说明了期望的工作流程。flatMap 操作传达了理解 Stream 管道所需的所有必要信息,而不需要通过需要额外步骤来增加任何复杂性。处理 Optionals 是必要的,应尽可能简洁地完成,以便整体的 Stream 管道尽可能易于理解和直接。

没有理由在设计 API 时不使用 Optionals,只是为了避免在 Streams 中使用 flatMap 操作。如果 Group::getAdmin 返回 null,你仍然必须在另一个 filter 操作中添加 null-check。用 filter 操作替换 flatMap 操作并没有任何好处,除非 admin 调用现在需要在其签名中明确处理 null,即使不再显而易见。

终端 Stream 操作

在 Streams 中使用 Optionals 不仅限于中间操作。流 API 的五个终端操作返回 Optional<T>,以提供其返回值的改进表示。它们都试图找到一个元素或减少 Stream。在空 Stream 的情况下,这些操作需要一个合理的缺席值的表示。Optionals 典型地展示了这一概念,因此使用它们而不是返回 null 是合乎逻辑的选择。

寻找元素

在 Stream API 中,前缀 "find" 表示,“找到”一个基于其存在的元素。根据流是否并行,有两种 "find" 操作可用,具有不同的语义:

Optional<T> findFirst()

返回流的第一个元素的 Optional,如果流为空则返回空的 Optional。并行和串行流之间没有区别。如果流缺乏相遇顺序,则可能返回任何元素。

Optional<T> findAny()

返回流的任何元素的 Optional,如果流为空则返回空的 Optional。返回的元素是非确定性的,以最大化并行流的性能。在大多数情况下返回第一个元素,但无法保证此行为!因此,对于一致返回元素,请改用findFirst

"find"操作仅适用于存在的概念,因此您需要相应地对流元素进行过滤。如果您只想知道特定元素是否存在,并且不需要元素本身,则可以使用相应的"match"方法之一:

  • boolean anyMatch(Predicate<? super T> predicate)

  • boolean noneMatch(Predicate<? super T> predicate)

这些终端操作包括过滤操作,并避免创建不必要的Optional<T>实例。

减少到单一值

将流通过组合或累积其元素减少到新数据结构是流的主要目的之一。并且与find操作一样,减少操作符必须处理空流。

这就是为什么流有三个终端的reduce操作,其中一个返回一个 Optional:Optional<T> reduce(BinaryOperator<T> accumulator)

使用提供的accumulator运算符对流的元素进行规约。返回值是规约的结果,如果流为空,则返回一个空的 Optional。

详见示例 9-11 以获取来自官方文档的等效伪代码示例⁹。

Example 9-11. Pseudo-code equivalent to the reduce operation
Optional<T> pseudoReduce(BinaryOperator<T> accumulator) {
  boolean foundAny = false;
  T result = null;

  for (T element : elements]) {
    if (!foundAny) {
      foundAny = true;
      result = element;
    } else {
      result = accumulator.apply(result, element);
    }
  }

  return foundAny ? Optional.of(result)
                  : Optional.empty();
}

另外两个reduce方法需要一个初始值来与流元素结合,以便返回一个具体的值,而不是一个 Optional。详见“减少元素”以获取更详细的解释和使用示例。

除了通用的reduce方法,还有两种常见的减少用例作为方法可用:

  • Optional<T> min​(Comparator<? super T> comparator)

  • Optional<T> max(Comparator<? super T> comparator)

这些方法基于提供的比较器返回“最小”或“最大”元素,如果流为空则返回空的 Optional。

Optional<T>是唯一适合由min/max返回的类型。无论如何,您都必须检查操作是否有结果。添加带有回退值作为参数的额外min/max方法将混乱流接口。由于返回的Optional,您可以轻松检查是否存在结果,或者改用回退值或异常。

原始可选项

您可能会问自己为什么需要原始的 Optional,因为原始变量永远不会是null。如果未初始化,任何原始变量都具有与其相应类型的零值。

尽管在技术上是正确的,但 Optional 并不只是防止值为null。它们还表示一种“无存在值”的实际状态,而这是原始类型所缺乏的。

在许多情况下,原始类型的默认值是足够的,比如表示网络端口:零是一个无效的端口号,因此你必须处理它。但如果零是一个有效的值,表达它的实际缺失变得更加困难。

直接使用Optional<T>类型与原始类型是行不通的,因为原始类型不能是泛型类型。然而,就像使用流一样,处理可选的原始值有两种方式:自动装箱或专用类型。

“原始类型”突显了使用对象包装类及其引入的开销问题。另一方面,自动装箱也不是免费的。

通常的原始类型都有专门的 Optional 变体:

  • java.util.OptionalInt

  • java.util.OptionalLong

  • java.util.OptionalDouble

它们的语义几乎与它们的通用对应物相同,但它们既不继承自Optional<T>也没有共同的接口。它们的特性也不相同,如缺少filtermapflatMap等多个操作。

原始的 Optional 类型可以消除不必要的自动装箱,这可以提高性能,但缺乏Optional<T>提供的全部功能。此外,与我在“原始流”中讨论的原始流变体不同,没有一种简单的方法可以在原始 Optional 变体和其对应的Optional<T>等效型之间进行转换。

尽管创建自己的包装类型以改进 Optional 值的处理(特别是对于原始类型)很容易,但在大多数情况下我不建议这样做。对于内部或private实现,可以使用任何所需的包装类型。但是代码的public接口应始终坚持使用最受期待和可用的类型。通常情况下,这意味着 JDK 中已经包含的内容。

注意事项

Optional 可以通过提供一个多功能的“盒子”来极大地改善 JDK 中的null处理,用于保存可能的null值,并提供(部分)功能性 API 来构建处理该值存在与否的管道。尽管优势显而易见,但它也伴随着一些显著的缺点,你需要了解这些缺点,以正确地使用它们,并避免任何意外的惊喜。

Optional 是普通类型

Optional<T>及其原始变体最明显的缺点之一是它们是普通类型。没有更深入地融入 Java 语法,比如新的 lambda 表达式语法,它们会遇到与 JDK 中任何其他类型相同的null引用问题。

因此,你仍然必须遵循最佳实践和非正式规则,以免抵消使用 Optionals 的好处。如果你设计一个 API 并决定使用 Optionals 作为返回类型,在任何情况下都不能返回 null!返回 Optional 是一个明确的信号,表明使用 API 的任何人至少会收到一个“盒子”,可能包含一个值,而不是可能的 null 值。如果没有可能的值,始终使用空的 Optional 或者等效的基本类型。

尽管如此,这个基本的设计要求必须通过约定来执行。没有额外的工具,如 SonarSource¹⁰,编译器不会帮助你。

敏感于身份的方法

尽管 Optionals 普通类型,但是与你预期的可能不同的是,一些敏感于身份的方法。这包括引用相等性运算符 ==(双等号),使用 hashCode 方法,或者使用实例进行线程同步。

注意

对象标识告诉你两个不同对象是否共享相同的内存地址,因此是同一个对象。这可以通过引用相等性运算符 ==(双等号)来测试。两个对象的相等性,通过它们的 equals 方法来测试,意味着它们包含相同的状态。

两个相同的对象也是相等的,但反过来不一定成立。仅因为两个对象包含相同的状态并不意味着它们也共享相同的内存地址。

行为上的差异在于 Optional 是基于值的类型,意味着其内部值是其主要关注点。像 equalshashCodetoString 这样的方法仅基于内部值,并忽略实际对象标识。因此,你应该将 Optional 实例视为可互换的,并且不适合用于像同步并发代码这样的身份相关操作,正如官方文档所述¹¹。

性能开销

使用 Optionals 时另一个需要考虑的点是性能影响,特别是在它们作为返回类型之外的主要设计目标时。

对于简单的 null 检查来说,Optionals 容易被误用,并提供一个回退值,如果内部值不存在:

// DON'T DO THIS

String value = Optional.ofNullable(maybeNull)
                       .orElse(fallbackValue);

// DON'T DO THIS

if (Optional.ofNullable(maybeNull).isPresent()) {
  // ...
}

这样简单的 Optional 流水线需要一个新的 Optional 实例,每个方法调用都会创建一个新的堆栈帧,这使得 JVM 不能像简单的 null 检查那样轻松优化你的代码。创建 Optional 除了检查存在性或提供回退外,并没有太多意义。

使用三元运算符或直接的 null 检查应该是你的首选解决方案:

// DO THIS INSTEAD

String value = maybeNull != null ? maybeNull
                                 : fallbackValue;

// DO THIS INSTEAD

if (maybeNull != null) {
  // ...
}

使用 Optional 而不是三元运算符可能看起来更美观,避免重复使用 maybeNull。减少实例创建和方法调用通常是可取的。

如果你仍然想要一个更美观的替代三元运算符的方法,Java 9 在 java.util.Objects 上引入了两个 static 帮助方法,用于检查null并提供替代值:

  • T requireNonNullElse(T obj, T defaultObj)

  • T requireNonNullElseGet(T obj, Supplier<? extends T> supplier)

回退值,或者在第二种方法中,Supplier 的结果,也必须是非null的。

为了避免由于意外的NullPointerException导致的崩溃,节省几个 CPU 循环毫无意义。就像流一样,性能和更安全、更简单的代码之间存在权衡。你需要根据你的需求找到这些之间的平衡。

集合的特殊考虑

null 是值缺失的技术表示。Optionals 为你提供了一种工具,可以安全地表示这种缺失,并使用实际对象进行进一步的转换、过滤等操作。然而,基于集合的类型已经可以表示其内部值的缺失。

集合类型已经是一个处理值的“盒子”,所以将其包装在Optional<T>中将创建另一个必须处理的层。空集合已经表示内部值的缺失,因此使用空集合作为null的替代品消除了可能的NullPointerException 以及 使用 Optional 时的额外层次。

当然,你仍然必须处理集合本身的缺失,意味着一个null引用。如果可能的话,你不应该对集合使用null,无论是作为参数还是返回值。设计你的代码始终使用空集合而不是null将具有与 Optional 相同的效果。如果你仍然需要区分null和空集合,或者相关的代码不在你的控制范围内或无法更改,那么使用null检查可能仍然优于引入另一层来处理。

Optionals 和序列化

Optional<T> 类型和原始变体不实现 java.io.Serializable,因此它们不适用于可序列化类型中的私有字段。这个决定是设计小组故意做出的,因为 Optionals 应该提供可选返回值的可能性,而不是成为空引用的通用解决方案。使Optional<T>可序列化将鼓励超出其预期设计目标的用例。

为了仍然在你的对象中获得 Optionals 的好处并保持可序列化性,你可以将它们用于你的public API,但在实现细节中使用非 Optional 字段,就像示例 9-12 中所示的那样

例子 9-12. 在可序列化类型中使用 Optionals
public class User implements Serializable {

  private UUID id;
  private String username;
  private LocalDateTime lastLogin;

  // ... usual getter/setter for id and username

  public Optional<LocalDateTime> getLastLogin() {
    return Optional.ofNullable(this.lastLogin);
  }

  public void setLastLogin(LocalDateTime lastLogin) {
    this.lastLogin = lastLogin;
  }
}

通过仅依赖于获取器对lastLogin的 Optional,在类型保持可序列化的同时仍提供 Optional API。

对空引用的最终思考

尽管被称为“价值十亿美元的错误”,null 本身并不邪恶。null 的发明者,查尔斯·安东尼·理查德·霍尔爵士(Sir Charles Antony Richard Hoare)认为,编程语言设计者应对使用其语言编写的程序中的错误负责¹²。

一种语言应该提供一个坚实的基础,具有很多创造性和控制性。允许存在 null 引用只是 Java 的许多设计选择之一,不过也不仅仅是这样。正如在第十章中解释的“捕获或声明要求”和 try-catch 块为你提供了应对明显错误的工具一样。但是由于 null 是任何类型的有效值,每个引用都可能会导致崩溃。即使你认为某些东西永远不会是 null,经验告诉我们,某个时刻可能是可能的。

存在 null 引用并不能使一种语言被认为设计不良。null 有其存在的理由,但这要求你对自己的代码更加注意。这并不意味着你应该将代码中的每个变量和参数都用 Optionals 替换。

Optionals 的初衷是提供一个有限的机制来处理可选的返回值,所以不要因为方便而过度使用或误用它们。在你控制的代码中,你可以对引用的可能为空性做更多的假设和保证,并相应地处理它,即使没有 Optionals 也可以。如果你遵循本书中强调的其他原则,比如小型、自包含、无副作用的纯函数,那么确保你的代码不会意外返回 null 引用就更容易了。

总结

  • Java 中没有语言级别或特殊语法可用于处理 null

  • null 是一个特殊情况,可以代表“不存在”和“未定义”两种状态,而你无法区分它们。

  • Optional<T> 类型允许使用操作链和后备来专门处理这些状态。

  • 还有针对基本类型的专门类型,尽管它们不提供功能平等性。

  • 其他处理 null 的方法也存在,比如注解或最佳实践。

  • 并非所有情况都适合使用 Optional。如果数据结构已经有了空值的概念,比如集合,再添加一层反而会适得其反。除非需要表示“未定义”状态,否则不应该将其包装成 Optional。

  • Optionals 和 Streams 之间的互操作性很好,几乎没有摩擦。

  • Optionals 不可序列化,所以如果需要序列化类型,则不要将它们用作私有字段。相反,将 Optionals 用作 getter 的返回值。

  • 存在替代实现,比如Google Guava 框架,尽管 Google 自己建议使用 Java 的 Optional。

  • null 本身并不邪恶。不要没有充分理由就用 Optional 替换每个变量。

¹ 变长参数不接受单独的null作为参数,因为它是不精确的参数类型,可能代表ObjectObject[]。要向变长参数传递单个null,需要将其包装在数组中:new Object[]{ null }

² 许多编程语言都有专门的运算符,用于在可能为null的引用上安全调用字段或方法。安全导航运算符维基百科文章中详细说明并提供了多种语言的示例。

³ null合并运算符类似于缩短的三元运算符。表达式x != null ? x : y缩短为x ?: y,其中?:(问号冒号)是运算符。不过,并非所有语言使用相同的运算符。维基百科文章概述了支持哪种运算符形式的不同编程语言。

⁴ Java 的 JIT(即时)编译器执行多种优化以改善执行代码。必要时,当有更多关于其执行方式的信息时,它会重新编译代码。有关可能的优化概述可在Open JDK Wiki上找到。

⁵ 提供标记注释最常见的库是FindBugz(适用于 Java 8 及以前),以及其精神继承者SpotBugz。JetBrains,IntelliJ IDE 和 JVM 语言Kotlin的创建者,也提供了一个包含注解的包

Checker Framework提供了这种在不同工具之间的“非标准”行为的示例

⁷ Guava 的Optional文档明确提到应优先使用 JDK 的变体。

⁸ McCabe, TJ. 1976. “A Complexity Measure” IEEE Transactions on Software Engineering, December 1976, Vol. SE-2 No. 4, 308–320

Optional reduce​(BinaryOperator accumulator)文档

¹⁰ SonarSource规则RSPEC-2789检查 Optional 是否为null

¹¹ 官方文档 明确提到了不可预测的标识方法行为作为“API 注释”。

¹² 查尔斯·安东尼·理查德·霍尔爵士在 2009 年的 “空引用:十亿美元的错误” 演讲中表达了这一观点。

第十章:函数式异常处理

尽管我们希望编写完美且无错误的代码,这几乎是不可能的事情。这就是为什么我们需要一种处理代码中不可避免问题的方法。Java 处理这种破坏性和异常控制流条件的机制选择是异常。

异常处理可能会很棘手,即使在命令式和面向对象的代码中也是如此。然而,将异常与函数式方法结合使用确实是一个真正的挑战,因为这些技术充满了考虑和要求。虽然有第三方库可以帮助解决这个问题,但长期依赖它们可能会因为引入新的依赖而导致技术债务,而不是全面采用更加函数化的方法。

本章将展示不同类型的异常及其在带有 lambda 的函数式编程中的影响。你将学习如何在 lambda 中处理异常,以及在函数上下文中处理控制流中断的替代方法。

Java 异常处理简介

通常,异常是程序执行过程中发生的特殊事件,会打断正常指令流。这个概念不仅存在于 Java 中,而且在许多其他编程语言中都有,并可以追溯到 Lisp 的起源¹。

实际上,异常处理的形式取决于编程语言。

try-catch

Java 的选择机制是 try-catch-块,它是语言的一个组成部分。

try {
  return doCalculation(input);
} catch (ArithmeticException e) {
  this.log.error("Calculation failed", e);
  return null;
}

它的整体概念自其创始以来略有发展。现在,你可以使用 multi-catch 块通过在它们的类型之间使用 |(管道)来捕获多个异常,而不需要多个 catch 块:

try {
  return doCalculation(input);
} catch (ArithmeticException | IllegalArgumentException e) {
  this.log.error("Calculation failed", e);
  return null;
}

如果需要处理资源,可以使用 try-with-resources 结构来自动关闭任何实现了 AutoCloseable 接口的资源:

try (var fileReader = new FileReader(file);
     var bufferedReader = new BufferedReader(fileReader)) {

    var firstLine = bufferedReader.readLine();
    System.out.println(firstLine);
} catch (IOException e) {
  System.err.printlin("Couldn't read first line of " + file);
}

无论你使用哪种变体,最终都会因为异常而打断代码的执行流程,从抛出异常的原点跳转到调用堆栈中最近的 catch 点,或者如果没有可用的话,会导致当前线程崩溃。

异常和错误的不同类型

在 Java 中有三种控制流中断类型,对于它们在代码中的处理有不同的要求:已检查未检查异常,以及错误

已检查异常

已检查异常是在正常控制流之外预期且有可能可恢复的事件。例如,你应该始终考虑到文件丢失 (FileNotFoundException) 或无效 URL (MalformedURLException) 的可能性。因为它们是预期的,所以必须遵循 Java 的捕获或指定要求。

未检查异常

未经检查的异常,另一方面,不可预料,通常是不可恢复的,例如:

  • 如果遇到不支持的操作,会抛出UnsupportedOperationException

  • 对于无效的数学计算,会抛出ArithmeticException

  • 如果遇到空引用,会抛出NullPointerException

它们不被视为方法的公共合同部分,而是代表了如果破坏任何假定的合同前提条件会发生什么。因此,这些异常不受捕获或指定要求的限制,通常方法即使知道在某些条件下会抛出它们,也不会用throws关键字表示它们。

然而,未经检查的异常仍然必须以某种形式处理,如果不希望程序崩溃的话。如果在本地没有处理,异常会自动上升到当前线程的调用堆栈,直到找到合适的处理程序。或者,如果找不到任何处理程序,线程将终止。对于单线程应用程序,运行时会终止,程序将崩溃。

错误

第三种控制流中断 — 错误 — 指的是在正常情况下不应捕获或无法处理的严重问题。

例如,如果运行时内存不足,运行时会抛出OutOfMemoryError。或者无限递归调用最终会导致StackOverflowError。在没有剩余内存的情况下,无论是堆还是栈都没有什么你可以做的。故障硬件是 Java 错误的另一个来源,例如磁盘错误的情况下会抛出java.io.IOError。这些都是严重且不可预料的问题,几乎没有可能优雅地恢复。这就是为什么错误不必遵循捕获或指定的要求。

Java 中的异常层次结构

异常属于哪个类别取决于其基类。所有异常都是经过检查的,除了那些继承自java.lang.RuntimeExceptionjava.lang.Error的类型。但它们共享一个共同的基类型:java.lang.Throwable。继承自后两者的类型要么是未经检查的,要么是一个错误。异常类型层次结构如图 10-1 所示。

Java 异常类型的层次结构

图 10-1. Java 中的异常层次结构

在编程语言中有不同类型的异常的概念相当不常见,并且由于它们在处理方式上的不同需求,是一个有争议的讨论话题。例如,Kotlin²继承了处理异常的一般机制,但没有任何经过检查的异常。

Lambda 表达式中的经过检查的异常

Java 的异常处理机制设计时考虑了特定的需求,这是在引入 lambda 18 年之前的事情。这就是为什么抛出和处理异常在新的函数式 Java 编码风格中不太适合的原因,除非特别考虑或完全忽略捕获或声明要求。

让我们来看看使用java.util.Files上的静态方法加载文件内容的方法签名如下:

public static String readString(Path path) throws IOException {
  // ...
}

方法签名非常简单,并且指示可能抛出检查的IOException,因此需要一个try-catch块。这就是为什么该方法不能作为方法引用或简单 lambda 使用的原因:

Stream.of(path1, path2, path3)
      .map(Files::readString)
      .forEach(System.out::println);

// Compiler Error:
// incompatible thrown types java.io.IOException in functional expression

问题源自于满足map操作所需的函数式接口。JDK 的函数式接口中没有一个抛出受检异常,因此它们与任何可能抛出异常的方法都不兼容。

注意

有些被@FunctionalInterface标记的接口会抛出异常,比如java.util.concurrent.Callable<V>。它们从定义上来说是函数式接口,但这是为了兼容性考虑,而不是因为它们可以随意表示函数式类型。

最明显的解决方案是使用try-catch块,将 lambda 转换为基于块的形式:

Stream.of(path1, path2, path3)
      .map(path -> {
        try {
          return Files.readString(path);
        } catch (IOException e) {
          return null;
        }
      })
      .forEach(System.out::println);

满足编译器要求的代码反而破坏了流式处理 lambda 的目的。操作的简洁性和直接性表达被异常处理所需的样板代码稀释了。

在 lambda 中使用异常几乎感觉像是一种反模式。throws声明表明调用者必须决定如何处理该异常,而 lambda 没有专门处理异常的方式,除了预先存在的try-catch,这不能用于方法引用。

尽管如此,仍然有某些处理异常的方式,可以在不失去(大部分)lambda、方法引用和流式处理(如 Streams 或 Optionals)的简洁和清晰性的情况下使用:

  • 安全的方法提取

  • 不检查异常

  • Sneaky throws

所有这些选项都是在函数式代码中缓解异常处理的不完美解决方案。尽管如此,我们将逐个查看它们,因为在某些情况下它们可能很有用,如果没有内置的正确处理异常的方式。

最后两者甚至可能是危险的,或者至少会成为代码异味,如果不明智地使用。尽管如此,了解这样的“最后手段”工具可以帮助您处理更复杂的预先存在的非函数式代码结合,以及提供更加函数化的方法。

安全的方法提取

在函数式代码中高效处理异常取决于谁有效地控制或拥有代码。如果抛出异常的代码完全在您的控制范围内,您应该始终充分处理它们。但通常,冒犯的代码是您自己的,或者您无法根据需要更改或重构它。这时,您仍然可以将其提取到一个具有适当局部异常处理的“更安全”的方法中。

创建一个“安全”方法将实际工作与处理任何异常解耦,恢复了调用者负责处理任何已检查异常的原则。任何函数式代码都可以使用安全方法,如 Example 10-1 中所示。

Example 10-1. 将抛出的代码提取到一个安全方法中
String safeReadString(Path path) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  try { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return Files.readString(path);
  } catch (IOException e) {
    return null;
  }
}

Stream.of(path1, path2, path3)
      .map(this::safeReadString) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      .filter(Objects::nonNull) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
      .forEach(System.out::println);

1

“安全”方法具有与throws IOException不同的方法签名。

2

异常在本地处理并返回适当的回退。

3

包装器方法可以用作方法引用,使代码简洁且易读。

4

必须相应地处理null元素的可能性。

流水线再次简洁明了。IOException被处理,以使其不影响流水线,但这种方法并非“一刀切”。

安全方法提取类似于外观模式的更局部化版本⁠³。与包装整个类以提供更安全的、上下文特定接口不同,只有特定方法获得新外观以改进它们的处理以适应特定用例。这减少了受影响的代码量,但仍为您提供了外观的优点,如减少复杂性和提高可读性。这也是未来重构工作的良好起点。

提取的安全方法可能比在 lambda 中使用try-catch块更好,因为您保留了内联 lambda 和方法引用的表现力,并有机会处理任何异常。但是,处理被限制在另一个抽象层上的现有代码以恢复对干扰性控制流条件的控制。方法的实际调用者——流操作——没有处理异常的机会,使得处理不透明且不灵活。

取消检查异常

处理已检查异常的下一种方法与最初使用已检查异常的基本目的背道而驰。不直接处理已检查异常,而是将其隐藏在未检查异常中以绕过捕获或指定要求。这是一个毫无意义但有效的使编译器满意的方法。

这种方法使用专门的功能接口,这些接口使用throws关键字来包装有问题的 lambda 或方法引用。它捕获原始异常并将其重新抛出为未检查的RuntimeException或其兄弟姐妹之一。这些功能接口扩展了原始接口,以确保兼容性。原始的单抽象方法使用一个default实现将其连接到抛出异常的方法,如示例 10-2 所示。

示例 10-2. 取消检查java.util.Function
@FunctionalInterface
public interface ThrowingFunction<T, U> extends Function<T, U> { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  U applyThrows(T elem) throws Exception; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  @Override
  default U apply(T t) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    try {
      return applyThrows(t);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  public static <T, U> Function<T, U> uncheck(ThrowingFunction<T, U> fn) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    return fn::apply;
  }
}

1

包装器扩展了原始类型以充当可插入替换。

2

单抽象方法(SAM)模仿原始方法,但会抛出异常。

3

原始的 SAM 被实现为一个default方法,用于将任何异常包装为RuntimeException

4

一个static助手用于取消检查任何抛出的Function<T, U>,以规避捕获或指定的要求。

ThrowingFunction<T, U>类型可以通过调用uncheck方法显式使用,也可以像在示例 10-3 中看到的那样隐式使用。

示例 10-3. 使用ThrowingFunction<T, U>
ThrowingFunction<Path, String> throwingFn = Files::readString; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

Stream.of(path1, path2, path3)
      .map(ThrowingFunction.uncheck(Files::readString)) ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      .filter(Objects::nonNull)
      .forEach(System.out::println);

1

任何抛出异常的方法都可以通过方法引用分配为ThrowingFunction,并在需要Function的上下文中使用。

2

或者,可以通过使用static助手uncheck来即时取消检查的抛出 lambda 或方法引用。

恭喜,编译器再次欣然接受,并且不再强制您处理异常。包装器类型没有解决原始可能导致控制流中断的问题,但将其隐藏在视线之外。如果流水线中出现任何异常,仍会爆炸,而没有任何可能的本地化异常处理。

警告

抛出异常的功能接口只是掩盖其异常状态。它们有其用途并且可能非常有用,但不应该被视为首选解决方案,而应该作为最后的手段。

隐式抛出异常

隐式抛出异常习语是一种方法,用于在方法签名中不声明throws关键字的情况下抛出已检查的异常。

而不是在方法体中使用throw关键字抛出已检查的异常,这要求在方法签名中使用throws声明,实际异常是通过另一个方法抛出的,如下所示:

String sneakyRead(File input) {

  // ...

  if (fileNotFound) {
    sneakyThrow(new IOException("File '" + file + "' not found."));
  }

  // ...
}

实际的异常抛出被委托给sneakyThrow方法。

等等,难道使用像sneakyThrow这样的方法抛出已检查的异常的人不必遵循捕获或指定的要求吗?

嗯,这里有一个例外(双关语)。你可以利用 Java 8 关于泛型和异常的类型推断的变化⁴。简单来说,如果一个泛型方法签名没有上限或下限,并且有throws E,编译器会假定类型ERuntimeException。这使你可以创建以下的sneakyThrow

<E extends Throwable> void sneakyThrow(Throwable e) throws E {
  throw (E) e;
}

不管参数e的实际类型如何,编译器都假定throws ERuntimeException,从而豁免了方法的捕获或指定要求。编译器可能不会抱怨,但这种方法是非常有问题的。

sneakyRead的方法签名不再表示其已检查的异常。已检查的异常应该被预期并且是可恢复的,因此属于方法的公共契约。通过删除throws关键字并规避捕获或指定的要求,你减少了传给调用者的信息量,使方法的公共契约更为不透明,以便获得便利性的原因。你仍然可以——而且应该——在方法的文档中列出所有异常及其原因。

该方法不再遵循通过绕过throws关键字和强制执行捕获或指定要求的“正常推理”。阅读代码的任何人都必须知道sneakyThrow的作用。你可以在调用后添加一个适当的return语句,至少表明这是一个退出点。但是失去了throws关键字所发出的意义。

警告

Sneaky throws 绕过了 Java 语言处理控制流中的一个重要部分。在一些内部实现的边缘情况下是有用的。然而,在像public方法这样的外部代码中,悄悄地抛出异常会打破任何 Java 开发者都能预期到的合理预期的方法和调用者之间的契约。

悄悄地抛出异常可能是内部代码的可接受的“最后手段”黑客方法,但你仍然必须通过上下文、方法名称和文档来传达其影响。在下一节中,我将展示一个在专门的内部代码实现中悄悄地抛出异常的合理用例。

异常的功能性方法

到目前为止,我只讨论了如何通过忽略和规避异常的预期目的来“强制执行”Java 的异常处理机制,使其与 Lambda 表达式协同工作。真正需要的是在功能性方法和传统构造之间找到一个合理的折衷和平衡。

你的选择包括设计你的代码根本不抛出异常,或者模仿其他更为功能性语言的异常处理方法。

不抛出异常

检查异常是方法合同的一个重要部分,并被设计为控制流的中断。这正是使处理它们变得如此困难的原因!因此,与其寻找更好的处理检查异常及其所有复杂性的方法,我们不如在功能上下文中寻找另一种处理控制流中断的替代方式。

"安全方法提取"讨论了一种通过用非抛出异常的“更安全”方法包装抛出异常的方法来替代抛出异常的方法的变体。如果您无法控制代码并且不能设计它以避免首次抛出异常,则此方法会有所帮助。它用Optional<T>替换了异常形式的破坏性控制流事件来表示“异常”状态:如果您能控制 API,可以设计其合同以避免使用异常或者至少使它们更加可管理。异常是对某种非法状态的反应。避免异常处理的最佳方法是在第一时间使这种非法状态的表示变得不可能。

我在第九章中讨论过,Optionals 是一个“盒子”,用于包装实际值。它是一种专门的类型,表示值的存在或不存在,而不会出现风险遇到null引用和最终可怕的NullPointerException

让我们再次看看之前的例子。不过这次,让我们使用 Optional 而不是抛出异常,正如在示例 10-4 中所见。

示例 10-4。使用Optional<String>而不是抛出IOException
Optional<String> safeReadString(Path path) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  try {
    var content = Files.readString(path);
    return Optional.of(content);
  } catch (IOException e) {
    return Optional.empty(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  }
}

1

使用Optional<String>而不是简单的String

2

返回一个Optional<String>,要么是文件内容,要么是空内容(在IOException的情况下),返回一个有效的非null对象。

返回Optional<String>比简单返回String具有两个优点。首先,返回了一个有效的对象,因此不需要额外的null检查来安全地使用它。其次,Optional 类型是处理内部值或其缺失的流畅函数管道的起点。

如果您的 API 不暴露任何需要控制流中断的非法状态,您或者调用这些方法的任何人都不必处理它们。Optionals 是一个简单且易于使用的选择,尽管它缺少一些理想的特性。新的safeReadString表明它无法读取文件,但不告诉您为什么无法这样做。

错误作为值

Optional<T> 仅提供值的存在与不存在之间的差异,而专用的 结果对象 提供了关于为什么操作可能失败的更多信息。表示操作整体结果的专用类型的概念并不新鲜。它们是包装对象,指示操作是否成功,并包括一个值或者如果不成功,原因。许多语言支持动态元组作为返回类型,因此你不需要像在 Go 中那样显式地表示你的操作类型:

func safeReadString(path string) (string, error) {
  // ...
}

content, err := safeReadString("location/content.md")
if err != nil {
  // error handling code
}

尽管 Java 缺乏这样的动态元组,但通过泛型,可以创建一个多功能且功能倾向的结果类型,利用本书讨论的工具和概念。

让我们一起创建一个基本的 Result<V, E extends Throwable> 类型。

创建支架

Result 类型的主要目标是保存可能的值,或者如果不成功,则保存表示失败原因的异常。

“传统”结果对象可以像在 Example 10-5 中展示的记录一样实现。

Example 10-5. 传统的 Result 对象
public record Result<V, E extends Throwable>(V value, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                                             E throwable,
                                             boolean isSuccess) {

  public static <V, E extends Throwable> Result<V, E> success(V value) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return new Result<>(value, null, true);
  }

  public static <V, E extends Throwable> Result<V, E> failure(E throwable) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return new Result<>(null, throwable, false);
  }
}

1

记录组件反映了不同的状态。显式的 isSuccess 字段有助于更好地确定操作是否成功,并支持 null 作为有效值。

2

方便的工厂方法提供了更表现力的 API。

即使这个简单的支架已经比使用 Optional 提供了一定的改进,方便的工厂方法是创建适当结果的表达性方式。

先前的 safeReadString 示例可以很容易地转换为使用 Result<V,E> 类型,如 Example 10-6 中所示。

Example 10-6. 使用 Result<V, E> 作为返回类型
Result<String, IOException> safeReadString(Path path) {
  try {
    return Result.success(Files.readString(path));
  } catch (IOException e) {
    return Result.failure(e);
  }
}

Stream.of(path1, path2, path3)
      .map(this::safeReadString)
      .filter(Result::isSuccess)
      .forEach(System.out::println);

这种新类型在流管道中使用起来与 Optional 一样简单。但真正的力量来自于通过引入依赖于成功状态的高阶函数,赋予它更多的功能属性。

使 Result<V, E> 具有功能性

Optional<T> 类型的一般特性为进一步改进 Result 类型提供了灵感,包括:

  • 转换其值或异常

  • 对异常的反应

  • 提供回退值

转换 valuethrowable 字段需要专用的 map 方法或组合方法来同时处理两种用例,如 Example 10-7 中所示。

Example 10-7. 向 Result<V, E> 添加转换器
public record Result<V, E extends Throwable> (V value,
                                              E throwable,
                                              boolean isSuccess) {
  // ...

  public <R> Optional<R> mapSuccess(Function<V, R> fn) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    return this.isSuccess ? Optional.ofNullable(this.value).map(fn)
                          : Optional.empty();
  }

  public <R> Optional<R> mapFailure(Function<E, R> fn) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    return this.isSuccess ? Optional.empty()
                          : Optional.ofNullable(this.throwable).map(fn);
  }

  public <R> R map(Function<V, R> successFn, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                   Function<E, R> failureFn) {
    return this.isSuccess ? successFn.apply(this.value) //
                          : failureFn.apply(this.throwable);
  }
}

1

这些单一映射方法非常相似,并转换相应的结果,成功或失败。这就是为什么两者必须返回一个 Optional 而不是具体值的原因。

2

通过组合的map方法,您可以在单个调用中处理成功或失败的两种情况。因为两种状态都被处理了,所以返回的是一个具体的值,而不是Optional

在映射方法的帮助下,现在您可以直接处理其中一个或两个情况,如下所示:

// HANDLE ONLY SUCCESS CASE

Stream.of(path1, path2, path3)
      .map(this::safeReadString)
      .map(result -> result.mapSuccess(String::toUpperCase))
      .flatMap(Optional::stream)
      .forEach(System.out::println);

// HANDLE BOTH CASES

var result = safeReadString(path).map(
  success -> success.toUpperCase(),
  failure -> "IO-Error: " + failure.getMessage()
);

还需要一种方法来处理Result,而无需首先转换其值或异常。

为了对特定状态做出反应,让我们添加ifSuccessifFailurehandle,如下所示:

public record Result<V, E extends Throwable> (V value,
                                              E throwable,
                                              boolean isSuccess) {
  // ...

  public void ifSuccess(Consumer<? super V> action) {
    if (this.isSuccess) {
      action.accept(this.value);
    }
  }

  public void ifFailure(Consumer<? super E> action) {
    if (!this.isSuccess) {
      action.accept(this.throwable);
    }
  }

  public void handle(Consumer<? super V> successAction,
                     Consumer<? super E> failureAction) {
    if (this.isSuccess) {
      successAction.accept(this.value);
    } else {
      failureAction.accept(this.throwable);
    }
  }
}

实现几乎与映射方法相同,只是它们使用Consumer而不是Function

注意

这两个附加功能仅限于副作用,因此在纯函数意义上并不是非常“函数式”。尽管如此,这些附加功能在命令式和函数式方法之间提供了一个很好的权宜之计。

接下来,让我们添加一些方便的方法来提供回退值。最明显的是orElseorElseGet,如下所示:

public record Result<V, E extends Throwable>(V value,
                                             E throwable,
                                             boolean isSuccess) {
  // ...

  public V orElse(V other) {
    return this.isSuccess ? this.value
                          : other;
  }

  public V orElseGet(Supplier<? extends V> otherSupplier) {
    return this.isSuccess ? this.value
                          : otherSupplier.get();
  }
}

这里没有什么意外。

然而,作为重新抛出内部Throwable的快捷方式添加orElseThrow并不是那么直接,因为它仍然必须遵守捕获或指定的要求。这实际上是我之前提到过的有关使用“偷偷抛出”的唯一可接受的用例,如在“偷偷抛出”中讨论的那样,以规避此要求:

public record Result<V, E extends Throwable>(V value,
                                             E throwable,
                                             boolean isSuccess) {
  // ...

  private <E extends Throwable> void sneakyThrow(Throwable e) throws E {
    throw (E) e;
  }

  public V orElseThrow() {
    if (!this.isSuccess) {
      sneakyThrow(this.throwable);
      return null;
    }

    return this.value;
  }
}

在这种特定情况下,由于orElseThrow()的一般上下文和公共契约,我认为“偷偷抛出”是合理的。就像Optional<T>一样,该方法强制解开可能结果的“盒子”,并用其名称警告您可能发生的异常。

还有很多可以期待的地方,比如添加一个Stream<V> stream()方法,以便更好地集成到 Stream 流水线中。尽管如此,这种一般方法是如何结合函数概念以提供一种处理破坏性控制流事件的替代方法的良好实践。本书中展示的实现非常简化,并减少到最少的代码量。

如果您打算使用类似Result<V, E>的类型,您应该查看 Java 生态系统中的某个函数库。像vavrjOOλ(发音为“JOOL”)和Functional Java这样的项目提供了非常全面和经过实战检验的实现,可以直接使用。

尝试/成功/失败模式

Scala 可以说是 JVM 上可用的最接近 Java 的函数式语言,不考虑 Clojure,因为后者具有更外来的语法和动态类型系统。它解决了许多 Java 被认为有的“缺陷”,并且从根本上是函数式的,包括处理异常条件的优秀方式。

尝试/成功/失败模式及其相关类型Try[+T]⁠⁵,Success[+T]Failure[+T],是 Scala 处理异常的一种更函数式方式。

Optional<T> 表示值可能丢失,而 Try[+T] 可以告诉你 为什么 并提供处理任何发生的异常的可能性,类似于本章前面讨论的 Result 类型。如果代码成功,将返回一个 Success[+T] 对象,如果失败,错误将包含在一个 Failure[+T] 对象中。Scala 还支持 模式匹配,一种处理不同结果的 switch-like 概念。这允许相当简洁直接的异常处理,而无需 Java 开发者通常习惯的样板代码。

注意

自 Java 17 起,Java 的 switch 结构可以使用类似 Scala 的模式匹配作为预览功能⁶。

Try[+T] 可以处于 Success[+T]Failure[+T] 状态,后者包含一个 Throwable。即使没有完全了解 Scala 语法,示例 10-8 中的代码对于 Java 开发者来说也不应该太陌生。

示例 10-8. Scala 的 Try/Success/Failure 模式
def readString(path: Path): Try[String] = Try { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  // code that will throw an Exception }

val path = Path.of(...);

readString(path) match { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  case Success(value) => println(value.toUpperCase) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  case Failure(e) => println("Couldn't read file: " + e.getMessage) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
}

1

返回类型是 Try[String],所以该方法必须返回一个包含 Path 内容的 Success[String],或者一个 Failure[Throwable]。Scala 不需要显式的 return 并隐式地返回最后一个值。任何异常都会被 Try { …​ } 结构捕获。

2

Scala 的模式匹配简化了结果处理。案例是 lambdas,并且整个块类似于带有 maporElse 操作的 Optional 调用链。

3

Success 提供对返回值的访问。

4

如果发生异常,将通过 Failure 情况处理。

Try[+A] 是 Scala 中的一个优秀特性,将类似于 Optional 和异常处理的概念结合到一个简单易用的类型和习惯用法中。但作为 Java 开发者,这对你意味着什么呢?

Java 没有提供任何类似 Scala try/success/failure 模式的简单性或语言集成性。

即使没有语言支持,你仍然可以尝试使用自 Java 8 以来的新功能工具来实现对 try/success/failure 模式的近似。所以现在让我们来做吧。

创建一个流水线

与流提供功能管道的起点类似,我们即将创建的 Try 类型将具有创建步骤、中间但独立的操作以及最后的终端操作来启动管道。

要复制 Scala 的功能,需要一个接受 lambda 的结构作为起点。

注意

与其他功能构造一样,需要许多变体来支持各种可用的功能接口。为了简化所需的代码,Try 类型仅支持 Function<T, R> 作为初始 lambda。

Try类型的主要要求包括:

  • 接受可能会抛出异常的 lambda

  • 提供success操作

  • 提供failure操作

  • 使用一个值开始管道

Try类型可以通过仅支持RuntimeException来简化,但这样就不会成为常规try-catch块的灵活替代。为了规避捕获或指定的要求,讨论的ThrowingFunction接口在"异常的取消检查"中讨论。

接受ThrowingFunction和可能的Function来处理RuntimeException的最小支架如示例 10-9 所示。

示例 10-9. 最小的Try<T, R>接受 lambda 和异常处理程序
public class Try<T, R> { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  private final Function<T, R>                fn; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  private final Function<RuntimeException, R> failureFn; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  public static <T, R> Try<T, R> of(ThrowingFunction<T, R> fn) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    Objects.requireNonNull(fn);

    return new Try<>(fn,
                     null);
  }

  private Try(Function<T, R> fn, ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
              Function<RuntimeException, R> failureFn) {
    this.fn = fn;
    this.failureFn = failureFn;
  }
}

1

泛型类型TR对应于Function<T, R>。使用class而不是record来隐藏唯一的构造函数。

2

构造需要保存初始的Function<T, R>和可能的错误处理Function<RuntimeException, R>。这两个字段都是final,使得Try类型是不可变的。

3

static工厂方法of提供了与其他函数管道类似的接口。它接受一个ThrowingFunction<T, R>来规避捕获或指定的要求,但立即将其分配给一个Function<T, R>

4

private构造函数强制使用工厂方法。

即使类型不起作用,从现有的 lambda 或方法引用创建新的管道也非常简单,如下所示:

var trySuccessFailure = Try.<Path, String> of(Files::readString);

of调用前的类型提示是必需的,因为编译器不能从周围的上下文中推断出类型。

接下来,该类型需要处理成功和失败。

处理成功和失败

需要两个新方法来处理Try管道的结果,即successfailure,如示例 10-10 中所示。

示例 10-10. 在Try<T, R>中处理成功和失败
public class Try<T, R> {

  // ...

  public Try<T, R> success(Function<R, R> successFn) {
    Objects.requireNonNull(successFn);

    var composedFn = this.fn.andThen(successFn); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    return new Try<>(composedFn,
                     this.failureFn);
  }

  public Try<T, R> failure(Function<RuntimeException, R> failureFn) {
    Objects.requireNonNull(failureFn);

    return new Try<>(this.fn, ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
                     failureFn);
  }
}

1

successFn被组合到原始的 lambda 中,为新的Try实例提供基础。failureFn则按原样使用。

2

处理错误只需要通过原始的fn和提供的failureFn

因为Try类型设计为不可变的,两个处理方法都返回一个新的Try实例。success方法使用函数组合来创建完全所需的任务,而failure方法则使用预先存在的 lambda 和提供的错误处理Function创建一个新的Try实例。

通过使用功能组合来进行success操作,而不是使用额外的控制路径,例如将successFn存储在另一个字段中,即使在没有对初始 lambda 的结果进行修改的情况下,也不需要处理程序。

使用处理程序方法就像您所期望的那样,并且感觉与使用流(Stream)的中间操作类似,如下所示:

var trySuccessFailure =
  Try.<Path, String> of(Files::readString)
                    .success(String::toUpperCase)
                    .failure(str -> null);

不过与流(Stream)不同的是,这些操作是彼此独立的,不是顺序管道。这更类似于可选值(Optionals)管道似乎是顺序的,但实际上有要遵循的轨迹。哪个处理操作,successfailure,应该进行评估取决于Try评估的状态。

是时候启动管道了。

运行管道

完成管道所需的最后一个操作是能够将值推送到管道下游并让处理程序完成其工作,形式上为一个apply方法,如示例 10-11 所示。

示例 10-11. 将值应用于Try
public class Try<T, R> {

  // ...

  public Optional<R> apply(T value) {
    try {
      var result = this.fn.apply(value);
      return Optional.ofNullable(result); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    }
    catch (RuntimeException e) {
      if (this.failureFn != null) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
        var result = this.failureFn.apply(e);
        return Optional.ofNullable(result);
      }
    }

    return Optional.empty(); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }
}

1

“快乐路径”是将fn应用于值。由于将success方法设计为函数组合,因此不需要特殊处理来运行初始 lambda 和可选的success转换。代码必须在try-catch-block 中运行以处理failure情况。

2

失败处理是可选的,因此需要进行null检查。

3

如果管道中没有添加错误处理程序,则此点是终极回退。

返回类型Optional<R>为功能管道提供了另一个起点。

现在我们的极简Try管道已经包含了调用一个可能抛出异常的方法并处理成功和失败情况所需的所有操作:

var path = Path.of("location", "content.md");

Optional<String> content =
  Try.<Path, String> of(Files::readString)
                    .success(String::toUpperCase)
                    .failure(str -> null)
                    .apply(path);

即使Try管道提供了高阶函数操作来处理可能抛出异常的 lambda,但管道本身在外部并不是功能性的。还是吗?

我选择的终端操作名称apply揭示了Try可能实现的功能接口,以便在其他功能管道中更容易使用,如流(Streams)或可选值(Optionals):Function<T, Optional<R>>

通过实现功能接口,Try类型变成了任何Function的即插即用替代品,无需实际逻辑更改,如示例 10-12 所示:

示例 10-12. 实现Function<T, Optional<R>>
public class Try<T, R> implements Function<T, Optional<R>> {

  // ...

  @Override
  public Optional<R> apply(T value) {
    // ...
  }
}

现在,任何Try管道都可以轻松在接受Function的任何高阶函数中使用,例如在流(Stream)的map操作中,如下所示:

Function<Path, Optional<String>> fileLoader =
  Try.<Path, String> of(Files::readString)
                    .success(String::toUpperCase)
                    .failure(str -> null);

Stream.of(path1, path2, path3)
      .map(fileLoader)
      .flatMap(Optional::stream)
      .toList();

与之前的Result一样,Try类型非常简约,应该被视为如何组合功能概念以创建新构造的一种练习,比如由高阶函数组成的惰性流畅管道。如果你想使用类似Try的类型,你应该考虑使用像vavr这样的成熟的功能第三方库,它提供了多功能的Try类型等等。

函数式异常处理的最后思考

我们代码中的突发和异常的控制流条件是不可避免的,这就是为什么我们需要一种处理它们的方法。异常处理有助于提高程序的安全性。例如,捕获或指定的要求旨在让您考虑预期的异常状态并相应地处理它们,以提高代码质量。虽然它肯定是有用的,但也很难实施。

在 Java 中处理异常可能是一个非常棘手的问题,无论使用何种功能方法。无论您选择哪种异常处理方法,总是有一个权衡:

  • 提取不安全方法以获取局部化的异常处理是一个不错的折衷方案,但不是一个易于使用的通用解决方案。

  • 设计您的 API 以不具有任何异常状态并不像听起来那么简单。

  • 取消选中您的异常是一个“最后手段”的工具,它将异常隐藏起来而没有机会处理它们,这与它们的目的相矛盾。

那么你应该怎么做呢?嗯,这取决于情况。

所有提出的解决方案都不是完美的。你必须在“方便性”和“可用性”之间找到平衡。异常有时是一个被过度使用的特性,但它们仍然是程序控制流的重要信号。长期来看,隐藏它们可能并不符合您的最佳利益,即使最终的代码更加简洁和合理,只要没有异常发生。

并非所有的命令式或面向对象的特性/技术在 Java 中都能用功能等价物替代。Java 的许多(功能性)缺陷可以通过绕过来获得它们的一般优势,即使最终的代码不像完全功能化的编程语言那样简洁。然而,异常是其中一个特性,在大多数情况下很难替代。它们通常是你应该尝试重构代码以使其“更加功能化”,或者功能方法可能不是问题的最佳解决方案的指示器之一。

或者,还有几个第三方库可供选择,如 Vavr 项目jOOλ,允许您在使用(检查的)Java 代码中规避或至少减轻问题。它们完成了所有相关包装接口的实现,并复制了其他语言的控制结构和类型,如模式匹配。但最终,您将得到高度专业化的代码,试图随心所欲地修改 Java,而不太考虑传统或常见的代码构造。依赖第三方库是一种长期承诺,不应轻率添加。

要点

  • 在函数式代码中,如 Lambda 表达式中没有专门的异常处理结构,只能像通常一样使用try-catch块,这导致代码冗长且笨拙。

  • 您可以通过多种方式实现或规避捕获或指定的要求,但这只是隐藏了原始的“问题”。

  • 自定义包装器可以提供更具功能性的方法。

  • 第三方库可以帮助减少处理异常所需的额外样板代码。但是,新引入的类型和结构对您的代码来说并非轻量级的增加,可能会产生大量技术债务。

  • 在函数式代码中选择正确处理异常的方式高度依赖于周围的上下文。

¹ Guy L. Steele 和 Richard P. Gabriel. 1996 年。《Lisp 的演变》。《编程语言历史---II。计算机协会,233-330 页》(https://doi.org/10.1145/234286.1057818)。

² 官方 Kotlin 文档强调了 Java 和 Kotlin 异常处理之间的区别。

³ Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). 设计模式:可重用面向对象软件的元素。马萨诸塞州波士顿:Addison Wesley。

⁴ 类型解析的规则在《Java SE 8 语言规范》的第 §18.4 节中列出。

⁵ Scala 的泛型类型使用[](方括号)而不是<>(尖括号)声明。+(加号)表示类型的变异性。有关类型变异的更多信息,请参见“Scala 之旅”

switch 的模式匹配首次预览在JEP 406中描述。第二个预览在Java 18中发布,其中包括JEP 420的描述。下一个发布版本,Java 19,包括在JEP 427中描述的第三个预览。该功能仍在发展中,并计划在 Java 20 中进行另一次预览,详见JEP 433

第十一章:惰性评估

虽然懒惰在人们看来经常是性格缺陷,但在一些编程语言中可以被视为有利特性。在计算机科学术语中,懒惰是代码评估的对手,与严格性 — 或渴望性 — 相对立。

本章将向您展示如何通过懒惰提高性能。您将了解严格和惰性评估之间的区别及其对代码设计的影响。

懒惰与严格性

语言的严格性描述了代码评估的语义。

严格评估尽可能快地发生,例如在声明或设置变量或将表达式作为参数传递时。然而,非严格评估发生在实际需要表达式结果时。这样,即使一个或多个子表达式无法评估,表达式仍然可以具有值。

例如,Haskell 是一种函数式编程语言,默认具有非严格语义,从最外层到最内层评估表达式。这允许您创建控制结构或无限数据序列,因为表达式的创建消费分离。

让我们来看一个简单方法的严格 Java 代码,它接受两个参数,但只使用一个来进行逻辑处理:

int add(int x, int y) {
  return x + x;
}

非严格 Haskell 等效函数声明更像变量赋值:

add x y = x + x

此函数也仅使用其第一个参数,并且根本不评估第二个参数y。这就是为什么以下 Haskell 代码仍然产生结果:

add 5 (1/0)
=> 10

如果您使用相同的参数调用此函数的 Java 等效项,值为1和表达式(1/0),它将抛出异常:

var result = add(5, (1/0));
// => java.lang.ArithmeticException: Division by zero

即使add调用的第二个参数在任何情况下都未被使用,作为严格语言的 Java 立即评估表达式。方法参数是按值传递的,这意味着它们在传递给方法之前会被评估,这在这种情况下会抛出ArithmeticException

注意

Java 的方法参数始终是按值传递的。对于非原始类型,在 JVM 中,参数作为对象句柄传递,使用一种称为引用的特殊类型。这在技术上仍然是按值传递,这使得一般术语和语义相当混乱。

相反,惰性评估被定义为仅在需要其结果时评估表达式。这意味着表达式的声明不会立即触发其评估,这使得 Java lambda 表达式成为惰性评估的完美匹配,正如在示例 11-1 中所见。

示例 11-1. Java 和 Suppliers 实现的惰性评估
int add (IntSupplier x, IntSupplier y) {

  var actualX = x.getAsInt();

  return actualX + actualX;
}

var result = add(() -> 5,
                 () -> 1 / 0);
// => 10

IntSupplier 实例的声明,或其内联等效项,是一个严格语句,并立即评估。然而,实际 lambda 体在没有显式调用getAsInt时不会评估,因此在这种情况下避免了ArithmeticException

本质上,严格性 是关于“做事情”,但 惰性 是关于“考虑要做的事情”。

Java 有多严格?

大多数编程语言既不是完全惰性也不是完全严格。Java 被认为是一种严格的语言,但在语言级别和 JDK 提供的类型中具有一些值得注意的惰性异常。

让我们来看看它们。

短路求值

Java 中提供了语言集成的惰性,采用逻辑运算符 &&(双与)和 ||(双或)进行逻辑 短路求值。这些运算符从左到右评估其操作数,仅在必要时进行评估。如果逻辑表达式由运算符左侧的表达式满足,则根本不会评估右侧的操作数,如表 11-1 中所示。

表 11-1. 逻辑短路运算符的评估

操作 leftExpr 的值 是否评估 rightExpr
leftExpr && rightExpr true
false
leftExpr &#124;&#124; rightExpr true
false

位运算逻辑运算符

类似的位运算符 &(单与)和 |(单或)进行 急切评估,并且具有与其逻辑兄弟不同的目的。位运算符比较整数类型的单个位,导致整数结果。

尽管与控制结构类似,这些逻辑操作数不能单独存在。它们必须始终作为另一个语句的一部分,例如 if-block 的条件或变量赋值,如示例 11-2 所示。对于赋值的短路求值的另一个优势是它们创建(实际上)final 参考,使它们成为与 Java 的功能方法完美匹配的选择。

示例 11-2. 逻辑短路运算符的使用
// WON'T COMPILE: unused result

left() || right();

// COMPILES: used as if condition

if (left() || right()) {
    // ...
}

// COMPILES: used as variable assignment

var result = left() || right();

如果表达式耗时或具有任何副作用,或者如果左侧无需评估则无需评估右侧表达式,则忽略右侧操作数的评估将非常有用。然而,它也可能是不评估所需表达式的来源,如果语句被短路,则需要的表达式在右侧。如果将它们作为决策的一部分,请务必仔细设计它们。

任何决策性代码都会因为纯函数而受益匪浅。预期行为简单直接,易于理解,没有任何潜在的副作用可能在重新设计或重构代码时被忽视,引入难以解决的微妙错误。您应该确保要么根本没有副作用,我认为这太绝对且通常是不现实的目标,要么命名您的方法以反映其后果。

控制结构

控制结构负责改变通过代码指令的路径。例如,if-else结构是一个条件分支,有一个(仅if)或多个(if-else)代码块。这些块根据其相应条件进行评估,这是一种惰性特性。在声明时严格评估if-else结构的任何部分将破坏其作为条件分支的目的。这种“怠惰例外于急切规则”的应用适用于所有分支和循环结构,如表 11-2 所列。

表 11-2. Java 中的惰性结构

分支控制结构 循环结构

| if-else ? :(三元运算符)

switch

catch | for while

do-while |

严格的非惰性控制结构语言很难想象,甚至可能不可能。

JDK 中的惰性类型

到目前为止,我已经讲述了 Java 的惰性如何直接内建到语言中,以操作符和控制结构的形式。然而,JDK 还提供了多种内置类型和数据结构,在运行时也具有一定程度的惰性。

惰性映射

地图的常见任务是检查键是否已经有映射值,并在缺少时提供一个。相关的代码需要多次检查和非(有效地)final变量,如下所示:

Map<String, User> users = ...;

var email = "john@doe.com";

var user = users.get(email);
if (user == null) {
  user = loadUser(email);
  users.put(email, user);
}

代码可能会因实际的Map实现而有所不同,但主要思想应该是清晰的。

一般来说,这已经是一种懒惰的方法,直到必要时才延迟加载用户。在 JDK 8 的多种类型中添加功能性增强的过程中,Map类型使用其computeIf…​方法获得了更简洁和功能性的替代方法。

根据键的映射值的存在性有两种可用的方法:

  • V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)

  • V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction)

第一个方法是前一个示例代码的理想替代品,如下所示:

Map<String, User> users = ...;

var email = "john@doe.com";

var user = users.computeIfAbsent(email,
                                 this::loadUser);

它需要所需的键作为其第一个参数,并且需要一个映射器Function<K, V>作为其第二个参数,如果键不存在,则为其提供新的映射值。computeIfPresent是仅在存在时重新映射值的对手。

还可以以V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction)方法的形式结合两种方法。它能够根据remapping函数的结果更新甚至删除映射值,如图 11-1 所示。

Map#compute 进行延迟重映射

图 11-1. 使用 Map#compute 进行延迟重映射

函数式方法的一般主题在 Maps 的惰性添加中清晰可见。现在,不再需要编写冗长和重复的代码来 处理 Map 及其映射值,而是可以集中精力于 发生什么 以及如何处理键和值。

Streams

Java Streams 是惰性功能流水线的完美示例。您可以定义一个复杂的 Stream 框架,其中包含昂贵的功能操作,这些操作只有在调用终端操作后才开始评估。处理的元素数量完全取决于管道设计,通过将表达式的定义与其实际评估分开,在数据处理管道中最大限度地减少所需的工作。

第六章 详细解释了 Streams 及其对数据处理的惰性方法。

Optionals

Optionals 是处理 null 值的一种非惰性方式。它们的一般方法类似于 Streams,但相对于 Streams,它们是严格评估的。有一些延迟操作可用,例如使用 SupplierT orElseGet(Supplier<? extends T> supplier) 方法,将执行延迟到绝对必要时。

第九章详细介绍了 Optionals,以及如何使用它们的更多信息。

Lambdas 和高阶函数

Lambdas 是在代码层面引入惰性的好方法。它们的声明是一个语句,因此是严格评估的。它们的主体——单抽象方法——封装了实际的逻辑,并在您的决定下进行评估。这使它们成为存储和传输表达式以供以后评估的简单方法。

让我们看看一些急切的代码,为方法提供参数,并展示如何利用 lambda 使其变为惰性。

一个急切的方法

在 示例 11-3 中,一个假想的 User 被一系列角色更新。这种更新并非总是发生,而是取决于 update 方法的内部逻辑。参数是 急切 提供的,需要通过 DAO⁠² 进行昂贵的查找调用。

示例 11-3. 使用急切方法参数更新 User
User updateUser(User user, List<Role> availableRoles) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  // ...
}

// HOW TO USE

var user = loadUserById(23L);
var availableRoles = this.dao.loadAllAvailableRoles(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
var updatedUser = updateUser(user, availableRoles); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

1

updateUser 方法需要 user 和所有可用角色的列表。更新本身依赖内部逻辑,可能实际上并不需要这些角色。

2

loadAllAvailableRoles(user) 被调用,无论 updateUser 方法是否需要角色。这导致了一个可能是不必要的昂贵的数据库访问。

3

所有参数在方法调用时已经评估完成。

即使在每种用例中角色并非必需,为 updateUser 提供角色会导致不必要的数据库调用和性能浪费。

那么如果这个调用并不总是必需的,你如何使它成为非强制性的呢?通过引入惰性。

更懒的方法

在像 Java 这样严格的语言中,所有方法参数都是提前提供的。即使一个参数实际上并不需要,方法也别无选择而必须接受它们。这在涉及执行昂贵的操作来预先创建这些参数(例如数据库调用)时尤为成问题,这可能会消耗您可用的资源和性能。

解决不必要的数据库调用的幼稚方法是将updateUser更改为直接接受 DAO,因此只有在必要时才能使用它:

User updateUser(User user, DAO roleDAO) {
  // ...
}

现在updateUser方法具有加载可用角色所需的所有工具。从表面上看,非惰性数据访问的初始问题得到了解决,但这种“解决方案”却引发了一个新的问题:内聚性。

现在updateUser方法直接使用 DAO,并不再与角色获取方式隔离。这种方法会使方法变得不纯,因为访问数据库被视为副作用,并使得验证和测试变得更加困难。如果updateUser方法根本不知道 DAO 类型,可能的 API 边界会使情况变得更加复杂。因此,您需要创建另一个抽象来检索角色。与创建一个额外的抽象层来弥合 DAO 和updateUser方法之间的差距不同,您可以将updateUser变成高阶函数并接受一个 lambda 表达式。

一个功能性的方法

要为在示例 11-3 中检索所需用户角色的功能抽象化,首先必须将问题分解为更抽象的表示,找出实际需要作为参数的内容,而不是如何获得参数值。

updateUser方法需要访问可用角色,这反映在原始方法签名中。这正是在代码中引入惰性将为您提供最灵活的解决方案的地方。

Supplier<T>类型是封装某些逻辑以按您的意愿检索值的最低级别可能性。与直接向updateUser提供 DAO 不同,lambda 表达式是惰性中间构造,用于加载角色,正如示例 11-4 中所见。

示例 11-4. 使用 lambda 更新User
void updateUser(User user, Supplier<List<Role>> availableRolesFn) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  // ...

  var availableRoles = availableRolesFn.get();

  // ...
}

// HOW TO USE

var user = loadUserById(23L);

updateUser(user, this.dao::loadAllAvailableRoles); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

1

updateUser方法签名必须更改为接受Supplier<List<Role>>,而不是已加载的List<Role>或 DAO 本身。

2

现在如何获取角色的逻辑已封装在一个方法引用中。

通过接受SupplierupdateUser变成高阶函数,可以创建一个表面上的新层,而无需额外的自定义类型来包装角色加载过程。

直接将DAO作为参数使用可以消除这些不利因素:

  • DAOupdateUser方法之间不再存在连接,从而可能产生一个纯粹的、无副作用的方法。

  • 你不需要额外的类型来表示这个抽象。已经可用的Supplier<T>函数接口是可能的最简单和最兼容的抽象形式。

  • 可以恢复测试性,而不需要可能复杂的 DAO 的模拟。

昂贵的操作,比如数据库查询,如果调用可以避免,可以极大地从延迟执行中受益。但这并不意味着,没有真正的需要,就将所有方法参数都延迟执行是正确的方法。还有其他解决方案,比如缓存昂贵调用的结果,可能比设计方法调用来接受延迟参数更简单。

使用Thunk进行延迟执行

Lambda 表达式是封装表达式以供以后评估的一种简单而低级的方式。不过,有一件事情是缺失的,那就是在评估后存储结果 — 记忆化 — 这样如果调用两次就不需要重新评估表达式了。有一种简单的方法可以弥补这个缺失:Thunk

一个 Thunk 是一个对计算进行包装的延迟执行,直到需要结果为止。与Supplier<T>不同,后者也延迟执行计算,但 Thunk 仅在第一次评估后直接返回结果,并在后续调用中不再进行评估。

Thunk 属于延迟加载/初始化的一般类别,这是在面向对象的代码中经常发现的设计模式。延迟加载和延迟初始化两种技术都是实现相同目标的类似机制:非严格评估和缓存结果。Supplier<T>只是延迟评估,而 Thunk 也缓存其结果。

让我们创建一个简单的 Thunk ,遵循虚拟代理设计模式³以成为Supplier<T>的可替换解决方案。

创建一个简单的 Thunk

最简单的方法是包装一个Supplier<T>实例,并在第一次评估后存储其结果。通过还实现Supplier<T>接口, Thunk 变成了一个可替换的解决方案,如示例 11-5 所示。

示例 11-5. 一个简单的Thunk<T>
public class Thunk<T> implements Supplier<T> { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  private final Supplier<T> expression; ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  private T result; ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

  private Thunk(Supplier<T> expression) {
    this.expression = expression;
  }

  @Override
  public T get() {
    if (this.result == null) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
      this.result = this.expression.get();
    }
    return this.result;
  }

  public static <T> Thunk<T> of(Supplier<T> expression) { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
    if (expression instanceof Thunk<T>) { ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
      return (Thunk<T>) expression;
    }

    return new Thunk<T>(expression);
  }
}

1

Thunk<T>实现了Supplier<T>以充当一种可替换的解决方案。

2

实际的Supplier<T>需要存储以延迟评估。

3

必须在评估后存储结果。

4

如果尚未评估,则解析表达式,并存储其结果。

5

一个方便的工厂方法,用于创建一个Thunk,而不需要new或泛型类型信息,所以唯一的构造函数可以是private

6

不需要为Thunk<T>创建一个Thunk<T>

这种 Thunk 实现简单而强大。通过使用任何Supplier<T>调用工厂方法添加记忆功能,以创建一个可替换的组件。像前一节中更新User那样,需要将方法引用包装在Thunk.of方法中:

updateUser(user, Thunk.of(this.dao::loadAllAvailableRoles));

对于Thunk<T>的功能性增强并不止于此。您可以轻松添加“粘合方法”,正如我在第二章中讨论的那样,以支持函数组合,如 Example 11-6 所示。

示例 11-6。对Thunk<T>的功能性增强
public class Thunk<T> implements Supplier<T> {

  // ...

  public static <T> Thunk<T> of(T value) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    return new Thunk<T>(() -> value);
  }

  public <R> Thunk<R> map(Function<T, R> mapper) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return Thunk.of(() -> mapper.apply(get()));
  }

  public <R> Thunk<R> flatMap(Function<T, Thunk<R>> mapper) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return Thunk.of(() -> mapper.apply(get()).get());
  }

  public void accept(Consumer<T> consumer) { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
    consumer.accept(get());
  }
}

1

创建一个Thunk<T>的工厂方法,用于创建单个值,而不是Supplier<T>

2

创建一个包括mapper函数的新的Thunk<R>

3

从返回 a+Thunk+的函数创建一个新的Thunk<R>,而不需要无谓地将其包装在另一个Thunk中。

4

消耗一个 Thunk 的结果。

通过添加“粘合”方法,Thunk<T>类型变得更加多功能,用于创建单表达式的惰性管道。

然而,还存在一个一般性问题:线程安全

线程安全的 Thunk

对于单线程环境,在前一节中讨论的Thunk<T>实现可以正常工作。但是,如果在表达式评估时从另一个线程访问它,可能会导致竞态条件重新评估。唯一可以防止这种情况发生的方法是在所有访问线程中同步它。

最直接的方法是将关键字synchronized添加到其get方法中。然而,这样做的明显缺点是始终需要synchronized访问以及相关的开销,即使评估已经完成。同步可能不像过去那样慢,但对于每次调用get方法来说仍然是一种开销,并且肯定会不必要地减慢代码速度。

那么,您如何改变实现以消除竞态条件,同时又尽可能不影响总体性能?您需要对何时何处竞态条件可能发生进行风险分析。

评估相关的竞态条件风险仅存在于表达式评估之前。之后,不会发生双重评估,因为结果已返回。这使您只需同步评估本身,而不是每次调用get方法。

Example 11-7 展示了引入专门的和synchronizedevaluate方法。将会很快解释它的实际实现及如何访问其结果。

示例 11-7。带有同步评估的Thunk<T>
public class Thunk<T> implements Supplier<T> {

  private Thunk(Supplier<T> expression) {
    this.expression = () -> evaluate(expression);
  }

  private synchronized T evaluate(Supplier<T> expression) {
    // ...
  }

  // ...
}

先前版本的Thunk使用了额外的value字段来确定expression是否已经评估。然而,新的线程安全变体用专用的抽象替换了存储的value及其检查,其持有值,如下所示:

private static class Holder<T> implements Supplier<T> {

  private final T value;

  Holder(T value) {
    this.value = value;
  }

  @Override
  public T get() {
    return this.value;
  }
}

Holder<T>执行两件事:

  • 持有评估值

  • 实现Supplier<T>

由于作为expression字段的插拔式替换,一种称为比较与交换(CAS)的技术得以实现。它用于设计并发算法,通过将变量的值与期望值进行比较,如果它们相等,则将该值交换为新值。该操作必须是原子的,这意味着访问底层数据要么完全成功,要么完全失败。这就是为什么evaluate方法必须是synchronized的原因。任何线程都可以在评估之前或之后看到数据,但从不会在评估过程中,从而消除竞态条件。

在示例 11-8 中,您可以看到evaluate的 CAS 实现。

现在,private字段+expression可以被新类型替换,如示例 11-7 所示。

示例 11-8。使用Holder<T>而不是Supplier<T>
public class Thunk<T> implements Supplier<T> {

  private static class Holder<T> implements Supplier<T> {
    // ...
  }

  private Supplier<T> holder; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  private Thunk(Supplier<T> expression) {
    this.holder = () -> evaluate(expression);
  }

  private synchronized T evaluate(Supplier<T> expression) {
    if (Holder.class.isInstance(this.holder) == false) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      var evaluated = expression.get();
      this.holder = new Holder<>(evaluated); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    }
    return this.holder.get();
  }

  @Override
  public T get() {
    return this.holder.get(); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  }
}

1

该字段被重命名以更好地反映其用途,并且在表达式评估后也被设置为非final

2

只有当holder字段当前不是Holder实例时,表达式才会被评估,而是在构造函数中创建的表达式。

3

在这一点上,holder字段持有原始的 lambda 表达式以评估初始表达式,并将其替换为带有评估结果的Holder实例。

4

synchronizedget方法直接使用holder字段访问值,因为它总是引用一个Supplier

改进后的Thunk<T>实现不再像以前那样简单,但通过将表达式的评估与访问解耦,消除了竞态条件。

首次访问时,holder字段将调用synchronizedevaluate方法,因此是线程安全的。在评估表达式时,任何额外的调用也将调用evaluate方法。而不是重新评估,holder字段的类型检查直接跳过,返回this.holder.get()的结果。在重新分配holder之后的任何访问都将完全跳过任何synchronized

就这样,您现在拥有一个线程安全的、惰性评估的Supplier<T>插入,只评估一次。

我们的Thunk实现使用synchronized,但有多种实现比较与交换算法的方法。可以使用 JDK 中的java.util.concurrent.atomic.Atomic…​类型之一,或者甚至使用ConcurrentHashMap#computeIfAbsent来防止竞态条件。Brian Goetz 的书籍“Java Concurrency”⁴为更好理解原子变量、非阻塞同步和 Java 的并发模型提供了一个良好的起点。

惰性的最终思考

本质上,惰性的核心思想是推迟所需的工作,直到它变得不可或缺的时刻。将创建消耗表达式分离给了您的代码一个新的模块化轴线。如果一个操作是可选的,并且不是每种用例都需要,这种方法可以极大地提高性能。然而,惰性评估也意味着您必须放弃对评估确切时间的某种程度控制。

感知和实际控制丧失使得对代码所需性能和内存特性的推理变得更加困难。总体性能需求是所有评估部分的总和。急切评估允许相当线性和组合性能评估。惰性将实际的计算成本从表达式定义的地方转移到使用它们的时候,有可能根本不运行代码。这就是为什么习惯性的惰性性能更难评估,因为与急切评估相比,感知的性能可能会立即改善,特别是如果您的代码有许多昂贵但可能是可选的代码路径。总体性能需求可能会根据一般情况和实际评估的代码而变化。您必须分析您的惰性代码的“平均”使用模式,并在不同场景下估计所需的性能特性,使得直接的基准测试变得非常困难。

软件开发是一个持续的战斗,要有效利用稀缺资源以达到所需或必需的性能。像延迟评估或数据处理中的流这样的惰性技术,是改进代码性能的低成本⁵,易于集成到现有代码库中的有效方法。它绝对会将所需工作降至最低,甚至可能为其他任务释放宝贵的性能。如果可以避免某些表达式或昂贵的计算,将其设为惰性绝对是一项值得长远努力的事业。

总结

  • 严格评估意味着表达式和方法参数在声明时立即评估。

  • 惰性评估通过推迟或甚至完全不评估表达式的结果,将创建消耗表达式分离,从而实现。

  • 严格性是关于“做事情”; 惰性是关于“考虑要做的事情”。

  • Java 在表达式和方法参数方面是一种“严格”的语言,尽管存在某些 惰性 操作符和控制结构。

  • Lambas 封装表达式,使它们成为可在您自行决定时进行评估的惰性包装器。

  • JDK 中有多个懒加载运行时结构和辅助方法。例如,流(Streams)是惰性的功能管道,OptionalMap 在其一般接口中提供了 惰性 的扩展。

  • Supplier<T> 接口是创建延迟计算的最简单方式。

  • Memoization,以 Thunk 的形式,有助于避免重新评估,可以用作 Supplier<T> 的即插即用替代方案。

  • 懒加载是性能优化的重要手段。最好的代码是根本不运行的代码。其次好的选择是仅在“按需”时运行它。

  • 对于延迟代码的性能需求评估较为困难,如果在不符合“真实世界”使用情况的环境中进行测试,可能会掩盖性能问题。

¹ 参见“有效地 final”以了解 有效 final 变量的定义和要求。

² DAO(数据访问对象)是一种模式,提供了与数据库等持久化层的抽象接口。它将应用程序调用转换为对底层持久化层特定操作的操作,而不会暴露其详细信息。

³ 维基百科关于代理模式提供了不同种类代理及其用途的概述。

⁴ Brian Goetz. 2006 年. “Java 并发编程实践.” Addison-Wesley. ISBN 978-0321349606.

⁵ “低 hanging fruit” 的概念描述了一个易于实现或利用的目标,相比于重新设计或重构整个代码库的替代方案。

第十二章:递归

递归是一种解决问题的方法,可以将其分解为其较小版本。许多开发人员将递归视为另一种 — 通常是复杂的 — 基于迭代的问题解决方法。但对于特定类型的问题,掌握不同的技术方式是很有益的。

本章介绍了递归的一般思想,如何实现递归方法以及它们在 Java 代码中与其他形式的迭代相比的位置。

什么是递归?

在“递归”中,你已经看到了计算阶乘的示例 — 所有小于或等于输入参数的正整数的乘积。许多书籍、指南和教程使用阶乘来演示递归,因为这是一个很好的部分解决问题的例子,并且也是本章的第一个示例。

计算阶乘的每一步都会将输入参数与下一个阶乘操作的结果相乘。当计算达到fac(1) — 定义为“1" — 时,链条终止并将值提供给前一步骤。完整的步骤可以在方程 12-1 中看到。

方程 12-1. 阶乘计算的正式表示

f a c ( n ) n f a c ( n - 1 ) n ( n - 1 ) f a c ( n - 2 ) 4 ( n - 1 ) ( n - 2 ) f a c ( 1 ) 4 ( n - 1 ) ( n - 2 ) * 1

这种计算步骤的泛化可视化了递归的基本概念:通过使用调用自身的方法并传入修改后的参数来解决问题,直到达到基本条件。

递归由两种不同的操作类型组成:

基本条件

基本条件是一个预定义的情况 — 问题的解决方案 — 它将返回一个实际值并展开递归调用链。它将其值提供给前一步骤,后者现在可以计算结果并将其返回给其前任,依此类推。

递归调用

直到调用链达到其基本条件,每一步都会通过使用修改后的输入参数调用自身来创建另一个步骤。

图 12-1 显示了递归调用链的一般流程。

用更小的问题解决问题

图 12-1. 用更小的问题解决问题

问题会变小,直到找到最小部分的解决方案。然后,此解决方案将成为下一个更大问题的输入,依此类推,直到所有部分的总和构建出原始问题的解决方案。

头递归与尾递归

递归调用分为两类,递归和递归,取决于方法体中递归调用的位置:

头递归

在递归方法调用后执行/评估其他语句/表达式,这使得它不是最后一个语句。

尾递归

递归调用是方法的最后一条语句,没有进一步的计算将其结果链接到当前调用。

让我们来看看使用这两种类型计算阶乘以更好地说明它们的差异。示例 12-1 展示了如何使用头递归。

示例 12-1. 使用头递归计算阶乘
long factorialHead(long n) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  if (n == 1L) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return 1L;
  }

  var nextN = n - 1L;

  return n * factorialHead(nextN); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

var result = factorialHead(4L);
// => 24

1

方法签名只包含当前递归步骤的输入参数。中间状态不在递归调用之间移动。

2

基本条件必须出现在递归调用之前。

3

返回值是依赖于递归调用结果的表达式,这使得它不是方法中唯一的最后语句。

现在是时候看尾递归了,正如示例 12-2 所示。

示例 12-2. 使用尾递归计算阶乘
long factorialTail(long n, long accumulator) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  if (n == 1L) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    return accumulator;
  }

  var nextN = n - 1L;
  var nextAccumulator = n * accumulator;

  return factorialTail(nextN, nextAccumulator); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
}

var result = factorialTail(4L, 1L); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
// => 24

1

方法签名包含一个累加器。

2

与头递归相比,基本条件没有改变。

3

不返回依赖于下一个递归调用的表达式,两个factorialTail参数都会在此之前求值。该方法仅返回递归调用本身。

4

累加器需要一个初始值。它反映了基本条件。

头递归和尾递归之间的主要区别在于调用堆栈的构建方式。

头递归中,递归调用在返回值之前执行。因此,最终结果在运行时从每个递归调用返回之后才可用。

使用尾递归时,先解决分解的问题,然后再将结果传递给下一个递归调用。实际上,任何给定递归步骤的返回值都与下一个递归调用的结果相同。如果运行时支持,这样可以优化调用堆栈,如下一节所示。

递归和调用堆栈

如果你再次查看图 12-1,你可以将每个方框看作是一个单独的方法调用,因此,在调用堆栈上会有一个新的栈帧。这是必需的,因为每个方框必须与先前的计算隔离开来,这样它们的参数不会相互影响。总的递归调用次数仅受到达到基本条件的时间限制。问题在于,可用的堆栈大小是有限的。过多的调用将填满可用的堆栈空间,并最终引发StackOverflowError

注意

栈帧包含单个方法调用的状态。每次代码调用一个方法时,JVM 都会创建并推送一个新的栈帧到线程的堆栈上。在方法返回后,其栈帧被弹出并丢弃。

实际的最大堆栈深度取决于可用堆栈大小¹,以及存储在各个帧中的内容。

为了防止栈溢出,许多现代编译器使用尾递归优化/消除来删除递归调用链中不再需要的帧。如果在递归调用之后没有额外的计算发生,那么栈帧就不再需要并且可以被移除。这将递归调用的栈帧空间复杂度从O(N)降低到O(1),产生更快、更节省内存的机器代码,避免栈溢出。

不幸的是,截至 2023 年初,Java 编译器和运行时尚缺乏这种特定能力。

尽管如此,递归仍然是特定问题子集的有价值工具,即使在没有调用栈优化的情况下也是如此。

更复杂的例子

阶乘计算虽然可以很好地解释递归,但并不是典型的“真实世界”问题。因此,现在是时候看一个更现实的例子了:遍历类似树结构的数据,就像在图 12-2 中看到的那样。

类似树结构的数据遍历

图 12-2. 类似树结构的数据遍历

数据结构有一个根节点,每个节点都有一个可选的左右子节点。它们的编号是为了标识,并不是遍历顺序。

节点由通用记录Node<T>表示,如示例 12-3 所示。

示例 12-3. 树节点结构
public record Node<T>(T value, Node<T> left, Node<T> right) {

  public static <T> Node<T> of(T value, Node<T> left, Node<T> right) {
    return new Node<>(value, left, right);
  }

  public static <T> Node<T> of(T value) {
    return new Node<>(value, null, null);
  }

  public static <T> Node<T> left(T value, Node<T> left) {
    return new Node<>(value, left, null);
  }

  public static <T> Node<T> right(T value, Node<T> right) {
    return new Node<>(value, null, right);
  }
}

var root = Node.of("1",
                   Node.of("2",
                           Node.of("4",
                                   Node.of("7"),
                                   Node.of("8")),
                           Node.of("5")),
                   Node.right("3",
                              Node.left("6",
                                        Node.of("9"))));

目标是“按顺序”遍历树。这意味着先遍历每个节点的左子节点,直到找不到其他左节点为止。然后,它将继续遍历其右子节点的左节点,然后再向上。

首先,我们将使用迭代方法实现树遍历,然后与递归方法进行比较。

迭代树遍历

借助while循环,遍历树的方式如您所料。这需要临时变量和协调遍历的样板代码,就像在示例 12-4 中看到的那样。

示例 12-4. 迭代树遍历
void traverseIterative(Node<String> root) {
  var tmpNodes = new Stack<Node<String>>(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
  var current = root;

  while(!tmpNodes.isEmpty() || current != null) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    if (current != null) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      tmpNodes.push(current);
      current = current.left();
      continue;
    }

    current = tmpNodes.pop(); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

    System.out.print(current.value()); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)

    current = current.right(); ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/6.png)
  }
}

1

需要辅助变量来保存迭代的当前状态。

2

迭代直到没有节点存在,或者nodeStack不再为空。

3

java.util.Stack保存直到底部节点被访问。

4

在此时,循环无法继续更深,因为它遇到了current == null,所以它将current设置为在tmpNodes中保存的最后一个节点。

5

输出节点值。

6

重复以上步骤,右子节点也是如此。

输出结果如预期:748251396

尽管它按预期工作,但代码并不十分简洁,需要可变的辅助变量才能正常运行。

让我们看看递归方法是否比迭代更好。

递归树遍历

要创建一个递归解决方案以遍历树,必须首先明确定义包括基本条件在内的不同步骤。

遍历树需要两次递归调用,一个动作和一个基本条件:

  • 遍历左节点

  • 遍历右节点

  • 打印节点的值

  • 如果找不到更多节点,则停止

这些不同步骤在 Java 中的实现顺序如示例 12-5 所示。

示例 12-5. 递归树遍历
void traverseRecursion(Node<String> node) {
  if (node == null) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    return;
  }

  traverseRecursion(node.left()); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

  System.out.print(node.value()); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

  traverseRecursion(node.right()); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
}

1

如果没有剩余节点,则停止遍历的基本条件。

2

首先,递归地遍历左子节点。只要左节点存在,这将再次调用traverse

3

其次,因为不再存在左子节点,需要打印当前值。

4

第三步,像之前一样使用相同逻辑遍历可能的右子节点。

输出与之前相同:748251396

代码不再需要外部迭代器或辅助变量来保存状态,实际处理逻辑被减少到最小。遍历不再处于做什么的命令式思维中。相反,它以更声明式的方式反映了如何实现目标的函数式方法。

通过将遍历过程移到类型本身并接受一个Consumer<Node<T>>来使树的遍历更加功能化,如示例 12-6 所示。

示例 12-6. 用遍历方法扩展Node<T>
record Node<T>(T value, Node<T> left, Node<T> right) {

  // ...

  private static <T> void traverse(Node<T> node, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                                   Consumer<T> fn) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    if (node == null) {
      return;
    }

    traverse(node.left(), fn);

    fn.accept(node.value());

    traverse(node.right(), fn);
  }

  public void traverse(Consumer<T> fn) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    Node.traverse(this, fn);
  }
}

root.traverse(System.out::print);

1

先前的traverse方法可以很容易地重构为原始类型上的private static方法。

2

新的traverse方法接受Consumer<Node<T>>以支持任何类型的操作。

3

一个用于遍历的public方法简化了调用,省略了作为其第一个参数的this

遍历类型变得更加容易。类型本身现在负责最佳遍历方式,并为使用者提供了灵活的解决方案。

与迭代方法相比,这种方式更为简洁、功能性强,更易于理解。但是,使用循环也有其优势。最大的优势是性能差异,通过交换所需的堆栈空间来获得可用的堆空间。在每次递归遍历操作时,不再创建新的堆栈帧,节点在tmpNodes上累积于堆中。这使得代码对于可能导致堆栈溢出的较大图形更加健壮。

正如你所见,没有一个简单的答案来确定哪种方法最佳。这完全取决于数据结构的类型以及需要处理的数据量。即使如此,你个人的偏好和对特定方法的熟悉程度可能比寻找“最佳”解决方案更为重要,用于编写简单明了、无 bug 的处理代码。

类似递归的 Streams

Java 的运行时可能不支持尾递归优化,然而,你仍然可以通过 lambda 表达式和 Streams 实现类似递归的体验,这些方式不会遭受堆栈溢出问题的困扰。

多亏了 Streams 的惰性特性,你可以构建一个流水线,直到递归问题得以解决。但与其递归调用 lambda 表达式不同,它返回一个新的表达式。这样,无论执行多少递归步骤,堆栈深度都将保持恒定。

与递归或者使用循环相比,这种方法相当复杂。虽然不常用,但它展示了如何结合 Java 的各种新功能组件来解决递归问题。如果你想进一步了解,请查看书籍的代码库

递归的最终思考

递归通常被忽视,因为很容易出错。例如,错误的基础条件可能是不可能实现的,这必然会导致堆栈溢出。总体来说,递归的流程更难以跟踪和理解,如果你不习惯的话。因为 Java 没有尾调用优化,你必须考虑不可避免的开销,这导致执行时间比迭代结构慢,而且可能会遇到StackOverflowError的问题。

在选择递归和其它替代方案时,务必考虑额外的开销和堆栈溢出问题。如果你在拥有充足内存和足够大栈大小的 JVM 中运行,即使是更大的递归调用链也不会成问题。但如果你的问题规模未知或不固定,长远来看采用替代方法可能更明智,以预防StackOverflowError

一些场景更适合采用递归方法,即使在 Java 中缺乏尾递归优化的情况下。递归将会感觉更自然地解决特定的问题,特别是对于像链表或树这样的自引用数据结构。遍历类似树结构的数据也可以通过迭代方式实现,但很可能会导致更复杂、更难以理解的代码。

但请记住,仅从技术角度选择最佳解决方案可能会削弱代码的可读性和合理性,从而影响长期可维护性。

表 12-1 提供了递归与迭代之间的区别概述,以便您更有效地选择使用它们。

表 12-1. 递归与迭代对比

递归 迭代
方法 自调用函数 循环结构
状态 存储在堆栈上 存储在控制变量中(例如循环索引)
进展 向基本条件 向控制值条件
终止条件 达到基本条件 达到控制变量条件
冗余性 较低冗余性,需要的最小样板和协调代码 较高冗余性,需要显式协调控制变量和状态。
如果未终止 StackOverflowError 无尽循环
开销 重复方法调用的开销较高。 恒定堆栈深度减少了开销。
性能 由于开销和缺少尾递归优化而性能较低。 由于恒定的调用堆栈深度而性能更好。
内存使用 每次调用都需要堆栈空间。 除了控制变量外没有额外的内存使用。
执行速度 较慢 较快

选择递归还是迭代取决于你要解决的问题以及代码运行的环境。对于解决更抽象的问题,递归通常是首选工具,而迭代更适合于更低级别的代码。迭代可能提供更好的运行时性能,但递归可以提高程序员的生产力。

别忘了你始终可以从熟悉的迭代方法开始,稍后转换为使用递归。

要点

  • 递归是对“传统”迭代的功能替代。

  • 递归最适合部分可解决的问题。

  • Java 缺乏尾递归优化,这可能导致 StackOverflowExceptions

  • 不要为了函数式而强制使用递归。你始终可以从迭代方法开始,稍后转换为递归方法。

¹ 大多数 JVM 实现的默认堆栈大小为一兆字节。你可以使用 -Xss 标志设置更大的堆栈大小。请参阅Oracle Java 工具文档获取更多信息。

第十三章:异步任务

现代工作负载需要更多关于如何高效利用系统资源的考虑。异步任务是提高应用响应性的绝佳工具,可以避免性能瓶颈。

Java 8 引入了新类型CompletableFuture<T>,它在以前的Future<T>类型基础上进行了改进,通过声明式和函数式方法创建异步任务。

本章介绍了为什么以及如何利用异步编程,以及CompletableFuture<T>相比 JDK 之前的异步任务更灵活和功能更强的方法。

同步与异步

同步和异步任务的概念并不局限于软件开发。

例如,面对面会议或电话会议是同步活动,至少是如果你专心的话。除了参与和可能做笔记外,你无法做其他任何事情。每个其他任务在会议/电话结束之前都被阻塞。如果会议/电话本应该是一封电子邮件——正如我大多数会议本应该的那样——则你当前的任务不会因需要立即注意而中断以前的任务。因此,电子邮件是非阻塞通信。

软件开发中也适用同样的原则。同步执行的任务按顺序运行,阻塞后续工作直到它们完成。从单线程的角度看,阻塞任务意味着等待结果,可能会浪费资源,因为在任务完成之前不做任何其他事情。

异步任务是启动一个在“其他地方”处理的任务,并在完成时得到通知。通过并发技术将它们的工作分发出去——通常是到另一个线程——以便它们不必等待完成。因此,当前线程不被阻塞,可以继续执行其他任务,正如图 13-1 中所示。

同步与异步执行的比较

图 13-1 同步与异步执行的比较

并行执行,正如我在第八章中讨论的,追求最大吞吐量作为其主要目标;单个任务的完成时间通常不是大计划中的重点。另一方面,像CompletableFuture这样的异步执行模型专注于系统的整体延迟和响应性。分发任务确保了响应迅速的系统,即使在单线程或资源受限的环境中也是如此。

Java Futures

Java 5 引入了接口 java.util.concurrent.Future<T> 作为异步计算结果的容器类型。要创建一个 Future,需要将任务以 RunnableCallable<T> 的形式提交给 ExecutorService,后者在单独的线程中启动任务,但立即返回一个 Future 实例。这样,当前线程可以继续执行更多工作,而不必等待 Future 计算的最终结果。

可以通过在 Future<T> 实例上调用 get 方法来检索结果,如果计算尚未完成,可能会阻塞当前线程。通常的流程示例在 示例 13-1 中有所体现。

示例 13-1. Future<T> 执行流程
var executor = Executors.newFixedThreadPool(10); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

Callable<Integer> expensiveTask = () -> { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    System.out.println("(task) start");

    TimeUnit.SECONDS.sleep(2);

    System.out.println("(task) done");

    return 42;
};

System.out.println("(main) before submitting the task");

var future = executor.submit(expensiveTask); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

System.out.println("(main) after submitting the task");

var theAnswer = future.get(); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

System.out.println("(main) after the blocking call future.get()");

// OUTPUT:
// (main) before submitting the task
// (task) start
// (main) after submitting the task
// ~~ 2 sec delay ~~
// (task) done
// (main) after the blocking call future.get()

1

需要显式的 ExecutorService 来启动 Callable<T>Runnable

2

Callable<T> 接口在函数接口引入 lambda 之前就已经存在了。它的预期用法相当于 Supplier<T>,但在其单一抽象方法中抛出 Exception

3

expensiveTask 的计算立即开始,并反映在输出中。

4

此时计算尚未完成,因此在 future 上调用 get 方法会阻塞当前线程,直到完成为止。

虽然 Future<T> 类型达到了作为 非阻塞 异步计算容器的基本要求,但其功能集仅限于几种方法:检查计算是否完成、取消计算和检索结果。

为了拥有一个多功能的异步编程工具,还有很多功能有待改进:

  • 更容易地获取结果,如在完成或失败时的回调。

  • 在函数组合的精神中链接和组合多个任务。

  • 集成错误处理和恢复可能性。

  • 无需 ExecutorService,可以手动创建或完成任务。

Java 8 在 Futures 的基础上进行了改进,通过引入接口 CompletionStage<T> 和其唯一实现 CompletableFuture<T> 来弥补缺失的功能。它们位于同一包 java.util.concurrent 中,是构建异步任务管道的多功能工具,功能集比它们之前的 Futures 更丰富。其中,Future<T> 是一个异步计算最终值的容器类型,而 CompletionStage<T> 表示异步管道的单个阶段,具有超过 70 个方法的大量 API!

使用 CompletableFuture 设计异步管道

CompletableFuture 的一般设计哲学与 Streams 类似:两者都是基于任务的管道,提供接受常见函数接口的参数化方法。新的 API 添加了大量的协调工具,返回CompletionStage<T>或+CompletableFuture的新实例。这种异步计算和协调工具的结合提供了以前缺失的所有功能,以流畅的组合和声明性 API。

由于庞大的CompletableFuture<T>API 和异步编程的复杂思维模型,让我们从一个简单的比喻开始:做早餐

想象的早餐由咖啡,面包和鸡蛋组成。按照同步 — 或阻塞 — 顺序准备早餐并没有太多意义。在咖啡壶完成或面包机完成之前等待开始煎鸡蛋是对可用资源的浪费,这将不必要地增加总准备时间,让你在坐下来吃饭时已经饿了。相反,你可以在咖啡壶和烤面包机工作时开始煎鸡蛋,只有当烤面包机弹出或咖啡壶完成时才对它们做出反应。

编程也是如此。可用资源应根据需要分配,不要浪费在昂贵且耗时长的任务上等待。这种异步管道的基本概念在许多语言中都以不同的名称存在,也许更常见的是Promises

承诺一个值

Promises是具有内置协调工具的异步管道的构建模块,允许链接和组合多个任务,包括错误处理。这样一个构建块要么是pending(未解决),要么是resolved(已解决且计算完成),要么是rejected(已解决,但处于错误状态)。在组合管道中在状态之间移动是通过在两个通道之间切换:数据错误来完成的,如图 13-2 所示。

Promise data and error channels

图 13-2. 承诺数据和错误通道

数据通道是“快乐路径”,如果一切顺利的话。然而,如果一个承诺失败,管道将切换到错误通道。这样,失败就不会像流(Streams)那样使整个管道崩溃,可以优雅地处理,甚至可以恢复并将管道切换回数据通道。

正如你所看到的,CompletableFuture API 就是另一个名字的 Promise。

创建一个 CompletableFuture

与其前身Future<T>一样,新的CompletableFuture<T>类型不提供任何构造函数来创建实例。新的Future<T>实例是通过将任务提交给java.util.concurrent.ExecutorService来创建的,后者会返回一个已经启动了任务的实例。

CompletableFuture<T>遵循相同的原则。然而,它不一定需要一个显式的ExecutorService来调度任务,这要归功于它的static工厂方法:

  • CompletableFuture<Void> runAsync(Runnable runnable)

  • CompletableFuture<U> supplyAsync(Supplier<U> supplier)

这两种方法还支持第二个参数,接受一个java.util.concurrent.Executor,这是ExecutorService类型的基础接口。如果选择不带Executor的变体,就会使用通用的 ForkJoinPool,就像在“流作为并行功能管道”中解释的并行流管道一样。

注意

将任务提交给ExecutorService以创建Future<T>最明显的区别是使用Supplier<T>代替Callable<T>。后者在其方法签名中明确抛出异常。因此,supplyAsync不能完全替代将Callable<T>提交给Executor

创建CompletableFuture<T>实例几乎等同于创建Future<T>实例,如示例 13-2 所示。示例没有使用类型推断,因此返回类型是可见的。通常情况下,你会更喜欢使用var关键字而不是显式类型。

示例 13-2. 使用便捷方法创建CompletableFuture
// FUTURE<T>

var executorService = ForkJoinPool.commonPool();

Future<?> futureRunnable =
  executorService.submit(() -> System.out.println("not returning a value"));

Future<String> futureCallable =
  executorService.submit(() -> "Hello, Async World!");

// COMPLETABLEFUTURE<T>

CompletableFuture<Void> completableFutureRunnable =
  CompletableFuture.runAsync(() -> System.out.println("not returning a value"));

CompletableFuture<String> completableFutureSupplier =
  CompletableFuture.supplyAsync(() -> "Hello, Async World!");

尽管在Future<T>CompletableFuture<T>的实例创建之间存在相似之处,但后者更为简洁,不一定需要ExecutorService。然而,更大的区别在于,CompletableFuture<T>实例提供了CompletionStage<T>实例的声明式和函数式管道的起点,而不是Future<T>的单一孤立的异步任务。

组合和合并任务

在使用CompletableFuture<T>实例开始后,可以进一步组合和组成它们,以创建更复杂的管道。

可用于构建异步管道的广泛操作可以根据其接受的参数和预期的使用案例分为三组:

转换结果

类似于流和可选项的map操作,CompletableFuture API 提供了类似的thenApply方法,它使用Function<T, U>来转换类型为T的前一个结果,并返回另一个CompletionStage<U>。如果转换函数返回另一个CompletionStage,使用thenCompose方法可以防止额外的嵌套,类似于流和可选项的flatMap操作。

消费结果

如其名称所示,thenAccept方法需要一个Consumer<T>来处理类型为T的前一个结果,并返回一个新的CompletionStage<Void>

执行完成后

如果不需要访问前一个结果,thenRun方法会执行一个Runnable并返回一个新的CompletionStage<Void>

由于存在太多方法,特别是额外的 -Async 方法,无法详细讨论每一个。其中大多数方法都有两个额外的 -Async 变体:一个与非 -Async 匹配,另一个带有额外的 Executor 参数。

-Async 方法在与前一个任务相同的线程中执行其任务,尽管这并不保证,如后文 “关于线程池和超时” 所述。 -Async 变体将使用一个新线程,由公共 ForkJoinPool 创建,或由提供的 Executor 创建。

为了保持简单,我将主要讨论非 -Async 变体。

组合任务

组合任务创建了一个连接的 CompletionStages 串行管道。

所有组合操作都遵循一个通用的命名方案:

<operation>Async

<操作> 名称来源于操作类型及其参数,主要使用功能接口的 SAM 前缀 then 加上它们接受的功能接口的名称:

  • CompletableFuture<Void> thenAccept(Consumer<? super T> action)

  • CompletableFuture<Void> thenRun(Runnable action)

  • CompletableFuture<U> thenApply(Function<? super T,? extends U> fn)

由于 API 的良好命名方案,使用任何操作都会产生流畅且简单的调用链。例如,想象一个书签管理器,它会扫描其网站以存储永久副本。整个任务可以异步运行,因此不会阻塞 UI 线程。任务本身包括三个步骤:下载网站、准备内容以供离线使用,最后存储,如 示例 13-3 所示。

示例 13-3. 异步书签管理器工作流程
var task = CompletableFuture.supplyAsync(() -> this.downloadService.get(url))
                            .thenApply(this.contentCleaner::clean)
                            .thenRun(this.storage::save);

组合操作仅支持 1:1,意味着它们接受前一阶段的结果并执行其预期的工作。如果您的任务管道需要多个流汇聚,您需要组合任务。

组合任务

将互相连接的 futures 组合以创建更复杂的任务可能非常有帮助。然而,有时不同的任务不需要或可以串行运行。在这种情况下,您可以使用接受另一个阶段的操作来组合 CompletionStage 实例。

它们的命名方案类似于之前的 1:1 组合操作:

<operation><restriction>Async

额外的 restriction 表示操作是否对两个阶段或任一阶段有效,使用适当命名的后缀 -Both-Either

表格 13-1 列出了可用的 2:1 操作。

表格 13-1. 组合操作

方法 参数 注意事项
thenCombine BiFunction<T, U, V> 两个 阶段正常完成后应用 BiFunction
thenAcceptBoth BiConsumer<T, U> 类似于 thenCombine,但不生成任何值。
runAfterBoth Runnable 在两个给定阶段都正常完成后评估 Runnable
applyToEither Function<T, U> Function应用于第一个完成的阶段。
acceptEither Consumer<T, U> 类似于applyToEither,但不生成任何值。
runAfterEither Runnable 在给定阶段之一正常完成后评估Runnable

与其他功能性 Java 特性一样,许多不同的操作归功于 Java 的静态类型系统及其如何解析通用类型。与 JavaScript 等其他语言不同,方法不能在单个参数或返回类型中接受多种类型。

可以轻松混合组合操作,如图 13-3 所示。

组合和组合任务

图 13-3. 组合和组合任务

可用的操作提供了几乎所有用例的各种功能。但是,在 Java 的异步 API 中仍然存在一些盲点,特别是缺少特定变体:使用BiFunction组合两个阶段的结果,返回另一个阶段而不创建嵌套的CompletionStage

thenCombine的行为类似于 Java 中的其他map操作。在嵌套返回值的情况下,需要类似于flatMap的操作,但对于CompletableFuture<T>而言,这种操作是缺失的。因此,您需要额外的thenCompose操作来展平嵌套的值,如示例 13-4 所示。

示例 13-4. 取消包装嵌套阶段
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> 42); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> 23); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

BiFunction<Integer, Integer, CompletableFuture<Integer>> task = ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
  (lhs, rhs) -> CompletableFuture.supplyAsync(() -> lhs + rhs);

CompletableFuture<Integer> combined =
  future1.thenCombine(future2, task) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
         .thenCompose(Function.identity()); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)

1

应该组合其结果的两个阶段。

2

消耗前一阶段组合结果的任务。

3

task的返回值通过thenCombine包装成另一个阶段,导致不需要的CompletionStage<CompletionStage<Integer>>

4

使用Function.identity()thenCompose调用取消包装嵌套阶段,管道再次成为CompletionStage<Integer>

如果任务返回的是CompletableFuture本身,而不是依赖调用者通过必要时包装成CompletableFuture来异步处理它,则此方法很有帮助。

同时运行超过两个 CompletableFuture

前面讨论的操作允许您运行最多两个 CompletableFutures 以创建一个新的 CompletableFuture。然而,处理超过两个的情况,如使用thenCombine等组合操作而不创建嵌套方法调用的噩梦,是不可能的。这就是为什么CompletableFuture<T>类型具有两个用于同时处理多个实例的静态便利方法的原因:

  • CompletableFuture<Void> allOf(CompletableFuture<?>…​ cfs)

  • CompletableFuture<Object> anyOf(CompletableFuture<?>…​ cfs)

allOfanyOf 方法协调预先存在的实例。因此,它们都不提供匹配的 -Async 变体,因为每个给定的 CompletableFuture 实例已经有其指定的 Executor。协调性质的另一个方面是它们的限制性返回类型。因为两者都接受任何类型的 CompletableFuture 实例,由泛型边界 <?> 表示,所以无法确定整体结果的明确 T,因为类型可以自由混合。allOf 的返回类型是 CompletableFuture<Void>,因此您无法在后续阶段访问给定实例的任何结果。但是,可以创建支持将结果作为备选项返回的辅助方法。我将向您展示如何在 “创建 CompletableFuture 辅助程序” 中执行此操作,但现在,让我们先通过 CompletableFuture 的其他操作。

异常处理

到目前为止,我已经向您展示了仅在“快乐路径”上行进而没有任何故障的管道。但是,如果在管道中发生异常,则承诺可能被拒绝,或者在 Java 中称为异常完成

与流或可选项在异常情况下炸毁整个管道不同,CompletableFuture API 将异常视为一等公民,并且是其工作流程的重要部分。这就是为什么异常处理不强加于任务本身,并且有多个可用于处理可能被拒绝的 Promise 的操作的原因:

  • CompletionStage<T> exceptionally(Function<Throwable, T> fn)

  • CompletionStage<U> handle(BiFunction<T, Throwable, U> fn)

  • CompletionStage<T> whenComplete(BiConsumer<T, Throwable> action)

使用 exceptionally 操作将异常钩子添加到管道中,如果之前的阶段没有发生异常,则会正常完成,并带有上一阶段的结果。在被拒绝的阶段,它的异常会应用到钩子的 fn 上,进行恢复尝试。要进行恢复,fn 需要返回任何类型为 T 的值,这将把管道切换回数据通道。如果没有可能的恢复,抛出一个新的异常,或者重新抛出已应用的异常,将使管道保持在异常完成状态并在错误通道上。

更灵活的 handle 操作将 exceptionallythenApply 的逻辑合并为一个单独的操作。BiFunction 参数取决于前一阶段的结果。如果它被拒绝,那么第二个类型为 Throwable 的参数是非 null 的。否则,类型为 T 的第一个参数具有值。请注意,它仍然可能是一个 null 值。

最后一个操作 whenComplete 类似于 handle 但不提供一种恢复被拒绝 Promise 的方式。

数据和错误通道再访

即使我解释了 Promise 实际上有两个通道,即数据和错误,但 CompletableFuture 管道实际上是操作的一条直线,就像 Streams 一样。每个管道阶段都寻找下一个兼容的操作,具体取决于当前阶段已完成的状态。在正常完成的情况下,下一个then/run/apply/等就会执行。对于异常完成的阶段,这些操作是“通过”操作,并且管道会进一步寻找下一个exceptionally/handle/whenComplete/等操作。

CompletableFuture 管道可能是通过流畅调用创建的一条直线,将其视为两个通道,如以前在图 13-2 中所做的那样,可以更好地了解发生了什么。每个操作存在于数据通道或错误通道中的一个,除了handlewhenComplete操作,它们存在于中间,因为它们无论管道的状态如何都会执行。

被拒绝的 Either 任务

通过使用组合操作注入另一个 CompletableFuture,可以获得一个直线管道。您可能会认为后缀-Either可能意味着要么管道可能正常完成以创建一个新的非拒绝阶段。嗯,您会感到惊讶!

如果先前的阶段被拒绝,那么acceptEither操作将保持被拒绝状态,无论另一个阶段是否正常完成,如示例 13-5 所示。

示例 13-5. Either 操作和被拒绝的阶段
CompletableFuture<String> notFailed =
  CompletableFuture.supplyAsync(() -> "Success!");

CompletableFuture<String> failed =
  CompletableFuture.supplyAsync(() -> { throw new RuntimeException(); });

// NO OUTPUT BECAUSE THE PREVIOUS STAGE FAILED

var rejected = failed.acceptEither(notFailed, System.out::println);

// OUTPUT BECAUSE THE PREVIOUS STAGE COMPLETED NORMALLY
var resolved = notFailed.acceptEither(failed, System.out::println);
// => Success!

记住的要点是,除了错误处理操作之外的所有操作都需要一个非拒绝的前一个阶段才能正常工作,即使对于-Either操作也是如此。如果有疑问,请使用错误处理操作来确保管道仍处于数据通道上。

终端操作

到目前为止,任何操作都会返回另一个CompletionStage<T>以进一步扩展管道。基于Consumer的操作可能满足许多用例,但是在某些时候,即使可能阻塞当前线程,您也需要实际的值。

Future<t>类型相比,CompletionStage<T>类型本身不提供任何额外的检索方法。但是,它的实现CompletableFuture<T>提供了两个选项:getNowjoin方法。这将终端操作数量增加到四个,如表 13-2 中所列。

表 13-2. 从管道获取值

方法签名 用例 异常

| T get() | 阻塞当前线程直到管道完成。 | InterruptedException(已检查)ExecutionException(已检查)

CancellationException(未检查)|

| T get(long timeout, TimeUnit unit) | 阻塞当前线程直到管道完成,但在达到timeout后抛出异常。 | TimeoutException(已检查)InterruptedException(已检查)

ExecutionException(已检查)

CancellationException(未检查)|

T getNow(T valueIfAbsent) 如果正常完成则返回管道的结果,或者抛出CompletionException。如果结果仍然挂起,则立即返回提供的回退值T而不取消管道。 CompletionException(未检查) CancellationException(未检查)
join() 阻塞当前线程直到管道完成。 如果发生异常完成,对应的异常会被包装成CompletionException

类型CompletableFuture<T>还添加了另一个管道协调方法,isCompletedExceptionally,给你总共四个影响或检索管道状态的方法,如表 13-3 所列。

表 13-3. 协调方法

方法签名 返回
boolean cancel(boolean mayInterruptIfRunning) CancellationException异常异常完成尚未完成的阶段。参数mayInterruptIfRunning被忽略,因为与Future<T>不同,中断不用于控制。
boolean isCancelled() 如果阶段在完成之前被取消,则返回true
boolean isDone() 如果阶段已经以任何状态完成,则返回true
boolean isCompletedExceptionally() 返回true如果阶段已经异常完成,或者已经处于拒绝状态。

这是一个非常庞大的 API,涵盖了许多用例。尽管如此,根据您的要求,可能会缺少一些边缘案例。但是,添加您的辅助程序以填补任何差距是很容易的,所以让我们来做吧。

创建一个 CompletableFuture Helper

虽然 CompletableFuture API 非常庞大,但仍然缺少某些用例。例如,如前文所述在“组合任务”中,静态辅助程序allOf的返回类型是CompletableFuture<Void>,因此您无法在后续阶段访问给定实例的任何结果。它是一种灵活的仅协调方法,接受任何类型的CompletableFuture<?>作为其参数,但是以不访问任何结果的代价来弥补这一点。为此,您可以根据需要创建一个辅助程序来补充现有的 API。

让我们创建一个类似于allOf的辅助程序,一次运行多个CompletableFuture实例,但仍然可以访问它们的结果:

static CompletableFuture<List<T>> eachOf(CompletableFuture<T> cfs...)

提议的辅助eachOf运行所有给定的CompletableFuture实例,就像allOf一样。然而,与allOf不同的是,新的辅助程序使用了泛型类型T而不是?(问号)。将此限制为单一类型使eachOf方法实际上可以返回一个CompletableFuture<List<T>>而不是无结果的CompletableFuture<Void>

辅助支架

需要一个方便的class来保存任何辅助方法。这些辅助方法对于一些特定边缘情况非常有用,否则无法以简洁的方式或者根本无法解决。最惯用且安全的方式是使用一个带有private构造函数的class,如下所示,以防止任何人意外扩展或实例化该类型。

public final class CompletableFutures {

  private CompletableFutures() {
    // SUPPRESS DEFAULT CONSTRUCTOR
  }
}
注意

具有private默认构造函数的辅助类本身不必final,以防止其可扩展性。扩展类在没有可见的隐式super构造函数的情况下无法编译。然而,将辅助类设为final表明了所需的意图,而不依赖隐式行为。

设计eachOf

eachOf的目标几乎与allOf相同。这两种方法都协调一个或多个CompletableFuture实例。然而,eachOf进一步管理结果。这导致以下要求:

  • 返回包含所有给定实例的CompletableFuture,就像allOf一样。

  • 给予对成功完成实例结果的访问。

第一个要求由allOf方法实现。然而,第二个要求需要额外的逻辑。需要你检查给定的实例并聚合它们的结果。

任何逻辑在前一个阶段以任何方式完成后运行的最简单方法是使用thenApply操作,如下所示:

public static <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) {

  return CompletableFuture.allOf(cfs)
                          .thenApply(???);
}

利用你在本书中学到的知识,可以通过创建一个流数据处理管道来聚合成功完成的CompletableFuture实例的结果。

让我们逐步进行创建这样一个管道所需的步骤。

首先,必须从给定的CompletableFuture<T>实例创建流。它是一个vararg方法参数,因此对应一个数组。当处理vararg时,辅助方法Arrays#stream(T[] arrays)是明显的选择:

Arrays.stream(cfs)

接下来,过滤成功完成的实例。虽然没有显式方法询问实例是否正常完成,但由于Predicate.not,可以询问其相反情况:

Arrays.stream(cfs)
      .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))

有两种方法可以立即从CompletableFuture中获取结果:get()join()。在这种情况下,后者更可取,因为它不会抛出已检查异常,简化了流管道,如第十章所讨论的那样:

Arrays.stream(cfs)
      .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))
      .map(CompletableFuture::join)

使用join方法会阻塞当前线程以获取结果。然而,流管道在allOf完成后运行,因此所有结果已经可用。并且通过预先过滤非成功完成的元素,不会抛出可能导致管道崩溃的异常。

最后,结果被聚合到一个List<T>中。这可以通过collect操作完成,或者如果使用 Java 16+,可以使用Stream<T>类型的toList方法完成:

Arrays.stream(cfs)
      .filter(Predicate.not(CompletableFuture::isCompletedExceptionally))
      .map(CompletableFuture::join)
      .toList();

现在可以使用流水线在 thenApply 调用中收集结果。CompletableFutures 及其 eachOf 辅助方法的完整实现如 示例 13-6 所示。

示例 13-6. eachOf 的完整实现
public final class CompletableFutures {

  private final static Predicate<CompletableFuture<?>> EXCEPTIONALLY = ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    Predicate.not(CompletableFuture::isCompletedExceptionally);

  public static <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) {

    Function<Void, List<T>> fn = unused -> ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      Arrays.stream(cfs)
            .filter(Predicate.not(EXCEPTIONALLY))
            .map(CompletableFuture::join)
            .toList();

    return CompletableFuture.allOf(cfs) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                            .thenApply(fn);
  }

  private CompletableFutures() {
    // SUPPRESS DEFAULT CONSTRUCTOR
  }
}

1

用于测试成功完成的 Predicate 不绑定到特定的 CompletableFuture 实例,因此可重用为 final static 字段。

2

结果收集操作由 Function<Void, List<T>> 表示,该函数与 allOf 的返回类型的内部类型和 eachOf 的预期返回类型相匹配。

3

整体任务仅仅是调用预先存在的 allOf 并将其与结果聚合管道组合。

就是这样!我们为某些用例创建了 allOf 的替代方案,以便轻松访问结果。

最终实现是解决问题的功能方法的示例。每个任务本身都是独立的,可以单独使用。但是通过组合它们,您可以创建由较小部分构建的更复杂的解决方案。

改进 CompletableFutures 助手

eachOf 方法与 allOf 互为补充,其工作方式与预期相同。如果给定的任何 CompletableFuture 实例失败,返回的 CompletableFuture<List<T>> 也将异常完成。

对于“fire & forget”用例,您可能只关注成功完成的任务,而不关心任何失败。但是,如果尝试使用 get 或类似方法提取其值,则失败的 CompletableFuture 将抛出异常。因此,让我们添加一个基于 eachOfbestEffort 辅助方法,始终成功完成并仅返回成功的结果。

主要目标与 eachOf 几乎相同,除非 allOf 调用返回了异常完成的 CompletableFuture<Void>,必须恢复。通过插入 exceptionally 操作添加异常钩子是显而易见的选择:

public static
<T> CompletableFuture<List<T>> bestEffort(CompletableFuture<T>... cfs) {

  Function<Void, List<T>> fn = ...; // no changes to Stream pipeline

  return CompletableFuture.allOf(cfs)
                          .exceptionally(ex -> null)
                          .thenApply(fn);
}

一开始,exceptionally lambda ex -> null 看起来可能有些奇怪。但是如果您检查其底层方法签名,其意图会变得更清晰。

在这种情况下,exceptionally 操作需要一个 Function<Throwable, Void> 来通过返回 Void 类型的值而不是抛出异常来恢复 CompletableFuture。这通过返回 null 来实现。之后,从 eachOf 到聚合流水线的聚合流水线被使用来收集结果。

提示

使用 handle 操作也可以实现相同的行为,并在单个 BiFunction 中处理成功或拒绝的两种状态。不过,将状态分开处理可以使管道更具可读性。

现在我们有两个具有共享逻辑的辅助方法,将共同逻辑提取到它们自己的方法中可能是合理的。这基于将孤立逻辑组合在一起以创建更复杂和完整任务的功能方法。Futures的可能重构实现在示例 13-7 中展示。

示例 13-7. 使用eachOfbestEffort重构的 Futures 实现
public final class CompletableFutures {

  private final static Predicate<CompletableFuture<?>> EXCEPTIONALLY = ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    Predicate.not(CompletableFuture::isCompletedExceptionally);

  private static <T> Function<Void, List<T>>
                     gatherResultsFn(CompletableFuture<T>... cfs) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    return unused -> Arrays.stream(cfs)
                      .filter(Predicate.not(EXCEPTIONALLY))
                      .map(CompletableFuture::join)
                      .toList();
  }

  public static <T> CompletableFuture<List<T>> eachOf(CompletableFuture<T>... cfs) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return CompletableFuture.allOf(cfs)
                            .thenApply(gatherResultsFn(cfs));
  }

  public static <T> CompletableFuture<List<T>> bestEffort(CompletableFuture<T>... cfs) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return CompletableFuture.allOf(cfs)
                            .exceptionally(ex -> null)
                            .thenApply(gatherResultsFn(cfs));
  }

  private CompletableFutures() {
    // SUPPRESS DEFAULT CONSTRUCTOR
  }
}

1

Predicate未更改。

2

结果收集逻辑被重构为一个private工厂方法,以确保在eachOfbestEffort中的一致处理。

3

两个public辅助方法都简化到最低限度。

重构后的CompletableFutures辅助程序比以前更简单、更健壮。任何可共享的复杂逻辑都得到重用,因此它提供了始终保持一致行为并最小化所需文档的方法,这些文档肯定应该添加以将预期功能沟通给任何调用方。

手动创建和完成

创建Future<T>实例的唯一方式(除了自己实现接口)是向ExecutorService提交任务。CompletableFuture<T>static方便工厂方法runAsyncsupplyAsync非常相似。与其前身不同,它们不是唯一创建实例的方式。

手动创建

由于CompletableFuture<T>类型是一个实际的实现而不是一个接口,它有一个构造函数,您可以使用它来创建一个未完成的实例,如下所示:

CompletableFuture<String> unsettled = new CompletableFuture<>();

然而,没有附加任务,它将永远不会完成或失败。相反,您需要手动完成这样的任务。

手动完成

有几种方法可以安排现有的CompletableFuture<T>实例并启动附加的管道:

  • boolean complete(T value)

  • boolean completeExceptionally(Throwable ex)

两种方法如果调用成功将返回true,将阶段转换为期望状态。

Java 9 引入了额外的complete方法,用于正常完成的阶段,形式为-Async变体,以及基于超时的一个:

  • CompletableFuture<T> completeAsync(Supplier<T> supplier)

  • CompletableFuture<T> completeAsync(Supplier<T> supplier, Executor executor)

  • CompletableFuture<T> completeOnTimeout(T value, long timeout, TimeUnit unit)

-Async变体使用新的异步任务,以supplier的结果完成当前阶段。

completeOnTimeout这种方法在达到timeout之前,如果该阶段未能完成,就使用给定的value完成当前阶段。

而不是创建一个新实例然后手动完成它,您也可以使用以下这些static方便的工厂方法之一创建一个已经完成的实例:

  • CompletableFuture<U> completedFuture(U value)

  • CompletableFuture<U> failedFuture(Throwable ex)(Java 9+)

  • CompletionStage<U> completedStage(U value)(Java 9+)

  • CompletionStage<U> failedStage(Throwable ex)(Java 9+)

这些已经完成的未来对象可以用于任何组合操作中,或者作为 CompletableFutures 流水线的起点,正如我将在下一节中讨论的那样。

手动创建和完成实例的用例

实际上,CompletableFuture API 提供了一种简单的方法来创建具有多个步骤的异步任务流水线。通过手动创建和完成阶段,您可以对之后如何执行流水线进行精细控制。例如,如果已知结果,则可以避免启动任务。或者,您可以为常见任务创建部分流水线工厂。

让我们看看几个可能的用例。

CompletableFuture 作为返回值

CompletableFuture 作为可能昂贵或长时间运行任务的出色返回值。

想象一个天气报告服务,调用 REST API 返回一个 WeatherInfo 对象。尽管天气随时间变化,但有意义的是在更新之前缓存某个地方的 WeatherInfo 一段时间。

REST 调用自然比简单的缓存查找更昂贵,并且需要更长时间,因此可能会阻塞当前线程太长时间,以至于无法接受。将其包装在 CompletableFuture 中提供了一种简单的方法,将任务从当前线程中卸载,导致以下通用的带有单一 public 方法的 WeatherService

public class WeatherService {

  public CompletableFuture<WeatherInfo> check(ZipCode zipCode) {
    return CompletableFuture.supplyAsync(
      () -> this.restAPI.getWeatherInfoFor(zipCode)
    );
  }
}

添加缓存需要两种方法,一种用于存储任何结果,另一种用于检索现有结果,如下所示:

public class WeatherService {

  private Optional<WeatherInfo> cached(ZipCode zipCode) {
    // ...
  }

  private WeatherInfo storeInCache(WeatherInfo info) {
    // ...
  }

  // ...
}

使用 Optional<WeatherInfo> 为您提供了一个功能性的起点,以稍后连接每个部分。缓存机制的实际实现对示例的目的和意图并不重要。

实际的 API 调用也应进行重构,以创建更小的逻辑单元,从而导致一个公共方法和三个私有的独立操作。通过使用 thenApply 方法和 storeInCache 方法,可以将将结果存储在缓存中的逻辑添加为 CompletableFuture 操作:

public class WeatherService {

  private Optional<WeatherInfo> cacheLookup(ZipCode zipCode) {
    // ...
  }

  private WeatherInfo storeInCache(WeatherInfo info) {
    // ...
  }

  private CompletableFuture<WeatherInfo> restCall(ZipCode zipCode) {

    Supplier<WeatherInfo> restCall = this.restAPI.getWeatherInfoFor(zipCode);

    return CompletableFuture.supplyAsync(restCall)
                            .thenApply(this::storeInCache);
  }

  public CompletableFuture<WeatherInfo> check(ZipCode zipCode) {
    // ...
  }
}

现在所有部分都可以组合起来完成提供缓存天气服务的任务,如示例 Example 13-8 所示。

示例 13-8. 使用 CompletableFutures 的缓存 WeatherService
public class WeatherService {

  private Optional<WeatherInfo> cacheLookup(ZipCode zipCode) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    // ...
  }

  private WeatherInfo storeInCache(WeatherInfo info) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    // ...
  }

  private CompletableFuture<WeatherInfo> restCall(ZipCode zipCode) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    Supplier<WeatherInfo> restCall = () -> this.restAPI.getWeatherInfoFor(zipCode);

    return CompletableFuture.supplyAsync(restCall)
                            .thenApply(this::storeInCache);
  }

  public CompletableFuture<WeatherInfo> check(ZipCode zipCode) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)

    return cacheLookup(zipCode).map(CompletableFuture::completedFuture) ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
                               .orElseGet(() -> restCall(zipCode)); ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
  }
}

1

缓存查找返回一个 Optional<WeatherInfo>,提供一个流畅和功能性的起点。 storeInCache 方法返回存储的 WeatherInfo 对象,可用作方法引用。

2

restCall 方法将 REST 调用本身与成功完成后的结果存储在缓存中结合在一起。

3

check 方法通过首先查找缓存来组合其他方法。

4

如果找到 WeatherInfo,它会立即返回一个已完成的 CompletableFuture<WeatherInfo>

5

如果未找到 WeahterInfo 对象,则 Optional 的 orElseGet 懒执行 reastCall 方法。

将 CompletableFutures 与 Optionals 结合的优势在于,对于调用者来说,后台发生了什么并不重要,无论数据是通过 REST 加载还是直接来自缓存。每个 private 方法都以最高效的方式执行单一任务,而 public 方法只在绝对需要时将它们组合为异步任务管道执行昂贵的工作。

未决的 CompletableFuture 管道

未决的 CompletableFuture 实例永远不会自行完成任何状态。类似于流,直到连接终端操作,CompletableFuture 任务管道不会执行任何工作。因此,它作为更复杂任务管道的第一阶段提供了一个完美的起点,甚至是稍后按需执行的预定义任务的脚手架。

假设您想处理图像文件。涉及多个独立步骤可能失败。与直接处理文件不同,工厂提供未决的 CompletedFuture 实例,如 Example 13-9 所示。

Example 13-9. 带有未决 CompletableFuture 的 ImageProcessor
public class ImageProcessor {

  public record Task(CompletableFuture<Path> start, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                     CompletableFuture<InputStream> end) {
    // NO BODY
  }

  public Task createTask(int maxHeight,
                         int maxWidth,
                         boolean keepAspectRatio,
                         boolean trimWhitespace) {

    var start = new CompletableFuture<Path>(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    var end = unsettled.thenApply(...) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
                       .exceptionally(...)
                       .thenApply(...)
                       .handle(...);

    return new Task(start, end); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
  }
}

1

调用者需要访问未决的第一阶段来启动管道,但还需要阶段来访问最终结果。

2

返回的 CompletableFuture 实例的通用类型必须与实际执行管道时调用者提供的类型匹配。在本例中,使用 Path 到图像文件。

3

任务管道始于一个未决实例,以便可以懒加载添加所需的处理操作。

4

Task 记录返回,以便轻松访问第一个和最后一个阶段。

运行任务管道是通过调用第一阶段 start 上的任意 complete 方法完成的。然后,最后一个阶段用于检索可能的结果,如下所示:

// CREATING LAZY TASK
var task = this.imageProcessor.createTask(800, 600, false, true);

// RUNNING TASK
var path = Path.of("a-functional-approach-to-java/cover.png");
task.start().complete(path);

// ACCESSING THE RESULT
var processed = task.end().get();

就像没有终端操作的流管道会为多个项创建一个延迟处理管道一样,未决的 CompletableFuture 管道是一个延迟可用的单个项任务管道。

关于线程池和超时

并发编程的另外两个方面不容忽视:超时和线程池。

默认情况下,所有-AsyncCompletableFuture操作使用 JDK 的公共ForkJoinPool。这是一个基于运行时设置具有合理默认值的高度优化的线程池¹。顾名思义,“common”池是一个共享池,也被 JDK 的其他部分如并行流使用。然而,与并行流不同,异步操作可以使用自定义的Executor。这使您可以使用适合您需求的线程池²,而不影响公共池。

守护线程

使用ForkJoinPool通过线程与通过Executor创建的用户线程之间的一个重要区别是它们能够比主线程生存更久。默认情况下,用户创建的线程是非守护线程,这意味着它们会比主线程更长时间地存活,并阻止 JVM 退出,即使主线程已完成所有工作。然而,通过ForkJoinPool使用线程可能会随着主线程的结束而被终止。有关该主题的更多详细信息,请参阅 Java 冠军 A N M Bazlur Rahman 的这篇博文

在最高效的线程上运行任务只是方程式的一半;考虑超时也是另一半。一个永不完成或超时的CompletableFuture将保持永远挂起,阻塞其线程。例如,如果尝试通过调用get()检索其值,则当前线程也会被阻塞。选择适当的超时可以防止永远阻塞的线程。然而,使用超时意味着现在也必须处理可能的TimeoutException

提供多种操作,包括中间操作和终端操作,如表 13-4 所列。

表 13-4. 与超时相关的操作

方法签名 用例
CompletableFuture<T> completeOnTimeout(T value, long timeout, TimeUnit unit) 在超时后使用提供的值正常完成阶段。(Java 9+)
CompletableFuture<T> orTimeout(long timeout, TimeUnit unit) 在超时后异常地完成阶段。(Java 9+)
T get(long timeout, TimeUnit unit) 阻塞当前线程直到计算结束。如果超时,则抛出TimeoutException异常。

中间操作completeOnTimeoutorTimeout提供了一种类似拦截器的操作,用于处理CompletableFuture管道中任何位置的超时情况。

超时的替代方案是通过调用boolean cancel(boolean mayInterruptIfRunning)取消正在运行的阶段。它取消一个未解决的阶段及其依赖项,因此可能需要一些协调和跟踪正在取消的正确阶段。

关于异步任务的最终思考

异步编程是并发编程的一个重要方面,以实现更好的性能和响应能力。然而,要理解异步代码执行的时间和在哪个线程上执行并不明显。

协调不同线程在 Java 中并不是什么新鲜事。如果你不习惯多线程编程,这可能是一件麻烦且难以高效完成的事情。这正是CompletableFuture API 的闪光之处。它将复杂的异步、可能是多步骤任务的创建与协调结合到一个广泛、一致且易于使用的 API 中。这使得你可以比以往更轻松地将异步编程纳入你的代码中。此外,你不需要通常与多线程编程相关的常见样板和“扶手”。

然而,就像所有编程技术一样,存在一个最佳问题上下文。如果不加区分地使用,异步任务可能会达不到预期的目标。

异步运行任务适用于以下任何情况:

  • 许多任务需要同时进行,至少其中一个能够取得进展。

  • 处理重 I/O、长时间运算、网络调用或任何类型的阻塞操作的任务。

  • 任务大多数是独立的,不必等待另一个任务完成。

即使是像CompletableFuture这样的高级抽象,多线程代码也是以可能的效率为代价的简单交换。

像其他并发或并行高级 API 一样,比如我在第八章中讨论的并行流 API,协调多个线程涉及非明显的成本。应该有意选择这些 API 作为优化技术,而不是作为一种希望更有效地利用可用资源的一揽子解决方案。

如果你对如何安全地在多线程环境中导航的细节感兴趣,我推荐由 Oracle 的 Java 语言架构师 Brian Goetz³ 所著的 Java Concurrency in Practice 这本书。即使自 2006 年发布以来引入了所有新的并发功能,这本书仍然是该主题的事实参考手册。

要点

  • Java 5 引入了类型Future<T>作为异步任务的容器类型,具有最终结果。

  • CompletableFuture API 在提供了许多以前不可用的理想特性的基础上,改进了Future<T>类型。它是一个声明式的、反应式的、基于 Lambda 的协调 API,拥有 70 多个方法。

  • 任务可以轻松地链式或合并成一个更复杂的管道,如果需要的话,每个任务都在新线程中运行。

  • 异常是头等公民,你可以在函数流畅调用中恢复,不像 Streams API。

  • CompletableFuture<T>实例可以手动创建,无需任何线程或其他协调即可使用预先存在的值,或者作为挂起实例,以提供其附加操作的按需启动点。

  • 由于CompletableFuture API 是一个并发工具,因此还需要考虑通常与并发相关的方面和问题,如超时和线程池。与并行流一样,异步运行任务应被视为一种优化技术,而不一定是首选选项。

¹ 通用的ForkJoinPool的默认设置及其如何更改的解释可在其文档中找到。

² 优秀的书籍Java 并发实战由 Josh Bloch 等人编写(ISBN 9780321349606),在第二部分:第八章 应用线程池中,提供了更好地理解线程池工作及其最佳应用的所有信息。

³ Goetz, Brian. 2006. “Java Concurrency in Practice.” Addison-Wesley. ISBN 978-0321349606.

第十四章:函数式设计模式

函数式编程对面向对象设计模式的回答通常是“只使用函数即可”。从技术上讲,这是正确的;在函数式编程中,这是一种无止境的递归。然而,从一个希望用函数原则增强你的代码的面向对象思维出发,需要更多实际的建议来以函数式方式利用已知模式。

本章将介绍 四人组 描述的一些常用面向对象设计模式,以及它们如何从函数式方法中受益。

什么是设计模式?

每次解决问题时,你都不必重新发明轮子。许多问题已经解决了,或者至少以设计模式的形式存在着一个通用的方法。作为一名 Java 开发者,你很可能已经使用过或遇到过一个或多个面向对象设计模式,即使当时你并不知道它们。

本质上,面向对象的设计模式是经过测试、验证、形式化和可重复使用的对常见问题的解决方案。

四人组 将他们描述的模式分为三组:

行为模式

如何处理对象的职责和通信之间的关系。

创建型模式

如何抽象对象创建/实例化过程,以帮助创建、组合和表示对象。

结构型模式

如何组合对象以形成更大或增强的对象。

设计模式是一般性的脚手架,可用于将知识与应用它们到特定问题的概念共享。这就是为什么并不是每种语言或方法都适用于每种模式。尤其是在函数式编程中,许多问题除了“只是函数”之外,并不需要特定的模式。

(函数式)设计模式

让我们来看看四种常用的面向对象设计模式以及如何以函数式方式处理它们:

  • 工厂模式(创建型)

  • 装饰器模式(结构型)

  • 策略模式(行为型)

  • 建造者模式(创建型)

工厂模式

工厂模式 属于 创建型模式 群体之一。它的目的是创建一个对象的实例,而不是通过使用 工厂 来暴露 如何创建 这些对象的实现细节。

面向对象方法

实现工厂模式的多种方式。对于我的示例,所有对象都具有共享的接口,而一个 enum 负责标识所需的对象类型:

public interface Shape {
  int corners();
  Color color();
  ShapeType type();
}

public enum ShapeType {
  CIRCLE,
  TRIANGLE,
  SQUARE,
  PENTAGON;
}

形状由记录表示,只需要一个 Color,因为它们可以直接推断出其他属性。一个简单的 Circle 记录可能是这样的:

public record Circle(Color color) implements Shape {

  public int corners() {
    return 0;
  }

  public ShapeType type() {
    return ShapeType.CIRCLE;
  }
}

一个 Shape 工厂需要接受 typecolor 来创建相应的 Shape 实例,如下所示:

public class ShapeFactory {

  public static Shape newShape(ShapeType type,
                               Color color) {
    Objects.requireNonNull(color);

    return switch (type) {
      case CIRCLE -> new Circle(color);
      case TRIANGLE -> new Triangle(color);
      case SQUARE -> new Square(color);
      case PENTAGON -> new Pentagon(color);
      default -> throw new IllegalArgumentException("Unknown type: " + type);
    };
  }
}

迄今为止所涉及的所有代码,模式有四个明确的部分:

  • 共享的 interface Shape

  • 标识形状的 enum ShapeType

  • 形状的具体实现(未显示)

  • ShapeFactory根据其typecolor创建形状

这些部分彼此依赖是预期的。然而,工厂和enum之间的这种相互依赖使整个方法对变更非常脆弱。如果引入新的ShapeType,工厂必须考虑它,否则在switchdefault情况下会抛出IllegalArgumentException,即使存在具体的实现类型。

注意

并不一定需要default情况,因为所有情况都已声明。它用来说明ShapeTypeShapeFactory之间的依赖关系以及如何减轻它。

为了改进工厂,可以通过引入更功能化的方法来减少其脆弱性,并进行编译时验证。

更加功能化的方法

此示例创建了相当简单的记录,只需要一个参数:Color。这些相同的构造函数使您可以直接将“工厂”移入enum,因此任何新形状都自动需要相应的工厂函数。

即使 Java 的enum类型基于常量名称,您仍然可以为每个常量附加相应的值。在这种情况下,用于创建离散对象的工厂函数形式的Function<Color, Shape>值:

public enum ShapeType {
  CIRCLE,
  TRIANGLE,
  SQUARE,
  PENTAGON;

  public final Function<Color, Shape> factory;

  ShapeType(Function<Color, Shape> factory) {
    this.factory = factory;
  }
}

由于常量声明现在需要额外的Function<Color, Shape>,所以代码不再编译。幸运的是,Shapes 的构造函数可用作方法引用,以创建相当简洁的工厂方法代码:

public enum ShapeType {
  CIRCLE(Circle::new),
  TRIANGLE(Triangle::new),
  SQUARE(Square::new),
  PENTAGON(Pentagon::new);

  // ...
}

enum通过将离散的创建方法作为其每个常量的附加值来增强了其功能。这样,任何未来的增加,比如HEXAGON,都会强制您提供相应的工厂方法,无法遗漏,因为编译器会强制执行。

现在剩下的就是创建新实例的能力。您可以简单地直接使用factory字段及其 SAMaccept(Color color),但我更喜欢添加一个额外的方法来进行健全性检查:

public enum ShapeType {

  // ...

  public Shape newInstance(Color color) {
    Objects.requireNonNull(color);
    return this.factory.apply(color);
  }
}

现在创建一个新的Shape实例非常容易:

var redCircle = ShapeType.CIRCLE.newInstance(Color.RED);

由于现在有了一个用于实例创建的专用方法,所以公共字段factory可能看起来多余。这在某种程度上是正确的。但它仍然提供了与工厂进一步交互的功能方式,比如功能组合来记录形状的创建:

Function<Shape, Shape> cornerPrint =
  shape -> {
    System.out.println("Shape created with " + shape.corners() + " corners.");
  };

ShapeType.CIRCLE.factory.andThen(cornerPrint)
                        .apply(Color.RED);

通过将工厂与enum融合,决策过程——调用哪个工厂方法——被直接绑定到ShapeType的对应方法上取代了。现在,Java 编译器强制您在对enum进行任何添加时实现工厂。

这种方法通过增加的编译时安全性减少了所需的样板代码,用于未来扩展。

装饰器模式

装饰器模式是允许在运行时修改对象行为的结构型模式。而不是子类化,对象被包装在一个“装饰器”中,其中包含所需的行为。

面向对象的方法

这种模式的面向对象实现要求装饰器与它们应装饰的类型共享接口。为了简化编写新装饰器,使用实现共享接口的抽象类作为任何装饰器的起点。

想象一个咖啡机,有一个方法来准备咖啡。共享接口和具体实现如下:

public interface CoffeeMaker {
  List<String> getIngredients();
  Coffee prepare();
}

public class BlackCoffeeMaker implements CoffeeMaker {

  @Override
  public List<String> getIngredients() {
    return List.of("Robusta Beans", "Water");
  }

  @Override
  public Coffee prepare() {
    return new BlackCoffee();
  }
}

目标是装饰咖啡机以添加像牛奶或糖这样的功能。因此,装饰器必须接受咖啡机并装饰 prepare 方法。一个简单的共享 abstract 装饰器如 ??? 所示。

public abstract class Decorator implements CoffeeMaker { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

  private final CoffeeMaker target;

  public Decorator(CoffeeMaker target) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    this.target = target;
  }

  @Override
  public List<String> getIngredients() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return this.target.getIngredients();
  }

  @Override
  public Coffee prepare() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    return this.target.prepare();
  }
}

1

Decorator 实现 CoffeeMaker,因此它可以作为一个插入式替换使用。

2

构造函数接受原始的 CoffeeMaker 实例,该实例应该被装饰。

3

getIngredientsprepare 方法只需调用装饰的 CoffeeMaker,因此任何实际的装饰器都可以使用 super 调用来获取“原始”结果。

abstract Decorator 类型聚合了装饰一个 CoffeeMaker 所需的最小功能。借助它的帮助,将蒸牛奶添加到咖啡中变得简单。现在你所需的只是一个牛奶盒,如 示例 14-1 中所示。

示例 14-1. 使用装饰器添加牛奶
public class AddMilkDecorator extends Decorator {

  private final MilkCarton milkCarton;

  public AddMilkDecorator(CoffeeMaker target,
                          MilkCarton milkCarton) { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    super(target);

    this.milkCarton = milkCarton;
  }

  @Override
  public List<String> getIngredients() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
    var newIngredients = new ArrayList<>(super.getIngredients());
    newIngredients.add("Milk");
    return newIngredients;
  }

  @Override
  public Coffee prepare() { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
    var coffee = super.prepare();
    coffee = this.milkCarton.pourInto(coffee);
    return coffee;
  }
}

1

构造函数需要接受所有的要求,因此除了 CoffeeMaker,还需要一个 MilkCarton

2

通过首先调用super,使结果可变,并将牛奶添加到之前使用的成分列表,装饰器钩子进入getIngredients调用。

3

prepare 调用同样要求 super 执行其预期目的,并用牛奶“装饰”结果的咖啡。

现在很容易制作“café con leche³":

CoffeeMaker coffeeMaker = new BlackCoffeeMaker();

CoffeeMaker decoratedCoffeeMaker =
  new AddMilkDecorator(coffeeMaker,
                       new MilkCarton());

Coffee cafeConLeche = decoratedCoffeeMaker.prepare();

装饰器模式实现起来相当简单。尽管如此,为了在咖啡中加入牛奶,需要大量代码。如果你的咖啡里也要加糖,你需要创建另一个装饰器,其中有冗余的样板代码,并且需要再次包装装饰的 CoffeeMaker

CoffeeMaker coffeeMaker = new BlackCoffeeMaker();

CoffeeMaker firstDecoratedCoffeeMaker =
  new AddMilkDecorator(coffeeMaker,
                       new MilkCarton());

CoffeeMaker lastDecoratedCoffeeMaker =
  new AddSugarDecorator(firstDecoratedCoffeeMaker);

Coffee lastDecoratedCoffeeMaker = coffeeMaker.prepare();

必须有一种更简单的方法来改进装饰器的创建和使用多个装饰器的过程。

因此,让我们看看如何改用功能组合。

更功能化的方法

任何重构工作朝向更功能化方法的第一步是剖析实际发生的事情。装饰器模式由两部分组成,适合改进:

  • 使用一个或多个装饰器装饰 CoffeeMaker

  • 创建一个 Decorator 本身

“如何装饰”的第一部分归结为获取现有的 CoffeeMaker 并“某种方式”添加新行为并返回一个新的 CoffeeMaker 以供使用。因此,实质上,该过程看起来像是一个 Function<CofeeMaker, CoffeeMaker>

与以往一样,逻辑被捆绑为方便类型中的 static 高阶方法。该方法接受一个 CoffeeMaker 和一个装饰器,并用功能组合将它们组合起来:

public final class Barista {

  public static CoffeeMaker decorate(CoffeeMaker coffeeMaker,
                                     Function<CoffeeMaker, CoffeeMaker> decorator) {

    return decorator.apply(coffeeMaker);
  }

  private Barista() {
    // Suppress default constructor.
    // Ensures non-instantiability and non-extendability.
  }
}

Barista 类有一个带参数的 decorate 方法,通过接受一个 Function<CofeeMaker, CoffeeMaker> 来反转流程以实际执行装饰的过程。尽管装饰现在“感觉”更加功能化,但仍然只接受一个单一的 Function,对于多个装饰仍然显得繁琐:

CoffeeMaker decoratedCoffeeMaker =
  Barista.decorate(new BlackCoffeeMaker(),
                   coffeeMaker -> new AddMilkDecorator(coffeeMaker,
                                                       new MilkCarton()));

CoffeeMaker finalCoffeeMaker =
  Barista.decorate(decoratedCoffeeMaker,
                   AddSugarDecorator::new);

幸运的是,在 Chapter 6 中我讨论了一种功能 API 来按顺序处理多个元素:Streams。

装饰过程有效地是一个 减少,原始 CoffeMaker 作为其初始值,并且 Function<CoffeeMaker, CoffeeMaker> 接受先前的值以创建新的 CoffeeMaker。因此,装饰过程看起来像是 Example 14-2 中描述的。

示例 14-2. 通过减少多个装饰
public final class Barista {

 public static
 CoffeeMaker decorate(CoffeeMaker coffeeMaker, ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
                      Function<CoffeeMaker, CoffeeMaker>... decorators) {

    Function<CoffeeMaker, CoffeeMaker> reducedDecorations = ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      Arrays.stream(decorators)
            .reduce(Function.identity(),
                    Function::andThen);

    return reducedDecorations.apply(coffeeMaker); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
  }
}

1

decorate 方法仍然接受原始的 CoffeeMaker 进行装饰。但是,由于可变参数的存在,可以提供任意数量的装饰。

2

通过创建一个流并将所有元素减少为单一的 Function<CoffeeMaker, CoffeeMaker> 来将装饰组合在一起。

3

最后,单一的减少装饰与 CoffeeMaker 组合。

现在,通过组合多个功能性和功能类似的技术,制作 café con leche 变得更加简单:

CoffeeMaker decoratedCoffeeMaker =
  Barista.decorate(new BlackCoffeeMaker(),
                   coffeeMaker -> new AddMilkDecorator(coffeeMaker,
                                                       new MilkCarton()),
                   AddSugarDecorator::new);

装饰过程通过将装饰器逐个嵌套改进为一个单一调用。然而,使用函数仍然可以改进装饰器的创建。

您可以使用另一个便利类型将装饰器组合在一起,而不必自己创建一个 Function<CoffeeMaker, CoffeeMaker> 形式的装饰器,通过使用 lambda 或方法引用。

Decorations 便利类型的实现与其静态工厂方法非常直接,如下代码所示:

public final class Decorations {

  public static Function<CoffeeMaker, CoffeeMaker> addMilk(MilkCarton milkCarton) {
    return coffeeMaker -> new AddMilkDecorator(coffeeMaker, milkCarton);
  }

  public static Function<CoffeeMaker, CoffeeMaker> addSugar() {
    return AddSugarCoffeeMaker::new;
  }

  // ...
}

所有可能的成分都可以通过单一类型获得,无需任何调用者了解实际实现或其他要求,除了每种方法的参数。这样,您可以使用更简洁流畅的调用来装饰您的咖啡:

CoffeeMaker maker = Barista.decorate(new BlackCoffeeMaker(),
                                     Decorations.addMilk(milkCarton),
                                     Decorations.addSugar());
var coffee = maker.prepare();

函数式方法的主要优点在于可能消除显式嵌套并暴露具体实现类型。与其用额外的类型和重复的样板填充您的包裹,不如利用 JDK 已有的函数接口来帮助您以更简洁的代码实现相同的结果。尽管应该将相关代码组合在一起,使相关功能位于一个单独的文件中,如果它能创建更好的层次结构,也可以拆分它,但并不需要。

策略模式

策略模式属于行为模式的一种。由于主导大多数面向对象设计的开闭原则⁠⁴,不同的系统通常通过抽象耦合,如针对接口而不是具体实现进行编程。

这种抽象耦合提供了更多理论组件一起工作的有用虚构,以便稍后实现,而不是您的代码知道实际实现。策略使用这种解耦的代码风格来创建基于相同抽象的可互换的小逻辑单元。哪个被选择在运行时决定。

面向对象方法

想象一下,您正在一个销售实物商品的电子商务平台上工作。这些商品必须以某种方式运送给客户。有多种方法可以运送物品,如不同的运输公司或运输类型。

这些各种运输选项共享一个常见的抽象,然后在系统的另一部分中使用,如ShippingService类型,用于发货包裹:

public interface ShippingStrategy {
  void ship(Parcel parcel);
}

public interface ShippingService {
  void ship(Parcel parcel,
            ShippingStrategy strategy);
}

然后,每个选项都实现为一个ShipppingStrategy。在这种情况下,让我们只看标准和加急运输:

public class StandardShipping implements ShippingStrategy {
  // ...
}

public class ExpeditedShipping implements ShippingStrategy {

  public ExpeditedShipping(boolean signatureRequired) {
    //...
  }

  // ...
}

每种策略都需要自己的类型和具体实现。这种一般方法看起来与我在前一节讨论的装饰器非常相似。这就是为什么它几乎可以以相同的功能方式简化的原因。

更功能化的方法

策略模式背后的整体概念归结为行为参数化。这意味着ShippingService提供了一个通用的框架,允许包裹被发货。然而,如何实际发货需要使用从外部传递给它的ShippingStrategy填充。

策略应该是小型和上下文绑定的决策,并且通常可以由一个功能接口表示。在这种情况下,您有多种选项来创建和使用策略:

  • Lambda 表达式和方法引用

  • 部分应用函数

  • 具体实现

简单的策略没有任何额外要求最好是通过class分组,并通过方法引用来使用与签名兼容的方法:

public final class ShippingStrategies {

  public static ShippingStrategy standardShipping() {
    return parcel -> ...;
  }
}

// HOW TO USE
shippingService.ship(parcel,
                     ShippingStrategies::standardShipping);

更复杂的策略可能需要额外的参数。这就是部分应用函数将代码累积到一个单一类型中以提供更简单创建方法的地方:

public final class ShippingStrategies {

  public static ShippingStrategy expedited(boolean requiresSignature) {

    return parcel -> {
      if (requiresSignature) {
        // ...
      }
    };
  }
}

// HOW TO USE
shippingService.ship(parcel,
                     ShippingStrategies.expedited(true));

这两个功能选项用于创建和使用策略已经是一种更为简洁的处理策略的方法。它们还消除了需要额外的实现类型来表示策略的要求。

然而,如果由于更复杂的策略或其他要求而两种功能选项都不可行,您总是可以使用具体实现。如果从面向对象的策略过渡,它们将是具体实现,从一开始就开始。这就是为什么策略模式是引入逐步转换现有策略为功能代码或至少在新策略中使用它的首选方法。

构建者模式

建造者模式是另一种用于通过将构建与表示分离来创建更复杂数据结构的创建型模式。它解决了各种对象创建问题,例如多步创建、验证和改进的可选参数处理。因此,它是Records的良好伴侣,后者只能一次性创建。在第五章中,我已经讨论了如何为Record创建一个构建器。然而,本节将从功能角度来看待构建器。

面向对象方法

假设您有一个简单的record User,具有三个属性和一个组件验证:

public record User(String email, String name, List<String> permissions) {

  public User {
    if (email == null || email.isBlank()) {
      throw new IllegalArgumentException("'email' must be set.");
    }

    if (permissions == null) {
      permissions = Collections.emptyList();
    }
  }
}

如果您需要分步创建User,比如稍后再添加permissions,如果没有额外的代码,您就无法成功。因此,让我们像???中展示的那样添加一个内部构建器。

public record User(String email, String name, List<String> permissions) {

  // ... shorthand constructor omitted

  public static class Builder { ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)

    private String email;
    private String name;
    private final List<String> permissions = new ArrayList<>();

    public Builder email(String email) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      this.email = email;
      return this;
    }

    public Builder name(String name) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)
      this.name = name;
      return this;
    }

    public Builder addPermission(String permission) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      this.permissions.add(permission);
      return this;
    }

    public User build() { ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/4.png)
      return new User(this.email, this.name, this.permissions);
    }
  }

  public static Builder builder() { ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/5.png)
    return new Builder();
  }
}

1

构建器被实现为一个内部static类,模仿其父记录的所有组件。

2

每个组件都有其专用的设置方法,返回Builder实例以进行流畅的调用链。

3

针对基于集合的字段的附加方法允许您添加单个元素。

4

build方法只需调用适当的User构造函数。

5

添加了一个static builder方法,因此您不需要自己创建Builder实例。

这需要相当多的样板代码和重复,以允许像这样更加灵活和简单的创建流程:

var builder = User.builder()
                  .email("ben@example.com")
                  .name("Ben Weidig");

// DO SOMETHING ELSE, PASS BUILDER ALONG

var user = builder.addPermission("create")
                  .addPermission("edit")
                  .build();

通常,通过添加更好的支持可选和非可选字段的telescoping constructors或附加验证代码,构建器会更加复杂。

老实说,目前设计中优化或更改生成器模式的方式并不多。你可以使用工具辅助的方法为你生成生成器,但这只会减少你需要编写的代码量,而不是生成器本身的必要性。

但这并不意味着生成器不能通过一些功能性的触摸来改进。

更多功能性方法

大多数情况下,生成器与它正在构建的类型强耦合,作为一个内部class,具有流畅的方法来提供参数和一个build方法来创建实际的对象实例。功能性方法可以通过多种方式改进此创建流程。

首先,它使昂贵值的惰性计算成为可能。与直接接受一个值不同,Supplier<T>为你提供了一个仅在build调用中解析的惰性包装器:

public record User(String email, String name, List<String> permissions) {

  // ...

  private Supplier<String> emailSupplier;

  public Builder email(Supplier<String> emailSupplier) {
    this.emailSupplier = emailSupplier;
    return this;
  }

  // ...

  User build() {
    var email = this.emailSupplier.get();
    // ...
  }
}

你可以支持懒惰和非懒惰的变体。例如,你可以更改原始方法以设置emailSupplier,而不是要求emailemailSupplier字段:

public record User(String email, String name, List<String> permissions) {

  // ...

  private Supplier<String> emailSupplier;

  public Builder email(String email) {
    this.emailSupplier = () -> email;
    return this;
  }

  // ...
}

第二,生成器可以模仿 Groovy 的with⁠⁵,如下所示:

var user = User.builder()
               .with(builder -> {
                 builder.email = "ben@example.com";
                 builder.name = "Ben Weidig";
               })
               .withPermissions(permissions -> {
                 permissions.add("create");
                 permissions.add("view");
               })
               .build();

要实现这一点,必须向生成器添加基于Consumer的高阶方法,如示例 14-3 所示。

示例 14-3. 向User生成器添加with方法
public record User(String email, String name, List<String> permissions) {

  // ...

  public static class Builder {

    public String email; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/1.png)
    public String name;

    private List<String> permissions = new ArrayList<>(); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/2.png)

    public Builder with(Consumer<Builder> builderFn) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      builderFn.accept(this);
      return this;
    }

    public Builder withPermissions(Consumer<List<String>> permissionsFn) { ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/fn-app-java/img/3.png)
      permissionsFn.accept(this.permissions);
      return this;
    }

    // ...
  }

  // ...
}

1

为了在Consumer中可变,生成器字段需要是public的。

2

但并非所有字段都应该是public的。例如,基于集合的类型最好由它们自己的with方法提供服务。

3

permissions添加另一个with方法可以防止意外将其设置为null,并减少Consumer中所需的代码到实际所需的操作。

当然,生成器本来可以使用public字段。但是,那样就无法进行流式调用。通过向其添加基于Consumerwith方法,整体调用链仍然是流畅的,而且你可以在创建流程中使用 lambda 甚至方法引用。

即使像生成器模式这样的设计模式没有与之相等的功能性变体,也可以通过在其中添加一些功能性概念使其更加通用。

对功能性设计模式的最终思考

将其称为“功能性设计模式”通常感觉像是一个反讽,因为它们几乎与它们的面向对象的对应物相反。面向对象的设计模式通常是针对常见(面向对象的)问题的形式化和易于重复的解决方案。这种形式化通常伴随着许多严格的概念隐喻和样板代码,几乎没有偏离的余地。

解决由面向对象设计模式提出的问题的函数化方法利用了函数的第一类公民权。它用函数接口替换了以前明确形式化的模板和必需的类型结构。结果代码更为简洁明了,还可以以新的方式进行结构化,比如返回具体实现的static方法或者部分应用的函数,而不是复杂的自定义类型层次结构。

然而,首先删除样板代码是一件好事吗?更为简单和简洁的代码始终是一个值得追求的目标。然而,初始的样板代码不仅仅是面向对象方法的要求:它还用于创建一个更复杂的操作域。

将所有中间类型替换为已有的函数接口,会减少代码阅读者直接可见的信息量。因此,在替换更具表达力的基于领域的方法及其所有类型和结构与使用更为函数化的方法之间,需要找到一个折中点。

幸运的是,与本书中讨论的大多数技术一样,这并非是“非此即彼”。在经典面向对象模式中识别函数化可能性需要你更高层次地看待问题的解决方式。例如,责任链设计模式处理为多个对象提供在预定义操作链中处理元素的机会。这听起来与流(Stream)或可选(Optional)管道的工作方式非常相似,或者函数组合创建功能链的方式。

面向对象设计模式帮助你识别解决问题的一般方法。然而,部分或完全转向更为函数化的解决方案往往能提供更简单和更简洁的替代方案。

要点

  • 面向对象设计模式是知识共享的一种被证明和形式化的方式。通常需要多种类型来表示特定领域的常见问题的解决方案。

  • 函数化方法利用第一类公民权替换所有额外类型与已有的函数接口。

  • 函数式原则允许删除许多通常由许多面向对象设计模式所需的样板代码。

  • 模式的实现变得更为简洁,但使用类型的显式表达能力可能会受到影响。如有必要,使用领域特定的函数接口恢复表达能力。

  • 即使对于没有函数等效的设计模式,添加某些函数技术也可以提高其灵活性和简洁性。

¹ 所谓的“无底蜷缠”描述了无限回归的问题:由递归原则管理的无限系列实体。每个实体依赖于或由其前身产生,这与许多函数设计哲学相符。

² Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design patterns: Elements of reusable object-oriented software. Boston, MA: Addison Wesley.

³ Café con leche是西班牙和拉丁美洲流行的一种咖啡变种。字面意思是“咖啡加牛奶”。我没有使用“flat white”作为我的例子,因为那样我就需要先蒸牛奶。

开闭原则SOLID 原则的一部分。它指出,实体(如类、方法、函数等)应对扩展开放,但对修改关闭。详细信息请参见维基百科页面:开闭原则SOLID

⁵ Groovy 提供了一个with方法,接受闭包以简化对相同变量的重复使用。更多信息请参阅官方 Groovy 风格指南

第十五章:Java 的函数式方法

许多编程语言支持函数式和命令式的代码风格。然而,语言的语法和工具通常会鼓励特定的解决常见问题的方法。尽管本书讨论了 JDK 中所有的函数式扩展,Java 仍然偏向于命令式和面向对象编程,大多数核心库中提供的类型和数据结构反映了这种偏好。

然而,正如我在本书中一直讨论的那样,这并不意味着必须是“非此即彼”的情况。您可以在不完全过渡到函数式的情况下用面向对象的代码增强函数式原则。为什么不两者兼得呢?要做到这一点,您需要采纳函数式的思维方式。

本章将本书迄今为止学到的内容汇总,并突出显示将影响您的函数式思维方式最重要的方面。它还展示了如何在适合面向对象环境的架构层次上应用函数式编程技术。

面向对象与函数式原则

要更好地理解函数式原则如何改善您的代码,有必要重新审视面向对象和函数式这两种范式的基本原则,以识别它们的不同之处和可能的连接点。这构建了基础知识,以确定在您的面向对象代码中纳入函数式方法的机会以及强行纳入它可能不合适的地方。

面向对象 编程的主要关注点是封装数据和行为、多态性和抽象化。它是一种基于隐喻的解决问题的方法,其中对象和连接代码模仿特定的问题域。这些对象通过公共合约(如接口)进行消息传递,每个对象有责任并通常管理自己的状态。使用这样的隐喻可以弥合计算机(需要一组指令)和开发者(可以以直接的方式表达其意图)之间的差距。面向对象编程是在“真实世界”及其不断和无尽的变化之后组织和管理命令式代码的优秀方法。

函数式编程 则利用数学原理解决问题,采用声明式的代码风格。与需要模拟“真实世界”来建模代码的隐喻不同,函数式编程的基础——λ演算——只关心数据结构及其使用高级抽象进行的转换。函数接收输入并产生输出,就是这样!数据和行为没有被封装起来;函数和数据结构就是。函数式编程通过避免副作用来绕过许多典型的面向对象编程和 Java 问题,比如在并发环境中处理可变状态或意外的副作用,因为它试图一开始就不产生任何副作用。

这两个简短的摘要已经突显了面向对象和函数式编程核心原则的不同之处。面向对象编程试图通过将代码的移动部分封装在熟悉的域中来驯服复杂性,而函数式编程则通过遵循数学原理来减少总体部分。函数式编程中更抽象的思维方式是为什么面向对象编程通常是首选的 Java 教学和学习方法。

正如我在第十四章中讨论的,面向对象和函数式编程这两种范式只是解决相同问题的不同方法。它们都是能够从不同方向解决同一问题的不同方法。如果宣称其中一种原则比另一种更好,无论是否打趣,都是愚蠢的。在面向对象编程中,比喻是一个强大的工具,使代码对非程序员和程序员感觉更自然。有些复杂的问题比起可能更简洁但高度抽象的功能性方法,更受益于一个良好的比喻性表达方式。

功能性心态

任何傻瓜都能写出计算机能理解的代码。优秀的程序员编写的是人类能理解的代码。

Martin Fowler,《重构:改善既有代码的设计》

手头可以使用所有的功能工具,但要有效地使用它们需要正确的心态。拥有功能性心态意味着有理由识别那些可以通过功能性方法改进的代码,无论是完全采用功能性,还是在关键和适当的地方注入一些功能性技术和原则。这种心态不会一夜之间形成;你必须通过实践来磨练以获取经验和直觉。

发展这种功能性心态始于希望消除或减少代码中的任意复杂性。您用于解决问题的技术和原则应该导致代码合理且更容易理解。

要与复杂系统进行推理意味着只凭手头的信息来理解和解决任何代码,而不依赖于隐藏的实现细节或可能过时的注释,不会有任何等待你的意外。你不需要查看多个文件或类型来理解解决的问题,或者不需要思考代码本身所涉及的许多决策。

你的代码正确性得到了非正式的证明,因为关于其功能的任何声明都得到了其合理性和随附的评论的支持。使用这样的代码的任何人都可以对其进行强有力的假设,并依赖其公共契约。面向对象编程的不透明性和其对行为和数据的封装通常使其比替代方法更难以推理。

让我们重新审视在何时应用功能性方法时将影响您决策的不同方面。

函数是第一类公民

函数式编程关乎函数及其头等公民的地位。这意味着函数与语言的其他构造一样重要,因为你可以:

  • 将函数赋值给变量

  • 将函数作为参数传递给另一个函数/方法

  • 从函数/方法返回一个函数

  • 创建没有名称的匿名函数

这些特性与 Java 中匿名类的使用方式非常相似,甚至在引入 lambda 表达式之前也是如此。不过,与匿名类不同的是,函数接口——Java 中函数概念的表示——在概念上更加通用化,并且通常与显式的类或领域类型分离。此外,JVM 利用invokedynamic操作码的方式也不同,如 “invokedynamic指令” 所解释的,这允许进行比匿名类更多样的优化。

尽管 Java 没有“即时”类型,并要求任何 lambda 表达式都由具体的函数接口表示,但它仍然可以让你使用 OO 和 FP 之间的主要差异之一,因为它提供了更高层次的抽象。函数抽象比它们的面向对象对应物更高级别。这意味着 FP 关注的是值,而不是具有严格数据结构的离散领域特定类型。

把函数及其更高级别的抽象看作机器中的小齿轮。面向对象的齿轮更大,并且专门设计用于更窄范围的任务;它们只适合于机器的特定部分。然而,更小的函数式齿轮更加统一和通用化,因此更容易在整个机器中使用。然后,它们可以组成群组,从一个单一简单的任务向复杂和更完整的任务发展。更大的任务是其所有更小部分的总和,而部分本身尽可能小而通用,可重用且易于测试。通过这种方式,你可以构建一个可按需组合的可重用函数库。

然而,Java 依赖于函数接口来表示函数和 lambda 既是福音也是诅咒。

这是一个诅咒,因为你不能有一个仅基于其参数和返回类型而没有相应函数接口的分离 lambda。类型推断减轻了痛苦,但在某些时候,实际类型必须对编译器可推断的类型有所了解。

它也是一种福音,因为它是在不破坏向后兼容性的情况下在 Java 的静态类型系统和主要的命令式面向对象代码风格之间架起桥梁的完美方式,也是一种新的思维方式。

避免副作用

提问不应该改变答案。

法国学者 Bertrand Meyer

拥有功能性思维方式还包括避免副作用。从功能性的角度来看,副作用是指修改任何类型的状态,它可以有许多形式。它不必是隐藏的或意外的,恰恰相反。许多形式的副作用,如访问数据库或进行任何 I/O 操作,都是有意的行为,并且是几乎每个系统的重要部分。然而,更少的副作用通常意味着代码中更少的意外和更小的 bug 表面。

有几种功能性方法可以减少副作用的数量,或者至少使它们更易管理。

纯函数

避免副作用的最基本方法是使用功能编程概念中的纯函数,因为它们依赖于两个基本保证:

  • 相同的输入总是会产生相同的输出。

  • 纯函数自包含,没有任何副作用。

看起来足够简单。

然而,实际上,在改善 Java 代码的纯度时,还有更多需要注意的方面。

任何纯函数只能依赖于声明的输入参数来生成其结果。任何隐藏状态或不可见依赖都是大忌。

想象一个为User实例创建问候语的函数,方法签名如下:

public String buildGreeting(User user)

方法签名及其公共契约揭示了一个单一的依赖:User参数。如果您不知道实际实现情况,可以安全地假定这是一个纯函数,对于重复调用相同的用户,会产生相同的问候语。

让我们来看一下它的实现:

public String buildGreeting(User user) {
  String greeting;
  if (LocalTime.now().getHour() < 12) {
    greeting = "Good morning";
  } else {
    greeting = "Hello"
  }

  return String.format("%s, %s", greeting, user.name());
}

然而,在检查实现时,第二个依赖项显露出来:时间。这种依赖不可见,依赖于上下文之外的状态,使整个方法不纯净。

要恢复纯度,第二个内部依赖项必须成为公共契约的一部分:

public String buildGreeting(User user, LocalTime time)

纯度被恢复,公共契约不再隐藏对白天时间的内部依赖,并清楚地传达它,而无需任何文档。

方法签名仍然可以进一步简化。如果仅使用其name,为什么将方法绑定到User类型?如果仅使用其小时,为什么使用LocalTime?创建一个更多功能的buildGreeting方法将只接受name而不是整个User实例。

参数的最低公共分母将给出可能的最通用和广泛应用的纯函数。尝试避免嵌套调用,通过更接近实际需要的值来扩展方法的适用性,而不是依赖于特定的领域类型。

想要理解纯函数的最佳方式是将它们视为完全隔离在它们自己的时空连续体中,与系统的其他部分分离开来。这就是为什么它们需要明确地接收所有的需求作为值传递,最好尽量减少中间对象。然而,这种更高的抽象会损失部分方法签名的表达力,所以你必须找到一个可接受的平衡点。

纯函数是函数式编程的基石。将任务简化为“相同的输入 + 处理 → 相同的输出”使方法签名更具有意义,更易于理解。

纯对象方法

纯函数仅存在于它们自己的上下文中,这就是为什么它们只能依赖它们的输入参数来生成它们的输出。在面向对象的环境中将这个原则转化是有点困难的。

从面向对象程序员的角度深入探讨纯函数的两个保证,它们揭示了在更广义上应用它们以创建更混合的方法的可能性,我称之为纯对象方法

如果一个对象类型的方法在先前讨论的意义上真正是纯粹的,它可以被定义为static,甚至不需要再属于对象类型。但是,将方法绑定到它们相关的类型,这是它们的一部分,是一种优势,并且不会很快消失。

以前一节中的buildGreeting方法为例。尽管它可以作为static方法变成一个纯函数,但直接将其添加到User类型作为实例方法也是有意义的。然而,这样做会损害可重用性,因为它不再完全隔离,而是与其周围的类型相互关联。然而,这种关系并不意味着它不能“尽可能纯净”。

像良好的对象类型一样,User类型封装了它的状态并创建了自己的微观宇宙,大部分与外部分离。一个纯对象方法可能会访问这个微观宇宙,并将它们视为附加的输入参数。然而,主要的警告是这些方法绑定到特定类型的不可重用性。

其他支持面向对象编程风格的多范式语言,比如 Python,更加突显了这种方法,正如下面的代码所示:

class User:

  name = ''

  def __init__(self, name):
    self.name = name

  def buildGreeting(self, time):
    # ...

在每个方法中将self作为显式输入参数使用,突显了方法对实例本身的依赖关系,就像 Java 中的this一样。即使对象的方法会影响其状态,只要除了其内部状态之外没有其他副作用,它仍然可以被称为“纯对象方法”。对象本身成为输入的一部分,因为它封装了副作用,并且调用后的状态使它们成为输出。

纯函数的功能设计原则仍然非常有用,如果你必须处理对象类型并且无法将其重构为新设计。相同规则适用,但对象状态视为输入参数。这就是为什么像buildGreeting中的time等进一步依赖项不应该隐藏在任何使用该方法的人员之外。对两个相同对象使用相同输入调用相同方法应产生相等的输出或新对象状态。

纯对象方法可能无法带来全功能方法和不可变数据结构的所有优势,尤其是在可重用性方面。尽管如此,将功能思维注入面向对象风格中,使类型更具可接近性、更安全、更可预测,因此更合理。

具有副作用的隔离

完全避免副作用是不可能的。面向对象编程(OOP)或一般的命令式代码通常与可变状态和副作用交织在一起。然而,影响状态的副作用通常在表面上是看不见的,如果使用不当,很容易破坏代码的合理性并引入微妙的错误。如果无法通过纯函数等技术完全避免副作用,则应该将其隔离,最好在逻辑单元的边缘,而不是在整个代码中随意散布。通过将较大的代码单元拆分为较小的任务,可以将可能的副作用限制在一些任务中,并且不会影响整体单元。

这种思维方式也存在于Unix 哲学中,由 Unix 操作系统共同创造者 Ken Thompson 发起。当时贝尔实验室计算科学研究中心主任 Doug McIlroy,也是Unix 管道的发明者,对其进行了如下总结¹:

编写只做一件事并且做得好的程序。编写可以相互协作的程序。

Doug McIlroy

将这种哲学转移到功能方法中意味着函数应该努力只做一件事,并且做到最好,而不影响其环境。设计你的函数尽可能小,但必要时尽可能大。复杂任务最好由多个组合的函数来完成,尽可能保持纯净性,而不是一个从一开始就是不纯的大函数。

I/O 是副作用的一个典型案例。加载文件,与数据库通信等,都是不纯的操作,因此应与纯函数分离开来。要封装一个副作用,你必须考虑实际副作用与处理其结果之间的缝隙。与其将加载文件和处理其内容作为一个单一操作,不如将其分成加载文件的副作用和处理实际数据的过程,如图 15-1 所示。

将操作分解为离散函数

图 15-1. 将操作拆分为离散函数

数据处理不再局限于文件加载或通常的文件,而是仅处理传入的数据。这使得操作成为一个纯的、可重复使用的函数,副作用仅限于loadFile方法,返回的Optional<String>为你提供了与之的功能桥接。

如果无法避免副作用,将任务分解为更小且最好是纯函数,以隔离和封装任何剩余的副作用。

偏爱表达式而非语句

如第一章所述,面向对象和函数式方法之间的关键区别是语句和表达式的普遍性。回顾一下,语句执行动作,如分配变量或控制语句,因此具有字面上的副作用。而表达式则评估它们的输入仅仅产生输出。

如果想减少副作用,使用表达式会导致更安全和更合理的代码,基于以下理由:

  • 纯表达式,如纯函数,没有任何副作用。

  • 表达式(大多数时候)可以在代码中定义;可用语句的类型由语言预定义。

  • 多次评估纯表达式将产生相同的输出,确保可预测性并启用某些缓存技术,如记忆化

  • 表达式可以很小,以保持纯粹性,并且仍然可以与其他表达式组合以解决更大的任务。

控制流if-else语句通常是用更功能化方法替换的一个好选择,特别是用于分配变量或创建。通过使用三元运算符来选择要使用的问候语,前面的buildGreeting方法变得更简洁、更直接。

public String buildGreeting(User user, LocalTime time) {

  String greeting = time.getHour() < 12 ? "Good Morning"
                                        : "Hello";

  return String.format("%s, %s", greeting, user.name());
}

三元运算符给您带来了另外两个优势。

首先,在单个表达式中声明并初始化变量greeting,而不是在if-else块之外未初始化。

其次,变量是有效地final。在这种特定情况下,这并不重要。但是,当您最终需要一个变量有效地final时,而不是要求您重构代码时,使用 lambda 表达式更好。

将复杂的语句列表和块拆分为较小的表达式,使代码更简洁、更易理解,并且能有效地使用final变量,正如你可能记得的早期章节中,使用变量在 lambda 表达式中是一个不可妥协的要求。

表达式通常优于语句,因为它们是值和函数的组合,旨在创建一个新值。它们通常比语句更紧凑和隔离,因此更安全使用。而语句则更像是一个独立的单元,用于执行副作用。

迈向不可变性

如果没有必要进行改变,那就需要不进行改变。

卢修斯·凯里,第二代福克兰子爵

另一种避免意外更改、从而避免副作用和潜在错误的方法是在可能和合理的情况下采用不可变性。即使不利用其他功能性原则,由于消除了意外更改作为太多错误的源头,你的代码库也会因不可变性而变得更加健壮。

为了防止任何未预见的变化,不可变性应该是程序中任何类型和集合的默认方法,特别是在并发环境中,详细讨论见 第 4 章。在许多使用案例中,你无需重新发明轮子,因为 JDK 为你提供了多种不可变数据结构的选项:

不可变集合

尽管 Java 并没有提供“完全”不可变的集合类型,但仍然有结构上不可变的类型,其中无法添加或删除元素。Java 9 中通过 static 工厂方法如 List.of 扩展了对集合的不可修改视图的概念,可以轻松创建结构上不可变的集合,详见 “迈向不可变性”。

不可变数学

java.math 及其两种不可变的任意精度类型 BigIntegerBigDecimal,是进行高精度计算的安全和不可变选项。

记录(JEP 395

在 Java 14 中作为预览功能引入,并在 15 中进一步完善,Records 提供了一种全新的数据结构作为易于使用的数据聚合类型。它们是 POJO 和有时 Java Beans 的良好替代品,或者你可以将它们用作小型、局部的不可变数据持有者,详见 第 5 章。

Java 日期和时间 API(JSR-310

Java 8 还引入了一种从根本上存储和操作日期和时间的新方法,使用不可变类型。该 API 为处理与日期和时间相关的任何事务提供了流畅、明确和简单的方式。

如你所见,越来越多的 Java API 构建或至少改进了对不可变性的支持,你也应该这样做。从一开始就考虑不可变性来设计你的数据结构和代码,可以在长期运行中为你节省大量烦恼。不再担心意外或未预期的变化,也不再担心并发环境下的线程安全问题。

然而,需要记住的一件事是,不可变性最适合于不可变数据。为任何更改创建一个新的不可变数据结构变得非常快速,需要的代码和内存消耗也会迅速增加。

不可变性是您可以引入到代码库中的最重要的方面之一,无论采用何种函数方法。“不变性优先”的思维方式给您提供了更安全和更合理的数据结构。然而,您通常的操作模式可能无法适应使用不变性管理数据所带来的新挑战。但请记住,如果没有其他选择,部分地违反不变性要比在成熟的代码库中事后添加不变性更容易。

使用 Map-Filter-Reduce 进行函数数据处理

大多数数据问题归结为迭代一系列元素,选择正确的元素,可能对它们进行操作或将它们收集到新数据结构中。以下示例——遍历用户列表,过滤正确用户并通知他们——是这些基本步骤的典型示例:

List<User> usersToNotify = new ArrayList<>();

for (var users : availableUsers) {
  if (user.hasValidSubscription()) {
    continue;
  }

  usersToNotify.add(user);
}

notify(usersToNotify);

这些问题非常适合使用流和map-filter-reduce的功能方法来解决,如“Map/Filter/Reduce”所讨论的。

不再通过for循环显式迭代用户并在先前定义的List中收集正确的元素,Stream 管道在流畅的声明式调用中完成整个任务:

List<User> usersToNotify = availableUsers.stream()
                                         .filter(User::hasValidSubscription)
                                         .toList();

notify(usersToNotify);

Stream 管道表达了做什么而不需要如何迭代元素的样板代码。它们是将基于语句的数据过滤和转换转换为功能管道的完美支架。流畅的调用简洁地描述了解决问题所需的步骤,特别是如果使用方法引用或返回所需功能接口的方法调用。

抽象指导实现

每个项目都是在需求之后设计的抽象基础上构建的。

面向对象设计使用低级抽象,形成强大的隐喻,定义系统的特性和约束。这种基于领域的方法非常表达力和强大,但也限制了类型的多样性和引入变更的易用性。由于需求通常随时间变化,过于严格的抽象导致系统不同部分之间不对齐。不对齐的抽象会导致摩擦和隐微错误,并可能需要大量工作来重新对齐。

函数式编程试图通过使用不绑定于特定领域的更高级抽象来避免不对齐的抽象。第十四章 几乎无条件地用 JDK 的广义函数接口替换常用的面向对象抽象,从而反映出这一点。将抽象从原始问题上下文中分离出来,创建更简单易复用的组件,并根据需要进行组合和混合,更容易修改任何函数系统。

面向对象和命令式代码很适合封装功能、对象状态和表示问题域。函数式概念是实现逻辑和更高级别抽象的优秀选择。并非每个数据结构都必须在问题域中表示,因此使用更通用的函数式类型创建可重用和更广泛的类型,这些类型由其用例驱动,而不是由域概念驱动。

要解决这个问题,如果你想在同一个系统中同时使用两个抽象级别,你必须找到两个抽象级别之间的平衡。在“在命令式世界中的函数式架构”中,我讨论了如何将两者结合起来作为一种架构决策,从而使高级函数式抽象包装在熟悉的命令式层中带来好处。

构建函数式桥梁

函数式方法意味着你的代码很可能存在于一个需要与任何你想要集成的函数式技术或概念一起工作的命令式和面向对象的环境中。本章后面,在“在命令式世界中的函数式架构”中,我将讨论如何将函数式代码集成到命令式环境中。

但首先,让我们看看如何弥合你现有的代码与新的函数式 API 之间的差距。

方法引用友好的签名

每一种方法,static 或者非静态的,以及任何构造函数都可以作为高阶函数中的潜在方法引用或者被函数接口表示。这就是为什么在设计你的 API 时考虑其他函数式 API 是有意义的。

例如,常用的 Stream 操作 mapfiltersort 分别接受一个 Function<T, R>Predicate<T>Comparator<T>,它们很好地转换成简单的方法签名。

查看所需的函数接口的 SAM;它是所需方法签名的蓝图。只要输入参数和返回类型匹配,你可以任意命名你的方法。

警告

将 SAM 签名简单地映射到方法引用的一个例外是未绑定的非static方法引用。由于该方法是通过类型本身引用的,而不是绑定到特定实例的,因此底层 lambda 表达式接受类型作为其第一个参数。

例如,String::toLowerCase 接受一个 String 并返回一个 String,因此是一个 Function<String, String>,尽管 toLowerCase 没有任何参数。

在设计任何 API 时,考虑它可能如何被函数式 API 使用并提供方法引用友好的签名是有意义的。你的方法仍然根据其周围的上下文具有表达力的名称,但也建立了一个与函数式 API 的简单方法引用的桥梁。

使用反向函数接口

功能接口通常都标有@FunctionalInterface注解。只要它们满足通用要求,如 “功能接口” 所述,接口就自动成为功能接口。因此,已有的代码可以受益于 lambda 和方法引用的简洁性,以及 JVM 的专业处理。

JDK 的许多长期界面现在都标有@FunctionaInterface,但您的代码可能尚未适应这些变化,无法从中受益。即使在 Java 8 之前,以下“现在可用的功能接口”已被广泛使用:

  • java.lang.Comparable<T>

  • java.lang.Runnable

  • java.util.Comparator<T>

  • java.util.concurrent.Callable<V>

例如,在 lambda 出现之前,由于所有样板代码,对集合进行排序是相当麻烦的:

users.sort(new Comparator<User>() {

  @Override
  public int compare(User lhs, User rhs) {
    return lhs.email().compareTo(rhs.email());
  }
});

Lambda 变体大大简化了样板代码:

users.sort((lhs, rhs) -> lhs.email().compareTo(rhs.email()));

但为什么要止步于此呢?如果您查看功能接口Comparator<T>,您将发现static和非static助手方法,可以使整体调用更加简洁,而不会失去任何表达能力:

users.sort(Comparator.comparing(User::email));

Java 8 不仅引入了新的功能接口,还改进了现有接口,使其可以很好地适应具有许多defaultstatic方法的新 API。始终查看功能接口中的非 SAM 方法,以找到可以通过功能组合简化代码或将常见任务简化为声明性调用链的隐藏宝石。

用于常见操作的 Lambda 工厂

设计您的 API 以匹配其他功能 API,这样您就可以使用方法引用并非总是可能的。这并不意味着您不能提供 lambda 工厂来简化高阶函数的使用。

例如,如果一个方法不符合特定的功能接口,因为它需要额外的参数,您可以使用部分应用使其适应高阶函数的方法签名。

假设有一个ProductCategory类型,其方法返回本地化描述如下:

public class ProductCategory {

  public String localizedDescription(Locale locale) {
    // ...
  }
}

该方法可由BiFunction<ProductCategory, Locale, String>表示,因此您无法将其用于 Stream 的map操作,并必须依赖 lambda 表达式:

var locale = Locale.GERMAN;

List<ProductCategory> categories = ...;

categories.stream()
          .map(category -> category.localizedDescription(locale))
          ...;

将接受Locale并返回Function<ProductCategory, String>static助手添加到ProductCategory中,可以使用它来代替创建 lambda 表达式:

public class ProductCategory {

  public static Function<ProductCategory, String>
                localizedDescriptionMapper(Locale locale) {
    return category -> category.localizedDescription(locale);
  }

  // ...
}

这种方式,ProductCategory仍然负责创建其预期的本地化映射函数。但调用更简单且可重用,如下所示:

categories.stream()
          .map(ProductCategory.localizedDescriptionMapper(locale))
          ...;

通过将工厂方法绑定到其相关类型来提供常见操作的 lambda 操作,可以为您提供一组预定义的预期任务,并节省调用者重复创建相同 lambda 表达式的时间。

显式实现功能接口

讨论中最常见的功能接口,在“The Big Four Functional Interface Categories”中有详细描述,这在您需要创建自己的专用类型之前已经足够长了,特别是如果包括多元性变体。然而,创建自己的功能接口有一个巨大的优势:更具表现力的域。

单看参数或返回类型,Function<Path, Path>可以代表任何东西。然而,一个名为VideoConvertJob的类型告诉您确切地正在发生什么。然而,要在功能方法中使用这种类型,它必须是一个功能接口。与创建新的孤立功能接口不同,您应该扩展现有的功能接口:

interface VideoConverterJob extends Function<Path, Path> {
  // ...
}

通过选择现有的功能接口作为基线,您的专用变体现在与Function<Path, Path>兼容,并继承了两个default方法andThencompose以支持功能组合的开箱即用。定制变体缩小了域,并与其祖先兼容。扩展现有接口还继承了 SAM 签名。

为了进一步改进域,您可以添加一个default方法来创建一个富有表现力的 API:

interface VideoConverterJob extends Function<Path, Path> {

  Path convert(Path sourceFile);

  default Path apply(Path sourceFile) {
    return convert(sourceFile);
  }

  // ...
}

为了实现 SAM 的方法,向现有接口添加default方法是一种方法,使其符合函数接口而不改变原始的公共契约,除了函数接口提供的额外功能。

使您的接口扩展一个功能接口,或者让您的类显式实现一个功能接口,可以在现有类型和高阶函数之间建立桥梁。仍然需要考虑 Java 的类型层次结构规则,但接受输入的最低公分母并返回可能的最具体类型是一个很好的经验法则。

使用 Optional 进行功能性空值处理

Optional 是处理(可能的)null值的一种优雅方式。在许多情况下,这本身就是一个巨大的优势。其另一个优点是它能够提供从可能的null值到后续操作之间的功能起点。

在以前null引用是一个死胡同,需要额外的代码来避免NullPointException的情况下,Optional 给您提供了一个声明性流水线,替换了处理null值所需的通常样板代码:

public Optional<User> tryLoadUser(long id) {
  // ...
}

boolean isAdminUser =
  tryLoadUser(23L).map(User::getPermissions)
                  .filter(Predicate.not(Permissions::isEmpty))
                  .map(Permissions::getGroup)
                  .flatMap(Group::getAdmin)
                  .map(User::isActive)
                  .orElse(Boolean.FALSE);

这个流水线取代了两个null检查(初始和Group::getAdmin)、一个if语句(filter操作),以及访问所需属性并提供合理的回退。总体任务在六行的流畅声明式调用中直接表达,而不是一个更复杂和难以跟踪的单独语句块。

很难反对控制语句的减少,结合成为一个功能性的起点,很可能会增加您对 (过度) 使用 Optional 的欲望,就像在我开始时一样。请记住,Optionals 被设计为一种专门的 返回 类型,而不是用于 null 相关代码的普遍替代品。并不是每个值都需要包装在 Optional 中,特别是简单的 null 检查:

// BAD: wrapping a value for a simple lookup

var nicknameOptional = Optional.ofNullable(customer.getNickname())
                               .orElse("Anonymous");

// BETTER: simpler null-check

var nicknameTernary = customer.getNickname() != null ? customer.getNickname()
                                                     : "Anonymous";

使用 Optional 或许 感觉 更清晰 —— 更容易遵循流程,没有控制结构,没有两个 null —— 但作为普通的 Java 类型,创建一个 Optional 并不是免费的。每个操作都需要检查 null 来执行其预期的工作,并可能创建一个新的 Optional 实例。三元操作符可能不如 Optional 吸引人,但肯定需要更少的资源。

自 Java 9 以来,实用类 java.util.Objects 增加了两个方法来进行简单的 null 检查,只需一个方法调用,而不创建额外的实例,这是 Optional 的首选替代品,仅具有 orElseorElseGet 操作:

var nickname = Objects.requireNonNullElse(customer.getNickname(), "Anonymous");

var nicknameWithSupplier = Objects.requireNonNullElse(customer.getNickname(),
                                                      () -> "Anonymous");

应该限制使用 Optional 作为可能的 null 值的改进返回容器的预期用例,并且在我看来,复杂的 Optional 流水线与多个操作。您不应该在代码中使用它们来执行简单的 null 检查,方法也不应直接接受它们作为参数。方法重载提供了一个更好的选择,如果参数并不总是需要。

并行和并发变得容易

编写并发或并行程序并不容易。创建额外的线程是简单的部分。然而,协调超过一个线程可能会变得非常复杂。与并行性和并发性相关的所有问题的最常见根源是在不同线程之间共享数据。

跨多个线程共享数据会带来自己的要求,在顺序程序中无需考虑,如同步和锁以确保数据完整性并防止数据竞争和死锁。

函数式编程通过建立在函数式原则上的原则为安全地使用并发和并行创建了许多机会,最显著的是以下几点:

不可变性

没有改变,就不会有数据竞争或死锁。数据结构可以安全地遍历线程边界。

纯函数

没有副作用,纯函数是隔离的,并且可以从任何线程调用,因为它们仅依赖于它们的输入来生成它们的输出。

本质上,函数式技术并不关心顺序执行或并发执行的区别,因为在其最严格的解释下,函数式编程不允许存在需要区分的环境。

Java 的并发特性,如并行流(第八章)和 CompletableFuture (第十三章),即使在完全功能的代码和数据结构中,仍需要线程协调。不过,JDK 会以适合大多数场景的方式为您完成这些工作。

要谨慎考虑潜在的开销。

函数式技术提供了巨大的生产力提升,并使您的代码更具表现力和健壮性。不过,并不意味着它自动更具性能,或者与命令式和面向对象的代码处于相同的性能水平。

Java 是一种非常多才多艺的语言,被许多公司和个人信赖,因为它的向后兼容性和通用 API 的稳定性是最好的之一。不过,这也以较少的语言本身变更为代价,至少与其他语言相比如此。这就是为什么本书涵盖的许多功能,如 Streams、CompleteFutures 或 Optionals,并非是本地语言功能,而是使用普通的 Java 代码在 JDK 中实现的原因。即使是 Records,作为一种完全具有独特语义的新构造,归根结底也只是扩展 java.lang.Record 的典型类,类似于枚举的工作方式,编译器在幕后生成所需的代码。不过,这并不意味着这些功能没有经过任何优化。它们仍然能够从所有 Java 代码可用的优化中受益。此外,Lambda 是一种利用 JVM 中专用操作码的语言特性,具有多种优化技术。

我知道,像 Streams 和 Optionals 这样的函数结构,对于每一个数据处理或 null 检查都非常诱人,因为在多年的 Java 语言停滞后,我也曾如此。尽管它们是优秀且高度优化的工具,但您必须记住,它们并非免费使用,并将产生一定不可避免的开销。

通常情况下,与生产力收益和更简洁、更直接的代码相比,开销是可以忽略不计的。永远记住肯特·贝克(Kent Beck)的一句话:“首先让它能够运行,然后让它正确,最后让它快。”不要因为担心潜在的开销而放弃函数式特性和 API,而不知道它们是否在首次影响您的代码。如果有疑问,先测量,再重构。

在命令式世界中的函数式架构。

选择特定的架构并不是一件容易的事情,并对任何项目都有深远的影响。这是一个重大的决定,不能轻易改变。如果您想在架构层面应用更多的函数式方法,它必须适应现有的命令式和面向对象的代码库,而不会过多地破坏现状。

函数(functions)作为功能架构中最基本和必不可少的单元,毫不奇怪地代表了业务逻辑的独立块。这些块通过按需组合作为工作流的构建块。每个工作流代表一个更大的逻辑单元,比如一个特性、一个用例、一个业务需求等。

在面向对象的世界中,使用函数式编程的典型架构方法是通过良好定义的边界将业务逻辑与与外界通信的方式分离开来。功能核心、命令式外壳(FC/IS)架构方法是一种尺寸灵活且可以低影响的方法。

虽然使用 FC/IS 设计从头开始构建系统是可行的,但也可以将这种设计集成到现有代码库中。FC/IS 是逐步重写和重构的绝佳选择,以引入函数式原则和技术到你的面向对象项目中。

如果你考虑代码及其实际目的,独立于任何范例或概念,它分为两个明显的组:执行工作和协调它。FC/IS 并没有将代码及其责任组织成单一范式,而是在涉及的两个范式之间划清了明显的界线,如图 15-2 所示。

功能核心、命令式外壳的基本布局

图 15-2. 功能核心、命令式外壳的基本布局

功能核心将业务逻辑和决策封装在独立和纯函数单元中。它充分利用了函数式编程的所有优势,并且以其所长:直接处理数据,不用担心由于纯函数和不可变性而产生的副作用或状态相关问题。这个核心然后被命令式外壳包裹,一个薄层用来保护它免受外界干扰,封装所有的副作用和可变状态。

外壳包含系统其他部分的依赖关系,并提供与外部交互的公共契约以访问 FC/IS。所有非功能性的东西都远离核心,并限制在外壳中。为了尽可能保持外壳的薄度,大部分决策留在核心中,因此外壳只需通过其边界委派工作并解释核心的结果。它是一个处理“真实世界”的胶水层,处理所有依赖项和可变状态,但尽可能少地进行路径和决策。

这种设计的主要优点之一是通过封装而几乎自然发生的职责明确分割。业务逻辑封装在核心中,使用纯函数不可变性等构建,使得它易于推理、模块化和可维护。相反,任何不纯的可变的东西,或者与其他系统的任何接触,都限制在外壳中,不能自行做出许多决策。

从对象到值

从外部看,只有命令式外壳是可见的,并提供了特定于问题域类型的低抽象级别。它看起来和感觉像通常的面向对象 Java 项目中的任何其他层。然而,函数核心则不需要了解外壳及其公共契约。相反,它仅依赖于高级抽象和值的交换,而不是对象及其相互作用方式。

这种从对象到值的转变是为了通过利用所有可用的函数工具保持核心的功能性和独立性所必需的。但它也突显了责任的分离。为了保持纯粹,任何可变性、状态或副作用都必须发生在外壳的边界之外,而不是实际业务逻辑之内。在其最精炼的形式中,这意味着穿越边界的任何东西都必须是一个值,甚至是最终的副作用!这就是为什么将副作用与纯函数分离以重新获得更多控制是如此重要的原因。比 Java“更函数化”的编程语言通常有专门的数据结构来处理副作用,比如 Scala 的MaybeTry类型。

Java 处理副作用最接近的类型是Optional<T>类型,能够在单一类型中表示两种状态。在第十章中,我还讨论了如何在 Java 中重新创建 Scala 的 Try/Success/Failure 模式,以更函数式的方式处理由于异常而导致的控制流中断。然而,驯服副作用所需的额外代码和样板文件清晰地表明它们应在命令式外壳中处理,这里有适当的工具和结构,不像在函数核心中那样是至少不希望这样做的。

关注点分离

函数仅基于其参数得出结论,而不访问或改变其周围的世界。然而,改变可能是必要的,比如在外壳中持久化数据、改变状态。

核心仅负责决策,而不是对这些决策采取行动。这就是为什么所有的变化,甚至是副作用,也必须能够作为值来表示。

想象一下,您想要从网站上爬取某些信息并将其存储在数据库中。总体任务大致包括以下步骤:

  1. 加载网站内容

  2. 提取必要的信息

  3. 决定信息是否相关

  4. 在数据库中持久化数据

要将任务适应 FC/IS 系统,首先需要根据其责任对其进行分类。

加载内容和持久化数据显然是 I/O 操作,包括副作用,因此属于shell。信息提取和决定其是否相关是数据处理,适合core。这种分类导致了任务分离,如图 15-3 所示。

FCIS 中的网页抓取责任

图 15-3. FCIS 中的网页抓取责任

如图所示,shell 与网络交互,并立即将内容传递到corecore 接收一个不可变的 String 值,并返回一个 Optional<String>,以表明根据其业务逻辑信息是否相关。如果shell 收到一个值,它将持久化该值以及它在上下文中仍然可以访问的任何其他信息。

关注点分离为代码带来另一个优势。从模块化的角度来看,core 能够使用任何输入源,而不仅仅是一个网站。这使得数据处理更加灵活和可重用。例如,可以先抓取多个页面并将其持久化在数据库中以供后续处理,而不是直接将其内容传递给core进行处理。core 不关心数据的来源,甚至不需要知道内容从何而来;它完全专注于其孤立的任务:提取和评估信息。因此,即使整体要求发生变化,core 也不一定需要改变。如果确实需要改变,可以根据需要重新组合现有的小逻辑单元。

FC/IS 的不同尺寸

FC/IS 可能看起来像是你的系统围绕的一个独特的组织布局。这是一种做法,但有一种更灵活的方式将 FC/IS 架构集成到系统中:使用不同尺寸的多个 FC/IS。

与其他架构设计不同,它不必定义或主导一个项目。不管你的整个应用程序是围绕单个还是多个 FC/IS 构建的,甚至创建一个 FC/IS 用于单一任务也是可能的。只要命令式 shell 与系统的其余部分集成,你就可以顺利进行!

FC/IS 的动态大小和集成允许在代码库中向更功能逻辑的逐步过渡,而不会破坏现有的结构。创建多个 FC/IS,如图 15-4,可以与之前的系统共存并相互交互,而外部用户甚至可能察觉不到这一点。

与现有系统交互的多个 FI/CS

图 15-4. 与现有系统交互的多个 FI/CS

对于确定 FC/IS 的大小,一个明智的方法是考虑其上下文和能力。与外界的边界 — 外壳的 表面 — 是所需大小的第一个指标。减少不同系统之间的耦合确保模块化、可扩展性和可维护性。

定义正确的上下文和适当的边界对经验来说至关重要,并且随着经验的积累变得更加容易。一个 FC/IS 应该尽可能小,但也要尽可能大。核心的功能单元或整个功能组可以在其他 FC/IS 中重复使用,以便于逐步在甚至是复杂的预先存在的系统中替换和集成它们。

测试一个 FC/IS

与任何其他重构工作一样,当您采用 FC/IS 设计时,应该使用适当的测试(如单元测试和集成测试)验证新结构。如果您的代码有依赖关系或像数据库这样的 I/O,则通常需要使用模拟或存根来更好地隔离测试的组件。

虽然有库可以简化创建这种替代方案,但整个概念也带来了一些缺点:

实施细节的了解

模拟通常需要详细的实现知识才能按预期工作。这些细节可能会随着时间的推移而发生变化,即使不改变公共契约或测试逻辑,每次重构尝试也会破坏模仿它们的模拟和存根。

偶发性测试

测试应该精确到点,仅测试绝对最小限度以确保正确性。依赖关系会创建额外的考虑层,即使测试的预期故事隐藏在下面。调试这样的测试可能很麻烦,因为你不仅需要调试测试本身和功能本身,还需要考虑其他任何层次的存在。

上下文由封装的专业领域知识在 核心 中和由此扩展的 外壳 的公共契约表示。

通常,依赖项会正确初始化并处于保证的有意义状态。另一方面,模拟和存根基本上是虚构的实现,用于减少组件之间的耦合并满足测试的最小要求集。

FC/IS 架构通过其责任的明确分离显著减少了这些通常的缺点,这种分离在其可测试性中得到了体现。

功能核心 — 系统的业务逻辑 — 由纯函数组成,这些函数通常是自然隔离的,非常适合单元测试。相同的测试输入需要满足相同的断言。因此,与具有更复杂设置要求的更大互联系统相比,核心通常很容易通过小型和精准的单元测试来验证,而无需使用测试替身。这种一般性的无依赖性消除了模拟和存根的需求。

命令式外壳仍然具有依赖性和副作用,并且显然不像核心那样易于测试;它仍然需要集成测试。然而,将大部分逻辑放在易于单元测试的核心中,需要较少的测试来验证外壳。任何新的 FC/IS 都可以依赖于经过测试和验证的函数式代码,易于理解,只需验证新的外壳

对 Java 函数式方法的最终思考

尽管显然我是函数式技术的支持者,在可能和合理的地方,我的日常 Java 工作仍然主要受命令式和面向对象编码的影响。你可能也处于类似的情况。在我们公司,Java 8 及其后续版本使我们能够逐步引入函数式技术,而无需重写整个架构或代码库。

例如,逐步在整个代码中建立不变性,作为数据结构的新基线,消除了通常在面向对象方法中存在的一整类问题。甚至是混合方法,比如前面提到的部分不可变的SessionState类型,也消除了可能引入微妙且难以调试问题的某些不利场景。

另一个重要的改进是设计方法签名时考虑了 Optionals。它使方法的意图更明显,与调用者清晰地传达可能缺失值的可能性,从而减少了NullPointerException,而无需大量的null检查。

函数式习语、概念和技术与面向对象的并不那么遥远,如人们经常宣称的那样。当然,它们是解决类似问题的不同方法。函数式编程的大多数优点也可以在面向对象和命令式环境中获得。

Java 作为一种语言,可能缺乏对某些函数式构造的支持。然而,作为一个生态系统广泛的平台,Java 带来了许多好处,无论选择哪种编程范式。

从根本上说,函数式编程是一种思维过程,而不是特定的语言。你不必从零开始建立系统来受益。从头开始往往关注的是生产力而不是所需的广度。由于代码库不断变化和演变,很容易忽视必要的边缘情况和大多数系统依赖的非常见结构。与其回到原点,不如逐步重写、重构和逐步注入函数式思维来减少总体复杂性。

然而,并非每种数据结构都需要重新设计,也不是每种类型都需要完全功能化。建立函数式思维的方法是实践它。从小事做起,不要强迫。你使用函数式构造的越多,就越容易识别可以从 Java 提供的函数式工具中受益的代码。

函数式方法的首要目标是减少理解和推理代码所需的认知能力。更简洁和更安全的构造,比如纯函数和不可变数据结构,提高了可靠性和长期可维护性。软件开发是关于用正确的工具控制复杂性,在我看来,Java 8+提供的函数式工具集非常强大,可以驯服你的命令式和面向对象的 Java 代码。

无论您将哪些函数式技术和概念集成到您的项目中,我希望您从我的书中学到的最重要的一课,在我看来,就是无论您选择面向对象编程还是函数式编程都没有关系。Oracle 的 Java 语言架构师 Brian Goetz 在其中一次演讲中说得很好:

不要成为一个函数式程序员。

不要成为一个面向对象的程序员。成为一个更好的程序员。

Brian Goetz,FP vs OO:选择两者

软件开发是关于选择最合适的工具来解决特定的问题。在我们作为 Java 开发人员日常工作中,将函数式概念和技术纳入我们工具箱是一种宝贵的新工具,它可以创建更可读,更合理,更易维护和可测试的代码。

要点

  • 面向对象编程(OOP)和函数式编程(FP)在其核心概念上有很大不同。然而,它们大多数概念并非互斥或完全正交。两者都可以用不同的方法解决同样的问题。

  • 合理的代码是最终目标,函数式思维方式有助于实现这一目标。

  • 函数式思维方式从小事做起,比如通过纯函数避免副作用或者接受不可变性

  • 函数式原则也可以成为架构决策的一部分,比如通过将业务逻辑和暴露给其他系统的表面分离,采用类似函数核心,命令式外壳的设计。

  • 函数核心,命令式外壳设计是逐渐将函数式原则和概念引入现有代码的极好工具。

¹ Salus, Peter H. 1994. “A Quarter-Century of Unix.” Addison-Wesley. ISBN 0-201-54777-5.

作者简介

在四岁时使用他的第一台计算机开始,Ben Weidig是一位自学成才的开发人员,在职业网页,移动和系统编程中拥有近 20 年的经验,使用多种语言。

在国际临床研究组织学习专业软件开发和项目管理经验后,他成为一名自由职业软件开发人员。在多个项目上经过长时间而密切的合作后,他与一家 SaaS 公司合并。作为联合主管,他塑造了公司的总体方向,参与了其基于 Java 的主要产品的所有方面,并监督并实施其移动战略。

在业余时间,他通过撰写关于 Java、函数式编程、最佳实践以及一般代码风格的文章,分享他的专业知识和经验。他还参与开源项目,不论是作为已建立项目的提交者,还是发布自己的代码。

posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(34)  评论(0编辑  收藏  举报