现代-Java-实战-全-
现代 Java 实战(全)
原文:Modern Java in Action
译者:飞龙
第一部分. 基础知识
本书的第一部分提供了基础知识,帮助你开始使用 Java 8 引入的新 Java 思想。到第一部分的结尾,你将全面了解 lambda 表达式是什么,你将能够编写既简洁又灵活的代码,以便轻松适应不断变化的需求。
在第一章中,我们总结了 Java 的主要变化(lambda 表达式、方法引用、流和默认方法)并为本书设定了场景。
在第二章中,你将了解行为参数化,这是一种 Java 8 高度依赖的软件开发模式,也是 lambda 表达式产生的动机。
第三章提供了完整的解释,包括代码示例和每一步的测验,解释了 lambda 表达式和方法引用的概念。
第一章. Java 8、9、10 和 11:发生了什么?
本章涵盖
-
为什么 Java 一直在变化
-
计算背景的变化
-
Java 进化的压力
-
介绍 Java 8 和 9 的新核心特性
自从 1996 年 Java 开发工具包(JDK 1.0)发布以来,Java 赢得了大量学生、项目经理和程序员的关注,他们是活跃的用户。它是一种表达性语言,并且继续被用于大小项目。从 Java 1.1(1997)到 Java 7(2011)的演变得到了很好的管理。Java 8 于 2014 年 3 月发布,Java 9 于 2017 年 9 月发布,Java 10 于 2018 年 3 月发布,Java 11 计划于 2018 年 9 月发布。问题是:为什么你应该关心这些变化?
1.1. 那么,重大新闻是什么呢?
我们认为 Java 8 的变化在很多方面比 Java 历史上的任何其他变化都更为深刻(Java 9 添加了重要但影响较小的生产力变化,你将在本章后面看到,而 Java 10 对类型推断进行了许多较小的调整)。好消息是这些变化使你更容易编写程序。例如,你不需要编写冗长的代码(根据重量对inventory中的苹果列表进行排序)如下:
Collections.sort(inventory, new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
在 Java 8 中,你可以编写更简洁的代码,这些代码更接近于问题陈述,如下所示:
inventory.sort(comparing(Apple::getWeight)); *1*
- 1 本书的第一段 Java 8 代码!
它读作“按苹果重量排序库存。”现在不用担心这段代码。本书将解释它做什么以及你如何编写类似的代码。
同时,硬件也有影响:通用 CPU 已经变成了多核——你笔记本电脑或台式机的处理器可能包含四个或更多的 CPU 核心。但绝大多数现有的 Java 程序只使用这些核心中的一个,而让其他三个闲置(或者只使用一小部分处理能力运行操作系统或病毒检查器)。
在 Java 8 之前,专家可能会告诉你必须使用线程来使用这些核心。问题是与线程一起工作既困难又容易出错。Java 一直遵循着一条不断尝试使并发更容易、更不易出错的进化道路。Java 1.0 有线程、锁甚至内存模型——当时最好的实践,但这些原语在非专业项目团队中证明太难可靠地使用。Java 5 添加了工业级的构建块,如线程池和并发集合。Java 7 添加了 fork/join 框架,使并行性更实用,但仍然困难。Java 8 给我们提供了一种新的、更简单的方式来思考并行性。但你还必须遵循一些规则,你将在本书中学到这些规则。
正如你将在本书后面看到的那样,Java 9 添加了另一种并发结构方法——响应式编程。尽管这有更专业的用途,但它标准化了利用越来越受欢迎的 RxJava 和 Akka 响应式流工具包的方法,这些工具包适用于高度并发的系统。
从前两个愿望(更简洁的代码和更简单的多核处理器使用)中产生了整个由 Java 8 捕获的统一结构。我们首先给你一个这些想法的快速尝鲜(希望足够吸引你,但足够简短以总结它们):
-
Streams API
-
将代码传递给方法的技巧
-
接口中的默认方法
Java 8 提供了一个新的 API(称为 Streams),它支持许多并行操作来处理数据,类似于你可能认为的数据库查询语言的方式——你以更高级的方式表达你想要的内容,而实现(在这里是 Streams 库)选择最佳的底层执行机制。因此,它避免了你需要编写使用 synchronized 的代码的需求,这不仅高度易出错,而且在多核 CPU 上可能比你想象的更昂贵。1
¹
多核 CPU 为每个处理器核心配备了独立的缓存(快速内存)。锁定需要这些缓存同步,这要求相对较慢的缓存一致性协议跨核心通信。
从稍微有些修正的观点来看,Java 8 中 Streams 的添加可以看作是 Java 8 中其他两个添加的直接原因:将代码传递给方法的简洁技巧(方法引用、lambda)和接口中的默认方法。
但将传递代码给方法仅仅视为 Streams 的后果,就低估了它在 Java 8 中的用途范围。它为你提供了一种新的简洁方式来表达行为参数化。假设你想编写两个只有几行代码不同的方法。现在你可以简单地传递不同部分的代码作为参数(这种编程技术比常见的复制粘贴方法更短、更清晰、更不容易出错)。专家们在这里会指出,在 Java 8 之前,行为参数化可以使用匿名类来编码——但我们让本章开头的例子自己说话,这个例子展示了 Java 8 如何提高代码的简洁性,从清晰度的角度来看。
Java 8 将代码传递给方法(并能返回它并将其纳入数据结构)的功能还提供了访问一系列通常被称为函数式编程的附加技术。简而言之,在函数式编程社区中被称为函数的这种代码,可以通过传递和组合的方式产生强大的编程习惯,你将在本书中看到这些习惯以 Java 的形式出现。
本章的核心内容从对语言演化的高层次讨论开始,接着讨论 Java 8 的核心特性,然后介绍了新特性简化了使用并受到新计算机架构青睐的函数式编程理念。本质上,第 1.2 节讨论了演化过程和 Java 之前所缺乏的概念,以简单的方式利用多核并行性。第 1.3 节解释了为什么在 Java 8 中将代码传递给方法是一种如此强大的新编程习惯,而第 1.4 节则对 Streams——Java 8 表示有序数据的新方式以及这些数据是否可以并行处理进行了同样的解释。第 1.5 节解释了新的 Java 8 特性默认方法如何使接口及其库能够以更少的麻烦和更少的重新编译来演化;它还解释了 Java 9 中添加的模块,这使得大型 Java 系统的组件比“只是一个包的 JAR 文件”更清晰地被指定。最后,第 1.6 节展望了 Java 和其他共享 JVM 的语言中函数式编程的理念。总之,本章介绍了在本书其余部分逐步阐述的理念。享受这次旅程吧!
1.2. 为什么 Java 还在不断变化?
20 世纪 60 年代,人们开始寻找完美的编程语言。Landin,这位时代的著名计算机科学家,在 1966 年的一篇里程碑式的文章中指出,已经有 700 种编程语言,并推测了接下来的 700 种编程语言会是什么样子——包括类似于 Java 8 中的函数式编程的论点。
²
P. J. Landin, “The Next 700 Programming Languages,” CACM 9(3):157–65, March 1966.
在数以千计的编程语言之后,学者们得出结论,编程语言的行为类似于生态系统:新的语言出现,而旧的语言除非它们进化,否则将被取代。我们都希望有一个完美的通用语言,但现实中某些语言更适合某些领域。例如,C 和 C++由于其小巧的运行时占用和尽管缺乏编程安全性而仍然受到欢迎,用于构建操作系统和各种其他嵌入式系统。这种缺乏安全性可能导致程序不可预测地崩溃,并暴露出病毒等的安全漏洞;确实,当额外的运行时占用可以接受时,类型安全的语言如 Java 和 C#已经取代了 C 和 C++在各个应用中的地位。
早期占据一个领域往往会使竞争对手望而却步。仅仅为了一个新特性而改变语言和工具链通常是痛苦的,但新来者最终会取代现有语言,除非它们足够快地进化以跟上。 (较老的读者通常能够引用他们之前编写过但后来流行度下降的一系列语言——Ada、Algol、COBOL、Pascal、Delphi 和 SNOBOL,仅举几个例子。)
你是一名 Java 程序员,Java 在占领(并取代竞争语言)编程任务的大生态系统领域方面取得了成功,近 20 年来一直如此。让我们来探讨一下其中的原因。
1.2.1. Java 在编程语言生态系统中的位置
Java 起步良好。从一开始,它就是一个设计精良的面向对象语言,拥有许多有用的库。它还从第一天起就支持小规模并发,这得益于其对线程和锁的集成支持(以及其早期对多核处理器上并发线程可能表现出意外行为的硬件中立内存模型的先见之明)。此外,将 Java 编译成 JVM 字节码(一种很快就被所有浏览器支持的虚拟机代码)意味着它成为了网络小程序(你还记得小程序吗?)的首选语言。确实,存在一种危险,即 Java 虚拟机(JVM)及其字节码将被视为比 Java 语言本身更重要,并且对于某些应用程序,Java 可能会被其竞争对手之一(如 Scala、Groovy 或 Kotlin)所取代,这些语言也运行在 JVM 上。JVM 的各种最近更新(例如,JDK7 中的新invokedynamic字节码)旨在帮助这些竞争语言在 JVM 上顺利运行,并且与 Java 进行互操作。Java 还在占领嵌入式计算的各种方面(从智能卡、烤面包机、机顶盒到汽车制动系统)方面取得了成功。
Java 是如何进入通用编程领域的?
在 20 世纪 90 年代,面向对象编程因其封装纪律而变得流行,它比 C 语言产生的软件工程问题要少;并且作为一种心智模型,它很容易捕捉到 Windows 95 及以后的 WIMP 编程模型。这可以总结如下:一切都是对象;鼠标点击向处理器发送事件消息(在Mouse对象中调用clicked方法)。Java 的“一次编写,到处运行”模型以及早期浏览器的(安全地)执行 Java 代码小程序的能力,使它在大学中占有一席之地,而这些大学的毕业生后来填充了工业界。最初对 Java 相对于 C/C++的额外运行成本存在抵制,但随着机器速度的提高,程序员的时间变得越来越重要。微软的 C#进一步验证了 Java 风格的面向对象模型。
但编程语言生态系统的气候正在变化;程序员越来越多地处理所谓的大数据(数据集达到数太字节及以上)并希望有效地利用多核计算机或计算集群来处理它们。这意味着使用并行处理——这是 Java 之前并不友好的。你可能已经接触过来自其他编程领域的想法(例如,谷歌的 map-reduce 或使用 SQL 等数据库查询语言进行数据操作的相对容易性),这些想法有助于你处理大量数据和多核 CPU。图 1.1(Figure 1.1)以图示方式总结了语言生态系统:将景观视为编程问题的空间,将特定地面的主要植被视为该程序的首选语言。气候变化是指新的硬件或新的编程影响(例如,“为什么我不能用类似 SQL 的风格编程?”)意味着不同的语言成为新项目的首选语言,就像气温升高意味着葡萄现在在更高的纬度地区生长一样。但存在滞后性——许多老农民会继续种植传统作物。总之,新语言正在出现并变得越来越受欢迎,因为它们迅速适应了气候变化。
图 1.1. 编程语言生态系统与气候变化

对于程序员来说,Java 8 增加的主要好处是它们提供了更多的编程工具和概念,以更快地解决新的或现有的编程问题,或者更重要的是,以更简洁、更易于维护的方式。尽管这些概念对 Java 来说是新的,但它们在类似的研究型语言中已被证明是强大的。在接下来的几节中,我们将突出和阐述三个推动 Java 8 功能开发以利用并行性和编写更简洁代码的编程概念背后的思想。我们将以与其他章节略有不同的顺序介绍它们,以便进行基于 Unix 的类比,并揭示 Java 8 新并行性中“需要这个因为那个”的依赖关系。
Java 的另一个气候变化因素
一个气候变化因素涉及如何设计大型系统。如今,一个大型系统通常包含来自其他地方的大型组件子系统,也许这些组件是基于其他供应商的组件构建的。更糟糕的是,这些组件及其接口也倾向于演变。Java 8 和 Java 9 通过提供默认方法和模块来解决这个问题,以促进这种设计风格。
下三个部分将探讨推动 Java 8 设计的三个编程概念。
1.2.2. 流处理
第一个编程概念是流处理。为了介绍的目的,一个流是一系列数据项的序列,这些数据项在概念上一次产生一个。一个程序可能一次从输入流中读取一个项目,并类似地写入输出流。一个程序输出流可能正好是另一个程序的输入流。
一个实际的例子是在 Unix 或 Linux 中,许多程序通过从标准输入(Unix 和 C 中的 stdin,Java 中的 System.in)读取数据,对其进行操作,然后将结果写入标准输出(Unix 和 C 中的 stdout,Java 中的 System.out)来运行。首先,有一点背景:Unix 的 cat 命令通过连接两个文件创建一个流,tr 将流中的字符进行转换,sort 对流中的行进行排序,而 tail -3 则给出流中的最后三行。Unix 命令行允许这些程序通过管道(|)链接在一起,例如
cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3
which(假设file1和file2每行包含一个单词)打印出字典顺序中最后出现的三个单词,在将它们转换为小写之前。我们说sort以流形式的行作为输入,并产生另一个流形式的行作为输出(后者是排序后的),如图 1.2 所示(图 1.2)。请注意,在 Unix 中,这些命令(cat、tr、sort和tail)是并发执行的,这样sort就可以在cat或tr完成之前处理前几行。一个更机械的类比是一个汽车制造装配线,其中一串汽车在各个处理站之间排队,每个站都取一辆车,对其进行修改,并将其传递到下一个站进行进一步处理;尽管装配线在物理上是序列,但各个站点的处理通常是并发的。
³
纯粹主义者会说“字符流”,但概念上更简单的是认为
sort重新排序了行。
图 1.2. Unix 命令在流上操作

Java 8 在java.util.stream中基于这个想法添加了一个 Streams API(注意大写的 S);Stream<T>是类型为 T 的项的序列。你现在可以将其视为一个花哨的迭代器。Streams API 有许多方法可以链接起来形成一个复杂的管道,就像在先前的例子中 Unix 命令被链接起来一样。
这个关键动机在于,你现在可以用 Java 8 在更高的抽象级别上进行编程,将这种流转换为那种流的思考方式(类似于你编写数据库查询时的思考方式),而不是逐个处理。另一个优点是 Java 8 可以透明地在输入的不同部分上并行运行你的Stream操作管道——这是几乎不费力的并行性,而不是使用Thread的艰苦工作。我们将在第四章([kindle_split_015.xhtml#ch04])到第七章([kindle_split_018.xhtml#ch07])中详细介绍 Java 8 Streams API。
1.2.3. 将代码传递给具有行为参数化的方法
Java 8 添加的第二个编程概念是能够将一段代码传递给一个 API。这听起来非常抽象。在 Unix 的例子中,你可能想告诉sort命令使用自定义排序。尽管sort命令支持命令行参数来执行各种预定义的排序类型,如逆序排序,但这些是有限的。
例如,假设你有一组发票 ID,其格式类似于 2013UK0001、2014US0002 等。前四位数字代表年份,接下来的两个字母代表国家代码,最后四位数字代表客户的 ID。你可能想按年份或客户 ID 或甚至国家代码对这些发票 ID 进行排序。你想要的是告诉sort命令接受一个用户定义的排序作为参数:传递给sort命令的一段单独的代码。
现在,作为一个直接的 Java 并行,你想要告诉sort方法使用自定义的顺序进行比较。你可以编写一个compareUsingCustomerId方法来比较两个发票 ID,但在 Java 8 之前,你不能将这个方法传递给另一个方法!你可以创建一个Comparator对象,就像我们在本章开头所展示的那样,将其传递给sort方法,但这很冗长,并且模糊了简单重用现有行为的概念。Java 8 增加了将方法(你的代码)作为参数传递给其他方法的能力。图 1.3,基于图 1.2,说明了这个概念。我们也将这个概念称为行为参数化。为什么这很重要?Streams API 建立在传递代码来参数化其操作行为的基础上,就像你传递compareUsingCustomerId来参数化sort的行为一样。
图 1.3. 将方法compareUsingCustomerId作为参数传递给sort

我们在本章的第 1.3 节中总结了这是如何工作的,但将全部细节留给第二章和第三章。第十八章和第十九章将探讨使用此功能可以执行的一些更高级的操作,以及来自函数式编程社区的技术。
1.2.4. 并行性和共享可变数据
第三个编程概念更为隐晦,它源于我们之前在流处理讨论中提到的“几乎免费的并行性”。你需要放弃什么?你可能需要在编写传递给流方法的行为的方式上做一些小的改变。一开始,这些改变可能会让你感到有些不舒服,但一旦习惯了,你会喜欢它们的。你必须提供在输入的不同部分上安全并发执行的行为。通常这意味着编写不访问共享可变数据来完成其工作的代码。有时这些被称为纯函数或无副作用函数或无状态函数,我们将在第十八章和第十九章中详细讨论这些。之前的并行性仅通过假设你的代码的多个副本可以独立工作而产生。如果有一个被写入的共享变量或对象,那么事情就不再有效了。如果两个进程同时想要修改共享变量怎么办?(第 1.4 节提供了一个带有图表的更详细解释。)你将在整本书中找到更多关于这种风格的内容。
Java 8 流比 Java 现有的线程 API 更容易利用并行性,所以尽管使用synchronized来打破没有共享可变数据规则是可能的,但它是在与系统作对,因为它是在围绕该规则优化的抽象中滥用。在多个处理核心上使用synchronized通常比你预期的要昂贵得多,因为同步迫使代码按顺序执行,这与并行化的目标相悖。
其中两点(没有共享可变数据和能够传递方法及函数—代码—到其他方法的能力)是通常所说的函数式编程范式的基石,你将在第十八章和第十九章中详细了解。相比之下,在命令式编程范式中,你通常用一系列改变状态的语句来描述程序。没有共享可变数据的要求意味着一个方法完全可以通过它如何将参数转换为结果来描述;换句话说,它表现得像一个数学函数,并且没有(可见的)副作用。
1.2.5. Java 需要进化
你之前已经看到了 Java 的进化。例如,泛型的引入和使用List<String>而不是仅仅List可能最初令人烦恼。但现在你已经熟悉了这种风格及其带来的好处(在编译时捕获更多错误,并使代码更容易阅读,因为你现在知道某个东西是一个列表)。
其他变化使得常见的事情更容易表达(例如,使用for-each循环而不是暴露Iterator的样板代码使用)。Java 8 的主要变化反映了一种从关注于改变现有值的经典面向对象,转向功能式编程谱系,其中你想要做什么(例如,创建一个表示从 A 到 B 的所有运输路线且价格低于给定价格的值)被视为首要的,并且与你如何实现它(例如,扫描一个数据结构修改某些组件)分离。请注意,经典面向对象编程和函数式编程作为极端,可能会显得相互冲突。但理念是从两种编程范式中获得最佳之处,这样你就有更大的机会拥有适合工作的正确工具。我们将在 1.3 节和 1.4 节中详细讨论这一点。
一个可以吸取的要点可能是这样的:语言需要进化以跟踪不断变化的硬件或程序员期望(如果您需要说服,请考虑 COBOL 曾经是商业上最重要的语言之一)。为了生存,Java 必须通过添加新功能来进化。如果没有使用新功能,这种进化将是徒劳的,因此在使用 Java 8 时,您正在保护作为 Java 程序员的生活方式。除此之外,我们有一种感觉,您会喜欢使用 Java 8 的新功能。问问任何使用过 Java 8 的人,他们是否愿意回去!此外,新的 Java 8 功能可能在生态系统类比中使 Java 能够征服其他语言目前占据的编程任务领域,因此 Java 8 程序员的需求将更加旺盛。
现在我们逐一介绍 Java 8 的新概念,并指出涵盖这些概念的章节,以更详细地介绍这些概念。
1.3. Java 中的函数
在编程语言中,函数一词通常用作方法的同义词,尤其是静态方法;除此之外,它还用于数学函数,即没有副作用的一个。幸运的是,正如您将看到的,当 Java 8 提到函数时,这些用法几乎是一致的。
Java 8 将函数作为新的值形式添加。这促进了流的使用,如第 1.4 节所述,Java 8 提供了流来利用多核处理器上的并行编程。我们首先展示函数作为值本身是有用的。
考虑 Java 程序可能操作的可能值。首先,有原始值,如 42(类型为int)和 3.14(类型为double)。其次,值可以是对象(更严格地说,是对象的引用)。获取这些值的唯一方法是通过使用new,可能通过工厂方法或库函数;对象引用指向类的实例。例如,"abc"(类型为String),new Integer(1111)(类型为Integer),以及显式调用HashMap构造函数的结果new HashMap<Integer, String>(100)。甚至数组也是对象。问题是什么?
为了回答这个问题,我们将指出,编程语言的全部目的就是操纵值,根据历史编程语言的传统,这些值因此被称为一等值(或公民,借用自 20 世纪 60 年代美国民权运动中的术语)。我们编程语言中的其他结构,可能有助于我们表达值的结构,但在程序执行期间不能传递,因此被称为二等公民。之前列出的值是一等 Java 公民,但各种其他 Java 概念,如方法和类,是二等公民的例子。当使用方法来定义类时,方法是很好的,这些类反过来可以实例化以产生值,但它们本身并不是值。这有什么关系吗?是的,结果证明,在运行时传递方法,并使它们成为一等公民,在编程中非常有用,因此 Java 8 的设计者添加了在 Java 中直接表达这种能力。顺便说一句,你可能想知道将其他二等公民(如类)变成一等公民值是否也是一个好主意。像 Smalltalk 和 JavaScript 这样的各种语言已经探索了这条路线。
1.3.1. 方法和 lambda 作为一等公民
在其他语言(如 Scala 和 Groovy)中的实验已经确定,允许像方法这样的概念作为一等值使用,通过增加程序员可用的工具集,使得编程变得更加容易。一旦程序员熟悉了一个强大的特性,他们就不愿意使用没有这个特性的语言!Java 8 的设计者决定允许方法作为值存在——以便于你编程。此外,Java 8 中将方法作为值的功能是其他各种 Java 8 特性(如 Streams)的基础。
我们首先介绍的新 Java 8 特性是方法引用。假设你想要过滤目录中的所有隐藏文件。你需要编写一个方法,给定一个File对象,它会告诉你该文件是否隐藏。幸运的是,File类中有一个名为isHidden的方法可以实现这个功能。它可以看作是一个接受File对象并返回boolean值的函数。但为了用于过滤,你需要将其包装成一个FileFilter对象,然后将该对象传递给File.listFiles方法,如下所示:
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
public boolean accept(File file) {
return file.isHidden(); *1*
}
});
- 1 过滤隐藏文件!
哎呀!这太糟糕了。尽管这只有三行重要的代码,但它却是三行晦涩难懂的代码——我们都会记得第一次遇到时会说“我真的必须这样做吗?”你已经有了可以使用的isHidden方法。为什么你还要把它封装在一个冗长的FileFilter类中,然后实例化它呢?因为在 Java 8 之前你必须这样做。
现在,你可以将那段代码重写如下:
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
哇!这不是很酷吗?你已经有isHidden函数可用,所以你可以使用 Java 8 的方法引用 :: 语法(意味着“使用这个方法作为值”)将其传递给listFiles方法;注意我们也在使用“函数”这个词来指代方法。我们稍后会解释其工作机制。一个优点是,你的代码现在读起来更接近问题陈述。
这只是冰山一角:方法不再是二等公民。类似于传递对象时使用对象引用(对象引用是通过new创建的),在 Java 8 中,当你写下File::isHidden时,你创建了一个方法引用,它可以像对象引用一样被传递。这一概念在第三章中有详细讨论。鉴于方法包含代码(方法的可执行体),使用方法引用使得代码可以被传递,就像图 1.3 中所示。图 1.4 展示了这一概念。你还会在下一节看到一个具体示例(从库存中选择苹果)。
图 1.4:将方法引用File::isHidden传递给listFiles方法

Lambda:匿名函数
除了允许(命名)方法成为一等公民外,Java 8 还允许更丰富的函数作为值的概念,包括lambda^([4])(或匿名函数)。例如,你现在可以写(int x) -> x + 1来表示“当用参数 x 调用时,返回值 x + 1 的函数。”你可能想知道为什么这有必要,因为你可以在MyMathsUtils类内部定义一个名为add1的方法,然后写MyMaths-Utils::add1!是的,你可以这样做,但新的 lambda 语法在没有方便的方法和类可用的情况下更为简洁。第三章详细探讨了 lambda。使用这些概念的程序被称为函数式编程风格的程序;这个短语意味着“编写将函数作为一等值传递的程序。”
⁴
最初以希腊字母λ(lambda)命名。虽然这个符号在 Java 中未使用,但其名称仍然存在。
1.3.2. 传递代码:一个示例
让我们看看这个如何帮助你编写程序(在第二章中有更详细的讨论)。所有示例代码都可在 GitHub 仓库和书籍网站上找到,两个链接都可以在www.manning.com/books/modern-java-in-action找到。假设你有一个名为Apple的类,它有一个getColor方法和一个包含Apples列表的变量inventory;那么你可能希望选择所有绿色的苹果(这里使用包含值GREEN和RED的Color枚举类型),并将它们作为一个列表返回。filter这个词通常用来表达这个概念。在 Java 8 之前,你可能写一个名为filterGreenApples的方法:
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>(); *1*
for (Apple apple: inventory){
if (GREEN.equals(apple.getColor())) { *2*
result.add(apple);
}
}
return result;
}
-
1 结果列表累积结果;它最初是空的,然后逐个添加绿色苹果。
-
2 高亮显示的文本只选择绿色苹果。
但接下来,有人可能想要重苹果的列表(比如超过 150 克),因此,带着沉重的心情,你会编写以下方法来实现这一点(可能甚至使用复制粘贴):
public static List<Apple> filterHeavyApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory){
if (apple.getWeight() > 150) { *1*
result.add(apple);
}
}
return result;
}
- 1 这里高亮显示的文本只选择重苹果。
我们都知道复制粘贴对软件工程的危险(对一个变体的更新和错误修复,但没有对另一个变体进行),嘿,这两个方法只在一条线上有所不同:高亮显示的 if 结构内的条件。如果高亮代码中两个方法调用之间的差异是可接受的重量范围,那么你可以将可接受的上下限重量作为参数传递给 filter——比如 (150, 1000) 来选择重苹果(超过 150 克)或 (0, 80) 来选择轻苹果(低于 80 克)。
但正如我们之前提到的,Java 8 使得将条件代码作为参数传递成为可能,避免了 filter 方法的代码重复。现在你可以这样写:
public static boolean isGreenApple(Apple apple) {
return GREEN.equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
return apple.getWeight() > 150;
}
public interface Predicate<T>{ *1*
boolean test(T t);
}
static List<Apple> filterApples(List<Apple> inventory,
Predicate<Apple> p) { *2*
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory){
if (p.test(apple)) { *3*
result.add(apple);
}
}
return result;
}
-
1 为了清晰起见(通常从 java.util.function 导入)
-
2 方法作为名为 p 的谓词参数传递(参见侧边栏“什么是谓词?”)。
-
3 苹果是否匹配由 p 表示的条件?
要使用这个,你可以调用以下任何一个:
filterApples(inventory, Apple::isGreenApple);
or
filterApples(inventory, Apple::isHeavyApple);
我们将在接下来的两个章节中详细解释它是如何工作的。现在要记住的关键思想是,你可以在 Java 8 中传递一个方法。
什么是谓词?
之前的代码传递了一个方法 Apple::isGreenApple(它接受一个 Apple 参数并返回一个布尔值)到 filterApples,该方法期望一个 Predicate <Apple> 参数。在数学中,谓词一词常用来表示一种类似于函数的东西,它接受一个参数的值并返回 true 或 false。正如你稍后将会看到的,Java 8 也允许你编写 Function<Apple, Boolean>——对于在学校学习过函数但没有学习过谓词的读者来说可能更熟悉——但使用 Predicate<Apple> 更为标准(并且稍微高效一些,因为它避免了将 boolean 包装成 Boolean)。
1.3.3. 从传递方法到 lambda 表达式
将方法作为值传递显然很有用,但当你可能只使用一次或两次时,不得不为短方法(如 isHeavyApple 和 isGreenApple)编写定义确实很烦人。但 Java 8 也解决了这个问题。它引入了一种新的表示法(匿名函数,或 lambda 表达式),它允许你只写
filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()) );
or
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
or
filterApples(inventory, (Apple a) -> a.getWeight() < 80 ||
RED.equals(a.getColor()) );
你甚至不需要编写只使用一次的方法定义;代码更加清晰,因为你不需要搜索来找到你传递的代码。但是,如果这样的 lambda 表达式长度超过几行(以至于其行为不是立即清晰的),则应改用具有描述性名称的方法引用,而不是使用匿名 lambda。代码的清晰度应该是你的指南。
Java 8 的设计者几乎可以在这里停下来,也许在多核 CPU 之前他们会这样做。到目前为止所展示的函数式编程证明是强大的,你将会看到。Java 可能会通过添加 filter 和一些其他作为通用库方法的“朋友”来完善,例如
static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);
你甚至不需要编写像 filterApples 这样的方法,因为例如,前面的调用
filterApples(inventory, (Apple a) -> a.getWeight() > 150 );
可以写成对库方法 filter 的调用:
filter(inventory, (Apple a) -> a.getWeight() > 150 );
但是,出于更好地利用并行性的原因,设计者没有这样做。Java 8 代替地包含了一个新的类似于集合的 API,称为 Stream,它包含了一组类似于 filter 操作的操作,这些操作可能是函数式程序员所熟悉的(例如,map 和 reduce),以及用于在 Collections 和 Streams 之间转换的方法,我们现在将研究这些方法。
1.4. 流
几乎每个 Java 应用程序都会 创建 和 处理 集合。但是,与集合一起工作并不总是理想的。例如,假设你需要从列表中过滤出昂贵的交易,然后按货币分组。你需要编写大量的样板代码来实现这个数据处理查询,如下所示:
Map<Currency, List<Transaction>> transactionsByCurrencies =
new HashMap<>(); *1*
for (Transaction transaction : transactions) { *2*
if(transaction.getPrice() > 1000){ *3*
Currency currency = transaction.getCurrency(); *4*
List<Transaction> transactionsForCurrency =
transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) { *5*
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency,
transactionsForCurrency);
}
transactionsForCurrency.add(transaction); *6*
}
}
-
1 创建一个映射,其中将累积分组交易
-
2 遍历交易列表
-
3 过滤昂贵的交易
-
4 提取交易的货币
-
5 如果分组映射中没有该货币的条目,则创建它。
-
6 将当前遍历的交易添加到具有相同货币的交易列表中
此外,由于存在多个嵌套的控制流语句,因此很难一眼看出代码的功能。
使用 Streams API,你可以这样解决这个问题:
import static java.util.stream.Collectors.groupingBy;
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream()
.filter((Transaction t) -> t.getPrice() > 1000) *1*
.collect(groupingBy(Transaction::getCurrency)); *2*
-
1 过滤昂贵的交易
-
2 按货币分组
目前不必担心这段代码,因为它可能看起来有点像魔法。第四章到第七章(Chapters 4–7)专门解释了如何理解 Streams API。现在,值得注意的是,Streams API 提供了与 Collections API 不同的数据处理方式。使用集合,你需要自己管理迭代过程。你需要使用for-each循环逐个遍历元素,依次处理它们。我们称这种方式为数据的外部迭代。相比之下,使用 Streams API,你不需要考虑循环。数据处理在库内部发生。我们称这个想法为内部迭代。我们将在第四章(chapter 4)中回到这些想法。
作为与集合一起工作的第二个痛点,先想想如果你有大量交易列表,你会如何处理它;你该如何处理这个庞大的列表?单个 CPU 无法处理如此大量的数据,但你可能桌上有多核计算机。理想情况下,你希望将工作分配给你的机器上可用的不同 CPU 核心,以减少处理时间。从理论上讲,如果你有八个核心,它们应该能够以使用一个核心八倍的速度处理你的数据,因为它们是并行工作的.^([5])
⁵
这种命名在某种程度上是不幸的。多核芯片中的每个核心都是一个完整的 CPU。但“多核 CPU”这个短语已经变得很常见,所以“核心”用来指代单个 CPU。
多核计算机
所有新的台式机和笔记本电脑都是多核计算机。它们不是单个 CPU,而是有四个、八个或更多的 CPU(通常称为核心 5)。问题是经典的 Java 程序只使用这些核心中的一个,其他核心的功率被浪费了。同样,许多公司使用计算集群(通过快速网络连接在一起的计算机)来有效地处理大量数据。Java 8 简化了新的编程风格,以更好地利用这类计算机。
谷歌的搜索引擎是一个例子,它太大,无法在单个计算机上运行。它读取互联网上的每一页,并创建一个索引,将任何互联网页面上出现的每个单词映射回包含该单词的每个 URL。然后,当你进行涉及多个单词的谷歌搜索时,软件可以快速使用这个索引给你提供包含这些单词的网页集合。试着想象你如何用 Java 编写这个算法(即使对于比谷歌的小的索引,你也需要利用你电脑上的所有核心)。
1.4.1. 多线程是困难的
问题在于,通过编写多线程代码(使用 Java 先前版本中的 Threads API)来利用并行性是困难的。你必须以不同的方式思考:线程可以同时访问和更新共享变量。结果,如果没有适当协调^([6]), 数据可能会意外改变。这种模型比逐步顺序模型更难思考^([7]). 例如,图 1.5 展示了两个线程试图在不正确同步的情况下向共享变量sum添加数字时可能出现的潜在问题。
⁶
传统上通过
synchronized关键字实现,但许多微妙的错误都源于其位置不当。基于Stream的 Java 8 并行性鼓励一种函数式编程风格,其中synchronized很少使用;它侧重于数据分区而不是协调对数据的访问。⁷
啊哈——这是推动语言进化的压力来源!
图 1.5. 两个线程试图向共享的sum变量添加时可能出现的潜在问题。结果是 105,而不是预期的 108。

Java 8 也通过 Streams API(java.util.stream)解决了这两个问题(与处理集合相关的样板代码和模糊性以及利用多核的困难)。第一个设计动机是存在许多数据处理的模式(类似于前一小节中的filterApples或来自数据库查询语言(如 SQL)的熟悉操作),这些模式反复出现,并且会从成为库的一部分中受益:基于标准(例如,重苹果)过滤数据,提取数据(例如,从列表中的每个苹果中提取重量字段),或分组数据(例如,将数字列表分组为偶数和奇数列表),等等。第二个动机是这些操作通常可以并行化。例如,如图 1.6 所示,在两个 CPU 上过滤列表可以通过要求一个 CPU 处理列表的前半部分,而另一个 CPU 处理列表的后半部分来实现。这被称为分叉步骤(1)。然后,CPU 过滤它们各自的半列表(2)。最后(3),一个 CPU 将合并两个结果。(这与 Google 搜索快速工作的方式密切相关,使用了两个以上的处理器。)
图 1.6. 将filter分叉到两个 CPU 上并合并结果

目前,我们只能说新的 Streams API 的行为与 Java 现有的 Collections API 类似:两者都提供了对数据项序列的访问。但记住这一点是有用的,即 Collections 主要是关于存储和访问数据,而 Streams 主要是关于对数据进行计算。这里的关键点是 Streams API 允许并鼓励流内的元素并行处理。虽然一开始可能看起来很奇怪,但过滤集合(例如,在上一个章节中对列表使用filterApples)最快的方法通常是将其转换为流,并行处理,然后再将其转换回列表。我们再次只是说“几乎免费获得并行处理”,并展示如何使用流和 lambda 表达式按顺序或并行地从列表中过滤出重苹果。
下面是一个顺序处理的例子:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
现在来看看并行处理的应用:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
Java 中的并行处理和无共享可变状态
人们常说 Java 中的并行处理很困难,所有关于synchronized的内容都容易出错。Java 8 中有什么神奇的子弹吗?
有两个神奇的子弹。首先,库处理分区——将大流分解成几个较小的流以供你并行处理。其次,这种从流中几乎免费获得的并行处理,仅在传递给库方法(如filter)的方法不交互(例如,通过具有可变共享对象)时才有效。但结果证明,这种限制对程序员来说感觉是自然的(例如,通过我们的Apple::isGreenApple示例)。尽管在函数式编程中,“函数式”的主要含义是“将函数作为一等值使用”,但它通常还有一个次要的含义,即“组件在执行期间不交互。”
第七章详细探讨了 Java 8 中的并行数据处理及其性能。Java 8 开发者在将所有这些新特性引入 Java 的过程中发现的一个实际问题,是现有接口的演变。例如,Collections.sort方法属于List接口,但从未被包含在内。理想情况下,你希望执行list.sort(comparator)而不是Collections.sort(list, comparator)。这看起来可能微不足道,但在 Java 8 之前,只有当你更新实现它的所有类时,你才能更新一个接口——这是一个后勤噩梦!这个问题在 Java 8 中通过默认方法得到了解决。
1.5. 默认方法和 Java 模块
如我们之前提到的,现代系统往往是由组件构建的——可能是从其他地方购买的。从历史上看,Java 在这方面支持很少,除了包含一组没有特定结构的 Java 包的 JAR 文件。此外,将这些接口演变到这样的包中也很困难——更改 Java 接口意味着更改实现它的每个类。Java 8 和 9 开始着手解决这个问题。
首先,Java 9 提供了一个模块系统,它提供了定义包含包集合的模块的语法,并更好地控制可见性和命名空间。模块通过结构丰富了一个简单的 JAR-like 组件,既作为用户文档,也用于机器检查;我们将在第十四章中详细解释它们。其次,Java 8 添加了默认方法来支持可进化的接口。我们将在第十三章中详细讨论这些内容。它们很重要,因为您将越来越多地在接口中遇到它们,但由于相对较少的程序员需要自己编写默认方法,并且它们有助于程序进化而不是帮助编写任何特定的程序,所以我们在这里简要介绍并基于示例进行说明。
在第 1.4 节中,我们给出了以下 Java 8 代码示例:
List<Apple> heavyApples1 =
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
List<Apple> heavyApples2 =
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
但这里有一个问题:Java 8 之前的List<T>没有stream或parallel-Stream方法,并且它实现的Collection<T>接口也没有这些方法——因为这些方法还没有被构想出来。没有这些方法,这段代码将无法编译。对于您自己的接口,最简单的解决方案可能是 Java 8 设计者将stream方法添加到Collection接口中,并在ArrayList类中添加实现。
但这样做对用户来说将是一场噩梦。许多替代集合框架实现了 Collections API 的接口。向接口添加新方法意味着所有具体的类都必须为其提供实现。语言设计者无法控制Collection的现有实现,因此您面临一个困境:如何在不破坏现有实现的情况下进化已发布的接口?
Java 8 的解决方案是打破最后一个链接:接口现在可以包含实现类不提供实现的方法定义。那么谁来实现它们呢?缺失的方法体作为接口的一部分(因此是默认实现)而不是在实现类中给出。
这为接口设计者提供了一种方法,可以在不破坏现有代码的情况下扩展接口——超出最初计划的方法。Java 8 允许在接口规范中使用现有的default关键字来实现这一点。
例如,在 Java 8 中,您可以直接在列表上调用sort方法。这是通过 Java 8 List接口中的以下默认方法实现的,它调用静态方法Collections.sort:
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
这意味着任何具体的List类都不需要显式实现sort方法,而在之前的 Java 版本中,这些具体的类如果没有提供sort方法的实现就无法重新编译。
但等等。一个类可以实现多个接口,对吧?如果你在几个接口中有多个默认实现,这意味着你在 Java 中有某种形式的多重继承吗?是的,在某种程度上。我们在第十三章 中展示了某些规则,这些规则防止了像 C++ 中臭名昭著的 菱形继承问题 这样的问题。
1.6. 函数式编程中的其他好想法
前几节介绍了函数式编程的两个核心思想,现在已成为 Java 的一部分:使用方法和 lambda 表达式作为一等值,以及在没有可变共享状态的情况下,函数或方法调用可以高效且安全地并行执行的想法。这两个想法都被我们之前描述的新 Streams API 所利用。
常见的函数式语言(SML、OCaml、Haskell)还提供了其他构造来帮助程序员。其中之一是通过显式使用更具描述性的数据类型来避免 null。计算机科学巨匠之一托尼·霍尔在 2009 年伦敦 QCon 的一个演讲中说:
我称之为我的十亿美元的错误。这是 1965 年对空引用的发明……我无法抗拒加入空引用的诱惑,仅仅因为它如此容易实现。
Java 8 引入了 Optional<T> 类,如果一致使用,可以帮助你避免空指针异常。它是一个可能包含或不包含值的容器对象。Optional<T> 包含处理值不存在情况的方法,因此你可以避免空指针异常。它使用类型系统来允许你指示一个变量预期可能缺少值。我们在第十一章 中详细讨论了 Optional<T>。
第二个想法是关于 (结构化)模式匹配。^([8]) 这在数学中有所应用。例如:
⁸
这个短语有两个用途。在这里,我们指的是从数学和函数式编程中熟悉的用法,即函数通过情况定义,而不是使用
if-then-else。另一个含义涉及像“在给定目录中查找所有形式为‘IMG*.JPG’的文件”这样的短语,这与所谓的正则表达式相关。
f(0) = 1
f(n) = n*f(n-1) otherwise
在 Java 中,你会写一个if-then-else或switch语句。其他语言已经表明,对于更复杂的数据类型,与使用if-then-else相比,模式匹配可以更简洁地表达编程思想。对于此类数据类型,你也可能使用多态性和方法重写作为if-then-else的替代方案,但关于哪种更合适,语言设计讨论仍在进行中。9] 我们会说两者都是有用的工具,你应该两者都具备。不幸的是,Java 8 没有对模式匹配提供全面支持,尽管我们在第十九章中展示了如何表达它。还正在讨论一个 Java 增强提案,以支持 Java 未来版本中的模式匹配(见openjdk.java.net/jeps/305)。同时,让我们用一个在 Scala 编程语言中表达(另一种使用 JVM 的类似 Java 的语言,它启发了 Java 的一些演变方面;见第二十章)的例子来说明。假设你想编写一个程序,对表示算术表达式的树进行基本简化。给定一个表示此类表达式的数据类型Expr,在 Scala 中,你可以编写以下代码来分解一个Expr并返回另一个Expr:
⁹
关于“表达式问题”(由 Phil Wadler 提出的术语)的维基百科文章为讨论提供了入口。
def simplifyExpression(expr: Expr): Expr = expr match {
case BinOp("+", e, Number(0)) => e *1*
case BinOp("-", e, Number(0)) => e *2*
case BinOp("*", e, Number(1)) => e *3*
case BinOp("/", e, Number(1)) => e *4*
case _ => expr *5*
}
-
1 加 0
-
2 减去 0
-
3 乘以 1
-
4 除以 1
-
5 这些情况无法简化,所以可以忽略
这里 Scala 的语法expr match对应于 Java 的switch (expr)。现在不必担心这段代码——你将在第十九章中了解更多关于模式匹配的内容。现在,你可以将模式匹配视为switch的扩展形式,它可以在分解数据类型的同时将其分解为其组件。
为什么 Java 中的switch语句仅限于原始值和字符串?函数式语言通常允许switch在更多数据类型上使用,包括允许模式匹配(在 Scala 代码中,这是通过使用match操作实现的)。在面向对象设计中,访问者模式是一种常用的模式,用于遍历一组类(例如汽车的各个部件:轮胎、引擎、底盘等)并对每个访问的对象应用操作。模式匹配的一个优点是编译器可以报告常见的错误,例如,“类Brakes是用于表示Car类组件的类族的一部分。你忘记显式处理它了。”
第十八章 和 第十九章 提供了关于函数式编程的全面教程介绍以及如何在 Java 8 中编写函数式风格的程序——包括其库中提供的函数工具包。第二十章 接着讨论了 Java 8 的特性与 Scala 语言中的特性相比——Scala 语言与 Java 类似,也是基于 JVM 实现的,并且迅速进化以威胁到 Java 在编程语言生态系统中的某些领域。这些内容位于本书的末尾,以提供更多关于为什么添加了新的 Java 8 和 Java 9 特性的见解。
Java 8、9、10 和 11 特性:从哪里开始?
Java 8 和 Java 9 都对 Java 进行了重大更新。但作为一名 Java 程序员,你每天在小型编码基础上最可能受到影响的是 Java 8 的新增功能——传递方法或 lambda 的概念正迅速成为至关重要的 Java 知识。相比之下,Java 9 的增强功能丰富了定义和使用更大规模组件的能力,无论是使用模块来构建系统还是导入响应式编程工具包。最后,Java 10 相比之前的升级要小得多,它允许局部变量的类型推断,我们将在 第二十一章 中简要讨论这一点,在那里我们还将提到由于 Java 11 的引入而带来的 lambda 表达式参数的更丰富语法。在撰写本文时,Java 11 预计将于 2018 年 9 月发布。Java 11 还引入了一个新的异步 HTTP 客户端库 (openjdk.java.net/jeps/321),该库利用了 Java 8 和 Java 9 的发展(详情见 第十五章、第十六章 和 第十七章)中的 CompletableFuture 和响应式编程。
摘要
-
记住语言生态系统的理念以及随之而来的语言进化或衰败的压力。尽管 Java 目前可能非常健康,但我们也可以回忆起其他健康的语言,如 COBOL,这些语言未能进化。
-
Java 8 的核心新增功能提供了令人兴奋的新概念和功能,以简化编写既有效又简洁的程序。
-
多核处理器并没有完全由 Java 8 之前的编程实践所充分利用。
-
函数是一等值;记住方法可以作为函数值传递,以及匿名函数(lambda)是如何编写的。
-
Java 8 的流概念概括了许多集合的方面,但前者通常使代码更易读,并允许流中的元素并行处理。
-
大规模基于组件的编程,以及系统接口的演变,在历史上并没有得到 Java 的良好服务。现在你可以在 Java 9 中指定模块来结构化系统,并使用默认方法来允许在不更改实现它的所有类的情况下增强接口。
-
函数式编程中的其他有趣想法包括处理
null和使用模式匹配。
第二章:使用行为参数化传递代码
本章涵盖
-
应对变化的需求
-
行为参数化
-
匿名类
-
lambda 表达式的预览
-
现实世界的例子:
Comparator、Runnable和 GUI
软件工程中一个众所周知的问题是,无论你做什么,用户需求都会改变。例如,想象一个帮助农民了解其库存的应用程序。农民可能希望有一个功能来查找他库存中所有绿色的苹果。但第二天他可能会告诉你,“实际上,我还想找到所有重量超过 150 克的苹果。”两天后,农民回来并补充说,“如果我能找到所有既绿色又重量超过 150 克的苹果那就太好了。”你如何应对这些不断变化的需求?理想情况下,你希望最小化你的工程努力。此外,类似的新功能应该易于实现且长期可维护。
行为参数化是一种软件开发模式,它允许你处理频繁的需求变更。简而言之,这意味着取一段代码并使其可用而不执行它。这段代码可以在稍后由程序的其他部分调用,这意味着你可以延迟执行这段代码。例如,你可以将这段代码作为参数传递给另一个稍后执行它的方法。因此,方法的行为是根据这段代码参数化的。例如,如果你处理一个集合,你可能想编写一个方法,
-
可以对列表中的每个元素执行“某种”操作
-
在处理完列表后可以执行“另一件事”
-
如果遇到错误,可以执行“另一件事”
这就是行为参数化所指的内容。这里有一个类比:你的室友知道如何开车去超市并回家。你可以告诉他去买一些东西,比如面包、奶酪和酒。这相当于调用一个方法 goAndBuy,传递一个产品列表作为其参数。但有一天你在办公室,你需要他做他以前从未做过的事情——从邮局取包裹。你需要给他一个指令列表:去邮局,使用这个参考号,和经理交谈,取回包裹。你可以通过电子邮件给他这个指令列表,当他收到时,他可以按照指令行事。你现在做了一件稍微复杂一点的事情,这相当于一个方法 goAndDo,它可以执行各种新的行为作为参数。
我们将从这个章节开始,通过一个例子向您展示您如何使代码更具灵活性,以适应不断变化的需求。基于这个知识,我们展示了如何为几个现实世界的例子使用行为参数化。例如,您可能已经使用过行为参数化模式,使用 Java API 中的现有类和接口来排序List,过滤文件名,或者告诉Thread执行代码块,甚至执行 GUI 事件处理。您很快就会意识到这个模式在 Java 中历史性地很冗长。从 Java 8 开始的 Lambda 表达式解决了冗长的问题。我们将在第三章中展示如何构造 Lambda 表达式,在哪里使用它们,以及如何通过采用它们使您的代码更简洁。
2.1. 应对变化需求
编写能够应对变化需求的代码是困难的。让我们通过一个我们将逐步改进的例子来探讨,展示一些使您的代码更具灵活性的最佳实践。在农场库存应用程序的背景下,您必须实现一个从列表中过滤绿色苹果的功能。听起来很简单,对吧?
2.1.1. 第一次尝试:过滤绿色苹果
假设,如第一章中所述,您有一个Color枚举可用,用于表示苹果的不同颜色:
enum Color { RED, GREEN }
一个可能的解决方案可能是以下这样:
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>(); *1*
for(Apple apple: inventory){
if( GREEN.equals(apple.getColor() ) { *2*
result.add(apple);
}
}
return result;
}
-
1 苹果的累加列表
-
2 仅选择绿色苹果
突出的行显示了选择绿色苹果所需的条件。您可以假设您有一个包含一组颜色,如GREEN的Color枚举。但现在农民改变了主意,想要过滤红色苹果。您能做什么?一个简单的方法是复制您的函数,将其重命名为filterRedApples,并将if条件更改为匹配红色苹果。然而,如果农民想要多种颜色,这种方法并不能很好地处理变化。一个好的原则是:当你发现自己正在编写几乎重复的代码时,尝试进行抽象。
2.1.2. 第二次尝试:参数化颜色
我们如何避免在filterGreenApples中复制大部分代码来创建filter-RedApples?为了参数化颜色并使代码更灵活以适应此类变化,您可以在方法中添加一个参数:
public static List<Apple> filterApplesByColor(List<Apple> inventory,
Color color) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if ( apple.getColor().equals(color) ) {
result.add(apple);
}
}
return result;
}
现在,您可以让农民满意,并按如下方式调用您的函数:
List<Apple> greenApples = filterApplesByColor(inventory, GREEN);
List<Apple> redApples = filterApplesByColor(inventory, RED);
...
太简单了,对吧?让我们稍微复杂化一下例子。农民回到你那里说:“区分轻苹果和重苹果真的很酷。重苹果的重量通常大于 150 克。”
穿上你的软件工程帽子,你事先意识到农民可能想要改变重量。因此,你创建以下方法来通过一个额外的参数处理各种重量:
public static List<Apple> filterApplesByWeight(List<Apple> inventory,
int weight) {
List<Apple> result = new ArrayList<>();
For (Apple apple: inventory){
if ( apple.getWeight() > weight ) {
result.add(apple);
}
}
return result;
}
这是一个不错的解决方案,但请注意,你必须复制大部分遍历库存和将过滤标准应用于每个苹果的实现。这有点令人失望,因为它打破了软件工程的 DRY(不要重复自己)原则。如果你想要改变过滤遍历来提高性能,你现在必须修改所有方法的实现,而不仅仅是单个方法。从工程努力的角度来看,这是昂贵的。
你可以将颜色和重量合并到一个名为filter的方法中。但这样你仍然需要一个方法来区分你想要过滤的属性。你可以添加一个标志来区分颜色和重量查询。(但永远不要这样做!我们很快就会解释原因。)
2.1.3. 第三次尝试:使用你能想到的所有属性进行过滤
尝试将所有属性合并的丑陋方法可能如下所示:
public static List<Apple> filterApples(List<Apple> inventory, Color color,
int weight, boolean flag) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if ( (flag && apple.getColor().equals(color)) ||
(!flag && apple.getWeight() > weight) ){ *1*
result.add(apple);
}
}
return result;
}
- 1 选择颜色或重量的丑陋方式
你可以这样使用(但看起来很丑):
List<Apple> greenApples = filterApples(inventory, GREEN, 0, true);
List<Apple> heavyApples = filterApples(inventory, null, 150, false);
...
这个解决方案非常糟糕。首先,客户端代码看起来很糟糕。true和false代表什么?此外,这个解决方案不太适应不断变化的需求。如果农民要求你根据苹果的不同属性进行过滤,例如大小、形状、产地等,怎么办?此外,如果农民要求你进行更复杂的查询,结合属性,例如既绿色又重的苹果,怎么办?你将要么有多个重复的filter方法,要么有一个极其复杂的方法。到目前为止,你已经用String、Integer、枚举类型或boolean等值对filterApples方法进行了参数化。这对于某些定义明确的问题来说可能很好。但在这个情况下,你需要一种更好的方式来告诉filterApples方法苹果的选择标准。在下一节中,我们将描述如何利用行为参数化来实现这种灵活性。
2.2. 行为参数化
你在前一节中看到,你需要一种比添加大量参数更好的方法来应对不断变化的需求。让我们退一步,找到一个更好的抽象层次。一个可能的解决方案是模拟你的选择标准:你正在处理苹果,并根据Apple的一些属性返回一个boolean。例如,它是绿色的吗?它的重量是否超过 150 克?我们称这为谓词(返回boolean的函数)。因此,让我们定义一个接口来模拟选择标准:
public interface ApplePredicate{
boolean test (Apple apple);
}
你现在可以声明多个ApplePredicate的实现,以表示不同的选择标准,如下所示(并在图 2.1 中说明):
图 2.1. 选择Apple的不同策略

public class AppleHeavyWeightPredicate implements ApplePredicate { *1*
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate { *2*
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
-
1 只选择重苹果
-
2 只选择绿色苹果
你可以将这些标准视为 filter 方法的不同行为。你所做的与策略设计模式(见 en.wikipedia.org/wiki/Strategy_pattern)相关,它允许你定义一组算法,封装每个算法(称为策略),并在运行时选择一个算法。在这种情况下,算法组是 ApplePredicate,不同的策略是 AppleHeavyWeightPredicate 和 AppleGreenColorPredicate。
但你如何利用 ApplePredicate 的不同实现呢?你需要让 filterApples 方法接受 ApplePredicate 对象来测试 Apple 上的条件。这就是行为参数化的含义:告诉方法接受多种行为(或策略)作为参数,并在内部使用它们来实现不同的行为。
要在运行示例中实现这一点,你需要在 filterApples 方法中添加一个参数来接受 ApplePredicate 对象。这带来了巨大的软件工程效益:你现在可以分离 filter-Apples 方法内部迭代的集合逻辑与应用于集合每个元素的行为(在这种情况下是一个谓词)。
2.2.1. 第四次尝试:通过抽象标准进行过滤
我们修改后的使用 ApplePredicate 的 filter 方法看起来是这样的:
public static List<Apple> filterApples(List<Apple> inventory,
ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for(Apple apple: inventory) {
if(p.test(apple)) { *1*
result.add(apple);
}
}
return result;
}
- 1 判定
p封装了对苹果进行测试的条件。
传递代码/行为
值得停下来稍作庆祝。这段代码比我们的第一次尝试更加灵活,但同时也易于阅读和使用!你现在可以创建不同的 ApplePredicate 对象并将它们传递给 filterApples 方法。自由灵活性!例如,如果农民要求你找到所有重量超过 150 克的红苹果,你所需要做的就是创建一个实现了 ApplePredicate 的类。你的代码现在足够灵活,可以应对任何涉及 Apple 属性的要求变化:
public class AppleRedAndHeavyPredicate implements ApplePredicate {
public boolean test(Apple apple){
return RED.equals(apple.getColor())
&& apple.getWeight() > 150;
}
}
List<Apple> redAndHeavyApples =
filterApples(inventory, new AppleRedAndHeavyPredicate());
你已经实现了一些酷炫的功能;filterApples 方法的行为取决于你通过 ApplePredicate 对象传递给它的代码。你已经参数化了 filterApples 方法的行为!
注意,在先前的例子中,唯一重要的代码是test方法的实现,如图 2.2 所示;这是定义filterApples方法新行为的地方。不幸的是,因为filterApples方法只能接受对象,你必须将这段代码包裹在一个ApplePredicate对象中。你所做的是类似于内联传递代码,因为你通过实现test方法的对象传递了一个boolean表达式。你将在第 2.3 节(以及在第三章中更详细地)看到,通过使用 lambda 表达式,你可以直接将表达式RED.equals(apple.getColor()) && apple.getWeight() > 150传递给filterApples方法,而无需定义多个ApplePredicate类。这减少了不必要的冗余。
图 2.2. 参数化filterApples的行为并传递不同的过滤策略

多种行为,一个参数
如我们之前所解释的,行为参数化非常出色,因为它允许你将迭代集合以进行过滤的逻辑与应用于该集合每个元素的行为分开。因此,你可以重用相同的方法,并给它赋予不同的行为以实现不同的功能,如图 2.3 所示。这就是为什么行为参数化是一个你应该在你的工具集中拥有的有用概念,用于创建灵活的 API。
图 2.3. 参数化filterApples的行为并传递不同的过滤策略

为了确保你对行为参数化的概念感到舒适,尝试做练习 2.1!
练习 2.1:编写一个灵活的prettyPrintApple方法
编写一个prettyPrintApple方法,它接受一个Apple的List,并且可以通过多种方式参数化,从apple生成一个String输出(有点像多个定制的toString方法)。例如,你可以告诉你的pretty-PrintApple方法只打印每个苹果的重量。此外,你可以告诉你的prettyPrintApple方法逐个打印每个苹果,并说明它是重还是轻。解决方案与我们已经探索过的过滤示例类似。为了帮助你开始,我们提供了一个pretty-PrintApple方法的粗略框架:
public static void prettyPrintApple(List<Apple> inventory, ???) {
for(Apple apple: inventory) {
String output = ???.???(apple);
System.out.println(output);
}
}
答案:
首先,你需要一种方式来表示一个接受Apple并返回格式化String结果的行为。你在创建Apple-Predicate接口时做过类似的事情:
public interface AppleFormatter {
String accept(Apple a);
}
你现在可以通过实现Apple-Formatter接口来表示多个格式化行为:
public class AppleFancyFormatter implements AppleFormatter {
public String accept(Apple apple) {
String characteristic = apple.getWeight() > 150 ? "heavy" : "light";
return "A " + characteristic +
" " + apple.getColor() +" apple";
}
}
public class AppleSimpleFormatter implements AppleFormatter {
public String accept(Apple apple) {
return "An apple of " + apple.getWeight() + "g";
}
}
最后,你需要告诉你的prettyPrintApple方法接受AppleFormatter对象并内部使用它们。你可以通过向pretty-PrintApple添加一个参数来实现这一点:
public static void prettyPrintApple(List<Apple> inventory,
AppleFormatter formatter) {
for(Apple apple: inventory) {
String output = formatter.accept(apple);
System.out.println(output);
}
}
Bingo!你现在能够将多个行为传递给 prettyPrintApple 方法。你是通过实例化 AppleFormatter 的实现并将它们作为参数传递给 prettyPrintApple 来做到这一点的:
prettyPrintApple(inventory, new AppleFancyFormatter());
这将产生类似以下输出的结果
A light green apple
A heavy red apple
...
或者试试这个:
prettyPrintApple(inventory, new AppleSimpleFormatter());
这将产生类似以下输出的结果
An apple of 80g
An apple of 155g
...
你已经看到你可以抽象行为并使你的代码适应需求变化,但这个过程很冗长,因为你需要声明多个你只实例化一次的类。让我们看看如何改进这一点。
2.3. 解决冗余
我们都知道,使用起来不方便的功能或概念会被避免。目前,当你想要将新行为传递给 filterApples 方法时,你必须声明几个实现 ApplePredicate 接口的类,然后实例化几个你只分配一次的 ApplePredicate 对象,如下列所示,总结了到目前为止你所看到的。这涉及到很多冗余,并且是一个耗时过程!
列表 2.1. 行为参数化:使用谓词过滤苹果
public class AppleHeavyWeightPredicate implements ApplePredicate { *1*
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate { *2*
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
public class FilteringApples {
public static void main(String...args) {
List<Apple> inventory = Arrays.asList(new Apple(80, GREEN),
new Apple(155, GREEN),
new Apple(120, RED));
List<Apple> heavyApples =
filterApples(inventory, new AppleHeavyWeightPredicate()); *3*
List<Apple> greenApples =
filterApples(inventory, new AppleGreenColorPredicate()); *4*
}
public static List<Apple> filterApples(List<Apple> inventory,
ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)){
result.add(apple);
}
}
return result;
}
}
-
1 选择重苹果
-
2 选择绿色苹果
-
3 结果是一个包含一个 155 克苹果的列表
-
4 结果是一个包含两个绿色苹果的列表
这是不必要的开销。你能做得更好吗?Java 有称为 匿名类 的机制,它允许你同时声明和实例化一个类。它们使你能够通过使代码更加简洁来进一步提高你的代码。但它们并不完全令人满意。第 2.3.3 节 预览了下一章,简要介绍了 lambda 表达式如何使你的代码更易读。
2.3.1. 匿名类
匿名类 就像你在 Java 中已经熟悉的局部类(在代码块中定义的类)。但匿名类没有名字。它们允许你同时声明和实例化一个类。简而言之,它们允许你创建临时的实现。
2.3.2. 第五次尝试:使用匿名类
以下代码展示了如何通过创建一个实现 ApplePredicate 的对象来使用匿名类重写过滤示例:
List<Apple> redApples = filterApples(inventory, new ApplePredicate() { *1*
public boolean test(Apple apple){
return RED.equals(apple.getColor());
}
});
- 1 使用匿名类参数化方法 filterApples 的行为。
匿名类常用于 GUI 应用程序的上下文中,用于创建事件处理对象。我们不想唤起 Swing 的痛苦记忆,但以下是在实践中常见的模式(这里使用 JavaFX API,Java 的现代 UI 平台):
button.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) {
System.out.println("Whoooo a click!!");
}
});
但匿名类仍然不够好。首先,它们往往很庞大,因为它们占用了很多空间,正如这里使用加粗的代码所展示的,使用了之前相同的两个示例:
List<Apple> redApples = filterApples(inventory, new ApplePredicate() { *1*
public boolean test(Apple a){
return RED.equals(a.getColor());
}
});
button.setOnAction(new EventHandler<ActionEvent>() { *1*
public void handle(ActionEvent event) {
System.out.println("Whoooo a click!!");
}
- 1 大量样板代码
第二,许多程序员发现它们难以使用。例如,练习 2.2 展示了一个经典的 Java 难题,它会让大多数程序员措手不及!试试你的手。
练习 2.2:匿名类难题
当这段代码执行时,输出将会是 4、5、6 还是 42?
public class MeaningOfThis {
public final int value = 4;
public void doIt() {
int value = 6;
Runnable r = new Runnable() {
public final int value = 5;
public void run(){
int value = 10;
System.out.println(this.value);
}
};
r.run();
}
public static void main(String...args) {
MeaningOfThis m = new MeaningOfThis();
m.doIt(); *1*
}
}
- 1 这行代码的输出是什么?
答案:
答案是 5,因为 this 指的是封装的 Runnable,而不是封装的类 MeaningOfThis。
通常来说,冗长性是坏事;它因为编写和维护冗长的代码需要很长时间,而且阅读起来不愉快,所以会阻碍语言特性的使用!好的代码应该一眼就能理解。尽管匿名类在一定程度上解决了为接口声明多个具体类所关联的冗长性,但它们仍然不尽如人意。在传递简单代码片段(例如,表示选择标准的 boolean 表达式)的上下文中,你仍然需要创建一个对象并显式实现一个方法来定义新的行为(例如,Predicate 的 test 方法或 EventHandler 的 handle 方法)。
理想情况下,我们希望鼓励程序员使用行为参数化模式,因为正如你所看到的,它使你的代码更能适应需求变化。在第三章中,你将看到 Java 8 语言设计者通过引入 lambda 表达式,一种更简洁的传递代码的方式,解决了这个问题。悬念已经足够;以下是对 lambda 表达式如何帮助你追求干净代码的简要预览。
2.3.3. 第六次尝试:使用 lambda 表达式
之前的代码可以用以下方式在 Java 8 中使用 lambda 表达式重写:
List<Apple> result =
filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));
你必须承认,这段代码看起来比我们之前的尝试干净得多!它很棒,因为它开始看起来与问题陈述非常接近。我们现在已经解决了冗长性问题。图 2.4 总结了到目前为止的旅程。
图 2.4. 行为参数化与值参数化

2.3.4. 第七次尝试:在 List 类型上抽象
在你向抽象化迈进的过程中,你还可以做一步。目前,filterApples 方法只适用于 Apple。但你也可以对 List 类型进行抽象,从而超越你思考的问题域,如下所示:
public interface Predicate<T> {
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) { *1*
List<T> result = new ArrayList<>();
for(T e: list) {
if(p.test(e)) {
result.add(e);
}
}
return result;
}
- 1 引入类型参数 T
你现在可以使用 filter 方法与香蕉、橙子、Integer 或 String 的 List!以下是一个使用 lambda 表达式的示例:
List<Apple> redApples =
filter(inventory, (Apple apple) -> RED.equals(apple.getColor()));
List<Integer> evenNumbers =
filter(numbers, (Integer i) -> i % 2 == 0);
这不酷吗?你成功地找到了灵活性和简洁性之间的甜点,这在 Java 8 之前是不可能的!
2.4. 现实世界示例
你现在已经看到,行为参数化是一种有用的模式,可以轻松适应不断变化的需求。此模式允许你封装一个行为(一段代码),并通过传递和使用你创建的行为(例如,为Apple的不同谓词)来参数化方法的行为。我们之前提到,这种方法类似于策略设计模式。你可能已经在实践中使用过这种模式。Java API 中的许多方法可以用不同的行为进行参数化。这些方法通常与匿名类一起使用。我们展示了四个示例,这些示例应该会巩固你传递代码的想法:使用Comparator进行排序、使用Runnable执行代码块、使用Callable从任务返回结果以及 GUI 事件处理。
2.4.1. 使用 Comparator 进行排序
对集合进行排序是一个常见的编程任务。例如,假设你的农民想要你根据苹果的重量对库存进行排序。或者,也许他改变了主意,想要你按颜色对苹果进行排序。听起来熟悉吗?是的,你需要一种方式来表示和使用不同的排序行为,以便轻松适应不断变化的需求。
从 Java 8 开始,List自带了一个sort方法(你也可以使用Collections.sort)。sort的行为可以使用java.util.Comparator对象进行参数化,该对象具有以下接口:
// java.util.Comparator
public interface Comparator<T> {
int compare(T o1, T o2);
}
因此,你可以通过创建一个专门实现的Comparator来为sort方法创建不同的行为。例如,你可以使用它来按增加的重量对库存进行排序,使用匿名类:
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
如果农民改变了他对如何排序苹果的想法,你可以创建一个专门匹配新要求的Comparator并将其传递给sort方法。如何排序的内部细节被抽象掉了。使用 lambda 表达式,它将如下所示:
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
再次,现在不必担心这个新的语法;下一章将详细介绍如何编写和使用 lambda 表达式。
2.4.2. 使用 Runnable 执行代码块
Java 线程允许代码块与程序的其他部分并发执行。但你怎么告诉一个线程它应该运行哪个代码块?可能有几个线程各自运行不同的代码。你需要一种方式来表示稍后要执行的代码。在 Java 8 之前,只能将对象传递给Thread构造函数,因此典型的笨拙使用模式是将包含返回void(无结果)的run方法的匿名类传递给Thread构造函数。这样的匿名类实现了Runnable接口。
在 Java 中,你可以使用Runnable接口来表示要执行的代码块;请注意,代码返回void(无结果):
// java.lang.Runnable
public interface Runnable {
void run();
}
你可以使用此接口创建具有你选择的行为的线程,如下所示:
Thread t = new Thread(new Runnable() {
public void run() {
System.out.println("Hello world");
}
});
但自从 Java 8 以来,你可以使用 lambda 表达式,因此对Thread的调用将如下所示:
Thread t = new Thread(() -> System.out.println("Hello world"));
2.4.3. 使用 Callable 返回结果
你可能熟悉 Java 5 中引入的ExecutorService抽象。与使用线程和Runnable相比,ExecutorService接口解耦了任务提交和执行的方式。与使用线程和Runnable相比,ExecutorService的好处是,通过使用ExecutorService,你可以将任务发送到线程池,并将结果存储在Future中。不用担心这对你来说不熟悉,我们将在后续章节中详细讨论并发时再次回到这个话题。现在,你需要知道的是,Callable接口用于模拟返回结果的任务。你可以将其视为升级版的Runnable:
// java.util.concurrent.Callable
public interface Callable<V> {
V call();
}
你可以通过提交任务到执行服务来使用它。在这里,你返回负责执行任务的Thread的名称:
ExecutorService executorService = Executors.newCachedThreadPool();
Future<String> threadName = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
return Thread.currentThread().getName();
}
});
使用 lambda 表达式,这段代码可以简化为以下形式:
Future<String> threadName = executorService.submit(
() -> Thread.currentThread().getName());
2.4.4. GUI 事件处理
在图形用户界面编程中,一个典型的模式是在响应某些事件时执行一个动作,例如点击或悬停在文本上。例如,如果用户点击发送按钮,你可能希望显示一个弹出窗口或者记录这个动作到一个文件中。再次强调,你需要一种应对变化的方法;你应该能够执行任何响应。在 JavaFX 中,你可以使用EventHandler通过将其传递给setOnAction来表示对事件的响应:
Button button = new Button("Send");
button.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) {
label.setText("Sent!!");
}
});
在这里,setOnAction方法的行为通过EventHandler对象进行参数化。使用 lambda 表达式,它看起来如下:
button.setOnAction((ActionEvent event) -> label.setText("Sent!!"));
概述
-
行为参数化是方法能够接受多种不同的行为作为参数,并在内部使用它们来实现不同的行为的能力。
-
行为参数化使你的代码更能适应不断变化的需求,并在未来节省工程努力。
-
将代码作为参数传递是一种将新的行为作为参数传递给方法的方式。但在 Java 8 之前,这种方式比较冗长。匿名类在 Java 8 之前有所帮助,可以减少为仅需要一次的接口声明多个具体类时的冗长。
-
Java API 包含许多可以参数化不同行为的方法,包括排序、线程和 GUI 处理。
第三章。Lambda 表达式
本章涵盖
-
简而言之,lambda
-
lambda 的使用位置和方式
-
执行环绕模式
-
函数式接口,类型推断
-
方法引用
-
组合 lambda
在上一章中,你看到使用行为参数化传递代码对于应对代码中频繁的需求变化是有用的。它允许你定义一个表示行为的代码块,然后将其传递。你可以决定在某个事件发生时(例如,按钮点击)或在算法的某些点上(例如,过滤算法中的“只有重量超过 150 克的苹果”谓词或排序中的自定义比较操作)运行该代码块。一般来说,使用这个概念,你可以编写更灵活和可重用的代码。
但你看到使用匿名类来表示不同的行为是不令人满意的。它很冗长,这并不鼓励程序员在实际中采用行为参数化。在本章中,我们将向你介绍 Java 8 中的一个新特性,它解决了这个问题:lambda 表达式。它们允许你以简洁的方式表示行为或传递代码。目前你可以将 lambda 表达式视为匿名函数,即没有声明名称的方法,但也可以像匿名类一样将其作为方法参数传递。
我们将展示如何构建它们,在哪里使用它们,以及如何通过使用它们来使你的代码更简洁。我们还解释了一些新的特性,如类型推断和 Java 8 API 中可用的新的重要接口。最后,我们介绍了方法引用,这是一个与 lambda 表达式相辅相成的新特性。
本章的组织方式旨在逐步教你如何编写更简洁和灵活的代码。在本章结束时,我们将所有教授的概念结合到一个具体的例子中;我们采用第二章中展示的排序示例,并逐步使用 lambda 表达式和方法引用来改进它,使其更简洁、更易读。本章本身很重要,而且因为你将在整本书中广泛使用 lambda 表达式。
3.1. Lambda 简述
Lambda 表达式可以理解为一种匿名函数的简洁表示,它可以被传递。它没有名称,但它有一个参数列表、一个主体、一个返回类型,也可能有一个可以抛出的异常列表。这是一个很大的定义;让我们将其分解:
-
匿名—**我们称之为“匿名”,因为它没有像方法那样具有显式的名称;写起来更少,思考起来也更少!
-
函数—**我们称之为“函数”,因为 lambda 并不与特定的类相关联,就像方法一样。但像方法一样,lambda 有一个参数列表、一个主体、一个返回类型,以及可能抛出的异常列表。
-
传递—**Lambda 表达式可以作为方法参数传递或存储在变量中。
-
简洁—**你不需要像匿名类那样编写很多样板代码。
如果你想知道术语lambda的来源,它起源于一个在学术界开发的系统,称为lambda calculus,用于描述计算。
你为什么应该关注 lambda 表达式?你在上一章中看到,在 Java 中传递代码目前既繁琐又冗长。好消息是!Lambdas 解决了这个问题;它们让你以简洁的方式传递代码。从技术上讲,Lambdas 并没有让你做任何在 Java 8 之前做不到的事情。但你不再需要编写使用匿名类的笨拙代码来从行为参数化中受益!Lambda 表达式将鼓励你采用我们在上一章中描述的行为参数化风格。最终结果是,你的代码将更加清晰和灵活。例如,使用 lambda 表达式,你可以以更简洁的方式创建一个自定义的Comparator对象。
在使用之前:
Comparator<Apple> byWeight = new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
};
在使用 lambda 表达式之后:
Comparator<Apple> byWeight =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
你必须承认代码看起来更清晰!如果你觉得 lambda 表达式的所有部分现在还不明白,不用担心;我们很快就会解释所有这些部分。现在,请注意,你实际上只传递了比较两个苹果重量的所需代码。这看起来像是传递了compare方法体的内容。你很快就会学到你可以进一步简化你的代码。我们将在下一节中解释你可以在哪里以及如何使用 lambda 表达式。
我们刚刚展示的 lambda 有三个部分,如图 3.1 所示:
图 3.1. lambda 表达式由参数、箭头和体组成。

-
参数列表—— 在这种情况下,它反映了
Comparator的compare方法的参数——两个Apple。 -
箭头—— 箭头
->将参数列表与 lambda 体分开。 -
lambda 体的内容—— 使用它们的重量比较两个
Apple。表达式被认为是 lambda 的返回值。
为了进一步说明,以下列表显示了 Java 8 中五个有效的 lambda 表达式示例。
列表 3.1. Java 8 中的有效 lambda 表达式
(String s) -> s.length() *1*
(Apple a) -> a.getWeight() > 150 *2*
(int x, int y) -> {
System.out.println("Result:");
System.out.println(x + y);
} *3*
() -> 42 *4*
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()) *5*
-
1 接受一个类型为 String 的参数,并返回一个 int。它没有返回语句,因为返回是隐含的。
-
2 接受一个类型为 Apple 的参数,并返回一个布尔值(苹果是否重于 150 克)。
-
3 接受两个类型为 int 的参数,不返回任何值(void 返回)。它的体包含两个语句。
-
4 不接受任何参数,返回 int 42
-
5 接受两个类型为 Apple 的参数,并返回一个表示它们重量比较的 int
这种语法是由 Java 语言设计者选择的,因为它在其他语言中得到了很好的反响,例如 C#和 Scala。JavaScript 有类似的语法。lambda 的基本语法是(称为表达式式lambda)
(parameters) -> expression
或(注意语句的括号,这种 lambda 通常被称为块式lambda)
(parameters) -> { statements; }
如你所见,lambda 表达式遵循简单的语法。通过练习 3.1 应该会让你知道你是否理解了这个模式。
练习 3.1:Lambda 语法
根据刚刚展示的语法规则,以下哪些不是有效的 lambda 表达式?
-
() -> {} -
() -> "Raoul" -
() -> { return "Mario"; } -
(Integer i) -> return "Alan" + i; -
(String s) -> { "Iron Man"; }
答案:
4 和 5 不是有效的 lambda 表达式;其余的都是有效的。详情:
-
这个 lambda 没有参数,并返回
void。它类似于一个空体的方法:public void run() { }。有趣的事实:这通常被称为汉堡 lambda。从侧面看看它,你会发现它有两个面包的形状,像汉堡。 -
这个 lambda 没有参数,并返回一个
String作为表达式。 -
这个 lambda 没有参数,并返回一个
String(使用显式的返回语句,在块中)。 -
return是一个控制流语句。为了使这个 lambda 有效,需要花括号,如下所示:(Integer i) -> { return "Alan" + i; }。 -
“钢铁侠”是一个表达式,不是一个语句。为了使这个 lambda 有效,你可以像下面这样移除花括号和分号:
(String s) -> "Iron Man"。或者如果你更喜欢,你可以使用显式的返回语句,如下所示:(String s) -> { return "Iron Man"; }。
表 3.1 这提供了一个带有用例示例的 lambda 表达式列表。
表 3.1. lambda 表达式示例
| 用例 | lambda 表达式示例 |
|---|---|
| 一个布尔表达式 | (List |
| 创建对象 | () -> new Apple(10) |
| 从对象中消费 | (Apple a) -> { System.out.println(a.getWeight()); }
} |
| 从对象中选择/提取 | (String s) -> s.length() |
|---|---|
| 合并两个值 | (int a, int b) -> a * b |
| 比较两个对象 | (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()) |
3.2. 哪里以及如何使用 lambda 表达式
你可能现在想知道你可以在哪里使用 lambda 表达式。在之前的例子中,你将一个 lambda 赋值给了一个类型为Comparator<Apple>的变量。你也可以使用另一个 lambda,这是你在上一章中实现的filter方法:
List<Apple> greenApples =
filter(inventory, (Apple a) -> GREEN.equals(a.getColor()));
你在哪里可以使用 lambda 表达式?你可以在函数式接口的上下文中使用 lambda 表达式。在下面的代码中,你可以将一个 lambda 作为filter方法的第二个参数传递,因为该方法期望一个类型为Predicate<T>的对象,这是一个函数式接口。不用担心这听起来很抽象;我们现在将详细解释这意味着什么以及什么是函数式接口。
3.2.1. 函数式接口
记得你在第二章中创建的Predicate<T>接口,以便你可以参数化filter方法的行为?它是一个函数式接口!为什么?因为Predicate只指定了一个抽象方法:
public interface Predicate<T> {
boolean test (T t);
}
简而言之,功能接口是一个指定恰好一个抽象方法的接口。你已经在 Java API 中知道了几个其他功能接口,如Comparator和Runnable,我们在第二章中探讨了这些接口:
public interface Comparator<T> { *1*
int compare(T o1, T o2);
}
public interface Runnable { *2*
void run();
}
public interface ActionListener extends EventListener { *3*
void actionPerformed(ActionEvent e);
}
public interface Callable<V> { *4*
V call() throws Exception;
}
public interface PrivilegedAction<T> { *5*
T run();
}
-
1 java.util.Comparator
-
2 java.lang.Runnable
-
3 java.awt.event.ActionListener
-
4 java.util.concurrent.Callable
-
5 java.security.PrivilegedAction
注意
你会在第十三章中看到,接口现在也可以有默认方法(一个具有主体并提供某些默认实现的方法,以防类没有实现该方法)。只要它指定仅一个抽象方法,接口仍然是功能接口。
为了检验你的理解,练习 3.2 应该让你知道你是否掌握了功能接口的概念。
练习 3.2:功能接口
以下哪些接口是功能接口?
public interface Adder {
int add(int a, int b);
}
public interface SmartAdder extends Adder {
int add(double a, double b);
}
public interface Nothing {
}
答案:
只有Adder是功能接口。
SmartAdder不是一个功能接口,因为它指定了两个名为add的抽象方法(其中一个是从Adder继承的)。
Nothing不是一个功能接口,因为它根本不声明任何抽象方法。
你可以用功能接口做什么?Lambda 表达式让你可以直接内联提供功能接口的抽象方法实现,并将整个表达式视为功能接口的一个实例(更技术地说,是功能接口的一个具体实现的实例)。你也可以用匿名内部类实现相同的功能,尽管这样做比较笨拙:你提供实现并直接内联实例化。以下代码是有效的,因为Runnable是一个只定义了一个抽象方法run的功能接口:
Runnable r1 = () -> System.out.println("Hello World 1"); *1*
Runnable r2 = new Runnable() { *2*
public void run() {
System.out.println("Hello World 2");
}
};
public static void process(Runnable r) {
r.run();
}
process(r1); *3*
process(r2); *4*
process(() -> System.out.println("Hello World 3")); *5*
-
1 使用 Lambda 表达式
-
2 使用匿名内部类
-
3 打印“Hello World 1”
-
4 打印“Hello World 2”
-
5 使用 Lambda 表达式直接传递打印“Hello World 3”
3.2.2. 函数描述符
功能接口的抽象方法签名描述了 Lambda 表达式的签名。我们称这个抽象方法为函数描述符。例如,Runnable接口可以被视为一个接受无参数并返回无参数(void)的函数签名,因为它只有一个名为run的抽象方法,它接受无参数并返回无参数(void)。^([1])
¹
一些语言,如 Scala,在其类型系统中提供显式的类型注解来描述函数的类型(称为函数类型)。Java 重用功能接口提供的现有命名类型,并在幕后将它们映射为函数类型的形式。
我们在本章中使用了特殊的符号来描述 lambda 表达式和函数式接口的签名。符号() -> void表示一个没有参数列表且返回void的函数。这正是Runnable接口所表示的。作为另一个例子,(Apple, Apple) -> int表示一个接受两个Apple作为参数并返回int的函数。我们将在本章后面的第 3.4 节和表 3.2 中提供更多关于函数描述符的信息。
你可能已经在想 lambda 表达式是如何进行类型检查的。我们将在第 3.5 节中详细说明编译器是如何检查一个 lambda 表达式在特定上下文中是否有效的。现在,只需理解一个 lambda 表达式可以被赋值给一个变量或者传递给期望一个函数式接口作为参数的方法,前提是这个 lambda 表达式的签名与函数式接口的抽象方法相同。例如,在我们之前的例子中,你可以直接将一个 lambda 表达式传递给process方法,如下所示:
public void process(Runnable r) {
r.run();
}
process(() -> System.out.println("This is awesome!!"));
当这段代码执行时,将打印出“这是惊人的!!”这个 lambda 表达式() -> System.out.println("This is awesome!!")不接受任何参数并返回void。这正是Runnable接口中定义的run方法的签名。
Lambda 表达式和 void 方法调用
虽然这可能会感觉有些奇怪,但以下 lambda 表达式是有效的:
process(() -> System.out.println("This is awesome"));
最后,System.out.println返回void,所以这显然不是一个表达式!为什么我们不需要像这样用大括号括起主体呢?
process(() -> { System.out.println("This is awesome"); });
事实上,Java 语言规范中定义了一个特殊的规则,用于 void 方法调用。你不需要将单个 void 方法调用用大括号括起来。
你可能想知道,“为什么我们只能在期望函数式接口的地方传递 lambda 表达式?”语言设计者考虑了其他方法,例如添加函数类型(有点像我们用来描述 lambda 表达式签名的特殊符号——我们将在第二十章和第二十一章中重新讨论这个话题)到 Java 中。但他们选择了这种方式,因为它自然地适应了语言,而没有增加语言的复杂性。此外,大多数 Java 程序员已经熟悉只有一个抽象方法的接口的概念(例如,用于事件处理)。然而,最重要的原因是函数式接口在 Java 8 之前已经被广泛使用。这意味着它们为使用 lambda 表达式提供了一个很好的迁移路径。实际上,如果你一直在使用Comparator和Runnable这样的函数式接口,或者甚至是你自己的只定义了一个抽象方法的接口,你现在可以使用 lambda 表达式而不需要更改你的 API。尝试练习 3.3 来测试你对 lambda 表达式可用位置的了解。
练习 3.3:你可以在哪里使用 lambda 表达式?
以下哪些是 lambda 表达式的有效用法?
-
execute(() -> {}); public void execute(Runnable r) { r.run(); } -
public Callable<String> fetch() { return () -> "Tricky example ;-)"; } -
Predicate<Apple> p = (Apple a) -> a.getWeight();
答案:
只有 1 和 2 是有效的。
第一个示例是有效的,因为 lambda 表达式 () -> {} 的签名是 () -> void,这与在 Runnable 中定义的抽象方法 run 的签名相匹配。请注意,运行此代码将不会做任何事情,因为 lambda 表达式的主体是空的!
第二个示例也是有效的。实际上,方法 fetch 的返回类型是 Callable<String>。当 T 被替换为 String 时,Callable<String> 定义了一个具有签名 () -> String 的方法。因为 lambda 表达式 () -> "Tricky example ;-)" 的签名是 () -> String,所以这个 lambda 表达式可以用于这个上下文中。
第三个示例是无效的,因为 lambda 表达式 (Apple a) -> a.getWeight() 的签名是 (Apple) -> Integer,这与在 Predicate<Apple> 中定义的方法 test 的签名不同:(Apple) -> boolean。
关于 @FunctionalInterface 呢?
如果你探索新的 Java API,你会注意到功能接口通常都带有 @FunctionalInterface. 注解(我们在 第 3.4 节 中展示了详尽的列表,其中我们深入探讨了如何使用功能接口。)这个注解用于指示该接口旨在作为功能接口,因此对文档很有用。此外,如果你使用 @FunctionalInterface 注解定义了一个接口,但该接口不是功能接口,编译器将返回一个有意义的错误。例如,错误信息可能是“接口 Foo 中发现多个非覆盖的抽象方法”,这表明有多个抽象方法可用。请注意,@FunctionalInterface 注解不是强制的,但在设计接口用于此目的时使用它是良好的实践。你可以将其视为表示方法被重写的 @Override 注解。
3.3. 将 lambda 表达式应用于实践:执行周围模式
让我们看看 lambda 表达式与行为参数化结合使用的一个示例,如何在实践中使用它们使代码更加灵活和简洁。在资源处理(例如,处理文件或数据库)中,一个常见的模式是打开资源,对其进行一些处理,然后关闭资源。设置和清理阶段总是相似的,并围绕着执行处理的重要代码。这被称为 执行周围 模式,如图 3.2 所示。例如,在下面的代码中,突出显示的行显示了从文件中读取一行所需的样板代码(注意,你还使用了 Java 7 的 try-with-resources 语句,这已经简化了代码,因为你不需要显式关闭资源):
图 3.2. 任务 A 和 B 被负责准备/清理的样板代码所包围。

public String processFile() throws IOException {
try (BufferedReader br =
new BufferedReader(new FileReader("data.txt"))) {
return br.readLine(); *1*
}
}
- 1 这是执行有用工作的行。
3.3.1. 第 1 步:记住行为参数化
这段当前代码有限。您只能读取文件的第一行。如果您想返回前两行,甚至是最常用的单词怎么办?理想情况下,您希望重用设置和清理的代码,并告诉 processFile 方法对文件执行不同的操作。这听起来熟悉吗?是的,您需要参数化 processFile 的行为。您需要一种方式将行为传递给 processFile,以便它可以使用 BufferedReader 执行不同的行为。
传递行为正是 lambda 表达式的用途。如果您想一次读取两行,新的 processFile 方法应该是什么样子?您需要一个接受 BufferedReader 并返回 String 的 lambda 表达式。例如,以下是如何打印 BufferedReader 的两行:
String result
= processFile((BufferedReader br) -> br.readLine() + br.readLine());
3.3.2. 第 2 步:使用函数式接口传递行为
我们之前解释过,lambda 表达式只能在函数式接口的上下文中使用。您需要创建一个与签名 BufferedReader -> String 匹配的接口,并且可能抛出 IOException。让我们称这个接口为 BufferedReaderProcessor:
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
您现在可以使用此接口作为您新 processFile 方法的参数:
public String processFile(BufferedReaderProcessor p) throws IOException {
...
}
3.3.3. 第 3 步:执行行为!
任何形式为 BufferedReader -> String 的 lambda 表达式都可以作为参数传递,因为它们与 BufferedReaderProcessor 接口中定义的 process 方法的签名相匹配。现在您只需要一种方式来执行 lambda 表达式在 processFile 方法体中代表的代码。记住,lambda 表达式允许您直接内联提供函数式接口抽象方法的实现,并且它们 将整个表达式视为函数式接口的一个实例。因此,您可以在 processFile 方法体内部调用 process 方法来执行处理:
public String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br =
new BufferedReader(new FileReader("data.txt"))) {
return p.process(br); *1*
}
}
- 1 处理 BufferedReader 对象
3.3.4. 第 4 步:传递 lambda 表达式
你现在可以重用 processFile 方法,并通过传递不同的 lambda 表达式以不同的方式处理文件。
以下展示了处理单行的示例:
String oneLine =
processFile((BufferedReader br) -> br.readLine());
以下展示了处理两行的示例:
String twoLines =
processFile((BufferedReader br) -> br.readLine() + br.readLine());
图 3.3 总结了使 processFile 方法更灵活所采取的四个步骤。
图 3.3. 应用执行环绕模式的四步过程

我们已经展示了如何使用函数式接口来传递 lambda 表达式。但您必须定义自己的接口。在下一节中,我们将探讨 Java 8 中添加的新接口,您可以使用这些接口重用来传递多个不同的 lambda 表达式。
3.4. 使用函数式接口
如你在 3.2.1 节 中所学,函数式接口指定了恰好一个抽象方法。函数式接口很有用,因为抽象方法的签名可以描述 lambda 表达式的签名。函数式接口中抽象方法的签名被称为 函数描述符。为了使用不同的 lambda 表达式,你需要一组可以描述常见函数描述符的函数式接口。Java API 中已经存在几个函数式接口,如 Comparable、Runnable 和 Callable,这些你在 3.2 节 中已经看到。
Java 8 的库设计者通过在 java.util.function 包内引入了几个新的函数式接口来帮助你。接下来我们将描述 Predicate、Consumer 和 Function 接口。更完整的列表可以在本节末尾的 表 3.2 中找到。
3.4.1. 断言
java.util.function.Predicate<T> 接口定义了一个名为 test 的抽象方法,它接受一个泛型类型 T 的对象并返回一个 boolean。它与之前你创建的那个完全相同,但它直接可用!当你需要表示使用类型 T 的对象的布尔表达式时,你可能想使用这个接口。例如,你可以定义一个接受 String 对象的 lambda,如下所示。
列表 3.2. 使用 Predicate
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
public <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> results = new ArrayList<>();
for(T t: list) {
if(p.test(t)) {
results.add(t);
}
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);
如果你查看 Predicate 接口的 Javadoc 规范,你可能会注意到额外的 and 和 or 方法。现在不必担心它们。我们将在 3.8 节 回到这些内容。
3.4.2. Consumer
java.util.function.Consumer<T> 接口定义了一个名为 accept 的抽象方法,它接受一个泛型类型 T 的对象并返回无结果(void)。当你需要访问类型 T 的对象并对它执行一些操作时,你可能使用这个接口。例如,你可以使用它来创建一个 forEach 方法,该方法接受一个 Integer 列表并对该列表的每个元素执行操作。在下面的列表中,你将使用这个 forEach 方法结合 lambda 来打印列表中的所有元素。
列表 3.3. 使用 Consumer
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
public <T> void forEach(List<T> list, Consumer<T> c) {
for(T t: list) {
c.accept(t);
}
}
forEach(
Arrays.asList(1,2,3,4,5),
(Integer i) -> System.out.println(i) *1*
);
- 1 Lambda 是 Consumer 接口中 accept 方法的实现。
3.4.3. Function
java.util.function.Function<T, R> 接口定义了一个名为 apply 的抽象方法,它接受一个泛型类型 T 的对象作为输入并返回一个泛型类型 R 的对象。当你需要定义一个将信息从输入对象映射到输出(例如,提取苹果的重量或将字符串映射到其长度)的 lambda 时,你可能使用这个接口。在下面的列表中,我们展示了如何使用它来创建一个 map 方法,将 String 列表转换为包含每个 String 长度的 Integer 列表。
列表 3.4. 使用 Function
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
public <T, R> List<R> map(List<T> list, Function<T, R> f) {
List<R> result = new ArrayList<>();
for(T t: list) {
result.add(f.apply(t));
}
return result;
}
// [7, 2, 6]
List<Integer> l = map(
Arrays.asList("lambdas", "in", "action"),
(String s) -> s.length() *1*
);
- 1 实现 Function 的 apply 方法
原始特化
我们描述了三个泛型功能接口:Predicate<T>、Consumer<T>和Function<T, R>。还有一些针对特定类型进行了特化的功能接口。
为了稍微回顾一下:每个 Java 类型要么是引用类型(例如,Byte、Integer、Object、List),要么是原始类型(例如,int、double、byte、char)。但泛型参数(例如,Consumer<T>中的T)只能绑定到引用类型。这是由于泛型在内部实现的方式。因此,在 Java 中有一个机制将原始类型转换为相应的引用类型。这个机制称为装箱。相反的方法(将引用类型转换为相应的原始类型)称为拆箱。Java 还有一个自动装箱机制来简化程序员的任务:装箱和拆箱操作是自动完成的。例如,这就是为什么以下代码是有效的(一个int被装箱为一个Integer):
²
一些其他语言,如 C#,没有这种限制。其他语言,如 Scala,只有引用类型。我们将在第二十章中重新讨论这个问题。第二十章。
List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++){
list.add(i);
}
但这会带来性能成本。装箱值是原始类型的包装,存储在堆上。因此,装箱值使用更多的内存,并且需要额外的内存查找来获取包装的原始值。
Java 8 还添加了我们之前描述的功能接口的特化版本,以避免在输入或输出为原始类型时进行自动装箱操作。例如,在以下代码中,使用IntPredicate避免了将值1000装箱的操作,而使用Predicate<Integer>则会将参数1000装箱为一个Integer对象:
public interface IntPredicate {
boolean test(int t);
}
IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000); *1*
Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
oddNumbers.test(1000); *2*
-
1 真的(没有装箱)
-
2 假的(装箱)
通常情况下,对于具有针对输入类型参数的特化的功能接口,合适的原始类型会位于其名称之前(例如,DoublePredicate、IntConsumer、LongBinaryOperator、IntFunction等)。Function接口也有针对输出类型参数的变体:ToIntFunction<T>、IntToDoubleFunction等。
表 3.2 总结了 Java API 中最常用的功能接口及其函数描述符,以及它们的原始特殊化。请记住,这些只是一个入门套件,如果需要,你总是可以创建自己的(练习 3.7 为此创造了 TriFunction)。创建自己的接口还可以帮助当特定领域的名称有助于程序理解和维护时。记住,符号 (T, U) -> R 显示了如何考虑函数描述符。箭头的左侧是一个表示参数类型的列表,右侧表示结果的类型。在这种情况下,它表示一个具有两个参数的函数,分别具有泛型类型 T 和 U,并且返回类型为 R。
表 3.2. Java 8 中添加的常见功能接口
| 功能接口 | Predicate |
Consumer |
|---|---|---|
| Predicate |
T -> boolean | IntPredicate, LongPredicate, DoublePredicate |
| Consumer |
T -> void | IntConsumer, LongConsumer, DoubleConsumer |
| Function<T, R> | T -> R | IntFunction
IntToLongFunction,
LongFunction
LongToDoubleFunction,
LongToIntFunction,
DoubleFunction
DoubleToIntFunction,
DoubleToLongFunction,
ToIntFunction
ToDoubleFunction
ToLongFunction
| Supplier |
() -> T | BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier |
|---|
| UnaryOperator
DoubleUnaryOperator |
| BinaryOperator
DoubleBinaryOperator |
| BiPredicate<T, U> | (T, U) -> boolean |
|---|
| BiConsumer<T, U> | (T, U) -> void | ObjIntConsumer
ObjDoubleConsumer
| BiFunction<T, U, R> | (T, U) -> R | ToIntBiFunction<T, U>, ToLongBiFunction<T, U>,
ToDoubleBiFunction<T, U> |
你现在已经看到了许多可以用来描述各种 lambda 表达式签名的功能接口。为了检验你到目前为止的理解,尝试练习 3.4。
练习 3.4:功能接口
你将使用哪些功能接口来描述以下函数描述符(lambda 表达式签名)?你将在 表 3.2 中找到大部分答案。作为进一步练习,提出可以与这些功能接口一起使用的有效 lambda 表达式。
-
T -> R -
(int, int) -> int -
T -> void -
() -> T -
(T, U) -> R
答案:
-
Function<T, R>是一个好的候选者。它通常用于将类型为T的对象转换为类型为R的对象(例如,Function<Apple, Integer>用于提取苹果的重量)。 -
IntBinaryOperator有一个名为applyAsInt的单个抽象方法,表示函数描述符(int, int) -> int。 -
Consumer<T>有一个名为accept的单个抽象方法,表示函数描述符T -> void。 -
Supplier<T>有一个名为get的单个抽象方法,表示函数描述符() -> T。 -
BiFunction<T, U, R>有一个名为apply的单个抽象方法,表示函数描述符(T, U) -> R。
为了总结关于功能接口和 Lambda 表达式的讨论,表 3.3 提供了用例、Lambda 表达式示例和可用的功能接口的总结。
表 3.3. 使用功能接口的 Lambda 表达式示例
| 用例 | Lambda 示例 | 匹配功能接口 |
|---|---|---|
| 一个布尔表达式 | (List |
Predicate<List |
| 创建对象 | () -> new Apple(10) | Supplier |
| 从对象中消费 | (Apple a) -> System.out.println(a.getWeight()) | Consumer |
| 从对象中选择/提取 | (String s) -> s.length() | Function<String, Integer> 或
ToIntFunction
| 合并两个值 | (int a, int b) -> a * b | IntBinaryOperator |
|---|
| 比较两个对象 | (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight
()) | Comparator
Integer> 或
ToIntBiFunction<Apple,
Apple> |
关于异常、Lambda 表达式和功能接口,有什么看法?
注意,没有任何功能接口允许抛出检查异常。如果您需要 Lambda 表达式的主体抛出异常,您有两个选择:定义自己的功能接口以声明检查异常,或者用 try/catch 块包装 Lambda 表达式的主体。
例如,在第 3.3 节中,我们介绍了一个新的功能接口 Buffered-Reader-Processor,该接口明确声明了 IOException:
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
BufferedReaderProcessor p = (BufferedReader br) -> br.readLine();
但您可能正在使用一个期望功能接口(如 Function<T, R>)的 API,并且没有创建自己的选项。您将在下一章中看到,Streams API 严重依赖于表 3.2 中的功能接口。在这种情况下,您可以显式捕获检查异常:
Function<BufferedReader, String> f =
(BufferedReader b) -> {
try {
return b.readLine();
}
catch(IOException e) {
throw new RuntimeException(e);
}
};
您现在已经看到了如何创建 Lambda 表达式以及在哪里如何使用它们。接下来,我们将解释一些更高级的细节:编译器如何检查 Lambda 表达式的类型以及您应该注意的规则,例如 Lambda 表达式在其主体内部引用局部变量以及与 void 兼容的 Lambda 表达式。您无需立即完全理解下一节,您可能希望稍后返回并继续阅读关于第 3.6 节的方法引用。
3.5. 类型检查、类型推断和限制
当我们最初提到 Lambda 表达式时,我们说它们允许您生成功能接口的一个实例。尽管如此,Lambda 表达式本身并不包含它实现哪个功能接口的信息。为了对 Lambda 表达式有一个更正式的理解,您应该知道 Lambda 表达式的类型。
3.5.1. 类型检查
lambda 的类型是从 lambda 被使用的上下文中推断出来的。上下文中 lambda 表达式期望的类型(例如,它传递给的方法参数或它被分配到的局部变量)被称为 目标类型。让我们通过一个例子来看看使用 lambda 表达式时幕后发生了什么。图 3.4 总结了以下代码的类型检查过程:
图 3.4. 解构 lambda 表达式的类型检查过程

List<Apple> heavierThan150g =
filter(inventory, (Apple apple) -> apple.getWeight() > 150);
类型检查过程如下分解:
-
首先,查找
filter方法的声明。 -
第二,它期望第二个形式参数是一个
Predicate<Apple>类型的对象(目标类型)。 -
第三,
Predicate<Apple>是一个定义了单个抽象方法test的函数式接口。 -
第四,
test方法描述了一个接受Apple并返回boolean类型的函数描述符。 -
最后,任何传递给
filter方法的参数都需要满足这一要求。
代码是有效的,因为传递的 lambda 表达式也接受一个 Apple 参数并返回 boolean。注意,如果 lambda 表达式抛出异常,则抽象方法的声明 throws 子句也必须匹配。
3.5.2. 相同的 lambda,不同的函数式接口
由于 目标类型 的概念,如果不同的函数式接口具有兼容的抽象方法签名,相同的 lambda 表达式可以与不同的函数式接口相关联。例如,前面描述的接口 Callable 和 PrivilegedAction 都代表接受无参数并返回泛型类型 T 的函数。因此,以下两个赋值是有效的:
Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> p = () -> 42;
在此情况下,第一个赋值的目标类型为 Callable<Integer>,第二个赋值的目标类型为 PrivilegedAction<Integer>。
在 表 3.3 中,我们展示了类似的例子;相同的 lambda 可以与多个不同的函数式接口一起使用:
Comparator<Apple> c1 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
ToIntBiFunction<Apple, Apple> c2 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
BiFunction<Apple, Apple, Integer> c3 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
菱形运算符
对于熟悉 Java 发展历程的各位来说,会记得 Java 7 已经通过使用菱形运算符 (<>) 引入了从上下文中推断类型的概念(这个想法在泛型方法中也可以找到)。一个给定的类实例表达式可以出现在两个或更多不同的上下文中,适当类型参数将被推断,如下例所示:
List<String> listOfStrings = new ArrayList<>();
List<Integer> listOfIntegers = new ArrayList<>();
特殊的 void 兼容规则
如果一个 lambda 表达式的主体是一个语句表达式,它与返回 void 的函数描述符兼容(前提是参数列表也兼容)。例如,以下两行都是合法的,尽管 List 的 add 方法返回的是 boolean 类型,而不是在 Consumer 上下文(T -> void)中期望的 void 类型:
// Predicate has a boolean return
Predicate<String> p = (String s) -> list.add(s);
// Consumer has a void return
Consumer<String> b = (String s) -> list.add(s);
到现在为止,你应该已经很好地理解了何时何地可以使用 lambda 表达式。它们可以从赋值上下文、方法调用上下文(参数和返回值)以及类型转换上下文中获取目标类型。为了检验你的知识,尝试练习题 3.5。
练习题 3.5:类型检查——为什么以下代码无法编译?
你该如何解决这个问题?
Object o = () -> { System.out.println("Tricky example"); };
答案:
lambda 表达式的上下文是 Object(目标类型)。但 Object 不是一个功能接口。为了解决这个问题,你可以将目标类型更改为 Runnable,它代表函数描述符 () -> void:
Runnable r = () -> { System.out.println("Tricky example"); };
你也可以通过将 lambda 表达式转换为 Runnable 来解决这个问题,这明确提供了目标类型。
Object o = (Runnable) () -> { System.out.println("Tricky example"); };
这种技术在处理具有相同函数描述符的两个不同功能接口的方法重载的上下文中非常有用。你可以将 lambda 表达式进行类型转换,以明确指定应该选择哪个方法签名。
例如,以下使用 execute 方法的 execute(() -> {}) 调用将是模糊的,因为 Runnable 和 Action 都具有相同的函数描述符:
public void execute(Runnable runnable) {
runnable.run();
}
public void execute(Action<T> action) {
action.act();
}
@FunctionalInterface
interface Action {
void act();
}
但是,你可以通过使用类型转换表达式来明确消除调用歧义:execute ((Action) () -> {});
你已经看到了如何使用目标类型来检查 lambda 是否可以在特定上下文中使用。它还可以用来做稍微不同的事情:推断 lambda 参数的类型。
3.5.3. 类型推断
你可以进一步简化你的代码。Java 编译器根据 lambda 表达式周围的上下文(目标类型)推断出与 lambda 表达式关联的功能接口,这意味着它也可以根据目标类型推断出 lambda 的适当签名。好处是编译器可以访问 lambda 表达式参数的类型,并且可以在 lambda 语法中省略这些类型。Java 编译器推断 lambda 表达式参数的类型,如下所示:^([3])
³
注意,当一个 lambda 表达式具有单个参数且其类型被推断时,参数名称周围的括号也可以省略。
List<Apple> greenApples =
filter(inventory, apple -> GREEN.equals(apple.getColor())); *1*
- 1 参数 apple 没有显式类型
当 lambda 表达式具有多个参数时,代码可读性的好处更为明显。例如,以下是如何创建一个 Comparator 对象的示例:
Comparator<Apple> c =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()); *1*
Comparator<Apple> c =
(a1, a2) -> a1.getWeight().compareTo(a2.getWeight()); *2*
-
1 没有类型推断
-
2 带类型推断
注意,有时明确包含类型可以使代码更易读,有时省略类型可以使代码更易读。没有规则说明哪种方式更好;开发者必须根据自己的判断来决定哪种方式能使他们的代码更易读。
3.5.4. 使用局部变量
我们之前展示的所有 lambda 表达式在其体内只使用了它们的参数。但是,lambda 表达式也可以像匿名类一样使用自由变量(不是参数,而是在外部作用域中定义的变量)。它们被称为捕获 lambda。例如,以下 lambda 捕获了变量portNumber:
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
尽管如此,还有一些小的变化。对这些变量可以做什么有一些限制。Lambda 允许无限制地捕获(在其体内引用)实例变量和静态变量。但是,当捕获本地变量时,它们必须显式声明为final或实际上是final。Lambda 表达式可以捕获只被分配一次的本地变量。(注意:捕获实例变量可以看作是捕获了最终的本地变量this。)例如,以下代码无法编译,因为变量portNumber被分配了两次:
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber); *1*
portNumber = 31337;
- 1 错误:本地变量 portNumber 不是 final 或实际上是 final。
本地变量的限制
你可能自己在想,为什么本地变量会有这些限制。首先,在幕后实现实例变量和本地变量之间存在一个关键的区别。实例变量存储在堆上,而本地变量则存在于栈上。如果 lambda 可以直接访问本地变量,并且 lambda 被用在线程中,那么使用 lambda 的线程可能会在分配变量的线程释放变量之后尝试访问该变量。因此,Java 通过访问其副本而不是原始变量来实现对空闲本地变量的访问。如果本地变量只被分配一次,这并不会造成任何区别——这就是限制的原因。
其次,这个限制也阻止了典型的命令式编程模式(正如我们在后面的章节中解释的,这些模式阻止了轻松的并行化),这些模式会修改外部变量。
闭包
你可能已经听说过“闭包”这个术语,也许正在想 lambda 是否满足闭包的定义(不要与 Clojure 编程语言混淆)。从科学的角度来说,一个“闭包”是一个函数的实例,它可以无限制地引用该函数的非局部变量。例如,闭包可以作为另一个函数的参数传递。它还可以访问和修改其作用域之外定义的变量。现在,Java 8 的 lambda 和匿名类与闭包有类似的行为:它们可以作为方法的参数传递,并且可以访问其作用域之外的变量。但是,它们有一个限制:它们不能修改 lambda 定义的方法中局部变量的内容。这些变量必须是隐式 final 的。有助于思考的是,lambda 是封闭于值而不是变量。如前所述,这个限制存在是因为局部变量存在于栈上,并且隐式地限制在它们所在的线程中。允许捕获可变局部变量会打开新的线程不安全可能性,这是不希望的(实例变量是可以的,因为它们存在于堆上,堆是线程间共享的)。
我们现在将描述 Java 8 代码中引入的另一个伟大特性:方法引用。把它们看作是某些 lambda 的简写版本。
3.6. 方法引用
方法引用允许你重用现有的方法定义,并将它们像 lambda 一样传递。在某些情况下,它们比使用 lambda 表达式更易于阅读,感觉更自然。以下是我们使用方法引用和更新的 Java 8 API 的一点点帮助编写的排序示例(我们将在第 3.7 节中更详细地探讨这个示例)。
在此之前:
inventory.sort((Apple a1, Apple a2)
a1.getWeight().compareTo(a2.getWeight()));
之后(使用方法引用和java.util.Comparator.comparing):
inventory.sort(comparing(Apple::getWeight)); *1*
- 1 你的第一个方法引用
不要担心新的语法和它们是如何工作的。你将在接下来的几节中了解到这些!
3.6.1. 简而言之
为什么你应该关注方法引用?方法引用可以看作是仅调用特定方法的 lambda 的简写。基本思想是,如果 lambda 代表“直接调用此方法”,那么最好通过方法名来引用它,而不是通过如何调用的描述。确实,方法引用允许你从一个现有的方法实现创建 lambda 表达式。但通过明确引用方法名,你的代码可以提高可读性。它是如何工作的?当你需要一个方法引用时,目标引用放在分隔符 :: 前面,方法名在后面提供。例如,Apple::getWeight 是指向在 Apple 类中定义的 getWeight 方法的引用方法。(记住,在 getWeight 后面不需要括号,因为你现在不是在调用它,你只是在引用它的名字。)这个方法引用是 lambda 表达式 (Apple apple) -> apple.getWeight() 的简写。表 3.4 给出了 Java 8 中一些可能的方法引用的更多示例。
表 3.4. Lambda 和方法引用等价的示例
| Lambda | 方法引用等价 |
|---|---|
| (Apple apple) -> apple.getWeight() | Apple::getWeight |
| () -> Thread.currentThread().dumpStack() | Thread.currentThread()::dumpStack |
| (str, i) -> str.substring(i) | String::substring |
| (String s) -> System.out.println(s) (String s) -> this.isValidName(s) | System.out::println this::isValidName |
你可以将方法引用看作是 lambda 的语法糖,因为它只引用单个方法,因为你可以用更少的代码来表达相同的内容。
构建方法引用的食谱
方法引用主要有三种类型:
-
一个指向静态方法的引用方法(例如,
Integer的parseInt方法,写作Integer::parseInt) -
一个指向任意类型实例方法的引用方法(例如,
String的length方法,写作String::length) -
一个指向现有对象或表达式实例方法的引用方法(例如,假设你有一个局部变量
expensiveTransaction,它包含一个Transaction类型的对象,该对象支持实例方法getValue;你可以写expensiveTransaction::getValue)
第二种和第三种方法引用可能在最初显得有些令人不知所措。第二种方法引用的想法,例如 String::length,是指向一个对象的方法,该对象将被作为 lambda 的参数之一提供。例如,lambda 表达式 (String s) -> s.toUpperCase() 可以重写为 String::toUpperCase。但第三种方法引用指的是在 lambda 中调用外部已存在的对象的方法。例如,lambda 表达式 () -> expensiveTransaction.getValue() 可以重写为 expensiveTransaction::getValue。这种第三种方法引用在需要传递定义为私有辅助方法的函数时特别有用。例如,假设你定义了一个辅助方法 isValidName:
private boolean isValidName(String string) {
return Character.isUpperCase(string.charAt(0));
}
你现在可以使用方法引用在 Predicate<String> 的上下文中传递这个方法:
filter(words, this::isValidName)
为了帮助你消化这些新知识,将 lambda 表达式重构为等效方法引用的简写规则遵循简单的食谱,如图 3.5 所示。
图 3.5. 构造三种不同类型 lambda 表达式的方法引用

注意,还有针对构造函数、数组构造函数和超调用的特殊形式的方法引用。让我们通过一个具体的例子来应用方法引用。假设你想要对一个字符串的 List 进行排序,忽略大小写差异。List 上的 sort 方法期望一个 Comparator 作为参数。你之前看到 Comparator 描述了一个具有签名 (T, T) -> int 的函数描述符。你可以定义一个使用 String 类中的 compareToIgnoreCase 方法的 lambda 表达式,如下所示(注意,compareToIgnoreCase 在 String 类中是预定义的):
List<String> str = Arrays.asList("a","b","A","B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));
lambda 表达式的签名与 Comparator 的函数描述符兼容。使用之前描述的食谱,示例也可以使用方法引用来编写;这导致代码更加简洁,如下所示:
List<String> str = Arrays.asList("a","b","A","B");
str.sort(String::compareToIgnoreCase);
注意,编译器会像对 lambda 表达式进行类型检查一样,来确定方法引用是否与给定的函数接口有效。方法引用的签名必须与上下文类型匹配。
为了检验你对方法引用的理解,请尝试练习题 3.6!
练习题 3.6:方法引用
以下 lambda 表达式的等效方法引用是什么?
-
ToIntFunction<String> stringToInt = (String s) -> Integer.parseInt(s); -
BiPredicate<List<String>, String> contains = (list, element) -> list.contains(element); -
Predicate<String> startsWithNumber = (String string) -> this .starts-WithNumber(string);
答案:
-
这个 lambda 表达式将其参数传递给
Integer的静态方法parseInt。这个方法接受一个要解析的String并返回一个int。因此,可以使用图 3.5 中的食谱 1(调用静态方法的 lambda 表达式)重写 lambda,如下所示:ToIntFunction<String> stringToInt = Integer::parseInt; -
这个 lambda 表达式使用它的第一个参数来调用其上的
contains方法。因为第一个参数是List类型,你可以按照以下方式使用图 3.5 中的2号配方:BiPredicate<List<String>, String> contains = List::contains;这是因为目标类型描述了一个函数描述符
(List<String>, String) -> boolean,而List::contains可以被解包到该函数描述符。 -
这个表达式风格的 lambda 调用了一个私有辅助方法。你可以按照以下方式使用图 3.5 中的3号配方:
Predicate<String> startsWithNumber = this::startsWithNumber
我们已经展示了如何重用现有的方法实现和创建方法引用。但你可以用类似的方法来处理类的构造函数。
3.6.2. 构造函数引用
你可以使用构造函数的名称和关键字new来创建现有构造函数的引用,如下所示:ClassName::new。它的工作方式与静态方法的引用类似。例如,假设有一个无参构造函数。这符合Supplier的签名() -> Apple,你可以这样做:
Supplier<Apple> c1 = Apple::new; *1*
Apple a1 = c1.get(); *2*
-
1 默认 Apple()构造函数的构造函数引用
-
2 调用供应商的 get 方法会产生一个新的 Apple。
这与以下等价
Supplier<Apple> c1 = () -> new Apple(); *1*
Apple a1 = c1.get(); *2*
-
1 使用默认构造函数创建 Apple 的 Lambda 表达式
-
2 调用供应商的 get 方法会产生一个新的 Apple。
如果你有一个签名Apple(Integer weight)的构造函数,它符合Function接口的签名,所以你可以这样做
Function<Integer, Apple> c2 = Apple::new; *1*
Apple a2 = c2.apply(110); *2*
-
1 Apple 类的构造函数引用(Integer weight)
-
2 使用给定的重量调用 Function 的 apply 方法会产生一个 Apple。
这与以下等价
Function<Integer, Apple> c2 = (weight) -> new Apple(weight); *1*
Apple a2 = c2.apply(110); *2*
-
1 使用给定重量的 Lambda 表达式创建 Apple
-
2 使用给定的重量调用 Function 的 apply 方法会产生一个新的 Apple 对象。
在以下代码中,将Integer类型的List中的每个元素传递给Apple的构造函数,使用我们之前定义的类似map方法,结果得到一个具有各种重量的苹果List:
List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new); *1*
public List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
List<Apple> result = new ArrayList<>();
for(Integer i: list) {
result.add(f.apply(i));
}
return result;
}
- 1 将构造函数引用传递给 map 方法
如果你有一个带有两个参数的构造函数Apple(Color color, Integer weight),它符合BiFunction接口的签名,所以你可以这样做:
BiFunction<Color, Integer, Apple> c3 = Apple::new; *1*
Apple a3 = c3.apply(GREEN, 110); *2*
-
1 Apple 类的构造函数引用(Color color, Integer weight)
-
2 带有给定颜色和重量的 BiFunction 的 apply 方法会产生一个新的 Apple 对象。
这与以下等价
BiFunction<String, Integer, Apple> c3 =
(color, weight) -> new Apple(color, weight); *1*
Apple a3 = c3.apply(GREEN, 110); *2*
-
1 使用给定的颜色和重量创建 Apple 的 Lambda 表达式
-
2 带有给定颜色和重量的 BiFunction 的 apply 方法会产生一个新的 Apple 对象。
指向构造函数而不实例化的能力使得一些有趣的应用成为可能。例如,你可以使用Map将构造函数与字符串值关联起来。然后你可以创建一个名为giveMeFruit的方法,给定一个String和一个Integer,可以创建不同重量不同类型的果实,如下所示:
static Map<String, Function<Integer, Fruit>> map = new HashMap<>();
static {
map.put("apple", Apple::new);
map.put("orange", Orange::new);
// etc...
}
public static Fruit giveMeFruit(String fruit, Integer weight){
return map.get(fruit.toLowerCase()) *1*
.apply(weight); *2*
}
-
1 从 map 中获取 Function<Integer, Fruit>
-
2 Function 的 apply 方法带有 Integer weight 参数,创建所需的 Fruit。
要检查你对方法和构造函数引用的理解,请尝试 3.7 的测验。
测验 3.7:构造函数引用
你看到了如何将零参数、一参数和二参数构造函数转换为构造函数引用。为了使用一个三参数构造函数,例如RGB(int, int, int),你需要做什么?
答案:
你看到构造函数引用的语法是ClassName::new,所以在这种情况下是RGB::new。但是,你需要一个与该构造函数引用签名匹配的函数式接口。因为函数式接口起始集中没有,你可以创建自己的:
public interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
现在,你可以使用构造函数引用如下:
TriFunction<Integer, Integer, Integer, RGB> colorFactory = RGB::new;
我们已经学习了很多新的信息:lambda 表达式、函数式接口和方法引用。我们将在下一节中将所有这些内容付诸实践。
3.7. 将 lambda 表达式和方法引用付诸实践
为了总结本章以及我们对 lambda 表达式的讨论,我们将继续我们最初的问题,即使用不同的排序策略对Apple列表进行排序。我们将展示如何逐步将一个原始解决方案演变成一个简洁的解决方案,使用本书中解释的所有概念和功能:行为参数化、匿名类、lambda 表达式和方法引用。我们将努力实现的最终解决方案如下(注意,所有源代码都可在本书的网站上找到:www.manning.com/books/modern-java-in-action):
inventory.sort(comparing(Apple::getWeight));
3.7.1. 步骤 1:传递代码
你很幸运;Java 8 API 已经为List提供了可用的sort方法,因此你不需要自己实现它。困难的部分已经完成了!但是,你该如何将排序策略传递给sort方法呢?嗯,sort方法具有以下签名-:
void sort(Comparator<? super E> c)
它期望一个Comparator对象作为参数来比较两个Apple对象!这就是你如何在 Java 中传递不同策略的方式:它们必须被封装在对象中。我们说sort的行为是参数化的:它的行为将根据传递给它的不同排序策略而有所不同。
你的第一个解决方案看起来像这样:
public class AppleComparator implements Comparator<Apple> {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
}
inventory.sort(new AppleComparator());
3.7.2. 步骤 2:使用匿名类
而不是为了实例化一次而实现Comparator,你看到你可以使用一个匿名类来改进你的解决方案:
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2){
return a1.getWeight().compareTo(a2.getWeight());
}
});
3.7.3. 步骤 3:使用 lambda 表达式
但你当前解决方案仍然很冗长。Java 8 引入了 lambda 表达式,它提供了一种轻量级的语法来实现相同的目标:传递代码。你看到 lambda 表达式可以在期望 函数式接口 的地方使用。作为提醒,函数式接口是一个只定义一个抽象方法的接口。抽象方法的签名(称为 函数描述符)可以描述 lambda 表达式的签名。在这种情况下,Comparator 代表一个函数描述符 (T, T) -> int。因为你在使用 Apples,所以它更具体地代表 (Apple, Apple) -> int。因此,你的新改进的解决方案看起来如下:
inventory.sort((Apple a1, Apple a2)
-> a1.getWeight().compareTo(a2.getWeight())
);
我们解释了 Java 编译器可以通过 lambda 出现的上下文来推断 lambda 表达式参数的类型。因此,你可以将你的解决方案重写如下:
inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
你能让你的代码更加易读吗?Comparator 包含一个名为 comparing 的静态辅助方法,它接受一个提取 Comparable 键的 Function,并生成一个 Comparator 对象(我们将在第十三章中解释为什么接口可以有静态方法 chapter 13)。它可以如下使用(注意现在你传递一个只有一个参数的 lambda;lambda 指定了如何从一个 Apple 中提取用于比较的键):
Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
你现在可以以稍微紧凑的形式重写你的解决方案:
import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));
3.7.4. 第 4 步:使用方法引用
我们解释了方法引用是 lambda 表达式的语法糖,它将它们的参数传递过去。你可以使用方法引用来使你的代码稍微不那么冗长(假设静态导入 java.util.Comparator.comparing):
inventory.sort(comparing(Apple::getWeight));
恭喜,这是你的最终解决方案!为什么这比 Java 8 之前的代码更好?这不仅因为它更短;它还非常明确其含义。代码读起来就像问题陈述“按苹果的重量排序库存。”
3.8. 有用的方法来组合 lambda 表达式
Java 8 API 中的几个函数式接口包含便利方法。具体来说,许多函数式接口,如 Comparator、Function 和 Predicate,它们用于传递 lambda 表达式,提供了允许组合的方法。这是什么意思呢?在实践中,这意味着你可以组合几个简单的 lambda 表达式来构建更复杂的表达式。例如,你可以将两个谓词组合成一个更大的谓词,该谓词在两个谓词之间执行 or 操作。此外,你还可以组合函数,使得一个函数的结果成为另一个函数的输入。你可能想知道为什么函数式接口中会有额外的函数。(毕竟,这与函数式接口的定义相矛盾!)诀窍在于我们将要介绍的方法被称为 默认方法(它们不是抽象方法)。我们将在 第十三章 中详细解释它们。现在,请相信我们,当你想了解更多关于默认方法和你可以用它们做什么时,再阅读 第十三章。
3.8.1. 比较器组合
你已经看到你可以使用静态方法 Comparator.comparing 来返回一个基于 Function 提取比较键的 Comparator,如下所示:
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
逆序
如果你想按重量降序对苹果进行排序,不需要创建不同的 Comparator 实例。该接口包括一个默认方法 reversed,它反转给定比较器的排序顺序。你可以通过重用初始 Comparator 来修改前面的示例,按重量降序对苹果进行排序:
inventory.sort(comparing(Apple::getWeight).reversed()); *1*
- 1 按重量降序排序
链式连接比较器
这听起来很棒,但如果你发现两个苹果的重量相同怎么办?在排序列表中哪个苹果应该有优先级?你可能想提供一个第二个 Comparator 来进一步细化比较。例如,在根据重量比较两个苹果之后,你可能想按原产国对它们进行排序。thenComparing 方法允许你这样做。它接受一个函数作为参数(就像 comparing 方法一样)并在使用初始 Comparator 认定两个对象相等时提供一个第二个 Comparator。你可以再次优雅地解决这个问题,如下所示:
inventory.sort(comparing(Apple::getWeight)
.reversed() *1*
.thenComparing(Apple::getCountry)); *2*
-
1 按重量降序排序
-
2 当两个苹果重量相同时,按国家进一步排序
3.8.2. 谓词组合
Predicate 接口包括三个方法,允许你重用现有的 Predicate 来创建更复杂的谓词:negate、and 和 or。例如,你可以使用 negate 方法来返回 Predicate 的否定,例如一个非红色的苹果:
Predicate<Apple> notRedApple = redApple.negate(); *1*
- 1 生成现有谓词对象 redApple 的否定
你可能想使用 and 方法将两个 lambda 表达式组合起来,以表明一个苹果既是红色的又是重的:
Predicate<Apple> redAndHeavyApple =
redApple.and(apple -> apple.getWeight() > 150); *1*
- 1 将两个谓词链式连接以产生另一个谓词对象
你可以将生成的谓词进一步组合,以表达红色且重的苹果(重量超过 150 克)或仅绿色的苹果:
Predicate<Apple> redAndHeavyAppleOrGreen =
redApple.and(apple -> apple.getWeight() > 150)
.or(apple -> GREEN.equals(a.getColor())); *1*
- 1 将三个谓词链起来构建一个更复杂的谓词对象
为什么这很棒?从更简单的 lambda 表达式,你可以表示更复杂的 lambda 表达式,它们仍然像问题陈述一样易读!请注意,链中方法and和or的优先级是从左到右的——没有括号等价物。所以a.or(b).and(c)必须读作(a || b) && c。同样,a.and(b).or(c)必须读作(a && b) || c。
3.8.3. 组合函数
最后,你也可以组合由Function接口表示的 lambda 表达式。Function接口为此提供了两个默认方法,andThen和compose,这两个方法都返回一个Function实例。
方法andThen返回一个函数,它首先对一个输入应用一个给定的函数,然后将另一个函数应用于该应用的结果。例如,给定一个函数f,它增加一个数字(x -> x + 1),以及另一个函数g,它将一个数字乘以 2,你可以将它们组合起来创建一个函数h,该函数首先增加一个数字,然后将结果乘以 2:
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g); *1*
int result = h.apply(1); *2*
-
1 在数学中,你会写成 g(f(x))或(g o f)(x)。
-
2 这返回 4。
你也可以像使用compose一样使用方法compose,首先应用作为compose参数给出的函数,然后将函数应用于结果。例如,在之前的compose示例中,这意味着f(g(x))而不是使用andThen的g(f(x)):
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g); *1*
int result = h.apply(1); *2*
-
1 在数学中,你会写成 f(g(x))或(f o g)(x)。
-
2 这返回 3。
图 3.6 说明了andThen和compose之间的区别。
图 3.6. 使用andThen与compose的比较

所有这些都听起来有点抽象。你如何在实践中使用它们?假设你有一些对表示为String的字母进行文本转换的实用方法:
public class Letter{
public static String addHeader(String text) {
return "From Raoul, Mario and Alan: " + text;
}
public static String addFooter(String text) {
return text + " Kind regards";
}
public static String checkSpelling(String text) {
return text.replaceAll("labda", "lambda");
}
}
你现在可以通过组合实用方法创建各种转换管道。例如,创建一个管道,首先添加标题,然后检查拼写,最后添加页脚,如下所示(如图 3.7 所示):
图 3.7. 使用andThen的转换管道

Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
= addHeader.andThen(Letter::checkSpelling)
.andThen(Letter::addFooter);
另一个管道可能只是添加标题和页脚,而不检查拼写:
Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline
= addHeader.andThen(Letter::addFooter);
3.9. 来自数学的类似想法
如果你熟悉高中数学,本节提供了对 lambda 表达式和函数传递概念的另一种观点。请随意跳过;本书中的其他内容都不依赖于它。但你可能会喜欢看到另一个视角。
3.9.1. 集成
假设你有一个(数学的,不是 Java 的)函数f,可能定义为
f(x) = x + 10
然后,一个经常被问到的问题(在学校以及科学和工程学位中)是在纸上绘制函数时找到函数下方的面积(将 x 轴视为零线)。例如,你写下

对于图 3.8 中显示的区域 figure 3.8。
图 3.8. 函数 f(x) = x + 10 在 x 从 3 到 7 下的面积

在这个例子中,函数 f 是一条直线,因此你可以通过梯形法(绘制三角形和矩形)轻松计算出这个面积,以找到解决方案:
1/2 × ((3 + 10) + (7 + 10)) × (7 – 3) = 60
现在,你如何在 Java 中表达这个想法?你的第一个问题是调和像积分符号或 dy/dx 这样的奇怪符号与熟悉的编程语言符号。
事实上,从第一性原理出发,你需要一个方法,可能叫做 integrate,它接受三个参数:一个是 f,其余的是极限(这里分别是 3.0 和 7.0)。因此,你希望在 Java 中编写类似以下内容,其中函数 f 作为参数传递:
integrate(f, 3, 7)
注意,你不能像在数学中那样编写如此简单的代码:
integrate(x + 10, 3, 7)
有两个原因。首先,x 的作用域不明确,其次,这将传递 x+10 的值进行积分,而不是传递函数 f。
事实上,数学中 dx 的秘密角色是表示“接受参数 x 并返回 x + 10 的函数。”
3.9.2. 连接到 Java 8 的 lambda 表达式
如我们之前提到的,Java 8 使用 (double x) -> x + 10(一个 lambda 表达式)的符号来精确地实现这个目的;因此你可以写出
integrate((double x) -> x + 10, 3, 7)
或者
integrate((double x) -> f(x), 3, 7)
或者,使用前面提到的方法引用,
integrate(C::f, 3, 7)
如果 C 是一个包含 f 作为静态方法的类。这个想法是你将 f 的代码传递给 integrate 方法。
你现在可能会想知道如何编写 integrate 方法本身。继续假设 f 是一个线性函数(直线)。你可能希望以类似于数学的形式编写:
public double integrate((double -> double) f, double a, double b) { *1*
return (f(a) + f(b)) * (b - a) / 2.0
}
- 1 错误的 Java 代码!(你不能像在数学中那样编写函数。)
但是因为 lambda 表达式只能在期望功能接口(在这种情况下,DoubleFunction^([4])) 的上下文中使用,所以你必须按照以下方式编写:
⁴
使用
DoubleFunction<Double>比使用Function<Double,Double>更高效,因为它避免了装箱结果。
public double integrate(DoubleFunction<Double> f, double a, double b) {
return (f.apply(a) + f.apply(b)) * (b - a) / 2.0;
}
或者使用 DoubleUnaryOperator,这也避免了装箱结果:
public double integrate(DoubleUnaryOperator f, double a, double b) {
return (f.applyAsDouble(a) + f.applyAsDouble(b)) * (b - a) / 2.0;
}
作为旁注,有点遗憾的是,你必须编写 f.apply(a) 而不是简单地像在数学中那样编写 f(a),但 Java 只能摆脱一切都是对象的观点,而不是函数真正独立的概念!
摘要
-
lambda 表达式 可以理解为一种匿名函数:它没有名字,但它有一系列参数、一个主体、一个返回类型,以及可能抛出的异常列表。
-
Lambda 表达式让你可以简洁地传递代码。
-
功能接口 是一个声明恰好一个抽象方法的接口。
-
Lambda 表达式只能在期望功能接口的地方使用。
-
Lambda 表达式允许你直接内联提供功能接口的抽象方法实现,并将整个表达式 视为功能接口的一个实例。
-
Java 8 在
java.util.function包中提供了一系列常见的功能接口,包括Predicate<T>、Function<T, R>、Supplier<T>、Consumer<T>和BinaryOperator<T>,这些接口在表 3.2 中进行了描述。 -
对于
Predicate<T>和Function<T, R>等常见泛型功能接口的原生特殊化,可以使用它们来避免装箱操作:IntPredicate、IntToLongFunction等等。 -
当需要在方法中执行一些必要的中间行为(例如资源分配和清理)时,可以使用 lambda 表达式来获得额外的灵活性和可重用性。
-
lambda 表达式期望的类型称为 目标 类型。
-
方法引用允许你重用现有的方法实现,并直接传递它。
-
类似于
Comparator、Predicate和Function的功能接口有几个默认方法,可以用来组合 lambda 表达式。
第二部分。使用流的函数式数据处理
本书第二部分深入探讨了新的 Streams API,它允许你以声明式的方式编写处理数据集合的强大代码。到第二部分的结尾,你将全面理解流是什么以及你如何在代码库中使用它们来简洁高效地处理数据集合。
第四章介绍了流的定义,并解释了它与集合的比较。
第五章详细调查了可用于表达复杂数据处理查询的流操作。你将查看许多模式,如过滤、切片、查找、匹配、映射和归约。
第六章介绍了收集器——Streams API 的一个特性,它允许你表达更复杂的数据处理查询。
在第七章中,你将了解流如何自动并行运行并利用你的多核架构。此外,你还将了解在使用并行流时需要避免的各种陷阱。
第四章。流的介绍
本章涵盖
-
什么是流?
-
集合与流
-
内部迭代与外部迭代
-
中间操作与终端操作
没有集合的 Java 你会做什么?几乎每个 Java 应用程序都会创建和处理集合。集合对于许多编程任务来说是基本的:它们允许你分组和处理数据。为了说明集合的实际应用,假设你被要求创建一个代表菜单的菜肴集合以计算不同的查询。例如,你可能想计算出菜单的总卡路里。或者,你可能需要过滤菜单以选择仅包含低卡路里菜肴的特殊健康菜单。但尽管集合对于几乎任何 Java 应用程序都是必要的,但操作集合远非完美:
-
许多业务逻辑涉及类似数据库的操作,例如按类别(例如,所有素食菜肴)分组菜肴列表或查找最昂贵的菜肴。你发现自己多少次需要使用迭代器重新实现这些操作?大多数数据库都允许你声明式地指定此类操作。例如,以下 SQL 查询允许你选择(或“过滤”)卡路里低的菜肴名称:
SELECT name FROM dishes WHERE calorie < 400。正如你所看到的,在 SQL 中,你不需要实现使用菜肴的calorie属性(例如,使用 Java 集合,例如使用迭代器和累加器)来过滤的方式。相反,你只需写出你想要的结果。这个基本想法意味着你不必过多担心如何显式地实现此类查询——它为你处理了!为什么你不能用类似的方法处理集合? -
你会如何处理大量元素?为了提高性能,你需要并行处理它并使用多核架构。但是,与使用迭代器相比,编写并行代码更复杂。此外,调试起来也没有乐趣!
Java 语言设计者能做些什么来节省你宝贵的时间,并使你的编程生活更轻松?你可能已经猜到了:答案是 流。
4.1. 什么是流?
流 是 Java API 的一个更新,它允许你以声明式的方式操作数据集合(你表达一个查询而不是为它编写特定的实现)。现在你可以把它们想象成数据集合上的高级迭代器。此外,流可以透明地并行处理,而不需要你编写任何多线程代码!我们将在 第七章 中详细解释流和并行化是如何工作的。要看到使用流的好处,比较以下代码返回低卡路里菜肴的名称,按卡路里数量排序——首先是 Java 7,然后是使用流的 Java 8。不必太担心 Java 8 的代码;我们将在下一节中详细解释它!
在 (Java 7) 之前:
List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish dish: menu) {
if(dish.getCalories() < 400) { *1*
lowCaloricDishes.add(dish);
}
}
Collections.sort(lowCaloricDishes, new Comparator<Dish>() { *2*
public int compare(Dish dish1, Dish dish2) {
return Integer.compare(dish1.getCalories(), dish2.getCalories());
}
});
List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish dish: lowCaloricDishes) {
lowCaloricDishesName.add(dish.getName()); *3*
}
-
1 使用累加器过滤元素
-
2 使用匿名类对菜肴进行排序
-
3 处理排序后的列表以选择菜肴的名称
在此代码中,你使用了一个“垃圾变量”,lowCaloricDishes。它的唯一目的是作为一个中间的临时容器。在 Java 8 中,这个实现细节被推入库中,属于它应该去的地方。
在 (Java 8) 之后:
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
List<String> lowCaloricDishesName =
menu.stream()
.filter(d -> d.getCalories() < 400) *1*
.sorted(comparing(Dish::getCalories)) *2*
.map(Dish::getName) *3*
.collect(toList()); *4*
-
1 选择卡路里低于 400 的菜肴
-
2 按卡路里排序
-
3 提取这些菜肴的名称
-
4 将所有名称存储在列表中
要利用多核架构并行执行此代码,你只需要将 stream() 改为 parallelStream():
List<String> lowCaloricDishesName =
menu.parallelStream()
.filter(d -> d.getCalories() < 400)
.sorted(comparing(Dishes::getCalories))
.map(Dish::getName)
.collect(toList());
你可能想知道当你调用 parallelStream 方法时会发生什么。使用了多少线程?性能有什么好处?你是否应该使用这个方法?第七章 详细介绍了这些问题。现在,你可以看到从软件工程的角度来看,这种方法提供了几个立即的好处:
-
代码以声明式方式编写:你指定想要实现什么(过滤低卡路里的菜品)而不是指定如何实现操作(使用控制流块,如循环和
if条件)。正如你在上一章中看到的,这种方法,加上行为参数化,使你能够应对不断变化的需求:你可以轻松地创建代码的附加版本,使用 lambda 表达式过滤高卡路里菜品,而无需复制和粘贴代码。关于这种方法的好处,另一种思考方式是线程模型与查询本身解耦。因为你提供了一个查询的配方,它可以是顺序执行或并行执行。你将在第七章中了解更多关于这一点。 -
你将把几个构建块操作链接起来,以表达一个复杂的数据处理管道(通过链接
sorted、map和collect操作来链接filter,如图 4.1 所示),同时保持代码的可读性和意图清晰。filter的结果传递给sorted方法,然后传递给map方法,最后传递给collect方法。
图 4.1. 连接流操作形成流管道

因为filter(或sorted、map和collect)等操作作为高级构建块可用,它们不依赖于特定的线程模型,它们的内部实现可以是单线程的,也可以潜在地透明地最大化你的多核架构!在实践中,这意味着你不再需要担心线程和锁来弄清楚如何并行化某些数据处理任务:Streams API 会为你完成这一切!
新的 Streams API 表达力强。例如,阅读完本章以及第五章和第六章后,你将能够编写如下代码:
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(groupingBy(Dish::getType));
这个特定的例子在第六章中有详细解释。它在一个Map内部按类型分组菜品。例如,Map可能包含以下结果:
{FISH=[prawns, salmon],
OTHER=[french fries, rice, season fruit, pizza],
MEAT=[pork, beef, chicken]}
现在考虑如何使用典型的命令式编程方法(使用循环)来实现这一点。但不要浪费太多时间。相反,拥抱本章和以下章节中流的强大功能!
其他库:Guava、Apache 和 lambdaj
为了给 Java 程序员提供更好的库来操作集合,已经进行了许多尝试。例如,Guava 是由 Google 创建的一个流行的库。它提供了额外的容器类,如 multimaps 和 multisets。Apache Commons Collections 库提供了类似的功能。最后,由本书的合著者 Mario Fusco 编写的 lambdaj 提供了许多在声明式编程中操作集合的实用工具,灵感来源于函数式编程。
现在,Java 8 自带了一个官方库,用于以更声明性的方式操作集合。
总结来说,Java 8 的 Streams API 允许你编写代码,使其
-
声明性— 更简洁、更易读
-
可组合— 更大的灵活性
-
可并行化— 更好的性能
在本章的剩余部分和下一章中,我们将使用以下领域作为我们的示例:一个menu,它不过是一个菜肴列表
List<Dish> menu = Arrays.asList(
new Dish("pork", false, 800, Dish.Type.MEAT),
new Dish("beef", false, 700, Dish.Type.MEAT),
new Dish("chicken", false, 400, Dish.Type.MEAT),
new Dish("french fries", true, 530, Dish.Type.OTHER),
new Dish("rice", true, 350, Dish.Type.OTHER),
new Dish("season fruit", true, 120, Dish.Type.OTHER),
new Dish("pizza", true, 550, Dish.Type.OTHER),
new Dish("prawns", false, 300, Dish.Type.FISH),
new Dish("salmon", false, 450, Dish.Type.FISH) );
在这里,一个Dish是一个不可变的类,定义为
public class Dish {
private final String name;
private final boolean vegetarian;
private final int calories;
private final Type type;
public Dish(String name, boolean vegetarian, int calories, Type type) {
this.name = name;
this.vegetarian = vegetarian;
this.calories = calories;
this.type = type;
}
public String getName() {
return name;
}
public boolean isVegetarian() {
return vegetarian;
}
public int getCalories() {
return calories;
}
public Type getType() {
return type;
}
@Override
public String toString() {
return name;
}
public enum Type { MEAT, FISH, OTHER }
}
现在,我们将更详细地探索如何使用 Streams API。我们将比较流和集合,并提供一些背景信息。在下一章中,我们将详细研究可用于表达复杂数据处理查询的流操作。我们将查看许多模式,如过滤、切片、查找、匹配、映射和归约。将有许多测验和练习来帮助你巩固理解。
接下来,我们将讨论如何创建和操作数值流(例如,生成偶数流或毕达哥拉斯三元组流)。最后,我们将讨论如何从不同的源创建流,例如从文件中创建流。我们还将讨论如何生成具有无限元素数量的流——这是你绝对不能使用集合完成的!
4.2. 开始使用流
我们从集合开始讨论流,因为这是开始使用流的最简单方式。Java 8 中的集合支持一个新的stream方法,该方法返回一个流(接口定义在java.util.stream.Stream中)。你稍后会发现,你还可以以各种其他方式获取流(例如,从数值范围或 I/O 资源生成流元素)。
首先,什么是流?一个简短的定义是“从支持数据处理操作的数据源中获取元素序列。”让我们一步一步地分解这个定义:
-
元素序列— 与集合一样,流提供了一个接口,用于访问特定元素类型的有序值集。因为集合是数据结构,所以它们主要关于存储和访问具有特定时间/空间复杂性的元素(例如,
ArrayList与LinkedList)。但流是关于表达计算,如filter、sorted和map,这些你之前已经看到。集合关于数据;流关于计算。我们将在接下来的章节中更详细地解释这个概念。 -
源— 流从数据提供源(如集合、数组或 I/O 资源)中消费。请注意,从有序集合生成流会保留顺序。来自列表的流元素将具有与列表相同的顺序。
-
数据处理操作— 流支持类似数据库的操作和函数式编程语言中的常见操作来操作数据,例如
filter、map、reduce、find、match、sort等。流操作可以顺序执行或并行执行。
此外,流操作有两个重要的特性:
-
管道化— 许多流操作返回一个流本身,允许操作被链式连接以形成一个更大的管道。这使我们能够进行某些优化,我们将在下一章中解释,例如惰性和短路。操作管道可以看作是对数据源的一个类似数据库的查询。
-
内部迭代— 与显式使用迭代器迭代集合不同,流操作在幕后为你进行迭代。我们曾在第一章中简要提到过这个概念,将在下一节中再次讨论。
让我们通过一个代码示例来解释所有这些概念:
import static java.util.stream.Collectors.toList;
List<String> threeHighCaloricDishNames =
menu.stream() *1*
.filter(dish -> dish.getCalories() > 300) *2*
.map(Dish::getName) *3*
.limit(3) *4*
.collect(toList()); *5*
System.out.println(threeHighCaloricDishNames); *6*
-
1 从菜单(菜肴列表)获取流
-
2 创建一个操作管道:首先过滤高卡路里菜肴
-
3 获取菜肴的名称
-
4 仅选择前三个
-
5 将结果存储在另一个列表中
-
6 结果为[pork, beef, chicken]
在这个例子中,你首先通过在menu上调用stream方法从菜肴列表中获取一个流。数据源是菜肴列表(菜单),它为流提供了一系列元素。接下来,你在流上应用一系列数据处理操作:filter、map、limit和collect。除了collect之外,所有这些操作都返回另一个流,因此可以将它们连接起来形成一个管道,这可以看作是对源的一个查询。最后,collect操作开始处理管道以返回一个结果(它与流不同,因为它返回了其他东西——这里是一个List)。在没有调用collect之前,不会产生任何结果,实际上甚至没有从menu中选择任何元素。你可以将其视为链中的方法调用在collect被调用之前排队。图 4.2 显示了流操作的顺序:filter、map、limit和collect,下面将简要描述每个操作:
-
filter— 接受一个 lambda 表达式来排除流中的某些元素。在这种情况下,你通过传递 lambda 表达式d -> d.getCalories() > 300选择卡路里超过 300 的菜肴。 -
map— 接受一个 lambda 表达式将一个元素转换成另一个元素或提取信息。在这种情况下,你通过传递方法引用Dish::getName(相当于 lambda 表达式d -> d.getName())提取每个菜肴的名称。 -
limit— 截断流,使其包含不超过给定数量的元素。 -
collect— 将流转换为另一种形式。在这种情况下,您将流转换为列表。这看起来有点像魔法;我们将在第六章中更详细地描述collect的工作原理。目前,您可以将collect视为一个操作,它接受各种将流元素累积到汇总结果中的方法作为参数。在这里,toList()描述了一个将流转换为列表的方法。
图 4.2. 使用流过滤菜单以找出三个高热量菜品的名称

注意我们描述的代码与您逐个处理菜单项列表时的代码有何不同。首先,您使用一种更声明性的风格来处理菜单中的数据,您说的是“需要做什么”: “找出三个高热量菜品的名称。” 您没有实现过滤(filter)、提取(map)或截断(limit)功能;这些功能通过 Streams 库提供。因此,Streams API 具有更多的灵活性来决定如何优化这个管道。例如,过滤、提取和截断步骤可以合并为单次遍历,并在找到三个菜品后立即停止。我们将在下一章中展示一个示例来证明这一点。
在我们更详细地探讨如何使用流进行操作之前,让我们稍微退后一步,来检查 Collections API 和新的 Streams API 之间的概念差异。
4.3. 流与集合的比较
现有的 Java 集合概念和新的流概念都提供了表示元素类型值的有序集合的数据结构的接口。通过 有序,我们是指我们通常按顺序遍历值,而不是以任何顺序随机访问它们。那么区别在哪里呢?
我们将从一种视觉隐喻开始。考虑一个存储在 DVD 上的电影。这是一个集合(可能是字节或帧——这里我们不在乎是哪一个),因为它包含了整个数据结构。现在考虑当它通过互联网 流式传输 时观看相同的视频。现在这是一个流(字节或帧)。流式视频播放器只需要下载用户正在观看之前的一小部分帧,这样您就可以在流中的大多数值甚至还没有被计算出来之前,从流的开始处显示值(考虑流式传输一场实时足球比赛)。请注意,视频播放器可能没有足够的内存来将整个流作为集合缓冲在内存中——如果您必须等待最后一帧出现才能开始显示视频,启动时间将会令人难以置信。出于视频播放器实现的考虑,您可能会选择 缓冲 流的一部分到集合中,但这与概念上的差异是不同的。
在最粗略的层面上,集合和流之间的区别在于何时进行计算。集合是一个内存中的数据结构,它持有数据结构当前拥有的所有值——集合中的每个元素在可以添加到集合之前都必须被计算。(你可以向集合中添加东西,也可以从中移除东西,但在这个时间点的每个时刻,集合中的每个元素都存储在内存中;元素在成为集合的一部分之前必须被计算。)
相比之下,流是一个概念上固定的数据结构(你不能从中添加或删除元素),其元素是按需计算的。这带来了显著的编程优势。在第六章中,我们将展示如何构建一个包含所有素数(2,3,5,7,11,……)的流是多么简单,尽管素数的数量是无限的。其理念是,用户将只从流中提取他们所需的价值,这些元素仅在需要时和需要时产生——对用户来说是隐形的。这是一种生产者-消费者关系的形式。另一种观点是,流就像是一个懒加载的集合:当消费者请求时,值才会被计算(用管理术语来说,这是需求驱动,甚至可以说是即时制造)。
相反,集合是积极构建的(供应商驱动:在你开始销售之前先填满你的仓库,就像一个有限寿命的圣诞新品),想象一下将其应用于素数示例。尝试构建一个包含所有素数的集合会导致一个程序循环,它永远在计算一个新的素数——将其添加到集合中——但永远不会完成构建集合,因此消费者永远不会看到它。
图 4.3 说明了流和集合之间的区别,应用于我们的 DVD 与互联网流媒体示例。
图 4.3. 流与集合的比较

另一个例子是浏览器互联网搜索。假设你在谷歌或在线电商商店中搜索一个有很多匹配项的短语。你不需要等待整个结果集及其照片下载完成,而是会得到一个流,其元素是最佳的前 10 个或 20 个匹配项,还有一个按钮可以点击来获取下一个 10 个或 20 个。当你,作为消费者,点击获取下一个 10 个时,供应商会根据需求计算这些结果,然后再将它们返回到你的浏览器进行显示。
4.3.1. 只能遍历一次
注意,与迭代器类似,流也只能遍历一次。之后,流就被认为是已经被消费了。你可以从初始数据源获取一个新的流来再次遍历,就像迭代器一样(假设它是一个可重复的源,如集合;如果它是一个 I/O 通道,你就无能为力了)。例如,以下代码会抛出一个异常,表明流已经被消费:
List<String> title = Arrays.asList("Modern", "Java", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println); *1*
s.forEach(System.out::println); *2*
-
1 打印标题中的每个单词
-
2 java.lang.IllegalStateException: 流已被操作或关闭
请记住,流只能消费一次!
流与集合的哲学
对于喜欢哲学观点的读者,你可以将流视为在时间上分散的一组值。相比之下,集合是一组在空间(在此处为计算机内存)上分散的值,它们在时间上的一个点上存在——并且你可以使用迭代器通过 for-each 循环访问集合内部的成员。
集合和流之间的另一个关键区别是它们如何管理数据迭代。
4.3.2. 外部迭代与内部迭代
使用 Collection 接口需要用户进行迭代(例如,使用 for-each);这被称为外部迭代。相比之下,Streams 库使用内部迭代——它为你执行迭代并负责将结果流值存储在某个地方;你只需提供一个函数,说明要做什么。以下代码示例说明了这种区别。
列表 4.1. 集合:使用 for-each 循环的外部迭代
List<String> names = new ArrayList<>();
for(Dish dish: menu) { *1*
names.add(dish.getName()); *2*
}
-
1 显式顺序迭代菜单列表
-
2 提取名称并将其添加到累加器中
注意,for-each 隐藏了一些迭代复杂性。for-each 结构是语法糖,它通过使用 Iterator 对象转换为更丑陋的代码。
列表 4.2. 集合:使用迭代器进行的外部迭代
List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) { *1*
Dish dish = iterator.next();
names.add(dish.getName());
}
- 1 显式迭代列表
列表 4.3. 流:内部迭代
List<String> names = menu.stream()
.map(Dish::getName) *1*
.collect(toList()); *2*
-
1 使用
getName方法参数化地图以提取菜肴的名称 -
2 开始执行操作管道;没有迭代
让我们用一个类比来理解内部迭代的差异和好处。假设你正在和你两岁的女儿索菲亚交谈,并希望她把玩具收起来:
-
你:“索菲亚,让我们把玩具收起来。地上有玩具吗?”
-
索菲亚:“是的,球。”
-
你:“好吧,把球放进盒子里。还有别的东西吗?”
-
索菲亚:“是的,我的娃娃在这里。”
-
你:“好吧,把娃娃放进盒子里。还有别的东西吗?”
-
索菲亚:“是的,我的书。”
-
你:“好吧,把书放进盒子里。还有别的东西吗?”
-
索菲亚:“没有,没有别的东西。”
-
你:“好的,我们完成了。”
这正是你每天在使用 Java 集合时所做的。你外部迭代一个集合,明确地逐个取出并处理项目。如果能够告诉索菲亚,“把地板上的所有玩具都放进盒子里。”那就好多了。还有两个其他原因说明为什么内部迭代更可取:首先,索菲亚可以选择用一只手拿娃娃,另一只手拿球,其次,她可以决定先拿离盒子最近的对象,然后再拿其他的。同样,使用内部迭代,项目的处理可以透明地并行进行或以不同的顺序进行,这可能会更优化。如果你像在 Java 中那样外部迭代集合,这些优化就变得很困难。这看起来可能是在吹毛求疵,但这是 Java 8 引入流的主要原因之一。Streams 库中的内部迭代可以自动选择数据表示和并行实现的实现,以匹配你的硬件。相比之下,一旦你通过编写 for-each 来选择外部迭代,那么你就承诺了自行管理任何并行性。(在实践中,“自行管理”意味着“某天我们会并行化这个”或“开始涉及任务和 synchronized 的漫长而艰巨的战斗。”)Java 8 需要一个像 Collection 这样的接口,但没有迭代器,因此有 Stream!图 4.4 展示了流(内部迭代)和集合(外部迭代)之间的差异。
图 4.4. 内部迭代与外部迭代

我们已经描述了集合和流之间的概念差异。具体来说,流使用内部迭代,其中库为你处理迭代。但这只有在你有预定义的操作列表(例如,filter 或 map)来处理时才有用,这些操作隐藏了迭代。这些操作中的大多数都接受 lambda 表达式作为参数,因此你可以像我们在上一章中展示的那样参数化它们的行为。Java 语言设计者提供了 Streams API,其中包含了一长串你可以用来表达复杂数据处理查询的操作。我们现在将简要地查看这个操作列表,并在下一章通过示例进行更详细的探索。为了检查你对外部迭代和内部迭代的理解,尝试下面的 4.1 测试题。
测试题 4.1:外部迭代与内部迭代
根据 列表 4.1 和 4.2 中关于外部迭代的学习,你会使用哪个流操作来重构以下代码?
List<String> highCaloricDishes = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while(iterator.hasNext()) {
Dish dish = iterator.next();
if(dish.getCalories() > 300) {
highCaloricDishes.add(d.getName());
}
}
答案:你需要使用 filter 模式
List<String> highCaloricDish =
menu.stream()
.filter(dish -> dish.getCalories() > 300)
.collect(toList());
如果你对如何精确地编写流查询还不熟悉,不要担心,你将在下一章中详细了解这一点。
4.4. 流操作
java.util.stream.Stream 中的流接口定义了许多操作。它们可以分为两类。让我们再次看看我们的上一个例子:
List<String> names = menu.stream() *1*
.filter(dish -> dish.getCalories() > 300) *2*
.map(Dish::getName) *2*
.limit(3) *2*
.collect(toList()); *3*
-
1 从菜式列表中获取流
-
2 中间操作
-
3 将流转换为列表
你可以看到两组操作:
-
filter、map和limit可以连接起来形成一个管道。 -
collect导致管道执行并关闭它。
可以连接的流操作称为 中间操作,而关闭流的操作称为 终端操作。图 4.5 突出了这两组操作。这种区分为什么很重要?
图 4.5. 中间操作与终端操作

4.4.1. 中间操作
中间操作,如 filter 或 sorted,返回另一个流作为返回类型。这允许操作连接起来形成一个查询。重要的是,中间操作在流管道上调用终端操作之前不会执行任何处理——它们是惰性的。这是因为中间操作通常可以被合并并处理为终端操作的单个遍历。
要理解流管道中发生的事情,修改代码,使每个 lambda 也打印出它正在处理的当前菜式。(像许多演示和调试技术一样,这对于生产代码来说是一种糟糕的编程风格,但在学习时可以直接解释评估的顺序。)
List<String> names =
menu.stream()
.filter(dish -> {
System.out.println("filtering:" + dish.getName());
return dish.getCalories() > 300;
}) *1*
.map(dish -> {
System.out.println("mapping:" + dish.getName());
return dish.getName();
}) *2*
.limit(3)
.collect(toList());
System.out.println(names);
-
1 在过滤过程中打印菜式
-
2 按提取名称的顺序打印菜式
当执行此代码时,将打印以下内容:
filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]
通过这样做,你可以注意到 Streams 库通过利用流的惰性特性执行了几个优化。首先,尽管许多菜式的卡路里超过 300,但只选择了前三个!这是因为 limit 操作和称为 短路 的技术,我们将在下一章中解释。其次,尽管 filter 和 map 是两个不同的操作,但它们被合并到了同一个遍历中(编译器专家称这种技术为 循环融合)。
4.4.2. 终端操作
终端操作从流管道生成一个结果。结果可以是任何非流值,例如 List、Integer,甚至是 void。例如,在下面的管道中,forEach 是一个终端操作,它返回 void 并对源中的每个菜式应用 lambda。将 System.out.println 传递给 forEach 是要求它打印从 menu 创建的流中的每个 Dish:
menu.stream().forEach(System.out::println);
为了检验你对中间操作与终端操作的理解,尝试练习 4.2。
练习 4.2:中间操作与终端操作
在下面的流管道中,你能识别出中间操作和终端操作吗?
long count = menu.stream()
.filter(dish -> dish.getCalories() > 300)
.distinct()
.limit(3)
.count();
答案:
流管道中的最后一个操作 count 返回一个 long,这是一个非流值。因此,它是一个 终端操作。所有之前的操作,filter、distinct、limit,都是连接的,并返回一个流。因此,它们是 中间操作。
4.4.3. 使用流
总结起来,使用流通常涉及三个项目:
-
数据源(如集合)用于执行查询
-
形成流管道的 中间操作 链
-
执行流管道并产生结果的 终端操作
流管道背后的思想与构建器模式类似(见 en.wikipedia.org/wiki/Builder_pattern)。在构建器模式中,有一系列设置配置的调用链(对于流来说,这是一系列中间操作),然后是一个调用 build 方法的调用(对于流来说,这是一个终端操作)。
为了方便起见,表 4.1 和 4.2 总结了您在迄今为止的代码示例中看到的中间和终端流操作。请注意,这是 Streams API 提供的操作的不完整列表;您将在下一章中看到更多操作!
表 4.1. 中间操作
| 操作 | 类型 | 返回类型 | 操作的参数 | 函数描述符 |
|---|---|---|---|---|
| filter | 中间操作 | Stream |
Predicate |
T -> boolean |
| map | 中间操作 | Stream |
Function<T, R> | T -> R |
| limit | 中间操作 | Stream |
||
| sorted | 中间操作 | Stream |
Comparator |
(T, T) -> int |
| distinct | 中间操作 | Stream |
表 4.2. 终端操作
| 操作 | 类型 | 返回类型 | 目的 |
| --- | --- | --- | --- | --- |
| forEach | 终端操作 | void | 消费流中的每个元素,并对每个元素应用 lambda 函数。 |
| count | 终端操作 | long | 返回流中的元素数量。 |
| collect | 终端操作 | (泛型) | 将流缩减为一个集合,如 List、Map 或甚至 Integer。有关更多详细信息,请参阅 第六章。 |
4.5. 路线图
在下一章中,我们将详细说明可用的流操作及其用例,以便您可以看到您可以使用它们表达哪些类型的查询。我们将探讨许多模式,如过滤、切片、查找、匹配、映射和归约,这些模式可以用来表达复杂的数据处理查询。
第六章 详细探讨了收集器。在本章中,我们只使用了流上的 collect() 终端操作(见 表 4.2),其形式为 collect(toList()),它创建了一个 List,其元素与应用于它的流的元素相同。
摘要
-
流是从支持数据处理操作的数据源中获取的一系列元素。
-
流使用内部迭代:迭代通过
filter、map和sorted等操作被抽象化。 -
有两种类型的流操作:中间操作和终端操作。
-
中间操作,如
filter和map,返回一个流并可以串联在一起。它们用于设置操作管道,但不会产生任何结果。 -
终端操作,如
forEach和count,返回非流值并处理流管道以返回结果。 -
流的元素是按需计算的(“延迟”计算)。
第五章. 使用流
本章涵盖
-
过滤、切片和映射
-
查找、匹配和归约
-
使用数值流(原始流特殊化)
-
从多个来源创建流
-
无限流
在上一章中,你看到流让你从外部迭代转移到内部迭代。你不需要编写代码,如下所示,其中你明确管理数据集合的迭代(外部迭代),
List<Dish> vegetarianDishes = new ArrayList<>();
for(Dish d: menu) {
if(d.isVegetarian()){
vegetarianDishes.add(d);
}
}
你可以使用 Streams API(内部迭代),它支持filter和collect操作,来为你管理数据集合的迭代。你需要做的只是将过滤行为作为参数传递给filter方法:
import static java.util.stream.Collectors.toList;
List<Dish> vegetarianDishes =
menu.stream()
.filter(Dish::isVegetarian)
.collect(toList());
这种不同的数据处理方式很有用,因为你可以让 Streams API 管理如何处理数据。因此,Streams API 可以在幕后进行优化。此外,使用内部迭代,Streams API 可以决定是否并行运行你的代码。使用外部迭代,这是不可能的,因为你承诺进行单线程的逐步顺序迭代。
在本章中,你将全面了解 Streams API 支持的各项操作。你将了解 Java 8 中可用的操作以及 Java 9 中的新增功能。这些操作将让你能够表达复杂的数据处理查询,如过滤、切片、映射、查找、匹配和归约。接下来,我们将探讨流的特殊情况:数值流、从多个来源(如文件和数组)构建的流,以及最终的无限流。
5.1. 过滤
在本节中,我们将探讨选择流元素的方法:使用谓词进行过滤和过滤唯一元素。
5.1.1. 使用谓词进行过滤
Stream接口支持一个filter方法(你现在应该很熟悉了)。这个操作接受一个谓词(返回boolean值的函数)作为参数,并返回一个包含所有匹配谓词的元素的流。例如,你可以通过过滤所有素食菜肴来创建一个素食菜单,如图 5.1 所示及其后的代码:
图 5.1. 使用谓词过滤流

List<Dish> vegetarianMenu = menu.stream()
.filter(Dish::isVegetarian) *1*
.collect(toList());
- 1 使用方法引用来检查一道菜是否适合素食者食用。
5.1.2. 过滤唯一元素
流还支持一个名为 distinct 的方法,该方法返回一个包含唯一元素的流(根据流生成的对象的 hashcode 和 equals 方法的实现)。例如,以下代码从列表中过滤出所有偶数,然后消除重复项(使用 equals 方法进行比较)。图 5.2 以可视化的方式展示了这一点。
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
.filter(i -> i % 2 == 0)
.distinct()
.forEach(System.out::println);
图 5.2. 在流中过滤唯一元素

5.2. 切片流

在本节中,我们将讨论如何以不同的方式在流中选择和跳过元素。有操作可以让你高效地使用谓词选择或丢弃元素,忽略流的前几个元素,或者截断流到给定的大小。
5.2.1. 使用谓词切片
Java 9 添加了两个对在流中高效选择元素有用的新方法:takeWhile 和 dropWhile。
使用 takeWhile
假设你有一个以下特殊的菜肴列表:
List<Dish> specialMenu = Arrays.asList(
new Dish("seasonal fruit", true, 120, Dish.Type.OTHER),
new Dish("prawns", false, 300, Dish.Type.FISH),
new Dish("rice", true, 350, Dish.Type.OTHER),
new Dish("chicken", false, 400, Dish.Type.MEAT),
new Dish("french fries", true, 530, Dish.Type.OTHER));
你会如何选择少于 320 卡路里的菜肴?从上一节中,你可能会本能地知道可以使用 filter 操作如下:
List<Dish> filteredMenu
= specialMenu.stream()
.filter(dish -> dish.getCalories() < 320)
.collect(toList()); *1*
- 1 列出季节性水果,虾
但是,你会注意到初始列表已经按卡路里数量排序了!在这里使用 filter 操作的缺点是,你需要遍历整个流,并且谓词应用于每个元素。相反,你可以在找到卡路里大于(或等于)320 的菜肴时停止。对于小型列表,这可能看起来不是什么大好处,但如果你处理的是可能非常大的元素流,它就会变得有用。但你怎么指定这个?takeWhile 操作就是为了解决这个问题而存在的!它允许你使用谓词切片任何流(甚至是一个无限流,你将在后面学到),但幸运的是,它会在找到不匹配的元素时停止。以下是如何使用它的示例:
List<Dish> slicedMenu1
= specialMenu.stream()
.takeWhile(dish -> dish.getCalories() < 320)
.collect(toList()); *1*
- 1 列出季节性水果,虾
使用 dropWhile
那么其他元素怎么办?如何找到卡路里超过 320 的元素?你可以使用 dropWhile 操作来完成这个任务:
List<Dish> slicedMenu2
= specialMenu.stream()
.dropWhile(dish -> dish.getCalories() < 320)
.collect(toList()); *1*
- 1 列出米饭,鸡肉,薯条
dropWhile 操作是 takeWhile 的补充。它丢弃起始处为假值的元素。一旦谓词评估为真,它就会停止并返回所有剩余的元素,即使剩余元素是无限数量的也能工作!
5.2.2. 截断流
流支持 limit(n) 方法,该方法返回另一个长度不超过给定大小的流。请求的大小作为参数传递给 limit。如果流是有序的,则返回前 n 个元素,最多不超过 n。例如,你可以通过以下方式创建一个 List,选择前三个卡路里超过 300 的菜肴:
List<Dish> dishes = specialMenu
.stream()
.filter(dish -> dish.getCalories() > 300)
.limit(3)
.collect(toList()); *1*
- 1 列出米饭,鸡肉,薯条
图 5.3 展示了filter和limit的组合。你可以看到,只有符合谓词的前三个元素被选中,并且结果立即返回。
图 5.3. 截断流

注意,limit也适用于无序流(例如,如果源是Set)。在这种情况下,你不应该假设limit产生的结果有任何顺序。
5.2.3. 跳过元素
流支持skip(n)方法,用于返回一个丢弃前n个元素的流。如果流中的元素少于n个,则返回一个空流。请注意,limit(n)和skip(n)是互补的!例如,以下代码跳过了前两个超过 300 卡路里的菜品,并返回了剩余的菜品。图 5.4 展示了这个查询。
List<Dish> dishes = menu.stream()
.filter(d -> d.getCalories() > 300)
.skip(2)
.collect(toList());
图 5.4. 跳过流中的元素

在我们转向映射操作之前,请将本节学到的内容通过练习 5.1 进行实践。
练习 5.1:过滤
你将如何使用流来过滤前两个肉菜?
答案:
你可以通过组合filter和limit方法并使用collect(toList())将流转换为列表来解决此问题:
List<Dish> dishes =
menu.stream()
.filter(dish -> dish.getType() == Dish.Type.MEAT)
.limit(2)
.collect(toList());
5.3. 映射
从某些对象中选择信息是一种常见的数据处理惯用方法。例如,在 SQL 中,你可以从表中选择特定的列。Streams API 通过map和flatMap方法提供了类似的设施。
5.3.1. 将函数应用于流中的每个元素
流支持map方法,该方法接受一个函数作为参数。该函数应用于每个元素,将其映射到新元素(单词mapping用于表示,因为它有一个类似于transforming的含义,但带有“创建新版本”而不是“修改”的细微差别)。例如,在以下代码中,你将方法引用Dish::getName传递给map方法来提取流中的菜品名称:
List<String> dishNames = menu.stream()
.map(Dish::getName)
.collect(toList());
因为getName方法返回一个字符串,所以map方法输出的流类型为Stream<String>。
让我们用一个稍微不同的例子来巩固你对map的理解。给定一个单词列表,你想要返回每个单词的字符数列表。你将如何做?你需要对列表中的每个元素应用一个函数。这听起来像是map方法的用武之地!要应用的函数应该接受一个单词并返回其长度。你可以通过将方法引用String::length传递给map来解决此问题:
List<String> words = Arrays.asList("Modern", "Java", "In", "Action");
List<Integer> wordLengths = words.stream()
.map(String::length)
.collect(toList());
让我们回到提取每个菜品名称的例子。如果你想知道每个菜品名称的长度,你可以通过以下方式链式使用另一个map:
List<Integer> dishNameLengths = menu.stream()
.map(Dish::getName)
.map(String::length)
.collect(toList());
5.3.2. 扁平化流
你已经看到了如何使用 map 方法返回列表中每个单词的长度。让我们进一步扩展这个想法:你如何返回一个单词列表的所有 唯一字符 的列表?例如,给定单词列表 ["Hello," "World"],你希望返回列表 ["H," "e," "l," "o," "W," "r," "d"]。
你可能认为这很简单,你可以将每个单词映射到一个字符列表,然后调用 distinct 来过滤重复的字符。第一次尝试可能如下所示:
words.stream()
.map(word -> word.split(""))
.distinct()
.collect(toList());
这种方法的问题在于传递给 map 方法的 lambda 返回每个单词的 String[](字符串数组)。map 方法返回的流是 Stream<String[]> 类型。你想要的应该是 Stream<String> 来表示字符流。图 5.5 展示了这个问题。
图 5.5. 使用 map 错误地从单词列表中找到唯一字符

幸运的是,有一个使用 flatMap 方法的解决方案来解决这个问题!让我们一步一步地看看如何解决这个问题。
尝试使用 map 和 Arrays.stream
首先,你需要一个字符流而不是数组流。有一个名为 Arrays.stream() 的方法,它接受一个数组并产生一个流:
String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);
在之前的管道中使用它,看看会发生什么:
words.stream()
.map(word -> word.split("")) *1*
.map(Arrays::stream) *2*
.distinct()
.collect(toList());
-
1 将每个单词转换为它各个字母的数组
-
2 将每个数组转换为单独的流
当前的解决方案仍然不起作用!这是因为你现在得到了一个流列表(更准确地说,是 List<Stream<String>>)。确实,你首先将每个单词转换成它各个字母的数组,然后将每个数组转换成单独的流。
使用 flatMap
你可以通过以下方式使用 flatMap 来解决这个问题:
List<String> uniqueCharacters =
words.stream()
.map(word -> word.split("")) *1*
.flatMap(Arrays::stream) *2*
.distinct()
.collect(toList());
-
1 将每个单词转换为它各个字母的数组
-
2 将生成的每个流扁平化为单个流
使用 flatMap 方法的效果是将每个数组映射到一个流,而不是映射到流的内容。使用 map(Arrays::stream) 生成时产生的所有单独的流都被合并——扁平化为一个单一的流。图 5.6 展示了使用 flatMap 方法的效果。与图 5.5 中的 map 的效果进行比较。
图 5.6. 使用 flatMap 从单词列表中找到唯一字符

简而言之,flatMap 方法允许你用另一个流替换流中的每个值,然后将所有生成的流连接成一个单一的流。
当我们讨论更高级的 Java 8 模式,例如使用新的库类 Optional 进行 null 检查时,我们将在第十一章回到 flatMap。为了巩固你对 map 和 flatMap 的理解,尝试练习 5.2。
练习 5.2:映射
1. 给定一个数字列表,你如何返回每个数字的平方列表?例如,给定 [1, 2, 3, 4, 5],你应该返回 [1, 4, 9, 16, 25]。
答案:
你可以通过使用带有 lambda 的map来解决此问题,该 lambda 接受一个数字并返回该数字的平方:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares =
numbers.stream()
.map(n -> n * n)
.collect(toList());
- 给定两个数字列表,你将如何返回所有数字对?例如,给定一个列表 [1, 2, 3] 和一个列表 [3, 4],你应该返回 [(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。为了简单起见,你可以将一对表示为包含两个元素的数组。
答案:
你可以使用两个map来遍历两个列表并生成对。但这将返回一个Stream<Stream<Integer[]>>。你需要做的是展平生成的流,以得到一个Stream<Integer[]>。这就是flatMap的作用:
List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs =
numbers1.stream()
.flatMap(i -> numbers2.stream()
.map(j -> new int[]{i, j})
)
.collect(toList());
- 你会如何扩展前面的例子以返回和为 3 的倍数的所有对?
答案:
你之前看到filter可以与谓词一起使用,从流中过滤元素。因为flatMap操作后,你有一个表示对的int[]流,你只需要一个谓词来检查和是否为 3 的倍数:
List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs =
numbers1.stream()
.flatMap(i ->
numbers2.stream()
.filter(j -> (i + j) % 3 == 0)
.map(j -> new int[]{i, j})
)
.collect(toList());
结果是 [(2, 4), (3, 3)]。
5.4. 查找和匹配
另一个常见的数据处理惯用方法是检查数据集中的一些元素是否匹配给定的属性。Streams API 通过流的方法allMatch、anyMatch、noneMatch、findFirst和findAny提供了这样的功能。
5.4.1. 检查谓词是否匹配至少一个元素
anyMatch方法可以用来回答“流中是否存在匹配给定谓词的元素?”例如,你可以用它来找出菜单中是否有素食选项:
if(menu.stream().anyMatch(Dish::isVegetarian)) {
System.out.println("The menu is (somewhat) vegetarian friendly!!");
}
anyMatch方法返回一个boolean,因此是一个终端操作。
5.4.2. 检查谓词是否匹配所有元素
allMatch方法与anyMatch类似,但会检查流中的所有元素是否匹配给定的谓词。例如,你可以用它来找出菜单是否健康(所有菜品的热量都低于 1000 卡路里):
boolean isHealthy = menu.stream()
.allMatch(dish -> dish.getCalories() < 1000);
noneMatch
allMatch的相反操作是noneMatch。它确保流中没有任何元素匹配给定的谓词。例如,你可以使用noneMatch将前面的例子重写如下:
boolean isHealthy = menu.stream()
.noneMatch(d -> d.getCalories() >= 1000);
这三个操作——anyMatch、allMatch和noneMatch——利用了我们所说的短路,这是熟悉的 Java 短路&&和||运算符的流版本。
短路评估
一些操作不需要处理整个流就能产生结果。例如,假设你需要评估一个由and运算符连接的大boolean表达式。你只需要找出其中一个表达式是false,就可以推断出整个表达式将返回false,无论表达式有多长;没有必要评估整个表达式。这就是短路的含义。
与流相关,某些操作如allMatch、noneMatch、findFirst和findAny不需要处理整个流来产生结果。一旦找到元素,就可以产生结果。同样,limit也是一个短路操作。该操作只需要创建一个给定大小的流,而不需要处理流中的所有元素。这样的操作很有用(例如,当你需要处理无限大小的流时,因为它们可以将无限流转换为有限大小的流)。我们将在第 5.7 节中展示无限流的示例。
5.4.3. 查找元素
findAny方法返回当前流的任意元素。它可以与其他流操作结合使用。例如,你可能希望找到一个素食菜。你可以将filter方法和findAny结合起来表达这个查询:
Optional<Dish> dish =
menu.stream()
.filter(Dish::isVegetarian)
.findAny();
在幕后,流管道将被优化以执行单次遍历,并在找到结果后立即完成,这是通过短路实现的。但是等等;代码中的这个Optional是什么东西?
Optional 概述
Optional<T>类(java.util.Optional)是一个容器类,用于表示值的存在或不存在。在之前的代码中,findAny可能找不到任何元素。而不是返回null,这众所周知是容易出错的,Java 8 库设计者引入了Optional<T>。我们不会在这里详细介绍Optional,因为我们将在第十一章中详细展示如何使用Optional来避免与null检查相关的错误。但到目前为止,了解Optional中有一些方法可以强制你显式检查值的存在或处理值不存在的情况是很好的:
-
isPresent()方法返回true如果Optional包含一个值,否则返回false。 -
ifPresent(Consumer<T> block)如果存在值,则执行给定的块。我们在第三章中介绍了Consumer函数式接口;它允许你传递一个接受类型为T的参数并返回void的 lambda 表达式。 -
T get()如果存在值,则返回该值;否则抛出NoSuchElementException。 -
T orElse(T other)如果存在值,则返回该值;否则返回默认值。
例如,在之前的代码中,你需要显式检查Optional对象中是否存在一个菜,才能访问其名称:
menu.stream()
.filter(Dish::isVegetarian)
.findAny() *1*
.ifPresent(dish -> System.out.println(dish.getName()); *2*
-
1 返回一个
Optional<Dish>。 -
2 如果包含值,则打印;否则不发生任何操作。
5.4.4. 查找第一个元素
一些流有一个 encounter order,它指定了项目在流中逻辑上出现的顺序(例如,从 List 或排序的数据序列生成的流)。对于这样的流,你可能希望找到第一个元素。有 findFirst 方法可以做到这一点,它的工作方式与 findAny 类似(例如,下面的代码给出了一个数字列表,找到第一个能被 3 整除的平方数):
List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> firstSquareDivisibleByThree =
someNumbers.stream()
.map(n -> n * n)
.filter(n -> n % 3 == 0)
.findFirst(); // 9
何时使用 findFirst 和 findAny
你可能会想知道为什么我们既有 findFirst 又有 findAny。答案是并行处理。在并行处理中找到第一个元素有更多的限制。如果你不关心返回哪个元素,可以使用 findAny,因为它在并行流中使用时限制较少。
5.5. Reducing
你所看到的终端操作要么返回一个 boolean(allMatch 等),要么返回 void(forEach),或者返回一个 Optional 对象(findAny 等)。你还在使用 collect 将流中的所有元素组合成一个 List。
在本节中,你将了解如何使用 reduce 操作将流中的元素组合起来,以表达更复杂的查询,例如“计算菜单中所有卡路里的总和”,或者“菜单中最高卡路里的菜品是什么?”这样的查询会将流中的所有元素反复组合,以产生一个单一值,例如一个 Integer。这些查询可以归类为 reduction operations(流被缩减为一个值)。在函数式编程语言的术语中,这被称为 fold,因为你可以将这个操作视为反复折叠一张长纸(你的流)直到它形成一个小的正方形,这就是折叠操作的结果。
5.5.1. 求和元素
在我们探讨如何使用 reduce 方法之前,先看看如何使用 for-each 循环来计算一个数字列表的元素总和是有帮助的:
int sum = 0;
for (int x : numbers) {
sum += x;
}
numbers 的每个元素都会通过迭代使用加法运算符来组合成一个结果。你通过反复使用加法来 reduce 数字列表到一个数字。这段代码中有两个参数:
-
总和变量的初始值,在这种情况下是
0 -
将列表中的所有元素组合起来的操作,在这种情况下是
+
如果你可以不重复粘贴代码就能将所有数字相乘,那岂不是很好?这就是 reduce 操作发挥作用的地方,它抽象了这种重复应用的模式。你可以如下方式计算流中所有元素的总和:
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
reduce 接受两个参数:
-
一个初始值,这里
0。 -
一个
BinaryOperator<T>用于合并两个元素并产生一个新值;在这里你使用的是 lambda 表达式(a, b) -> a + b。
你也可以通过传递不同的 lambda 表达式 (a, b) -> a * b 到 reduce 操作中,来轻松地将所有元素相乘:
int product = numbers.stream().reduce(1, (a, b) -> a * b);
图 5.7 展示了 reduce 操作在流上的工作方式:lambda 重复组合每个元素,直到包含整数 4、5、3、9 的流被缩减为一个单一值。
图 5.7. 使用 reduce 对流中的数字求和

让我们深入探讨 reduce 操作是如何将数字流求和的。首先,0 被用作 lambda 的第一个参数(a),然后从流中消耗 4 并用作第二个参数(b)。0 + 4 产生 4,这成为新的累积值。然后 lambda 再次被调用,使用累积值和流的下一个元素 5,这产生了新的累积值 9。继续前进,lambda 再次被调用,使用累积值和下一个元素 3,这产生了 12。最后,lambda 被调用,使用 12 和流的最后一个元素 9,这产生了最终值 21。
你可以通过使用方法引用使这段代码更加简洁。从 Java 8 开始,Integer 类现在包含一个静态的 sum 方法来添加两个数字,这正是你想要的,而不是反复编写相同的 lambda 代码:
int sum = numbers.stream().reduce(0, Integer::sum);
没有初始值
reduce 也有一个不带初始值的重载变体,它返回一个 Optional 对象:
Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));
为什么它返回 Optional<Integer>?考虑流中没有元素的情况。reduce 操作不能返回一个和,因为它没有初始值。这就是为什么结果被包裹在一个 Optional 对象中,以表明和可能不存在。现在看看你还能用 reduce 做些什么。
5.5.2. 最大值和最小值
结果表明,计算最大值和最小值只需要 reduce 操作!让我们看看如何将你刚刚学到的关于 reduce 的知识应用到计算流中的最大或最小元素。正如你所看到的,reduce 接受两个参数:
-
初始值
-
一个用于组合两个流元素并产生新值的 lambda
lambda 按步骤应用于流中的每个元素,使用加法运算符,如 图 5.7 所示。你需要一个 lambda,给定两个元素,返回它们的最大值。reduce 操作将使用新的值与流的下一个元素一起产生一个新的最大值,直到整个流被消耗!你可以如下使用 reduce 来计算流中的最大值;这在 图 5.8 中有说明。
Optional<Integer> max = numbers.stream().reduce(Integer::max);
要计算最小值,你需要将 Integer.min 传递给 reduce 操作,而不是 Integer.max:
Optional<Integer> min = numbers.stream().reduce(Integer::min);
你同样可以使用 lambda (x, y) -> x < y ? x : y 来代替 Integer-::min,但后者显然更容易阅读!
图 5.8. reduce 操作——计算最大值

为了测试你对 reduce 操作的理解,尝试练习 5.3。
练习 5.3:reduce
你将如何使用 map 和 reduce 方法来计算流中菜品的数量?
答案:
你可以通过将流中的每个元素映射到数字 1,然后使用 reduce 求和来解决此问题!这相当于按顺序计数流中的元素数量:
int count = menu.stream()
.map(d -> 1)
.reduce(0, (a, b) -> a + b);
map 和 reduce 的链通常被称为 map-reduce 模式,由于 Google 在网页搜索中使用它而闻名,因为它可以很容易地并行化。注意,在第四章(kindle_split_015.xhtml#ch04)中,你看到了内置方法 count 来计算流中的元素数量:
long count = menu.stream().count();
减少方法和并行性的好处
使用 reduce 相比于你之前编写的逐步迭代求和的好处是,迭代被内部迭代抽象化,这使得内部实现可以选择并行执行 reduce 操作。迭代求和示例涉及到对 sum 变量的共享更新,这并不容易并行化。如果你添加所需的同步,你可能会发现线程竞争会剥夺你本应从并行性中获得的所有性能!并行化这个计算需要不同的方法:划分输入,求和分区,然后合并求和。但现在代码开始看起来非常不同。你将在第七章中看到使用 fork/join 框架的样子。但到目前为止,重要的是要意识到可变累加器模式对于并行化来说是一个死胡同。你需要一个新的模式,这正是 reduce 为你提供的。你还会在第七章中看到,为了并行求和流中的所有元素,几乎不需要修改你的代码:stream() 变为 parallelStream():
int sum = numbers.parallelStream().reduce(0, Integer::sum);
但执行此代码并行化需要付出代价,我们将在后面解释:传递给 reduce 的 lambda 不能改变状态(例如,实例变量),并且操作需要是结合律和交换律,这样它就可以按任何顺序执行。
你已经看到了产生 Integer 的归约示例:流的和、流的最大值或流的元素数量。你将在 5.6 节中看到,有额外的内置方法如 sum 和 max 可用于帮助你编写更简洁的代码,用于常见的归约模式。我们将在下一章中调查使用 collect 方法的更复杂形式的归约。例如,你不仅可以把流归约成一个 Integer,如果你想要按类型分组菜品,你也可以把它归约成一个 Map。
流操作:无状态与有状态
你已经看到了很多流操作。一个初步的介绍可能会使它们看起来像万能药。一切都很顺利,当你使用 parallelStream 而不是 stream 从集合中获取流时,你会免费获得并行性。
当然,对于许多应用来说,情况确实如此,正如你在前面的例子中看到的。你可以将菜肴列表转换为流,使用filter选择特定类型的各种菜肴,然后map到结果流中添加卡路里数,最后reduce以生成菜单的总卡路里数。你甚至可以在并行中进行这样的流计算。但是,这些操作有不同的特性。它们在操作时需要什么样的内部状态存在一些问题。
map和filter这样的操作从输入流中取每个元素,并在输出流中产生零个或一个结果。这些操作通常是无状态的:它们没有内部状态(假设用户提供的 lambda 表达式或方法引用没有内部可变状态)。
但是像reduce、sum和max这样的操作需要内部状态来累积结果。在这种情况下,内部状态很小。在我们的例子中,它由一个int或double组成。无论正在处理的流中有多少元素,内部状态的大小都是有限的。
相比之下,一些操作如sorted或distinct最初看起来像filter或map——所有这些操作都接受一个流并产生另一个流(一个中间操作),但有一个关键的区别。排序和从流中删除重复项都需要知道之前的历史才能完成它们的工作。例如,排序需要在向输出流添加单个项目之前将所有元素缓冲;该操作的存储需求是无界的。如果数据流很大或无限,这可能会成为问题。(反转所有质数流应该做什么?它应该返回最大的质数,数学告诉我们这是不存在的。)我们称这些操作为有状态的操作。
你现在已经看到了很多可以用来表达复杂数据处理查询的流操作!表 5.1 总结了迄今为止看到的操作。你将在下一节通过练习来练习它们。
表 5.1. 中间和终端操作
| 操作 | 类型 | 返回类型 | 类型/功能接口 | 函数描述符 |
|---|---|---|---|---|
| filter | 中间操作 | Stream |
Predicate |
T -> boolean |
| distinct | 中间操作(有状态-无界) | Stream |
||
| takeWhile | 中间操作 | Stream |
Predicate |
T -> boolean |
| dropWhile | 中间操作 | Stream |
Predicate |
T -> boolean |
| skip | 中间操作(有状态-有界) | Stream |
long | |
| limit | 中间操作(有状态-有界) | Stream |
long | |
| map | 中间操作 | Stream |
Function<T, R> | T -> R |
| flatMap | 中间操作 | Stream |
Function<T, Stream |
T -> Stream |
| sorted | 中间操作(有状态-无界) | Stream |
Comparator |
(T, T) -> int |
| anyMatch | 终端操作 | boolean | Predicate |
T -> boolean |
| noneMatch | 终端 | boolean | Predicate |
T -> boolean |
| allMatch | 终端 | boolean | Predicate |
T -> boolean |
| findAny | 终端 | Optional |
||
| findFirst | 终端 | Optional |
||
| forEach | 终端 | void | Consumer |
T -> void |
| collect | 终端 | R | Collector<T, A, R> | |
| reduce | 终端(有状态有界) | Optional |
BinaryOperator |
(T, T) -> T |
| count | 终端 | long |
5.6. 将所有内容付诸实践
在本节中,你可以练习到目前为止所学的关于流的知识。我们现在探索一个不同的域:执行交易的交易者。你的经理要求你找到对八个查询的答案。你能做到吗?我们在 5.6.2 节中给出了解决方案,但你应该先尝试一下,以获得一些练习:
-
找到 2011 年所有的交易并按价值排序(从小到大)。
-
交易者工作过的所有独特城市有哪些?
-
找到所有来自剑桥的交易者并按名称排序。
-
返回一个按字母顺序排序的所有交易者名称的字符串。
-
有任何交易者基于米兰吗?
-
打印居住在剑桥的交易者的所有交易的价值。
-
所有交易中价值最高的是多少?
-
找到价值最小的交易。
5.6.1. 域:交易者和交易
这里是你将工作的域,一个包含Traders 和Transactions 的列表:
Trader raoul = new Trader("Raoul", "Cambridge");
Trader mario = new Trader("Mario","Milan");
Trader alan = new Trader("Alan","Cambridge");
Trader brian = new Trader("Brian","Cambridge");
List<Transaction> transactions = Arrays.asList(
new Transaction(brian, 2011, 300),
new Transaction(raoul, 2012, 1000),
new Transaction(raoul, 2011, 400),
new Transaction(mario, 2012, 710),
new Transaction(mario, 2012, 700),
new Transaction(alan, 2012, 950)
);
Trader和Transaction是如下定义的类:
public class Trader{
private final String name;
private final String city;
public Trader(String n, String c){
this.name = n;
this.city = c;
}
public String getName(){
return this.name;
}
public String getCity(){
return this.city;
}
public String toString(){
return "Trader:"+this.name + " in " + this.city;
}
}
public class Transaction{
private final Trader trader;
private final int year;
private final int value;
public Transaction(Trader trader, int year, int value){
this.trader = trader;
this.year = year;
this.value = value;
}
public Trader getTrader(){
return this.trader;
}
public int getYear(){
return this.year;
}
public int getValue(){
return this.value;
}
public String toString(){
return "{" + this.trader + ", " +
"year: "+this.year+", " +
"value:" + this.value +"}";
}
}
5.6.2. 解决方案
我们现在在以下代码列表中提供解决方案,以便你可以验证你对所学内容的理解。做得好!
列表 5.1. 找到 2011 年所有的交易并按价值排序(从小到大)
List<Transaction> tr2011 =
transactions.stream()
.filter(transaction -> transaction.getYear() == 2011) *1*
.sorted(comparing(Transaction::getValue)) *2*
.collect(toList()); *3*
-
1 传递一个谓词以过滤选择 2011 年的交易
-
2 使用交易的价值对它们进行排序
-
3 将结果流的全部元素收集到一个列表中
列表 5.2. 交易者工作过的所有独特城市有哪些?
List<String> cities =
transactions.stream()
.map(transaction -> transaction.getTrader().getCity()) *1*
.distinct() *2*
.collect(toList());
-
1 从与交易关联的交易者中提取城市
-
2 仅选择独特的城市
你还没有看到这个,但你也可以省略distinct()并使用toSet()代替,这将把流转换成集合。你将在第六章中了解更多关于它的内容。
Set<String> cities =
transactions.stream()
.map(transaction -> transaction.getTrader().getCity())
.collect(toSet());
列表 5.3. 找到所有来自剑桥的交易者并按名称排序
List<Trader> traders =
transactions.stream()
.map(Transaction::getTrader) *1*
.filter(trader -> trader.getCity().equals("Cambridge")) *2*
.distinct() *3*
.sorted(comparing(Trader::getName)) *4*
.collect(toList());
-
1 从交易中提取所有交易者
-
2 仅选择来自剑桥的交易者
-
3 移除任何重复项
-
4 按交易者的名称对生成的流进行排序
列表 5.4. 返回一个按字母顺序排序的所有交易者名称的字符串
String traderStr =
transactions.stream()
.map(transaction -> transaction.getTrader().getName()) *1*
.distinct() *2*
.sorted() *3*
.reduce("", (n1, n2) -> n1 + n2); *4*
-
1 提取所有交易者的名称作为字符串流
-
2 移除重复的名称
-
3 按字母顺序排序名称
-
4 逐个合并名称以形成一个包含所有名称的字符串
注意,这个解决方案效率不高(所有字符串都会被反复连接,这会在每次迭代中创建一个新的 String 对象)。在下一章中,你将看到一种更高效的解决方案,它使用 joining() 如下(内部使用 StringBuilder):
String traderStr =
transactions.stream()
.map(transaction -> transaction.getTrader().getName())
.distinct()
.sorted()
.collect(joining());
列表 5.5. 是否有任何商人是基于米兰的?
boolean milanBased =
transactions.stream()
.anyMatch(transaction -> transaction.getTrader()
.getCity()
.equals("Milan")); *1*
- 1 将一个谓词传递给 anyMatch 以检查是否有来自米兰的商人。
列表 5.6. 打印居住在剑桥的商人的所有交易值
transactions.stream()
.filter(t -> "Cambridge".equals(t.getTrader().getCity())) *1*
.map(Transaction::getValue) *2*
.forEach(System.out::println); *3*
-
1 选择居住在剑桥的交易
-
2 提取这些交易的值
-
3 打印每个值
列表 5.7. 所有交易中最高值是多少?
Optional<Integer> highestValue =
transactions.stream()
.map(Transaction::getValue) *1*
.reduce(Integer::max); *2*
-
1 提取每笔交易的价值
-
2 计算结果流的最大值
列表 5.8. 找到价值最小的交易
Optional<Transaction> smallestTransaction =
transactions.stream()
.reduce((t1, t2) ->
t1.getValue() < t2.getValue() ? t1 : t2); *1*
- 1 通过反复比较每笔交易的价值来找到最小的交易
你可以做得更好。流支持 min 和 max 方法,这些方法接受一个 Comparator 作为参数,以指定在计算最小值或最大值时与哪个键进行比较:
Optional<Transaction> smallestTransaction =
transactions.stream()
.min(comparing(Transaction::getValue));
5.7. 数值流
你之前已经看到可以使用 reduce 方法来计算流中元素的总和。例如,你可以这样计算菜单中的卡路里总数:
int calories = menu.stream()
.map(Dish::getCalories)
.reduce(0, Integer::sum);
这个代码的问题在于存在一个隐蔽的装箱成本。在幕后,每个 Integer 需要被解箱为原始类型,然后才能执行求和操作。此外,如果你可以直接像下面这样调用 sum 方法,不是更好吗?
int calories = menu.stream()
.map(Dish::getCalories)
.sum();
但这是不可能的。问题是 map 方法生成一个 Stream<T>。即使流中的元素类型为 Integer,Streams 接口也没有定义 sum 方法。为什么没有?假设你只有一个像 menu 这样的 Stream<Dish>;能够对菜肴求和是没有意义的。但不用担心;Streams API 还提供了 原始流特殊化,它们支持用于处理数字流的专业方法。
5.7.1. 原始流特殊化
Java 8 引入了三个原始特殊化流接口来解决这个问题,IntStream、DoubleStream 和 LongStream,它们分别将流中的元素特殊化为 int、long 和 double——从而避免了隐藏的装箱成本。这些接口中的每一个都带来了新的方法来执行常见的数值归约,例如 sum 用于计算数值流的总和,max 用于找到最大元素。此外,它们在必要时还有将它们转换回对象流的方法。需要记住的是,这些特殊化的额外复杂性并非固有的。它反映了装箱的复杂性——基于效率的 int 和 Integer 以及其他类型的差异。
映射到数值流
你将最常使用的将流转换为特殊版本的方法是 mapToInt、mapToDouble 和 mapToLong。这些方法的工作方式与之前看到的 map 方法完全相同,但返回的是特殊流而不是 Stream<T>。例如,你可以使用 mapToInt 如下计算 menu 中的卡路里总和:
int calories = menu.stream() *1*
.mapToInt(Dish::getCalories) *2*
.sum();
-
1 返回一个 Stream
-
2 返回一个 IntStream
在这里,方法 mapToInt 从每个菜品(表示为 Integer)中提取所有卡路里,并返回一个 IntStream 作为结果(而不是 Stream<Integer>)。然后你可以调用定义在 IntStream 接口上的 sum 方法来计算卡路里的总和!注意,如果流为空,sum 将默认返回 0。IntStream 还支持其他便利方法,如 max、min 和 average。
将对象流转换回
同样,一旦你有了数字流,你可能想将其转换回非特殊化流。例如,IntStream 的操作限制为产生原始整数:IntStream 的 map 操作接受一个接受 int 并产生 int(IntUnaryOperator)的 lambda。但你可能想产生不同的值,例如 Dish。为此,你需要访问定义在 Streams 接口上的更通用的操作。要从原始流转换为通用流(每个 int 将被装箱为 Integer),你可以使用 boxed 方法,如下所示:
IntStream intStream = menu.stream().mapToInt(Dish::getCalories); *1*
Stream<Integer> stream = intStream.boxed(); *2*
-
1 将流转换为数字流
-
2 将数字流转换为 Stream
你将在下一节中了解到 boxed 在处理需要装箱到通用流中的数字范围时特别有用。
默认值:OptionalInt
总和示例很方便,因为它有一个默认值:0。但如果你想在 IntStream 中计算最大元素,你需要不同的方法,因为 0 是一个错误的结果。如何区分流没有元素和实际最大值是 0 呢?我们之前介绍了 Optional 类,它是一个表示值存在或不存在的内容容器。Optional 可以与 Integer、String 等引用类型参数化。还有 Optional 的原始特殊版本,用于三种原始流特殊化:OptionalInt、OptionalDouble 和 OptionalLong。
例如,你可以通过调用 max 方法来找到 IntStream 的最大元素,该方法返回一个 OptionalInt:
OptionalInt maxCalories = menu.stream()
.mapToInt(Dish::getCalories)
.max();
你现在可以显式处理 OptionalInt 来定义一个默认值,如果没有最大值:
int max = maxCalories.orElse(1); *1*
- 1 提供一个明确的默认最大值,如果没有值
5.7.2. 数字范围
在处理数字时,一个常见的用例是处理数值范围。例如,假设你想生成 1 到 100 之间的所有数字。Java 8 在 IntStream 和 LongStream 上引入了两个静态方法来帮助生成这样的范围:range 和 rangeClosed。这两个方法都接受范围的起始值作为第一个参数,范围的结束值作为第二个参数。但是 range 是排他的,而 rangeClosed 是包含的。让我们看一个例子:
IntStream evenNumbers = IntStream.rangeClosed(1, 100) *1*
.filter(n -> n % 2 == 0); *2*
System.out.println(evenNumbers.count()); *3*
-
1 代表范围 1 到 100
-
2 代表从 1 到 100 的偶数流
-
3 代表从 1 到 100 的 50 个偶数
在这里,你使用 rangeClosed 方法生成从 1 到 100 的所有数字的范围。它产生一个流,你可以链式调用 filter 方法来选择仅包含偶数的数字。在这个阶段还没有进行任何计算。最后,你在结果流上调用 count。因为 count 是一个终端操作,它将处理流并返回结果 50,这是从 1 到 100(包括 100)的偶数数量。请注意,相比之下,如果你使用 IntStream.range(1, 100),结果将是 49 个偶数,因为 range 是排他的。
5.7.3. 将数值流应用于实践:毕达哥拉斯三元组
现在,我们将看一个更复杂的例子,这样你可以巩固你关于数值流以及你迄今为止学到的所有流操作的知识。如果你的选择是接受这个任务,你的任务是创建一个毕达哥拉斯三元组的流。
毕达哥拉斯三元组
什么是毕达哥拉斯三元组?我们需要回顾一下过去的几年。在你的数学课上,你了解到著名的古希腊数学家毕达哥拉斯发现,某些数字三元组 (a, b, c) 满足公式 a * a + b * b = c * c,其中 a、b 和 c 是整数。例如,(3, 4, 5) 是一个有效的毕达哥拉斯三元组,因为 3 * 3 + 4 * 4 = 5 * 5 或者 9 + 16 = 25。这样的三元组有无限多个。例如,(5, 12, 13)、(6, 8, 10) 和 (7, 24, 25) 都是有效的毕达哥拉斯三元组。这样的三元组很有用,因为它们描述了直角三角形的三个边长,如图 图 5.9 所示。
图 5.9. 毕达哥拉斯定理

表示三元组
你从哪里开始?第一步是定义一个三元组。而不是(更确切地)定义一个新的类来表示三元组,你可以使用一个包含三个元素的 int 数组。例如,new int[]{3, 4, 5} 来表示元组 (3, 4, 5)。现在你可以使用数组索引访问元组的每个单独的组件。
过滤好的组合
假设有人提供了三元组的前两个数字:a和b。你怎么知道这将形成一个好的组合?你需要测试a * a + b * b的平方根是否是一个整数。在 Java 中,这表示为Math.sqrt(a*a + b*b) % 1 == 0。(给定一个浮点数 x,在 Java 中,它的分数部分是通过 x % 1.0获得的,而像 5.0 这样的整数有零分数部分。)我们的代码在filter操作中使用了这个想法(你稍后会看到如何使用它来形成有效的代码):
filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
假设周围的代码已经为a提供了一个值,并且假设stream提供了b的可能值,filter将只选择那些可以与a形成勾股数的三元组的b值。
生成元组
在filter之后,你知道a和b可以形成一个正确的组合。你现在需要创建一个三元组。你可以使用map操作将每个元素转换为一个勾股数三元组,如下所示:
stream.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.map(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});
生成 b 值
你越来越接近了!你现在需要为b生成值。你看到Stream.rangeClosed允许你生成一个给定区间的数字流。你可以用它来为b提供数值,这里是从 1 到 100:
IntStream.rangeClosed(1, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.boxed()
.map(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});
注意,你在filter之后调用boxed,以从rangeClosed返回的IntStream生成一个Stream<Integer>。这是因为map为流中的每个元素返回一个int数组。IntStream的map方法期望每个流元素只返回另一个int,这不是你想要的!你可以使用IntStream的mapToObj方法重写它,它返回一个对象值流:
IntStream.rangeClosed(1, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.mapToObj(b -> new int[]{a, b, (int) Math.sqrt(a * a + b * b)});
生成值
我们假设的一个关键组件是a的值。你现在有一个生成勾股数三元的流,只要知道a的值。你怎么解决这个问题?就像b一样,你需要为a生成数值!最终的解决方案如下:
Stream<int[]> pythagoreanTriples =
IntStream.rangeClosed(1, 100).boxed()
.flatMap(a ->
IntStream.rangeClosed(a, 100)
.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
.mapToObj(b ->
new int[]{a, b, (int)Math.sqrt(a * a + b * b)})
);
好的,flatMap是什么意思?首先,你从 1 到 100 创建一个数值范围来生成a的值。对于每个给定的a值,你创建一个三元组的流。将a的值映射到三元组的流会导致一个流中的流!flatMap方法执行映射并将所有生成的三元组流合并成一个单一的流。结果,你产生了一个三元组的流。注意,你还改变了b的范围为从a到 100。没有必要从值1开始,因为这会创建重复的三元组(例如,(3, 4, 5)和(4, 3, 5))。
运行代码
你现在可以运行你的解决方案,并使用你之前看到的limit操作显式地选择从生成的流中返回多少个三元组:
pythagoreanTriples.limit(5)
.forEach(t ->
System.out.println(t[0] + ", " + t[1] + ", " + t[2]));
这将打印
3, 4, 5
5, 12, 13
6, 8, 10
7, 24, 25
8, 15, 17
你能做得更好吗?
当前解决方案不是最优的,因为您计算了平方根两次。一种使代码更紧凑的可能方法是生成所有形式为 (a*a, b*b, a*a+b*b) 的三元组,然后过滤出符合您标准的三元组:
Stream<double[]> pythagoreanTriples2 =
IntStream.rangeClosed(1, 100).boxed()
.flatMap(a ->
IntStream.rangeClosed(a, 100)
.mapToObj(
b -> new double[]{a, b, Math.sqrt(a*a + b*b)}) *1*
.filter(t -> t[2] % 1 == 0)); *2*
-
1 生成三元组
-
2 元组的第三个元素必须是整数。
5.8. 构建流
希望到现在您已经相信流是强大且非常有用的,可以用来表达数据处理查询。您能够使用 stream 方法从集合中获取流。此外,我们还向您展示了如何从一系列数字创建数值流。但您可以通过许多其他方式创建流!本节展示了您如何从一系列值、数组、文件以及甚至从生成函数创建无限流!
5.8.1. 从值流
您可以使用静态方法 Stream.of 通过显式值创建一个流,该方法可以接受任意数量的参数。例如,在以下代码中,您直接使用 Stream.of 创建一个字符串流。然后,在打印之前将字符串转换为大写:
Stream<String> stream = Stream.of("Modern ", "Java ", "In ", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);
您可以使用 empty 方法获取一个空流,如下所示:
Stream<String> emptyStream = Stream.empty();
5.8.2. 从可空类型流
在 Java 9 中,添加了一个新方法,允许您从可空对象创建流。在玩转流之后,您可能遇到了一个从可能为 null 的对象中提取对象的情况,然后需要将其转换为流(或对于 null,为空流)。例如,System.getProperty 方法在没有给定键的属性时返回 null。要与其一起使用流,您需要显式检查 null,如下所示:
String homeValue = System.getProperty("home");
Stream<String> homeValueStream
= homeValue == null ? Stream.empty() : Stream.of(value);
使用 Stream.ofNullable 您可以更简单地重写此代码:
Stream<String> homeValueStream
= Stream.ofNullable(System.getProperty("home"));
这种模式与 flatMap 和可能包含可空对象的值流结合使用时特别有用:
Stream<String> values =
Stream.of("config", "home", "user")
.flatMap(key -> Stream.ofNullable(System.getProperty(key)));
5.8.3. 从数组流
您可以使用静态方法 Arrays.stream 从数组创建一个流,该方法接受一个数组作为参数。例如,您可以将原始 int 数组转换为 IntStream,然后对 IntStream 进行求和以生成一个 int,如下所示:
int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum(); *1*
- 1 总和为 41。
5.8.4. 从文件流
Java 的 NIO API(非阻塞 I/O),用于处理文件等 I/O 操作,已更新以利用 Streams API。java.nio.file.Files 中的许多静态方法返回一个流。例如,一个有用的方法是 Files.lines,它从给定的文件返回字符串流。使用您到目前为止所学的内容,您可以使用此方法来找出文件中的唯一单词数量,如下所示:
long uniqueWords = 0;
try(Stream<String> lines =
Files.lines(Paths.get("data.txt"), Charset.defaultCharset())){ *1*
uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" "))) *2*
.distinct() *3*
.count(); *4*
}
catch(IOException e){
*5*
}
-
1 流是 AutoCloseable,因此不需要 try-finally
-
2 生成单词流
-
3 移除重复项
-
4 计算唯一单词的数量
-
5 处理打开文件时发生的异常
您可以使用 Files.lines 返回一个流,其中每个元素都是给定文件中的一行。由于流的来源是一个 I/O 资源,因此此调用被一个 try/catch 块包围。实际上,Files.lines 调用将打开一个 I/O 资源,需要关闭以避免泄漏。在过去,您需要一个显式的 finally 块来完成此操作。方便的是,Stream 接口实现了 AutoCloseable 接口。这意味着资源的管理是在 try 块中为您处理的。一旦您有了行流,您就可以通过在 line 上调用 split 方法来将每一行拆分成单词。注意您是如何使用 flatMap 来生成一个单词的扁平化流,而不是为每一行生成多个单词流。最后,您通过链式调用 distinct 和 count 方法来计算流中的每个不同单词。
5.8.5. 从函数中生成流:创建无限流!
Streams API 提供了两个静态方法来从函数生成流:Stream.iterate 和 Stream.generate。这两个操作允许您创建我们所说的 无限流,这种流没有固定的大小,就像您从固定集合创建流时那样。由 iterate 和 generate 生成的流根据函数按需创建值,因此可以无限计算值!通常,在这样流上使用 limit(n) 是合理的,以避免打印无限数量的值。
迭代
在我们解释它之前,让我们看看如何使用 iterate 的一个简单示例:
Stream.iterate(0, n -> n + 2)
.limit(10)
.forEach(System.out::println);
iterate 方法接受一个初始值,这里 0,以及一个应用于每个新产生的值的 lambda(类型为 Unary-Operator<T>)。在这里,您使用 lambda n -> n + 2 返回前一个元素加上 2。因此,iterate 方法产生一个所有偶数的流:流中的第一个元素是初始值 0。然后它加上 2 产生新值 2;再次加上 2 产生新值 4,依此类推。这个 iterate 操作本质上是有序的,因为结果依赖于前一次应用。请注意,这个操作产生一个 无限流——流没有结束,因为值是按需计算的,可以无限计算。我们说流是 无界的。正如我们之前讨论的,这是流和集合之间的一个关键区别。您使用 limit 方法来显式限制流的大小。在这里,您只选择前 10 个偶数。然后您调用 forEach 最终操作来消费流并单独打印每个元素。
通常,当您需要生成一系列连续的值时(例如,一个日期后面跟着它的下一个日期:1 月 31 日,2 月 1 日,等等),您应该使用 iterate。要查看如何应用 iterate 的更复杂示例,请尝试练习题 5.4。
练习题 5.4:斐波那契元组序列
斐波那契数列因其是经典的编程练习而闻名。以下序列中的数字是斐波那契数列的一部分:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55... 数列的前两个数字是 0 和 1,每个后续数字都是前两个数字的和。
斐波那契元组的序列类似;你有一个数字及其在序列中的后续数字的序列:(0, 1), (1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21)...。
你的任务是使用iterate方法生成斐波那契数列的前 20 个元素!
让我们帮你开始。第一个问题是iterate方法接受一个UnaryOperator<T>作为参数,你需要一个元组流,如(0, 1)。你可以再次相当草率地使用一个包含两个元素的数组来表示一个元组。例如,new int[]{0, 1}表示斐波那契数列的第一个元素(0, 1)。这将作为iterate方法的初始值:
Stream.iterate(new int[]{0, 1}, ???)
.limit(20)
.forEach(t -> System.out.println("(" + t[0] + "," + t[1] +")"));
在这个测验中,你需要找出代码中高亮的???。记住,iterate会连续应用给定的 lambda。
答案:
Stream.iterate(new int[]{0, 1},
t -> new int[]{t[1], t[0]+t[1]})
.limit(20)
.forEach(t -> System.out.println("(" + t[0] + "," + t[1] +")"));
它是如何工作的?iterate需要一个 lambda 来指定后续元素。在(3, 5)这个元组的情况下,后续元素是(5, 3+5) = (5, 8)。下一个是(8, 5+8)。你能看到模式吗?给定一个元组,其后续元素是(t[1], t[0] + t[1])。这正是以下 lambda 所指定的:t -> new int[]{t[1],t[0] + t[1]}。运行此代码,你会得到序列(0, 1), (1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21)...。注意,如果你想要打印正常的斐波那契数列,你可以使用map来提取每个元组的第一个元素:
Stream.iterate(new int[]{0, 1},
t -> new int[]{t[1],t[0] + t[1]})
.limit(10)
.map(t -> t[0])
.forEach(System.out::println);
这段代码将生成斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34...。
在 Java 9 中,iterate方法增加了对谓词的支持。例如,你可以从 0 开始生成数字,一旦数字大于 100 就停止迭代:
IntStream.iterate(0, n -> n < 100, n -> n + 4)
.forEach(System.out::println);
iterate方法接受一个谓词作为其第二个参数,它告诉你何时继续迭代直到。请注意,你可能认为你可以使用filter操作来实现相同的结果:
IntStream.iterate(0, n -> n + 4)
.filter(n -> n < 100)
.forEach(System.out::println);
但事实并非如此。实际上,这段代码不会终止!原因是过滤器中无法知道数字会无限增加,所以它会无限期地过滤它们!你可以通过使用takeWhile来解决这个问题,它会短路流:
IntStream.iterate(0, n -> n + 4)
.takeWhile(n -> n < 100)
.forEach(System.out::println);
但是,你必须承认,带有谓词的iterate要简洁得多!
生成
类似于iterate方法,generate方法允许你按需生成一个无限值的流。但generate不会对每个新产生的值连续应用一个函数。它接受一个类型为Supplier<T>的 lambda 来提供新值。让我们看看如何使用它的一个例子:
Stream.generate(Math::random)
.limit(5)
.forEach(System.out::println);
这段代码将生成从 0 到 1 的五个随机双精度浮点数的流。例如,一次运行给出以下结果:
0.9410810294106129
0.6586270755634592
0.9592859117266873
0.13743396659487006
0.3942776037651241
静态方法 Math.random 被用作新值的生成器。再次使用 limit 方法显式限制流的大小;否则,流将是无界的!
你可能想知道是否还有其他有用的方法可以使用 generate 方法。我们使用的供应商(Math.random 的方法引用)是无状态的:它没有记录任何可以用于后续计算的值。但供应商不必是无状态的。你可以创建一个可以存储并修改状态以生成流中下一个值的供应商。作为一个例子,我们将展示如何使用 generate 创建 quiz 5.4 中的斐波那契数列,以便你可以将其与使用 iterate 方法的方案进行比较!但重要的是要注意,有状态的供应商在并行代码中是不安全的。为了完整性,本章末尾展示了斐波那契的 IntSupplier,但通常应避免使用!我们将在第七章中进一步讨论具有副作用的操作和并行流)。
在我们的例子中,我们将使用 IntStream 来说明旨在避免装箱操作的代码。IntStream 上的 generate 方法接受一个 IntSupplier 而不是 Supplier<T>。例如,以下是如何生成一个无限流中的 1:
IntStream ones = IntStream.generate(() -> 1);
你在 第三章 中看到,lambda 允许你通过直接提供方法的实现来创建函数式接口的实例。你也可以通过实现 IntSupplier 接口中定义的 getAsInt 方法来传递一个显式的对象,如下所示(尽管这看起来有些冗长,但请耐心等待我们):
IntStream twos = IntStream.generate(new IntSupplier(){
public int getAsInt(){
return 2;
}
});
generate 方法将使用给定的供应商并反复调用 getAsInt 方法,该方法始终返回 2。但这里使用的匿名类与 lambda 的区别在于,匿名类可以通过字段定义状态,而 getAsInt 方法可以修改这种状态。这是一个副作用示例。迄今为止你看到的所有 lambda 都是无副作用的;它们没有改变任何状态。
回到我们的斐波那契任务,你现在需要做的是创建一个 Int-Supplier,它在状态中维护序列中的前一个值,这样 getAsInt 就可以使用它来计算下一个元素。此外,它还可以在下次调用时更新 IntSupplier 的状态。以下代码展示了如何创建一个在调用时将返回下一个斐波那契元素的 IntSupplier:
IntSupplier fib = new IntSupplier(){
private int previous = 0;
private int current = 1;
public int getAsInt(){
int oldPrevious = this.previous;
int nextValue = this.previous + this.current;
this.previous = this.current;
this.current = nextValue;
return oldPrevious;
}
};
IntStream.generate(fib).limit(10).forEach(System.out::println);
代码创建了一个IntSupplier实例。此对象具有可变状态:它通过两个实例变量跟踪前一个斐波那契元素和当前斐波那契元素。getAsInt方法在调用时改变对象的状态,以便每次调用都产生新的值。相比之下,我们使用iterate的方法是纯不可变的;您没有修改现有状态,而是在每次迭代中创建新的元组。您将在第七章中了解到,为了并行处理流并期望得到正确的结果,您应该始终优先选择不可变方法。
注意,由于您正在处理无限大小的流,您必须使用操作limit显式地限制其大小;否则,终端操作(在这种情况下为forEach)将永远计算。同样,您不能对无限流进行排序或归约,因为所有元素都需要被处理,但这将永远无法完成,因为流包含无限数量的元素!
5.9. 概述
这是一个漫长但收获颇丰的章节!您现在可以更有效地处理集合。确实,流让您能够简洁地表达复杂的数据处理查询。此外,流可以透明地并行化。
摘要
-
Streams API 允许您表达复杂的数据处理查询。常见的流操作总结在表 5.1 中。
-
您可以使用
filter、distinct、takeWhile(Java 9)、dropWhile(Java 9)、skip和limit方法过滤和切片流。 -
当您知道源已排序时,
takeWhile和dropWhile方法比过滤器更高效。 -
您可以使用
map和flatMap方法从流中提取或转换元素。 -
您可以使用
findFirst和findAny方法在流中查找元素。您可以使用allMatch、noneMatch和anyMatch方法在流中匹配给定的谓词。 -
这些方法利用了短路:一旦找到结果,计算就会停止;无需处理整个流。
-
您可以使用
reduce方法迭代地组合流的所有元素以产生一个结果,例如,计算流的和或找到流的最大值。 -
一些操作,如
filter和map,是无状态的:它们不存储任何状态。一些操作,如reduce,存储状态以计算值。一些操作,如sorted和distinct,也存储状态,因为它们需要在返回新的流之前缓冲流的所有元素。这些操作被称为有状态操作。 -
流有三种原始特化:
IntStream、DoubleStream和LongStream。它们的操作也相应地进行了特化。 -
流不仅可以从集合中创建,还可以从值、数组、文件以及特定的方法(如
iterate和generate)中创建。 -
无限流具有无限数量的元素(例如所有可能的字符串)。这是可能的,因为流的元素仅在需要时才被生成。您可以使用
limit等方法从无限流中获取有限流。
第六章. 使用流收集数据
本章涵盖
-
使用
Collectors类创建和使用收集器 -
将数据流减少到单个值
-
摘要作为减少的特殊情况
-
数据的分组和分区
-
开发您自己的自定义收集器
在上一章中,您了解到流可以帮助您使用类似数据库的操作处理集合。您可以将 Java 8 流视为数据集的高级懒迭代器。它们支持两种类型的操作:中间操作,如 filter 或 map,以及终端操作,如 count、findFirst、forEach 和 reduce。中间操作可以链接起来,将一个流转换为另一个流。这些操作不会从流中消耗资源;它们的目的只是设置一个流管道。相比之下,终端操作 确实 会从流中消耗资源,以产生最终结果(例如,返回流中的最大元素)。它们通常可以通过优化流管道来缩短计算。
我们已经在第 4 和 5 章节中使用了流上的 collect 终端操作,但我们在那里主要用它来将流的所有元素组合成一个 List。在本章中,您将发现 collect 是一个类似于 reduce 的减少操作,它接受将流元素累积到摘要结果的各种配方作为参数。这些配方由一个新的 Collector 接口定义,因此区分 Collection、Collector 和 collect 非常重要!
这里有一些使用 collect 和收集器的示例查询:
-
按货币将交易列表分组,以获得具有该货币的所有交易的价值总和(返回一个
Map<Currency, Integer>) -
将交易列表划分为两组:昂贵和不昂贵(返回一个
Map<Boolean, List<Transaction>>) -
创建多级分组,例如按城市分组交易,然后进一步根据它们是否昂贵进行分类(返回一个
Map<String, Map<Boolean, List<Transaction>>>)
激动吗?太好了。让我们从一个受益于收集器的示例开始探索。想象一个场景,您有一个 Transaction 的 List,并且您想根据它们的名义货币对它们进行分组。在 Java 8 之前,即使是这样一个简单的用例也难以实现,如下面的列表所示。
列表 6.1. 以命令式方式按货币分组交易
Map<Currency, List<Transaction>> transactionsByCurrencies =
new HashMap<>(); *1*
for (Transaction transaction : transactions) { *2*
Currency currency = transaction.getCurrency(); *3*
List<Transaction> transactionsForCurrency =
transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) { *4*
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies
.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction); *5*
}
-
1 创建一个地图,其中将累积分组交易
-
2 遍历交易列表
-
3 提取交易的货币
-
4 如果分组映射中没有这个货币的条目,则创建它
-
5 将当前遍历的交易添加到具有相同货币的交易列表中
如果你是一位经验丰富的 Java 开发者,你可能觉得写这样的代码很舒服,但你必须承认,对于这样一个简单的任务来说,这需要很多代码。更糟糕的是,这比写代码还难读!代码的目的在第一眼看来并不明显,尽管它可以用简单的英语直接表达:“按货币将交易列表分组。”正如你将在本章中学到的,你可以通过使用更通用的Collector参数来对流的collect方法进行操作,而不是使用前一章中使用的toList特殊情况,从而用单个语句达到完全相同的结果:
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream().collect(groupingBy(Transaction::getCurrency));
这种比较相当尴尬,不是吗?
6.1. 简要介绍收集器
之前的例子清楚地展示了函数式编程相对于命令式方法的一个主要优势:你必须制定你想要获得的结果的“是什么”,而不是获得它的步骤,即“怎么做”。在前面的例子中,传递给collect方法的参数是实现Collector接口的实例,这是一个如何构建流中元素摘要的配方。在前一章中,toList配方说,“依次列出每个元素。”在这个例子中,groupingBy配方说,“创建一个Map,其键是(货币)桶,其值是这些桶中的元素列表。”
如果你进行多级分组,这个例子中命令式和函数式版本之间的差异就更加明显:在这种情况下,由于需要大量嵌套循环和条件,命令式代码很快就会变得难以阅读、维护和修改。相比之下,正如你将在第 6.3 节中发现的那样,函数式风格的版本可以很容易地通过添加额外的收集器来增强。
6.1.1. 收集器作为高级归约
这个最后的观察结果又提出了另一个典型的好处:一个精心设计的函数式 API 具有更高的可组合性和可重用性。收集器非常有用,因为它们提供了一种简洁而灵活的方式来定义collect用于生成结果集合的准则。更具体地说,在流上调用collect方法会触发一个(由Collector参数化的)对流本身的元素进行的归约操作(如图 6.1 所示)。它遍历流中的每个元素,并让Collector处理它们。
图 6.1. 按货币对交易进行分组的过程

通常,Collector会对元素应用一个转换函数。这通常是无效果的恒等转换(例如,在toList中)。然后该函数将结果累积在构成此过程最终输出的数据结构中。例如,在我们之前展示的交易分组示例中,转换函数从每个交易中提取货币,然后交易本身使用货币作为键累积到结果Map中。
Collector接口方法的实现定义了如何在流上执行减少操作,例如我们货币示例中的那种。我们将在第 6.5 节和第 6.6 节中研究如何创建自定义收集器。但Collectors实用类提供了许多静态工厂方法,方便地创建最常用的收集器实例,这些收集器已准备好使用。最直接且最常用的收集器是toList静态方法,它将流的所有元素收集到一个List中:
List<Transaction> transactions =
transactionStream.collect(Collectors.toList());
6.1.2. 预定义收集器
在本章的剩余部分,我们将主要探讨预定义收集器的功能,这些收集器可以通过Collectors类提供的工厂方法(例如groupingBy)创建。这些提供了三个主要功能:
-
将流元素减少并汇总为单个值
-
元素分组
-
元素分区
我们从允许您减少和汇总的收集器开始。这些在多种用例中都很方便,例如在先前的示例中找到交易列表中交易值的总额。
然后,您将了解如何对流的元素进行分组,将先前的示例推广到多级分组或结合不同的收集器以对每个结果子组应用进一步的减少操作。我们还将描述分区作为分组的一个特殊情况,使用谓词(一个返回布尔值的单参数函数)作为分组函数。
在第 6.4 节的末尾,您将找到一个总结本章中探索的所有预定义收集器的表格。最后,在第 6.5 节中,在您探索(在第 6.6 节)如何创建自己的自定义收集器以用于Collectors类的工厂方法未涵盖的情况之前,您将了解更多关于Collector接口的信息。
6.2. 减少 和 汇总
为了说明可以从Collectors工厂类创建的可能收集器实例的范围,我们将重用我们在前一章中引入的领域:一份由美味佳肴组成的菜单!
正如你所学的,收集器(stream 方法的 collect 参数)通常用于需要将流的项目重新组织到集合中的情况。但更普遍的是,它们可以在你想将流中的所有项目组合成单个结果时使用。这个结果可以是任何类型,从表示树的复杂的多级映射到表示菜单中所有卡路里总和的单个整数。我们将查看这两种结果类型:第 6.2.2 节 中的单个整数和 第 6.3.1 节 中的多级分组。
作为第一个简单的例子,让我们使用由 counting 工厂方法返回的收集器来计算菜单中的菜品种数:
long howManyDishes = menu.stream().collect(Collectors.counting());
你可以更直接地写成这样
long howManyDishes = menu.stream().count();
但是,当与其他收集器结合使用时,counting 收集器可能很有用,我们将在后面演示。
在本章的其余部分,我们将假设你已经导入了 Collectors 类的所有静态工厂方法,如下所示:
import static java.util.stream.Collectors.*;
因此,你可以写 counting() 而不是 Collectors.counting() 等等。
让我们继续通过查看如何在流中找到最大值和最小值来探索简单的预定义收集器。
6.2.1. 在值流中查找最大值和最小值
假设你想要找到菜单中卡路里最高的菜。你可以使用两个收集器,Collectors.maxBy 和 Collectors.minBy,来计算流中的最大值或最小值。这两个收集器接受一个 Comparator 作为参数,用于比较流中的元素。在这里,你创建了一个基于卡路里含量的 Comparator,并将其传递给 Collectors.maxBy:
Comparator<Dish> dishCaloriesComparator =
Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish =
menu.stream()
.collect(maxBy(dishCaloriesComparator));
你可能会想知道 Optional<Dish> 是什么意思。为了回答这个问题,我们必须问,“如果 menu 是空的怎么办?”没有菜可以返回!Java 8 引入了 Optional,它是一个可能包含或不包含值的容器。在这里,它完美地代表了可能返回或不返回菜的想法。我们简要地提到了它,在 第五章 中,当你遇到 findAny 方法时。现在不必担心它;我们将在 第十一章 中专门研究 Optional<T> 及其操作。
另一个常见的返回单个值的归约操作是将流中对象的数值字段的值相加。或者,你可能想要计算平均值。这样的操作称为 汇总 操作。让我们看看你如何使用收集器来表示它们。
6.2.2. 汇总
Collectors 类提供了一个特定的工厂方法用于求和:Collectors.summingInt。它接受一个函数,该函数将一个对象映射到要相加的 int 值,并返回一个收集器,当传递给常规的 collect 方法时,执行所需的汇总。例如,你可以使用以下方法找到菜单列表中的总卡路里数:
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
在这里,收集过程如图 6.2 所示。在遍历流的过程中,每个菜品被映射为其卡路里数,然后从这个初始值(在这种情况下是0)开始将该数字添加到一个累加器中。
图 6.2. summingInt收集器的聚合过程

Collectors.summingLong和Collectors.summingDouble方法的行为完全相同,并且可以在需要分别求和的字段是long或double时使用。
但总结不仅仅是简单的求和。Collectors.averaging-Int及其对应的averagingLong和averagingDouble也可用于计算相同数值集合的平均值:
double avgCalories =
menu.stream().collect(averagingInt(Dish::getCalories));
到目前为止,你已经看到了如何使用收集器来计算流中的元素数量,找到这些元素的数值属性的极大值和极小值,并计算它们的总和和平均值。尽管如此,你经常可能想要检索两个或多个这些结果,并且可能希望在一个操作中完成。在这种情况下,你可以使用由summarizingInt工厂方法返回的收集器。例如,你可以通过单个summarizing操作来计算菜单中的元素数量,并获取每个菜品中包含的卡路里的总和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics =
menu.stream().collect(summarizingInt(Dish::getCalories));
这个收集器将所有信息收集到一个名为IntSummaryStatistics的类中,该类提供了方便的 getter 方法来访问结果。打印menu-Statistic对象将产生以下输出:
IntSummaryStatistics{count=9, sum=4300, min=120,
average=477.777778, max=800}
通常,还有相应的summarizingLong和summarizingDouble工厂方法,以及相关的类型LongSummaryStatistics和DoubleSummary-Statistics。这些在需要收集的属性是原始类型的long或double时使用。
6.2.3. 连接字符串
由joining工厂方法返回的收集器将所有从流中每个对象调用toString方法得到的字符串连接成一个单一的字符串。这意味着你可以按照以下方式连接菜单中所有菜品的名称:
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
注意,joining内部使用StringBuilder来将生成的字符串追加到一个字符串中。另外,注意如果Dish类有一个返回菜品名称的toString方法,你将获得相同的结果,而无需使用函数从每个菜品中提取名称来映射原始流:
String shortMenu = menu.stream().collect(joining());
它们都产生相同的字符串
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon
这很难阅读。幸运的是,joining工厂方法是重载的,其中一个重载变体接受一个用于分隔两个连续元素的字符串,因此你可以使用逗号分隔的菜品名称列表:
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
如预期,这将生成
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
迄今为止,我们探讨了各种将流归约到单个值的收集器。在下一节中,我们将演示所有这种形式的归约过程都是Collectors.reducing工厂方法提供的更一般归约收集器的特例。
6.2.4. 使用归约进行泛化总结
我们之前讨论的所有收集器实际上只是可以使用reducing工厂方法定义的归约过程的便利特例。Collectors.reducing工厂方法是对它们的泛化。前面讨论的特殊情况可能只是为了程序员的便利而提供的。(但请记住,程序员的便利性和可读性是最重要的!)例如,你可以使用从reducing方法创建的收集器来计算菜单中的总卡路里,如下所示:
int totalCalories = menu.stream().collect(reducing(
0, Dish::getCalories, (i, j) -> i + j));
它需要三个参数:
-
第一个参数是归约操作的起始值,在没有元素的流的情况下,它也将是返回的值,因此显然在数值求和的情况下
0是合适的值。 -
第二个参数是你在第 6.2.2 节中用来将菜品转换为一个表示其卡路里含量的
int的同一个函数。 -
第三个参数是一个
BinaryOperator,它将两个项目聚合为同一类型的单个值。在这里,它将两个int相加。
同样,你可以使用reducing的一个参数版本找到最高卡路里的菜品,如下所示:
Optional<Dish> mostCalorieDish =
menu.stream().collect(reducing(
(d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
你可以将使用一个参数的reducing工厂方法创建的收集器视为三个参数方法的特例,它使用流中的第一个项目作为起点,并使用一个恒等函数(一个返回其输入参数不变的功能)作为转换函数。这也意味着当将一个参数的reducing收集器传递给空流的collect方法时,它将没有起始点,正如我们在第 6.2.1 节中解释的那样,因此它返回一个Optional<Dish>对象。
收集与归约
我们在前一章和这一章中讨论了很多归约。你可能想知道流接口的collect和reduce方法之间的区别,因为通常你可以使用任一方法获得相同的结果。例如,你可以使用reduce方法实现toList Collector所做的工作,如下所示:
Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream();
List<Integer> numbers = stream.reduce(
new ArrayList<Integer>(),
(List<Integer> l, Integer e) -> {
l.add(e);
return l; },
(List<Integer> l1, List<Integer> l2) -> {
l1.addAll(l2);
return l1; });
这种解决方案有两个问题:一个是语义问题,另一个是实际问题。语义问题在于reduce方法旨在合并两个值并产生一个新的值;它是一个不可变的归约。相比之下,collect方法旨在修改一个容器以累积它应该产生的结果。这意味着之前的代码片段错误地使用了reduce方法,因为它正在原地修改用作累加器的List。正如你将在下一章中更详细地看到的那样,使用具有错误语义的reduce方法也是实际问题的原因:这个归约过程不能并行工作,因为多个线程对相同数据结构的并发修改可能会损坏List本身。在这种情况下,如果你想保证线程安全,你将需要每次都分配一个新的List,这将通过对象分配来损害性能。这正是collect方法在表达对可变容器进行归约时非常有用,但关键是在并行友好方式下的原因,你将在本章后面学到。
集合框架灵活性:以不同的方式执行相同的操作
你可以使用reducing收集器进一步简化之前的求和示例,通过使用Integer类的sum方法的引用来代替你用来编码相同操作的 lambda 表达式。这导致以下结果:
int totalCalories = menu.stream().collect(reducing(0, *1*
Dish::getCalories, *2*
Integer::sum)); *3*
-
1 初始值
-
2 变换函数
-
3 聚合函数
从逻辑上讲,这种归约操作按照图 6.3 所示进行,其中累加器使用起始值初始化,然后通过聚合函数迭代组合,每个流元素的变换函数应用的结果。
图 6.3. 计算菜单中总卡路里数的归约过程

我们在第 6.2 节开头提到的counting收集器实际上是通过使用三个参数的reducing工厂方法类似实现的。它将流中的每个元素转换为一个类型为Long且值为1的对象,然后对所有这些1求和。它的实现如下:
public static <T> Collector<T, ?, Long> counting() {
return reducing(0L, e -> 1L, Long::sum);
}
泛型?通配符的使用
在刚刚展示的代码片段中,你可能注意到了用作counting工厂方法返回的收集器签名的第二个泛型类型中的?通配符。你应该已经熟悉这种表示法,尤其是如果你经常使用 Java 集合框架的话。但在这里,它仅仅意味着收集器的累加器类型是未知的,或者等价地说,累加器本身可以是任何类型。我们在这里使用它是为了精确地报告方法在Collectors类中最初定义的签名,但在本章的其余部分,我们避免使用任何通配符表示法,以使讨论尽可能简单。
我们已经在第五章中观察到,还有另一种方法可以执行相同的操作而不使用收集器——通过将菜品流映射到每个菜品的卡路里数,然后使用与上一个版本中相同的方法引用来减少这个结果流:
int totalCalories =
menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();
注意,像任何在流上的单参数reduce操作一样,调用reduce(Integer::sum)返回的不是int,而是一个Optional<Integer>,以安全的方式处理空流上的归约操作。在这里,您使用其get方法提取Optional对象内的值。请注意,在这种情况下使用get方法是安全的,仅因为您确信菜品的流不为空。一般来说,正如您将在第十章中学习的,使用允许您提供默认值的方法(如orElse或orElseGet)来解包Optional中最终包含的值更安全。最后,并且更加简洁,您可以通过将流映射到IntStream并对其调用sum方法来实现相同的结果:
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();
选择最适合您情况的最佳解决方案
再次证明,一般而言(特别是在 Java 8 中添加到 Collections 框架中的基于函数式原则的新 API),函数式编程通常提供多种执行相同操作的方法。此示例还表明,收集器比直接在 Streams 接口上可用的方法稍微复杂一些,但作为交换,它们提供了更高层次的抽象和泛化,并且更可重用和可定制。
我们的建议是探索尽可能多的解决方案来解决问题,但始终选择最专业化的解决方案,它足够通用以解决问题。这通常是考虑可读性和性能的最佳决定。例如,为了计算我们菜单中的总卡路里,我们更喜欢最后一个解决方案(使用IntStream),因为它最简洁,也可能是最易读的。同时,它也是性能最好的,因为IntStream让我们避免了所有无用的自动装箱操作,或者从Integer到int的隐式转换,在这种情况下这些操作都是不必要的。
接下来,通过完成第 6.1 节的练习来测试您对如何将reducing用作其他收集器泛化的理解。
练习 6.1:使用 reducing 连接字符串
以下哪个使用reducing收集器的陈述是此joining收集器(如第 6.2.3 节中所述)的有效替代品?
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
-
String shortMenu = menu.stream().map(Dish::getName) .collect( reducing( (s1, s2) -> s1 + s2 ) ).get(); -
String shortMenu = menu.stream() .collect( reducing( (d1, d2) -> d1.getName() + d2.getName() ) ).get(); -
String shortMenu = menu.stream() .collect( reducing( "", Dish::getName, (s1, s2) -> s1 + s2 ) );
答案:
陈述 1 和 3 是有效的,而 2 无法编译。
-
这将每个菜品的名称转换为,就像原始语句使用
joining收集器所做的那样,然后使用String作为累加器,并逐个将菜品的名称追加到它上面。 -
这不能编译,因为
reducing接受的单一参数是一个BinaryOperator<T>,它是一个BiFunction<T,T,T>。这意味着它需要一个接受两个参数并返回相同类型值的函数,但那里使用的 lambda 表达式有两个菜肴作为参数,但返回一个字符串。 -
这以空字符串作为累加器开始减少过程,并在遍历菜肴流时,将每个菜肴转换为它的名称并将其追加到累加器中。注意,正如我们提到的,
reducing不需要三个参数来返回一个Optional,因为在空流的情况下,它可以返回一个更有意义的值,即用作初始累加器值的空字符串。
注意,尽管语句 1 和 3 是joining收集器的有效替代,但它们在这里被用来展示reducing至少在概念上可以被视为本章讨论的所有其他收集器的泛化。尽管如此,出于所有实际目的,我们始终建议出于可读性和性能原因使用joining收集器。
6.3. 分组
一个常见的数据库操作是根据一个或多个属性对集合中的项目进行分组。正如你在前面的交易货币分组示例中看到的,当以命令式风格实现时,这个操作可能会很繁琐、冗长且容易出错。但通过使用 Java 8 鼓励的更函数式风格的单一、可读的语句,它可以很容易地翻译成。作为此功能如何工作的第二个示例,假设你想要根据类型对菜单中的菜肴进行分类,将含肉的菜肴放在一个组中,含鱼的放在另一个组中,所有其他菜肴放在第三个组中。你可以很容易地使用由Collectors.groupingBy工厂方法返回的收集器执行此任务,如下所示:
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(groupingBy(Dish::getType));
这将导致以下Map:
{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza],
MEAT=[pork, beef, chicken]}
在这里,你将一个Function(以方法引用的形式表达)传递给groupingBy方法,该Function提取流中每个Dish对应的Dish.Type。我们称这个Function为分类函数,特别是因为它用于将流中的元素分类到不同的组中。这种分组操作的结果,如图 6.4 所示,是一个Map,其映射键是分类函数返回的值,相应的映射值是具有该分类值的流中所有项的列表。在菜单分类示例中,键是菜肴的类型,其值是包含该类型所有菜肴的列表。
图 6.4. 分组过程中流中项目的分类

但并不是总是可以使用方法引用作为分类函数,因为你可能希望使用比简单的属性访问器更复杂的东西进行分类。例如,你可以决定将所有卡路里在 400 或以下的菜品分类为“减肥”,将 400 到 700 卡路里的菜品设置为“正常”,将超过 700 卡路里的菜品设置为“高脂肪”。因为 Dish 类的作者没有提供这样的操作作为方法,所以在这种情况下你不能使用方法引用,但你可以在 lambda 表达式中表达这个逻辑:
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} ));
现在你已经看到了如何根据菜品的类型和卡路里将菜单中的菜品分组,但也许你还需要进一步操作原始分组的成果,在下一节中我们将展示如何实现这一点。
6.3.1. 操作分组元素
经常在执行分组操作后,你可能需要操作每个结果组中的元素。例如,假设你只想过滤卡路里较高的菜品,比如说超过 500 卡路里的。你可能认为在这种情况下,你可以在分组之前应用这个过滤谓词,如下所示:
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream().filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
这个解决方案是可行的,但可能有一个相关的缺点。如果你尝试在我们的菜单中的菜品上使用它,你将获得如下所示的 Map:
{OTHER=[french fries, pizza], MEAT=[pork, beef]}
你看到了那里的问题吗?因为没有一种类型的鱼满足我们的过滤谓词,所以这个键在结果映射中完全消失了。为了解决这个问题,Collectors 类重载了 groupingBy 工厂方法,其中一个变体也接受一个类型为 Collector 的第二个参数,以及通常的分类函数。这样,就可以将过滤谓词移动到这个第二个 Collector 中,如下所示:
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
filtering(dish -> dish.getCalories() > 500, toList())));
filtering 方法是 Collectors 类的另一个静态工厂方法,它接受一个 Predicate 来过滤每个组中的元素,以及一个用于重新分组过滤元素的进一步 Collector。这样,结果 Map 也将保留鱼类型的条目,即使它映射了一个空列表:
{OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
另一种可能非常有用的操作分组元素的方法是通过映射函数进行转换。为此,类似于你看到的 filtering Collector,Collectors 类通过 mapping 方法提供了另一个 Collector,它接受一个映射函数和另一个用于收集应用该函数到每个元素的结果的 Collector。通过使用它,你可以将组中的每个 Dish 转换为它们各自的名字,如下所示:
Map<Dish.Type, List<String>> dishNamesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
mapping(Dish::getName, toList())));
注意,在这种情况下,结果 Map 中的每个组都是一个 Strings 的 List,而不是像前例中的 Dishes。你也可以使用第三个 Collector 与 groupingBy 结合来执行 flatMap 转换,而不是普通的 map。为了演示这是如何工作的,让我们假设我们有一个 Map,将每个 Dish 关联到一个标签列表,如下所示:
Map<String, List<String>> dishTags = new HashMap<>();
dishTags.put("pork", asList("greasy", "salty"));
dishTags.put("beef", asList("salty", "roasted"));
dishTags.put("chicken", asList("fried", "crisp"));
dishTags.put("french fries", asList("greasy", "fried"));
dishTags.put("rice", asList("light", "natural"));
dishTags.put("season fruit", asList("fresh", "natural"));
dishTags.put("pizza", asList("tasty", "salty"));
dishTags.put("prawns", asList("tasty", "roasted"));
dishTags.put("salmon", asList("delicious", "fresh"));
如果你需要提取每个菜肴类型的标签,你可以轻松地使用 flatMapping Collector 来实现这一点:
Map<Dish.Type, Set<String>> dishNamesByType =
menu.stream()
.collect(groupingBy(Dish::getType,
flatMapping(dish -> dishTags.get( dish.getName() ).stream(),
toSet())));
在这里,对于每个 Dish,我们得到一个标签的 List。所以,类似于我们在前一章节中已经看到的,我们需要执行一个 flatMap 来将结果的两级列表展平成一个单一的列表。此外,请注意,这次我们将每个组中执行的 flatMapping 操作的结果收集到一个 Set 中,而不是像之前那样使用 List,以避免同一类型的多个 Dish 关联到相同的标签。这个操作的结果 Map 如下所示:
{MEAT=[salty, greasy, roasted, fried, crisp], FISH=[roasted, tasty, fresh,
delicious], OTHER=[salty, greasy, natural, light, tasty, fresh, fried]}
到目前为止,我们只使用单一标准对菜单中的菜肴进行分组,例如按类型或卡路里,但如果你想同时使用多个标准怎么办?分组之所以强大,是因为它能够有效地组合。让我们看看如何做到这一点。
6.3.2. 多级分组
我们在前一节中使用过的两个参数 Collectors.groupingBy 工厂方法,用于操作分组操作产生的组中的元素,也可以用来执行两级分组。为了实现这一点,你可以向它传递一个第二级的内部 groupingBy 给外部的 groupingBy,定义一个第二级标准来分类流的项目,如下一列表所示。
列表 6.2. 多级分组
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType, *1*
groupingBy(dish -> { *2*
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} )
)
);
-
1 第一级分类函数
-
2 第二级分类函数
这两级分组的结果是类似于以下的两级 Map:
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
这里,外部的 Map 的键是由第一级分类函数生成的值:鱼、肉、其他。这个 Map 的值又是其他 Maps,它们的键是由第二级分类函数生成的值:正常、减肥或脂肪。最后,第二级的 Maps 的值是流中元素的 List,当分别应用于第一和第二分类函数时,返回相应的一级和二级键值:三文鱼、披萨等等。这种多级分组操作可以扩展到任意数量的级别,一个 n- 级分组的结果是一个 n- 级 Map,模拟一个 n- 级树结构。
图 6.5 展示了这种结构也等同于一个 n- 维表,突出了分组操作的分类目的。
图 6.5. 多级嵌套映射与 n- 维分类表的等价性

通常,将groupingBy视为“桶”是有帮助的。第一个groupingBy为每个键创建一个桶。然后你使用下游收集器收集每个桶中的元素,以此类推,以实现n-级分组!
6.3.3. 在子组中收集数据
在前面的章节中,你看到了可以通过传递第二个groupingBy收集器到外部的groupingBy来达到多级分组。但更一般地,传递给第一个groupingBy的第二个收集器可以是任何类型的收集器,而不仅仅是另一个groupingBy。例如,可以通过将counting收集器作为第二个参数传递给groupingBy收集器来计算菜单中每种类型的Dish数量:
Map<Dish.Type, Long> typesCount = menu.stream().collect(
groupingBy(Dish::getType, counting()));
结果是以下Map:
{MEAT=3, FISH=2, OTHER=4}
还要注意,实际上,只有一个参数的groupingBy(f),其中f是分类函数,是groupingBy(f, toList())的简写。
再举一个例子,你可以重新设计你已经用过的收集器,以找到菜单中热量最高的菜品,以实现类似的结果,但现在按菜品的类型进行分类:
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType,
maxBy(comparingInt(Dish::getCalories))));
这个分组的最终结果显然是一个Map,其键是可用的Dish类型,值是Optional<Dish>,包装了对应类型中热量最高的Dish:
{FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
注意
这个Map中的值是Optionals,因为这是maxBy工厂方法生成的收集器的结果类型,但在现实中,如果菜单中没有给定类型的Dish,那么这个类型就不会有Optional.empty()作为值;它根本不会作为键出现在Map中。groupingBy收集器在第一次在流中找到元素时,会懒加载地在一个分组的Map中添加一个新的键,在应用分组标准时产生这个键。这意味着在这种情况下,Optional包装器是没有用的,因为它不是模拟一个可能缺失但意外存在的值,而只是因为这个类型是减少收集器返回的类型。
调整收集器结果到不同类型
由于在最后分组操作的结果中包装所有值的Optionals 在这种情况下没有用,你可能想去掉它们。为了实现这一点,或者更一般地,为了调整收集器返回的结果到不同类型,你可以使用Collectors.collectingAndThen工厂方法返回的收集器,如下所示。
列表 6.3. 在每个子组中找到热量最高的菜品
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType, *1*
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)), *2*
Optional::get))); *3*
-
1 分组函数
-
2 包装收集器
-
3 转换函数
这个工厂方法接受两个参数——要适配的收集器和转换函数,并返回另一个收集器。这个额外的收集器作为旧收集器的包装器,并在collect操作的最后一步使用转换函数映射返回的值。在这种情况下,包装的收集器是使用maxBy创建的,转换函数Optional::get提取Optional返回的值。正如我们所说的,这里这是安全的,因为reducing收集器永远不会返回Optional.empty()。结果是以下Map:
{FISH=salmon, OTHER=pizza, MEAT=pork}
使用多个嵌套收集器相当常见,起初它们之间的交互方式可能并不总是明显。图 6.6 可以帮助你可视化它们是如何一起工作的。从最外层开始向内移动,注意以下内容:
-
收集器由虚线表示,因此
groupingBy是最外层的,根据不同菜肴的类型将菜单流分组为三个子流。 -
groupingBy收集器包装了collectingAndThen收集器,因此每个分组操作产生的子流都进一步通过这个第二个收集器进行减少。 -
collectingAndThen收集器依次包装了一个第三个收集器,即maxBy。 -
子流的减少操作随后由
reducing收集器执行,但包含它的collectingAndThen收集器对其结果应用了Optional::get转换函数。 -
对于给定的类型,三个转换后的值,即最高卡路里的
Dish(通过在每个三个子流上执行此过程的结果),将是groupingBy收集器返回的Map中与相应分类键关联的值,即Dish的类型。
图 6.6. 通过嵌套一个收集器在另一个内部来组合多个收集器的影响

与groupingBy一起使用的收集器的其他示例
更一般地说,作为groupingBy工厂方法的第二个参数传递的收集器将被用来对分类到同一组的流中的所有元素执行进一步的减少操作。例如,你也可以重用创建来计算菜单中所有菜肴卡路里总和的收集器,以获得类似的结果,但这次是为每个Dish组:
Map<Dish.Type, Integer> totalCaloriesByType =
menu.stream().collect(groupingBy(Dish::getType,
summingInt(Dish::getCalories)));
另一个常用的收集器,通常与groupingBy一起使用,是由mapping方法生成的收集器。此方法接受两个参数:一个将流中的元素转换成另一个类型的函数,以及一个进一步收集此转换结果的收集器。它的目的是通过在累积之前对每个输入元素应用映射函数,将接受给定类型元素的收集器适配到处理不同类型对象的收集器。为了看到使用此收集器的实际示例,假设您想知道菜单中每种Dish类型可用的CaloricLevel。您可以通过结合使用groupingBy和mapping收集器来实现此结果,如下所示:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
menu.stream().collect(
groupingBy(Dish::getType, mapping(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toSet() )));
在这里,传递给映射方法的转换函数将Dish映射到其CaloricLevel,正如您之前所看到的。然后,将CaloricLevel的流传递给一个toSet收集器,类似于toList,但它将流元素累积到一个Set中,而不是累积到一个List中,以保留唯一的值。与早期示例一样,这个映射收集器将用于收集由分组函数生成的每个子流中的元素,从而使您能够获得以下Map作为结果:
{OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}
从这里您可以轻松地了解您的选择。如果您想吃鱼并且正在节食,您可以轻松地找到一道菜;同样,如果您饿了并且想要高热量的食物,您可以通过选择菜单中的肉类部分来满足您强烈的食欲。注意,在前面的例子中,没有关于返回的Set类型的保证。但通过使用toCollection,您可以有更多的控制。例如,您可以通过传递一个构造器引用来请求一个HashSet:
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
menu.stream().collect(
groupingBy(Dish::getType, mapping(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toCollection(HashSet::new) )));
6.4. 分区
分区是分组的特殊情况:有一个称为分区函数的谓词作为分类函数。分区函数返回布尔值的事实意味着结果分组Map将有一个Boolean作为键类型,因此,最多可以有两个不同的组——一个用于true,一个用于false。例如,如果您是素食主义者或者邀请了素食朋友一起吃饭,您可能对将菜单分为素食和非素食菜肴感兴趣:
Map<Boolean, List<Dish>> partitionedMenu =
menu.stream().collect(partitioningBy(Dish::isVegetarian)); *1*
- 1 分区函数
这将返回以下Map:
{false=[pork, beef, chicken, prawns, salmon],
true=[french fries, rice, season fruit, pizza]}
因此,您可以通过从这个Map中获取以true为键的值来检索所有素食菜肴:
List<Dish> vegetarianDishes = partitionedMenu.get(true);
注意,您可以通过使用与分区相同的谓词过滤从“列表”菜单创建的流,然后将结果收集到一个额外的“列表”中,从而实现相同的结果:
List<Dish> vegetarianDishes =
menu.stream().filter(Dish::isVegetarian).collect(toList());
6.4.1. 分区的优点
分区的好处是保留了流元素的两个列表,对于分区函数返回true或false的应用。在先前的例子中,你可以通过访问partitionedMenu Map中键false的值来获得非素食Dish的List,使用两个单独的过滤操作:一个使用谓词,另一个使用其否定。同样,正如你已经看到的分组,partitioningBy工厂方法有一个重载版本,你可以传递第二个收集器,如下所示:
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType =
menu.stream().collect(
partitioningBy(Dish::isVegetarian, *1*
groupingBy(Dish::getType))); *2*
-
1 分区函数
-
2 第二个收集器
这将产生一个两级的Map:
{false={FISH=[prawns, salmon], MEAT=[pork, beef, chicken]},
true={OTHER=[french fries, rice, season fruit, pizza]}}
在这里,将菜肴按其类型分组的方法被单独应用于由分区产生的素食和非素食菜肴的两个子流,从而生成一个类似于你在第 6.3.1 节中执行的两级分组时获得的二级Map。作为另一个例子,你可以重用你之前的代码来找出素食和非素食菜肴中最高卡路里的菜肴:
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
menu.stream().collect(
partitioningBy(Dish::isVegetarian,
collectingAndThen(maxBy(comparingInt(Dish::getCalories)),
Optional::get)));
这将产生以下结果:
{false=pork, true=pizza}
我们在本节开始时说,你可以将分区视为分组的一个特殊情况。还值得注意的是,partitioningBy返回的Map实现更紧凑、更高效,因为它只需要包含两个键:true 和 false。事实上,内部实现是一个具有两个字段的专用Map。groupingBy和partitioningBy收集器之间的类比并不止于此;正如你将在下一个练习中看到的,你也可以以类似于你在第 6.3.1 节中分组的方式执行多级分区。
练习 6.2:使用 partitioningBy
正如你所看到的,与groupingBy收集器一样,partitioningBy收集器也可以与其他收集器结合使用。特别是它可以与第二个partitioningBy收集器一起使用,以实现多级分区。以下多级分区的结果会是什么?
-
menu.stream().collect(partitioningBy(Dish::isVegetarian, partitioningBy(d -> d.getCalories() > 500))); -
menu.stream().collect(partitioningBy(Dish::isVegetarian, partitioningBy(Dish::getType))); -
menu.stream().collect(partitioningBy(Dish::isVegetarian, counting()));
答案:
-
这是一个有效的多级分区,产生了以下两级的
Map:{ false={false=[chicken, prawns, salmon], true=[pork, beef]}, true={false=[rice, season fruit], true=[french fries, pizza]}} -
这将无法编译,因为
partitioningBy需要一个谓词,一个返回布尔值的函数。方法引用Dish::getType不能用作谓词。 -
这计算了每个分区中的项目数量,从而产生了以下
Map:{false=5, true=4}
为了给出一个如何使用partitioningBy收集器的最后一个例子,我们将暂时放下菜单数据模型,看看一些更复杂但也更有趣的东西:将数字分为质数和非质数。
6.4.2. 将数字分为质数和非质数
假设你想编写一个接受int n 作为参数的方法,并将前n个自然数分为质数和非质数。但首先,开发一个测试给定候选数字是否为质数的谓词将是有用的:
public boolean isPrime(int candidate) {
return IntStream.range(2, candidate) *1*
.noneMatch(i -> candidate % i == 0); *2*
}
-
1 从 2 开始生成一个自然数范围,包括 2,但不包括候选数
-
2 如果候选数不能被流中的任何数整除,则返回 true
简单的优化是只测试小于或等于候选数的平方根的因子:
public boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
现在大部分工作已经完成。要将前n个数字分成质数和非质数,只需创建一个包含这些n个数字的流,然后使用partitioningBy收集器将其与您刚刚开发的isPrime方法作为谓词进行归约即可:
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(
partitioningBy(candidate -> isPrime(candidate)));
}
我们现在已经涵盖了可以使用Collectors类的静态工厂方法创建的所有收集器,展示了它们如何工作的实际示例。表 6.1 将它们全部汇总在一起,包括它们应用于Stream<T>时返回的类型,以及它们在名为menuStream的Stream<Dish>上的实际使用示例。
表 6.1. Collectors类的主要静态工厂方法
| 工厂方法 | 返回类型 | 用于 |
|---|---|---|
| toList | List |
将流中的所有项目收集到列表中。 |
| Example use: List |
||
| toSet | Set |
将流中的所有项目收集到集合中,消除重复项。 |
| Example use: Set |
||
| toCollection | Collection |
将流中的所有项目收集到由提供的供应商创建的集合中。 |
| Example use: Collection |
||
| counting | Long | 计算流中项目的数量。 |
| Example use: long howManyDishes = menuStream.collect(counting()); | ||
| summingInt | Integer | 对流中项目的 Integer 属性值求和。 |
| Example use: int totalCalories = menuStream.collect(summingInt(Dish::getCalories)); | ||
| averagingInt | Double | 计算流中项目 Integer 属性的平均值。 |
| Example use: double avgCalories = menuStream.collect(averagingInt(Dish::getCalories)); | ||
| summarizingInt | IntSummaryStatistics | 收集有关流中项目 Integer 属性(如最大值、最小值、总和和平均值)的统计信息。 |
| Example use: IntSummaryStatistics menuStatistics = menuStream.collect(summarizingInt(Dish::getCalories)); | ||
| joining | String | 将流中每个项目调用 toString 方法的结果连接起来。 |
| Example use: String shortMenu = menuStream.map(Dish::getName).collect(joining(", ")); | ||
| maxBy | Optional |
一个 Optional,包含根据给定的比较器在此流中的最大元素,如果流为空,则为 Optional.empty()。 |
| Example use: Optional |
||
| minBy | Optional |
一个 Optional,包含根据给定的比较器在此流中的最小元素,如果流为空,则为 Optional.empty()。 |
| Example use: Optional |
||
| reducing | 归约操作产生的类型 | 从一个用作累加器的初始值开始,迭代地将每个流项目与一个二元操作符组合,以将流归约为一个单一值。 |
| Example use: int totalCalories = menuStream.collect(reducing(0, Dish::getCalories, Integer::sum)); | ||
| collectingAndThen | 转换函数返回的类型 | 包装另一个收集器并对其结果应用转换函数。 |
| Example use: int howManyDishes = menuStream.collect(collectingAndThen(toList(), List::size)); | ||
| groupingBy | Map<K, List |
根据流中项目的一个属性值对项目进行分组,并使用这些值作为结果 Map 的键。 |
| Example use: Map<Dish.Type,List |
||
| partitioningBy | Map<Boolean, List |
根据对每个项目应用谓词的结果对流中的项目进行分区。 |
| Example use: Map<Boolean,List |
如本章开头所述,所有这些收集器都实现了Collector接口,因此在本章剩余部分,我们将更详细地研究这个接口。我们研究该接口中的方法,然后探索如何实现你自己的收集器。
6.5。Collector接口
Collector接口包含一组方法,为如何实现特定的归约操作(收集器)提供了一个蓝图。你已经看到了许多实现了Collector接口的收集器,例如toList或groupingBy。这也意味着你可以自由地创建定制的归约操作,通过提供自己的Collector接口实现。在第 6.6 节中,我们将展示如何实现Collector接口以创建一个收集器,将数字流划分为质数和非质数,比之前看到的方法更高效。
要开始使用Collector接口,我们关注本章开头遇到的第一批收集器之一:toList工厂方法,它将流中的所有元素收集到一个List中。我们说过,你将在日常工作中经常使用这个收集器,但至少在概念上,它也是容易开发的。更详细地研究这个收集器的实现方式是了解Collector接口定义以及其方法返回的函数如何被collect方法内部使用的好方法。
让我们先看看下一列表中Collector接口的定义,它显示了接口签名以及它声明的五个方法。
列表 6.4。Collector接口
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
Function<A, R> finisher();
BinaryOperator<A> combiner();
Set<Characteristics> characteristics();
}
在这个列表中,以下定义适用:
-
T是要收集的流中项的泛型类型。 -
A是累加器的类型,在收集过程中,部分结果将累积在这个对象上。 -
R是收集操作结果的对象类型(通常是,但不总是,集合)。
例如,你可以实现一个 ToListCollector<T> 类,该类将 Stream<T> 的所有元素收集到一个具有以下签名的 List<T> 中
public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
其中,正如我们很快就会澄清的,用于累积过程的对象也将是收集过程的最终结果。
6.5.1. 理解 Collector 接口声明的方法
我们现在可以逐个分析 Collector 接口声明的五种方法。当我们这样做时,你会注意到前四种方法返回一个函数,该函数将由 collect 方法调用,而第五种方法 characteristics 提供了一组特性,这是一个由 collect 方法本身使用的提示列表,以知道在执行归约操作时允许使用哪些优化(例如,并行化)。
创建新的结果容器:供应商方法
supplier 方法必须返回一个空的累加器的 Supplier——一个无参数的函数,当调用时创建用于收集过程的空累加器实例。显然,对于返回累加器本身作为结果的收集器,如我们的 ToListCollector,这个空累加器也将代表在空流上执行收集过程的结果。在我们的 ToListCollector 中,supplier 将返回一个空的 List,如下所示:
public Supplier<List<T>> supplier() {
return () -> new ArrayList<T>();
}
注意,你也可以传递一个构造器引用:
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
向结果容器添加元素:累加器方法
accumulator 方法返回执行归约操作的函数。当遍历流中的第 n 个元素时,此函数将使用两个参数应用,累加器是归约的结果(在收集了流的前 n–1 个元素之后)以及第 n 个元素本身。该函数返回 void,因为累加器是在原地修改的,这意味着其内部状态通过函数应用被改变,以反映遍历元素的效果。对于 ToListCollector,此函数只需将当前项添加到包含已遍历项的列表中:
public BiConsumer<List<T>, T> accumulator() {
return (list, item) -> list.add(item);
你也可以使用方法引用,这更简洁:
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
对结果容器应用最终转换:完成器方法
finisher方法必须返回一个函数,在完全遍历流并在累积过程结束时调用,以便将累加器对象转换为整个收集操作的最终结果。通常,就像在ToListCollector的情况下,累加器对象已经与最终预期的结果一致。因此,不需要执行转换,所以finisher方法必须返回identity函数:
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
这三种方法足以执行流的顺序减少,从逻辑角度来看,可以像图 6.7 中所示那样进行。在实践中,由于流的惰性特性,可能需要在collect操作之前执行一系列其他中间操作,以及理论上可以并行执行减少的可能性,实现细节要复杂一些。
图 6.7. 顺序减少过程的逻辑步骤

合并两个结果容器:合并方法
combiner方法,这是四种返回由减少操作使用的函数的方法中的最后一种,定义了当子部分并行处理时,从流的不同子部分减少产生的累加器如何合并。在toList的情况下,此方法的实现很简单;将包含从流的第二部分收集到的项目的列表添加到遍历第一部分时获得的列表的末尾:
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1; }
}
添加这种第四种方法允许流进行并行减少。这使用了 Java 7 中引入的 fork/join 框架和你在下一章中将学习的Spliterator抽象。它遵循一个类似于图 6.8 中所示的过程,并在此处详细描述。
-
原始的 流 会被递归地分割成子流,直到一个定义流是否需要进一步分割的条件变为假(当被分配的工作单元太小,并行计算通常比顺序计算慢,生成比你的处理核心更多的并行任务是没有意义的)。
-
到这一点,所有 子流 都可以并行处理,每个子流都使用图 6.7 中所示的顺序减少算法。
-
最后,所有部分结果都通过收集器的
combiner方法返回的函数成对合并。这是通过将对应于原始流每个分割的子流的减少结果组合起来完成的。
图 6.8. 使用combiner方法并行化减少过程

特征方法
最后一个方法characteristics返回一个不可变的Characteristics集合,定义了收集器的行为——特别是提供有关流是否可以并行归约以及在这种情况下哪些优化是有效的提示。Characteristics是一个枚举,包含三个条目:
-
UNORDERED— 归约的结果不受流中项目遍历和累积顺序的影响。 -
CONCURRENT— 累加器函数可以从多个线程中并发调用,然后这个收集器可以对流进行并行归约。如果收集器没有也标记为UNORDERED,它只能在应用于无序数据源时执行并行归约。 -
IDENTITY_FINISH— 这表示由 finisher 方法返回的函数是恒等函数,其应用可以省略。在这种情况下,累加器对象直接用作归约过程的最终结果。这也意味着从累加器A到结果R的不检查类型转换是安全的。
到目前为止开发的ToListCollector是IDENTITY_FINISH,因为用于在流中累积元素的List已经是预期的最终结果,不需要任何进一步的转换,但它不是UNORDERED,因为如果你将其应用于有序流,你希望这种顺序在结果List中得以保留。最后,它是CONCURRENT的,但根据我们刚才所说的,只有当其底层数据源是无序时,流才会并行处理。
6.5.2. 将它们全部放在一起
在上一个子节中分析的五个方法是你开发自己的ToListCollector所需的一切,你可以通过将它们全部放在一起来实现它,如以下列表所示。
列表 6.5. ToListCollector
import java.util.*;
import java.util.function.*;
import java.util.stream.Collector;
import static java.util.stream.Collector.Characteristics.*;
public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new; *1*
}
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add; *2*
}
@Override
public Function<List<T>, List<T>> finisher() {
return Function.identity(); *3*
}
@Override
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2); *4*
return list1; *5*
};
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(
IDENTITY_FINISH, CONCURRENT)); *6*
}
}
-
1 创建集合操作的起点
-
2 累积遍历的项目,就地修改累加器
-
3 识别函数
-
4 修改第一个累加器,将其与第二个的内容合并
-
5 返回修改后的第一个累加器
-
6 将收集器标记为 IDENTITY_FINISH 和 CONCURRENT
注意,这个实现与Collectors.toList方法返回的实现并不相同,但它们之间的差异仅在于一些小的优化。这些优化主要与 Java API 提供的收集器在需要返回空列表时使用Collections.emptyList()单例的事实有关。这意味着它可以安全地用作原始 Java 的示例,以收集菜单流中所有Dish的列表:
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());
与标准公式的剩余差异
List<Dish> dishes = menuStream.collect(toList());
是toList是一个工厂,而你必须使用new来实例化你的ToList-Collector。
执行自定义收集而不创建收集器实现
在IDENTITY_FINISH集合操作的情况下,还有进一步的可能性获得相同的结果,而无需开发一个全新的Collector接口实现。Streams 有一个重载的collect方法,接受三个其他函数——supplier、accumulator和combiner——它们具有与Collector接口相应方法返回的函数完全相同的语义。例如,可以将流中所有菜肴的所有项收集到一个List中,如下所示:
List<Dish> dishes = menuStream.collect(
ArrayList::new, *1*
List::add, *2*
List::addAll); *3*
-
1 供应者
-
2 累加器
-
3 组合器
我们认为,这种第二种形式,即使比前者更紧凑和简洁,但可读性较低。此外,在适当的类中开发您自定义收集器的实现可以促进其重用,并有助于避免代码重复。还值得注意的是,不允许向这个第二个collect方法传递任何Characteristics,因此它始终表现为IDENTITY_FINISH和CONCURRENT收集器,而不是UNORDERED收集器。
在下一节中,您将把实现收集器的新的知识提升到下一个层次。您将为更复杂但希望更具体和有说服力的用例开发自己的自定义收集器。
6.6. 开发自己的收集器以获得更好的性能
在第 6.4 节中,我们讨论了分区,您使用Collectors类提供的许多方便的工厂方法之一创建了一个收集器,将前n个自然数分为素数和非素数,如下所示。
列表 6.6. 将前n个自然数分为素数和非素数
public Map<Boolean, List<Integer>> partitionPrimes(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(partitioningBy(candidate -> isPrime(candidate));
}
在那里,你通过限制要测试的候选素数的除数数量不超过候选数的平方根,从而在原始isPrime方法上实现了改进:
public boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
有没有一种方法可以获得更好的性能?答案是肯定的,但为此你必须开发一个自定义收集器。
6.6.1. 只除以素数
一种可能的优化是只测试候选数是否能被素数整除。测试一个非素数的除数是没有意义的!你可以将测试限制为只针对在当前候选数之前找到的素数。你之前使用的预定义收集器的问题,以及你必须开发一个自定义收集器的原因,是在收集过程中你无法访问部分结果。这意味着在测试给定的候选数是否为素数时,你无法访问到目前为止找到的其他素数列表。
假设你有一个这样的列表;你可以将它传递给isPrime方法,并按如下方式重写:
public static boolean isPrime(List<Integer> primes, int candidate) {
return primes.stream().noneMatch(i -> candidate % i == 0);
}
此外,你还应该实现之前使用的相同优化,并且只对小于候选数平方根的素数进行测试。你需要一种方法,一旦下一个素数大于候选数的根,就可以立即停止测试候选数是否能被素数整除。你可以通过使用 Stream 的 takeWhile 方法轻松实现这一点:
public static boolean isPrime(List<Integer> primes, int candidate){
int candidateRoot = (int) Math.sqrt((double) candidate);
return primes.stream()
.takeWhile(i -> i <= candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
练习 6.3:在 Java 8 中模拟 takeWhile
takeWhile 方法是在 Java 9 中引入的,所以不幸的是,如果你还在使用 Java 8,则无法使用此解决方案。你该如何解决这个问题,并在 Java 8 中实现类似的功能?
答案:
你可以实现自己的 takeWhile 方法,该方法给定一个排序后的列表和一个谓词,返回满足谓词的列表的最长前缀:
public static <A> List<A> takeWhile(List<A> list, Predicate<A> p) {
int i = 0;
for (A item : list) {
if (!p.test(item)) { *1*
return list.subList(0, i); *2*
}
i++;
}
return list; *3*
}
-
1 检查列表中的当前项是否满足谓词
-
2 如果不是,则返回测试项之前子列表的前缀
-
3 列表中的所有项都满足谓词,因此返回列表本身
使用这个方法,你可以重写 isPrime 方法,并且再次只对候选素数与小于其平方根的素数进行测试:
public static boolean isPrime(List<Integer> primes, int candidate){
int candidateRoot = (int) Math.sqrt((double) candidate);
return takeWhile(primes, i -> i <= candidateRoot)
.stream()
.noneMatch(p -> candidate % p == 0);
}
注意,与 Streams API 提供的不同,这个 takeWhile 的实现是 eager 的。在可能的情况下,始终优先选择 Java 9 Stream 的 lazy 版本的 takeWhile,以便它可以与 noneMatch 操作合并。
拥有这个新的 isPrime 方法后,你现在可以准备实现你自己的自定义收集器。首先,你需要声明一个新的类,该类实现了 Collector 接口。然后,你需要开发 Collector 接口所需的五个方法。
第一步:定义收集器类的签名
让我们从类签名开始,记住 Collector 接口被定义为
public interface Collector<T, A, R>
其中 T、A 和 R 分别是流中元素的类型、用于累积部分结果的对象的类型以及 collect 操作的最终结果类型。在这种情况下,你想要收集 Integer 类型的流,同时累积器和结果类型都是 Map<Boolean, List<Integer>>(与在 列表 6.6 中的前一个分区操作得到的相同 Map),键为 true 和 false,值分别为素数和非素数的列表:
public class PrimeNumbersCollector
implements Collector<Integer, *1*
Map<Boolean, List<Integer>>, *2*
Map<Boolean, List<Integer>>> *3*
-
1 流中元素的类型
-
2 累积器的类型
-
3 收集操作的结果类型
第二步:实现减少过程
接下来,你需要实现 Collector 接口中声明的五个方法。supplier 方法必须返回一个函数,当调用该函数时,会创建累积器:
public Supplier<Map<Boolean, List<Integer>>> supplier() {
return () -> new HashMap<Boolean, List<Integer>>() {{
put(true, new ArrayList<Integer>());
put(false, new ArrayList<Integer>());
}};
}
在这里,你不仅创建了一个将用作累加器的Map,而且还使用两个空列表在true和false键下初始化它。这就是你在收集过程中分别添加素数和非素数的地方。你收集器最重要的方法是accumulator方法,因为它包含了定义流元素如何收集的逻辑。在这种情况下,它也是实现我们之前描述的优化的关键。在任何给定的迭代中,你现在可以访问收集过程的局部结果,即包含迄今为止找到的素数的累加器:
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
acc.get( isPrime(acc.get(true), candidate) ) *1*
.add(candidate); *2*
};
}
-
1 根据 isPrime 的结果获取素数或非素数列表
-
2 将候选者添加到适当的列表
在这个方法中,你调用isPrime方法,将其(连同你想要测试是否为素数的数字)以及迄今为止找到的素数列表传递给它。(这些是累积Map中由true键索引的值。)然后,这个调用的结果被用作键来获取素数或非素数列表,这样你就可以将新的候选者添加到正确的列表中。
第 3 步:使收集器并行工作(如果可能)
下一个方法必须在并行收集过程中合并两个部分累加器,因此在这种情况下,它必须通过将第二个Map中素数和非素数列表的所有数字添加到第一个Map中相应的列表来合并这两个Map:
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (Map<Boolean, List<Integer>> map1,
Map<Boolean, List<Integer>> map2) -> {
map1.get(true).addAll(map2.get(true));
map1.get(false).addAll(map2.get(false));
return map1;
};
}
注意,实际上这个收集器不能并行使用,因为算法本质上是顺序的。这意味着combiner方法永远不会被调用,你可以将其实现留空(或者更好,抛出UnsupportedOperationException)。我们决定仍然实现它,只是为了完整性。
第 4 步:finisher 方法和收集器的特征方法
最后两个方法的实现相当直接。正如我们所说,accumulator与收集器的结果相同,因此不需要任何进一步的转换,而finisher方法返回identity函数:
public Function<Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>> finisher() {
return Function.identity();
}
关于特征方法,我们之前已经说过,它既不是CONCURRENT也不是UNORDERED,而是IDENTITY_FINISH:
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
}
以下列表显示了PrimeNumbersCollector的最终实现。
列表 6.7. PrimeNumbersCollector
public class PrimeNumbersCollector
implements Collector<Integer,
Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>> {
@Override
public Supplier<Map<Boolean, List<Integer>>> supplier() {
return () -> new HashMap<Boolean, List<Integer>>() {{ *1*
put(true, new ArrayList<Integer>());
put(false, new ArrayList<Integer>());
}};
}
@Override
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
acc.get( isPrime( acc.get(true), *2*
candidate) )
.add(candidate); *3*
};
}
@Override
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (Map<Boolean, List<Integer>> map1,
Map<Boolean, List<Integer>> map2) -> { *4*
map1.get(true).addAll(map2.get(true));
map1.get(false).addAll(map2.get(false));
return map1;
};
}
@Override
public Function<Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>> finisher() {
return Function.identity(); *5*
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH)); *6*
}
}
-
1 使用包含两个空列表的 Map 开始收集过程
-
2 将已找到的素数列表传递给 isPrime 方法
-
3 从 Map 中获取素数或非素数列表,根据 isPrime 方法返回的结果,并将当前候选者添加到其中
-
4 将第二个 Map 合并到第一个 Map 中
-
5 收集过程结束时不需要转换,因此使用恒等函数终止它
-
6 这个收集器是 IDENTITY_FINISH,但既不是 UNORDERED 也不是 CONCURRENT,因为它依赖于质数是按顺序发现的这一事实。
您现在可以使用这个新的自定义收集器来替代之前使用partitioningBy工厂方法在第 6.4 节中创建的旧收集器,并得到完全相同的结果:
public Map<Boolean, List<Integer>>
partitionPrimesWithCustomCollector(int n) {
return IntStream.rangeClosed(2, n).boxed()
.collect(new PrimeNumbersCollector());
}
6.6.2. 比较收集器的性能
使用partitioningBy工厂方法创建的收集器和您刚刚开发的自定义收集器在功能上是相同的,但您是否通过自定义收集器实现了提高partitioningBy收集器性能的目标?让我们快速编写一个测试程序来检查这一点:
public class CollectorHarness {
public static void main(String[] args) {
long fastest = Long.MAX_VALUE;
for (int i = 0; i < 10; i++) { *1*
long start = System.nanoTime();
partitionPrimes(1_000_000); *2*
long duration = (System.nanoTime() - start) / 1_000_000; *3*
if (duration < fastest) fastest = duration; *4*
}
System.out.println(
"Fastest execution done in " + fastest + " msecs");
}
}
-
1 运行测试 10 次
-
2 将前一百万个自然数分为质数和非质数
-
3 持续时间(毫秒)
-
4 检查这是否是执行最快的
注意,一个更科学的基准测试方法将是使用一个框架,例如 Java Microbenchmark Harness (JMH),但我们不想在这里增加使用此类框架的复杂性,并且对于这个用例,这个小基准测试类提供的结果已经足够准确。这个类将前一百万个自然数分为质数和非质数,使用partitioningBy工厂方法创建的收集器调用该方法 10 次,并记录最快的执行时间。在 Intel i5 2.4 GHz 上运行它,它将打印以下结果:
Fastest execution done in 4716 msecs
现在将测试程序中的partitionPrimes替换为partitionPrimesWithCustomCollector,以测试您开发的自定义收集器的性能。现在程序将打印
Fastest execution done in 3201 msecs
还不错!这意味着您没有浪费时间开发这个自定义收集器,原因有两个:首先,当您需要时,您学习了如何实现自己的收集器。其次,您实现了大约 32%的性能提升。
最后,重要的是要注意,正如您在列表 6.5 中对ToListCollector所做的那样,通过将实现PrimeNumbersCollector核心逻辑的三个函数传递给collect方法的重载版本,作为参数,您可以得到相同的结果:
public Map<Boolean, List<Integer>> partitionPrimesWithCustomCollector
(int n) {
IntStream.rangeClosed(2, n).boxed()
.collect(
() -> new HashMap<Boolean, List<Integer>>() {{ *1*
put(true, new ArrayList<Integer>());
put(false, new ArrayList<Integer>());
}},
(acc, candidate) -> { *2*
acc.get( isPrime(acc.get(true), candidate) )
.add(candidate);
},
(map1, map2) -> { *3*
map1.get(true).addAll(map2.get(true));
map1.get(false).addAll(map2.get(false));
});
}
-
1 供应者
-
2 累加器
-
3 组合器
正如您所看到的,这样您可以避免创建一个完全新的实现Collector接口的类;生成的代码更加紧凑,即使它可能也难以阅读,并且肯定不太可重用。
摘要
-
collect是一个终端操作,它接受各种配方(称为收集器)作为参数,用于将流中的元素累积到总结结果中。 -
预定义的收集器包括将流元素减少和总结为单个值,例如计算最小值、最大值或平均值。这些收集器在表 6.1 中总结。
-
预定义的收集器允许您使用
groupingBy对流的元素进行分组,并使用partitioningBy对流的元素进行分区。 -
收集器有效地组合以创建多级分组、分区和归约。
-
您可以通过实现
Collector接口中定义的方法来开发自己的收集器。
第七章. 并行数据处理和性能
本章涵盖
-
使用并行流并行处理数据
-
并行流的性能分析
-
分支/合并框架
-
使用
Spliterator分割数据流
在前三章中,您已经看到新的Streams接口如何让您以声明性方式操作数据集合。我们还解释了,从外部迭代到内部迭代的转变使得 Java 库能够控制流元素的处理。这种方法减轻了 Java 开发者显式实现优化以加快数据集合处理速度的负担。迄今为止,最重要的好处是能够在这些集合上执行操作管道,这会自动利用您计算机上的多个核心。
例如,在 Java 7 之前,并行处理数据集合非常繁琐。首先,您需要显式地将包含您数据的结构分割成子部分。其次,您需要将这些子部分分配给不同的线程。第三,您需要适当地同步它们以避免不希望的竞态条件,等待所有线程完成,最后合并部分结果。Java 7 引入了一个名为fork/join的框架来更一致且更不易出错地执行这些操作。我们将在第 7.2 节中探讨这个框架。
在本章中,您将发现Streams接口如何让您有机会在数据集合上轻松执行并行操作。它允许您声明性地将顺序流转换为并行流。此外,您还将看到 Java 如何通过使用在 Java 7 中引入的分支/合并框架来实现这一魔法,或者更实际地说,并行流是如何在底层工作的。您还将发现了解并行流内部工作方式的重要性,因为如果您忽略这个方面,您可能会通过误用它们而获得意外的(并且可能是错误的)结果。
尤其是您将发现,在并行处理不同块之前,并行流被分割成块的方式在某些情况下可能是这些不正确且看似无法解释的结果的来源。因此,您将学习如何通过实现和使用自己的Spliterator来控制这个分割过程。
7.1. 并行流
在第四章中,我们简要提到了Streams接口允许你以方便的方式并行处理其元素:可以通过在集合源上调用parallelStream方法将一个集合转换为并行流。一个并行流是一个将元素分割成多个块,并使用不同的线程处理每个块的流。因此,你可以自动将给定操作的工作负载分配到多核处理器的所有核心上,并保持它们都同样忙碌。让我们通过一个简单的例子来实验这个想法。
假设你需要编写一个方法,该方法接受一个数字n作为参数,并返回从 1 到n的数字之和。一个直接(可能有些天真)的方法是生成一个无限数字流,限制它只包含传入的数字,然后使用BinaryOperator将两个数字相加的归约操作来减少生成的流,如下所示:
public long sequentialSum(long n) {
return Stream.iterate(1L, i -> i + 1) *1*
.limit(n) *2*
.reduce(0L, Long::sum); *3*
}
-
1 生成自然数的无限流
-
2 限制它只包含前n个数字**
-
3 通过求和所有数字来减少流
在更传统的 Java 术语中,这段代码与其迭代对应物等价:
public long iterativeSum(long n) {
long result = 0;
for (long i = 1L; i <= n; i++) {
result += i;
}
return result;
}
这个操作似乎是一个很好的并行化候选,特别是对于大的n值。但你应该从哪里开始?你是在结果变量上同步吗?你使用多少线程?谁生成数字?谁将它们加起来?
不要担心这些。如果你采用并行流,这是一个要简单得多的问题!
7.1.1. 将顺序流转换为并行流
你可以通过将流转换为并行流来使前面的函数式归约过程(求和)并行运行;在顺序流上调用parallel方法:
public long parallelSum(long n) {
return Stream.iterate(1L, i -> i + 1)
.limit(n)
.parallel() *1*
.reduce(0L, Long::sum);
}
- 1 将流转换为并行流
在之前的代码中,用于对流中所有数字求和的归约过程的工作方式与第 5.4.1 节中描述的方式类似。不同之处在于,流现在被内部分割成多个块。因此,归约操作可以独立且并行地在各个块上工作,如图 7.1 所示。最后,相同的归约操作将每个子流的局部归约结果值合并,从而产生整个初始流的归约过程的结果。
图 7.1. 并行归约操作

注意,在现实中,在顺序流上调用parallel方法并不意味着对流本身进行任何具体的转换。内部,会设置一个boolean标志来表示你希望并行执行所有跟随parallel调用之后的操作。同样,你也可以通过在并行流上调用sequential方法将其转换为顺序流。注意,你可能认为通过结合这两种方法,你可以更精细地控制你在遍历流时希望并行执行哪些操作以及哪些操作是顺序执行的。例如,你可以做如下操作:
stream.parallel()
.filter(...)
.sequential()
.map(...)
.parallel()
.reduce();
但最后调用parallel或sequential的会生效并影响整个流水线。在这个例子中,流水线将并行执行,因为这是流水线中的最后一个调用。
配置并行流使用的线程池
看一下流的parallel方法,你可能想知道并行流使用的线程从哪里来,有多少个,以及如何自定义这个过程。
并行流内部使用默认的ForkJoinPool(你将在第 7.2 节中了解更多关于 fork/join 框架的信息),默认情况下,线程的数量与处理器数量相同,由Runtime.getRuntime().availableProcessors()返回。
但你可以使用系统属性java.util.concurrent.ForkJoinPool.common.parallelism来改变这个池的大小,如下例所示:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism",
"12");
这是一个全局设置,因此它将影响你代码中的所有并行流。相反,目前还不可能为单个并行流指定这个值。一般来说,让ForkJoinPool的大小等于机器上的处理器数量是一个有意义的默认值,我们强烈建议除非你有充分的理由,否则不要修改它。
回到求和练习,我们说过,当在多核处理器上运行其并行版本时,你可以期待显著的性能提升。现在你有三种方法以三种不同的方式执行完全相同的操作(迭代风格、顺序归约和并行归约),那么让我们看看哪个是最快的!
7.1.2. 测量流性能
我们声称并行化求和的方法应该比顺序和迭代方法表现更好。然而,在软件工程中,猜测永远不是一个好主意!在优化性能时,你应该始终遵循三个黄金法则:测量,测量,再测量。为此,我们将使用名为 Java Microbenchmark Harness (JMH) 的库来实现一个微基准测试。这是一个工具包,它以简单、基于注解的方式帮助创建可靠的微基准测试,用于 Java 程序以及任何其他针对 Java 虚拟机 (JVM) 的语言。实际上,为在 JVM 上运行的程序开发正确且有意义的基准测试并不容易,因为有许多因素需要考虑,例如 HotSpot 需要多少预热时间来优化字节码以及垃圾收集器引入的开销。如果你使用 Maven 作为构建工具,那么要开始在项目中使用 JMH,你需要在 pom.xml 文件(它定义了 Maven 构建过程)中添加几个依赖项。
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.17.4</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.17.4</version>
</dependency>
第一个库是 JMH 的核心实现,而第二个包含一个注解处理器,它有助于通过生成 Java 归档(JAR)文件来运行你的基准测试,一旦你也在你的 Maven 配置中添加了以下插件:
<build>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<finalName>benchmarks</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.
resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
完成这些后,你可以以这种方式基准测试本节开头引入的 sequentialSum 方法,如下一个列表所示。
列表 7.1. 测量累加前 n 个数字的函数的性能
@BenchmarkMode(Mode.AverageTime) *1*
@OutputTimeUnit(TimeUnit.MILLISECONDS) *2*
@Fork(2, jvmArgs={"-Xms4G", "-Xmx4G"}) *3*
public class ParallelStreamBenchmark {
private static final long N= 10_000_000L;
@Benchmark *4*
public long sequentialSum() {
return Stream.iterate(1L, i -> i + 1).limit(N)
.reduce( 0L, Long::sum);
}
@TearDown(Level.Invocation) *5*
public void tearDown() {
System.gc();
}
}
-
1 测量基准测试方法的平均耗时
-
2 使用毫秒作为时间单位打印基准测试结果
-
3 执行基准测试 2 次,以增加结果的可靠性,使用 4Gb 的堆空间
-
4 要基准测试的方法
-
5 尝试在基准测试的每次迭代后运行垃圾收集器
当你编译这个类时,之前配置的 Maven 插件会生成一个名为 benchmarks.jar 的第二个 JAR 文件,你可以按照以下方式运行它:
java -jar ./target/benchmarks.jar ParallelStreamBenchmark
我们配置基准测试使用过大的堆来尽可能避免垃圾收集器的影响,出于同样的原因,我们尝试强制垃圾收集器在基准测试的每次迭代后运行。尽管采取了所有这些预防措施,但必须注意的是,结果应该带着怀疑的态度来看待。许多因素会影响执行时间,例如你的机器支持多少核心!你可以通过运行书中仓库中提供的代码在自己的机器上尝试这个。
当你启动前者时,让 JMH 执行 20 次基准方法的预热迭代,以允许 HotSpot 优化代码,然后执行 20 次更多迭代,这些迭代用于计算最终结果。这 20+20 次迭代是 JMH 的默认行为,但你可以通过其他 JMH 特定注解或更方便地通过使用-w和-i标志将它们添加到命令行来更改这两个值。在配备 Intel i7-4600U 2.1 GHz 四核的计算机上执行它,会打印出以下结果:
Benchmark Mode Cnt Score Error Units
ParallelStreamBenchmark.sequentialSum avgt 40 121.843 ± 3.062 ms/op
你应该预期使用传统for循环的迭代版本会运行得更快,因为它在更低级别工作,更重要的是,它不需要执行任何原始值的装箱或拆箱。我们可以通过向列表 7.1 的基准测试类添加第二个方法并使用@Benchmark注解来检查这个直觉。
@Benchmark
public long iterativeSum() {
long result = 0;
for (long i = 1L; i <= N; i++) {
result += i;
}
return result;
}
在我们的测试机器上运行这个第二个基准(可能已经注释掉第一个以避免再次运行)后,我们得到了以下结果:
Benchmark Mode Cnt Score Error Units
ParallelStreamBenchmark.iterativeSum avgt 40 3.278 ± 0.192 ms/op
这证实了我们的预期:迭代版本几乎比我们预期的使用顺序流的版本快 40 倍。现在让我们用使用并行流的版本做同样的操作,也将该方法添加到我们的基准测试类中。我们得到了以下结果:
Benchmark Mode Cnt Score Error Units
ParallelStreamBenchmark.parallelSum avgt 40 604.059 ± 55.288 ms/op
这相当令人失望:求和方法的并行版本并没有充分利用我们的四核 CPU,其速度比顺序版本慢大约五倍。你如何解释这个意外的结果?有两个问题混合在一起:
-
iterate生成装箱对象,在它们可以相加之前必须将它们拆箱成数字。 -
iterate难以分割成可以独立执行的独立块。
第二个问题特别有趣,因为你需要保持一个心理模型,即某些流操作比其他操作更容易并行化。具体来说,iterate操作难以分割成可以独立执行的块,因为一个函数应用的输入总是依赖于前一个应用的结果,如图 7.2 所示。
图 7.2. iterate本质上是顺序的。

这意味着在这个特定情况下,减少过程并没有像图 7.1 中描述的那样进行:在减少过程的开始时,整个数字列表不可用,这使得无法有效地将流分成要并行处理的块。通过将流标记为并行,你给顺序处理增加了在每个不同线程上分配每个求和操作的开销。
这展示了并行编程可能很棘手,有时甚至反直觉。当误用(例如,使用不友好的并行操作,如 iterate)时,它可能会降低程序的整体性能,因此理解当你调用那个看似神奇的 parallel 方法时幕后发生了什么是强制性的。
使用更专业的方法
那么你如何有效地使用多核处理器和流来执行并行求和呢?我们已经在第五章中讨论了一种名为 LongStream.rangeClosed 的方法。与 iterate 相比,这种方法有两个优点:
-
LongStream.rangeClosed直接在原始long数字上工作,因此没有装箱和拆箱的开销。 -
LongStream.rangeClosed生成数字范围,这些范围可以轻松地分割成独立的块。例如,范围 1–20 可以分割成 1–5、6–10、11–15 和 16–20。
让我们先通过向我们的基准测试类添加以下方法来查看它在顺序流上的性能,以检查与拆箱相关的开销是否相关:
@Benchmark
public long rangedSum() {
return LongStream.rangeClosed(1, N)
.reduce(0L, Long::sum);
}
这次输出的是
Benchmark Mode Cnt Score Error Units
ParallelStreamBenchmark.rangedSum avgt 40 5.315 ± 0.285 ms/op
数字流比之前使用 iterate 工厂方法生成的早期顺序版本要快得多,因为数字流避免了所有由非专业流执行的所有不必要的自动装箱和自动拆箱操作造成的开销。这是选择正确数据结构通常比并行化使用它们的算法更重要这一点的证据。但是,如果你尝试使用这个新版本中的并行流会怎样呢?
@Benchmark
public long parallelRangedSum() {
return LongStream.rangeClosed(1, N)
.parallel()
.reduce(0L, Long::sum);
}
现在,将此方法添加到我们的基准测试类中,我们得到了
Benchmark Mode Cnt Score Error Units
ParallelStreamBenchmark.parallelRangedSum avgt 40 2.677 ± 0.214 ms/op
最后,我们得到了一个比其顺序对应版本更快的并行归约,因为这次归约操作可以像在图 7.1 中所示的那样执行。这也表明,使用正确的数据结构并且使其并行运行可以保证最佳性能。请注意,这个最新版本也比原始迭代版本快约 20%,这表明,当正确使用时,函数式编程风格允许我们以比命令式对应物更简单、更直接的方式使用现代多核 CPU 的并行性。
然而,请记住,并行化并非免费午餐。并行化过程本身要求你递归地分割流,将每个子流的归约操作分配给不同的线程,然后在一个单一值中合并这些操作的结果。但将数据在多个核心之间移动比你预期的要昂贵得多,因此,确保在另一个核心上并行执行的工作比从一个核心到另一个核心传输数据所需的时间更长是很重要的。一般来说,有许多情况下无法或不太方便使用并行化。但在你使用并行stream来加速你的代码之前,你必须确保你使用它是正确的;如果结果会出错,那么即使结果更快也是没有帮助的。让我们看看一个常见的陷阱。
7.1.3. 正确使用并行流
由滥用并行流产生的错误的主要原因是在算法中修改了一些共享状态。以下是通过修改共享累加器来实现前 n 个自然数之和的方法:
public long sideEffectSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).forEach(accumulator::add);
return accumulator.total;
}
public class Accumulator {
public long total = 0;
public void add(long value) { total += value; }
}
这种类型的代码很常见,尤其是对于熟悉命令式编程范式的开发者来说。这段代码与你习惯于迭代数字列表时的操作非常相似:你初始化一个累加器,逐个遍历列表中的元素,并将它们添加到累加器中。
这段代码有什么问题?不幸的是,它是无法恢复的,因为它是基本顺序的。你每次访问 total 时都会有一个数据竞争。如果你尝试通过同步来修复它,你将失去所有的并行性。为了理解这一点,让我们尝试将 stream 转换为并行流:
public long sideEffectParallelSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
return accumulator.total;
}
尝试使用列表 7.1 的 harness 运行这个最后的方法,并打印每次执行的输出:
System.out.println("SideEffect parallel sum done in: " +
measurePerf(ParallelStreams::sideEffectParallelSum, 10_000_000L) + "
msecs" );
你可能会得到以下类似的结果:
Result: 5959989000692
Result: 7425264100768
Result: 6827235020033
Result: 7192970417739
Result: 6714157975331
Result: 7497810541907
Result: 6435348440385
Result: 6999349840672
Result: 7435914379978
Result: 7715125932481
SideEffect parallel sum done in: 49 msecs
这次你的方法性能并不重要。唯一相关的是,每次执行都返回不同的结果,所有结果都与正确的值 50000005000000 相去甚远。这是由于多个线程同时访问累加器,特别是执行 total += value,尽管它看起来是原子的,但实际上并不是。问题的根源在于 forEach 块内部调用的方法具有改变多个线程之间共享对象可变状态的外部效应。如果你想在没有任何类似坏惊喜的情况下使用并行流,就必须避免这些情况。
现在你已经知道共享可变状态与并行流以及一般的并行计算不太兼容。我们将在第十八章和第十九章中更详细地讨论函数式编程时回到避免突变这个想法。现在,请记住,避免共享可变状态可以确保你的并行流产生正确的结果。接下来,我们将探讨一些实用的建议,你可以使用这些建议来确定何时使用并行流以获得性能提升。
7.1.4. 高效使用并行流
通常,尝试给出何时使用并行流的任何定量提示都是不可能的(也是没有意义的),因为任何特定的标准,例如“只有当流包含超过一千个元素时”,对于在特定机器上运行的特定操作可能是正确的,但在略微不同的环境中则完全错误。尽管如此,至少可以提供一些定性的建议,这些建议在决定在某种情况下是否使用并行流时可能是有用的:
-
如果有疑问,请进行测量。将顺序流转换为并行流是微不足道的,但并不总是正确的事情。正如我们已经在本节中展示的那样,并行流并不总是比相应的顺序版本快。此外,并行流有时可能会以反直觉的方式工作,因此在选择顺序流和并行流时,最重要的建议是始终使用适当的基准测试来检查它们的性能。
-
注意装箱问题。自动装箱和拆箱操作可能会严重影响性能。Java 8 包含原始流(
IntStream、LongStream和DoubleStream),以避免此类操作,因此尽可能使用它们。 -
一些操作在并行流上的性能不如在顺序流上。特别是,依赖于元素顺序的操作,如
limit和findFirst,在并行流中成本较高。例如,findAny的性能将优于findFirst,因为它不受操作顺序的限制。你可以通过调用unordered方法将有序流转换为无序流。例如,如果你需要流中的 N 个元素,而你并不一定对前 N 个元素感兴趣,对无序并行流调用limit可能比在有序流(例如,当源是List时)上执行更有效。 -
考虑流执行的操作的总计算成本。其中 N 是要处理的元素数量,Q 是通过流管道处理这些元素之一的近似成本,NQ* 的乘积给出了这种成本的大致定性估计。Q 的值越高,使用并行流时获得良好性能的机会就越大。
-
对于少量数据,选择并行流几乎从未是明智的决定。并行处理仅几个元素的优势不足以弥补并行化过程带来的额外成本。
-
考虑底层数据结构如何有效地分解流。例如,
ArrayList比LinkedList更有效地分割,因为前者可以均匀分割而不需要遍历,而后者则必须这样做。此外,使用range工厂方法创建的原始流可以快速分解。最后,正如你将在第 7.3 节中学习的,你可以通过实现自己的Spliterator来完全控制这个分解过程。 -
流的特性以及通过管道进行的中间操作如何修改它们,可以改变分解过程的表现。例如,一个
SIZED流可以被分成两个相等的部分,然后每个部分可以更有效地并行处理,但过滤器操作可能会丢弃不可预测数量的元素,使得流本身的大小变得未知。 -
考虑终端操作是否具有昂贵或便宜的合并步骤(例如,
Collector中的combiner方法)。如果这是昂贵的,那么由每个子流生成的部分结果的合并所造成的成本可能会超过并行流带来的性能优势。
表 7.1 总结了某些流源在可分解性方面的并行友好性。
表 7.1. 流源和可分解性
| 源 | 可分解性 |
|---|---|
| ArrayList | 优秀 |
| LinkedList | 差 |
| IntStream.range | 优秀 |
| Stream.iterate | 差 |
| HashSet | 良好 |
| TreeSet | 良好 |
最后,我们需要强调,并行流在幕后使用的用于并行执行操作的架构是 Java 7 中引入的分支/合并框架。并行求和示例证明,为了正确使用并行流,了解并行流的内部机制至关重要,因此我们将在下一节详细研究分支/合并框架。
7.2. 分支/合并框架
分支/合并框架被设计为递归地将可并行化的任务分解成更小的任务,然后将每个子任务的输出组合起来以产生整体结果。它是ExecutorService接口的实现,将这些子任务分配到线程池中的工作线程,称为ForkJoinPool。让我们首先探索如何定义任务和子任务。
7.2.1. 与 RecursiveTask 一起工作
要向此池提交任务,您必须创建一个 RecursiveTask<R> 的子类,其中 R 是并行化任务(及其每个子任务)产生的结果的类型,或者如果任务不返回结果(尽管它可能更新其他非局部结构),则为 RecursiveAction。要定义 RecursiveTasks,您只需要实现其单个抽象方法 compute:
protected abstract R compute();
此方法定义了将当前任务拆分为子任务的逻辑以及当不再可能或方便进一步拆分时产生单个子任务结果的算法。因此,此方法的实现通常类似于以下伪代码:
if (task is small enough or no longer divisible) {
compute task sequentially
} else {
split task in two subtasks
call this method recursively possibly further splitting each subtask
wait for the completion of all subtasks
combine the results of each subtask
}
通常,没有精确的标准来决定是否应该进一步拆分给定的任务,但有各种启发式方法可以帮助您做出这个决定。我们将在 7.2.2 节 中更详细地说明这些方法。递归任务拆分过程在 图 7.3 中以视觉形式综合展示。
图 7.3. Fork/join 过程

如您所注意到的,这不过是众所周知的分而治之算法的并行版本。为了演示如何使用 fork/join 框架并基于我们之前的示例,让我们尝试使用此框架计算一系列数字(在此由数字数组 long[] 表示)的总和。如前所述,您需要首先为 RecursiveTask 类提供一个实现,如列表 7.2 中的 ForkJoinSumCalculator 所示。
列表 7.2. 使用 fork/join 框架执行并行求和
public class ForkJoinSumCalculator
extends java.util.concurrent.RecursiveTask<Long> { *1*
private final long[] numbers; *2*
private final int start; *3*
private final int end;
public static final long THRESHOLD = 10_000; *4*
public ForkJoinSumCalculator(long[] numbers) { *5*
this(numbers, 0, numbers.length);
}
private ForkJoinSumCalculator(long[] numbers, int start, int end) { *6*
this.numbers = numbers;
this.start = start;
this.end = end;
}
@Override
protected Long compute() { *7*
int length = end - start; *8*
if (length <= THRESHOLD) {
return computeSequentially(); *9*
}
ForkJoinSumCalculator leftTask =
new ForkJoinSumCalculator(numbers, start, start + length/2); *10*
leftTask.fork(); *11*
ForkJoinSumCalculator rightTask =
new ForkJoinSumCalculator(numbers, start + length/2, end); *12*
Long rightResult = rightTask.compute(); *13*
Long leftResult = leftTask.join(); *14*
return leftResult + rightResult; *15*
}
private long computeSequentially() { *16*
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}
return sum;
}
}
-
1 将 RecursiveTask 扩展为可用于 fork/join 框架的任务
-
2 要求和的数字数组
-
3 此子任务处理的子数组的初始和最终位置
-
4 将任务拆分为子任务的大小阈值
-
5 公共构造函数用于创建主任务
-
6 私有构造函数用于创建主任务的子任务
-
7 覆盖 RecursiveTask 的抽象方法
-
8 此任务求和的子数组的大小
-
9 如果大小小于或等于阈值,则按顺序计算结果
-
10 创建一个子任务来求和数组的前半部分
-
11 使用 ForkJoinPool 的另一个线程异步执行新创建的子任务
-
12 创建一个子任务来求和数组的后半部分
-
13 同步执行此第二个子任务,可能允许进一步的递归拆分
-
14 读取第一个子任务的结果——如果它尚未准备好则等待
-
15 合并两个子任务的结果
-
16 低于阈值的简单顺序算法
现在编写一个执行并行求和前 n 个自然数的方法的步骤非常简单。您需要将所需的数字数组传递给 ForkJoinSumCalculator 构造函数:
public static long forkJoinSum(long n) {
long[] numbers = LongStream.rangeClosed(1, n).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
return new ForkJoinPool().invoke(task);
}
在这里,您使用Long-Stream生成一个包含前n个自然数的数组。然后,您创建一个ForkJoinTask(RecursiveTask的父类),将此数组传递给列表 7.2 中所示的ForkJoinSumCalculator的公共构造函数。最后,您创建一个新的ForkJoinPool并将该任务传递给其invoke方法。此最后方法的返回值是ForkJoin-SumCalculator类在ForkJoinPool内部执行时定义的任务的结果。
注意,在实际应用中,使用多个ForkJoinPool是没有意义的。因此,您通常应该只实例化一次,并将此实例保留在静态字段中,使其成为单例,这样它就可以方便地被软件的任何部分重用。在这里,为了创建它,您正在使用它的默认无参数构造函数,这意味着您希望池使用 JVM 可用的所有处理器。更确切地说,此构造函数将使用Runtime.availableProcessors返回的值来确定池使用的线程数。请注意,尽管availableProcessors方法的名字如此,但实际上它返回的是可用的核心数,包括由于超线程而产生的任何虚拟核心。
运行 ForkJoinSumCalculator
当您将ForkJoinSumCalculator任务传递给ForkJoinPool时,此任务由池中的一个线程执行,该线程随后调用任务的compute方法。此方法检查任务是否足够小,可以顺序执行;否则,它将待求和的数字数组分成两半,并将它们分配给两个新的ForkJoinSumCalculator,这些新任务被安排由Fork-JoinPool执行。因此,此过程可以递归重复,直到满足不再方便或不再可能进一步拆分的条件(在这种情况下,如果待求和的项目数小于或等于 10,000)。在此点,每个子任务的结果顺序计算,并通过分支过程创建的任务的二叉树回溯到其根。然后计算任务的结果,结合每个子任务的局部结果。此过程在图 7.4 中显示。
图 7.4. 分支/合并算法

再次使用本章开头开发的 harness,您可以使用分支/合并框架显式检查求和方法的性能:
System.out.println("ForkJoin sum done in: " + measureSumPerf(
ForkJoinSumCalculator::forkJoinSum, 10_000_000) + " msecs" );
在这种情况下,它产生以下输出:
ForkJoin sum done in: 41 msecs
在这里,性能不如使用并行流的版本,但这仅仅是因为您被迫在允许使用ForkJoinSumCalculator任务之前,将整个数字流放入一个long[]数组中。
7.2.2. 使用分支/合并框架的最佳实践
尽管 fork/join 框架相对容易使用,但不幸的是,它也很容易误用。以下是一些有效使用它的最佳实践:
-
在任务上调用
join方法会阻塞调用者,直到该任务产生的结果准备好。因此,必须在启动两个子任务的计算之后调用它。否则,你最终会得到一个比原始顺序算法更慢、更复杂的版本,因为每个子任务都必须等待另一个子任务完成才能开始。 -
ForkJoinPool的invoke方法不应该在RecursiveTask内部使用。相反,你应该始终直接调用compute或fork方法;只有顺序代码应该使用invoke来开始并行计算。 -
在子任务上调用
fork方法是将其调度到Fork-JoinPool上的方式。虽然对左右子任务都调用它看起来很自然,但这样做不如直接在其中一个上调用compute更有效率。这样做允许你为两个子任务中的任何一个重用相同的线程,并避免由于在池中不必要的进一步任务分配而产生的开销。 -
使用 fork/join 框架调试并行计算可能很棘手。特别是,通常在最喜欢的 IDE 中浏览堆栈跟踪以发现问题的原因是非常常见的,但这种方法不适用于 fork/join 计算,因为
compute的调用发生在与概念调用者不同的线程中,而概念调用者是调用fork的代码。 -
正如你在并行流中发现的那样,你不应该理所当然地认为在多核处理器上使用 fork/join 框架进行的计算比顺序版本更快。我们之前已经说过,一个任务应该可以分解为几个独立的子任务,以便能够并行化并取得相关的性能提升。所有这些子任务应该比创建新任务的执行时间更长;一个常见的做法是将 I/O 放入一个子任务,将计算放入另一个子任务,从而重叠计算与 I/O。此外,在比较相同算法的顺序和并行版本的性能时,你应该考虑其他因素。像任何其他 Java 代码一样,fork/join 框架需要被“预热”,或者执行几次,然后才能被 JIT 编译器优化。这就是为什么在测量性能之前,总是重要地多次运行程序,就像我们在我们的工具中做的那样。此外,编译器内建优化可能会不公平地给顺序版本带来优势(例如,通过执行死代码分析——移除从未使用的计算)。
fork/join 拆分策略值得再提一句:你必须选择用于决定给定子任务是否应该进一步拆分或足够小以至于可以顺序评估的准则。我们将在下一节中给出一些关于这个问题的提示。
7.2.3. 工作窃取
在我们的ForkJoinSumCalculator示例中,我们决定当要加和的数字数组最多包含 10,000 项时停止创建更多子任务。这是一个任意的选择,但在大多数情况下,除了尝试通过使用不同的输入进行多次尝试来优化它之外,很难找到一个好的启发式方法。在我们的测试案例中,我们从一个包含 1000 万个项目的数组开始,这意味着ForkJoinSumCalculator至少会分叉 1000 个子任务。这看起来可能像是一种资源浪费,因为我们是在一个只有四个核心的机器上运行的。在这种情况下,这可能确实是正确的,因为所有任务都是 CPU 密集型的,并且预计会花费相似的时间。
但将大量细粒度任务进行分叉通常是一个明智的选择。这是因为理想情况下,你希望将并行化任务的工作量分割成这样的方式,即每个子任务花费的时间完全相同,保持 CPU 的所有核心都处于同等忙碌状态。不幸的是,特别是在比我们这里展示的简单示例更接近现实场景的情况下,每个子任务所需的时间可能会因使用低效的分割策略或由于不可预测的原因(如缓慢的磁盘访问或需要与外部服务协调执行)而大幅变化。
Fork/join 框架通过一种称为窃取工作的技术来解决这个问题。在实践中,这意味着任务在大约ForkJoinPool中的所有线程上大致均匀分配。每个线程都持有分配给它的任务的双链队列,并且一旦它完成一个任务,它就会从队列的头部拉取另一个任务并开始执行。由于我们之前列出的原因,一个线程可能比其他线程更快地完成分配给它的所有任务,这意味着它的队列会变得空,而其他线程仍然相当忙碌。在这种情况下,线程不会空闲,而是随机选择另一个线程的队列,并从队列的尾部“窃取”一个任务。这个过程会一直持续到所有任务都执行完毕,然后所有队列都变为空。这就是为什么拥有许多较小的任务,而不是只有少数较大的任务,有助于更好地在工作线程之间平衡工作负载。
更普遍地,这种窃取工作算法用于在池中的工作线程之间重新分配和平衡任务。
展示了这个过程是如何发生的。当一个工作线程队列中的任务被分割成两个子任务时,其中一个子任务被另一个空闲的工作线程“窃取”。如前所述,这个过程可以递归地继续进行,直到用于定义给定子任务应该顺序执行的条件变为真。
图 7.5. Fork/join 框架使用的窃取工作算法

现在应该已经很清楚,一个流如何使用 fork/join 框架并行处理其项目了,但仍然缺少一个关键因素。在本节中,我们分析了一个例子,其中你明确开发了将数字数组拆分为多个任务的逻辑。然而,当你在本章开头使用并行流时,你并没有做类似的事情,这意味着必须有一个自动机制为你拆分流。这个新的自动机制被称为Spliterator,我们将在下一节中探讨它。
7.3. Spliterator
Spliterator 是 Java 8 中添加的另一个新接口;其名称代表“可拆分迭代器”。与Iterator类似,Spliterator用于遍历源中的元素,但它们也被设计为并行执行。虽然你可能不需要在实际中开发自己的Spliterator,但了解如何做到这一点将使你对并行流的工作原理有更深入的理解。Java 8 已经为它包含在其 Collections Framework 中的所有数据结构提供了一个默认的Spliterator实现。Collection接口现在提供了一个默认方法spliterator()(你将在第十三章中了解更多关于默认方法的内容),该方法返回一个Spliterator对象。Spliterator接口定义了几个方法,如下面的列表所示。
列表 7.3. Spliterator 接口
public interface Spliterator<T> {
boolean tryAdvance(Consumer<? super T> action);
Spliterator<T> trySplit();
long estimateSize();
int characteristics();
}
如同往常,T是Spliterator遍历的元素类型。tryAdvance方法的行为与正常Iterator类似,因为它用于顺序地逐个消费Spliterator的元素,如果有其他元素要遍历,则返回true。但trySplit方法更具体于Spliterator接口,因为它用于将一些元素拆分到一个第二个Spliterator(由该方法返回的)中,允许这两个并行处理。Spliterator还可以通过其estimateSize方法提供一个估计值,表示剩余要遍历的元素数量,因为即使是一个不准确但计算快速的值也可以用于更均匀地拆分结构。
为了在需要时控制这个过程,理解这个拆分过程是如何在内部执行的非常重要。因此,我们将在下一节中更详细地分析它。
7.3.1. 拆分过程
将流分割成多个部分的算法是一个递归过程,其步骤如下所示图 7.6。在第一步中,对第一个Spliterator调用trySplit生成第二个Spliterator。然后在第二步中,它再次被调用在这两个Spliterator上,总共生成四个。框架会持续在Spliterator上调用trySplit方法,直到它返回null以表示正在处理的数据结构不再可分割,如图 3 所示。最后,当所有Spliterator都向trySplit调用返回null时,递归分割过程在步骤 4 中终止。
图 7.6. 递归分割过程

这个分割过程也可以受到Spliterator本身的特性的影响,这些特性通过characteristics方法声明。
Spliterator的特性
Spliterator接口声明的最后一个抽象方法是characteristics,它返回一个int编码,表示Spliterator本身的特性集。Spliterator客户端可以使用这些特性来更好地控制和优化其使用。表 7.2 总结了它们。(不幸的是,尽管这些在概念上与收集器的特性重叠,但它们的编码方式不同。)特性是在Spliterator接口中定义的 int 常量。
表 7.2. Spliterator的特性
| 特性 | 含义 |
|---|---|
| 有序 | 元素有一个定义的顺序(例如,一个 List),因此Spliterator在遍历和分割它们时强制执行这个顺序。 |
| 唯一 | 对于每个遍历的元素对 x 和 y,x.equals(y)返回 false。 |
| 排序 | 遍历的元素遵循预定义的排序顺序。 |
| 有大小 | 这个Spliterator是从一个已知大小(例如,一个 Set)的来源创建的,因此estimatedSize()返回的值是精确的。 |
| 非空 | 保证遍历的元素不会为空。 |
| 不可变 | 这个Spliterator的来源不能被修改。这意味着在遍历期间不能添加、删除或修改任何元素。 |
| 并发 | 这个Spliterator的来源可以安全地由其他线程并发修改,无需任何同步。 |
| 子大小 | 这个Spliterator及其从其分割产生的所有后续Spliterator都是 SIZED。 |
现在你已经看到了Spliterator接口是什么以及它定义了哪些方法,你可以尝试开发自己的Spliterator实现。
7.3.2. 实现自己的Spliterator
让我们看看你可能需要实现自己的Spliterator的实际情况。我们将开发一个简单的方法来计算String中的单词数量。这个方法的迭代版本可以写成如下所示。
列表 7.4. 迭代单词计数方法
public int countWordsIteratively(String s) {
int counter = 0;
boolean lastSpace = true;
for (char c : s.toCharArray()) { *1*
if (Character.isWhitespace(c)) {
lastSpace = true;
} else {
if (lastSpace) counter++; *2*
lastSpace = false;
}
}
return counter;
}
-
1 逐个遍历 String 中的所有字符
-
2 当最后一个字符是空格而当前遍历的不是时,增加单词计数器
让我们用这种方法处理但丁的《地狱》第一句(见en.wikipedia.org/wiki/Inferno_(Dante)):
final String SENTENCE =
" Nel mezzo del cammin di nostra vita " +
"mi ritrovai in una selva oscura" +
" ché la dritta via era smarrita ";
System.out.println("Found " + countWordsIteratively(SENTENCE) + " words");
注意,我们在句子中添加了一些额外的随机空格,以证明迭代实现即使在两个单词之间有多个空格的情况下也能正确工作。正如预期的那样,此代码将打印出以下内容:
Found 19 words
理想情况下,你希望以更函数式的风格达到相同的结果,因为这样你将能够,如前所述,使用并行流并行化此过程,而无需显式处理线程及其同步。
以函数式风格重写 WordCounter
首先,你需要将String转换为流。不幸的是,只有int、long和double有原始流,所以你必须使用Stream<Character>:
Stream<Character> stream = IntStream.range(0, SENTENCE.length())
.mapToObj(SENTENCE::charAt);
你可以通过对这个流执行归约来计算单词数。在归约流时,你必须携带一个由两个变量组成的状态:一个int变量用于计算到目前为止找到的单词数,一个boolean变量用于记住最后一个遇到的Character是否是空格。因为 Java 没有元组(一个表示无包装对象的异构元素有序列表的构造),你必须创建一个新的类WordCounter,它将如以下列表所示封装这个状态。
列表 7.5. 一个在遍历Characters流时计算单词的类
class WordCounter {
private final int counter;
private final boolean lastSpace;
public WordCounter(int counter, boolean lastSpace) {
this.counter = counter;
this.lastSpace = lastSpace;
}
public WordCounter accumulate(Character c) { *1*
if (Character.isWhitespace(c)) {
return lastSpace ?
this :
new WordCounter(counter, true);
} else {
return lastSpace ?
new WordCounter(counter+1, false) : *2*
this;
}
}
public WordCounter combine(WordCounter wordCounter) { *3*
return new WordCounter(counter + wordCounter.counter,
wordCounter.lastSpace); *4*
}
public int getCounter() {
return counter;
}
}
-
1 累积方法逐个遍历字符,就像迭代算法所做的那样
-
2 当最后一个字符是空格而当前遍历的不是时,增加单词计数器
-
3 通过求和计数器合并两个 WordCounter
-
4 只使用计数器的总和,因此你不必关心 lastSpace
在这个列表中,accumulate方法定义了如何改变WordCounter的状态,或者更确切地说,用哪个状态创建一个新的WordCounter,因为这是一个不可变类。这一点很重要。我们使用不可变类累积状态,以便在下一步中可以并行化这个过程。每当流中遍历一个新的Character时,都会调用accumulate方法。特别是,正如你在列表 7.4 中的countWordsIteratively方法中所做的那样,当遇到一个新的非空格字符时,计数器会增加,并且遇到的最后一个字符是空格。图 7.7 显示了accumulate方法遍历新Character时WordCounter的状态转换。
图 7.7. 当遍历新的Character c时WordCounter的状态转换

第二种方法,combine,被调用来聚合两个WordCounter的局部结果,这两个WordCounter分别作用于Character流的不同子部分,因此它通过求和它们的内部计数器来合并两个WordCounter。
现在你已经编码了如何在WordCounter上累积字符以及如何在WordCounter本身中组合它们,编写一个将Character流减少的方法就很简单了:
private int countWords(Stream<Character> stream) {
WordCounter wordCounter = stream.reduce(new WordCounter(0, true),
WordCounter::accumulate,
WordCounter::combine);
return wordCounter.getCounter();
}
现在,你可以尝试使用包含但丁的《地狱》第一句的String创建的流来尝试这个方法:
Stream<Character> stream = IntStream.range(0, SENTENCE.length())
.mapToObj(SENTENCE::charAt);
System.out.println("Found " + countWords(stream) + " words");
你可以检查其输出是否与迭代版本生成的输出一致:
Found 19 words
到目前为止,一切顺利,但我们说过,在函数式术语中实现WordCounter的主要理由之一是能够轻松并行化此操作,那么让我们看看这是如何工作的。
使WordCounter并行工作
你可以尝试使用并行流来加速单词计数操作,如下所示:
System.out.println("Found " + countWords(stream.parallel()) + " words");
不幸的是,这次输出是
Found 25 words
显然出了些问题,但问题是什么?问题并不难发现。因为原始字符串是在任意位置分割的,有时一个单词被分成两部分,然后被计数两次。总的来说,这表明如果结果可能受到流分割位置的影响,那么从顺序流到并行流可能会导致错误的结果。
如何解决这个问题?解决方案包括确保字符串不是在随机位置分割,而是在单词的末尾分割。为此,你必须实现一个Spliterator,它只将字符串分割在两个单词之间(如下所示),然后从它创建并行流。
列表 7.6. WordCounterSpliterator
class WordCounterSpliterator implements Spliterator<Character> {
private final String string;
private int currentChar = 0;
public WordCounterSpliterator(String string) {
this.string = string;
}
@Override
public boolean tryAdvance(Consumer<? super Character> action) {
action.accept(string.charAt(currentChar++)); *1*
return currentChar < string.length(); *2*
}
@Override
public Spliterator<Character> trySplit() {
int currentSize = string.length() - currentChar;
if (currentSize < 10) {
return null; *3*
}
for (int splitPos = currentSize / 2 + currentChar;
splitPos < string.length(); splitPos++) { *4*
if (Character.isWhitespace(string.charAt(splitPos))) { *5*
Spliterator<Character> spliterator = *6*
new WordCounterSpliterator(string.substring(currentChar,
splitPos));
currentChar = splitPos; *7*
return spliterator; *8*
}
}
return null;
}
@Override
public long estimateSize() {
return string.length() - currentChar;
}
@Override
public int characteristics() {
return ORDERED + SIZED + SUBSIZED + NON-NULL + IMMUTABLE;
}
}
-
1 消费当前字符
-
2 如果还有更多字符要消费,则返回 true
-
3 返回 null 以表示要解析的字符串足够小,可以顺序处理
-
4 将候选分割位置设置为要解析的字符串的一半
-
5 将分割位置推进到下一个空格
-
6 从起始位置到分割位置创建一个新的
WordCounter-Spliterator来解析字符串 -
7 将当前
Word-CounterSpliterator的起始位置设置为分割位置 -
8 找到一个空格并创建了新的 Spliterator,因此退出循环
这个Spliterator是从要解析的String创建的,并通过保持正在遍历的索引来迭代其Characters。让我们快速回顾一下实现Spliterator接口的WordCounterSpliterator的方法:
-
tryAdvance方法将当前索引位置的Character从String中传递给Consumer,并增加这个位置。作为其参数传递的Consumer是一个内部 Java 类,它将消耗的Character转发到在遍历流时必须应用于它的函数集,在这种情况下,只有一个减少函数,即WordCounter类的accumulate方法。如果新的光标位置小于总String长度并且还有要迭代的Character,则tryAdvance方法返回true。 -
trySplit方法是Spliterator中最重要的一个,因为它定义了用于拆分要迭代的数组的逻辑。正如你在实现 列表 7.1 的RecursiveTask的compute方法中所做的那样,你在这里首先要做的第一件事是设置一个限制,你不想在这个限制以下执行进一步的拆分。在这里,你只使用 10 个Character的低限制,以确保你的程序会在解析相对较短的String时执行一些拆分。但在实际应用中,你将不得不使用更高的限制,就像你在 fork/join 示例中所做的那样,以避免创建过多的任务。如果剩余要遍历的Character数量低于这个限制,你返回null以表示不需要进一步的拆分。相反,如果你需要执行拆分,你将候选拆分位置设置为剩余要解析的String块的一半。但你不会直接使用这个拆分位置,因为你想要避免在单词中间拆分,所以你会向前移动,直到找到一个空白Character。一旦找到一个合适的拆分位置,你将创建一个新的Spliterator,它将遍历从当前位置到拆分位置的子字符串块;你将this的当前位置设置为拆分位置,因为它前面的部分将由新的Spliterator管理,然后返回它。 -
还要遍历的元素
estimatedSize是由这个Spliterator解析的String的总长度与当前迭代的当前位置之间的差值。 -
最后,
characteristics方法向框架表明这个Spliterator是ORDERED(顺序是String中Character的序列),SIZED(estimatedSize方法返回的值是精确的),SUBSIZED(由trySplit方法创建的其他Spliterator也具有精确的大小),NON-NULL(String中不能有null Character),以及IMMUTABLE(在解析String时不能添加更多的Character,因为String本身是一个不可变类)。
使用 WordCounterSpliterator
你现在可以使用新的 WordCounterSpliterator 来并行流,如下所示:
Spliterator<Character> spliterator = new WordCounterSpliterator(SENTENCE);
Stream<Character> stream = StreamSupport.stream(spliterator, true);
传递给 StreamSupport.stream 工厂方法的第二个布尔参数意味着你想要创建一个并行流。将此并行流传递给 countWords 方法
System.out.println("Found " + countWords(stream) + " words");
产生预期的正确输出:
Found 19 words
你已经看到了如何使用 Spliterator 来控制分割数据结构所使用的策略。Spliterator 的最后一个显著特性是,在第一次遍历、第一次分割或第一次查询估计大小时,可以将要遍历的元素源绑定到点,而不是在创建时。当这种情况发生时,它被称为 延迟绑定 的 Spliterator。我们已在 附录 C 中专门介绍如何开发一个实用类,利用此功能在同一个流上并行执行多个操作。
摘要
-
内部迭代允许你在不显式使用和协调代码中的不同线程的情况下并行处理流。
-
即使并行处理流如此简单,也不能保证在所有情况下这样做会使程序运行得更快。并行软件的行为和性能有时可能是反直觉的,因此始终有必要对其进行测量,并确保你没有使程序变慢。
-
在一组数据上并行执行操作,如并行流所做的那样,可以提供性能提升,尤其是在要处理的数据元素数量巨大或每个单个元素的处理特别耗时的情况下。
-
从性能角度来看,使用合适的数据结构,例如,尽可能使用原始流而不是非专用流,通常比尝试并行化某些操作更重要。
-
Fork/Join 框架允许你递归地将可并行化的任务分割成更小的任务,在不同的线程上执行它们,然后将每个子任务的输出结果合并,以产生整体结果。
-
Spliterator定义了并行流如何分割它遍历的数据。
第三部分. 使用流和 lambda 进行有效编程
本书第三部分探讨了各种 Java 8 和 Java 9 主题,这些主题将使你更有效地使用 Java,并使用现代惯用模式增强你的代码库。因为它面向更高级的编程思想,所以本书后面的内容不依赖于这里描述的技术。
第八章是第二版的新章节,探讨了 Java 8 和 Java 9 的集合 API 增强,涵盖了使用集合工厂以及学习与 List 和 Set 集合一起使用的新惯用模式,以及涉及 Map 的惯用模式。
第九章探讨了如何使用新的 Java 8 特性和一些食谱来改进现有的代码。此外,它还探讨了重要的软件开发技术,如设计模式、重构、测试和调试。
第十章在第二版中也是新的。它探讨了基于领域特定语言(DSL)的 API 的想法。这不仅是一种强大的 API 设计方式,而且这种方式越来越受欢迎,并且在 Java 中已经很明显,例如在Comparator、Stream和Collector接口中。
第八章. 集合 API 增强
本章涵盖
-
使用集合工厂
-
学习使用
List和Set的新惯用模式 -
学习与
Map一起工作的惯用模式
没有集合 API,你的 Java 开发者生活将会相当孤独。集合被用于每个 Java 应用程序中。在前面的章节中,你看到了集合与 Streams API 结合使用是多么有用,这对于表达数据处理查询非常有用。尽管如此,集合 API 存在各种缺陷,有时使其使用起来既冗长又容易出错。
在本章中,你将了解 Java 8 和 Java 9 中集合 API 的新增功能,这将使你的生活变得更轻松。首先,你将了解 Java 9 中的集合工厂——简化创建小列表、集合和映射过程的添加。接下来,你将学习如何利用 Java 8 的增强功能在列表和集合中应用惯用移除和替换模式。最后,你将了解可用于处理映射的新便利操作。
第九章探讨了重构旧式 Java 代码的更广泛的技术。
8.1. 集合工厂
Java 9 引入了一些方便的方式来创建小的集合对象。首先,我们将回顾为什么程序员需要一个更好的方式来做事情;然后我们将向您展示如何使用新的工厂方法。
你如何在 Java 中创建一个小的元素列表?你可能想将要去度假的朋友的名字分组,例如。这里有一种方法:
List<String> friends = new ArrayList<>();
friends.add("Raphael");
friends.add("Olivia");
friends.add("Thibaut");
但要存储三个字符串,却要写这么多行代码!一个更方便的方式来编写这段代码是使用Arrays.asList()工厂方法:
List<String> friends
= Arrays.asList("Raphael", "Olivia", "Thibaut");
你得到一个固定大小的列表,你可以更新它,但不能添加或删除元素。尝试添加元素,例如,会导致 Unsupported-ModificationException,但使用 set 方法更新是允许的:
List<String> friends = Arrays.asList("Raphael", "Olivia");
friends.set(0, "Richard");
friends.add("Thibaut"); *1*
- 1 抛出
UnsupportedOperationException异常
这种行为似乎有些令人惊讶,因为底层列表是由一个固定大小的可变数组支持的。
如何看待一个 Set?不幸的是,没有 Arrays.asSet() 工厂方法,所以你需要另一个技巧。你可以使用 HashSet 构造函数,它接受一个 List:
Set<String> friends "
= new HashSet<>(Arrays.asList("Raphael", "Olivia", Thibaut"));
或者,你也可以使用 Streams API:
Set<String> friends
= Stream.of("Raphael", "Olivia", "Thibaut")
.collect(Collectors.toSet());
然而,这两种解决方案都远非优雅,并且在幕后涉及不必要的对象分配。此外,请注意,你得到的是一个可变的 Set。
如何看待 Map?创建小地图没有优雅的方法,但别担心;Java 9 添加了工厂方法,使你在需要创建小列表、集合和地图时生活变得更简单。
集合字面量
一些语言,包括 Python 和 Groovy,支持集合字面量,允许你使用特殊语法创建集合,例如使用 [42, 1, 5] 创建一个包含三个数字的列表。Java 没有提供语法支持,因为语言的变化伴随着高昂的维护成本,并限制了未来可能语法的使用。相反,Java 9 通过增强 Collection API 来添加支持。
我们通过向你展示 List 的新特性来开始对 Java 中创建集合的新方法的探索。
8.1.1. 列表工厂
你可以通过调用工厂方法 List.of 来创建一个列表:
List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
System.out.println(friends); *1*
- 1 [Raphael, Olivia, Thibaut]
然而,你会注意到一些奇怪的事情。尝试向你的朋友列表中添加一个元素:
List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
friends.add("Chih-Chun");
运行此代码会导致 java.lang.UnsupportedOperationException。事实上,生成的列表是不可变的。使用 set() 方法替换项会抛出类似的异常。你也不能使用 set 方法来修改它。然而,这种限制是好事,因为它保护你免受集合的不希望变化的侵害。没有任何东西阻止你拥有本身可变的元素。如果你需要一个可变列表,你仍然可以手动实例化一个。最后,请注意,为了防止意外的错误并允许更紧凑的内部表示,不允许使用 null 元素。
重载与可变参数
如果你进一步检查 List 接口,你会注意到 List.of 有几个重载变体:
static <E> List<E> of(E e1, E e2, E e3, E e4)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5)
你可能会想知道为什么 Java API 没有一个使用可变参数接受任意数量元素的方法,如下所示:
static <E> List<E> of(E... elements)
在底层,可变参数版本分配了一个额外的数组,该数组被包装在一个列表中。你为分配数组、初始化它以及稍后进行垃圾回收付出了代价。通过 API 提供固定数量的元素(最多十个),你不必承担这个代价。请注意,你仍然可以使用超过十个元素创建List.of,但在此情况下将调用可变参数签名。你还在Set.of和Map.of中看到了这种模式。
你可能会想知道是否应该使用 Streams API 而不是新的集合工厂方法来创建这样的列表。毕竟,你之前章节中看到可以使用Collectors.toList()收集器将流转换为列表。除非你需要设置某种形式的数据处理和转换,我们建议使用工厂方法;它们更易于使用,并且工厂方法的实现更简单、更合适。
现在你已经了解了List的新工厂方法,在下一节中,你将学习如何使用Set。
8.1.2. 集合工厂
与List.of类似,你可以从元素列表中创建一个不可变的Set:
Set<String> friends = Set.of("Raphael", "Olivia", "Thibaut");
System.out.println(friends); *1*
- 1 [拉斐尔,奥利维亚,蒂博]
如果你尝试通过提供重复的元素来创建Set,你会收到一个Illegal-ArgumentException。这个异常反映了集合强制其包含的元素唯一性的契约:
Set<String> friends = Set.of("Raphael", "Olivia", "Olivia"); *1*
- 1 java.lang.IllegalArgumentException: duplicate element: 奥利维亚
Java 中另一个流行的数据结构是Map。在下一节中,你将了解创建Map的新方法。
8.1.3. 映射工厂
创建映射比创建列表和集合要复杂一些,因为你必须包含键和值。在 Java 9 中,你有两种初始化不可变映射的方法。你可以使用工厂方法Map.of,它交替使用键和值:
Map<String, Integer> ageOfFriends
= Map.of("Raphael", 30, "Olivia", 25, "Thibaut", 26);
System.out.println(ageOfFriends); *1*
- 1 {奥利维亚=25, 拉斐尔=30, 蒂博=26}
如果你想创建一个包含最多十个键和值的较小映射,这个方法很方便。要超出这个范围,请使用名为Map.ofEntries的替代工厂方法,它接受Map.Entry<K, V>对象,但使用可变参数实现。此方法需要额外的对象分配来包装键和值:
import static java.util.Map.entry;
Map<String, Integer> ageOfFriends
= Map.ofEntries(entry("Raphael", 30),
entry("Olivia", 25),
entry("Thibaut", 26));
System.out.println(ageOfFriends); *1*
- 1 {奥利维亚=25, 拉斐尔=30, 蒂博=26}
Map.entry是一个新的工厂方法,用于创建Map.Entry对象。
练习 8.1
你认为以下代码片段的输出是什么?
List<String> actors = List.of("Keanu", "Jessica")
actors.set(0, "Brad");
System.out.println(actors)
答案:
抛出UnsupportedOperationException。由List.of产生的集合是不可变的。
到目前为止,你已经看到新的 Java 9 工厂方法允许你更简单地创建集合。但在实践中,你必须处理这些集合。在下一节中,你将学习关于List和Set的一些新增强功能,它们实现了开箱即用的常见处理模式。
8.2. 使用列表和集合
Java 8 将一些方法引入了List和Set接口:
-
removeIf移除与谓词匹配的元素。它适用于所有实现List或Set的类(并且从Collection接口继承而来)。 -
replaceAll在List上可用,并使用一个 (UnaryOperator) 函数替换元素。 -
sort也在List接口上可用,并排序列表本身。
所有这些方法都会对其调用的集合进行修改。换句话说,它们会改变集合本身,这与流操作不同,流操作会产生一个新的(复制的)结果。为什么会有这样的方法?修改集合可能会出错且冗长。因此,Java 8 添加了 removeIf 和 replaceAll 来帮助。
8.2.1. removeIf
考虑以下代码,它试图移除以数字开头的参考代码的交易:
for (Transaction transaction : transactions) {
if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
transactions.remove(transaction);
}
}
你能看出问题吗?不幸的是,这段代码可能会导致 Concurrent-ModificationException。为什么?在底层,for-each 循环使用了一个 Iterator 对象,所以执行的代码如下:
for (Iterator<Transaction> iterator = transactions.iterator();
iterator.hasNext(); ) {
Transaction transaction = iterator.next();
if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
transactions.remove(transaction); *1*
}
}
- 1 问题是我们通过两个不同的对象迭代和修改集合
注意,有两个单独的对象管理着这个集合:
-
通过使用
next()和hasNext()查询源的Iterator对象。 -
通过调用
remove()方法移除元素的Collection对象本身。
因此,迭代器的状态不再与集合的状态同步,反之亦然。为了解决这个问题,你必须显式使用 Iterator 对象并调用它的 remove() 方法:
for (Iterator<Transaction> iterator = transactions.iterator();
iterator.hasNext(); ) {
Transaction transaction = iterator.next();
if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
iterator.remove();
}
}
这段代码编写起来变得相当冗长。现在,这个代码模式可以直接使用 Java 8 的 removeIf 方法来直接表达,这不仅更简单,还能保护你免受这些错误的影响。它需要一个表示要移除哪些元素的谓词:
transactions.removeIf(transaction ->
Character.isDigit(transaction.getReferenceCode().charAt(0)));
有时,你不想移除一个元素,而是想替换它。为此,Java 8 添加了 replaceAll。
8.2.2. replaceAll
List 接口上的 replaceAll 方法允许你将列表中的每个元素替换为一个新的元素。使用 Streams API,你可以这样解决这个问题:
referenceCodes.stream() *1*
.map(code -> Character.toUpperCase(code.charAt(0)) +
code.substring(1))
.collect(Collectors.toList())
.forEach(System.out::println); *2*
-
1 [a12, C14, b13]
-
2 输出 A12, C14, B13
这段代码会生成一个新的字符串集合,然而,你想要一种方法来更新现有的集合。你可以使用一个 ListIterator 对象,如下所示(支持 set() 方法来替换元素):
for (ListIterator<String> iterator = referenceCodes.listIterator();
iterator.hasNext(); ) {
String code = iterator.next();
iterator.set(Character.toUpperCase(code.charAt(0)) + code.substring(1));
}
如你所见,这段代码相当冗长。此外,正如我们之前解释的,使用 Iterator 对象与集合对象结合可能会因为混合迭代和修改集合而出现错误。在 Java 8 中,你可以简单地写
referenceCodes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) +
code.substring(1));
你已经学到了 List 和 Set 的新特性,但不要忘记 Map。Map 接口的新增内容将在下一节中介绍。
8.3. 使用 Map
Java 8 引入了几种由 Map 接口支持的默认方法。(默认方法在第十三章中有详细说明,但在这里你可以把它们看作是接口中预实现的方法。)这些新操作的目的在于通过使用现成的惯用模式来帮助你编写更简洁的代码,而不是自己实现它。我们将在以下章节中查看这些操作,从全新的 forEach 开始。
8.3.1. forEach
传统的迭代 Map 的键和值是尴尬的。实际上,你需要使用 Map 的 entrySet 中的 Map.Entry<K, V> 迭代器:
for(Map.Entry<String, Integer> entry: ageOfFriends.entrySet()) {
String friend = entry.getKey();
Integer age = entry.getValue();
System.out.println(friend + " is " + age + " years old");
}
自从 Java 8 以来,Map 接口支持了 forEach 方法,该方法接受一个 BiConsumer,它接受键和值作为参数。使用 forEach 可以使你的代码更简洁:
ageOfFriends.forEach((friend, age) -> System.out.println(friend + " is " +
age + " years old"));
与迭代日期相关的一个问题是排序。Java 8 引入了几种方便的方式来比较 Map 中的条目。
8.3.2. 排序
两个新的实用工具让你可以按值或键对映射的条目进行排序:
-
Entry.comparingByValue -
Entry.comparingByKey
代码
Map<String, String> favouriteMovies
= Map.ofEntries(entry("Raphael", "Star Wars"),
entry("Cristina", "Matrix"),
entry("Olivia",
"James Bond"));
favouriteMovies
.entrySet()
.stream()
.sorted(Entry.comparingByKey())
.forEachOrdered(System.out::println); *1*
- 1 根据人员的姓名按字母顺序处理流中的元素
输出顺序如下:
Cristina=Matrix
Olivia=James Bond
Raphael=Star Wars
HashMap 和性能
Java 8 对 HashMap 的内部结构进行了更新,以提高性能。映射的条目通常存储在通过键生成的哈希码访问的桶中。但如果许多键返回相同的哈希码,性能会下降,因为桶被实现为 LinkedList,其检索复杂度为 O(n)。如今,当桶变得太大时,它们会动态地被排序树替换,这些树具有 O(log(n)) 的检索复杂度,并提高了冲突元素的查找。请注意,这种使用排序树的方法仅在键是 Comparable(如 String 或 Number 类)时才可行。
另一个常见的模式是当你在 Map 中查找的键不存在时该如何处理。新的 getOrDefault 方法可以帮助。
8.3.3. getOrDefault
当你查找的键不存在时,你会收到一个 null 引用,你必须检查它以防止 NullPointerException。一种常见的设计风格是提供默认值。现在你可以通过使用 getOrDefault 方法更简单地编码这个想法。此方法将键作为第一个参数,将默认值(在键不存在于 Map 中时使用)作为第二个参数:
Map<String, String> favouriteMovies
= Map.ofEntries(entry("Raphael", "Star Wars"),
entry("Olivia", "James Bond"));
System.out.println(favouriteMovies.getOrDefault("Olivia", "Matrix")); *1*
System.out.println(favouriteMovies.getOrDefault("Thibaut", "Matrix")); *2*
-
1 输出詹姆斯·邦德
-
2 输出矩阵
注意,如果键存在于 Map 中,但意外地关联了 null 值,getOrDefault 仍然可以返回 null。另外,请注意,你传递的回退表达式始终会被评估,无论键是否存在。
Java 8 还包含了一些处理给定键值存在和不存在的高级模式。你将在下一节中了解这些新方法。
8.3.4. 计算模式
有时,你可能需要根据一个Map中是否存在某个键来有条件地执行一个操作并存储其结果,例如,根据键来缓存一个昂贵操作的输出。如果键存在,就没有必要重新计算结果。以下三种新操作可以帮助你:
-
computeIfAbsent——如果给定的键没有指定值(它不存在或其值为 null),则使用键计算一个新的值并将其添加到Map中。 -
computeIfPresent——如果指定的键存在,计算一个新的值并添加到Map中。 -
compute——这个操作为给定的键计算一个新的值并将其存储在Map中。
computeIfAbsent的一个用途是缓存信息。假设你解析一组文件中的每一行并计算它们的 SHA-256 表示。如果你之前已经处理过这些数据,就没有必要重新计算。
现在假设你通过使用Map来实现缓存,并使用MessageDigest实例来计算 SHA-256 散列:
Map<String, byte[]> dataToHash = new HashMap<>();
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
然后,你可以遍历数据并缓存结果:
lines.forEach(line ->
dataToHash.computeIfAbsent(line, *1*
this::calculateDigest)); *2*
private byte[] calculateDigest(String key) { *3*
return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
}
-
1 行是映射中要查找的键。
-
2 如果键不存在时执行的操作
-
3 将为给定键计算散列的帮助程序
这种模式对于方便地处理存储多个值的映射也非常有用。如果你需要向Map<K, List<V>>添加一个元素,你需要确保条目已经被初始化。这种模式是一种冗长的实现方式。假设你想要为你的朋友 Raphael 构建一个电影列表:
String friend = "Raphael";
List<String> movies = friendsToMovies.get(friend);
if(movies == null) { *1*
movies = new ArrayList<>();
friendsToMovies.put(friend, movies);
}
movies.add("Star Wars"); *2*
System.out.println(friendsToMovies); *3*
-
1 检查列表是否已初始化。
-
2 添加电影。
-
3
{Raphael: [Star Wars]}
你如何使用computeIfAbsent呢?如果键未找到,它会在将计算值添加到Map后返回计算值;否则,它返回现有值。你可以如下使用它:
friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>())
.add("Star Wars"); *1*
- 1
{Raphael: [Star Wars]}
computeIfPresent方法在Map中与键关联的当前值存在且非空时计算一个新的值。注意一个细微之处:如果产生值的函数返回 null,则当前映射将从Map中移除。然而,如果你需要移除映射,则重载的remove方法更适合这项任务。你将在下一节中了解这个方法。
8.3.5. 移除模式
你已经了解remove方法,它允许你根据给定的键移除Map条目。自从 Java 8 以来,一个重载的版本只有在键与特定值关联时才会移除条目。之前,这段代码是这样实现这个行为的(我们并不反对汤姆·克鲁斯,但杰克·雷 acher 2收到了差评):
String key = "Raphael";
String value = "Jack Reacher 2";
if (favouriteMovies.containsKey(key) &&
Objects.equals(favouriteMovies.get(key), value)) {
favouriteMovies.remove(key);
return true;
}
else {
return false;
}
现在你可以这样做到同样的事情,你不得不承认这更加切中要害:
favouriteMovies.remove(key, value);
在下一节中,你将了解如何在Map中替换元素和移除元素的方法。
8.3.6. 替换模式
Map 有两个新方法,允许你替换 Map 内部的条目:
-
replaceAll—将每个条目的值替换为应用BiFunction的结果。这种方法与前面看到的List上的replaceAll方法类似。 -
Replace—允许你在Map中替换一个值,如果键存在。一个额外的重载只替换键映射到特定值的值。
你可以将 Map 中的所有值格式化如下:
Map<String, String> favouriteMovies = new HashMap<>(); *1*
favouriteMovies.put("Raphael", "Star Wars");
favouriteMovies.put("Olivia", "james bond");
favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());
System.out.println(favouriteMovies); *2*
-
1 我们必须使用可变映射,因为我们将会使用 replaceAll
-
2 {奥利维亚=詹姆斯·邦德, 拉斐尔=星球大战}
你所学的替换模式与单个 Map 一起工作。但如果你必须从两个 Map 中组合和替换值怎么办?你可以使用一个新的 merge 方法来完成这个任务。
8.3.7. 合并
假设你想要合并两个中间 Map,可能是两组联系人各自的两个单独的 Map。你可以使用 putAll 如下:
Map<String, String> family = Map.ofEntries(
entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
entry("Raphael", "Star Wars"));
Map<String, String> everyone = new HashMap<>(family);
everyone.putAll(friends); *1*
System.out.println(everyone); *2*
-
1 将朋友映射中的所有条目复制到每个人映射中
-
2 {克里斯蒂娜=詹姆斯·邦德, 拉斐尔=星球大战, 特奥=星球大战}
这段代码在没有重复键的情况下按预期工作。如果你需要更多灵活性来组合值,可以使用新的 merge 方法。这个方法接受一个 BiFunction 来合并具有重复键的值。假设克里斯蒂娜同时在家庭和朋友映射中,但关联的电影不同:
Map<String, String> family = Map.ofEntries(
entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
Map<String, String> friends = Map.ofEntries(
entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));
然后,你可以结合使用 merge 方法与 forEach 来提供一个处理冲突的方法。以下代码连接了两个电影的字符串名称:
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) ->
everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)); *1*
System.out.println(everyone); *2*
-
1 给定一个重复键,连接两个值
-
2 输出 {拉斐尔=星球大战, 克里斯蒂娜=詹姆斯·邦德 & 矩阵, 特奥=星球大战}
注意,merge 方法处理空值的方式相当复杂,如 Javadoc 所述:
如果指定的键尚未与值关联或与 null 关联,[
merge] 将它与给定的非空值关联。否则,[merge] 将关联的值替换为给定的重映射函数的结果,或者如果结果是 null,则删除 [它]。
你也可以使用 merge 来实现初始化检查。假设你有一个 Map 来记录一部电影被观看的次数。在你增加其值之前,你需要检查代表电影的键是否在映射中:
Map<String, Long> moviesToCount = new HashMap<>();
String movieName = "JamesBond";
long count = moviesToCount.get(movieName);
if(count == null) {
moviesToCount.put(movieName, 1);
}
else {
moviesToCount.put(moviename, count + 1);
}
这段代码可以重写为
moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);
在这种情况下,merge 的第二个参数是 1L。Javadoc 指出,此参数是“要与非现有值或与键关联的 null 值合并的非空值;或者,如果没有现有值或与键关联的 null 值,则与键关联。”因为对该键返回的值是 null,所以第一次提供值 1。下一次,因为该键的值被初始化为 1,所以 BiFunction 被应用于增加计数。
练习 8.2
确定以下代码的作用,并考虑你可以使用什么惯用操作来简化它:
Map<String, Integer> movies = new HashMap<>();
movies.put("JamesBond", 20);
movies.put("Matrix", 15);
movies.put("Harry Potter", 5);
Iterator<Map.Entry<String, Integer>> iterator =
movies.entrySet().iterator();
while(iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
if(entry.getValue() < 10) {
iterator.remove();
}
}
System.out.println(movies); *1*
- 1 {Matrix=15, JamesBond=20}
答案:
你可以在映射的集合上使用removeIf方法,它接受一个谓词并删除元素:
movies.entrySet().removeIf(entry -> entry.getValue() < 10);
你已经学习了Map接口的扩展。一些新的增强功能被添加到了它的一个堂兄弟:ConcurrentHashMap,你将在下一节学习到。
8.4. 改进的ConcurrentHashMap
ConcurrentHashMap类被引入以提供一种更现代的HashMap,它也是并发友好的。ConcurrentHashMap允许并发add和update操作,这些操作只锁定内部数据结构的一部分。因此,与同步的Hashtable替代品相比,读写操作的性能得到了提升。(注意,标准的HashMap是不同步的。`))
8.4.1. 归约和搜索
ConcurrentHashMap支持三种新的操作类型,这与你在流中看到的情况相似:
-
forEach—对每个(键,值)执行给定的操作 -
reduce—将所有给定的(键,值)通过一个归约函数组合成一个结果 -
search—对每个(键,值)应用一个函数,直到函数产生一个非空结果
每种操作类型支持四种形式,接受带有键、值、Map.Entry和(键,值)参数的函数:
-
使用键和值(
forEach, reduce, search) -
使用键(
forEachKey, reduceKeys, searchKeys) -
使用值(
forEachValue, reduceValues, searchValues) -
使用
Map.Entry对象(forEachEntry, reduceEntries, search-Entries)操作
注意,这些操作不会锁定ConcurrentHashMap的状态;它们在操作过程中对元素进行操作。提供给这些操作的功能不应该依赖于任何顺序或任何可能在计算过程中改变的其他对象或值。
此外,你还需要为所有这些操作指定一个并行度阈值。如果映射的当前大小小于给定的阈值,则操作将按顺序执行。1的值启用最大并行性,使用公共线程池。Long.MAX_VALUE的阈值值在单个线程上运行操作。除非你的软件架构具有高级资源使用优化,否则你通常应该坚持这些值。
在这个例子中,你使用reduceValues方法在映射中找到最大值:
ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>(); *1*
long parallelismThreshold = 1;
Optional<Integer> maxValue =
Optional.ofNullable(map.reduceValues(parallelismThreshold, Long::max));
- 1 假设
ConcurrentHashMap被更新以包含多个键和值
注意,对于每个reduce操作(如reduceValuesToInt、reduceKeysToLong等),都有原始特殊化,这更有效,因为它们防止了装箱。
8.4.2. 计数
ConcurrentHashMap类提供了一个名为mappingCount的新方法,它以长整型返回映射中的映射数量。你应该在返回 int 类型的方法size之前使用它,因为这样做可以为当映射数量不再适合 int 时使用的情况提供未来保障。
8.4.3. 集合视图
ConcurrentHashMap类提供了一个名为keySet的新方法,它返回一个将ConcurrentHashMap视为Set的视图。(映射中的更改反映在Set中,反之亦然。)你也可以通过使用新的静态方法newKeySet来创建由ConcurrentHashMap支持的Set。
摘要
-
Java 9 支持集合工厂,允许你使用
List.of、Set.of、Map.of和Map.ofEntries创建小的不可变列表、集合和映射。 -
这些集合工厂返回的对象是不可变的,这意味着你无法在创建后改变它们的状态。
-
List接口支持默认方法removeIf、replaceAll和sort。 -
Set接口支持默认方法removeIf。 -
Map接口包括几个新的默认方法,用于常见模式,并减少了错误范围。 -
ConcurrentHashMap支持从Map继承的新默认方法,但提供了线程安全的实现。
第九章. 重构、测试和调试
本章涵盖
-
重构代码以使用 lambda 表达式
-
体会 lambda 表达式对面向对象设计模式的影响
-
测试 lambda 表达式
-
调试使用 lambda 表达式和 Streams API 的代码
在本书的前八章中,你看到了 lambda 和 Streams API 的表达能力。你主要是在创建使用这些特性的新代码。如果你必须从头开始一个新的 Java 项目,你可以立即使用 lambda 和流。
不幸的是,你并不总是可以从零开始一个新的项目。大多数时候,你必须处理一个使用较旧版本的 Java 编写的现有代码库。
本章介绍了几个示例,展示了如何重构现有代码以使用 lambda 表达式来提高可读性和灵活性。此外,我们还讨论了由于 lambda 表达式,几个面向对象的设计模式(包括策略、模板方法、观察者、责任链和工厂)可以变得更加简洁。最后,我们探讨了如何测试和调试使用 lambda 表达式和 Streams API 的代码。
在第十章中,我们探讨了重构代码的更广泛方式,以使应用程序逻辑更易于阅读:创建领域特定语言。
9.1. 优化可读性和灵活性
从本书的开头,我们就主张 lambda 表达式可以让您编写更简洁、更灵活的代码。代码更加简洁,因为 lambda 表达式允许您以更紧凑的形式表示一段行为,与使用匿名类相比。我们还在第三章中展示了方法引用如何让您在只想将现有方法作为参数传递给另一个方法时,编写更加简洁的代码。
您的代码更加灵活,因为 lambda 表达式鼓励我们第二章中引入的行为参数化风格。您的代码可以使用和执行作为参数传递的多个行为来应对需求变化。
在本节中,我们将所有内容整合在一起,向您展示如何使用之前章节中学到的特性(lambda 表达式、方法引用和流)来重构代码,以提升代码的可读性和灵活性。
9.1.1. 提高代码可读性
提高代码的可读性意味着什么?定义良好的可读性可能具有主观性。普遍的观点是,这个术语意味着“其他人理解这段代码的难易程度。”提高代码的可读性确保了除了您之外的其他人也能理解并维护您的代码。您可以通过以下步骤确保您的代码对其他人来说是可理解的,例如确保您的代码有良好的文档并遵循编码标准。
使用 Java 8 引入的特性也可以与之前的版本相比提高代码的可读性。您可以通过减少代码的冗长性来使代码更容易理解。此外,您还可以通过使用方法引用和 Streams API 更好地展示代码的意图。
在本章中,我们描述了三种使用 lambda 表达式、方法引用和流的简单重构方法,您可以将这些方法应用到您的代码中以提高其可读性:
-
将匿名类重构为 lambda 表达式
-
将 lambda 表达式重构为方法引用
-
将命令式风格的数据处理重构为流
9.1.2. 从匿名类到 lambda 表达式
您应该考虑的第一个简单重构是将实现单个抽象方法的匿名类使用转换为 lambda 表达式。为什么?我们希望在之前的章节中已经说服您,匿名类是冗长且容易出错的。通过采用 lambda 表达式,您将产生更简洁、更易读的代码。正如第三章中所示,以下是一个创建Runnable对象的匿名类及其 lambda 表达式对应物:
Runnable r1 = new Runnable() { *1*
public void run(){
System.out.println("Hello");
}
};
Runnable r2 = () -> System.out.println("Hello"); *2*
-
1 之前,使用匿名类
-
2 之后,使用 lambda 表达式
但在某些情况下,将匿名类转换为 lambda 表达式可能是一个困难的过程.^([1]) 首先,匿名类和 lambda 表达式中的this和super的含义不同。在匿名类内部,this指的是匿名类本身,但在 lambda 内部,它指的是封装类。其次,匿名类允许遮蔽封装类的变量。Lambda 表达式不能(这将导致编译错误),如下面的代码所示:
¹
这篇优秀的论文详细描述了该过程:
dig.cs.illinois.edu/papers/lambdaRefactoring.pdf。
int a = 10;
Runnable r1 = () -> {
int a = 2; *1*
System.out.println(a);
};
Runnable r2 = new Runnable(){
public void run(){
int a = 2; *2*
System.out.println(a);
}
};
-
1 编译错误
-
2 一切正常!
最后,将匿名类转换为 lambda 表达式可能会使代码在重载的上下文中变得模糊。确实,匿名类的类型在实例化时是明确的,但 lambda 的类型取决于其上下文。以下是一个说明这种情况下可能存在问题的例子。假设您已声明了一个与Runnable具有相同签名的函数式接口,这里称为Task(当您在领域模型中需要更有意义的接口名称时可能会发生这种情况):
interface Task {
public void execute();
}
public static void doSomething(Runnable r){ r.run(); }
public static void doSomething(Task a){ r.execute(); }
现在,您可以无问题地传递一个实现Task的匿名类:
doSomething(new Task() {
public void execute() {
System.out.println("Danger danger!!");
}
});
但将这个匿名类转换为 lambda 表达式会导致一个模糊的方法调用,因为Runnable和Task都是有效的目标类型:
doSomething(() -> System.out.println("Danger danger!!")); *1*
- 1 问题;doSomething(Runnable)和 doSomething(Task)都匹配。
您可以通过提供显式的转换(Task)来解决歧义:
doSomething((Task)() -> System.out.println("Danger danger!!"));
尽管存在这些问题,但请保持乐观;有好消息!大多数集成开发环境(IDE)——如 NetBeans、Eclipse 和 IntelliJ——支持这种重构,并自动确保这些陷阱不会出现。
9.1.3. 从 lambda 表达式到方法引用
Lambda 表达式非常适合需要传递的简短代码。但尽可能使用方法引用来提高代码的可读性。方法名称可以更清楚地表达代码的意图。例如,在第六章中,我们向您展示了以下代码来按卡路里水平对菜肴进行分组:
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
menu.stream()
.collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}));
您可以将 lambda 表达式提取到单独的方法中,并将其作为参数传递给groupingBy。代码变得更加简洁,其意图也更加明确:
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
menu.stream().collect(groupingBy(Dish::getCaloricLevel)); *1*
- 1 Lambda 表达式被提取到方法中。
您需要将getCaloricLevel方法添加到Dish类本身中,这样代码才能正常工作:
public class Dish{
...
public CaloricLevel getCaloricLevel() {
if (this.getCalories() <= 400) return CaloricLevel.DIET;
else if (this.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}
}
此外,尽可能考虑使用辅助静态方法,如comparing和maxBy。这些方法是为与方法引用一起使用而设计的!确实,与我们在第三章中展示的 lambda 表达式相比,这段代码更清楚地表达了其意图,如下所示:
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); *1*
inventory.sort(comparing(Apple::getWeight)); *2*
-
1 您需要考虑比较的实现。
-
2 读起来像问题陈述
此外,对于许多常见的缩减操作,如 sum、maximum,有一些内置的辅助方法可以与方法引用结合使用。例如,我们展示了如何使用 Collectors API,你可以比使用 lambda 表达式和低级别的 reduce 操作组合更清晰地找到最大值或总和。而不是编写
int totalCalories =
menu.stream().map(Dish::getCalories)
.reduce(0, (c1, c2) -> c1 + c2);
尝试使用替代的内置收集器,这些收集器更清晰地陈述了问题。在这里,我们使用收集器 summingInt(名称在文档化代码方面大有裨益):
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
9.1.4. 从命令式数据处理到 Streams
理想情况下,你应该尝试将所有使用迭代器处理典型数据处理模式的代码转换为使用 Streams API。为什么?Streams API 更清晰地表达了数据处理管道的意图。此外,流可以在幕后进行优化,利用短路和惰性以及利用你的多核架构,正如我们在第七章中解释的那样。
以下命令式代码表达了两种模式(过滤和提取),它们被混合在一起,迫使程序员在弄清楚代码做什么之前仔细考虑整个实现。此外,一个并行执行的实现将更加困难。参见第七章(特别是 7.2 节)以了解涉及的工作:
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu) {
if(dish.getCalories() > 300){
dishNames.add(dish.getName());
}
}
另一种使用 Streams API 的替代方案,读起来更像问题陈述,并且可以轻松并行化:
menu.parallelStream()
.filter(d -> d.getCalories() > 300)
.map(Dish::getName)
.collect(toList());
不幸的是,将命令式代码转换为 Streams API 可能是一项艰巨的任务,因为你需要考虑控制流语句,如 break、continue 和 return,然后推断出正确的流操作来使用。好消息是,一些工具也可以帮助你完成这项任务。好消息是,一些工具(例如 Lambda-Ficator,ieeexplore.ieee.org/document/6606699)也可以帮助你完成这项任务。
9.1.5. 提高代码灵活性
我们在第二章和第三章中论证,lambda 表达式鼓励行为参数化的风格。你可以用不同的 lambda 来表示多种行为,然后可以将它们传递出去执行。这种风格让你能够应对需求变化(例如,使用 Predicate 创建多个过滤方式或使用 Comparator 进行比较)。在下一节中,我们将探讨一些你可以应用到代码库中的模式,以立即从 lambda 表达式中受益。
采用函数式接口
首先,没有功能接口,你无法使用 lambda 表达式;因此,你应该开始在代码库中引入它们。但在哪种情况下应该引入它们?在本章中,我们讨论了两种常见的代码模式,可以将它们重构为利用 lambda 表达式:条件延迟执行和执行周围。在下一节中,我们将向你展示如何使用 lambda 表达式更简洁地重写各种面向对象设计模式——例如策略和模板方法设计模式。
条件延迟执行
在业务逻辑代码中看到控制流语句被破坏是很常见的。典型场景包括安全检查和记录。考虑以下使用内置 Java Logger类的代码:
if (logger.isLoggable(Log.FINER)) {
logger.finer("Problem: " + generateDiagnostic());
}
这有什么问题?几点:
-
记录器的状态(它支持哪些级别)通过
isLoggable方法在客户端代码中暴露。 -
为什么你每次记录消息之前都必须查询记录器对象的状态?这会使你的代码变得杂乱。
一个更好的替代方案是使用log方法,该方法在记录消息之前,内部检查记录器对象是否设置为正确的级别:
logger.log(Level.FINER, "Problem: " + generateDiagnostic());
这种方法更好,因为你的代码中没有if检查,记录器的状态也不再暴露。不幸的是,这段代码仍然有一个问题:即使记录器没有启用传递的消息级别,日志消息总是会被评估。
Lambda 表达式可以帮助。你需要一种方式来延迟消息的构建,以便它只能在给定条件下生成(在这里,当记录器级别设置为FINER时)。结果证明,Java 8 API 设计者知道这个问题,并引入了一个重载的log方法,该方法接受一个Supplier作为参数。这个替代log方法具有以下签名:
public void log(Level level, Supplier<String> msgSupplier)
现在你可以这样调用它:
logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
log方法仅在记录器处于正确的级别时,内部执行作为参数传递的 lambda 表达式。log方法的内部实现大致如下:
public void log(Level level, Supplier<String> msgSupplier) {
if(logger.isLoggable(level)){
log(level, msgSupplier.get()); *1*
}
}
- 1 执行 lambda 表达式
从这个故事中我们能学到什么?如果你在客户端代码中多次查询对象的状态(例如记录器的状态),只是为了调用这个对象上的某个方法(例如记录一条消息),考虑引入一个新的方法,该方法在内部检查对象的状态后,仅通过 lambda 表达式或方法引用调用该方法。你的代码将更易于阅读(更简洁),并且封装性更好,而不会在客户端代码中暴露对象的状态。
执行周围
在第三章中,我们讨论了你可以采用的另一种模式:执行周围。如果你发现自己用相同的准备和清理阶段包围不同的代码,你通常可以将这些代码拉入 lambda。好处是你可以重用处理准备和清理阶段的逻辑,从而减少代码重复。
这是你在第三章中看到的代码。它重用了相同的逻辑来打开和关闭文件,但可以用不同的 lambda 参数化来处理文件:
String oneLine =
processFile((BufferedReader b) -> b.readLine()); *1*
String twoLines =
processFile((BufferedReader b) -> b.readLine() + b.readLine()); *2*
public static String processFile(BufferedReaderProcessor p) throws
IOException {
try(BufferedReader br = new BufferedReader(new
FileReader("ModernJavaInAction/chap9/data.txt"))) {
return p.process(br); *3*
}
}
public interface BufferedReaderProcessor { *4*
String process(BufferedReader b) throws IOException;
}
-
1 传递一个 lambda。
-
2 传递不同的 lambda。
-
3 执行作为参数传递的 Buffered-ReaderProcessor。
-
4 一个用于 lambda 的功能接口,可以抛出 IOException
这段代码是通过引入功能接口 BufferedReader-Processor 实现的,它允许你传递不同的 lambda 来处理 BufferedReader 对象。
在本节中,你看到了如何应用各种配方来提高你代码的可读性和灵活性。在下一节中,你将看到 lambda 表达式如何移除与常见面向对象设计模式相关的样板代码。
9.2. 使用 lambda 重构面向对象设计模式
新的语言特性往往使现有的代码模式或习惯用法不那么受欢迎。例如,Java 5 中 for-each 循环的引入取代了许多显式迭代器的使用,因为它更不容易出错,更简洁。Java 7 中菱形运算符 <> 的引入减少了实例创建时显式泛型的使用(并逐渐推动 Java 程序员接受类型推断)。
一类特定的模式被称为设计模式.^([2]) 设计模式可以视为解决软件设计中常见问题的可重用蓝图。它们类似于建筑工程师拥有一套可重用的解决方案来构建特定场景下的桥梁(如悬索桥、拱桥等)。例如,访问者设计模式是一种常见的解决方案,用于将算法与其需要操作的特定结构分离。单例模式是一种常见的解决方案,用于限制类的实例化只能有一个对象。
²
见 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的 Design Patterns: Elements of Reusable Object-Oriented Software;ISBN 978-0201633610,ISBN 0-201-63361-2
Lambda 表达式为程序员提供了另一个新的工具。它们可以提供替代设计模式解决问题的方案,但通常工作量更少,方式更简单。许多现有的面向对象设计模式可以通过 lambda 表达式变得冗余或以更简洁的方式编写。
在本节中,我们探讨了五种设计模式:
-
策略
-
模板方法
-
观察者
-
责任链
-
工厂
我们向你展示 lambda 表达式如何提供一种替代方法来解决每个设计模式旨在解决的问题。
9.2.1. 策略
策略模式是表示一系列算法并允许你在运行时从中选择的一种常见解决方案。你曾在第二章中简要地看到过这个模式,当时我们向你展示了如何使用不同的谓词(如重苹果或绿苹果)来过滤库存。你可以将此模式应用于多种场景,例如使用不同的标准验证输入,使用不同的解析方式或格式化输入。
策略模式由三个部分组成,如图 9.1 所示:
-
表示某种算法的接口(接口
Strategy) -
一个或多个具体实现该接口以表示多个算法(具体类
ConcreteStrategyA,ConcreteStrategyB) -
一个或多个使用策略对象的客户端
图 9.1. 策略设计模式

假设你想要验证一个文本输入是否按照不同的标准(例如仅由小写字母组成或为数字)正确格式化。你首先定义一个接口来验证文本(表示为 String):
public interface ValidationStrategy {
boolean execute(String s);
}
其次,你定义该接口的一个或多个实现:
public class IsAllLowerCase implements ValidationStrategy {
public boolean execute(String s){
return s.matches("[a-z]+");
}
}
public class IsNumeric implements ValidationStrategy {
public boolean execute(String s){
return s.matches("\\d+");
}
}
然后,你可以在你的程序中使用这些不同的验证策略:
public class Validator {
private final ValidationStrategy strategy;
public Validator(ValidationStrategy v) {
this.strategy = v;
}
public boolean validate(String s) {
return strategy.execute(s);
}
}
Validator numericValidator = new Validator(new IsNumeric());
boolean b1 = numericValidator.validate("aaaa"); *1*
Validator lowerCaseValidator = new Validator(new IsAllLowerCase ());
boolean b2 = lowerCaseValidator.validate("bbbb"); *2*
-
1 返回 false
-
2 返回 true
使用 lambda 表达式
到目前为止,你应该已经认识到 ValidationStrategy 是一个函数式接口。此外,它具有与 Predicate<String> 相同的功能描述符。因此,你不需要声明新的类来实现不同的策略,可以直接传递更简洁的 lambda 表达式:
Validator numericValidator =
new Validator((String s) -> s.matches("[a-z]+")); *1*
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator =
new Validator((String s) -> s.matches("\\d+")); *1*
boolean b2 = lowerCaseValidator.validate("bbbb");
- 1 直接传递 lambda 表达式
如你所见,lambda 表达式消除了策略设计模式固有的样板代码。如果你这么想,lambda 表达式封装了一块代码(或策略),这正是策略设计模式被创建的原因,所以我们建议你在类似的问题上使用 lambda 表达式。
9.2.2. 模板方法
模板方法设计模式是在你需要表示算法的轮廓并具有更改其某些部分的额外灵活性时的一种常见解决方案。好吧,这个模式听起来有点抽象。换句话说,当你说“我很想使用这个算法,但我需要更改几行使其按我的要求工作”时,模板方法模式很有用。
这里有一个这个模式如何工作的例子。假设您需要编写一个简单的在线银行应用程序。用户通常输入客户 ID;应用程序从银行的数据库中获取客户的详细信息,并做一些使客户满意的事情。不同分支的在线银行应用程序可能有不同的使客户满意的方式(例如向他的账户添加奖金或减少他的文件工作)。您可以编写以下抽象类来表示在线银行应用程序:
abstract class OnlineBanking {
public void processCustomer(int id){
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy(c);
}
abstract void makeCustomerHappy(Customer c);
}
processCustomer方法为在线银行算法提供了一个草图:根据其 ID 获取客户并使客户满意。现在,不同的分支可以通过继承OnlineBanking类来提供makeCustomerHappy方法的不同的实现。
使用 lambda 表达式
您可以使用您喜欢的 lambda 表达式来解决相同的问题(创建算法的轮廓并让实施者插入一些部分)。您想要插入的算法组件可以用 lambda 表达式或方法引用表示。
在这里,我们将一个类型为Consumer<Customer>的第二个参数引入到processCustomer方法中,因为它与之前定义的makeCustomerHappy方法的签名相匹配:
public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy.accept(c);
}
现在,您可以通过传递 lambda 表达式直接插入不同的行为,而无需对Online-Banking类进行子类化:
new OnlineBankingLambda().processCustomer(1337, (Customer c) ->
System.out.println("Hello " + c.getName());
这个例子展示了 lambda 表达式如何帮助您移除设计模式中固有的样板代码。
9.2.3. 观察者
观察者设计模式是在一个对象(称为主题)需要自动通知其他对象列表(称为观察者)在某个事件发生时(例如状态变化)时的常见解决方案。您通常在处理 GUI 应用程序时遇到这种模式。您在按钮等 GUI 组件上注册一组观察者。如果按钮被点击,观察者将被通知并可以执行特定操作。但观察者模式不仅限于 GUI。观察者设计模式也适用于多个交易者(观察者)希望对股票(主题)价格的变化做出反应的情况。图 9.2 展示了观察者模式的 UML 图。
图 9.2. 观察者设计模式

现在编写一些代码来查看观察者模式在实际中的实用性。您将为类似 Twitter 这样的应用程序设计并实现一个定制的通知系统。概念很简单:几家新闻机构(《纽约时报》,《卫报》和《世界报》)订阅了新闻推文的源,如果推文包含特定的关键词,它们可能希望收到通知。
首先,您需要一个Observer接口来分组观察者。它有一个名为notify的方法,当有新的推文可用时,将由主题(Feed)调用:
interface Observer {
void notify(String tweet);
}
现在,你可以声明不同的观察者(这里,是三家报纸),它们会对推文中包含的不同关键词执行不同的操作:
class NYTimes implements Observer {
public void notify(String tweet) {
if(tweet != null && tweet.contains("money")){
System.out.println("Breaking news in NY! " + tweet);
}
}
}
class Guardian implements Observer {
public void notify(String tweet) {
if(tweet != null && tweet.contains("queen")){
System.out.println("Yet more news from London... " + tweet);
}
}
}
class LeMonde implements Observer {
public void notify(String tweet) {
if(tweet != null && tweet.contains("wine")){
System.out.println("Today cheese, wine and news! " + tweet);
}
}
}
你仍然缺少关键部分:主题。为主题定义一个接口:
interface Subject {
void registerObserver(Observer o);
void notifyObservers(String tweet);
}
主题可以使用registerObserver方法注册新的观察者,并使用notifyObservers方法通知观察者关于推文的信息。现在实现Feed类:
class Feed implements Subject {
private final List<Observer> observers = new ArrayList<>();
public void registerObserver(Observer o) {
this.observers.add(o);
}
public void notifyObservers(String tweet) {
observers.forEach(o -> o.notify(tweet));
}
}
这个实现很简单:该 feed 维护一个内部观察者列表,当收到一条推文时可以通知这些观察者。你可以创建一个演示应用程序来连接主题和观察者:
Feed f = new Feed();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
f.notifyObservers("The queen said her favourite book is Modern Java in Action!");
毫不奇怪,《卫报》 收到了这条推文。
使用 lambda 表达式
你可能想知道如何使用 lambda 表达式与观察者设计模式结合。注意,实现Observer接口的各个类都为单个方法提供了实现:notify。它们封装了当收到推文时要执行的行为。Lambda 表达式专门设计用来移除这些样板代码。你不需要显式实例化三个观察者对象,可以直接传递一个 lambda 表达式来表示要执行的行为:
f.registerObserver((String tweet) -> {
if(tweet != null && tweet.contains("money")){
System.out.println("Breaking news in NY! " + tweet);
}
});
f.registerObserver((String tweet) -> {
if(tweet != null && tweet.contains("queen")){
System.out.println("Yet more news from London... " + tweet);
}
});
你是否应该始终使用 lambda 表达式?答案是:不。在我们描述的例子中,lambda 表达式工作得很好,因为要执行的行为很简单,因此它们有助于移除样板代码。但是观察者可能更复杂;它们可能有状态,定义多个方法等。在这些情况下,你应该坚持使用类。
9.2.4. 职责链
职责链模式是创建处理对象链(如操作链)的常见解决方案。一个处理对象可能做一些工作并将结果传递给另一个对象,该对象也做一些工作并将结果传递给另一个处理对象,依此类推。
通常,此模式通过定义一个表示处理对象的抽象类来实现,该类定义了一个字段来跟踪后继者。当它完成其工作后,处理对象将工作转交给其后继者。代码看起来像这样:
public abstract class ProcessingObject<T> {
protected ProcessingObject<T> successor;
public void setSuccessor(ProcessingObject<T> successor){
this.successor = successor;
}
public T handle(T input) {
T r = handleWork(input);
if(successor != null){
return successor.handle(r);
}
return r;
}
abstract protected T handleWork(T input);
}
图 9.3 使用 UML 说明了职责链模式。
图 9.3. 职责链设计模式

这里,你可能认出了我们在 9.2.2 节中讨论的模板方法设计模式。handle方法提供了一个处理工作的框架。你可以通过子类化Processing-Object类并提供handleWork方法的实现来创建不同类型的处理对象。
这里有一个如何使用此模式的例子。你可以创建两个执行一些文本处理的处理对象:
public class HeaderTextProcessing extends ProcessingObject<String> {
public String handleWork(String text) {
return "From Raoul, Mario and Alan: " + text;
}
}
public class SpellCheckerProcessing extends ProcessingObject<String> {
public String handleWork(String text) {
return text.replaceAll("labda", "lambda"); *1*
}
}
- 1 哦,我们忘记在“lambda”中写‘m’了!
现在,你可以连接两个处理对象来构建操作链:
ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2); *1*
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result); *2*
-
1 连接两个处理对象
-
2 打印出“来自 Raoul, Mario 和 Alan:lambda 真的很有魅力吗?!!”
使用 lambda 表达式
等一下——这个模式看起来像是函数链(即组合)。我们在第三章中讨论了组合 lambda 表达式。你可以将处理对象表示为 Function<String, String> 的一个实例,或者(更精确地)一个 UnaryOperator<String>。要链式调用它们,请使用 andThen 方法组合这些函数:
UnaryOperator<String> headerProcessing =
(String text) -> "From Raoul, Mario and Alan: " + text; *1*
UnaryOperator<String> spellCheckerProcessing =
(String text) -> text.replaceAll("labda", "lambda"); *2*
Function<String, String> pipeline =
headerProcessing.andThen(spellCheckerProcessing); *3*
String result = pipeline.apply("Aren't labdas really sexy?!!");
-
1 第一个处理对象
-
2 第二个处理对象
-
3 组合两个函数,形成一个操作链。
9.2.5. 工厂
工厂设计模式允许你创建对象,同时不向客户端暴露实例化逻辑。假设你为一家银行工作,该银行需要一种创建不同金融产品的方法:贷款、债券、股票等等。
通常,你会创建一个 Factory 类,其中包含一个负责创建不同对象的方法,如下所示:
public class ProductFactory {
public static Product createProduct(String name) {
switch(name){
case "loan": return new Loan();
case "stock": return new Stock();
case "bond": return new Bond();
default: throw new RuntimeException("No such product " + name);
}
}
}
在这里,Loan、Stock 和 Bond 是 Product 的子类型。createProduct 方法可以包含额外的逻辑来配置每个创建的产品。但好处是你可以创建这些对象,同时不向客户端暴露构造函数和配置,这使得客户端创建产品更加简单,如下所示:
Product p = ProductFactory.createProduct("loan");
使用 lambda 表达式
你在第三章中看到,你可以像引用方法一样引用构造函数:使用方法引用。以下是如何引用 Loan 构造函数的示例:
Supplier<Product> loanSupplier = Loan::new;
Loan loan = loanSupplier.get();
使用这种技术,你可以通过创建一个将产品名称映射到其构造函数的 Map 来重写前面的代码:
final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
map.put("loan", Loan::new);
map.put("stock", Stock::new);
map.put("bond", Bond::new);
}
你可以使用这个 Map 来实例化不同的产品,就像使用工厂设计模式一样:
public static Product createProduct(String name){
Supplier<Product> p = map.get(name);
if(p != null) return p.get();
throw new IllegalArgumentException("No such product " + name);
}
这种技术是一种巧妙的方法,使用 Java 8 的这个特性来实现与工厂模式相同的目的。但如果工厂方法 create-Product 需要传递多个参数给产品构造函数,这种技术就不太适用了。你将不得不提供除简单的 Supplier 之外的函数式接口。
假设你想引用需要三个参数(两个 Integer 和一个 String)的产品构造函数;你需要创建一个特殊的函数式接口 TriFunction 来支持这样的构造函数。因此,Map 的签名变得更加复杂:
public interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
Map<String, TriFunction<Integer, Integer, String, Product>> map
= new HashMap<>();
你已经看到了如何使用 lambda 表达式编写和重构代码。在下一节中,你将看到如何确保你的新代码是正确的。
9.3. 测试 lambda
你已经在代码中使用了 lambda 表达式,代码看起来既美观又简洁。但在大多数开发者的工作中,你得到的报酬不是写漂亮的代码,而是写正确的代码。
通常,良好的软件工程实践涉及使用单元测试来确保你的程序按预期行为。你编写测试用例,断言你的源代码的小部分产生预期的结果。考虑一个简单的用于图形应用的Point类:
public class Point {
private final int x;
private final int y;
private Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
public Point moveRightBy(int x) {
return new Point(this.x + x, this.y);
}
}
以下单元测试检查moveRightBy方法是否按预期行为:
@Test
public void testMoveRightBy() throws Exception {
Point p1 = new Point(5, 5);
Point p2 = p1.moveRightBy(10);
assertEquals(15, p2.getX());
assertEquals(5, p2.getY());
}
9.3.1. 测试可见 lambda 的行为
这段代码运行良好,因为moveRightBy方法是公开的,因此可以在测试用例中测试。但 lambda 没有名字(毕竟它们是无名函数),在你的代码中测试它们是棘手的,因为你不能通过名字引用它们。
有时,你可以通过一个字段访问到一个 lambda,这样你可以重用它,并且你想要测试那个 lambda 中封装的逻辑。你能做什么?你可以像调用方法一样测试 lambda。假设你在Point类中添加一个静态字段compareByXAndThenY,它让你可以访问由方法引用生成的Comparator对象:
public class Point {
public final static Comparator<Point> compareByXAndThenY =
comparing(Point::getX).thenComparing(Point::getY);
...
}
记住,lambda 表达式生成一个函数式接口的实例。因此,你可以测试那个实例的行为。在这里,你可以通过不同的参数调用Comparator对象compareByXAndThenY上的compare方法来测试其行为是否符合预期:
@Test
public void testComparingTwoPoints() throws Exception {
Point p1 = new Point(10, 15);
Point p2 = new Point(10, 20);
int result = Point.compareByXAndThenY.compare(p1 , p2);
assertTrue(result < 0);
}
9.3.2. 专注于使用 lambda 的方法的行为
但 lambda 的目的在于封装一个一次性行为,以便其他方法使用。在这种情况下,你不应该公开 lambda 表达式;它们只是实现细节。相反,我们认为你应该测试使用 lambda 表达式的那个方法的行为。考虑这里显示的moveAllPoints-RightBy方法:
public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
return points.stream()
.map(p -> new Point(p.getX() + x, p.getY()))
.collect(toList());
}
测试 lambda p -> new Point(p.getX() + x, p.getY())没有意义(有意为之);它只是moveAllPointsRightBy方法的实现细节。相反,你应该专注于测试moveAllPointsRightBy方法的行为:
@Test
public void testMoveAllPointsRightBy() throws Exception {
List<Point> points =
Arrays.asList(new Point(5, 5), new Point(10, 5));
List<Point> expectedPoints =
Arrays.asList(new Point(15, 5), new Point(20, 5));
List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
assertEquals(expectedPoints, newPoints);
}
注意,在单元测试中,Point类适当地实现equals方法是重要的;否则,它依赖于Object的默认实现。
9.3.3. 将复杂的 lambda 提取到单独的方法中
也许你会遇到一个非常复杂的 lambda 表达式,其中包含很多逻辑(例如具有边缘情况的复杂技术定价算法)。你该怎么办,因为你不能在测试中引用 lambda 表达式?一种策略是将 lambda 表达式转换为方法引用(这涉及到声明一个新的常规方法),正如我们在第 9.1.3 节中解释的那样。然后你可以像测试任何常规方法一样测试新方法的行为。
9.3.4. 测试高阶函数
接受函数作为参数或返回另一个函数的方法(所谓的高阶函数,在第十九章中解释)处理起来稍微有些困难。如果方法接受 lambda 作为参数,你可以用不同的 lambda 来测试其行为。你可以用你在第二章中创建的filter方法来测试不同的谓词:
@Test
public void testFilter() throws Exception {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
List<Integer> even = filter(numbers, i -> i % 2 == 0);
List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
assertEquals(Arrays.asList(2, 4), even);
assertEquals(Arrays.asList(1, 2), smallerThanThree);
}
如果需要测试的方法返回另一个函数呢?你可以通过将其视为功能接口的实例来测试该函数的行为,就像我们之前用Comparator展示的那样。
不幸的是,并非所有事情都能一帆风顺,你的测试可能会报告一些与 lambda 表达式使用相关的错误。因此,在下一节中,我们将转向调试。
9.4. 调试
开发者的工具箱中有两种主要的旧式武器用于调试有问题的代码:
-
检查堆栈跟踪
-
记录
Lambda 表达式和流可以为你的典型调试流程带来新的挑战。我们将在本节中探讨这两个方面。
9.4.1. 检查堆栈跟踪
当你的程序停止(例如,抛出异常)时,你需要知道的第一件事是程序停止的位置以及它是如何到达那里的。堆栈帧对此很有用。每次你的程序执行方法调用时,都会生成有关调用的信息,包括调用在程序中的位置、调用的参数以及被调用方法的局部变量。这些信息存储在堆栈帧中。
当你的程序失败时,你会得到一个堆栈跟踪,这是你的程序如何到达失败状态的总结,从堆栈帧到堆栈帧。换句话说,你得到了一个直到失败出现时的方法调用宝贵列表。这个列表有助于你理解问题是如何发生的。
使用 lambda 表达式
不幸的是,由于 lambda 表达式没有名字,堆栈跟踪可能会有些令人困惑。考虑以下故意编写来失败的简单代码:
import java.util.*;
public class Debugging{
public static void main(String[] args) {
List<Point> points = Arrays.asList(new Point(12, 2), null);
points.stream().map(p -> p.getX()).forEach(System.out::println);
}
}
运行此代码会产生类似于以下堆栈跟踪(取决于你的 javac 版本;你可能不会得到相同的堆栈跟踪):
Exception in thread "main" java.lang.NullPointerException
at Debugging.lambda$main$0(Debugging.java:6) *1*
at Debugging$$Lambda$5/284720968.apply(Unknown Source)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
.java:193)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators
.java:948)
...
- 1 这行代码中的$0 代表什么?
哎呀!发生了什么事?程序当然失败了,因为点的列表中的第二个元素是null。你试图处理一个null引用。由于错误发生在流管道中,使流管道工作的整个方法调用序列都暴露给你。但请注意,堆栈跟踪产生了以下神秘的行:
at Debugging.lambda$main$0(Debugging.java:6)
at Debugging$$Lambda$5/284720968.apply(Unknown Source)
这些行意味着错误发生在 lambda 表达式内部。不幸的是,由于 lambda 表达式没有名字,编译器必须编造一个名字来引用它们。在这种情况下,名字是lambda$main$0,这并不直观,如果你有包含多个 lambda 表达式的长类,可能会出现问题。
即使你使用了方法引用,仍然有可能堆栈不会显示你使用的方法名称。将前面的 lambda p -> p.getX() 改为方法引用 Point::getX 也会导致问题堆栈跟踪:
points.stream().map(Point::getX).forEach(System.out::println);
Exception in thread "main" java.lang.NullPointerException
at Debugging$$Lambda$5/284720968.apply(Unknown Source) *1*
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
.java:193)
...
- 1 这行代码是什么意思?
注意,如果方法引用引用的是在同一类中声明的同一方法,它将出现在堆栈跟踪中。在以下示例中:
import java.util.*;
public class Debugging{
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3);
numbers.stream().map(Debugging::divideByZero).forEach(System
.out::println);
}
public static int divideByZero(int n){
return n / 0;
}
}
divideByZero 方法在堆栈跟踪中报告正确:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Debugging.divideByZero(Debugging.java:10) *1*
at Debugging$$Lambda$1/999966131.apply(Unknown Source)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
.java:193)
...
- 1 divideByZero 出现在堆栈跟踪中。
通常,请记住,涉及 lambda 表达式的堆栈跟踪可能更难以理解。这是编译器在未来版本的 Java 中可以改进的一个领域。
9.4.2. 记录信息
假设你正在尝试调试流操作管道。你能做什么?你可以使用 forEach 来打印或记录流的结果,如下所示:
List<Integer> numbers = Arrays.asList(2, 3, 4, 5);
numbers.stream()
.map(x -> x + 17)
.filter(x -> x % 2 == 0)
.limit(3)
.forEach(System.out::println);
此代码产生以下输出:
20
22
不幸的是,在调用 forEach 之后,整个流都被消费了。了解流管道中每个操作(map、filter、limit)产生的结果将是有用的。
流操作 peek 可以帮助。peek 的目的是在流被消费时对每个元素执行一个动作。然而,它不会像 forEach 那样消费整个流;它将执行了动作的元素传递给管道中的下一个操作。图 9.4 展示了 peek 操作。
图 9.4. 使用 peek 检查流管道中流动的值

在以下代码中,你使用 peek 来打印流管道中每个操作前后的中间值:
List<Integer> result =
numbers.stream()
.peek(x -> System.out.println("from stream: " + x)) *1*
.map(x -> x + 17)
.peek(x -> System.out.println("after map: " + x)) *2*
.filter(x -> x % 2 == 0)
.peek(x -> System.out.println("after filter: " + x)) *3*
.limit(3)
.peek(x -> System.out.println("after limit: " + x)) *4*
.collect(toList());
-
1 打印从源中消耗的当前元素
-
2 打印映射操作的结果。
-
3 打印过滤操作后选定的数字。
-
4 打印限制操作后选定的数字。
此代码在管道的每个步骤都产生有用的输出:
from stream: 2
after map: 19
from stream: 3
after map: 20
after filter: 20
after limit: 20
from stream: 4
after map: 21
from stream: 5
after map: 22
after filter: 22
after limit: 22
摘要
-
Lambda 表达式可以使你的代码更易读和灵活。
-
考虑将匿名类转换为 lambda 表达式,但要注意关键字
this的含义和变量遮蔽等细微的语义差异。 -
与 lambda 表达式相比,方法引用可以使你的代码更易读。
-
考虑将迭代集合处理转换为使用 Streams API。
-
Lambda 表达式可以移除与多个面向对象设计模式(如策略、模板方法、观察者、责任链、工厂)相关的样板代码。
-
Lambda 表达式可以进行单元测试,但通常你应该关注测试 lambda 表达式出现的那些方法的行为了。
-
考虑将复杂的 lambda 表达式提取到常规方法中。
-
Lambda 表达式可以使堆栈跟踪更难以阅读。
-
流的
peek方法在将中间值作为它们流经流管道的某些点时进行日志记录很有用。
第十章. 使用 lambda 的领域特定语言
本章涵盖
-
哪些领域特定语言(DSLs)及其形式
-
在 API 中添加 DSL 的优缺点
-
在 JVM 上可用于普通 Java DSL 的替代方案
-
从现代 Java 接口和类中现有的 DSL 中学习
-
实现有效的基于 Java 的领域特定语言(DSL)的模式和技术
-
如何在常用的 Java 库和工具中使用这些模式
开发者常常忘记,编程语言首先是一种语言。任何语言的主要目的都是以最清晰、最易懂的方式传达信息。也许编写得好的软件最重要的特征就是清晰地传达其意图——正如著名计算机科学家 Harold Abelson 所说:“程序必须是为了让人阅读而编写的,而只是偶然地为了机器执行。”
可读性和可理解性在旨在模拟应用程序核心业务的软件部分中尤为重要。编写可以被开发团队和领域专家共享和理解代码有助于提高生产力。领域专家可以参与到软件开发过程中,并从业务角度验证软件的正确性。因此,可以尽早发现错误和误解。
为了达到这个结果,通常通过领域特定语言(DSL)来表述应用程序的业务逻辑。DSL 是一种小型的、通常非通用目的的编程语言,它专门针对特定领域进行定制。该 DSL 使用该领域的术语。例如,你可能熟悉 Maven 和 Ant,你可以把它们看作是表述构建过程的 DSL。你也熟悉 HTML,这是一种专门用于定义网页结构的语言。从历史上看,由于它的僵化和过多的冗余,Java 从未流行于实现一个既紧凑又适合非技术人士阅读的 DSL。然而,现在 Java 支持 lambda 表达式,你就有新的工具在你的工具箱中!实际上,你在第三章中了解到 lambda 表达式有助于减少代码冗余并提高程序的信噪比。
考虑一个用 Java 实现的数据库。在数据库的底层,可能有很多复杂的代码来确定给定记录在磁盘上的存储位置,为表构建索引,以及处理并发事务。这个数据库可能是由相对有经验的程序员编写的。假设现在你想编写一个类似于我们在第四章和第五章中探索的查询:“找到给定菜单上所有少于 400 卡路里的菜单项。”
从历史上看,这样的专家程序员可能会以这种方式快速编写低级代码,并认为任务很简单:
while (block != null) {
read(block, buffer)
for (every record in buffer) {
if (record.calorie < 400) {
System.out.println (record.name);
}
}
block = buffer.next();
}
这个解决方案有两个主要问题:一个是不太有经验的程序员很难创建(可能需要锁定、I/O 或磁盘分配的微妙细节),更重要的是,它处理的是系统级概念,而不是应用级概念。
一个新加入的用户界面程序员可能会说:“为什么你们不能提供一个 SQL 接口,让我可以编写SELECT name FROM menu WHERE calorie < 400,其中menu包含以 SQL 表形式表达的餐厅菜单?现在我可以比所有这些系统级垃圾更有效地编程!” 这句话很难反驳!本质上,程序员要求使用 DSL 与数据库交互,而不是编写纯 Java 代码。从技术上讲,这种类型的 DSL 被称为外部,因为它期望数据库有一个 API,可以解析和评估以文本形式编写的 SQL 表达式。你将在本章后面了解更多关于外部和内部 DSL 之间的区别。
但如果你回顾第四章和第五章,你会注意到这段代码也可以使用Stream API 更简洁地编写,如下所示:
menu.stream()
.filter(d -> d.getCalories() < 400)
.map(Dish::getName)
.forEach(System.out::println)
这种使用链式方法,这是Stream API 的一个典型特征,通常被称为流畅风格,因为它易于快速理解,与 Java 循环中的复杂控制流形成对比。
这种风格有效地捕捉了 DSL。在这种情况下,这个 DSL 不是外部的,而是内部的。在内部 DSL 中,应用级原语被暴露为 Java 方法,可以在表示数据库的一个或多个类类型上使用,这与外部 DSL 中原语的非 Java 语法形成对比,例如上面 SQL 讨论中的 SELECT FROM。
从本质上讲,设计一个 DSL 包括决定应用级程序员需要执行哪些操作(仔细避免由系统级概念引起的任何不必要的污染),并向程序员提供这些操作。
对于内部领域特定语言(DSL),这个过程意味着暴露适当的类和方法,以便代码可以流畅地编写。外部 DSL 需要更多的努力;你不仅必须设计 DSL 的语法,还要为 DSL 实现一个解析器和评估器。然而,如果你设计得当,也许低技能的程序员可以快速有效地编写代码(从而为公司赚取维持运营的资金),而无需直接在您美丽(但非专家难以理解)的系统级代码中进行编程!
在本章中,你将通过几个示例和用例了解什么是 DSL;你将了解何时应该考虑实现一个,以及它的好处是什么。然后你将探索一些在 Java 8 API 中引入的小型 DSL。你还了解如何使用相同的模式创建自己的 DSL。最后,你将研究一些广泛使用的 Java 库和框架如何采用这些技术,通过一系列 DSL 提供其功能,使它们的 API 更易于访问和使用。
10.1. 为你的领域设计一种特定语言
DSL(领域特定语言)是一种为解决特定商业领域问题而设计的定制语言。例如,你可能正在开发一个用于会计的软件应用程序。你的业务领域包括诸如银行对账单和诸如对账等操作。你可以创建一个定制的 DSL 来表示该领域的问题。在 Java 中,你需要想出一组类和方法来表示该领域。从某种意义上说,你可以将 DSL 视为创建用于与特定商业领域接口的 API。
DSL 不是一种通用编程语言;它限制了特定领域可用的操作和词汇,这意味着你考虑的事情更少,可以更多地关注解决手头的业务问题。你的 DSL 应该允许其用户仅处理该领域的复杂性。其他更底层的实现细节应该被隐藏——就像将类的底层实现细节方法设为私有一样。这导致了一个用户友好的 DSL。
什么不是 DSL?DSL 不是普通的英语。它也不是一种让领域专家实现低级业务逻辑的语言。有两个原因应该推动你朝着开发 DSL 的方向发展:
-
沟通是王道。你的代码应该清楚地传达其意图,即使是非程序员也能理解。这样,这个人就可以贡献于验证代码是否符合业务需求。
-
代码一旦编写,就要多次阅读。可读性对于可维护性至关重要。换句话说,你应该始终以你的同事会感谢你而不是恨你的方式编写代码!
一个设计良好的 DSL 提供了许多好处。尽管如此,开发和使用定制的 DSL 既有优点也有缺点。在第 10.1.1 节中,我们更详细地探讨了优缺点,以便你可以决定在特定场景下何时(或不)使用 DSL。
10.1.1. DSL 的优缺点
DSLs,就像软件开发中的其他技术和解决方案一样,并不是万能的。使用 DSL 与您的领域进行交互可以是资产也可以是负债。DSL 可以成为资产,因为它提高了您澄清代码业务意图的抽象级别,并使代码更易于阅读。但这也可能成为负债,因为 DSL 的实现本身就是需要测试和维护的代码。因此,调查 DSL 的优势和成本是有用的,这样您就可以评估将 DSL 添加到您的项目中是否会带来积极的投资回报。
DSLs 提供以下优势:
-
简洁性—**一个方便封装业务逻辑的 API 允许您避免重复,从而生成更简洁的代码。
-
可读性—**使用属于领域词汇表中的词汇可以使代码即使对领域非专家来说也是可理解的。因此,代码和领域知识可以在组织更广泛的成员之间共享。
-
可维护性—**针对良好设计的 DSL 编写的代码更容易维护和修改。对于业务相关的代码,可维护性尤为重要,因为这部分应用程序可能变化最为频繁。
-
更高的抽象级别—**DSL 中可用的操作与领域处于相同的抽象级别,从而隐藏了与领域问题严格无关的细节。
-
专注度—**为表达业务领域规则而设计的语言有助于程序员专注于代码的特定部分。结果是生产力的提高。
-
关注点分离—**在专用语言中表达业务逻辑使将业务相关的代码与应用程序的基础设施部分隔离开来变得更容易。结果是代码更容易维护。
相反,将 DSL 引入您的代码库可能会带来一些不利因素:
-
DSL 设计的难度—**在简洁有限的语境内捕捉领域知识是有难度的。
-
开发成本—**将 DSL 添加到您的代码库是一项长期投资,前期成本较高,可能会在项目早期阶段延迟您的项目。此外,DSL 的维护及其演变还会增加额外的工程开销。
-
额外的间接层—**DSL 在尽可能薄的一层中封装了您的领域模型,以避免引入性能问题。
-
另一种需要学习的技术—**如今,开发者习惯于使用多种语言。然而,将 DSL 添加到您的项目中,却隐含着您和您的团队需要学习一门新的语言。更糟糕的是,如果您决定拥有多个覆盖您业务领域不同领域的 DSL,将它们无缝结合可能会很困难,因为 DSL 倾向于独立演变。
-
宿主语言限制—**一些通用编程语言(Java 就是其中之一)因其冗长和语法严格而闻名。这些语言使得设计用户友好的 DSL 变得困难。事实上,在冗长编程语言之上开发的 DSL 受到繁琐语法的限制,可能不易阅读。Java 8 中 lambda 表达式的引入为缓解这个问题提供了一个强大的新工具。
考虑到这些正负参数列表,决定是否为你的项目开发领域特定语言(DSL)并不容易。此外,你还有 Java 以外的其他选择来实现自己的 DSL。在调查你可以采用哪些模式和策略来在 Java 8 及更高版本中开发易于阅读和使用的 DSL 之前,我们快速探索这些替代方案,并描述它们可能成为适当解决方案的情况。
10.1.2. JVM 上可用的不同 DSL 解决方案
在本节中,你将学习 DSL 的分类。你还将了解到,除了 Java 之外,你还有许多选择来实现 DSL。在后面的章节中,我们将重点介绍如何使用 Java 特性来实现 DSL。
最常见的 DSL 分类方法是由 Martin Fowler 提出的,即区分内部和外部 DSL。内部 DSL(也称为嵌入式 DSL)是在现有宿主语言(可能是纯 Java 代码)之上实现的,而外部 DSL 被称为独立型,因为它们是从零开始开发的,语法独立于宿主语言。
此外,JVM 为你提供了一个介于内部和外部 DSL 之间的第三种可能性:另一种在 JVM 上运行但比 Java 更灵活、更丰富的通用编程语言,例如 Scala 或 Groovy。我们将这种第三种替代方案称为多语言 DSL。
在接下来的几节中,我们将按顺序查看这三种类型的 DSL。
内部领域特定语言(Internal DSL)
因为这本书是关于 Java 的,当我们提到内部 DSL 时,我们明确指的是用 Java 编写的 DSL。从历史上看,Java 并不被认为是一种适合 DSL 的语言,因为其繁琐、不灵活的语法使得编写易于阅读、简洁、表达丰富的 DSL 变得困难。lambda 表达式的引入在很大程度上缓解了这个问题。正如你在第三章中看到的,lambda 表达式以简洁的方式用于行为参数化。实际上,广泛使用 lambda 表达式会导致具有更可接受的信号/噪声比的 DSL,因为它减少了与匿名内部类相关的冗长性。为了演示信号/噪声比,尝试使用 Java 7 语法打印一个String列表,但使用 Java 8 的新forEach方法:
List<String> numbers = Arrays.asList("one", "two", "three");
numbers.forEach( new Consumer<String>() {
@Override
public void accept( String s ) {
System.out.println(s);
}
} );
在这个片段中,粗体的部分承载着代码的信号。所有剩余的代码都是语法噪声,它不会提供额外的益处,而且在 Java 8 中,这些代码甚至不再是必要的。匿名内部类可以被 lambda 表达式替换。
numbers.forEach(s -> System.out.println(s));
或者,可以通过方法引用来更加简洁地实现:
numbers.forEach(System.out::println);
当你预期用户具有一定的技术背景时,你可能会很高兴用 Java 来构建你的 DSL。如果 Java 语法不是问题,选择在纯 Java 中开发你的 DSL 有许多优点:
-
与学习新的编程语言及其通常用于开发外部 DSL 的工具相比,学习实现良好的 Java DSL 所需的模式和技术所付出的努力是微不足道的。
-
你的 DSL 是用纯 Java 编写的,所以它与你的其他代码一起编译。由于集成了第二语言编译器或用于生成外部 DSL 的工具,不会产生额外的构建成本。
-
你的开发团队不需要熟悉不同的语言或可能不熟悉且复杂的第三方工具。
-
你的 DSL 用户将拥有你最喜欢的 Java 集成开发环境(IDE)通常提供的所有功能,例如自动完成和重构功能。现代 IDE 正在提高对其他流行 JVM 语言的兼容性,但仍然没有提供与为 Java 开发者提供的服务相当的支持。
-
如果你需要实现多个 DSL 来覆盖你的领域或多个领域,只要它们是用纯 Java 编写的,你就不必担心它们之间的组合问题。
另一种可能性是通过结合基于 JVM 的编程语言来组合使用相同 Java 字节码的 DSL。我们称这些 DSL 为多语言 DSL,并在下一节中对其进行描述。
多语言 DSL
现在,可能超过 100 种语言在 JVM 上运行。其中一些语言,如 Scala 和 Groovy,相当流行,而且并不难找到精通它们的开发者。其他语言,包括 JRuby 和 Jython,是将其他知名编程语言移植到 JVM 上的。最后,其他新兴语言,如 Kotlin 和 Ceylon,正在获得越来越多的关注,主要是因为它们声称具有与 Scala 相当的特性,但具有更低的内在复杂性和更平缓的学习曲线。所有这些语言都比 Java 年轻,并且设计时采用了更宽松、更简洁的语法。这一特性很重要,因为它有助于实现一个由于嵌入的编程语言而具有较少固有冗余的 DSL。
特别地,Scala 几乎有几个特性,如柯里化和隐式转换,这些特性在开发领域特定语言(DSL)时非常方便。你可以在第二十章 chapter 20 中了解 Scala 的概述以及它与 Java 的比较。目前,我们希望通过一个小的示例让你对使用这些特性能做什么有一个感觉。
假设你想要构建一个重复执行另一个函数f的给定次数的实用函数。作为一个初步尝试,你可能会在 Scala 中得到以下递归实现。(不要担心语法;整体思路才是重要的。)
def times(i: Int, f: => Unit): Unit = {
f *1*
if (i > 1) times(i - 1, f) *2*
}
-
1 执行
f函数。 -
2 如果计数器
i是正数,则递减它并递归调用times函数。
注意,在 Scala 中,使用大的i值调用此函数不会导致栈溢出,这就像在 Java 中发生的那样,因为 Scala 有尾调用优化,这意味着对times函数的递归调用不会被添加到栈中。你可以在第十八章和第十九章中了解更多关于这个主题的内容。你可以使用这个函数重复执行另一个函数(例如打印 "Hello World" 三次),如下所示:
times(3, println("Hello World"))
如果你将times函数进行柯里化,或者将其参数分成两组(我们将在第十九章中详细讨论柯里化),
def times(i: Int)(f: => Unit): Unit = {
f
if (i > 1 times(i - 1)(f)
}
你可以通过在花括号中多次传递要执行的函数来达到相同的结果:
times(3) {
println("Hello World")
}
最后,在 Scala 中,你可以通过只有一个函数将Int转换为匿名类,该函数将重复执行的函数作为参数。再次提醒,不要担心语法和细节。这个示例的目的是给你一个关于超越 Java 可能性的想法。
implicit def intToTimes(i: Int) = new { *1*
def times(f: => Unit): Unit = { *2*
def times(i: Int, f: => Unit): Unit = { *3*
f
if (i > 1) times(i - 1, f)
}
times(i, f) *4*
}
}
-
1 定义了一个从 Int 到匿名类的隐式转换。
-
2 该类只有一个接受另一个函数
f作为参数的times函数。 -
3 一个接受两个参数的二次函数在第一个函数的作用域内定义。
-
4 调用内部
times函数。
以这种方式,你的小型 Scala 嵌入式 DSL 的用户可以执行一个打印 "Hello World" 三次的函数,如下所示:
3 times {
println("Hello World")
}
如你所见,结果没有语法噪音,即使是非开发者也能轻松理解。在这里,数字3被编译器自动转换为存储在i字段中的类的实例。然后使用无点表示法调用times函数,将重复的函数作为参数。
在 Java 中无法获得类似的结果,因此使用更适合 DSL 的语言的优势是显而易见的。然而,这个选择也有一些明显的不便:
-
你必须学习一门新的编程语言,或者你的团队中必须有已经掌握这门语言的人。因为这些语言中构建良好的 DSL 通常需要使用相对高级的功能,对新语言的肤浅了解通常是不够的。
-
你需要通过集成多个编译器来复杂化你的构建过程,以构建用两种或更多语言编写的源代码。
-
最后,尽管在 JVM 上运行的多数语言都声称与 100%的 Java 兼容,但与 Java 进行互操作通常需要尴尬的技巧和妥协。此外,这种互操作有时会导致性能损失。例如,Scala 和 Java 集合不兼容,所以当一个 Scala 集合需要传递给 Java 函数或反之亦然时,原始集合必须转换为目标语言原生 API 中的一个集合。
外部 DSL
将 DSL 添加到你的项目的第三种选择是实现一个外部 DSL。在这种情况下,你必须从头开始设计一种新的语言,包括其自己的语法和语义。你还需要设置一个单独的基础设施来解析新语言,分析解析器的输出,并生成执行外部 DSL 的代码。这是一项大量工作!执行这些任务所需的技能既不常见也不容易获得。如果你确实想走这条路,ANTLR 是一个常用的解析器生成器,它可以帮助你,并且与 Java 紧密配合。
此外,即使从头开始设计一个连贯的编程语言也不是一个简单任务。另一个常见问题是外部 DSL 很容易失控,并覆盖它未设计用于的领域和目的。
在开发外部领域特定语言(DSL)中最大的优势是它提供的几乎无限的灵活性。你可以设计一种语言,使其完美地满足你领域的需求和特殊性。如果你做得好,结果将是一种极其易读的语言,专门用于描述和解决你业务中的问题。另一个积极的结果是,在 Java 中开发的底层代码与使用外部 DSL 编写的业务代码之间的清晰分离。然而,这种分离是一把双刃剑,因为它也在 DSL 和宿主语言之间创建了一个人工层。
在本章的剩余部分,你将了解可以帮助你开发有效的基于现代 Java 的内部 DSL 的模式和技术。你首先将探索这些想法是如何被用于原生 Java API 的设计中的,特别是 Java 8 及其以后的 API 新增功能。
10.2. 现代 Java API 中的小型 DSL
首先利用 Java 新功能特性的 API 是原生 Java API 本身。在 Java 8 之前,原生 Java API 已经有一些只有一个抽象方法的接口,但正如你在第 10.1 节中看到的,它们的使用需要实现一个具有庞大语法的匿名内部类。lambda 表达式和(也许从 DSL 的角度来看甚至更重要)方法引用的添加改变了游戏规则,使功能接口成为 Java API 设计的基础。
Java 8 中的 Comparator 接口已经添加了新的方法。你在第十三章中了解到,一个接口可以包含静态方法和默认方法。目前,Comparator 接口是一个很好的例子,展示了 lambda 如何提高原生 Java API 中方法的复用性和可组合性。
假设你有一个代表人员的对象列表(Person 对象),并且你想根据人员的年龄对这些对象进行排序。在 lambda 之前,你必须通过内部类实现 Comparator 接口:
Collections.sort(persons, new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.getAge() - p2.getAge();
}
});
正如你在本书的许多其他示例中看到的,现在你可以用更紧凑的 lambda 表达式替换内部类:
Collections.sort(people, (p1, p2) -> p1.getAge() - p2.getAge());
这种技术大大提高了代码的信号/噪声比。然而,Java 也有一系列静态实用方法,允许你以更可读的方式创建 Comparator 对象。这些静态方法包含在 Comparator 接口中。通过静态导入 Comparator.comparing 方法,你可以将前面的排序示例重写如下:
Collections.sort(persons, comparing(p -> p.getAge()));
更好的是,你可以用方法引用替换 lambda:
Collections.sort(persons, comparing(Person::getAge));
这种方法的优点可以进一步发挥。如果你想按年龄对人员进行排序,但顺序相反,你可以利用添加在 Java 8 中的实例方法 reverse:
Collections.sort(persons, comparing(Person::getAge).reverse());
此外,如果你想按字母顺序对同龄人进行排序,你可以将这个 Comparator 与一个基于名称进行比对的 Comparator 组合起来:
Collections.sort(persons, comparing(Person::getAge)
.thenComparing(Person::getName));
最后,你可以使用在 List 接口中添加的新 sort 方法来进一步整理:
persons.sort(comparing(Person::getAge)
.thenComparing(Person::getName));
这个小的 API 是集合排序领域的最小 DSL。尽管其范围有限,但这个 DSL 已经展示了如何通过精心设计的 lambda 和方法引用来提高代码的可读性、复用性和可组合性。
在下一节中,我们将探讨一个更丰富、更广泛使用的 Java 8 类,其中可读性的提升更为明显:Stream API。
10.2.1. 将 Stream API 视为操作集合的 DSL
Stream 接口是原生 Java API 中引入的小型内部 DSL 的一个很好的例子。实际上,Stream 可以被视为一个紧凑但强大的 DSL,它可以过滤、排序、转换、分组和操作集合中的项。假设你需要读取一个日志文件并收集以 "ERROR" 开头的前 40 行,你可以按照以下列表所示以命令式方式执行此任务。
列表 10.1. 以命令式方式读取日志文件中的错误行
List<String> errors = new ArrayList<>();
int errorCount = 0;
BufferedReader bufferedReader
= new BufferedReader(new FileReader(fileName));
String line = bufferedReader.readLine();
while (errorCount < 40 && line != null) {
if (line.startsWith("ERROR")) {
errors.add(line);
errorCount++;
}
line = bufferedReader.readLine();
}
这里,为了简洁,我们省略了错误处理部分的代码。尽管如此,代码过于冗长,其意图并不立即明显。损害可读性和可维护性的另一个方面是缺乏清晰的关注点分离。实际上,具有相同职责的代码散布在多个语句中。例如,用于逐行读取文件的代码位于三个地方:
-
FileReader创建的位置 -
while循环的第二个条件,用于检查文件是否已终止 -
while循环的末尾本身读取文件中的下一行
类似地,将收集到的行数限制在列表中的前 40 行的代码也散布在三个语句中:
-
初始化变量
errorCount的操作 -
while循环的第一个条件 -
当在日志中找到以
"ERROR"开头的行时增加计数器的语句
通过Stream接口以更函数式的方式实现相同的结果要容易得多,并且代码更加紧凑,如列表 10.2 所示。
列表 10.2. 以函数式风格读取日志文件中的错误行
List<String> errors = Files.lines(Paths.get(fileName)) *1*
.filter(line -> line.startsWith("ERROR")) *2*
.limit(40) *3*
.collect(toList()); *4*
-
1 打开文件并创建一个字符串流,其中每个字符串对应文件中的一行。
-
2 过滤以“ERROR”开头的行。
-
3 限制结果只显示前 40 行。
-
4 将结果字符串收集到列表中。
Files.lines是一个静态实用方法,它返回一个Stream<String>,其中每个String代表要解析的文件中的一行。这部分代码是唯一需要逐行读取文件的代码。同样,limit(40)语句就足以将收集的错误行数限制在前 40 行。你能想象出更易于阅读的吗?
Stream API 流畅的风格是另一个有趣的特点,这是良好设计的领域特定语言(DSL)的典型特征。所有中间操作都是懒加载的,并返回另一个Stream,允许一系列操作被管道化。终端操作是急切的,并触发整个管道的计算结果。
是时候调查另一个小型 DSL 的 API 了,该 DSL 旨在与Stream接口的collect方法一起使用:Collectors API。
10.2.2. 将收集器作为聚合数据的 DSL
您已经看到Stream接口可以被视为一个操作数据列表的 DSL。同样,Collector接口可以被视为一个对数据进行聚合操作的 DSL。在第六章中,我们探讨了Collector接口,并解释了如何使用它来收集、分组和分区Stream中的项目。我们还研究了Collectors类提供的静态工厂方法,以方便地创建不同类型的Collector对象并将它们组合起来。现在是时候回顾这些方法是如何从 DSL 的角度来设计的了。特别是,由于Comparator接口中的方法可以组合起来以支持多字段排序,Collector也可以组合起来实现多级分组。例如,您可以首先按品牌然后按颜色对汽车列表进行分组,如下所示:
Map<String, Map<Color, List<Car>>> carsByBrandAndColor =
cars.stream().collect(groupingBy(Car::getBrand,
groupingBy(Car::getColor)));
与您连接两个Comparator所做的工作相比,您在这里注意到了什么?您通过以流畅的方式组合两个Comparator来定义了多字段Comparator,
Comparator<Person> comparator =
comparing(Person::getAge).thenComparing(Person::getName);
而Collectors API 允许你通过嵌套Collector来创建多级Collector:
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>
carGroupingCollector =
groupingBy(Car::getBrand, groupingBy(Car::getColor));
通常,流畅风格被认为比嵌套风格更易读,尤其是在涉及三个或更多组件的组合时。这种风格上的差异是一种好奇心吗?事实上,它反映了由于最内层的Collector必须首先评估,但从逻辑上讲,它是最后一个分组操作,因此这是一种故意的、有意识的设计选择。在这种情况下,使用几个静态方法而不是流畅地连接它们来创建Collector创建,允许首先评估最内层的分组,但看起来像是在代码中的最后一个。
实现一个GroupingBuilder,它委托给groupingBy工厂方法,但允许流畅地组合多个分组操作会更简单(除了在定义中使用泛型之外)。接下来的列表展示了如何实现。
列表 10.3. 一个流畅分组收集器构建器
import static java.util.stream.Collectors.groupingBy;
public class GroupingBuilder<T, D, K> {
private final Collector<? super T, ?, Map<K, D>> collector;
private GroupingBuilder(Collector<? super T, ?, Map<K, D>> collector) {
this.collector = collector;
}
public Collector<? super T, ?, Map<K, D>> get() {
return collector;
}
public <J> GroupingBuilder<T, Map<K, D>, J>
after(Function<? super T, ? extends J> classifier) {
return new GroupingBuilder<>(groupingBy(classifier, collector));
}
public static <T, D, K> GroupingBuilder<T, List<T>, K>
groupOn(Function<? super T, ? extends K> classifier) {
return new GroupingBuilder<>(groupingBy(classifier));
}
}
这个流畅构建器有什么问题?尝试使用它会使问题变得明显:
Collector<? super Car, ?, Map<Brand, Map<Color, List<Car>>>>
carGroupingCollector =
groupOn(Car::getColor).after(Car::getBrand).get()
如您所见,使用这个实用程序类是不直观的,因为分组函数必须相对于相应的嵌套分组级别以相反的顺序编写。如果您尝试重构这个流畅构建器以修复排序问题,您会发现不幸的是,Java 类型系统不允许您这样做。
通过更仔细地查看原生 Java API 及其设计决策背后的原因,您已经开始学习一些用于实现可读性 DSL 的模式和有用的技巧。在下一节中,您将继续研究开发有效 DSL 的技术。
10.3. 在 Java 中创建 DSL 的模式和技术
DSL 提供了一个友好、易读的 API 来处理特定的领域模型。因此,我们从这个部分开始定义一个简单的领域模型;然后我们讨论可以用来在它之上创建 DSL 的模式。
样例领域模型由三部分组成。第一部分是建模在特定市场上交易的股票的普通 Java Bean:
public class Stock {
private String symbol;
private String market;
public String getSymbol() {
return symbol;
}
public void setSymbol(String symbol) {
this.symbol = symbol;
}
public String getMarket() {
return market;
}
public void setMarket(String market) {
this.market = market;
}
}
第二件事是在给定价格下买卖一定数量的股票的交易:
public class Trade {
public enum Type { BUY, SELL }
private Type type;
private Stock stock;
private int quantity;
private double price;
public Type getType() {
return type;
}
public void setType(Type type) {
this.type = type;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public Stock getStock() {
return stock;
}
public void setStock(Stock stock) {
this.stock = stock;
}
public double getValue() {
return quantity * price;
}
}
最后一件事情是客户为结算一个或多个交易而下达的订单:
public class Order {
private String customer;
private List<Trade> trades = new ArrayList<>();
public void addTrade(Trade trade) {
trades.add(trade);
}
public String getCustomer() {
return customer;
}
public void setCustomer(String customer) {
this.customer = customer;
}
public double getValue() {
return trades.stream().mapToDouble(Trade::getValue).sum();
}
}
这个领域模型很简单。创建表示订单的对象很繁琐,例如。尝试为你的客户 BigBank 定义一个简单的订单,它包含两个交易,如列表 10.4 所示。
列表 10.4. 直接使用领域对象的 API 创建股票交易订单
Order order = new Order();
order.setCustomer("BigBank");
Trade trade1 = new Trade();
trade1.setType(Trade.Type.BUY);
Stock stock1 = new Stock();
stock1.setSymbol("IBM");
stock1.setMarket("NYSE");
trade1.setStock(stock1);
trade1.setPrice(125.00);
trade1.setQuantity(80);
order.addTrade(trade1);
Trade trade2 = new Trade();
trade2.setType(Trade.Type.BUY);
Stock stock2 = new Stock();
stock2.setSymbol("GOOGLE");
stock2.setMarket("NASDAQ");
trade2.setStock(stock2);
trade2.setPrice(375.00);
trade2.setQuantity(50);
order.addTrade(trade2);
这段代码的冗长性几乎无法接受;你不能期望非开发领域的专家一眼就能理解和验证它。你需要的是一个反映领域模型的领域特定语言(DSL),并允许以更直接、直观的方式对其进行操作。你可以采用各种方法来实现这一结果。在本节的其余部分,你将了解这些方法的优缺点。
10.3.1. 方法链
要探索的第一种 DSL 风格是最常见的之一。它允许你通过单一的方法调用链定义一个交易订单。以下列表展示了这种类型 DSL 的一个示例。
列表 10.5. 使用方法链创建股票交易订单
Order order = forCustomer( "BigBank" )
.buy( 80 )
.stock( "IBM" )
.on( "NYSE" )
.at( 125.00 )
.sell( 50 )
.stock( "GOOGLE" )
.on( "NASDAQ" )
.at( 375.00 )
.end();
这段代码看起来是一个很大的改进,不是吗?你的领域专家很可能能够轻松理解这段代码。但你是如何实现一个 DSL 来达到这个结果的?你需要一些构建器,通过流畅的 API 创建这个领域的对象。顶级构建器创建并包装一个订单,使其能够添加一个或多个交易,如下一列表所示。
列表 10.6. 提供方法链 DSL 的订单构建器
public class MethodChainingOrderBuilder {
public final Order order = new Order(); *1*
private MethodChainingOrderBuilder(String customer) {
order.setCustomer(customer);
}
public static MethodChainingOrderBuilder forCustomer(String customer) {
return new MethodChainingOrderBuilder(customer); *2*
}
public TradeBuilder buy(int quantity) {
return new TradeBuilder(this, Trade.Type.BUY, quantity); *3*
}
public TradeBuilder sell(int quantity) {
return new TradeBuilder(this, Trade.Type.SELL, quantity); *4*
}
public MethodChainingOrderBuilder addTrade(Trade trade) {
order.addTrade(trade); *5*
return this; *6*
}
public Order end() {
return order; *7*
}
}
-
1 由这个构建器包装的订单
-
2 一个静态工厂方法用于创建由给定客户放置的订单构建器
-
3 创建一个 TradeBuilder 来构建一个购买股票的交易
-
4 创建一个 TradeBuilder 来构建一个出售股票的交易
-
5 将交易添加到订单中
-
6 返回订单构建器本身,允许你流畅地创建和添加更多交易
-
7 终止订单的构建并返回它
订单构建器的buy()和sell()方法创建并返回另一个构建器,该构建器构建一个交易并将其添加到订单本身:
public class TradeBuilder {
private final MethodChainingOrderBuilder builder;
public final Trade trade = new Trade();
private TradeBuilder(MethodChainingOrderBuilder builder,
Trade.Type type, int quantity) {
this.builder = builder;
trade.setType( type );
trade.setQuantity( quantity );
}
public StockBuilder stock(String symbol) {
return new StockBuilder(builder, trade, symbol);
}
}
TradeBuilder的唯一公共方法用于创建另一个构建器,然后构建Stock类的实例:
public class StockBuilder {
private final MethodChainingOrderBuilder builder;
private final Trade trade;
private final Stock stock = new Stock();
private StockBuilder(MethodChainingOrderBuilder builder,
Trade trade, String symbol) {
this.builder = builder;
this.trade = trade;
stock.setSymbol(symbol);
}
public TradeBuilderWithStock on(String market) {
stock.setMarket(market);
trade.setStock(stock);
return new TradeBuilderWithStock(builder, trade);
}
}
StockBuilder有一个单一的方法on(),它指定了股票的市场,将股票添加到交易中,并返回最后一个构建器:
public class TradeBuilderWithStock {
private final MethodChainingOrderBuilder builder;
private final Trade trade;
public TradeBuilderWithStock(MethodChainingOrderBuilder builder,
Trade trade) {
this.builder = builder;
this.trade = trade;
}
public MethodChainingOrderBuilder at(double price) {
trade.setPrice(price);
return builder.addTrade(trade);
}
}
TradeBuilderWithStock 的这个公共方法设置了交易股票的单位价格,并返回原始订单构建器。正如你所看到的,这个方法允许你流畅地添加其他交易到订单中,直到调用 MethodChaining-OrderBuilder 的结束方法。选择多个构建器类——特别是两个不同的交易构建器——是为了迫使 DSL 的用户以预定的顺序调用其流畅 API 的方法,确保在用户开始创建下一个交易之前,交易已被正确配置。这种方法的其他优点是,用于设置订单的参数都在构建器的作用域内。这种方法最小化了静态方法的使用,并允许方法名称作为命名参数,从而进一步提高了这种 DSL 风格的可读性。最后,这种技术产生的流畅 DSL 具有尽可能少的语法噪声。
不幸的是,方法链的主要问题是实现构建器所需的冗长性。需要大量的粘合代码来混合顶层构建器和底层构建器。另一个明显的缺点是,你无法强制执行你用来强调领域对象嵌套层次结构的缩进约定。
在下一节中,你将研究第二种具有不同特性的 DSL 模式。
10.3.2. 使用嵌套函数
嵌套函数 DSL 模式的名称来源于它通过使用嵌套在其他函数中的函数来填充领域模型。下面的列表展示了这种方法的 DSL 风格。
列表 10.7. 使用嵌套函数创建股票交易订单
Order order = order("BigBank",
buy(80,
stock("IBM", on("NYSE")),
at(125.00)),
sell(50,
stock("GOOGLE", on("NASDAQ")),
at(375.00))
);
实现这种 DSL 风格所需的代码比你在第 10.3.1 节中学到的要紧凑得多。
下面的列表中的 NestedFunctionOrderBuilder 展示了向用户提供具有这种 DSL 风格的 API 是可能的。(在这个列表中,我们隐含地假设所有静态方法都已导入。)
列表 10.8. 提供嵌套函数 DSL 的订单构建器
public class NestedFunctionOrderBuilder {
public static Order order(String customer, Trade... trades) {
Order order = new Order(); *1*
order.setCustomer(customer);
Stream.of(trades).forEach(order::addTrade); *2*
return order;
}
public static Trade buy(int quantity, Stock stock, double price) {
return buildTrade(quantity, stock, price, Trade.Type.BUY); *3*
}
public static Trade sell(int quantity, Stock stock, double price) {
return buildTrade(quantity, stock, price, Trade.Type.SELL); *4*
}
private static Trade buildTrade(int quantity, Stock stock, double price,
Trade.Type buy) {
Trade trade = new Trade();
trade.setQuantity(quantity);
trade.setType(buy);
trade.setStock(stock);
trade.setPrice(price);
return trade;
}
public static double at(double price) { *5*
return price;
}
public static Stock stock(String symbol, String market) {
Stock stock = new Stock(); *6*
stock.setSymbol(symbol);
stock.setMarket(market);
return stock;
}
public static String on(String market) { *7*
return market;
}
}
-
1 为指定客户创建订单
-
2 将所有交易添加到订单中
-
3 创建一个购买股票的交易
-
4 创建一个出售股票的交易
-
5 一个用于定义交易股票单位价格的虚拟方法
-
6 创建交易股票
-
7 一个用于定义股票交易市场的虚拟方法
与方法链相比,这种技术的另一个优点是,通过不同函数的嵌套方式,可以直观地看到领域对象的层次结构(例如,在示例中,一个订单包含一个或多个交易,每个交易引用单个股票)。
不幸的是,这个模式也存在一些问题。您可能已经注意到,生成的 DSL 需要很多括号。此外,必须传递给静态方法的参数列表是严格预定的。如果您的领域对象有一些可选字段,您需要实现那些方法的多个重载版本,这样您就可以省略缺失的参数。最后,不同参数的含义是由它们的顺序而不是它们的名称定义的。您可以通过引入一些占位方法来减轻最后一个问题,就像您在NestedFunctionOrderBuilder中的at()和on()方法所做的那样,这些方法的唯一目的是阐明参数的作用。
我们之前向您展示的两个 DSL 模式不需要使用 lambda 表达式。在下一节中,我们将展示第三种技术,该技术利用了 Java 8 引入的功能特性。
10.3.3. 使用 lambda 表达式进行函数序列
下一个 DSL 模式使用 lambda 表达式定义的函数序列。在您的常规股票交易领域模型之上以这种方式实现 DSL 允许您定义一个订单,如列表 10.9 所示。
列表 10.9. 使用函数序列创建股票交易订单
Order order = order( o -> {
o.forCustomer( "BigBank" );
o.buy( t -> {
t.quantity( 80 );
t.price( 125.00 );
t.stock( s -> {
s.symbol( "IBM" );
s.market( "NYSE" );
} );
});
o.sell( t -> {
t.quantity( 50 );
t.price( 375.00 );
t.stock( s -> {
s.symbol( "GOOGLE" );
s.market( "NASDAQ" );
} );
});
} );
要实现这种方法,您需要开发几个接受 lambda 表达式的构建器,并通过执行它们来填充领域模型。这些构建器以与在 DSL 实现中使用方法链相同的方式保留要创建的对象的中间状态。如您在方法链模式中所做的那样,您有一个顶级构建器来创建订单,但这次,构建器接受Consumer对象作为参数,以便 DSL 的用户可以使用 lambda 表达式来实现它们。下一个列表显示了实现此方法所需的代码。
列表 10.10. 提供函数序列 DSL 的订单构建器
public class LambdaOrderBuilder {
private Order order = new Order(); *1*
public static Order order(Consumer<LambdaOrderBuilder> consumer) {
LambdaOrderBuilder builder = new LambdaOrderBuilder();
consumer.accept(builder); *2*
return builder.order; *3*
}
public void forCustomer(String customer) {
order.setCustomer(customer); *4*
}
public void buy(Consumer<TradeBuilder> consumer) {
trade(consumer, Trade.Type.BUY); *5*
}
public void sell(Consumer<TradeBuilder> consumer) {
trade(consumer, Trade.Type.SELL); *6*
}
private void trade(Consumer<TradeBuilder> consumer, Trade.Type type) {
TradeBuilder builder = new TradeBuilder();
builder.trade.setType(type);
consumer.accept(builder); *7*
order.addTrade(builder.trade); *8*
}
}
-
1 由该构建器包装的订单
-
2 执行传递给订单构建器的 lambda 表达式
-
3 通过执行 OrderBuilder 的 Consumer 返回填充的订单
-
4 设置下订单的客户
-
5 消费 TradeBuilder 以创建购买股票的交易
-
6 消费 TradeBuilder 以创建销售股票的交易
-
7 执行传递给 TradeBuilder 的 lambda 表达式
-
8 通过执行 TradeBuilder 的 Consumer 将交易添加到订单中
订单构建器的buy()和sell()方法接受两个Consumer<TradeBuilder>类型的 lambda 表达式。当执行时,这些方法将填充一个购买或销售交易,如下所示:
public class TradeBuilder {
private Trade trade = new Trade();
public void quantity(int quantity) {
trade.setQuantity( quantity );
}
public void price(double price) {
trade.setPrice( price );
}
public void stock(Consumer<StockBuilder> consumer) {
StockBuilder builder = new StockBuilder();
consumer.accept(builder);
trade.setStock(builder.stock);
}
}
最后,TradeBuilder接受一个第三构建器的Consumer,该构建器旨在定义交易股票:
public class StockBuilder {
private Stock stock = new Stock();
public void symbol(String symbol) {
stock.setSymbol( symbol );
}
public void market(String market) {
stock.setMarket( market );
}
}
这种模式结合了两种先前 DSL 风格的两个积极特点。像方法链模式一样,它允许以流畅的方式定义交易顺序。此外,类似于嵌套函数风格,它在不同 lambda 表达式的嵌套级别中保留了我们的领域对象的层次结构。
不幸的是,这种方法需要大量的设置代码,并且使用 DSL 本身也受到 Java 8 lambda 表达式语法的噪声影响。
在这三种 DSL 风格之间进行选择主要是一个口味问题。这也需要一些经验来找到最适合你想要创建领域语言的领域模型。此外,你可以在单个 DSL 中结合两种或更多这些风格,正如你在下一节中看到的那样。
10.3.4. 将所有内容组合在一起
如你所见,所有三种 DSL 模式都有优点和缺点,但没有什么阻止你在单个 DSL 中一起使用它们。你最终可能会开发出一个 DSL,通过它可以定义你的股票交易订单,如下面的列表所示。
列表 10.11. 通过使用多个 DSL 模式创建股票交易订单
Order order =
forCustomer( "BigBank", *1*
buy( t -> t.quantity( 80 ) *2*
.stock( "IBM" ) *3*
.on( "NYSE" )
.at( 125.00 )),
sell( t -> t.quantity( 50 )
.stock( "GOOGLE" )
.on( "NASDAQ" )
.at( 125.00 )) );
-
1 嵌套函数用于指定顶级订单的属性
-
2 使用单个 lambda 表达式创建单个交易
-
3 在填充交易对象的 lambda 表达式主体中进行方法链
在这个例子中,嵌套函数模式与 lambda 方法相结合。每个交易都是由 TradeBuilder 的 Consumer 创建的,该 TradeBuilder 由 lambda 表达式实现,如下面的列表所示。
列表 10.12. 提供混合多种风格的 DSL 的订单构建器
public class MixedBuilder {
public static Order forCustomer(String customer,
TradeBuilder... builders) {
Order order = new Order();
order.setCustomer(customer);
Stream.of(builders).forEach(b -> order.addTrade(b.trade));
return order;
}
public static TradeBuilder buy(Consumer<TradeBuilder> consumer) {
return buildTrade(consumer, Trade.Type.BUY);
}
public static TradeBuilder sell(Consumer<TradeBuilder> consumer) {
return buildTrade(consumer, Trade.Type.SELL);
}
private static TradeBuilder buildTrade(Consumer<TradeBuilder> consumer,
Trade.Type buy) {
TradeBuilder builder = new TradeBuilder();
builder.trade.setType(buy);
consumer.accept(builder);
return builder;
}
}
最后,辅助类 TradeBuilder 和它内部使用的 StockBuilder(实现如下所示)提供了一个实现方法链模式的流畅 API。在你做出这个选择之后,你可以通过 lambda 表达式的主体以最紧凑的方式编写填充交易的代码:
public class TradeBuilder {
private Trade trade = new Trade();
public TradeBuilder quantity(int quantity) {
trade.setQuantity(quantity);
return this;
}
public TradeBuilder at(double price) {
trade.setPrice(price);
return this;
}
public StockBuilder stock(String symbol) {
return new StockBuilder(this, trade, symbol);
}
}
public class StockBuilder {
private final TradeBuilder builder;
private final Trade trade;
private final Stock stock = new Stock();
private StockBuilder(TradeBuilder builder, Trade trade, String symbol){
this.builder = builder;
this.trade = trade;
stock.setSymbol(symbol);
}
public TradeBuilder on(String market) {
stock.setMarket(market);
trade.setStock(stock);
return builder;
}
}
列表 10.12 是一个例子,说明了本章讨论的三个 DSL 模式如何结合以实现可读的 DSL。这样做可以让你利用各种 DSL 风格的优点,但这种技术有一个小缺点:结果 DSL 看起来不如使用单一技术的 DSL 统一,因此这个 DSL 的用户可能需要更多时间来学习它。
到目前为止,你已经使用了 lambda 表达式,但正如 Comparator 和 Stream API 所示,使用方法引用可以进一步提高许多 DSL 的可读性。我们将在下一节通过一个使用方法引用在股票交易领域模型中的实际例子来证明这一点。
10.3.5. 在 DSL 中使用方法引用
在本节中,你尝试向你的股票交易领域模型添加另一个简单的功能。这个功能在订单的净价值中添加零个或多个以下税费后,计算订单的最终价值,如以下列表所示。
列表 10.13. 可以应用于订单净价值的税费
public class Tax {
public static double regional(double value) {
return value * 1.1;
}
public static double general(double value) {
return value * 1.3;
}
public static double surcharge(double value) {
return value * 1.05;
}
}
实现这样的税费计算器最简单的方法是使用一个静态方法,该方法接受订单以及每个可能应用的税费的一个布尔标志(列表 10.14)。
列表 10.14. 使用一组布尔标志应用订单净价值的税费
public static double calculate(Order order, boolean useRegional,
boolean useGeneral, boolean useSurcharge) {
double value = order.getValue();
if (useRegional) value = Tax.regional(value);
if (useGeneral) value = Tax.general(value);
if (useSurcharge) value = Tax.surcharge(value);
return value;
}
这样,就可以在应用地区税和附加费后,但未应用一般税的情况下,计算出订单的最终价值,如下所示:
double value = calculate(order, true, false, true);
这个实现的可读性问题很明显:很难记住正确的布尔变量的顺序,也很难理解哪些税费已经应用,哪些没有。解决这个问题的规范方法是实现一个TaxCalculator,它提供一个最小的领域特定语言(DSL),可以流畅地逐个设置布尔标志,如以下列表所示。
列表 10.15. 一个流畅定义要应用的税费的税费计算器
public class TaxCalculator {
private boolean useRegional;
private boolean useGeneral;
private boolean useSurcharge;
public TaxCalculator withTaxRegional() {
useRegional = true;
return this;
}
public TaxCalculator withTaxGeneral() {
useGeneral= true;
return this;
}
public TaxCalculator withTaxSurcharge() {
useSurcharge = true;
return this;
}
public double calculate(Order order) {
return calculate(order, useRegional, useGeneral, useSurcharge);
}
}
使用这个TaxCalculator可以清楚地表明你想要将地区税和附加费应用于订单的净价值:
double value = new TaxCalculator().withTaxRegional()
.withTaxSurcharge()
.calculate(order);
这个解决方案的主要问题是其冗长性。它无法扩展,因为你需要在你的领域中的每个税费都有一个布尔字段和方法。通过使用 Java 的功能特性,你可以以更紧凑、更灵活的方式实现相同的结果,在可读性方面。要了解如何实现,请参考以下列表中的重构TaxCalculator。
列表 10.16. 一个流畅组合要应用的税费函数的税费计算器
public class TaxCalculator {
public DoubleUnaryOperator taxFunction = d -> d; *1*
public TaxCalculator with(DoubleUnaryOperator f) {
taxFunction = taxFunction.andThen(f); *2*
return this; *3*
}
public double calculate(Order order) {
return taxFunction.applyAsDouble(order.getValue()); *4*
}
}
-
1 计算要应用于订单价值的所有税费的函数
-
2 获取新的税费计算函数,将当前函数与作为参数传递的函数组合
-
3 返回这个,允许流畅地连接更多的税费函数
-
4 通过将税费计算函数应用于订单的净价值来计算最终订单价值
使用这个解决方案,你只需要一个字段:一个函数,当应用于订单的净价值时,一次性添加通过TaxCalculator类配置的所有税费。这个函数的起始值是恒等函数。在这个点上,还没有添加任何税费,因此订单的最终价值与净价值相同。当通过with()方法添加新的税费时,这个税费与当前的税费计算函数组合,从而在单个函数中包含所有添加的税费。最后,当订单传递给calculate()方法时,将应用由各种配置的税费组合而成的税费计算函数到订单的净价值上。这个重构后的TaxCalculator可以使用如下方式:
double value = new TaxCalculator().with(Tax::regional)
.with(Tax::surcharge)
.calculate(order);
这个解决方案使用方法引用,易于阅读,并给出简洁的代码。它也很灵活,如果在Tax类中添加了新的税函数,你可以立即使用你的功能TaxCalculator而无需修改。
现在我们已经讨论了在 Java 8 及更高版本中实现 DSL 的各种技术,有趣的是研究这些策略是如何被广泛采用的 Java 工具和框架所使用的。
10.4. 实际应用中的 Java 8 DSL
在第 10.3 节中,你学习了三个用于在 Java 中开发 DSL 的有用模式,以及它们的优缺点。表 10.1 总结了我们迄今为止所讨论的内容。
表 10.1. DSL 模式及其优缺点
| 模式名称 | 优点 | 缺点 |
|---|---|---|
| 方法链 |
-
作为关键字参数的方法名
-
与可选参数配合良好
-
可以强制 DSL 用户以预定的顺序调用方法
-
最小或无静态方法的使用
-
最低可能的语法噪声
|
-
实现冗长
-
将构建器粘合在一起的粘合代码
-
仅通过缩进约定定义的领域对象层次结构
|
| 嵌套函数 |
|---|
-
实现冗长度较低
-
通过函数嵌套回显的领域对象层次结构
|
-
严重使用静态方法
-
参数由位置而不是名称定义
-
对于可选参数需要方法重载
|
| 使用 lambda 表达式进行函数序列 |
|---|
-
与可选参数配合良好
-
最小或无静态方法的使用
-
通过嵌套的 lambda 表达式回显的领域对象层次结构
-
没有构建器的粘合代码
|
-
实现冗长
-
DSL 中的 lambda 表达式产生的更多语法噪声
|
现在是时候通过分析这些模式在三个著名的 Java 库中的应用来巩固你迄今为止所学的知识了:一个 SQL 映射工具、一个行为驱动开发框架以及一个实现企业集成模式的工具。
10.4.1. jOOQ
SQL 是最常见和广泛使用的 DSL 之一。因此,有一个 Java 库提供了一个很好的 DSL 来编写和执行 SQL 查询并不令人惊讶。jOOQ 是一个内部 DSL,它直接在 Java 中实现了类型安全的 SQL 嵌入语言。源代码生成器会逆向工程数据库模式,这允许 Java 编译器检查复杂的 SQL 语句。这个逆向工程过程产生的信息可以用来导航你的数据库模式。作为一个简单的例子,以下 SQL 查询
SELECT * FROM BOOK
WHERE BOOK.PUBLISHED_IN = 2016
ORDER BY BOOK.TITLE
可以使用 jOOQ DSL 像这样编写:
create.selectFrom(BOOK)
.where(BOOK.PUBLISHED_IN.eq(2016))
.orderBy(BOOK.TITLE)
jOOQ DSL 的另一个优点是它可以与Stream API 结合使用。这个特性允许你通过单个流畅的语句在内存中操作 SQL 查询执行的结果,如下面的列表所示。
列表 10.17. 使用 jOOQ DSL 从数据库中选择书籍
Class.forName("org.h2.Driver");
try (Connection c =
getConnection("jdbc:h2:~/sql-goodies-with-mapping", "sa", "")) { *1*
DSL.using(c) *2*
.select(BOOK.AUTHOR, BOOK.TITLE) *3*
.where(BOOK.PUBLISHED_IN.eq(2016))
.orderBy(BOOK.TITLE)
.fetch() *4*
.stream() *5*
.collect(groupingBy( *6*
r -> r.getValue(BOOK.AUTHOR),
LinkedHashMap::new,
mapping(r -> r.getValue(BOOK.TITLE), toList())))
.forEach((author, titles) -> *7*
System.out.println(author + " is author of " + titles));
}
-
1 创建到 SQL 数据库的连接
-
2 使用刚刚创建的数据库连接开始 jOOQ SQL 语句
-
3 通过 jOOQ DSL 定义 SQL 语句
-
4 从数据库中获取数据;jOOQ 语句在此结束
-
5 使用 Stream API 开始操作从数据库获取的数据
-
6 按作者分组书籍
-
7 打印作者的名字以及他们所写的书籍
显然,实现 jOOQ DSL 所选择的主要 DSL 模式是方法链。实际上,这种模式的各种特性(允许可选参数和需要以预定的顺序调用某些方法)对于模仿良好形成的 SQL 查询的语法至关重要。这些特性,加上其较低的语法噪声,使得方法链模式非常适合 jOOQ 的需求。
10.4.2. Cucumber
行为驱动开发(BDD)是测试驱动开发的扩展,它使用一种简单的领域特定脚本语言,由结构化语句组成,描述各种业务场景。Cucumber,像其他 BDD 框架一样,将这些语句转换为可执行的测试用例。因此,应用这种开发技术产生的脚本既可以作为可运行的测试,也可以作为给定业务功能的验收标准。BDD 还将开发努力集中在交付优先级高、可验证的业务价值上,并通过使领域专家和程序员共享业务词汇来弥合他们之间的差距。
这些抽象概念可以通过一个使用 Cucumber 的实际例子来澄清,Cucumber 是一个 BDD 工具,它允许开发者用纯英语编写业务场景。以下是如何使用 Cucumber 的脚本语言定义一个简单的业务场景:
Feature: Buy stock
Scenario: Buy 10 IBM stocks
Given the price of a "IBM" stock is 125$
When I buy 10 "IBM"
Then the order value should be 1250$
Cucumber 使用分为三部分的符号:前提条件的定义(Given)、对测试中的域对象的实际调用(When),以及检查测试用例结果的断言(Then)。
定义测试场景的脚本是用一个具有有限关键词的外部 DSL 编写的,它允许你以自由格式编写句子。这些句子通过正则表达式进行匹配,捕获测试用例的变量,并将它们作为参数传递给实现测试本身的方法。从 第 10.3 节 开头的股票交易域模型开始,可以开发一个 Cucumber 测试用例,以检查股票交易订单的价值是否正确计算,如下一列表所示。
列表 10.18. 使用 Cucumber 注解实现测试场景
public class BuyStocksSteps {
private Map<String, Integer> stockUnitPrices = new HashMap<>();
private Order order = new Order();
@Given("^the price of a \"(.*?)\" stock is (\\d+)\\$$") *1*
public void setUnitPrice(String stockName, int unitPrice) {
stockUnitValues.put(stockName, unitPrice); *2*
}
@When("^I buy (\\d+) \"(.*?)\"$") *3*
public void buyStocks(int quantity, String stockName) {
Trade trade = new Trade(); *4*
trade.setType(Trade.Type.BUY);
Stock stock = new Stock();
stock.setSymbol(stockName);
trade.setStock(stock);
trade.setPrice(stockUnitPrices.get(stockName));
trade.setQuantity(quantity);
order.addTrade(trade);
}
@Then("^the order value should be (\\d+)\\$$") *5*
public void checkOrderValue(int expectedValue) {
assertEquals(expectedValue, order.getValue()); *6*
}
}
-
1 定义股票单价为此场景的前提条件
-
2 存储库存单价
-
3 定义对测试中的域模型要采取的操作
-
4 根据域模型进行填充
-
5 定义预期的场景结果
-
6 检查测试断言
Java 8 中 lambda 表达式的引入使得 Cucumber 能够开发一种替代语法,通过使用两个参数的方法来消除注解:注解值中之前包含的正则表达式和实现测试方法的 lambda 表达式。当你使用这种第二种类型的表示法时,你可以像这样重写测试场景:
public class BuyStocksSteps implements cucumber.api.java8.En {
private Map<String, Integer> stockUnitPrices = new HashMap<>();
private Order order = new Order();
public BuyStocksSteps() {
Given("^the price of a \"(.*?)\" stock is (\\d+)\\$$",
(String stockName, int unitPrice) -> {
stockUnitValues.put(stockName, unitPrice);
});
// ... When and Then lambdas omitted for brevity
}
}
这种替代语法具有明显的优势,即简洁。特别是,用匿名 lambda 表达式替换测试方法,消除了寻找有意义的名称(在测试场景中很少增加可读性)的负担。
Cucumber 的 DSL 非常简单,但它展示了如何有效地结合外部 DSL 和内部 DSL,并且(再次)表明 lambda 表达式允许你编写更紧凑、更易读的代码。
10.4.3. Spring Integration
Spring Integration 扩展了基于依赖注入的 Spring 编程模型,以支持众所周知的企业集成模式。1 Spring Integration 的主要目标是提供一个简单的模型来实现复杂的集成解决方案,并促进异步、消息驱动架构的采用。
¹
更多详情请参阅书籍:“企业集成模式:设计、构建和部署消息解决方案”(Addison-Wesley),作者 Gregor Hohpe 和 Bobby Woolf,2004 年。
Spring Integration 在基于 Spring 的应用程序中实现了轻量级远程通信、消息传递和调度。这些功能也通过一个丰富、流畅的 DSL 提供,该 DSL 不仅仅是建立在传统 Spring XML 配置文件之上的语法糖。
Spring Integration 实现了基于消息的应用程序所需的所有最常见模式,例如通道、端点、轮询器和通道拦截器。端点在 DSL 中以动词的形式表达,以提高可读性,并且通过将这些端点组合成一个或多个消息流来构建集成过程。下面的列表显示了 Spring Integration 如何通过一个简单但完整的示例来工作。
列表 10.19. 使用 Spring Integration DSL 配置 Spring Integration 流程
@Configuration
@EnableIntegration
public class MyConfiguration {
@Bean
public MessageSource<?> integerMessageSource() {
MethodInvokingMessageSource source =
new MethodInvokingMessageSource(); *1*
source.setObject(new AtomicInteger());
source.setMethodName("getAndIncrement");
return source;
}
@Bean
public DirectChannel inputChannel() {
return new DirectChannel(); *2*
}
@Bean
public IntegrationFlow myFlow() {
return IntegrationFlows *3*
.from(this.integerMessageSource(), *4*
c -> c.poller(Pollers.fixedRate(10))) *5*
.channel(this.inputChannel())
.filter((Integer p) -> p % 2 == 0) *6*
.transform(Object::toString) *7*
.channel(MessageChannels.queue("queueChannel")) *8*
.get(); *9*
}
}
-
1 创建一个新的 Message-Source,每次调用时增加一个 AtomicInteger
-
2 传递来自 MessageSource 的数据的通道
-
3 通过遵循方法链模式使用 builder 开始创建 IntegrationFlow
-
4 使用之前定义的 MessageSource 作为此 IntegrationFlow 的源
-
5 查询 MessageSource 以出队它所传递的数据
-
6 只过滤偶数
-
7 将从 MessageSource 检索到的整数转换为字符串
-
8 将 channel queueChannel 设置为 IntegrationFlow 的输出
-
9 终止 IntegrationFlow 的构建并返回它
在这里,myFlow()方法通过使用 Spring Integration DSL 构建IntegrationFlow。它使用IntegrationFlows类提供的流畅构建器,该类实现了方法链模式。在这种情况下,结果流程以固定速率轮询MessageSource,提供一系列Integer;过滤出偶数并将它们转换为String,最后以类似于原生 Java 8 Stream API 的风格将结果发送到输出通道。此 API 允许将消息发送到流中的任何组件,如果你知道其inputChannel名称。如果流程从直接通道开始,而不是MessageSource,则可以使用以下 lambda 表达式定义Integration-Flow:
@Bean
public IntegrationFlow myFlow() {
return flow -> flow.filter((Integer p) -> p % 2 == 0)
.transform(Object::toString)
.handle(System.out::println);
}
如你所见,在 Spring Integration DSL 中最广泛使用的模式是方法链。这种模式与IntegrationFlow构建器的主要目的非常契合:创建消息传递和数据转换的流程。然而,正如这个最后的例子所示,它还使用了 lambda 表达式进行函数序列,用于构建顶级对象(在某些情况下也用于内部更复杂的方法参数)。
摘要
-
DSL 的主要目的是填补开发者和领域专家之间的差距。编写实现应用程序业务逻辑的代码的人通常对程序将要使用的业务领域没有深入的了解。用非开发者可以理解的语言编写这种业务逻辑并不会使领域专家成为程序员,但它确实使他们能够阅读和验证逻辑。
-
DSL(领域特定语言)的两大主要类别是内部(在用于开发将使用 DSL 的应用程序的同一种语言中实现)和外部(使用专门设计的不同语言)。内部 DSL 需要较少的开发工作量,但语法受到宿主语言的限制。外部 DSL 提供了更高的灵活性,但实现起来更困难。
-
可以通过使用 JVM 上已经可用的另一种编程语言来开发多语言 DSL,例如 Scala 或 Groovy。这些语言通常比 Java 更灵活、更简洁。然而,将它们与 Java 集成需要更复杂的构建过程,并且它们与 Java 的互操作性可能远非无缝。
-
由于其冗长和严格的语法,Java 并不是开发内部 DSL 的理想编程语言,但 Java 8 中 lambda 表达式和方法引用的引入极大地改善了这种情况。
-
现代 Java 已经在其原生 API 中提供了小的 DSL。这些 DSL,如
Stream和Collectors类中的那些,对于排序、过滤、转换和分组数据集合非常有用和方便。 -
在 Java 中实现 DSL 所使用的三个主要模式是方法链、嵌套函数和函数序列。每种模式都有其优缺点,但你可以在单个 DSL 中结合所有三种模式,以利用所有三种技术。
-
许多 Java 框架和库允许通过领域特定语言(DSL)使用其功能。本章探讨了其中的三个:jOOQ,一个 SQL 映射工具;Cucumber,一个行为驱动开发(BDD)框架;以及 Spring Integration,一个实现企业集成模式的 Spring 扩展。
第四部分. 每日 Java
本书第四部分探讨了 Java 8 和 Java 9 中围绕使项目编码更容易和更可靠的多种新特性。我们首先介绍 Java 8 中引入的两个 API。
第十一章介绍了java.util.Optional类,它允许你设计更好的 API 并减少null指针异常。
第十二章探讨了日期和时间 API,它极大地改进了之前处理日期和时间的易出错 API。
然后我们解释了 Java 8 和 Java 9 在编写大型系统以及使它们能够进化的增强功能。
在第十三章中,你将了解默认方法是什么,如何以兼容的方式使用它们来进化 API,一些实用的使用模式,以及有效使用默认方法的规则。
第十四章是第二版新增的内容,探讨了 Java 模块系统——Java 9 中的一个主要增强功能,它使大型系统能够以文档化和可执行的方式模块化,而不是“仅仅是一堆杂乱无章的包。”
第十一章. 使用 Optional 作为 null 的更好替代方案
本章涵盖
-
null引用有什么问题,为什么你应该避免它们 -
从
null到Optional:以安全的方式重写你的领域模型 -
将可选用于实际工作:从你的代码中移除 null 检查
-
读取可能包含在可选中的值的多种方式
-
考虑到可能缺失的值重新思考编程
如果你作为 Java 开发者的一生中曾经遇到过NullPointerException,请举手。如果这个Exception是你最常遇到的,请继续举手。不幸的是,我们此刻无法看到你,但我们相信你很可能已经举起了手。我们也猜测你可能正在想:“是的,我同意。NullPointerExceptions 对于任何 Java 开发者,无论是新手还是专家来说都是一种痛苦。但我们对此无能为力,因为这是我们为了使用这样方便且可能不可避免的构造null引用而付出的代价。”这种感受在(命令式)编程世界中很常见;然而,这可能不是全部真相,更可能是一种有深厚历史根源的偏见。
英国计算机科学家托尼·霍尔(Tony Hoare)在 1965 年设计 ALGOL W 时引入了null引用,这是第一种具有堆分配记录的类型化编程语言,后来他说他这样做“仅仅因为它很容易实现。”尽管他的目标是“确保所有引用的使用都能绝对安全,由编译器自动进行检查”,但他决定对null引用做出例外,因为他认为这是建模值缺失最方便的方式。多年以后,他后悔了这个决定,称其为“我的十亿美元的错误。”我们都看到了它的效果。我们检查一个对象字段,可能是为了确定其值是否是两种预期形式之一,结果却发现我们检查的不是对象,而是一个null指针,它立即引发那个讨厌的NullPointerException。
在现实中,霍尔的声明可能低估了过去 50 年中数百万开发者因null引用导致的错误所造成的成本。确实,近几十年来创建的大多数语言,包括 Java,都是基于相同的设计决策构建的,可能是因为与旧语言兼容的原因,或者(更有可能的是),正如霍尔所说,“仅仅因为它很容易实现。”我们首先向你展示一个关于null问题的简单示例。
¹
值得注意的是,大多数类型化函数式语言,如 Haskell 和 ML,不包括在内。这些语言包括代数数据类型,允许简洁地表达数据类型,包括对特殊值(如
null)是否要在类型级别上显式指定的明确说明。
11.1. 如何建模值的缺失?
想象一下,你有一个如下嵌套的对象结构,用于表示一个拥有汽车并购买汽车保险的人。
列表 11.1. Person/Car/Insurance数据模型
public class Person {
private Car car;
public Car getCar() { return car; }
}
public class Car {
private Insurance insurance;
public Insurance getInsurance() { return insurance; }
}
public class Insurance {
private String name;
public String getName() { return name; }
}
以下代码有什么问题?
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
这段代码看起来相当合理,但许多人并不拥有汽车,那么调用getCar方法的结果是什么?一个常见的令人遗憾的做法是返回null引用来表示值的缺失(在这里,表示汽车的缺失)。因此,调用getInsurance方法返回了null引用的保险,这导致在运行时抛出NullPointerException,并停止程序进一步运行。但不仅如此。如果person是null呢?如果getInsurance方法也返回null呢?
11.1.1. 通过防御性检查减少NullPointerException
你能做些什么来避免遇到意外的NullPointerException?通常,你可以在必要时添加null检查(有时,在过度防御性编程的情况下,甚至在不必要的地方)并且通常以不同的风格。以下列表展示了防止NullPointerException的一个方法尝试。
列表 11.2. Null-安全尝试 1:深重的疑虑
public String getCarInsuranceName(Person person) {
if (person != null) { *1*
Car car = person.getCar();
if (car != null) { *1*
Insurance insurance = car.getInsurance();
if (insurance != null) { *1*
return insurance.getName();
}
}
}
return "Unknown";
}
- 1 每次对
null的检查都会增加调用链剩余部分的嵌套级别。
这个方法在每次解引用变量时都会进行 null 检查,如果在这个解引用链中遍历的任何变量是 null 值,则返回字符串 "Unknown"。这个规则的唯一例外是,你不会检查保险公司名称是否为 null,因为(就像任何其他公司一样)你 知道 它必须有一个名称。请注意,你之所以能够避免这个最后的检查,仅仅是因为你对业务领域的了解,但这个事实并没有反映在建模数据的 Java 类中。
我们将这个方法在 列表 11.2 中标记为“深疑虑”,因为它展示了一个反复出现的模式:每次你怀疑一个变量可能是 null 时,你都必须添加一个额外的嵌套 if 块,增加代码的缩进级别。这种技术显然扩展性不好,并损害了可读性,所以你可能想尝试另一种解决方案。尝试通过在下一个列表中展示的不同方法来避免这个问题。
列表 11.3. Null-安全尝试 2:退出点过多
public String getCarInsuranceName(Person person) {
if (person == null) { *1*
return "Unknown";
}
Car car = person.getCar();
if (car == null) { *1*
return "Unknown";
}
Insurance insurance = car.getInsurance();
if (insurance == null) { *1*
return "Unknown";
}
return insurance.getName();
}
- 1 每次对
null的检查都会增加一个额外的退出点。
在这次尝试中,你试图避免深层嵌套的 if 块,采用不同的策略:每次遇到 null 变量时,你返回字符串 "Unknown"。然而,这个解决方案也远非理想;现在方法有四个不同的退出点,这使得维护变得困难。更糟糕的是,在 null 的情况下要返回的默认值,即字符串 "Unknown",在三个地方被重复——(我们希望)没有拼写错误!(你当然可能想要将重复的字符串提取到一个常量中,以防止这个问题。)
此外,这个过程是容易出错的。如果你忘记检查某个属性是否可能是 null 会怎样?我们在本章中论证,使用 null 来表示值的缺失是错误的方法。你需要的是更好地建模值的存在和缺失的方法。
11.1.2. null 的问题
回顾我们到目前为止的讨论,Java 中 null 引用的使用既引起理论问题,也引起实际问题:
-
这是一个错误来源。
NullPointerException是 Java 中最常见的一种异常。 -
它膨胀了你的代码。 它通过使代码必须填充
null检查(这些检查通常是深层嵌套的)来降低可读性。 -
它是无意义的。 它没有任何语义意义,特别是在静态类型语言中,它代表了错误地建模值缺失的方式。
-
它违背了 Java 的哲学。 Java 总是除了一个情况外隐藏指针给开发者:
null指针。 -
它破坏了类型系统。
null不携带任何类型或其他信息,因此它可以分配给任何引用类型。这种情况是一个问题,因为当null被传播到系统的另一部分时,你不知道那个null初始时应该是什么。
为了为其他解决方案提供一些背景,在下一节中,我们将简要地看看其他编程语言能提供什么。
11.1.3. 其他语言中 null 的替代方案有哪些?
近年来,像 Groovy 这样的语言通过引入一个表示为?.的安全导航操作符来解决这个问题,以安全地导航可能为null的值。为了理解这个过程是如何工作的,考虑以下 Groovy 代码,它检索一个给定的人用来为汽车投保的保险公司的名称:
def carInsuranceName = person?.car?.insurance?.name
这个语句所做的事情应该是清晰的。一个人可能没有车,你通常会通过将Person对象的汽车引用赋值为null来模拟这种可能性。同样,一辆车可能没有被投保。Groovy 的安全导航操作符允许你安全地导航这些可能为null的引用,而不会通过调用链传播null引用,在链中的任何值是null的情况下返回null。
类似的功能曾提出并被废弃用于 Java 7。然而,我们似乎并不缺少 Java 中的安全导航操作符。所有 Java 开发者面对NullPointerException时的第一个冲动是快速修复它,通过添加一个if语句,在调用方法之前检查值是否不是null。如果你以这种方式解决这个问题,而不考虑它是否适合你的算法或数据模型在那种特定情况下呈现null值,你并没有修复一个错误,而是在隐藏它,使得下次被叫来工作的人(很可能是你下周或下个月)发现和修复它变得更加困难。你是在把脏东西扫到地毯下。Groovy 的 null 安全解引用操作符只是更大、更强大的扫帚,让你在不必过于担心其后果的情况下犯这个错误。
其他函数式语言,如 Haskell 和 Scala,持有不同的观点。Haskell 包含一个Maybe类型,它本质上封装了一个可选值。Maybe类型的值可以包含给定类型的值或无值。Haskell 没有null引用的概念。Scala 有一个类似的构造,称为Option[T],用于封装类型T的值的呈现或缺失,我们将在第二十章中讨论。然后你必须显式地检查值是否存在,这需要使用Option类型上的操作,这强制执行了“null检查”的概念。你不能再忘记检查null——因为检查是由类型系统强制执行的。
好吧,我们有点偏离主题了,所有这些都听起来相当抽象。你可能想知道 Java 8。Java 8 通过引入一个名为 java.util.Optional<T> 的新类来从这种可选值的想法中汲取灵感!在本章中,我们展示了使用此类来建模可能缺失的值而不是将 null 引用分配给它们的优点。我们还阐明了从 null 迁移到 Optional 需要你重新思考你在领域模型中处理可选值的方式。最后,我们探讨了这种新 Optional 类的功能,并提供了一些实际示例,展示了如何有效地使用它。最终,你将学习如何设计更好的 API,用户可以通过阅读方法的签名来判断是否可以期望一个可选值。
11.2. 引入 Optional 类
Java 8 引入了一个名为 java.util.Optional<T> 的新类,它受到了 Haskell 和 Scala 的启发。该类封装了一个可选值。例如,如果你知道一个人可能没有汽车,那么 Person 类内部的 car 变量不应该声明为 Car 类型,并在这个人没有汽车时分配一个 null 引用;相反,它应该声明为 Optional<Car> 类型,如图 11.1 所示。
图 11.1. 一个可选的 Car

当存在值时,Optional 类会将其包装起来。相反,值的缺失通过方法 Optional.empty() 返回的空 Optional 来建模。这个静态工厂方法返回 Optional 类的特殊单例实例。你可能想知道 null 引用和 Optional.empty() 之间的区别。从语义上看,它们可能被视为相同的东西,但在实践中,区别很大。尝试取消引用 null 总是会导致 NullPointerException,而 Optional.empty() 是一个有效的、可工作的 Optional 类型的对象,可以用有用的方式调用。你很快就会看到这一点。
使用 Optional 而不是 null 的一个重要、实用的语义区别在于,在前者的情况下,声明一个 Optional<Car> 类型的变量而不是 Car 类型,清楚地表明那里允许缺失的值。相反,始终使用 Car 类型,并可能将 null 引用分配给该类型的变量,意味着除了你对业务模型的知识之外,你没有其他帮助来理解 null 是否属于该给定变量的有效域。
考虑到这一点,你可以重新设计 列表 11.1 中的原始模型,如下所示使用 Optional 类。
列表 11.4. 通过使用 Optional 重新定义 Person/Car/Insurance 数据模型
public class Person {
private Optional<Car> car; *1*
public Optional<Car> getCar() { return car; }
}
public class Car {
private Optional<Insurance> insurance; *2*
public Optional<Insurance> getInsurance() { return insurance; }
}
public class Insurance {
private String name; *3*
public String getName() { return name; }
}
-
1 一个人可能没有汽车,因此你可以将此字段标记为可选。
-
2 汽车可能没有保险,因此你可以将此字段标记为可选。
-
3 保险公司必须有一个名称。
注意Optional类的使用如何丰富了你的模型的语义。一个人引用Optional<Car>,一辆车引用Optional<Insurance>,这在领域中明确表示一个人可能或可能不拥有一辆车,以及这辆车可能或可能不被保险。
同时,保险公司名称被声明为String类型而不是Optional<String>类型的事实表明,保险公司必须有一个名称。这样,你就可以确定在解引用保险公司名称时是否会得到NullPointer-Exception;你不需要添加null检查,因为这样做会隐藏问题而不是解决问题。保险公司必须有一个名称,所以如果你发现一个没有名称的保险公司,你必须找出你的数据中有什么问题,而不是添加一段代码来掩盖这种情况。一致地使用Optional值可以在计划中缺失的值和仅因为你的算法或数据中的问题而缺失的值之间创建一个清晰的区分。重要的是要注意,Optional类的意图并不是要替换每一个null引用。相反,它的目的是帮助你设计更易于理解的 API,这样通过阅读方法的签名,你可以知道是否期望一个可选值。你必须主动解包可选值来处理值的缺失。
11.3. 采用 Optional 的模式
到目前为止,一切顺利;你已经学会了如何在类型中使用可选来澄清你的领域模型,你也看到了这种方法相对于用null引用表示缺失值的优点。你现在如何使用可选?更具体地说,你如何使用一个被可选包裹的值?
11.3.1. 创建 Optional 对象
在使用Optional之前的第一步是学习如何创建可选对象!你可以通过几种方式创建它们。
空的 Optional
如前所述,你可以通过使用静态工厂方法Optional.empty获取一个空的可选对象:
Optional<Car> optCar = Optional.empty();
从非null值创建 Optional
你也可以使用静态工厂方法Optional.of从一个非null值创建一个可选对象:
Optional<Car> optCar = Optional.of(car);
如果car是null,会立即抛出NullPointerException(而不是在你尝试访问汽车的属性时得到潜在的错误)。
从null创建 Optional
最后,通过使用静态工厂方法Optional.ofNullable,你可以创建一个可能包含null值的Optional对象:
Optional<Car> optCar = Optional.ofNullable(car);
如果car是null,生成的Optional对象将是空的。
你可能会想象我们会继续研究如何从一个可选对象中获取值。get方法正是这样做的,我们稍后会详细讨论。但是,当可选对象为空时,get会抛出异常,所以以不严谨的方式使用它实际上会重新创建使用null时引起的所有维护问题。相反,我们首先看看使用可选值的方法,这些方法避免了显式的测试,并受到流上类似操作的启发。
11.3.2. 使用 map 从可选对象中提取和转换值
一个常见的模式是从对象中提取信息。例如,你可能想从一个保险公司中提取名称。在提取名称之前,你需要检查insurance是否为null,如下所示:
String name = null;
if(insurance != null){
name = insurance.getName();
}
Optional支持一个map方法来实现这种模式,其工作方式如下(从现在开始,我们使用列表 11.4 中提出的模型):
Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);
这种方法在概念上与你在第四章和第五章中看到的 Stream 的map方法相似。map操作将提供的函数应用于流中的每个元素。你也可以将Optional对象视为特定数据集合,最多包含一个元素。如果Optional包含一个值,则map方法传递的函数会转换该值。如果Optional为空,则不执行任何操作。图 11.2 说明了这种相似性,展示了当你将一个将正方形转换为三角形的函数传递给正方形流和正方形可选对象的map方法时会发生什么。
图 11.2. 比较Stream和Optional的map方法

这个想法看起来很有用,但你如何使用它来重写列表 11.1 中的代码?
public String getCarInsuranceName(Person person) {
return person.getCar().getInsurance().getName();
}
如何以安全的方式链式调用多个方法?
答案是使用Optional支持的另一个方法,称为flatMap。
11.3.3. 使用 flatMap 链式连接可选对象
因为你已经学会了如何使用map,你的第一个反应可能是使用map重写代码如下:
Optional<Person> optPerson = Optional.of(person);
Optional<String> name =
optPerson.map(Person::getCar)
.map(Car::getInsurance)
.map(Insurance::getName);
不幸的是,这段代码无法编译。为什么?变量optPerson的类型是Optional<Person>,所以调用map方法是完全可行的。但是getCar返回一个类型为Optional<Car>的对象(如列表 11.4 所示),这意味着map操作的结果是一个类型为Optional<Optional<Car>>的对象。因此,调用getInsurance是无效的,因为最外层的可选对象包含另一个可选对象作为其值,这当然不支持getInsurance方法。图 11.3 说明了你将得到的嵌套可选结构。
图 11.3. 二级可选对象

你该如何解决这个问题?再次,你可以看看你之前在流中使用过的模式:flatMap 方法。在流中,flatMap 方法接受一个函数作为参数,并返回另一个流。这个函数应用于流中的每个元素,结果产生一个流中的流。但 flatMap 有将每个生成的流替换为其内容的效应。换句话说,所有由该函数生成的单独的流都被合并或展平成一个单一的流。你在这里想要的类似的东西,但你想要展平一个两级的 Optional 到一个级别。
正如 图 11.2 对 map 方法所做的那样,图 11.4 阐述了 Stream 和 Optional 类的 flatMap 方法的相似性。
图 11.4. 比较 Stream 和 Optional 的 flatMap 方法

在这里,传递给流 flatMap 方法的函数将每个正方形转换成包含两个三角形的另一个流。然后简单 map 的结果是包含三个其他流的流,每个流包含两个三角形,但 flatMap 方法将这个两级的流展平成一个包含总共六个三角形的单一流。同样,传递给 Optional 的 flatMap 方法的函数将原始 Optional 中的正方形转换成一个包含三角形的 Optional。如果这个函数传递给 map 方法,结果将是一个包含另一个包含三角形的 Optional 的 Optional,但 flatMap 方法将这个两级的 Optional 展平成一个包含三角形的单一 Optional。
使用 Optional 查找汽车的保险公司名称
现在你已经了解了 Optional 的 map 和 flatMap 方法的理论,你就可以将它们付诸实践。在 列表 11.2 和 11.3 中做出的丑陋尝试可以通过使用 列表 11.4 中的基于 Optional 的数据模型来重写,如下所示。
列表 11.5. 使用 Optional 查找汽车的保险公司名称
public String getCarInsuranceName(Optional<Person> person) {
return person.flatMap(Person::getCar)
.flatMap(Car::getInsurance)
.map(Insurance::getName)
.orElse("Unknown"); *1*
}
- 1 如果结果
Optional为空,则提供一个默认值
将 列表 11.5 与前两种尝试进行比较,显示了在处理可能缺失的值时使用 Optional 的优势。这次,你可以用一个容易理解的表达式来获得你想要的结果,而不是通过条件分支来增加代码的复杂性。
在实现方面,首先请注意,你修改了 getCar-InsuranceName 方法的签名,如 列表 11.2 和 11.3 所示。我们明确指出,可能存在一种情况,即传递给此方法的 Person 不存在,例如当通过标识符从数据库中检索 Person 时,你想要模拟给定标识符在数据中不存在 Person 的可能性。你通过将方法参数的类型从 Person 更改为 Optional<Person> 来模拟这个额外需求。
再次强调,这种方法允许你通过类型系统明确表达出在领域模型知识中原本可能保持隐含的内容:语言的第一要务,即使是编程语言,也是沟通。将一个方法声明为接受一个可选参数或返回一个可选结果,这样就可以向你的同事以及所有未来的方法使用者表明,它可以接受一个空值或返回一个空值。
使用可选进行 Person/Car/Insurance 解引用链
从这个 Optional<Person> 开始,Person 中的 Car,Car 中的 Insurance,以及 Insurance 中包含的保险公司名称的 String,都通过本章前面引入的 map 和 flatMap 方法组合进行解引用。图 11.5 展示了这个操作流程。
图 11.5. 使用可选进行 Person/Car/Insurance 解引用链

在这里,你从包裹 Person 的可选开始,并对其调用 flatMap(Person::getCar)。正如我们所说的,你可以逻辑上认为这个调用分为两个步骤。在步骤 1 中,一个 Function 被应用于可选内的 Person 以进行转换。在这种情况下,Function 通过一个方法引用来调用该 Person 的 getCar 方法。因为该方法返回一个 Optional<Car>,所以可选内的 Person 被转换成该类型的一个实例,从而在 flatMap 操作中形成一个两级的可选,该可选被扁平化。从理论角度来看,你可以将这个扁平化操作视为组合两个嵌套可选的操作,如果至少有一个为空,则结果为空可选。在现实中,如果你在空可选上调用 flatMap,则没有任何变化,空可选按原样返回。相反,如果可选包裹了一个 Person,则传递给 flatMap 方法的 Function 被应用于该 Person。因为该 Function 应用产生的值已经是可选,所以 flatMap 方法可以按原样返回它。
第二步与第一步类似,将 Optional<Car> 转换为 Optional<Insurance>。第 3 步将 Optional<Insurance> 转换为 Optional<String>:因为 Insurance.getName() 方法返回一个 String。在这种情况下,不需要 flatMap。
在这一点上,如果这个调用链中的任何方法返回一个空的 Optional 或包含所需的保险公司名称,那么生成的 Optional 将是空的。你如何读取这个值?毕竟,你最终会得到一个可能包含或不包含保险公司名称的 Optional<String>。在 列表 11.5 中,我们使用了另一个名为 orElse 的方法,它会在 Optional 为空时提供一个默认值。许多方法提供了默认操作或解包 Optional。在下一节中,我们将详细探讨这些方法。
在领域模型中使用可选类型及其不可序列化的原因
在 列表 11.4 中,我们展示了如何在领域模型中使用 Optional 来标记允许缺失或保持未定义的特定类型的值。然而,Optional 类的设计者基于不同的假设和不同的使用场景来开发它。特别是,Java 语言架构师 Brian Goetz 明确表示,Optional 的目的是仅支持可选返回语法的习惯用法。
由于 Optional 类并非旨在用作字段类型,它没有实现 Serializable 接口。因此,在领域模型中使用 Optional 可能会破坏需要序列化模型才能工作的工具或框架的应用程序。尽管如此,我们相信我们已经向你展示了为什么在领域中使用 Optional 作为适当类型是一个好主意,尤其是在你需要遍历可能不存在对象的图时。或者,如果你需要一个可序列化的领域模型,我们建议你至少提供一个方法,允许以可选形式访问任何可能缺失的值,如下例所示:
public class Person {
private Car car;
public Optional<Car> getCarAsOptional() {
return Optional.ofNullable(car);
}
}
|

Java 9 中引入的 Optional 类的 stream() 方法允许你将包含值的 Optional 转换为只包含该值的 Stream,或者将一个空的 Optional 转换为同样空的 Stream。这种技术在某些常见情况下特别方便:当你有一个 Optional 的 Stream,需要将其转换成另一个只包含原始 Stream 中非空 Optional 的值的 Stream 时。在本节中,我们通过另一个实际例子来展示为什么你可能需要处理一个 Optional 的 Stream,以及如何执行这个操作。
列表 11.6 中的示例使用了在 列表 11.4 中定义的 Person/Car/Insurance 领域模型,假设你需要实现一个方法,该方法接收一个 List<Person> 并返回一个包含列表中拥有汽车的人所使用的所有不同保险公司名称的 Set<String>。
列表 11.6. 找出由人员列表使用的不同保险公司名称
public Set<String> getCarInsuranceNames(List<Person> persons) {
return persons.stream()
.map(Person::getCar) *1*
.map(optCar -> optCar.flatMap(Car::getInsurance)) *2*
.map(optIns -> optIns.map(Insurance::getName)) *3*
.flatMap(Optional::stream) *4*
.collect(toSet()); *5*
}
-
1 将人员列表转换为包含他们最终拥有的汽车的 Optional
Stream。 -
2 将每个 Optional
平铺映射到相应的 Optional 。 -
3 将每个 Optional
映射到包含相应名称的 Optional 。 -
4 将 Stream<Optional
> 转换为只包含现有名称的 Stream 。 -
5 将结果字符串收集到集合中,以获得唯一的值。
通常,操作 Stream 的元素会导致一系列长链的转换、过滤和其他操作,但这个案例有一个额外的复杂性,因为每个元素也被包装进了一个 Optional。记住,你通过使 getCar() 方法返回 Optional<Car> 而不是简单的 Car 来模拟一个人可能没有车的事实。因此,在第一次 map 转换之后,你得到了一个 Stream<Optional<Car>>。在这个时候,接下来的两个 map 转换允许你将每个 Optional<Car> 转换为 Optional<Insurance>,然后再将它们转换为 Optional<String>,就像你在 列表 11.5 中对一个单独的元素所做的那样,而不是一个 Stream。
在这三个转换结束时,你得到了一个 Stream<Optional<String>>,其中一些 Optional 可能是空的,因为一个人没有车或者车没有保险。Optional 的使用允许你在缺失值的情况下以完全无空安全的方式执行这些操作,但现在你面临的问题是,在将结果收集到集合之前,需要去除空的 Optional 并展开剩余的 Optional 中包含的值。当然,你可以通过一个 filter 后跟一个 map 来获得这个结果,如下所示:
Stream<Optional<String>> stream = ...
Set<String> result = stream.filter(Optional::isPresent)
.map(Optional::get)
.collect(toSet());
然而,正如列表 11.6 中预期的,使用Optional类的stream()方法可以在一个操作中实现相同的结果,而不是两个操作。实际上,此方法将每个Optional转换为一个包含零个或一个元素的Stream,具体取决于转换后的Optional是否为空。因此,对该方法的引用可以被视为一个从Stream的单个元素到另一个Stream的函数,然后传递给在原始Stream上调用的flatMap方法。正如你已经学到的,这样每个元素都会被转换为一个Stream,然后两级的Stream流被展平为单级。这个技巧允许你在一步中展开包含值的Optional并跳过空的Optional。
11.3.5. 默认操作和展开 Optional
在第 11.3.3 节中,你决定使用orElse方法来读取一个Optional值,该方法还允许你在空Optional的情况下返回一个默认值。Optional类提供了几个实例方法来读取Optional实例中包含的值:
-
get()是这些方法中最简单但也是最不安全的。如果存在包装的值,则返回该值,否则抛出NoSuchElementException。因此,除非你确定Optional包含一个值,否则几乎总是不建议使用此方法。此外,此方法在嵌套null检查方面并没有太大的改进。 -
orElse(T other)是列表 11.5 中使用的的方法,正如我们之前提到的,它允许你在Optional不包含值时提供一个默认值。 -
orElseGet(Supplier<? extends T> other)是orElse方法的懒加载对应物,因为只有在Optional不包含值时才会调用供应商。你应该在默认值创建耗时(为了提高效率)或你希望供应商仅在Optional为空时被调用时使用此方法(当使用orElseGet时至关重要)。 -
or(Supplier<? extends Optional<? extends T>> supplier)与之前的orElseGet方法类似,但它不会展开Optional内部的值(如果存在)。实际上,此方法(从 Java 9 引入)不执行任何操作,当Optional包含值时按原样返回Optional,但当原始Optional为空时,它将延迟提供一个不同的Optional。 -
orElseThrow(Supplier<? extends X> exceptionSupplier)与get方法类似,当optional为空时抛出异常,但它允许你选择要抛出的异常类型。 -
ifPresent(Consumer<? super T> consumer)允许你在存在值时执行作为参数给出的操作;否则,不执行任何操作。
Java 9 引入了一个额外的实例方法:
ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)。这与ifPresent不同,它接受一个Runnable,当Optional为空时执行基于空的操作。
11.3.6. 组合两个可选对象
现在假设您有一个方法,给定一个Person和一个Car,查询一些外部服务并实现一些复杂的业务逻辑来找到为该组合提供最便宜保单的保险公司:
public Insurance findCheapestInsurance(Person person, Car car) {
// queries services provided by the different insurance companies
// compare all those data
return cheapestCompany;
}
此外,假设您想开发一个 null 安全的版本的方法,该方法接受两个可选参数,并返回一个Optional<Insurance>,如果传递给它的值中至少有一个为空,则该值将为空。Optional类还提供了一个isPresent方法,当可选包含值时返回true,因此您的第一次尝试可能是如下实现此方法:
public Optional<Insurance> nullSafeFindCheapestInsurance(
Optional<Person> person, Optional<Car> car) {
if (person.isPresent() && car.isPresent()) {
return Optional.of(findCheapestInsurance(person.get(), car.get()));
} else {
return Optional.empty();
}
}
这种方法的优势在于,在其签名中清楚地表明传递给它的Person和Car值都可能缺失,因此它不能返回任何值。不幸的是,它的实现与如果方法以Person和Car作为参数,这两个参数都可能为null时编写的null检查非常相似。有没有一种更好的、更符合习惯的方法来实现这个方法,使用Optional类的功能?花几分钟时间通过练习 11.1,并尝试找到一个优雅的解决方案。
练习 11.1:在不展开的情况下组合两个可选对象
使用您在本节中学到的map和flatMap方法的组合,将前一个nullSafeFindCheapestInsurance()方法的实现重写为一个语句。
答案:
您可以在一个语句中实现该方法,并且不使用任何条件结构,如三元运算符,如下所示:
public Optional<Insurance> nullSafeFindCheapestInsurance(
Optional<Person> person, Optional<Car> car) {
return person.flatMap(p -> car.map(c -> findCheapestInsurance(p, c)));
}
在这里,您在第一个可选对象上调用flatMap,所以如果这个可选对象为空,传递给它的 lambda 表达式将不会执行,并且这个调用将返回一个空的可选对象。相反,如果存在人,flatMap将其用作Function的输入,该Function返回一个Optional<Insurance>,这是flatMap方法所要求的。这个函数的主体在第二个可选对象上调用map,所以如果它不包含任何Car,则Function返回一个空的可选对象,整个nullSafeFindCheapestInsurance方法也是如此。最后,如果Person和Car都存在,传递给map方法的 lambda 表达式可以安全地调用原始的findCheapestInsurance方法。
Optional类和Stream接口之间的类比不仅限于map和flatMap方法。第三个方法filter在两个类上都有类似的行为,我们将在下一节中探讨它。
11.3.7. 使用 filter 拒绝某些值
通常,您需要在一个对象上调用一个方法来检查某个属性。例如,您可能需要检查保险公司的名称是否等于 CambridgeInsurance。为了安全地这样做,首先检查指向 Insurance 对象的引用是否为 null,然后调用 getName 方法,如下所示:
Insurance insurance = ...;
if(insurance != null && "CambridgeInsurance".equals(insurance.getName())){
System.out.println("ok");
}
您可以使用 Optional 对象上的 filter 方法重写此模式,如下所示:
Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance ->
"CambridgeInsurance".equals(insurance.getName()))
.ifPresent(x -> System.out.println("ok"));
filter 方法接受一个谓词作为参数。如果 Optional 对象中存在值,并且该值与谓词匹配,则 filter 方法返回该值;否则,它返回一个空的 Optional 对象。如果您还记得,您可以将 Optional 视为一个最多包含一个元素的流,那么这个方法的行为应该是清晰的。如果 Optional 已经为空,则它没有任何效果;否则,它将谓词应用于 Optional 中包含的值。如果此应用返回 true,则 Optional 保持不变;否则,值被过滤掉,留下空的 Optional。您可以通过完成练习 11.2 来测试您对 filter 方法工作的理解。
练习 11.2:过滤可选值
假设您的 Person/Car/Insurance 模型的 Person 类也有一个 getAge 方法来访问人员的年龄,通过使用以下签名修改 代码列表 11.5 中的 getCarInsuranceName 方法:
public String getCarInsuranceName(Optional<Person> person, int minAge)
以便仅在人员年龄大于或等于 minAge 参数时返回保险公司名称。
答案:
您可以通过将此条件编码在传递给 filter 方法的谓词中,来过滤 Optional<Person>,以删除任何年龄不足 minAge 参数的包含人员:
public String getCarInsuranceName(Optional<Person> person, int minAge) {
return person.filter(p -> p.getAge() >= minAge)
.flatMap(Person::getCar)
.flatMap(Car::getInsurance)
.map(Insurance::getName)
.orElse("Unknown");
}
在下一节中,我们将研究 Optional 类的其余功能,并提供更多实际示例,说明您可以使用各种技术重新实现您编写的代码以管理缺失值。
表 11.1 总结了 Optional 类的方法。
表 11.1. Optional 类的方法
| 方法 | 描述 |
|---|---|
| empty | 返回一个空的 Optional 实例 |
| filter | 如果存在值且与给定的谓词匹配,则返回此 Optional;否则,返回空的 |
| flatMap | 如果存在值,则返回应用提供的映射函数后的 Optional;否则,返回空的 Optional |
| get | 如果存在,则返回此 Optional 包装的值;否则,抛出 NoSuchElementException |
| ifPresent | 如果存在值,则使用指定的消费者调用该值;否则,不执行任何操作 |
| ifPresentOrElse | 如果存在值,则使用该值作为输入执行操作;否则,使用无输入执行不同的操作 |
| isPresent | 如果存在值,则返回 true;否则,返回 false |
| map | 如果存在值,则应用提供的映射函数到它上 |
| of | 返回一个包装给定值的 Optional,如果此值为 null 则抛出 NullPointer-Exception |
| ofNullable | 返回一个包装给定值的 Optional,如果此值为 null 则返回空的 Optional |
| or | 如果值存在,则返回相同的 Optional;否则,返回由供应函数产生的另一个 Optional |
| orElse | 如果存在值,则返回该值;否则,返回给定的默认值 |
| orElseGet | 如果存在值,则返回该值;否则,返回由给定的 Supplier 提供的值 |
| orElseThrow | 如果存在值,则返回该值;否则,抛出由给定的 Supplier 创建的异常 |
| stream | 如果存在值,则返回只包含该值的 Stream;否则,返回一个空的 Stream |
11.4. 使用 Optional 的实用示例
正如你所学的,有效使用新的Optional类意味着对如何处理可能缺失的值的完全重新思考。这种重新思考不仅涉及你编写的代码,而且还涉及(可能甚至更重要)你与原生 Java API 的交互。
事实上,我们相信,如果当时在开发这些 API 时就有Optional类,那么其中许多 API 可能会被编写得不同。出于向后兼容性的原因,旧的 Java API 不能被修改以正确使用 optionals,但并非一切都已失去。你可以通过向你的代码中添加小的实用方法来修复,或者至少解决这个问题,这些方法允许你从 optionals 的力量中受益。你可以通过几个实际示例看到如何做到这一点。
11.4.1. 将可能为 null 的值包装在 Optional 中
一个现有的 Java API 几乎总是返回null来表示所需值不存在或由于某些原因获取它的计算失败。例如,Map的get方法在没有为请求的键包含映射时返回null作为其值。但是,由于我们之前列出的原因,在这种情况下的大多数情况下,你更希望这些方法返回一个 optional。你不能修改这些方法的签名,但你很容易用 optional 包装它们返回的值。继续使用Map示例,假设你有一个Map<String, Object>,通过以下方式访问由key索引的值
Object value = map.get("key");
如果与String "key"关联的map中没有值,则返回null。你可以通过将map返回的值包装在 optional 中来改进这样的代码。你可以添加一个丑陋的if-then-else来增加代码复杂性,或者你可以使用我们之前讨论过的Optional.ofNullable方法:
Optional<Object> value = Optional.ofNullable(map.get("key"));
你可以在每次想要安全地将可能为null的值转换为 optional 时使用此方法。
11.4.2. 异常与 Optional 的比较
抛出异常是 Java API 中在无法提供值时返回 null 的另一种常见替代方案。一个典型的例子是 Integer.parseInt(String) 静态方法提供的将 String 转换为 int 的操作。在这种情况下,如果 String 不包含可解析的整数,则此方法会抛出 NumberFormatException。再次强调,最终效果是代码在 String 不表示整数时发出无效参数的信号,唯一的区别是这次您必须使用 try/catch 块而不是使用 if 条件来控制值是否不是 null。
您还可以使用空的可选值来模拟由不可转换的 String 引起的无效值,因此您更倾向于让 parseInt 返回一个可选值。您不能更改原始的 Java 方法,但没有任何东西阻止您实现一个微小的实用方法,将其包装起来,并按需返回一个可选值,如下面的列表所示。
列表 11.7. 将 String 转换为 Integer 并返回一个可选值
public static Optional<Integer> stringToInt(String s) {
try {
return Optional.of(Integer.parseInt(s)); *1*
} catch (NumberFormatException e) {
return Optional.empty(); *2*
}
}
-
1 如果字符串可以被转换为整数,则返回包含它的可选值。
-
2 否则,返回一个空的可选值。
我们的建议是将几个类似的方法收集到一个实用类中,您可以将其称为 OptionalUtility。从那时起,您将始终可以使用 OptionalUtility.stringToInt 方法将 String 转换为 Optional<Integer>。您可以忘记您在其中封装了丑陋的 try/catch 逻辑。
11.4.3. 原始可选值及其不应使用的原因
注意,与流一样,可选值也有其原始对应物——OptionalInt、Optional-Long 和 OptionalDouble——因此 列表 11.7 中的方法可以返回 Optional-Int 而不是 Optional<Integer>。在 第五章 中,我们出于性能原因鼓励使用原始流(特别是当它们可能包含大量元素时),但鉴于 Optional 最多只能有一个值,这种理由在这里不适用。
我们不鼓励使用原始的可选值,因为它们缺少 map、flatMap 和 filter 方法,这些方法(正如您在 第 11.2 节 中所看到的)是 Optional 类最有用的方法。此外,就像流一样,可选值不能与其原始对应物组合,因此如果 列表 11.7 中的方法返回 OptionalInt,您就不能将其作为方法引用传递给另一个可选值的 flatMap 方法。
11.4.4. 整合所有内容
在本节中,我们展示了我们迄今为止所介绍的 Optional 类的方法如何在一个更有说服力的用例中一起使用。假设您有一些 Properties,它们作为配置参数传递给您的程序。为了本例的目的,以及测试您将要开发的代码,请按照以下方式创建一些示例 Properties:
Properties props = new Properties();
props.setProperty("a", "5");
props.setProperty("b", "true");
props.setProperty("c", "-3");
假设您的程序需要从这些 Properties 中读取一个值并将其解释为秒数。因为持续时间必须是一个正数(>0),您将需要一个具有以下签名的 Properties:
public int readDuration(Properties props, String name)
因此,当给定属性的值为表示正整数的 String 时,该方法返回该整数,但在所有其他情况下返回零。为了明确这一要求,使用几个 JUnit 断言对其进行形式化:
assertEquals(5, readDuration(param, "a"));
assertEquals(0, readDuration(param, "b"));
assertEquals(0, readDuration(param, "c"));
assertEquals(0, readDuration(param, "d"));
这些断言反映了原始要求:readDuration 方法对于属性 "a" 返回 5,因为该属性的值是一个可以转换为正数的 String,对于 "b" 返回 0,因为它不是数字,对于 "c" 返回 0,因为它是数字但为负数,对于 "d" 返回 0,因为不存在具有该名称的属性。尝试实现满足此要求的方法,如下一个列表所示。
列表 11.8. 命令式地从属性中读取持续时间
public int readDuration(Properties props, String name) {
String value = props.getProperty(name);
if (value != null) { *1*
try {
int i = Integer.parseInt(value); *2*
if (i > 0) { *3*
return i;
}
} catch (NumberFormatException nfe) { }
}
return 0; *4*
}
-
1 确保存在具有所需名称的属性。
-
2 尝试将字符串属性转换为数字。
-
3 检查生成的数字是否为正数。
-
4 如果任何条件失败,则返回 0。
如您所预期的那样,生成的实现是复杂的且不可读的,它以 if 语句和 try/catch 块的形式呈现了多个嵌套条件。花几分钟时间在 11.3 号测验中找出您如何使用本章学到的知识达到相同的结果。
注意使用 optionals 和 streams 的常见风格;两者都让人联想到数据库查询,其中多个操作被链接在一起。
测验 11.3:使用 Optional 从属性中读取持续时间
使用 Optional 类的功能和 列表 11.7 中的实用方法,尝试使用单个流畅语句重新实现 列表 11.8 中的命令式方法。
答案:
因为 Properties.getProperty(String) 方法返回的值在所需的属性不存在时为 null,所以使用 ofNullable 工厂方法将此值转换为 Optional 是方便的。然后,您可以将 Optional<String> 转换为 Optional<Integer>,将其 flatMap 方法传递到在 列表 11.7 中开发的 OptionalUtility.stringToInt 方法。最后,您可以轻松过滤掉负数。这样,如果这些操作中的任何一个返回一个空的 Optional,则方法返回传递给 orElse 方法的默认值 0;否则,它返回 Optional 中包含的正整数。此描述的实现如下:
public int readDuration(Properties props, String name) {
return Optional.ofNullable(props.getProperty(name))
.flatMap(OptionalUtility::stringToInt)
.filter(i -> i > 0)
.orElse(0);
}
总结
-
null引用在编程语言中历史上被引入,用于表示值的缺失。 -
Java 8 引入了
java.util.Optional<T>类来表示值的呈现或缺失。 -
您可以使用静态工厂方法
Optional.empty、Optional.of和Optional.ofNullable创建Optional对象。 -
Optional类支持许多方法——例如map、flatMap和filter——这些方法在概念上与流的方法相似。 -
使用
Optional强迫您主动解包可选对象以处理值的缺失;因此,您保护了您的代码免受意外的null指针异常。 -
使用
Optional可以帮助您设计更好的 API,用户可以通过阅读方法的签名来判断是否期望一个可选值。
第十二章。新的日期和时间 API
本章涵盖
-
为什么我们需要一个新的日期和时间库,这个库在 Java 8 中引入
-
为人类和机器表示日期和时间
-
定义一段时间
-
操作、格式化和解析日期
-
处理不同的时区和日历
Java API 包含许多有用的组件,可以帮助您构建复杂的应用程序。不幸的是,Java API 并非总是完美的。我们相信,大多数经验丰富的 Java 开发者都会同意,在 Java 8 之前,日期和时间支持远非理想。不过,别担心;Java 8 引入了一个全新的日期和时间 API 来解决这个问题。
在 Java 1.0 中,对日期和时间的唯一支持是 java.util.Date 类。尽管它的名字叫日期,但这个类并不代表一个日期,而是一个以毫秒精度的时间点。更糟糕的是,一些模糊的设计决策,如它的偏移选择,损害了这个类的可用性:年份从 1900 年开始,而月份从索引 0 开始。如果你想表示 Java 9 的发布日期,即 2017 年 9 月 21 日,你必须创建一个 Date 实例,如下所示:
Date date = new Date(117, 8, 21);
打印这个日期对作者来说:
Thu Sep 21 00:00:00 CET 2017
这不是很直观,对吧?此外,即使是 Date 类的 toString 方法返回的 String 也可能相当误导。它还包括 JVM 的默认时区 CET,在我们的情况下是中欧时间。实际上,Date 类本身只是插入 JVM 默认时区!
当 Java 1.0 发布时,Date 类的问题和局限性立即变得明显,但也很清楚,如果不破坏其向后兼容性,这些问题是无法修复的。因此,在 Java 1.1 中,Date 类的许多方法都被弃用,并被替代的 java.util.Calendar 类所取代。不幸的是,Calendar 类也存在类似的问题和设计缺陷,这会导致容易出错的代码。月份也从索引 0 开始。(至少 Calendar 去掉了年份的 1900 偏移。)更糟糕的是,Date 和 Calendar 类的存在增加了开发者之间的混淆。(你应该使用哪一个?)此外,像 DateFormat 这样的功能,用于以语言无关的方式格式化和解析日期或时间,只能与 Date 类一起使用。
DateFormat 带有自己的问题集。例如,它不是线程安全的,这意味着如果有两个线程同时尝试使用相同的格式化器解析日期,你可能会收到不可预测的结果。
最后,Date 和 Calendar 都是可变类。将 2017 年 9 月 21 日修改为 10 月 25 日意味着什么?这个设计选择可能会导致维护噩梦,正如你将在第十八章(关于函数式编程)中更详细地了解的那样。
结果是,所有这些缺陷和不一致性都鼓励了使用第三方日期和时间库,例如 Joda-Time。出于这些原因,Oracle 决定在原生 Java API 中提供高质量的日期和时间支持。因此,Java 8 在 java.time 包中集成了许多 Joda-Time 功能。
在本章中,我们探讨了新日期和时间 API 引入的功能。我们首先从基本用例开始,例如创建既适合人类使用也适合机器使用的时间和日期。然后我们逐步探索新日期和时间 API 的更高级应用,例如操作、解析和打印日期时间对象,以及处理不同的时区和替代日历。
12.1. LocalDate、LocalTime、LocalDateTime、Instant、Duration 和 Period
我们首先探索如何创建简单的日期和间隔。java.time 包包含许多新类来帮助你:LocalDate、LocalTime、LocalDateTime、Instant、Duration 和 Period。
12.1.1. 使用 LocalDate 和 LocalTime
当你开始使用新的日期和时间 API 时,LocalDate 类可能是你遇到的第一个类。这个类的实例是一个不可变对象,代表一个没有一天中的时间的纯日期。特别是,它不携带任何时区信息。
你可以通过使用 of 静态工厂方法创建一个 LocalDate 实例。LocalDate 实例提供了许多方法来读取其最常用的值(年、月、星期几等),如下所示。
列表 12.1. 创建 LocalDate 并读取其值
LocalDate date = LocalDate.of(2017, 9, 21); *1*
int year = date.getYear(); *2*
Month month = date.getMonth(); *3*
int day = date.getDayOfMonth(); *4*
DayOfWeek dow = date.getDayOfWeek(); *5*
int len = date.lengthOfMonth(); *6*
boolean leap = date.isLeapYear(); *7*
-
1 2017-09-21
-
2 2017
-
3 九月
-
4 21
-
5 星期四
-
6 30 (九月的天数)
-
7 false (非闰年)
你也可以通过使用 now 工厂方法从系统时钟获取当前日期:
LocalDate today = LocalDate.now();
本章剩余部分中我们调查的所有其他日期时间类都提供了一个类似的工厂方法。你还可以通过传递一个 TemporalField 到 get 方法来访问相同的信息。TemporalField 是一个定义如何访问时间对象特定字段值的接口。ChronoField 枚举实现了这个接口,因此你可以方便地使用该枚举的一个元素与 get 方法一起使用,如下所示。
列表 12.2. 使用 TemporalField 读取 LocalDate 值
int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoField.DAY_OF_MONTH);
您可以使用内置的 getYear()、getMonthValue() 和 getDayOfMonth() 方法以更易读的形式访问信息,如下所示:
int year = date.getYear();
int month = date.getMonthValue();
int day = date.getDayOfMonth();
同样,一天中的时间,如 13:45:20,由 LocalTime 类表示。您可以通过使用名为 of 的两个重载的静态工厂方法来创建 LocalTime 的实例。第一个接受小时和分钟,第二个也接受秒。与 LocalDate 类一样,LocalTime 类提供了一些获取器方法来访问其值,如下面的列表所示。
列表 12.3. 创建 LocalTime 并读取其值
LocalTime time = LocalTime.of(13, 45, 20); *1*
int hour = time.getHour(); *2*
int minute = time.getMinute(); *3*
int second = time.getSecond(); *4*
-
1 13:45:20
-
2 13
-
3 45
-
4 20
您可以通过解析表示它们的字符串来创建 LocalDate 和 LocalTime。为了完成这个任务,请使用它们的 parse 静态方法:
LocalDate date = LocalDate.parse("2017-09-21");
LocalTime time = LocalTime.parse("13:45:20");
可以将 DateTimeFormatter 传递给 parse 方法。这个类的实例指定了如何格式化日期和/或时间对象。它旨在替代我们之前提到的旧的 java.util.DateFormat。我们将在 第 12.2.2 节 中更详细地展示如何使用 DateTimeFormatter。此外,请注意,这两个 parse 方法都会抛出 DateTimeParseException,它扩展了 RuntimeException,如果字符串参数不能解析为有效的 LocalDate 或 LocalTime。
12.1.2. 组合日期和时间
被称为 LocalDateTime 的组合类将 LocalDate 和 LocalTime 配对。它表示一个没有时区的日期和时间,可以直接创建或通过组合日期和时间来创建,如下面的列表所示。
列表 12.4. 直接创建 LocalDateTime 或通过组合日期和时间
// 2017-09-21T13:45:20
LocalDateTime dt1 = LocalDateTime.of(2017, Month.SEPTEMBER, 21, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13, 45, 20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);
注意,可以通过将时间传递给 LocalDate 或日期传递给 LocalTime 来创建 LocalDateTime,分别使用它们的 atTime 或 atDate 方法。您还可以通过使用 toLocalDate 和 toLocalTime 方法从 LocalDateTime 中提取 LocalDate 或 LocalTime 组件:
LocalDate date1 = dt1.toLocalDate(); *1*
LocalTime time1 = dt1.toLocalTime(); *2*
-
1 2017-09-21
-
2 13:45:20
12.1.3. Instant:机器的日期和时间
作为人类,我们习惯于用周、天、小时和分钟来思考日期和时间。然而,这种表示方式对计算机来说并不容易处理。从机器的角度来看,最自然的格式是表示连续时间线上一个点的单一大数。这种方法被新的 java.time.Instant 类所采用,它表示自 Unix 纪元时间以来经过的秒数,按照惯例设置为 1970 年 1 月 1 日午夜 UTC。
您可以通过传递秒数给其ofEpochSecond静态工厂方法来创建此类的实例。此外,Instant类支持纳秒精度。ofEpochSecond静态工厂方法的补充重载版本接受一个纳秒调整参数,该参数是对传递的秒数的调整。这个重载版本调整纳秒参数,确保存储的纳秒分数在 0 到 999,999,999 之间。因此,以下对ofEpochSecond工厂方法的调用返回完全相同的Instant:
Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
Instant.ofEpochSecond(2, 1_000_000_000); *1*
Instant.ofEpochSecond(4, -1_000_000_000); *2*
-
1 在 2 秒后 1 亿纳秒(1 秒)
-
2 在 4 秒前 1 亿纳秒(1 秒)
如您已经看到,对于LocalDate和其他可读日期时间类,Instant类支持另一个名为now的静态工厂方法,它允许您捕获当前时刻的时间戳。重要的是要强调,Instant仅适用于机器使用。它由若干秒和纳秒组成。因此,它不提供处理对人类有意义的任何时间单位的任何能力。例如,以下语句
int day = Instant.now().get(ChronoField.DAY_OF_MONTH);
会抛出如下异常:
java.time.temporal.UnsupportedTemporalTypeException: Unsupported field:
DayOfMonth
但您可以使用Duration和Period类来处理Instant,我们将在下一节中探讨。
12.1.4. 定义一个 Duration 或 Period
您迄今为止看到的所有类都实现了Temporal接口,该接口定义了如何读取和操作表示通用时间点的对象的值。我们已经向您展示了创建不同Temporal实例的几种方法。下一步自然的步骤是创建两个时间对象之间的持续时间。Duration类的between静态工厂方法正好用于此目的。您可以根据以下方式创建两个LocalTime、两个LocalDateTime或两个Instant之间的持续时间:
Duration d1 = Duration.between(time1, time2);
Duration d1 = Duration.between(dateTime1, dateTime2);
Duration d2 = Duration.between(instant1, instant2);
由于LocalDateTime和Instant是为不同的目的而设计的,一个用于人类,另一个用于机器,因此不允许将它们混合使用。如果您尝试在这两者之间创建持续时间,您将只能获得DateTimeException。此外,因为Duration类用于表示以秒和最终纳秒为单位的时间量,因此您不能将LocalDate传递给between方法。
当您需要用年、月和日来表示时间量时,您可以使用Period类。您可以使用该类的between工厂方法找出两个LocalDate之间的差异:
Period tenDays = Period.between(LocalDate.of(2017, 9, 11),
LocalDate.of(2017, 9, 21));
最后,Duration和Period类还有其他方便的工厂方法,可以直接创建它们的实例,而无需将它们定义为两个时间对象的差,如下所示。
列表 12.5. 创建Duration和Period
Duration threeMinutes = Duration.ofMinutes(3);
Duration threeMinutes = Duration.of(3, ChronoUnit.MINUTES);
Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);
Duration和Period类共享许多类似的方法,表 12.1 中列出了这些方法。
表 12.1. 表示时间间隔的日期时间类的常用方法
| Method | Static | 描述 |
|---|---|---|
| between | Yes | 在两个时间点之间创建时间间隔 |
| from | Yes | 从时间单位创建时间间隔 |
| of | Yes | 从其组成部分创建此时间间隔的实例 |
| parse | Yes | 从字符串创建此时间间隔的实例 |
| addTo | No | 创建此时间间隔的副本,并添加指定的时态对象 |
| get | No | 读取此时间间隔的部分状态 |
| isNegative | No | 检查此时间间隔是否为负数,不包括零 |
| isZero | No | 检查此时间间隔是否为零长度 |
| minus | No | 创建此时间间隔的副本,并减去一定的时间量 |
| multipliedBy | No | 创建此时间间隔的副本,并将其乘以给定的标量 |
| negated | No | 创建此时间间隔的副本,其长度取反 |
| plus | No | 创建此时间间隔的副本,并添加一定的时间量 |
| subtractFrom | No | 从指定的时态对象中减去此时间间隔 |
我们迄今为止调查的所有类都是不可变的,这是一个很好的设计选择,允许更函数式编程风格,确保线程安全,并保持领域模型的一致性。尽管如此,新的日期和时间 API 提供了一些方便的方法来创建这些对象的修改版本。例如,您可能想要将三个天添加到现有的 LocalDate 实例中,我们将在下一节中探讨如何做到这一点。此外,我们还将探讨如何从给定的模式(如 dd/MM/yyyy)或甚至以编程方式创建日期时间格式化器,以及如何使用此格式化器进行日期的解析和打印。
12.2. 操作、解析和格式化日期
创建现有 LocalDate 的修改版本最直接和最简单的方法是更改其属性之一,使用其 withAttribute 方法之一。请注意,所有这些方法都返回一个具有修改后属性的新对象,如列表 12.6 所示;它们不会修改现有对象!
列表 12.6. 以绝对方式操作 LocalDate 的属性
LocalDate date1 = LocalDate.of(2017, 9, 21); *1*
LocalDate date2 = date1.withYear(2011); *2*
LocalDate date3 = date2.withDayOfMonth(25); *3*
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2); *4*
-
1 2017-09-21
-
2 2011-09-21
-
3 2011-09-25
-
4 2011-02-25
您可以使用更通用的 with 方法做同样的事情,将 TemporalField 作为第一个参数,如列表 12.6 的最后一条语句所示。这个最后的 with 方法是列表 12.2 中使用的 get 方法的对偶。这两个方法都声明在所有类(如 LocalDate、LocalTime、LocalDateTime 和 Instant)实现的 Temporal 接口中,这些类属于日期和时间 API。更确切地说,get 和 with 方法分别允许您读取和修改 Temporal 对象的字段。如果请求的字段不支持特定的 Temporal,则抛出 UnsupportedTemporalTypeException,例如在 Instant 上的 ChronoField.MONTH_OF_YEAR 或在 LocalDate 上的 ChronoField.NANO_OF_SECOND。
¹
记住,这样的“
with”方法不会修改现有的Temporal对象,而是创建一个具有特定字段更新的副本。这个过程被称为功能更新(见第十九章)。
甚至可以以声明方式操作 LocalDate。例如,您可以添加或减去给定的时间量,如列表 12.7 所示。
列表 12.7. 以相对方式操作 LocalDate 的属性
LocalDate date1 = LocalDate.of(2017, 9, 21); *1*
LocalDate date2 = date1.plusWeeks(1); *2*
LocalDate date3 = date2.minusYears(6); *3*
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS); *4*
-
1 2017-09-21
-
2 2017-09-28
-
3 2011-09-28
-
4 2012-03-28
与我们之前解释的 with 和 get 方法类似,用于列表 12.7 最后一条语句的通用 plus 方法,以及相应的 minus 方法,都在 Temporal 接口中声明。这些方法允许您将 Temporal 向前或向后移动给定的时间量,该时间量由一个数字加上 TemporalUnit 定义,其中 ChronoUnit 枚举提供了对 TemporalUnit 接口的方便实现。
如您所预料的,所有代表时间点的日期时间类,如 LocalDate、LocalTime、LocalDateTime 和 Instant,都有许多共同的方法。表 12.2 总结了这些方法。
表 12.2. 代表时间点的日期时间类的常用方法
| 方法 | 静态 | 描述 |
|---|---|---|
| from | 是 | 从传递的时间对象创建此类的实例 |
| now | 是 | 从系统时钟创建时间对象 |
| of | 是 | 从其组成部分创建此时间对象的实例 |
| parse | 是 | 从字符串创建此时间对象的实例 |
| atOffset | 否 | 将此时间对象与区域偏移量结合 |
| atZone | 否 | 将此时间对象与时区结合 |
| format | 否 | 使用指定的格式化程序将此时间对象转换为字符串(对于 Instant 不可用) |
| get | 否 | 读取此时间对象的部分状态 |
| minus | 否 | 创建此时间对象的副本,并减去一定的时间量 |
| plus | 否 | 创建此时间对象的副本,并添加一定的时间量 |
| with | 无 | 创建一个部分状态已更改的此时间对象的副本 |
通过练习 12.1 检查你到目前为止关于操作日期所学的知识。
练习 12.1:操作 LocalDate
在以下操作之后,日期变量的值将会是多少?
LocalDate date = LocalDate.of(2014, 3, 18);
date = date.with(ChronoField.MONTH_OF_YEAR, 9);
date = date.plusYears(2).minusDays(10);
date.withYear(2011);
答案:
2016-09-08
正如你所见,你可以以绝对方式或相对方式操作日期。你还可以在单个语句中连接多个操作,因为每次更改都会创建一个新的 LocalDate 对象,后续调用将操作前一个调用创建的对象。最后,这段代码片段中的最后一个语句没有可观察的效果,因为像往常一样,它创建了一个新的 LocalDate 实例,但我们没有将这个新值赋给任何变量。
12.2.1. 使用 TemporalAdjusters
你迄今为止看到的所有日期操作都比较直接。然而,有时你需要执行更高级的操作,例如将日期调整到下一个星期日、下一个工作日或月底的最后一天。在这种情况下,你可以向 with 方法的重载版本传递一个 TemporalAdjuster,它提供了一种更可定制的定义所需操作的方式来操作特定日期。日期和时间 API 已经为最常见的用例提供了许多预定义的 TemporalAdjuster。你可以通过使用 TemporalAdjusters 类中包含的静态工厂方法来访问它们,如 列表 12.8 所示。
列表 12.8. 使用预定义的 TemporalAdjusters
import static java.time.temporal.TemporalAdjusters.*;
LocalDate date1 = LocalDate.of(2014, 3, 18); *1*
LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); *2*
LocalDate date3 = date2.with(lastDayOfMonth()); *3*
-
1 2014-03-18
-
2 2014-03-23
-
3 2014-03-31
表 12.3 列出了你可以使用这些工厂方法创建的 TemporalAdjuster。
表 12.3. TemporalAdjusters 类的工厂方法
| 方法 | 描述 |
|---|---|
| dayOfWeekInMonth | 创建一个新的日期,与当前月份的序数星期相同的日期。(负数从月底开始计数。) |
| firstDayOfMonth | 创建一个新的日期,设置为当前月份的第一天。 |
| firstDayOfNextMonth | 创建一个新的日期,设置为下一个月的第一天。 |
| firstDayOfNextYear | 创建一个新的日期,设置为下一年度的第一天。 |
| firstDayOfYear | 创建一个新的日期,设置为当前年份的第一天。 |
| firstInMonth | 创建一个新的日期,与当前月份的第一匹配星期相同的日期。 |
| lastDayOfMonth | 创建一个新的日期,设置为当前月份的最后一天。 |
| lastDayOfNextMonth | 创建一个新的日期,设置为下一个月的最后一天。 |
| lastDayOfNextYear | 创建一个新的日期,设置为下一年度的最后一天。 |
| lastDayOfYear | 创建一个新的日期,设置为当前年份的最后一天。 |
| lastInMonth | 创建一个新的日期,与当前月份的最后匹配星期相同的日期。 |
| next previous | 创建一个新的日期,设置为调整日期之后/之前的指定星期的第一次出现。 |
| nextOrSame previousOrSame | 在调整的日期之后/之前创建一个新的日期,设置为指定星期几的第一天,除非它已经在那天,在这种情况下返回相同的对象。 |
如你所见,TemporalAdjuster允许你执行更复杂的日期操作,同时仍然像问题陈述一样易于阅读。此外,如果你找不到适合你需求的预定义TemporalAdjuster,创建你自己的自定义TemporalAdjuster实现相对简单。实际上,TemporalAdjuster接口只声明了一个方法(这使得它成为一个函数式接口),定义如下所示。
列表 12.9. TemporalAdjuster接口
@FunctionalInterface
public interface TemporalAdjuster {
Temporal adjustInto(Temporal temporal);
}
这个例子意味着TemporalAdjuster接口的实现定义了如何将一个Temporal对象转换为另一个Temporal。你可以将TemporalAdjuster视为一个UnaryOperator<Temporal>。花几分钟时间练习你到目前为止所学的内容,并在练习 12.2 中实现你自己的TemporalAdjuster。
练习 12.2:实现自定义的TemporalAdjuster
开发一个名为NextWorkingDay的类,实现TemporalAdjuster接口,该接口将日期向前移动一天,但跳过星期六和星期日。使用
date = date.with(new NextWorkingDay());
应该将日期移动到下一天,如果这一天是星期一到星期五,但如果这一天是星期六或星期日,则移动到下一个星期一。
答案:
你可以这样实现NextWorkingDay调整器:
public class NextWorkingDay implements TemporalAdjuster {
@Override
public Temporal adjustInto(Temporal temporal) {
DayOfWeek dow =
DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK)); *1*
int dayToAdd = 1; *2*
if (dow == DayOfWeek.FRIDAY) dayToAdd = 3; *3*
else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2; *4*
return temporal.plus(dayToAdd, ChronoUnit.DAYS); *5*
}
}
-
1 读取当前日期。
-
2 通常添加一天。
-
3 但如果今天是星期五,则添加三天。
-
4 如果今天是星期六,则添加两天。
-
5 返回添加了正确天数后的修改日期。
这个TemporalAdjuster通常将日期向前移动一天,除非今天是星期五或星期六,在这种情况下,分别将日期向前移动三天或两天。请注意,因为TemporalAdjuster是一个函数式接口,你可以通过 lambda 表达式传递这个调整器的行为:
date = date.with(temporal -> {
DayOfWeek dow =
DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
int dayToAdd = 1;
if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;
else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;
return temporal.plus(dayToAdd, ChronoUnit.DAYS);
});
你可能希望在代码的几个地方应用这种操作,因此我们建议将它的逻辑封装在一个合适的类中,就像我们在这里做的那样。对于你经常使用的所有操作都这样做。最终,你将拥有一个小型的调整器库,你和你的团队可以轻松地在代码库中重用这些调整器。
如果你想要使用 lambda 表达式定义TemporalAdjuster,最好通过使用TemporalAdjusters类的ofDateAdjuster静态工厂方法来这样做,它接受一个UnaryOperator<LocalDate>,如下所示:
TemporalAdjuster nextWorkingDay = TemporalAdjusters.ofDateAdjuster(
temporal -> {
DayOfWeek dow =
DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
int dayToAdd = 1;
if (dow == DayOfWeek.FRIDAY) dayToAdd = 3;
else if (dow == DayOfWeek.SATURDAY) dayToAdd = 2;
return temporal.plus(dayToAdd, ChronoUnit.DAYS);
});
date = date.with(nextWorkingDay);
你可能还想在日期和时间对象上执行另一个常见操作,那就是以特定于你业务域的格式打印它们。同样,你可能还想将这些格式的日期String转换为实际的日期对象。在下一节中,我们将展示新日期和时间 API 提供的机制来完成这些任务。
12.2.2. 打印和解析日期时间对象
格式化和解析是处理日期和时间的其他相关功能。新的 java.time.format 包致力于这些目的。该包中最重要的类是 DateTimeFormatter。创建格式化器的最简单方法是通过其静态工厂方法和常量。例如,BASIC_ISO_DATE 和 ISO_LOCAL_DATE 这样的常量是 DateTimeFormatter 类的预定义实例。您可以使用所有 DateTimeFormatter 创建表示给定日期或时间的特定格式的 String。例如,我们通过使用两个不同的格式化器生成一个 String:
LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); *1*
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); *2*
-
1 20140318
-
2 2014-03-18
您还可以解析表示日期或时间的 String,以重新创建日期对象本身。您可以通过使用代表时间点或区间的 Date 和 Time API 中所有类的 parse 工厂方法来实现此任务:
LocalDate date1 = LocalDate.parse("20140318",
DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2014-03-18",
DateTimeFormatter.ISO_LOCAL_DATE);
与旧的 java.util.DateFormat 类相比,所有的 DateTimeFormatter 实例都是线程安全的。因此,您可以创建单例格式化器,如由 DateTimeFormatter 常量定义的格式化器,并在多个线程之间共享。下面的列表显示了 DateTimeFormatter 类还支持一个静态工厂方法,允许您从特定的模式创建格式化器。
列表 12.10. 从模式创建 DateTimeFormatter
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);
在这里,LocalDate 的 format 方法生成一个表示日期的 String,该日期符合请求的格式。接下来,静态 parse 方法通过解析生成的 String 重新创建相同的日期,使用相同的格式化器。ofPattern 方法还有一个重载版本,允许您为给定的 Locale 创建格式化器,如下面的列表所示。
列表 12.11. 创建本地化的 DateTimeFormatter
DateTimeFormatter italianFormatter =
DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.ITALIAN);
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date.format(italianFormatter); // 18\. marzo 2014
LocalDate date2 = LocalDate.parse(formattedDate, italianFormatter);
最后,如果您需要更多的控制,DateTimeFormatterBuilder 类允许您通过使用有意义的步骤定义复杂的格式化器。此外,它还提供了不区分大小写的解析、宽松解析(允许解析器使用启发式方法来解释不精确匹配指定格式的输入)、填充和格式化器的可选部分的能力。您可以通过 DateTimeFormatterBuilder 编程构建与 列表 12.11 中使用的相同的 italianFormatter,例如,如下面的列表所示。
列表 12.12. 构建 DateTimeFormatter
DateTimeFormatter italianFormatter = new DateTimeFormatterBuilder()
.appendText(ChronoField.DAY_OF_MONTH)
.appendLiteral(". ")
.appendText(ChronoField.MONTH_OF_YEAR)
.appendLiteral(" ")
.appendText(ChronoField.YEAR)
.parseCaseInsensitive()
.toFormatter(Locale.ITALIAN);
到目前为止,您已经学习了如何创建、操作、格式化和解析时间点和区间,但您还没有看到如何处理涉及日期和时间的细微差别。您可能需要处理不同的时区或替代的日历系统。在接下来的几节中,我们将通过使用新的日期和时间 API 探索这些主题。
12.3. 与不同时区和日历一起工作
您迄今为止看到的任何类都不包含任何关于时区的信息。处理时区是另一个重要的问题,新日期和时间 API 已经极大地简化了这个问题。新的 java.time.ZoneId 类是旧 java.util.TimeZone 类的替代品。它的目标是更好地保护您免受与时区相关的复杂性的影响,例如处理夏令时(DST)。像日期和时间 API 的其他类一样,它是不可变的。
12.3.1. 使用时区
时区是一组与标准时间相同的规则相对应的区域。大约有 40 个时区包含在 ZoneRules 类的实例中。您可以通过调用 getRules() 在 ZoneId 上获取该时区的规则。特定的 ZoneId 通过区域 ID 来识别,如下例所示:
ZoneId romeZone = ZoneId.of("Europe/Rome");
所有区域 ID 都采用 "{area}/{city}" 的格式,可用的位置集合是由互联网名称与数字地址分配机构(IANA)时区数据库(见 www.iana.org/time-zones)提供的。您还可以通过使用新方法 toZoneId 将旧的 TimeZone 对象转换为 ZoneId:
ZoneId zoneId = TimeZone.getDefault().toZoneId();
当您有一个 ZoneId 对象时,您可以将其与 LocalDate、LocalDateTime 或 Instant 结合,将其转换为 ZonedDateTime 实例,这些实例表示相对于指定时区的时间点,如下所示。
列表 12.13. 将时区应用于时间点
LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
ZonedDateTime zdt1 = date.atStartOfDay(romeZone);
LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
ZonedDateTime zdt2 = dateTime.atZone(romeZone);
Instant instant = Instant.now();
ZonedDateTime zdt3 = instant.atZone(romeZone);
图 12.1 展示了 ZonedDateTime 的组成部分,以帮助您理解 LocalDate、LocalTime、LocalDateTime 和 ZoneId 之间的差异。
图 12.1. 理解 ZonedDateTime

您也可以通过使用 ZoneId: 将 LocalDateTime 转换为 Instant。
LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
Instant instantFromDateTime = dateTime.toInstant(romeZone);
或者,您也可以反过来操作:
Instant instant = Instant.now();
LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);
注意,使用 Instant 非常有用,因为您经常需要处理与 Date 类相关的遗留代码。在那里,添加了两个方法来帮助在已弃用的 API 和新的日期和时间 API 之间进行互操作:toInstant() 和静态方法 fromInstant()。
12.3.2. 从 UTC/Greenwich 的固定偏移量
表达时区的另一种常见方式是使用相对于 UTC/Greenwich 的固定偏移量。例如,您可以使用这种表示法来说明,“纽约比伦敦晚五小时”。在这种情况下,您可以使用 ZoneOffset 类,它是 ZoneId 的子类,表示时间与伦敦格林威治零子午线之间的差异,如下所示:
ZoneOffset newYorkOffset = ZoneOffset.of("-05:00");
-05:00 的偏移量确实对应于美国东部标准时间。然而,请注意,以这种方式定义的 ZoneOffset 没有任何夏令时管理,因此在大多数情况下不建议使用。因为 ZoneOffset 也是一个 ZoneId,你可以像在本书前面的 清单 12.13 中所示的那样使用它。你还可以创建一个 OffsetDateTime,它表示 ISO-8601 日历系统中相对于 UTC/Greenwich 的日期时间:
LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45);
OffsetDateTime dateTimeInNewYork = OffsetDateTime.of(date, newYorkOffset);
新日期和时间 API 支持的另一个高级功能是对非 ISO 日历系统的支持。
12.3.3. 使用替代日历系统
ISO-8601 日历系统是事实上的世界公历系统。但 Java 8 提供了四种额外的日历系统。每个日历系统都有一个专门的日期类:ThaiBuddhistDate、MinguoDate、JapaneseDate 和 HijrahDate。所有这些类,连同 LocalDate,都实现了 ChronoLocalDate 接口,该接口旨在表示任意历法中的日期。你可以从 LocalDate 创建这些类中的一个实例。更普遍地说,你可以通过使用它们的 from 静态工厂方法创建任何其他 Temporal 实例,如下所示:
LocalDate date = LocalDate.of(2014, Month.MARCH, 18);
JapaneseDate japaneseDate = JapaneseDate.from(date);
或者,你可以为特定的 Locale 显式创建一个日历系统,并为该 Locale 创建一个日期实例。在新日期和时间 API 中,Chronology 接口表示一个日历系统,你可以通过使用它的 ofLocale 静态工厂方法来获取其实例:
Chronology japaneseChronology = Chronology.ofLocale(Locale.JAPAN);
ChronoLocalDate now = japaneseChronology.dateNow();
日期和时间 API 的设计者建议在大多数情况下使用 LocalDate 而不是 Chrono-LocalDate,因为开发者可能会在代码中做出一些假设,而这些假设在多日历系统中不幸地并不成立。这些假设可能包括认为一天或一个月的值永远不会超过 31,或者一年包含 12 个月,甚至一年有固定数量的月份。出于这些原因,我们建议在您的应用程序中始终使用 LocalDate,包括所有存储、操作和业务规则的解释,而您应该只在需要本地化程序输入或输出时使用 ChronoLocalDate。
伊斯兰历
在 Java 8 中添加的新日历中,HijrahDate(伊斯兰历)似乎是最复杂的,因为它可能有变体。回历日历系统基于月相。有各种方法来确定新月份,例如新月在世界上任何地方都可以看到,或者必须首先在沙特阿拉伯看到。withVariant 方法用于选择所需的变体。Java 8 将 Umm Al-Qura 变体作为标准包含在 HijrahDate 中。
以下代码演示了显示当前伊斯兰年斋月开始和结束日期的示例:
HijrahDate ramadanDate =
HijrahDate.now().with(ChronoField.DAY_OF_MONTH, 1)
.with(ChronoField.MONTH_OF_YEAR, 9); *1*
System.out.println("Ramadan starts on " +
IsoChronology.INSTANCE.date(ramadanDate) + *2*
" and ends on " +
IsoChronology.INSTANCE.date( *3*
ramadanDate.with(
TemporalAdjusters.lastDayOfMonth())));
-
1 获取当前的回历日期;然后将其更改为具有斋月的第一天,即第九个月。
-
2 IsoChronology.INSTANCE 是 IsoChronology 类的静态实例。
-
3 伊斯兰历 1438 年的斋月从 2017 年 5 月 26 日开始,到 2017 年 6 月 24 日结束。
摘要
-
旧的
java.util.Date类以及 Java 8 之前在 Java 中用于建模日期和时间的所有其他类都有许多不一致性和设计缺陷,包括可变性和一些选择不佳的偏移量、默认值和命名。 -
新的日期和时间 API 中的所有日期时间对象都是不可变的。
-
这个新的 API 提供了两种不同的时间表示,以管理人类和机器在操作它时的不同需求。
-
你可以以绝对和相对的方式操作日期和时间对象,这些操作的结果始终是一个新的实例,而原始对象保持不变。
-
TemporalAdjusters 允许你以比更改其值更复杂的方式操作日期,并且你可以定义和使用你自己的自定义日期转换。 -
你可以定义一个格式化程序来以特定格式打印和解析日期时间对象。这些格式化程序可以从模式或程序化创建,并且它们都是线程安全的。
-
你可以表示一个时区,相对于特定的区域/位置,以及相对于 UTC/格林尼治的固定偏移量,并将其应用于日期时间对象以本地化它。
-
你可以使用与 ISO-8601 标准系统不同的日历系统。
第十三章. 默认方法
本章涵盖
-
默认方法是什么
-
以兼容的方式发展 API
-
默认方法的用法模式
-
分辨规则
传统上,Java 接口将相关方法组合在一起形成一个合同。任何(非抽象)实现接口的类必须为接口中定义的每个方法提供实现,或者从超类继承实现。但是,当库设计者需要更新接口以添加新方法时,这个要求会导致问题。确实,现有的具体类(可能不受接口设计者的控制)需要被修改以反映新的接口合同。这种情况尤其有问题,因为 Java 8 API 在现有接口上引入了许多新方法,例如你在前几章中使用的 List 接口上的 sort 方法。想象一下,所有替代集合框架(如 Guava 和 Apache Commons)的愤怒维护者现在需要修改实现 List 接口的所有类,以提供 sort 方法的实现!
但不必担心。Java 8 引入了一种新的机制来解决这个问题。这听起来可能有些令人惊讶,但自从 Java 8 以来,接口可以通过两种方式声明带有实现代码的方法。首先,Java 8 允许接口内部存在静态方法。其次,Java 8 引入了一种名为默认方法的新特性,允许你为接口中的方法提供一个默认实现。换句话说,接口现在可以为方法提供具体实现。因此,如果现有类没有明确提供实现,它们将自动继承默认实现,这允许你以非侵入性的方式演进接口。你一直在使用多个默认方法。你看到的两个例子是 List 接口中的 sort 和 Collection 接口中的 stream。
你在第一章中看到的 List 接口中的 sort 方法是 Java 8 的新特性,其定义如下:
default void sort(Comparator<? super E> c){
Collections.sort(this, c);
}
注意返回类型之前的新 default 修饰符。这就是你如何知道一个方法是默认方法的方式。在这里,sort 方法调用 Collections.sort 方法来执行排序。多亏了这个新方法,你可以通过直接调用方法来对列表进行排序:
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder()); *1*
- 1 排序是 List 接口中的一个默认方法。
在这段代码中还有其他新内容。注意你调用了 Comparator.naturalOrder 方法。这个 Comparator 接口中的新静态方法返回一个 Comparator 对象,用于按自然顺序(标准的字母数字排序)对元素进行排序。你在第四章中看到的 Collection 中的 stream 方法如下所示:
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
在这里,stream 方法,你在前几章中广泛使用它来处理集合,调用 StreamSupport.stream 方法来返回一个流。注意 stream 方法的主体是如何调用 spliterator 方法的,而 spliterator 也是 Collection 接口的一个默认方法。
哇!接口现在像抽象类一样了吗?是的,也不是;这里有一些根本性的区别,我们将在本章中解释。更重要的是,为什么你应该关心默认方法?默认方法的主要用户是库设计者。正如我们稍后解释的那样,默认方法的引入是为了以兼容的方式演进库,例如 Java API,如图 13.1 所示。
图 13.1. 向接口中添加方法

简而言之,向接口中添加方法会导致许多问题;实现该接口的现有类需要更改以提供该方法的实现。如果你控制着接口及其所有实现,那么情况并不太糟糕。但这种情况通常并不成立——这也正是默认方法产生的动机,默认方法允许类自动从接口继承默认实现。
如果您是库设计师,本章很重要,因为默认方法提供了一种在不修改现有实现的情况下演变接口的方法。此外,正如我们在本章后面解释的那样,默认方法可以通过提供一种灵活的多重继承行为机制来帮助结构化您的程序;一个类可以从多个接口继承默认方法。因此,即使您不是库设计师,您也可能对了解默认方法感兴趣。
静态方法和接口
在 Java 中,定义一个接口和一个定义了许多静态方法的实用伴随类是一个常见的模式。例如,Collections是一个伴随类,用于处理Collection对象。现在,静态方法可以存在于接口中,因此您代码中的此类实用类可以消失,它们的静态方法可以移动到接口内部。这些伴随类仍然保留在 Java API 中,以保持向后兼容性。
本章的结构如下。首先,我们带您了解一个 API 演变的使用案例以及可能出现的各种问题。然后,我们解释什么是默认方法,并讨论如何使用它们来解决使用案例中的问题。接下来,我们展示如何创建自己的默认方法,以在 Java 中实现一种多重继承的形式。最后,我们提供一些关于 Java 编译器如何解决一个类继承多个具有相同签名的默认方法时可能出现的歧义的技术信息。
13.1. API 的演变
为了理解为什么在 API 发布后演变 API 很困难,假设为了本节的目的,您是一个流行的 Java 绘图库的设计师。您的库包含一个Resizable接口,该接口定义了许多简单可调整大小的形状必须支持的方法:setHeight、setWidth、getHeight、getWidth和set-AbsoluteSize。此外,您还提供了几个现成的实现,例如Square和Rectangle。由于您的库非常受欢迎,一些用户已经使用您的Resizable接口创建了他们自己的有趣实现,例如Ellipse。
在发布您的 API 几个月后,您意识到Resizable缺少一些功能。例如,如果接口有一个接受增长因子作为参数以调整形状大小的setRelativeSize方法,那就很好了。您可能将setRelativeSize方法添加到Resizable中,并更新Square和Rectangle的实现。但不要这么快!那么,所有创建了Resizable接口自己实现的用户怎么办?不幸的是,您无法访问并更改实现Resizable的他们的类。这个问题与 Java 库设计者在需要进化 Java API 时面临的问题相同。在下一节中,我们将详细探讨一个示例,该示例展示了修改已发布的接口的后果。
13.1.1. API 版本 1
您的Resizable接口的第一个版本具有以下方法:
public interface Resizable extends Drawable{
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
}
用户实现
您最忠诚的用户决定创建他自己的Resizable实现,称为Ellipse:
public class Ellipse implements Resizable {
...
}
他创建了一个游戏,该游戏处理不同类型的Resizable形状(包括他自己的Ellipse):
public class Game{
public static void main(String...args){
List<Resizable> resizableShapes =
Arrays.asList(new Square(), new Rectangle(), new Ellipse()); *1*
Utils.paint(resizableShapes);
}
}
public class Utils{
public static void paint(List<Resizable> l){
l.forEach(r -> {
r.setAbsoluteSize(42, 42); *2*
r.draw();
});
}
}
-
1 可调整大小的形状列表
-
2 在每个形状上调用
setAbsoluteSize方法
13.1.2. API 版本 2
在您的库使用了几个月后,您收到了许多更新Resizable实现(如Square、Rectangle等)以支持setRelativeSize方法的请求。您推出了 API 的第二个版本,如图所示,并在图 13.2 中进行了说明。
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
void setRelativeSize(int wFactor, int hFactor); *1*
}
- 1 为 API 版本 2 添加新方法
图 13.2. 通过向Resizable添加方法来演化 API。重新编译应用程序会产生错误,因为它依赖于Resizable接口。

用户的问题
Resizable的这次更新产生了问题。首先,接口现在要求实现setRelativeSize方法,但您的用户创建的Ellipse实现没有实现setRelativeSize方法。向接口添加新方法是二进制兼容的,这意味着如果未尝试重新编译它们,现有的类文件实现仍然可以运行。在这种情况下,即使向Resizable接口添加了setRelativeSize方法,游戏仍然可以运行(除非重新编译)。尽管如此,用户可以修改他游戏中Utils.paint方法的实现以使用set-RelativeSize方法,因为paint方法期望一个Resizable对象列表作为参数。如果传递了一个Ellipse对象,由于没有实现setRelativeSize方法,在运行时会抛出错误:
Exception in thread "main" java.lang.AbstractMethodError:
lambdasinaction.chap9.Ellipse.setRelativeSize(II)V
第二,如果用户尝试重新构建他的整个应用程序(包括Ellipse),他将得到以下编译错误:
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does
not override abstract method setRelativeSize(int,int) in Resizable
因此,更新已发布的 API 会创建向后不兼容性,这就是为什么演进现有的 API,例如官方 Java 集合 API,会给 API 的用户带来问题。您有演进 API 的替代方案,但它们的选择并不好。例如,您可以创建 API 的单独版本并维护旧版和新版,但这有几个不便之处。首先,对于库设计者来说,这更复杂。其次,您的用户可能必须在同一代码库中使用 API 的这两个版本,这会影响内存空间和加载时间,因为他们的项目需要更多的类文件。
在这种情况下,默认方法就派上用场了。它们让库设计者可以在不破坏现有代码的情况下演进 API,因为实现更新接口的类会自动继承默认实现。
不同类型的兼容性:二进制、源和行为
在向 Java 程序引入更改时,主要有三种兼容性:二进制、源和行为兼容性(参见blogs.oracle.com/darcy/entry/kinds_of_compatibility)。您已经看到,向接口添加方法是二进制兼容的,但如果实现接口的类被重新编译,则会引发编译错误。了解不同类型的兼容性是很好的,因此在这个侧边栏中,我们将详细探讨它们。
二进制兼容性意味着现有的二进制文件在引入更改后继续无错误地链接(这涉及到验证、准备和解析),而不会出错。例如,向接口添加方法就是二进制兼容的,因为如果它没有被调用,接口的现有方法仍然可以正常运行而不会出现问题。
在其最简单的形式中,源兼容性意味着在引入更改后,现有的程序仍然可以编译。向接口添加方法不是源兼容的;现有的实现不会重新编译,因为它们需要实现新方法。
最后,行为兼容性意味着在更改后使用相同的输入运行程序会产生相同的行为。向接口添加方法是行为兼容的,因为该方法在程序中从未被调用(或被实现覆盖)。
13.2. 简要介绍默认方法
您已经看到了向已发布的 API 添加方法是如何破坏现有实现的。默认方法是 Java 8 中引入的,以便以兼容的方式演进 API。现在,一个接口可以包含实现类没有提供实现的方法签名。谁来实现它们?缺失的方法体作为接口的一部分给出(因此,默认实现),而不是在实现类中。
如何识别一个默认方法?简单:它以default修饰符开头,并包含一个体,就像在类中声明的类方法一样。在集合库的上下文中,你可以定义一个接口Sized,它有一个抽象方法size和一个默认方法isEmpty,如下所示:
public interface Sized {
int size();
default boolean isEmpty() { *1*
return size() == 0;
}
}
- 1 默认方法
现在,任何实现Sized接口的类都会自动继承isEmpty的实现。因此,向接口添加一个带有默认实现的方 法并不构成源不兼容。
现在回到 Java 绘图库和你的游戏的初始示例。具体来说,为了以兼容的方式(这意味着你的库的用户不需要修改所有实现Resizable的类)进化你的库,使用默认方法并为setRelativeSize提供一个默认实现,如下所示:
default void setRelativeSize(int wFactor, int hFactor){
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
因为接口现在可以有带有实现的方法,这意味着 Java 中的多重继承已经到来吗?如果一个实现类也定义了相同的方法签名或默认方法可以被覆盖会发生什么?现在不用担心这些问题;有一些规则和机制可以帮助你处理这些问题。我们将在第 13.4 节中详细探讨它们。
你可能已经猜到了,默认方法在 Java 8 API 中被广泛使用。在本章的介绍中,你看到我们在前几章中广泛使用的Collection接口中的stream方法是一个默认方法。List接口中的sort方法也是一个默认方法。我们在第三章中介绍的大多数功能接口(如Predicate、Function和Comparator)也引入了新的默认方法,如Predicate.and和Function.andThen。(记住,功能接口只包含一个抽象方法;默认方法是具体方法。)
Java 8 中的抽象类与接口
抽象类和接口之间的区别是什么?两者都可以包含抽象方法和有体方法。
首先,一个类只能从一个抽象类扩展,但一个类可以实现多个接口。
其次,一个抽象类可以通过实例变量(字段)强制执行一个共同的状态。接口不能有实例变量。
要将你对默认方法的了解付诸实践,尝试一下第 13.1 题。
第 13.1 题:removeIf
对于这个测验,假设你是 Java 语言和 API 的大师之一。你已经收到了许多在ArrayList、TreeSet、LinkedList和其他所有集合上使用removeIf方法的请求。removeIf方法应该从集合中移除所有匹配给定谓词的元素。在这个测验中,你的任务是找出增强Collections API 的这种新方法的最佳方式。
答案:
如何最有效地增强 Collections API?您可以在 Collections API 的每个具体类中复制和粘贴 removeIf 的实现,但这种解决方案对 Java 社区来说是一种犯罪。您还能做什么?嗯,所有的 Collection 类都实现了一个名为 java.util.Collection 的接口。很好;您可以在那里添加一个方法吗?是的。您已经了解到默认方法允许您以源兼容的方式在接口内部添加实现。所有实现 Collection 的类(包括用户类,这些类不是 Collections API 的一部分)都可以使用 removeIf 的实现。removeIf 的代码解决方案如下(这大致是官方 Java 8 Collections API 中的实现)。这个解决方案是 Collection 接口中的一个默认方法:
default boolean removeIf(Predicate<? super E> filter) {
boolean removed = false;
Iterator<E> each = iterator();
while(each.hasNext()) {
if(filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
13.3. 默认方法的用法模式
您已经看到默认方法可以以兼容的方式演变库。您还能用它们做什么?您可以创建具有默认方法的自己的接口。您可能希望在下文探讨的两个用例中这样做:可选方法和行为的多继承。
13.3.1. 可选方法
您可能遇到过实现接口但留空某些方法实现的类。以 Iterator 接口为例,它定义了 hasNext 和 next 方法,但也定义了 remove 方法。在 Java 8 之前,remove 方法通常被忽略,因为用户决定不使用该功能。因此,许多实现 Iterator 的类都有 remove 方法的空实现,这导致了不必要的样板代码。
使用默认方法,您可以为此类方法提供默认实现,因此具体类不需要显式提供空实现。Java 8 中的 Iterator 接口提供了如下 remove 方法的默认实现:
interface Iterator<T> {
boolean hasNext();
T next();
default void remove() {
throw new UnsupportedOperationException();
}
}
因此,您可以减少样板代码。任何实现 Iterator 接口的类不再需要声明一个空 remove 方法来忽略它,因为现在它已经有了默认实现。
13.3.2. 行为的多继承
默认方法使之前不可能的事情变得优雅:行为的多继承,即一个类能够从多个地方重用代码的能力(图 13.3)。
图 13.3. 单继承与多继承

记住,Java 中的类只能从另一个类继承,但类始终允许实现多个接口。为了确认,以下是 Java API 中 ArrayList 类的定义:
public class ArrayList<E> extends AbstractList<E> *1*
implements List<E>, RandomAccess, Cloneable,
Serializable { *2*
}
-
1 从一个类继承
-
2 实现四个接口
类型多继承
在这里,ArrayList 扩展了一个类并直接实现了四个接口。因此,ArrayList 直接是七个类型的子类型:AbstractList、List、RandomAccess、Cloneable、Serializable、Iterable 和 Collection。从某种意义上说,你已经有多种类型的多重继承。
由于 Java 8 中接口方法可以有实现,类可以从多个接口继承行为(实现代码)。在下一节中,我们将通过一个示例来展示你如何利用这种能力来获得好处。保持接口最小化和正交性,让你在代码库中实现行为重用和组合。
具有正交功能的接口最小化
假设你需要为创建的游戏定义具有不同特性的几个形状。一些形状应该是可调整大小的但不能旋转;一些应该是可旋转和可移动的但不能调整大小。你如何实现代码的重用?
你可以从定义一个独立的 Rotatable 接口开始,该接口有两个抽象方法:setRotationAngle 和 getRotationAngle。该接口还声明了一个默认的 rotateBy 方法,你可以通过使用 setRotationAngle 和 get-RotationAngle 方法来实现,如下所示:
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees){ *1*
setRotationAngle((getRotationAngle () + angleInDegrees) % 360);
}
}
- 1 rotateBy 方法的默认实现
这种技术某种程度上与模板设计模式相关,其中通过需要实现的其他方法来定义一个骨架算法。
现在,任何实现 Rotatable 的类都需要为 setRotationAngle 和 getRotationAngle 提供实现,但可以免费继承 rotateBy 的默认实现。
同样,你可以定义之前看到的两个接口:Moveable 和 Resizable。这两个接口都包含默认实现。以下是 Moveable 的代码:
public interface Moveable {
int getX();
int getY();
void setX(int x);
void setY(int y);
default void moveHorizontally(int distance){
setX(getX() + distance);
}
default void moveVertically(int distance){
setY(getY() + distance);
}
}
以下是 Resizable 的代码:
public interface Resizable {
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
default void setRelativeSize(int wFactor, int hFactor){
setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
}
}
组合接口
你可以通过组合这些接口来为你的游戏创建不同的具体类。例如,怪物可以是可移动的、可旋转的,也可以是可调整大小的:
public class Monster implements Rotatable, Moveable, Resizable {
... *1*
}
- 1 需要为所有抽象方法提供实现,但不需要为默认方法提供实现
Monster 类自动从 Rotatable、Moveable 和 Resizable 接口继承默认方法。在这种情况下,Monster 继承了 rotateBy、moveHorizontally、moveVertically 和 setRelativeSize 的实现。
现在你可以直接调用不同的方法:
Monster m = new Monster(); *1*
m.rotateBy(180); *2*
m.moveVertically(10); *3*
-
1 构造函数内部设置坐标、高度、宽度和默认角度。
-
2 从 Rotatable 调用 rotateBy
-
3 从 Moveable 调用 moveVertically
假设现在你需要声明另一个类,该类是可移动和可旋转的,但不能调整大小,例如太阳。你不需要复制和粘贴代码;你可以重用 Moveable 和 Rotatable 接口中的默认实现,如下所示。
public class Sun implements Moveable, Rotatable {
... *1*
}
- 1 需要为所有抽象方法提供实现,但不需要为默认方法提供实现
图 13.4 展示了该场景的 UML 图。
图 13.4. 多种行为组合

这里是定义简单接口(如你的游戏中的接口)并使用默认实现的另一个优点。假设你需要修改moveVertically的实现以使其更高效。你可以在Moveable接口中直接更改其实现,并且所有实现它的类都会自动继承代码(前提是它们没有自己实现该方法)!
继承被认为是有害的
在代码重用方面,继承不应该是你的唯一答案。例如,从一个有 100 个方法和字段可以重用的类继承一个方法是一个糟糕的想法,因为这会增加不必要的复杂性。你最好使用委托:通过成员变量直接调用所需类的方 法来创建一个方法。因此,你有时会发现故意声明为final的类:它们不能被继承以防止这种反模式,或者它们的核心理念被破坏。请注意,有时final类有其位置。例如,String是 final 的,因为你不希望任何人能够干扰这种核心功能。
同样的想法也适用于具有默认方法的接口。通过保持你的接口最小化,你可以实现更大的组合,因为你只能选择你需要的实现。
你已经看到默认方法对许多使用模式很有用。但这里有一些值得思考的问题:如果一个类实现了两个具有相同默认方法签名的接口,会发生什么?类可以使用哪个方法?我们将在下一节中探讨这个问题。
13.4. 解决规则
如你所知,在 Java 中,一个类只能扩展一个父类,但可以实现多个接口。随着 Java 8 中默认方法的引入,一个类继承多个具有相同签名的方法的可能 性。应该使用哪个方法版本?在实践中,这种冲突可能非常罕见,但一旦发生,就必须有规则来指定如何处理冲突。本节解释了 Java 编译器如何解决这种潜在的冲突。我们的目标是回答诸如“在下面的代码中,C正在调用哪个hello方法?”等问题。请注意,以下示例旨在探索问题场景;这些场景在实践 中不一定经常发生:
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A {
public static void main(String... args) {
new C().hello(); *1*
}
}
- 1 打印的是什么?
此外,你可能听说过 C++中的菱形问题,其中一个类可以继承两个具有相同签名的方法。哪个会被选择?Java 8 提供了解决这个问题的规则。继续阅读!
13.4.1. 需要了解的三个解决规则
当一个类从多个地方(例如另一个类或接口)继承具有相同签名的方法时,你需要遵循三个规则:
-
类总是获胜。类或超类中的方法声明优先于任何默认方法声明。
-
否则,子接口获胜:在具有最具体默认提供接口中具有相同签名的那个方法被选中。(如果
B扩展A,则B比较具体。) -
最后,如果选择仍然模糊不清,从多个接口继承的类必须通过覆盖它并显式调用所需的方法来显式选择要使用哪个默认方法实现。
我们保证这些是您需要知道的唯一规则!在下一节中,我们将查看一些例子。
13.4.2. 最具体默认提供接口获胜
在这里,你回顾了本节开头提到的例子,其中 C 实现了 B 和 A,它们定义了一个名为 hello 的默认方法。此外,B 扩展了 A。图 13.5 为该场景提供了一个 UML 图。
图 13.5. 最具体的默认提供接口获胜。

编译器将使用哪个 hello 方法的声明?规则 2 表示具有最具体默认提供接口的方法被选中。因为 B 比较具体,所以选择了 B 的 hello。因此,程序打印 "Hello from B"。
现在考虑如果 C 如下继承 D 会发生什么(如图 13.6 所示):
图 13.6. 从类继承并实现两个接口

public class D implements A{ }
public class C extends D implements B, A {
public static void main(String... args) {
new C().hello(); *1*
}
}
- 1 会打印什么?
规则 1 表示类中的方法声明具有优先级。但 D 没有覆盖 hello;它实现了接口 A。因此,它有一个来自接口 A 的默认方法。规则 2 表示如果没有类或超类中的方法,则选择具有最具体默认提供接口的方法。因此,编译器在接口 A 的 hello 方法和接口 B 的 hello 方法之间有选择。因为 B 更具体,所以程序再次打印 "Hello from B"。
为了检验你对解析规则的理解,尝试测验 13.2。
测验 13.2:记住解析规则
对于这个测验,重用前面的例子,除了 D 明确覆盖了 A 的 hello 方法。你认为会打印出什么?
public class D implements A{
void hello(){
System.out.println("Hello from D");
}
}
public class C extends D implements B, A {
public static void main(String... args) {
new C().hello();
}
}
答案:
程序打印 "Hello from D",因为超类中的方法声明具有优先级,正如规则 1 所述。
注意,如果 D 如下声明,
public abstract class D implements A {
public abstract void hello();
}
即使在层次结构中的其他地方存在默认实现,C 也必须自己实现 hello 方法。
13.4.3. 冲突和显式消除歧义
你迄今为止看到的例子可以通过前两个解析规则来解决。现在假设 B 不再扩展 A(如图 13.7 所示):
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B {
default void hello() {
System.out.println("Hello from B");
}
}
public class C implements B, A { }
图 13.7. 实现两个接口

规则 2 现在无法帮助你,因为没有更具体的接口可以选择。A 和 B 的 hello 方法都可以是有效的选项。因此,Java 编译器产生了一个编译错误,因为它不知道哪个方法更适合:"错误:类 C 从类型 B 和 A 继承了不相关的 hello() 默认值。"
解决冲突
解决两个可能有效方法之间的冲突的解决方案并不多;你必须明确决定你希望 C 使用哪个方法声明。为此,你可以在类 C 中重写 hello 方法,然后在它的主体中明确调用你想要使用的方法。Java 8 引入了新的语法 X.super.m(...),其中 X 是你想要调用其方法 m 的超接口。如果你想 C 使用来自 B 的默认方法,例如,代码看起来是这样的:
public class C implements B, A {
void hello(){
B.super.hello(); *1*
}
}
- 1 明确选择调用接口 B 的方法
尝试做 13.3 题的测验,以调查一个相关复杂的情况。
测验 13.3:几乎相同的签名
对于这个测验,假设接口 A 和 B 被声明如下:
public interface A{
default Number getNumber(){
return 10;
}
}
public interface B{
default Integer getNumber(){
return 42;
}
}
还假设类 C 被声明如下:
public class C implements B, A {
public static void main(String... args) {
System.out.println(new C().getNumber());
}
}
程序将打印什么?
答案:
C 无法区分 A 或 B 的哪个方法更具体。因此,类 C 将无法编译。
13.4.4. 钻石问题
最后,考虑一个让 C++ 社区感到寒心的场景:
public interface A{
default void hello(){
System.out.println("Hello from A");
}
}
public interface B extends A { }
public interface C extends A { }
public class D implements B, C {
public static void main(String... args) {
new D().hello(); *1*
}
}
- 1 打印了什么?
图 13.8 展示了此场景的 UML 图。这个问题被称为 钻石问题,因为图看起来像钻石。D 继承了哪个默认方法声明:来自 B 的还是来自 C 的?你只有一个方法声明可以选择。只有 A 声明了默认方法。因为接口是 D 的超接口,所以代码将打印 "来自 A 的问候"。
图 13.8. 钻石问题

现在,如果 B 也有一个具有相同签名的默认 hello 方法会发生什么?规则 2 表示你选择最具体的默认提供接口。因为 B 比较具体于 A,所以选择了来自 B 的默认方法声明。如果 B 和 C 都声明了一个具有相同签名的 hello 方法,你将有一个冲突,需要像我们之前展示的那样明确解决。
作为旁注,你可能想知道如果你在接口 C 中添加一个抽象的 hello 方法(一个非默认的方法)会发生什么(仍然没有在 A 和 B 中声明方法):
public interface C extends A {
void hello();
}
C 语言中新的 hello 抽象方法比接口 A 中的默认 hello 方法具有优先级,因为 C 更具体。因此,类 D 需要为 hello 方法提供一个显式的实现;否则,程序将无法编译。
C++ 钻石问题
C++ 中的菱形问题更为复杂。首先,C++ 允许类的多重继承。默认情况下,如果类 D 从类 B 和 C 继承,并且类 B 和 C 都从 A 继承,类 D 将访问一个 B 对象的副本和一个 C 对象的副本。因此,使用 A 的方法必须显式地限定:它们是从 B 还是 C 来的?此外,类具有状态,因此从 B 修改成员变量不会反映在 C 对象的副本中。
你已经看到,如果一个类从多个具有相同签名的默认方法继承,默认方法的解析机制很简单。系统地遵循三个规则来解决所有可能的冲突:
-
首先,在类或超类中显式的方法声明优先于任何默认方法声明。
-
否则,将选择在最具特定性的默认提供接口中具有相同签名的那个方法。
-
最后,如果仍然存在冲突,你必须显式地覆盖默认方法并选择你的类应该使用哪一个。
摘要
-
Java 8 中的接口可以通过默认方法和静态方法具有实现代码。
-
默认方法以
default关键字开头,并包含一个主体,就像类方法一样。 -
向已发布的接口添加抽象方法会导致源不兼容。
-
默认方法有助于库设计者以向后兼容的方式演进 API。
-
默认方法可用于创建可选方法和行为的多重继承。
-
当一个类从多个具有相同签名的默认方法继承时,存在解决冲突的规则。
-
在类或超类中的方法声明优先于任何默认方法声明。否则,将选择在最具特定性的默认提供接口中具有相同签名的那个方法。
-
当两个方法具有相同的特定性时,一个类必须显式地覆盖此方法,例如选择调用哪一个。
第十四章。Java 模块系统

本章涵盖
-
导致 Java 采用模块系统的进化力量
-
主要结构:模块声明和 requires 以及 exports 指令
-
自动模块化旧版 Java 归档 (JAR)
-
模块化与 JDK 库
-
模块和 Maven 构建
-
简要总结除简单的
requires和exports之外的其他模块指令
与 Java 9 一起引入的主要且最常讨论的新特性是其模块系统。该特性是在 Jigsaw 项目中开发的,其开发历时近十年。这个时间表是衡量这一新增功能重要性的良好指标,也是衡量 Java 开发团队在实现它时所遇到的困难的良好指标。本章提供了背景信息,说明为什么作为开发者你应该关心模块系统是什么,以及新的 Java 模块系统旨在做什么以及你如何从中受益。
注意,Java 模块系统是一个复杂的话题,值得一本书来专门讨论。我们推荐 Nicolai Parlog 的《Java 模块系统》(Manning Publications,www.manning.com/books/the-java-module-system)作为全面资源的参考。在本章中,我们故意保持广泛的概述,以便你理解主要动机,并快速了解如何使用 Java 模块。
14.1. 驱动力:对软件进行推理
在深入探讨 Java 模块系统的细节之前,了解一些动机和背景对于理解 Java 语言设计者设定的目标是有用的。模块化意味着什么?模块系统试图解决什么问题?本书花费了大量时间讨论新的语言特性,这些特性帮助我们编写更接近问题声明的代码,因此更容易理解和维护。然而,这种关注是低层次的。最终,在高层(软件架构层面),你希望与一个易于推理的软件项目合作,因为当你对你的代码库进行更改时,这会使你更有效率。在接下来的章节中,我们强调了两个有助于产生易于推理的软件的设计原则:关注点分离和信息隐藏。
14.1.1. 关注点分离
关注点分离(SoC)是一个促进将计算机程序分解为不同特性的原则。假设你需要开发一个会计应用程序,该程序可以解析不同格式的费用,分析它们,并向客户提供总结报告。通过应用 SoC,你将解析、分析和报告分解为称为 模块 的独立部分——具有很少重叠的代码集合。换句话说,模块将类分组,允许你表达应用程序中类之间的可见性关系。
你可能会说:“啊,但 Java 包已经将类分组。”你说得对,但 Java 9 模块让你能够更细粒度地控制哪些类可以看到哪些其他类,并允许在编译时检查这种控制。本质上,Java 包不支持模块化。
SoC 原则在架构观点(如模型与视图与控制器)和低级方法(如将业务逻辑与恢复机制分离)中都很有用。其好处包括
-
允许独立工作于各个部分,这有助于团队协作
-
促进独立部分的复用
-
更容易维护整体系统
14.1.2. 信息隐藏
信息隐藏是一种鼓励隐藏实现细节的原则。为什么这个原则很重要?在构建软件的背景下,需求可能会频繁变化。通过隐藏实现细节,你可以减少局部更改导致程序其他部分级联更改的可能性。换句话说,这是一个有用的原则,用于管理和保护你的代码。你经常听到封装这个术语用来表示特定的代码片段与应用程序的其他部分隔离得很好,以至于更改其内部实现不会对其产生负面影响。在 Java 中,你可以通过适当地使用private关键字来让编译器检查类内的组件是否得到了良好的封装。但直到 Java 9 之前,没有语言结构允许编译器检查类和包仅对预期目的可用。
14.1.3. Java 软件
这两个原则在任何设计良好的软件中都是基本的。它们如何与 Java 语言特性相匹配?Java 是一种面向对象的语言,你使用类和接口。你通过将处理特定问题的包、类和接口分组来使你的代码模块化。在实践中,对原始代码进行推理有点抽象。因此,像 UML 图(或者更简单地说,方框和箭头)这样的工具通过直观地表示代码各部分之间的依赖关系来帮助你推理你的软件。图 14.1 显示了一个将应用程序管理用户配置文件分解为三个特定关注点的 UML 图。
图 14.1. 具有依赖关系的三个独立关注点

关于信息隐藏,在 Java 中,你熟悉使用可见性修饰符来控制对方法、字段和类的访问:public、protected、包级别和 private。然而,正如我们在下一节中将要阐明的,在许多情况下,它们的粒度还不够精细,你可能被迫声明一个方法为 public,即使你并没有打算让它对最终用户可访问。在 Java 的早期,当应用程序和依赖链相对较小的时候,这个问题并不是很大。现在,随着许多 Java 应用程序变得庞大,这个问题变得更加重要。确实,如果你在一个类中看到一个public字段或方法,你可能觉得自己有权使用它(难道不是吗?),即使设计者可能认为它只适用于他自己的几个类中的私有使用!
现在你已经了解了模块化的好处,你可能想知道支持它如何导致 Java 发生变化。我们将在下一节中解释。
14.2. 为什么设计 Java 模块系统
在本节中,你将了解为什么为 Java 语言和编译器设计了新的模块系统。首先,我们介绍 Java 9 之前的模块化限制。接下来,我们提供关于 JDK 库的背景信息,并解释为什么模块化它很重要。
14.2.1. 模块化限制
不幸的是,在 Java 9 之前,Java 内置的用于帮助生成模块化软件项目的支持有限。Java 有三个级别来分组代码:类、包和 JAR。对于类,Java 一直支持访问修饰符和封装。然而,在包和 JAR 级别,封装很少。
有限的可见性控制
如前所述,Java 提供了访问修饰符来支持信息隐藏。这些修饰符是公共的、受保护的、包级别的和私有的可见性。但包之间的可见性控制怎么办?大多数应用程序定义了几个包来分组各种类,但包对可见性控制的支持有限。如果你想使一个包中的类和接口对另一个包可见,你必须将它们声明为公共的。因此,这些类和接口对每个人都是可访问的。这个问题的一个典型例子是,当你看到名称中包含字符串 "impl" 的伴随包,以提供默认实现。在这种情况下,因为该包内的代码被定义为公共的,你无法阻止用户使用这些内部实现。结果,如果不进行破坏性更改,就很难演进你的代码,因为你认为仅供内部使用的代码被程序员临时用来使某物工作,然后被冻结到系统中。更糟糕的是,从安全角度来看,这种情况很糟糕,因为你可能增加了攻击面,因为更多的代码暴露在篡改的风险中。
类路径
在本章前面,我们讨论了编写易于维护和理解的软件的好处——换句话说,更容易推理。我们还讨论了关注点的分离以及模块之间的依赖建模。不幸的是,在打包和运行应用程序时,Java 历史上在这些想法上的支持不足。事实上,你必须将所有编译后的类放入一个单一的扁平 JAR 文件中,然后通过类路径使其可访问.^([1]) 然后 JVM 可以根据需要动态定位和从类路径加载类。
¹
这种拼写用于 Java 文档中,但
classpath通常用于程序的参数。
不幸的是,类路径和 JAR 的组合有几个缺点。
首先,类路径没有相同类的版本概念。例如,你不能指定解析库中的类 JSONParser 应属于版本 1.0 或版本 2.0,因此你无法预测如果类路径上有两个不同版本的相同库会发生什么。这种情况在大型应用程序中很常见,因为你可能使用不同版本的相同库来支持应用程序的不同组件。
其次,类路径不支持显式依赖项;不同 JAR 中的所有类都被合并到类路径上的一个类集合中。换句话说,类路径不允许你明确声明一个 JAR 依赖于另一个 JAR 中包含的一组类。这种情况使得推理类路径和提出诸如:
-
任何东西遗漏了吗?
-
有任何冲突吗?
建设工具如 Maven 和 Gradle 可以帮助你解决这个问题。然而,在 Java 9 之前,Java 和 JVM 都没有对显式依赖项的支持。这些问题通常被称为 JAR 地狱或类路径地狱。这些问题的直接后果是,在尝试和错误的过程中,通常需要不断在类路径上添加和删除类文件,希望 JVM 能够在没有抛出如 ClassNotFound-Exception 等运行时异常的情况下执行你的应用程序。理想情况下,你希望这些问题在开发早期就被发现。一致地使用 Java 9 模块系统可以在编译时检测到所有这些错误。
封装和类路径地狱不仅仅是你的软件架构的问题。那么 JDK 本身呢?
14.2.2. 单一 JDK
Java 开发工具包(JDK)是一组工具,它允许你使用和运行 Java 程序。你可能最熟悉的工具是 javac,用于编译 Java 程序,以及 java,用于加载和运行 Java 应用程序,还有 JDK 库,它提供了包括输入/输出、集合和流在内的运行时支持。第一个版本于 1996 年发布。重要的是要理解,就像任何软件一样,JDK 已经增长并显著增加了大小。许多技术被添加,后来被弃用。CORBA 是一个很好的例子。无论你在应用程序中使用 CORBA 还是未使用,其类都包含在 JDK 中。这种情况在运行在移动设备或云上的应用程序中尤其成问题,这些应用程序通常不需要 JDK 库中所有可用的部分。
作为整个生态系统,你如何避免这个问题?Java 8 引入了 紧凑配置文件 的概念,作为向前迈出的一步。引入了三个配置文件,以便根据你感兴趣的 JDK 库的不同部分具有不同的内存占用。然而,紧凑配置文件只提供了短期解决方案。JDK 中的许多内部 API 都不是为公共使用而设计的。不幸的是,由于 Java 语言提供的封装性较差,这些 API 通常被广泛使用。例如,sun.misc.Unsafe 类被几个库(包括 Spring、Netty 和 Mockito)使用,但从未打算在 JDK 内部之外提供。因此,在不引入不兼容更改的情况下,很难演进这些 API。
所有这些问题都为设计一个 Java 模块系统提供了动力,该系统也可以用来模块化 JDK 本身。简而言之,需要新的结构化构造来允许您选择需要 JDK 的哪些部分以及如何对类路径进行推理,并提供更强的封装以演进平台。
14.2.3. 与 OSGi 的比较
本节比较了 Java 9 模块与 OSGi。如果您还没有听说过 OSGi,我们建议您跳过本节。
在基于项目 Jigsaw 的模块引入 Java 9 之前,Java 已经有一个强大的模块系统,名为 OSGi,即使它不是 Java 平台正式的一部分。开放服务网关倡议(OSGi)始于 2000 年,直到 Java 9 的到来,它代表了在 JVM 上实现模块化应用程序的事实标准。
在现实中,OSGi 和新的 Java 9 模块系统并不是相互排斥的;它们可以在同一个应用程序中共存。事实上,它们的功能只是部分重叠。OSGi 具有更广泛的范围,并提供了许多 Jigsaw 中不可用的功能。
OSGi 模块被称为bundles,并在特定的 OSGi 框架中运行。存在几个认证的 OSGi 框架实现,但最广泛采用的两个是 Apache Felix 和 Equinox(它也被用来运行 Eclipse IDE)。当在 OSGi 框架中运行时,单个捆绑包可以远程安装、启动、停止、更新和卸载,而无需重启。换句话说,OSGi 为捆绑包定义了一个清晰的周期,这些状态列在表 14.1 中。
表 14.1. OSGi 中的捆绑包状态
| 捆绑包状态 | 描述 |
|---|---|
| 已安装 | 该捆绑包已成功安装。 |
| 解析中 | 捆绑包需要的所有 Java 类都可用。 |
| 启动中 | 捆绑包正在启动,已调用 BundleActivator.start 方法,但启动方法尚未返回。 |
| 活跃 | 该捆绑包已成功激活并正在运行。 |
| 停止中 | 捆绑包正在停止。已调用 BundleActivator.stop 方法,但停止方法尚未返回。 |
| 未安装 | 该捆绑包已被卸载。它不能移动到另一个状态。 |
无需重新启动应用程序即可热插拔不同的子部分,这可能是 OSGi 相对于 Jigsaw 的主要优势。每个捆绑包通过一个文本文件定义,该文件描述了捆绑包需要哪些外部包才能工作,以及捆绑包公开导出哪些内部包并将其提供给其他捆绑包。
OSGi 的另一个有趣的特点是它允许在框架中同时安装同一捆绑包的不同版本。Java 9 模块系统不支持版本控制,因为 Jigsaw 仍然为每个应用程序使用一个单独的类加载器,而 OSGi 为每个捆绑包加载其自己的类加载器。
14.3. Java 模块:整体情况
Java 9 提供了一个新的 Java 程序结构单元:模块。模块通过一个新的关键字 module 引入,后跟其名称和其主体。这样的 模块描述符 生活在一个特殊的文件中:module-info.java,它被编译成 module-info.class。模块描述符的主体由子句组成,其中最重要的两个是 requires 和 exports。前者子句指定了您的模块需要哪些其他模块来运行,而 exports 指定了您的模块希望对其他模块可见的所有内容。您将在后面的章节中详细了解这些子句。
²
技术上,Java 9 模块形成标识符(如
module、requires和export)是 受限关键字。您仍然可以在程序的其他地方使用它们作为标识符(为了向后兼容),但在允许模块的上下文中它们被解释为关键字。³
法律上,文本形式被称为 模块声明,而在
module-info.class中的二进制形式被称为 模块描述符。
模块描述符描述并封装了一个或多个包(通常与这些包位于同一文件夹中),但在简单用例中,它只导出(使可见)这些包中的一个。
Java 模块描述符的核心结构如图 图 14.2 所示。
图 14.2. Java 模块描述符的核心结构 (module-info.java)

将模块的 exports 和 requires 部分想象成拼图(或许这就是 Project Jigsaw 这个工作名称的由来)的凸起(或标签)和凹槽是很有帮助的。图 14.3 展示了几个模块的示例。
图 14.3. 由四个模块(A、B、C、D)构建的 Java 系统的拼图风格示例。模块 A 需要模块 B 和 C 存在,从而获得对由模块 B 和 C 分别导出的包 pkgB 和 pkgC 的访问。模块 C 可以类似地使用它从模块 C 所需的包 pkgD,但模块 B 不能使用 pkgD。

当您使用 Maven 等工具时,模块描述的许多细节由 IDE 处理,并从用户那里隐藏起来。
话虽如此,在下一节中,我们将通过示例更详细地探讨这些概念。
14.4. 使用 Java 模块系统开发应用程序
在本节中,您将通过从头开始构建一个简单的模块化应用程序来概述 Java 9 模块系统。您将学习如何构建、打包和启动一个小型模块化应用程序。本节不会详细解释每个主题,而是展示整体情况,以便您在需要时可以独立深入探究。
14.4.1. 设置应用程序
要开始使用 Java 模块系统,您需要一个示例项目来编写代码。也许您经常出差,去杂货店购物,或者和朋友们一起喝咖啡,您不得不处理大量的收据。没有人喜欢管理费用。为了帮助自己,您编写了一个可以管理费用的应用程序。该应用程序需要执行以下任务:
-
从文件或 URL 读取费用列表;
-
解析这些费用的字符串表示;
-
计算统计数据;
-
显示有用的摘要;
-
为这些任务提供一个主要的启动和关闭协调器。
您需要定义不同的类和接口来建模此应用程序中的概念。首先,一个Reader接口允许您从源读取序列化的费用。您将拥有不同的实现,例如HttpReader或FileReader,具体取决于源。您还需要一个Parser接口将 JSON 对象反序列化为可以在 Java 应用程序中操作的领域对象Expense。最后,您需要一个SummaryCalculator类来负责根据Expense对象列表计算统计数据,并返回SummaryStatistics对象。
现在您有一个项目,您如何使用 Java 模块系统对其进行模块化?显然,项目涉及多个关注点,您希望将其分离:
-
从不同的源读取数据(
Reader,HttpReader,FileReader) -
解析来自不同格式的数据(
Parser,JSONParser,ExpenseJSON-Parser) -
表示领域对象(
Expense) -
计算并返回统计数据(
SummaryCalculator,SummaryStatistics) -
协调不同的关注点(
ExpensesApplication)
在这里,出于教学目的,我们将采取细粒度方法。您可以将每个关注点分组到一个单独的模块中,如下所示(我们将在稍后更详细地讨论模块命名方案):
-
expenses.readers
-
expenses.readers.http
-
expenses.readers.file
-
expenses.parsers
-
expenses.parsers.json
-
expenses.model
-
expenses.statistics
-
expenses.application
对于这个简单的应用程序,您采用细粒度分解来展示模块系统的不同部分。在实践中,对于一个简单的项目来说,采取这样的细粒度方法可能会带来高昂的前期成本,而收益可能有限,即正确封装项目的小部分。然而,随着项目的增长和更多内部实现的添加,封装和推理的好处变得更加明显。您可以想象前面的列表作为一个包列表,这取决于您的应用程序边界。一个模块将一系列包分组在一起。也许每个模块都有特定实现的包,您不希望将其暴露给其他模块。例如,expenses.statistics模块可能包含用于不同实验统计方法实现的几个包。稍后,您可以决定将这些包中的哪些发布给用户。
14.4.2. 精细和粗粒度模块化
当您对系统进行模块化时,可以选择粒度。在最精细的方案中,每个包都有自己的模块(如前一章所述);在最粗粒度的方案中,单个模块包含系统中的所有包。如前所述,第一个方案增加了设计成本,但收益有限,第二个方案则失去了模块化的所有好处。最佳选择是将系统分解成模块,并辅以定期的审查过程,以确保不断发展的软件项目保持足够的模块化,这样您可以继续对其进行分析和修改。
简而言之,模块化是软件生锈的敌人。
14.4.3. Java 模块系统基础
让我们从基本的模块化应用程序开始,它只有一个模块来支持主应用程序。项目目录结构如下,每个级别嵌套在一个目录中:
|─ expenses.application
|─ module-info.java
|─ com
|─ example
|─ expenses
|─ application
|─ ExpensesApplication.java
您已经注意到了这个神秘的 module-info.java 文件,它是项目结构的一部分。此文件是一个模块描述符,正如我们在本章前面所解释的,它必须位于模块源代码文件层次结构的根目录,以便您可以指定模块的依赖关系以及您想要公开的内容。对于您的支出应用程序,顶层的 module-info.java 文件包含一个模块描述,它有一个名称,但除此之外是空的,因为它既不依赖于任何其他模块,也不将其功能公开给其他模块。您将在稍后学习更复杂的特性,从第 14.5 节开始。module-info.java 的内容如下:
module expenses.application {
}
如何运行一个模块化应用程序?查看一些命令来了解底层部分。此代码由您的 IDE 和构建系统自动化,但了解正在发生的事情是有用的。当您处于项目模块源代码目录中时,请运行以下命令:
javac module-info.java
com/example/expenses/application/ExpensesApplication.java -d target
jar cvfe expenses-application.jar
com.example.expenses.application.ExpensesApplication -C target
这些命令生成的输出类似于以下内容,显示了哪些文件夹和类文件被纳入生成的 JAR (expenses-application.jar):
added manifest
added module-info: module-info.class
adding: com/(in = 0) (out= 0)(stored 0%)
adding: com/example/(in = 0) (out= 0)(stored 0%)
adding: com/example/expenses/(in = 0) (out= 0)(stored 0%)
adding: com/example/expenses/application/(in = 0) (out= 0)(stored 0%)
adding: com/example/expenses/application/ExpensesApplication.class(in = 456)
(out= 306)(deflated 32%)
最后,您以模块化应用程序的形式运行生成的 JAR:
java --module-path expenses-application.jar \
--module expenses/com.example.expenses.application.ExpensesApplication
您应该熟悉前两个步骤,它们代表将 Java 应用程序打包成 JAR 的标准方式。唯一的新部分是文件 module-info.java 成为编译步骤的一部分。
运行 Java .class 文件的 java 程序有两个新选项:
-
--module-path—此选项指定可加载的模块。此选项与--classpath参数不同,后者使类文件可用。 -
--module—此选项指定要运行的主模块和类。
模块的声明不包含版本字符串。解决版本选择问题并不是 Java 9 模块系统的特定设计点,因此不支持版本控制。理由是这个问题是构建工具和容器应用程序需要解决的问题。
14.5. 与多个模块一起工作
现在你已经知道如何使用一个模块设置基本的应用程序,你就可以使用多个模块做一些更实际的事情了。你希望你的支出应用程序能够从一个源读取支出。为此,引入一个新的模块 expenses.readers,它封装了这些职责。两个模块 expenses.application 和 expenses.readers 之间的交互由 Java 9 的 exports 和 requires 子句指定。
14.5.1. exports 子句
这是我们如何声明模块 expenses.readers 的示例。(现在不用担心语法和概念;我们将在稍后介绍这些主题。)
module expenses.readers {
exports com.example.expenses.readers; *1*
exports com.example.expenses.readers.file; *1*
exports com.example.expenses.readers.http; *1*
}
- 1 这些是包名称,而不是模块名称。
有一个新特性:exports 子句,它使得特定包中的公共类型可供其他模块使用。默认情况下,所有内容都被封装在一个模块中。模块系统采用白名单方法,这有助于你获得强大的封装,因为你需要明确决定什么可以供其他模块使用。(这种方法可以防止你意外地导出一些内部特性,黑客可以利用这些特性在几年后破坏你的系统。)
你的项目两个模块版本的目录结构现在看起来是这样的:
|─ expenses.application
|─ module-info.java
|─ com
|─ example
|─ expenses
|─ application
|─ ExpensesApplication.java
|─ expenses.readers
|─ module-info.java
|─ com
|─ example
|─ expenses
|─ readers
|─ Reader.java
|─ file
|─ FileReader.java
|─ http
|─ HttpReader.java
14.5.2. requires 子句
或者,你可以这样编写 module-info.java:
module expenses.readers {
requires java.base; *1*
exports com.example.expenses.readers; *2*
exports com.example.expenses.readers.file; *2*
exports com.example.expenses.readers.http; *2*
}
-
1 这是一个模块名称,而不是包名称。
-
2 这是一个包名称,而不是模块名称。
新的元素是 requires 子句,它允许你指定模块所依赖的内容。默认情况下,所有模块都依赖于一个名为 java.base 的平台模块,该模块包括 net、io 和 util 等 Java 主包。这个模块默认总是需要的,所以你不需要明确地说明。(这类似于在 Java 中说 "class Foo { ... }" 等同于说 "class Foo extends Object { ... }"。)
当你需要导入除 java.base 之外的其他模块时,这变得很有用。
requires 和 exports 子句的组合使得 Java 9 中类的访问控制更加复杂。表 14.2 总结了在 Java 9 之前和之后不同访问修饰符下的可见性差异。
表 14.2. Java 9 提供了对类可见性的更精细控制
| 类可见性 | 在 Java 9 之前 | 在 Java 9 之后 |
|---|---|---|
| 对所有人公开的所有类 | ![]() |
(exports 和 requires 子句的组合) |
| 公共类数量有限 | ![]() |
(exports 和 requires 子句的组合) |
| 仅在单个模块内部公开 | ![]() |
(没有导出条款) |
| 受保护的 | ![]() |
![]() |
| 包 | ![]() |
![]() |
| 私有 | ![]() |
![]() |
14.5.3. 命名
在这个阶段,对模块的命名约定进行评论是有用的。我们采用了简短的方法(例如,expenses.application),以免混淆模块和包的概念。(一个模块可以导出多个包。)然而,推荐的约定是不同的。
Oracle 建议你按照与用于包的相同反向互联网域名约定(例如,com.iteratrlearning.training)来命名模块。此外,模块的名称应与其主要导出 API 包相对应,该包也应遵循该约定。如果一个模块没有该包,或者由于其他原因需要与它的导出包之一不对应的名称,它应该以与其作者相关的互联网域名的反向形式开头。
现在你已经学会了如何设置一个包含多个模块的项目,那么如何打包和运行它呢?我们将在下一节中介绍这个主题。
14.6. 编译和打包
现在你已经熟悉了设置项目和声明模块,你就可以看到如何使用像 Maven 这样的构建工具来编译你的项目了。本节假设你熟悉 Maven,它是 Java 生态系统中最常见的构建工具之一。另一个流行的构建工具是 Gradle,如果你还没有听说过它,我们鼓励你探索一下。
首先,你需要为每个模块引入一个pom.xml文件。实际上,每个模块都可以独立编译,使其表现得像一个独立的项目。你还需要为所有模块的父项目添加一个pom.xml,以协调整个项目的构建。现在的整体结构如下所示:
|─ pom.xml
|─ expenses.application
|─ pom.xml
|─ src
|─ main
|─ java
|─ module-info.java
|─ com
|─ example
|─ expenses
|─ application
|─ ExpensesApplication.java
|─ expenses.readers
|─ pom.xml
|─ src
|─ main
|─ java
|─ module-info.java
|─ com
|─ example
|─ expenses
|─ readers
|─ Reader.java
|─ file
|─ FileReader.java
|─ http
|─ HttpReader.java
注意到三个新的pom.xml文件和 Maven 目录项目结构。模块描述符(module-info.java)需要位于src/main/java目录中。Maven 将设置javac以使用适当的模块源路径。
expenses.readers项目的pom.xml文件如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>expenses.readers</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<parent>
<groupId>com.example</groupId>
<artifactId>expenses</artifactId>
<version>1.0</version>
</parent>
</project>
需要注意的重要事项是,此代码明确提到了父模块以帮助构建过程。父模块是 ID 为expenses的工件。你需要在pom.xml中定义父模块,正如你很快就会看到的。
接下来,你需要指定expenses.application模块的pom.xml。这个文件与前面的类似,但你必须添加对expenses.readers项目的依赖,因为ExpensesApplication需要它包含的类和接口来编译:
<?xml version="1.0" encoding="UTF-8"?>
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>expenses.application</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<parent>
<groupId>com.example</groupId>
<artifactId>expenses</artifactId>
<version>1.0</version>
</parent>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>expenses.readers</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
现在有两个模块,expenses.application 和 expenses.readers,它们都有自己的 pom.xml,你可以设置全局的 pom.xml 来指导构建过程。Maven 支持具有多个 Maven 模块的工程,使用特殊的 XML 元素 <module>,它引用子项目的 artifact IDs。以下是完整的定义,它引用了两个子模块 expenses.application 和 expenses.readers:
<?xml version="1.0" encoding="UTF-8"?>
<project
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>expenses</artifactId>
<packaging>pom</packaging>
<version>1.0</version>
<modules>
<module>expenses.application</module>
<module>expenses.readers</module>
</modules>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>9</source>
<target>9</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
恭喜!现在你可以运行命令 mvn clean package 来生成项目中模块的 JAR 文件。此命令生成
./expenses.application/target/expenses.application-1.0.jar
./expenses.readers/target/expenses.readers-1.0.jar
你可以通过以下方式在模块路径上包含这两个 JAR 来运行你的模块应用程序:
java --module-path \
./expenses.application/target/expenses.application-1.0.jar:\
./expenses.readers/target/expenses.readers-1.0.jar \
--module \
expenses.application/com.example.expenses.application.ExpensesApplication
到目前为止,你已经学习了关于你创建的模块,你也看到了如何使用 requires 来引用 java.base。然而,现实世界的软件依赖于外部模块和库。这个过程是如何工作的,如果遗留库没有使用显式的 module-info.java 进行更新怎么办?在下一节中,我们将通过介绍自动模块来回答这些问题。
14.7. 自动模块
你可能认为你的 HttpReader 的实现是低级的;相反,你可能想使用来自 Apache 项目的专用库,比如 httpclient。你如何将这个库集成到你的项目中?你已经学习了 requires 子句,所以尝试在 expenses.readers 项目的 module-info.java 中添加它。再次运行 mvn clean package 来查看结果。不幸的是,结果是坏消息:
[ERROR] module not found: httpclient
你得到这个错误是因为你还需要更新你的 pom.xml 来声明依赖。当你构建具有 module-info.java 的项目时,maven 编译器插件会将所有依赖项放在模块路径上,以便在项目中下载和识别适当的 JAR,如下所示:
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.3</version>
</dependency>
</dependencies>
现在运行 mvn clean package 可以正确构建项目。不过,要注意一个有趣的现象:库 httpclient 不是一个 Java 模块。它是一个你希望作为模块使用的外部库,但它还没有被模块化。Java 将适当的 JAR 转换为所谓的自动模块。模块路径上的任何没有 module-info 文件的 JAR 都成为自动模块。这个自动模块隐式导出所有其包。这个自动模块的名称是自动生成的,从 JAR 名称派生而来。你有几种方法可以派生名称,但最简单的方法是使用带有 --describe-module 参数的 jar 工具:
jar --file=./expenses.readers/target/dependency/httpclient-4.5.3.jar \
--describe-module
httpclient@4.5.3 automatic
在这种情况下,名称是 httpclient。
最后一步是运行应用程序并将 httpclient JAR 添加到模块路径中:
java --module-path \
./expenses.application/target/expenses.application-1.0.jar:\
./expenses.readers/target/expenses.readers-1.0.jar \
./expenses.readers/target/dependency/httpclient-4.5.3.jar \
--module \
expenses.application/com.example.expenses.application.ExpensesApplication
注意
有一个项目(github.com/moditect/moditect)旨在为 Maven 中的 Java 9 模块系统提供更好的支持,例如自动生成 module-info 文件。
14.8. 模块声明和子句
Java 模块系统是一个庞大的系统。正如我们之前提到的,如果你想要深入了解这个主题,我们建议你阅读一本关于该主题的专著。尽管如此,本节为你提供了一个关于模块声明语言中可用其他关键字的大致概述,以帮助你了解可能实现的功能。
如你在前面的章节中学到的,你通过使用module指令来声明一个模块。在这里,它的名字是com.iteratrlearning.application:
module com.iteratrlearning.application {
}
模块声明中可以包含什么内容?你已经了解了requires和exports子句,但还有其他子句,包括requires-transitive、exports-to、open、opens、uses和provides。我们将在以下章节逐一介绍这些子句。
14.8.1. requires
requires子句允许你在编译时和运行时指定你的模块依赖于另一个模块。例如,模块com.iteratrlearning.application依赖于模块com.iteratrlearning.ui:。
module com.iteratrlearning.application {
requires com.iteratrlearning.ui;
}
结果是,只有由com.iteratrlearning.ui导出的公共类型才对com.iteratrlearning.application可用。
14.8.2. exports
exports子句使特定包中的公共类型可供其他模块使用。默认情况下,没有包被导出。通过明确指定应该导出哪些包,你可以获得强大的封装性。在以下示例中,导出了包com.iteratrlearning.ui.panels和com.iteratrlearning.ui.widgets。(注意,exports接受一个包名作为参数,而requires接受一个模块名,尽管它们有相似的命名方案。)
module com.iteratrlearning.ui {
requires com.iteratrlearning.core;
exports com.iteratrlearning.ui.panels;
exports com.iteratrlearning.ui.widgets;
}
14.8.3. requires transitive
你可以指定一个模块可以使用另一个模块所需的公共类型。例如,你可以在模块com.iteratrlearning.ui的声明中修改requires子句,将其设置为requires-transitive:
module com.iteratrlearning.ui {
requires transitive com.iteratrlearning.core;
exports com.iteratrlearning.ui.panels;
exports com.iteratrlearning.ui.widgets;
}
module com.iteratrlearning.application {
requires com.iteratrlearning.ui;
}
结果是,模块com.iteratrlearning.application可以访问由com.iteratrlearning.core.导出的公共类型。当所需的模块(这里为com.iteratrlearning.ui)返回由该模块(com.iteratrlearning.core)所需的另一个模块的类型时,传递性是有用的。在模块com.iteratrlearning.application内部重新声明requires com.iteratrlearning.core会非常麻烦。这个问题通过transitive得到了解决。现在,任何依赖于com.iteratrlearning.ui的模块都会自动读取com.iteratrlearning.core模块。
14.8.4. exports to
你有进一步的可见性控制级别,可以通过使用exports to结构来限制特定导出的允许用户。正如你在第 14.8.2 节中看到的,你可以通过调整模块声明来限制com.iteratrlearning.ui.widgets的允许用户为com .iteratrlearning.ui.widgetuser:
module com.iteratrlearning.ui {
requires com.iteratrlearning.core;
exports com.iteratrlearning.ui.panels;
exports com.iteratrlearning.ui.widgets to
com.iteratrlearning.ui.widgetuser;
}
14.8.5. open 和 opens
在模块声明中使用 open 限定符,允许其他模块以反射方式访问其所有包。open 限定符除了允许反射访问之外,对模块可见性没有影响,正如以下示例所示:
open module com.iteratrlearning.ui {
}
在 Java 9 之前,你可以通过反射检查对象的私有状态。换句话说,没有什么真正是封装的。对象关系映射(ORM)工具,如 Hibernate,经常使用这种能力直接访问和修改状态。在 Java 9 中,默认不再允许使用反射。前面代码中的 open 子句用于在需要时允许这种行为。
你可以不打开整个模块以供反射,而可以在模块声明中使用 opens 子句单独打开其包,按需进行。你还可以在 opens-to 变体的 to 限定符中使用,以限制允许执行反射访问的模块,类似于 exports-to 限制允许 require 导出包的模块。
14.8.6. 使用和提供
如果你熟悉服务和 ServiceLoader,Java 模块系统允许你使用 provides 子句指定模块作为服务提供者,使用 uses 子句指定服务消费者。然而,这个主题是高级的,超出了本章的范围。如果你对结合模块和服务加载器感兴趣,我们建议你阅读前面章节中提到的由 Nicolai Parlog(Manning Publications)所著的全面资源《Java 模块系统》。
14.9. 一个更大的示例以及如何了解更多
你可以从以下示例中感受到模块系统的风味,该示例取自 Oracle 的 Java 文档。此示例展示了使用本章讨论的大多数特性的模块声明。这个例子并不是为了吓唬你(绝大多数模块语句都是简单的导出和需求),但它让你看到了一些更丰富的特性:
module com.example.foo {
requires com.example.foo.http;
requires java.logging;
requires transitive com.example.foo.network;
exports com.example.foo.bar;
exports com.example.foo.internal to com.example.foo.probe;
opens com.example.foo.quux;
opens com.example.foo.internal to com.example.foo.network,
com.example.foo.probe;
uses com.example.foo.spi.Intf;
provides com.example.foo.spi.Intf with com.example.foo.Impl;
}
本章讨论了引入新的 Java 模块系统的必要性,并对其主要特性进行了温和的介绍。我们没有涵盖许多特性,包括服务加载器、额外的模块描述符子句以及用于处理模块的工具,如 jdeps 和 jlink。如果你是 Java EE 开发者,在将你的应用程序迁移到 Java 9 时,重要的是要记住,与 EE 相关的几个包在模块化的 Java 9 虚拟机中默认不加载。例如,JAXB API 类现在被认为是 Java EE API,并且不再在 Java SE 9 的默认类路径中可用。你需要通过使用 --add-modules 命令行开关显式添加感兴趣的模块以保持兼容性。例如,要添加 java.xml.bind,你需要指定 --add-modules java.xml.bind。
正如我们之前提到的,要公正地对待 Java 模块系统,需要一本书,而不仅仅是一章。为了更深入地探讨细节,我们建议阅读之前在本章中提到的 Nicolai Parlog(Manning Publications)所著的《The Java Module System》一书。
摘要
-
关注点分离和信息隐藏是两个重要的原则,有助于构建你可以推理的软件。
-
在 Java 9 之前,你通过引入具有特定关注点的包、类和接口来使代码模块化,但这些元素对于有效的封装还不够丰富。
-
类路径地狱问题使得推理应用程序的依赖变得困难。
-
在 Java 9 之前,JDK 是单一的,这导致了高昂的维护成本和受限的进化。
-
Java 9 引入了一个新的模块系统,其中
module-info.java文件命名了一个模块并指定了其依赖(requires)和公共 API(exports)。 -
requires子句允许你指定对其他模块的依赖。 -
exports子句使得模块中特定包的公共类型对其他模块可用。 -
模块的推荐命名约定遵循反向互联网域名约定。
-
模块路径上的任何没有
module-info文件的 JAR 都成为自动模块。 -
自动模块隐式导出它们所有的包。
-
Maven 支持使用 Java 9 模块系统构建的应用程序。
第五部分. 加强版 Java 并发
本书第五部分探讨了在 Java 中构建并发程序的更高级方法——超越第六章和第七章中引入的易于使用的流并行处理理念。再次强调,本书其余部分的内容不依赖于本部分,因此如果您(目前)不需要探索这些想法,请随时跳过这部分。
第十五章是第二版的新增内容,涵盖了异步 API 的“大局”理念,包括 Futures 和反应式编程背后的发布-订阅协议,这些都被封装在 Java 9 Flow API 中。
第十六章探讨了CompletableFuture,它允许您以声明式的方式表达复杂的异步计算,与 Streams API 的设计相呼应。
第十七章也是第二版的新增内容,详细探讨了 Java 9 Flow API,重点关注实用的反应式编程代码。
第十五章. CompletableFuture 和反应式编程背后的概念
本章涵盖
-
线程、未来以及导致 Java 支持更丰富并发 API 的进化力量
-
异步 API
-
并发计算的框-通道视图
-
用于动态连接框的 CompletableFuture 组合器
-
构成 Java 9 Flow API 反应式编程基础的发布-订阅协议
-
反应式编程和反应式系统
近年来,两个趋势迫使开发者重新思考软件的编写方式。第一个趋势与运行应用程序的硬件有关,第二个趋势涉及应用程序的结构(尤其是它们如何交互)。我们在第七章中讨论了硬件趋势的影响。我们指出,自从多核处理器问世以来,提高应用程序速度最有效的方法是编写能够充分利用多核处理器的软件。您了解到,您可以分解大任务,并使每个子任务与其他子任务并行运行。您还学习了如何使用自 Java 7 以来可用的 fork/join 框架和 Java 8 中的并行流以比直接操作线程更简单、更有效的方式完成此任务。
第二个趋势反映了应用程序对互联网服务的可用性和使用的增加。例如,微服务架构在过去几年中得到了增长。你的应用程序不再是单一的大型应用,而是被细分为更小的服务。这些较小服务的协调需要增加网络通信。同样,许多互联网服务通过公共 API 提供,由知名提供商如谷歌(本地化信息)、Facebook(社交信息)和 Twitter(新闻)提供。如今,开发一个完全独立工作的网站或网络应用相对罕见。你的下一个网络应用更有可能是一个混合应用,使用来自多个来源的内容,并将其聚合以简化用户的生活。
你可能想要构建一个网站,收集并总结特定主题在法国用户中的社交媒体情绪。为此,你可以使用 Facebook 或 Twitter API 来查找关于该主题的多种语言的趋势评论,并使用你内部的算法对最相关的评论进行排序。然后你可能使用谷歌翻译将评论翻译成法语或使用谷歌地图定位其作者,聚合所有这些信息,并在你的网站上显示。
如果这些外部网络服务响应缓慢,当然,你将希望向用户提供部分结果,例如,在地图服务器响应或超时之前显示一个带有问号的通用地图,而不是显示一个空白屏幕。图 15.1 说明了这种风格的mashup应用如何与远程服务交互。
图 15.1. 一个典型的混合应用

要实现这样的应用程序,你必须联系互联网上的多个网络服务。但你不想阻塞你的计算并浪费 CPU 宝贵的数十亿时钟周期等待这些服务的答案。例如,你不应该在处理来自 Twitter 的数据之前等待来自 Facebook 的数据。
这种情况代表了多任务编程的另一面。在第七章中讨论的 fork/join 框架和并行流是并行化的宝贵工具;它们将任务分解为多个子任务,并在不同的核心、CPU 或甚至机器上并行执行这些子任务。
相反,当你处理并发而不是并行时,或者当你主要的目标是在同一 CPU 上执行几个松散相关的任务,尽可能让它们的内核忙碌以最大化应用程序的吞吐量时,你想要避免阻塞线程并浪费其计算资源,在等待(可能相当长一段时间)远程服务或查询数据库的结果。
Java 为这种情形提供了两个主要的工具集。首先,正如你将在第十六章和第十七章中看到的,Future接口,特别是其 Java 8 的CompletableFuture实现,通常提供简单而有效的解决方案(第十六章)。最近,Java 9 增加了响应式编程的概念,它围绕所谓的发布-订阅协议通过Flow API 构建,提供了更复杂的编程方法(第十七章)。
图 15.2 展示了并发与并行之间的区别。并发是一种编程属性(重叠执行),即使对于单核机器也可以发生,而并行性是执行硬件的属性(同时执行)。
图 15.2. 并发与并行对比

本章的其余部分解释了支撑 Java 新 CompletableFuture 和 Flow API 的基本思想。
我们首先解释 Java 在并发方面的演变,包括线程和高级抽象,如线程池和 Future(第 15.1 节)。我们注意到第七章主要讨论了在循环程序中使用并行性。第 15.2 节探讨了如何更好地利用并发进行方法调用。第 15.3 节提供了一种图示方法,将程序的某些部分视为通过通道进行通信的盒子。第 15.4 节和第 15.5 节探讨了 Java 8 和 9 中的 CompletableFuture 和响应式编程原则。最后,[第 15.6 节解释了响应式系统与响应式编程之间的区别。
读者指南
本章包含很少的实际 Java 代码。我们建议只想看代码的读者跳转到第十六章和第十七章。另一方面,正如我们所有人所发现的,实现不熟悉想法的代码可能难以理解。因此,我们使用简单的函数并包含图表来解释大局,例如 Flow API 背后的发布-订阅协议,该协议捕获了响应式编程。
我们通过一个运行示例来举例说明大多数概念,展示如何使用各种 Java 并发特性来计算表达式,如f(x)+g(x),然后返回或打印结果——假设f(x)和g(x)是长时间运行的计算。
15.1. 不断发展的 Java 对表达并发的支持
Java 在支持并发编程方面已经取得了很大的进步,这主要反映了过去 20 年来硬件、软件系统和编程概念的变化。总结这一演变可以帮助你理解新添加内容的原因以及它们在编程和系统设计中的作用。
最初,Java 有锁(通过 synchronized 类和方法)、Runnables 和 Threads。在 2004 年,Java 5 引入了 java.util.concurrent 包,它支持更丰富的并发性,特别是 ExecutorService 接口(它将任务提交与线程执行解耦),以及 Callable<T> 和 Future<T>,它们是 Runnable 和 Thread 的高级和返回结果的变体,并使用了泛型(也是在 Java 5 中引入的)。ExecutorServices 可以执行 Runnables 和 Callables。这些特性促进了随后一年出现的多核 CPU 上的并行编程。说实话,没有人喜欢直接与线程打交道!
¹
ExecutorService接口通过submit方法扩展了Executor接口以运行Callable;Executor接口仅有一个execute方法用于Runnables。
Java 的后续版本继续增强并发支持,因为随着程序员需要有效地编程多核 CPU,这种需求变得越来越迫切。正如您在 第七章 中所看到的,Java 7 添加了 java.util.concurrent.Recursive-Task 以支持分治算法的 fork/join 实现,Java 8 添加了对 Streams 和它们并行处理的支持(建立在新添加的 lambda 支持之上)。
Java 通过提供对 组合 未来的支持(通过 Java 8 的 CompletableFuture 实现 Future,第 15.4 节 和 第十六章),进一步丰富了其并发特性,并且 Java 9 提供了对分布式异步编程的显式支持。这些 API 为您提供了构建本章引言中提到的混合应用的心理模型和工具集。在那里,应用程序通过联系各种网络服务并在实时中为用户组合它们的信息,或者将其作为进一步的网络服务公开来工作。这个过程被称为 响应式编程,Java 9 通过 发布-订阅协议(由 java.util.concurrent.Flow 接口指定;参见 第 15.5 节 和 第十七章)提供了对它的支持。CompletableFuture 和 java.util.concurrent.Flow 的一个关键概念是提供编程结构,使得尽可能独立地执行任务,并以一种能够尽可能充分利用多核或多机提供的并行性的方式执行。
15.1.1. 线程和高级抽象
我们中的许多人都是从操作系统课程中了解到线程和进程的。单核 CPU 计算机可以支持多个用户,因为它的操作系统为每个用户分配一个进程。操作系统为这些进程提供单独的虚拟地址空间,这样两个用户就会感觉他们是计算机的唯一用户。操作系统通过定期唤醒以在进程之间共享 CPU 来进一步这种错觉。一个进程可以请求操作系统为其分配一个或多个线程——这些线程与拥有它们的进程共享地址空间,因此可以并发和协作地运行任务。
在多核环境中,可能是一个只运行一个用户进程的单用户笔记本电脑,除非它使用线程,否则程序永远无法充分利用笔记本电脑的计算能力。每个核心可以用于一个或多个进程或线程,但如果你不使用线程,实际上你只是在使用处理器核心中的一个。
事实上,如果你有一个四核 CPU 并且能够安排每个核心持续进行有用的工作,你的程序理论上可以快四倍(当然,开销会降低这个结果)。给定一个包含 1,000,000 个数字的数组,存储学生在示例中回答的正确问题的数量,比较程序
long sum = 0;
for (int i = 0; i < 1_000_000; i++) {
sum += stats[i];
}
在单线程上运行,这在单核时代工作得很好,有一个创建四个线程的版本,第一个线程执行
long sum0 = 0;
for (int i = 0; i < 250_000; i++) {
sum0 += stats[i];
}
以及第四个线程执行
long sum3 = 0;
for (int i = 750_000; i < 1_000_000; i++) {
sum3 += stats[i];
}
这四个线程由主程序依次启动(Java 中的.start()方法),等待它们完成(.join()方法),然后进行计算。
sum = sum0 + ... + sum3;
问题在于,对每个循环都这样做既麻烦又容易出错。此外,对于不是循环的代码,你能做什么?
第七章展示了 Java Streams 如何通过使用内部迭代而不是外部迭代(显式循环)来以最小的程序员努力实现并行性:
sum = Arrays.stream(stats).parallel().sum();
吸取的想法是,并行 Stream 迭代是一个比显式使用线程的高级概念。换句话说,这种对 Streams 的使用抽象了线程的特定使用模式。这种抽象到 Streams 类似于设计模式,但好处是大部分复杂性都在库内部实现,而不是样板代码。第七章还解释了如何使用 Java 7 中的java.util.concurrent.RecursiveTask支持来并行化分而治之算法的 fork/join 抽象,提供了一种在多核机器上高效求和数组的高级方法。
在查看线程的额外抽象之前,我们来看看(Java 5)的ExecutorServices概念以及这些进一步抽象所基于的线程池。
15.1.2. 执行器和线程池
Java 5 提供了 Executor 框架和线程池的概念,作为一个更高级的想法,它捕捉了线程的力量,允许 Java 程序员将任务提交与任务执行解耦。
线程的问题
Java 线程直接访问操作系统线程。问题是操作系统线程的创建和销毁都很昂贵(涉及与页表的交互),而且数量有限。超过操作系统线程的数量可能会导致 Java 应用程序神秘地崩溃,所以请注意不要在继续创建新线程的同时留下线程运行。
操作系统(和 Java)线程的数量将显著超过硬件线程的数量^([2]),因此即使一些操作系统线程被阻塞或睡眠,所有硬件线程也可以有效地执行代码。例如,2016 年英特尔 Core i7-6900K 服务器处理器有八个核心,每个核心有两个对称多处理(SMP)硬件线程,总共 16 个硬件线程,服务器可能包含几个这样的处理器,可能包含 64 个硬件线程。相比之下,笔记本电脑可能只有一到两个硬件线程,因此可移植程序必须避免对可用的硬件线程数量做出假设。相反,给定程序的最佳 Java 线程数量取决于可用的硬件核心数量!
²
我们在这里会使用“核心”这个词,但像英特尔 i7-6900K 这样的 CPU 每个核心都有多个硬件线程,因此即使对于像缓存未命中这样的短暂延迟,CPU 也能执行有用的指令。
线程池及其为何更好
Java 的ExecutorService提供了一个接口,你可以提交任务并在稍后获取它们的结果。预期的实现使用线程池,这可以通过工厂方法之一创建,例如newFixedThreadPool方法:
ExecutorService newFixedThreadPool(int nThreads)
此方法创建一个包含nThreads(通常称为工作线程)的ExecutorService,并将它们存储在线程池中,从其中取出未使用的线程以按先到先服务的原则运行提交的任务。当它们的任务结束时,这些线程被返回到池中。一个很好的结果是,在保持任务数量为硬件适当的数量同时,向线程池提交数千个任务的成本很低。可能的配置包括队列大小、拒绝策略和不同任务的优先级。
注意措辞:程序员提供一个任务(一个Runnable或Callable),由线程执行。
线程池及其为何更差
线程池在几乎所有方面都比显式线程操作更好,但你需要意识到两个“陷阱”:
-
一个包含 k 个线程的线程池只能同时执行 k 个任务。任何进一步的提交任务都会被保留在队列中,直到现有任务之一完成才会分配线程。这种情况通常很好,因为它允许你提交许多任务而不会意外地创建过多的线程,但你必须警惕那些睡眠或等待 I/O 或网络连接的任务。在阻塞 I/O 的上下文中,这些任务在等待时占用工作线程,但不会做任何有用的工作。尝试使用四个硬件线程和一个大小为 5 的线程池提交 20 个任务(图 15.3)。你可能期望任务会并行运行,直到所有 20 个任务都完成。但假设前三个提交的任务睡眠或等待 I/O。那么,剩余的 15 个任务就只能有 2 个线程可用,所以你只能获得预期吞吐量的一半(如果你用 8 个线程创建线程池的话)。如果早期任务提交或已运行的任务需要等待后续任务提交,这通常是对 Futures 的典型使用模式,那么甚至可能导致线程池发生死锁。
图 15.3. 睡眠任务会降低线程池的吞吐量。
![图 15.3 的替代文本]()
要点是要尽量避免向线程池提交可能阻塞(睡眠或等待事件)的任务,但在现有系统中你并不总是能这样做。
-
Java 通常在允许从
main返回之前等待所有线程完成,以避免杀死执行关键代码的线程。因此,在实践中,作为良好卫生习惯的一部分,在退出程序之前关闭每个线程池是很重要的(因为该池的工作线程已经被创建但尚未终止,因为它们正在等待另一个任务提交)。在实践中,通常会有一个长时间运行的ExecutorService来管理一个始终运行的互联网服务。Java 确实提供了Thread.setDaemon方法来控制这种行为,我们将在下一节讨论。
15.1.3. 线程的其他抽象:非嵌套的方法调用
为了解释为什么本章中使用的并发形式与第七章(并行 Stream 处理和 fork/join 框架)中使用的并发形式不同,我们将指出第七章中使用的并发形式有一个特殊属性:在方法调用中启动的任何任务(或线程)都会等待它完成后再返回。换句话说,线程创建和匹配的join()以正确的方式嵌套在方法调用的调用-返回嵌套中。这种称为严格 fork/join的想法在图 15.4 中展示。
图 15.4. 严格的 fork/join。箭头表示线程,圆圈表示 fork 和 join,矩形表示方法调用和返回。

在一种更宽松的 fork/join 形式中,派生的任务可以从内部方法调用中逃逸,但在外部调用中合并,因此提供给用户的外观仍然像是正常的调用,^([3]) 如图 15.5 所示。
³
比较一下“功能性思考”(第十八章),其中我们讨论了提供一个无副作用的接口给内部使用副作用的方法!
图 15.5. 松弛的 fork/join

在本章中,我们关注更丰富的并发形式,其中用户方法调用创建的线程(或派生的任务)可能比调用存活得更久,如图 15.6 所示。
图 15.6. 异步方法

这种方法通常被称为异步方法,尤其是当正在进行的派生任务继续执行对方法调用者有帮助的工作时。我们将在本章后面探索 Java 8 和 9 的技术,以从这些方法中受益,从 15.2 节开始,但首先,检查一下危险:
-
当前线程与方法调用后的代码并发运行,因此需要仔细编程以避免数据竞争。
-
如果 Java 的
main()方法在当前线程终止之前返回会发生什么?有两个答案,都相当令人不满意:-
在退出应用程序之前等待所有此类挂起的线程。
-
杀死所有挂起的线程然后退出。
-
前一种解决方案由于忘记的线程而永远不会终止,存在看似应用程序崩溃的风险;后一种解决方案会中断写入磁盘的 I/O 操作序列,从而将外部数据置于不一致的状态。为了避免这两个问题,确保你的程序跟踪它创建的所有线程,并在退出前将它们全部合并(包括关闭任何线程池)。
Java 线程可以使用setDaemon()方法调用标记为daemon^([4])或 nondaemon。守护线程在退出时被杀死(因此对于不会留下不一致磁盘状态的服务很有用),而从main返回则继续等待所有非守护线程终止,然后退出程序。
⁴
从词源学上讲,daemon和demon都源于同一个希腊单词,但daemon捕捉到的是有益精神的概念,而demon捕捉到的是邪恶精神的概念。UNIX 为计算目的创造了daemon这个词,用于系统服务,如 sshd,一个监听传入 ssh 连接的进程或线程。
15.1.4. 你希望从线程中得到什么?
您希望的是能够构建程序结构,以便每当它可以从并行化中受益时,都有足够多的任务来占用所有硬件线程,这意味着构建程序以拥有许多较小的任务(但不要因为任务切换的成本而太小)。您在第七章中看到了如何为循环和分而治之算法做这件事,使用并行流处理和 fork/join,但在本章的其余部分(以及第十六章和第十七章),您将看到如何为方法调用做这件事,而无需编写大量的模板线程操作代码。
15.2. 同步和异步 API
第七章向您展示了 Java 8 Streams 如何让您利用并行硬件。这种利用分为两个阶段。首先,您将外部迭代(显式的for循环)替换为内部迭代(使用 Stream 方法)。然后您可以使用 Streams 上的parallel()方法,允许 Java 运行时库并行处理元素,而不是重写每个循环以使用复杂的线程创建操作。一个额外的优势是,当循环执行时,运行时系统比程序员更清楚地了解可用的线程数量,程序员只能猜测。
除了基于循环的计算之外,其他情况也可以从并行化中受益。本章以及第十六章和第十七章的背景是一个重要的 Java 开发,即异步 API。
让我们以一个运行示例来探讨,即计算方法f和g的调用结果之和的问题,这两个方法的签名如下:
int f(int x);
int g(int x);
为了强调,我们将把这些签名称为同步 API,因为它们在物理返回时返回结果,这种含义很快就会变得清晰。您可能需要使用一个代码片段来调用它们并打印它们结果的和:
int y = f(x);
int z = g(x);
System.out.println(y + z);
现在假设方法f和g执行时间较长。(这些方法可能实现数学优化任务,如梯度下降,但在第十六章和第十七章中,我们考虑更实际的案例,其中它们执行网络查询。)一般来说,Java 编译器无法优化此代码,因为f和g可能以编译器不清楚的方式交互。但是,如果您知道f和g没有交互,或者您不在乎,您希望f和g在单独的 CPU 核心上执行,这样总执行时间只是f和g调用时间的最大值,而不是总和。您需要做的就是分别在不同的线程中运行f和g的调用。这个想法非常好,但它使之前的简单代码复杂化了^([5])。
⁵
这里的一些复杂性涉及到将结果从线程中传回。只有最终的外部对象变量可以在 lambda 表达式或内部类中使用,但真正的问题是所有的显式线程操作。
class ThreadExample {
public static void main(String[] args) throws InterruptedException {
int x = 1337;
Result result = new Result();
Thread t1 = new Thread(() -> { result.left = f(x); } );
Thread t2 = new Thread(() -> { result.right = g(x); });
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(result.left + result.right);
}
private static class Result {
private int left;
private int right;
}
}
您可以通过使用 Future API 接口而不是 Runnable 来简化此代码。假设您之前已将线程池设置为 ExecutorService(例如 executorService),您可以编写
public class ExecutorServiceExample {
public static void main(String[] args)
throws ExecutionException, InterruptedException {
int x = 1337;
ExecutorService executorService = Executors.newFixedThreadPool(2);
Future<Integer> y = executorService.submit(() -> f(x));
Future<Integer> z = executorService.submit(() -> g(x));
System.out.println(y.get() + z.get());
executorService.shutdown();
}
}
但这段代码仍然被涉及显式调用 submit 的样板代码所污染。
您需要一个更好的方式来表达这个想法,类似于在 Streams 的内部迭代避免了使用线程创建语法来并行化外部迭代的需求。
答案涉及到将 API 更改为 异步 API。^([6]) 与允许方法在物理返回到调用者(同步)的同时返回其结果相比,您允许它在产生结果之前物理返回,如图 15.6 所示。因此,对 f 的调用以及此调用之后的代码(这里是对 g 的调用)可以并行执行。您可以通过两种技术实现这种并行性,这两种技术都会改变 f 和 g 的签名。
⁶
同步 API 也被称为 阻塞 API,因为物理返回被延迟,直到结果准备好(在考虑对 I/O 操作的调用时最为明显),而异步 API 可以自然地实现非阻塞 I/O(API 调用仅初始化 I/O 操作而不等待结果,前提是所使用的库,如 Netty,支持非阻塞 I/O 操作)。
第一种技术以更好的方式使用 Java Futures。Futures 出现在 Java 5 中,并在 Java 8 中扩展为 CompletableFuture 以使其可组合;我们在 第 15.4 节 中解释了这个概念,并在 第十六章 中通过一个工作的 Java 代码示例详细探讨了 Java API。第二种技术是一种使用 Java 9 java.util.concurrent.Flow 接口的响应式编程风格,基于 第 15.5 节 中解释的发布-订阅协议,并在 第十七章 中通过实际代码进行示例。
这些替代方案如何影响 f 和 g 的签名?
15.2.1. Future-style API
在这个替代方案中,将 f 和 g 的签名更改为
Future<Integer> f(int x);
Future<Integer> g(int x);
并更改调用为
Future<Integer> y = f(x);
Future<Integer> z = g(x);
System.out.println(y.get() + z.get());
这个想法是方法 f 返回一个 Future,其中包含一个继续评估其原始体的任务,但 f 的返回尽可能快地发生在调用之后。方法 g 类似地返回一个 Future,第三行代码使用 get() 等待两个 Future 完成,并求和它们的结果。
在这种情况下,您可以在不减少并行性的情况下保持 API 和 g 的调用不变——只需为 f 引入 Future。在更大的程序中不这样做有两个原因:
-
g的其他使用可能需要 Future 风格的版本,因此你更喜欢统一的 API 风格。 -
为了使并行硬件尽可能快地执行你的程序,拥有更多且更小的任务(在合理范围内)是有用的。
15.2.2. 响应式 API
在第二种替代方案中,核心思想是通过改变f和g的签名来使用回调风格编程。
void f(int x, IntConsumer dealWithResult);
这种替代方案一开始可能看起来令人惊讶。如果f不返回值,它怎么能工作呢?答案是,你将一个回调^([7])(一个 lambda)作为额外的参数传递给f,然后f的主体启动一个任务,在准备好时调用这个 lambda 而不是使用return返回值。再次强调,f在启动任务以评估主体后立即返回,这导致以下代码风格:
⁷
一些作者使用术语回调来表示任何作为方法参数传递的 lambda 或方法引用,例如
Stream.filter或Stream.map的参数。我们只将其用于那些可以在方法返回后调用的 lambda 和方法引用。
public class CallbackStyleExample {
public static void main(String[] args) {
int x = 1337;
Result result = new Result();
f(x, (int y) -> {
result.left = y;
System.out.println((result.left + result.right));
} );
g(x, (int z) -> {
result.right = z;
System.out.println((result.left + result.right));
});
}
}
啊,但这并不相同!在这段代码打印出正确的结果(f和g调用的总和)之前,它打印出完成最快的值(有时甚至打印出总和两次,因为没有锁定,加法的两个操作数可以在任一println调用执行之前更新)。有两个答案:
-
你可以通过在测试后调用
println来恢复原始行为,使用 if-then-else 检查两个回调都已调用,可能通过适当的锁定来计数。 -
这种响应式风格的 API 旨在对一系列事件做出反应,而不是对单个结果,对于
Futures 来说更为合适。
注意,这种响应式编程风格允许方法f和g多次调用它们的回调dealWithResult。f和g的原始版本被迫使用只能执行一次的return。同样,Future只能完成一次,其结果可以通过get()获取。从某种意义上说,响应式风格的异步 API 自然地启用了一组值(我们稍后将将其比作流),而Future风格的 API 则对应于一次性概念框架。
在第 15.5 节中,我们细化了这个核心思想示例,以模拟包含公式=C1+C2的工作表调用。
你可能会争辩说,这两种替代方案都会使代码更复杂。在某种程度上,这种论点是正确的;你不应该无意识地使用每个方法的 API。但是,API 比显式地操作线程使代码更简单(并使用更高级的构造)。此外,谨慎使用这些 API 进行方法调用,这些调用(a)导致长时间的计算(可能超过几毫秒)或(b)等待网络或来自人类的输入,可以显著提高应用程序的效率。在情况(a)中,这些技术使你的程序更快,而无需在程序中显式地使用污染线程。在情况(b)中,还有一个额外的好处,即底层系统可以有效地使用线程而不会阻塞。我们将在下一节中转向这一点。
15.2.3. 休眠(以及其他阻塞操作)被认为是有害的
当你与人类或需要限制事件发生速率的应用程序交互时,一种自然的编程方式是使用sleep()方法。然而,休眠的线程仍然占用系统资源。如果你只有少数几个线程,这种情况并不重要,但如果你有很多线程,其中大多数都在休眠,那就很重要了。(参见 15.2.1 节和 15.3 图的讨论。)
要记住的教训是,在线程池中休眠的任务通过阻塞其他任务开始运行来消耗资源。(它们不能停止已分配给线程的任务,因为操作系统调度这些任务。)
当然,不仅仅是休眠会阻塞线程池中可用的线程。任何阻塞操作都可以做到这一点。阻塞操作分为两类:等待另一个任务执行某些操作,例如在 Future 上调用get();以及等待外部交互,如从网络、数据库服务器或键盘等人类界面设备读取。
你能做什么?一个相当独裁的回答是永远不要在任务中阻塞,或者至少在你的代码中只允许少数例外。 (参见 15.2.4 节以了解现实情况。)更好的替代方案是将任务分为两部分——在之前和之后——并让 Java 在不会阻塞的情况下仅调度后面的部分。
比较代码 A,显示为一个单一的任务
work1();
Thread.sleep(10000); *1*
work2();
- 1. 休眠 10 秒。
使用代码 B:
public class ScheduledExecutorServiceExample {
public static void main(String[] args) {
ScheduledExecutorService scheduledExecutorService
= Executors.newScheduledThreadPool(1);
work1();
scheduledExecutorService.schedule(
ScheduledExecutorServiceExample::work2, 10, TimeUnit.SECONDS); *1*
scheduledExecutorService.shutdown();
}
public static void work1(){
System.out.println("Hello from Work1!");
}
public static void work2(){
System.out.println("Hello from Work2!");
}
}
- 1. 在 work1()完成后 10 秒为 work2()安排一个单独的任务。
想象这两个任务都在线程池中执行。
考虑代码 A 的执行方式。首先,它被排队在线程池中执行,然后开始执行。然而,在执行到一半时,它在sleep()调用中阻塞,占用了一个工作线程整整 10 秒钟什么也不做。然后它执行work2()并在终止和释放工作线程之前完成。相比之下,代码 B 执行work1()然后终止——但仅在 10 秒后排队一个任务来执行work2()。
代码 B 更好,但为什么?代码 A 和代码 B 做的是同一件事。区别在于,代码 A 在睡眠时占用了一个宝贵的线程,而代码 B 则将另一个任务排队执行(只需要少量内存,且不需要线程)而不是睡眠。
这种效果是在创建任务时应始终牢记在心的事情。任务在开始执行时占用宝贵的资源,因此你应该努力让它们运行直到完成并释放其资源。而不是阻塞,任务应在提交后续任务以完成其打算执行的工作后终止。
尽可能的情况下,此指南也适用于 I/O。而不是进行传统的阻塞式读取,任务应发出一个非阻塞的“启动读取”方法调用,并在请求运行时库在读取完成后安排后续任务后终止。
这种设计模式可能会让人感觉代码难以阅读。但 Java 的CompletableFuture接口(第 15.4 节和第十六章)在运行时库中抽象了这种代码风格,使用组合子而不是对Future的阻塞get()操作的显式使用,正如我们之前讨论的那样。
最后,我们将指出,如果线程是无限且便宜的,代码 A 和代码 B 将同样有效。但它们不是,所以当你有超过几个可能睡眠或阻塞的任务时,代码 B 是最佳选择。
15.2.4. 现实检查
如果你正在设计一个新的系统,为了利用并行硬件,将系统设计为包含许多小型、并发任务,以便所有可能的阻塞操作都通过异步调用实现,这可能是一条可行的道路。但现实需要打破这种“一切异步”的设计原则。(记住,“最好的是好的敌人。”)自 2002 年 Java 1.4 以来,Java 就拥有了非阻塞 I/O 原语(java.nio),但它们相对复杂且不太为人所知。实用地说,我们建议你尝试确定那些可以从 Java 增强的并发 API 中受益的情况,并使用它们而无需担心使每个 API 都异步。
你还可以查看像 Netty(netty.io/)这样的较新库,它为网络服务器提供了一致的阻塞/非阻塞 API。
15.2.5. 异常如何与异步 API 协同工作?
在基于 Future 和反应式风格的异步 API 中,被调用方法的逻辑主体在单独的线程中执行,调用者的执行很可能已经退出了围绕调用放置的任何异常处理程序的范畴。显然,会触发异常的不寻常行为需要执行替代操作。但这种操作可能是什么呢?在 CompletableFuture 的 Future 实现中,API 包括在 get() 方法时暴露异常的提供,还提供了如 exceptionally() 之类的从异常中恢复的方法,我们将在第十六章 16 中讨论。
对于反应式风格的异步 API,您必须通过引入一个额外的回调来修改接口,这个回调在抛出异常而不是执行 return 时被调用。为此,在反应式 API 中包含多个回调,如下例所示:
void f(int x, Consumer<Integer> dealWithResult,
Consumer<Throwable> dealWithException);
然后,f 的主体可能会执行
dealWithException(e);
如果有多个回调,而不是单独提供它们,您可以将它们等价地包装在一个单一的对象中的方法。例如,Java 9 的 Flow API 将这些多个回调包装在一个单一的对象中(Subscriber<T> 类的对象,包含四个方法,被解释为回调)。以下是其中的三个:
void onComplete()
void onError(Throwable throwable)
void onNext(T item)
分离的回调指示何时有值可用(onNext),何时在尝试提供值时出现异常(onError),以及何时 onComplete 回调允许程序指示不会产生更多值(或异常)。对于前面的示例,f 的 API 现在将是
void f(int x, Subscriber<Integer> s);
并且 f 的主体现在会通过执行来指示一个异常,该异常表示为 Throwable t
s.onError(t);
将包含多个回调的此 API 与从文件或键盘设备读取数字进行比较。如果您将此类设备视为生产者而不是被动数据结构,它会产生一系列“这里有数字”或“这里有格式不正确的项而不是数字”的项目,最后是一个“没有更多字符(文件结束)”的通知。
通常将这些调用称为消息或 事件。例如,您可能会说,文件读取器产生了数字事件 3、7 和 42,然后是一个格式不正确的数字事件,接着是数字事件 2,然后是文件结束事件。
当将这些事件视为 API 的一部分时,需要注意的是,API 并没有表明这些事件的相对顺序(通常称为 通道协议)。在实践中,相关的文档通过使用诸如“在 onComplete 事件之后,将不再产生更多事件”之类的阶段来指定协议。
15.3. 盒子-通道模型
通常,设计和思考并发系统的最佳方式是直观地表示。我们称这种技术为箱-通道模型。考虑一个涉及整数的简单情况,这是对之前计算 f(x) + g(x) 例子的一般化。现在你想要用参数 x 调用方法或函数 p,将它的结果传递给函数 q1 和 q2,然后用这两个调用的结果调用方法或函数 r,最后打印结果。(为了避免解释中的混乱,我们不会区分类 C 的方法 m 和其关联的函数 C::m。)直观地看,这个任务很简单,如图 15.7 所示。
图 15.7. 一个简单的箱-通道图

查看两种在 Java 中编码 图 15.7 的方式,以了解它们引起的问题。第一种方式是
int t = p(x);
System.out.println( r(q1(t), q2(t)) );
这段代码看起来很清晰,但 Java 会依次运行对 q1 和 q2 的调用,这是你在尝试利用硬件并行性时想要避免的。
另一种方式是使用 Futures 并行评估 f 和 g:
int t = p(x);
Future<Integer> a1 = executorService.submit(() -> q1(t));
Future<Integer> a2 = executorService.submit(() -> q2(t));
System.out.println( r(a1.get(),a2.get()));
注意:我们没有在这个例子中将 p 和 r 包裹在 Futures 中,因为箱-通道图的形状。p 必须在所有其他操作之前完成,而 r 必须在所有其他操作之后完成。如果我们改变这个例子来模仿
System.out.println( r(q1(t), q2(t)) + s(x) );
其中我们需要将所有五个函数(p、q1、q2、r 和 s)包裹在 Futures 中以最大化并发性。
如果系统中并发的总量很小,这种解决方案效果很好。但如果系统变得很大,包含许多独立的箱-通道图,并且其中一些盒子本身内部使用自己的盒子和通道,会怎样呢?在这种情况下,许多任务可能会等待(通过调用 get())一个 Future 完成,正如在 15.1.2 节 中讨论的,结果可能是硬件并行性的低效利用,甚至死锁。此外,通常很难充分理解这样的大型系统结构,以确定有多少任务可能会等待 get()。Java 8 采取的解决方案(CompletableFuture;有关详细信息,请参阅 15.4 节)是使用组合器。你已经看到,你可以使用 compose() 和 andThen() 等方法在两个 Function 上操作以获得另一个 Function(参见 第三章)。例如,假设 add1 将整数加 1,而 dble 将整数加倍,你可以编写
Function<Integer, Integer> myfun = add1.andThen(dble);
创建一个将它的参数加倍并将结果加 2 的 Function。但箱-通道图也可以直接且优雅地使用组合器编码。Java Functions p、q1、q2 和 BiFunction r 可以简洁地捕获 图 15.7:
p.thenBoth(q1,q2).thenCombine(r)
不幸的是,thenBoth 和 thenCombine 并不是以这种形式成为 Java Function 和 BiFunction 类的一部分。
在下一节中,你将看到类似的思想如何应用于 CompletableFuture 并防止任务必须使用 get() 等待。
在离开这一节之前,我们想强调的是,盒子和通道模型可以用来组织思想和代码。在某种重要的意义上,它提高了构建更大系统的抽象级别。你画盒子(或在程序中使用组合器)来表达你想要的计算,稍后执行,可能比手动编码计算更有效率。这种组合器的使用不仅适用于数学函数,也适用于 Future 和数据反应流。在 第 15.5 节 中,我们将这些盒子和通道图推广到宝石图,其中每个通道上都会显示多个宝石(代表消息)。盒子和通道模型还帮助你从直接编程并发转变为允许组合器内部执行工作。同样,Java 8 Streams 从程序员必须遍历数据结构到组合器在内部执行工作的角度转变了视角。
15.4. CompletableFuture 和并发组合器
Future 接口的一个问题是它是一个接口,这鼓励你将你的并发编程任务视为 Future。然而,从历史上看,Future 提供了很少的操作,除了 FutureTask 实现:创建一个具有给定计算的未来,运行它,等待它终止等等。Java 的后续版本提供了更多的结构化支持(例如,第七章中讨论的 RecursiveTask)。
Java 8 带来的新功能是能够使用 Future 接口的 CompletableFuture 实现来组合 Future。那么为什么叫它 CompletableFuture 而不是,比如说,ComposableFuture 呢?嗯,一个普通的 Future 通常是用一个 Callable 创建的,它会被执行,然后通过 get() 获取结果。但是 CompletableFuture 允许你创建一个 Future 而不给它任何要运行的代码,并且一个 complete() 方法允许其他线程稍后用值完成它(因此得名),这样 get() 就可以访问那个值。为了并发地求和 f(x) 和 g(x),你可以这样写
public class CFComplete {
public static void main(String[] args)
throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
int x = 1337;
CompletableFuture<Integer> a = new CompletableFuture<>();
executorService.submit(() -> a.complete(f(x)));
int b = g(x);
System.out.println(a.get() + b);
executorService.shutdown();
}
}
或者你可以这样写
public class CFComplete {
public static void main(String[] args)
throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
int x = 1337;
CompletableFuture<Integer> a = new CompletableFuture<>();
executorService.submit(() -> b.complete(g(x)));
int a = f(x);
System.out.println(a + b.get());
executorService.shutdown();
}
}
注意,这两种代码版本都可能因为有一个线程阻塞等待 get 而浪费处理资源(回想一下 第 15.2.3 节)。前者如果 f(x) 花费时间更长,后者如果 g(x) 花费时间更长。使用 Java 8 的 CompletableFuture 可以让你避免这种情况;但首先有一个测验。
测验 15.1:
在继续阅读之前,思考一下你如何编写任务以完美地利用线程:当 f(x) 和 g(x) 都在执行时有两个活跃的线程,以及从第一个线程完成开始直到返回语句的一个线程。
答案是,你会使用一个任务来执行f(x),第二个任务来执行g(x),第三个任务(一个新的或现有的)来计算总和,并且某种方式下,第三个任务在第一个两个完成之前不能开始。你如何在 Java 中解决这个问题?
解决方案是使用 Future 上的组成思想。
首先,回顾一下你在本书中之前见过两次的组成操作。组成操作是一种在许多其他语言中使用的强大程序结构化思想,但只有在 Java 8 中添加了 lambda 表达式之后,Java 才开始流行这种思想。这个想法的一个实例是在流上组成操作,如下面的例子所示:
myStream.map(...).filter(...).sum()
这个想法的另一个实例是在两个Function上使用compose()和andThen()方法来获取另一个Function(参见第 15.5 节)。
这为你提供了一个使用CompletableFuture<T>中的thenCombine方法添加两个计算结果的新且更好的方法。目前不必过于担心细节;我们将在第十六章更全面地讨论这个话题。thenCombine方法具有以下签名(略微简化以防止泛型和通配符相关的杂乱):
CompletableFuture<V> thenCombine(CompletableFuture<U> other,
BiFunction<T, U, V> fn)
这个方法接受两个CompletableFuture值(结果类型为T和U)并创建一个新的(结果类型为V)。当前两个完成时,它获取它们的结果,对这两个结果应用fn,然后不阻塞地完成结果 future。前面的代码现在可以按照以下形式重写:
public class CFCombine {
public static void main(String[] args) throws ExecutionException,
InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
int x = 1337;
CompletableFuture<Integer> a = new CompletableFuture<>();
CompletableFuture<Integer> b = new CompletableFuture<>();
CompletableFuture<Integer> c = a.thenCombine(b, (y, z)-> y + z);
executorService.submit(() -> a.complete(f(x)));
executorService.submit(() -> b.complete(g(x)));
System.out.println(c.get());
executorService.shutdown();
}
}
thenCombine这一行是关键:在不知道Futures中的a和b的计算内容的情况下,它创建了一个仅在前面两个计算完成时才在线程池中运行的调度计算。第三个计算c将它们的结果相加(最重要的是)并且只有在其他两个计算完成之后才被认为有资格在线程上执行(而不是开始执行然后阻塞)。因此,没有实际等待操作,这在代码的早期两个版本中是麻烦的。在那个版本中,如果 Future 中的计算恰好是第二个完成的,那么线程池中的两个线程仍然处于活动状态,尽管你只需要一个!图 15.8 以图解方式展示了这种情况。在两个早期版本中,计算y+z都是在计算f(x)或g(x)的相同固定线程上进行的,中间可能存在潜在的等待。相比之下,使用thenCombine仅在f(x)和g(x)都完成之后才调度求和计算。
图 15.8. 显示三个计算:f(x)、g(x)和它们结果的时序图

为了明确起见,对于许多代码片段,你不需要担心几个线程被阻塞等待get(),因此 Java 8 之前的 Futures 仍然是合理的编程选项。然而,在某些情况下,你可能需要拥有大量的 Futures(例如处理对服务的多个查询)。在这些情况下,使用CompletableFuture及其组合器来避免阻塞调用get()以及可能的并行性丢失或死锁通常是最佳解决方案。
15.5. 发布-订阅和响应式编程
对于Future和CompletableFuture的心理模型是独立并发执行的计算。Future 的结果在计算完成后通过get()方法可用。因此,Futures 是单次执行的,代码只运行到完成一次。
相比之下,响应式编程的心理模型是一个类似于 Future 的对象,随着时间的推移,它会产生多个结果。考虑两个例子,从一个温度计对象开始。你期望这个对象会反复产生结果,每隔几秒给你一个温度值。另一个例子是代表 Web 服务器监听器组件的对象;这个对象等待网络中出现 HTTP 请求,并以类似的方式产生请求的数据。然后其他代码可以处理结果:温度或 HTTP 请求的数据。然后温度计和监听器对象回到感应温度或监听之前,可能还会产生进一步的结果。
在这里要注意两点。核心点是这些例子类似于 Futures,但不同之处在于它们可以多次完成(或产生)而不是单次执行。另一个点是,在第二个例子中,早期结果可能和后期看到的结果一样重要,而对于温度计,大多数用户只对最新的温度感兴趣。但为什么这种编程被称为响应式呢?答案是程序的其他部分可能想要对低温报告做出反应(例如打开加热器)。
你可能会认为前面的想法只是一个 Stream。如果你的程序自然地适合 Stream 模型,Stream 可能是最好的实现。然而,总的来说,响应式编程范式更具表现力。一个 Java Stream 只能被一个终端操作消费。正如我们在第 15.3 节中提到的,Stream 范式使得表达可以在两个处理管道之间分割值序列的操作(例如 fork)或处理和合并来自两个不同流的项(例如 join)变得困难。Streams 具有线性处理管道。
Java 9 使用java.util.concurrent.Flow内部可用的接口来建模响应式编程,并编码了所谓的发布-订阅模型(或协议,通常简称为 pub-sub)。你可以在第十七章中详细了解 Java 9 Flow API,但在这里我们提供一个简要概述。有三个主要概念:
-
一个发布者,订阅者可以订阅它。
-
这种连接被称为订阅。
-
消息(也称为事件)通过连接传输。
图 15.9 以图形方式展示了这个想法,其中订阅是通道,发布者和订阅者是盒子上的端口。多个组件可以订阅单个发布者,一个组件可以发布多个单独的流,一个组件可以订阅多个发布者。在下一节中,我们将使用 Java 9 Flow 接口的术语逐步展示这个想法是如何工作的。
图 15.9. 发布-订阅模型

15.5.1. 用于求和两个流的示例用途
发布-订阅的一个简单但具有代表性的例子是将两个信息源的事件结合起来,并供其他人查看。这个过程一开始可能听起来很神秘,但这就是电子表格中包含公式的单元格在概念上所做的事情。建模一个包含公式"=C1+C2"的电子表格单元格 C3。每当 C1 或 C2 被更新(由人类或因为单元格包含另一个公式),C3 都会更新以反映变化。以下代码假设唯一可用的操作是添加单元格的值。
首先,建模一个持有值的细胞的概念:
private class SimpleCell {
private int value = 0;
private String name;
public SimpleCell(String name) {
this.name = name;
}
}
目前,代码很简单,你可以初始化几个细胞,如下所示:
SimpleCell c2 = new SimpleCell("C2");
SimpleCell c1 = new SimpleCell("C1");
你如何指定当c1或c2的值发生变化时,c3将这两个值相加?你需要一种方式让c1和c2订阅c3的事件。为此,引入接口Publisher<T>,其核心看起来像这样:
interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
此接口接受一个订阅者作为参数,它可以与之通信。Subscriber<T>接口包括一个简单的方法onNext,该方法将信息作为参数,然后可以自由提供特定的实现:
interface Subscriber<T> {
void onNext(T t);
}
你如何将这两个概念结合起来?你可能意识到Cell实际上既是Publisher(可以订阅单元格到其事件)又是Subscriber(对其他单元格的事件做出反应)。Cell类的实现现在看起来像这样:
private class SimpleCell implements Publisher<Integer>, Subscriber<Integer> {
private int value = 0;
private String name;
private List<Subscriber> subscribers = new ArrayList<>();
public SimpleCell(String name) {
this.name = name;
}
@Override
public void subscribe(Subscriber<? super Integer> subscriber) {
subscribers.add(subscriber);
}
private void notifyAllSubscribers() { *1*
subscribers.forEach(subscriber -> subscriber.onNext(this.value));
}
@Override
public void onNext(Integer newValue) {
this.value = newValue; *2*
System.out.println(this.name + ":" + this.value); *3*
notifyAllSubscribers(); *4*
}
}
-
1 此方法通过新值通知所有订阅者。
-
2 通过更新其值来响应它所订阅的细胞的新值
-
3 在控制台打印值,但可以是渲染更新单元格的一部分 UI
-
4 通知所有订阅者关于更新后的值
尝试一个简单的例子:
Simplecell c3 = new SimpleCell("C3");
SimpleCell c2 = new SimpleCell("C2");
SimpleCell c1 = new SimpleCell("C1");
c1.subscribe(c3);
c1.onNext(10); // Update value of C1 to 10
c2.onNext(20); // update value of C2 to 20
这段代码输出以下结果,因为 C3 直接订阅了 C1:
C1:10
C3:10
C2:20
你如何实现 "C3=C1+C2" 的行为?你需要引入一个能够存储算术运算两边(左和右)的独立类:
public class ArithmeticCell extends SimpleCell {
private int left;
private int right;
public ArithmeticCell(String name) {
super(name);
}
public void setLeft(int left) {
this.left = left;
onNext(left + this.right); *1*
}
public void setRight(int right) {
this.right = right;
onNext(right + this.left); *2*
}
}
-
1 更新单元格值并通知任何订阅者。
-
2 更新单元格值并通知任何订阅者。
现在你可以尝试一个更实际的例子:
ArithmeticCell c3 = new ArithmeticCell("C3");
SimpleCell c2 = new SimpleCell("C2");
SimpleCell c1 = new SimpleCell("C1");
c1.subscribe(c3::setLeft);
c2.subscribe(c3::setRight);
c1.onNext(10); // Update value of C1 to 10
c2.onNext(20); // update value of C2 to 20
c1.onNext(15); // update value of C1 to 15
输出是
C1:10
C3:10
C2:20
C3:30
C1:15
C3:35
通过检查输出,你可以看到当 C1 更新为 15 时,C3 立即做出反应并更新其值。发布者-订阅者交互中很酷的一点是你可以设置一个发布者和订阅者的图。例如,你可以创建另一个依赖于 C3 和 C4 的单元格 C5,通过表达 "C5=C3+C4":
ArithmeticCell c5 = new ArithmeticCell("C5");
ArithmeticCell c3 = new ArithmeticCell("C3");
SimpleCell c4 = new SimpleCell("C4");
SimpleCell c2 = new SimpleCell("C2");
SimpleCell c1 = new SimpleCell("C1");
c1.subscribe(c3::setLeft);
c2.subscribe(c3::setRight);
c3.subscribe(c5::setLeft);
c4.subscribe(c5::setRight);
然后你可以在你的电子表格中执行各种更新:
c1.onNext(10); // Update value of C1 to 10
c2.onNext(20); // update value of C2 to 20
c1.onNext(15); // update value of C1 to 15
c4.onNext(1); // update value of C4 to 1
c4.onNext(3); // update value of C4 to 3
这些操作会产生以下输出:
C1:10
C3:10
C5:10
C2:20
C3:30
C5:30
C1:15
C3:35
C5:35
C4:1
C5:36
C4:3
C5:38
最后,C5 的值是 38,因为 C1 是 15,C2 是 20,而 C4 是 3。
命名法
因为数据从发布者(生产者)流向订阅者(消费者),开发者经常使用诸如 上游 和 下游 这样的词汇。在前面的代码示例中,上游 onNext() 方法接收到的 newValue 数据通过调用 notifyAllSubscribers() 传递给下游的 onNext() 调用。
这就是发布-订阅的核心思想。然而,我们省略了一些事情,其中一些是直接的装饰,而其中一个(背压)是如此重要,以至于我们在下一节中单独讨论它。
首先,我们将讨论直接的事情。正如我们在第 15.2 节中提到的,流的实际编程可能需要传递比 onNext 事件更多的信息,因此订阅者(监听器)需要定义 onError 和 onComplete 方法,以便发布者可以指示异常和数据流的终止。(也许温度计的例子已经被替换,并且将永远不会通过 onNext 产生更多值。)onError 和 onComplete 方法在 Java 9 Flow API 的实际 Subscriber 接口中得到支持。这些方法就是为什么这个协议比传统的观察者模式更强大的原因之一。
两个简单但至关重要的想法,这些想法极大地复杂化了 Flow 接口,即压力和反压。这些想法可能看起来并不重要,但它们对于线程利用至关重要。假设你的温度计,之前每几秒钟报告一次温度,现在升级到了一个更好的版本,每毫秒报告一次温度。你的程序能否足够快速地对这些事件做出反应,或者可能会发生缓冲区溢出并导致崩溃?(回想一下,如果可能阻塞的任务超过几个,给线程池分配大量任务时会出现的问题。)同样,假设你订阅了一个向你的手机提供所有短信消息的发布者。在只有少量短信消息的情况下,这个订阅在我的新手机上可能运行良好,但几年后,当有数千条消息时,所有这些消息都可能在不到一秒的时间内通过调用onNext发送,会发生什么?这种情况通常被称为压力。
现在想象一个包含写有消息的球体的垂直管道。你还需要一种形式的反压,例如一种限制添加到柱子中的球体数量的机制。Java 9 Flow API 通过一个request()方法(在一个新接口Subscription中实现)实现了反压,该方法邀请发布者发送下一个项目(或多个项目),而不是以无限的速度发送项目(拉模型而不是推模型)。我们将在下一节中讨论这个话题。
15.5.2. 反压
你已经看到了如何将包含onNext、onError和OnComplete方法的Subscriber对象传递给Publisher,发布者在适当的时候调用这个对象。该对象将信息从Publisher传递到Subscriber。你希望通过反压(流量控制)限制发送信息的速率,这需要你从Subscriber向Publisher发送信息。问题是Publisher可能有多个Subscriber,而你希望反压只影响点对点连接。在 Java 9 Flow API 中,Subscriber接口包括第四个方法
void onSubscribe(Subscription subscription);
这被称为在Publisher和Subscriber之间建立的通道上发送的第一个事件。Subscription对象包含使Subscriber能够与Publisher通信的方法,如下所示:
interface Subscription {
void cancel();
void request(long n);
}
注意回调中常见的“这似乎是反的”效应。Publisher创建Subscription对象并将其传递给Subscriber,Subscriber可以调用其方法将信息从Subscriber传递回Publisher。
15.5.3. 真实反压的简单形式
为了使发布-订阅连接能够逐个处理事件,你需要进行以下更改:
-
安排
Subscriber将OnSubscribe传递的Subscription对象本地存储,可能作为一个字段subscription。 -
使
onSubscribe、onNext和(可能)onError的最后一个动作是调用channel.request(1)来请求下一个事件(只有一个事件,这阻止了Subscriber被淹没)。 -
改变
Publisher,使得notifyAllSubscribers(在这个例子中)只通过请求的通道发送onNext或onError事件。(通常,Publisher会为每个Subscriber创建一个新的Subscription对象,以便多个Subscriber可以各自以自己的速率处理数据。)
虽然这个过程看起来很简单,但实现背压需要考虑一系列的实现权衡:
-
你会以最慢的
Subscriber的速度发送事件,还是为每个Subscriber有一个单独的尚未发送的数据队列? -
当这些队列过度增长时会发生什么?
-
如果
Subscriber没有准备好,你会丢弃事件吗?
选择取决于发送数据的语义。丢失一个温度报告序列可能无关紧要,但丢失银行账户中的信用额度肯定很重要!
你经常听到这个概念被称为基于反应式拉取的背压。这个概念被称为基于反应式拉取,因为它提供了一种方式,让Subscriber通过事件(反应式)从Publisher拉取(请求)更多信息。结果是背压机制。
15.6. 反应式系统与反应式编程
在编程和学术界,你可能会越来越多地听到关于反应式系统和反应式编程的内容,重要的是要意识到这些术语表达的是相当不同的概念。
反应式系统是一个程序,其架构允许它对其运行时环境的变化做出反应。反应式系统应具备的属性在《反应式宣言》(www.reactivemanifesto.org)中得到了形式化(见第十七章)。这三个属性可以总结为响应性、弹性和弹性。
响应性意味着反应式系统可以实时响应输入,而不是因为系统正在为其他人处理大任务而延迟简单的查询。弹性意味着系统通常不会因为一个组件失败而失败;损坏的网络链接不应该影响不涉及该链接的查询,并且可以重新路由到未响应组件的查询。弹性意味着系统可以调整其工作负载的变化,并继续高效地执行。就像你可以在酒吧在服务食物和服务饮料之间动态重新分配员工,以便两条队伍的等待时间相似一样,你可以调整与各种软件服务关联的工作线程数量,以确保没有工作线程空闲,同时确保每个队列继续被处理。
显然,你可以用许多方式实现这些属性,但主要的方法是使用由与java.util.concurrent.Flow关联的接口提供的反应式编程风格。这些接口的设计反映了反应式宣言的第四个和最后一个属性:消息驱动。消息驱动系统具有基于箱-通道模型的内部 API,组件等待被处理的输入,结果以消息的形式发送到其他组件,以使系统能够响应。
15.7. 路线图
第十六章通过一个真实的 Java 示例探讨了CompletableFuture API,而第十七章探讨了 Java 9 Flow(发布-订阅)API。
概述
-
Java 对并发的支持已经发展并持续发展。线程池通常很有帮助,但当你有许多可能阻塞的任务时,可能会引起问题。
-
使方法异步(在所有工作完成之前返回)允许额外的并行性,与用于优化循环的并行性互补。
-
你可以使用箱-通道模型来可视化异步系统。
-
Java 8 的
CompletableFuture类和 Java 9 Flow API 都可以表示箱-通道图。 -
CompletableFuture类表达了一次性异步计算。可以使用组合器来组合异步计算,而无需承担传统 Future 使用中固有的阻塞风险。 -
Flow API 基于发布-订阅协议,包括背压,并构成了 Java 中反应式编程的基础。
-
反应式编程可用于实现反应式系统。
第十六章. CompletableFuture:可组合的异步编程
本章涵盖
-
创建异步计算并检索其结果
-
通过使用非阻塞操作提高吞吐量
-
设计和实现异步 API
-
异步消费同步 API
-
管道化和合并两个或更多异步操作
-
对异步操作完成的响应
第十五章探讨了现代并发上下文:多个处理资源(CPU 核心等)可用,并且您希望以高级方式尽可能利用这些资源(而不是在程序中散布结构不良、难以维护的线程操作)。我们指出,并行流和 fork/join 并行性为在遍历集合的程序和涉及分而治之的程序中表达并行性提供了高级构造,但方法调用提供了执行代码的并行执行的机会。Java 8 和 9 引入了两个特定的 API 来实现此目的:CompletableFuture和响应式编程范式。本章通过实际代码示例解释了 Java 8 CompletableFuture实现如何为您的编程工具箱提供额外的武器。它还讨论了 Java 9 中引入的添加功能。
16.1. 简单使用 Futures
Future接口是在 Java 5 中引入的,用于模拟在未来的某个时刻可用的结果。例如,当调用者发起请求时,对远程服务的查询不会立即可用。Future接口模拟异步计算,并提供了一个引用,当计算本身完成时,该引用将指向结果。在Future内部触发可能耗时的操作允许调用者Thread继续执行有用的工作,而不是等待操作的结果。您可以将这个过程想象成把一袋衣服拿到您最喜欢的干洗店。干洗店会给您一张收据,告诉您衣服什么时候会被清洗(一个Future);在此期间,您可以做一些其他活动。Future的另一个优点是它比低级的Threads 更容易使用。要使用Future,通常需要将耗时的操作包装在一个Callable对象中,并将其提交给ExecutorService。以下列表显示了 Java 8 之前编写的示例。
列表 16.1. 在Future中异步执行长时间操作
ExecutorService executor = Executors.newCachedThreadPool(); *1*
Future<Double> future = executor.submit(new Callable<Double>() { *2*
public Double call() {
return doSomeLongComputation(); *3*
}});
doSomethingElse(); *4*
try {
Double result = future.get(1, TimeUnit.SECONDS); *5*
} catch (ExecutionException ee) {
// the computation threw an exception
} catch (InterruptedException ie) {
// the current thread was interrupted while waiting
} catch (TimeoutException te) {
// the timeout expired before the Future completion
}
-
1 创建一个 ExecutorService,允许您将任务提交到线程池。
-
2 将 Callable 提交给 ExecutorService。
-
3 在单独的线程中异步执行长时间操作。
-
4 在异步操作进行时做其他事情。
-
5 获取异步操作的结果,如果结果尚未可用则阻塞,但最多等待 1 秒钟然后超时。
如图 16.1 所示,这种编程风格允许你的线程在另一个由ExecutorService提供的单独线程中并发执行长时间操作的同时执行其他任务。然后,当你没有异步操作的结果就无法进行任何其他有意义的工作时,你可以通过调用其get方法从Future检索它。如果操作已经完成,此方法立即返回操作的结果,或者它会阻塞你的线程,等待其结果可用。
图 16.1. 使用Future异步执行长时间操作

注意这个场景的问题。如果长时间操作永远不会返回怎么办?为了处理这种可能性,几乎总是使用get函数的两个参数版本是一个好主意,它接受一个超时参数,指定线程愿意等待Future结果的最大时间(及其时间单位)(如列表 16.1 所示)。get函数的无参数版本将无限期地等待。
16.1.1. 理解 Futures 及其限制
这个简单的例子表明,Future接口提供了检查异步计算是否完成的方法(通过使用isDone方法),等待其完成,并检索其结果。但这些都不足以让你编写简洁的并发代码。例如,表达Future结果之间的依赖关系是困难的。声明式地指定,“当长时间计算的结果可用时,请将其结果发送给另一个长时间计算,当它完成时,将其结果与另一个查询的结果合并。”使用Future中可用的操作来实现这个规范是一个不同的故事,这就是为什么在实现中拥有更多声明式特性会有所帮助,例如:
-
在两个异步计算独立或第二个依赖于第一个的结果时结合它们
-
等待一组
Future执行的所有任务的完成 -
只等待一组
Future中的最快任务完成(可能是因为Future正在以不同的方式尝试计算相同的值)并检索其结果 -
以编程方式完成一个
Future(即通过手动提供异步操作的结果) -
对
Future完成的响应(即在完成发生时被通知,然后能够使用Future的结果执行进一步的操作,而不是在等待其结果时被阻塞)
在本章的其余部分,你将学习CompletableFuture类(它实现了Future接口)如何通过 Java 8 的新特性以声明式的方式实现所有这些功能。Stream和CompletableFuture的设计遵循类似的模式,因为它们都使用 lambda 表达式和管道化。因此,可以说CompletableFuture对于普通的Future就像Stream对于Collection一样。
16.1.2. 使用 CompletableFutures 构建异步应用程序
为了探索CompletableFuture的功能,在本节中,你将逐步开发一个最佳价格查找应用程序,该应用程序联系多个在线商店以找到给定产品或服务的最低价格。在这个过程中,你将学习几个重要的技能:
-
如何为你的客户提供异步 API(如果你是某个在线商店的所有者,这很有用)。
-
当你是一个同步 API 的消费者时,如何让你的代码非阻塞。你将发现如何将两个后续的异步操作管道化,将它们合并成一个单独的异步计算。这种情况发生在,例如,在线商店返回了你想要购买的商品的原价以及折扣码时。在计算该商品的实际价格之前,你必须联系第二个远程折扣服务来找出与该折扣码相关的百分比折扣。
-
如何反应性地处理表示异步操作完成的事件的完成,以及这样做如何允许最佳价格查找应用程序在每家商店返回其价格时不断更新你想要购买的商品的最佳购买报价,而不是等待所有商店返回各自的报价。这项技能还可以避免用户在一家商店的服务器宕机时永远看到空白屏幕的情况。
同步与异步 API
术语“同步 API”是另一种谈论传统方法调用的方式:你调用它,调用者等待方法计算,方法返回,调用者继续使用返回的值。即使调用者和被调用者是在不同的线程上执行的,调用者仍然会等待被调用者完成。这种情况导致了“阻塞调用”这个术语的产生。
相比之下,在异步 API 中,方法立即返回(或者至少在计算完成之前返回),将剩余的计算委托给一个线程,该线程异步于调用者运行——因此,短语“非阻塞调用”。剩余的计算通过调用回调方法将其值提供给调用者,或者调用者调用一个“等待计算完成”的进一步方法。这种计算方式在 I/O 系统编程中很常见:你启动一个磁盘访问,它在执行更多计算的同时异步发生,当你没有更多有用的事情可做时,你等待磁盘块加载到内存中。请注意,阻塞和非阻塞通常用于操作系统对 I/O 的具体实现。然而,即使在非 I/O 环境中,这些术语也常常与异步和同步互换使用。
16.2. 实现异步 API
要开始实现最佳价格查找应用程序,定义每个商店应提供的 API。首先,一个商店声明一个方法,该方法返回产品的价格,给定其名称:
public class Shop {
public double getPrice(String product) {
// to be implemented
}
}
此方法的内部实现将查询商店的数据库,但可能还会执行其他耗时任务,例如联系其他外部服务(如商店的供应商或与制造商相关的促销折扣)。为了模拟这种长时间运行的方法执行,在本章的其余部分,你使用delay方法,该方法引入了 1 秒的人工延迟,如下所示。
列表 16.2. 模拟 1 秒延迟的方法
public static void delay() {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
为了本章的目的,你可以通过调用delay然后返回一个随机计算的价格值来模拟getPrice方法,如下所示。返回随机计算的价格的代码可能看起来有点像黑客行为;它通过使用charAt的结果作为数字来随机化价格。
列表 16.3. 在getPrice方法中引入模拟延迟
public double getPrice(String product) {
return calculatePrice(product);
}
private double calculatePrice(String product) {
delay();
return random.nextDouble() * product.charAt(0) + product.charAt(1);
}
此代码意味着当 API 的消费者(在这种情况下,最佳价格查找应用程序)调用此方法时,它会保持阻塞,然后空闲 1 秒,等待其同步完成。这种情况是不可接受的,尤其是考虑到最佳价格查找应用程序必须对其网络中的所有商店重复此操作。在本章的后续部分,你将发现如何通过以异步方式消费此同步 API 来解决此问题。但为了学习如何设计异步 API,你继续在本节中假装站在另一边。你是一位明智的店主,意识到这种同步 API 对其用户来说是多么痛苦,你希望将其重写为异步 API,以便让你的客户的生活更轻松。
16.2.1. 将同步方法转换为异步方法
为了实现这个目标,你首先必须将 getPrice 方法转换为 getPriceAsync 方法,并更改其返回值,如下所示:
public Future<Double> getPriceAsync(String product) { ... }
正如我们在本章引言中提到的,Java 5 中引入了 java.util.concurrent.Future 接口,用于表示异步计算的结果。(也就是说,调用线程可以在不阻塞的情况下继续执行。)Future 是一个用于访问尚未可用但最终可以通过调用其 get 方法来检索的值的句柄。因此,getPriceAsync 方法可以立即返回,给调用线程一个机会在同时执行其他有用的计算。Java 8 的 CompletableFuture 类为你提供了多种实现此方法的简便方法,如下所示。
列表 16.4. 实现 getPriceAsync 方法
public Future<Double> getPriceAsync(String product) {
CompletableFuture<Double> futurePrice = new CompletableFuture<>(); *1*
new Thread( () -> {
double price = calculatePrice(product); *2*
futurePrice.complete(price); *3*
}).start();
return futurePrice; *4*
}
-
1 创建将包含计算结果的 CompletableFuture。
-
2 在不同的 Thread 中异步执行计算。
-
3 当结果可用时,在 Future 上设置长时间计算返回的值。
-
4 不等待包含的结果的计算完成就返回 Future。
在这里,你创建了一个 CompletableFuture 的实例,它代表一个异步计算,并在结果可用时包含该结果。然后你创建一个不同的 Thread 来执行实际的价格计算,并在等待长时间的计算完成之前返回 Future 实例。当请求产品的价格最终可用时,你可以使用其 complete 方法完成 CompletableFuture,以设置值。这一特性也解释了这种 Java 8 Future 实现的名称。API 的客户端可以调用它,如下一个列表所示。
列表 16.5. 使用异步 API
Shop shop = new Shop("BestShop");
long start = System.nanoTime();
Future<Double> futurePrice = shop.getPriceAsync("my favorite product"); *1*
long invocationTime = ((System.nanoTime() - start) / 1_000_000);
System.out.println("Invocation returned after " + invocationTime
+ " msecs");
// Do some more tasks, like querying other shops
doSomethingElse();
// while the price of the product is being calculated
try {
double price = futurePrice.get(); *2*
System.out.printf("Price is %.2f%n", price);
} catch (Exception e) {
throw new RuntimeException(e);
}
long retrievalTime = ((System.nanoTime() - start) / 1_000_000);
System.out.println("Price returned after " + retrievalTime + " msecs");
-
1 查询商店以获取产品的价格。
-
2 从 Future 读取价格或阻塞,直到它可用。
如你所见,客户端请求商店获取某种产品的价格。因为商店提供了一个异步 API,所以这个调用几乎立即返回 Future,客户端可以通过它稍后检索产品的价格。然后客户端可以执行其他任务,例如查询其他商店,而不是保持阻塞,等待第一个商店产生所需的结果。稍后,当客户端在没有产品价格的情况下无法执行其他有意义的任务时,它可以调用 Future 上的 get。通过这样做,客户端解包 Future 中包含的值(如果异步任务已完成)或者保持阻塞,直到该值可用。代码在 列表 16.5 中产生的输出可能如下所示:
Invocation returned after 43 msecs
Price is 123.26
Price returned after 1045 msecs
您可以看到,调用 getPriceAsync 方法的返回时间远早于价格计算最终完成的时间。在 第 16.4 节 中,您将了解到客户端还可以避免任何阻塞的风险。相反,当 Future 完成时,客户端会被通知,并且可以在计算结果可用时执行回调代码,该代码通过 lambda 表达式或方法引用定义。现在,我们将解决另一个问题:如何在异步任务执行期间管理错误。
16.2.2. 处理错误
您到目前为止开发的代码在一切顺利的情况下可以正常工作。但如果价格计算生成错误会发生什么?遗憾的是,在这种情况下,您会得到一个特别负面的结果:表示错误的异常仍然局限于尝试计算产品价格的线程中,并最终杀死该线程。因此,客户端将永远阻塞,等待 get 方法的返回结果。
客户端可以通过使用接受超时参数的重载版 get 方法来防止这个问题。使用超时来防止代码其他部分出现类似情况是一种良好的实践。这样,客户端至少可以避免无限期地等待,但当超时到期时,它会通过 TimeoutException 被通知。因此,客户端将没有机会发现导致失败的线程中计算产品价格的原因。为了使客户端了解商店无法提供请求产品价格的原因,您必须通过 CompletableFuture 的 completeExceptionally 方法传播导致问题的 Exception。将这个想法应用到 列表 16.4 中,产生以下列表中的代码。
列表 16.6. 在 CompletableFuture 内部传播错误
public Future<Double> getPriceAsync(String product) {
CompletableFuture<Double> futurePrice = new CompletableFuture<>();
new Thread( () -> {
try {
double price = calculatePrice(product);
futurePrice.complete(price); *1*
} catch (Exception ex) {
futurePrice.completeExceptionally(ex); *2*
}
}).start();
return futurePrice;
}
-
1 如果价格计算正常完成,则使用价格完成 Future。
-
2 否则,使用导致失败的异常来异常完成 Future。
现在,客户端将收到一个 ExecutionException(它包含一个 Exception 参数,包含原因——原始的价格计算方法抛出的 Exception)。如果该方法抛出一个表示产品不可用的 RuntimeException,例如,客户端将收到以下类似的 ExecutionException:
Exception in thread "main" java.lang.RuntimeException:
java.util.concurrent.ExecutionException: java.lang.RuntimeException:
product not available
at java89inaction.chap16.AsyncShopClient.main(AsyncShopClient.java:16)
Caused by: java.util.concurrent.ExecutionException: java.lang.RuntimeException:
product not available
at java.base/java.util.concurrent.CompletableFuture.reportGet
(CompletableFuture.java:395)
at java.base/java.util.concurrent.CompletableFuture.get
(CompletableFuture.java:1999)
at java89inaction.chap16.AsyncShopClient.main(AsyncShopClient.java:14)
Caused by: java.lang.RuntimeException: product not available
at java89inaction.chap16.AsyncShop.calculatePrice(AsyncShop.java:38)
at java89inaction.chap16.AsyncShop.lambda$0(AsyncShop.java:33)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run
(CompletableFuture.java:1700)
at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.exec
(CompletableFuture.java:1692)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:283)
at java.base/java.util.concurrent.ForkJoinPool.runWorker
(ForkJoinPool.java:1603)
at java.base/java.util.concurrent.ForkJoinWorkerThread.run
(ForkJoinWorkerThread.java:175)
使用 supplyAsync 工厂方法创建 CompletableFuture
到目前为止,您已经创建了 CompletableFuture 并在方便的时候程序化地完成它们,但 CompletableFuture 类附带了许多方便的工厂方法,可以使这个过程更加容易和简洁。例如,supplyAsync 方法允许您使用单个语句重写 列表 16.4 中的 getPriceAsync 方法,如下一个列表所示。
列表 16.7. 使用supplyAsync工厂方法创建CompletableFuture
public Future<Double> getPriceAsync(String product) {
return CompletableFuture.supplyAsync(() -> calculatePrice(product));
}
supplyAsync方法接受一个Supplier作为参数,并返回一个CompletableFuture,该CompletableFuture将通过调用该Supplier异步完成。这个Supplier由ForkJoinPool中的一个Executor运行,但你可以通过将Executor作为第二个参数传递给该方法的重载版本来指定不同的Executor。更一般地说,你可以将Executor传递给所有其他CompletableFuture工厂方法。你在第 16.3.4 节中使用了这种能力,我们展示了使用适合你应用程序特性的Executor可以对其性能产生积极影响。
还要注意,列表 16.7 中getPriceAsync方法返回的CompletableFuture与你手动在列表 16.6 中创建和完成的CompletableFuture是等效的,这意味着它提供了你仔细添加的错误管理。
在本章的剩余部分,我们假设你无法控制Shop类实现的 API,并且它只提供同步阻塞方法。这种情况通常发生在你想使用某个服务提供的 HTTP API 时。你可以看到,即使在这种情况下,仍然可以异步查询多个商店,从而避免在单个请求上阻塞,从而提高你最佳价格查找应用程序的性能和吞吐量。
16.3. 使你的代码非阻塞
你被要求开发一个最佳价格查找应用程序,而你需要查询的所有商店都只提供与第 16.2 节开头所示相同的同步 API。换句话说,你有一份商店列表,如下所示:
List<Shop> shops = List.of(new Shop("BestPrice"),
new Shop("LetsSaveBig"),
new Shop("MyFavoriteShop"),
new Shop("BuyItAll"));
你必须实现一个具有以下签名的函数,该函数给定一个产品的名称,返回一个字符串列表。每个字符串包含商店的名称和在该商店请求的产品价格,如下所示:
public List<String> findPrices(String product);
你的第一个想法可能就是使用你在第四章、第五章和第六章中学到的Stream功能。你可能会有写类似下面这样的代码的冲动。(是的,如果你已经认为这个第一个解决方案不好,那真是太好了!)
列表 16.8. 顺序查询所有商店的findPrices实现
public List<String> findPrices(String product) {
return shops.stream()
.map(shop -> String.format("%s price is %.2f",
shop.getName(), shop.getPrice(product)))
.collect(toList());
}
这个解决方案很简单。现在尝试使用你目前非常想要的唯一产品:myPhone27S 来使用findPrices方法。此外,记录该方法运行所需的时间,如下所示列表所示。这些信息让你可以比较该方法与后来开发的改进方法的性能。
列表 16.9. 检查findPrices的正确性和性能
long start = System.nanoTime();
System.out.println(findPrices("myPhone27S"));
long duration = (System.nanoTime() - start) / 1_000_000;
System.out.println("Done in " + duration + " msecs");
列表 16.9 中的代码会产生如下输出:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74]
Done in 4032 msecs
如你所预期,findPrices方法运行所需的时间比 4 秒长几毫秒,因为四个商店是顺序查询的,一个接一个地阻塞,每个商店需要 1 秒钟来计算请求产品的价格。你如何改进这个结果?
16.3.1. 使用并行 Stream 并行化请求
在阅读第七章之后,第一个应该想到的快速改进是避免使用顺序Stream而不是并行Stream来避免这种顺序计算,如下一个列表所示。
列表 16.10. 并行化findPrices方法
public List<String> findPrices(String product) {
return shops.parallelStream() *1*
.map(shop -> String.format("%s price is %.2f",
shop.getName(), shop.getPrice(product)))
.collect(toList());
}
- 1 使用并行 Stream 并行检索不同商店的价格。
通过再次运行列表 16.9 中的代码来找出这个新的findPrices版本是否有所改进:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74]
Done in 1180 msecs
干得好!看起来这个想法简单但有效。现在四个商店是并行查询的,所以代码完成需要超过一秒钟。
你能做得更好吗?尝试将findPrices方法中所有对商店的同步调用转换为异步调用,使用你迄今为止学到的关于CompletableFutures 的知识。
16.3.2. 使用 CompletableFutures 进行异步请求
你之前看到可以使用工厂方法supplyAsync来创建CompletableFuture对象。现在使用它:
List<CompletableFuture<String>> priceFutures =
shops.stream()
.map(shop -> CompletableFuture.supplyAsync(
() -> String.format("%s price is %.2f",
shop.getName(), shop.getPrice(product))))
.collect(toList());
使用这种方法,你将获得一个List<CompletableFuture<String>>,其中List中的每个CompletableFuture在其计算完成后都包含商店的String名称。但是,因为你试图用CompletableFutures 重新实现的findPrices方法必须返回一个List<String>,你将不得不等待所有这些未来的完成并提取它们包含的值,然后才能返回List。
要实现这个结果,你可以对原始的List<CompletableFuture<String>>应用第二个map操作,对List中的所有未来调用join,然后逐个等待它们的完成。请注意,CompletableFuture类的join方法与在Future接口中声明的get方法具有相同的意义,唯一的区别是join不会抛出任何检查型异常。通过使用join,你不需要在传递给这个第二个map的 lambda 表达式中添加try/catch块。将所有这些放在一起,你可以像下面的列表所示重写findPrices方法。
列表 16.11. 使用CompletableFutures 实现findPrices方法
public List<String> findPrices(String product) {
List<CompletableFuture<String>> priceFutures =
shops.stream()
.map(shop -> CompletableFuture.supplyAsync( *1*
() -> shop.getName() + " price is " +
shop.getPrice(product)))
.collect(Collectors.toList());
return priceFutures.stream()
.map(CompletableFuture::join) *2*
.collect(toList());
}
-
1 使用 CompletableFuture 异步计算每个价格。
-
2 等待所有异步操作完成。
注意,你使用两个独立的流管道,而不是将两个 map 操作一个接一个地放在同一个流处理管道中——这有一个很好的原因。鉴于中间流操作的延迟性质,如果你在一个管道中处理流,你只能成功同步和顺序地执行所有对不同商店的请求。只有当前一个的计算完成时,才会开始创建每个 CompletableFuture 来查询特定的商店,让 join 方法返回该计算的结果。图 16.2 清晰地说明了这个重要细节。
图 16.2 的上半部分显示,使用单个管道处理流意味着评估顺序(由虚线标识)是顺序的。实际上,只有在前一个完全评估之后才会创建一个新的 CompletableFuture。相反,图的下半部分展示了首先将 CompletableFuture 收集到一个列表中(由椭圆形表示),这样它们就可以在等待完成之前同时开始。
图 16.2. 为什么 Stream 的延迟性导致顺序计算以及如何避免它

运行 列表 16.11 中的代码来检查 findPrices 方法的第三个版本的性能,你可能会得到如下输出:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74]
Done in 2005 msecs
这个结果相当令人失望,不是吗?运行时间超过 2 秒,这个使用 CompletableFuture 的实现比 列表 16.8 中的原始天真顺序和阻塞实现要快,但它也几乎比之前使用并行流的实现慢一倍。考虑到你只是对顺序版本进行了微小的修改就得到了并行流版本,这更加令人失望。
新的 CompletableFuture 版本需要相当多的工作。但在这个场景中使用 CompletableFuture 是浪费时间吗?或者你忽略了一些重要的东西?在继续前进之前,花几分钟时间回想一下,你正在测试的代码样本是在一个能够并行运行四个线程的机器上运行的.^([1])
¹
如果你使用的是能够并行运行更多线程的机器(比如,八个),你需要更多的商店和并行进程来重现这些页面上的行为。
16.3.3. 寻找更好的扩展性解决方案
并行流版本之所以表现良好,仅仅是因为它可以并行运行四个任务,因此可以为每个商店分配一个线程。如果你决定将第五个商店添加到你的最佳价格查找应用程序爬取的商店列表中,会发生什么呢?不出所料,顺序版本需要超过 5 秒的时间来运行,如下面的输出所示:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74, ShopEasy price is 166.08]
Done in 5025 msecs *1*
- 1 使用顺序流程序的输出
不幸的是,并行流版本也比之前多了一秒钟,因为可以并行运行的四个线程(在公共线程池中可用)现在都在忙于处理前四家商店。第五个查询必须等待前一个操作完成以释放一个线程,如下所示:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74, ShopEasy price is 166.08]
Done in 2167 msecs *1*
- 1 使用并行流程序的输出
那么CompletableFuture版本呢?用第五家商店试一试:
[BestPrice price is 123.26, LetsSaveBig price is 169.47, MyFavoriteShop price
is 214.13, BuyItAll price is 184.74, ShopEasy price is 166.08]
Done in 2006 msecs *1*
- 1 使用 CompletableFutures 程序的输出
使用CompletableFuture版本的程序似乎比使用并行流的版本快一点,但这种版本也不令人满意。如果你尝试用九家商店运行你的代码,并行流版本需要 3143 毫秒,而CompletableFuture版本则需要 3009 毫秒。这两个版本看起来相当,原因很好:它们都内部使用相同的公共池,默认情况下具有与Runtime.getRuntime().availableProcessors()返回的线程数量相等的固定线程数。然而,CompletableFuture版本有一个优势:与并行Streams API 相比,它允许你指定一个不同的Executor来提交任务。你可以配置这个Executor,并调整其线程池的大小,使其更好地满足你应用程序的需求。在下一节中,你将把这个更好的可配置性转化为你应用程序的实际性能提升。
16.3.4. 使用自定义的 Executor
在这种情况下,创建一个具有考虑你应用程序中可能期望的实际工作负载的线程数的Executor似乎是一个明智的选择。你如何正确地调整这个Executor的大小?
调整线程池大小
在伟大的书籍《Java 并发实践》(Addison-Wesley,2006 年;jcip.net)中,Brian Goetz 及其合著者提供了一些关于寻找线程池最佳大小的建议。这些建议很重要,因为如果池中的线程数太多,线程最终会竞争稀缺的 CPU 和内存资源,浪费它们的时间进行上下文切换。相反,如果这个数字太小(正如你应用程序中可能的那样),CPU 的一些核心将未被充分利用。Goetz 建议,你可以使用以下公式来计算正确的池大小,以近似所需的 CPU 使用率:
N^(threads) = N^(CPU) * U^(CPU) * (1 + W/C)
在这个公式中,NCPU 是通过Runtime.getRuntime().availableProcessors()可用的核心数。
-
U^(CPU) 是目标 CPU 使用率(介于 0 和 1 之间)。
-
W/C 是等待时间与计算时间的比率。
应用程序大约有 99%的时间在等待商店的响应,因此你可以估计一个 W/C 比率为 100。如果你的目标是 100%的 CPU 使用率,你应该有一个包含 400 个线程的线程池。在实践中,拥有比商店更多的线程是浪费的,因为你将会有一些永远不会使用的线程。因此,你需要设置一个具有固定数量的线程的Executor,该数量等于你需要查询的商店数量,这样你就有每个商店一个线程。此外,设置一个上限为 100 个线程,以避免在商店数量较多时服务器崩溃,如下面的列表所示。
列表 16.12. 适用于最佳价格查找应用的定制Executor
private final Executor executor =
Executors.newFixedThreadPool(Math.min(shops.size(), 100), *1*
(Runnable r) -> {
Thread t = new Thread(r);
t.setDaemon(true); *2*
return t;
}
);
-
1 创建一个包含与商店数量相同或小于 100 个线程的线程池。
-
2 使用守护线程,这不会阻止程序的终止。
注意你正在创建一个由守护线程组成的线程池。Java 程序在正常线程执行时无法终止或退出,所以一个等待永远不会满足的事件的遗留线程会导致问题。相比之下,将线程标记为守护线程意味着程序终止时它可以被杀死。这没有性能差异。现在你可以将新的Executor作为supplyAsync工厂方法的第二个参数传递。此外,现在创建一个CompletableFuture,如下所示,以从给定的商店检索请求产品的价格:
CompletableFuture.supplyAsync(() -> shop.getName() + " price is " +
shop.getPrice(product), executor);
经过这次改进,处理五个商店的CompletableFuture解决方案需要 1021 毫秒,处理九个商店需要 1022 毫秒。这种趋势一直持续到商店数量达到你之前计算的 400 个阈值。这个例子证明了创建一个适合你应用程序特性的Executor并使用CompletableFutures 向其提交任务是一个好主意。这种策略几乎总是有效的,当你大量使用异步操作时,这是一个需要考虑的因素。
并行化:通过 Streams 还是 CompletableFutures?
你已经看到了在集合上执行并行计算的两个方法:将集合转换为并行流并在其上使用如map之类的操作,或者遍历集合并在CompletableFuture中生成操作。后者通过调整线程池的大小提供更多控制,这确保了你的整体计算不会因为所有(固定数量的)线程都在等待 I/O 而阻塞。
我们使用这些 API 的建议如下:
-
如果你正在进行没有 I/O 的计算密集型操作,
Stream接口提供了最简单的实现,也可能是最有效的实现。(如果所有线程都是计算密集型的,那么拥有比处理器核心更多的线程是没有意义的。) -
如果你的并行工作单元涉及等待 I/O(包括网络连接),则
CompletableFuture解决方案提供了更多的灵活性,并允许你匹配线程数与等待/计算机(W/C)比,如前所述。另一个避免在流处理管道中涉及 I/O 等待时使用并行流的原因是流的惰性可能会使得推理等待发生的时间更加困难。
你已经学会了如何利用CompletableFuture为你的客户端提供一个异步 API,并作为同步但缓慢的服务器客户端,但你只在每个Future中执行了一个耗时的操作。在下一节中,你将使用CompletableFuture以声明式风格将多个异步操作管道化,这与你通过使用 Streams API 所学的类似。
16.4. 管道化异步任务
假设所有商店都已同意使用集中式折扣服务。此服务使用五个折扣代码,每个代码都有一个不同的折扣百分比。你通过定义一个Discount.Code枚举来表示这个想法,如下所示。
列表 16.13. 定义折扣代码的枚举
public class Discount {
public enum Code {
NONE(0), SILVER(5), GOLD(10), PLATINUM(15), DIAMOND(20);
private final int percentage;
Code(int percentage) {
this.percentage = percentage;
}
}
// Discount class implementation omitted, see Listing 16.14
}
此外,假设商店已同意更改getPrice方法的格式,现在它返回一个格式为ShopName:price:DiscountCode的String。你的示例实现返回一个随机的Discount.Code以及已经计算出的随机价格,如下所示:
public String getPrice(String product) {
double price = calculatePrice(product);
Discount.Code code = Discount.Code.values()[
random.nextInt(Discount.Code.values().length)];
return String.format("%s:%.2f:%s", name, price, code);
}
private double calculatePrice(String product) {
delay();
return random.nextDouble() * product.charAt(0) + product.charAt(1);
}
调用getPrice可能返回如下String:
BestPrice:123.26:GOLD
16.4.1. 实现折扣服务
你的最佳价格查找应用现在应该从商店获取价格;解析生成的String;并且,对于每个String,查询折扣服务器的需求。这个过程确定了所需产品的最终折扣价格。(与每个折扣代码相关联的实际折扣百分比可能会改变,这就是为什么每次都要查询服务器。)商店生成的String的解析封装在以下Quote类中:
public class Quote {
private final String shopName;
private final double price;
private final Discount.Code discountCode;
public Quote(String shopName, double price, Discount.Code code) {
this.shopName = shopName;
this.price = price;
this.discountCode = code;
}
public static Quote parse(String s) {
String[] split = s.split(":");
String shopName = split[0];
double price = Double.parseDouble(split[1]);
Discount.Code discountCode = Discount.Code.valueOf(split[2]);
return new Quote(shopName, price, discountCode);
}
public String getShopName() { return shopName; }
public double getPrice() { return price; }
public Discount.Code getDiscountCode() { return discountCode; }
}
你可以通过将商店生成的String传递给静态parse工厂方法来获取Quote类的实例——它包含商店的名称、未折扣的价格和折扣代码。
Discount服务还有一个applyDiscount方法,它接受一个Quote对象并返回一个String,声明了产生该报价的商店的折扣价格,如下所示。
列表 16.14. Discount服务
public class Discount {
public enum Code {
// source omitted ...
}
public static String applyDiscount(Quote quote) {
return quote.getShopName() + " price is " +
Discount.apply(quote.getPrice(), *1*
quote.getDiscountCode());
}
private static double apply(double price, Code code) {
delay(); *2*
return format(price * (100 - code.percentage) / 100);
}
}
-
1 将折扣代码应用于原始价格。
-
2 模拟折扣服务响应的延迟。
16.4.2. 使用折扣服务
因为 Discount 服务是一个远程服务,你再次给它添加了 1 秒的模拟延迟,如下一列表所示。正如你在第 16.3 节中所做的那样,首先尝试重新实现 findPrices 方法,以最明显(但遗憾的是,是顺序和同步的)的方式适应这些新要求。
列表 16.15. 使用 Discount 服务实现的简单 findPrices 实现
public List<String> findPrices(String product) {
return shops.stream()
.map(shop -> shop.getPrice(product)) *1*
.map(Quote::parse) *2*
.map(Discount::applyDiscount) *3*
.collect(toList());
}
-
1 从每个商店检索未折扣的价格。
-
2 将报价中商店返回的字符串转换为报价对象。
-
3 联系折扣服务以在每个报价上应用折扣。
你通过在商店流上管道化三个 map 操作来获得期望的结果:
-
第一个操作将每个商店转换为一个
String,该字符串编码了该商店请求产品的价格和折扣代码。 -
第二个操作解析这些
String,将每个String转换为Quote对象。 -
第三个操作联系远程
Discount服务,该服务计算最终折扣价格,并返回另一个包含该价格的商店名称的String。
如你所想,这个实现的性能远非最佳。但像往常一样通过运行你的基准测试来尝试测量它:
[BestPrice price is 110.93, LetsSaveBig price is 135.58, MyFavoriteShop price
is 192.72, BuyItAll price is 184.74, ShopEasy price is 167.28]
Done in 10028 msecs
如预期,这段代码运行需要 10 秒,因为查询五个商店所需的 5 秒加上折扣服务应用折扣代码到五个商店返回的价格上所消耗的 5 秒。你已经知道你可以通过将流转换为并行流来提高这个结果。但你也知道(从第 16.3 节中),当你增加要查询的商店数量时,这个解决方案的扩展性不好,因为流依赖于固定的公共线程池。相反,你了解到你可以通过定义一个自定义的 Executor 来更好地利用你的 CPU,该 Executor 调度 CompletableFuture 执行的任务。
16.4.3. 组合同步和异步操作
在本节中,你尝试异步重新实现 findPrices 方法,再次使用 CompletableFuture 提供的功能。下一列表显示了代码。如果你对某些东西看起来不熟悉,我们将在本节中解释代码。
列表 16.16. 使用 CompletableFuture 实现 findPrices 方法
public List<String> findPrices(String product) {
List<CompletableFuture<String>> priceFutures =
shops.stream()
.map(shop -> CompletableFuture.supplyAsync( *1*
() -> shop.getPrice(product), executor))
.map(future -> future.thenApply(Quote::parse)) *2*
.map(future -> future.thenCompose(quote -> *3*
CompletableFuture.supplyAsync(
() -> Discount.applyDiscount(quote), executor)))
.collect(toList());
return priceFutures.stream()
.map(CompletableFuture::join) *4*
.collect(toList());
}
-
1 异步从每个商店检索未折扣的价格。
-
2 当报价可用时,将商店返回的字符串转换为报价对象。
-
3 将结果
Future与另一个异步任务组合,应用折扣代码。 -
4 等待流中的所有
Future完成,并提取它们各自的结果。
这次事情看起来有点复杂,所以尝试一步一步地理解正在发生的事情。图 16.3 描述了这三个转换的顺序。
图 16.3. 组合同步操作和异步任务

你正在执行与列表 16.15 中同步解决方案相同的三个map操作,但在必要时将这些操作异步化,使用CompletableFuture类提供的功能。
获取价格
你已经在本章的各种示例中看到了这三个操作中的第一个;你通过传递一个 lambda 表达式到supplyAsync工厂方法来异步查询商店。第一次转换的结果是一个Stream<CompletableFuture<String>>,其中每个CompletableFuture在完成时都包含由相应商店返回的String。请注意,你使用在列表 16.12 中开发的自定义Executor配置了CompletableFuture。
解析报价
现在你必须通过第二次转换将这些String转换为Quote。但由于这种解析操作没有调用任何远程服务或进行任何 I/O 操作,它可以几乎瞬间完成,并且可以同步执行而不引入任何延迟。因此,你通过在第一步生成的CompletableFutures 上调用thenApply方法并传递一个将String转换为Quote实例的Function来实现这个第二次转换。
注意,使用thenApply方法不会阻塞你的代码,直到你调用的CompletableFuture完成。当CompletableFuture最终完成时,你想要通过传递给then-Apply方法的 lambda 表达式来转换它包含的值,从而将流中的每个CompletableFuture<String>转换为相应的CompletableFuture<Quote>。你可以将这个过程视为构建一个食谱,指定如何处理CompletableFuture的结果,就像你在处理流管道时一样。
为计算折后价格而编写未来
第三次map操作涉及联系远程Discount服务,将商店收到的非折后价格应用适当的折扣百分比。这种转换与之前的不同,因为它需要在远程执行(或者在这种情况下,需要通过延迟模拟远程调用),因此,你也希望异步执行此操作。
为了实现这个目标,就像你用getPrice的第一个supplyAsync调用一样,你将这个操作作为一个 lambda 表达式传递给supplyAsync工厂方法,它返回另一个CompletableFuture。在这个时候,你有了两个异步操作,用两个不同的CompletableFuture建模,你想要按顺序执行:
-
从商店获取价格并将其转换为
Quote。 -
将此
Quote传递给Discount服务以获取最终折后价格。
Java 8 的CompletableFuture API 提供了专门用于此目的的thenCompose方法,允许你将两个异步操作串联起来,当第一个操作的结果可用时,将其传递给第二个操作。换句话说,你可以通过在第一个CompletableFuture上调用thenCompose方法并将一个Function传递给它来组合两个CompletableFuture。这个Function以第一个CompletableFuture完成时返回的值作为参数,并返回一个使用第一个的结果作为其计算输入的第二个CompletableFuture。请注意,使用这种方法时,虽然Future正在从商店检索报价,但主线程可以执行其他有用的操作,例如响应用户界面事件。
将这三个map操作产生的Stream的元素收集到一个List中,你得到一个List<CompletableFuture<String>>。最后,你可以通过使用join等待这些CompletableFuture的完成并提取它们的值,就像你在列表 16.11 中所做的那样。在列表 16.8 中实现的findPrices方法的新版本可能产生如下输出:
[BestPrice price is 110.93, LetsSaveBig price is 135.58, MyFavoriteShop price
is 192.72, BuyItAll price is 184.74, ShopEasy price is 167.28]
Done in 2035 msecs
你在列表 16.16 中使用的thenCompose方法,就像CompletableFuture类的其他方法一样,有一个带有Async后缀的变体,即thenComposeAsync。一般来说,没有Async后缀的方法在其名称中将在前一个任务所在的线程中执行其任务,而以Async结尾的方法总是将后续任务提交到线程池,因此每个任务可以由不同的线程处理。在这种情况下,第二个CompletableFuture的结果依赖于第一个,因此使用此方法的任一变体对最终结果或其大致时间没有影响。你选择使用带有thenCompose的变体仅仅是因为它稍微更有效率,因为减少了线程切换的开销。然而,请注意,并不总是清楚使用的是哪个线程,特别是如果你运行一个管理自己的线程池的应用程序(如 Spring)。
16.4.4. 组合两个 CompletableFutures:依赖性和独立性
在列表 16.16 中,你在第一个CompletableFuture上调用了thenCompose方法,并将第二个CompletableFuture传递给它,该CompletableFuture需要第一个执行的结果作为输入。在另一种常见情况下,你需要组合两个独立CompletableFuture执行的操作的结果,而且你不想在开始第二个操作之前等待第一个完成。
在这种情况下,使用thenCombine方法。此方法将BiFunction作为第二个参数,它定义了当两个CompletableFuture都可用时如何组合它们的结果。与thenCompose方法一样,thenCombine方法还有一个Async变体。在这种情况下,使用thenCombineAsync方法会导致由BiFunction定义的组合操作提交到线程池,并在单独的任务中异步执行。
转到本章的运行示例,你可能知道其中一家商店提供的价格是€(欧元),但你总是想以\((美元)的形式与客户沟通。你可以异步地询问商店给定产品的价格,并从远程汇率服务中单独检索€和\)之间的当前汇率。在两个请求都完成后,你可以通过将价格乘以汇率来组合结果。使用这种方法,你将获得一个第三CompletableFuture,当两个CompletableFuture的结果都可用并且通过BiFunction组合时,它将完成,如下面的列表所示。
列表 16.17. 组合两个独立的CompletableFuture
Future<Double> futurePriceInUSD =
CompletableFuture.supplyAsync(() -> shop.getPrice(product)) *1*
.thenCombine(
CompletableFuture.supplyAsync(
() -> exchangeService.getRate(Money.EUR, Money.USD)), *2*
(price, rate) -> price * rate *3*
));
-
1 创建一个任务,查询商店以获取产品的价格。
-
2 创建一个独立任务以检索美元和欧元之间的汇率转换率。
-
3 通过相乘将价格和汇率结合起来。
在这种情况下,因为组合操作是一个简单的乘法,执行它将是一个资源的浪费,所以你需要使用thenCombine方法而不是其异步的thenCombineAsync对应方法。图 16.4 显示了列表 16.17 中创建的任务如何在池的不同线程上执行以及它们的结果是如何组合的。
图 16.4. 组合两个独立的异步任务

16.4.5. 关于 Future 与 CompletableFuture 的反思
在列表 16.16 和 16.17 的最后两个示例中,清楚地展示了CompletableFuture相对于其他 Java 8 之前的Future实现的最大优势之一。CompletableFuture使用 lambda 表达式提供声明式 API。此 API 允许你轻松组合和组合各种同步和异步任务,以最有效的方式执行复杂操作。为了更直观地了解CompletableFuture的代码可读性优势,尝试仅使用 Java 7 来获取列表 16.17 的结果。下一个列表将向您展示如何做到这一点。
列表 16.18. Java 7 中组合两个Future
ExecutorService executor = Executors.newCachedThreadPool(); *1*
final Future<Double> futureRate = executor.submit(new Callable<Double>() {
public Double call() {
return exchangeService.getRate(Money.EUR, Money.USD); *2*
}});
Future<Double> futurePriceInUSD = executor.submit(new Callable<Double>() {
public Double call() {
double priceInEUR = shop.getPrice(product); *3*
return priceInEUR * futureRate.get(); *4*
}});
-
1 创建一个 ExecutorService,允许你向线程池提交任务。
-
2 创建一个 Future,用于检索欧元和美元之间的汇率。
-
3 在第二个 Future 中找到给定商店请求产品的价格。
-
4 在用于查找价格的同一 Future 中乘以价格和汇率。
在列表 16.18 中,你创建了一个第一个Future,向Executor提交一个Callable以查询外部服务以找到 EUR 和 USD 之间的汇率。然后你创建了一个第二个Future,检索给定商店请求产品的 EUR 价格。最后,正如你在列表 16.17 中所做的那样,你将汇率乘以 EUR 价格,在同一 future 中查询商店以检索 EUR 价格。请注意,在列表 16.17 中使用thenCombineAsync而不是thenCombine将与在列表 16.18 中执行第三个Future中的价格乘以汇率相等效。这两种实现之间的差异可能看起来很小,只是因为你正在组合两个Future。
16.4.6. 有效使用超时

如第 16.2.2 节中所述,在尝试读取Future计算出的值时指定超时总是一个好主意,以避免在等待该值的计算时被无限期地阻塞。Java 9 引入了一些方便的方法,这些方法丰富了CompletableFuture提供的超时功能。orTimeout方法使用ScheduledThreadExecutor在指定超时时间过后,用TimeoutException完成CompletableFuture,并返回另一个CompletableFuture。通过使用此方法,你可以进一步连接你的计算管道,并通过提供友好的消息来处理TimeoutException。你可以在列表 16.17 中的Future上添加超时,并在方法链的末尾添加此方法,以在 3 秒后未完成时抛出TimeoutException,如下一列表所示。当然,超时持续时间应与你的业务需求相匹配。
列表 16.19. 向CompletableFuture添加超时
Future<Double> futurePriceInUSD =
CompletableFuture.supplyAsync(() -> shop.getPrice(product))
.thenCombine(
CompletableFuture.supplyAsync(
() -> exchangeService.getRate(Money.EUR, Money.USD)),
(price, rate) -> price * rate
))
.orTimeout(3, TimeUnit.SECONDS); *1*
- 1 如果在 3 秒后未完成,让 Future 抛出 Timeout-Exception。Java 9 中增加了异步超时管理。
有时,如果服务暂时无法及时响应,使用默认值也是可以接受的。你可能会决定在列表 16.19 中,你希望等待交易所提供当前 EUR 兑 USD 的汇率不超过 1 秒,但如果请求需要更长的时间来完成,你不想因为一个Exception而终止整个计算。相反,你可以通过使用预定义的汇率来回退。你可以通过使用completeOnTimeout方法轻松地添加这种第二种超时,该方法也是在 Java 9 中引入的(以下列表)。
列表 16.20. 超时后使用默认值完成CompletableFuture
Future<Double> futurePriceInUSD =
CompletableFuture.supplyAsync(() -> shop.getPrice(product))
.thenCombine(
CompletableFuture.supplyAsync(
() -> exchangeService.getRate(Money.EUR, Money.USD))
.completeOnTimeout(DEFAULT_RATE, 1, TimeUnit.SECONDS), *1*
(price, rate) -> price * rate
))
.orTimeout(3, TimeUnit.SECONDS);
- 1 如果汇率服务在 1 秒内不提供结果,使用默认汇率。
与orTimeout方法一样,completeOnTimeOut方法返回一个CompletableFuture,因此你可以将其与其他CompletableFuture方法链式调用。为了回顾,你已经配置了两种类型的超时:一种是在整个计算超过 3 秒时使整个计算失败,另一种是在 1 秒后过期,但用预定的值完成Future而不是导致失败。
你几乎完成了你的最佳价格查找应用程序,但仍然缺少一个成分。你希望用户一有可用价格就能看到商店提供的价格(就像汽车保险和航班比较网站通常所做的那样),而不是像你现在所做的那样等待所有价格请求完成。在下一节中,你将了解如何通过响应CompletableFuture的完成而不是对其调用get或join来达到这个目标,从而避免在CompletableFuture本身完成之前被阻塞。
16.5. 响应 CompletableFuture 的完成
在本章中你看到的所有代码示例中,你都模拟了具有 1 秒延迟的远程调用方法。在现实世界的场景中,你需要从你的应用程序中联系到的远程服务可能由于服务器负载、网络延迟等因素而具有不可预测的延迟,也许还因为服务器认为你的应用程序的业务价值与支付更多查询费用的应用程序相比如何。
由于这些原因,你想要购买的产品价格可能在某些商店比其他商店更早可用。在下一列表中,你通过引入 0.5 到 2.5 秒的随机延迟来模拟这种场景,使用randomDelay方法而不是等待 1 秒的delay方法。
列表 16.21. 模拟 0.5 到 2.5 秒随机延迟的方法
private static final Random random = new Random();
public static void randomDelay() {
int delay = 500 + random.nextInt(2000);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
到目前为止,你已经实现了findPrices方法,使其仅在所有商店的价格都可用时显示价格。现在你希望最佳价格查找应用程序能够在给定商店的价格一有可用就显示,而不必等待最慢的那个(甚至可能超时)。你如何实现这一进一步的改进?
16.5.1. 重构最佳价格查找应用程序
首先要避免的是等待包含所有价格的List的创建。你需要直接与CompletableFuture的流一起工作,其中每个CompletableFuture正在执行给定商店所需的操作序列。在下一列表中,你将实现的第一部分重构为findPricesStream方法,以生成这个CompletableFuture的流。
列表 16.22. 重构findPrices方法以返回一个Future流
public Stream<CompletableFuture<String>> findPricesStream(String product) {
return shops.stream()
.map(shop -> CompletableFuture.supplyAsync(
() -> shop.getPrice(product), executor))
.map(future -> future.thenApply(Quote::parse))
.map(future -> future.thenCompose(quote ->
CompletableFuture.supplyAsync(
() -> Discount.applyDiscount(quote), executor)));
}
在这一点上,你将一个第四个 map 操作添加到 findPricesStream 方法返回的 Stream 上,该方法中已经执行了三个操作。这个新操作在每个 CompletableFuture 上注册一个动作;这个动作在 CompletableFuture 完成时立即消费其值。Java 8 的 CompletableFuture API 通过 thenAccept 方法提供了这个功能,该方法接受一个 Consumer 作为参数,该 Consumer 消费其完成的值。在这种情况下,这个值是由折扣服务返回的 String,其中包含商店的名称以及该商店请求产品的折扣价格。你想要执行的唯一动作是打印这个值:
findPricesStream("myPhone").map(f -> f.thenAccept(System.out::println));
正如你在 thenCompose 和 thenCombine 方法中看到的,thenAccept 方法有一个名为 thenAcceptAsync 的 Async 变体。Async 变体将传递给它的 Consumer 的执行调度到线程池中的新线程上,而不是使用完成 CompletableFuture 的相同线程直接执行。因为你想要避免不必要的上下文切换,并且(更重要的是)你想要尽快对 CompletableFuture 的完成做出反应,而不是等待新线程可用,所以在这里不使用这个变体。
因为 thenAccept 方法已经指定了当 CompletableFuture 可用时如何消费其产生的结果,所以它返回一个 CompletableFuture<Void>。因此,map 操作返回一个 Stream<CompletableFuture<Void>>。对于 CompletableFuture<Void>,除了等待其完成之外,你几乎无法做任何事情,但这正是你所需要的。你还想给最慢的商店一个机会来提供其响应并打印其返回的价格。为此,你可以将流中的所有 CompletableFuture<Void> 放入一个数组中,然后等待它们全部完成,就像在下面的列表中所展示的那样。
列表 16.23. 对 CompletableFuture 完成做出反应
CompletableFuture[] futures = findPricesStream("myPhone")
.map(f -> f.thenAccept(System.out::println))
.toArray(size -> new CompletableFuture[size]);
CompletableFuture.allOf(futures).join();
allOf 工厂方法接受一个 CompletableFuture 数组作为输入,并返回一个 CompletableFuture<Void>,只有当传递的所有 CompletableFuture 都已完成时才会完成。在 allOf 方法返回的 CompletableFuture 上调用 join 提供了一种等待原始流中所有 CompletableFuture 完成的简单方法。这种技术在最佳价格查找应用程序中很有用,因为它可以显示一条消息,例如 所有商店都返回了结果或超时,这样用户就不会继续想知道是否还有更多的价格可能变得可用。
在其他应用程序中,您可能只想等待数组中CompletableFuture的完成,例如,如果您正在咨询两个货币兑换服务器,并且对第一个响应的结果感到满意,您可以使用anyOf工厂方法。作为一个细节,此方法接受一个CompletableFuture数组作为输入,并返回一个CompletableFuture<Object>,它以第一个完成的CompletableFuture的相同值完成。
16.5.2. 将所有内容整合在一起
如在第 16.5 节开头所述,现在假设所有模拟远程调用的方法都使用列表 16.21 中的randomDelay方法,引入介于 0.5 到 2.5 秒之间的随机延迟,而不是 1 秒的延迟。使用此更改运行列表 16.23 中的代码,您会发现商店提供的价格不会像之前那样同时出现,而是随着给定商店的折扣价格可用而逐步打印。为了使此更改的结果更明显,代码略有修改,以报告每个价格计算所需的时间戳:
long start = System.nanoTime();
CompletableFuture[] futures = findPricesStream("myPhone27S")
.map(f -> f.thenAccept(
s -> System.out.println(s + " (done in " +
((System.nanoTime() - start) / 1_000_000) + " msecs)")))
.toArray(size -> new CompletableFuture[size]);
CompletableFuture.allOf(futures).join();
System.out.println("All shops have now responded in "
+ ((System.nanoTime() - start) / 1_000_000) + " msecs");
运行此代码会产生类似于以下内容的输出:
BuyItAll price is 184.74 (done in 2005 msecs)
MyFavoriteShop price is 192.72 (done in 2157 msecs)
LetsSaveBig price is 135.58 (done in 3301 msecs)
ShopEasy price is 167.28 (done in 3869 msecs)
BestPrice price is 110.93 (done in 4188 msecs)
All shops have now responded in 4188 msecs
您可以看到,由于随机延迟的影响,第一个价格现在打印的速度比最后一个快两倍以上!
16.6. 路线图
第十七章探讨了 Java 9 Flow API,该 API 通过启用计算在可选终止之前产生一系列值,从而泛化了Computable-Future(一次性,要么计算要么带有值的终止)的概念。
摘要
-
通过使用异步任务执行相对较长的操作可以提高应用程序的性能和响应速度,尤其是如果它依赖于一个或多个远程外部服务。
-
您应该考虑为您的客户端提供一个异步 API。您可以通过使用
CompletableFuture的特性轻松实现一个。 -
CompletableFuture允许您传播和管理异步任务中生成的错误。 -
您可以通过将同步 API 的调用包装在
CompletableFuture中,异步地从同步 API 中消费。 -
当多个异步任务独立且其中一个的结果用作另一个的输入时,您可以组合或合并多个异步任务。
-
您可以在
CompletableFuture上注册一个回调,以便在Future完成并且其结果可用时,反应性地执行一些代码。 -
您可以确定
CompletableFuture列表中所有值何时完成,或者您可以只等待第一个完成。 -
Java 9 通过
orTimeout和completeOnTimeout方法在CompletableFuture上添加了对异步超时的支持。
第十七章. 响应式编程

本章涵盖
-
定义响应式编程并讨论响应式宣言的原则
-
应用程序和系统级别的响应式编程
-
使用响应式流和 Java 9 Flow API 展示示例代码
-
介绍广泛使用的响应式库 RxJava
-
探索 RxJava 操作以转换和组合多个响应式流
-
展示文档化响应式流操作的宝石图
在我们深入探讨响应式编程是什么以及它是如何工作的之前,澄清为什么这种新范例越来越重要是有帮助的。几年前,最大的应用程序有数十台服务器和数 GB 的数据;几秒钟的响应时间和以小时计的离线维护时间被认为是可接受的。如今,这种情况正在迅速变化,至少有三个原因:
-
大数据—大数据通常以 PB(拍字节)为单位,并且每天都在增加。
-
异构环境—应用程序被部署在从移动设备到运行数千个多核处理器的基于云的集群的多种环境中。
-
使用模式—用户期望毫秒级响应时间和 100%的在线时间。
这些变化意味着今天的需求无法通过昨天的软件架构得到满足。这种情况在移动设备成为互联网流量最大来源的今天尤为明显,而在不久的将来,当这种流量被物联网(IoT)所超越时,情况只会变得更糟。
响应式编程通过允许你以异步方式处理和组合来自不同系统和来源的数据项流来解决这些问题。事实上,遵循这种范例编写的应用程序会对发生的数据项做出反应,这使得它们在与用户的交互中更加响应。此外,响应式方法不仅可以应用于构建单个组件或应用程序,还可以应用于将许多组件协调成一个完整的响应式系统。以这种方式构建的系统可以在不同的网络条件下交换和路由消息,在考虑故障和中断的情况下提供高负载下的可用性。(请注意,尽管开发者传统上认为他们的系统或应用程序是由组件构建的,但在这种新的混合风格中,松散耦合的系统构建方式,这些组件往往是完整的应用程序本身。因此,“组件”和“应用程序”几乎是同义词。)
响应式应用程序和系统的特征和优势在下一节讨论的响应式宣言中得到了体现。
17.1. 响应式宣言
响应式宣言(www.reactivemanifesto.org)——由 Jonas Bonér、Dave Farley、Roland Kuhn 和 Martin Thompson 于 2013 年和 2014 年开发——为开发响应式应用程序和系统制定了一套核心原则。宣言确定了四个特征:
-
响应性—**反应式系统具有快速且更重要的是一致、可预测的响应时间。因此,用户知道可以期待什么。这一事实反过来又增加了用户的信心,这无疑是可用应用程序的关键方面。
-
弹性—**一个系统必须在出现故障的情况下保持响应性。反应式宣言建议了不同的技术来实现弹性,包括复制组件的执行、在时间(发送者和接收者有独立的生命周期)和空间(发送者和接收者运行在不同的进程中)上解耦这些组件,以及让每个组件异步地将任务委托给其他组件。
-
弹性—**另一个损害应用程序响应性的问题是,它们在其生命周期内可能会受到不同的工作负载的影响。反应式系统被设计为能够自动对更重的工作负载做出反应,通过增加分配给受影响组件的资源数量。
-
消息驱动—**弹性和弹性要求构成系统的组件的边界清晰定义,以确保松散耦合、隔离和位置透明。这些边界之间的通信是通过异步消息传递来完成的。这种选择既实现了弹性(通过将故障作为消息委托)又实现了弹性(通过监控交换的消息数量,然后相应地扩展管理这些资源的资源数量)。
图 17.1 展示了这四个特性是如何相互关联和依赖的。这些原则在不同的尺度上都是有效的,从小型应用程序的内部结构到确定这些应用程序如何协调以构建大型系统。然而,关于应用这些想法的粒度级别的具体问题,值得进一步详细讨论。
图 17.1. 反应式系统的关键特性

17.1.1. 应用级别的反应式
反应式编程在应用级组件中的主要特性允许异步执行任务。正如我们在本章的其余部分所讨论的,以异步和非阻塞的方式处理事件流对于最大化现代多核 CPU 的使用率至关重要,更确切地说,对于竞争使用它们的线程至关重要。为了实现这一目标,反应式框架和库在较轻的构造(如未来、演员;以及更常见的)事件循环中共享线程(相对昂贵且稀缺的资源),这些事件循环分发一系列回调,旨在聚合、转换和管理要处理的事件。
背景知识检查
如果你对于诸如事件、消息、信号和事件循环(或发布-订阅、监听器和背压,这些将在本章后面使用)等术语感到困惑,请阅读第十五章中的温和介绍。如果不,请继续阅读。
这些技术不仅比线程更便宜,而且从开发者的角度来看还有一个主要优势:它们提高了实现并发和异步应用程序的抽象级别,使开发者能够专注于业务需求,而不是处理低级多线程问题(如同步、竞态条件和死锁)的典型问题。
在使用这些线程多路复用策略时,最重要的是永远不要在主事件循环中执行阻塞操作。将所有 I/O 密集型操作(如访问数据库或文件系统或调用可能需要很长时间或不可预测时间完成的远程服务)视为阻塞操作是有帮助的。通过提供一个实际例子来解释为什么你应该避免阻塞操作,既容易又有趣。
想象一个简化但典型的多路复用场景,其中有两个线程的线程池处理三个事件流。同时只能处理两个流,并且流必须尽可能公平和高效地竞争这两个线程。现在假设处理一个流的事件触发一个可能缓慢的 I/O 操作,例如通过阻塞 API 写入文件系统或从数据库检索数据。如图 17.2 图 17.2 所示,在这种情况下,线程 2 无谓地阻塞等待 I/O 操作完成,因此尽管线程 1 可以处理第一个流,但在阻塞操作完成之前,第三个流无法被处理。
图 17.2. 一个阻塞操作无谓地占用线程,阻止它执行其他计算。

为了克服这个问题,大多数响应式框架(如 RxJava 和 Akka)允许通过一个单独的专用线程池来执行阻塞操作。主线程池中的所有线程都可以不间断地运行,保持 CPU 的所有核心以尽可能高的使用率运行。为 CPU 密集型和 I/O 密集型操作保留单独的线程池还有进一步的优点,即允许你以更精细的粒度对线程池进行大小和配置,并更精确地监控这两种类型任务的性能。
通过遵循响应式原则来开发应用程序只是响应式编程的一个方面,而且往往甚至不是最困难的。拥有一套设计精美的响应式应用程序,在独立运行时效率高,至少与使它们在一个协调良好的响应式系统中协作一样重要。
17.1.2. 系统级别的响应式
一个反应式系统是一种软件架构,它允许多个应用程序作为一个单一、连贯、弹性的平台协同工作,同时也允许这些应用程序足够解耦,以至于当其中一个失败时,不会导致整个系统崩溃。反应式应用程序和系统之间的主要区别在于,前者通常基于短暂的数据流进行计算,被称为事件驱动。后者旨在组合应用程序并促进通信。具有这种特性的系统通常被称为消息驱动系统。
消息和事件之间的另一个重要区别是,消息是针对一个定义明确的单一目的地,而事件是事实,将被注册以观察它们的组件接收。在反应式系统中,这些消息也必须是异步的,以保持发送和接收操作与发送者和接收者之间的解耦。这种解耦是组件之间完全隔离的要求,对于在故障(弹性)和重负载(弹性)下保持系统响应性是基本的。
更精确地说,通过隔离发生故障的组件,以防止故障传播到相邻组件,并从那里以灾难性的级联方式传播到整个系统,从而在反应式架构中实现弹性。在这种反应式意义上,弹性不仅仅是容错。系统不会优雅地退化,而是通过隔离故障并使系统恢复到健康状态,从而完全恢复。这种“魔法”是通过包含错误并将它们作为消息发送到其他作为监督者的组件来实现的。通过这种方式,可以从失败组件外部安全的环境中执行问题的管理。
由于隔离和解耦对于弹性至关重要,弹性主要是由位置透明性实现的,它允许反应式系统的任何组件与任何其他服务通信,无论接收者位于何处。位置透明性反过来又允许系统根据当前的工作负载复制和(自动)扩展任何应用程序。这种位置无关的扩展显示了反应式应用程序(异步、并发和解耦)和反应式系统(可以通过位置透明性在空间上解耦)之间的另一个区别。
在本章的其余部分,你将通过一些反应式编程的示例将这些想法付诸实践,特别是你将探索 Java 9 的 Flow API。
17.2. 反应式流和 Flow API
反应式编程 是一种使用反应式流的编程方式。反应式流是一种基于发布-订阅(或 pub-sub)协议(在第十五章中解释)的标准技术,用于异步、按顺序处理可能无界的数据流,并带有强制性的非阻塞背压。背压是发布-订阅中用于防止流中事件慢消费者被一个或多个更快生产者压垮的流量控制机制。当这种情况发生时,处于压力下的组件失败或无序地丢弃事件是不可接受的。该组件需要一种方式来请求上游生产者减速,或者告诉它们在接收更多数据之前可以接受和处理的特定时间内的数据量。
值得注意的是,内置背压的要求是由流处理异步性质所证明的。事实上,当执行同步调用时,系统会隐式地通过阻塞 API 进行背压。不幸的是,这种情况阻止你执行任何其他有用的任务,直到阻塞操作完成,因此你最终会浪费大量资源等待。相反,使用异步 API 可以最大化硬件的使用率,但会面临压垮某些其他较慢下游组件的风险。在这种情况下,背压或流量控制机制就派上用场了;它们建立了一种协议,防止数据接收者被压垮,而不需要阻塞任何线程。
这些要求和它们所隐含的行为被总结在反应式流项目(Reactive Streams^([1]))中,该项目涉及 Netflix、Red Hat、Twitter、Lightbend 和其他公司的工程师,并产生了四个相互关联的接口的定义,这些接口代表了任何反应式流实现必须提供的最小功能集。这些接口现在是 Java 9 的一部分,嵌套在新的java.util.concurrent.Flow类中,并由许多第三方库实现,包括 Akka Streams(Lightbend)、Reactor(Pivotal)、RxJava(Netflix)和 Vert.x(Red Hat)。在下一节中,我们将详细检查这些接口声明的方法,并阐明它们预期如何用于表达反应式组件。
¹
我们为反应式流项目命名,但使用反应式流的概念。
17.2.1. 介绍 Flow 类
Java 9 为反应式编程添加了一个新类:java.util.concurrent.Flow。这个类只包含静态组件,不能被实例化。Flow 类包含四个嵌套接口,用于表达反应式编程的发布-订阅模型,该模型由反应式流项目标准化:
-
发布者
-
订阅者
-
订阅
-
处理器
Flow 类允许通过相关接口和静态方法建立受控流组件,其中 Publisher 产生由一个或多个 Subscriber 消费的项目,每个 Subscriber 都由一个 Subscription 管理。Publisher 是一个提供可能无界序列事件的提供者,但它受到背压机制的约束,必须根据从其 Subscriber(s) 收到的需求来产生它们。Publisher 是一个 Java 函数式接口(仅声明一个抽象方法),允许 Subscriber 将自己注册为 Publisher 发出事件的监听器;Publisher 和 Subscriber 之间的流控制,包括背压,由 Subscription 管理。这三个接口,连同 Processor 接口,在 列表 17.1、17.2、17.3 和 17.4 中列出。
列表 17.1. Flow.Publisher 接口
@FunctionalInterface
public interface Publisher<T> {
void subscribe(Subscriber<? super T> s);
}
在另一方面,Subscriber 接口有四个回调方法,当 Publisher 产生相应的事件时,由 Publisher 调用。
列表 17.2. Flow.Subscriber 接口
public interface Subscriber<T> {
void onSubscribe(Subscription s);
void onNext(T t);
void onError(Throwable t);
void onComplete();
}
这些事件必须严格按照此协议定义的顺序发布(并调用相应的方法):
onSubscribe onNext* (onError | onComplete)?
这种表示法意味着 onSubscribe 总是作为第一个事件被调用,随后是任意数量的 onNext 信号。事件流可以无限进行,或者可以通过 onComplete 回调来终止,表示不再产生更多元素,或者通过 onError 如果 Publisher 遇到失败。(与从终端读取字符串或文件结束或 I/O 错误的指示进行比较。)
当 Subscriber 在 Publisher 上注册时,Publisher 的第一个动作是调用 onSubscribe 方法来返回一个 Subscription 对象。Subscription 接口声明了两个方法。Subscriber 可以使用第一个方法来通知 Publisher 它已准备好处理给定数量的事件;第二个方法允许它取消 Subscription,从而告诉 Publisher 它不再感兴趣接收其事件。
列表 17.3. Flow.Subscription 接口
public interface Subscription {
void request(long n);
void cancel();
}
Java 9 流规范定义了一套规则,通过这些规则这些接口的实现应该相互协作。这些规则可以总结如下:
-
Publisher必须向Subscriber发送不超过Subscription的request方法指定的元素数量。然而,Publisher可以发送少于请求的onNext并通过调用onComplete来终止Subscription,如果操作成功或通过onError如果它失败。在这些情况下,当达到终端状态(onComplete或onError)时,Publisher不能向其Subscribers 发送任何其他信号,并且必须认为Subscription已被取消。 -
Subscriber必须通知Publisher它已准备好接收和处理n个元素。通过这种方式,Subscriber对Publisher实施反向压力,防止Subscriber被过多的管理事件所淹没。此外,当处理onComplete或onError信号时,Subscriber不被允许在Publisher或Subscription上调用任何方法,并且必须认为Subscription已被取消。最后,Subscriber必须准备好在没有先前的Subscription.request()方法调用的情况下接收这些终端信号,甚至在调用Subscription.cancel()之后也要接收一个或多个onNext。 -
Subscription由一个Publisher和一个Subscriber共享,并代表它们之间独特的关系。因此,它必须允许Subscriber从onSubscribe和onNext方法中同步调用其request方法。标准指定Subscription.cancel()方法的实现必须是幂等的(重复调用与单次调用具有相同的效果)并且线程安全的,这样,在第一次调用之后,对Subscription的任何其他附加调用都没有效果。调用此方法要求Publisher最终放弃对相应Subscriber的任何引用。不建议使用相同的Subscriber对象重新订阅,但规范没有规定在这种情况下抛出异常,因为所有之前取消的订阅都必须无限期地存储。
展示了实现 Flow API 定义的接口的应用程序的典型生命周期。
图 17.3. 使用 Flow API 的反应式应用程序的生命周期

Flow 类的第四个也是最后一个成员是 Processor 接口,它扩展了 Publisher 和 Subscriber 接口,而不需要任何额外的函数。
列表 17.4. Flow.Processor 接口
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> { }
实际上,此接口代表了通过反应式流处理的事件的转换阶段。当收到错误时,Processor 可以选择从中恢复(然后认为 Subscription 已被取消)或立即将 onError 信号传播给其 Subscriber(s)。当最后一个 Subscriber 取消其 Subscription 时,Processor 应取消其上游 Subscription 以传播取消信号(即使规范没有严格要求这样做)。
Java 9 的 Flow API/Reactive Streams API 要求任何 Subscriber 接口所有方法的实现都不应该阻塞 Publisher,但它没有指定这些方法应该同步还是异步地处理事件。然而,请注意,这些接口定义的所有方法都返回 void,这样就可以完全异步地实现。
在接下来的这一节中,你将通过一个简单实用的例子来运用到目前为止所学到的知识。
17.2.2. 创建你的第一个反应式应用程序
在Flow类中定义的接口,在大多数情况下,并不是直接实现的。不寻常的是,Java 9 库也没有提供实现它们的类!这些接口是由我们之前提到的反应式库(Akka、RxJava 等)实现的。Java 9 对java.util.concurrency.Flow的规范既是一个所有这些库都必须遵守的合同,也是一个通用语言,允许基于不同反应式库开发的应用程序相互合作和通信。此外,这些反应式库通常提供更多功能(类和方法,这些类和方法可以转换和合并反应式流,而不仅仅是java.util.concurrency.Flow接口指定的最小子集)。
话虽如此,直接在 Java 9 的Flow API 之上开发第一个反应式应用程序是有意义的,以了解前面几节讨论的四个接口是如何协同工作的。为此,你将编写一个简单的温度报告程序,使用反应式原则。这个程序有两个组件:
-
TempInfo,模拟一个远程温度计(不断报告 0 到 99 华氏度之间随机选择的温度,这对于大多数美国城市来说很合适) -
TempSubscriber,它监听这些报告并打印安装在特定城市中的传感器报告的温度流
第一步是定义一个简单的类,用于传达当前报告的温度,如下面的列表所示。
列表 17.5. 一个传达当前报告温度的 Java Bean
import java.util.Random;
public class TempInfo {
public static final Random random = new Random();
private final String town;
private final int temp;
public TempInfo(String town, int temp) {
this.town = town;
this.temp = temp;
}
public static TempInfo fetch(String town) { *1*
if (random.nextInt(10) == 0) *2*
throw new RuntimeException("Error!");
return new TempInfo(town, random.nextInt(100)); *3*
}
@Override
public String toString() {
return town + " : " + temp;
}
public int getTemp() {
return temp;
}
public String getTown() {
return town;
}
}
-
1 通过静态工厂方法创建给定城镇的 TempInfo 实例。
-
2 获取当前温度可能会在十次中随机失败一次。
-
3 返回 0 到 99 华氏度范围内的随机温度
在定义了这个简单的领域模型之后,你可以实现一个针对特定城镇温度的Subscription,每当其Subscriber请求报告时,就会发送温度报告,如下面的列表所示。
列表 17.6. 一个向其Subscriber发送TempInfo流的Subscription
import java.util.concurrent.Flow.*;
public class TempSubscription implements Subscription {
private final Subscriber<? super TempInfo> subscriber;
private final String town;
public TempSubscription( Subscriber<? super TempInfo> subscriber,
String town ) {
this.subscriber = subscriber;
this.town = town;
}
@Override
public void request( long n ) {
for (long i = 0L; i < n; i++) { *1*
try {
subscriber.onNext( TempInfo.fetch( town ) ); *2*
} catch (Exception e) {
subscriber.onError( e ); *3*
break;
}
}
}
@Override
public void cancel() {
subscriber.onComplete(); *4*
}
}
-
1 每次由 Subscriber 发起的请求中循环一次
-
2 将当前温度发送给 Subscriber
-
3 在获取温度失败的情况下,将错误传播给 Subscriber
-
4 如果取消订阅,向 Subscriber 发送完成(onComplete)信号。
下一步是创建一个Subscriber,每次它接收到新元素时,都会打印从Subscription接收到的温度,并请求新的报告,如下面的列表所示。
列表 17.7. 一个打印接收到的温度的Subscriber
import java.util.concurrent.Flow.*;
public class TempSubscriber implements Subscriber<TempInfo> {
private Subscription subscription;
@Override
public void onSubscribe( Subscription subscription ) { *1*
this.subscription = subscription;
subscription.request( 1 );
}
@Override
public void onNext( TempInfo tempInfo ) { *2*
System.out.println( tempInfo );
subscription.request( 1 );
}
@Override
public void onError( Throwable t ) { *3*
System.err.println(t.getMessage());
}
@Override
public void onComplete() {
System.out.println("Done!");
}
}
-
1 存储订阅并发送第一个请求
-
2 打印接收到的温度并请求进一步的一个
-
3 在出错时打印错误信息
下一个列表展示了如何使用创建 Publisher 并通过 TempSubscriber 订阅到它的 Main 类来使你的响应式应用程序工作。
列表 17.8. 创建一个 main 类:创建一个 Publisher 并将 TempSubscriber 订阅到它
import java.util.concurrent.Flow.*;
public class Main {
public static void main( String[] args ) {
getTemperatures( "New York" ).subscribe( new TempSubscriber() ); *1*
}
private static Publisher<TempInfo> getTemperatures( String town ) { *2*
return subscriber -> subscriber.onSubscribe(
new TempSubscription( subscriber, town ) );
}
}
-
1 创建一个新的纽约温度
Publisher并将TempSubscriber订阅到它 -
**2 返回一个向订阅它的订阅者发送
TempSubscription的Publisher**
在这里,getTemperatures 方法返回一个接受 Subscriber 作为参数并调用其 onSubscribe 方法的 lambda 表达式,并将一个新的 TempSubscription 实例传递给它。因为这个 lambda 的签名与 Publisher 功能接口的唯一抽象方法签名相同,Java 编译器可以自动将 lambda 转换为 Publisher(正如你在第三章中学到的)。main 方法创建了一个纽约温度的 Publisher,然后订阅了一个新的 TempSubscriber 类实例。运行 main 会产生类似以下的输出:
New York : 44
New York : 68
New York : 95
New York : 30
Error!
在先前的运行中,TempSubscription 成功获取了纽约的温度四次,但在第五次读取时失败了。似乎你正确地使用了 Flow API 的四个接口中的三个来实现了这个问题。但你确定代码中没有错误吗?通过完成以下练习来思考这个问题。
练习 17.1:
到目前为止开发的示例存在一个微妙的问题。然而,这个问题被这样一个事实所掩盖,即在某个时刻,温度流会被 TempInfo 工厂方法内部随机生成的错误中断。如果你注释掉生成随机错误的语句并让 main 运行足够长的时间,你能猜到会发生什么吗?
答案:
你到目前为止所做的问题在于,每次 TempSubscriber 在其 onNext 方法中接收到一个新元素时,它会向 TempSubscription 发送一个新的请求,然后 request 方法会向 Temp-Subscriber 本身发送另一个元素。这些递归调用一个接一个地推入栈中,直到栈溢出,生成类似于以下的 StackOverflowError:
Exception in thread "main" java.lang.StackOverflowError
at java.base/java.io.PrintStream.print(PrintStream.java:666)
at java.base/java.io.PrintStream.println(PrintStream.java:820)
at flow.TempSubscriber.onNext(TempSubscriber.java:36)
at flow.TempSubscriber.onNext(TempSubscriber.java:24)
at flow.TempSubscription.request(TempSubscription.java:60)
at flow.TempSubscriber.onNext(TempSubscriber.java:37)
at flow.TempSubscriber.onNext(TempSubscriber.java:24)
at flow.TempSubscription.request(TempSubscription.java:60)
...
你可以做什么来解决这个问题并避免栈溢出?一个可能的解决方案是在 TempSubscription 中添加一个 Executor,然后使用它从不同的线程向 TempSubscriber 发送新的元素。为了达到这个目标,你可以按照下一个列表所示修改 TempSubscription。(该类是不完整的;完整的定义使用了 列表 17.6 中的剩余定义。)
列表 17.9. 向 TempSubscription 添加一个 Executor
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TempSubscription implements Subscription { *1*
private static final ExecutorService executor =
Executors.newSingleThreadExecutor();
@Override
public void request( long n ) {
executor.submit( () -> { *2*
for (long i = 0L; i < n; i++) {
try {
subscriber.onNext( TempInfo.fetch( town ) );
} catch (Exception e) {
subscriber.onError( e );
break;
}
}
});
}
}
-
1 省略了原始
TempSubscription的未修改代码。 -
2 从不同的线程向订阅者发送下一个元素
到目前为止,你只使用了 Flow API 定义的四个接口中的三个。那么 Processor 接口呢?使用该接口的一个好例子是创建一个 Publisher,它报告的是摄氏温度而不是华氏温度(对于美国以外的订阅者)。
17.2.3. 使用 Processor 转换数据
如 17.2.1 节 所述,Processor 既是 Subscriber 也是 Publisher。实际上,它旨在订阅一个 Publisher 并在转换数据后重新发布它接收到的数据。作为一个实际例子,实现一个 Processor,它订阅一个发布华氏温度的 Publisher 并在将其转换为摄氏度后重新发布,如下一个列表所示。
列表 17.10. 一个将华氏温度转换为摄氏温度的 Processor
import java.util.concurrent.Flow.*;
public class TempProcessor implements Processor<TempInfo, TempInfo> { *1*
private Subscriber<? super TempInfo> subscriber;
@Override
public void subscribe( Subscriber<? super TempInfo> subscriber ) {
this.subscriber = subscriber;
}
@Override
public void onNext( TempInfo temp ) {
subscriber.onNext( new TempInfo( temp.getTown(),
(temp.getTemp() - 32) * 5 / 9) ); *2*
}
@Override
public void onSubscribe( Subscription subscription ) {
subscriber.onSubscribe( subscription ); *3*
}
@Override
public void onError( Throwable throwable ) {
subscriber.onError( throwable ); *3*
}
@Override
public void onComplete() {
subscriber.onComplete(); *3*
}
}
-
1 将一个
TempInfo转换为另一个TempInfo的处理器 -
2 在将温度转换为摄氏度后重新发布
TempInfo -
3 所有其他信号都未改变地委托给上游订阅者。
注意,TempProcessor 中唯一包含一些业务逻辑的方法是 onNext,它将温度从华氏转换为摄氏后再重新发布。所有其他实现 Subscriber 接口的方法只是将接收到的所有信号(委托)传递给上游 Subscriber,而 Publisher 的 subscribe 方法将上游 Subscriber 注册到 Processor 中。
下一个列表将 TempProcessor 用于你的 Main 类中。
列表 17.11. Main 类:创建一个 Publisher 并将其订阅给 TempSubscriber
import java.util.concurrent.Flow.*;
public class Main {
public static void main( String[] args ) {
getCelsiusTemperatures( "New York" ) *1*
.subscribe( new TempSubscriber() ); *2*
}
public static Publisher<TempInfo> getCelsiusTemperatures(String town) {
return subscriber -> {
TempProcessor processor = new TempProcessor(); *3*
processor.subscribe( subscriber );
processor.onSubscribe( new TempSubscription(processor, town) );
};
}
}
-
1 为纽约创建一个新的摄氏温度发布者
-
2 将
TempSubscriber订阅到发布者 -
3 创建一个
TempProcessor并将其置于订阅者和返回的发布者之间
这次,运行 Main 产生了以下输出,其中温度是摄氏温度尺度上的典型值:
New York : 10
New York : -12
New York : 23
Error!
在本节中,你直接实现了在 Flow API 中定义的接口,并在这样做的同时,通过发布-订阅协议熟悉了异步流处理,这是 Flow API 的核心思想。但这个例子中有一点稍微不同寻常,我们将在下一节中讨论。
17.2.4. 为什么 Java 不提供 Flow API 的实现?
Java 9 中的 Flow API 相当奇特。Java 库通常提供接口及其实现,但在这里,你亲自实现了 Flow API。让我们将其与 List API 进行比较。正如你所知,Java 提供了由许多类实现的List<T>接口,包括ArrayList<T>。更精确(并且对用户来说几乎不可见)的是,ArrayList<T>类扩展了抽象类AbstractList<T>,该类实现了接口List<T>。相比之下,Java 9 声明了接口Publisher<T>,但没有提供实现,这就是为什么你必须定义自己的(除了从实现中获得的学习收益之外)。让我们面对现实——一个接口本身可能有助于你组织编程思想,但它并不能帮助你更快地编写程序!
发生了什么?答案是历史的:有多个 Java 代码库的 reactive streams(如 Akka 和 RxJava)。最初,这些库是分别开发的,尽管它们通过发布-订阅思想实现了反应式编程,但它们使用了不同的命名法和 API。在 Java 9 标准化的过程中,这些库发生了演变,使得它们的类正式实现了java.util.concurrent.Flow中的接口,而不是仅仅实现了反应式概念。这一标准使得不同库之间的协作更加紧密。
注意,构建一个 reactive-streams 实现是复杂的,因此大多数用户将仅使用现有的实现。像许多实现接口的类一样,它们通常提供比最小实现所需更丰富的功能。
在下一节中,你将使用最广泛使用的库之一:由 Netflix 开发的 RxJava(Java 的反应式扩展)库,特别是当前的 RxJava 2.0 版本,它实现了 Java 9 的 Flow 接口。
17.3. 使用反应式库 RxJava
RxJava 是第一个在 Java 中开发反应式应用的库之一。它诞生于 Netflix,作为由微软在.NET 环境中最初开发的 Reactive Extensions (Rx)项目的移植。RxJava 2.0 版本被调整以符合本章前面解释的反应式流 API,并被 Java 9 作为java.util.concurrent.Flow采用。
当你在 Java 中使用外部库时,这一点从导入中很明显。例如,你导入 Java Flow 接口,包括Publisher,使用如下一行代码:
import java.lang.concurrent.Flow.*;
但你还需要导入适当的实现类,例如使用一行代码
import io.reactivex.Observable;
如果你想要使用Publisher的Observable实现,正如你将在本章后面选择做的那样。
我们必须强调一个架构问题:良好的系统架构风格避免在整个系统中使任何仅在系统的一部分中使用的详细概念可见。因此,只在使用 Observable 的额外结构时使用 Observable 是一个好的实践,否则使用其 Publisher 接口。请注意,你无需思考就能观察到这个指南与 List 接口。即使一个方法可能传递了一个你知道是 ArrayList 的值,你也会声明这个值的参数类型为 List,这样你就避免了暴露和约束实现细节。实际上,你允许稍后从 ArrayList 更改为 LinkedList 的实现,而不需要无处不在的更改。
在本节的其余部分,你将使用 RxJava 的反应式流实现来定义一个温度报告系统。你遇到的第一问题是 RxJava 提供了两个类,这两个类都实现了 Flow.Publisher。
在阅读 RxJava 文档时,你会发现有一个类是 io.reactivex.Flowable 类,它包括 Java 9 Flow(使用请求)的基于反应式拉取的背压特性,这在 列表 17.7 和 17.9 中举例说明。背压防止 Subscriber 被快速 Publisher 产生的数据淹没。另一个类是原始 RxJava io.reactivex.Observable 版本的 Publisher,它不支持背压。这个类编程起来更简单,更适合用户界面事件(如鼠标移动);这些事件是无法合理进行背压的流。(你不能要求用户减慢或停止移动鼠标!)因此,RxJava 提供了这两个实现类来表示常见的事件流概念。
RxJava 的建议是在你有不超过一千个元素的流或处理 GUI 事件(如鼠标移动或触摸事件)时使用非背压的 Observable,这些事件无法进行背压,而且本身也不频繁。
由于我们在上一节讨论 Flow API 时已经分析了背压场景,所以我们不再讨论 Flowable;相反,我们将通过一个没有背压的使用案例来展示 Observable 接口的工作。值得注意的是,任何订阅者都可以通过在订阅上调用 request(Long.MAX_VALUE) 来有效地关闭背压,即使这种做法除非你确定 Subscriber 总是能够及时处理所有接收到的事件,否则不建议这样做。
17.3.1. 创建和使用一个可观察对象
Observable 和 Flowable 类提供了许多方便的工厂方法,允许你创建许多类型的反应式流。(Observable 和 Flowable 都实现了 Publisher,因此这些工厂方法发布反应式流。)
你可能想要创建的最简单的Observable是由固定数量的预定义元素组成的,如下所示:
Observable<String> strings = Observable.just( "first", "second" );
在这里,just()工厂方法将一个或多个元素转换为发出这些元素的Observable。订阅此Observable的订阅者将按顺序接收到onNext("first")、onNext("second")和onComplete()消息。
²
这种命名约定稍微有些不幸,因为 Java 8 开始使用
of()来为类似工厂方法命名,这些方法是由 Stream 和 Optional API 推广的。
另一个相当常见的Observable工厂方法,尤其是在你的应用程序与用户实时交互时,以固定的时间速率发出事件:
Observable<Long> onePerSec = Observable.interval(1, TimeUnit.SECONDS);
interval工厂方法返回一个名为onePerSec的Observable,它发出一个无限序列,序列中的long类型值按升序排列,从零开始,以你选择的固定时间间隔(本例中为 1 秒)发出。现在计划使用onePerSec作为另一个Observable的基础,该Observable每秒发出给定城镇报告的温度。
作为实现这一目标的中间步骤,你可以每秒打印这些温度。为此,你需要订阅onePerSec,以便在每过一秒时收到通知,然后获取并打印感兴趣城镇的温度。在 RxJava 中,Observable在 Flow API 中扮演了Publisher的角色,因此Observer类似于 Flow 的Subscriber接口。RxJava 的Observer接口声明了与 Java 9 中给出的Subscriber相同的函数,区别在于onSubscribe方法有一个Disposable参数而不是Subscription。如我们之前提到的,Observable不支持背压,因此它没有形成Subscription一部分的request方法。完整的Observer接口如下:
³
注意,自 Java 9 以来,
Observer接口和Observable类已被弃用。新代码应使用 Flow API。RxJava 将如何发展还有待观察。
public interface Observer<T> {
void onSubscribe(Disposable d);
void onNext(T t);
void onError(Throwable t);
void onComplete();
}
然而,请注意,RxJava 的 API 比原生 Java 9 Flow API 更灵活(有更多的重载变体)。例如,你可以通过传递一个具有onNext方法签名的 lambda 表达式来订阅Observable,并省略其他三个方法。换句话说,你可以使用只实现onNext方法的Observer来订阅Observable,该Observer接收事件的处理为Consumer,其他方法默认为无操作(no-op)以处理完成和错误。通过使用此功能,你可以订阅Observable onePerSec并使用它每秒打印纽约的温度,所有这些都在一行代码中完成:
onePerSec.subscribe(i -> System.out.println(TempInfo.fetch( "New York" )));
在这个语句中,onePerSec Observable每秒发出一个事件。在收到这个消息后,Subscriber会获取纽约的温度并打印出来。然而,如果你将这个语句放在main方法中并尝试执行它,你将看不到任何东西,因为每秒发布一个事件的Observable是在属于 RxJava 计算线程池的线程中执行的,该线程池由守护线程组成。4 但你的main程序会立即终止,在这个过程中,它会杀死守护线程,使其无法产生任何输出。
⁴
这个事实似乎在文档中并不明显,尽管你可以在stackoverflow.com在线开发者社区中找到类似的说法。
作为一种小技巧,你可以在前面的语句之后放置一个线程休眠来防止这种立即终止。更好的方法是使用blockingSubscribe方法,该方法在当前线程(在这种情况下,是主线程)上调用回调。为了演示的目的,blockingSubscribe是完美的。然而,在生产环境中,你通常使用subscribe方法,如下所示:
onePerSec.blockingSubscribe(
i -> System.out.println(TempInfo.fetch( "New York" ))
);
你可能会得到以下输出:
New York : 87
New York : 18
New York : 75
java.lang.RuntimeException: Error!
at flow.common.TempInfo.fetch(TempInfo.java:18)
at flow.Main.lambda$main$0(Main.java:12)
at io.reactivex.internal.observers.LambdaObserver
.onNext(LambdaObserver.java:59)
at io.reactivex.internal.operators.observable
.ObservableInterval$IntervalObserver.run(ObservableInterval.java:74)
不幸的是,由于设计原因,温度获取可能会随机失败(实际上在读取三次后就会失败)。因为你的Observer只实现了快乐路径,并且没有进行任何错误管理,例如onError,这种失败会以未捕获异常的形式出现在用户面前。
是时候提高标准并开始使这个例子复杂化一点了。你不仅想要添加错误管理,还要泛化你所拥有的。你不想立即打印温度,而是提供一个工厂方法,该方法返回一个每秒发出这些温度的Observable,最多(比如说)五次后完成。你可以通过使用名为create的工厂方法轻松实现这个目标,该方法从一个 lambda 表达式创建一个Observable,将另一个Observer作为参数,并返回void,如下所示。
列表 17.12. 创建每秒发出温度的Observable
public static Observable<TempInfo> getTemperature(String town) {
return Observable.create(emitter -> *1*
Observable.interval(1, TimeUnit.SECONDS) *2*
.subscribe(i -> {
if (!emitter.isDisposed()) { *3*
if ( i >= 5 ) { *4*
emitter.onComplete();
} else {
try {
emitter.onNext(TempInfo.fetch(town)); *5*
} catch (Exception e) {
emitter.onError(e); *6*
}
}
}}));
}
-
1 从一个消耗观察者的函数创建 Observable
-
2 一个每秒发出无限升序长整型序列的 Observable
-
3 只有在消耗的观察者尚未被处置(对于先前的错误)时才执行某些操作。
-
4 如果温度已经发出五次,完成观察者并终止流
-
5 否则,向观察者发送温度报告
-
6 发生错误时,通知观察者
在这里,你从消耗ObservableEmitter的函数创建返回的Observable,向它发送所需的事件。RxJava 的ObservableEmitter接口扩展了基本的 RxJava Emitter,你可以将其视为没有onSubscribe方法的Observer,
public interface Emitter<T> {
void onNext(T t);
void onError(Throwable t);
void onComplete();
}
几个额外的设置新 Disposable 在 Emitter 上和检查序列是否已经被下游丢弃的方法。
内部,你订阅了一个如 onePerSec 这样的 Observable,它发布了一个无限序列的递增长整型,每秒一个。在订阅函数(作为 subscribe 方法的参数传递)中,你首先使用 ObservableEmitter 接口提供的 isDisposed 方法检查消耗的 Observer 是否已经被丢弃。(这种情况可能会在早期迭代中发生错误。)如果温度已经发射了五次,代码将完成 Observer,终止流;否则,它将在一个 try/catch 块中将请求城镇的最新温度报告发送到 Observer。如果在获取温度期间发生错误,它将错误传播到 Observer。
现在很容易实现一个完整的 Observer,稍后它将被用来订阅由 getTemperature 方法返回的 Observable 并打印它发布的温度,如下一个列表所示。
列表 17.13. 打印接收到的温度的 Observer
import io.reactivex.Observer;
import io.reactivex.disposables.Disposable;
public class TempObserver implements Observer<TempInfo> {
@Override
public void onComplete() {
System.out.println( "Done!" );
}
@Override
public void onError( Throwable throwable ) {
System.out.println( "Got problem: " + throwable.getMessage() );
}
@Override
public void onSubscribe( Disposable disposable ) {
}
@Override
public void onNext( TempInfo tempInfo ) {
System.out.println( tempInfo );
}
}
这个 Observer 与 列表 17.7 中的 TempSubscriber 类(实现了 Java 9 的 Flow.Subscriber) 类似,但你有一个更进一步的简化。因为 RxJava 的 Observable 不支持背压,所以在处理已发布的元素后,你不需要进一步 request() 更多元素。
在下一个列表中,你创建了一个主程序,在这个程序中,你将这个 Observer 订阅到由 getTemperature 方法返回的 Observable,该方法来自 列表 17.12。
列表 17.14. 一个打印纽约温度的 main 类
public class Main {
public static void main(String[] args) {
Observable<TempInfo> observable = getTemperature( "New York" ); *1*
observable.blockingSubscribe( new TempObserver() ); *2*
}
}
-
1 创建一个每秒发射纽约报告温度的
Observable -
2 使用一个简单的
Observer订阅到那个Observable并打印温度
假设这次在获取温度时没有发生错误,main 每秒打印一行,共打印五次,然后 Observable 发射 onComplete 信号,因此你可能得到以下输出:
New York : 69
New York : 26
New York : 85
New York : 94
New York : 29
Done!
是时候进一步丰富你的 RxJava 示例了,特别是看看这个库如何让你操作一个或多个响应式流。
17.3.2. 转换和组合 Observables
与原生 Java 9 Flow API 提供的功能相比,RxJava 和其他反应库在处理反应流方面的主要优势之一是它们提供了一套丰富的函数工具箱,用于组合、创建和过滤这些流中的任何流。正如我们在前面的章节中展示的那样,一个流可以用作另一个流的输入。此外,你已经了解了在第 17.2.3 节中使用的 Java 9 Flow.Processor,用于将华氏温度转换为摄氏温度。但你也可以过滤一个流以获取只包含你感兴趣元素的另一个流,使用给定的映射函数转换这些元素(这两件事都可以通过Flow.Processor实现),或者以许多方式合并或组合两个流(这不能通过Flow.Processor实现)。
这些转换和组合函数可能相当复杂,以至于用普通语言解释它们的行为可能会导致尴尬、复杂的句子。为了获得一个概念,看看 RxJava 如何记录其mergeDelayError函数:
将发出 Observables 的 Observable 扁平化为一个 Observable,以允许观察者接收所有成功发出的项目,而不会因其中一个 Observable 的错误通知而中断,同时限制对这些 Observable 的并发订阅数量。
你必须承认这个函数所做的事情并不立即明显。为了减轻这个问题,反应流社区决定以视觉方式记录这些函数的行为,使用所谓的弹珠图。一个弹珠图,如图 17.4 所示,将反应流中元素的时序序列表示为水平线上的几何形状;特殊符号表示错误和完成信号。方框表示命名操作符如何转换这些元素或组合多个流。
图 17.4. 记录一个典型反应库提供的操作符的弹珠图例

使用这种表示法,很容易提供 RxJava 库所有函数特性的视觉表示,如图 17.5 所示,该图例展示了map(将Observable发布的元素进行转换)和merge(将两个或更多Observable发出的事件合并成一个)。
图 17.5. map 和 merge 函数的弹珠图

你可能会想知道如何使用map和merge来改进和添加你在前面章节中开发的 RxJava 示例的功能。使用map是一种更简洁的方式来实现使用 Flow API 的Processor实现的华氏到摄氏的转换,如下面的列表所示。
列表 17.15. 使用map在Observable上转换华氏温度到摄氏温度
public static Observable<TempInfo> getCelsiusTemperature(String town) {
return getTemperature( town )
.map( temp -> new TempInfo( temp.getTown(),
(temp.getTemp() - 32) * 5 / 9) );
}
这个简单的方法接受 列表 17.12 中的 getTemperature 方法返回的 Observable,并返回另一个 Observable,它重新发出第一个 Observable(每秒发布一次)发布的温度,在将其从华氏度转换为摄氏度后。
为了加强你对如何操作 Observable 发出的元素的理解,尝试在以下练习中使用另一个转换函数。
练习 17.2:仅过滤负温度
Observable 类的 filter 方法接受一个 Predicate 作为参数,并产生一个第二个 Observable,它只发出通过该 Predicate 定义测试的元素。假设你被要求开发一个预警系统,当有结冰风险时提醒用户。你如何使用这个操作符创建一个只当温度低于零时才发出城镇摄氏温度的 Observable?(摄氏度方便地使用零作为水的冰点。)
答案:
只需使用 列表 17.15 中的方法返回的 Observable,并应用一个只接受负温度的 Predicate 的 filter 操作符,如下所示:
public static Observable<TempInfo> getNegativeTemperature(String town) {
return getCelsiusTemperature( town )
.filter( temp -> temp.getTemp() < 0 );
}
现在也假设你被要求推广这个最后的方法,并允许用户拥有一个不仅为单个城镇,也为一组城镇发出温度的 Observable。列表 17.16 通过对每个城镇调用 列表 17.15 中的方法一次,并将从这些调用中获得的所有 Observable 通过 merge 函数合并成一个来满足最后一个要求。
列表 17.16. 合并一个或多个城镇报告的温度
public static Observable<TempInfo> getCelsiusTemperatures(String... towns) {
return Observable.merge(Arrays.stream(towns)
.map(TempObservable::getCelsiusTemperature)
.collect(toList()));
}
这个方法接受一个包含你想要温度的城镇集合的 varargs 参数。这个 varargs 被转换为 String 的流;然后每个 String 都传递给 列表 17.11(在 列表 17.15 中改进)中的 getCelsiusTemperature 方法。这样,每个城镇都被转换为一个每秒发出该城镇温度的 Observable。最后,Observable 的流被收集到一个列表中,该列表被传递给 Observable 类本身提供的 merge 静态工厂方法。这个方法接受一个 Observable 的 Iterable,并组合它们的输出,使它们像一个单一的 Observable。换句话说,生成的 Observable 发出所有在传递的 Iterable 中包含的 Observable 发布的事件,并保持它们的时序。
要测试这个方法,可以在以下列表中将其用于一个最终的 main 类。
列表 17.17. 打印三个城镇温度的 main 类
public class Main {
public static void main(String[] args) {
Observable<TempInfo> observable = getCelsiusTemperatures(
"New York", "Chicago", "San Francisco" );
observable.blockingSubscribe( new TempObserver() );
}
}
这个main类与列表 17.14 中的类相同,只是你现在正在订阅列表 17.16 中getCelsiusTemperatures方法返回的Observable,并打印三个城镇注册的温度。运行这个main会产生如下输出:
New York : 21
Chicago : 6
San Francisco : -15
New York : -3
Chicago : 12
San Francisco : 5
Got problem: Error!
每秒钟,main都会打印每个请求城镇的温度,直到温度检索操作引发一个错误,该错误传播到Observer,中断数据流。
这章的目的不是提供一个完整的 RxJava(或任何其他反应式库)概述,这需要一本完整的书,而是让你对这类工具包的工作方式有一个感觉,并介绍你到反应式编程的原则。我们只是触及了这种编程风格的表面,但我们希望我们已经展示了它的一些优点,并激发了你对它的好奇心。
摘要
-
反应式编程背后的基本思想已有 20 到 30 年的历史,但最近因为现代应用对处理数据量和用户期望的高要求而变得流行。
-
这些思想通过反应式宣言得到了正式化,该宣言指出,反应式软件必须由四个相互关联的特征来表征:响应性、弹性、弹性和消息驱动性。
-
反应式编程的原则可以应用于实现单个应用程序,以及在设计和集成多个应用程序的反应式系统中,有一些差异。
-
一个反应式应用程序基于异步处理一个或多个由反应式流传达的事件流。由于反应式流在反应式应用程序开发中的角色非常关键,包括 Netflix、Pivotal、Lightbend 和 Red Hat 在内的公司联盟将这些概念标准化,以最大化不同实现之间的互操作性。
-
由于反应式流是异步处理的,因此它们被设计为具有内置的背压机制。这可以防止消费者被更快的生产者淹没。
-
这个设计和标准化过程的结果已经被纳入 Java 中。Java 9 的
FlowAPI 定义了四个核心接口:Publisher、Subscriber、Subscription和Processor。 -
在大多数情况下,这些接口不是为了直接由开发者实现,而是作为实现反应式范式的各种库之间的通用语言。
-
这些工具包中最常用的是 RxJava,它(除了 Java 9
FlowAPI 定义的基本功能外)还提供了许多有用的强大操作符。例如,包括方便地转换和过滤单个反应式流发布的元素的操作符,以及合并和聚合多个流的操作符。
第六部分. 函数式编程与 Java 未来的演变
在本书的最后一部分,我们通过一个关于如何编写有效的 Java 函数式风格程序的教程介绍,以及 Java 8 特性与 Scala 特性的比较,稍微回顾了一下。
第十八章提供了一个关于函数式编程的全面教程,介绍了其中的一些术语,并解释了如何在 Java 中编写函数式风格的程序。
第十九章涵盖了更高级的函数式编程技术,包括高阶函数、柯里化、持久数据结构、惰性列表和模式匹配。你可以将这一章视为将实用技术应用于你的代码库以及使你成为更知识渊博的程序员的信息混合体。
第二十章接着讨论了 Java 8 特性与 Scala 语言特性的比较——Scala 是一种像 Java 一样在 JVM 之上实现的语言,并且它迅速发展,威胁到了 Java 在编程语言生态系统中的某些领域。
最后,第二十一章回顾了学习 Java 8 的历程以及向函数式编程风格的温和推动。此外,我们还推测了 Java 8 和 Java 9 之外,Java 可能有哪些未来的增强功能和新的特性。
第十八章. 函数式思考
本章涵盖
-
为什么选择函数式编程?
-
什么是函数式编程?
-
声明式编程和引用透明性
-
编写函数式风格 Java 的指南
-
迭代与递归的比较
你在这本书中经常看到“函数式”这个词。到现在,你可能对函数式意味着什么有一些想法。它是关于 lambda 表达式和一等函数,还是关于限制你修改对象的权利?采用函数式风格你能得到什么?
在本章中,我们揭示了这些问题的答案。我们解释了什么是函数式编程,并介绍了一些相关术语。首先,我们检查了函数式编程背后的概念——如副作用、不可变性、声明式编程和引用透明性——然后我们将这些概念与 Java 8 联系起来。在第十九章中,我们更仔细地研究了函数式编程技术,如高阶函数、柯里化、持久数据结构、惰性列表、模式匹配和组合子。
18.1. 实施和维护系统
首先,想象一下,你被要求管理一个你从未见过的庞大软件系统的升级。你应该接受维护这样一个软件系统的任务吗?经验丰富的 Java 承包商在决定时只稍微带点玩笑地说:“首先搜索关键字synchronized;如果你找到了,就说不(反映修复并发错误的难度)。否则,更详细地考虑系统的结构。”我们将在接下来的段落中提供更多细节。然而,首先,我们将注意到,正如你在前面的章节中看到的,Java 8 添加的流允许你在不担心锁定的情况下利用并行性,前提是你接受无状态行为。(也就是说,你的流处理管道中的函数不交互,一个函数从另一个函数写入的变量中读取或写入。)
你还希望程序看起来如何,以便更容易工作?你希望它结构良好,有一个可理解的类层次结构,反映了系统的结构。你可以通过使用软件工程度量耦合(系统的各个部分之间的相互依赖性)和内聚性(系统的各个部分之间的关系)来估计这样的结构。
但对于许多程序员来说,关键的是维护期间的调试:一些代码崩溃是因为它观察到了一个意外的值。但是,程序中的哪些部分参与了创建和修改这个值?想想看,你有多少维护问题属于这个类别!^([1]) 事实证明,函数式编程提倡的无副作用和不可变性的概念可以帮助。我们将在以下章节中更详细地探讨这些概念。
¹
我们建议阅读 Michael Feathers 所著的《有效地与遗留代码工作》(Prentice Hall,2004 年),以获取更多关于这个主题的信息。
18.1.1. 共享的可变数据
最终,上一节讨论的意外变量值问题的原因在于共享的可变数据结构被你的维护中心中的多个方法读取和更新。假设有多个类保留了对一个列表的引用。作为维护者,你需要回答以下问题:
-
谁拥有这个列表?
-
如果一个类修改了这个列表会发生什么?
-
其他类是否期望这个变化?
-
这些类是如何了解这个变化的?
-
为了满足这个列表中的所有假设,类需要被通知这个变化,还是它们应该为自己制作防御性副本?
换句话说,共享的可变数据结构使得跟踪程序不同部分的变化变得更加困难。图 18.1 说明了这个概念。
图 18.1. 在多个类之间共享的可变数据。很难理解谁拥有这个列表。

考虑一个不修改任何数据结构的系统。这个系统将是一个梦寐以求的维护系统,因为你不会对某个对象意外修改数据结构有任何坏惊喜。一个既不修改其封装类的状态也不修改任何其他对象的状态,并且通过使用return返回其整个结果的方法被称为纯或无副作用。
什么是副作用?简而言之,副作用是在函数本身内部不完全封闭的操作。以下是一些例子:
-
在构造函数内部(例如 setter 方法)之外,就地修改数据结构,包括对任何字段的赋值
-
抛出异常
-
执行 I/O 操作,例如写入文件
另一种看待无副作用概念的方法是考虑不可变对象。一个不可变对象是在实例化后不能改变其状态的对象,因此它不会受到函数操作的影响。当不可变对象被实例化时,它们永远不会进入意外状态。你可以共享它们而不需要复制,而且它们是线程安全的,因为它们不能被修改。
无副作用的概念可能看起来是一种严重的限制,你可能怀疑是否真的可以以这种方式构建系统。我们希望在本章结束时说服你,它们确实可以这样做。好消息是,接受这一想法的系统组件可以在不使用锁的情况下使用多核并行处理,因为方法不再会相互干扰。此外,这个概念对于立即理解程序的哪些部分是独立的非常有用。
这些想法来自函数式编程,我们将在下一节中介绍。
18.1.2. 声明式编程
首先,我们探讨声明式编程的概念,这是函数式编程的基础。
通过编写程序来实现系统的两种思考方式。一种集中在如何做事上。(首先做这个,然后更新那个,等等。)例如,如果你想计算列表中最昂贵的交易,你通常会执行一系列命令。(从一个列表中取出一个交易并与暂定的最昂贵交易进行比较;如果它更贵,它就变成了暂定的最昂贵交易;然后与列表中的下一个交易重复此过程,等等。)
这种“如何”风格的编程非常适合经典面向对象编程(有时称为命令式编程),因为它有指令模仿计算机的低级词汇(如赋值、条件分支和循环),如以下代码所示:
Transaction mostExpensive = transactions.get(0);
if(mostExpensive == null)
throw new IllegalArgumentException("Empty list of transactions");
for(Transaction t: transactions.subList(1, transactions.size())){
if(t.getValue() > mostExpensive.getValue()){
mostExpensive = t;
}
}
另一种方法集中在要做什么上。你在第四章和第五章中看到,通过使用 Streams API,你可以这样指定这个查询:
Optional<Transaction> mostExpensive =
transactions.stream()
.max(comparing(Transaction::getValue));
这个查询如何实现的详细细节留给库来处理。我们将这个想法称为内部迭代。它的巨大优势是查询的阅读方式就像问题陈述一样,因此与试图理解一系列命令的作用相比,它立即变得清晰。
这种“是什么”风格通常被称为声明式编程。你提供规则说明你想要什么,并期望系统决定如何实现这个目标。这种类型的编程很棒,因为它更接近于问题陈述。
18.1.3. 为什么是函数式编程?
函数式编程体现了这种声明式编程的思想(使用不交互的表达式来表达你想要的内容,系统可以选择实现方式),以及本章前面提到的无副作用计算。这两个想法可以帮助你更轻松地实现和维护系统。
注意,某些语言特性,如操作组合和传递行为(我们通过第三章中的 lambda 表达式来展示),是使用声明式风格自然地读写代码所必需的。使用流,你可以将多个操作链接起来以表达复杂的查询。这些特性是函数式编程语言的特征。我们在第十九章(通过组合器的视角)更仔细地研究这些特性。
为了使讨论具体化并将其与 Java 8 的新特性联系起来,在下一节中我们将具体定义函数式编程的概念及其在 Java 中的表示。我们想传达这样一个事实:通过使用函数式编程风格,你可以编写严肃的程序而不依赖于副作用。
18.2. 什么是函数式编程?
对于“什么是函数式编程?”这个问题的过于简化的回答是“用函数进行编程。”什么是函数?
想象一个方法接受一个int和一个double作为参数并产生一个double——并且还产生副作用,比如通过更新一个可变变量来计数它被调用的次数,如图 18.2 所示。
图 18.2. 带有副作用的函数

然而,在函数式编程的上下文中,一个函数对应于数学函数:它接受零个或多个参数,返回一个或多个结果,并且没有副作用。你可以将函数视为一个黑盒,它接受一些输入并产生一些输出,如图 18.3 所示。
图 18.3. 没有副作用的函数

这种函数与你在像 Java 这样的编程语言中看到的方法之间的区别是核心的。(数学函数如log或sin可能会有这样的副作用的想法是不可想象的。)特别是,数学函数在用相同的参数重复调用时总是返回相同的结果。这种特征排除了Random.nextInt这样的方法,我们将在第 18.2.2 节进一步讨论这个概念,即引用透明性。
当我们说函数式时,我们指的是像数学一样,没有副作用。现在出现了一个编程的微妙之处。我们是说:每个函数是否只由函数和数学思想,如if-then-else构建?或者一个函数在内部做非函数式的事情,只要它不将这些副作用暴露给系统的其余部分?换句话说,如果程序员执行了一个调用者无法观察到的副作用,那么这个副作用存在吗?调用者不需要知道或关心,因为它无法影响他们。
为了强调这种差异,我们将前者称为纯函数式编程,后者称为函数式风格编程。
18.2.1. 函数式 Java
在实践中,你无法在 Java 中完全以纯函数式风格编程。例如,Java 的 I/O 模型由副作用方法组成。(调用Scanner.nextLine会产生副作用,即从文件中消耗一行,因此连续调用两次通常会产生不同的结果。)尽管如此,你仍然可以将系统的核心组件编写成好像它们是纯函数式的。在 Java 中,你将编写函数式风格的程序。
首先,有一个关于没有人看到你的副作用,因此,在功能意义上的进一步微妙之处。假设一个函数或方法除了在进入后增加一个字段,在退出前减少它之外,没有副作用。从由单个线程组成的程序的角度来看,这种方法没有可见的副作用,可以被视为函数式风格。另一方面,如果另一个线程可以检查该字段——或者可以并发调用该方法——则该方法不是函数式的。你可以通过将此方法的主体用锁包裹来隐藏这个问题,这样你就可以争论说这个方法是函数式的。但这样做,你将失去在多核处理器上使用两个核心并行执行该方法两次调用的能力。你的副作用可能对程序不可见,但从程序员的角度来看,它会导致执行速度变慢。
我们的指导方针是,要被视为函数式风格,一个函数或方法只能修改局部变量。此外,它引用的对象应该是不可变的——也就是说,所有字段都是final的,所有引用类型的字段都递归地引用其他不可变对象。稍后,你可以允许更新在方法中新鲜创建的对象的字段,这样它们就不会从其他地方可见,也不会保存以影响后续调用的结果。
然而,我们的指导方针并不完整。要成为函数式,还有一个额外的要求,即函数或方法不应该抛出任何异常。一个合理的解释是,抛出异常意味着除了通过函数返回值之外,还在通过其他方式传递结果;参见图 18.2 中的黑盒模型。在这里有争议的空间,一些作者认为未捕获的异常表示致命错误是可以接受的,而捕获异常的行为才代表非函数式控制流。然而,这种对异常的使用仍然打破了黑盒模型中简单的“传递参数,返回结果”隐喻,导致出现一个表示异常的第三个箭头,如图 18.4 所示。
图 18.4. 抛出异常的函数

函数和部分函数
在数学中,一个函数必须对每个可能的参数值给出确切的一个结果。但许多常见的数学运算实际上应该被称为部分函数。也就是说,对于某些或大多数输入值,它们会给出确切的一个结果,但对于其他输入值,它们是未定义的,根本不会给出任何结果。例如,当第二个操作数是零时进行除法,或者当其参数为负数时进行平方根运算。我们通常在 Java 中通过抛出异常来模拟这些情况。
你如何在不使用异常的情况下表达像除法这样的函数?使用像Optional<T>这样的类型。而不是有“double sqrt(double)但可能抛出异常”这样的签名,sqrt将具有签名Optional<Double> sqrt(double)。它要么返回一个表示成功的值,要么在其返回值中表明它无法执行请求的操作。是的,这样做确实意味着调用者需要检查每个方法调用是否可能导致空的Optional。这可能听起来像是一个大问题,但根据我们对函数式编程与纯函数式编程的指导,你可以选择在本地使用异常,但不通过大规模接口暴露它们,从而在不增加代码膨胀风险的情况下获得函数式风格的优势。
要被视为功能性的,你的函数或方法应该只调用那些你可以隐藏非功能行为的副作用库函数(即,确保它们对数据结构所做的任何修改都不会被调用者看到,可能通过先复制并捕获任何异常来实现)。在第 18.2.4 节中,你通过复制列表的方式,在insertAll方法内部隐藏了副作用库函数List.add的使用。
你通常可以通过使用注释或声明带有标记注解的方法来标记这些规定,并且匹配你在第四章到第七章中为传递给并行流处理操作(如Stream.map)的函数所施加的限制。
最后,出于实用主义的原因,你可能发现对于函数式风格的代码来说,能够将调试信息输出到某种形式的日志文件中是很方便的。这段代码不能严格地描述为函数式,但在实践中,你保留了函数式编程的大部分好处。
18.2.2. 引用透明性
对无可见副作用(对调用者不可见的结构修改、无 I/O、无异常)的限制编码了引用透明性的概念。一个函数是引用透明的,如果它在用相同的参数值调用时总是返回相同的值。例如,String.replace方法就是引用透明的,因为"raoul".replace('r', 'R')总是产生相同的结果(replace返回一个新String,其中所有的r都被大写的R替换),而不是更新它的this对象,因此它可以被认为是一个函数。
换句话说,一个函数在相同的输入下始终产生相同的结果,无论它在何时何地被调用。这也解释了为什么Random.nextInt不被视为函数式。在 Java 中,使用Scanner对象从用户的键盘获取输入违反了引用透明性,因为调用nextLine方法可能每次调用都会产生不同的结果。但添加两个final int变量总是产生相同的结果,因为变量的内容永远不会改变。
引用透明性是程序理解的一个很好的属性。它还包括对昂贵或长期操作进行保存而不是重新计算优化的概念,这个过程被称为记忆化或缓存。尽管这个话题很重要,但在这里它是一个小的旁白,所以我们将在第十九章中讨论它。
Java 在引用透明性方面有一个轻微的复杂性。假设你调用一个返回List的方法两次。这两次调用可能返回内存中不同列表的引用,但包含相同的元素。如果这些列表被视为可变的面向对象值(因此非相同),则该方法不是引用透明的。如果你计划将这些列表用作纯(不可变)值,那么将这些值视为相等是有意义的,因此函数是引用透明的。一般来说,在函数式风格的代码中,你选择将这些函数视为引用透明的。
在下一节中,我们将从更广泛的视角探讨是否进行突变。
18.2.3. 面向对象与函数式编程风格
我们首先将函数式编程与(极端的)经典面向对象编程进行对比,然后观察到 Java 8 将这些风格视为面向对象光谱上的极端。作为一名 Java 程序员,即使没有有意识地思考,你也几乎肯定使用了函数式编程的一些方面和我们称之为极端面向对象编程的一些方面。正如我们在第一章(kindle_split_011.xhtml#ch01)中提到的,硬件(如多核)和程序员期望(如数据库查询来操作数据)的变化正在推动 Java 软件工程风格更多地转向函数式的一端,本书的一个目标就是帮助你适应这种变化的环境。
在光谱的一端是极端的面向对象观点:一切皆对象,程序通过更新字段和调用更新相关对象的方法来操作。在光谱的另一端是引用透明的函数式编程风格,没有(可见的)突变。在实践中,Java 程序员总是混合这些风格。你可能通过使用包含可变内部状态的Iterator遍历数据结构,但以函数式风格计算,例如,数据结构中值的总和。(在 Java 中,如前所述,此过程可能包括更新局部变量。)本章和第十九章(kindle_split_034.xhtml#ch19)的一个目标就是讨论编程技术,并引入函数式编程的特性,以便你能够编写更模块化且更适合多核处理器的程序。将这些想法视为你编程武器库中的额外武器。
18.2.4. 实践中的函数式风格
首先,解决一个给初学者的编程练习,以体现函数式风格:给定一个List<Integer>值,例如{1, 4, 9},构造一个List<List<Integer>>值,其成员是{1, 4, 9}的所有子集,顺序不限。{1, 4, 9}的子集包括{1, 4, 9},{1, 4},{1, 9},{4, 9},{1},{4},{9}和{}。
有八个子集,包括空子集,写作{}。每个子集都表示为List<Integer>类型,这意味着答案是List<List<Integer>>类型。
学生们经常在思考如何开始时遇到问题,需要通过提示^([2])来帮助他们,即“{1, 4, 9}的子集要么包含 1,要么不包含。”那些不包含 1 的子集是{4, 9}的子集,而包含 1 的子集可以通过取{4, 9}的子集并将 1 插入到每个子集中来获得。不过有一个细微之处:我们必须记住空集恰好只有一个子集——它自己。这种理解让你在 Java 中以简单、自然、自上而下的函数式编程风格进行编码,如下所示:^([3])
²
有时,麻烦(但聪明!)的学生会指出一个涉及数字二进制表示的巧妙编码技巧。(Java 解决方案代码对应于 000,001,010,011,100,101,110,111。)我们告诉这样的学生计算列表的所有排列;对于示例{1, 4, 9},有六个。
³
为了具体化,我们在这里给出的代码使用
List<Integer>,但你可以将其在方法定义中替换为泛型List<T>;然后你可以将更新的subsets方法应用于List<String>以及List<Integer>。
static List<List<Integer>> subsets(List<Integer> list) {
if (list.isEmpty()) { *1*
List<List<Integer>> ans = new ArrayList<>();
ans.add(Collections.emptyList());
return ans;
}
Integer fst = list.get(0);
List<Integer> rest = list.subList(1,list.size());
List<List<Integer>> subAns = subsets(rest); *2*
List<List<Integer>> subAns2 = insertAll(fst, subAns); *3*
return concat(subAns, subAns2); *4*
}
-
1 如果输入列表为空,它只有一个子集:空列表本身。
-
2 否则取出一个元素,fst,并找到其余所有子集以给出 subAns;subAns 形成答案的一半。
-
3 答案的一半,subAns2,由 subAns 中的所有列表组成,但每个元素列表都通过在前面添加 fst 来调整。
-
4 然后将两个子答案连接起来。
当以{1, 4, 9}作为输入时,解决方案程序产生{{}, {9}, {4}, {4, 9}, {1}, {1, 9}, {1, 4}, {1, 4, 9}}。当你定义了这两个缺失的方法时,请尝试一下。
为了复习,你假设缺失的方法insertAll和concat本身是函数式的,并推断出你的函数subsets也是函数式的,因为其中没有操作会改变任何现有结构。(如果你熟悉数学,你会认出这个论点是通过归纳得出的。)
现在看看定义insertAll。这里有一个危险点。假设你定义了insertAll使其改变其参数,可能通过更新subAns的所有元素以包含fst。那么程序将错误地导致subAns以与subAns2相同的方式被修改,从而导致一个神秘地包含八个{1,4,9}副本的答案。相反,将insertAll函数式地定义为以下内容:
static List<List<Integer>> insertAll(Integer fst,
List<List<Integer>> lists) {
List<List<Integer>> result = new ArrayList<>();
for (List<Integer> list : lists) {
List<Integer> copyList = new ArrayList<>(); *1*
copyList.add(fst);
copyList.addAll(list);
result.add(copyList);
}
return result;
}
- 1 复制列表以便可以添加到它。即使它是可变的,你也不会复制底层结构。(整数是不可变的。)
注意,你正在创建一个新的 List,它包含 subAns 的所有元素。你利用了 Integer 对象不可变的事实;否则,你将不得不克隆每个元素。将 insertAll 等方法视为函数式的方法引起的关注,为你提供了一个自然的位置来放置所有这些精心复制的代码:在 insertAll 内部,而不是在其调用者中。
最后,你需要定义 concat 方法。在这种情况下,解决方案很简单,但我们恳求你不要使用它;我们只展示它,以便你可以比较不同的风格:
static List<List<Integer>> concat(List<List<Integer>> a,
List<List<Integer>> b) {
a.addAll(b);
return a;
}
相反,我们建议你编写以下代码:
static List<List<Integer>> concat(List<List<Integer>> a,
List<List<Integer>> b) {
List<List<Integer>> r = new ArrayList<>(a);
r.addAll(b);
return r;
}
为什么?concat 的第二个版本是一个纯函数。函数可能在其内部使用变异(向列表 r 添加元素),但它基于其参数返回结果,并且不修改任何一个参数。相比之下,第一个版本依赖于在调用 concat(subAns, subAns2) 之后,没有人再次引用 subAns 的值。对于我们的 subsets 定义,这种情况是成立的,因此当然使用更便宜的 concat 版本更好。答案取决于你如何评估你的时间。比较你后来花费在搜索难以捉摸的错误上的时间与制作副本的额外成本。
无论你如何注释不纯的 concat 只能“在第一个参数可以被任意覆盖时使用,并且仅在 subsets 方法中使用,并且对 subsets 的任何更改都必须根据此注释进行审查”,总有人会在某个代码片段中找到它有用,那里它似乎可以工作。你的未来噩梦调试问题已经诞生。我们将在第十九章 中重新审视这个问题。
吸收要点:在设计周期早期,以函数式方法来思考编程问题,这些方法仅以它们的输入参数和输出结果(做什么)为特征,通常比思考如何做以及过早地考虑变异更有效。
在下一节中,我们将详细讨论递归。
18.3. 递归与迭代
递归 是在函数式编程中推广的一种技术,让你能够以“做什么”的方式思考。纯函数式编程语言通常不包含迭代结构,如 while 和 for 循环。这些结构通常隐藏着使用变异的邀请。例如,while 循环中的条件需要更新;否则,循环将执行零次或无限次。然而,在许多情况下,循环是可行的。我们曾主张,对于函数式风格,如果你没有人看到你在做,那么你可以进行变异,因此修改局部变量是可以接受的。当你使用 Java 中的 for-each 循环 for(Apple apple : apples) { } 时,它解码为这个 Iterator:
Iterator<Apple> it = apples.iterator();
while (it.hasNext()) {
Apple apple = it.next();
// ...
}
这种翻译没有问题,因为突变(使用 next 方法改变 Iterator 的状态,以及在 while 体内部将值赋给 apple 变量)对发生突变的方法的调用者不可见。但是,当你使用 for-each 循环,例如搜索算法时,以下情况是有问题的,因为循环体正在更新与调用者共享的数据结构:
public void searchForGold(List<String> l, Stats stats){
for(String s: l){
if("gold".equals(s)){
stats.incrementFor("gold");
}
}
}
事实上,循环体有一个不能被忽视的副作用,即它不能作为函数式风格来忽略:它改变了与程序其他部分共享的 stats 对象的状态。
因此,像 Haskell 这样的纯函数式编程语言省略了这样的副作用操作。你该如何编写程序呢?理论上的答案是,每个程序都可以通过使用递归而不是迭代来重写,这样就不需要可变性。使用递归可以让您摆脱逐步更新的迭代变量。一个经典的学校问题是计算阶乘函数(对于正数参数)的迭代方式和递归方式(假设输入大于 0),如下面的两个列表所示。
列表 18.1. 迭代阶乘
static long factorialIterative(long n) {
long r = 1;
for (int i = 1; i <= n; i++) {
r *= i;
}
return r;
}
列表 18.2. 递归阶乘
static long factorialRecursive(long n) {
return n == 1 ? 1 : n * factorialRecursive(n-1);
}
第一个列表演示了一种基于标准循环的形式:变量 r 和 i 在每次迭代中更新。第二个列表以更数学化的形式展示了递归定义(函数调用自身)。在 Java 中,递归形式通常效率较低,正如我们在下一个示例之后立即讨论的那样。
然而,如果您已经阅读了本书的前几章,您知道 Java 8 流提供了一种更简单的声明性方式来定义阶乘,如下面的列表所示。
列表 18.3. 流阶乘
static long factorialStreams(long n){
return LongStream.rangeClosed(1, n)
.reduce(1, (long a, long b) -> a * b);
}
现在我们转向效率问题。作为 Java 用户,当功能编程狂热者告诉你应该总是使用递归而不是迭代时,要小心。一般来说,进行递归函数调用比发出迭代所需的单个机器级分支指令要昂贵得多。每次调用 factorialRecursive 函数时,都会在调用栈上创建一个新的栈帧来保存每个函数调用的状态(它需要进行的乘法)直到递归完成。您对阶乘的递归定义需要与输入成比例的内存。因此,如果您用大输入运行 factorialRecursive,您很可能会收到 StackOverflowError:
Exception in thread "main" java.lang.StackOverflowError
递归真的没有用吗?当然不是!函数式语言为这个问题提供了一个答案:尾调用优化。基本思想是,你可以编写一个阶乘的递归定义,其中递归调用是函数中最后发生的事情(或者调用在尾位置)。这种不同的递归风格可以被优化以快速运行。下一个列表提供了一个尾递归定义的阶乘。
列表 18.4. 尾递归阶乘
static long factorialTailRecursive(long n) {
return factorialHelper(1, n);
}
static long factorialHelper(long acc, long n) {
return n == 1 ? acc : factorialHelper(acc * n, n-1);
}
函数 factorialHelper 是尾递归的,因为递归调用是该函数中最后发生的事情。相比之下,在 factorialRecursive 的早期定义中,最后发生的事情是 n 和递归调用结果的乘法。
这种递归形式很有用,因为它不需要在单独的栈帧中存储递归的每个中间结果,编译器可以决定重用单个栈帧。实际上,在 factorialHelper 的定义中,中间结果(阶乘的部分结果)直接作为参数传递给函数。不需要在单独的栈帧上跟踪每个递归调用的中间结果;它可以直接作为 factorialHelper 的第一个参数访问。图 18.5 和 18.6 展示了阶乘递归和尾递归定义之间的差异。
图 18.5. 阶乘的递归定义,需要几个栈帧

图 18.6. 阶乘的尾递归定义,可以重用单个栈帧

坏消息是 Java 不支持这种优化。但采用尾递归可能比经典递归是一种更好的实践,因为它为最终编译器优化开辟了道路。许多现代 JVM 语言,如 Scala、Groovy 和 Kotlin,可以优化这些递归的使用,这些递归与迭代等效(并且以相同的速度执行)。因此,纯函数式编程的拥护者也可以在保持纯度的同时高效地执行。
Java 8 编写指南中提到,你通常可以用流来替换迭代以避免突变。此外,当递归允许你以更简洁、无副作用的方式编写算法时,你可以用递归替换迭代。确实,递归可以使示例更容易阅读、编写和理解(如本章前面所示的部分示例),而程序员效率通常比执行时间的小差异更重要。
在本节中,我们讨论了具有方法功能性的函数式编程;我们所说的所有内容都适用于 Java 的第一个版本。在第十九章中,我们将探讨 Java 8 中引入一等函数带来的惊人强大可能性。
摘要
-
减少共享可变数据结构可以帮助你在长期内维护和调试你的程序。
-
函数式编程风格提倡无副作用的方法和声明式编程。
-
函数式方法的特点仅在于它们的输入参数和输出结果。
-
如果一个函数在用相同的参数值调用时总是返回相同的值,则该函数是引用透明的。例如
while循环这样的迭代结构可以用递归替换。 -
尾递归可能在 Java 中比经典递归是一种更好的实践,因为它为潜在的编译器优化打开了道路。
第十九章. 函数式编程技术
本章涵盖
-
一等公民、高阶函数、柯里化和部分应用
-
持久数据结构
-
惰性评估和惰性列表作为泛化 Java 流
-
模式匹配及其在 Java 中的模拟
-
指称透明性和缓存
在第十八章中,你看到了如何以函数式的方式思考;以无副作用的方法来思考可以帮助你编写更易于维护的代码。在本章中,我们介绍了更高级的函数式编程技术。你可以将本章视为将实际可应用于代码库的技术与使你成为更有知识程序员的信息相结合。我们讨论了高阶函数、柯里化、持久数据结构、惰性列表、模式匹配、具有指称透明性的缓存和组合子。
19.1. 到处都是函数
在第十八章中,我们使用“函数式编程风格”这个短语来表示函数和方法的行为应该像数学风格的函数一样,没有副作用。函数式语言程序员经常更广泛地使用这个短语,表示函数可以被用作其他值:作为参数传递,作为结果返回,并存储在数据结构中。可以像其他值一样使用的函数被称为一等函数。一等函数是 Java 8 相对于之前版本 Java 新增的特性:你可以将任何方法作为函数值使用,使用::操作符创建方法引用,并使用 lambda 表达式(如(int x) -> x + 1)直接表达函数值。在 Java 8 中,使用方法引用如下将Integer.parseInt方法存储在变量中是完全有效的^([1]):
¹
如果你计划将
Integer::parseInt方法作为唯一要存储在变量strToInt中的方法,你可能想让strToInt具有ToIntFunction<String>类型以节省装箱。在这里你没有这样做,因为使用这样的 Java 原始应用可能会妨碍你看到正在发生的事情,即使这些应用提高了原始类型的效率。
Function<String, Integer> strToInt = Integer::parseInt;
19.1.1. 高阶函数
到目前为止,你主要使用函数值是一等公民的事实,将它们传递给 Java 8 流处理操作(如第四章至第七章),并在将 Apple::isGreen-Apple 作为函数值传递给 filterApples 时实现类似的行为参数化效果(如第一章和第二章所示)。另一个有趣的例子是使用静态方法 Comparator.comparing,它接受一个函数作为参数并返回另一个函数(一个 Comparator),如下面的代码和图 19.1 所示:
图 19.1. comparing 接受一个函数作为参数并返回另一个函数。

Comparator<Apple> c = comparing(Apple::getWeight);
当你在第三章中组合函数以创建操作流水线时,你做了类似的事情:
Function<String, String> transformationPipeline
= addHeader.andThen(Letter::checkSpelling)
.andThen(Letter::addFooter);
在函数式编程社区中,能够至少执行以下操作之一的函数(如 Comparator.comparing)被称为 高级函数:
-
接受一个或多个函数作为参数
-
返回一个函数作为结果
这种描述直接关联到 Java 8 函数,因为它们不仅可以作为参数传递,还可以作为结果返回,分配给局部变量,甚至可以插入到结构中。一个计算器程序可能有一个 Map<String, Function<Double, Double>>,将 String "sin" 映射到 Function<Double, Double> 以持有方法引用 Math::sin。你在第八章学习工厂设计模式时也做了类似的事情。
喜欢在第三章结尾处计算示例的读者可以将微分类型视为
Function<Function<Double,Double>, Function<Double,Double>>
因为它接受一个函数作为参数(例如 (Double x) -> x * x)并返回一个函数作为结果(在这个例子中,(Double x) -> 2 * x)。我们将此代码写成函数类型(最左边的 Function)以明确确认你可以将这个微分函数传递给另一个函数。但最好记住,微分函数的类型和签名
Function<Double,Double> differentiate(Function<Double,Double> func)
说的是同一件事。
副作用和高级函数
我们在 第七章 中提到,传递给流操作的函数通常是无副作用的,并指出了否则可能出现的问题(例如,由于你没有考虑到竞态条件而导致的不正确结果甚至不可预测的结果)。这个原则也适用于你使用高阶函数的一般情况。当你编写一个高阶函数或方法时,你事先不知道将传递给它什么参数,如果参数有副作用,这些副作用可能会做什么。如果你的代码使用了作为参数传递的函数,这些函数会在程序状态中引起不可预测的变化,那么推理你的代码做什么会变得非常复杂;这些函数甚至可能以难以调试的方式干扰你的代码。记录你愿意从作为参数传递的函数中接受的副作用是一个好的设计原则。没有副作用是最好的!
在下一节中,我们将转向柯里化:一种可以帮助你模块化函数和重用代码的技术。
19.1.2. 柯里化
在我们给出柯里化的理论定义之前,我们将举一个例子。应用几乎总是需要国际化,因此从一个单位集转换到另一个单位集是一个反复出现的问题。
单位转换总是涉及一个转换系数,有时还涉及基线调整系数。例如,将摄氏度转换为华氏度的公式是 CtoF(x) = x*9/5 + 32。所有单位转换的基本模式如下:
-
乘以转换系数。
-
如果相关,调整基线。
你可以用以下通用方法表达这个模式:
static double converter(double x, double f, double b) {
return x * f + b;
}
在这里,x 是你想要转换的量,f 是转换系数,而 b 是基线。但这种方法有点过于通用。通常,你需要进行大量相同单位对之间的转换,例如千米到英里。你可以在每次调用时用三个参数调用 converter 方法,但每次都提供系数和基线会显得很繁琐,而且你可能会不小心打错字。
你可以为每个应用编写一个新的方法,但这样做会错过底层逻辑的重用。
这是一个利用现有逻辑的同时为特定应用定制转换器的好方法。你可以定义一个工厂,制造单参数转换函数来举例说明柯里化的概念:
static DoubleUnaryOperator curriedConverter(double f, double b){
return (double x) -> x * f + b;
}
现在你只需要传递 curriedConverter 转换系数和基线(f 和 b),它会乐意地返回一个函数(x 的函数)来完成你所要求的事情。然后你可以使用工厂来生产你需要的任何转换器,如下所示:
DoubleUnaryOperator convertCtoF = curriedConverter(9.0/5, 32);
DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
DoubleUnaryOperator convertKmtoMi = curriedConverter(0.6214, 0);
由于 DoubleUnaryOperator 定义了一个 applyAsDouble 方法,你可以这样使用你的转换器:
double gbp = convertUSDtoGBP.applyAsDouble(1000);
因此,你的代码更加灵活,并且它重用了现有的转换逻辑!
反思你在这里所做的事情。你并不是一次性将所有参数x、f和b传递给converter方法,而是只请求参数f和b,并返回另一个函数——当给定一个参数x时,它返回x * f + b。这个两阶段过程使你能够重用转换逻辑并创建具有不同转换因子的不同函数。
柯里化(Currying)的正式定义
柯里化^([a]) 是一种技术,其中两个参数的函数 f(例如 x 和 y)被视为一个参数的函数 g,它返回另一个参数的函数。后者的函数值与原始函数的值相同——即 f(x,y) = (g(x))(y)。
^a
“柯里化”这个词与印度食物无关;这个术语是以逻辑学家 Haskell Brooks Curry 命名的,他使这项技术流行起来。然而,他将其归功于 Moses Ilyich Schönfinkel。我们是否应该将柯里化称为 schönfinkeling?
这种定义是通用的。你可以将一个六参数函数 curry 成首先接受编号为 2、4 和 6 的参数,它返回一个接受参数 5 的函数,该函数返回一个接受剩余参数 1 和 3 的函数。
当传递了一些参数(但少于所有参数)时,函数是部分应用的。
在下一节中,我们将转向函数式编程风格的另一个方面:数据结构。如果禁止修改数据结构,是否还能用它们来编程?
19.2. 持久化数据结构
在函数式编程风格程序中使用的数据结构有各种名称,例如函数式数据结构和不可变数据结构,但最常见的是持久化数据结构。(不幸的是,这个术语与数据库中“持久化”的概念相冲突,意味着“超出一次程序运行的生命周期。”)
首先,要注意的是,函数式方法不允许更新任何全局数据结构或任何作为参数传递的结构。为什么?因为调用它两次很可能会产生不同的答案,违反了引用透明性和将方法理解为一个简单的从参数到结果的映射的能力。
19.2.1. 摧毁性更新与函数式
考虑可能出现的问题。假设你将 A 到 B 的火车旅程表示为一个可变的TrainJourney类(单链表的简单实现),其中int字段表示旅程的一些细节,例如旅程当前阶段的票价。需要换乘的旅程通过onward字段具有几个链接的TrainJourney对象;直达火车或旅程的最后一程,onward为null:
class TrainJourney {
public int price;
public TrainJourney onward;
public TrainJourney(int p, TrainJourney t) {
price = p;
onward = t;
}
}
现在假设你有代表从 X 到 Y 和从 Y 到 Z 的旅程的单独的TrainJourney对象。你可能想创建一个连接这两个TrainJourney对象(即 X 到 Y 到 Z)的旅程。
这里是一个简单的传统命令式方法来链接这些火车旅程:
static TrainJourney link(TrainJourney a, TrainJourney b){
if (a==null) return b;
TrainJourney t = a;
while(t.onward != null){
t = t.onward;
}
t.onward = b;
return a;
}
此方法通过找到TrainJourney中a的最后一部分,并用列表b替换a列表末尾的null标记来实现。如果a没有元素,则需要一个特殊情况。
这里的问题是:假设一个变量firstJourney包含从 X 到 Y 的路线,而另一个变量secondJourney包含从 Y 到 Z 的路线。如果您调用link(firstJourney, secondJourney),此代码会破坏性地更新firstJourney以包含secondJourney,因此除了请求从 X 到 Z 的单一用户看到预期的合并旅程外,从 X 到 Y 的旅程也被破坏性地更新了。实际上,firstJourney变量不再是 X 到 Y 的路线,而是一条从 X 到 Z 的路线,这破坏了依赖于firstJourney未修改的代码!假设firstJourney代表早上的伦敦到布鲁塞尔的火车,所有试图前往布鲁塞尔的后续用户都会惊讶地看到需要一段后续旅程,可能到科隆。我们都与这类有关数据结构变化可见性的 bug 作过斗争。
对于这个问题,函数式风格的解决方案是禁止这种有副作用的函数。如果您需要一个数据结构来表示计算的结果,您应该创建一个新的数据结构,而不是修改现有的数据结构,就像您之前所做的那样。这样做在标准面向对象编程中通常也是一个最佳实践。对函数式方法的一个常见反对意见是它会导致过多的复制,程序员会说,“我会记住”或“我会记录”副作用。但这种乐观是一种陷阱,对于后来必须处理您代码的维护程序员来说。因此,函数式风格的解决方案如下:
static TrainJourney append(TrainJourney a, TrainJourney b){
return a==null ? b : new TrainJourney(a.price, append(a.onward, b));
}
这段代码明显是函数式风格的(不使用任何变异,即使是局部性的)并且不会修改任何现有的数据结构。然而,请注意,代码并没有创建一个新的TrainJourney。如果a是一个包含n个元素的序列,而b是一个包含m个元素的序列,则代码返回一个包含n+m个元素的序列,其中前n个元素是新的节点,最后的m个元素与TrainJourney b共享。请注意,用户需要确保不修改append的结果,因为这样做可能会破坏作为序列b传入的火车。和
说明了破坏性
append和函数式append之间的区别。
图 19.2. 数据结构被破坏性更新。

图 19.3. 函数式风格,没有修改数据结构

19.2.2. 树的另一个例子
在离开这个主题之前,考虑另一种数据结构:一个二叉搜索树,可能用于实现类似于 HashMap 的接口。想法是 Tree 包含一个表示键的 String 和一个表示其值的 int,可能是姓名和年龄:
class Tree {
private String key;
private int val;
private Tree left, right;
public Tree(String k, int v, Tree l, Tree r) {
key = k; val = v; left = l; right = r;
}
}
class TreeProcessor {
public static int lookup(String k, int defaultval, Tree t) {
if (t == null) return defaultval;
if (k.equals(t.key)) return t.val;
return lookup(k, defaultval,
k.compareTo(t.key) < 0 ? t.left : t.right);
}
// other methods processing a Tree
}
您想使用二叉搜索树来查找 String 值以生成一个 int。现在考虑您如何更新与给定键关联的值(为了简单起见,假设键已经存在于树中):
public static void update(String k, int newval, Tree t) {
if (t == null) { /* should add a new node */ }
else if (k.equals(t.key)) t.val = newval;
else update(k, newval, k.compareTo(t.key) < 0 ? t.left : t.right);
}
添加新节点比较复杂。最简单的方法是让方法 update 返回已遍历的 Tree(除非您需要添加节点,否则不会改变)。现在这段代码稍微有些笨拙,因为用户需要记住 update 尝试原地更新树,并返回与传入相同的树。但如果原始树为空,则返回一个新节点:
public static Tree update(String k, int newval, Tree t) {
if (t == null)
t = new Tree(k, newval, null, null);
else if (k.equals(t.key))
t.val = newval;
else if (k.compareTo(t.key) < 0)
t.left = update(k, newval, t.left);
else
t.right = update(k, newval, t.right);
return t;
}
注意,update 的两个版本都会修改现有的 Tree,这意味着所有使用存储在树中的 map 的用户都会看到这种修改。
19.2.3. 使用函数式方法
您如何以函数式方式编程这样的树更新?您需要为新的键值对创建一个新节点。您还需要在树的根节点到新节点路径上创建新节点,如下所示:
public static Tree fupdate(String k, int newval, Tree t) {
return (t == null) ?
new Tree(k, newval, null, null) :
k.equals(t.key) ?
new Tree(k, newval, t.left, t.right) :
k.compareTo(t.key) < 0 ?
new Tree(t.key, t.val, fupdate(k,newval, t.left), t.right) :
new Tree(t.key, t.val, t.left, fupdate(k,newval, t.right));
}
通常,这段代码并不昂贵。如果树深度为 d 且平衡得合理,它可以有大约 2^d 个条目,所以您只需重新创建其中的一小部分。
我们将这段代码写成单个条件表达式,而不是使用 if-then-else,以强调主体是一个没有副作用的单个表达式。但您可能更喜欢编写等效的 if-then-else 链,每个都包含一个返回。
update 和 fupdate 之间的区别是什么?我们之前提到,update 方法假设每个用户都想要共享数据结构并看到程序任何部分引起的更新。因此,在非函数式代码中,每当您向树添加某种结构化值时,您必须复制它,因为有人可能后来会假设他可以更新它。相比之下,fupdate 完全是函数式的;它创建一个新的 Tree 作为结果,但尽可能多地与它的参数共享。图 19.4 阐述了这一概念。您有一个由存储人的姓名和年龄的节点组成的树。调用 fupdate 不会修改现有的树;它创建新的节点“位于树的一侧”,而不会损害现有的数据结构。
图 19.4. 在制作此 Tree 更新过程中,没有现有的数据结构受到损害。

这种函数式数据结构通常被称为持久性的——它们的值持续存在,并且与其他地方发生的变化隔离——因此作为程序员,你可以确信fupdate不会修改作为其参数传递的数据结构。有一个前提条件:条约的另一方要求所有持久数据结构的用户都必须遵守不修改的要求。如果不这样做,忽视这个前提条件的程序员可能会修改fupdate的结果(例如,通过更改 Emily 的 20)。然后这种修改就会作为(几乎肯定是不希望看到的)意外和延迟的变化出现在传递给fupdate作为参数的数据结构中!
从这个角度来看,fupdate可以更高效。不允许修改现有结构的规则允许结构只略有不同(例如,用户 A 看到的Tree和用户 B 看到的修改版本)在它们的结构的公共部分共享存储。你可以通过将类Tree的字段key、val、left和right声明为final来让编译器帮助强制执行此规则。但请记住,final只保护字段,而不是指向的对象,该对象可能需要其自己的字段是final以保护它,依此类推。
你可能会说,“我希望树的结构更新能被某些用户看到(但诚实地讲,不是所有用户)。”你有两个选择。一个选择是经典的 Java 解决方案:在更新某些内容时要小心,检查是否需要先复制它。另一个选择是函数式风格的解决方案:你每次更新时逻辑上都会创建一个新的数据结构(这样就不会有任何东西被修改),并安排将适当的数据结构版本传递给用户。这个想法可以通过 API 来强制执行。如果某些数据结构的客户端需要看到更新,他们应该通过返回最新版本的 API 进行操作。不想看到更新的客户端(例如,用于长时间运行的统计分析)使用他们检索到的任何副本,知道它不能在他们不知情的情况下被修改。
这种技术就像在 CD-R 上更新文件一样,它允许通过激光烧录只写入一次文件。文件的多个版本存储在 CD 上(智能 CD 刻录软件甚至可能共享多个版本的公共部分),你通过传递文件起始位置的适当块地址(或包含版本信息的文件名编码)来选择你想要使用的版本。在 Java 中,情况比 CD 要好得多,因为旧的数据结构版本不再被使用时会被垃圾回收。
19.3. 使用流进行懒计算
你在前面的章节中看到,流是处理数据集合的绝佳方式。但是出于各种原因,包括高效的实现,Java 8 的设计者以一种相当具体的方式将流添加到 Java 中。一个限制是,你不能递归地定义一个流,因为流只能被消费一次。在接下来的几节中,我们将向你展示这种情况可能存在的问题。
19.3.1. 自定义流
回顾第六章(kindle_split_017.xhtml#ch06)中生成素数的例子,以理解递归流的这个概念。在第六章中,你看到(可能作为MyMath-Utils类的一部分),你可以按照以下方式计算素数流:
public static Stream<Integer> primes(int n) {
return Stream.iterate(2, i -> i + 1)
.filter(MyMathUtils::isPrime)
.limit(n);
}
public static boolean isPrime(int candidate) {
int candidateRoot = (int) Math.sqrt((double) candidate);
return IntStream.rangeClosed(2, candidateRoot)
.noneMatch(i -> candidate % i == 0);
}
但这个解决方案有些尴尬。你必须每次迭代通过每个数字,看看它是否可以被候选数字整除。(实际上,你只需要测试已经被分类为素数的数字。)
理想情况下,流应该过滤掉那些可以被流正在生成的素数整除的数字。这个过程可能如下所示:
-
你需要一个数字流,从该流中你可以选择素数。
-
从那个流中取出第一个数字(流的头部),它将是一个素数。(在初始步骤中,这个数字是 2。)
-
从流的尾部过滤掉所有可以被那个数字整除的数字。
-
结果的尾部是新的数字流,你可以用它来找到素数。本质上,你回到了步骤 1,因此这个算法是递归的。
注意,这个算法有几个原因不好,^([2]) 但对于处理流的目的来说,算法是简单的,易于推理。在接下来的几节中,你将尝试使用 Streams API 编写这个算法。
²
你可以在www.cs.hmc.edu/~oneill/papers/Sieve-JFP.pdf找到更多关于为什么该算法较差的信息。
第 1 步:获取一个数字流
你可以通过使用IntStream.iterate方法(我们在第五章中描述过)从 2 开始获取一个无限数字流,如下所示:
static Intstream numbers(){
return IntStream.iterate(2, n -> n + 1);
}
第 2 步:取出头部
IntStream提供了一个findFirst方法,你可以用它来返回第一个元素:
static int head(IntStream numbers){
return numbers.findFirst().getAsInt();
}
第 3 步:过滤尾部
定义一个获取流尾部的函数:
static IntStream tail(IntStream numbers){
return numbers.skip(1);
}
给定流的头部,你可以按照以下方式过滤数字:
IntStream numbers = numbers();
int head = head(numbers);
IntStream filtered = tail(numbers).filter(n -> n % head != 0);
第 4 步:递归创建素数流
接下来是棘手的部分。你可能想尝试传递回过滤后的结果流,这样你就可以取出它的头部并过滤更多的数字,如下所示:
static IntStream primes(IntStream numbers) {
int head = head(numbers);
return IntStream.concat(
IntStream.of(head),
primes(tail(numbers).filter(n -> n % head != 0))
);
}
坏消息
不幸的是,如果你运行第 4 步中的代码,你会得到以下错误:java.lang.IllegalStateException: stream has already been operated upon or closed。确实,你使用了两个终端操作来将流分割成其头部和尾部:findFirst和skip。记得从第四章中,在你对一个流调用终端操作后,它就会被永久消耗!
惰性求值
有一个额外且更重要的问题:静态方法IntStream.concat期望两个流实例,但它的第二个参数是对primes的直接递归调用,导致无限递归!对于许多 Java 用途,Java 8 流上的限制,如不允许递归定义,是无问题的,并且给你的数据库查询提供了表达性和并行化的能力。因此,Java 8 的设计者选择了一个甜点。尽管如此,从 Scala 和 Haskell 等函数式语言中获得的更通用的流特性和模型可以作为你编程工具箱的有用补充。你需要的是一个方法,可以惰性地评估concat第二个参数中primes方法的调用。(在更技术性的编程术语中,我们称这个概念为惰性求值,非严格求值,甚至按名调用。)只有当你需要处理素数(例如使用limit方法)时,流才应该被评估。Scala(我们将在第二十章中探讨)提供了对这个想法的支持。在 Scala 中,你可以像下面这样编写前面的算法,其中操作符#::执行惰性连接(只有在需要消费流时才会评估参数):
def numbers(n: Int): Stream[Int] = n #:: numbers(n+1)
def primes(numbers: Stream[Int]): Stream[Int] = {
numbers.head #:: primes(numbers.tail filter (n => n % numbers.head != 0))
}
不要担心这段代码。它的唯一目的是向你展示 Java 与其他函数式编程语言之间差异的一个领域。花一点时间思考一下参数是如何评估的。在 Java 中,当你调用一个方法时,所有参数都会立即完全评估。但是,当你使用 Scala 中的#::时,连接操作会立即返回,并且元素只有在需要时才会被评估。
在下一节中,我们将转向直接在 Java 中实现这个惰性列表的想法。
19.3.2. 你自己的惰性列表
Java 8 流通常被描述为惰性的。它们在某个特定方面是惰性的:流的行为像一个可以按需生成值的黑盒。当你对一个流应用一系列操作时,这些操作仅仅是保存起来。只有当你对一个流应用一个终端操作时,才会进行任何计算。当你对一个流应用多个操作(可能是一个filter和一个map,然后是一个终端操作reduce)时,这种延迟具有很大的优势:流只需要遍历一次,而不是对每个操作都进行遍历。
在本节中,你将考虑懒列表的概念,它是更一般流的形式。(懒列表形成了一个类似于流的概念。)懒列表还提供了一个思考高阶函数的绝佳方式。你将函数值放入数据结构中,这样大部分时间它都可以在那里闲置,但当你调用它(按需)时,它可以创建更多的数据结构。图 19.5 说明了这个想法。
图 19.5. LinkedList 的元素存在于(分散在)内存中。但 LazyList 的元素是由 Function 按需创建的;你可以将它们视为在时间上分散。

接下来,你将看到这个概念是如何工作的。你想要使用我们之前描述的算法生成一个无限素数列表。
创建一个基本链表
回想一下,你可以在 Java 中通过以下方式定义一个简单的链表样式类 MyLinkedList,如下所示(使用最小的 MyList 接口):
interface MyList<T> {
T head();
MyList<T> tail();
default boolean isEmpty() {
return true;
}
}
class MyLinkedList<T> implements MyList<T> {
private final T head;
private final MyList<T> tail;
public MyLinkedList(T head, MyList<T> tail) {
this.head = head;
this.tail = tail;
}
public T head() {
return head;
}
public MyList<T> tail() {
return tail;
}
public boolean isEmpty() {
return false;
}
}
class Empty<T> implements MyList<T> {
public T head() {
throw new UnsupportedOperationException();
}
public MyList<T> tail() {
throw new UnsupportedOperationException();
}
}
现在你可以按照以下方式构建一个示例 MyLinkedList 值:
MyList<Integer> l =
new MyLinkedList<>(5, new MyLinkedList<>(10, new Empty<>()));
创建一个基本懒列表
将此类适应懒列表概念的一个简单方法是不一次性将尾部存储在内存中,而是使用你在 第三章 中看到的 Supplier<T>(你也可以将其视为一个具有函数描述符 void -> T 的工厂)来生成列表的下一个节点。这种设计导致以下代码:
import java.util.function.Supplier;
class LazyList<T> implements MyList<T>{
final T head;
final Supplier<MyList<T>> tail;
public LazyList(T head, Supplier<MyList<T>> tail) {
this.head = head;
this.tail = tail;
}
public T head() {
return head;
}
public MyList<T> tail() {
return tail.get(); *1*
}
public boolean isEmpty() {
return false;
}
}
- 1 注意,与 head 方法相比,使用 Supplier 的 tail 编码了懒性。
从 Supplier 调用 get 方法会导致创建一个 Lazy-List 节点(就像工厂创建一个新对象一样)。
现在你可以创建从 n 开始的无限懒列表,如下所示。将一个 Supplier 作为 LazyList 构造函数的 tail 参数传递,它将创建数字序列中的下一个元素:
public static LazyList<Integer> from(int n) {
return new LazyList<Integer>(n, () -> from(n+1));
}
如果你尝试以下代码,你会看到它打印出 2 3 4。确实,数字是在需要时生成的。为了检查,适当地插入 System.out.println 或注意,如果 from(2) 尝试从 2 开始计算所有数字,它将永远运行:
LazyList<Integer> numbers = from(2);
int two = numbers.head();
int three = numbers.tail().head();
int four = numbers.tail().tail().head();
System.out.println(two + " " + three + " " + four);
再次生成素数
看看你是否可以使用你到目前为止所做的一切来生成一个自定义的懒列表素数(这是你无法使用 Streams API 做到的事情)。如果你要使用新的 LazyList 来翻译之前使用 Streams API 的代码,代码看起来可能像这样:
public static MyList<Integer> primes(MyList<Integer> numbers) {
return new LazyList<>(
numbers.head(),
() -> primes(
numbers.tail()
.filter(n -> n % numbers.head() != 0)
)
);
}
实现一个懒过滤
不幸的是,LazyList(更准确地说,List 接口)没有定义 filter 方法,所以前面的代码无法编译!为了解决这个问题,声明一个 filter 方法,如下所示:
public MyList<T> filter(Predicate<T> p) {
return isEmpty() ?
this : *1*
p.test(head()) ?
new LazyList<>(head(), () -> tail().filter(p)) :
tail().filter(p);
}
- 1 你可以返回 new Empty<>(),但使用 'this' 一样好,也是空的。
你的代码编译并通过,可以使用了!你可以通过链式调用 tail 和 head 来计算前三个素数,如下所示:
LazyList<Integer> numbers = from(2);
int two = primes(numbers).head();
int three = primes(numbers).tail().head();
int five = primes(numbers).tail().tail().head();
System.out.println(two + " " + three + " " + five);
这段代码打印出 2 3 5,这是前三个素数。现在你可以玩一些有趣的事情了。例如,你可以打印出所有的素数。(通过编写一个printAll方法,程序会无限运行,该方法会迭代打印列表的头部和尾部。)
static <T> void printAll(MyList<T> list){
while (!list.isEmpty()){
System.out.println(list.head());
list = list.tail();
}
}
printAll(primes(from(2)));
由于本章是关于函数式编程的,我们应该解释一下,这段代码可以简洁地递归编写:
static <T> void printAll(MyList<T> list){
if (list.isEmpty())
return;
System.out.println(list.head());
printAll(list.tail());
}
然而,这个程序不会无限运行。遗憾的是,它最终会因为栈溢出而失败,因为 Java 不支持尾调用消除,如第十八章所述(kindle_split_033.xhtml#ch18)。
回顾
你已经用惰性列表和函数构建了大量技术,你只使用它们来定义一个包含所有素数的数据结构。这有什么实际用途呢?好吧,你已经看到了如何将函数放入数据结构中(因为 Java 8 允许这样做),你可以使用这些函数在需要时创建数据结构的一部分,而不是在结构创建时。如果你正在编写一个游戏程序,比如国际象棋,这可能很有用;你可以有一个理论上代表所有可能移动的整个树的数据结构(太大而不能积极计算),但可以在需要时创建。这种数据结构将是一个惰性树,而不是惰性列表。我们之所以在本章中专注于惰性列表,是因为它们提供了与 Java 8 的另一项功能——流的联系,这使得我们能够讨论流与惰性列表的优缺点。
性能问题仍然存在。人们很容易认为懒散地做事比积极地做事更好。当然,按需计算程序所需的价值和数据结构,而不是像传统执行那样创建所有这些值(也许更多),似乎是更好的选择。不幸的是,现实世界并不那么简单。懒散地做事的开销(比如在LazyList中的项目之间的额外Supplier)除非你探索的数据结构不到 10%,否则会超过这种理论上的好处。最后,有一种微妙的方式,你的LazyList值并不是真正懒散的。如果你遍历一个LazyList值,比如from(2),可能到第 10 项,它也会创建所有节点两次,创建 20 个节点而不是 10 个。这种结果几乎不懒散。问题是tail中的Supplier在每次按需探索LazyList时都会被反复调用。你可以通过安排tail中的Supplier只在第一次按需探索时调用,并将结果缓存起来,从而在这一点上固化列表来解决这个问题。为了实现这个目标,在你的LazyList定义中添加一个private Optional<LazyList<T>> alreadyComputed字段,并安排tail方法适当地咨询和更新它。纯函数式语言 Haskell 就是这样安排的,使其所有数据结构在后者意义上都是适当的懒散的。如果你对 Haskell 感兴趣,可以阅读许多关于 Haskell 的文章之一。
我们的指导方针是记住,懒散的数据结构可以成为你编程武器库中的有用武器。当它们使应用程序更容易编程时使用这些结构;如果它们导致不可接受的低效,则以更传统的风格重写它们。
下一个部分处理几乎所有函数式编程语言(除了 Java)的另一个特性:模式匹配。
19.4. 模式匹配
被普遍认为的函数式编程还有一个其他重要的方面:(结构化)模式匹配,不要与模式匹配和正则表达式混淆。第一章以观察数学可以写出如下定义结束:
f(0) = 1
f(n) = n*f(n-1) otherwise
而在 Java 中,你必须编写一个if-then-else或switch语句。随着数据类型的变得更加复杂,处理它们所需的代码(和混乱)量也会增加。使用模式匹配可以减少这种混乱。
为了说明,考虑一个你想遍历的树结构。考虑一个由数字和二元运算组成的简单算术语言:
class Expr { ... }
class Number extends Expr { int val; ... }
class BinOp extends Expr { String opname; Expr left, right; ... }
假设你被要求编写一个简化某些表达式的函数。例如,5 + 0 可以简化为 5。使用我们的Expr类,new BinOp("+", new Number(5), new Number(0))可以简化为Number(5)。你可能像这样遍历一个Expr结构:
Expr simplifyExpression(Expr expr) {
if (expr instanceof BinOp
&& ((BinOp)expr).opname.equals("+"))
&& ((BinOp)expr).right instanceof Number
&& ... // it's all getting very clumsy
&& ... ) {
return (Binop)expr.left;
}
...
}
你可以看到,这段代码很快就会变得丑陋!
19.4.1. 访问者设计模式
在 Java 中展开数据类型的另一种方法是使用访问者设计模式。本质上,你创建一个单独的类,该类封装了一个访问特定数据类型的算法。
访问者类通过接受数据类型的特定实例作为输入来工作;然后它可以访问所有成员。以下是一个示例。首先,向 BinOp 添加方法 accept,该方法接受 SimplifyExprVisitor 作为参数并将自身传递给它(并为 Number 添加类似的方法):
class BinOp extends Expr{
...
public Expr accept(SimplifyExprVisitor v){
return v.visit(this);
}
}
现在 SimplifyExprVisitor 可以访问 BinOp 对象并展开它:
public class SimplifyExprVisitor {
...
public Expr visit(BinOp e){
if("+".equals(e.opname) && e.right instanceof Number && ...){
return e.left;
}
return e;
}
}
19.4.2. 模式匹配救命
一个更简单的解决方案是使用一个名为模式匹配的功能。该功能在 Java 中不可用,因此我们将使用 Scala 编程语言的小例子来说明模式匹配。这些例子给你一个想法,如果 Java 支持模式匹配,可能会实现什么。
给定表示算术表达式的数据类型 Expr,在 Scala 编程语言(我们之所以使用它是因为其语法与 Java 最接近)中,你可以编写以下代码来分解一个表达式:
def simplifyExpression(expr: Expr): Expr = expr match {
case BinOp("+", e, Number(0)) => e // Adding zero
case BinOp("*", e, Number(1)) => e // Multiplying by one
case BinOp("/", e, Number(1)) => e // Dividing by one
case _ => expr // Can't simplify expr
}
这种模式匹配的使用提供了一种极其简洁且富有表现力的方式来操作许多树状数据结构。通常,这种技术对于构建编译器或处理业务规则的引擎非常有用。请注意,Scala 语法
Expression match { case Pattern => Expression ... }
与 Java 语法相似
switch (Expression) { case Constant : Statement ... }
使用 Scala 的通配符模式 _ 使最终的 case 扮演 Java 中的 default 角色:。主要的可见语法差异是 Scala 以表达式为导向,而 Java 则更以语句为导向。但对于程序员来说,主要的表达差异是 Java 中的 case 标签模式仅限于几种原始类型、枚举、一些封装特定原始类型的特殊类以及 String。使用具有模式匹配功能的语言的最大实际优势之一是你可以避免使用大型的 switch 或 if-then-else 语句链,这些语句链与字段选择操作交织在一起。
很明显,Scala 的模式匹配在表达简洁性方面优于 Java,你可以期待未来 Java 允许更具有表现力的 switch 语句。(我们在第二十一章中提出了这个特性的具体建议。chapter 21。)
同时,我们向你展示 Java 8 lambdas 如何提供在 Java 中实现类似模式匹配代码的另一种方法。我们纯粹为了向你展示 lambdas 的另一个有趣应用来描述这项技术。
在 Java 中伪造模式匹配
首先,考虑 Scala 的模式匹配 match 表达式形式的丰富性。案例
def simplifyExpression(expr: Expr): Expr = expr match {
case BinOp("+", e, Number(0)) => e
...
意味着“检查 expr 是否是 BinOp,提取其三个组件(opname,left,right),然后对这些组件进行模式匹配——第一个与 String + 匹配,第二个与变量 e(它总是匹配)匹配,第三个与模式 Number(0) 匹配。”换句话说,Scala(以及许多其他函数式语言)中的模式匹配是多层的。你用 Java 8 的 lambdas 模拟的模式匹配只产生单层模式匹配。在前面的例子中,你的模拟会表达 BinOp(op, l, r) 或 Number(n) 这样的情况,但不会表达 BinOp("+", e, Number(0))。
首先,我们做一个稍微令人惊讶的观察:既然你有了 lambdas,原则上你可以在代码中永远不用 if-then-else。你可以用方法调用替换 condition ? e1 : e2 这样的代码,如下所示:
myIf(condition, () -> e1, () -> e2);
在某个地方,可能是在图书馆里,你会找到一个定义(类型为 T 的泛型):
static <T> T myIf(boolean b, Supplier<T> truecase, Supplier<T> falsecase) {
return b ? truecase.get() : falsecase.get();
}
类型 T 扮演了条件表达式的结果类型角色。原则上,你可以用其他控制流结构,如 switch 和 while,执行类似的技巧。
在正常代码中,这种编码会使你的代码更加晦涩,因为 if-then-else 完美地捕捉了这个习语。但我们已经注意到,Java 的 switch 和 if-then-else 并没有捕捉到模式匹配的习语,而且结果证明 lambdas 可以编码(单层)模式匹配——比 if-then-else 链要整洁得多。
回到 Expr 类的模式匹配值(该类有两个子类,BinOp 和 Number),你可以定义一个 patternMatchExpr 方法(再次泛型 T,模式匹配的结果类型):
interface TriFunction<S, T, U, R>{
R apply(S s, T t, U u);
}
static <T> T patternMatchExpr(
Expr e,
TriFunction<String, Expr, Expr, T> binopcase,
Function<Integer, T> numcase,
Supplier<T> defaultcase) {
return
(e instanceof BinOp) ?
binopcase.apply(((BinOp)e).opname, ((BinOp)e).left,
((BinOp)e).right) :
(e instanceof Number) ?
numcase.apply(((Number)e).val) :
defaultcase.get();
}
结果是,方法调用
patternMatchExpr(e, (op, l, r) -> {return *binopcode*;},
(n) -> {return *numcode*;},
() -> {return *defaultcode*;});
判断 e 是否是 BinOp(如果是,则运行 binopcode,它可以通过标识符 op, l, r 访问 BinOp 的字段)或 Number(如果是,则运行 numcode,它可以通过值 n 访问),该方法甚至为 defaultcode 做了准备,如果有人后来创建了一个既不是 BinOp 也不是 Number 的树节点,则会执行该代码。
以下列表显示了如何通过简化加法和乘法表达式来开始使用 patternMatchExpr。
列表 19.1. 实现模式匹配以简化表达式
public static Expr simplify(Expr e) {
TriFunction<String, Expr, Expr, Expr> binopcase = *1*
(opname, left, right) -> {
if ("+".equals(opname)) { *2*
if (left instanceof Number && ((Number) left).val == 0) {
return right;
}
if (right instanceof Number && ((Number) right).val == 0) {
return left;
}
}
if ("*".equals(opname)) { *3*
if (left instanceof Number && ((Number) left).val == 1) {
return right;
}
if (right instanceof Number && ((Number) right).val == 1) {
return left;
}
}
return new BinOp(opname, left, right);
};
Function<Integer, Expr> numcase = val -> new Number(val); *4*
Supplier<Expr> defaultcase = () -> new Number(0); *5*
return patternMatchExpr(e, binopcase, numcase, defaultcase); *6*
-
1 处理 BinOp 表达式
-
2 处理加法情况
-
3 处理乘法情况
-
4 处理一个 Number
-
5 如果用户提供了一个未识别的 Expr,则有一个默认情况
-
6 应用模式匹配
现在,你可以按照以下方式调用 simplify 方法:
Expr e = new BinOp("+", new Number(5), new Number(0));
Expr match = simplify(e);
System.out.println(match); *1*
- 1 打印 5
到目前为止,你已经看到了很多信息:高阶函数、柯里化、持久数据结构、惰性列表和模式匹配。下一节将探讨我们推迟到最后的某些细微差别,以避免使文本过于复杂。
19.5. 杂项
在本节中,我们探讨函数式编程和引用透明性的两个细微之处:一个关于效率,另一个关于返回相同的结果。这些问题很有趣,但我们将其放在这里,因为这些问题涉及到副作用,并不是概念上的核心。我们还探讨了组合子的概念——即接受两个或更多函数并返回另一个函数的方法或函数。这个想法启发了 Java 8 API 的许多新增功能,以及更近期的 Java 9 Flow API。
19.5.1. 缓存或记忆化
假设你有一个无副作用的computeNumberOfNodes(Range)方法,它计算一个具有树形拓扑结构的网络中给定范围内的节点数。假设网络永远不会改变(即结构是不可变的),但调用computeNumberOfNodes方法计算成本很高,因为需要递归遍历结构。你可能需要反复计算结果。如果你有引用透明性,你有一个巧妙的方法来避免这种额外的开销。一个标准的解决方案是记忆化——在方法周围添加一个缓存(如HashMap)。首先,包装器会咨询缓存以查看(参数,结果)对是否已经在缓存中。如果是这样,它可以立即返回存储的结果。否则,你会调用computeNumberOfNodes,但在从包装器返回之前,你将新的(参数,结果)对存储在缓存中。严格来说,这个解决方案并不是纯函数式的,因为它会修改多个调用者共享的数据结构,但包装后的代码是引用透明的。
实际上,这段代码是这样工作的:
final Map<Range,Integer> numberOfNodes = new HashMap<>();
Integer computeNumberOfNodesUsingCache(Range range) {
Integer result = numberOfNodes.get(range);
if (result != null){
return result;
}
result = computeNumberOfNodes(range);
numberOfNodes.put(range, result);
return result;
}
注意
Java 8 通过为这种用例添加compute-If-Absent方法来增强Map接口(见附录 Bappendix B)。你可以使用computeIfAbsent来编写更清晰的代码:
Integer computeNumberOfNodesUsingCache(Range range) {
return numberOfNodes.computeIfAbsent(range,
this::computeNumberOfNodes);
}
很明显,computeNumberOfNodesUsingCache方法具有引用透明性(假设computeNumberOfNodes方法也是引用透明的)。但numberOfNodes具有可变的共享状态,而HashMap不是synchronized^([3]),这意味着这段代码不是线程安全的。即使使用(锁保护的)Hashtable或(无锁的)ConcurrentHashMap代替HashMap,如果从多个核心对numberOfNodes进行并行调用,也可能不会产生预期的性能。在将(参数,结果)对放回map之前,你发现range不在map中,这之间有一个竞争条件,这意味着多个进程可能会计算相同的值添加到map中。
³
这里是容易滋生错误的地方。使用
HashMap如此容易,以至于很容易忘记 Java 手册指出它不是线程安全的(或者因为你的程序目前是单线程的而不在乎)。
也许从这场斗争中我们能得到的最好的东西就是这样一个事实:将可变状态与并发混合比我们想象的要复杂。函数式编程除了用于低级性能黑客,如缓存之外,避免这种做法。第二个启示是,除了实现缓存等技巧之外,如果你以函数式风格编写代码,你永远不需要关心你调用的另一个函数式方法是否是同步的,因为你知道它没有共享的可变状态。
19.5.2. “返回相同的对象”是什么意思?
再次考虑第 19.2.3 节中的二叉树示例。在图 19.4 中,变量t指向一个现有的Tree,图显示了调用fupdate("Will", 26, t)以产生一个新的Tree的效果,这个新Tree可能被分配给变量t2。图清楚地表明t及其所有可到达的数据结构都没有被修改。现在假设你在附加赋值中执行一个文本上相同的调用:
t3 = fupdate("Will", 26, t);
现在t3指向三个包含与t2中相同数据的新的节点。问题是fupdate是否是引用透明的。引用透明意味着“相同的参数(这里的情况)意味着相同的结果。”问题是t2和t3是不同的引用,因此(t2 == t3)是false,所以看起来你将不得不得出结论,fupdate不是引用透明的。但是当你使用不可修改的持久数据结构时,t2和t3之间不存在逻辑上的差异。
我们可以对此进行长时间的辩论,但最简单的格言是,函数式编程通常使用equals来比较结构化值,而不是==(引用相等),因为数据不会被修改,在这个模型下,fupdate是引用透明的。
19.5.3. 组合子
在函数式编程中,编写一个接受两个函数并产生另一个将这两个函数以某种方式组合的函数(可能被写成方法)是很常见且自然的。这个概念通常被称为组合子。Java 8 API 中的许多新功能都受到了这种想法的启发,例如CompletableFuture类中的thenCombine方法。你可以给这个方法提供两个CompletableFuture和一个BiFunction来产生另一个CompletableFuture。
尽管对函数式编程中组合子的详细讨论超出了本书的范围,但看看几个特殊情况以给你一个关于操作函数的常见和自然函数式编程结构的味道是值得的。以下方法编码了函数组合的概念:
static <A,B,C> Function<A,C> compose(Function<B,C> g, Function<A,B> f) {
return x -> g.apply(f.apply(x));
}
此方法将函数 f 和 g 作为参数,并返回一个函数,其效果是先执行 f,然后执行 g。然后你可以定义一个操作,将内部迭代作为组合器。假设你想要对数据进行操作,并重复应用函数 f,n 次,就像循环一样。你的操作(可以称为 repeat)接受一个函数 f,说明一次迭代发生的事情,并返回一个函数,说明 n 次迭代发生的事情。例如,调用
repeat(3, (Integer x) -> 2*x);
返回 x ->(2*(2*(2*x)) 或等价于 x -> 8*x。
你可以通过编写以下代码来测试此代码:
System.out.println(repeat(3, (Integer x) -> 2*x).apply(10));
这将打印 80。
你可以这样编写 repeat 方法(注意零次循环的特殊情况):
static <A> Function<A,A> repeat(int n, Function<A,A> f) {
return n==0 ? x -> x *1*
: compose(f, repeat(n-1, f)); *2*
}
-
1 如果 n 为零,则返回无操作的身份函数。
-
2 否则,先执行 f,重复 n-1 次,然后再次执行。
这种想法的变体可以模拟更丰富的迭代概念,包括在迭代之间传递可变状态的函数模型。但是,现在是时候继续前进了。本章的作用是为你提供一个关于函数式编程的总结,作为 Java 8 的基础。许多优秀的书籍都深入探讨了函数式编程。
摘要
-
一等函数是可以作为参数传递、作为结果返回,并且可以存储在数据结构中的函数。
-
高阶函数接受一个或多个函数作为输入或返回另一个函数。Java 中的典型高阶函数包括
comparing、andThen和compose。 -
柯里化是一种让你模块化函数和重用代码的技术。
-
持久数据结构在修改时保留其之前的版本。因此,它可以防止不必要的防御性复制。
-
Java 中的流不能自定义。
-
惰性列表是 Java 流的更表达性版本。惰性列表允许你通过使用可以创建更多数据结构的供应商按需生成列表的元素。
-
模式匹配是一种函数式特性,它允许你展开数据类型。你可以将数据匹配视为泛化 Java 的
switch语句。 -
引用透明性允许计算被缓存。
-
组合器是结合两个或更多函数或其他数据结构的函数式思想。
第二十章。混合 OOP 和 FP:比较 Java 和 Scala
本章涵盖
-
Scala 简介
-
Java 与 Scala 以及反之的关系
-
Scala 中的函数与 Java 中的函数比较
-
类和特质
Scala 是一种混合面向对象和函数式编程的编程语言。它通常被视为想要在 JVM 上运行且具有 Java 感觉的静态类型编程语言中具有函数式特性的程序员的 Java 的替代语言。Scala 引入了许多比 Java 更多的特性:更复杂的类型系统、类型推断、模式匹配(如第十九章所述)、定义特定领域语言的构造,等等。此外,你可以在 Scala 代码中访问所有 Java 库。
你可能会想知道为什么在一本 Java 书中会有关于 Scala 的章节。这本书主要关注在 Java 中采用函数式编程风格。Scala,就像 Java 一样,支持集合的函数式处理概念(即类似流式操作)、一等函数和默认方法。但 Scala 将这些想法进一步推进,提供了比 Java 更大的功能集来支持这些想法。我们相信,您可能会对将 Scala 与 Java 采用的方法进行比较,并看到 Java 的局限性感到有趣。本章旨在阐明这一点,以满足您的求知欲。我们并不一定鼓励您采用 Scala 而不是 Java。JVM 上其他有趣的新的编程语言,如 Kotlin,也值得一看。本章的目的是开阔您的视野,了解 Java 之外还有什么。我们相信,对于一位全面发展的软件工程师来说,了解更广泛的编程语言生态系统是很重要的。
还要记住,本章的目的不是教您如何编写地道的 Scala 代码,也不是告诉您关于 Scala 的所有内容。Scala 支持许多在 Java 中不可用的特性(如模式匹配、for-comprehensions 和 implicits),我们不会讨论这些特性。相反,我们专注于比较 Java 和 Scala 的特性,以给您一个更全面的了解。您会发现,与 Java 相比,您可以在 Scala 中编写更简洁、更易读的代码,例如。
本章从 Scala 的简介开始:编写简单的程序和操作集合。接下来,我们讨论 Scala 中的函数:一等函数、闭包和柯里化。最后,我们探讨 Scala 中的类以及一个称为 traits 的特性,这是 Scala 对接口和默认方法的实现。
20.1. Scala 简介
本节简要介绍了 Scala 的基本特性,以便您对简单的 Scala 程序有一个感觉。我们从一个稍微修改过的“Hello world”示例开始,这个示例以命令式风格和函数式风格编写。然后我们查看 Scala 支持的一些数据结构——List、Set、Map、Stream、Tuple 和 Option——并将它们与 Java 进行比较。最后,我们介绍 traits,Scala 用它来替代 Java 的接口,它也支持在对象实例化时继承方法。
20.1.1. Hello beer
为了从经典的“Hello world”示例中有所改变,我们可以引入一些啤酒。你希望在屏幕上打印以下输出:
Hello 2 bottles of beer
Hello 3 bottles of beer
Hello 4 bottles of beer
Hello 5 bottles of beer
Hello 6 bottles of beer
命令式风格的 Scala
当你使用命令式风格在 Scala 中打印这个输出时,代码如下:
object Beer {
def main(args: Array[String]){
var n : Int = 2
while( n <= 6){
println(s"Hello ${n} bottles of beer") *1*
n += 1
}
}
}
- 1 字符串插值
你可以在官方 Scala 网站上找到有关如何运行此代码的信息(见docs.scala-lang.org/getting-started.html)。这个程序看起来与你在 Java 中编写的程序相似,其结构也与 Java 程序相似,包含一个名为main的方法,该方法接受一个字符串数组作为参数。(类型注解遵循s : String语法,而不是 Java 中的Strings。)main方法不返回任何值,因此在 Scala 中不需要声明返回类型,就像在 Java 中使用void时必须做的那样。
注意
在 Scala 中,通常非递归方法声明不需要显式返回类型,因为 Scala 可以为你推断类型。
在我们查看main方法体之前,我们需要讨论object声明。毕竟,在 Java 中,你必须在类内部声明main方法。object声明引入了一个单例对象,同时声明了一个名为Beer的类并实例化它。只创建了一个实例。这个例子是第一个将经典设计模式(单例设计模式)作为语言特性实现的例子,并且可以直接使用。此外,你可以将object声明内的方法视为静态声明,这就是为什么main方法的签名没有明确声明为static。
现在看看main方法的主体。这个方法看起来与 Java 方法相似,但语句不需要以分号结尾(这是可选的)。主体包含一个while循环,它递增一个可变变量n。对于n的每个新值,你都会在屏幕上打印一个字符串,使用预定义的println方法。println行展示了 Scala 的另一个特性:字符串插值,它允许你直接在字符串字面量中嵌入变量和表达式。在先前的代码中,你可以在字符串字面量s"Hello ${n} bottles of beer"中直接使用变量n。在字符串前加上插值器s提供了这种魔法。通常在 Java 中,你必须进行显式的连接,例如"Hello " + n + " bottles of beer"。
函数式风格的 Scala
但在我们整本书都在讨论函数式编程风格之后,Scala 又能提供什么呢?前面的代码可以用更函数式的方式在 Java 中写成如下形式:
public class Foo {
public static void main(String[] args) {
IntStream.rangeClosed(2, 6)
.forEach(n -> System.out.println("Hello " + n +
" bottles of beer"));
}
}
下面是这段代码在 Scala 中的样子:
object Beer {
def main(args: Array[String]){
2 to 6 foreach { n => println(s"Hello ${n} bottles of beer") }
}
}
Scala 代码与 Java 代码类似,但更简洁。首先,你可以使用表达式2 to 6创建一个范围。这里有一个很酷的点:2是一个Int类型的对象。在 Scala 中,一切都是对象;没有像 Java 中那样的原始类型概念,这使得 Scala 成为一门完整的面向对象语言。Scala 中的Int对象支持一个名为to的方法,它接受另一个Int作为参数并返回一个范围。你可以写成2.to(6)。但是只有一个参数的方法可以写成中缀形式。接下来,foreach(小写e)与 Java 中的forEach(大写E)类似。这个方法在范围上可用(你再次使用中缀表示法),它接受一个 lambda 表达式作为参数,用于对每个元素应用。lambda 表达式的语法与 Java 中的类似,但箭头是=>而不是->。^([1]) 上述代码是函数式的;你并没有像在早期示例中使用while循环那样修改变量。
¹
注意,Scala 中的术语匿名函数和closures(可以互换使用)指的是 Java 中称为 lambda 表达式的概念。
20.1.2. 基本数据结构:List、Set、Map、Tuple、Stream、Option
喝了几杯啤酒解渴之后,你感觉好吗?大多数真实程序都需要操作和存储数据,所以在本节中,你将使用 Scala 操作集合,并查看这个过程与 Java 的比较。
创建集合
在 Scala 中创建集合很简单,这得益于 Scala 对简洁性的重视。为了举例说明,以下是如何创建一个Map:
val authorsToAge = Map("Raoul" -> 23, "Mario" -> 40, "Alan" -> 53)
这段代码中有几个新特点。首先,你可以直接使用语法->创建一个Map并将键与值关联起来,这是很酷的。与 Java 中手动添加元素不同,你不需要手动添加元素:
Map<String, Integer> authorsToAge = new HashMap<>();
authorsToAge.put("Raoul", 23);
authorsToAge.put("Mario", 40);
authorsToAge.put("Alan", 53);
然而,你已经在第八章中了解到,Java 9 有几个受 Scala 启发的工厂方法,可以帮助你整理这类代码:
Map<String, Integer> authorsToAge
= Map.ofEntries(entry("Raoul", 23),
entry("Mario", 40),
entry("Alan", 53));
第二个新特点是,你可以选择不注释变量authorsToAge的类型。你可以明确写出val authorsToAge : Map[String, Int],但 Scala 可以为你推断变量的类型。(注意,代码仍然是静态检查的。所有变量在编译时都有一个给定的类型。)我们将在第二十一章中回到这个特性。第三,你使用val关键字而不是var。有什么区别?关键字val表示变量是只读的,不能重新赋值(就像 Java 中的final一样)。关键字var表示变量是可读写的。
那其他集合呢?你可以轻松创建一个List(单链表)或一个Set(无重复),如下所示:
val authors = List("Raoul", "Mario", "Alan")
val numbers = Set(1, 1, 2, 3, 5, 8)
authors变量有三个元素,而numbers变量有五个元素。
不可变与可变
需要记住的一个重要事实是,你之前创建的集合默认是不可变的,这意味着创建后不能更改。不可变性很有用,因为你知道在程序的任何时刻访问集合都会得到具有相同元素的集合。
你如何在 Scala 中更新不可变集合?回到 第十九章 中使用的术语,Scala 中的此类集合被称为 持久性。更新集合会产生一个新的集合,尽可能多地与上一个版本共享,而不会受到更改的影响(如图 19.3 和 19.4 所示)。由于这个属性,你的代码具有更少的隐式数据依赖:关于代码中哪个位置更新集合(或任何其他共享数据结构)以及何时更新的混淆更少。
以下示例演示了这个概念。向 Set 添加一个元素:
val numbers = Set(2, 5, 3);
val newNumbers = numbers + 8 *1*
println(newNumbers) *2*
println(numbers) *3*
-
1 在这里,+ 是一种将 8 添加到集合的方法,从而创建一个新的集合对象。
-
2 (2, 5, 3, 8)
-
3 (2, 5, 3)
在这个例子中,数字集合没有被修改。相反,创建了一个新的 Set,并添加了一个额外的元素。
注意,Scala 不会强迫你使用不可变集合——只是使你在代码中采用不可变性变得容易。此外,在 scala.collection.mutable 包中提供了可变版本的集合。
不可变与不可变
Java 提供了多种创建不可变集合的方法。在以下代码中,变量 newNumbers 是集合 numbers 的只读视图:
Set<Integer> numbers = new HashSet<>();
Set<Integer> newNumbers = Collections.unmodifiableSet(numbers);
这段代码意味着你将无法通过 newNumbers 变量添加新元素。但是,不可变集合是可变集合的包装器,因此你可以通过访问 numbers 变量来添加元素。
相比之下,不可变集合保证没有任何东西可以更改集合,无论指向它的变量有多少。
我们在 第十九章 中解释了如何创建持久数据结构:在修改时保留其先前版本的不可变数据结构。任何修改都会产生一个新的更新结构。
使用集合
现在你已经看到了如何创建集合,你需要知道你可以用它们做什么。Scala 中的集合支持类似于 Java Stream API 中的操作。你可能会在以下示例中认出 filter 和 map,如图 20.1 所示:
val fileLines = Source.fromFile("data.txt").getLines.toList()
val linesLongUpper
= fileLines.filter(l => l.length() > 10)
.map(l => l.toUpperCase())
图 20.1. Scala 的 List 的流式操作

不要担心第一行,它将文件转换为包含文件中行的字符串列表(类似于 Java 中的 Files.readAllLines 提供的功能)。第二行创建了一个包含两个操作的管道:
-
一个
filter操作,只选择长度大于 10 的行 -
一个将长行转换为大写的
map操作
这段代码也可以写成以下形式:
val linesLongUpper
= fileLines filter (_.length() > 10) map(_.toUpperCase())
你使用中缀表示法以及下划线字符(_),它是一个占位符,与任何参数位置匹配。在这种情况下,你可以将 _.length() 读取为 l => l.length()。在传递给 filter 和 map 的函数中,下划线绑定要处理的行参数。
Scala 的集合 API 中还有许多其他有用的操作。我们建议查看 Scala 文档以获得一些想法(docs.scala-lang.org/overviews/collections/introduction.html)。请注意,此 API 比流的 API(包括支持解包操作,这允许你组合两个列表的元素)略丰富,因此查看它绝对会带来一些编程习惯。这些习惯也可能在未来版本的 Java 的 Streams API 中出现。
最后,记住在 Java 中,你可以通过在 Stream 上调用 parallel 来请求并行执行管道。Scala 也有类似的技巧。你只需要使用 par 方法:
val linesLongUpper
= fileLines.par filter (_.length() > 10) map(_.toUpperCase())
元组
这一节探讨了在 Java 中通常非常冗长的另一个特性:元组。你可能想使用元组来按姓名和电话号码(在这里是简单的对)分组人员,而不需要声明一个专门的新类并为它实例化一个对象:("Raoul", "+44 7700 700042")、("Alan", "+44 7700 700314"),等等。
不幸的是,Java 不提供对元组的支持,所以你必须创建自己的数据结构。以下是一个简单的 Pair 类:
public class Pair<X, Y> {
public final X x;
public final Y y;
public Pair(X x, Y y){
this.x = x;
this.y = y;
}
}
当然,你还需要显式地实例化对:
Pair<String, String> raoul = new Pair<>("Raoul", "+44 7700 700042");
Pair<String, String> alan = new Pair<>("Alan", "+44 7700 700314");
好吧,但是三元组和任意大小的元组怎么办?为每个元组大小定义一个新的类既繁琐又最终会影响你程序的易读性和可维护性。
Scala 提供了 元组字面量,允许你通过简单的语法糖和正常的数学符号创建元组,如下所示:
val raoul = ("Raoul", "+44 7700 700042")
val alan = ("Alan", "+44 7700 700314")
Scala 支持任意大小的^([2])元组,所以以下都是可能的:
²
元组最多有 22 个元素。
val book = (2018 "Modern Java in Action", "Manning") *1*
val numbers = (42, 1337, 0, 3, 14) *2*
-
1 一个类型为 (Int, String, String) 的元组
-
2 一个类型为 (Int, Int, Int, Int, Int) 的元组
你可以通过使用访问器 _1、_2(从 1 开始)来通过位置访问元组的元素,就像这个例子一样:
println(book._1) *1*
println(numbers._4) *2*
-
1 打印 2018
-
2 打印 3
难道这个例子不像你在 Java 中需要写的那么好吗?好消息是,关于在 Java 的未来版本中引入元组字面量的讨论正在进行。(有关 Java 中可能的新功能的更多讨论,请参阅第二十一章)
流
我们迄今为止所描述的集合——List、Set、Map 和 Tuple——都是立即评估的(即立即)。到现在为止,你知道 Java 中的流是按需评估的(即惰性)。你在 第五章 中看到,由于这个特性,流可以表示一个无限序列而不会溢出内存。
Scala 还提供了一个相应的惰性评估数据结构,称为 Stream。但 Scala 中的 Stream 提供的功能比 Java 中的更多。Scala 中的 Stream 会记住计算过的值,以便可以访问前面的元素。此外,Stream 是索引的,因此可以通过索引访问元素,就像列表一样。请注意,这些额外属性的权衡是 Stream 相比 Java 的 Stream 在内存效率上更低,因为能够引用前面的元素意味着元素需要被记住(缓存)。
Option
你还会熟悉另一种数据结构 Option——Scala 对 Java 的 Optional 的实现,我们已在 第十一章 中讨论过。我们曾论证,在可能的情况下,你应该使用 Optional 来设计更好的 API,这样用户通过阅读方法的签名就可以知道他们是否可以期待一个可选值。在可能的情况下,你应该使用这种数据结构而不是 null 来防止 null-pointer 异常。
你在 第十一章 中看到,你可以使用 Optional 来返回一个大于某个最小年龄的人的保险名称,如下所示:
public String getCarInsuranceName(Optional<Person> person, int minAge) {
return person.filter(p -> p.getAge() >= minAge)
.flatMap(Person::getCar)
.flatMap(Car::getInsurance)
.map(Insurance::getName)
.orElse("Unknown");
}
在 Scala 中,你可以像使用 Optional 一样使用 Option:
def getCarInsuranceName(person: Option[Person], minAge: Int) =
person.filter(_.age >= minAge)
.flatMap(_.car)
.flatMap(_.insurance)
.map(_.name)
.getOrElse("Unknown")
你可以识别出相同的结构和除了 getOrElse 之外的方法名称,getOrElse 是 Java 中的 orElse 的等价物。你看,在这本书的整个过程中,你已经学习了可以直接应用于其他编程语言的新概念!不幸的是,由于与 Java 的兼容性原因,Scala 中也存在 null,但其使用被高度不建议。
20.2. 函数
Scala 函数可以被视为执行任务的指令序列。这些函数对于抽象行为非常有用,并且是函数式编程的基石。
在 Java 中,你熟悉 方法:与类关联的函数。你也看到了 lambda 表达式,可以被认为是匿名函数。Scala 提供了比 Java 更丰富的功能来支持函数,我们将在本节中探讨这些功能。Scala 提供以下功能:
-
函数类型——表示 Java 函数描述符(即表示在函数式接口中声明的抽象方法的签名)的语法糖,我们在 第三章 中描述过
-
没有对非局部变量进行写入限制的匿名函数,这是 Java 的 lambda 表达式所不具备的
-
支持 柯里化,这意味着将接受多个参数的函数分解为一系列函数,每个函数接受一些参数
20.2.1. 首类函数在 Scala 中
Scala 中的函数是 首类值,这意味着它们可以作为参数传递,作为结果返回,并存储在变量中,就像 Integer 和 String 这样的值一样。正如我们在前面的章节中向您展示的那样,Java 中的方法引用和 lambda 表达式也可以看作是首类函数。
下面是一个 Scala 中首类函数如何工作的示例。假设你有一个表示你收到的推文的字符串列表。你想要根据不同的标准过滤这个列表,比如提到单词 Java 的推文或特定短长度的推文。你可以将这些两个标准表示为 谓词(返回 Boolean 的函数):
def isJavaMentioned(tweet: String) : Boolean = tweet.contains("Java")
def isShortTweet(tweet: String) : Boolean = tweet.length() < 20
在 Scala 中,你可以直接将这些方法传递给内置的 filter,如下所示(正如你可以在 Java 中使用方法引用传递它们一样):
val tweets = List(
"I love the new features in Java",
"How's it going?",
"An SQL query walks into a bar, sees two tables and says 'Can I join you?'"
)
tweets.filter(isJavaMentioned).foreach(println)
tweets.filter(isShortTweet).foreach(println)
现在检查内置方法 filter 的签名:
def filterT => Boolean): List[T]
你可能会想知道参数 p 的类型 (T) => Boolean 是什么意思,因为在 Java 中,你可能会期望一个函数式接口。这种 Scala 语法(目前)在 Java 中不可用,但它描述了一种 函数类型。在这里,该类型表示一个接受类型 T 的对象并返回 Boolean 的函数。在 Java 中,这种类型可以表示为 Predicate<T> 或 Function<T, Boolean>,它与 isJavaMentioned 和 isShortTweet 方法的签名相同,因此你可以将它们作为 filter 的参数传递。Java 语言的开发者决定不引入类似语法来保持语言与先前版本的一致性。(在语言的新版本中引入过多的新语法被视为增加了过多的认知负担。)
20.2.2. 匿名函数和闭包
Scala 也支持匿名函数,其语法与 lambda 表达式类似。在下面的示例中,你可以将一个匿名函数赋值给名为 isLong-Tweet 的变量,该函数检查给定的推文是否很长:
val isLongTweet : String => Boolean *1*
= (tweet : String) => tweet.length() > 60 *2*
-
1 一个从 String 到 Boolean 的函数类型变量
-
2 一个匿名函数
在 Java 中,lambda 表达式允许你创建一个函数式接口的实例。Scala 有一个类似的机制。前面的代码是声明一个类型为 scala.Function1 的匿名类(一个单参数函数)的语法糖,它提供了 apply 方法的实现:
val isLongTweet : String => Boolean
= new Function1[String, Boolean] {
def apply(tweet: String): Boolean = tweet.length() > 60
}
因为变量 isLongTweet 存储了一个 Function1 类型的对象,所以你可以调用 apply 方法,这可以看作是调用函数:
isLongTweet.apply("A very short tweet") *1*
- 1 返回 false
在 Java 中,你可以这样做:
Function<String, Boolean> isLongTweet = (String s) -> s.length() > 60;
boolean long = islongTweet.apply("A very short tweet");
为了让你可以使用 lambda 表达式,Java 提供了几个内置的函数式接口,如Predicate、Function和Consumer。Scala 提供特质(你现在可以将其视为接口)来实现相同的功能:从Function0(具有 0 个参数和返回结果的函数)到Function22(具有 22 个参数的函数),所有这些都定义了apply方法。
Scala 中另一个酷炫的技巧允许你通过类似于函数调用的语法糖隐式调用apply方法:
isLongTweet("A very short tweet") *1*
- 1 返回 false
编译器自动将调用f(a)转换为f.apply(a),更一般地,将调用f(a1, ..., an)转换为f.apply(a1, ..., an),如果f是一个支持apply方法的对象。(注意,apply可以有任意数量的参数。)
闭包
在第三章中,我们讨论了 Java 中的 lambda 表达式是否构成闭包。闭包是函数的一个实例,它可以无限制地引用该函数的非局部变量。但是 Java 中的 lambda 表达式有一个限制:它们不能修改 lambda 定义的方法中局部变量的内容。这些变量必须隐式地声明为final。有助于思考的是,lambda 表达式是封闭在值上,而不是变量上。
相比之下,Scala 中的匿名函数可以捕获变量本身,而不是变量当前所引用的值。在 Scala 中以下情况是可能的:
def main(args: Array[String]) {
var count = 0
val inc = () => count+=1 *1*
inc()
println(count) *2*
inc()
println(count) *3*
}
-
1 捕获并增加计数的闭包
-
2 打印 1
-
3 打印 2
但在 Java 中,以下结果会导致编译器错误,因为count被隐式地强制为final:
public static void main(String[] args) {
int count = 0;
Runnable inc = () -> count+=1; *1*
inc.run();
System.out.println(count);
inc.run();
}
- 1 错误:计数必须是 final 或实际上是 final 的。
我们在第七章、第十八章和第十九章中争论说,你应该尽可能避免修改,以使你的程序更容易维护和并行化,因此仅在绝对必要时使用此功能。
20.2.3. 柯里化
在第十九章中,我们描述了一种称为柯里化的技术,其中两个参数(例如x和y)的函数f被视为一个参数的函数g,它返回一个也是参数为单个参数的函数。这个定义可以推广到具有多个参数的函数,产生多个参数为单个参数的函数。换句话说,你可以将接受多个参数的函数分解为一系列函数,每个函数接受参数的子集。Scala 提供了一个构造,使得可以轻松地对现有函数进行柯里化。
要理解 Scala 带来了什么,首先回顾一下 Java 中的一个示例。你可以定义一个简单的乘法方法来乘以两个整数:
static int multiply(int x, int y) {
return x * y;
}
int r = multiply(2, 10);
但这个定义要求将所有参数传递给它。你可以通过使其返回另一个函数来手动分解multiply方法:
static Function<Integer, Integer> multiplyCurry(int x) {
return (Integer y) -> x * y;
}
由multiplyCurry返回的函数捕获x的值并将其与它的参数y相乘,返回一个Integer。因此,你可以在map中使用multiplyCurry来将每个元素乘以 2:
Stream.of(1, 3, 5, 7)
.map(multiplyCurry(2))
.forEach(System.out::println);
这段代码产生结果 2,6,10,14。这段代码之所以能工作,是因为map期望一个Function作为参数,而multiplyCurry返回一个Function。
在 Java 中手动拆分函数以创建 curried 形式有点繁琐,尤其是当函数有多个参数时。Scala 有一种特殊的语法可以自动执行这个操作。你可以定义一个普通的multiply方法如下:
def multiply(x : Int, y: Int) = x * y
val r = multiply(2, 10)
这里是 curried 形式的例子:
def multiplyCurry(x :Int)(y : Int) = x * y *1*
val r = multiplyCurry(2)(10) *2*
-
1 定义 curried 函数
-
2 调用 curried 函数
当你使用(x: Int)(y: Int)语法时,multiplyCurry方法接受两个参数列表,每个列表有一个Int参数。相比之下,multiply接受一个包含两个Int参数的参数列表。当你调用multiplyCurry时会发生什么?multiplyCurry的第一个调用使用单个Int(参数x),即multiplyCurry(2),返回另一个函数,该函数接受一个参数y并将其与捕获的x的值(在这里是值 2)相乘。我们说这个函数是部分应用的,如第十九章中解释的那样,因为不是所有参数都提供了。第二次调用将x和y相乘。你可以将第一次调用multiplyCurry存储在一个变量中并重用它,如下所示:
val multiplyByTwo : Int => Int = multiplyCurry(2)
val r = multiplyByTwo(10) *1*
- 1 20
与 Java 相比,在 Scala 中,你不需要像上一个例子那样手动提供函数的 curried 形式。Scala 提供了一个方便的函数定义语法,用来表示一个函数有多个 curried 参数列表。
20.3. 类和特质
在本节中,我们来看 Java 中的类和接口与 Scala 中的类和接口如何比较。这两个构造是设计应用程序的关键。你会发现 Scala 的类和接口可以提供比 Java 更多的灵活性。
20.3.1. Scala 类减少冗余
因为 Scala 是一种完全面向对象的语言,所以你可以创建类并实例化它们来生成对象。在其最基本的形式中,声明和实例化类的语法与 Java 相似。下面是如何声明一个Hello类的示例:
class Hello {
def sayThankYou(){
println("Thanks for reading our book")
}
}
val h = new Hello()
h.sayThankYou()
Getter 和 setter
当你有一个带有字段的类时,Scala 变得更加有趣。你是否遇到过只定义了一组字段而没有声明长列表的 getter、setter 和适当构造函数的 Java 类?多么痛苦!此外,你经常看到对每个方法实现的测试。在企业 Java 应用程序中,通常会有大量代码用于此类。考虑这个简单的Student类:
public class Student {
private String name;
private int id;
public Student(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
你必须手动定义一个构造函数来初始化所有字段,两个 getter 和两个 setter。一个简单的类现在有超过 20 行代码。几个 IDE(集成开发环境)和工具可以帮助你生成此代码,但你的代码库仍然必须处理与实际业务逻辑相比不太有用的大量额外代码。
在 Scala 中,构造函数、getter 和 setter 可以隐式生成,这导致代码更加简洁:
class Student(var name: String, var id: Int)
val s = new Student("Raoul", 1) *1*
println(s.name) *2*
s.id = 1337 *3*
println(s.id) *4*
-
1 初始化一个 Student 对象。
-
2 获取名称并打印 Raoul。
-
3 设置 id。
-
4 打印 1337。
在 Java 中,你可以通过定义公共字段来获得类似的行为,但你仍然需要显式地定义构造函数。Scala 类可以为你节省样板代码。
20.3.2. Scala 特质与 Java 接口
Scala 还有一个用于抽象的有用特性,称为特质,它是 Scala 对 Java 接口的替代。特质可以定义抽象方法和具有默认实现的方法。特质也可以像 Java 中的接口一样多重继承,因此你可以将它们视为类似于支持默认方法的 Java 接口。特质还可以包含字段,如抽象类,这是 Java 接口不支持的功能。特质像抽象类吗?不,因为与抽象类不同,特质可以多重继承。Java 始终具有类型的多重继承,因为一个类可以实现多个接口。Java 8 通过默认方法引入了行为的多重继承,但仍然不允许状态的多重继承——这是 Scala 特质允许的。
要在 Scala 中看到特质的样子,定义一个名为Sized的特质,其中包含一个名为size的可变字段和一个名为isEmpty的方法,具有默认实现:
trait Sized {
var size : Int = 0 *1*
def isEmpty() = size == 0 *2*
}
-
1 一个名为“大小”的字段
-
2 一个具有默认实现的方法 isEmpty
你可以在声明时与一个类组合此代码,例如一个始终具有大小 0 的Empty类:
class Empty extends Sized *1*
println(new Empty().isEmpty()) *2*
-
1 从特质 Sized 继承的类
-
2 打印 true
有趣的是,与 Java 接口相比,特质可以在对象实例化时进行组合(但这个操作仍然是一个编译时操作)。你可以创建一个Box类,并决定一个特定的实例应该支持由特质Sized定义的操作,如下所示:
class Box
val b1 = new Box() with Sized *1*
println(b1.isEmpty()) *2*
val b2 = new Box()
b2.isEmpty() *3*
-
1 在对象实例化时组合特质
-
2 打印 true
-
3 编译错误:Box 类声明没有从 Sized 继承。
如果继承了多个特质,声明具有相同签名的方法或具有相同名称的字段会发生什么?Scala 提供了类似于应用于默认方法的限制规则(第十三章)。
摘要
-
Java 和 Scala 将面向对象和函数式编程特性结合到一个编程语言中;两者都运行在 JVM 上,并且在很大程度上可以互操作。
-
Scala 支持类似于 Java 中的集合抽象,如
List、Set、Map、Stream、Option,但也支持元组。 -
Scala 提供了比 Java 更丰富的功能,支持比 Java 更多的函数。这些功能包括函数类型、无限制访问局部变量的闭包,以及内置的柯里化形式。
-
Scala 中的类可以提供隐式构造函数、获取器和设置器。
-
Scala 支持特质,这些特质是包含字段和默认方法的接口。
第二十一章。结论和 Java 的未来方向
本章涵盖
-
新的 Java 8 特性及其对编程风格的影响
-
新的 Java 9 模块系统
-
新的每六个月一次的 Java 增量发布生命周期
-
构成 Java 10 的第一个增量发布
-
一些你可能会在 Java 的未来版本中看到的实现想法
在这本书中,我们涵盖了大量的内容,我们希望你觉得你已经准备好开始在自己的代码中使用新的 Java 8 和 9 特性,也许是在我们的示例和测验的基础上。在本章中,我们回顾了学习 Java 8 的旅程,以及向函数式编程风格的温和推动,以及 Java 9 引入的新模块化能力和其他一些改进的优势。你还了解了 Java 10 中包含的内容。此外,我们还推测了在 Java 9、10、11 和 12 之后的未来增强和新特性可能是什么。
21.1. Java 8 特性回顾
要帮助你理解 Java 8 作为一种实用、有用的语言的好方法,就是依次回顾其特性。我们不想只是简单地列出它们,而是希望将它们呈现为相互关联,以便你不仅能够理解它们作为一组特性,还能理解它们是 Java 8 高级语言设计的整体概述。在本章的另一个目标中,我们强调大多数 Java 8 的新特性是如何促进 Java 中函数式编程的。记住,支持函数式编程并不是一个任性的设计选择,而是一种以两个趋势为中心的有意识的设计策略,我们将它们视为模型中的气候变化,这两个趋势在第一章中有所描述:
-
由于硅技术的原因,摩尔定律每年提供的额外晶体管不再转化为单个 CPU 核心的更高时钟速度,现在需要利用多核处理器的强大功能。简单来说,让你的代码运行得更快需要并行代码。
-
越来越倾向于使用声明性风格简洁地处理数据集合,例如,从某些数据源中提取所有符合给定标准的数据,并对结果应用某些操作(总结它或创建一个结果集合以供后续处理)。这种风格与使用不可变对象和集合相关联,然后对这些对象和集合进行处理,以产生进一步的不变值。
无论是哪种动机,传统的面向对象、命令式方法都无法有效支持。这种方法以修改字段和应用迭代器为中心。在一个核心上修改数据,然后在另一个核心上读取它,其成本出奇地高,更不用说它还引入了易出错的锁定需求。同样,当你专注于迭代和修改现有对象时,流式编程习惯可能会感觉陌生。但这两个趋势得到了函数式编程思想的支撑,这也解释了为什么 Java 8 的中心力从你从 Java 中期待的方向有所偏移。
本章从宏观统一的角度回顾了你在本书中学到的内容,并展示了在新环境下一切是如何相互融合的。
21.1.1. 行为参数化(lambda 和方法引用)
要编写一个可重用的方法,如filter,你需要指定其参数为过滤标准的描述。尽管 Java 专家可以在 Java 的早期版本中通过将过滤标准包装为类内的方法并传递该类的实例来完成这项任务,但这种解决方案不适合通用使用,因为它编写和维护起来过于繁琐。
正如你在第二章和第三章中发现的那样,Java 8 提供了一种从函数式编程中借用来的方法,可以将一段代码传递给方法。Java 方便地提供了两种变体:
-
传递一个 lambda(一段一次性代码),例如
apple -> apple.getWeight() > 150 -
传递一个方法引用到现有方法,例如
Apple::isHeavy
这些值具有Function<T, R>、Predicate<T>和BiFunction<T, U, R>等类型,接收者可以通过使用apply、test等方法来执行它们。这些类型被称为函数式接口,并且有一个抽象方法,正如你在第三章中学到的。lambda 本身可能看起来是一个相当狭窄的概念,但 Java 8 在许多新的 Streams API 中使用它们的方式,使它们成为了 Java 的中心。
21.1.2. 流
Java 中的集合类,连同迭代器和for-each构造,长期以来一直为程序员提供了荣誉服务。Java 8 的设计师们本可以轻松地将filter和map等方法添加到集合中,利用 lambda 表达式来表示类似数据库的查询。然而,他们添加了一个新的 Streams API,这是第四章到第七章的主题,值得我们停下来思考一下原因。
Collections 有什么问题需要它们被类似Streams 的概念所取代或增强?我们可以这样总结:如果你有一个大集合,并且对其应用三个操作(可能将集合中的对象映射到求两个字段的和,过滤满足某些标准的和,然后对结果进行排序),你将对集合进行三次单独的遍历。相反,Streams API 会懒惰地将这些操作组合成一个管道,并执行一次流遍历,完成所有操作。这个过程对于大数据集来说效率更高,并且由于内存缓存等原因,数据集越大,最小化遍历次数就越重要。
另一个同样重要的原因是并行处理元素,这对于高效利用多核 CPU 至关重要。Streams,特别是parallel方法,允许将流标记为适合并行处理。回想一下,并行性和可变状态不太搭配,所以核心函数式概念(无副作用的操作和用 lambda 表达式和方法引用参数化的方法,允许内部迭代而不是外部迭代,如第四章所述)是利用map、filter等来并行利用流的关键。
在下一节中,我们将探讨这些想法,即我们以流的形式引入的,在CompletableFuture的设计中有一个直接的对应物。
21.1.3. CompletableFuture
Java 自 Java 5 以来就提供了Future接口。Futures 对于利用多核很有用,因为它们允许将任务在另一个线程或核心上生成,并允许生成任务继续执行。当生成任务需要结果时,它可以使用get方法等待Future完成(产生其值)。
第十六章解释了 Java 8 的CompletableFuture对Future的实现,这再次利用了 lambda 表达式。一个有用但稍微不够精确的格言是“CompletableFuture是Future的,就像Stream是Collection的。”为了比较:
-
Stream允许你将操作管道化,并通过map、filter等提供行为参数化,消除了在使用迭代器时通常必须编写的样板代码。 -
CompletableFuture提供了thenCompose、thenCombine和allOf等操作,这些操作以函数式编程风格的简洁编码提供了涉及Futures 的常见设计模式,并让你避免类似的命令式样板代码。
这种操作风格,尽管是在一个更简单的场景中,也适用于 Java 8 对Optional的操作,我们将在下一节重新探讨这一点。
21.1.4. Optional
Java 8 库提供了 Optional<T> 类,允许你的代码指定一个值是类型 T 的有效值或由静态方法 Optional.empty 返回的缺失值。这个特性对于程序理解和文档来说非常棒。它提供了一个具有显式缺失值的数据类型,而不是之前使用 null 指针来指示缺失值,程序员永远无法确定这是一个计划中的缺失值还是由于早期错误计算导致的意外 null。
如第十一章所述 chapter 11,如果一致地使用 Optional<T>,程序就不应该产生 NullPointerExceptions。再次强调,你可以将这种情况视为一个孤立的例子,与 Java 8 的其他部分无关,并问自己,“从一种缺失值形式转换为另一种形式如何帮助我编写程序?”仔细检查后发现,Optional<T> 类提供了 map、filter 和 ifPresent 方法。这些方法的行为与 Streams 类中相应的方法类似,可以用来链式计算,再次以函数式风格进行,由库而不是用户代码来完成缺失值的测试。Optional<T> 中内部测试与外部测试的选择与 Streams 库在用户代码中如何进行内部迭代与外部迭代直接相关。Java 9 为 Optional API 添加了各种新方法,包括 stream()、or() 和 ifPresentOrElse()。
21.1.5. 流 API
Java 9 标准化了反应式流和基于反应式拉取的背压协议,这是一种旨在防止慢速消费者被一个或多个较快的生产者压倒的机制。Flow API 包含四个核心接口,库实现可以支持这些接口以提供更广泛的兼容性:Publisher、Subscriber、Subscription 和 Processor。
本节最后一个主题不是关于函数式编程风格,而是关于 Java 8 对向上兼容的库扩展的支持,这种支持是由软件工程的需求驱动的。
21.1.6. 默认方法
Java 8 有其他添加的功能,但它们都没有特别影响任何程序的表达能力。但有一个对库设计者有帮助的功能,允许向接口添加默认方法。在 Java 8 之前,接口定义了方法签名;现在它们还可以为接口设计者怀疑不是所有客户端都希望明确提供的那些方法提供默认实现。
这个工具是库设计者的一个伟大新工具,因为它使他们能够通过不需要要求所有客户端(实现此接口的类)添加代码来定义此方法,从而增强接口的新操作。因此,默认方法也与库的用户相关,因为它们保护用户免受未来接口更改的影响(参见第十三章 chapter 13)。
21.2. Java 9 模块系统
Java 8 增加了大量的新特性(例如接口上的 lambdas 和默认方法)以及原生 API 中的新有用类,如 Stream 和 CompletableFuture。Java 9 没有引入任何新的语言特性,但主要是在 Java 8 中开始的工作进行了打磨,通过在 Stream 上添加 takeWhile 和 dropWhile 以及在 CompletableFuture 上添加 completeOn-Timeout 等一些有用的方法来完善那里引入的类。实际上,Java 9 的主要重点是引入新的模块系统。这个新系统除了新的 module-info.java 文件外,不会影响语言,但仍然从架构的角度改善了设计和编写应用程序的方式,明确标记了子部分的边界,并定义了它们如何交互。
不幸的是,Java 9 对 Java 的向后兼容性造成了比任何其他版本更大的损害(尝试用 Java 9 编译一个大的 Java 8 代码库)。但这个代价是值得的,因为适当的模块化带来的好处。一个原因是确保跨包之间有更好的和更强的封装。实际上,Java 可见性修饰符被设计用来定义方法和类之间的封装,但在包之间,只有一种可见性是可能的:public。这种缺乏使得正确地模块化一个系统变得困难,特别是指定模块中哪些部分是为公共使用设计的,哪些部分是应该隐藏给其他模块和应用的实现细节。
第二个原因,它是跨包封装弱化的直接后果,即没有适当的模块系统,无法避免暴露与同一环境中所有代码运行的安全相关的功能。恶意代码可能访问模块的关键部分,从而绕过其中编码的所有安全措施。
最后,新的 Java 模块系统使得 Java 运行时可以被分割成更小的部分,因此你可以只使用你应用所必需的部分。如果你的新 Java 项目需要 CORBA,这可能会令人惊讶,但它很可能会包含在你所有的 Java 应用中。尽管这一举措对于传统尺寸的计算设备可能影响有限,但对于嵌入式设备以及你的 Java 应用越来越多地在容器化环境中运行的情况来说,它非常重要。换句话说,Java 模块系统是一个使能器,它允许在物联网(IoT)应用和云中使用 Java 运行时。
如在第十四章讨论中所述,Java 模块系统通过引入一种语言级机制来模块化你的大型系统和 Java 运行时本身,从而解决了这些问题。Java 模块系统的优势包括以下方面:
-
可靠的配置— 明确声明模块需求可以在构建时而不是在运行时(在缺少、冲突或循环依赖的情况下)早期检测到错误。
-
强封装— Java 模块系统使模块能够仅导出特定包,然后通过内部实现将每个模块的公共和可访问边界分开。
-
提高安全性— 不允许用户调用模块的特定部分,这使得攻击者更难规避其中实现的安全控制。
-
更好的性能— 当一个类可以引用少量组件而不是由运行时加载的任何其他类时,许多优化技术可以更有效地发挥作用。
-
可伸缩性— Java 模块系统允许将 Java SE 平台分解为只包含运行应用程序所需特性的更小部分。
通常,模块化是一个难题,它不太可能像 Java 8 中的 lambda 表达式那样成为 Java 9 快速采用的驱动因素。然而,我们认为,从长远来看,你在模块化应用程序上投入的努力将因易于维护而得到回报。
到目前为止,我们已经总结了本书中涵盖的 Java 8 和 9 的概念。在下一节中,我们将转向更棘手的话题,即 Java 9 之外可能存在于 Java 管道中的未来增强功能和重大特性。
21.3. Java 10 局部变量类型推断
在 Java 中,每次你引入一个变量或方法时,你都会同时给出它的类型。例如,这个例子
double convertUSDToGBP(double money) { ExchangeRate e = ...; }
包含三种类型,这些类型给出了convertUSDToGBP的结果类型、其参数money的类型以及其局部变量e的类型。随着时间的推移,这一要求已经以两种方式放宽。首先,当上下文确定类型参数时,你可以在表达式中省略泛型的类型参数。这个例子
Map<String, List<String>> myMap = new HashMap<String, List<String>>();
可以从 Java 7 开始简写为以下形式:
Map<String, List<String>> myMap = new HashMap<>();
第二,为了将上下文确定的类型传播到表达式中相同的思想,一个 lambda 表达式如下
Function<Integer, Boolean> p = (Integer x) -> *booleanExpression;*
可以缩短为
Function<Integer, Boolean> p = x -> *booleanExpression;*
通过省略类型。在两种情况下,编译器都会推断省略的类型。
当类型由单个标识符组成时,类型推断有几个优点,主要优点是在用一种类型替换另一种类型时减少了编辑工作量。但随着类型大小的增加,由更进一步的泛型类型参数化的泛型,类型推断有助于提高可读性.^([1]) Scala 和 C#语言允许在局部变量初始化声明中将类型替换为(受限的)关键字var;编译器会从右侧填充适当类型。前面用 Java 语法展示的myMap声明可以表示如下:
¹
当然,类型推断必须合理。类型推断在只有一个方式,或者一个容易记录的方式,来重新创建用户省略的类型时效果最好。如果系统推断出的类型与用户所想的类型不同,就会出现问题。因此,良好的类型推断设计会在可以推断出两个不可比较的类型时产生错误;启发式方法可能会给人一种随机选择错误类型的印象。
var myMap = new HashMap<String, List<String>>();
这个想法被称为局部变量类型推断,并包含在 Java 10 中。
然而,有一些小的担忧。给定一个继承自Vehicle类的Car类,声明
var x = new Car();
隐式声明x的类型为Car或Vehicle(甚至Object)?在这种情况下,一个简单的解释,即缺失的类型是初始化器的类型(在这里,是Car)是完全可以理解的。Java 10 正式化了这一事实,同时也指出var不能在没有初始化器的情况下使用。
21.4. Java 的未来是什么?
我们在本节中讨论的一些点在 JDK 增强提案网站上讨论得更为详细openjdk.java.net/jeps/0。在这里,我们仔细解释了为什么看似合理的想法有细微的困难或与现有特征的交互,这阻碍了它们直接被纳入 Java。
21.4.1. 声明位置方差
Java 支持通配符作为灵活的机制,允许泛型(通常称为使用位置方差)进行子类型化。这种支持使得以下赋值有效:
List<? extends Number> numbers = new ArrayList<Integer>();
但以下省略"? extends"的赋值会产生编译时错误:
List<Number> numbers = new ArrayList<Integer>(); *1*
- 1 不兼容的类型
许多编程语言,如 C#和 Scala,支持一种称为声明位置方差机制的不同方差机制。这些语言允许程序员在定义泛型类时指定方差。这个特性对于本质上可变的类很有用。例如,Iterator本质上是一致的,而Comparator本质上是对抗的,当你使用它们时,你不需要考虑? extends或? super。将声明位置方差添加到 Java 中会有用,因为这些规范出现在类的声明处。因此,这个添加将减少程序员的认知负担。注意,在撰写时(2018 年),一个 JDK 增强提案将允许在 Java 的后续版本中默认启用声明位置方差(openjdk.java.net/jeps/300)。
21.4.2. 模式匹配
正如我们在第十九章中讨论的,函数式语言通常提供某种形式的模式匹配——一种增强的switch形式——你可以问,“这个值是否是给定类的实例?”(可选地)递归地询问其字段是否具有某些值。在 Java 中,简单的 case 测试看起来像这样:
if (op instanceof BinOp){
Expr e = ((BinOp) op).getLeft();
}
注意,即使在 op 引用的对象显然属于该类型的情况下,你仍然需要在类型转换表达式中重复 BinOp 类型。
当然,你可能有一个复杂的表达式层次结构需要处理,使用多个 if 条件链的方法会使你的代码更加冗长。值得提醒的是,传统的面向对象设计不鼓励使用 switch,并鼓励使用如访问者模式之类的模式,其中数据类型相关的控制流是通过方法调度而不是通过 switch 来实现的。在编程语言的另一端,在函数式编程风格中,对数据类型值的模式匹配通常是设计程序最方便的方式。
将 Scala 风格的模式匹配完全泛化到 Java 似乎是一项大工程,但鉴于最近对 switch 的泛化,允许 String 使用,你可以想象一个更温和的语法扩展,允许 switch 通过使用 instanceof 语法来操作对象。实际上,一个 JDK 增强提案探索了将模式匹配作为 Java 的语言特性(openjdk.java.net/jeps/305)。以下示例回顾了第十九章中的例子,并假设有一个类 Expr,它被继承为 BinOp 和 Number:
switch (someExpr) {
case (op instanceof BinOp):
doSomething(op.getOpName(), op.getLeft(), op.getRight());
case (n instanceof Number):
dealWithLeafNode(n.getValue());
default:
defaultAction(someExpr);
}
注意以下几点。首先,这段代码从模式匹配中借鉴了这样的想法:在 case (op instanceof BinOp): 中,op 是一个新的局部变量(类型为 BinOp),它绑定到与 someExpr 相同的值。同样,在 Number 情况下,n 成为一个 Number 类型的变量。在默认情况下,没有变量被绑定。与使用一系列 if-then-else 语句和子类型转换相比,这个提议消除了大量的样板代码。一个传统的面向对象设计师可能会争辩说,这样的数据类型分发代码最好用子类型中重写的访问者风格方法来表示,但从函数式编程的角度来看,这种解决方案导致相关代码分散在几个类定义中。这种经典的设计二分法在文献中被讨论为表达式问题.^([2])
²
对于更完整的解释,请参阅
en.wikipedia.org/wiki/Expression_problem。
21.4.3. 泛型的更丰富形式
本节讨论了 Java 泛型的两个限制,并探讨了一种可能的演变来减轻它们。
实体化泛型
当 Java 5 引入泛型时,它们必须与现有的 JVM 向后兼容。为此,ArrayList<String>和ArrayList<Integer>的运行时表示是相同的。这种模型被称为泛型多态的擦除模型。这个选择与某些小的运行时成本相关联,但对于程序员来说,最显著的影响是泛型类型的参数只能是对象,而不能是原始类型。假设 Java 允许,比如说,ArrayList<int>。那么你可以在堆上分配一个ArrayList对象,包含一个原始值,如int 42,但是ArrayList容器不会包含任何指示它是否包含一个Object值,如String,或者一个原始int值,如 42。
在某种程度上,这种状况似乎是无害的。如果你从一个ArrayList<int>中获取一个原始的 42,并从一个ArrayList<String>中获取一个String对象"abc",你为什么还会担心ArrayList容器是不可区分的呢?不幸的是,答案是垃圾回收,因为ArrayList内容缺少运行时类型信息,会导致 JVM 无法确定你的ArrayList的第 13 个元素是一个String引用(将被跟踪并标记为垃圾回收使用)还是一个int原始值(绝对不是要跟踪的)。
在 C#语言中,ArrayList<String>、ArrayList<Integer>和ArrayList<int>的运行时表示在原则上不同。即使这些表示相同,运行时也会保留足够类型信息,以便例如垃圾回收确定一个字段是引用还是原始值。这种模型被称为泛型多态的实体化模型,或者更简单地说,实体化泛型。单词实体化意味着“使原本隐含的东西变得明确”。
实体化泛型显然是可取的,因为它们能够实现原始类型及其对应对象类型的更全面统一——你将在以下章节中看到这可能会成为问题。对于 Java 来说,主要困难是向后兼容性,无论是在 JVM 中还是在使用反射并期望泛型被擦除的现有程序中。
泛型在函数类型上的额外语法灵活性
当泛型在 Java 5 中添加时,它们证明是一个非常好的特性。它们也适用于表达许多 Java 8 lambda 表达式和方法引用的类型。你可以这样表达一个单参数函数:
Function<Integer, Integer> square = x -> x * x;
如果你有一个双参数函数,你使用类型BiFunction<T, U, R>,其中T是第一个参数的类型,U是第二个参数,R是结果。但是除非你自己声明,否则没有TriFunction。
同样,你不能使用Function<T, R>来引用接受零个参数并返回结果类型R的方法;你必须使用Supplier<R>代替。
从本质上讲,Java 8 的 lambda 表达式丰富了你可以编写的代码,但类型系统并没有跟上代码的灵活性。在许多函数式语言中,你可以编写,例如,类型 (Integer, Double) => String 来表示 Java 8 中的 BiFunction<Integer, Double, String>,以及 Integer => String 来表示 Function<Integer, String>,甚至 () => String 来表示 Supplier<String>。你可以将 => 理解为 Function、BiFunction、Supplier 等的 infix 版本。Java 语法类型的一个简单扩展,允许 infix =>,将导致更易读的类型,类似于在第二十章中讨论的 Scala 提供的类型,如 chapter 20 所述。
原始类型特化和泛型
在 Java 中,所有原始类型(例如 int)都有一个相应的对象类型(这里,java.lang.Integer)。通常,程序员将这些类型称为未装箱和已装箱。尽管这种区别有提高运行时效率的值得称赞的目标,但类型可能会变得令人困惑。例如,为什么在 Java 8 中你写 Predicate<Apple> 而不是 Function<Apple, Boolean>?当 Predicate<Apple> 类型的对象通过 test 方法被调用时,返回的是一个原始的 boolean。
与所有 Java 泛型一样,Function 只能通过对象类型进行参数化。在 Function<Apple, Boolean> 的情况下,这是对象类型 Boolean,而不是原始类型 boolean。Predicate<Apple> 更高效,因为它避免了将 boolean 封装成 Boolean。这个问题导致了多个类似接口的创建,例如 LongToIntFunction 和 BooleanSupplier,这进一步增加了概念上的负担。
另一个例子涉及 void 和对象类型 Void 之间的区别,void 只能修饰方法返回类型且没有值,而 Void 的唯一值是 null(这是一个在论坛中经常出现的问题)。Function 的特殊情况,如 Supplier<T>,在上一节提出的新语法中可以写成 () => T,进一步证明了原始类型和对象类型之间的区别所引起的后果。我们之前讨论了如何通过具体化的泛型来解决这些问题。
21.4.4. 对不可变性的更深入支持
一些专家读者可能对我们说 Java 8 有三种值形式感到有些不满:
-
原始值
-
(对)对象
-
(对)函数
在一个层面上,我们将坚持我们的观点,说:“但是,这些是方法现在可以将其作为参数传递并作为结果返回的值。”但我们也想承认这种解释有点问题。当你返回一个可变数组的引用时,你返回了多少(数学)值?String 或不可变数组显然是值,但对于可变对象或数组来说,情况远没有那么明确。你的方法可能返回一个元素按升序排列的数组,但其他代码可能在以后改变其元素之一。
如果你对 Java 中的函数式编程风格感兴趣,你需要语言支持来表达“不可变值”。正如在第十八章中提到的(kindle_split_033.xhtml#ch18),关键字 final 达不到这个目的;它只能阻止它修饰的字段被更新。考虑以下示例:
final int[] arr = {1, 2, 3};
final List<T> list = new ArrayList<>();
第一行禁止另一个赋值 arr = ...,但不禁止 arr[1] = 2;第二行禁止对 list 进行赋值,但不禁止其他方法改变 list 中的元素数量。关键字 final 对于原始值来说效果很好,但对于对象引用来说,它往往会产生一种错误的安全感。
我们要引导的是这个观点:鉴于函数式编程风格强调不要修改现有结构,存在一个强有力的论据支持一个如 transitively_final 这样的关键字,它可以修饰引用类型的字段,并确保该字段或通过该字段直接或间接访问的任何对象都不能进行修改。
这样的类型代表了对值的某种直觉:值是不可变的,只有变量(包含值的容器)可以被修改以包含不同的不可变值。正如我们在本节开头所提到的,Java 作者(包括我们)有时会不一致地讨论 Java 值是否可能是可变数组的可能性。在下一节中,我们将回到正确的直觉,并讨论值类型的概念,即使值类型的变量仍然可以被更新(除非它们被 final 修饰),值类型也只能包含不可变值。
21.4.5. 值类型
在本节中,我们讨论原始类型和对象类型之间的区别,这是在讨论对值类型的渴望之后进行的,值类型有助于你以函数式的方式编写程序,因为对象类型对于面向对象编程是必要的。我们讨论的许多问题都是相关的,因此没有简单的方法可以单独解释一个问题。相反,我们通过其各种方面来识别问题。
编译器难道不能将 Integer 和 int 视为相同类型处理吗?
考虑到 Java 自 1.1 版本以来逐渐获得的隐式装箱和拆箱,你可能会问是否是时候让 Java 将 Integer 和 int 视为相同类型,并依赖 Java 编译器优化到 JVM 的最佳形式了。
这个想法在原则上很棒,但考虑一下在 Java 中添加Complex类型所带来的问题,以了解为什么装箱有问题。Complex类型,它用实部和虚部来模拟所谓的复数,自然地以以下方式引入:
class Complex {
public final double re;
public final double im;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
public static Complex add(Complex a, Complex b) {
return new Complex(a.re+b.re, a.im+b.im);
}
}
但Complex类型的值是引用类型,对Complex的每个操作都需要进行对象分配,这使add中的两个添加的成本相形见绌。程序员需要一个Complex的原始类型类似物,可能称为complex。
问题在于程序员想要一个无包装的对象,而 Java 和 JVM 都没有提供真正的支持。你可以回到那个哀叹:“哦,但编译器肯定可以优化这个。”遗憾的是,这个过程比看起来要困难得多;尽管基于所谓的逃逸分析进行的编译器优化有时可以确定无包装是可行的,但其适用性受到 Java 对Object的假设的限制,这些假设自 Java 1.1 以来就存在了。考虑以下难题:
double d1 = 3.14;
double d2 = d1;
Double o1 = d1;
Double o2 = d2;
Double ox = o1;
System.out.println(d1 == d2 ? "yes" : "no");
System.out.println(o1 == o2 ? "yes" : "no");
System.out.println(o1 == ox ? "yes" : "no");
结果是“是”、“否”、“是”。一个经验丰富的 Java 程序员可能会说,“什么愚蠢的代码。每个人都知道你应该在最后两行使用equals而不是==。”但我们会坚持下去。尽管所有这些原始类型和对象都包含不可变的值 3.14 并且应该是不可区分的,但o1和o2的定义创建了新的对象,而==运算符(身份比较)可以区分它们。请注意,在原始类型上,身份比较进行位比较,而在对象上,它进行引用相等性比较。通常,你会意外地创建一个新的不同的Double对象,编译器需要尊重它,因为Object的语义,Double从中继承,要求这样做。你之前已经见过这个讨论,无论是在本章关于值类型的讨论中,还是在第十九章中,我们讨论了函数式更新持久数据结构的方法的引用透明性。
值类型:并非所有内容都是原始类型或对象
我们建议解决这个问题的方法是重新工作 Java 的假设,即(1)所有不是原始类型的东西都是对象,因此继承Object,以及(2)所有引用都是对对象的引用。
开发以这种方式开始。值有两种形式:
-
具有可变字段的对象类型,除非用
final禁止,并且具有身份,该身份可以用==测试。 -
值类型,它们是不可变的并且没有引用身份。原始类型是这个更广泛概念的一个子集。
你可以允许用户定义值类型(可能从小写字母开始,以强调它们与诸如int和boolean这样的原始类型相似)。在值类型上,默认情况下,==会执行元素级的比较,就像硬件在int上执行位级比较一样。我们需要小心处理浮点成员,因为比较是一个相对复杂的操作。Complex类型将是一个非原始值类型的完美例子;这些类型类似于 C#中的结构体。
此外,值类型可以减少存储需求,因为它们没有引用标识。图 21.1 展示了大小为三的数组,其元素 0、1 和 2 分别是浅灰色、白色和深灰色。左图显示了当Pair和Complex是Object时的典型存储需求,右图显示了当Pair和Complex是值类型时的更好布局。注意,我们在图中用小写字母称呼它们,以强调它们与原始类型的相似性。还要注意,值类型可能会产生更好的性能,不仅对于数据访问(多级指针间接替换为单个索引寻址指令),而且对于硬件缓存的使用(由于数据连续性)。
图 21.1. 对象与值类型

注意,由于值类型没有引用标识,编译器可以自由地进行装箱和拆箱。如果你从一个函数将一个complex类型作为参数传递到另一个函数,编译器可以自然地将它作为两个单独的双精度浮点数传递。(在 JVM 中,不进行装箱直接返回它会更复杂,因为 JVM 只为可以在 64 位机器寄存器中表示的值提供了方法返回指令。)但是,如果你传递一个更大的值类型作为参数(可能是一个大的不可变数组),编译器可以透明地将它作为引用传递,当它被装箱时。这种技术已经在 C#中存在。微软表示(docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/value-types):
基于值类型的变量直接包含值。将一个值类型变量赋值给另一个变量时,会复制包含的值。这与引用类型变量的赋值不同,引用类型变量的赋值会复制对象的引用,但不会复制对象本身。
在撰写本文时(2018 年),Java 中关于值类型的 JDK 增强提案正在等待审批(openjdk.java.net/jeps/169)。
装箱、泛型、值类型:相互依赖问题
我们希望在 Java 中有值类型,因为函数式风格的程序处理的是没有身份的不变值。我们希望将原始类型视为值类型的特例,但 Java 目前具有的泛型擦除模型意味着值类型不能与泛型一起使用而不进行装箱。由于擦除模型,原始类型的对象(装箱)版本(如Integer)对于集合和 Java 泛型来说仍然至关重要,但现在它们继承Object(因此,引用相等)被视为一个缺点。解决任何这些问题意味着解决所有这些问题。
21.5. 加速 Java 的发展
在 22 年内,Java 已经发布了十个主要版本——平均每个版本之间相隔两年多。在某些情况下,等待时间长达五年。Java 架构师意识到,这种情况已经不再可持续,因为它不能足够快地推动语言的发展,这也是为什么在 JVM 上新兴的语言(如 Scala 和 Kotlin)为 Java 创造了一个巨大的功能差距的主要原因。对于像 lambda 和 Java 模块系统这样巨大且革命性的功能,这样的长发布周期可以说是合理的,但它也意味着,在将这些大变化之一完全实现之前,任何小的改进都必须无理由地等待,然后才能被纳入语言中。例如,第八章中讨论的集合工厂方法,在 Java 9 模块系统最终确定之前就已经准备好发货了。
由于这些原因,已经决定从现在开始,Java 将拥有六个月的开发周期。换句话说,Java 和 JVM 的新主要版本将每六个月出现一次,Java 10 于 2018 年 3 月发布,Java 11 预计于 2018 年 9 月发布。Java 架构师也意识到,尽管这种更快的开发周期对语言本身以及习惯于不断尝试新技术的敏捷公司和开发者来说是有益的,但它可能对更保守的组织造成问题,这些组织通常以较慢的速度更新其软件。因此,Java 架构师还决定,每三年将有一个长期支持(LTS)版本,将在随后的三年内得到支持。Java 9 不是一个 LTS 版本,因此现在被认为已经走到了生命的尽头,因为 Java 10 已经发布。同样的事情也会发生在 Java 10 上。相比之下,Java 11 将是一个 LTS 版本,计划于 2018 年 9 月发布,并支持到 2021 年 9 月。图 21.2 显示了计划在未来几年发布的 Java 版本的生命周期。
图 21.2. 未来 Java 版本的生命周期

我们强烈认同缩短 Java 开发周期的决定,尤其是在现在,当所有软件系统和语言都旨在尽可能快速地改进的时候。较短的开发周期使 Java 能够以适当的速度进化,并使语言在未来几年保持相关性和适用性。
21.6. 最后的话
这本书探讨了 Java 8 和 9 添加的主要新特性。Java 8 可能代表了 Java 历史上迈出的最大一步。与之相比,上一个十年前(2005 年)Java 5 中泛型的引入,也是一个相当大的进化步骤。Java 9 最显著的特征是引入了期待已久的模块系统,这对软件架构师可能比开发者更有趣。Java 9 还通过 Flow API 标准化了其协议,从而拥抱了响应式流。Java 10 引入了局部变量类型推断,这是在其他编程语言中流行的功能,有助于提高生产力。Java 11 允许将局部变量类型推断的var语法用于隐式类型 lambda 表达式的参数列表。也许更重要的是,Java 11 接受了本书讨论的并发和响应式编程思想,并带来一个新的异步 HTTP 客户端库,该库完全采用CompletableFutures。最后,在撰写本文时,Java 12 宣布支持一个增强的 switch 结构,它可以作为一个表达式而不是仅仅作为一个语句使用——这是函数式编程语言的一个关键特性。实际上,switch 表达式为 Java 中模式匹配的引入铺平了道路,我们在第 21.4.2 节中讨论了这一点。所有这些语言更新都表明,函数式编程思想和影响将继续在未来进入 Java!
在本章中,我们探讨了进一步 Java 进化的压力。总之,我们提出以下声明:
Java 8、9、10 和 11 是暂停但不要停止的地方!
我们希望您已经享受了与我们一起的学习之旅,并且我们已经激发了您探索 Java 进一步演化的兴趣。
附录 A. 其他语言更新
在本附录中,我们讨论了 Java 8 中的三个其他语言更新:重复注解、类型注解和泛型目标类型推断。附录 B 讨论了 Java 8 中的库更新。我们不讨论 JDK 8 更新,如 Nashorn 和 Compact Profiles,因为它们是新的 JVM 功能。本书重点介绍 库 和 语言 更新。如果您对 Nashorn 和 Compact Profiles 感兴趣,请阅读以下链接:openjdk.java.net/projects/nashorn/ 和 openjdk.java.net/jeps/161。
A.1. 注解
Java 8 中的注解机制在两个方面得到了增强:
-
你可以重复注解。
-
你可以对任何类型使用注解。
在解释这些更新之前,快速回顾一下在 Java 8 之前你可以用注解做什么是有价值的。
Java 中的 注解 是一种机制,允许你用附加信息装饰程序元素(注意,在 Java 8 之前,只有声明可以被注解)。换句话说,它是一种 语法元数据。例如,注解在 JUnit 框架中很受欢迎。在以下代码中,方法 setUp 被注解为 @Before,而方法 testAlgorithm 被注解为 @Test:
@Before
public void setUp(){
this.list = new ArrayList<>();
}
@Test
public void testAlgorithm(){
...
assertEquals(5, list.size());
}
注解适用于多种用例:
-
在 JUnit 的上下文中,注解可以区分应该作为单元测试运行的方法和用于设置工作的方法。
-
注解可以用于文档。例如,
@Deprecated注解用于指示一个方法不应再使用。 -
Java 编译器也可以处理注解,以检测错误、抑制警告或生成代码。
-
注解在 Java EE 中很受欢迎,它们用于配置企业应用程序。
A.1.1. 重复注解
以前的 Java 版本禁止在声明上指定给定注解类型的多个注解。因此,以下代码是无效的:
@interface Author { String name(); }
@Author(name="Raoul") @Author(name="Mario") @Author(name="Alan") *1*
class Book{ }
- 1 错误:重复注解
Java EE 程序员经常使用一种惯用语来规避这个限制。你声明一个新的注解,其中包含一个要重复的注解数组。它看起来像这样:
@interface Author { String name(); }
@interface Authors {
Author[] value();
}
@Authors(
{ @Author(name="Raoul"), @Author(name="Mario") , @Author(name="Alan")}
)
class Book{}
Book 类的嵌套注解看起来相当丑陋。这就是为什么 Java 8 基本上移除了这个限制,从而使事情变得整洁一些。现在,你可以在声明上指定多个相同类型的注解,前提是它们规定该注解是可重复的。这不是默认行为;你必须明确要求注解可重复。
使注解可重复
如果一个注解被设计为可重复的,你只需使用它即可。但是,如果你为用户提供注解,则需要设置来指定注解可以重复。这里有两个步骤:
-
将注解标记为
@Repeatable。 -
提供一个容器注解。
这样你可以使@Author注解可重复:
@Repeatable(Authors.class)
@interface Author { String name(); }
@interface Authors {
Author[] value();
}
因此,Book类可以用多个@Author注解进行注解:
@Author(name="Raoul") @Author(name="Mario") @Author(name="Alan")
class Book{ }
在编译时,Book类被认为是通过@Authors({ @Author(name= "Raoul"), @Author(name="Mario"), @Author(name="Alan")})注解的,因此你可以将这种新机制视为 Java 程序员之前使用的惯用语的语法糖。注解仍然被封装在一个容器中,以确保与旧版反射方法的兼容性。Java API 中的getAnnotation(Class<T> annotationClass)方法返回注解元素上的类型为T的注解。如果有多个类型为T的注解,这个方法应该返回哪个注解?
不深入细节,类Class支持一个新的get-AnnotationsByType方法,它简化了可重复注解的处理。例如,你可以如下使用它来打印Book类上的所有Author注解:
public static void main(String[] args) {
Author[] authors = Book.class.getAnnotationsByType(Author.class); *1*
Arrays.asList(authors).forEach(a -> { System.out.println(a.name()); });
}
- 1 获取由可重复的
Author注解组成的数组
为了使这可行,可重复注解及其容器都必须有RUNTIME保留策略。有关与旧版反射方法兼容性的更多信息,请参阅此处:cr.openjdk.java.net/~abuckley/8misc.pdf。
A.1.2. 类型注解
截至 Java 8,注解也可以应用于任何类型的使用。这包括new运算符、类型转换、instanceof检查、泛型类型参数以及implements和throws子句。在这里,我们使用@NonNull注解表明String类型的变量name不能为null:
@NonNull String name = person.getName();
同样,你可以注解列表中元素的类型:
List<@NonNull Car> cars = new ArrayList<>();
这有什么有趣的地方?类型上的注解可以用于程序分析。在这两个例子中,一个工具可以确保getName不会返回null,以及列表中的汽车元素始终不是null。这可以帮助减少你代码中的意外错误。
Java 8 不提供官方注解或使用它们的工具。它只提供了在类型上使用注解的能力。幸运的是,存在一个名为 Checker 框架的工具,它定义了几个类型注解,并允许你使用它们来增强类型检查。如果你感兴趣,我们邀请你查看它的教程:www.checkerframework.org。有关在你的代码中可以使用注解的位置的更多信息,请参阅此处:docs.oracle.com/javase/specs/jls/se8/html/jls-9.html#jls-9.7.4。
A.2. 泛化目标类型推断
Java 8 增强了泛型参数的推断。你已经在 Java 8 之前熟悉了使用上下文信息进行类型推断。例如,Java 中empty-List方法的定义如下:
static <T> List<T> emptyList();
方法emptyList使用类型参数T进行参数化。你可以如下调用它以向类型参数提供显式类型:
List<Car> cars = Collections.<Car>emptyList();
但 Java 能够推断泛型参数。以下等价:
List<Car> cars = Collections.emptyList();
在 Java 8 之前,这种基于上下文(即目标类型)的推断机制是有限的。例如,以下是不可能的:
static void cleanCars(List<Car> cars) {
}
cleanCars(Collections.emptyList());
你会得到以下错误:
cleanCars (java.util.List<Car>)cannot be applied to
(java.util.List<java.lang.Object>)
为了修复它,你必须提供像我们之前展示的那样显式的类型参数。
在 Java 8 中,目标类型包括方法参数,因此你不需要提供显式的泛型参数:
List<Car> cleanCars = dirtyCars.stream()
.filter(Car::isClean)
.collect(Collectors.toList());
在此代码中,正是这种增强让你能够编写Collectors.toList()而不是Collectors.<Car>toList()。
附录 B. 其他库更新
本附录回顾了 Java 8 库的主要新增内容。
B.1. 集合
集合 API 的最大更新是引入了流,我们已在第四章、第五章和第六章中讨论过。第九章中也讨论了其他更新,本附录中还总结了额外的少量新增内容。
B.1.1. 其他方法
Java API 设计者充分利用了默认方法,并为集合接口和类添加了几个新方法。新方法列在表 B.1 中。
表 B.1. 集合类和接口新增的方法
| 类/接口 | 新方法 |
|---|---|
| Map | getOrDefault, forEach, compute, computeIfAbsent, computeIfPresent, merge, putIfAbsent, remove(key, value), replace, replaceAll, of, ofEntries |
| Iterable | forEach, spliterator |
| Iterator | forEachRemaining |
| 集合 | removeIf, stream, parallelStream |
| List | replaceAll, sort, of |
| BitSet | stream |
| Set | of |
地图
Map接口是最新的接口,支持几个新的便捷方法。例如,可以使用getOrDefault方法来替换之前检查Map是否包含给定键映射的现有惯用语。如果没有,您可以提供一个默认值以返回。之前您会这样做:
Map<String, Integer> carInventory = new HashMap<>();
Integer count = 0;
if(map.containsKey("Aston Martin")){
count = map.get("Aston Martin");
}
您现在可以更简单地执行以下操作:
Integer count = map.getOrDefault("Aston Martin", 0);
注意,这仅在没有任何映射的情况下才有效。例如,如果键显式映射到值null,则不会返回任何默认值。
另一个特别有用的方法是computeIfAbsent,我们在第十九章([kindle_split_034.xhtml#ch19])中简要提到,当时解释了记忆化。它允许您方便地使用缓存模式。假设您需要从不同的网站获取和处理数据。在这种情况下,缓存数据很有用,这样您就不必多次执行(昂贵的)获取操作:
public String getData(String url){
String data = cache.get(url);
if(data == null){ *1*
data = getData(url);
cache.put(url, data); *2*
}
return data;
}
-
1 检查数据是否已缓存。
-
2 如果不是,请获取数据,然后将其缓存到 Map 中以供将来使用。
您现在可以通过使用computeIfAbsent来更简洁地编写以下代码:
public String getData(String url){
return cache.computeIfAbsent(url, this::getData);
}
所有其他方法的描述可以在官方 Java API 文档中找到(docs.oracle.com/javase/8/docs/api/java/util/Map.html)。请注意,ConcurrentHashMap也更新了额外的功能。我们将在第 B.2 节中讨论它们。
集合
可以使用removeIf方法来删除集合中所有匹配谓词的元素。请注意,这与Streams API 中包含的filter方法不同。Streams API 中的filter方法会产生一个新的流;它不会修改当前的流或源。
列表
replaceAll 方法将 List 中的每个元素替换为应用给定运算符后的结果。它与流中的 map 方法类似,但它会修改 List 的元素。相比之下,map 方法会产生新的元素。
例如,以下代码将打印 [2, 4, 6, 8, 10],因为 List 在原地被修改:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.replaceAll(x -> x * 2);
System.out.println(numbers); *1*
- 1 打印 [2, 4, 6, 8, 10]
B.1.2. 集合类
Collections 类已经存在很长时间,用于操作或返回集合。现在它包括额外的返回不可修改、同步、检查和空的 NavigableMap 和 NavigableSet 的方法。此外,它还包括 checkedQueue 方法,该方法返回一个扩展了动态类型检查的 Queue 视图。
B.1.3. 比较器
Comparator 接口现在包括默认和静态方法。你曾在第三章中使用 Comparator.comparing 静态方法来返回一个 Comparator 对象,该对象由提取排序键的函数给出。
新增实例方法包括以下内容:
-
reversed——返回一个具有当前Comparator反向排序的Comparator。 -
thenComparing——返回一个在两个对象相等时使用另一个Comparator的Comparator。 -
thenComparingInt、thenComparingDouble、thenComparingLong——与thenComparing方法类似,但接受针对原始类型的专用函数(分别是ToIntFunction、ToDoubleFunction和ToLongFunction)。
新增静态方法包括以下内容:
-
comparingInt、comparingDouble、comparingLong——与comparing方法类似,但接受针对原始类型的专用函数(分别是ToIntFunction、ToDoubleFunction和ToLongFunction)。 -
naturalOrder——返回一个对Comparable对象施加自然排序的Comparator对象。 -
nullsFirst、nullsLast——返回一个将null视为小于非null或大于非null的Comparator对象。 -
reverseOrder—等同于naturalOrder().reverse()。
B.2. 并发
Java 8 带来了与并发相关的几个更新。首先是当然的,并行流的引入,我们将在第七章中探讨。还有 CompletableFuture 类的引入,你可以在第十六章中了解它。
有其他值得注意的更新。例如,Arrays 类现在支持并行操作。我们将在章节 B.3 中讨论这些操作。
在本节中,我们查看 java.util.concurrent.atomic 包的更新,该包处理原子变量。此外,我们讨论 ConcurrentHashMap 类的更新,它支持几个新方法。
B.2.1. 原子操作
java.util.concurrent.atomic 包提供了几个数字类,例如 AtomicInteger 和 AtomicLong,它们支持对单个变量的原子操作。它们已更新以支持新的方法:
-
getAndUpdate—原子地使用给定的函数更新当前值,返回更新前的值。 -
updateAndGet—原子地使用给定的函数更新当前值,返回更新后的值。 -
getAndAccumulate—原子地使用给定的函数更新当前值,该函数应用于当前值和给定值,返回更新前的值。 -
accumulateAndGet—原子地使用给定的函数更新当前值,该函数应用于当前值和给定值,返回更新后的值。
这里是如何原子性地设置一个观察到的值为 10 和现有原子整数之间的最小值:
int min = atomicInteger.accumulateAndGet(10, Integer::min);
加法器和累加器
Java API 建议在多个线程频繁更新但读取较少的情况下(例如,在统计学的上下文中)使用新的类LongAdder、LongAccumulator、DoubleAdder和DoubleAccumulator,而不是使用等效的Atomic类。这些类设计为动态增长以减少线程竞争。
类LongAdder和DoubleAdder支持加法操作,而LongAccumulator和DoubleAccumulator提供了一个组合值的函数。例如,要计算几个值的总和,可以使用以下方式LongAdder。
列表 B.1. 使用LongAdder计算值的总和
LongAdder adder = new LongAdder(); *1*
adder.add(10); *2*
// ...
long sum = adder.sum(); *3*
-
1 使用默认构造函数将初始总和值设置为 0。
-
2 在几个不同的线程中进行一些加法操作。
-
3 在某个点上获取总和。
或者你可以按照以下方式使用LongAccumulator。
列表 B.2. 使用LongAccumulator计算值的总和
LongAccumulator acc = new LongAccumulator(Long::sum, 0);
acc.accumulate(10); *1*
// ...
long result = acc.get(); *2*
-
1 在几个不同的线程中累积值。
-
2 在某个点上获取结果。
B.2.2. ConcurrentHashMap
ConcurrentHashMap类被引入以提供更现代的HashMap,它是线程安全的。ConcurrentHashMap允许并发添加和更新,但只锁定内部数据结构的一部分。因此,与同步的Hashtable替代品相比,读写操作的性能得到了提高。
性能
ConcurrentHashMap的内部结构已更新以提高性能。映射的条目通常存储在通过键的生成哈希码访问的桶中。但如果许多键返回相同的哈希码,性能将下降,因为桶实现为具有 O(n)检索的List。在 Java 8 中,当桶变得太大时,它们会动态地替换为具有 O(log(n))检索的有序树。请注意,这仅在键是Comparable(例如,String或Number类)时才可能。
类似于流的操作
ConcurrentHashMap支持三种新的操作,这些操作让人联想到你在流中看到的内容:
-
forEach—对每个(key, value)执行给定的操作 -
reduce—使用一个归约函数将所有给定的(key, value)组合成一个结果 -
search—对每个(键,值)应用一个函数,直到函数产生一个非null结果
每种操作支持四种形式,接受具有键、值、Map.Entry 和(键,值)参数的函数:
-
与键和值操作(
forEach、reduce、search) -
与键操作(
forEachKey、reduceKey、searchKey) -
与值操作(
forEachValue、reduceValue、searchValues) -
与
Map.Entry对象操作(forEachEntry、reduceEntries、searchEntries)
注意,这些操作不会锁定 ConcurrentHashMap 的状态。它们在操作过程中对元素进行操作。提供给这些操作的功能不应依赖于任何排序或任何可能在计算过程中改变的其他对象或值。
此外,您还需要为所有这些操作指定一个并行度阈值。如果当前 map 的大小估计小于给定的阈值,则操作将按顺序执行。使用 1 的值启用最大并行性,使用公共线程池。使用 Long.MAX_VALUE 的值在单个线程上运行操作。
在此示例中,我们使用 reduceValues 方法在 map 中找到最大值:
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
Optional<Integer> maxValue =
Optional.of(map.reduceValues(1, Integer::max));
注意,对于每个 reduce 操作,都有 int、long 和 double 的原始特化(例如,reduceValuesToInt、reduceKeysToLong 等)。
计数
ConcurrentHashMap 类提供了一个名为 mappingCount 的新方法,它以 long 返回 map 中的映射数量。它应该用于替代返回 int 的方法 size。这是因为映射的数量可能不适合 int。
集合视图
ConcurrentHashMap 类提供了一个名为 keySet 的新方法,它返回 ConcurrentHashMap 作为 Set 的视图(map 的更改反映在 Set 中,反之亦然)。您还可以使用新的静态方法 newKeySet 创建由 ConcurrentHashMap 支持的 Set。
B.3. Arrays
Arrays 类提供了各种静态方法来操作数组。现在它包括四个新的方法(它们有原始特化的重载变体)。
B.3.1. 使用 parallelSort
parallelSort 方法以并行方式对指定的数组进行排序,使用自然顺序,或者使用额外的 Comparator 对对象数组进行排序。
B.3.2. 使用 setAll 和 parallelSetAll
setAll 和 parallelSetAll 方法分别按顺序或并行地设置指定数组的所有元素,使用提供的函数计算每个元素。该函数接收元素索引并返回该索引的值。因为 parallelSetAll 是并行执行的,所以该函数必须是无副作用的,如第七章和 18 所述。
例如,您可以使用 setAll 方法生成包含值 0、2、4、6、... 的数组:
int[] evenNumbers = new int[10];
Arrays.setAll(evenNumbers, i -> i * 2);
B.3.3. 使用 parallelPrefix
parallelPrefix方法并行地累积给定数组中的每个元素,使用提供的二元运算符。在下一个列表中,您将生成 1、2、3、4、5、6、7、...的值。
列表 B.3. parallelPrefix并行累积数组元素
int[] ones = new int[10];
Arrays.fill(ones, 1);
Arrays.parallelPrefix(ones, (a, b) -> a + b); *1*
- 1 ones is now [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
B.4. 数字和数学
Java 8 API 通过新方法增强了Number和Math类。
B.4.1. 数字
Number类的新方法如下:
-
Short、Integer、Long、Float和Double类包含了sum、min和max静态方法。您在第五章中与reduce操作一起看到了这些方法。 -
Integer和Long类包括compareUnsigned、divide-Unsigned、remainderUnsigned和toUnsignedString方法,用于处理无符号值。 -
Integer和Long类分别包括静态方法parseUnsignedInt和parseUnsignedLong,用于将字符串解析为无符号的int或long。 -
Byte和Short类包括toUnsignedInt和toUnsigned-Long方法,通过无符号转换将参数转换为int或long。同样,Integer类现在包括静态方法toUnsignedLong。 -
Double和Float类包括静态方法isFinite,用于检查参数是否为有限的浮点值。 -
Boolean类现在包括静态方法logicalAnd、logicalOr和logicalXor,用于在两个布尔值之间应用and、or和xor操作。 -
BigInteger类包括byteValueExact、shortValueExact、intValueExact和longValueExact方法,将这些BigInteger转换为相应的原始类型。但如果转换过程中信息丢失,它将抛出一个算术异常。
B.4.2. 数学
Math类包括在操作结果溢出时抛出算术异常的新方法。这些方法包括addExact、subtractExact、multiplyExact、incrementExact、decrementExact和negateExact,它们具有int和long参数。此外,还有一个静态的toIntExact方法,用于将long值转换为int。其他增加包括静态方法floorMod、floorDiv和nextDown。
B.5. 文件
对Files类的明显增加让您可以从文件生成流。我们在第五章中提到了新的静态方法Files.lines;它允许您以流的形式惰性读取文件。其他返回流的实用静态方法包括以下内容:
-
Files.list——生成一个包含给定目录条目的Stream<Path>。列表不是递归的。因为流是惰性消费的,所以这是一个处理可能非常大的目录的有用方法。 -
Files.walk—就像Files.list一样,它生成一个包含给定目录条目的Stream<Path>。但是列表是递归的,并且可以配置深度级别。请注意,遍历是按深度优先进行的。 -
Files.find—通过递归遍历目录以找到匹配给定谓词的条目,从而生成一个Stream<Path>。
B.6. 反射
我们在附录 A 中讨论了 Java 8 中注释机制的几个变化。附录 A。反射 API 已更新以支持这些变化。
反射 API 的另一个新增功能是,现在可以通过新的java.lang.reflect.Parameter类来访问方法参数的信息,如名称和修饰符,该类在新的java.lang.reflect.Executable类中被引用,该类作为Method和Constructor共同功能的一个共享超类。
B.7. 字符串
String类现在包含一个方便的静态方法join,正如你可能猜到的,它使用分隔符连接字符串!你可以如下使用它:
String authors = String.join(", ", "Raoul", "Mario", "Alan");
System.out.println(authors); *1*
- 1 拉乌尔,马里奥,艾伦
附录 C. 在流上并行执行多个操作
Java 8 流的一个最大的限制是,在处理它时只能操作一次,并且只能得到一个结果。确实,如果你尝试第二次遍历流,你所能达到的只有像这样的异常:
java.lang.IllegalStateException: stream has already been operated upon or closed
尽管如此,在处理单个流时,你可能会希望得到多个结果。例如,你可能想要像在 第 5.7.3 节 中做的那样,以流的形式解析日志文件,但在单步中收集多个统计数据。或者,继续使用在 第四章、第五章 和 第六章 中用来解释 Stream 功能的菜单数据模型,你可能在遍历菜肴流时想要检索不同的信息。
换句话说,你希望在单次遍历中通过多个 lambda 推送流,为此你需要一种 fork 方法,并将不同的函数应用于每个分叉的流。更好的是,如果你能够使用不同的线程并行执行这些操作,那就太棒了。
不幸的是,这些功能目前在 Java 8 提供的流实现中尚不可用,但在这个附录中,我们将向你展示如何使用 Spliterator 以及其后期绑定能力,结合 BlockingQueues 和 Futures 来实现这个有用的功能,并通过一个方便的 API 提供它.^([1])
¹
本附录其余部分所提出的实现基于 Paul Sandoz 在他发送给 lambda-dev 邮件列表的电子邮件中提出的解决方案:
mail.openjdk.java.net/pipermail/lambda-dev/2013-November/011516.html。
C.1. 分叉流
在流上并行执行多个操作的第一件事是创建一个包装原始流的 StreamForker,你可以在其上定义你想要执行的不同操作。请看下面的列表。
列表 C.1. 定义一个 StreamForker 以在流上执行多个操作
public class StreamForker<T> {
private final Stream<T> stream;
private final Map<Object, Function<Stream<T>, ?>> forks =
new HashMap<>();
public StreamForker(Stream<T> stream) {
this.stream = stream;
}
public StreamForker<T> fork(Object key, Function<Stream<T>, ?> f) {
forks.put(key, f); *1*
return this; *2*
}
public Results getResults() {
// To be implemented
}
}
-
1 使用键索引要应用于流的函数。
-
2 返回此以便流畅地多次调用分叉方法。
在这里,fork 方法接受两个参数:
-
一个
Function,它将流转换成表示这些操作之一的任何类型的输出结果 -
一个键,它将允许你检索该操作的输出,并将这些键/函数对累积到一个内部的
Map中
fork 方法返回 StreamForker 本身;因此,你可以通过分叉多个操作来构建一个管道。图 C.1 展示了 StreamForker 的主要思想。
图 C.1. StreamForker 的作用

在这里,用户定义了三个要在三个键索引的流上执行的操作。然后 StreamForker 遍历原始流并将其分叉成三个其他流。在此阶段,可以在分叉的流上并行应用这三个操作,并使用这些函数应用的结果(按其相应的键索引)来填充结果 Map。
通过调用 getResults 方法触发通过 fork 方法添加的所有操作的执行,该方法返回一个实现如下定义的 Results 接口的实现:
public static interface Results {
public <R> R get(Object key);
}
此接口只有一个方法,你可以传递一个在 fork 方法中使用的键 Object,该方法返回与该键对应的操作的结果。
C.1.1. 使用 ForkingStreamConsumer 实现 Results 接口
getResults 方法可以按以下方式实现:
public Results getResults() {
ForkingStreamConsumer<T> consumer = build();
try {
stream.sequential().forEach(consumer);
} finally {
consumer.finish();
}
return consumer;
}
ForkingStreamConsumer 实现了之前定义的 Results 接口和 Consumer 接口。正如你将在更详细地分析其实现时看到的那样,其主要任务是消费流中的所有元素并将它们多路复用到通过 fork 方法提交的操作数量的 BlockingQueues。请注意,确保流是顺序的,因为如果在并行流上执行 forEach 方法,其元素可能会无序地推送到队列中。finish 方法向这些队列添加特殊元素以表示没有更多项目需要处理。用于创建 ForkingStreamConsumer 的 build 方法在下一列表中显示。
列表 C.2. 创建 ForkingStreamConsumer 所使用的 build 方法
private ForkingStreamConsumer<T> build() {
List<BlockingQueue<T>> queues = new ArrayList<>(); *1*
Map<Object, Future<?>> actions = *2*
forks.entrySet().stream().reduce(
new HashMap<Object, Future<?>>(),
(map, e) -> {
map.put(e.getKey(),
getOperationResult(queues, e.getValue()));
return map;
},
(m1, m2) -> {
m1.putAll(m2);
return m1;
});
return new ForkingStreamConsumer<>(queues, actions);
}
-
1 为每个操作创建一个队列列表。
-
2 使用用于识别这些操作的键来映射将包含操作结果的
Futures。
在列表 C.2 中,你首先创建了之前提到的 BlockingQueues 的 List。然后你创建了一个 Map,其键用于识别要在流上执行的不同操作,其值是包含这些操作相应结果的 Futures。然后,BlockingQueues 的列表和 Futures 的 Map 被传递给 ForkingStreamConsumer 的构造函数。每个 Future 都使用此 getOperationResult 方法创建,如下一列表所示。
列表 C.3. 使用 getOperationResult 方法创建的 Futures
private Future<?> getOperationResult(List<BlockingQueue<T>> queues,
Function<Stream<T>, ?> f) {
BlockingQueue<T> queue = new LinkedBlockingQueue<>();
queues.add(queue); *1*
Spliterator<T> spliterator = new BlockingQueueSpliterator<>(queue); *2*
Stream<T> source = StreamSupport.stream(spliterator, false); *3*
return CompletableFuture.supplyAsync( () -> f.apply(source) ); *4*
}
-
1 创建一个队列并将其添加到队列列表中。
-
2 创建一个遍历该队列元素的 Spliterator。
-
3 创建一个以该 Spliterator 为源的流。
-
4 创建一个 Future,异步计算给定函数在该流上的应用。
getOperationResult 方法创建一个新的 BlockingQueue 并将其添加到队列的 List 中。这个队列被传递给一个新的 BlockingQueueSpliterator,它是一个后期绑定的 Spliterator,从队列中读取要遍历的项目;我们将在稍后考察它是如何制作的。
然后,你创建一个遍历此 Spliterator 的顺序流,最后创建一个 Future 来计算应用表示你想要在此流上执行的操作之一的函数的结果。这个 Future 是使用实现 Future 接口的 CompletableFuture 类的静态工厂方法创建的。这是 Java 8 中引入的另一个新类,我们在第十六章 中对其进行了详细调查。
C.1.2. 开发 ForkingStreamConsumer 和 BlockingQueueSpliterator
你需要开发的最后两个部分是之前介绍的 ForkingStreamConsumer 和 BlockingQueueSpliterator 类。第一个可以按以下方式实现。
列表 C.4. 用于将流元素添加到多个队列的 ForkingStreamConsumer
static class ForkingStreamConsumer<T> implements Consumer<T>, Results {
static final Object END_OF_STREAM = new Object();
private final List<BlockingQueue<T>> queues;
private final Map<Object, Future<?>> actions;
ForkingStreamConsumer(List<BlockingQueue<T>> queues,
Map<Object, Future<?>> actions) {
this.queues = queues;
this.actions = actions;
}
@Override
public void accept(T t) {
queues.forEach(q -> q.add(t)); *1*
}
void finish() {
accept((T) END_OF_STREAM); *2*
}
@Override
public <R> R get(Object key) {
try {
return ((Future<R>) actions.get(key)).get(); *3*
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
-
1 将流遍历的元素传播到所有队列中
-
2 向队列添加一个最后元素以表示流已结束
-
3 返回给定键索引的操作结果,并等待计算它的 Future 完成
这个类实现了 Consumer 和 Results 接口,并持有 BlockingQueues 的 List 和执行流上不同操作的 Map 的引用。
Consumer 接口要求实现 accept 方法。在这里,每次 ForkingStreamConsumer 接受流的一个元素时,它将该元素添加到所有 BlockingQueues 中。此外,在将原始流的所有元素添加到所有队列之后,finish 方法会导致向所有队列添加最后一个项目。当遇到 BlockingQueueSpliterators 时,这个项目将使队列理解没有更多元素需要处理。
Results 接口要求实现 get 方法。在这里,它检索在 Map 中通过键索引的 Future,并展开其结果或等待结果可用。
最后,将为流上要执行的操作创建一个 BlockingQueueSpliterator。每个 BlockingQueueSpliterator 将有一个引用,指向由 ForkingStreamConsumer 填充的 BlockingQueue 之一,它可以像以下列表中所示的那样实现。
列表 C.5. 从 BlockingQueue 读取遍历元素的一个 Spliterator
class BlockingQueueSpliterator<T> implements Spliterator<T> {
private final BlockingQueue<T> q;
BlockingQueueSpliterator(BlockingQueue<T> q) {
this.q = q;
}
@Override
public boolean tryAdvance(Consumer<? super T> action) {
T t;
while (true) {
try {
t = q.take();
break;
} catch (InterruptedException e) { }
}
if (t != ForkingStreamConsumer.END_OF_STREAM) {
action.accept(t);
return true;
}
return false;
}
@Override
public Spliterator<T> trySplit() {
return null;
}
@Override
public long estimateSize() {
return 0;
}
@Override
public int characteristics() {
return 0;
}
}
在这个列表中,实现了一个 Spliterator,不是为了定义如何分割流的政策,而只是为了使用其后期绑定能力。因此,trySplit 方法未实现。
此外,由于你无法预见还能从队列中取出多少元素,因此从 estimatedSize 方法返回任何有意义的值都是不可能的。此外,因为你没有尝试任何拆分,这种估计将变得毫无用处。这个实现没有我们列在表 7.2 中的任何 Spliterator 特性,所以 characteristic 方法返回 0。
这里只实现了 tryAdvance 方法,该方法等待从其 BlockingQueue 中取出原始流添加到其中的元素。它将这些元素发送到一个 Consumer,该 Consumer(基于如何在 getOperationResult 方法中创建此 Spliterator)是进一步流(在相应的函数应用于其中一个 fork 方法调用时)的来源。tryAdvance 方法返回 true,以通知其调用者还有其他元素要消费,直到它在队列中找到由 ForkingStreamConsumer 添加的特殊 Object,以表示没有更多元素要从队列中取出。图 C.2 展示了 StreamForker 及其构建块的总览。
图 C.2. StreamForker 的构建块

在图中,左上角的 StreamForker 有一个 Map,其中每个要执行的流操作(由一个函数定义)通过一个键进行索引。右边的 ForkingStreamConsumer 为这些操作中的每一个都保留一个队列,并消费原始流中的所有元素,将它们多路复用到所有队列中。
在图的底部,每个队列都有一个 BlockingQueueSpliterator 拉取其项目,并作为不同流的来源。最后,这些由原始流拆分出来的每个流,都作为参数传递给一个函数,从而执行要执行的操作之一。现在你已经有了 StreamForker 的所有组件,因此它已经准备好使用。
C.1.3. 将 StreamForker 应用到工作中
让我们将 StreamForker 应用到我们在第四章中定义的菜单数据模型上,通过拆分原始的菜肴流,并行地对它执行四个不同的操作,如下所示。特别是,你想要生成所有可用菜肴名称的逗号分隔列表,计算菜单的总卡路里,找到卡路里最高的菜肴,并按类型对所有菜肴进行分组。
列表 C.6. 将 StreamForker 应用到工作中
Stream<Dish> menuStream = menu.stream();
StreamForker.Results results = new StreamForker<Dish>(menuStream)
.fork("shortMenu", s -> s.map(Dish::getName)
.collect(joining(", ")))
.fork("totalCalories", s -> s.mapToInt(Dish::getCalories).sum())
.fork("mostCaloricDish", s -> s.collect(reducing(
(d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2))
.get())
.fork("dishesByType", s -> s.collect(groupingBy(Dish::getType)))
.getResults();
String shortMenu = results.get("shortMenu");
int totalCalories = results.get("totalCalories");
Dish mostCaloricDish = results.get("mostCaloricDish");
Map<Dish.Type, List<Dish>> dishesByType = results.get("dishesByType");
System.out.println("Short menu: " + shortMenu);
System.out.println("Total calories: " + totalCalories);
System.out.println("Most caloric dish: " + mostCaloricDish);
System.out.println("Dishes by type: " + dishesByType);
StreamForker提供了一个方便的、流畅的 API 来分割流并将不同的操作分配给每个分割的流。这些操作以在流上应用函数的形式表达,并且可以由任何任意对象识别;在这种情况下,我们选择使用String。当你不再需要添加分割时,你可以在StreamForker上调用getResults来触发所有定义的操作并获取StreamForker.Results。因为这些操作是在内部异步执行的,所以getResults方法会立即返回,而无需等待所有结果都可用。
你可以通过将用于识别该操作的键传递给StreamForker.Results接口来获取特定操作的结果。如果在那时该操作的计算完成,get方法将返回相应的结果;否则,它将阻塞,直到该结果可用。
如预期的那样,这段代码生成了以下输出:
Short menu: pork, beef, chicken, french fries, rice, season fruit,
pizza, prawns, salmon
Total calories: 4300
Most caloric dish: pork
Dishes by type: {OTHER=[french fries, rice, season fruit, pizza], MEAT=[pork,
beef, chicken], FISH=[prawns, salmon]}
C.2. 性能考虑
由于性能原因,你不应该假设这种方法比多次遍历流更有效。使用阻塞队列造成的开销很容易超过当流由全部在内存中的数据组成时并行执行不同操作的优势。
相反,当涉及到一些昂贵的 I/O 操作时,例如当流源是一个大文件时,只访问一次流可能是一个获胜的选择;因此(就像往常一样),在优化应用程序性能时,唯一有意义的规则是“只测量它!”
这个例子演示了如何在一次操作中执行同一流上的多个操作。更重要的是,我们相信这证明了即使特定的功能不是由原生 Java API 提供的,lambda 表达式的灵活性和一点点的创新,在重用和组合现有功能上,也可以让你自己实现缺失的功能。
附录 D. Lambda 和 JVM 字节码
您可能会想知道 Java 编译器如何实现 lambda 表达式,以及 Java 虚拟机 (JVM) 如何处理它。如果您认为 lambda 表达式可以简单地翻译成匿名类,请继续阅读。本附录简要讨论了 lambda 表达式是如何编译的,通过检查生成的类文件。
D.1. 匿名类
我们在 第二章 中展示了匿名类可以同时声明和实例化一个类。因此,就像 lambda 表达式一样,它们可以用来为函数式接口提供实现。
由于 lambda 表达式为函数式接口的抽象方法提供了实现,因此似乎在编译过程中要求 Java 编译器将 lambda 表达式翻译成匿名类是直截了当的。但是匿名类有一些不希望的特性,这些特性会影响应用程序的性能:
-
编译器为每个匿名类生成一个新的类文件。 文件名通常看起来像
ClassName$1,其中ClassName是匿名类出现的类的名称,后面跟着一个美元符号和一个数字。生成许多类文件是不希望的,因为每个类文件在使用之前都需要被加载和验证,这会影响应用程序的启动性能。如果 lambda 被翻译成匿名类,那么每个 lambda 就会有一个新的类文件。 -
每个新的匿名类都会为类或接口引入一个新的子类型。 如果您有表达
Comparator的 100 个不同的 lambda,那么这意味着 100 个不同的Comparator子类型。在某些情况下,这可能会使 JVM 提高运行时性能变得更加困难。
D.2. 字节码生成
一个 Java 源文件由 Java 编译器编译成 Java 字节码。然后 JVM 可以执行生成的字节码并运行应用程序。匿名类和 lambda 表达式在编译时使用不同的字节码指令。您可以使用以下命令检查任何类文件的字节码和常量池:
javap -c -v ClassName
让我们尝试使用旧的 Java 7 语法实现 Function 接口的实例,作为一个匿名内部类,如下所示。
列表 D.1. 作为匿名内部类实现的 Function
import java.util.function.Function;
public class InnerClass {
Function<Object, String> f = new Function<Object, String>() {
@Override
public String apply(Object obj) {
return obj.toString();
}
};
}
这样做,作为匿名内部类创建的 Function 对应生成的字节码将大致如下:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class InnerClass$1
8: dup
9: aload_0
10: invokespecial #3 // Method InnerClass$1."<init>":(LInnerClass;)V
13: putfield #4 // Field f:Ljava/util/function/Function;
16: return
这段代码展示了以下内容:
-
使用字节码操作
new实例化类型为InnerClass$1的对象。同时将新创建的对象引用压入栈中。 -
操作
dup复制栈上的那个引用。 -
此值随后被指令
invokespecial消耗,该指令初始化对象。 -
栈顶现在仍然包含对对象的引用,该对象使用
putfield指令存储在LambdaBytecode类的f1字段中。
InnerClass$1 是编译器为匿名类生成的名称。如果你想确认这一点,你也可以检查 InnerClass$1 类文件,你将找到实现 Function 接口的代码:
class InnerClass$1 implements
java.util.function.Function<java.lang.Object, java.lang.String> {
final InnerClass this$0;
public java.lang.String apply(java.lang.Object);
Code:
0: aload_1
1: invokevirtual #3 //Method
java/lang/Object.toString:()Ljava/lang/String;
4: areturn
}
D.3. Invokedynamic to the rescue
现在,让我们尝试使用新的 Java 8 语法,即 lambda 表达式,来做同样的事情。检查以下列表中代码生成的类文件。
列表 D.2. 使用 lambda 表达式实现的 Function
import java.util.function.Function;
public class Lambda {
Function<Object, String> f = obj -> obj.toString();
}
你将找到以下字节码指令:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: invokedynamic #2, 0 // InvokeDynamic
#0:apply:()Ljava/util/function/Function;
10: putfield #3 // Field f:Ljava/util/function/Function;
13: return
我们解释了在匿名内部类中翻译 lambda 表达式的缺点,确实你可以看到结果非常不同。额外类的创建已被 invokedynamic 指令所取代。
invokedynamic 指令
invokedynamic 字节码指令是在 JDK7 中引入的,以支持 JVM 上的动态类型语言。invokedynamic 在调用方法时添加了另一层间接性,以便让一些依赖于特定动态语言的逻辑确定调用目标。此指令的典型用途如下:
def add(a, b) { a + b }
在这里,a 和 b 的类型在编译时是未知的,并且可能会不时地改变。因此,当 JVM 首次执行 invokedynamic 时,它会咨询一个启动方法,该启动方法实现了确定要调用实际方法的与语言相关的逻辑。启动方法返回一个链接的调用点。如果 add 方法用两个 int 调用,那么随后的调用也很可能也是用两个 int 调用。因此,在每次调用时都不需要重新发现要调用的方法。调用点本身可以包含定义在什么条件下需要重新链接的逻辑。
在列表 D.2 中,invokedynamic 指令被用于一个与最初引入时的不同目的。实际上,这里它被用来延迟在字节码中翻译 lambda 表达式的策略,直到运行时。换句话说,以这种方式使用 invokedynamic 允许将 lambda 表达式的实现代码生成推迟到运行时。这种设计选择有积极的影响:
-
将 lambda 表达式体翻译成字节码的策略变成了一个纯粹的实施细节。它也可以在动态中更改,或者在未来的 JVM 实现中进行优化和修改,以保持字节码的向后兼容性。
-
如果 lambda 从未使用,则没有开销,例如额外的字段或静态初始化器。
-
对于无状态(非捕获)lambda,可以创建一个 lambda 对象的实例,将其缓存,并始终返回相同的实例。这是一个常见的用例,在 Java 8 之前,人们习惯于显式地这样做;例如,在静态最终变量中声明特定的
Comparator实例。 -
由于这个翻译必须在 lambda 首次被调用时执行,并且其结果被链接,所以没有额外的性能开销。所有后续调用都可以跳过这个慢路径,并调用之前链接的实现。
D.4. 代码生成策略
lambda 表达式通过将其主体放入在运行时创建的静态方法之一中,被翻译成字节码。无状态 lambda,即不捕获其封闭作用域中的任何状态的 lambda,如我们在列表 D.2 中定义的那样,是最简单的 lambda 类型,需要被翻译。在这种情况下,编译器可以生成一个与 lambda 表达式具有相同签名的方 法,因此这个翻译过程的最终结果可以逻辑上看作如下:
public class Lambda {
Function<Object, String> f = [dynamic invocation of lambda$1]
static String lambda$1(Object obj) {
return obj.toString();
}
}
在以下示例中,lambda 表达式捕获最终(或实际上是最终)局部变量或字段的情况,要复杂一些:
public class Lambda {
String header = "This is a ";
Function<Object, String> f = obj -> header + obj.toString();
}
在这种情况下,生成的方法的签名不能与 lambda 表达式相同,因为需要添加额外的参数来携带封闭上下文的额外状态。实现这个目标的最简单方法是,在 lambda 表达式的参数前面添加一个额外的参数,用于每个捕获的变量,因此用于实现前一个 lambda 表达式的生成方法将类似于以下这样:
public class Lambda {
String header = "This is a ";
Function<Object, String> f = [dynamic invocation of lambda$1]
static String lambda$1(String header, Object obj) {
return obj -> header + obj.toString();
}
}
关于 lambda 表达式翻译过程的更多信息,可以在这里找到:cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html。




(
(没有导出条款)
浙公网安备 33010602011771号