Java-编程入门-全-

Java 编程入门(全)

原文:zh.annas-archive.org/md5/C2294D9F4E8891D4151421288379909B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目的是让读者对 Java 基础知识有扎实的理解,通过一系列从基础到实际编程的实践步骤来引导他们。讨论和示例旨在激发专业直觉,使用经过验证的编程原则和实践。

完成本书后,您将能够做到以下事情:

  • 安装 Java 虚拟机并运行它

  • 安装和配置集成开发环境(编辑器)

  • 编写、编译和执行 Java 程序和测试

  • 理解并使用 Java 语言基础知识

  • 理解并应用面向对象设计原则

  • 掌握最常用的 Java 构造

本书适合对象

目标受众是那些想要在现代 Java 编程中追求职业的人,以及想要刷新他们对最新 Java 版本知识的初学者和中级 Java 程序员。

本书涵盖内容

第一章,计算机上的 Java 虚拟机(JVM),介绍了 Java 作为一种语言和工具。它描述了 Java 创建的动机、历史、版本、架构原则和组件。还概述了 Java 的营销定位和主要应用领域。然后,一系列实际步骤将引导您完成 Java 虚拟机在计算机上的安装和配置,以及其使用和主要命令。

第二章,Java 语言基础,介绍了 Java 作为面向对象编程(OOP)语言的基本概念。您将学习类、接口、对象及其关系,以及 OOP 的概念和特性。

第三章,您的开发环境设置,解释了开发环境是什么,并指导您进行配置和调整。它还概述了流行的编辑器和构建框架。逐步说明帮助读者创建自己的开发环境,并进行配置,包括设置类路径并在实践中使用它。

第四章,你的第一个 Java 项目,利用到目前为止学到的一切,引导读者编写程序和开发者测试并运行它们的过程。

第五章,Java 语言元素和类型,使读者熟悉 Java 语言元素:标识符、变量、文字、关键字、分隔符、注释等。它还描述了基本类型和引用类型。特别关注了 String 类、枚举类型和数组。

第六章,接口、类和对象构造,解释了 Java 编程的最重要方面——应用程序编程接口(API)、对象工厂、方法重写、隐藏和重载。还介绍了关键字 this 和 super 的用法。该章节以讨论最终类和方法结束。

第七章,包和可访问性(可见性),介绍了包的概念,并教读者如何创建和使用它以提高代码清晰度。它还描述了类和类成员(方法和属性)的不同可访问性(可见性)级别。最后讨论了封装的关键面向对象设计概念。

第八章,面向对象设计(OOD)原则,提供了 Java 编程的更高层次视图。它讨论了良好设计的标准,并提供了经过验证的 OOD 原则指南。它还演示了说明所讨论原则的代码示例。

第九章,运算符、表达式和语句,帮助您深入了解 Java 编程的三个核心元素:运算符、表达式和语句。您将看到所有 Java 运算符的列表,了解最受欢迎的运算符的详细信息,并能够执行说明每个运算符的关键方面的具体示例。

第十章,控制流语句,描述了允许根据实现的算法逻辑构建程序流的 Java 语句,包括条件语句、迭代语句、分支语句和异常。

第十一章,JVM 进程和垃圾回收,让读者深入了解 JVM,看到它不仅仅是一个程序运行器。除了应用程序线程外,它还执行多个服务线程。其中一个服务线程执行一个重要任务,释放未使用对象的内存。

第十二章,Java 标准和外部库,概述了包含在 JDK 中的最受欢迎的库和外部库。简要示例演示了库的功能。该章还指导用户如何在互联网上找到库。

第十三章,Java 集合,向您介绍了 Java 集合,并提供了演示它们用法的代码示例。

第十四章,管理集合和数组,向您介绍了允许您创建、初始化和修改集合和数组的类。它们还允许创建不可修改和不可变的集合。其中一些类属于 Java 标准库,另一些属于流行的 Apache Commons 库。

第十五章,管理对象、字符串、时间和随机数,演示了 Java 标准库和 Apache Commons 中的类和实用程序,每个程序员都必须掌握,以成为有效的编码人员。

第十六章,数据库编程,解释了如何编写能够操作数据库中数据的 Java 代码——插入、读取、更新和删除。它还提供了 SQL 语言和基本数据库操作的简要介绍。

第十七章,Lambda 表达式和函数式编程,解释了函数式编程的概念。它概述了 JDK 提供的函数式接口,并解释了如何在 lambda 表达式中使用它们。

第十八章,流和管道,向读者介绍了数据流处理的强大概念。它解释了流是什么,如何使用 lambda 表达式处理它们,以及如何构建处理管道。它还展示了如何轻松地并行组织流处理。

第十九章,响应式系统,概述了您未来职业工作的前景。随着更多数据被处理和服务变得更加复杂,对更具适应性、高度可扩展和分布式流程的需求呈指数级增长,这正是我们将在本章中解决的问题——这样的软件系统在实践中是什么样子。

充分利用本书

读者不需要对 Java 编程有先验知识,尽管对编程的理解会帮助他们从本书中获得最多的知识。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. Click on Code Downloads & Errata.

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作。

文件下载后,请确保使用以下最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Introduction-to-Programming。我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上获得。请查看!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/IntroductiontoProgramming_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘"。

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望引起您对代码块的特别关注时,相关行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都将按照以下格式编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"从管理面板中选择系统信息"。

警告或重要提示会显示为这样。

提示和技巧会显示为这样。

Get in touch

我们始终欢迎读者的反馈。

一般反馈:请通过电子邮件feedback@packtpub.com,并在主题中提及书名。如果您对本书的任何方面有疑问,请通过电子邮件questions@packtpub.com与我们联系。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误确实会发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并提供材料链接。

如果您有兴趣成为作者:如果您对某个专题有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。阅读并使用本书后,为什么不在购买书籍的网站上留下评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决策,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问packtpub.com

第一章:在您的计算机上安装 Java 虚拟机(JVM)

本书将指导您达到中级 Java 编程技能。编程不仅仅是了解语言语法。它还涉及编写、编译和执行程序或运行整个软件系统所需的工具和信息来源。这条路上的第一步是学习 Java 的重要组件,包括Java 开发工具包JDK)和Java 虚拟机JVM)。

本章将介绍 Java 作为一种语言和工具,并建立最重要的术语。它还将描述 Java 创建背后的动机,涵盖其历史、版本、版本和技术,并概述 Java 的营销位置和主要应用领域。然后,一系列实际步骤将引导读者完成在其计算机上安装和配置 Java,并介绍主要的 Java 命令。

在本章中,我们将涵盖以下主题:

  • 什么是 Java?

  • Java 平台、版本、版本和技术

  • Java SE 开发工具包(JDK)的安装和配置

  • 主要的 Java 命令

  • 练习- JDK 工具和实用程序

什么是 Java?

由于本书是为初学者编写的,我们将假设你对 Java 几乎一无所知。但即使你知道一些,甚至很多,回顾基础知识也总是有帮助的,即使只是让你通过欣赏自己已经掌握了多少而感到自豪。因此,我们将从定义 Java、JVM、编译、字节码等术语开始。

基本术语

在谈论 Java 时,人们将 Java、JVM、JDK、SDK 和 Java 平台视为同义词。法律定义将 Java 视为Sun 公司一套技术的商标,但我们通常不会将 Java 视为商标。最常见的情况是,当有人说 Java 时,他们指的是一种由人类用来表达一系列指令(程序)的编程语言,这些指令可以由计算机执行(不是直接执行,而是在程序被编译/转换为计算机理解的代码之后)。人类可读的 Java 程序称为源代码,经过所有转换后的计算机可读程序称为二进制代码,因为它只使用 1 和 0 来表示。

您可以在docs.oracle.com/javase/specs/找到完整的Java 语言规范(描述)。它比人们预期的要容易得多,即使对于新手来说,它也可能会有所帮助,特别是如果将其用作参考文档。不要因为前几节的正式语言而感到泄气。尽量阅读你能理解的部分,并在理解 Java 的过程中回来,以及对更深入和更精确的定义的动力增加时再来阅读。

JVM 是一个程序,它将 Java.class文件的字节码翻译成二进制机器码,并将其发送到微处理器执行。

你有没有注意到有两个类似的术语,bytecodebyte code?在对话中,这两者的区别几乎是不可察觉的,所以人们可以互换使用它们。但是它们是有区别的。Byte code(或Byte Code,更准确地说)是一种可以由名为 JVM 的特殊程序执行的语言。相比之下,bytecode是由 Java 编译器(另一个程序)生成的指令的格式(每个指令占用一个字节,因此得名),该编译器读取人类可读的源代码并将其转换为 Byte Code。

Bytecode 是以 JVM 理解的格式表达的二进制代码。然后,JVM 读取(加载,使用名为类加载器的程序)字节码,将指令转换为二进制代码(JVM 正在运行的特定计算机微处理器理解的格式中的指令),并将结果传递给 CPU,即执行它的微处理器。

类是由 Java 编译器生成的文件(扩展名为.class),从具有相同名称和扩展名为.java 的源代码文件中生成。有十多种 JVM 实现,由不同公司创建,但我们将重点关注 Oracle JVM 的实现,称为 HotSpot。在第十一章,JVM 进程和垃圾收集中,我们将更仔细地查看 JVM 的功能、架构和进程。

在 Java 语言规范(https://docs.oracle.com/javase/specs)的同一页上,您可以找到 Java 虚拟机规范。我们建议您将其用作术语和理解 JVM 功能的参考来源。

JDK 是一组软件工具和支持库,允许创建和执行 Java 语言程序。

自 Java 9 以来,不再支持小程序(可以在浏览器中执行的组件),因此我们将不再详细讨论它们。应用程序是可以(编译后)在安装了 JVM 的计算机上执行的 Java 程序。因此,JDK 至少包括编译器、JVM 和 Java 类库(JCL)-一组可供应用程序调用的即用程序。但实际上,它还有许多其他工具和实用程序,可以帮助您编译、执行和监视 Java 应用程序。包含 JVM、JCL、类加载器和支持文件的 JDK 子集允许执行(运行)字节码。这样的组合称为 Java 运行时环境(JRE)。每个 Java 应用程序都在单独的 JVM 实例(副本)中执行,该实例具有自己分配的计算机内存,因此两个 Java 应用程序不能直接交流,而只能通过网络(Web 服务和类似手段)进行交流。

软件开发工具包(SDK)是一组软件工具和支持库,允许使用特定编程语言创建应用程序。Java 的 SDK 称为 JDK。

因此,当人们在提到 JDK 时使用 SDK 时,他们是正确的,但不够精确。

Java 平台由编译器、JVM、支持库和其他工具组成。

在前述定义中的支持库是 Java 标准库,也称为 JCL,并且对于执行字节码是必需的。如果程序需要一些其他库(不包括在 JCL 中),则它们必须在编译时添加(参见第三章,您的开发环境设置,描述了如何执行此操作),并包含在生成的字节码中。Java 平台可以是以下四种之一:Java 平台标准版(Java SE)、Java 平台企业版(Java EE)、Java 平台微型版(Java ME)或 Java Card。以前还有 JavaFX 平台,但自 Java 8 以来已合并到 Java SE 中。我们将在下一节讨论差异。

Open JDK 是 Java SE 的免费开源实现。

这些是最基本的术语。其他术语将根据需要在本书的相应上下文中介绍。

历史和流行度

Java 于 1995 年首次由 Sun Microsystems 发布。它源自 C 和 C++,但不允许用户在非常低的层次上操纵计算机内存,这是许多困难的根源,包括内存泄漏相关的问题,如果 C 和 C++程序员对此不太小心的话,他们会遇到。Java 因其简单性、可移植性、互操作性和安全性而脱颖而出,这使其成为最受欢迎的编程语言之一。据估计,截至 2017 年,全球有近 2000 万程序员(其中近 400 万在美国),其中大约一半使用 Java。有充分的理由相信,未来对软件开发人员的需求,包括 Java 开发人员,只会增长。因此,学习 Java 看起来是迈向稳定职业的一步。而学习 Java 实际上并不是非常困难。我们将向您展示如何做到这一点;只需继续阅读、思考,并在计算机上实践所有建议。

Java 被构想为一种允许用户一次编写,到处运行的工具-这是另一个解释和理解的术语。这意味着编译后的 Java 代码可以在支持 Java 的所有计算机上运行,而无需重新编译。正如您已经了解的那样,支持 Java意味着对于每个操作系统,都存在一个可以将字节码转换为二进制代码的解释器。这就是到处运行的实现方式:只要有 Java 解释器可用的地方。

在概念被证明受欢迎并且 Java 牢固地确立为其他面向对象语言中的主要参与者之一后,Sun Microsystems 将其大部分 JVM 作为自由和开源软件,并受 GNU通用公共许可证GPL)管理。2007 年,Sun Microsystems 将其所有 JVM 的核心代码都以自由和开源的分发条款提供,除了一小部分 Sun 没有版权的代码。2010 年,甲骨文收购了 Sun Microsystems,并宣布自己是Java 技术的管理者,致力于培育参与和透明度的社区

如今,Java 在许多领域中被广泛使用,最突出的是在 Android 编程和其他移动应用程序中,在各种嵌入式系统(各种芯片和专用计算机)、桌面图形用户界面GUI)开发以及各种网络应用程序,包括网络应用程序和网络服务。Java 也广泛用于科学应用程序,包括快速扩展的机器学习和人工智能领域。

原则

根据Java 编程语言的设计目标www.oracle.com/technetwork/java/intro-141325.html),在创建 Java 语言时有五个主要目标。Java 语言必须是:

  • 面向对象和熟悉:这意味着它必须看起来像 C++,但没有不必要的复杂性(我们将在第二章中讨论面向对象的术语,Java 语言基础

  • 架构中立和可移植:这意味着能够使用 JVM 作为将语言(源代码)与每个特定操作系统的知识(通常称为平台)隔离的环境

  • 高性能:它应该与当时领先的编程语言一样工作

  • 解释性:它可以在不链接的情况下移至执行阶段(从多个.class文件创建单个可执行文件),从而允许更快的编写-编译-执行循环(尽管现代 JVM 经过优化,以保持经常使用的.class文件的二进制版本,以避免重复解释)

  • 多线程:它应该允许多个并发执行作业(线程),例如同时下载图像和处理其他用户命令和数据

  • 动态:链接应该在执行期间发生

  • 安全:它必须在运行时受到良好的保护,以防未经授权的修改

结果证明这些目标是明确定义的和富有成效的,因为 Java 成为了互联网时代的主要语言。

Java 平台、版本、版本和技术

在日常讨论中,一些程序员会交替使用这些术语,但是 Java 平台、版本、版本和技术之间是有区别的。本节将重点解释这一点。

平台和版本

我们几乎每天都会听到“平台”这个术语。它的含义取决于上下文,但在最一般的意义上,它指的是一个允许某人做某事的设备或环境。它作为一个基础、一个环境、一个平台。在信息技术领域,平台提供了一个操作环境,软件程序可以在其中开发和执行。操作系统是平台的典型例子。Java 有自己的操作环境,正如我们在前面的部分中提到的,它有四个平台(和六个版本):

  • Java 平台标准版(Java SE):当人们说 Java 时,他们指的是这个版本。它包括 JVM、JCL 和其他工具和实用程序,允许在桌面和服务器上开发和部署 Java 应用程序。在本书中,我们将在这个版本的范围内进行讨论,并且只在本节中提到其他版本。

  • Java 平台企业版(Java EE):由 Java SE、服务器(提供应用程序服务的计算机程序)、增强库、代码示例、教程和其他文档组成,用于开发和部署大规模、多层次和安全的网络应用程序。

  • Java 平台微型版(Java ME):这是 Java SE 的一个小型(使用少量资源)子集,具有一些专门的类库,用于开发和部署嵌入式和移动设备的 Java 应用程序,比如手机、个人数字助理、电视机顶盒、打印机、传感器等。还有一个针对 Android 编程的 Java ME 变体(具有自己的 JVM 实现),由 Google 开发。它被称为 Android SDK。

  • Java Card:这是 Java 平台中最小的一个,用于开发和部署 Java 应用程序到小型嵌入式设备,比如智能卡。它有两个版本(引用自官方 Oracle 文档,网址为www.oracle.com/technetwork/java/embedded/javacard/documentation/javacard-faq-1970428.html#3):

  • Java Card Classic Edition,它针对的是当今所有垂直市场上部署的智能卡,基于 ISO7816 和 ISO14443 通信。

  • Java Card Connected Edition,这是为了支持一个 Web 应用程序模型而开发的,其中 servlet 在卡上运行,TCP/IP 作为基本协议,并且在高端安全微控制器上运行,通常基于 32 位处理器,并支持像 USB 这样的高速通信接口。

版本

自 1996 年首次发布以来,Java 已经发展了九个主要版本:

  • JDK 1.0(1996 年 1 月 23 日)

  • JDK 1.1(1997 年 2 月 19 日)

  • J2SE 1.2(1998 年 12 月 8 日)

  • J2SE 1.3(2000 年 5 月 8 日)

  • J2SE 1.4(2002 年 2 月 6 日)

  • J2SE 5.0(2004 年 9 月 30 日)

  • Java SE 6(2006 年 12 月 11 日)

  • Java SE 7(2011 年 7 月 28 日)

  • Java SE 8(2014 年 3 月 18 日)

  • Java SE 9(2017 年 9 月 21 日)

  • Java SE 10(2018 年 3 月 20 日)

关于更改 Java 版本方案有几个建议。自 Java 10 以来,JDK 引入了新的基于时间的版本$YEAR.$MONTH。此外,计划每年 3 月和 9 月发布一个新的 Java 版本。因此,Java 11 将于 2018 年 9 月发布,JVM 版本为 18.9。我们将很快向您展示如何显示您正在使用的 JDK 版本。

技术

技术这个词被滥用了。程序员几乎用它来表示任何东西。如果您查看甲骨文的 Java 技术列表(www.oracle.com/java/technologies/index.html),您将找到以下列表:

  • 嵌入式,包括以前列出的除了 Java EE 之外的所有 Java 平台,通常经过一些修改,通常具有更小的占用空间和其他优化

  • Java SE,包括 Java SE 和 Java SE Advanced,其中包括 Java SE 和一些用于企业级(不仅仅是开发计算机)安装的监控和管理工具

  • Java EE,如前所述

  • ,包括基于云的可靠、可扩展和弹性的服务

但在 Oracle 词汇表(www.oracle.com/technetwork/java/glossary-135216.html)中,以下技术被添加到列表中:

  • JavaSpaces:提供分布式持久性的技术

  • Jini 技术:一种应用程序编程接口API),可以自动连接设备和服务

在其他地方,在 Oracle Java 10 文档的首页(docs.oracle.com/javase/10),客户端技术列如下:

与此同时,在 Oracle Java 教程(docs.oracle.com/javase/tutorial/getStarted/intro/cando.html)中,Java Web StartJava Plug-In被提及为部署技术,用于将您的应用程序部署到最终用户。

然而,甲骨文提供的最大的 Java 技术列表在专门用于技术网络的页面上(www.oracle.com/technetwork/java/index.html)。除了 Java SE、Java SE Advanced 和 Suite、Java 嵌入式、Java EE、Java FX 和 Java Card 之外,还列出了Java TVJava DB开发工具。如果您转到 Java SE 或 Java EE 页面,在“技术”选项卡下,您会发现超过两打的 API,以及各种软件组件也列为技术。因此,人们不应该感到惊讶在任何地方找到任何种类的 Java 技术列表。

似乎与 Java 有关的任何东西都至少被称为技术一次。为了避免进一步的混淆,从现在开始,在本书中,我们将尽量避免使用技术这个词。

Java SE 开发工具包(JDK)安装和配置

从现在开始,每当我们谈论 Java 时,我们指的是 Java SE 10 版。我们将把它称为 Java 10,或 Java,或 JDK,除非另有说明。

从哪里开始

在您的计算机上进行任何 Java 开发之前,您需要安装和配置 JDK。为了做到这一点,搜索互联网以获取 JDK 下载,并选择任何以www.oracle.com/开头的链接。截至目前,最好的链接应该是www.oracle.com/technetwork/java/javase/downloads/index.html

如果您按照上述链接,您将看到这个部分:

让我们称这个页面为Page1,以供以后参考。现在,您可以点击 JDK 下的下载链接。其他两个下载链接提供了 JRE,正如您已经知道的,它只允许您运行已经编译的 Java 程序;我们需要编写一个程序,将其编译成字节码,然后运行它。

带有 Java 安装程序的页面

点击后,您将看到一个页面(Page2)有这个部分:

这些是不同操作系统OS)的 Java 安装程序。您需要选择适合您操作系统的程序,并单击相应的链接(不要忘记使用单选按钮点击接受许可协议;如果有疑问,通过链接 Oracle Binary Code License Agreement for Java SE 阅读许可协议)。对于 Linux,有两个安装程序 - 一个是 Red Hat Package Manager 格式(.rpm),另一个只是一个存档(.tar)和压缩(.gz)版本。还要注意,在此列表中,只有 64 位操作系统的安装程序。截至目前,尚不清楚 32 位版本是否会被正式弃用,尽管它作为早期访问版本可用。

选择您需要的安装程序,并下载它。

如何安装

现在是安装 Java 的时候,基本上包括以下四个步骤:

  1. 扩展安装程序

  2. 创建目录

  3. 将文件复制到这些目录中

  4. 使 Java 可执行文件无需输入完整路径

要找到详细的安装说明,返回Page1并点击安装说明链接。找到适用于您操作系统的链接,并按照提供的步骤进行操作,但只选择与 JDK 相关的步骤。

最终,您将能够运行java -version命令,它将显示以下内容:

如您所见,它显示 Java 的版本为 10.0.1,JRE 和 JVM 的版本为 18.3(构建 10.0.1)。目前还不清楚未来的 Java、JRE 和 JVM 版本是否会遵循相同的格式。

无论如何,如果java -version命令显示您尝试安装的版本,这意味着您已经正确安装了 Java,现在可以享受与之一起工作。从现在开始,每当有新版本发布时,您都会收到升级提示,您只需点击提供的链接即可进行升级。或者,您可以转到安装程序页面(Page2),下载相应的安装程序,启动它,并重复您已经熟悉的过程。

实际上,程序员并不会每次都升级他们的 Java 安装。他们会保持开发版本与生产环境中的 Java 版本相同(以避免潜在的不兼容性)。如果他们想在升级生产环境之前尝试新版本,他们可能会在计算机上安装两个版本的 Java,并行使用。在第三章中,您的开发环境设置,您将学习如何做到这一点,以及如何在它们之间切换。

主要的 Java 命令

在前一节中,您看到了一个 Java 命令的示例,显示了 JVM 版本。顺便说一句,命令java启动了 JVM,并用于运行编译后的 Java 程序的字节码(我们将在第四章中详细演示如何做到这一点,您的第一个 Java 项目)。

JVM 执行命令

现在,如果您只运行java,输出将显示帮助的简短版本。由于它相当长,我们将分几部分显示。这是第一部分:

它显示了三种运行 JVM 的方式:

  • 执行一个类,一个包含字节码的.class文件

  • 要执行一个 jar 文件,一个带有扩展名.jar的文件,其中包含以 ZIP 格式的.class文件(甚至可能是整个应用程序),还包括一个特定于 Java 的清单文件

  • 执行模块中的主类(一组.class文件和其他资源,比.jar文件更好地结构化),通常是应用程序或其一部分

如你所见,在上述每个命令中,都必须显式提供一个主类。它是必须首先执行的.class文件。它充当应用程序的主入口,并启动加载其他类(在需要时)以运行应用程序的链。这样的命令示例是:

java MyGreatApplication

实际上,这意味着当前目录中有一个名为MyGreatApplication.class的文件,但我们不应指定文件扩展名。否则,JVM 将寻找文件MyGreatApplication.class.class,当然找不到,也无法运行任何内容。

在本书中,我们不会显式使用这些命令中的任何一个,并且将其留给编辑器在幕后运行,因为现代编辑器不仅帮助编写和修改源代码;它还可以编译和执行编写的代码。这就是为什么它不仅被称为编辑器,而是集成开发环境IDE)。

尽管如此,我们将继续概述所有java命令选项,这样你就会知道在你的 IDE 背后发生了什么。要享受驾车乐趣,不需要了解引擎的内部工作细节,但了解其运作原理是有帮助的。此外,随着你的专业水平的提高和你所工作的应用程序的增长,你将需要调整 JVM 配置,因此这是第一次在幕后偷看。

以下是java命令输出的下一部分:

在前面的屏幕截图中,你可以看到两个已弃用的选项,后面是与类路径和模块路径相关的选项。最后两个选项非常重要。它们允许指定应用程序所在位置的类和应用程序使用的库的位置。后者可以是你编写的类或第三方库。

模块的概念超出了本书的范围,但模块路径的使用方式与类路径非常相似。类路径选项告诉 JVM 在哪里查找类,而模块路径告诉 JVM 模块的位置。可以在同一命令行中同时使用两者。

例如,假设你有一个名为MyGreatApplication.class的文件(其中包含你的程序的字节码MyGreatApplication.java),存储在dir2目录中,这是dir1目录的子目录,你的终端窗口当前显示的是dir1目录的内容:

如你所见,还有另一个目录dir3,我们创建它来存储另一个文件SomeOtherProgram.class,这是你的应用程序使用的。我们还在dir4中放入了其他支持的.class文件库,这些文件被收集在SomeLibrary.jar中。然后运行你的应用程序的命令行如下:

java -cp dir2:dir3:dir4/SomeLibrary.jar  MyGreatApplication //on Unix
java -cp dir2;dir3;dir4\SomeLibrary.jar  MyGreatApplication //on Windows

或者,我们可以将SomeOtherProgram.classMyGreatApplication.class放入some.jarsome.zip文件,并将其放在dir5中。然后,命令将采用以下形式之一:

java -cp dir4/SomeLibrary.jar:dir5/some.zip MyGreatApplication //Unix
java -cp dir4/SomeLibrary.jar:dir5/some.jar MyGreatApplication //Unix
java -cp dir4\SomeLibrary.jar;dir5\some.zip MyGreatApplication //Windows
java -cp dir4\SomeLibrary.jar;dir5\some.jar MyGreatApplication //Windows

我们可以使用-cp选项,也可以使用-classpath--class-path选项。它们只是三种不同的约定,以便习惯于其中一种的人可以直观地编写命令行。这些风格中没有一个比其他更好或更差,尽管我们每个人都有偏好和意见。如果没有使用任何 classpath 选项,JVM 只会在当前目录中查找类。一些类(标准库)总是位于 Java 安装的某些目录中,因此无需使用 classpath 选项列出它们。我们将在第三章中更详细地讨论设置 classpath。

java命令输出的下一部分列出了一些选项,允许在实际执行应用程序之前验证一切是否设置正确:

由于模块超出了本书的范围,我们将跳过这些内容,继续输出的下一部分:

-D 选项允许设置一个可供应用程序访问的带有值的参数。它经常用于向应用程序传递一些值或标志,应用程序可以用来改变其行为。如果需要传递更多信息,那么就使用.properties文件(带有许多标志和各种值),而属性文件的位置则通过-D选项传递。完全取决于程序员,.properties文件或通过-D选项传递的值应该是什么。但是与应用程序配置相关的最佳实践也取决于您使用的特定框架或库。您将随着时间学会它们,这些实践超出了初学者程序员课程。

-verbose 选项提供了更多信息(比我们在这些截图中看到的)和一些特定的数据,取决于标志classmodulegcjni,其中gc代表垃圾收集器,将在第十一章中讨论。对于其他标志,您可以阅读官方的 Oracle 文档,但很可能您不会很快使用它们。

-version 选项显示已安装的 Java 版本。这在第一天就非常有用,因为它允许随时检查当前使用的 Java 版本。在前面的部分中,我们演示了如何做到这一点,以及它产生的输出。当发布新版本的 Java 时,许多程序员会与他们当前使用的版本并行安装它,并在它们之间切换,无论是为了学习新功能还是为了开始为新版本编写代码,同时保留为旧版本编写的旧代码。您将学会如何在同一台计算机上安装两个版本的 Java,并在第三章中,您的开发环境设置中学会如何在它们之间切换。

我们将跳过与模块相关的选项。

在前面的截图中的其余选项与帮助相关。选项-?-h-help--help显示了我们在这些截图中展示的内容,而选项-X--help-extra提供了额外的信息。您可以自己尝试所有这些选项。

帮助输出的最后一部分如下:

我们将不讨论这些选项。只需注意如何使用上一行中解释的长选项(带有两个连字符)。

编译命令

如前所述,用 Java 编写的程序称为源代码,并存储在.java文件中。编译命令javac读取它,并创建相应的带有 Java 字节码的.class文件。

让我们运行javac命令,而不指定.java文件。它将显示帮助信息。让我们分部分地进行审查:

帮助告诉我们,这个命令的格式如下:

javac <options> <source files>

要编译一些文件,可以在选项后的命令行中列出它们(如果文件不在当前目录中,必须使用绝对或相对路径前置文件名)。 列出的文件在 Oracle Solaris 中用冒号(:)分隔,在 Windows 中用分号(;)分隔,可以是目录、.jar文件或.zip文件。 还可以列出文件中的所有源文件,并使用@filename选项提供此文件名(请参阅前面的屏幕截图)。 但不要试图记住所有这些。 您很少(如果有的话)会显式运行javajavac命令。 您可能会使用一个 IDE 为您执行(请参阅第三章,您的开发环境设置)。 这也是我们将跳过前面屏幕截图中列出的大多数选项并仅提到其中两个选项的原因:--class-path(或-classpath-cp),它指定当前编译代码所需的.class文件的位置,和-d,它指示创建.class文件的位置。

以下是javac帮助的下一部分:

我们将在此提到前面屏幕截图中的唯一选项是--help(或-help),它提供了我们现在正在浏览的相同帮助消息。

最后,javac帮助的最后一部分如下:

我们已经描述了选项--source-path(或-sourcepath)。 选项-verbose要求编译器提供更详细的报告,说明它正在做什么,而选项--version(或-version)显示 JDK 版本:

命令 jcmd 和其他命令

还有十几个其他的 Java 命令(工具和实用程序),您可能只有在专业编程几年后才会开始使用,如果有的话。 它们都在 Oracle Java 在线文档中有描述。 只需搜索 Java 实用程序和工具。

其中,我们只找到一个从 Java 编程的第一天起就非常有用的命令jcmd。 如果运行它,它会显示计算机上正在运行的所有 Java 进程(JVM 实例)。 在此示例中,您可以看到三个 Java 进程,进程 ID 分别为 3408、3458 和 3454:

进程 3408 运行 Maven 服务器(您的 IDE 通常会启动它)。 进程 3458 是我们运行jcmd。 进程 3454 是一个编辑器(IDE)IntelliJ IDEA,正在运行小型演示应用程序com.packt.javapath.App

这样,您可以随时检查您的计算机上是否有一个失控的 Java 进程。 如果您想要停止它,可以使用任务管理器,或者需要 PID 的kill命令。

当您想要监视您的 Java 应用程序时,也需要了解 PID。 我们将在第十一章,JVM 进程和垃圾收集中讨论这一点。

通过这一点,我们完成了对 Java 命令的概述。 正如我们已经提到的,您的 IDE 将在幕后使用所有这些命令,因此您可能永远不会使用它们,除非您进行生产支持(这是在您开始学习 Java 几年后)。 但我们认为您需要了解它们,这样您就可以连接 Java 开发过程的各个方面。

练习 - JDK 工具和实用程序

在您的计算机上,找到 Java 安装目录,并列出所有命令(工具和实用程序) - 执行文件 - 存在那里。

如果您在其他可执行文件中看到javajavac,则您就在正确的位置。

答案

以下是安装在 Java 10.0.1 中的所有可执行文件的列表:

找到这个目录的一种方法是查看环境变量PATH的值。例如,在 Mac 电脑上,Java 安装在目录/Library/Java/JavaVirtualMachines/jdk-10.jdk/Contents/Home/bin中。

描述 JVM 安装位置的 Oracle 文档可以在www.java.com/en/download/help/version_manual.xml找到。

总结

在本章中,您已经学习了最重要的与 Java 相关的术语——JVM、JDK、SDK、Java 平台等,涵盖了 Java 程序生命周期的主要阶段,从源代码到字节码再到执行。您还了解了 Java 的历史、创建背后的动机、版本和版本。提供的实际步骤和建议帮助您在计算机上安装 Java 并运行其主要命令javajavacjcmd。有关更多详细信息,您被引用到官方的 Oracle 文档。找到并理解这些文档的能力是成为 Java 程序员成功的先决条件,因此我们建议您跟随所有提供的链接,并在互联网上进行一些相关搜索,以便您能够轻松找到良好的信息来源。

在下一章中,我们将深入探讨 Java 作为一种编程语言,并涵盖基础知识。这将成为接下来章节的基础(或者说是一个跳板)。如果您是 Java 的新手,我们建议您继续阅读而不要跳过,因为每一章都建立在前一章的知识基础上。即使您对 Java 有一些了解,重新复习基础知识也总是有帮助的。拉丁谚语说:“Repetitio est mater studiorum”(重复是学习之母)。

第二章:Java 语言基础

现在您对 Java 及其相关术语和工具有了一个大致的了解,我们将开始讨论 Java 作为一种编程语言。

本章将介绍 Java 作为面向对象编程OOP)语言的基本概念。您将了解类、接口和对象及其关系。您还将学习 OOP 的概念和特性。

在本章中,我们将涵盖以下主题:

  • Java 编程的基本术语

  • 类和对象(实例)

  • 类(静态)和对象(实例)成员

  • 接口、实现和继承

  • OOP 的概念和特性

  • 练习-接口与抽象类

我们称它们为基础,因为它们是 Java 作为一种语言的基本原则,而在您可以开始专业编程之前还有更多要学习。对于那些第一次学习 Java 的人来说,学习 Java 的基础是一个陡峭的斜坡,但之后的道路会变得更容易。

Java 编程的基本术语

Java 编程基础的概念有很多解释。一些教程假设基础对于任何面向对象的语言都是相同的。其他人讨论语法和基本语言元素和语法规则。还有一些人将基础简化为允许计算的值类型、运算符、语句和表达式。

我们对 Java 基础的看法包括了前面各种方法的一些元素。我们选择的唯一标准是实用性和逐渐增加的复杂性。我们将从本节的简单定义开始,然后在后续章节中深入探讨。

字节码

在最广泛的意义上,Java 程序(或任何计算机程序)意味着一系列顺序指令,告诉计算机该做什么。在计算机上执行之前,程序必须从人类可读的高级编程语言编译成机器可读的二进制代码。

在 Java 的情况下,人类可读的文本,称为源代码,存储在一个.java文件中,并可以通过 Java 编译器javac编译成字节码。Java 字节码是 JVM 的指令集。字节码存储在一个.class文件中,并可以由 JVM 或更具体地说是由 JVM 使用的即时JIT)编译器解释和编译成二进制代码。然后由微处理器执行二进制代码。

字节码的一个重要特点是它可以从一台机器复制到另一台机器的 JVM 上执行。这就是 Java 可移植性的含义。

缺陷(bug)及其严重程度和优先级

bug这个词,意思是小故障和困难,早在 19 世纪就存在了。这个词的起源是未知的,但看起来好像动词to bug的意思是打扰,来自于一种讨厌的感觉,来自于一个嗡嗡作响并威胁要咬你或其他东西的昆虫-虫子。这个词在计算机第一次建造时就被用于编程缺陷。

缺陷的严重程度各不相同-它们对程序执行或结果的影响程度。一些缺陷是相当微不足道的,比如数据以人类可读的格式呈现。如果同样的数据必须由其他无法处理这种格式的系统消耗,那就另当别论了。那么这样的缺陷可能被归类为关键,因为它将不允许系统完成数据处理。

缺陷的严重程度取决于它对程序的影响,而不是修复它有多困难。

一些缺陷可能会导致程序在达到期望结果之前退出。例如,一个缺陷可能导致内存或其他资源的耗尽,并导致 JVM 关闭。

缺陷优先级,缺陷在待办事项列表中的高度,通常与严重性相对应。但是,由于客户的感知,一些低严重性的缺陷可能会被优先考虑。例如,网站上的语法错误,或者可能被视为冒犯的拼写错误。

缺陷的优先级通常对应于其严重性,但有时,优先级可能会根据客户的感知而提高。

Java 程序依赖

我们还提到,程序可能需要使用已编译为字节码的其他程序和过程。为了让 JVM 找到它们,您必须在java命令中使用-classpath选项列出相应的.class文件。几个程序和过程组成了一个 Java 应用程序。

应用程序用于其任务的其他程序和过程称为应用程序依赖项。

请注意,JVM 在其他类代码请求之前不会读取.class文件。因此,如果在应用程序执行期间不发生需要它们的条件,那么类路径上列出的一些.class文件可能永远不会被使用。

语句

语句是一种语言构造,可以编译成一组指令给计算机。与日常生活中的 Java 语句最接近的类比是英语语句,这是一种表达完整思想的基本语言单位。Java 中的每个语句都必须以;(分号)结尾。

以下是一个声明语句的示例:

int i;

前面的语句声明了一个int类型的变量i,代表整数(见第五章,Java 语言元素和类型)。

以下是一个表达式语句:

 i + 2; 

前面的语句将 2 添加到现有变量i的值中。当声明时,int变量默认被赋值为 0,因此此表达式的结果为2,但未存储。这就是为什么它经常与声明和赋值语句结合使用的原因:

int j = i + 2;

这告诉处理器创建一个int类型的变量j,并为其分配一个值,该值等于变量i当前分配的值加 2。在第九章,运算符、表达式和语句中,我们将更详细地讨论语句和表达式。

方法

Java 方法是一组语句,总是一起执行,目的是对某个输入产生某个结果。方法有一个名称,要么一组输入参数,要么根本没有参数,一个在{}括号内的主体,以及一个返回类型或void关键字,表示该消息不返回任何值。以下是一个方法的示例:

int multiplyByTwo(int i){
  int j = i * 2;
  return j;
}

在前面的代码片段中,方法名为multiplyByTwo。它有一个int类型的输入参数。方法名和参数类型列表一起称为方法签名。输入参数的数量称为arity。如果两个方法具有相同的名称、相同的 arity 和相同的输入参数列表中类型的顺序,则它们具有相同的签名。

这是从 Java 规范第8.4.2 节方法签名中摘取的方法签名定义的另一种措辞。另一方面,在同一规范中,人们可能会遇到诸如:具有相同名称和签名的多个方法Tuna中的方法getNumberOfScales具有名称、签名和返回类型等短语。因此,要小心;即使是规范的作者有时也不将方法名包括在方法签名的概念中,如果其他程序员也这样做,不要感到困惑。

同一个前面的方法可以用许多风格重写,并且得到相同的结果:

int multiplyByTwo(int i){ 
  return i * 2;
}

另一种风格如下:

int multiplyByTwo(int i){ return i * 2; }

一些程序员更喜欢最紧凑的风格,以便能够在屏幕上看到尽可能多的代码。但这可能会降低另一个程序员理解代码的能力,这可能会导致编程缺陷。

另一个例子是一个没有输入参数的方法:

int giveMeFour(){ return 4; }

这是相当无用的。实际上,没有参数的方法会从数据库中读取数据,例如,或者从其他来源读取数据。我们展示这个例子只是为了演示语法。

这是一个什么都不做的代码示例:

void multiplyByTwo(){ }

前面的方法什么也不做,也不返回任何东西。语法要求使用关键字void来指示没有返回值。实际上,没有返回值的方法通常用于将数据记录到数据库,或者发送数据到打印机、电子邮件服务器、另一个应用程序(例如使用 Web 服务),等等。

为了完整起见,这是一个具有许多参数的方法的示例:

String doSomething(int i, String s, double a){
  double result = Math.round(Math.sqrt(a)) * i;
  return s + Double.toString(result);
}

上述方法从第三个参数中提取平方根,将其乘以第一个参数,将结果转换为字符串,并将结果附加(连接)到第二个参数。将在第五章中介绍使用的Math类的类型和方法,Java 语言元素和类型。这些计算并没有太多意义,仅供说明目的。

Java 中的所有方法都声明在称为的结构内。一个类有一个名称和一个用大括号{}括起来的主体,在其中声明方法:

class MyClass {
  int multiplyByTwo(int i){ return i * 2; }
  int giveMeFour(){ return 4;} 
}

类也有字段,通常称为属性;我们将在下一节讨论它们。

主类和主方法

一个类作为 Java 应用程序的入口。在启动应用程序时,必须在java命令中指定它:

java -cp <location of all .class files> MyGreatApplication

在上述命令中,MyGreatApplication是作为应用程序起点的类的名称。当 JVM 找到文件MyGreatApplication.class时,它会将其读入内存,并在其中查找名为main()的方法。这个方法有一个固定的签名:

public static void main(String[] args) {
  // statements go here
}

让我们把前面的代码片段分成几部分:

  • public表示这个方法对任何外部程序都是可访问的(参见第七章,包和可访问性(可见性)

  • static表示该方法在所有内存中只存在一个副本(参见下一节)

  • void表示它不返回任何东西

  • main是方法名

  • String[] args表示它接受一个 String 值的数组作为输入参数(参见第五章,Java 语言元素和类型

  • //表示这是一个注释,JVM 会忽略它,这里只是为了人类(参见第五章,Java 语言元素和类型

前面的main()方法什么也不做。如果运行,它将成功执行但不会产生结果。

您还可以看到输入参数写成如下形式:

public static void main(String... args) {
  //body that does something
}

它看起来像是不同的签名,但实际上是相同的。自 JDK 5 以来,Java 允许将方法签名的最后一个参数声明为相同类型的变量可变性的一系列参数。这被称为varargs。在方法内部,可以将最后一个输入参数视为数组String[],无论它是显式声明为数组还是作为可变参数。如果你一生中从未使用过 varargs,那么你会没问题。我们告诉你这些只是为了让你在阅读其他人的代码时避免混淆。

main()方法的最后一个重要特性是其输入参数的来源。没有其他代码调用它。它是由 JVM 本身调用的。那么参数是从哪里来的呢?人们可能会猜想命令行是参数值的来源。在java命令中,到目前为止,我们假设没有参数传递给主类。但是如果主方法期望一些参数,我们可以构造命令行如下:

java -cp <location of all .class files> MyGreatApplication 1 2

这意味着在main()方法中,输入数组args [0]的第一个元素的值将是1,而输入数组args [1]的第二个元素的值将是2。是的,你注意到了,数组中元素的计数从0开始。我们将在第五章中进一步讨论这个问题,Java 语言元素和类型。无论是显式地使用数组String[] args描述main()方法签名,还是使用可变参数String... args,结果都是一样的。

然后main()方法中的代码调用同一 main.class文件中的方法或使用-classpath选项列出的其他.class文件中的方法。在接下来的部分中,我们将看到如何进行这样的调用。

类和对象(实例)

类用作创建对象的模板。创建对象时,类中声明的所有字段和方法都被复制到对象中。对象中字段值的组合称为对象状态。方法提供对象行为。对象也称为类的实例。

每个对象都是使用运算符new和看起来像一种特殊类型的方法的构造函数创建的。构造函数的主要职责是设置初始对象状态。

现在让我们更仔细地看一看 Java 类和对象。

Java 类

Java 类存储在.java文件中。每个.java文件可以包含多个类。它们由 Java 编译器javac编译并存储在.class文件中。每个.class文件只包含一个已编译的类。

每个.java文件只包含一个public类。类名前的关键字public使其可以从其他文件中的类访问。文件名必须与公共类名匹配。文件还可以包含其他类,它们被编译成自己的.class文件,但只能被给出其名称的公共类访问.java文件。

这就是文件MyClass.java的内容可能看起来像的样子:

public class MyClass {
  private int field1;
  private String field2;
  public String method1(int i){
    //statements, including return statement
  }
  private void method2(String s){
    //statements without return statement
  }
}

它有两个字段。关键字private使它们只能从类内部,从它的方法中访问。前面的类有两个方法 - 一个是公共的,一个是私有的。公共方法可以被任何其他类访问,而私有方法只能从同一类的其他方法中访问。

这个类似乎没有构造函数。那么,基于这个类的对象的状态将如何初始化?答案是,事实上,每个没有显式定义构造函数但获得一个默认构造函数的类。这里有两个显式添加的构造函数的例子,一个没有参数,另一个有参数:

public class SomeClass {
  private int field1;
  public MyClass(){
    this.field1 = 42;
  }
  //... other content of the class - methods
  //    that define object behavior
}

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }
  //... methods here
}

在上面的代码片段中,关键字this表示当前对象。它的使用是可选的。我们可以写field1 = val1;并获得相同的结果。但是最好使用关键字this来避免混淆,特别是当(程序员经常这样做)参数的名称与字段的名称相同时,比如在下面的构造函数中:

public MyClass(int field1, String field1){
  field1 = field1;
  field2 = field2;
}

添加关键字this使代码更友好。有时候,这是必要的。我们将在第六章中讨论这样的情况,接口、类和对象构造

一个构造函数也可以调用这个类或任何其他可访问类的方法:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
    method1(33);
    method2(val2);
  }
  public String method1(int i){
    //statements, including return statement
  }
  private void method2(String s){
    //statements without return statement
  }
}

如果一个类没有显式定义构造函数,它会从默认的基类java.lang.Object中获得一个默认构造函数。我们将在即将到来的继承部分解释这意味着什么。

一个类可以有多个不同签名的构造函数,用于根据应用程序逻辑创建具有不同状态的对象。一旦在类中添加了带参数的显式构造函数,除非也显式添加默认构造函数,否则默认构造函数将不可访问。澄清一下,这个类只有一个默认构造函数:

public class MyClass {
  private int field1;
  private String field2;
  //... other methods here
}

这个类也只有一个构造函数,但没有默认构造函数:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }
  //... other methods here
}

这个类有两个构造函数,一个有参数,一个没有参数:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(){ }
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }
  //... other methods here
}

没有参数的前面构造函数什么也不做。它只是为了方便客户端代码创建这个类的对象,但不关心对象的特定初始状态。在这种情况下,JVM 创建默认的初始对象状态。我们将在第六章中解释默认状态,接口、类和对象构造

同一个类的每个对象,由任何构造函数创建,都有相同的方法(相同的行为),即使它的状态(分配给字段的值)是不同的。

这些关于 Java 类的信息对于初学者来说已经足够了。尽管如此,我们还想描述一些其他类,这些类可以包含在同一个.java文件中,这样你就可以在其他人的代码中识别它们。这些其他类被称为嵌套类。它们只能从同一个文件中的类中访问。

我们之前描述的类-.java文件中唯一的一个公共类-也被称为顶级类。它可以包括一个称为内部类的嵌套类:

public class MyClass { // top-level class
  class MyOtherClass { // inner class   
    //inner class content here
  }
}

顶级类还可以包括一个静态(关于静态成员的更多信息请参见下一节)嵌套类。static类不被称为内部类,只是一个嵌套类:

public class MyClass { // top-level class
  static class MyYetAnotherClass { // nested class
    // nested class content here
  }
}

任何方法都可以包括一个只能在该方法内部访问的类。它被称为本地类:

public class MyClass { // top-level class
  void someMethod() {
    class MyInaccessibleAnywhereElseClass { // local class
      // local class content here
    }
  }
}

本地类并不经常使用,但并不是因为它没有用。程序员只是不记得如何创建一个只在一个方法内部需要的类,而是创建一个外部或内部类。

最后但并非最不重要的一种可以包含在与公共类相同文件中的类是匿名类。它是一个没有名称的类,允许在原地创建一个对象,可以覆盖现有方法或实现一个接口。让我们假设我们有以下接口,InterfaceA,和类MyClass

public interface InterfaceA{
  void doSomething();
}
public class MyClass { 
  void someMethod1() {
    System.out.println("1\. Regular is called");
  }
  void someMethod2(InterfaceA interfaceA) {
    interfaceA.doSomething();
  }
}

我们可以执行以下代码:

MyClass myClass = new MyClass();
myClass.someMethod1();
myClass = new MyClass() {     //Anonymous class extends class MyClass
  public void someMethod1(){              // and overrides someMethod1()
    System.out.println("2\. Anonymous is called");
  }
};
myClass.someMethod1();
myClass.someMethod2(new InterfaceA() { //Anonymous class implements
  public void doSomething(){     //  InterfaceA

    System.out.println("3\. Anonymous is called");
  }
});

结果将是:

1\. Regular is called
2\. Anonymous is called
3\. Anonymous is called

我们不希望读者完全理解前面的代码。我们希望读者在阅读本书后能够做到这一点。

这是一个很长的部分,包含了很多信息。其中大部分只是供参考,所以如果你记不住所有内容,不要感到难过。在完成本书并获得一些 Java 编程的实际经验后,再回顾这一部分。

接下来还有几个介绍性部分。然后[第三章](18c6e8b8-9d8a-4ece-9a3f-cd00474b713e.xhtml),您的开发环境设置,将引导您配置计算机上的开发工具,并且在[第四章](64574f55-0e95-4eda-9ddb-b05da6c41747.xhtml),您的第一个 Java 项目,您将开始编写代码并执行它-每个软件开发人员都记得的时刻。

再走几步,你就可以称自己为 Java 程序员了。

Java 对象(类实例)

人们经常阅读-甚至 Oracle 文档也不例外-对象被用于模拟现实世界的对象。这种观点起源于面向对象编程之前的时代。那时,程序有一个用于存储中间结果的公共或全局区域。如果不小心管理,不同的子例程和过程-那时称为方法-修改这些值,互相干扰,使得很难追踪缺陷。自然地,程序员们试图规范对数据的访问,并且使中间结果只能被某些方法访问。一组方法和只有它们可以访问的数据开始被称为对象。

这些构造也被视为现实世界对象的模型。我们周围的所有对象可能都有某种内在状态,但我们无法访问它,只知道对象的行为。也就是说,我们可以预测它们对这个或那个输入会有什么反应。在类(对象)中创建只能从同一类(对象)的方法中访问的私有字段似乎是隐藏对象状态的解决方案。因此,模拟现实世界对象的原始想法得以延续。

但是经过多年的面向对象编程,许多程序员意识到这样的观点可能会产生误导,并且在试图将其一贯应用于各种软件对象时实际上可能会产生相当大的危害。例如,一个对象可以携带用作算法参数的值,这与任何现实世界的对象无关,但与计算效率有关。或者,另一个例子,一个带回计算结果的对象。程序员通常称之为数据传输对象DTO)。除非扩展现实世界对象的定义,否则它与现实世界对象无关,但那将是一个伸展。

软件对象只是计算机内存中的数据结构,实际值存储在其中。内存是一个现实世界的对象吗?物理内存单元是,但它们携带的信息并不代表这些单元。它代表软件对象的值和方法。关于对象的这些信息甚至不是存储在连续的内存区域中:对象状态存储在一个称为堆的区域中,而方法存储在方法区中,具体取决于 JVM 实现,可能或可能不是堆的一部分。

在我们的经验中,对象是计算过程的一个组成部分,通常不是在现实世界对象的模型上运行。对象用于传递值和方法,有时相关,有时不相关。方法和值的集合可能仅仅为了方便或其他考虑而被分组在一个类中。

公平地说,有时软件对象确实代表现实世界对象的模型。但关键是这并不总是如此。因此,除非真的是这样,让我们不将软件对象视为现实世界对象的模型。相反,让我们看看对象是如何创建和使用的,以及它们如何帮助我们构建有用的功能 - 应用程序。

正如我们在前一节中所描述的,对象是基于类创建的,使用关键字new和构造函数 - 要么是默认的,要么是显式声明的。例如,考虑以下类:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }

  public String method1(int i){
    //statements, including return statement
  }
  //... other methods are here
}

如果我们有这个类,我们可以在其他类的方法中写以下内容:

public AnotherClass {
  ...
  public void someMethod(){
    MyClass myClass = new MyClass(3, "some string");
    String result = myClass.method1(2);
  }
  ...
}

在前面的代码中,语句MyClass myClass = new MyClass(3, "some string");创建了一个MyClass类的对象,使用了它的构造函数和关键字new,并将新创建的对象的引用分配给变量myClass。我们选择了一个对象引用的标识符,它与类名匹配,第一个字母小写。这只是一个约定,我们也可以选择另一个标识符(比如boo),结果是一样的。在第五章中,Java 语言元素和类型,我们会更详细地讨论标识符和变量。正如你在前面的例子中看到的,在下一行中,一旦创建了一个引用,我们就可以使用它来访问新创建对象的公共成员。

任何 Java 对象都只能通过使用关键字(运算符)new和构造函数来创建。这个过程也被称为类实例化。对对象的引用可以像任何其他值一样传递(作为变量、参数或返回值),每个有权访问引用的代码都可以使用它来访问对象的公共成员。我们将在下一节中解释什么是公共成员

类(静态)和对象(实例)成员

我们已经提到了与对象相关的公共成员这个术语。在谈到main()方法时,我们还使用了关键字static。我们还声明了一个被声明为static的成员在 JVM 内存中只能有一个副本。现在,我们将定义所有这些,以及更多。

私有和公共

关键字privatepublic被称为访问修饰符。还有默认和protected访问修饰符,但我们将在第七章中讨论它们,包和可访问性(可见性)。它们被称为访问修饰符,因为它们调节类、方法和字段的可访问性(有时也被称为可见性),并且它们修改相应的类、方法或字段的声明。

一个类只有在它是嵌套类时才能是私有的。在前面的Java 类部分,我们没有为嵌套类使用显式访问修饰符(因此,我们使用了默认的),但如果我们希望只允许从顶级类和同级访问这些类,我们也可以将它们设为私有。

私有方法或私有字段只能从声明它的类(对象)中访问。

相比之下,公共类、方法或字段可以从任何其他类中访问。请注意,如果封闭类是私有的,那么方法或字段就不能是公共的。这是有道理的,不是吗?如果类本身在公共上是不可访问的,那么它的成员如何能是公共的呢?

静态成员

只有当类是嵌套类时,才能声明一个类为静态。类成员——方法和字段——也可以是静态的,只要类不是匿名的或本地的。任何代码都可以访问类的静态成员,而不需要创建类实例(对象)。在前面的章节中,我们在一个代码片段中使用了类Math,就是这样的一个例子。静态类成员在字段的情况下也被称为类变量,方法的情况下被称为类方法。请注意,这些名称包含class这个词作为形容词。这是因为静态成员与类相关联,而不是与类实例相关联。这意味着在 JVM 内存中只能存在一个静态成员的副本,尽管在任何时刻可以创建和驻留在那里的类的许多实例(对象)。

这里是另一个例子。假设我们有以下类:

public class MyClass {
  private int field1;
  public static String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }

  public String method1(int i){
    //statements, including return statement
  }
  public static void method2(){
    //statements
  }
  //... other methods are here
}

从任何其他类的任何方法,可以通过以下方式访问前述MyClass类的公共静态成员:

MyClass.field2 = "any string";
String s = MyClass.field2 + " and another string";

前述操作的结果将是将变量s的值分配为any string and another stringString类将在第五章中进一步讨论,Java 语言元素和类型

同样,可以通过以下方式访问类MyClass的公共静态方法method2()

MyClass.method2();

MyClass的其他方法仍然可以通过实例(对象)访问:

MyClass mc = new MyClass(3, "any string");
String someResult = mc.method1(42);

显然,如果所有成员都是静态的,就没有必要创建MyClass类的对象。

然而,有时可以通过对象引用访问静态成员。以下代码可能有效 - 这取决于javac编译器的实现。如果有效,它将产生与前面代码相同的结果:

MyClass mc = new MyClass(3, "any string");
mc.field2 = "Some other string";
mc.method2();

有些编译器会提供警告,比如通过实例引用访问静态成员,但它们仍然允许你这样做。其他编译器会产生错误无法使静态引用非静态方法/字段,并强制你纠正代码。Java 规范不规定这种情况。但是,通过对象引用访问静态类成员不是一个好的做法,因为它使得代码对于人类读者来说是模棱两可的。因此,即使你的编译器更宽容,最好还是避免这样做。

对象(实例)成员

非静态类成员在字段的情况下也称为实例变量,或者在方法的情况下称为实例方法。它只能通过对象的引用后跟一个点“。”来访问。我们已经看到了几个这样的例子。

按照长期以来的传统,对象的字段通常声明为私有的。如果必要,提供set()和/或get()方法来访问这些私有值。它们通常被称为 setter 和 getter,因为它们设置和获取私有字段的值。这是一个例子:

public class MyClass {
  private int field1;
  private String field2;
  public void setField1(String val){
    this.field1 = val;
  }
  public String getField1(){
    return this.field1;
  }
  public void setField2(String val){
    this.field2 = val;
  }
  public String getField2(){
    return this.field2;
  }
  //... other methods are here
}

有时,有必要确保对象状态不能被改变。为了支持这种情况,程序员使用构造函数来设置状态并删除 setter:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }
  public String getField1(){
    return this.field1;
  }

  public String getField2(){
    return this.field2;
  }
  //... other non-setting methods are here
}

这样的对象称为不可变的。

方法重载

具有相同名称但不同签名的两个方法代表方法重载。这是一个例子:

public class MyClass {
  public String method(int i){
    //statements
  }
  public int method(int i, String v){
    //statements
  }
}

以下是不允许的,会导致编译错误,因为返回值不是方法签名的一部分,如果它们具有相同的签名,则无法用于区分一个方法和另一个方法:

public class MyClass {
  public String method(int i){
    //statements
  }
  public int method(int i){ //error
    //statements
  }
}

然而,这是允许的,因为这些方法具有不同的签名:

public String method(String v, int i){
  //statements
}
public String method(int i, String v){
  //statements
}

接口、实现和继承

现在,我们要进入 Java 编程的最重要领域——接口、实现和继承这些广泛使用的 Java 编程术语。

接口

在日常生活中,“接口”这个词非常流行。它的含义与 Java 接口在编程中所扮演的角色非常接近。它定义了对象的公共界面。它描述了如何与对象进行交互以及可以期望它具有什么。它隐藏了内部类的工作原理,只公开了具有返回值和访问修饰符的方法签名。接口不能被实例化。接口类型的对象只能通过创建实现该接口的类的对象来创建(接口实现将在下一节中更详细地介绍)。

例如,看下面的类:

public class MyClass {
  private int field1;
  private String field2;
  public MyClass(int val1, String val2){
    this.field1 = val1;
    this.field2 = val2;
  }
  public String method(int i){
    //statements
  }
  public int method(int i, String v){
    //statements
  }
}

它的接口如下:

public interface MyClassInterface {
  String method(int i);
  int method(int i, String v);
}

因此,我们可以写public class MyClass implements MyClassInterface {...}。我们将在下一节中讨论它。

由于接口是公共的界面,默认情况下假定方法访问修饰符public,可以省略。

接口不描述如何创建类的对象。要发现这一点,必须查看类并查看它的构造函数的签名。还可以检查并查看是否存在可以在不创建对象的情况下访问的公共静态类成员。因此,接口只是类实例的公共界面。

让我们来看看接口的其余功能。根据 Java 规范,接口的主体可以声明接口的成员,即字段、方法、类和接口。如果您感到困惑,并问接口和类之间的区别是什么,您有一个合理的关注,我们现在将解决这个问题。

接口中的字段隐式地是公共的、静态的和最终的。修饰符final表示它们的值不能被改变。相比之下,在类中,类本身、它的字段、方法和构造函数的默认访问修饰符是包私有的,这意味着它只在自己的包内可见。包是相关类的命名组。您将在第七章中了解它们,包和可访问性(可见性)

接口主体中的方法可以声明为默认、静态或私有。默认方法的目的将在下一节中解释。静态方法可以通过接口名称和点“.”从任何地方访问。私有方法只能被同一接口内的其他方法访问。相比之下,类中方法的默认访问修饰符是包私有的。

至于在接口内声明的类,它们隐式地是静态的。它们也是公共的,可以在没有接口实例的情况下访问,而创建接口实例是不可能的。我们不会再多谈论这样的类,因为它们用于超出本书范围的非常特殊的领域。

与类类似,接口允许在其内部声明内部接口。可以像任何静态成员一样从外部访问它,使用顶级接口和点“.”。我们想提醒您,接口默认是公共的,不能被实例化,因此默认是静态的。

与接口相关的最后一个非常重要的术语是抽象方法。接口中列出的没有实现的方法签名称为抽象方法,接口本身称为抽象,因为它抽象化、总结并移除了实现中的方法签名。抽象不能被实例化。例如,如果在任何类前面放置关键字abstract并尝试创建其对象,即使类中的所有方法都不是抽象的,编译器也会抛出错误。在这种情况下,类仅作为具有默认方法的接口。然而,在它们的使用上有显著的区别,您将在本章的接下来的继承部分中看到。

我们将在第六章接口,类和对象构建中更多地讨论接口,并在第七章包和可访问性(可见性)中涵盖它们的访问修饰符。

实现

一个接口可以被类实现,这意味着该类为接口中列出的每个抽象方法提供了一个具体的实现。这里是一个例子:

interface Car {
  double getWeightInPounds();
  double getMaxSpeedInMilesPerHour();
}

public class CarImpl implements Car{
  public double getWeightInPounds(){
    return 2000d;
  }
  public double getMaxSpeedInMilesPerHour(){
    return 100d;
  }
}

我们将类命名为CarImpl,表示它是接口Car的实现。但是我们可以随意为其命名。

接口及其类实现也可以有其他方法,而不会引起编译错误。接口中额外方法的唯一要求是必须是默认方法并有具体实现。向类添加任何其他方法都不会干扰接口实现。例如:

interface Car {
  double getWeightInPounds();
  double getMaxSpeedInMilesPerHour();
  default int getPassengersCount(){
    return 4;
  } 
}

public class CarImpl implements Car{
  private int doors;
  private double weight, speed;
  public CarImpl(double weight, double speed, int doors){
    this.weight = weight;
    this.speed = speed;
    this.dooes = doors;
  }
  public double getWeightInPounds(){
    return this.weight;
  }
  public double getMaxSpeedInMilesPerHour(){
    return this.speed;
  }
  public int getNumberOfDoors(){
    return this.doors;
  }
}

如果我们现在创建一个CarImpl类的实例,我们可以调用类中声明的所有方法:

CarImpl car = new CarImpl(500d, 50d, 3); 
car.getWeightInPounds();         //Will return 500.0
car.getMaxSpeedInMilesPerHour(); //Will return 50.0
car.getNumberOfDoors();          //Will return 3

这并不令人惊讶。

但是,这里有一些你可能意想不到的:

car.getPassengersCount();          //Will return 4

这意味着通过实现一个接口,类获得了接口默认方法。这就是默认方法的目的:为实现接口的所有类添加功能。如果没有默认方法,如果向旧接口添加一个抽象方法,所有当前的接口实现将触发编译错误。但是,如果添加一个带有default修饰符的新方法,现有的实现将继续像往常一样工作。

现在,另一个很好的技巧。如果一个类实现了与默认方法相同签名的方法,它将覆盖(一个技术术语)接口的行为。这里是一个例子:

interface Car {
  double getWeightInPounds();
  double getMaxSpeedInMilesPerHour();
  default int getPassengersCount(){
    return 4;
  } 
}

public class CarImpl implements Car{
  private int doors;
  private double weight, speed;
  public CarImpl(double weight, double speed, int doors){
    this.weight = weight;
    this.speed = speed;
    this.dooes = doors;
  }
  public double getWeightInPounds(){
    return this.weight;
  }
  public double getMaxSpeedInMilesPerHour(){
    return this.speed;
  }
  public int getNumberOfDoors(){
    return this.doors;
  }
  public int getPassengersCount(){
    return 3;
  } 
}

如果我们使用本例中描述的接口和类,我们可以编写以下代码:

CarImpl car = new CarImpl(500d, 50d, 3); 
car.getPassengersCount();        //Will return 3 now !!!!

如果接口的所有抽象方法都没有被实现,那么类必须声明为抽象类,并且不能被实例化。

接口的目的是代表它的实现-所有实现它的类的所有对象。例如,我们可以创建另一个实现Car接口的类:

public class AnotherCarImpl implements Car{
  public double getWeightInPounds(){
    return 2d;
  }
  public double getMaxSpeedInMilesPerHour(){
    return 3d;
  }
  public int getNumberOfDoors(){
    return 4;
  }
  public int getPassengersCount(){
      return 5;

   } 
}

然后我们可以让Car接口代表它们中的每一个:

Car car = new CarImpl(500d, 50d, 3); 
car.getWeightInPounds();          //Will return 500.0
car.getMaxSpeedInMilesPerHour();  //Will return 50.0
car.getNumberOfDoors();           //Will produce compiler error
car.getPassengersCount();         //Still returns 3 !!!!

car = new AnotherCarImpl();
car.getWeightInPounds();          //Will return 2.0
car.getMaxSpeedInMilesPerHour();  //Will return 3.0
car.getNumberOfDoors();           //Will produce compiler error
car.getPassengersCount();         //Will return 5 

从前面的代码片段中可以得出一些有趣的观察。首先,当变量car声明为接口类型时(而不是类类型,如前面的例子),不能调用接口中未声明的方法。

其次,car.getPassengersCount()方法第一次返回3。人们可能期望它返回4,因为car被声明为接口类型,人们可能期望默认方法起作用。但实际上,变量car指的是CarImpl类的对象,这就是为什么执行car.getPassengersCount()方法的是类的实现。

使用接口时,应该记住签名来自接口,但实现来自类,或者来自默认接口方法(如果类没有实现它)。这里还有默认方法的另一个特性。它们既可以作为可以实现的签名,也可以作为实现(如果类没有实现它)。

如果接口中有几个默认方法,可以创建私有方法,只能由接口的默认方法访问。它们可以用来包含公共功能,而不是在每个默认方法中重复。私有方法无法从接口外部访问。

有了这个,我们现在可以达到 Java 基础知识的高峰。在此之后,直到本书的结尾,我们只会添加一些细节并增强您的编程技能。这将是在高海拔高原上的一次漫步-您走得越久,就会感到越舒适。但是,要到达那个高度,我们需要爬上最后的上坡路;继承。

继承

一个类可以获取(继承)所有非私有非静态成员,因此当我们使用这个类的对象时,我们无法知道这些成员实际上位于哪里-在这个类中还是在继承它们的类中。为了表示继承,使用关键字extends。例如,考虑以下类:

class A {
  private void m1(){...}
  public void m2(){...}
}

class B extends class A {
  public void m3(){...}
}

class C extends class B {
}

在这个例子中,类BC的对象的行为就好像它们各自有方法m2()m3()。唯一的限制是一个类只能扩展一个类。类A是类B和类C的基类。类B只是类C的基类。正如我们已经提到的,它们每个都有默认的基类java.lang.Object。类BC是类A的子类。类C也是类B的子类。

相比之下,一个接口可以同时扩展许多其他接口。如果AIBICIDIEIFI是接口,那么允许以下操作:

interface AI extends BI, CI, DI {
  //the interface body
}
interface DI extends EI, FI {
  //the interface body
}

在上述例子中,接口AI继承了接口BICIDIEIFI的所有非私有非静态签名,以及任何其他是接口BICIDIEIFI的基接口。

回到上一节的话题,实现,一个类可以实现多个接口:

class A extends B implements AI, BI, CI, DI {
  //the class body
}

这意味着类A继承了类B的所有非私有非静态成员,并实现了接口AIBICIDI,以及它们的基接口。实现多个接口的能力来自于前面的例子,如果重写成这样,结果将完全相同:

interface AI extends BI, CI, DI {
  //the interface body
}

class A extends B implements AI {
  //the class body
}

扩展接口(类)也称为超级接口(超类)或父接口(父类)。扩展接口(类)称为子接口(子类)或子接口(子类)。

让我们用例子来说明这一点。我们从接口继承开始:

interface Vehicle {
  double getWeightInPounds();
}

interface Car extends Vehicle {
  int getPassengersCount();
}

public class CarImpl implements Car {
  public double getWeightInPounds(){
    return 2000d;
  }
  public int getPassengersCount(){
    return 4;
  }
}

在上述代码中,类CarImpl必须实现两个签名(列在接口Vehicle和接口Car中),因为从它的角度来看,它们都属于接口Car。否则,编译器会抱怨,或者类CarImpl必须声明为抽象的(不能被实例化)。

现在,让我们看另一个例子:

interface Vehicle {
  double getWeightInPounds();
}

public class VehicleImpl implements Vehicle {
  public double getWeightInPounds(){
    return 2000d;
  }
}

interface Car extends Vehicle {
  int getPassengersCount();
}

public class CarImpl extends VehicleImpl implements Car {
  public int getPassengersCount(){
    return 4;
  }
}

在这个例子中,类CarImpl不需要实现getWeightInPounds()的抽象方法,因为它已经从基类VehicleImpl继承了实现。

所述类继承的一个后果通常对于初学者来说并不直观。为了证明这一点,让我们在类CarImpl中添加方法getWeightInPounds()

public class VehicleImpl {
  public double getWeightInPounds(){
    return 2000d;
  }
}

public class CarImpl extends VehicleImpl {
  public double getWeightInPounds(){
    return 3000d;
  }
  public int getPassengersCount(){
    return 4;
  }
}

在这个例子中,为了简单起见,我们不使用接口。因为类CarImpl是类VehicleImpl的子类,它可以作为类VehicleImpl的对象行为,这段代码将编译得很好:

VehicleImpl vehicle = new CarImpl();
vehicle.getWeightInPounds();

问题是,你期望在前面片段的第二行中返回什么值?如果你猜测是 3,000,你是正确的。如果不是,不要感到尴尬。习惯需要时间。规则是,基类类型的引用可以引用其任何子类的对象。它被广泛用于覆盖基类行为。

峰会就在眼前。只剩下一步了,尽管它带来了一些你在读这本书之前可能没有预料到的东西,如果你对 Java 一无所知。

java.lang.Object 类

所以,这里有一个惊喜。每个 Java 类,默认情况下(没有显式声明),都扩展了Object类。准确地说,它是java.lang.Object,但我们还没有介绍包,只会在第七章中讨论它们,包和可访问性(可见性)

所有 Java 对象都继承了它的所有方法。共有十个:

  • public boolean equals (Object obj)

  • public int hashCode()

  • public Class getClass()

  • public String toString()

  • protected Object clone()

  • public void wait()

  • public void wait(long timeout)

  • public void wait(long timeout, int nanos)

  • public void notify()

  • public void notifyAll()

让我们简要地访问每个方法。

在我们这样做之前,我们想提一下,你可以在你的类中重写它们的默认行为,并以任何你需要的方式重新实现它们,程序员经常这样做。我们将在第六章中解释如何做到这一点,接口、类和对象构造

equals()方法

java.lang.Object类的equals()方法看起来是这样的:

public boolean equals(Object obj) {
  //compares references of the current object
  //and the reference obj 
}

这是它的使用示例:

Car car1 = new CarImpl();
Car car2 = car1;
Car car3 = new CarImpl();
car1.equals(car2);    //returns true
car1.equals(car3);    //returns false

从前面的例子中可以看出,默认方法equals()的实现只比较指向存储对象的地址的内存引用。这就是为什么引用car1car2是相等的——因为它们指向同一个对象(内存的相同区域,相同的地址),而car3引用指向另一个对象。

equals()方法的典型重新实现使用对象的状态进行比较。我们将在第六章中解释如何做到这一点,接口、类和对象构造

hashCode()方法

java.lang.Object类的hashCode()方法看起来是这样的:

public int hashCode(){
  //returns a hash code value for the object 
  //based on the integer representation of the memory address
}

Oracle 文档指出,如果两个方法根据equals()方法的默认行为是相同的,那么它们具有相同的hashCode()返回值。这很棒!但不幸的是,同一份文档指出,根据equals()方法,两个不同的对象可能具有相同的hasCode()返回值。这就是为什么程序员更喜欢重新实现hashCode()方法,并在重新实现equals()方法时使用它,而不是使用对象状态。尽管这种需要并不经常出现,我们不会详细介绍这种实现的细节。如果感兴趣,你可以在互联网上找到很好的文章。

getClass()方法

java.lang.Object类的getClass()方法看起来是这样的:

public Class getClass(){
  //returns object of class Class that has
  //many methods that provide useful information
}

从这个方法中最常用的信息是作为当前对象模板的类的名称。我们将在第六章中讨论为什么可能需要它,接口、类和对象构造**.可以通过这个方法返回的Class类的对象来访问类的名称。

toString()方法

java.lang.Object类的toString()方法看起来像这样:

public String toString(){
  //return string representation of the object
}

这个方法通常用于打印对象的内容。它的默认实现看起来像这样:

public String toString() {
  return getClass().getName()+"@"+Integer.toHexString(hashCode());
}

正如你所看到的,它并不是非常具有信息性,所以程序员们会在他们的类中重新实现它。这是类Object中最常重新实现的方法。程序员们几乎为他们的每个类都这样做。我们将在第九章中更详细地解释String类及其方法,运算符、表达式和语句

clone()方法

java.lang.Object类的clone()方法看起来像这样:

protected Object clone(){
  //creates copy of the object
}

这个方法的默认结果返回对象字段的副本,这是可以接受的,如果值不是对象引用。这样的值被称为原始类型,我们将在第五章中精确定义,Java 语言元素和类型。但是,如果对象字段持有对另一个对象的引用,那么只有引用本身会被复制,而不是引用的对象本身。这就是为什么这样的副本被称为浅层副本。要获得深层副本,必须重新实现clone()方法,并遵循可能相当广泛的对象树的所有引用。幸运的是,clone()方法并不经常使用。事实上,你可能永远不会遇到需要使用它的情况。

在阅读本文时,你可能会想知道,当对象被用作方法参数时会发生什么。它是使用clone()方法作为副本传递到方法中的吗?如果是,它是作为浅层副本还是深层副本传递的?答案是,都不是。只有对象的引用作为参数值传递进来,所以所有接收相同对象引用的方法都可以访问存储对象状态的内存区域。

这为意外数据修改和随后的数据损坏带来了潜在风险,将它们带入不一致的状态。这就是为什么,在传递对象时,程序员必须始终意识到他们正在访问可能在其他方法和类之间共享的值。我们将在第五章中更详细地讨论这一点,并在第十一章中扩展这一点,JVM 进程和垃圾回收,在讨论线程和并发处理时。

The wait() and notify() methods

wait()notify()方法及其重载版本用于线程之间的通信——轻量级的并发处理进程。程序员们不会重新实现这些方法。他们只是用它们来增加应用程序的吞吐量和性能。我们将在第十一章中更详细地讨论wait()notify()方法,JVM 进程和垃圾回收

现在,恭喜你。你已经踏上了 Java 基础复杂性的高峰,现在将继续水平前行,添加细节并练习所学知识。在阅读前两章的过程中,你已经在脑海中构建了 Java 知识的框架。如果有些东西不清楚或者忘记了,不要感到沮丧。继续阅读,你将有很多机会来刷新你的知识,扩展它,并保持更长时间。这将是一段有趣的旅程,最终会有一个不错的奖励。

面向对象编程概念

现在,我们可以谈论一些对你来说更有意义的概念,与在你学习主要术语并看到代码示例之前相比。这些概念包括:

  • 对象/类:它将状态和行为保持在一起

  • 封装:它隐藏了状态和实现的细节

  • 继承:它将行为/签名传播到类/接口扩展链中

  • 接口:它将签名与实现隔离开来

  • 多态:这允许一个对象由多个实现的接口和任何基类表示,包括java.lang.Object

到目前为止,你已经熟悉了上述所有内容,因此这将主要是一个总结,只添加一些细节。这就是我们学习的方式——观察特定事实,构建更大的图景,并随着新的观察不断改进这个图景。我们一直在做这件事,不是吗?

对象/类

一个 Java 程序和整个应用程序可以在不创建一个对象的情况下编写。只需在你创建的每个类的每个方法和每个字段前面使用static关键字,并从静态的main()方法中调用它们。你的编程能力将受到限制。你将无法创建一支可以并行工作的对象军队,他们可以在自己的数据副本上做类似的工作。但你的应用程序仍然可以工作。

此外,在 Java 8 中,添加了函数式编程特性,允许我们像传递对象一样传递函数。因此,你的无对象应用程序可能会非常强大。而且,一些没有对象创建能力的语言被使用得非常有效。然而,在面向对象的语言被证明有用并变得流行之后,第一个是 Smalltalk,一些传统的过程式语言,如 PHP、Perl、Visual Basic、COBOL 2002、Fortran 2003 和 Pascal 等,都添加了面向对象的能力。

正如我们刚才提到的,Java 还将其功能扩展到覆盖函数式编程,从而模糊了过程式、面向对象和函数式语言之间的界限。然而,类的存在和使用它们来创建对象的能力是编程语言必须支持的第一个概念,才能被归类为面向对象。

封装

封装——使数据和函数(方法)无法从外部访问或者有受控的访问——是创建面向对象语言的主要驱动因素之一。Smalltalk 是基于对象之间的消息传递的想法创建的,当一个对象调用另一个对象的方法时,这在 Smalltalk 和 Java 中都是这样做的。

封装允许调用对象的服务,而不知道这些服务是如何实现的。它减少了软件系统的复杂性,增加了可维护性。每个对象都可以独立地完成其工作,而无需与其客户端协调实现的更改,只要它不违反接口中捕获的合同。

我们将在第七章中进一步详细讨论封装,包和可访问性(可见性)

继承

继承是另一个面向对象编程概念,受到每种面向对象语言的支持。通常被描述为能够重用代码的能力,这是一个真实但经常被误解的说法。一些程序员认为继承能够在应用程序之间实现代码的重用。根据我们的经验,应用程序之间的代码重用可以在没有继承的情况下实现,并且更多地依赖于应用程序之间的功能相似性,而不是特定的编程语言特性。这更多地与将通用代码提取到共享可重用库中的技能有关。

在 Java 或任何其他面向对象的语言中,继承允许在基类中实现的公共功能在其子类中重用。它可以用于通过将基类组装到一个共享的库中,实现模块化并提高代码的可重用性。但在实践中,这种方法很少被使用,因为每个应用程序通常具有特定的要求,一个共同的基类要么太简单而实际上无用,要么包含许多特定于每个应用程序的方法。此外,在第六章《接口、类和对象构造》中,我们将展示,使用聚合更容易实现可重用性,这是基于使用独立对象而不是继承。

与接口一起,继承使多态成为可能。

接口(抽象)

有时,接口的面向对象编程概念也被称为抽象,因为接口总结(抽象)了对象行为的公共描述,隐藏了其实现的细节。接口是封装和多态的一个组成部分,但足够重要,以至于被作为一个单独的概念来阐述。其重要性将在第八章《面向对象设计(OOD)原则》中变得特别明显,当我们讨论从项目想法和愿景到具体编程解决方案的过渡时。

接口和继承为多态提供了基础。

多态

从我们提供的代码示例中,您可能已经意识到,一个对象具有所有实现的接口中列出的方法和其基类的所有非私有非静态方法,包括java.lang.Object。就像一个拥有多重国籍的人一样,它可以被视为其基类或实现的接口的对象。这种语言能力被称为多态(来自poly - 许多和morphos - 形式)。

请注意,广义上讲,方法重载——当具有相同名称的方法根据其签名可以具有不同行为时——也表现出多态行为。

练习-接口与抽象类

接口和抽象类之间有什么区别?我们没有讨论过,所以您需要进行一些研究。

在 Java 8 中引入接口的默认方法后,差异显著缩小,在许多情况下可以忽略不计。

答案

抽象类可以有构造函数,而接口不能。

抽象类可以有状态,而接口不能。抽象类的字段可以是私有的和受保护的,而在接口中,字段是公共的、静态的和最终的。

抽象类可以具有任何访问修饰符的方法实现,而接口中实现的默认方法只能是 public。

如果您想要修改的类已经扩展到另一个类,您就不能使用抽象类,但是您可以实现一个接口,因为一个类只能扩展到另一个类,但可以实现多个接口。

总结

在本章中,您已经学习了 Java 和任何面向对象编程语言的基本概念。您现在了解了类和对象作为 Java 的基本构建模块,知道了静态和实例成员是什么,以及了解了接口、实现和继承。这是本初学者章节中最复杂和具有挑战性的练习,将读者带到了 Java 语言的核心,介绍了我们将在本书的其余部分中使用的语言框架。这个练习让读者接触到了关于接口和抽象类之间差异的讨论,这在 Java 8 发布后变得更加狭窄。

在下一章中,我们将转向编程的实际问题。读者将被引导完成在他们的计算机上安装必要工具和配置开发环境的具体步骤。之后,所有新的想法和软件解决方案将被演示,包括具体的代码示例。

第三章:你的开发环境设置

到目前为止,你可能已经对如何在计算机上编译和执行 Java 程序有了相当好的了解。现在,是时候学习如何编写程序了。在你能够做到这一点之前,这一章是最后一步。因为你需要先设置好你的开发环境,所以这一章将解释什么是开发环境,以及为什么你需要它。然后,它将引导你进行配置和调整,包括设置类路径。在此过程中,我们将提供流行编辑器的概述和 IntelliJ IDEA 的具体建议。

在这一章中,我们将涵盖以下主题:

  • 什么是开发环境?

  • 设置类路径

  • IDE 概述

  • 如何安装和配置 IntelliJ IDEA

  • 练习 - 安装 NetBeans

什么是开发环境?

开发环境是安装在你的计算机上的一组工具,它允许你编写 Java 程序(应用程序)和测试它们,与同事分享源代码,并对源代码进行编译和运行。我们将在本章讨论每个开发工具和开发过程的各个阶段。

Java 编辑器是你的主要工具

一个支持 Java 的编辑器是开发环境的中心。原则上,你可以使用任何文本编辑器来编写程序并将其存储在.java文件中。不幸的是,普通文本编辑器不会警告你有关 Java 语言语法错误。这就是为什么支持 Java 的专门编辑器是编写 Java 程序的更好选择。

现代 Java 语言编辑器不仅仅是一个写作工具。它还具有与同一台计算机上安装的 JVM 集成的能力,并使用它来编译应用程序,执行它,等等。这就是为什么它不仅仅被称为编辑器,而是 IDE。它还可以与其他开发工具集成,因此你不需要退出 IDE 来将源代码存储在远程服务器上,例如源代码控制系统。

Java IDE 的另一个巨大优势是它可以提醒你有关语言的可能性,并帮助你找到实现所需功能的更好方法。

IDE 还支持代码重构。这个术语意味着改变代码以获得更好的可读性、可重用性或可维护性,而不影响其功能。例如,如果有一段代码在多个方法中使用,可以将其提取到一个单独的方法中,并在所有地方使用它,而不是复制代码。另一个例子是当类、方法或变量的名称更改为更具描述性的名称。使用普通编辑器需要你手动查找旧名称使用的所有地方。而 IDE 会为你完成这项工作。

IDE 的另一个有用功能是能够生成类的样板代码和标准方法,比如构造函数、getter、setter 或toString()方法。它通过让程序员专注于重要的事情来提高程序员的生产力。

因此,请确保你对所选择的 IDE 感到舒适。作为程序员,你将在大部分工作时间内与你的 IDE 编辑器一起工作。

源代码编译

一个集成开发环境(IDE)使用计算机上安装的javac编译器来查找所有 Java 语言的语法错误。早期发现这些错误比在应用程序已经在生产环境中运行后发现要容易得多。

并非所有编程语言都可以通过这种方式支持。Java 可以,因为 Java 是一种严格类型的语言,这意味着在使用变量之前需要为每个变量声明类型。在第二章中的示例中,您看到了intString类型。之后,如果尝试对变量进行不允许的操作,或者尝试为其分配另一种类型,IDE 将警告您,您可以重新查看或坚持您编写代码的方式(当您知道自己在做什么时)。

尽管名称相似,JavaScript 与之相反,是一种动态类型的语言,允许在不定义其类型的情况下声明变量。这就是为什么 Java 新手可以从一开始就开发一个更复杂和完全功能的应用程序,而复杂的 JavaScript 代码即使对于经验丰富的程序员来说也仍然是一个挑战,并且仍然无法达到 Java 代码的复杂程度。

顺便说一下,尽管 Java 是在 C++之后引入的,但它之所以受欢迎,却是因为它对对象类型操作施加的限制。在 Java 中,与 C++相比,难以追踪的运行时错误的风险要小得多。运行时错误是那些不能仅根据语言语法在编译时由 IDE 找到的代码问题。

代码共享

IDE 集成了代码共享系统。在相同代码上的协作需要将代码放置在一个称为源代码存储库或版本控制存储库的共享位置,所有团队成员都可以访问。最著名的共享存储库之一是基于 Git 版本控制系统的基于 Web 的版本控制存储库 GitHub(github.com/)。其他流行的源代码控制系统包括 CVS、ClearCase、Subversion 和 Mercurial 等。

关于这些系统的概述和指导超出了本书的范围。我们提到它们是因为它们是开发环境的重要组成部分。

代码和测试执行

使用 IDE,甚至可以执行应用程序或其测试。为了实现这一点,IDE 首先使用javac工具编译代码,然后使用 JVM(java工具)执行它。

IDE 还允许我们以调试模式运行应用程序,当执行可以在任何语句处暂停。这允许程序员检查变量的当前值,这通常是查找可怕的运行时错误的最有效方式。这些错误通常是由执行过程中分配给变量的意外中间值引起的。调试模式允许我们缓慢地沿着有问题的执行路径走,并查看导致问题的条件。

IDE 功能中最有帮助的一个方面是它能够维护类路径或管理依赖关系,我们将在下一节中讨论。

设置类路径

为了使javac编译代码并使java执行它,它们需要知道组成应用程序的文件的位置。在第二章中,Java 语言基础,在解释javacjava命令的格式时,我们描述了-classpath选项允许您列出应用程序使用的所有类和第三方库(或者说依赖的)的方式。现在,我们将讨论如何设置这个列表。

手动设置

有两种设置方式:

  • 通过-classpath命令行选项

  • 通过CLASSPATH环境变量

我们将首先描述如何使用-classpath选项。它在javacjava命令中具有相同的格式:

-classpath dir1;dir2\*;dir3\alibrary.jar  (for Windows)

javac -classpath dir1:dir2/*:dir3/alibrary.jar   (for Lunix)

在前面的例子中,dir1dir2dir3是包含应用程序文件和应用程序依赖的第三方.jar文件的文件夹。每个文件夹也可以包括对目录的路径。路径可以是绝对路径,也可以是相对于运行此命令的当前位置的路径。

如果一个文件夹不包含.jar文件(例如只有.class文件),那么只需要列出文件夹名称即可。两个工具javacjava在搜索特定文件时都会查看文件夹内的内容。dir1文件夹提供了这样一个例子。

如果一个文件夹包含.jar文件(其中包含.class文件),则可以执行以下两种操作之一:

  • 指定通配符*,以便在该文件夹中搜索所有.jar文件以查找所请求的.class文件(前面的dir2文件夹就是这样一个例子)

  • 单独列出每个.jar文件(存储在dir3文件夹中的alibrary.jar文件就是一个例子)

CLASSPATH环境变量与-classpath命令选项具有相同的目的。作为CLASSPATH变量的值指定的文件位置列表的格式与前面描述的-classpath选项设置的列表相同。如果使用CLASSPATH,则可以在不使用-classpath选项的情况下运行javacjava命令。如果两者都使用,则CLASSPATH的值将被忽略。

要查看CLASSPATH变量的当前值,请打开命令提示符或终端,然后在 Windows OS 中键入echo %CLASSPATH%,在 Linux 中键入echo $CLASSPATH。很可能你什么都不会得到,这意味着CLASSPATH变量在您的计算机上没有使用。您可以使用set命令为其分配一个值。

可以使用-classpath选项包括CLASSPATH值:

-classpath %CLASSPATH%;dir1;dir2\*;dir3\alibrary.jar (for Windows)

-classpath $CLASSPATH:dir1:dir2/*:dir3/alibrary.jar (for Lunix)

请注意,javacjava工具是 JDK 的一部分,因此它们知道在 JDK 中附带的 Java 标准库的位置,并且无需在类路径上指定标准库的.jar文件。

Oracle 提供了如何设置类路径的教程,网址为docs.oracle.com/javase/tutorial/essential/environment/paths.html

在类路径上搜索

无论使用-classpath还是CLASSPATH,类路径值都表示.class.jar文件的列表。javacjava工具总是从左到右搜索列表。如果同一个.class文件被列在多个位置(例如在多个文件夹或.jar文件中),那么只会找到它的第一个副本。如果类路径中包含同一库的多个版本,可能会导致问题。例如,如果在旧版本之后列出了库的新版本,则可能永远找不到库的新版本。

此外,库本身可能依赖于其他.jar文件及其特定版本。两个不同的库可能需要相同的.jar文件,但版本不同。

如您所见,当类路径上列出了许多文件时,它们的管理可能很快就会成为一项全职工作。好消息是,您可能不需要担心这个问题,因为 IDE 会为您设置类路径。

IDE 会自动设置类路径

正如我们已经提到的,javacjava工具知道在 JDK 安装中附带的标准库的位置。如果您的代码使用其他库,您需要告诉 IDE 您需要哪些库,以便 IDE 可以找到它们并设置类路径。

为了实现这一点,IDE 使用了一个依赖管理工具。如今最流行的依赖管理工具是 Maven 和 Gradle。由于 Maven 的历史比 Gradle 长,所有主要的 IDE 都有这个工具,无论是内置的还是通过插件集成的。插件是可以添加到应用程序(在这种情况下是 IDE)中以扩展其功能的软件。

Maven 有一个广泛的在线存储库,存储了几乎所有现有的库和框架。要告诉具有内置 Maven 功能的 IDE 您的应用程序需要哪些第三方库,您必须在名为pom.xml的文件中标识它们。IDE 从pom.xml文件中读取您需要的内容,并从 Maven 存储库下载所需的库到您的计算机。然后,IDE 可以在执行javacjava命令时将它们列在类路径上。我们将向您展示如何在第四章中编写pom.xml内容,您的第一个 Java 项目

现在是选择你的 IDE,安装它并配置它的时候了。在下一节中,我们将描述最流行的 IDE。

有许多 IDE

有许多可免费使用的 IDE:NetBeans、Eclipse、IntelliJ IDEA、BlueJ、DrJava、JDeveloper、JCreator、jEdit、JSource、jCRASP 和 jEdit 等。每个都有一些追随者,他们坚信自己的选择是最好的,所以我们不打算争论。毕竟这是一个偏好问题。我们将集中在三个最流行的 IDE 上 - NetBeans、Eclipse 和 IntelliJ IDEA。我们将使用 IntelliJ IDEA 免费的 Community Edition 进行演示。

我们建议在最终选择之前阅读有关这些和其他 IDE 的文档,甚至尝试它们。对于您的初步研究,您可以使用维基百科文章en.wikipedia.org/wiki/Comparison_of_integrated_development_environments#Java,其中有一张表比较了许多现代 IDE。

NetBeans

NetBeans 最初是在 1996 年作为布拉格查理大学的 Java IDE 学生项目创建的。1997 年,围绕该项目成立了一家公司,并生产了 NetBeans IDE 的商业版本。1999 年,它被 Sun Microsystems 收购。2010 年,在 Oracle 收购 Sun Microsystems 后,NetBeans 成为由 Oracle 生产的开源 Java 产品的一部分,并得到了大量开发人员的贡献。

NetBeans IDE 成为 Java 8 的官方 IDE,并可以与 JDK 8 一起下载在同一个捆绑包中;请参阅www.oracle.com/technetwork/java/javase/downloads/jdk-netbeans-jsp-142931.html

2016 年,Oracle 决定将 NetBeans 项目捐赠给 Apache 软件基金会,并表示通过即将发布的 Java 9 和 NetBeans 9 以及未来的成功,开放 NetBeans 治理模型,使 NetBeans 成员在项目的方向和未来成功中发挥更大的作用

NetBeans IDE 有 Windows、Linux、Mac 和 Oracle Solaris 版本。它可以编码、编译、分析、运行、测试、分析、调试和部署所有 Java 应用程序类型 - Java SE、JavaFX、Java ME、Web、EJB 和移动应用程序。除了 Java,它还支持多种编程语言,特别是 C/C++、XML、HTML5、PHP、Groovy、Javadoc、JavaScript 和 JSP。由于编辑器是可扩展的,可以插入对许多其他语言的支持。

它还包括基于 Ant 的项目系统、对 Maven 的支持、重构、版本控制(支持 CVS、Subversion、Git、Mercurial 和 ClearCase),并可用于处理云应用程序。

Eclipse

Eclipse 是最广泛使用的 Java IDE。它有一个不断增长的广泛插件系统,因此不可能列出其所有功能。它的主要用途是开发 Java 应用程序,但插件也允许我们用 Ada、ABAP、C、C++、C#、COBOL、D、Fortran、Haskell、JavaScript、Julia、Lasso、Lua、NATURAL、Perl、PHP、Prolog、Python、R、Ruby、Rust、Scala、Clojure、Groovy、Scheme 和 Erlang 编写代码。开发环境包括 Eclipse Java 开发工具JDT)用于 Java 和 Scala,Eclipse CDT 用于 C/C++,Eclipse PDT 用于 PHP 等。

Eclipse这个名字是在与微软 Visual Studio 的竞争中创造出来的,Eclipse 的目标是超越 Visual Studio。随后的版本以木星的卫星——卡利斯托、欧罗巴和迦尼米德的名字命名。之后,以发现这些卫星的伽利略的名字命名了一个版本。然后,使用了两个与太阳有关的名字——希腊神话中的太阳神赫利俄斯和彩虹的七种颜色之一——靛蓝。之后的版本,朱诺,有三重含义:罗马神话中的人物、一个小行星和前往木星的宇宙飞船。开普勒、月球和火星延续了天文主题,然后是来自化学元素名称的氖和氧。光子代表了对太阳主题名称的回归。

Eclipse 还可以编码、编译、分析、运行、测试、分析、调试和部署所有 Java 应用程序类型和所有主要平台。它还支持 Maven、重构、主要版本控制系统和云应用程序。

可用插件的种类繁多可能对新手构成挑战,甚至对更有经验的用户也是如此,原因有两个:

  • 通常有多种方法可以向 IDE 添加相同的功能,通过组合不同作者的类似插件

  • 一些插件是不兼容的,这可能会导致难以解决的问题,并迫使我们重新构建 IDE 安装,特别是在新版本发布时

IntelliJ IDEA

IntelliJ IDEA 付费版本绝对是当今市场上最好的 Java IDE。但即使是免费的 Community Edition 在三大主要 IDE 中也占据着强势地位。在下面的维基百科文章中,您可以看到一个表格,它很好地总结了付费的 Ultimate 和免费的 Community Edition 之间的区别:en.wikipedia.org/wiki/IntelliJ_IDEA

它是由 JetBrains(以前被称为 IntelliJ)软件公司开发的,该公司在布拉格、圣彼得堡、莫斯科、慕尼黑、波士顿和新西伯利亚拥有约 700 名员工(截至 2017 年)。第一个版本于 2001 年 1 月发布,是最早具有集成高级代码导航和代码重构功能的 Java IDE 之一。从那时起,这个 IDE 以其对代码的深入洞察而闻名,正如作者在其网站上描述产品特性时所说的那样:www.jetbrains.com/idea/features

与前面描述的另外两个 IDE 一样,它可以编码、编译、分析、运行、测试、分析、调试和部署所有 Java 应用程序类型和所有主要平台。与前两个 IDE 一样,它还支持 Ant、Maven 和 Gradle,以及重构、主要版本控制系统和云应用程序。

在下一节中,我们将为您介绍 IntelliJ IDEA Community Edition 的安装和配置过程。

安装和配置 IntelliJ IDEA

以下步骤和截图将演示在 Windows 上安装 IntelliJ IDEA Community Edition,尽管对于 Linux 或 macOS,安装并没有太大的不同。

下载和安装

您可以从www.jetbrains.com/idea/download下载 IntelliJ IDEA 社区版安装程序。下载安装程序后,通过双击它或右键单击并从菜单中选择“打开”选项来启动它。然后,通过单击“下一个>”按钮,接受所有默认设置,除非您需要执行其他操作。这是第一个屏幕:

您可以使用“浏览...”按钮并选择“任何位置”作为目标文件夹,或者只需单击“下一个>”并在下一个屏幕上接受默认位置:

在下一个屏幕上选中 64 位启动器(除非您的计算机仅支持 32 位)和.java

我们假设您已经安装了 JDK,因此在前一个屏幕上不需要检查“下载并安装 JRE”。如果您尚未安装 JDK,可以检查“下载并安装 JRE”,或者按照第一章中描述的步骤安装 JDK,计算机上的 Java 虚拟机(JVM)

下一个屏幕允许您自定义启动菜单中的条目,或者您可以通过单击“安装”按钮接受默认选项:

安装程序将花费一些时间来完成安装。下一个屏幕上的进度条将让您了解还有多少时间才能完成整个过程:

安装完成后,下一个>按钮变为可点击时,请使用它转到下一个屏幕。

在下一个屏幕上选中“运行 IntelliJ IDEA”框,并单击“完成”按钮:

安装已完成,现在我们可以开始配置 IDE。

配置 IntelliJ IDEA

当 IntelliJ IDEA 第一次启动时,它会询问您是否有来自先前 IDE 版本的设置:

由于这是您第一次安装 IntelliJ IDEA,请单击“不导入设置”。

接下来的一个或两个屏幕也只会显示一次——在新安装的 IDE 首次启动时。它们将询问您是否接受 JetBrains 的隐私政策,以及您是否愿意支付许可证费用,还是希望继续使用免费的社区版或免费试用版(这取决于您获得的特定下载)。以您喜欢的方式回答问题,如果您接受隐私政策,下一个屏幕将要求您选择主题——白色(IntelliJ)或黑色(Darcula)。

我们选择了暗色主题,正如您将在我们的演示屏幕上看到的那样。但您可以选择任何您喜欢的,然后以后再更改:

在上面的屏幕上,底部可以看到两个按钮:跳过剩余和设置默认和下一个:默认插件。如果您单击“跳过剩余并设置默认”,您将跳过现在配置一些设置的机会,但以后可以进行配置。对于此演示,我们将单击“下一个:默认插件”按钮,然后向您展示如何稍后重新访问设置。

这是默认设置选项的屏幕:

您可以单击前面屏幕上的任何“自定义...”链接,查看可能的选项,然后返回。我们将仅使用其中的三个——构建工具、版本控制和测试工具。我们将首先通过单击“自定义...”来开始构建工具:

我们将保留 Maven 选项的选择,但其他选项的存在不会有害,甚至可以帮助您以后探索相关功能。

点击保存更改并返回,然后点击版本控制符号下的自定义...链接:

我们稍后会谈一下源代码控制工具(或版本控制工具,它们也被称为),但是本书不涵盖这个主题的完整内容。在前面的屏幕上,您可以勾选您知道将要使用的版本控制系统的复选框。否则,请保持所有复选框都被勾选,这样一旦您打开从列出的工具之一检出的代码源树,版本控制系统就会自动集成。

点击保存更改并返回,然后点击测试工具符号下的自定义...链接:

在前面的屏幕上,我们将只保留 JUnit 复选框被选中,因为我们希望我们的演示配置清除不必要的干扰。但您可以保持所有复选框都被选中。拥有其他选项也没有坏处。此外,您可能决定在将来使用其他选项。

正如您所见,原则上,我们不需要更改任何默认设置。我们只是为了向您展示可用的功能。

点击保存更改并返回,然后点击“下一步:特色插件”按钮,然后点击“开始使用 IntelliJ IDEA”按钮。

如果您在安装时没有配置 IDE,或者做了一些不同的事情并希望更改配置,可以稍后进行更改。

我们将在安装后解释如何访问 IntelliJ IDEA 中的配置设置,并在第四章《您的第一个 Java 项目》中提供相应的屏幕截图。

练习 - 安装 NetBeans IDE

下载并安装 NetBeans IDE。

答案

截至撰写本文时,下载最新版本的 NetBeans 页面为netbeans.org/features/index.html

下载完成后,启动安装程序。您可能会收到一条消息,建议您在启动安装程序时使用--javahome选项。找到相应的安装说明,并执行。NetBeans 版本需要特定版本的 Java,不匹配可能会导致安装或运行问题。

如果安装程序启动而没有警告,您可以按照向导进行操作,直到屏幕显示安装成功完成并有“完成”按钮。点击“完成”按钮,然后运行 NetBeans。您现在可以开始使用 NetBeans IDE 编写 Java 代码。阅读完第四章《您的第一个 Java 项目》后,尝试在 NetBeans 中创建一个类似的项目,并看看与 IntelliJ IDEA 相比您是否喜欢它。

摘要

现在您知道开发环境是什么,以及您在计算机上需要哪些工具来开始编码。您已经学会了如何配置 IDE 以及它在幕后为您做了什么。您现在知道在选择 IDE 时要寻找什么。

在下一章中,您将开始使用它来编写和编译代码并进行测试。您将学习什么是 Java 项目,如何创建和配置一个项目,以及如何在不离开 IDE 的情况下执行代码和测试代码,这意味着您将成为一名 Java 程序员。

第四章:您的第一个 Java 项目

在前几章中,您学到了关于 Java 的许多东西,包括其基本方面和主要工具。现在,我们将应用所学知识来完成并迈出迈向真实程序的第一步——创建一个 Java 项目。我们将向您展示如何编写应用程序代码,如何测试它以及如何执行主代码及其测试。

在本章中,我们将涵盖以下主题:

  • 什么是项目?

  • 创建项目

  • 编写和构建应用程序代码

  • 执行和单元测试应用程序

  • 练习:JUnit @Before@After注解

什么是项目?

让我们从项目的定义和起源开始。

项目的定义和起源

根据牛津词典的英语,术语项目一个个人或协作的企业,经过精心计划以实现特定目标。这个术语被 IDE 的设计者采用,意思是组成应用程序的文件集合。这就是为什么项目这个术语经常被用作应用程序的同义词。

与项目相关的术语

构成项目的文件存储在文件系统的目录中。最顶层的目录称为项目根目录,项目的其余目录形成其下的树。这就是为什么项目也可以被看作是包含应用程序和其测试的所有.java文件和其他文件的目录树。非 Java 文件通常称为资源,并存储在同名目录中。

程序员还使用源代码树源代码这些术语作为项目的同义词。

当一个项目使用另一个项目的类时,它们被打包成一个.jar文件,通常构成一个(一个或多个独立类的集合)或框架(一组旨在共同支持某些功能的类)。库和框架之间的区别不影响您的项目如何访问其类,因此从现在开始,我们将称项目使用的所有第三方.jar文件为库。在Maven 项目配置部分,我们将向您展示如何访问这些库,如果您的代码需要它们。

项目的生命周期

Java 项目的生命周期包括以下阶段(步骤、阶段):

  • 可行性:是否继续进行项目的决定

  • 需求收集和高级设计

  • 类级设计:开发阶段的第一阶段

  • 项目创建

  • 编写应用程序代码及其单元测试

  • 项目构建:代码编译

  • 将源代码存储在远程存储库中并与其他程序员共享

  • 项目打包:将.class文件和所有支持的非 Java 文件收集到一个.jar文件中,通常称为项目构件构件

  • 项目安装:将构件保存在二进制存储库(也称为构件库)中,从中可以检索并与其他程序员共享。这个阶段是开发阶段的最后一个阶段

  • 在测试环境中部署和执行项目;将构件放入一个可以在类似于生产环境的条件下执行和测试的环境中,这是测试阶段

  • 项目在生产环境中部署和执行:这是生产(也称为维护)阶段的第一阶段

  • 项目增强和维护:修复缺陷并向应用程序添加新功能

  • 在不再需要项目后关闭项目

在本书中,我们只涵盖了四个项目阶段:

  • 项目设计(参见第八章,面向对象设计(OOD)原则

  • 项目创建

  • 编写应用程序代码及其单元测试

  • 项目构建,使用javac工具进行代码编译

我们将向您展示如何使用 IntelliJ IDEA 社区版执行所有这些阶段,但其他 IDE 也有类似的操作。

为了构建项目,IDE 使用 Java 编译器(javac工具)和依赖管理工具。后者设置了javacjava命令中-classpath选项的值。最流行的三种依赖管理工具是 Maven、Gradle 和 Ant。IntelliJ IDEA 具有内置的 Maven 功能,不需要安装外部的依赖管理工具。

创建项目

有几种在 IntelliJ IDEA(或其他任何 IDE)中创建项目的方法:

  • 使用项目向导(请参阅“使用项目向导创建项目”部分)

  • 从文件系统中读取现有源代码

  • 从源代码控制系统中读取现有源代码

在本书中,我们只会介绍第一种选项——使用项目向导。其他两个选项只需一步即可完成,无需太多解释。在学会如何手动创建项目之后,您将了解在从现有源代码自动创建项目时发生了什么。

使用项目向导创建项目

当您启动 IntelliJ IDEA 时,除了第一次,它会显示您已创建的项目列表。否则,您只会看到以下屏幕:

导入项目、打开项目和从版本控制中检出这三个选项允许您处理现有项目。我们在本书中不会使用它们。

单击“创建新项目”链接,这将带您到项目创建向导的第一个屏幕。在左上角选择 Java,然后单击右上角的“新建”按钮,并选择计算机上安装的 JDK 的位置。之后,单击右下角的“确定”按钮。

在下一个窗口中,不要选择任何内容,只需单击“下一步”按钮:

您在上面的屏幕截图中看不到“下一步”按钮,因为它在实际屏幕的底部,其余部分是空白空间,我们决定不在这里显示。

在下一个屏幕中,在上方的字段中输入项目名称(通常是您的应用程序名称),如下所示:

对于我们的演示代码,我们选择了项目(应用程序)名称为javapath,意思是 Java 编程的路径。单击上一个屏幕底部的“完成”按钮,您应该会看到类似于这样的内容:

如果您在左窗格中看不到项目结构,请单击“查看”(在最顶部菜单中),然后选择“工具窗口”,然后选择“项目”,如下面的屏幕截图所示:

现在您应该能够看到项目结构:

前面的项目包括:

  • .idea目录保存了项目的 IntelliJ IDEA 设置

  • src目录,包括子目录:

  • main,将在其java子目录(对于.java文件)和resources子目录(对于其他类型的文件)中保存应用程序文件,

  • test,将在其java(对于.java文件)和resources子目录(对于其他类型的文件)中保存应用程序的测试。

  • javapath.iml文件,这是另一个带有项目配置的 IntelliJ IDEA 文件

  • External Libraries目录,其中包含项目使用的所有库

在前面的截图中,你还可以看到pom.xml文件。这个文件用于描述代码所需的其他库。我们将在“Maven 项目配置”部分解释如何使用它。IDE 会自动生成它,因为在上一章中,在配置 IDE 时,我们指示了我们希望在 IDE 默认设置中与 Maven 集成。如果你还没有这样做,现在你可以右键单击项目名称(在我们的例子中是JavaPath),然后选择“添加框架支持”:

然后,你将看到一个屏幕,你可以选择 Maven:

点击“确定”按钮,pom.xml文件将被创建。如果pom.xml文件没有 Maven 符号,应该按照前面的截图进行相同的步骤。添加 Maven 支持后的效果如下:

触发pom.xml创建的另一种方法是响应右下角弹出的小窗口,其中包含各种建议,包括“添加为 Maven 项目”(这意味着代码依赖将由 Maven 管理):

如果你错过了点击前面的链接,你仍然可以通过点击底部的链接来恢复建议:

它将把建议带回到屏幕左下角:

点击“添加为 Maven 项目”链接,pom.xml文件将被创建。

另一个有用的建议如下:

我们建议你点击“启用自动导入”链接。这将使 IDE 更好地支持你的项目,从而免除你手动执行某些操作。

如果以上方法都不适用于你,总是可以手动创建pom.xml文件。只需右键单击左窗格中的项目名称(JavaPath),选择“新建”,选择“文件”,然后输入文件名pom.xml,并点击“确定”按钮。

Maven 项目配置

正如我们已经提到的,Maven 在编译和运行应用程序时帮助组成javacjava命令。它设置了-classpath选项的值。为了实现这一点,Maven 从pom.xml中读取项目所需的库列表。你有责任正确指定这些库。否则,Maven 将无法找到它们。

默认情况下,pom.xml文件位于项目根目录。这也是 IDE 运行javac命令并将src/main/java目录设置为类路径的目录,以便javac可以找到项目的源文件。它还将编译后的.class文件放在target/classes目录中,也放在根目录中,并在执行java命令时将此目录设置为类路径。

pom.xml的另一个功能是描述你的项目,以便它可以在你的计算机上唯一地被识别,甚至在互联网上的所有其他项目中也是如此。这就是我们现在要做的。让我们来看看pom.xml文件的内部:

你可以看到标识项目的三个 XML 标签:

  • groupId标识组织或开源社区内项目的组

  • artifactId标识组内的特定项目

  • version标识项目的版本

groupId标签中设置的值必须遵循包命名约定,所以现在,我们需要解释一下包是什么。包是 Java 应用程序的最大结构单元。每个包都将相关的 Java 类分组在一起。不同包中的两个不同类可以具有相同的名称。这就是为什么包也被称为命名空间。

包名必须是唯一的。它使我们能够正确地识别一个类,即使在类路径上列出了具有相同名称的其他包中存在一个类。包可以有几个子包。它们以类似于文件系统的目录结构的层次结构组织。包含所有其他包的包称为顶级包。它的名称被用作pom.xml文件的groupId标签值。

包命名约定要求顶级包名基于创建包的组织的互联网域名(倒序)。例如,如果域名是oracle.com,那么顶级包名必须是com.oracle,后面跟着(在一个点,.后)项目名称。或者,可以在倒置的域名和项目名称之间插入子域、部门名称或任何其他项目组。然后,其他子包跟随。

许多 JDK 标准库的包以jdkjavajavax开头,例如。但最佳实践是遵循 Java 规范第 6.1 节中定义的命名约定(docs.oracle.com/javase/specs)。

选择一个独特的包名可能会有问题,当一个开源项目开始时,没有任何组织在脑海中。在这种情况下,程序员通常使用org.github.<作者的名字>或类似的东西。

在我们的项目中,我们有一个顶级的com.packt.javapath包。这样做有一点风险,因为另一个 Packt 的作者可能决定以相同的名称开始包。最好以com.packt.nicksamoylov.javapath开始我们的包。这样,作者的名字将解决可能的冲突,除非当然,另一个同名的作者开始为 Packt 写 Java 书。但是,我们决定冒险简洁。此外,我们认为我们在这本书中创建的代码不会被另一个项目使用。

因此,我们项目的groupId标签值将是com.packt.javapath

artifactId标签值通常设置为项目名称。

version标签值包含项目版本。

artifactIdversion用于在项目打包期间形成.jar文件名。例如,如果项目名称是javapath,版本是1.0.0.jar文件名将是javapath-1.0.0.jar

因此,我们的pom.xml现在看起来像这样:

注意版本中的-SNAPSHOT后缀。它的用处只有当您要与其他程序员共享同一个项目时才会显现出来。但我们现在会解释它,这样您就能理解这个值的目的。当一个项目的构件(一个.jar文件)被创建时,它的名称将是javapath-1.0-SNAPSHOT.jar。文件名中的-SNAPSHOT表示它是一个正在进行的工作,代码正在从构建到构建中改变。这样,使用您的构件的其他 Maven 管理的项目将在.jar文件上的时间戳更改时每次下载它。

当代码稳定下来,更改变得罕见时,您可以将版本值设置为1.0.0,并且只有在代码更改并发布新项目版本时才更改它——例如javapath-1.0.0.jarjavapath-1.0.1.jarjavapath-1.2.0.jar。然后,使用javapath的其他项目不会自动下载新的文件版本。相反,另一个项目的程序员可以阅读每个新版本的发布说明,并决定是否使用它;新版本可能会引入不希望的更改,或者与他们的应用程序代码不兼容。如果他们决定需要一个新版本,他们会在项目的pom.xml文件中的dependencies标签中设置它,然后 Maven 会为他们下载它。

在我们的pom.xml文件中,还没有dependencies标签。但它可以放置在<project>...</project>标签的任何位置。让我们看一下pom.xml文件中依赖项的一些示例。我们现在可以将它们添加到项目中,因为无论如何我们以后都会使用它们:

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.1.0-M1</version>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.2.2</version>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.4</version>
  </dependency>
</dependencies>

第一个org.junit.jupiter依赖项是指junit-jupiter-api-5.1.0-M1.jar文件,其中包含编写测试所需的.class文件。我们将在下一节编写应用程序代码和测试中使用它。

第二个org.postgresql依赖项是指postgresql-42.2.2.jar文件,允许我们连接并使用 PostgreSQL 数据库。我们将在第十六章中使用此依赖项,数据库编程

第三个依赖项是指org.apache.commons文件commons-lang3-3.4.jar,其中包含许多称为实用程序的小型、非常有用的方法,其中一些我们将大量使用,用于各种目的。

每个.jar文件都存储在互联网上的一个仓库中。默认情况下,Maven 将搜索其自己的中央仓库,位于repo1.maven.org/maven2。您需要的绝大多数库都存储在那里。但在您需要指定其他仓库的罕见情况下,除了 Maven 中央仓库之外,您可以这样做:

<repositories>
  <repository>
    <id>my-repo1</id>
    <name>your custom repo</name>
    <url>http://jarsm2.dyndns.dk</url>
  </repository>
  <repository>
    <id>my-repo2</id>
    <name>your custom repo</name>
    <url>http://jarsm2.dyndns.dk</url>
  </repository>
</repositories>

阅读 Maven 指南,了解有关 Maven 的更多详细信息maven.apache.org/guides

配置了pom.xml文件后,我们可以开始为我们的第一个应用程序编写代码。但在此之前,我们想提一下如何自定义 IntelliJ IDEA 的配置,以匹配您对 IDE 外观和其他功能的偏好。

随时更改 IDE 设置

您可以随时更改 IntelliJ IDEA 的设置和项目配置,以调整 IDE 的外观和行为,使其最适合您的风格。花点时间看看您可以在以下每个配置页面上设置什么。

要更改 IntelliJ IDEA 本身的配置:

  • 在 Windows 上:点击顶部菜单上的文件,然后选择设置

  • 在 Linux 和 macOS 上:点击顶部菜单上的 IntelliJ IDEA,然后选择首选项

您访问的配置屏幕将类似于以下内容:

四处点击并查看您在这里可以做什么,以便了解 IDE 的可能性。

要更改特定于项目的设置,请单击文件,然后选择项目结构,并查看可用的设置和选项。请注意,可以通过右键单击项目名称(在左窗格中)然后选择打开模块设置来访问相同的屏幕。

在您建立了自己的风格并了解了自己的偏好之后,您可以将它们设置为 IDE 配置的默认设置,方法是通过文件|其他设置|默认设置。

默认项目结构也可以通过文件|其他设置|默认项目结构进行设置。这些默认设置将在每次创建新项目时自动应用。

有了这些,我们可以开始编写我们的应用程序代码了。

编写应用程序代码

这是程序员职业中最有趣的活动。这也是本书的目的——帮助你写出优秀的 Java 代码。

让我们从你的第一个应用程序的需求开始。它应该接受一个整数作为输入,将其乘以2,并以以下格式打印结果:<输入数字> * 2 = <结果>

现在,让我们来设计一下。我们将创建SimpleMath类,其中包含multiplyByTwo(int i)方法,该方法将接受一个整数并返回结果。这个方法将被MyApplication类的main()方法调用。main()方法应该:

  • 从用户那里接收一个输入数字

  • 将输入值传递给multiplyByTwo(int i)方法

  • 得到结果

  • 以所需的格式在屏幕上打印出来

我们还将为multiplyByTwo(int i)方法创建测试,以确保我们编写的代码能够正确运行。

我们将首先创建包含我们的.java文件的目录。目录路径必须与每个类的包名匹配。我们已经讨论过包,并将顶级包名设置为groupId值。现在,我们将描述如何在.java文件中声明它。

Java 包声明

包声明是任何 Java 类的第一行。它以package关键字开头,后面跟着包名。javacjava工具使用完全限定的类名在类路径上搜索类,这是一个在类名前附加包名的类名。例如,如果我们将MyApplication类放在com.packt.javapath.ch04demo包中,那么这个类的完全限定名将是com.packt.javapath.ch04demo.MyApplication。你可以猜到,ch04demo代表第四章的演示代码。这样,我们可以在不同的章节中使用相同的类名,它们不会冲突。这就是包名用于唯一标识类在类路径上的目的。

包的另一个功能是定义.java文件的位置,相对于src\main\java目录(适用于 Windows)或src/main/java目录(适用于 Linux)。包名必须与属于该包的文件的路径匹配:

src\main\java\com\packt\javapath\ch04demo\MyApplication.java (for Windows)

src/main/java/com/packt/javapath/ch04demo/MyApplication.java (for Linux) 

包名与文件位置之间的任何不匹配都会触发编译错误。当使用 IDE 向包名右键单击后使用 IDE 向导创建新类时,IDE 会自动将正确的包声明添加为.java文件的第一行。但是,如果不使用 IDE 创建新的源文件,那么就需要自己负责匹配包名和.java文件的位置。

如果.java文件位于src\main\java目录(适用于 Windows)或src/main/java目录(适用于 Linux)中,则可以不声明包名。Java 规范将这样的包称为默认包。使用默认包只适用于小型或临时应用程序,因为随着类的数量增加,一百甚至一千个文件的平面列表将变得难以管理。此外,如果你编写的代码要被其他项目使用,那么这些其他项目将无法在没有包名的情况下引用你的类。在第七章《包和可访问性(可见性)》中,我们将更多地讨论这个问题。

在编译过程中,.class文件的目录树是由javac工具创建的,并且它反映了.java文件的目录结构。Maven 在项目根目录中创建了一个target目录,并在其中创建了一个classes子目录。然后,Maven 在javac命令中使用-d选项指定这个子目录作为生成文件的输出位置:

//For Windows:
javac -classpath src\main\java -d target\classes 
 com.packt.javapath.ch04demo.MyApplication.java

//For Linux:
javac -classpath src/main/java -d target/classes 
 com.packt.javapath.ch04demo.MyApplication.java

在执行过程中,.class文件的位置设置在类路径上:

//For Windows:
java -classpath target\classes com.packt.javapath.ch04demo.MyApplication

//For Linux:
java -classpath target/classes com.packt.javapath.ch04demo.MyApplication

有了包声明、其功能以及与目录结构的关系的知识,让我们创建我们的第一个包。

创建一个包

我们假设您已经按照“使用项目向导创建项目”的步骤创建了项目。如果您已经关闭了 IDE,请重新启动它,并通过在“最近项目”列表中选择JavaPath来打开创建的项目。

项目打开后,在左窗格中点击src文件夹,然后点击main文件夹。现在应该看到java文件夹:

右键单击java文件夹,选择“新建”菜单项,然后选择“包”菜单项:

在弹出窗口中输入com

点击“确定”按钮。将创建com文件夹。

在左窗格中右键单击它,选择“新建”菜单项,然后选择“包”菜单项,在弹出窗口中输入packt

重复这个过程,在packt文件夹下创建javapath文件夹,然后在javapath文件夹下创建ch04demo文件夹。在com.packt.javapath.ch04demo包就位后,我们可以创建它的成员——MyApplication类。

创建MyApplication

要创建一个类,在左窗格中右键单击com.packt.javapath.che04demo包,选择“新建”菜单项,然后选择“Java 类”菜单项,在弹出窗口中输入MyApplication

点击“确定”按钮,类将被创建:

右窗格中MyApplication类的名称变得模糊。这就是 IntelliJ IDEA 指示它尚未被使用的方式。

构建应用程序

在幕后,IDE 会在每次更改代码时编译您正在编写的代码。例如,尝试删除右窗格中类名称中的第一个字母M。IDE 会立即警告您有语法错误:

如果将鼠标移到前面截图中类声明的红色气泡或任何下划线类声明的红线上,您将看到“类'yApplication'是公共的,应该在名为'yApplication.java'的文件中声明”的消息。您可能还记得我们在第二章中谈到过这一点,Java 语言基础知识

每个.java文件只包含一个public类。文件名必须与公共类名匹配。

因为 IDE 在每次更改后都会编译代码,所以在少量.java文件的情况下,显式构建项目是不必要的。但是当应用程序的大小增加时,您可能不会注意到出现问题。

这就是为什么请求 IDE 定期重新编译(或者换句话说,构建)应用程序的所有.java文件是一个好的做法,方法是点击顶部菜单中的“构建”,然后选择“重建项目”菜单项:

您可能已经注意到其他相关的菜单项:Build Project 和 Build Module 'javapath'。模块是一种跨包捆绑类的方式。但是使用模块超出了本书的范围。Build Project 仅重新编译已更改的类以及使用更改的类的类。只有在构建时间显着时才有意义。另一方面,Rebuild Projects 重新编译所有.java文件,无论它们是否已更改,我们建议您始终使用它。这样,您可以确保每个类都已重新构建,并且没有遗漏依赖项。

单击 Rebuild Projects 后,您将在左窗格中看到一个新的target文件夹:

这是 Maven(和 IntelliJ IDEA 使用的内置 Maven)存储.class文件的地方。您可能已经注意到javac工具为包名的每个部分创建一个文件夹。这样,编译类的树完全反映了源类的树。

现在,在继续编写代码之前,我们将执行一个技巧,使您的源树看起来更简单。

隐藏一些文件和目录

如果您不希望看到特定于 IDE 的文件(例如.iml文件)或临时文件和目录(例如target文件夹),可以配置 IntelliJ IDEA 不显示它们。只需单击 File | Settings(在 Windows 上)或 IntelliJ IDEA | Preferences(在 Linux 和 macOS 上),然后单击左列中的 Editor 菜单项,然后单击 File Types。生成的屏幕将具有以下部分:

在屏幕底部,您可以看到忽略文件和文件夹标签以及带有文件名模式的输入字段。在列表的末尾添加以下内容:*.iml;.idea;target;。然后,单击 OK 按钮。现在,您的项目结构应该如下所示:

它仅显示应用程序源文件和第三方库(在外部库下)。

创建 SimpleMath 类

现在让我们创建另一个包com.packt.javapath.math,并在其中创建SimpleMath类。这样做的原因是,将来我们计划在此包中有几个类似的与数学相关的类,以及其他与数学无关的类。

在左窗格中,右键单击com.packt.javapath.ch04demo包,选择 New,然后单击 Package。在提供的输入字段中键入math,然后单击 OK 按钮。

右键单击math包名称,选择 New,然后单击 Java Class,在提供的输入字段中键入SimpleMath,然后单击 OK 按钮。

你应该创建一个新的SimpleMath类,看起来像这样:

创建方法

首先,我们将以下方法添加到SimpleMath类中:

public int multiplyByTwo(int i){
  return i * 2;
}

现在,我们可以将使用上述方法的代码添加到MyApplication类中:

public static void main(String[] args) {
  int i = Integer.parseInt(args[0]);
  SimpleMath simpleMath = new SimpleMath();
  int result = simpleMath.multiplyByTwo(i);
  System.out.println(i + " * 2 = " + result);
}

上述代码非常简单。应用程序从String[] args输入数组的第一个元素接收一个整数作为输入参数。请注意,Java 数组中的第一个元素的索引是 0,而不是 1。参数作为字符串传递,并且必须通过使用标准 Java 库中java.lang.Integer类的parseInt()静态方法转换(解析)为int类型。我们将在第五章中讨论 Java 类型,Java 语言元素和类型

然后,创建了一个SimpleMath类的对象,并调用了multiplyByTwo()方法。返回的结果存储在int类型的result变量中,然后使用标准 Java 库的java.lang.System类以所需的格式打印出来。这个类有一个out静态属性,它持有一个对java.io.PrintStream类对象的引用。而PrintStream类又有println()方法,它将结果打印到屏幕上。

执行和单元测试应用程序

有几种方法可以执行我们的新应用程序。在构建应用程序部分,我们看到所有编译后的类都存储在target文件夹中。这意味着我们可以使用java工具并列出带有-classpath选项的target文件夹来执行应用程序。

要做到这一点,打开命令提示符或终端窗口,然后转到我们新项目的根目录。如果不确定在哪里,可以查看 IntelliJ IDEA 窗口顶部显示的完整路径。一旦进入项目根目录(即存放pom.xml文件的文件夹),运行以下命令:

在上述截图中,可以看到-classpath选项(我们使用了缩写版本-cp)列出了所有编译后的类所在的目录。之后,我们输入了com.packt.javapath.ch04demo.MyApplication主类的名称,因为我们必须告诉java工具哪个类是应用程序的入口点,并包含main()方法。然后,我们输入2作为主类的输入参数。你可能还记得,main()方法期望它是一个整数。

当我们运行该命令时,结果以预期格式显示输出:2 * 2 = 4

或者,我们可以将所有编译后的类收集到一个myapp.jar文件中,并使用类似的java命令在类路径上列出myapp.jar文件来运行:

在上述截图中,可以看到我们首先进入了target文件夹及其classes子文件夹,然后使用jar命令将其内容(所有编译后的类)收集到myapp.jar文件中。然后,我们使用java命令并列出了myapp.jar文件和-classpath选项。由于myapp.jar文件在当前目录中,我们不包括任何目录路径。java命令的结果与之前相同:2 * 2 = 4

另一种进入项目根目录的方法是直接从 IDE 打开终端窗口。在 IntelliJ IDEA 中,可以通过单击左下角的 Terminal 链接来实现:

然后,我们可以在 IDE 内部的终端窗口中输入所有上述命令。

但是,有一种更简单的方法可以在项目开发阶段从 IDE 中执行应用程序,而不必输入所有上述命令,这是推荐的方法。这是你的 IDE,记住吗?我们将在下一节中演示如何做到这一点。

使用 IDE 执行应用程序

为了能够从 IDE 执行应用程序,首次需要进行一些配置。在 IntelliJ IDEA 中,如果单击最顶部的菜单项,点击 Run,然后选择 Edit Configurations...,将会看到以下屏幕:

单击左上角的加号(+)符号,并在新窗口中输入值:

在名称字段中输入MyApplication(或其他任何名称)。

在主类字段中输入com.packt.javapath.ch02demo.MyApplication

在程序参数字段中输入2(或其他任何数字)。

在右上角的单一实例复选框中选中。这将确保您的应用程序始终只运行一个实例。

在填写了所有描述的值之后,单击右下角的 OK 按钮。

现在,如果您打开MyApplication类,您将看到两个绿色箭头 - 一个在类级别,另一个在main()方法中:

单击其中任何一个绿色箭头,您的应用程序将被执行。

结果将显示在 IntelliJ IDEA 左下角。将打开一个名为 Run 的窗口,并且您将看到应用程序执行的结果。如果您在程序参数字段中输入了2,则结果应该是相同的:2 * 2 = 4

创建单元测试

现在,让我们为SimpleMath类的multiplyByTwo()方法编写一个测试,因为我们希望确保multiplyByTwo()方法按预期工作。只要项目存在,这样的测试就很有用,因为您可以在每次更改代码时运行它们,并验证现有功能没有意外更改。

方法是应用程序中最小的可测试部分。这就是为什么这样的测试被称为单元测试。为您创建的每个方法编写单元测试是一个好主意(例如,除了诸如 getter 和 setter 之类的微不足道的方法)。

我们将使用一个名为 JUnit 的流行测试框架。有几个版本。在撰写本文时,版本 5 是最新版本,但版本 3 和 4 仍在积极使用。我们将使用版本 5。它需要 Java 8 或更高版本,并且我们假设您的计算机上至少安装了 Java 9。

如我们已经提到的,在使用第三方库或框架时,您需要在pom.xml文件中将其指定为依赖项。一旦您这样做,Maven 工具(或 IDE 的内置 Maven 功能)将在 Maven 在线存储库中查找相应的.jar文件。它将下载该.jar文件到您计算机主目录中自动创建的.m2文件夹中的本地 Maven 存储库。之后,您的项目可以随时访问并使用它。

我们已经在Maven 项目配置部分的pom.xml中设置了对 JUnit 5 的依赖。但是,假设我们还没有这样做,以便向您展示程序员通常如何做。

首先,您需要进行一些研究并决定您需要哪个框架或库。例如,通过搜索互联网,您可能已经阅读了 JUnit 5 文档(junit.org/junit5)并发现您需要在junit-jupiter-api上设置 Maven 依赖项。有了这个,您可以再次搜索互联网,这次搜索maven dependency junit-jupiter-api,或者只搜索maven dependency junit 5。您搜索结果中的第一个链接很可能会将您带到以下页面:

选择您喜欢的任何版本(我们选择了最新版本 5.1.0-M1)并单击它。

将打开一个新页面,告诉您如何在pom.xml中设置依赖项:

或者,您可以转到 Maven 存储库网站(mvnrepository.com)并在其搜索窗口中键入junit-jupiter-api。然后,单击提供的链接之一,您将看到相同的页面。

如果您在阅读第三章 您的开发环境设置时没有添加junit-jupiter-api依赖项,现在可以通过将提供的依赖项复制到pom.xml文件中的<dependencies></dependencies>标签内来添加它:

现在,您可以使用 JUnit 框架创建单元测试。

在 IntelliJ IDEA 中,junit-jupiter-api-5.1.0-M1.jar文件也列在左侧窗格的External Libraries文件夹中。如果您打开列表,您将看到还有两个其他库,这些库没有在pom.xml文件中指定:junit-latform-commons-1.0.0-M1.jaropentest4j-1.0.0.jar。它们存在是因为junit-jupiter-api-5.1.0-M1.jar依赖于它们。这就是 Maven 的工作原理-它发现所有依赖项并下载所有必要的库。

现在,我们可以为SimpleMath类创建一个测试。我们将使用 IntelliJ IDEA 来完成。打开SimpleMath类,右键单击类名,然后选择 Go To,点击 Test:

您将会看到一个小弹出窗口:

单击 Create New Test...,然后以下窗口将允许您配置测试:

在 IntelliJ IDEA 中有对 JUnit 5 的内置支持。在前面的屏幕中,选择 JUnit5 作为测试库,并选中multiplyByTwo()方法的复选框。然后,单击右下角的 OK 按钮。测试将被创建:

请注意,在左侧窗格的test/java文件夹下,创建了一个与SimpleMath类的包结构完全匹配的包结构。在右侧窗格中,您可以看到SimpleMathTest测试类,其中包含一个针对multiplyByTwo()方法的测试(目前为空)。测试方法可以有任何名称,但必须在其前面加上@Test,这被称为注解。它告诉测试框架这是其中一个测试。

让我们实现测试。例如,我们可以这样做:

正如你所看到的,我们已经创建了SimpleMath类的对象,并调用了带有参数2multiplyByTwo()方法。我们知道正确的结果应该是4,我们使用来自 JUnit 框架的assertEquals()方法来检查结果。我们还在类和测试方法中添加了@DisplayName注解。您很快就会看到这个注解的作用。

现在让我们修改SimpleMath类中的mutliplyByTwo()方法:

我们不仅仅是乘以2,我们还将1添加到结果中,所以我们的测试将失败。首先在错误的代码上运行测试是一个好习惯,这样我们可以确保我们的测试能够捕捉到这样的错误。

执行单元测试

现在,让我们回到SimpleMathTest类,并通过单击绿色箭头之一来运行它。类级别上的绿色箭头运行所有测试方法,而方法级别上的绿色箭头只运行该测试方法。因为我们目前只有一个测试方法,所以单击哪个箭头都无所谓。结果应该如下所示:

这正是我们希望看到的:测试期望得到一个等于4的结果,但实际得到了5。这让我们对我们的测试是否正确工作有了一定的信心。

请注意,在左侧窗格中,我们可以看到来自@DisplayName注解的显示名称-这就是这些注解的目的。

还要单击右侧窗格中的每个蓝色链接,以查看它们的作用。第一个链接提供有关预期和实际结果的更详细信息。第二个链接将带您到测试的行,其中包含失败测试的断言,这样您就可以看到确切的上下文并纠正错误。

现在,您可以再次转到SimpleMath类,并删除我们添加的1。然后,单击左上角的绿色三角形(参见前面的屏幕截图)。这意味着重新运行测试。结果应该如下所示:

顺便说一下,您可能已经注意到我们的屏幕截图和项目路径已经略有改变。这是因为我们现在是从在 macOS 上运行的 IntelliJ IDEA 中获取屏幕截图,所以我们可以覆盖 Windows 和 macOS。正如您所看到的,IntelliJ IDEA 屏幕在 Windows 和 macOS 系统上的外观基本相同。

多少单元测试足够?

这总是任何程序员在编写新方法或修改旧方法时都会考虑的问题-有多少单元测试足以确保应用程序得到彻底测试,以及应该是什么样的测试?通常,仅为应用程序的每个方法编写一个测试是不够的。通常需要测试许多功能方面。但是,每个测试方法应该只测试一个方面,这样更容易编写和理解。

例如,对于我们简单的multiplyByTwo()方法,我们可以添加另一个测试(我们将称之为multiplyByTwoRandom()),它会将随机整数作为输入传递给方法,并重复一百次。或者,我们可以考虑一些极端的数字,比如0和负数,并查看我们的方法如何处理它们(例如,我们可以称它们为multiplyByZero()multiplyByNegative())。另一个测试是使用一个非常大的数字-比 Java 允许的最大整数的一半还要大(我们将在第五章中讨论这样的限制,Java 语言元素和类型)。我们还可以考虑在multiplyByTwo()方法中添加对传入参数值的检查,并在传入参数大于最大整数的一半时抛出异常。我们将在第十章中讨论异常,控制流语句

您可以看到最简单的方法的单元测试数量增长得多快。想象一下,对于一个比我们简单代码做得多得多的方法,可以编写多少单元测试。

我们也不希望写太多的单元测试,因为我们需要在项目的整个生命周期中维护所有这些代码。过去,不止一次,一个大项目因为编写了太多复杂的单元测试而变得维护成本过高,而这些测试几乎没有增加任何价值。这就是为什么通常在项目代码稳定并在生产中运行一段时间后,如果有理由认为它有太多的单元测试,团队会重新审视它们,并确保没有无用的测试、重复的测试或其他明显的问题。

编写良好的单元测试,可以快速工作并彻底测试代码,这是一种随着经验而来的技能。在本书中,我们将利用一切机会与您分享单元测试的最佳实践,以便在本书结束时,您将在这个非常重要的专业 Java 编程领域中有一些经验。

练习-JUnit @Before 和@After 注释

阅读 JUnit 用户指南(junit.org/junit5/docs/current/user-guide)和类SampleMathTest两个新方法:

  • 只有在任何测试方法运行之前执行一次的方法

  • 只有在所有测试方法运行后执行一次的方法

我们没有讨论它,所以您需要进行一些研究。

答案

对于 JUnit 5,可以用于此目的的注释是@BeforeAll@AfterAll。这是演示代码:

public class DemoTest {
  @BeforeAll
  static void beforeAll(){
    System.out.println("beforeAll is executed");
  }
  @AfterAll
  static void afterAll(){
    System.out.println("afterAll is executed");
  }
  @Test
  void test1(){
    System.out.println("test1 is executed");
  }
  @Test
  void test2(){
    System.out.println("test2 is executed");
  }
}

如果您运行它,输出将是:

beforeAll is executed
test1 is executed
test2 is executed
afterAll is executed 

总结

在本章中,您了解了 Java 项目以及如何设置和使用它们来编写应用程序代码和单元测试。您还学会了如何构建和执行应用程序代码和单元测试。基本上,这就是 Java 程序员大部分时间所做的事情。在本书的其余部分,您将更详细地了解 Java 语言、标准库以及第三方库和框架。

在下一章中,我们将深入探讨 Java 语言的元素和类型,包括intStringarrays。您还将了解标识符是什么,以及如何将其用作变量的名称,以及有关 Java 保留关键字和注释的信息。

第五章:Java 语言元素和类型

本章从定义语言元素-标识符、变量、文字、关键字、分隔符和注释开始系统地介绍 Java。它还描述了 Java 类型-原始类型和引用类型。特别关注String类、enum类型和数组。

在本章中,我们将涵盖以下主题:

  • 什么是 Java 语言元素?

  • 注释

  • 标识符和变量

  • 保留和受限关键字

  • 分隔符

  • 原始类型和文字

  • 引用类型和字符串

  • 数组

  • 枚举类型

  • 练习-变量声明和初始化

什么是 Java 语言元素?

与任何编程语言一样,Java 具有适用于语言元素的语法。这些元素是用于构成语言结构的构建块,允许程序员表达意图。元素本身具有不同的复杂性级别。较低级别(更简单)的元素使得构建更高级别(更复杂)的元素成为可能。有关 Java 语法和语言元素的更详细和系统的处理,请参阅 Java 规范(docs.oracle.com/javase/specs)。

在本书中,我们从属于最低级别之一的输入元素开始。它们被称为输入元素,因为它们作为 Java 编译器的输入。

输入元素

根据 Java 规范,Java 输入元素可以是以下三种之一:

  • 空白字符:可以是这些 ASCII 字符之一- SP(空格),HT(水平制表符)或 FF(换页符,也称为分页符)

  • 注释:一个自由形式的文本,不会被编译器处理,而是原样转换为字节码,因此程序员在编写代码时使用注释来添加人类可读的解释。注释可以包括空格,但不会被识别为输入元素;它只会作为注释的一部分进行处理。我们将在注释部分描述注释的语法规则并展示一些示例。

  • 令牌:可以是以下之一:

  • 标识符:将在标识符和变量部分描述。

  • 关键字:将在保留和受限关键字部分描述。

  • 分隔符:将在分隔符部分描述。

  • 文字:将在原始类型和文字部分描述。一些文字可以包括空格,但不会被识别为输入元素;空格只会作为文字的一部分进行处理。

  • 运算符:将在第九章中描述,运算符、表达式和语句**。

输入元素用于构成更复杂的元素,包括类型。一些关键字用于表示类型,我们也将在本章中讨论它们。

类型

Java 是一种强类型语言,这意味着任何变量声明必须包括其类型。类型限制了变量可以保存的值以及如何传递这个值。

Java 中的所有类型分为两类:

  • 原始类型:在原始类型和文字部分描述

  • 引用类型:在引用类型和字符串部分描述

一些引用类型需要更多关注,要么是因为它们的复杂性,要么是因为其他细节,必须解释清楚以避免将来的混淆:

  • 数组:在数组部分描述

  • 字符串(大写的第一个字符表示它是一个类的名称):在引用类型和字符串部分描述

  • 枚举类型:在枚举类型部分描述

注释

Java 规范提供了关于注释的以下信息:

"有两种注释:

/文本/

传统注释:从 ASCII 字符/到 ASCII 字符/的所有文本都被忽略(与 C 和 C++一样)。

//文本

行尾注释:从 ASCII 字符//到行尾的所有文本都被忽略(就像 C++中一样)。

这是我们已经编写的SimpleMath类中注释的一个例子:

public class SimpleMath {
  /*
    This method just multiplies any integer by 2
    and returns the result
  */
  public int multiplyByTwo(int i){        
    //Should we check if i is bigger than 1/2 of Integer.MAX_VALUE ?
    return i * 2; // The magic happens here
  }
}

注释不会以任何方式影响代码。它们只是程序员的注释。此外,不要将它们与 JavaDoc 或其他文档生成系统混淆。

标识符和变量

标识符和变量是 Java 中最常用的元素之一。它们密切相关,因为每个变量都有一个名称,而变量的名称是一个标识符。

标识符

标识符是 Java 标记列表中的第一个。它是一系列符号,每个符号可以是字母、美元符号$、下划线_或任何数字 0-9。限制如下:

  • 标识符的第一个符号不能是数字

  • 单个符号标识符不能是下划线_

  • 标识符不能与关键字拼写相同(请参阅保留和受限关键字部分)

  • 标识符不能是布尔文字truefalse

  • 标识符不能拼写为特殊类型null

如果违反上述任何限制,编译器将生成错误。

实际上,标识符使用的字母通常来自英文字母表-小写或大写。但也可以使用其他字母表。您可以在 Java 规范的第 3.8 节中找到可以包含在标识符中的字母的正式定义(docs.oracle.com/javase/specs)。以下是该部分示例的列表:

  • i3

  • αρετη

  • String

  • MAX_VALUE

  • isLetterOrDigit

为了展示各种可能性,我们可以再添加两个合法标识符的示例:

  • $

  • _1

变量

变量是一个存储位置,正如 Java 规范在变量部分所述。它有一个名称(标识符)和一个分配的类型。变量指的是存储值的内存。

Java 规范规定了八种变量:

  • 类变量:可以在不创建对象的情况下使用的静态类成员

  • 实例变量:只能通过对象使用的非静态类成员

  • 数组成员:数组元素(参见数组部分)

  • 方法参数:传递给方法的参数

  • 构造函数参数:创建对象时传递给构造函数的参数

  • Lambda 参数:传递给 lambda 表达式的参数。我们将在第十七章中讨论它,Lambda 表达式和函数式编程

  • 异常参数:在捕获异常时创建,我们将在第十章中讨论它,控制流语句

  • 局部变量:在方法内声明的变量

从实际角度看,所有八种变量可以总结如下:

  • 类成员,静态或非静态

  • 数组成员(也称为组件或元素)

  • 方法、构造函数或 lambda 表达式的参数

  • catch 块的异常参数

  • 常规的局部代码变量,最常见的一种

大多数情况下,当程序员谈论变量时,他们指的是最后一种。它可以是类成员、类实例、参数、异常对象或您正在编写的代码所需的任何其他值。

变量声明、定义和初始化

让我们先看一下例子。假设我们连续有这三行代码:

int x;  //declartion of variable x
x = 1;  //initialization of variable x
x = 2;  //assignment of variable x 

从前面的例子中可以看出,变量初始化是将第一个(初始)值赋给变量。所有后续的赋值不能称为初始化。

本地变量在初始化之前不能使用:

int x;
int result = x * 2;  //generates compilation error

前面代码的第二行将生成编译错误。如果一个变量是类的成员(静态或非静态)或数组的组件,并且没有显式初始化,它将被赋予一个默认值,该默认值取决于变量的类型(参见Primitive types and literalsReference types and String部分)。

声明创建一个新变量。它包括变量类型和名称(标识符)。单词declaration是 Java 规范中使用的一个技术术语,第 6.1 节(docs.oracle.com/javase/specs)。但是一些程序员在 Java 中使用单词 definition 作为 declaration 的同义词,因为在其他一些编程语言(例如 C 和 C++)中,单词 definition 用于 Java 中不存在的一种语句类型。因此,要注意这一点,并假设当你听到definition应用于 Java 时,它们指的是 declaration。

在编写 Java 代码时,大多数情况下,程序员将声明和初始化语句结合在一起。例如,可以声明并初始化一个int类型的变量来保存整数1,如下所示:

int $ = 1;
int _1 = 1;
int i3 = 1;
int αρετη = 1;
int String = 1;
int MAX_VALUE = 1;
int isLetterOrDigit = 1;

相同的标识符可以用来声明和初始化一个String类型的变量来保存abs

String $ = "abc";
String _1 = "abc";
String i3 = "abc";
String αρετη = "abc";
String String = "abc";
String MAX_VALUE = "abc";
String isLetterOrDigit = "abc";

正如您可能已经注意到的,在前面的例子中,我们使用了Identifier部分示例中的标识符。

final 变量(常量)

final 变量是一旦初始化就不能被赋予另一个值的变量。它由final关键字表示:

void someMethod(){
  final int x = 1;
  x = 2; //generates compilation error
  //some other code
}

尽管如此,以下代码将正常工作:

void someMethod(){
  final int x;
  //Any code that does not use variable x can be added here
  x = 2;
  //some other code 
}
```java

前面的代码不会生成编译错误,因为在声明语句中,本地变量不会自动初始化为默认值。只有在变量没有显式初始化时,类、实例变量或数组组件才会被初始化为默认值(参见*Primitive types and literals*和*Reference types and String*部分)。

当一个 final 变量引用一个对象时,它不能被赋值给另一个对象,但是随时可以改变被分配的对象的状态(参见*引用类型和 String*部分)。对于引用数组的变量也是一样,因为数组是一个对象(参见*数组*部分)。

由于 final 变量不能被更改,它是一个常量。如果它具有原始类型或`String`类型,则称为常量变量。但是 Java 程序员通常将术语常量应用于类级别的 final 静态变量,并将本地 final 变量称为 final 变量。按照惯例,类级别常量的标识符以大写字母写入。以下是一些示例:

```java
static final String FEBRUARY = "February";
static final int DAYS_IN_DECEMBER = 31;

这些常量看起来与以下常量非常相似:

Month.FEBRUARY;
TimeUnit.DAYS;
DayOfWeek.FRIDAY;

但前面的常量是在一种特殊类型的类中定义的,称为enum,尽管在所有实际目的上,所有常量的行为都是相似的,因为它们不能被更改。只需检查常量的类型,就可以知道其类(类型)提供了什么方法。

保留和受限关键字

关键字是 Java 标记中列出的第二个,我们已经看到了几个 Java 关键字——abstractclassfinalimplementsintinterfacenew, package, private, public, return, static, 和 void。现在我们将列出所有保留关键字的完整列表。这些关键字不能用作标识符。

保留关键字

以下是 Java 9 的所有 49 个关键字的列表:

abstract class final implements int
interface new package private public
return static void if this
break double default protected throw
byte else import synchronized throws
case enum instanceof boolean transient
catch extends switch short try
char for assert do finally
continue float long strictfp volatile
native super while _ (下划线)

这些关键字用于不同的 Java 元素和语句,不能用作标识符。gotoconst_(下划线)关键字尚未用作关键字,但它们可能在未来的 Java 版本中使用。目前,它们只是包含在保留关键字列表中,以防止它们用作标识符。但它们可以作为标识符的一部分,例如:

int _ = 3; //Error, underscore is a reserved keyword
int __ = 3; //More than 1 underscore as an identifier is OK
int _1 = 3;
int y_ = 3;
int goto_x = 3;
int const1 = 3;

truefalse 看起来像关键字,不能用作标识符,但实际上它们不是 Java 关键字。它们是布尔字面值(值)。我们将在基本类型和字面值部分定义字面值是什么。

还有另一个看起来像关键字的词,但实际上是一种特殊类型——null(参见引用类型和字符串部分)。它也不能用作标识符。

受限关键字

有十个词被称为受限关键字:openmodulerequirestransitiveexportsopenstousesprovideswith。它们被称为受限,因为它们在模块声明的上下文中不能作为标识符,我们将不在本书中讨论。在所有其他地方,可以将它们用作标识符。以下是这种用法的一个例子:

int to = 1;
int open = 1;
int uses = 1;
int with = 1;
int opens =1;
int module = 1;
int exports =1;
int provides = 1;
int requires = 1;
int transitive = 1;

然而,最好不要在任何地方将它们用作标识符。有很多其他方法来命名一个变量。

分隔符

分隔符是 Java 标记中列出的第三个。以下是它们的全部十二个,没有特定的顺序:

;  { }  ( )  [ ]  ,  .  ...  ::  @

分号";"

到目前为止,您已经非常熟悉分隔符;(分号)的用法。它在 Java 中的唯一作用是终止语句:

int i;  //declaration statement
i = 2;  //assignment statement
if(i == 3){    //flow control statement called if-statement
  //do something
}
for(int i = 0; i < 10; i++){  
  //do something with each value of i
}

大括号“{}”

你已经看到了类周围的大括号{}

class SomeClass {
  //class body with code
}

你也看到了方法体周围的大括号:

void someMethod(int i){
  //...
  if(i == 2){
    //block of code
  } else {
    //another block of code
  }
  ...
}

大括号也用于表示控制流语句中的代码块(参见第十章,控制流语句):

void someMethod(int i){
  //...
  if(i == 2){
    //block of code
  } else {
    //another block of code
  }
  ...
}

它们用于初始化数组(请参阅数组部分):

int[] myArray = {2,3,5};

还有一些其他很少使用的构造,其中使用大括号。

括号“()”

您还看到了使用分隔符()(括号)在方法定义和方法调用中保持方法参数列表:

void someMethod(int i) {
  //...
  String s = anotherMethod();
  //...
}

它们还用于控制流语句(请参阅第十章,控制流语句):

if(i == 2){
  //...
}

在类型转换期间(请参阅基本类型和文字部分),它们放在类型周围:

long v = 23;
int i = (int)v;

至于设置执行的优先级(请参阅第九章,运算符,表达式和语句),您应该从基本代数中熟悉它:

x = (y + z) * (a + b).

括号“[]”

分隔符[](方括号)用于数组声明(请参阅数组部分):

int[] a = new int[23];

逗号“,”

逗号,用于括号中列出方法参数的分隔:

void someMethod(int i, String s, int j) {
  //...
  String s = anotherMethod(5, 6.1, "another param");
  //...
}

逗号也可以用于在声明语句中分隔相同类型的变量:

int i, j = 2; k;

在上面的示例中,ijk三个变量都声明为int类型,但只有变量j初始化为2

在循环语句中使用逗号具有与声明多个变量相同的目的(请参阅第十章,控制流语句):

for (int i = 0; i < 10; i++){
   //...
} 

句号“.”

分隔符.(句点)用于分隔包名称的各个部分,就像您在com.packt.javapath示例中看到的那样。

您还看到了如何使用句号来分隔对象引用和该对象的方法:

int result = simpleMath.multiplyByTwo(i);

同样,如果simpleMath对象具有a的公共属性,则可以将其称为simpleMath.a

省略号“...”

分隔符...(省略号)仅用于 varargs:

int someMethod(int i, String s, int... k){
  //k is an array with elements k[0], k[1], ...
}

可以以以下任何一种方式调用前面的方法:

someMethod(42, "abc");          //array k = null
someMethod(42, "abc", 42, 43);  //k[0] = 42, k[1] = 43
int[] k = new int[2];
k[0] = 42;
k[1] = 43;
someMethod(42, "abc", k);       //k[0] = 42, k[1] = 43

在第二章中,Java 语言基础,在讨论main()方法时,我们解释了 Java 中varargs(可变参数)的概念。

冒号"::"

分隔符::(冒号)用于 lambda 表达式中的方法引用(请参阅第十七章,Lambda 表达式和函数式编程):

List<String> list = List.of("1", "32", "765");
list.stream().mapToInt(Integer::valueOf).sum();

@符号“@”

分隔符@(@符号)用于表示注释:

@Override
int someMethod(String s){
  //...
}

在第四章中创建单元测试时,您已经看到了注释的几个示例,您的第一个 Java 项目。 Java 标准库中有几个预定义的注释(@Deprecated@Override@FunctionalInterface等)。 我们将在第十七章中使用其中一个(@FunctionalInterface),Lambda 表达式和函数式编程

注释是元数据。它们描述类、字段和方法,但它们本身不会被执行。Java 编译器和 JVM 读取它们,并根据注释以某种方式处理所描述的类、字段或方法。例如,在第四章,您的第一个 Java 项目中,您看到我们如何使用@Test注释。在公共非静态方法前面添加它会告诉 JVM 它是一个必须运行的测试方法。因此,如果您执行此类,JVM 将仅运行此方法。

或者,如果您在方法前面使用@Override注释,编译器将检查此方法是否实际覆盖了父类中的方法。如果在任何类的父类中找不到非私有非静态类的匹配签名,则编译器将引发错误。

还可以创建新的自定义注释(JUnit 框架确实如此),但这个主题超出了本书的范围。

基本类型和文字

Java 只有两种变量类型:基本类型和引用类型。基本类型定义了变量可以保存的值的类型以及这个值可以有多大或多小。我们将在本节讨论基本类型。

引用类型允许我们只向变量分配一种值 - 对存储对象的内存区域的引用。我们将在下一节引用类型和字符串中讨论引用类型。

基本类型可以分为两组:布尔类型和数值类型。数值类型组可以进一步分为整数类型(byteshortintlongchar)和浮点类型(float 和 double)。

每种基本类型都由相应的保留关键字定义,列在保留和受限关键字部分中。

布尔类型

布尔类型允许变量具有两个值之一:truefalse。正如我们在保留关键字部分中提到的那样,这些值是布尔文字,这意味着它们是直接表示自己的值 - 而不是一个变量。我们将在基本类型文字部分更多地讨论文字。

这是一个b变量声明和初始化为值true的示例:

boolean b = true;

这是另一个示例,使用表达式将true值分配给b布尔变量:

 int x = 1, y = 1;
 boolean b = 2 == ( x + y );

在前面的示例中,在第一行中,声明了两个int基本类型的变量xy,并分别赋值为1。在第二行,声明了一个布尔变量,并将其赋值为2 == ( x + y )表达式的结果。括号设置了执行的优先级,如下所示:

  • 计算分配给xy变量的值的总和

  • 使用==布尔运算符将结果与2进行比较

我们将在第九章,运算符、表达式和语句中学习运算符和表达式。

布尔变量用于控制流语句,我们将在第十章,控制流语句中看到它们的许多用法。

整数类型

Java 整数类型的值占用不同数量的内存:

  • byte:8 位

  • char:16 位

  • short:16 位

  • int:32 位

  • long:64 位

除了char之外,所有这些都是有符号整数。符号值(负号-0,正号+1)占据值的二进制表示的第一位。这就是为什么有符号整数只能作为正数,只能容纳无符号整数值的一半。但它允许有符号整数容纳负数,而无符号整数则不能。例如,在byte类型(8 位)的情况下,如果它是无符号整数,它可以容纳的值的范围将从 0 到 255(包括 0 和 255),因为 8 的 2 次方是 256。但是,正如我们所说,byte类型是有符号整数,这意味着它可以容纳的值的范围是从-128 到 127(包括-128、127 和 0)。

char类型的情况下,它可以包含从 0 到 65535 的值,因为它是一个无符号整数。这个整数(称为代码点)标识 Unicode 表中的一个记录(en.wikipedia.org/wiki/List_of_Unicode_characters)。每个 Unicode 表记录都有以下列:

  • 代码点: 十进制值,Unicode 记录的数字表示

  • Unicode 转义: 带有\u前缀的四位数

  • 可打印符号: Unicode 记录的图形表示(控制码不可用)

  • 描述: 符号的可读描述

以下是 Unicode 表中的五个记录:

代码点 Unicode 转义 可打印符号 描述
8 \u0008 退格
10 \u000A 换行
36 \u0024 ` 代码点
--- --- --- ---
8 \u0008 退格
10 \u000A 换行
美元符号
51 \u0033 3 数字三
97 \u0061 a 拉丁小写字母 a

前两个示例是代表不可打印的控制码的 Unicode 示例。控制码用于向设备(例如显示器或打印机)发送命令。Unicode 集中只有 66 个这样的代码。它们的代码点从 0 到 32 和从 127 到 159。其余的 65535 个 Unicode 记录都有一个可打印的符号,即记录所代表的字符。

char类型的有趣(并且经常令人困惑)之处在于 Unicode 转义和代码点可以互换使用,除非char类型的变量参与算术运算。在这种情况下,使用代码点的值。为了证明这一点,让我们看一下以下代码片段(在注释中,我们捕获了输出):

char a = '3';
System.out.println(a);         //  3
char b = '$';
System.out.println(b);         //  $
System.out.println(a + b);     //  87
System.out.println(a + 2);     //  53
a = 36;    
System.out.println(a);         //  $ 

如您所见,char类型的变量ab代表3$符号,并且只要它们不参与算术运算,就会显示为这些符号。否则,只使用代码点值。

从这五个 Unicode 记录中可以看出,3字符的代码点值为 51,而$字符的代码点值为 36。这就是为什么将ab相加得到 87,将2加到a上得到 53 的原因。

在示例代码的最后一行中,我们将十进制值 36 分配给了char类型的变量a。这意味着我们已经指示 JVM 将代码点为 36 的字符$分配给变量a

这就是为什么char类型包含在 Java 的整数类型组中的原因,因为它在算术运算中充当数字类型。

每种原始类型可以容纳的值的范围如下:

  • byte:从-128 到 127,包括

  • short:从-32,768 到 32,767,包括

  • int:从-2.147.483.648 到 2.147.483.647,包括

  • long:从-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807,包括

  • char:从'\u0000'到'\uffff',即从 0 到 65,535,包括

您可以随时使用每种原始类型的相应包装类访问每种类型的最大值和最小值(我们将在第九章中更详细地讨论包装类,运算符,表达式和语句)。以下是一种方法(在注释中,我们已经显示了输出):

byte b = Byte.MIN_VALUE;
System.out.println(b);     //  -127
b = Byte.MAX_VALUE;
System.out.println(b);     //   128

short s = Short.MIN_VALUE;
System.out.println(s);      // -32768 
s = Short.MAX_VALUE;
System.out.println(s);      //  32767

int i = Integer.MIN_VALUE;
System.out.println(i);      // -2147483648
i = Integer.MAX_VALUE;
System.out.println(i);      //  2147483647

long l = Long.MIN_VALUE;
System.out.println(l);      // -9223372036854775808
l = Long.MAX_VALUE;
System.out.println(l);      //  9223372036854775807 

char c = Character.MIN_VALUE;
System.out.println((int)c); // 0
c = Character.MAX_VALUE;
System.out.println((int)c); // 65535

您可能已经注意到了(int)c构造。它称为转换,类似于电影制作期间对演员进行特定角色的尝试。任何原始数值类型的值都可以转换为另一个原始数值类型的值,前提是它不大于目标类型的最大值。否则,在程序执行期间将生成错误(此类错误称为运行时错误)。我们将在第九章运算符,表达式和语句中更多地讨论原始数值类型之间的转换。

数值类型和boolean类型之间的转换是不可能的。如果您尝试执行此操作,将生成编译时错误。

浮点类型

在 Java 规范中,浮点类型(floatdouble)的定义如下:

"单精度 32 位和双精度 64 位格式 IEEE 754 值。"

这意味着float类型占用 32 位,double类型占用 64 位。它们表示带有点“。”后的分数部分的正数和负数值:1.2345.5610.-1.34。默认情况下,在 Java 中,带有点的数值被假定为double类型。因此,以下赋值会导致编译错误:

float r = 23.4;

为了避免错误,必须通过在值后附加fF字符来指示该值必须被视为float类型,如下所示:

float r = 23.4f;
or
float r = 23.4F;

这些值(23.4f23.4F)本身称为文字。我们将在原始类型文字部分中更多地讨论它们。

最小值和最大值可以通过与整数相同的方式找到。只需运行以下代码片段(在注释中,我们捕获了我们在计算机上得到的输出):

System.out.println(Float.MIN_VALUE);  //1.4E-45
System.out.println(Float.MAX_VALUE);  //3.4028235E38
System.out.println(Double.MIN_VALUE); //4.9E-324 
System.out.println(Double.MAX_VALUE); //1.7976931348623157E308

负值的范围与正数的范围相同,只是在每个数字前面加上减号-。零可以是0.0-0.0

原始类型的默认值

声明变量后,在使用之前必须为其分配一个值。正如我们在变量声明,定义和初始化部分中提到的,必须显式初始化或分配值给局部变量。例如:

int x;
int y = 0;
x = 1;

但是,如果变量被声明为类字段(静态),实例(非静态)属性或数组组件,并且未显式初始化,则会自动使用默认值进行初始化。值本身取决于变量的类型:

  • 对于byteshortintlong类型,默认值为零,0

  • 对于floatdouble类型,默认值为正零,0.0

  • 对于char类型,默认值是\u0000,点码为零

  • 对于boolean类型,默认值是false

原始类型文字

文字是输入类型部分列出的 Java 标记中的第四个。它是一个值的表示。我们将在引用类型和字符串部分讨论引用类型的文字。现在我们只讨论原始类型的文字。

为了演示原始类型的文字,我们将在com.packt.javapath.ch05demo包中使用一个LiteralsDemo程序。您可以通过右键单击com.packt.javapath.ch05demo包,然后选择 New | Class,并输入LiteralsDemo类名来创建它,就像我们在第四章中描述的那样,你的第一个 Java 项目

在原始类型中,boolean类型的文字是最简单的。它们只有两个:truefalse。我们可以通过运行以下代码来演示:

public class LiteralsDemo {
  public static void main(String[] args){
    System.out.println("boolean literal true: " + true);
    System.out.println("boolean literal false: " + false);
  }
}

结果将如下所示:

这些都是可能的布尔文字(值)。

现在,让我们转向更复杂的char类型文字的话题。它们可以是以下形式:

  • 一个单个字符,用单引号括起来

  • 一个转义序列,用单引号括起来

单引号,或者撇号,是一个具有 Unicode 转义\u0027(十进制代码点 39)的字符。当我们在整数类型部分演示char类型在算术运算中作为数值类型的行为时,我们已经看到了几个char类型文字的例子。

以下是char类型文字作为单个字符的其他示例:

System.out.println("char literal 'a': " + 'a');
System.out.println("char literal '%': " + '%');
System.out.println("char literal '\u03a9': " + '\u03a9'); //Omega
System.out.println("char literal '™': " + '™'); //Trade mark sign

如果你运行上面的代码,输出将如下所示:

现在,让我们谈谈char类型文字的第二种类型 - 转义序列。它是一组类似于控制码的字符组合。实际上,一些转义序列包括控制码。以下是完整列表:

  • \b(退格 BS,Unicode 转义\u0008

  • \t(水平制表符 HT,Unicode 转义\u0009

  • \n(换行 LF,Unicode 转义\u000a

  • \f(换页 FF,Unicode 转义\u000c

  • \r(回车 CR,Unicode 转义\u000d

  • \ "(双引号",Unicode 转义\u0022

  • \``(单引号',Unicode 转义\u0027`)

  • \\(反斜杠\,Unicode 转义\u005c

正如你所看到的,转义序列总是以反斜杠(\)开头。让我们演示一些转义序列的用法:

System.out.println("The line breaks \nhere");
System.out.println("The tab is\there");
System.out.println("\"");
System.out.println('\'');
System.out.println('\\');

如果你运行上面的代码,输出将如下所示:

正如你所看到的,\n\t转义序列只作为控制码。它们本身不可打印,但会影响文本的显示。其他转义序列允许在其他情况下无法打印的上下文中打印符号。连续三个双引号或单引号将被视为编译器错误,就像单个反斜杠字符在没有反斜杠的情况下使用时一样。

char类型文字相比,浮点文字要简单得多。如前所述,默认情况下,23.45文字为double类型,如果要将其设置为double类型,则无需添加字母dD。但是,如果您愿意更明确,可以这样做。另一方面,float类型文字需要在末尾添加字母fF。让我们运行以下示例(请注意我们如何使用\n转义序列在输出之前添加换行符):

System.out.println("\nfloat literal 123.456f: " + 123.456f);
System.out.println("double literal 123.456d: " + 123.456d);

结果如下:

浮点类型文字也可以使用eE表示科学计数法(参见en.wikipedia.org/wiki/Scientific_notation):

System.out.println("\nfloat literal 1.234560e+02f: " + 1.234560e+02f);
System.out.println("double literal 1.234560e+02d: " + 1.234560e+02d);

前面代码的结果如下:

如您所见,无论以十进制格式还是科学格式呈现,值都保持不变。

byteshortintlong整数类型的文字默认为int类型。以下赋值不会导致任何编译错误:

byte b = 10;
short s = 10;
int i = 10;
long l = 10;

但以下每一行都会生成错误:

byte b = 128;
short s = 32768;
int i = 2147483648;
long l = 2147483648;

这是因为byte类型可以容纳的最大值为 127,short类型可以容纳的最大值为 32,767,int类型可以容纳的最大值为 2,147,483,647。请注意,尽管long类型可以容纳的最大值为 9,223,372,036,854,775,807,但最后一个赋值仍然失败,因为 2,147,483,648 文字默认为int类型,但超过了最大的int类型值。要创建long类型的文字,必须在末尾添加字母lL,因此以下赋值也可以正常工作:

long l = 2147483648L;

使用大写L是一个好习惯,因为小写字母l很容易与数字1混淆。

前面的整数字面值示例是用十进制数系统表示的。但是,byteshortintlong类型的文字也可以用二进制(基数 2,数字 0-1),八进制(基数 8,数字 0-7)和十六进制(基数 16,数字 0-9 和 a-f)数系统表示。以下是演示代码:

System.out.println("\nPrint literal 12:");
System.out.println("- bin 0b1100: "+ 0b1100);
System.out.println("- oct    014: "+ 014);
System.out.println("- dec     12: "+ 12);
System.out.println("- hex    0xc: "+ 0xc);

如果运行上述代码,输出将是:

如您所见,二进制文字以0b(或0B)开头,后跟以二进制系统表示的值121100(=2⁰*0 + 2¹*0 + 2²*1 + 2³ *1)。八进制文字以0开头,后跟以八进制系统表示的值1214(=8⁰*4 + 8¹*1)。十进制文字就是12。十六进制文字以0x(或0X)开头,后跟以十六进制系统表示的值 12——c(因为在十六进制系统中,符号af(或AF)对应的是十进制值1015)。

在文字前面加上减号(-)会使值变为负数,无论使用哪种数字系统。以下是演示代码:

System.out.println("\nPrint literal -12:");
System.out.println("- bin 0b1100: "+ -0b1100);
System.out.println("- oct    014: "+ -014);
System.out.println("- dec     12: "+ -12);
System.out.println("- hex    0xc: "+ -0xc);

如果运行上述代码,输出将如下所示:

另外,为了完成我们对原始类型文字的讨论,我们想提到原始类型文字中下划线(_)的可能用法。在长数字的情况下,将其分成组有助于快速估计其数量级。以下是一些示例:

int speedOfLightMilesSec = 299_792_458; 
float meanRadiusOfEarthMiles = 3_958.8f;
long creditCardNumber = 1234_5678_9012_3456L;

让我们看看当我们运行以下代码时会发生什么:

long anotherCreditCardNumber = 9876____5678_____9012____1234L;
System.out.println("\n" + anotherCreditCardNumber);

前面代码的输出如下:

正如您所看到的,如果在数字文字中的数字之间放置一个或多个下划线,这些下划线将被忽略。在任何其他位置放置下划线将导致编译错误。

引用类型和字符串

当对象分配给变量时,此变量保存对对象所在内存的引用。从实际的角度来看,这样的变量在代码中被处理,就好像它是所代表的对象一样。这样的变量的类型可以是类、接口、数组或特殊的null类型。如果分配了null,则对象的引用将丢失,变量不再代表任何对象。如果对象不再使用,JVM 将在称为垃圾收集的过程中从内存中删除它。我们将在第十一章中描述这个过程,JVM 进程和垃圾收集

还有一种称为类型变量的引用类型,用于声明泛型类、接口、方法或构造函数的类型参数。它属于 Java 泛型编程的范畴,超出了本书的范围。

所有对象,包括数组,都继承自第二章中描述的java.lang.Object类的所有方法,Java 语言基础

引用java.lang.String类(或只是String)的变量也是引用类型。但在某些方面,String对象的行为类似于原始类型,这有时可能会令人困惑。这就是为什么我们将在本章中专门介绍String类的原因。

此外,枚举类型(也是引用类型)需要特别注意,我们将在本节末尾的枚举类型子节中进行描述。

类类型

使用相应的类名声明类类型的变量:

<Class name> variableName;

它可以通过将null或该类的对象(实例)进行赋值来进行初始化。如果该类有一个超类(也称为父类)从中继承(扩展),则可以使用超类的名称进行变量声明。这是由于 Java 多态性的存在,该多态性在第二章中有所描述,Java 语言基础。例如,如果SomeClass类扩展SomeBaseClass,则以下声明和初始化都是可能的:

SomeBaseClass someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();
```java

而且,由于每个类默认都扩展了`java.lang.Object`类,以下声明和初始化也是可能的:

```java
Object someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();

我们将在第九章中更多地讨论将子类对象分配给基类引用的情况,运算符、表达式和语句

接口类型

使用相应的接口名称声明接口类型的变量:

<Interface name> variableName;
```java

它可以通过将`null`或实现接口的类的对象(实例)分配给它来进行初始化。这是一个例子:

```java
interface SomeInterface{
  void someMethod();
}
interface SomeOtherInterface{
  void someOtherMethod();
}
class SomeClass implements SomeInterface {
  void someMethod(){
    ...
  }
} 
class SomeOtherClass implements SomeOtherInterface{
  void someOtherMethod(){
    ...
  }
}
SomeInterface someInterface = new SomeClass();
someInterface = new SomeOtherClass(); //not possible, error
someInterface.someMethod();         //works just fine
someInterface.someOtherMethod();   //not possible, error

我们将在[第九章](33ed1fb4-36e0-499b-8156-4d5e88a2c404.xhtml)中更多地讨论将子类型分配给基类型引用。

数组

在 Java 中,数组是引用类型,并且也扩展(继承自)Object类。数组包含与声明的数组类型相同的类型的组件,或者可以将值分配给数组类型的类型。组件的数量可以为零,在这种情况下,数组为空数组。

数组组件没有名称,并且由索引引用,该索引是正整数或零。说具有n长度的n个组件的数组。一旦创建数组对象,其长度就永远不会改变。

数组声明以类型名称和空括号[]开头:

byte[] bs;
long[][] ls;
Object[][] os;
SomeClass[][][] scs; 

括号对的数量表示数组的维数(或嵌套深度)。

有两种创建和初始化数组的方法:

  • 通过创建表达式,使用new关键字,类型名称和每个括号中每个维度的长度的括号;例如:
        byte[] bs = new byte[100];
        long[][] ls = new long [2][3];
        Object[][] os = new Object[3][2];
        SomeClass[][][] scs = new SomeClass[3][2][1]; 
  • 通过数组初始化程序,使用由大括号括起来的每个维度的逗号分隔值的列表,例如:
        int[][] is = { { 1, 2, 3 }, { 10, 20 }, { 3, 4, 5, 6 } };
        float[][] fs = { { 1.1f, 2.2f, 3 }, { 10, 20.f, 30.f } };
        Object[] oss = { new Object(), new SomeClass(), null, "abc" };
        SomeInterface[] sis = { new SomeClass(), null, new SomeClass() };

从这些示例中可以看出,多维数组可以包含不同长度的数组(int [] [] is数组)。此外,只要值可以分配给数组类型的变量(float [] [] fsObject [] isSomeInterface [] sis数组),组件类型值可以与数组类型不同。

因为数组是对象,所以每次创建数组时都会初始化其组件。让我们考虑这个例子:

int[][] is = new int[2][3];
System.out.println("\nis.length=" + is.length);
System.out.println("is[0].length=" + is[0].length);
System.out.println("is[0][0].length=" + is[0][0]);
System.out.println("is[0][1].length=" + is[0][1]);
System.out.println("is[0][2].length=" + is[0][2]);
System.out.println("is[1].length=" + is[0].length);
System.out.println("is[1][0].length=" + is[1][0]);
System.out.println("is[1][1].length=" + is[1][1]);
System.out.println("is[1][2].length=" + is[1][2]);

如果我们运行前面的代码片段,输出将如下所示:

![](img / a2463ad3-fe53-43ab-9e19-511714b556cf.png)

可以在不初始化某些维度的情况下创建多维数组:

int[][] is = new int[2][];
System.out.println("\nis.length=" + is.length);
System.out.println("is[0]=" + is[0]);
System.out.println("is[1]=" + is[1]);

此代码运行的结果如下:

![](img / 9c7279b2-2fe4-48b6-aa7e-b42fae6c43e1.png)

缺少的维度可以稍后添加:

int[][] is = new int[2][];
is[0] = new int[3];
is[1] = new int[3];

重要的是,必须在使用之前初始化维度。

引用类型的默认值

引用类型的默认值是null。这意味着如果引用类型是静态类成员或实例字段,并且没有显式分配初始值,它将自动初始化并分配null的值。请注意,在数组的情况下,这适用于数组本身和其引用类型组件。

引用类型字面量

null字面量表示没有对引用类型变量的任何赋值。让我们看下面的代码片段:

SomeClass someClass = new SomeClass();
someClass.someMethod();
someClass = null;
someClass.someMethod(); // throws NullPointerException

第一条语句声明了someClass变量,并为其分配了SomeClass类对象的引用。然后使用其引用调用了该类的一个方法。接下来的一行将null字面量赋给someClass变量。它从变量中移除了引用值。因此,当在下一行中我们尝试再次调用相同的方法时,我们会得到NullPointerException,这只有在使用的引用被赋予null值时才会发生。

String类型也是一个引用类型。这意味着String变量的默认值是nullString类从java.lang.Object类继承了所有方法,就像其他引用类型一样。

但在某些方面,String类的对象的行为就像原始类型一样。我们将讨论一个这样的情况——当String对象用作方法参数时——在将引用类型值作为方法参数传递部分。我们现在将讨论String类像原始类型一样行为的其他情况。

String类型的另一个特性使它看起来像一个原始类型的是,它是唯一一个不仅仅只有null字面量的引用类型。String类型也可以有零个或多个字符的字面量,用双引号括起来——"""$""abc""12-34"String字面量的字符也可以包括转义序列。以下是一些例子:

System.out.println("\nFirst line.\nSecond line.");
System.out.println("Tab space\tin the line");
System.out.println("It is called a \"String literal\".");
System.out.println("Latin Capital Letter Y with diaeresis: \u0178");

如果你执行上述代码片段,输出将如下所示:

但是,与char类型字面量相反,String字面量在算术运算中不像数字那样行为。String类型适用的唯一算术运算是加法,它的行为类似于连接:

System.out.println("s1" + "s2");
String s1 = "s1";
System.out.println(s1 + "s2");
String s2 = "s1";
System.out.println(s1 + s2);

运行上述代码,你会看到以下内容:

String的另一个特点是,String类型的对象是不可变的。

字符串的不可变性

不能改变分配给变量的String类型值而不改变引用。JVM 作者决定这样做有几个原因:

  • 所有的String字面量都存储在同一个称为字符串池的共同内存区域中。在存储新的String字面量之前,JVM 会检查是否已经存储了这样的字面量。如果这样的对象已经存在,就不会创建新对象,而是返回对现有对象的引用作为对新对象的引用。以下代码演示了这种情况:
        System.out.println("s1" == "s1");
        System.out.println("s1" == "s2");
        String s1 = "s1";
        System.out.println(s1 == "s1");
        System.out.println(s1 == "s2");
        String s2 = "s1";
        System.out.println(s1 == s2);

在上述代码中,我们使用了==关系运算符,它用于比较原始类型的值和引用类型的引用。如果我们运行这段代码,结果将如下所示:

你可以看到,文字的各种比较(直接或通过变量)始终在两个文字拼写相同的情况下产生true,并且在拼写不同的情况下产生false。这样,长String文字不会被复制,内存使用更好。

为了避免不同方法同时修改相同文字的并发修改,每次我们尝试改变String文字时,都会创建一个带有更改的文字副本,而原始的String文字保持不变。以下是演示它的代码:

        String s1 = "\nthe original string";
        String s2 = s1.concat(" has been changed"); 
        System.out.println(s2);
        System.out.println(s1);

String类的concat()方法将另一个String文字添加到s1的原始值,并将结果分配给s1变量。此代码的输出如下:

正如你所看到的,分配给s1的原始文字没有改变。

  • 这样设计的另一个原因是安全性-这是 JVM 作者所考虑的最高优先级目标之一。String文字广泛用作用户名和密码,用于访问应用程序、数据库和服务器。String值的不可变性使其不太容易受到未经授权的修改。

  • 另一个原因是,有一些计算密集型的过程(例如Object父类中的hashCode()方法)在长String值的情况下可能会相当耗费资源。通过使String对象不可变,如果已经对具有相同拼写的值执行了这样的计算,就可以避免这样的计算。

这就是为什么所有修改String值的方法都返回String类型的原因,它是指向携带结果的新String对象的引用。前面代码中的concat()方法就是这种方法的典型例子。

String对象不是从文字创建的情况下,情况变得有些复杂,而是使用String构造函数new String("some literal")。在这种情况下,String对象存储在存储所有类的所有对象的相同区域,并且每次使用new关键字时,都会分配另一块内存(具有另一个引用)。以下是演示它的代码:

String s3 = new String("s");
String s4 = new String("s");
System.out.println(s3 == s4);

如果你运行它,输出将如下:

正如你所看到的,尽管拼写相同,但对象具有不同的内存引用。为了避免混淆并仅通过拼写比较String对象,始终使用String类的equals()方法。以下是演示其用法的代码:

System.out.println("s5".equals("s5"));  //true
System.out.println("s5".equals("s6"));  //false
String s5 = "s5";
System.out.println(s5.equals("s5"));   //true
System.out.println(s5.equals("s6"));   //false
String s6 = "s6";
System.out.println(s5.equals(s5));     //true
System.out.println(s5.equals(s6));     //false
String s7 = "s6";
System.out.println(s7.equals(s6));     //true
String s8 = new String("s6");
System.out.println(s8.equals(s7));     //true
String s9 = new String("s9");
System.out.println(s8.equals(s9));     //false

如果你运行它,结果将是:

我们将结果添加为前面代码的注释,以方便您查看。正如你所看到的,String类的equals()方法仅基于值的拼写返回truefalse,因此当拼写比较是你的目标时,始终使用它。

顺便说一句,你可能记得equals()方法是在Object类中定义的——String类的父类。String类有它自己的equals()方法,它覆盖了父类中具有相同签名的方法,就像我们在第二章中展示的那样,Java 语言基础String类的equals()方法的源代码如下:

public boolean equals(Object anObject) {
  if (this == anObject) {
    return true;
  }
  if (anObject instanceof String) {
    String aString = (String)anObject;
    if (coder() == aString.coder()) {
      return isLatin1() ? 
             StringLatin1.equals(value, aString.value)
            : StringUTF16.equals(value, aString.value);
    }
  }
  return false;
}

正如你所看到的,它首先比较引用,如果它们指向相同的对象,则返回true。但是,如果引用不同,它会比较值的拼写,这实际上发生在StringLatin1StringUTF16类的equals()方法中。

我们希望你能明白String类的equals()方法通过首先执行引用比较来进行优化,只有在不成功时才比较值本身。这意味着在代码中不需要比较引用。相反,对于String类型的对象比较,总是只使用equals()方法。

有了这个,我们就进入了本章讨论的最后一个引用类型——enum类型。

枚举类型

在描述enum类型之前,让我们看一个使用案例作为拥有这种类型的动机。假设我们想创建一个描述TheBlows家庭的类:

public class TheBlows {
  private String name, relation, hobby = "biking";
  private int age;
  public TheBlows(String name, String relation, int age) {
    this.name = name;
    this.relation = relation;
    this.age = age;
  }
  public String getName() { return name; } 
  public String getRelation() { return relation; }
  public int getAge() { return age; }
  public String getHobby() { return hobby; }
  public void setHobby(String hobby) { this.hobby = hobby; }
}

我们将默认爱好设置为骑车,并允许稍后更改,但其他属性必须在对象构造期间设置。这很好,除了我们不想在系统中有超过四个这个家庭的成员,因为我们非常了解TheBlows家庭的所有成员。

为了强加这些限制,我们决定提前创建TheBlows类的所有可能对象,并将构造函数设为私有:

public class TheBlows {
  public static TheBlows BILL = new TheBlows("Bill", "father", 42);
  public static TheBlows BECKY = new TheBlows("BECKY", "mother", 37);
  public static TheBlows BEE = new TheBlows("Bee", "daughter", 5);
  public static TheBlows BOB = new TheBlows("Bob", "son", 3);
  private String name, relation, hobby = "biking";
  private int age;
  private TheBlows(String name, String relation, int age) {
    this.name = name;
    this.relation = relation;
    this.age = age;
  }
  public String getName() { return name; }
  public String getRelation() { return relation; }
  public int getAge() { return age; }
  public String getHobby() { return hobby; }
  public void setHobby(String hobby) { this.hobby = hobby; }
}

现在只有TheBlows类的四个实例存在,这个类的其他对象都不能被创建。让我们看看如果运行以下代码会发生什么:

System.out.println(TheBlows.BILL.getName());
System.out.println(TheBlows.BILL.getHobby());
TheBlows.BILL.setHobby("fishing");
System.out.println(TheBlows.BILL.getHobby());

我们将得到以下输出:

同样,我们可以创建TheJohns家庭,有三个家庭成员:

public class TheJohns {
  public static TheJohns JOE = new TheJohns("Joe", "father", 42);
  public static TheJohns JOAN = new TheJohns("Joan", "mother", 37);
  public static TheJohns JILL = new TheJohns("Jill", "daughter", 5);
  private String name, relation, hobby = "joggling";
  private int age;
  private TheJohns(String name, String relation, int age) {
    this.name = name;
    this.relation = relation;
    this.age = age;
  }
  public String getName() { return name; }
  public String getRelation() { return relation; }
  public int getAge() { return age; }
  public String getHobby() { return hobby; }
  public void setHobby(String hobby) { this.hobby = hobby; }
}

While doing that, we noticed a lot of commonalities in these two classes and decided to create a Family base class:

public class Family {
  private String name, relation, hobby;
  private int age;
  protected Family(String name, String relation, int age, String hobby) {
    this.name = name;
    this.relation = relation;
    this.age = age;
    this.hobby = hobby;
  }
  public String getName() { return name; }
  public String getRelation() { return relation; }
  public int getAge() { return age; }
  public String getHobby() { return hobby; }
  public void setHobby(String hobby) { this.hobby = hobby; }
}

Now the TheBlows and TheJohns classes can be substantially simplified after extending the Family class. Here's how the TheBlows class can now look:

public class TheBlows extends Family {
  public static TheBlows BILL = new TheBlows("Bill", "father", 42);
  public static TheBlows BECKY = new TheBlows("Becky", "mother", 37);
  public static TheBlows BEE = new TheBlows("Bee", "daughter", 5);
  public static TheBlows BOB = new TheBlows("Bob", "son", 3);
  private TheBlows(String name, String relation, int age) {
    super(name, relation, age, "biking");
  }
}

And that is the idea behind the enum type—to allow the creating of classes with a fixed number of named instances.

The enum reference type class extends the java.lang.Enum class. It defines the set of constants, each of them an instance of the enum type it belongs to. The declaration of such a set starts with the enum keyword. Here is an example:

enum Season { SPRING, SUMMER, AUTUMN, WINTER }

Each of the listed items—SPRING, SUMMER, AUTUMN, and WINTER—is an instance of Season. They are the only four instances of the Season class that can exist in an application. No other instance of the Season class can be created. And that is the reason for the creation of the enum type: it can be used for cases when the list of instances of a class has to be limited to the fixed set, such as the list of possible seasons.

The enum declaration can also be written in a camel-case style:

enum Season { Spring, Summer, Autumn, Winter }

But the all-uppercase style is used more often because, as we mentioned earlier, the static final constant's identifiers in Java programming are written this way by convention, in order to distinguish them from the non-constant variable. And enum constants are static and final implicitly.

Let's review an example of the Season class usage. Here is a method that prints different messages, depending on the season:

void enumDemo(Season season){
  if(season == Season.WINTER){
    System.out.println("Dress up warmer");
  } else {
    System.out.println("You can drees up lighter now");
  }
}

Let's see what happens if we run the following two lines:

enumDemo(Season.WINTER);
enumDemo(Season.SUMMER);

The result will be as follows:

You probably have noticed that we used an == operator that compares references. That is because the enum instances (as all static variables) exist uniquely in memory. And the equals() method (implemented in the java.lang.Enum parent class) brings the same result. Let's run the following code:

Season season = Season.WINTER;
System.out.println(Season.WINTER == season);
System.out.println(Season.WINTER.equals(season));

The result will be:

这是因为java.lang.Enum类的equals()方法是这样实现的:

public final boolean equals(Object other) {
  return this == other;
}

正如您所看到的,它确切地比较了两个对象引用-this(指代当前对象的保留关键字)和对另一个对象的引用。如果您想知道为什么参数具有Object类型,我们想提醒您,所有引用类型,包括enumString,都扩展了java.lang.Object。它们是隐式的。

java.lang.Enum的其他有用方法如下:

  • name(): 返回enum常量的标识符,就像在声明时拼写的那样。

  • ordinal(): 返回与枚举常量在声明时的位置相对应的整数(列表中的第一个枚举常量的序数值为零)。

  • valueOf(): 根据其名称返回enum常量对象。

  • toString(): 默认情况下返回与name()方法相同的值,但可以被重写以返回任何其他String值。

  • values(): 在java.lang.Enum类的文档中找不到的静态方法。在 Java 规范的 8.9.3 节(docs.oracle.com/javase/specs)中,它被描述为隐式声明的,而 Java 教程(docs.oracle.com/javase/tutorial/java/javaOO/enum.html)则指出编译器在创建枚举时会自动添加一些特殊方法

其中,一个静态的values()方法返回一个包含enum的所有值的数组,按照它们被声明的顺序。

让我们看一个它们用法的例子。这是我们将用于演示的enum类:

enum Season {
  SPRING, SUMMER, AUTUMN, WINTER;
}

以下是使用它的代码:

System.out.println(Season.SPRING.name());
System.out.println(Season.SUMMER.ordinal());
System.out.println(Enum.valueOf(Season.class, "AUTUMN"));
System.out.println(Season.WINTER.name());

前面片段的输出如下:

第一行是name()方法的输出。第二行是ordinal()方法的返回值:SUMMER常量在列表中是第二个,因此其序数值为 1。第三行是应用于valueOf()方法返回的AUTUMNenum常量的toString()方法的结果。最后一行是应用于WINTER常量的toString()方法的结果。

equals()name()ordinal()方法在java.lang.Enum中被声明为final,因此它们不能被重写,而是按原样使用。valueOf()方法是静态的,不与任何类实例关联,因此不能被重写。我们唯一可以重写的方法是toString()方法:

enum Season {
  SPRING, SUMMER, AUTUMN, WINTER;
  public String toString() {
    return "The best season";
  }
}

如果我们再次运行前面的代码,结果如下:

现在,您可以看到toString()方法对于每个常量返回相同的结果。必要时,toString()方法可以为每个常量重写。让我们看一下Season类的这个版本:

enum Season2 {
  SPRING,
  SUMMER,
  AUTUMN,
  WINTER { public String toString() { return "Winter"; } };
  public String toString() {
    return "The best season";
  }
}

我们只为WINTER常量重写了toString()方法。如果我们再次运行相同的代码片段,结果将如下:

正如您所看到的,除了WINTER之外,所有常量都使用了旧版本的toString()

还可以为enum常量添加任何属性(以及 getter 和 setter),并将每个常量与相应的值关联起来。这是一个例子:

enum Season {
  SPRING("Spring", "warmer than winter", 60),
  SUMMER("Summer", "the hottest season", 100),
  AUTUMN("Autumn", "colder than summer", 70),
  WINTER("Winter", "the coldest season", 40);

  private String feel, toString;
  private int averageTemperature;
  Season(String toString, String feel, int t) {
    this.feel = feel;
    this.toString = toString;
    this.averageTemperature = t;
  }
  public String getFeel(){ return this.feel; }
  public int getAverageTemperature(){
    return this.averageTemperature;
  }
  public String toString() { return this.toString; }
}

在上面的示例中,我们在Season类中添加了三个属性:feeltoStringaverageTemperature。我们还创建了一个构造函数(用于为对象状态分配初始值的特殊方法),该构造函数接受这三个属性并添加获取器和toString()返回值的方法。然后,在每个常量的括号中,我们设置了在创建此常量时要传递给构造函数的值。

这是我们将要使用的演示方法:

void enumDemo(Season season){
  System.out.println(season + " is " + season.getFeel());
  System.out.println(season + " has average temperature around " 
                               + season.getAverageTemperature());
}

enumDemo()方法接受enum Season常量并构造并显示两个句子。让我们为每个季节运行上述代码,就像这样:

enumDemo2(Season3.SPRING);
enumDemo2(Season3.SUMMER);
enumDemo2(Season3.AUTUMN);
enumDemo2(Season3.WINTER);

结果如下:

图片

enum类是一种非常强大的工具,它允许我们简化代码,并使其在运行时更加受保护,因为所有可能的值都是可预测的,并且可以提前测试。例如,我们可以使用以下单元测试来测试SPRING常量的获取器:

@DisplayName("Enum Season tests")
public class EnumSeasonTest {
  @Test
  @DisplayName("Test Spring getters")
  void multiplyByTwo(){
    assertEquals("Spring", Season.SPRING.toString());
    assertEquals("warmer than winter", Season.SPRING.getFeel());
    assertEquals(60, Season.SPRING.getAverageTemperature());
  }
}

当然,获取器的代码不会出现太多错误。但如果enum类有更复杂的方法,或者固定值列表来自于一些应用需求文档,这样的测试将确保我们已按照要求编写了代码。

在标准的 Java 库中,有几个enum类。以下是这些类中常量的几个例子,可以让你了解其中的内容:

Month.FEBRUARY;
TimeUnit.DAYS;
TimeUnit.MINUTES;
DayOfWeek.FRIDAY;
Color.GREEN;
Color.green;

所以,在创建自己的enum之前,尝试检查并查看标准库是否已提供具有所需值的类。

将引用类型值作为方法参数传递

一种需要特别讨论的引用类型和原始类型之间的重要区别是它们的值在方法中的使用方式。让我们通过示例来看看区别。首先,我们创建SomeClass类:

class SomeClass{
  private int count;
  public int getCount() {
    return count;
  }
  public void setCount(int count) {
      this.count = count;
    }
}

然后我们创建一个使用它的类:

public class ReferenceTypeDemo {
  public static void main(String[] args) {
    float f = 1.0f;
    SomeClass someClass = new SomeClass();
    System.out.println("\nBefore demoMethod(): f = " + f + 
                             ", count = " + someClass.getCount());
    demoMethod(f, someClass);
    System.out.println("After demoMethod(): f = " + f 
                           + ", count = " + someClass.getCount());
  }
  private static void demoMethod(float f, SomeClass someClass){
    //... some code can be here
    f = 42.0f;
    someClass.setCount(42);
    someClass = new SomeClass();
    someClass.setCount(1001);
  }
}

首先让我们看看demoMethod()内部。我们为演示目的使其非常简单,但假设它做了更多的事情,然后为f变量(参数)分配一个新值,并在SomeClass类的对象上设置一个新的计数值。然后,此方法尝试用指向具有另一个计数值的新SomeClass对象的新值替换传入的引用。

main()方法中,我们声明并初始化fsomeClass变量,并打印它们,然后将它们作为参数传递给demoMethod()方法,并再次打印相同变量的值。让我们运行main()方法并查看结果,结果应该如下所示:

要理解区别,我们需要考虑这两个事实:

  • 方法传递的值是通过副本传递的

  • 引用类型的值是指向所指对象所在内存的引用

这就是为什么当传递原始值(或String,如我们已经解释的那样是不可变的)时,会创建实际值的副本,因此原始值不会受到影响。

同样,如果传入对象的引用被传入,那么方法中的代码只能访问其副本,因此无法更改原始引用。这就是为什么我们尝试更改原始引用值并使其引用另一个对象并没有成功的原因。

但是方法内部的代码可以访问原始对象并使用引用值的副本更改其计数值,因为该值仍指向原始对象所在的相同内存区域。这就是为什么方法内部的代码能够执行原始对象的任何方法,包括更改对象状态(实例字段的值)的方法。

当将对象状态更改为参数传递时,称为副作用,有时会在以下情况下使用:

  • 方法必须返回多个值,但无法通过返回的结构来实现

  • 程序员不够熟练

  • 第三方库或框架利用副作用作为获取结果的主要机制

但是最佳实践和设计原则(在这种情况下是单一责任原则,我们将在第八章中讨论面向对象设计(OOD)原则)指导程序员尽量避免副作用,因为副作用经常导致代码不易阅读(对于人类来说)和难以识别和修复的微妙运行时效果。

必须区分副作用和称为委托模式的代码设计模式(en.wikipedia.org/wiki/Delegation_pattern),当在传入的对象上调用的方法是无状态的。我们将在第八章中讨论设计模式,面向对象设计(OOD)原则

类似地,当数组作为参数传入时,副作用是可能的。以下是演示它的代码:

public class ReferenceTypeDemo {
  public static void main(String[] args) {
    int[] someArray = {1, 2, 3};
    System.out.println("\nBefore demoMethod(): someArray[0] = " 
                                               + someArray[0]);
    demoMethod(someArray);
    System.out.println("After demoMethod(): someArray[0] = " 
                                                + someArray[0]);
  }
  private static void demoMethod(int[] someArray){
    someArray[0] = 42;
    someArray = new int[3];
    someArray[0] = 43;
  }
}

前面代码的执行结果如下:

您可以看到,尽管在方法内部,我们能够将新数组分配给传入的变量,但值43的分配仅影响新创建的数组,但对原始数组没有影响。然而,使用传入的引用值的副本更改数组组件是可能的,因为副本仍然指向相同的原始数组。

并且,为了结束关于引用类型作为方法参数和可能的副作用的讨论,我们想证明String类型参数-由于String值的不可变性-在作为参数传递时的行为类似于原始类型。这是演示代码:

public class ReferenceTypeDemo {
  public static void main(String[] args) {
    String someString = "Some string";
    System.out.println("\nBefore demoMethod(): string = " 
                                              + someString);
    demoMethod(someString);
    System.out.println("After demoMethod(): string = " 
                                              + someString);
  }
  private static void demoMethod(String someString){
    someString = "Some other string";
  }
}

上述代码产生以下结果:

方法内的代码无法更改原始参数值。这样做的原因不是-与原始类型的情况一样-在将其传递到方法之前复制了参数值。在这种情况下,副本仍指向相同的原始String对象。实际原因是更改String值不会更改该值,而是创建另一个具有更改结果的String对象。这就是我们在String 类型和文字部分中描述的String值不可变性机制。分配给传入的引用值的副本的新(更改的)String对象的引用,并且不会对仍然指向原始 String 对象的原始引用值产生影响。

有了这个,我们结束了关于 Java 引用类型和 String 的讨论。

练习-变量声明和初始化

以下哪些陈述是正确的:

  1. int x ='x';

  2. int x1 =“x”;

  3. char x2 =“x”;

  4. char x4 = 1;

  5. String x3 = 1;

  6. Month.MAY = 5;

  7. Month month = Month.APRIL;

答案

1, 4, 7

总结

本章为讨论更复杂的 Java 语言构造奠定了基础。 Java 元素的知识,例如标识符,变量,文字,关键字,分隔符,注释和类型-原始和引用-对于 Java 编程是必不可少的。如果不正确理解,您还有机会了解一些可能引起混淆的领域,例如 String 类型的不可变性和引用类型作为方法参数时可能的副作用。数组和enum类型也得到了详细解释,使读者能够使用这些强大的构造并提高其代码的质量。

在下一章中,读者将介绍 Java 编程的最常见术语和编码解决方案-应用程序编程接口API),对象工厂,方法覆盖,隐藏和重载。然后,关于软件系统设计和聚合(vs 继承)的优势的讨论将使读者进入最佳设计实践的领域。 Java 数据结构的概述将结束本章,为读者提供实用的编程建议和推荐。

第六章:接口,类和对象构造

本章向读者解释了 Java 编程的最重要方面:应用程序编程接口(API),对象工厂,方法重写,隐藏和重载。接着是聚合(而不是继承)的设计优势的解释,开始讨论软件系统设计。本章最后概述了 Java 数据结构。

在本章中,我们将涵盖以下主题:

  • 什么是 API?

  • 接口和对象工厂作为 API

  • 重写,隐藏和重载

  • thissuper关键字

  • 构造函数和构造函数重载

  • 最终变量,最终方法和最终类

  • 对象关联(聚合)

  • 练习-将类实例化限制为单个共享实例

API 是什么?

术语应用程序编程接口(API)是程序员用来实现所需功能的协议,程序和服务的规范。API 可以代表基于 Web 的系统,操作系统,数据库系统,计算机硬件或软件库。

除此之外,在日常生活中,术语 API 经常用于实现规范的系统。例如,您可能熟悉 Twitter APIs(developer.twitter.com/en/docs)或 Amazon APIs(developer.amazon.com/services-and-apis),或者您可能已经使用能够通过提供数据(测量结果)来响应请求的设备(传感器)。因此,当程序员说我们可以使用 Amazon API时,他们不仅指提供的程序描述,还指服务本身。

在 Java 中,我们还有一些关于API 使用的术语变体,我们希望在以下小节中进行识别和描述。

Java API

Java API 包括两大类 API 和实现它们的库:

  • Java 核心包(www.oracle.com/technetwork/java/api-141528.html)随 Java 安装提供并包含在 JDK 中

  • 其他可以单独下载的框架和库,例如 Apache Commons APIs(commons.apache.org),或者我们已经在 Maven 的pom.xml文件中包含为依赖项的三个库。其中绝大多数可以在 Maven 仓库(mvnrepository.com)中找到,但也可以在其他地方找到各种新的和实验性的库和框架。

命令行 API

命令行 API 描述了命令格式及其可能的选项,可用于执行应用程序(工具)。我们在第一章中讨论使用javajavac工具(应用程序)时看到了这样的例子,您的计算机上的 Java 虚拟机(JVM)。我们甚至在第四章中构建了自己的应用程序,定义了其 API,并描述了其命令行 API,接受整数作为参数。

基于 HTTP 的 API

基于 Web 的应用程序通常使用各种协议(en.wikipedia.org/wiki/List_of_web_service_protocols)提供基于 HTTP 的 API,允许通过互联网访问应用程序功能。HTTP 代表超文本传输协议,是分布式信息系统的应用协议,是万维网WWW)数据通信的基础。

最流行的两种 Web 服务协议是:

  • 基于 XML 的SOAP(Simple Object Access Protocol)协议

  • 基于 JSON 的 REST 或 RESTful(REpresentational State Transfer)风格的 HTTP 协议

两者都描述了如何访问功能(服务)并将其合并到应用程序中。我们在本书中不描述 Web 服务。

软件组件 API

软件组件可以是一个库,一个应用子系统,一个应用层,甚至是一个单独的类——可以通过调用其方法直接从 Java 代码中使用的东西。软件组件的 API 看起来像描述方法签名的接口,可以在实现接口的类的对象上调用这些方法。如果组件有公共静态方法(不需要对象,只能使用类调用),这些方法也必须包含在 API 描述中。但是,对于组件 API 的完整描述,正如我们在第二章中已经提到的那样,关于如何创建组件的对象的信息也应该是 API 描述的一部分。

在本书中,我们不会超越应用程序边界,并且只会在先前描述的软件组件 API 的意义上使用术语 API。而且,我们将按其名称称呼实现 API 的实体(API 描述的服务):应用子系统,应用层,库,类,接口和方法。

这就是为什么我们开始了一个关于接口和对象工厂的 API 相关讨论,它们相互补充,并且与静态方法一起组成了软件组件 API 的完整描述。

接口和对象工厂作为 API

名词抽象意味着书籍、文章或正式演讲的内容摘要。形容词抽象意味着存在于思想中或作为一个想法,但没有具体的或实体的存在。动词抽象意味着从理论上或与其他事物分开考虑(某事)。

这就是为什么接口被称为抽象——因为它只捕捉方法签名,不描述如何实现结果。相同接口的各种实现——不同的类——可能行为完全不同,即使它们接收相同的参数并返回相同的结果。最后一句是一个有深意的陈述,因为我们还没有定义行为这个术语。现在让我们来做。

类或其对象的行为由其方法执行的操作和它们返回的结果定义。如果一个方法不返回任何东西(void),则称这样的方法仅用于其副作用。

这种观点意味着返回值的方法具有直接(而不是副作用)的效果。然而,它也可能具有副作用,例如向另一个应用程序发送消息,或者在数据库中存储数据。理想情况下,应该尝试在方法名称中捕捉副作用。如果这不容易,因为方法做了很多事情,这可能表明需要将这样的方法分解为几个更好聚焦的方法。

同一方法签名的两个实现可能具有不同的行为的说法只有在方法名称没有捕捉到所有副作用,或者实现的作者没有遵守方法名称的含义时才有意义。但即使不同实现的行为相同,代码本身、它使用的库以及其有效性可能是不同的。

为什么隐藏实现细节很重要,我们将在第八章中解释,面向对象设计(OOD)原则。现在,我们只是提到客户端与实现的隔离允许系统更灵活地采用相同实现的新版本或完全切换到另一个实现。

接口

我们在第二章中讨论了接口,现在我们只看一些例子。让我们创建一个新的包,com.packt.javapath.ch06demo.api。然后,我们可以右键单击com.packt.javapath.ch06demo.api,打开 New | Java Class,选择 Interface,输入Calculator,然后单击 OK 按钮。我们已经创建了一个接口,并且可以向其添加一个方法签名,int multiplyByTwo(int i),结果如下:

这将是实现此接口的每个类的公共接口。在现实生活中,我们不会使用包名称api,而是使用calculator,因为它更具体和描述性。但是我们正在讨论术语“API”,这就是我们决定以这种方式命名包的原因。

让我们创建另一个包,com.packt.javapath.ch06demo.api.impl,其中将保存所有Calculator的实现和我们将添加到com.packt.javapath.ch06demo.api包中的其他接口。第一个实现是CalulatorImpl类。到目前为止,您应该已经知道如何在其中创建com.packt.javapath.ch06demo.api.impl包和CalulatorImpl类。结果应该如下所示:

我们将实现放在了比api更深一级的包中,这表明这些细节不应该暴露给我们创建的 API 的用户。

此外,我们需要编写一个测试并使用它来确保我们的功能对用户来说是正确和方便的。同样,我们假设您现在知道如何做到这一点。结果应该如下所示:

然后,我们添加缺失的测试主体和注释,如下所示:

@DisplayName("API Calculator tests")
public class CalculatorTest {
  @Test
  @DisplayName("Happy multiplyByTwo()")
  void multiplyByTwo(){
    CalculatorImpl calculator = new CalculatorImpl();
    int i = 2;
    int result = calculator.multiplyByTwo(i);
    assertEquals(4, result);
  }
}

这段代码不仅作为功能测试,还可以被视为 API 用户编写的客户端代码的示例。因此,测试帮助我们从客户端的角度看待我们的 API。通过观察这段代码,我们意识到我们无法完全隐藏实现细节。即使我们将创建对象的行更改为以下内容:

Calculator calculator = new CalculatorImpl();

这意味着,如果我们更改CalculatorImpl构造函数的签名或切换到同一接口的另一个实现(称为AnotherCalculatorImpl),客户端代码也必须更改。为了避免这种情况,程序员使用称为对象工厂的类。

Object factory

对象工厂的目的是隐藏对象创建的细节,以便客户端在实现更改时无需更改代码。让我们创建一个生产Calculator对象的工厂。我们将把它放在与Calculator接口的实现位于同一包com.packt.javapath.ch06demo.api.impl中:

我们可以更改测试(客户端代码)以使用此工厂:

@DisplayName("API Calculator tests")
public class CalculatorTest {
  @Test
  @DisplayName("Happy multiplyByTwo()")
  void multiplyByTwo(){
    Calculator calculator = CalculatorFactory.createInstance();
    int i = 2;
    int result = calculator.multiplyByTwo(i);
    assertEquals(4, result);
  }
}

通过这样做,我们已经实现了我们的目标:客户端代码不会对实现Calculator接口的类有任何概念。例如,我们可以更改工厂,以便它创建另一个类的对象:

public static Calculator create(){
  return AnotherCalculatorImpl();
}

AnotherCalculatorImpl类可能如下所示:

class AnotherCalculatorImpl  implements Calculator {
  public int multiplyByTwo(int i){
    System.out.println(AnotherCalculatorImpl.class.getName());
    return i + i;
  }
}

这个multiplyByTwo()方法是将两个值相加,而不是将输入参数乘以 2。

我们还可以使工厂读取配置文件,并根据配置文件的值实例化实现:

public class CalculatorFactory {
  public static Calculator create(){
    String whichImpl = 
       Utils.getStringValueFromConfig("calculator.conf", "which.impl");
    if(whichImpl.equals("multiplies")){
      return new CalculatorImpl();
    } else if (whichImpl.equals("adds")){
      return new AnotherCalculatorImpl();
    } else {
      throw new RuntimeException("Houston, we have a problem. " +
        "Unknown key which.impl value " + whichImpl + " is in config.");
    } 
  }     
}

我们还没有讨论if...else结构或RuntimeException类(参见第十章,控制流语句)。我们很快会讨论Utils.getStringValueFromConfig()方法。但是,我们希望你理解这段代码的作用:

  • 读取配置文件

  • 根据which.impl键的值实例化类

  • 如果没有与which.impl键的值对应的类,则通过抛出异常退出方法(因此通知客户端存在必须解决的问题)

这是配置文件calculator.conf可能的样子:

{
  "which.impl": "multiplies"
}

这称为JavaScript 对象表示JSON)格式,它基于由冒号(:)分隔的键值对。您可以在www.json.org/上了解更多关于 JSON 的信息。

calculator.conf文件位于resources目录(main目录的子目录)中。默认情况下,Maven 将此目录的内容放在类路径上,因此应用程序可以找到它。

要告诉工厂使用另一个Calculator实现,我们只需要做以下事情:

  • 更改文件calculator.conf中键which.impl的值

  • 更改工厂的create()方法以根据这个新值实例化新的实现

重要的是要注意,当我们切换Calculator实现时,客户端代码(CalculatorTest类)不受影响。这是使用接口和对象工厂类隐藏实现细节对客户端代码的优势。

现在,让我们看看Utils类及其getStringValueFromConfig()方法的内部。

读取配置文件

通过查看getStringValueFromConfig()方法的真实实现,我们超前于你对 Java 和 Java 库的了解。因此,我们不希望你理解所有的细节,但我们希望这种暴露会让你了解事情是如何做的,我们的课程目标是什么。

使用 json-simple 库

getStringValueFromConfig()方法位于Utils类中,我们已经创建了这个类来从.conf文件中读取值。这个类有以下代码:

import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;

public class Utils {
  private static JSONObject config = null;
  public static String getStringValueFromConfig(String configFileName, 
                                                            String key){
    if(config == null){
      ClassLoader classLoader = Utils.class.getClassLoader();
      File file =
           new File(classLoader.getResource(configFileName).getFile());
      try(FileReader fr = new FileReader(file)){
        JSONParser parser = new JSONParser();
        config = (JSONObject) parser.parse(fr);
      } catch (ParseException | IOException ex){
        ex.printStackTrace();
        return "Problem reading config file.";
      }
    }
    return config.get(key) == null ? "unknown" : (String)config.get(key);
  }
}

首先,请注意称为缓存的技术。我们首先检查config静态类字段的值。如果它不是null,我们就使用它。否则,我们使用相同的类加载器在类路径上找到config文件,该类加载器用于加载我们传递的已知类。我们解析配置文件,这意味着将其分解为键值对。结果是我们分配给config字段的JSONObject类的生成对象的引用(缓存它,以便下次可以使用)。

这是缓存技术,用于避免浪费时间和其他资源。这种解决方案的缺点是,对配置文件的任何更改都需要重新启动应用程序,以便重新读取文件。在我们的情况下,我们假设这是可以接受的。但在其他情况下,我们可以添加一个定时器,并在定义的时间段过后刷新缓存数据,或者做类似的事情。

为了读取配置文件,我们使用 Apache Commons 库中的FileReader类(commons.apache.org/proper/commons-io)。为了让 Maven 知道我们需要这个库,我们已经将以下依赖项添加到pom.xml文件中:

<dependency>
  <groupId>commons-io</groupId>
  <artifactId>commons-io</artifactId>
  <version>2.5</version>
</dependency>

要处理 JSON 格式的数据,我们使用 JSON.simple 库(也是根据 Apache 许可发布的),并将以下依赖项添加到pom.xml中:

<dependency>
  <groupId>com.googlecode.json-simple</groupId>
  <artifactId>json-simple</artifactId>
  <version>1.1</version>
</dependency>

JSONObject类以 JSON 格式存储键值对。如果传入的键在文件中不存在,JSONObject类的对象返回值为null。在这种情况下,我们的getStringValueFromConfig()方法返回一个String字面量 unknown。否则,它将返回值转换为String。我们可以这样做,因为我们知道该值可以赋给String类型的变量。

<condition>? <option1> : <option2>构造被称为三元运算符。当条件为真时,它返回option1,否则返回option2。我们将在第九章中更多地讨论它,运算符、表达式和语句

使用 json-api 库

或者,我们可以使用另一个 JSON 处理 API 及其实现:

<dependency>
  <groupId>javax.json</groupId>
  <artifactId>javax.json-api</artifactId>
  <version>1.1.2</version>
</dependency>
<dependency>
  <groupId>org.glassfish</groupId>
  <artifactId>javax.json</artifactId>
  <version>1.1.2</version>
</dependency>

然后getStringValueFromConfig()方法的代码看起来会有些不同:

import javax.json.Json;
import javax.json.JsonObject;
import javax.json.JsonReader;
public class Utils {
  private static JsonObject config = null;
  public static String getStringValueFromConfig(String FileName, 
                                                           String key){
    if(config == null){
      ClassLoader classLoader = Utils.class.getClassLoader();
      File file = new File(classLoader.getResource(fileName).getFile());
      try(FileInputStream fis = new FileInputStream(file)){
        JsonReader reader = Json.createReader(fis);
        config = reader.readObject();
      } catch (IOException ex){
        ex.printStackTrace();
        return "Problem reading config file.";
      }
    }
    return config.get(key) == null ? "unknown" : config.getString(key);
  }
}

这个第二个实现需要的代码稍微少一些,并且使用了更一致的驼峰命名风格(JsonObjectJSONObject)。但是,由于它们的性能并没有太大的不同,使用哪个库在很大程度上取决于个人偏好。

单元测试

让我们创建一个单元测试,证明该方法按预期工作。到目前为止,你应该能够在test/java/com/packt/javapath/ch06demo目录(或在 Windows 的test\java\com\packt\javapath\ch06demo目录)中创建一个UtilsTest类。测试应该如下所示:

@DisplayName("Utils tests")
public class UtilsTest {
  @Test
  @DisplayName("Test reading value from config file by key")
  void getStringValueFromConfig(){
    //test body we will write here
  }
}

接下来,我们添加test/resources/utilstest.conf文件(对于 Windows 是test\resources\utilstest.conf):

{
  "unknown": "some value"
}

它将扮演config文件的角色。有了这个,测试代码看起来如下:

@Test
@DisplayName("Test reading value from config file by key")
void getStringValueFromConfig(){
  String fileName = "utilstest.conf";
  String value = Utils.getStringValueFromConfig(fileName, "some value");
  assertEquals("some value", value);

  value = Utils.getStringValueFromConfig(fileName, "some value");
  assertEquals("unknown", value);
}

我们测试两种情况:

  • 返回的值应该在第一种情况下等于some value

  • 如果在配置文件中键不存在,则值应该返回为unknown

我们运行这个测试并观察成功。为了确保,我们还可以将utilstest.conf文件的设置更改为以下内容:

{
  "unknown": "another value"
}

这应该导致测试在第一种情况下失败。

让我们重新审视一下 Calculator API。

计算器 API

根据前面的讨论,我们可以在Calculator接口中描述 Calculator API 如下:

public interface Calculator {
  int multiplyByTwo(int i);
}
static Calculator createInstance(){
  return CalculatorFactory.create();
}

如果Calculator实现的构造函数需要参数,我们将把它们添加到接口的create()工厂方法和createInstance()静态方法中。

Calculator接口只存在一个实现时,前面的 API 声明就足够了。但是当你给客户端提供两个或更多的实现选择时,就像我们之前描述的那样,API 还应该包括calculator.conf配置文件的描述。

配置描述将不得不列出which.impl键的所有可能值(在我们的例子中是multipliesadds)。我们还需要解释实现之间的差异,以便使用我们的计算器的程序员能够做出知情的选择。

如果这听起来太多了,那么你可能需要退一步重新审视你的 API 设计,因为它可能没有很好地聚焦,试图涵盖太多东西。考虑将这样的 API 分解为几个更简单的 API。描述每个较小的 API 更容易编写和理解。

例如,这是如何在我们的情况下将配置描述添加到接口中的:

public interface Calculator {
  int multiplyByTwo(int i);
  static Calculator createInstance(){
    return  CalculatorFactory.create();
  }
  String CONF_NAME = "calculator.conf";
  String CONF_WHICH_IMPL = "which.impl";
  enum WhichImpl{
    multiplies, //use multiplication operation
    adds        //use addition operation
  }
}

正如你所看到的,我们在常量中捕获了配置文件名,以及配置键名。我们还为键的所有可能值创建了一个enum。我们还添加了实现之间差异的解释作为注释。如果解释太长,注释可以提供对文档、网站名称或 URL 的引用,例如。

由于配置文件中存在两种实现和两种可能的值,我们需要运行我们的单元测试CalculatorTest两次——对于配置的每种可能的值——以确保两种实现都按预期工作。但我们不想改变交付软件组件本身的配置。

这是test/resources目录(对于 Windows 为test\resources)再次发挥作用的时候。让我们在其中创建一个calculator.conf文件,并将以下行添加到CalculatorTest测试中,这将打印出该文件中的当前设置:

String whichImpl = 
   Utils.getStringValueFromConfig(Calculator.CONF_NAME, 
                                     Calculator.CONF_WHICH_IMPL);
System.out.println(Calculator.CONF_WHICH_IMPL + "=" + whichImpl);

CalculatorTest代码应如下所示:

void multiplyByTwo() {
  WhichImpl whichImpl = 
      Utils.getWhichImplValueFromConfig(Calculator.CONF_NAME, 
                                        Calculator.CONF_WHICH_IMPL);
  System.out.println("\n" + Calculator.CONF_WHICH_IMPL + 
                                                   "=" + whichImpl);
  Calculator calculator = Calculator.createInstance();
  int i = 2;
  int result = calculator.multiplyByTwo(i);
  assertEquals(4, result);
}

我们还可以添加一行,打印出每个实现的类名:

public class CalculatorImpl implements Calculator {
  public int multiplyByTwo(int i){
    System.out.println(CalculatorImpl.class.getClass().getName());
    return i * 2;
  }
}
public class AnotherCalculatorImpl implements Calculator {
  public int multiplyByTwo(int i){
    System.out.println(AnotherCalculatorImpl.class.getClass().getName());
    return i + i;
 }
}

如果我们将test目录中的calculator.conf文件中的which.impl值设置为adds,则会变成这样:

CalculatorTest测试的结果将是:

输出告诉我们三件事:

  • calculator.confwhich.impl的值被设置为adds

  • 使用了相应的AnotherCalculatorImpl实现

  • 调用的实现按预期工作

类似地,我们可以针对calculator.conf文件设置为multiplies运行我们的单元测试。

结果看起来很好,但我们仍然可以改进代码,使其不那么容易出错,如果将来某人决定通过添加新的实现或类似的方式来增强功能。我们可以利用添加到Calculator接口的常量,并使create()工厂方法更不容易受人为错误影响:

public static Calculator create(){
  String whichImpl = Utils.getStringValueFromConfig(Calculator.CONF_NAME, 
                                       Calculator.CONF_WHICH_IMPL);         
  if(whichImpl.equals(Calculator.WhichImpl.multiplies.name())){
    return new CalculatorImpl();
  } else if (whichImpl.equals(Calculator.WhichImpl.adds.name())){
    return new AnotherCalculatorImpl();
  } else {
    throw new RuntimeException("Houston, we have a problem. " +
                     "Unknown key " + Calculator.CONF_WHICH_IMPL +
                     " value " + whichImpl + " is in config.");
  }
}

为了确保测试完成了其工作,我们将测试目录中的calculator.conf文件中的值更改为add(而不是adds),然后再次运行测试。输出将如下所示:

如预期的那样,测试失败了。这使我们对代码的工作方式有了一定的信心,而不仅仅是显示成功。

然而,当代码被修改或扩展时,代码可以改进以变得更易读,更易测试,并且更不易受人为错误影响。利用enum功能的知识,我们可以编写一个方法,将calculator.conf文件中键which.impl的值转换为类enum WhichImpl的一个常量(实例)。为此,我们将此新方法添加到类Utils中:

WhichImpl getWhichImplValueFromConfig(String configFileName, String key){
  String whichImpl = getStringValueFromConfig(configFileName, key);
  try{
    return Enum.valueOf(WhichImpl.class, whichImpl);
  } catch (IllegalArgumentException ex){
    throw new RuntimeException("Houston, we have a problem. " +
                     "Unknown key " + Calculator.CONF_WHICH_IMPL +
                     " value " + whichImpl + " is in config.");
  }
}

这段代码基于getStringValueFromConfig()方法的使用,我们已经测试过并知道它按预期工作。try...catch结构允许我们捕获和处理一些代码(在这种情况下是Enum.valueOf()方法)遇到无法解决的条件并抛出异常的情况(我们将在第十章中学到更多关于这个的知识,控制流语句)。人们必须阅读 Java API 文档,才能知道Enum.valueOf()方法可能会抛出异常。例如,这是关于Enum.valueOf()方法的文档中的一句引用:

"Throws: IllegalArgumentException - 如果指定的枚举类型没有具有指定名称的常量,或者指定的类对象不表示枚举类型"

阅读即将使用的任何第三方类的 API 文档是一个好主意。在我们的代码中,我们捕获它并以一致的方式用我们自己的措辞抛出一个新的异常。

正如你所期望的,我们还为getWhichImplValueFromConfig()方法编写了一个单元测试,并将其添加到UtilsTest中:

@Test
@DisplayName("Test matching config value to enum WhichImpl")
void getWhichImpValueFromConfig(){
  String confifFileName = "utilstest.conf";
  for(int i = 1; i <= WhichImpl.values().length; i++){
    String key = String.valueOf(i);
    WhichImpl whichImpl = 
       Utils.getWhichImplValueFromConfig(confifFileName, key);
    System.out.println(key + "=" + whichImpl);
  }
  try {
    WhichImpl whichImpl = 
       Utils.getWhichImplValueFromConfig(confifFileName, "unknown");
    fail("Should not get here! whichImpl = " + whichImpl);
  } catch (RuntimeException ex){
    assertEquals("Houston, we have a problem. " +
                 "Unknown key which.impl value unknown is in config.", 
                 ex.getMessage());
  }
  try {
    WhichImpl whichImpl = 
       Utils.getWhichImplValueFromConfig(confifFileName, "some value");
    fail("Should not get here! whichImpl = " + whichImpl);
  } catch (RuntimeException ex){
    assertEquals("Houston, we have a problem. " +
                 "Unknown key which.impl value unknown is in config.", 
                 ex.getMessage());
  }
}

为了支持这个测试,我们还在utilstest.conf文件中添加了两个条目:

{
  "1": "multiplies",
  "2": "adds",
  "unknown": "unknown"
}

这个测试涵盖了三种情况:

  • 如果enum WhichImpl中的所有常量都存在于配置文件中,那么getWhichImplValueFromConfig()方法就可以正常工作——它会找到它们中的每一个,不会抛出异常

  • 如果传递给getWhichImplValueFromConfig()方法的键不是来自enum WhichImpl,则该方法会抛出一个异常,其中包含消息Houston, we have a problem. Unknown key which.impl value unknown is in config

  • 如果传递给getWhichImplValueFromConfig()方法的键在配置文件中不存在,则该方法会抛出一个异常,其中包含消息Houston, we have a problem. Unknown key which.impl value unknown is in config

当我们确信这个方法按预期工作时,我们可以重写create()工厂方法如下:

public static Calculator create(){
  WhichImpl whichImpl = 
    Utils.getWhichImplValueFromConfig(Calculator.CONF_NAME, 
                                      Calculator.CONF_WHICH_IMPL);
  switch (whichImpl){
    case multiplies:
      return new CalculatorImpl();
    case adds:
      return new AnotherCalculatorImpl();
    default:
      throw new RuntimeException("Houston, we have another " + 
                "problem. We do not have implementation for the key " +
                Calculator.CONF_WHICH_IMPL + " value " + whichImpl);
  }
}

switch()结构非常简单:它将执行线程定向到与匹配相应值的 case 下的代码块(更多信息请参阅第十章,控制流语句)。

The benefit of creating and using the method getWhichImplValueFromConfig() is that the create() method became much cleaner and focused on one task only: creating the right object. We will talk about the Single Responsibility Principle in section So many OOD principles and so little time of Chapter 8, Object-Oriented Design (OOD) Principles.

We have captured the Calculator API in one place—the interface Calculator —and we have tested it and proved that it works as designed. But there is another possible API aspect—the last one—we have not covered, yet.

Adding static methods to API

Each of the classes that implement the Calculator interface may have static methods in addition to the instance methods defined in the interface. If such static methods could be helpful to the API's users, we should be able to document them in the Calculator interface, too, and that is what we are going to do now.

Let's assume that each of the implementations of the Calculator interface has a static method, addOneAndConvertToString():

public class CalculatorImpl implements Calculator {
  public static String addOneAndConvertToString(double d){
    System.out.println(CalculatorImpl.class.getName());
    return Double.toString(d + 1);
  }
  //...
}
public class AnotherCalculatorImpl implements Calculator {
  public static String addOneAndConvertToString(double d){
    System.out.println(AnotherCalculatorImpl.class.getName());
    return String.format("%.2f", d + 1);
  }
  //...
}

Notice that the methods have the same signature but slightly different implementations. The method in CalculatorImpl returns the result as is, while the method in AnotherCalculatorImpl returns the formatted value with two decimal places (we will show the result shortly).

Usually, static methods are called via a dot-operator applied to a class:

String s1 = CalculatorImpl.addOneAndConvertToString(42d);
String s2 = AnotherCalculatorImpl.addOneAndConvertToString(42d);

But, we would like to hide (encapsulate) from an API client the implementation details so that the client code continues to use only the interface Calculator. To accomplish that goal, we will use the class CalculatorFactory again and add to it the following method:

public static String addOneAndConvertToString(double d){
  WhichImpl whichImpl = 
       Utils.getWhichImplValueFromConfig(Calculator.CONF_NAME, 
                                         Calculator.CONF_WHICH_IMPL);
  switch (whichImpl){
    case multiplies:
      return CalculatorImpl.addOneAndConvertToString(d);
    case adds:
      return AnotherCalculatorImpl.addOneAndConvertToString(d);
    default:
      throw new RuntimeException("Houston, we have another " +
                "problem. We do not have implementation for the key " +
                Calculator.CONF_WHICH_IMPL + " value " + whichImpl);
  }
}

As you may have noticed, it looks very similar to the factory method create(). We also used the same values of the which.impl property—multiplies and adds—as identification of the class. With that, we can add the following static method to the Calculator interface:

static String addOneAndConvertToString(double d){
  return CalculatorFactory.addOneAndConvertToString(d);
}

As you can see, this way we were able to hide the names of the classes that implemented the interface Calculator and the static method addOneAndConvertToString (), too.

To test this new addition, we have expanded code in CalculatorTest by adding these lines:

double d = 2.12345678;
String mString = "3.12345678";
String aString = "3.12";
String s = Calculator.addOneAndConvertToString(d);
if(whichImpl.equals(Calculator.WhichImpl.multiplies)){
  assertEquals(mString, s);
} else {
  assertNotEquals(mString, s);
}
if(whichImpl.equals(Calculator.WhichImpl.adds)){
  assertEquals(aString, s);
} else {
  assertNotEquals(aString, s);
}

在测试中,我们期望String类型的一个值,在WhichImpl.multiplies的情况下是相同的值,而在WhichImpl.adds的情况下是不同格式的值(只有两位小数)。让我们在calculator.conf中使用以下设置运行CalculatorTest

{
  "which.impl": "adds"
}

结果是:

当我们将calculator.conf设置为值multiplies时,结果如下:

有了这个,我们完成了对计算器 API 的讨论。

API 已完成

我们的 API 的最终版本如下:

public interface Calculator {
  int multiplyByTwo(int i);
  static Calculator createInstance(){
    return  CalculatorFactory.create();
  }
  static String addOneAndConvertToString(double d){
    return  CalculatorFactory.addOneAndConvertToString(d);
  }
  String CONF_NAME = "calculator.conf";  //file name
  String CONF_WHICH_IMPL = "which.impl"; //key in the .conf file
  enum WhichImpl{
    multiplies, //uses multiplication operation
                // and returns addOneAndConvertToString() 
                // result without formating
    adds    //uses addition operation 
            // and returns addOneAndConvertToString()
            // result with two decimals only
  }
}

这样,我们保持了单一的记录源——捕获所有 API 细节的接口。如果需要更多细节,注释可以引用一些外部 URL,其中包含描述每个Calculator实现的完整文档。并且,重复我们在本节开头已经说过的,方法名称应该描述方法产生的所有副作用。

实际上,程序员试图编写小巧、重点突出的方法,并在方法名称中捕获方法的所有内容,但他们很少在接口中添加更多的抽象签名。当他们谈论 API 时,他们通常只指的是抽象签名,这是 API 最重要的方面。但我们认为在一个地方记录所有其他 API 方面也是一个好主意。

重载、重写和隐藏

我们已经提到了方法重写,并在第二章中解释了它,Java 语言基础。方法重写是用子类(或实现接口的类中的默认方法)的方法替换父类中实现的方法,这些方法具有相同的签名(或在实现接口的类中,或在相应的子接口中)。方法重载是在同一个类或接口中创建几个具有相同名称和不同参数(因此,不同签名)的方法。在本节中,我们将更详细地讨论接口、类和类实例的重写和重载成员,并解释隐藏是什么。我们从一个接口开始。

接口方法重载

我们在第二章,Java 语言基础中已经说过,除了抽象方法,接口还可以有默认方法和静态成员——常量、方法和类。

如果接口中已经存在抽象、默认或静态方法m(),就不能添加另一个具有相同签名(方法名称和参数类型列表)的方法m()。因此,以下示例生成编译错误,因为每对方法具有相同的签名,而访问修饰符(privatepublic)、staticdefault关键字、返回值类型和实现不是签名的一部分:

interface A {
  int m(String s);
  double m(String s);  
} 
interface B {
  int m(int s);
  static int m(int i) { return 42; }
}
interface C {
  int m(double i);
  private double m(double s) { return 42d; }
}
interface D {
  int m(String s);
  default int m(String s) { return 42; }
}
interface E {
  private int m(int s) { return 1; };
  default double m(int i) { return 42d; }
}
interface F {
  default int m(String s) { return 1; };
  static int m(String s) { return 42; }
}
interface G {
  private int m(double d) { return 1; };
  static int m(double s) { return 42; }
}
interface H {
  default int m(int i) { return 1; };
  default double m(int s) { return 42d; }
}

要创建不同的签名,要么更改方法名称,要么更改参数类型列表。具有相同方法名称和不同参数类型的两个或多个方法构成方法重载。以下是接口中合法的方法重载示例:

interface A {
  int m(String s);
  int m(String s, double d);
  int m(double d, String s);
  String m(int i);
  private double m(double d) { return 42d; }
  private int m(int i, String s) { return 1; }
  default int m(String s, int i) { return 1; }
} 
interface B {
  static int m(String s, int i) { return 42; }
  static int m(String s) { return 42; }
}

重载也适用于继承的方法,这意味着以下非静态方法的重载与前面的示例没有区别:

interface D {
  default int m(int i, String s) { return 1; }
  default int m(String s, int i) { return 1; }
}
interface C {
  default double m(double d) { return 42d; }
}
interface B extends C, D {
  int m(double d, String s);
  String m(int i);
}
interface A extends B {
  int m(String s);
  int m(String s, double d);
}

您可能已经注意到我们在上一个代码中将private方法更改为default。我们这样做是因为private访问修饰符会使方法对子接口不可访问,因此无法在子接口中重载。

至于静态方法,以下组合的静态和非静态方法虽然允许,但不构成重载:

interface A {
  int m(String s);
  static int m(String s, double d) { return 1 }
} 
interface B {
  int m(String s, int i);
  static int m(String s) { return 42; }
}
interface D {
  default int m(String s, int s) { return 1; }
  static int m(String s, double s) { return 42; }
}
interface E {
  private int m() { return 1; }
  static int m(String s) { return 42; }
}

静态方法属于类(因此在应用程序中是唯一的),而非静态方法与实例相关(每个对象都会创建一个方法副本)。

出于同样的原因,不同接口的静态方法不会相互重载,即使这些接口存在父子关系:

interface G {
  static int m(String s) { return 42; }
}

interface F extends G {
  static int m(String s, int i) { return 42; }
}

只有属于同一接口的静态方法才能相互重载,而非静态接口方法即使属于不同接口也可以重载,前提是它们具有父子关系。

接口方法重写

与重载相比,重写只发生在非静态方法,并且只有当它们具有完全相同的签名时才会发生。

另一个区别是,重写方法位于子接口中,而被重写的方法属于父接口。以下是方法重写的示例:

interface D {
  default int m(String s) { // does not override anything
    return 1; 
  } 
}

interface C extends D {
  default int m(String d) { // overrides method of D
    return 42; 
  } 
}

直接实现接口C的类,如果没有实现方法m(),将从接口C获取该方法的实现,而不会从接口D获取该方法的实现。只有直接实现接口D的类,如果没有实现方法m(),将从接口D获取该方法的实现。

注意我们使用了直接这个词。通过说类X直接实现接口C,我们的意思是类X定义如下:class X implements C。如果接口C扩展 D,则类X也实现接口D,但不是直接实现。这是一个重要的区别,因为在这种情况下,接口C的方法可以覆盖具有相同签名的接口D的方法,从而使它们对类X不可访问。

在编写依赖于覆盖的代码时,一个好的做法是使用注解@Override来表达程序员的意图。然后,Java 编译器和使用它的 IDE 将检查覆盖是否发生,并在带有此注解的方法没有覆盖任何内容时生成错误。以下是一些例子:

interface B {
  int m(String s);
}
interface A extends B {
  @Override             //no error 
  int m(String s);
}
interface D {
  default int m1(String s) { return 1; }
}
interface C extends D {
  @Override            //error
  default int m(String d) { return 42; }
}

错误将帮助您注意到父接口中的方法拼写不同(m1()m())。以下是另一个例子:

interface D {
  static int m(String s) { return 1; }
}
interface C extends D {
  @Override                  //error
  default int m(String d) { return 42; }
}

这个例子会生成一个错误,因为实例方法不能覆盖静态方法,反之亦然。此外,静态方法不能覆盖父接口的静态方法,因为接口的每个静态方法都与接口本身相关联,而不是与类实例相关联:

interface D {
  static int m(String s) { return 1; }
}
interface C extends D{
  @Override               //error
  static int m(String d) { return 42; }
}

但是子接口中的静态方法可以隐藏父接口中具有相同签名的静态方法。实际上,任何静态成员——字段、方法或类——都可以隐藏父接口的相应静态成员,无论是直接父接口还是间接父接口。我们将在下一节讨论隐藏。

接口静态成员隐藏

让我们看一下以下两个接口:

interface B {
  String NAME = "B";
  static int m(String d) { return 1; }
  class Clazz{
    String m(){ return "B";}
  }
}

interface A extends B {
  String NAME = "A";
  static int m(String d) { return 42; }
  class Clazz{
    String m(){ return "A";}
  }
}

接口B是接口A的父接口(也称为超接口或基接口),接口的所有成员默认都是public。接口字段和类也默认都是static。因此,接口AB的所有成员都是publicstatic。让我们运行以下代码:

public static void main(String[] args) {
  System.out.println(B.NAME);
  System.out.println(B.m(""));
  System.out.println(new B.Clazz().m());
}

结果将如下所示:

正如您所看到的,效果看起来像是覆盖,但产生它的机制是隐藏。在类成员隐藏的情况下,差异更为显著,我们将在下一节讨论。

类成员隐藏

让我们看看这两个类:

class ClassC {
  public static String field = "static field C";
  public static String m(String s){
    return "static method C";
  }
}

class ClassD extends ClassC {
  public static String field = "static field D";
  public static String m(String s){
    return "static method D";
  }
}

}

System.out.println(ClassD.field);
System.out.println(ClassD.m(""));
System.out.println(new ClassD().field);
System.out.println(new ClassD().m(""));
ClassC object = new ClassD();
System.out.println(object.field);
System.out.println(object.m(""));
```java

System.out.println(ClassD.field);

System.out.println(ClassD.m(""));

System.out.println(new ClassD().field);

System.out.println(new ClassD().m(""));

ClassC 对象 = new ClassD();

System.out.println(object.field);

System.out.println(object.m(""));

```java
1 System.out.println(ClassD.field);       //static field D
2 System.out.println(ClassD.m(""));       //static method D
3 System.out.println(new ClassD().field); //static field D
4 System.out.println(new ClassD().m("")); //static method D
5 ClassC object = new ClassD();
6 System.out.println(object.field);       //static field C
7 System.out.println(object.m(""));       //static method C

```java

1 System.out.println(ClassD.field); //静态字段 D

2 System.out.println(ClassD.m("")); //静态方法 D

3 System.out.println(new ClassD().field); //静态字段 D

4 System.out.println(new ClassD().m("")); //静态方法 D

5 ClassC object = new ClassD();

6 System.out.println(object.field); //静态字段 C

7 System.out.println(object.m("")); //静态方法 C

```java
class ClassC {
  public static String field1 = "instance field C";
  public String m1(String s){
    return "instance method C";
  }
}
class ClassD extends ClassC {
  public String field1 = "instance field D";
  public String m1(String s){
    return "instance method D";
  }
}
```java

类 ClassC {

public static String field1 = "实例字段 C";

public String m1(String s){

返回"实例方法 C";

}

}

类 ClassD 扩展自 ClassC {

public String field1 = "实例字段 D";

public String m1(String s){

返回"实例方法 D";

}

}

```java
System.out.println(new ClassD().field1);
System.out.println(new ClassD().m1(""));
ClassC object1 = new ClassD();
System.out.println(object1.m1(""));
System.out.println(object1.field1);
System.out.println(((ClassD)object1).field1);

```java

System.out.println(new ClassD().field1);

System.out.println(new ClassD().m1(""));

ClassC object1 = new ClassD();

System.out.println(object1.m1(""));

System.out.println(object1.field1);

System.out.println(((ClassD)object1).field1);

```java
1 System.out.println(new ClassD().field1);     //instance field D
2 System.out.println(new ClassD().m1(""));     //instance method D
3 ClassC object1 = new ClassD();
4 System.out.println(object1.m1(""));          //instance method D
5 System.out.println(object1.field1);          //instance field C
6 System.out.println(((ClassD)object1).field1);//instance field D

```java

1 System.out.println(new ClassD().field1); //实例字段 D

2 System.out.println(new ClassD().m1("")); //实例方法 D

3 ClassC object1 = new ClassD();

4 System.out.println(object1.m1("")); //实例方法 D

5 System.out.println(object1.field1); //实例字段 C

6 System.out.println(((ClassD)object1).field1); //实例字段 D

```java
class ClassC {
  private String field1 = "instance field C";
  public String getField(){ return field1; }
  public void setField(String s){ field1 = s; }
  public String m1(String s){
    return "instance class C";
  }
}
class ClassD extends ClassC {
  private String field1 = "instance field D";
  public String getField(){ return field1; }
  public void setField(String s){ field1 = s; }
  public String m1(String s){
    return "instance class D";
  }
}
```java

类 ClassC {

私有字符串字段 1 = "实例字段 C";

public String getField(){ return field1; }

public void setField(String s){ field1 = s; }

public String m1(String s){

return "实例类 C";

}

}

class ClassD extends ClassC {

private String field1 = "实例字段 D";

public String getField(){ return field1; }

public void setField(String s){ field1 = s; }

public String m1(String s){

return "实例类 D";

}

}

```java
void m() {
  // some code
}
int m(String s){
  // some code
  return 1;
}
void m(int i){
  // some code
}
int m(String s, double d){
  // some code
  return 1;
}
int m(double d, String s){
  // some code
  return 1;
}
```java

void m() {

// 一些代码

}

int m(String s){

// 一些代码

return 1;

}

void m(int i){

// 一些代码

}

int m(String s, double d){

// 一些代码

return 1;

}

int m(double d, String s){

// 一些代码

return 1;

}

```java
public class SimpleMath {
    public int multiplyByTwo(int i){
       return i * 2;
    }
}
```java

public class SimpleMath {

public int multiplyByTwo(int i){

return i * 2;

}

}

```java
public class SimpleMath {
    public int multiplyByTwo(int i){
        return 2 * i;
    }
    public int multiplyByTwo(String s){
        int i = Integer.parseInt(s);
        return 2 * i;
    }
}
```java

public class SimpleMath {

public int multiplyByTwo(int i){

return 2 * i;

}

public int multiplyByTwo(String s){

int i = Integer.parseInt(s);

return 2 * i;

}

}

```java
public class SimpleMath {
    public int multiplyByTwo(int i){
       return 2 * i;
    }
    public int multiplyByTwo(String s){
       int i = Integer.parseInt(s);
       return multiplyByTwo(i);
    }
}
```java

public class SimpleMath {

public int multiplyByTwo(int i){

return 2 * i;

}

public int multiplyByTwo(String s){

int i = Integer.parseInt(s);

return multiplyByTwo(i);

}

}

```java
public class SimpleMath {
  private int i;
  private String s;
  public SimpleMath() {
  }
  public SimpleMath(int i) {
    this.i = i;
  }
  public SimpleMath(String s) {
    this.s = s;
  }
  // Other methods that use values of the fields i and s
  // go here
}
```java

public class SimpleMath {

private int i;

private String s;

public SimpleMath() {

}

public SimpleMath(int i) {

this.i = i;

}

public SimpleMath(String s) {

this.s = s;

}

// Other methods that use values of the fields i and s

// go here

}

```java
public SimpleMath(int i) {
  this.i = i;
}
```java

public SimpleMath(int i) {

this.i = i;

}

```java
public class Person {
  private String firstName;
  private String lastName;
  private LocalDate dob;
  public Person(String firstName, String lastName, LocalDate dob) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.dob = dob;
  }
  public String getFirstName() { return firstName; }
  public String getLastName() { return lastName; }
  public LocalDate getDob() { return dob; }

  @Override
  public boolean equals(Object other){
    if (other == null) return false;
    if (this == other) return true;
    if (!(other instanceof Person)) return false;
    final Person that = (Person) other;
    return this.getFirstName().equals(that.getFirstName()) &&
           this.getLastName().equals(that.getLastName()) &&
           this.getDob().equals(that.getDob());
  }
}
```java

public class Person {

private String firstName;

private String lastName;

private LocalDate dob;

public Person(String firstName, String lastName, LocalDate dob) {

this.firstName = firstName;

this.lastName = lastName;

this.dob = dob;

}

public String getFirstName() { return firstName; }

public String getLastName() { return lastName; }

public LocalDate getDob() { return dob; }

@Override

public boolean equals(Object other){

if (other == null) return false;

if (this == other) return true;

if (!(other instanceof Person)) return false;

final Person that = (Person) other;

return this.getFirstName().equals(that.getFirstName()) &&

this.getLastName().equals(that.getLastName()) &&

this.getDob().equals(that.getDob());

}

}

```java
public class PersonTest {
  @Test
  void equals() {
    LocalDate dob = LocalDate.of(2001, 01, 20);
    LocalDate dob1 = LocalDate.of(2001, 01, 21);

    Person p = new Person("Joe", "Blow", dob);
    assertTrue(p.equals(p));
    assertTrue(p.equals(new Person("Joe", "Blow", dob)));

    assertFalse(p.equals(new Person("Joe1", "Blow", dob)));
    assertFalse(p.equals(new Person("Joe", "Blow1", dob)));
    assertFalse(p.equals(new Person("Joe", "Blow", dob1)));
    assertFalse(p.equals( new Person("Joe1", "Blow1", dob1)));
  }
}
```java

public class PersonTest {

@Test

void equals() {

LocalDate dob = LocalDate.of(2001, 01, 20);

LocalDate dob1 = LocalDate.of(2001, 01, 21);

Person p = new Person("Joe", "Blow", dob);

assertTrue(p.equals(p));

assertTrue(p.equals(new Person("Joe", "Blow", dob)));

assertFalse(p.equals(new Person("Joe1", "Blow", dob)));

assertFalse(p.equals(new Person("Joe", "Blow1", dob)));

assertFalse(p.equals(new Person("Joe", "Blow", dob1)));

assertFalse(p.equals( new Person("Joe1", "Blow1", dob1)));

}

}

```java
assertFalse(p.equals(null));
assertFalse(p.equals(new Person(null, "Blow", dob)));
assertFalse(p.equals(new Person("Joe", null, dob)));
assertFalse(p.equals(new Person(null, null, dob)));
assertFalse(p.equals(new Person(null, null, null)));

assertTrue(new Person(null, "Blow", dob)
   .equals(new Person(null, "Blow", dob)));
assertTrue(new Person("Joe", null, dob)
   .equals(new Person("Joe", null, dob)));
assertTrue(new Person("Joe", "Blow", null)
   .equals(new Person("Joe", "Blow", null)));
assertTrue(new Person(null, null, null)
   .equals(new Person(null, null, null)));

```java

assertFalse(p.equals(null));

assertFalse(p.equals(new Person(null, "Blow", dob)));

assertFalse(p.equals(new Person("Joe", null, dob)));

assertFalse(p.equals(new Person(null, null, dob)));

assertFalse(p.equals(new Person(null, null, null)));

assertTrue(new Person(null, "Blow", dob)

.equals(new Person(null, "Blow", dob)));

assertTrue(new Person("Joe", null, dob)

.equals(new Person("Joe", null, dob)));

assertTrue(new Person("Joe", "Blow", null)

.equals(new Person("Joe", "Blow", null)));

assertTrue(new Person(null, null, null)

.equals(new Person(null, null, null)));

```java
return this.getFirstName().equals(that.getFirstName()) &&
       this.getLastName().equals(that.getLastName()) &&
       this.getDob().equals(that.getDob());

```java

return this.getFirstName().equals(that.getFirstName()) &&

this.getLastName().equals(that.getLastName()) &&

this.getDob().equals(that.getDob());

```java
public Person(String firstName, String lastName, LocalDate dob) {
  this.firstName = firstName == null ? "" : firstName;
  this.lastName = lastName == null ? "" : lastName;
  this.dob = dob;
  if(dob == null){
    throw new RuntimeException("Date of birth is null");
  }
}
```java

public Person(String firstName, String lastName, LocalDate dob) {

this.firstName = firstName == null ? "" : firstName;

this.lastName = lastName == null ? "" : lastName;

this.dob = dob;

if(dob == null){

throw new RuntimeException("Date of birth is null");

}

}

```java
assertFalse(p.equals(null));
assertFalse(p.equals(new Person(null, "Blow", dob)));
assertFalse(p.equals(new Person("Joe", null, dob)));
assertFalse(p.equals(new Person(null, null, dob)));
try {
  new Person("Joe", "Blow", null);
} catch (RuntimeException ex){
  assertNotNull(ex.getMessage());
  //add the record ex.getMessage() to the log here
}

assertTrue(new Person(null, "Blow", dob)
   .equals(new Person(null, "Blow", dob)));
assertTrue(new Person("Joe", null, dob)
   .equals(new Person("Joe", null, dob)));
assertTrue(new Person(null, null, dob)
   .equals(new Person(null, null, dob)));
```java

assertFalse(p.equals(null));

assertFalse(p.equals(new Person(null, "Blow", dob)));

assertFalse(p.equals(new Person("Joe", null, dob)));

assertFalse(p.equals(new Person(null, null, dob)));

try {

new Person("Joe", "Blow", null);

} catch (RuntimeException ex){

assertNotNull(ex.getMessage());

//在这里将记录 ex.getMessage()添加到日志

}

assertTrue(new Person(null, "Blow", dob)

.equals(new Person(null, "Blow", dob)));

assertTrue(new Person("Joe", null, dob)

.equals(new Person("Joe", null, dob)));

assertTrue(new Person(null, null, dob)

.equals(new Person(null, null, dob)));

```java
public class Vehicle {
  private int weightPounds, horsePower;
  public Vehicle(int weightPounds, int horsePower) {
    this.weightPounds = weightPounds;
    this.horsePower = horsePower;
  }
  protected int getWeightPounds(){ return this.weightPounds; }
  protected double getSpeedMph(double timeSec, int weightPounds){
    double v = 
        2.0 * this.horsePower * 746 * timeSec * 32.174 / weightPounds;
    return Math.round(Math.sqrt(v) * 0.68);
  }
}
```java

public class Vehicle {

private int weightPounds, horsePower;

public Vehicle(int weightPounds, int horsePower) {

this.weightPounds = weightPounds;

this.horsePower = horsePower;

}

protected int getWeightPounds(){ return this.weightPounds; }

protected double getSpeedMph(double timeSec, int weightPounds){

double v =

2.0 * this.horsePower * 746 * timeSec * 32.174 / weightPounds;

返回 Math.round(Math.sqrt(v)* 0.68);

}

}

```java
public class Truck extends Vehicle {
  private int payloadPounds;
  public Truck(int payloadPounds, int weightPounds, int horsePower) {
    super(weightPounds, horsePower);
    this.payloadPounds = payloadPounds;
  }
  public void setPayloadPounds(int payloadPounds) {
    this.payloadPounds = payloadPounds;
  }
  protected int getWeightPounds(){ 
    return this.payloadPounds + getWeightPounds(); 
  }
  public double getSpeedMph(double timeSec){
    return getSpeedMph(timeSec, getWeightPounds());
  }
}
```java

public class Truck extends Vehicle {

private int payloadPounds;

public Truck(int payloadPounds,int weightPounds,int horsePower){

super(weightPounds,horsePower);

this.payloadPounds = payloadPounds;

}

public void setPayloadPounds(int payloadPounds){

this.payloadPounds = payloadPounds;

}

protected int getWeightPounds(){

返回 this.payloadPounds + getWeightPounds();

}

public double getSpeedMph(double timeSec){

返回以英里/小时为单位的速度(timeSec,getWeightPounds())。

}

}

```java
public class Car extends Vehicle {
  private int passengersCount;
  public Car(int passengersCount, int weightPounds, int horsePower) {
    super(weightPounds , horsePower);
    this.passengersCount = passengersCount;
  }
  public void setPassengersCount(int passengersCount) {
    this.passengersCount = passengersCount;
  }

  protected int getWeightPounds(){ 
    return this.passengersCount * 200 + getWeightPounds(); }
  public double getSpeedMph(double timeSec){
    return getSpeedMph(timeSec, getWeightPounds());
  }
}
```java

public class Car extends Vehicle {

private int passengersCount;

public Car(int passengersCount,int weightPounds,int horsePower){

super(weightPounds,horsePower);

this.passengersCount = passengersCount;

}

public void setPassengersCount(int passengersCount){

this.passengersCount = passengersCount;

}

protected int getWeightPounds(){

返回 this.passengersCount * 200 + getWeightPounds();}

public double getSpeedMph(double timeSec){

返回以英里/小时为单位的速度(timeSec,getWeightPounds());

}

}

```java
Truck truck = new Truck(500, 2000, 300);
System.out.println(truck.getSpeedMph(10));

```java

Truck truck = new Truck(500,2000,300);

System.out.println(truck.getSpeedMph(10));

```java
protected int getWeightPounds(){ 
  return this.payloadPounds + getWeightPounds(); 
}
```java

protected int getWeightPounds(){

return this.payloadPounds + getWeightPounds();

}

```java
protected int getWeightPounds(){ 
  return this.payloadPounds + super.getWeightPounds(); 
}
```java

protected int getWeightPounds(){

return this.payloadPounds + super.getWeightPounds();

}

```java
public double getSpeedMph(double timeSec){
  return getSpeedMph(timeSec, getWeightPounds());
}
```java

public double getSpeedMph(double timeSec){

return getSpeedMph(timeSec, getWeightPounds());

}

```java
public double getSpeedMph(double timeSec){
  return getSpeedMph(timeSec, this.getWeightPounds());
}
```java

public double getSpeedMph(double timeSec){

return getSpeedMph(timeSec, this.getWeightPounds());

}

```java
public ClassName(){
  super();
}
```java

public ClassName(){

super();

}

```java
public class Parent {
}
public class Child extends Parent{
}
```java

public class Parent {

}

public class Child extends Parent{

}

```java
new Child();
```java

new Child();

```java
public class Parent {
  public Parent(int i) {
  }
}
```java

public class Parent {

public Parent(int i) {

}

}

```java
public class Parent {
  public Parent() {
  }
  public Parent(int i) {
  }
}
```java

public class Parent {

public Parent() {

}

public Parent(int i) {

}

}

```java
public class Child extends Parent{
  public Child() {
    super(10);
  }
}
```java

public class Child extends Parent{

public Child() {

super(10);

}

}

```java
public class Child extends Parent{
  public Child(int i) {
    super(i);
  }
}
```java

子类继承父类

public Child(int i) {

super(i);

}

}

```java
public class GrandDad{
  private String name = "GrandDad";
  public GrandDad() {
    System.out.println(name);
  }
}
public class Parent extends GrandDad{
  private String name = "Parent";
  public Parent() {
    System.out.println(name);
  }
}
public class Child extends Parent{
  private String name = "Child";
  public Child() {
    System.out.println(name);
  }
}
```java

public class GrandDad{

private String name = "GrandDad";

public GrandDad() {

System.out.println(name);

}

}

public class Parent extends GrandDad{

private String name = "Parent";

public Parent() {

System.out.println(name);

}

}

public class Child extends Parent{

private String name = "Child";

public Child() {

System.out.println(name);

}

}

```java
GrandDad.class.getSimpleName(); //always returns "GrandDad"
```java

GrandDad.class.getSimpleName(); //总是返回"GrandDad"

```java
public class GrandDad{
  private static String NAME = GrandDad.class.getSimpleName();
  public GrandDad() {
    System.out.println(NAME);
  }
}
public class Parent extends GrandDad{
  private static String NAME = Parent.class.getSimpleName();
  public Parent() {
    System.out.println(NAME);
  }
}
public class Child extends Parent{
  private static String NAME = Child.class.getSimpleName();
  public Child() {
    System.out.println(NAME);
  }
}
```java

public class GrandDad{

private static String NAME = GrandDad.class.getSimpleName();

public GrandDad() {

System.out.println(NAME);

}

}

public class Parent extends GrandDad{

私有静态字符串名称= Parent.class.getSimpleName();

public Parent(){

System.out.println(NAME);

}

}

public class Child extends Parent{

私有静态字符串名称= Child.class.getSimpleName();

public Child(){

System.out.println(NAME);

}

}

```java
public class GrandDad{
  private static String NAME = GrandDad.class.getSimpleName()
  public GrandDad() {
    System.out.println(NAME);
  }
  public GrandDad(String familyName) {
    System.out.println(familyName + ": " + NAME);
  }
}
public class Parent extends GrandDad{
  private static String NAME = Parent.class.getSimpleName()
  public Parent() {
    System.out.println(NAME);
  }
  public Parent(String familyName) {
    System.out.println(familyName + ": " + NAME);
  }
}
public class Child extends Parent{
  private static String NAME = Child.class.getSimpleName()
  public Child() {
    System.out.println(NAME);
  }
  public Child(String familyName) {
    System.out.println(familyName + ": " + NAME);
  }
}
```java

public class GrandDad{

私有静态字符串名称= GrandDad.class.getSimpleName()

public GrandDad(){

System.out.println(NAME);

}

public GrandDad(String familyName){

System.out.println(familyName +“:”+ NAME);

}

}

public class Parent extends GrandDad{

私有静态字符串名称= Parent.class.getSimpleName()

public Parent(){

System.out.println(NAME);

}

public Parent(String familyName){

System.out.println(familyName +“:”+ NAME);

}

}

public class Child extends Parent{

私有静态字符串名称= Child.class.getSimpleName()

public Child(){

System.out.println(NAME);

}

public Child(String familyName){

System.out.println(familyName +“:”+ NAME);

}

}

```java
public GrandDad(String familyName) {
  System.out.println(familyName + ": " + NAME);
}
public Parent(String familyName) {
  super(familyName);
  System.out.println(familyName + ": " + NAME);
}
public Child(String familyName) {
  super(familyName);
  System.out.println(familyName + ": " + NAME);
}
```java

public GrandDad(String familyName){

System.out.println(familyName +“:”+ NAME);

}

public Parent(String familyName){

super(familyName);

System.out.println(familyName +“:”+ NAME);

}

public Child(String familyName){

super(familyName);

System.out.println(familyName +“:”+ NAME);

}

```java
public class Child extends Parent{
  private static String NAME = Child.class.getSimpleName()
  public Child() {
    this("The Defaults");
  }
  public Child(String familyName) {
    super(familyName);
    System.out.println(familyName + ": " + NAME);
  }
}
```java

public class Child extends Parent{

私有静态字符串名称= Child.class.getSimpleName()

public Child(){

this(“The Defaults”);

}

public Child(String familyName){

super(familyName);

System.out.println(familyName +“:”+ NAME);

}

}

```java
        class SomeClass{
          private String someValue = "Initial value";
          public void setSomeValue(String someValue) {
            this.someValue = someValue;
          }
          public String getSomeValue() {
            return someValue;
          }
        }
        public class FinalDemo {
          public static void main(String... args) {
            final SomeClass o = new SomeClass();
            System.out.println(o.getSomeValue());   //Initial value
            o.setSomeValue("Another value");
            System.out.println(o.getSomeValue());   //Another value
            o.setSomeValue("Yet another value");
            System.out.println(o.getSomeValue());   //Yet another value

            final String s1, s2;
            final int x, y;
            y = 2;
            int v = y + 2;
            x = v - 4;
            System.out.println("x = " + x);        //x = 0
            s1 = "1";
            s2 = s1 + " and 2";
            System.out.println(s2);                // 1 and 2 
            //o = new SomeClass();                 //error
            //s2 = "3";                            //error
            //x = 5;                               //error
            //y = 6;                               //error
          }
        }
```java

类 SomeClass {

私有字符串 someValue =“初始值”;

public void setSomeValue(String someValue){

this.someValue = someValue;

}

public String getSomeValue(){

返回 someValue;

}

}

公共类 FinalDemo {

public static void main(String ... args){

最终 SomeClass o = new SomeClass();

System.out.println(o.getSomeValue()); //初始值

o.setSomeValue(“另一个值”);

System.out.println(o.getSomeValue()); //另一个值

o.setSomeValue(“另一个值”);

System.out.println(o.getSomeValue()); //另一个值

最终字符串 s1,s2;

最终 int x,y;

y = 2;

int v = y + 2;

x = v-4;

System.out.println(“x =”+ x); // x = 0

s1 =“1”;

s2 = s1 +“和 2”;

System.out.println(s2); // 1 和 2

// o = new SomeClass(); //错误

// s2 =“3”; //错误

// x = 5; //错误

// y = 6; //错误

}

}

```java
        public class FinalDemo {
          final SomeClass o = new SomeClass();
          final String s1 = "Initial value";
          final String s2;
          final String s3;
          final int i = 1;
          final int j;
          final int k;
          {
            j = 2;
            s2 = "new value";
          }
          public FinalDemo() {
            k = 3;
            s3 = "new value";
          }
          public void method(){
            //this.i = 4;         //error
            //this.j = 4;         //error
            //this.k = 4;         //error
            //this.s3 = "";       //error
            this.o.setSomeValue("New value");
          }
        }
```java

公共类 FinalDemo {

最终 SomeClass o = new SomeClass();

最终字符串 s1 =“初始值”;

最终 s2;

最终字符串 s3;

最终 int i = 1;

最终 int j; 

最终 k;

{

j = 2;

s2 =“新值”;

}

公共 FinalDemo(){

k = 3;

s3 =“新值”;

}

public void method(){

// this.i = 4; //错误

// this.j = 4; //错误

// this.k = 4; //错误

// this.s3 =“”; //错误

this.o.setSomeValue(“新值”);

}

}

```java
        public class FinalDemo {
          final static SomeClass OBJ = new SomeClass();
          final static String S1 = "Initial value";
          final static String S2;
          final static int INT1 = 1;
          final static int INT2;
          static {
            INT2 = 2;
            S2 = "new value";
          }    
          void method2(){
            OBJ.setSomeValue("new value");
            //OBJ = new SomeClass();
            //S1 = "";
            //S2 = "";
            //INT1 = 0;
            //INT2 = 0;
          }
        }
```java

公共类 FinalDemo {

最终静态 SomeClass OBJ = new SomeClass();

最终静态字符串 S1 =“初始值”;

最终静态字符串 S2;

最终静态 int INT1 = 1;

最终静态 int INT2;

静态{

INT2 = 2;

S2 =“新值”;

}

void method2(){

OBJ.setSomeValue(“新值”);

// OBJ = new SomeClass();

// S1 =“”;

// S2 =“”;

// INT1 = 0;

// INT2 = 0;

}

}

```java
void someMethod(final int i, final String s, final SomeClass o){
    //... 
}
```java

void someMethod(final int i, final String s, final SomeClass o){

//...

}

```java
class FinalVariable{
    private int i;
    public FinalVariable() { this.i = 1; }
    public void setInt(int i){
        this.i = 100;
        i = i;
    }
    public int getInt(){
        return this.i;
    }
}
```java

class FinalVariable{

private int i;

public FinalVariable() { this.i = 1; }

public void setInt(int i){

this.i = 100;

i = i;

}

public int getInt(){

return this.i;

}

}

```java
FinalVariable finalVar = new FinalVariable();
System.out.println("Initial setting: finalVar.getInt()=" + 
                                                 finalVar.getInt());
finalVar.setInt(5);
System.out.println("After setting to 5: finalVar.getInt()=" + 
                                                 finalVar.getInt());
```java

FinalVariable finalVar = new FinalVariable();

System.out.println("初始设置:finalVar.getInt()=" +

finalVar.getInt());

finalVar.setInt(5);

System.out.println("设置为 5 后:finalVar.getInt()=" +

finalVar.getInt());

```java
public void setInt(final int i){
  this.i = 100;
  i = i;
}
```java

public void setInt(final int i){

this.i = 100;

i = i;

}

```java
public void setInt(final int i){
    this.i = 100;
    this.i = i;
}
```java

public void setInt(final int i){

this.i = 100;

this.i = i;

}

```java
public class SingletonClassExample {
  private static SingletonClassExample OBJECT = null;

  private SingletonClassExample(){}

  public final SingletonClassExample getInstance() {
    if(OBJECT == null){
      OBJECT = new SingletonClassExample();
    }
    return OBJECT;
  }

  //... other class functionality
}
```java

public class SingletonClassExample {

private static SingletonClassExample OBJECT = null;

private SingletonClassExample(){}

public final SingletonClassExample getInstance() {

if(OBJECT == null){

OBJECT = new SingletonClassExample();

}

return OBJECT;

}

//... 其他类功能

}

另一种解决方案可能是将类私有化到工厂类中,并将其存储在工厂字段中,类似于以前的代码。

但要注意,如果这样一个单一对象具有正在改变的状态,就必须确保可以同时修改状态并依赖于它,因为这个对象可能会被不同的方法同时使用。

总结

本章还对最常用的术语 API 进行了详细讨论,以及相关主题的对象工厂、重写、隐藏和重载。此外,还详细探讨了关键字thissuper的使用,并在构造函数的解释过程中进行了演示。本章以关键字final及其在局部变量、字段、方法和类中的使用进行了概述。

在下一章中,我们将描述包和类成员的可访问性(也称为可见性),这将帮助我们扩展面向对象编程的一个关键概念,封装。这将为我们讨论面向对象设计原则奠定基础。

第七章:包和可访问性(可见性)

到目前为止,您已经非常熟悉包了。在本章中,我们将完成其描述,然后讨论类和类成员(方法和字段)的不同访问级别(也称为可见性)。这将涉及到面向对象编程的关键概念——封装,并为我们讨论面向对象设计原则奠定基础。

在本章中,我们将涵盖以下主题:

  • 什么是导入?

  • 静态导入

  • 接口访问修饰符

  • 类访问修饰符

  • 方法访问修饰符

  • 属性访问修饰符

  • 封装

  • 练习-阴影跟读

什么是导入?

导入允许我们在.java文件的开始(类或接口声明之前)只指定一次完全限定的类或接口名称。导入语句的格式如下:

import <package>.<class or interface name>;

例如,看下面的:

import com.packt.javapath.ch04demo.MyApplication;

从现在开始,这个类只能通过它的名称MyApplication在代码中引用。也可以使用通配符(*)导入包的所有类或接口:

import com.packt.javapath.ch04demo.*;

注意,前面的导入语句导入了com.packt.javapath.ch04demo包的子包的类和接口。如果需要,必须逐个导入每个子包。

但在继续之前,让我们谈谈.java文件结构和包。

.java文件和包的结构

正如您所知道的,包名反映了目录结构,从包含.java文件的项目目录开始。每个.java文件的名称必须与其中定义的公共类的名称相同。.java文件的第一行是以package关键字开头的包声明,其后是实际的包名称——本文件的目录路径,其中斜线替换为句点。让我们看一些例子。我们主要关注包含类定义的.java文件,但我们也会看一些带有接口和enum类定义的文件,因为特殊的导入类型(称为静态导入)主要用于接口和enum

我们假设src/main/java(对于 Linux)或src\main\java(对于 Windows)项目目录包含所有.java文件,并且定义在com.packt.javapath包的MyClassMyEnum类和MyInterface接口的定义存储在文件中:

src/main/java/com/packt/javapath/MyClass.java (for Linux) 
src/main/java/com/packt/javapath/MyEnum.java
src/main/java/com/packt/javapath/MyInterface.java 

或(对于 Windows)

src\main\java\com\packt\javapath\MyClass.java (for Windows) 
src\main\java\com\packt\javapath\MyEnum.java
src\main\java\com\packt\javapath\MyInterface.java 

这些文件的第一行如下所示:

package com.packt.javapath;

如果我们什么都不导入,则每个文件的下一行是一个类或接口声明。

MyClass类的声明如下:

public class MyClass extends SomeClass 
     implements Interface1, Interface2, ... {...}

它包括以下内容:

  • 访问修饰符;该文件中的其中一个类必须是public

  • class关键字

  • 类名(按约定以大写字母开头的标识符)

  • 如果类是另一个类的子类,则有extends关键字和父类的名称

  • 如果类实现了一个或多个接口,则有implements关键字,后跟它实现的接口的逗号分隔列表

  • 类的主体(其中定义了字段和方法)用大括号{}括起来

MyEnum类的声明如下所示:

public enum MyEnum implements Interface1, Interface2, ... {...}

它包括以下内容:

  • 访问修饰符;如果它是文件中定义的唯一类,则必须是public

  • enum关键字

  • 类名(标识符),按约定以大写字母开头

  • 没有extends关键字,因为枚举类型隐式地扩展了java.lang.Enum类,在 Java 中,一个类只能有一个父类

  • 如果类实现了一个或多个接口,则有implements关键字,后跟它实现的接口的逗号分隔列表

  • 类的主体(其中定义了常量和方法)用大括号{}括起来

MyInterface接口的声明如下所示:

public interface MyInterface extends Interface1, Interface2, ... {...}

它包括以下内容:

  • 访问修饰符;文件中的一个接口必须是public

  • interface关键字

  • 接口名称(标识符),按约定以大写字母开头

  • 如果接口是一个或多个接口的子接口,则接口后跟extends关键字,后跟父接口的逗号分隔列表

  • 接口的主体(其中定义了字段和方法)用大括号{}括起来

如果没有导入,我们需要通过其完全限定名来引用我们正在使用的每个类或接口,其中包括包名和类或接口名。例如,MyClass类的声明将如下所示:

public class MyClass 
          extends com.packt.javapath.something.AnotherMyClass 
          implements com.packt.javapath.something2.Interface1,
                     com.packt.javapath.something3.Interface2

或者,假设我们想要实例化com.packt.javapath.something包中的SomeClass类。该类的完全限定名称将是com.packt.javapath.something.SomeClass,其对象创建语句将如下所示:

com.packt.javapath.something.SomeClass someClass =
                    new com.packt.javapath.something.SomeClass();

这太冗长了,不是吗?这就是包导入发挥作用的地方。

单个类导入

为了避免在代码中使用完全限定的类或接口名称,我们可以在包声明和类或接口声明之间的空间中添加一个导入语句:

package com.packt.javapath;
import com.packt.javapath.something.SomeClass;
public class MyClass {
  //... 
  SomeClass someClass = new SomeClass();
  //...
}

如您所见,导入语句允许避免使用完全限定的类名,这使得代码更易于阅读。

多个类导入

如果从同一包中导入了多个类或接口,则可以使用星号(*)通配符字符导入所有包成员。

如果SomeClassSomeOtherClass属于同一个包,则导入语句可能如下所示:

package com.packt.javapath;
import com.packt.javapath.something.*;
public class MyClass {
  //... 
  SomeClass someClass = new SomeClass();
  SomeOtherClass someClass1 = new SomeOtherClass();
  //...
}

使用星号的优点是导入语句的列表较短,但这样的风格隐藏了导入的类和接口的名称。因此,程序员可能不知道它们确切来自哪里。此外,当两个或更多的包包含具有相同名称的成员时,你只需将它们明确地导入为单个类导入。否则,编译器会生成一个错误。

另一方面,偏爱通配符导入的程序员认为它有助于防止意外地创建一个已经存在于其中一个导入包中的类的名称。因此,在风格和配置 IDE 以使用或不使用通配符导入时,你必须自己做出选择。

在 IntelliJ IDEA 中,默认的导入风格是使用通配符。如果你想切换到单个类导入,请点击 文件 | 其他设置 | 默认设置,如下面的截图所示:

在打开的界面上,选择编辑器 | Java 并勾选使用单个类导入复选框:

在这个页面上还有其他你可能会觉得有用的设置,所以尽量记住如何访问它。

静态导入

静态导入允许单独导入一个类或接口的公共成员——字段和方法。如果你查看我们的一个测试类,你会看到以下的静态导入语句:

import static org.junit.jupiter.api.Assertions.*;

这个语句允许我们写成以下形式:

Person p = new Person("Joe", "Blow", dob);
assertTrue(p.equals(p));

那就是不再写这样的代码:

Person p = new Person("Joe", "Blow", dob);
Assertions.assertTrue(p.equals(p));

这是静态导入用法的一个广泛案例。另一个常见的用例是静态导入接口或 enum 的常量。例如,如果我们有一个如下所示的接口:

package com.packt.javapath.api;
public interface Constants {
  String NAME = "name";
}

然后,要使用它的常量,可以静态导入它们:

package com.packt.javapath;
import static com.packt.javapath.api.Constants.*;
public class MyClass {
  //...
  String s = "My " + NAME + " is Joe";
  System.out.println(s);        //Prints: My name is Joe
  //...
} 

顺便说一句,同样的效果也可以通过非静态导入那个 Constants 接口并让类实现它来实现:

package com.packt.javapath;
import com.packt.javapath.api.Constants;
public class MyClass implements Constants {
  //...
  String s = "My " + NAME + " is Joe";
  System.out.println(s);        //Prints: My name is Joe
  //...
} 

这种实现接口以使用它们的常量的风格在 Java 程序员中非常流行。

为了使用 enum 常量,使用静态导入的示例看起来类似:

import static java.time.DayOfWeek.*;

它允许代码使用 DayOfWeek 常量作为 MONDAY,而不是 DayOfWeek.MONDAY

访问修饰符

有三个明确的访问修饰符——public、private 和 protected——以及一个隐式的(默认的)访问修饰符,当没有设置访问修饰符时会被暗示。它们可以应用于顶级类或接口、它们的成员和构造函数。顶级类或接口可以包括成员类或接口。类的其他成员包括字段和方法。类还有构造函数。

为了演示可访问性,让我们创建一个包名为 com.packt.javapath.Ch07demo.pack01 的包,其中包含两个类和两个接口:

public class PublicClass01 {
  public static void main(String[] args){
    //We will write code here
  }
}

class DefaultAccessClass01 {
}

public interface PublicInterface01 {
  String name = "PublicInterface01";
}

interface DefaultAccessInterface01 {
  String name = "DefaultAccessInterface01";
}

我们还将创建另一个包名为 com.packt.javapath.Ch07demo.pack02 的包,并在其中放置一个类:

public class PublicClass02 {
  public static void main(String[] args){
    //We will write code here
  }
}

前述的每个类和接口都在自己的文件中:

现在我们准备探讨类、接口、它们的成员和构造函数的可访问性。

顶级类或接口的可访问性

公共类或接口可从任何地方访问。我们可以导入它们并从另一个包中访问它们:

import com.packt.javapath.Ch07demo.pack01.PublicClass01;
import com.packt.javapath.Ch07demo.pack01.PublicInterface01;
//import com.packt.javapath.Ch07demo.pack01.DefaultAccessClass01;
//import com.packt.javapath.Ch07demo.pack01.DefaultAccessInterface01;

public class PublicClass02 {
  public static void main(String[] args){
    System.out.println(PublicInterface01.name);
    PublicClass01 o = new PublicClass01();

  }
}

在上述代码中,两个导入语句被注释掉了,因为它们会生成错误。这是因为在DefaultAccessClass01类和DefaultAccessClass01接口中,我们没有使用访问修饰符,这使它们只能被同一包中的成员访问。

没有访问修饰符,顶级类或接口只能被同一包中的成员访问。

将顶级类或接口的访问修饰符声明为private将使它们无法访问,因此对于顶级类或接口使用private访问修饰符是没有意义的。

protected关键字不能应用于顶级。这个限制并不明显。我们将在下一节中看到,protected意味着它对包成员和子类可访问。因此,有人可能会认为protected访问也适用于顶级类或接口。然而,Java 的作者决定不这样做,如果您尝试将顶级类或接口设为protected,编译器将生成异常。

但是,privateprotected访问修饰符可以应用于内部类或接口——顶级类或接口的成员。

类或接口成员的访问

即使类或接口成员被声明为公共的,如果封闭类或接口是不可访问的,则无法访问它们。因此,以下所有讨论都将在假设类或接口是可访问的情况下进行。

类或接口的成员可以访问同一类或接口的其他成员,无论它们有什么访问修饰符。这是有道理的,不是吗?这一切都发生在同一个封闭类或接口中。

默认情况下,接口成员是公共的。因此,如果可以访问接口本身,则可以访问没有访问修饰符的成员。而且,只是提醒您,接口字段默认为静态和最终(常量)。

另一方面,没有访问修饰符的类成员只能被包成员访问。因此,类或接口可能是公共的,但它们的成员是不太可访问的,除非明确地公开。

私有类或接口成员只能被同一类或接口的其他成员访问。这是最受限制的访问。即使类的子类也不能访问其父类的私有成员。

包内受保护成员可被同一包中的其他成员以及类或接口的子类访问,这意味着受保护成员可以被重写。这通常被程序员用作意图的表达:他们将那些期望被重写的成员设置为受保护的。否则,他们将它们设置为私有或公共。默认的——无访问修饰符——访问极少被使用。

私有:只允许同一类(或接口)访问

无修饰符(默认):允许从同一类(或接口)和同一包中访问

受保护:允许从同一类(或接口)、同一包和任何子类中访问

公共:允许从任何地方访问

内部类和接口也遵循相同的访问规则。下面是一个包含内部类和接口的类的示例:

public class PublicClass01 {
  public static void main(String[] args){
    System.out.println(DefaultAccessInterface01.name);
    DefaultAccessClass01 o = new DefaultAccessClass01();
  }
  class DefaultAccessClass{
  }
  protected class ProtectedClass{
  }
  private class PrivateClass{
  }
  interface DefaultAccessInterface {
  }
  protected class ProtectedInterface{
  }
  private class PrivateInterface{
  }
}

下面是一个带有内部类和接口的接口:

public interface PublicInterface01 {
  String name = "PublicInterface01";

  class DefaultAccessClass{
  }
  interface DefaultAccessInterface {
  }
}

正如您所见,接口的内部类和接口只允许默认(公共)访问。

并且,为了重申我们已经讨论过的内容,我们将简要提及成员可访问性的一些其他相关方面:

  • 静态嵌套类(在静态类的情况下被称为嵌套类)无法访问同一类的非静态成员,而它们可以访问它

  • 作为某个顶层类的成员,静态嵌套类可以是公共的、受保护的、包可访问的(默认)、或私有的

  • 类的公共、受保护和包可访问成员会被子类继承

构造函数的可访问性与任何类成员相同

正如本节标题所述,这就是我们可以说的关于构造函数的可访问性的一切。当然,当我们谈论构造函数时,我们只谈论类。

构造函数有一个有趣的特性,就是它们只能具有私有访问权限。这意味着一个类可以提供自己的工厂方法(见第六章,接口、类和对象构造),控制每个对象如何构造,甚至控制可以将多少个对象放入循环中。在每个对象都需要访问某个资源(文件或另一个数据库)的情况下,最后一个特性尤为有价值,因为该资源对并发访问的支持有限。以下是这样一个具有限制创建对象数量的最简单版本的工厂方法的样子:

private String field;
private static int count;
private PublicClass02(String s){
  this.field = s;
}
public static PublicClass02 getInstance(String s){
  if(count > 5){
    return null;
  } else {
    count++;
    return new PublicClass02(s);
  }
}

这段代码的用处不大,我们只是展示它来演示私有可访问构造函数的使用方式。这是可能的,因为每个类成员都可以访问所有其他类成员,无论它们的访问修饰符如何。

所有与可访问性相关的特性除非产生了一些优势,否则都不会被需要。这就是我们接下来要讨论的内容 - 关于面向对象编程的中心概念,称为封装,它是不可能没有可访问性控制。

封装

面向对象编程的概念诞生于管理软件系统不断增加的复杂性的努力中。封装将数据和程序捆绑在一个对象中,并对它们进行了受控访问(称为封装),从而实现了更好地组织分层的数据和程序,其中一些隐藏,其他则可以从外部访问。前面部分描述的可访问性控制是它的重要部分之一。与继承、接口(也称为抽象)和多态性一起,封装成为面向对象编程的中心概念之一。

往往没有一个面向对象编程的概念能清晰地与另一个分开。接口也有助于隐藏(封装)实现细节。继承可以覆盖和隐藏父类的方法,为可访问性增加了动态性。所有这三个概念使得可以增加多态性的概念 - 相同的对象能够根据上下文呈现为不同类型(基于继承或已实现的接口),或者根据数据可用性改变其行为(使用组合 - 我们将在第八章中讨论,面向对象设计(OOD)原则或方法重载、隐藏和覆盖)。

但是,如果没有封装,上述任何一个概念都是不可能的。这就是为什么它是面向对象编程四个概念中最基本的概念。你可能会经常听到它被提到,所以我们决定专门讲解封装概念的术语及其提供的优势:

  • 数据隐藏和解耦

  • 灵活性、可维护性、重构

  • 可重用性

  • 可测试性

数据隐藏和解耦

当我们将对象状态(字段的值)和一些方法私有化或施加其他限制访问内部对象数据的措施时,我们参与了数据隐藏。对象功能的用户只能根据其可访问性调用特定方法,而不能直接操纵对象的内部状态。对象的用户可能不知道功能的具体实现方式和数据存储方式。他们将所需的输入数据传递给可访问的方法,并获得结果。这样,我们将内部状态与其使用和 API 的实现细节解耦了。

在同一个类中将相关方法和数据分组也增加了解耦,这次是在不同功能的不同区域之间。

您可能会听到密集耦合这个词,作为一种应该只在没有其他选择的情况下允许的东西,因为通常意味着更改一个部分就需要相应更改另一个部分。即使在日常生活中,我们也喜欢处理模块化的系统,允许只替换一个模块而不更改其余系统的任何其他组件。

这就是为什么程序员通常喜欢松散耦合,虽然这通常会以无法确定在所有可能的执行路径上都不存在意外惊喜的代价。一个经过深思熟虑的覆盖关键用例的测试系统通常有助于降低缺陷在生产中传播的可能性。

灵活性、可维护性和重构

在我们谈到解耦时,灵活性和可维护性的想法可能会因为联想而产生。松散耦合的系统更加灵活和易于维护。

例如,在第六章中,接口、类和对象构造,我们演示了一种灵活的解决方案来实现对象工厂:

public static Calculator createInstance(){
  WhichImpl whichImpl = 
      Utils.getWhichImplValueFromConfig(Utils.class,
            Calculator.CONF_NAME, Calculator.CONF_WHICH_IMPL);
  switch (whichImpl){
    case multiplies:
      return new CalculatorImpl();
    case adds:
      return new AnotherCalculatorImpl();
    default:
      throw new RuntimeException("Houston, we have another problem."+
                  " We do not have implementation for the key " +
                  Calculator.CONF_WHICH_IMPL + " value " + whichImpl);
    }
}

它与其 Calculator 接口(其 API)紧密耦合,但这是不可避免的,因为它是实现必须遵守的协议。至于工厂内部的实现,只要它遵循协议就可以更自由地从任何限制中脱颖而出。

我们只能创建实现的每个实例一次,并只返回那个实例(使每个类成为单例)。以下是以单例模式实现 CalculatorImpl 的示例:

private static Calculator calculator = null;
public static Calculator createInstance(){
  WhichImpl whichImpl = 
      Utils.getWhichImplValueFromConfig(Utils.class,
            Calculator.CONF_NAME, Calculator.CONF_WHICH_IMPL);
  switch (whichImpl){
    case multiplies:
      if(calculator == null){
        calculator = new CalculatorImpl();
      }
      return calculator;
    case adds:
      return new AnotherCalculatorImpl();
    default:
      throw new RuntimeException("Houston, we have another problem."+
                      " We do not have implementation for the key " +
                  Calculator.CONF_WHICH_IMPL + " value " + whichImpl);
    }
}

或者我们可以在工厂中添加另一个 Calculator 实现作为嵌套类,并使用它来替代 CalculatorImpl

public static Calculator createInstance(){
  String whichImpl = Utils.getStringValueFromConfig(CalculatorFactory.class,
            "calculator.conf", "which.impl");
  if(whichImpl.equals("multiplies")){
    return new Whatever();
  } else if (whichImpl.equals("adds")){
    return new AnotherCalculatorImpl();
  } else {
    throw new RuntimeException("Houston, we have a problem. " +
              "Unknown key which.impl value " + whichImpl +
              " is in config.");
  }

}

static class Whatever implements Calculator {
  public static String addOneAndConvertToString(double d){
    System.out.println(Whatever.class.getName());
    return Double.toString(d + 1);
  }
  public int multiplyByTwo(int i){
    System.out.println(Whatever.class.getName());
    return i * 2;
  }
}

工厂的客户端代码不会发现任何区别,除非它在从工厂返回的对象上使用 getClass() 方法打印有关类的信息。但这是另一件事情。从功能上讲,我们的新实现 Whatever 将像旧实现一样工作。

实际上,这是一个常见的做法,可以在一个发布版中从一个内部实现改变到另一个。当然会有漏洞修复和新功能添加。随着实现代码的不断发展,其程序员会不断地关注重构的可能性。在计算机科学中,Factoring 是 Decomposition 的同义词,Decomposition 是将复杂代码拆分为更简单的部分的过程,以使代码更易于阅读和维护。例如,假设我们被要求编写一个方法,该方法接受 String 类型的两个参数(每个参数都表示一个整数),并将它们相加作为一个整数返回。经过一番思考,我们决定这样做:

public long sum(String s1, String s2){
  int i1 = Integer.parseInt(s1);
  int i2 = Integer.parseInt(s1);
  return i1 + i2;
}

但然后我们要求提供可能输入值的样本,这样我们就可以在接近生产条件的情况下测试我们的代码。结果发现,一些值可以高达 10,000,000,000,这超过了 2,147,483,647(Java 允许的最大Integer.MAX_VALUE整数值)。因此,我们已经将我们的代码更改为以下内容:

public long sum(String s1, String s2){
  long l1 = Long.parseLong(s1);
  long l2 = Long.parseLong(s2);
  return l1 + l2;
}

现在我们的代码可以处理高达 9,223,372,036,854,775,807 的值(这是Long.MAX_VALUE)。我们将代码部署到生产环境,并且在几个月内一直运行良好,被一个处理统计数据的大型软件系统使用。然后系统切换到了新的数据源,代码开始出现问题。我们进行了调查,发现新的数据源产生的值可以包含字母和一些其他字符。我们已经测试了我们的代码以处理这种情况,并发现以下行抛出NumberFormatException

long l1 = Long.parseLong(s1);

我们与领域专家讨论了情况,他们建议我们记录不是整数的值,跳过它们,并继续进行求和计算。因此,我们已经修复了我们的代码,如下所示:

public long sum(String s1, String s2){
  long l1 = 0;
  try{
    l1 = Long.parseLong(s1);
  } catch (NumberFormatException ex){
    //make a record to a log
  }
  long l2 = 0;
  try{
    l2 = Long.parseLong(s2);
  } catch (NumberFormatException ex){
    //make a record to a log
  }
  return l1 + l2;
}

我们迅速将代码发布到生产环境,但是在下一个发布中获得了新的要求:输入的String值可以包含小数。因此,我们已经改变了处理输入String值的方式,假设它们带有小数值(这也包括整数值),并重构了代码,如下所示:

private long getLong(String s){
  double d = 0;
  try{
    d = Double.parseDouble(s);
  } catch (NumberFormatException ex){
    //make a record to a log
  }
  return Math.round(d);
}
public long sum(String s1, String s2){
  return getLong(s1) + getLong(s2);
}

这就是重构所做的事情。它重新构造了代码而不改变其 API。随着新的需求不断出现,我们可以修改getLong()方法,甚至不用触及sum()方法。我们还可以在其他地方重用getLong()方法,这将是下一节的主题。

可重用性

封装绝对使得实现可重用性变得更容易,因为它隐藏了实现细节。例如,在前一节中我们编写的getLong()方法可以被同一类的另一个方法重用:

public long sum(int i, String s2){
  return i + getLong(s2);
}

它甚至可以被公开并被其他类使用,就像下面的代码一样:

int i = new Ch07DemoApp().getLong("23", "45.6");

这将是一个组合的例子,当某些功能是使用不相关的类的方法(通过组合)构建时。而且,由于它不依赖于对象状态(这样的方法称为无状态),因此它可以是静态的:

int i = Ch07DemoApp.getLong("23", "45.6");

如果该方法在运行时由多个其他方法同时使用,甚至这样一个简单的代码也可能需要受到保护(同步),防止并行使用。但是这样的考虑超出了本书的范围。如果有疑问,请不要使方法静态。

如果您阅读面向对象编程的历史,您会发现继承最初被赋予了,除其他外,成为代码重用的主要机制。而它确实完成了任务。子类继承(重用)了其父类的所有方法,并且只覆盖那些需要为子类专业化的方法。

但在实践中,似乎其他重复使用技术更受欢迎,尤其是对于重复使用的方法是无状态的情况。我们将在第八章中更详细地讨论这一原因,面向对象设计(OOD)原则

可测试性

代码可测试性是另一个封装有所帮助的领域。如果实现细节没有被隐藏,我们就需要测试每一行代码,并且每次更改实现中的任何行时都需要更改测试。但是,隐藏细节在 API 外观后面允许我们仅专注于所需的测试用例,并且受可能输入数据集(参数值)的限制。

此外,还有一些框架允许我们创建一个对象,根据输入参数的特定值返回特定结果。Mockito 是一个流行的框架,它可以做到这一点(site.mockito.org)。这样的对象称为模拟对象。当您需要从一个对象的方法中获取特定结果以测试其他方法时,它们特别有帮助,但您不能运行作为数据源的方法的实际实现,因为您没有必要的数据在数据库中,例如,或者它需要一些复杂的设置。为了解决这个问题,您可以用返回您需要的数据的实际实现替换某些方法的实际实现——模拟它们,无条件地或以对某些输入数据做出响应。没有封装,这样模拟方法行为可能是不可能的,因为客户端代码将与特定实现绑定,您将无法在不更改客户端代码的情况下更改它。

练习 - 遮蔽

编写演示变量遮蔽的代码。我们还没有讨论过它,所以您需要做一些研究。

回答

这是一个可能的解决方案:

public class ShadowingDemo {
  private String x = "x";
  public void printX(){
    System.out.println(x);   
    String x = "y";
    System.out.println(x);   
  }
}

如果您运行 new ShadowingDemo().printX();,它将首先打印 x,然后打印 y,因为以下行中的局部变量 x 遮蔽了 x 实例变量:

String x = "y";

请注意,遮蔽可能是缺陷的源泉,也可能有益于程序。如果没有它,您将无法使用已经被实例变量使用的局部变量标识符。这里还有另一个案例的例子,变量遮蔽有助于:

private String x = "x";
public void setX(String x) {
  this.x = x;
}

x 局部变量(参数)遮蔽了 x 实例变量。它允许使用相同的标识符来命名一个局部变量,该标识符已经被用于实例变量名。为了避免可能的混淆,建议使用关键字 this 引用实例变量,就像我们在上面的示例中所做的那样。

摘要

在这一章中,你了解了面向对象语言的一个基本特性——类、接口、它们的成员和构造函数的可访问性规则。现在你可以从其他包中导入类和接口,并避免使用它们的完全限定名。所有这些讨论使我们能够介绍面向对象编程的核心概念——封装。有了这个,我们就可以开始对面向对象设计OOD)原则进行有根据的讨论。

下一章介绍了 Java 编程的更高层次视角。它讨论了良好设计的标准,并提供了一份对经过验证的 OOD 原则的指南。每个设计原则都有详细的描述,并使用相应的代码示例进行了说明。

第八章:面向对象设计(OOD)原则

在本章中,我们将回到对编程和特别是 Java 编程的高层视图。我们将展示设计在软件系统过程中的作用,从最早的可行性阶段开始,经过高层设计、详细设计,最终到编码和测试。我们将讨论良好设计的标准,并提供一份经过验证的 OOD 原则指南。讨论将通过代码示例加以说明,演示主要 OOD 原则的应用。

在本章中,我们将涵盖以下主题:

  • 设计的目的是什么?

  • 封装和编程到接口

  • 利用多态性

  • 尽可能解耦

  • 优先使用聚合而不是继承

  • 这么多 OOD 原则,时间却如此有限

  • 单一职责原则

  • 开闭原则

  • 里斯科夫替换原则

  • 接口隔离原则

  • 依赖反转原则

  • 练习 - 设计模式

设计的目的是什么?

任何项目都需要规划和对将要构建的东西的愿景。当同一个团队的几个成员必须协调他们的活动时,这尤为重要。但即使你是一个人工作,你也必须制定某种计划,无论是设计文档还是只是编写代码而没有以其他形式记录你的想法。这就是设计的目的——清晰地设想未来的系统,以便能够开始构建它。

在这个过程中,设计会不断演变、改变并变得更加详细。项目生命周期的每个阶段都需要不同的东西。这就是我们现在要讨论的——随着项目从最初的想法到完整实施的进展,设计的目的如何演变。

这里描述的项目步骤看起来是顺序的,但实际上它们是有重叠的。更重要的是,软件开发的敏捷方法鼓励将每个功能移动到所有项目步骤中,而不是等到发现未来产品的所有功能。

在敏捷方法论中,交付物不是需求、设计或任何其他文档,而是部署到生产环境并产生价值的功能代码(也称为最小可行产品(MVP))。每次迭代都必须在一两周内完成。然后,基于真实客户体验的反馈循环允许不断调整最初的愿景,并驱动所有努力以在最短时间内实现最有价值的解决方案,并最小化资源浪费。

许多现代成功的产品,如果不是大多数,都是以这种方式推向市场的。它们的作者经常承认,只有少数原创的想法被实现了,如果有的话。生活是一个伟大的笑话,不是吗?它偏爱那些更快适应变化的人。

现在,让我们走过项目生命周期,看看系统设计是如何随着项目的进展而演变的。

项目的可行性

决定某个项目是否值得融资必须在非常早期就做出。否则,它可能根本就不会开始。这意味着决策者必须提供足够的信息,以提供一定程度的信心,即风险是合理的,值得承担。这些信息包括高层需求、高层设计,甚至原型设计或其他证明可用技术可以用于成功实施。基于这些数据和市场调研,项目倡导者估计工作量、费用、潜在收入和未来利润——一切目标的母亲。

甚至在项目获得绿灯之前,产品成功最关键的特性就已经被确定,并以可与未来客户沟通的形式呈现,并与他们讨论甚至测试。如果团队中包括过去做过类似事情的人,肯定有助于简化决策过程。

这个阶段的目的是以一种所有参与者和潜在客户都能理解的形式呈现未来的系统。

需求收集和原型制作

一旦项目获得批准和预算,需求收集就会全速进行,同时进行原型实现。事实上,原型通常被用作需求收集的工具。它有助于讨论具体的关键细节并避免误解。

在这个项目阶段,高级设计不断进展,同时发现有关输入信息来源、消耗它所需的过程(和产生必要结果的过程)、可以用来执行它的技术,以及客户可能如何与系统交互的更多细节。

随着对未来系统的更多数据,以及它可能如何工作和实现,可以确定可能妨碍进展或使整个项目不可能的障碍。因此,决策者继续密切关注结果并进行批判性评估。

在这个阶段,设计的目的是将所有输入数据整合成未来运行系统的连贯动态图像。在面向对象编程的四个支柱中,封装和接口处于高级设计的前沿。实现细节应在关键领域进行核查,并证明可以使用所选的技术。但它们保持隐藏在接口后面,后者专注于系统与客户的互动以及发现实现的新功能和非功能要求。

高级设计

高级设计最明显的特征是其专注于子系统和它们之间的接口的系统结构。如果产品必须与外部系统交互,这些交互的接口和协议也是高级设计的一部分。架构也被确认和验证为能够支持设计。

对于典型的中型软件系统,高级设计可以用包及其公共接口的列表来表达。如果系统具有图形用户界面,通常原型和线框图就足够了。

详细设计

一旦确定要实现的用例,详细设计就开始发挥作用。业务代表为新产品功能设置优先级。程序员确定并调整接口以支持第一个功能,并开始创建类来实现将在第一次迭代中交付的第一个用例。

最初,实现可能在某些地方使用硬编码(虚拟)数据。因此,用例可能具有有限的应用范围。尽管如此,这样的实现是有价值的,因为它允许执行所有必需的过程,因此生产中的客户可以测试该功能并了解预期的情况。程序员还为每个实现的方法创建单元测试,即使是虚拟的方法也是如此。与此同时,用例被捕获在执行跨类和子系统的场景的集成测试中。

在第一次迭代结束时,高优先级的用例已经实现并通过自动化测试进行了全面测试。第一次迭代通常非常忙碌。但程序员们有动力不再重复他们的错误,通常会充满热情并具有比平时更高的生产力。

详细设计的目的是为编码提供模板。一旦模板建立,所有未来的类将主要是从现有类中剪切和粘贴。这就是为什么第一个类通常由高级程序员实现或在他们的密切监督下实现。在这样做的同时,他们试图尽可能保持封装封闭,以获得最小和直观的接口,并在可能的情况下利用继承和多态性。

命名约定也是第一次迭代的重要组成部分。它必须反映领域术语,并且所有团队成员都能理解。因此,这个阶段的设计目的是为项目创建编码模式和词汇。

编码

正如你所看到的,编码从高层设计开始,甚至可能更早。随着详细设计产生了第一个结果,编码变得更加紧张。新成员可以加入团队,其中一些可能是初级成员。增加团队成员是最喜欢的管理活动,但必须以受控的方式进行,以便每个新成员都能得到指导,并且能够充分理解所有关于新产品功能的业务讨论。

这个阶段的设计活动侧重于实现细节及其测试。在详细设计期间创建的模式必须根据需要进行应用和调整。编码期间的设计目的是验证到目前为止所做的所有设计决策,并产生具体的解决方案,表达为代码行。重构是这个阶段的主要活动之一,也有几次迭代。

测试

在编码完成时,测试也已编写,并且运行了多次。它们通常在每次向源代码库提交新的更改块时执行。一些公司正在实践持续集成模型,一旦提交到源代码库,就会触发自动回归和集成测试,并随后部署到生产环境。

然而,仍然有许多开发团队专门有专门的测试专家,在代码部署到测试环境后,会手动测试并使用一些专门的工具。

这个阶段的设计工作侧重于测试覆盖率、测试自动化以及与其他系统的集成,无论是自动化的还是非自动化的。部署和在生产环境中进行有限测试(称为冒烟测试)也是这个阶段设计工作的一部分。

测试期间的设计目的是确保所有交付的用例都经过测试,包括负面和非功能性测试。监控和报告系统性能也是这个阶段的重要活动。

良好设计的路线图

正如我们在前一节中讨论的设计演变,我们已经暗示了确保设计质量的标准:

  • 它必须足够灵活,以适应即将到来的变化(它们像税收一样不可避免,所以最好做好准备)

  • 它必须清晰地传达项目结构和每个部分的专业化

  • 它必须使用明确定义的领域术语

  • 它必须允许独立测试部分并将其集成在一起

  • 它必须以一种允许我们与未来客户讨论的形式呈现,并且理想情况下,由他们测试。

  • 它必须充分利用四个面向对象的概念——封装、接口、继承和多态性

这些是任何项目和任何面向对象语言的一般标准。但在本书中,我们介绍了 Java 最佳实践,因此我们需要主要讨论 Java 中的详细设计、编码和测试,所有这些都与最后一个标准有关。这就是我们现在要做的。

封装和编码到接口

我们多次在不同的上下文中提到了封装和接口。这既不是偶然的,也不是有意的。这是不可避免的。封装和接口是出于尽可能隐藏实现的必要性而产生的。它解决了早期编程中的两个问题:

  • 未受监管的数据共享访问

  • 以下是输出的屏幕截图:

当部分之间的关系结构不够完善时更改代码时的困难

正如我们在第六章中所演示的,接口、类和对象构造,使对象的状态私有化也解决了涉及继承时实例字段和实例方法之间可访问性的差异。子类不能覆盖父类的非私有字段,只能隐藏它们。只有方法可以被覆盖。为了演示这种差异,让我们创建以下三个类:

public class Grandad {
  public String name = "Grandad";
  public String getName() { return this.name; }
}

public class Parent extends Grandad {
  public String name = "Parent";
  public String getName() { return this.name; }
}

public class Child extends Parent {
  public String name = "Child";
  public String getName() { return this.name; }
}

车辆数量

每个都有一个具有相同名称的公共字段和相同签名的方法。现在,在不往下看的情况下,尝试猜测以下代码的输出:

Grandad grandad = new Child();
System.out.println(grandad.name);
System.out.println(grandad.getName());

```java

+   所有车辆开始移动后的秒数

+   车辆负载:汽车乘客数量和卡车的有效载荷

最后一个参数应该是可选的。它可以是以下之一:

+   基于目标城市的当前交通统计数据建模

+   设置特定值,以评估新交通法规的影响

以下是位于`com.packt.javapath.ch08demo.traffic`包中的建模系统 API 的详细设计:

```java
public interface Vehicle {
  double getSpeedMph(double timeSec);
  static List<Vehicle> getTraffic(int vehiclesCount){
    return TrafficFactory.get(vehiclesCount);
  }
}
public interface Car extends Vehicle {
  void setPassengersCount(int passengersCount);
}
public interface Truck extends Vehicle {
  void setPayloadPounds(int payloadPounds);
}

正如您所看到的,我们只向客户端公开接口并隐藏实现(关于这一点我们将在下一节详细讨论)。只要满足合同,它允许我们以我们认为最好的方式实现接口。如果以后更改了实现,客户端不需要更改他们的代码。这是封装和解耦接口与实现的一个例子。正如我们在上一章中讨论的那样,它还有助于代码的可维护性、可测试性和可重用性。更多关于后者的内容请参见更喜欢聚合而不是继承部分,尽管我们应该指出,继承也有助于代码重用,我们将在下一节中看到它的证明。

通过从Vehicle接口扩展CarTruck接口,我们已经暗示了我们将使用多态性,这就是我们将在接下来的部分讨论的内容。

利用多态性

CarTruck接口正在扩展(子类)Vehicle接口。这意味着实现Car接口的类(例如,我们给这样的类命名为CarImpl),在实例化时,创建了一个具有三种类型的对象——VehicleCarCarImpl。这些类型类似于一个人拥有三个国家的护照。每种国籍都有特定的权利和限制,一个人可以选择在国际旅行的不同情况下如何呈现自己,同样,CarImpl类的对象可以转换为这些类型中的任何一个,只要在进行转换的代码中可以访问该类型。这就是我们所说的类型可访问性的含义:

  • 我们已经将CarTruckVehicle接口声明为 public,这意味着任何包中的任何代码都可以访问这些类型

  • 我们不希望客户端代码能够访问这些接口的实现,因此我们创建了com.packt.javapath.ch08demo.traffic.impl包,并将所有实现放在那里,而不指定访问修饰符(因此使用默认访问,使它们只对同一包中的其他成员可见)

这里是交通接口的实现:

class VehicleImpl implements Vehicle {
  public double getSpeedMph(double timeSec){
    return 42;
  }
}
class TruckImpl implements Truck {
  public void setPayloadPounds(int payloadPounds){
  }
}
class CarImpl implements Car {
  public void setPassengersCount(int passengersCount){
  }
}

我们在com.packt.javapath.ch08demo.traffic.impl包中创建了这些类,并使用了一些虚拟数据,只是为了使它们编译通过。但是CarImplTruckImpl类仍然会生成编译错误,因为Vehicle接口中列出了getSpeedMph()方法,而这两个类中没有实现。CarTruck接口扩展了Vehicle接口,因此继承了它的抽象getSpeedMph()方法。

因此,现在我们需要在这两个类中实现getSpeedMph()方法,或者将它们都作为VehicleImpl类的子类,而这个方法已经被实现了。我们决定汽车和卡车的速度可能会以相同的方式计算,所以扩展VehicleImpl类是正确的方法。如果以后我们发现CarImplTruckImpl类需要不同的实现,我们可以覆盖父类中的实现。以下是相同两个类的新版本:

abstract class VehicleImpl implements Vehicle {
  public double getSpeedMph(double timeSec){
    return 42;
  }
}
class TruckImpl extends VehicleImpl implements Truck {
  public void setPayloadPounds(int payloadPounds){
  }
}
class CarImpl extends VehicleImpl implements Car {
  public void setPassengersCount(int passengersCount){
  }
}

请注意,我们还将VehicleImpl类设为抽象类,这使得不可能创建VehicleImpl类的对象。只能创建它的子类的对象。我们这样做是因为我们将其用作包含一些通用功能的基类,但我们永远不会需要通用的Vehicle对象,只需要特定的对象——CarTruck

我们遵循了尽可能封装一切的建议。受限制的访问权限可以在以后更改为更可访问的权限。这比在已经编写了依赖于现有较不受限制访问级别的客户端代码之后再限制访问权限要容易得多。

所以,回到CarImplTruckImpl交通接口的实现。它们无法从包外访问,但这并不是问题,因为我们定义的 API 不需要它。如果TrafficFactory类可以访问它们,那就足够了。这就是为什么我们在com.packt.javapath.ch08demo.traffic.impl包中创建TrafficFactor类,它可以作为同一包的成员访问这两个实现:

package com.packt.javapath.ch08demo.traffic.impl;

import com.packt.javapath.ch08demo.traffic.Vehicle;
import java.util.ArrayList;
import java.util.List;

public class TrafficFactory {
  public static List<Vehicle> get(int vehiclesCount) {
    List<Vehicle> list = new ArrayList();
    return list;
  }
}

它并没有做太多事情,但在设计阶段足够好,以确保所有类都就位并具有适当的访问权限,然后我们开始编码。我们将在第十三章中更多地讨论List<Vehicle>构造。现在,假设它代表实现Vehicle接口的对象列表就足够了。

现在,我们可以编写以下客户端代码:

double timeSec = 5;
int vehiclesCount = 4;
List<Vehicle> traffic = Vehicle.getTraffic(vehiclesCount);
for(Vehicle vehicle: traffic){
  System.out.println("Loaded: " + vehicle.getSpeedMph(timeSec));
  if(vehicle instanceof Car){
    ((Car) vehicle).setPassengersCount(0); 
    System.out.println("Car(no load): " + vehicle.getSpeedMph(timeSec));
  } else {
    ((Truck) vehicle).setPayloadPounds(0);
    System.out.println("Truck(no load): " + vehicle.getSpeedMph(timeSec));
  }
}

前面的代码从TrafficFactory中检索任意数量的车辆(在本例中为 4 辆)。工厂隐藏(封装)了交通建模实现的细节。然后,代码在 for 循环中对列表进行迭代(参见第十章,控制流语句),并打印出每辆车在车辆开始移动后 5 秒的速度。

然后,代码演示了客户端可以更改车辆携带的负载,这是必需的。对于汽车,我们将乘客人数设置为零,对于卡车,我们将它们的有效载荷设置为零。

我们执行此代码并没有得到结果,因为交通工厂返回了一个空列表。但是代码编译并运行,我们可以开始实现接口。我们可以将任务分配给不同的团队成员,只要他们不改变接口,我们就不必担心协调他们之间的工作。

确保接口、继承和多态性得到充分利用后,我们可以将注意力转向编码细节。

尽量解耦

我们选择了继承来实现代码在不同实现之间的共享。结果如下。这是VehicleImpl类:

abstract class VehicleImpl implements Vehicle {
  private int weightPounds, horsePower;
  public VehicleImpl(int weightPounds, int horsePower) {
    this.weightPounds = weightPounds;
    this.horsePower = horsePower;
  }
  protected int getWeightPounds(){ return this.weightPounds; }
  protected double getSpeedMph(double timeSec, int weightPounds){
    double v = 2.0 * this.horsePower * 746 * timeSec * 
                                          32.174 / weightPounds;
    return Math.round(Math.sqrt(v) * 0.68);
  }
}

请注意,一些方法具有protected访问权限,这意味着只有相同包和类子类的成员才能访问它们。这也是为了更好地封装。我们的代码客户端不需要访问这些方法,只有子类需要。以下是其中一个:

class CarImpl extends VehicleImpl implements Car {
  private int passengersCount;
  public CarImpl(int passengersCount, int weightPounds, int horsePower){
    super(weightPounds , horsePower);
    this.passengersCount = passengersCount;
  }
  public void setPassengersCount(int passengersCount) {
    this.passengersCount = passengersCount;
  }
  protected int getWeightPounds(){ 
    return this.passengersCount * 200 + super.getWeightPounds(); 
  }
  public double getSpeedMph(double timeSec){
    return getSpeedMph(timeSec, this.getWeightPounds());
  }
}

在前面的代码中,thissuper关键字允许我们区分应该调用哪个方法-当前子对象中的方法还是父对象中的方法。

前面实现的另外两个方面值得注意:

  • getWeightPounds() 方法的访问修饰符设置为protected。这是因为在父类中也声明了具有相同签名和protected访问修饰符的方法。但是,重写的方法不能比被重写的方法具有更严格的访问权限。或者,为了加强封装性,我们可以在CarImpl中更改方法名称为getCarWeightPounds(),并将其设置为私有。然后,就不需要使用thissuper关键字了。但是,另一个包中的类无法访问protected方法,因此我们决定保留getWeightPounds()名称并使用thissuper关键字,承认这只是一种风格问题。

  • 构造函数的访问权限也可以设置为默认(包级别)。

TruckImpl类看起来类似于以下代码片段:

class TruckImpl extends VehicleImpl implements Truck {
  private int payloadPounds;
  TruckImpl(int payloadPounds, int weightPounds, int horsePower) {
    super(weightPounds, horsePower);
    this.payloadPounds = payloadPounds;
  }
  public void setPayloadPounds(int payloadPounds) {
    this.payloadPounds = payloadPounds;
  }
  protected int getWeightPounds(){ 
    return this.payloadPounds + super.getWeightPounds(); 
  }
  public double getSpeedMph(double timeSec){
    return getSpeedMph(timeSec, this.getWeightPounds());
  }
}

TrafficFactory类可以访问这些类和它们的构造函数来根据需要创建对象:

public class TrafficFactory {
  public static List<Vehicle> get(int vehiclesCount) {
    List<Vehicle> list = new ArrayList();
    for (int i = 0; i < vehiclesCount; i++){
      Vehicle vehicle;
      if (Math.random() <= 0.5) {
        vehicle = new CarImpl(2, 2000, 150);
      } else {
        vehicle = new TruckImpl(500, 3000, 300);
      }
      list.add(vehicle);
    }
    return list;
  }
}

Math类的random()静态方法生成 0 到 1 之间的随机十进制数。我们用它来使交通的结果看起来有些真实。而且,目前我们在每辆车辆的构造函数中传递的值是硬编码的。

现在,我们可以运行以下代码(我们已经在前面的几页中讨论过):

public class TrafficApp {
  public static void main(String... args){
    double timeSec = 5;
    int vehiclesCount = 4;
    List<Vehicle> traffic = Vehicle.getTraffic(vehiclesCount);
    for(Vehicle vehicle: traffic){
      System.out.println("Loaded: " + vehicle.getSpeedMph(timeSec));
      if(vehicle instanceof Car){
        ((Car) vehicle).setPassengersCount(0);
        System.out.println("Car(no load): " + 
                           vehicle.getSpeedMph(timeSec));
      } else {
        ((Truck) vehicle).setPayloadPounds(0);
        System.out.println("Truck(no load): " + 
                           vehicle.getSpeedMph(timeSec));
      }
    }
  }
}

结果如下:

计算得到的速度是相同的,因为输入数据在TrafficFactory中是硬编码的。但在我们继续并使输入数据不同之前,让我们创建一个速度计算测试:

package com.packt.javapath.ch08demo.traffic.impl;

class SpeedCalculationTest {
  @Test
  void speedCalculation() {
    double timeSec = 5;
    Vehicle vehicle = new CarImpl(2, 2000, 150);
    assertEquals(83.0, vehicle.getSpeedMph(timeSec));
    ((Car) vehicle).setPassengersCount(0);
    assertEquals(91.0, vehicle.getSpeedMph(timeSec));

    vehicle = new TruckImpl(500, 3000, 300);
    assertEquals(98.0, vehicle.getSpeedMph(timeSec));
    ((Truck) vehicle).setPayloadPounds(0);
    assertEquals(105.0, vehicle.getSpeedMph(timeSec));
   }
}

我们可以访问CarImplTruckImpl类,因为该测试属于同一个包,尽管它位于项目的不同目录中(在test目录下,而不是main)。在类路径上,它们根据其包的位置放置,即使源来自另一个源树。

我们已经测试了我们的代码,现在我们可以专注于处理真实数据并为客户在TrafficFactory中创建相应的对象。实现与接口解耦,直到准备好为止,我们可以保持其硬编码状态,以便客户端可以开始编写和测试他们的代码,而无需等待我们的系统完全功能可用。这是封装和接口的另一个优点。

优先选择聚合而非继承

在现实项目中工作过的人都知道需求随时可能变化。在我们的项目中,甚至在第二次迭代完成之前,就需要向CarTruck接口添加新的方法,同时速度计算在自己的项目中增长。负责实现接口的程序员和负责速度计算的程序员开始修改CarImplTruckImplVehicleImpl文件。

不仅如此,另一个项目决定使用我们的速度计算功能,但他们想将其应用于其他对象,而不是汽车和卡车。那时我们意识到需要改变我们的实现,以支持聚合功能而非继承功能,这也是一般情况下推荐的设计策略之一,因为它增加了解耦和促进了更灵活的设计。这是什么意思。

我们将VehicleImpl类的getSpeedMph()方法复制到一个新的com.packt.javapath.ch08demo.speedmodel.impl包中的SpeedModelImpl类中。

class SpeedModelImpl implements SpeedModel {
  public double getSpeedMph(double timeSec, int weightPounds,
                            int horsePower){
    double v = 2.0 * horsePower * 746 * timeSec * 32.174 / weightPounds;
    return Math.round(Math.sqrt(v) * 0.68);
  }
}

我们将SpeedModelFactory添加到同一个包中:

public class SpeedModelFactory {
  public static SpeedModel speedModel(){
    return new SpeedModelImpl();
  }
}

然后我们在com.packt.javapath.ch08demo.speedmodel包中创建了一个SpeedModel接口:

public interface SpeedModel {
  double getSpeedMph(double timeSec, int weightPounds, int horsePower);
  static SpeedModel getInstance(Month month, int dayOfMonth, int hour){
    return SpeedModelFactory.speedModel(month, dayOfMonth, hour);
  }
}

现在,我们通过为SpeedModel对象添加一个 setter 并在速度计算中使用此对象来更改VehicleImpl类:

abstract class VehicleImpl implements Vehicle {
  private int weightPounds, horsePower;
  private SpeedModel speedModel;
  public VehicleImpl(int weightPounds, int horsePower) {
    this.weightPounds = weightPounds;
    this.horsePower = horsePower;
  }
  protected int getWeightPounds(){ return this.weightPounds; }
  protected double getSpeedMph(double timeSec, int weightPounds){
    if(this.speedModel == null){
      throw new RuntimeException("Speed model is required");
    } else {
      return speedModel.getSpeedMph(timeSec, weightPounds, horsePower);
    }
  }
  public void setSpeedModel(SpeedModel speedModel) {
    this.speedModel = speedModel;
  }
}

正如您所看到的,如果在设置 SpeedModel 对象之前调用getSpeedMph()方法,它现在会抛出异常(并停止工作)。

我们还更改了TrafficFactory并让它在交通对象上设置SpeedModel

public class TrafficFactory {
  public static List<Vehicle> get(int vehiclesCount) {
    SpeedModel speedModel = SpeedModelFactory.speedModel();
    List<Vehicle> list = new ArrayList();
    for (int i = 0; i < vehiclesCount; i++) {
      Vehicle vehicle;
      if (Math.random() <= 0.5) {
        vehicle = new CarImpl(2, 2000, 150);
      } else {
        vehicle = new TruckImpl(500, 3000, 300);
      }
      ((VehicleImpl)vehicle).setSpeedModel(speedModel);
      list.add(vehicle);
    }
    return list;
  }
}

现在,速度模型继续独立于交通模型进行开发,我们完成了所有这些而不改变客户端的代码(这种不影响接口的内部代码更改称为重构)。这是封装和接口解耦的好处。Vehicle对象的行为现在是聚合的,这使我们能够在不修改其代码的情况下更改其行为。

尽管本节的标题是优先使用聚合而不是继承,但这并不意味着继承应该总是被避免。继承有其自身的用途,对于多态行为尤其有益。但是当我们谈论设计灵活性和代码可重用性时,它有两个弱点:

  • Java 类不允许我们扩展超过一个父类,因此,如果类已经是子类,则不能扩展另一个类以重用其方法

  • 继承需要类之间的父子关系,而无关的类通常共享相同的功能

有时,继承是解决手头问题的唯一方法,有时使用它会在以后引起问题。现实情况是我们永远无法可靠地预测未来会发生什么,因此如果使用继承或不使用继承的决定最终是错误的话,不要感到难过。

这么多 OOD 原则,时间却那么少

如果您在互联网上搜索 OOD 原则,您很容易找到许多包含数十个推荐设计原则的列表。它们都有意义。

例如,以下是经常捆绑在一起的五个最受欢迎的 OOD 原则,缩写为 SOLID(由原则标题的第一个字母组成):

  • 单一责任原则:一个类应该只有一个责任

  • 开闭原则:一个类应该封装其功能(关闭),但应该能够扩展

  • 里氏替换原则:对象应该能够被其子对象替换(替换)而不会破坏程序

  • 接口隔离原则:许多面向客户的接口比一个通用接口更好

  • 依赖反转原则:代码应该依赖于接口,而不是实现。

正如我们之前所说,关于如何实现更好的设计还有许多其他好主意。你应该学习所有这些吗?答案很大程度上取决于你喜欢学习新技能的方式。有些人通过实验来学习,其他人通过借鉴他人的经验来学习,大多数人则是通过这两种方法的结合来学习。

好消息是,我们在本章讨论的设计标准、面向对象的概念以及良好设计的路线图,能够在大多数情况下引导你找到一个坚实的面向对象设计解决方案。

但如果你决定了解更多关于面向对象设计,并看看其他人是如何解决软件设计问题的,不要犹豫去了解它们。毕竟,人类是通过将他们的经验传递给下一代,才走出了洞穴,登上了宇宙飞船。

练习-设计模式

有许多面向对象设计模式共享了特定编码问题的软件设计解决方案。面向对象设计模式也经常被程序员用来讨论不同的实现方式。

它们通常被分为四类:创建、行为、结构和并发模式。阅读它们并:

  • 在每个类别中列出一种模式

  • 列出我们已经使用过的三种模式

答案

四种模式——每种类别中的一种——可能是以下这些:

  • 创建模式:工厂方法

  • 结构模式:组合

  • 行为模式:访问者

  • 并发模式:消息模式

在这本书中,我们已经使用了以下模式:

  • 延迟初始化:在第六章中,接口、类和对象构造,我们初始化了SingletonClassExample OBJECT静态字段,但只有在调用getInstance()方法时才会初始化

  • 单例模式:在第六章中,接口、类和对象构造,查看SingletonClassExample

  • 外观模式:在第六章中,接口、类和对象构造,当我们创建了一个Calculator接口,用于捕捉对实现功能的所有可能交互

总结

在本章中,我们重新审视了编程的高层视图,特别是 Java 编程。我们讨论了软件系统开发过程中的设计演变,从最早的可行性阶段开始,经过高层设计、详细设计,最终到编码和测试。我们讨论了良好设计的标准,面向对象的概念,主要的面向对象设计原则,并提供了一个良好面向对象设计的路线图。我们通过代码示例来说明所有讨论过的面向对象设计原则的应用。

在下一章中,我们将更深入地探讨 Java 编程的三个核心元素:运算符、表达式和语句。我们将定义并讨论所有 Java 运算符,更详细地探讨最流行的运算符,并在具体示例中演示它们,以及表达式和语句。

第九章:运算符、表达式和语句

在本章中,将详细定义和解释 Java 编程的三个核心元素-运算符、表达式和语句。讨论将通过具体示例来支持,以说明这些元素的关键方面。

将涵盖以下主题:

  • Java 编程的核心元素是什么?

  • Java 运算符、表达式和语句

  • 运算符优先级和操作数的求值顺序

  • 原始类型的扩展和缩小转换

  • 原始类型和引用类型之间的装箱和拆箱

  • 引用类型的 equals()方法

  • 练习-命名语句

Java 编程的核心元素是什么?

在第二章中,Java 语言基础,我们概述了 Java 作为一种语言的许多方面,甚至定义了语句是什么。现在,我们将更系统地研究 Java 的核心元素。

“元素”这个词有点过载(玩弄方法重载的类比)。在第五章中,Java 语言元素和类型,我们介绍了输入元素,这些元素是由 Java 规范标识的:空格、注释和标记。这就是 Java 编译器解析源代码并理解其含义的方式。标记列表包括标识符、关键字、分隔符、文字和运算符。这就是 Java 编译器如何为其遇到的标记添加更多含义。

在讨论输入元素时,我们解释了它们用于构建语言的更复杂元素。在本章中,我们将从运算符标记开始,展示如何使用表达式-更复杂的 Java 元素来构建它。

但并非所有 Java 运算符都是标记。instanceofnew运算符是关键字,而.运算符(字段访问或方法调用)、::方法引用运算符和( type )强制转换运算符是分隔符。

正如我们在第二章中所说的,Java 语言基础,在 Java 中,语句的作用类似于英语中的句子,它表达了一个完整的思想。在编程语言中,语句是一行完整的代码,执行某些操作。

另一方面,表达式是语句的一部分,它求值为一个值。每个表达式都可以是一个语句(如果结果值被忽略),而大多数语句不包括表达式。

这就是 Java 的三个核心元素-运算符、表达式和语句的关系。

运算符

以下是 Java 中所有 44 个运算符的列表:

运算符 描述
算术一元和二元运算符
递增和递减一元运算符
相等运算符
关系运算符
逻辑运算符
条件运算符
赋值运算符
赋值运算符
按位运算符
箭头和方法引用运算符
实例创建运算符
字段访问/方法调用运算符
类型比较运算符
(目标类型)强制转换运算符

一元意味着与单个操作数一起使用,而二元意味着它需要两个操作数。

在接下来的小节中,我们将定义并演示大多数运算符,除了很少使用的赋值运算符&=|=^=<<=>>=>>>=,以及按位运算符。

另外,请注意,如果应用于整数(按位)和布尔值(逻辑),&|运算符的行为是不同的。在本书中,我们将仅讨论这些运算符作为逻辑运算符。

箭头运算符->和方法引用运算符::将在第十七章中定义和讨论,Lambda 表达式和函数式编程

算术一元(+ -)和二进制运算符:+ - * / %

理解运算符的最佳方法是看它们的实际应用。以下是我们的演示应用程序代码(其中包含在注释中捕获的结果),解释了一元运算符+-

public class Ch09DemoApp {
  public static void main(String[] args) {
    int i = 2;   //unary "+" is assumed by default
    int x = -i;  //unary "-" makes positive become negative
    System.out.println(x);   //prints: -2
    int y = -x;  //unary "-" makes negative become positive
    System.out.println(y);   //prints: 2
  }
}

以下代码演示了二进制运算符+-*/%

int z = x + y;              //binary "+" means "add"
System.out.println(z);      //prints: 0

z = x - y;                  //binary "-" means "subtract"
System.out.println(z);      //prints: -4
System.out.println(y - x);  //prints: 4

z = x * y;
System.out.println(z);      //prints: -4

z = x / y;
System.out.println(z);      //prints: -1

z = x * y;
System.out.println(z % 3);  //prints: -1
System.out.println(z % 2);  //prints: 0
System.out.println(z % 4);  //prints: 0

你可能已经猜到了,%运算符(称为模数)将左操作数除以右操作数,并返回余数。

一切看起来都很合乎逻辑和预期。但是,当我们尝试用余数除以另一个整数时,却没有得到预期的结果:

int i1 = 11;
int i2 = 3;
System.out.println(i1 / i2); //prints: 3 instead of 3.66...
System.out.println(i1 % i2); //prints remainder: 2

结果i1/i2应该大于3。它必须是3.66...或类似的值。问题是由于操作中涉及的所有数字都是整数引起的。在这种情况下,Java 假设结果也应该表示为整数,并丢弃(不四舍五入)小数部分。

现在,让我们将操作数之一声明为double类型,值为 11,并再次尝试除法:

double d1 = 11;
System.out.println(d1/i2);    //prints: 3.6666666666666665

这一次,我们得到了预期的结果,还有其他方法可以实现相同的结果:

System.out.println((float)i1 / i2);  //prints: 3.6666667
System.out.println(i1 / (double)i2); //prints: 3.6666666666666665
System.out.println(i1 * 1.0 / i2);   //prints: 3.6666666666666665
System.out.println(i1 * 1f / i2);    //prints: 3.6666667
System.out.println(i1 * 1d / i2);    //prints: 3.6666666666666665

正如你所看到的,你可以将任何操作数转换为floatdouble类型(取决于你需要的精度),或者你可以包含floatdouble类型的数字。你可能还记得第五章中所述,带有小数部分的值默认为double。或者,你可以明确选择要添加的值的类型,就像我们在前面代码的最后两行中所做的那样。

无论你做什么,只要小心两个整数相除。如果你不希望小数部分被丢弃,至少将一个操作数转换为floatdouble(稍后在Cast operator: ( target type )部分详细了解转换运算符)。然后,如果需要,你可以将结果四舍五入到任何你喜欢的精度,或者将其转换回int

int i1 = 11;
int i2 = 3;
float r = (float)i1 / i2;
System.out.println(r);                 //prints: 3.6666667
float f = Math.round(r * 100f) / 100f;
System.out.println(f);                 //prints: 3.67
int i3 = (int)f;
System.out.println(i3);                //prints: 3

Java 整数除法:如果不确定,将其中一个操作数设为doublefloat,或者简单地给其中一个添加1.0的乘数。

String的情况下,二进制运算符+表示连接,这个运算符通常被称为连接运算符:

String s1 = "Nick";
String s2 = "Samoylov";
System.out.println(s1 + " " + s2);  //prints: Nick Samoylov
String s3 = s1 + " " + s2;
System.out.println(s3);             //prints: Nick Samoylov

并且只是作为提醒,在第五章中,Java 语言元素和类型,我们演示了应用于原始类型char的算术运算使用字符的代码点-字符的数值:

char c1 = 'a';
char c2 = '$';

System.out.println(c1 + c2);       //prints: 133
System.out.println(c1/c2);         //prints: 2 
System.out.println((float)c1/c2);  //prints: 2.6944444

只有在记住符号a的代码点是 97,而符号$的代码点是 36 时,这些结果才有意义。

在大多数情况下,Java 中的算术运算都相当直观,不会引起混淆,除了两种情况:

  • 当除法的所有操作数都是整数时

  • char变量用作算术运算符的操作数时

递增和递减一元运算符:++ --

以下代码显示了++--运算符的工作原理,取决于它们的位置,变量之前(前缀)还是变量之后(后缀):

int i = 2;
System.out.println(++i);        //prints: 3
System.out.println("i=" + i);   //prints: i=3
System.out.println(--i);        //prints: 2
System.out.println("i=" + i);   //prints: i=2

System.out.println(i++);        //prints: 2
System.out.println("i=" + i);   //prints: i=3
System.out.println(i--);        //prints: 3
System.out.println("i=" + i);   //prints: i=2

如果放在前缀位置,它会在返回变量的值之前将其值减 1。但是当放在后缀位置时,它会在返回变量的值之后将其值减 1。

++x表达式在返回结果之前增加x变量的值,而x++表达式在返回结果后增加x变量的值。

习惯这需要时间。但一旦你习惯了,写++x;x++会感觉很容易,而不是x = x + 1;。在这种情况下使用前缀或后缀递增没有区别,因为它们都最终会增加x

int x = 0;
++x;
System.out.println(x);   //prints: 1
x = 0;
x++;
System.out.println(x);   //prints: 1

前缀和后缀之间的区别只有在使用返回值而不是后缀返回后变量的值时才会出现。例如,这是演示代码:

int x = 0;
int y = x++ + x++;
System.out.println(y);   //prints: 1
System.out.println(x);   //prints: 2

y的值由第一个x++返回 0 形成,然后将x增加 1。第二个x++得到 1 作为当前的x值并返回它,所以y的值变为 1。同时,第二个x++再次增加x的值 1,所以x的值变为 2。

这种功能在表达式中更有意义:

int n = 0;
int m = 5*n++;
System.out.println(m);   //prints: 0
System.out.println(n);   //prints: 1

它允许我们首先使用变量的当前值,然后将其增加 1。因此,后缀递增(递减)运算符具有增加(递减)变量值的副作用。正如我们已经提到的,这对于数组元素访问特别有益:

int k = 0;
int[] arr = {88, 5, 42};
System.out.println(arr[k++]);  //prints: 88
System.out.println(k);         //prints: 1
System.out.println(arr[k++]);  //prints: 5
System.out.println(k);         //prints: 2
System.out.println(arr[k++]);  //prints: 42
System.out.println(k);         //prints: 3

通过将k设置为-1并将++移到前面也可以实现相同的结果:

int k = -1;
int[] arr = {88, 5, 42};
System.out.println(arr[k++]);  //prints: 88
System.out.println(k);         //prints: 1
System.out.println(arr[++k]);  //prints: 5
System.out.println(k);         //prints: 2
System.out.println(arr[++k]);  //prints: 42
System.out.println(k);         //prints: 3

但是,使用k=0k++读起来更好,因此成为访问数组组件的典型方式。但是,只有在需要按索引访问数组元素时才有用。例如,如果需要从索引2开始访问数组,则需要使用索引:

int[] arr = {1,2,3,4};
int j = 2;
System.out.println(arr[j++]);  //prints: 3
System.out.println(arr[j++]);  //prints: 4

但是,如果您要按顺序访问数组,从索引 0 开始,那么有更经济的方法。请参见第十章,控制流语句

相等运算符:  ==   !=

等号运算符==(表示相等)和!=(表示不相等)比较相同类型的值,并返回Booleantrue,如果操作数的值相等,则返回false。整数和布尔原始类型的相等性很简单:

char a = 'a';
char b = 'b';
char c = 'a';
System.out.println(a == b);  //prints: false
System.out.println(a != b);  //prints: true
System.out.println(a == c);  //prints: true
System.out.println(a != c);  //prints: false

int i1 = 1;
int i2 = 2;
int i3 = 1;
System.out.println(i1 == i2);  //prints: false
System.out.println(i1 != i2);  //prints: true
System.out.println(i1 == i3);  //prints: true

System.out.println(i1 != i3);  //prints: false

boolean b1 = true;
boolean b2 = false;
boolean b3 = true;
System.out.println(b1 == b2);  //prints: false
System.out.println(b1 != b2);  //prints: true
System.out.println(b1 == b3);  //prints: true
System.out.println(b1 != b3);  //prints: false

在这段代码中,char类型与算术运算一样,被视为等于其代码点的数值。否则,很难理解以下行的结果:

System.out.println((a + 1) == b); //prints: true

但是,从以下结果可以明显看出这行的解释:

System.out.println(b - a);        //prints: 1
System.out.println((int)a);       //prints: 97
System.out.println((int)b);       //prints: 98

a的代码点是97b的代码点是98

对于基本类型floatdouble,等号运算符似乎以相同的方式工作。以下是double类型相等的示例:

double d1 = 0.42;
double d2 = 0.43;
double d3 = 0.42;
System.out.println(d1 == d2);  //prints: false
System.out.println(d1 != d2);  //prints: true
System.out.println(d1 == d3);  //prints: true
System.out.println(d1 != d3);  //prints: false

但是,这是因为我们比较的是作为文字创建的数字,带有固定小数部分。如果我们比较以下计算的结果,很有可能得到的值永远不会等于预期的结果,因为有些数字(例如1/3)无法准确表示。那么1/3的情况是什么?以小数表示,它有一个永无止境的小数部分:

System.out.println((double)1/3);    //prints: 0.3333333333333333 

这是为什么在比较floatdouble类型的值时,使用关系运算符<><==>更可靠(请参见下一小节)。

在对象引用的情况下,等号运算符比较的是引用本身,而不是对象及其值:

SomeClass c1 = new SomeClass();
SomeClass c2 = new SomeClass();
SomeClass c3 = c1;
System.out.println(c1 == c2);     //prints: false
System.out.println(c1 != c2);     //prints: true
System.out.println(c1 == c3);     //prints: true
System.out.println(c1 != c3);     //prints: false
System.out.println(new SomeClass() == new SomeClass());  //prints: false

Object equality based on the values they contain has to be performed using the equals() method. We talked about it in Chapter 2, Java Language Basics, and will discuss it more in the Method equals() of reference types section later.

Relational operators:  <  >  <=  >=

Relational operators can only be used with primitive types:

int i1 = 1;
int i2 = 2;
int i3 = 1;
System.out.println(i1 > i2);    //prints: false
System.out.println(i1 >= i2);   //prints: false
System.out.println(i1 >= i3);   //prints: true
System.out.println(i1 < i2);    //prints: true
System.out.println(i1 <= i2);   //prints: true
System.out.println(i1 <= i3);   //prints: true

System.out.println('a' >= 'b');  //prints: false
System.out.println('a' <= 'b');  //prints: true

double d1 = 1/3;
double d2 = 0.34;
double d3 = 0.33;
System.out.println(d1 < d2);  //prints: true
System.out.println(d1 >= d3); //prints: false     

In the preceding code, we see that int type values compare to each other as expected, and char type values compare to each other based on their numeric code point values.

当将原始类型char的变量用作算术、相等或关系运算符的操作数时,它们分配的数值等于它们表示的字符的代码点。

到目前为止,除了最后一行之外,没有什么意外。我们已经确定,作为小数表示的1/3应该是0.3333333333333333,这比0.33大。为什么d1 >= d3返回false?如果你说这是因为整数除法,那么你是正确的。即使赋值给double类型的变量,结果也是 0.0,因为整数除法1/3先发生,然后才将结果赋给d1。以下是演示它的代码:

double d1 = 1/3;
double d2 = 0.34;
double d3 = 0.33;
System.out.println(d1 < d2);   //prints: true
System.out.println(d1 >= d3);  //prints: false
System.out.println(d1);        //prints: 0.0
double d4 = 1/3d;
System.out.println(d4);        //prints: 0.3333333333333333
System.out.println(d4 >= d3);  //prints: true

但除此之外,使用关系运算符与等式运算符相比,使用floatdouble类型的值会产生更可预测的结果。

在比较floatdouble类型的值时,请使用关系运算符<><==>,而不是等式运算符==!=

就像在实验物理学中一样,在比较floatdouble类型的值时,请考虑精度。

Logical operators:  !  &  |

首先让我们定义每个逻辑运算符:

  • 一元运算符!如果操作数为false则返回true,否则返回false

  • 二进制运算符&如果两个操作数都为true,则返回true

  • 二进制运算符|如果两个操作数中至少有一个为true,则返回true

以下是演示代码:

boolean x = false;
System.out.println(!x);  //prints: true
System.out.println(!!x); //prints: false
boolean y = !x;
System.out.println(y & x); //prints: false
System.out.println(y | x); //prints: true
boolean z = true;
System.out.println(y & z); //prints: true
System.out.println(y | z); //prints: true

注意!运算符可以多次应用于同一个值。

条件运算符:  &&   ||    ? : (三元)

我们可以重用先前的代码示例,但使用&&||运算符,而不是&|运算符:

boolean x = false;
boolean y = !x;
System.out.println(y && x); //prints: false
System.out.println(y || x); //prints: true
boolean z = true;
System.out.println(y && z); //prints: true
System.out.println(y || z); //prints: true

结果并没有不同,但执行上有区别。运算符&|总是检查两个操作数的值。与此同时,在&&的情况下,如果左操作数返回false&&运算符会在不评估右操作数的情况下返回false。而在||的情况下,如果左操作数返回true||运算符会在不评估右操作数的情况下返回true。以下是演示这种差异的代码:

int i = 1, j = 3, k = 10;
System.out.println(i > j & i++ < k);  //prints: false
System.out.println("i=" + i);         //prints: i=2
System.out.println(i > j && i++ < k); //prints: false
System.out.println("i=" + i);         //prints: i=2

&&&两个运算符都返回false。但是在&&的情况下,第二个操作数i++ < k不会被检查,变量i的值也不会改变。如果第二个操作数需要花费时间来评估,这样的优化可以节省时间。

&&||运算符在&&的情况下,如果左操作数返回false,则不评估右操作数;在||的情况下,如果左操作数返回true,则不评估右操作数。

然而,&运算符在需要始终检查第二个操作数时是有用的。例如,第二个操作数可能是一个可能抛出异常并在某些罕见条件下改变逻辑流程的方法。

第三个条件运算符称为三元运算符。它的工作原理如下:

int n = 1, m = 2;
System.out.println(n > m ? "n > m" : "n <= m"); //prints: n <= m
System.out.println(n > m ? true : false);       //prints: false
int max = n > m ? n : m;      
System.out.println(max);                        //prints: 2

它评估条件,如果条件为真,则返回第一个条目(问号后面的内容,?);否则,返回第二个条目(冒号后面的内容,:)。这是一种非常方便和紧凑的方式,可以选择两个选项,而不是使用完整的if-else语句结构:

String result;
if(n > m){
  result = "n > m";
} else {
  result = "n <= m";
} 

我们将在第十章中讨论这样的语句(称为条件语句),控制流语句

赋值运算符(最受欢迎的): = += -= *= /= %=

尽管我们不是第一次讨论它们,但这些是最常用的运算符,特别是=简单赋值运算符,它只是将一个值赋给一个变量(也可以说是给变量赋值)。我们已经多次看到了简单赋值的用法示例。

在使用简单赋值时唯一可能的注意事项是,当左侧的变量类型与右侧的值或变量类型不同时。类型的差异可能导致原始类型的值变窄变宽,或者在一个类型是原始类型而另一个类型是引用类型时导致装箱拆箱。我们将在稍后的原始类型的扩宽和变窄转换原始类型和引用类型之间的装箱和拆箱部分讨论这样的赋值。

其余的赋值运算符(+= -= *= /= %=)称为复合赋值运算符:

  • x += 2; 分配这个加法的结果:x = x + 2;

  • x -= 2; 分配这个减法的结果:x = x - 2;

  • x *= 2; 分配这个乘法的结果:x = x * 2;

  • x /= 2; 分配这个除法的结果:x = x / 2;

  • x %= 2; 分配这个除法的余数:x = x + x % 2;

操作x = x + x % 2;是基于运算符优先级规则的,我们将在稍后的运算符优先级和操作数的评估顺序部分讨论这些规则。根据这些规则,%运算符(取模)首先执行,然后是+运算符(加法),然后将结果分配给左操作数变量x。这是演示代码:

int x = 1;
x += 2;
System.out.println(x);    //prints: 3
x -= 1;
System.out.println(x);    //prints: 2
x *= 2;
System.out.println(x);    //prints: 4
x /= 2;
System.out.println(x);    //prints: 2
x %= 2;
System.out.println(x);    //prints: 0

再次,每当遇到整数除法时,最好将其转换为floatdouble除法,然后根据需要四舍五入或将其转换为整数。在我们的例子中,我们没有任何小数部分的损失。但是,如果我们不知道x的值,代码可能如下所示:

x = 11;
double y = x;
y /= 3;          //That's the operation we wanted to do on x

System.out.println(y);        //prints: 3.6666666666666665
x = (int)y;
System.out.println(x);        //prints: 3

//or, if we need to round up the result:
double d = Math.round(y);     //prints: 4.0
System.out.println(d);
x = (int) d;
System.out.println(x);        //prints: 4

在这段代码中,我们假设我们不知道x的值,所以我们切换到double类型以避免失去小数部分。计算结果后,我们要么将其转换为int(小数部分丢失),要么四舍五入到最接近的整数。

在这个简单的除法中,我们可能会失去小数部分并得到3,即使不转换为double类型。但在现实生活中的计算中,公式通常不会那么简单,所以人们可能永远不知道整数除法可能发生的确切位置。这就是为什么在开始计算之前最好将值转换为floatdouble的良好做法。

实例创建运算符:new

到目前为止,我们已经看到new运算符被使用了很多次。它通过为新对象分配内存并返回对该内存的引用来实例化(创建)一个类。然后,这个引用通常被分配给与用于创建对象的类相同类型的变量,或者它的父类型,尽管我们也看到过一个情况,即引用从未被分配。在第六章中,接口、类和对象构造,例如,我们使用这段代码来演示构造函数是如何被调用的:

new Child();
new Child("The Blows");

但这种情况非常罕见,大多数时候我们需要一个对新创建的对象的引用,以便调用它的方法:

SomeClass obj = new SomeClass();
obj.someMethod();

在调用new运算符并分配内存后,相应的(显式或默认)构造函数初始化新对象的状态。我们在第六章中对此进行了广泛讨论,接口、类和对象构造

由于数组也是对象,因此也可以使用new运算符和任何 Java 类型来创建数组:

int[] arrInt = new int[42];

[]符号允许我们设置数组长度(最大组件数,也称为元素)-在前面的代码中是42。可能会产生混淆的一个潜在来源是,在编译时,Java 允许将值分配给大于数组长度的索引的组件:

int[] arrInt = new int[42];
arrInt[43] = 22;

但当程序运行时,行arrInt[43] = 22将抛出异常:

也可以使用数组初始化程序而不使用new运算符来创建数组:

int[] arrInt = {1,2,3,4};

只能使用new运算符创建类实例。数组可以使用new运算符或{}初始化程序创建。

我们在第五章中对此进行了广泛讨论,Java 语言元素和类型。如果没有明确初始化,则数组的值将设置为取决于类型的默认值(我们在第五章中也描述了它们,Java 语言元素和类型)。以下是一个代码示例:

int[] arrInt = new int[42];
//arrInt[43] = 22;
System.out.println(arrInt[2]);      //prints: 0
System.out.println(arrInt.length);  //prints: 42
int[] arrInit = {1,2,3,4};
System.out.println(arrInit[2]);      //prints: 3
System.out.println(arrInit.length);  //prints: 4

而且,只是为了提醒你,数组的第一个元素的索引是 0。

类型比较运算符:  instanceof

instanceof运算符需要两个引用类型的操作数。这是因为它检查对象的父子关系,包括接口的实现。如果左操作数(对象引用)扩展或实现右侧的类型,则求值为true,否则为false。显然,每个引用instanceof Object都返回true,因为在 Java 中,每个类都隐式继承了Object类。当instanceof应用于任何类型的数组时,它仅对右操作数Object返回true。而且,由于null不是任何类型的实例,所以null instanceof对于任何类型都返回false。以下是演示代码:

interface IntrfA{}
class ClassA implements IntrfA {}
class ClassB extends ClassA {}
class ClassX implements IntrfA {}

private void instanceofOperator() {
  ClassA classA = new ClassA();
  ClassB classB = new ClassB();
  ClassX classX = new ClassX();
  int[] arrI = {1,2,3};
  ClassA[] arrA = {new ClassA(), new ClassA()};

  System.out.println(classA instanceof Object); //prints: true
  System.out.println(arrI instanceof Object);   //prints: true
  System.out.println(arrA instanceof Object);   //prints: true
//System.out.println(arrA instanceof ClassA);   //error

  System.out.println(classA instanceof IntrfA); //prints: true
  System.out.println(classB instanceof IntrfA); //prints: true
  System.out.println(classX instanceof IntrfA); //prints: true

  System.out.println(classA instanceof ClassA); //prints: true
  System.out.println(classB instanceof ClassA); //prints: true
  System.out.println(classA instanceof ClassB); //prints: false
//System.out.println(classX instanceof ClassA); //error

  System.out.println(null instanceof ClassA);   //prints: false
//System.out.println(classA instanceof null);   //error
  System.out.println(classA == null);           //prints: false
  System.out.println(classA != null);           //prints: true
}

大多数结果都是直接的,可能是预期的。唯一可能预期的是classX instanceof ClassAClassXClassA都实现了相同的接口IntrfA,所以它们之间有一些亲和力-每个都可以转换为IntrfA接口:

IntrfA intA = (IntrfA)classA;
intA = (IntrfA)classX;

但是这种关系不是父子类型的,所以instanceof运算符甚至不能应用于它们。

instanceof运算符允许我们检查类实例(对象)是否具有某个类作为父类或实现了某个接口。

我们看到了classA instanceof null的类似问题,因为null根本不引用任何对象,尽管null是引用类型的文字。

在前面代码的最后两个语句中,我们展示了如何将对象引用与null进行比较。在调用引用之前,通常会使用此类比较,以确保引用不是null。它有助于避免令人恐惧的NullPointerException,它会中断执行流程。我们将在第十章中更多地讨论异常,控制流语句

更喜欢多态而不是 instanceof 运算符

instance of 运算符 非常有帮助。我们在本书中多次使用它。但是,有些情况可能需要我们重新考虑使用它的决定。

每次你考虑使用instanceof运算符时,试着看看是否可以通过多态来避免它。

为了说明这个提示,这里有一些代码可以从多态中受益,而不是使用intanceof运算符:

class ClassBase {
}
class ClassY extends ClassBase {
  void method(){

    System.out.println("ClassY.method() is called");
  }
}
class ClassZ extends ClassBase {
  void method(){
    System.out.println("ClassZ.method() is called");
  }
}
class SomeClass{
  public void doSomething(ClassBase object) {
    if(object instanceof ClassY){
      ((ClassY)object).method();
    } else if(object instanceof ClassZ){
      ((ClassZ)object).method();
    }
    //other code 
  }
}

如果我们运行以下代码片段:

SomeClass cl = new SomeClass();
cl.doSomething(new ClassY());

我们将看到这个:

然后,我们注意到ClassYClassZ中的方法具有相同的签名,因此我们可以将相同的方法添加到基类ClassBase中:

class ClassBase {
  void method(){
    System.out.println("ClassBase.method() is called");
  }
}

并简化SomeClass的实现:

class SomeClass{
  public void doSomething(ClassBase object) {
    object.method();
    //other code 
  }

在调用new SomeClass().doSomething(new ClassY())之后,我们仍然会得到相同的结果:

这是因为method()在子类中被重写。在ClassBase中实现的方法可以做一些事情或什么都不做。这并不重要,因为它永远不会被执行(除非你使用super关键字从子类中将其强制转换来特别调用它)。

并且在重写时,不要忘记使用@Override注解:

class ClassZ extends ClassBase {
  @Override
  void method(){
    System.out.println("ClassY.method() is called");
  }
}

注解将帮助您验证您没有错误,并且每个子类中的方法与父类中的方法具有相同的签名。

字段访问或方法调用运算符:  .

在类或接口内部,可以通过名称访问该类或接口的字段或方法。但是从类或接口外部,非私有字段或方法可以使用点(.)运算符访问和:

  • 如果字段或方法是非静态的(实例成员),则对象名称

  • 如果字段或方法是静态的,则接口或类名

点运算符(.)可以用于访问非私有字段或方法。如果字段或方法是静态的,则点运算符应用于接口或类名。如果字段或方法是非静态的,则点运算符应用于对象引用。

我们已经看到了许多这样的例子。因此,我们将所有情况总结在一个接口和实现它的类中。假设我们有以下名为InterfaceM的接口:

interface InterfaceM {
  String INTERFACE_FIELD = "interface field";
  static void staticMethod1(){
    System.out.println("interface static method 1");
  }
  static void staticMethod2(){
    System.out.println("interface static method 2");
  }
  default void method1(){
    System.out.println("interface default method 1");
  }
  default void method2(){
    System.out.println("interface default method 2");
  }
  void method3();
}

我们可以使用点运算符(.)来访问非私有字段或方法,如下所示:

System.out.println(InterfaceM.INTERFACE_FIELD);    //1: interface field
InterfaceM.staticMethod1();               //2: interface static method
InterfaceM.staticMethod2();               //3: interface static method
//InterfaceM.method1();                         //4: compilation error
//InterfaceM.method2();                         //5: compilation error
//InterfaceM.method3();                         //6: compilation error

System.out.println(ClassM.INTERFACE_FIELD);       //7: interface field

案例 1、2 和 3 很简单。案例 4、5 和 6 会生成编译错误,因为非静态方法只能通过实现接口的类的实例(对象)访问。案例 7 是可能的,但不是访问接口字段(也称为常量)的推荐方式。使用接口名称访问它们(如案例 1 中)使代码更易于理解。

现在让我们创建一个实现InterfaceM接口的ClassM类:

class ClassM implements InterfaceM {
  public static String CLASS_STATIC_FIELD = "class static field";
  public static void staticMethod2(){
    System.out.println("class static method 2");
  }
  public static void staticMethod3(){
    System.out.println("class static method 3");
  }
  public String instanceField = "instance field";
  public void method2(){
    System.out.println("class instance method 2");
  }
  public void method3(){
      System.out.println("class instance method 3");
    }
}

以下是使用点运算符(`。)访问类字段和方法的所有可能情况:

  //ClassM.staticMethod1();                       //8: compilation error
  ClassM.staticMethod2();                     //9: class static method 2
  ClassM.staticMethod3();                    //10: class static method 3

  ClassM classM = new ClassM();
  System.out.println(ClassM.CLASS_STATIC_FIELD);//11: class static field
  System.out.println(classM.CLASS_STATIC_FIELD);//12: class static field
  //System.out.println(ClassM.instanceField);    //13: compilation error
  System.out.println(classM.instanceField);         //14: instance field
  //classM.staticMethod1();                      //15: compilation error
  classM.staticMethod2();                   //16: class static method  2
  classM.staticMethod3();                    //17: class static method 3
  classM.method1();                     //18: interface default method 1
  classM.method2();                        //19: class instance method 2
  classM.method3();                        //20: class instance method 3
}

案例 8 会生成编译错误,因为静态方法属于实现它的类或接口(在这种情况下)。

案例 9 是静态方法隐藏的一个例子。接口中实现了具有相同签名的方法,但被类实现隐藏了。

案例 10 和 11 很简单。

案例 12 是可能的,但不建议。使用类名访问静态类字段使代码更易于理解。

案例 13 显然是一个错误,因为只能通过实例(对象)访问实例字段。

案例 14 是案例 13 的正确版本。

类 15 是一个错误,因为静态方法属于实现它的类或接口(在这种情况下),而不是类实例。

案例 16 和 17 是可能的,但不是访问静态方法的推荐方式。使用类名(而不是实例标识符)访问静态方法使代码更易于理解。

案例 18 演示了接口如何为类提供默认实现。这是可能的,因为ClassM implements InterfaceM有效地继承了接口的所有方法和字段。我们说有效地是因为在法律上正确的术语是类implements接口。但事实上,实现接口的类以与子类继承它们相同的方式获得接口的所有字段和方法。

案例 19 是类覆盖接口默认实现的一个例子。

案例 20 是经典接口实现的一个例子。这是接口的最初想法:提供 API 的抽象。

强制转换运算符:(目标类型)

强制转换运算符用于类型转换,将一个类型的值分配给另一个类型的变量。通常,它用于启用编译器否则不允许的转换。例如,我们在讨论整数除法、char类型作为数值类型以及将类引用分配给一个已实现接口类型的变量时,我们使用了类型转换:

int i1 = 11;
int i2 = 3;
System.out.println((float)i1 / i2);  //prints: 3.6666667

System.out.println((int)a);          //prints: 97

IntrfA intA = (IntrfA)classA;

在进行强制转换时,有两个潜在的问题需要注意:

  • 对于原始类型,值应该小于目标类型可以容纳的最大值(我们将在原始类型的扩展和缩小转换部分中详细讨论这一点)

  • 对于引用类型,左操作数应该是右操作数的父类(即使是间接的),或者左操作数应该是右操作数所代表的类实现的接口(即使是间接的):

interface I1{}
interface I2{}
interface I3{}
class A implements I1, I2 {}
class B extends A implements I3{}
class C extends B {}
class D {}
public static void main(String[] args) {
   C c = new C();    //1
   A a = (A)c;       //2
   I1 i1 = (I1)c;    //3
   I2 i2 = (I2)c;    //4
   I3 i3 = (I3)c;    //5
   c = (C)a;         //6
   D d = new D();    //7
   //a = (A)d;       //8 compilation error
   i1 = (I1)d;       //9 run-time error
}

在这段代码中,第 6 种情况是可能的,因为我们知道对象a最初是基于对象c进行转换的,所以我们可以将其转换回类型C并期望它能够完全作为类C的对象正常运行。

第 8 种情况不会编译,因为其父子关系可以由编译器验证。

对于第 9 种情况,由于超出了本书范围的原因,编译器并不容易。因此,在编写代码时,IDE 不会给出提示,你可能认为一切都会按照你的期望工作。但是在运行时,你可能会得到ClassCastException

程序员们看到这种情况就像看到NullPointerExceptionArrayOutOfBoundException一样高兴。这就是为什么与类的强制转换相比,对接口的强制转换必须更加小心。

类型转换是将一个类型的值分配给另一个类型的变量。在执行此操作时,请确保目标类型可以容纳该值,并在必要时检查其是否超过最大目标类型值。

也可以将原始类型转换为匹配的引用类型:

Integer integer1 = 3;                  //line 1 
System.out.println(integer1);          //prints: 3
Integer integer2 = Integer.valueOf(4); 
int i = integer2;                      //line 4
System.out.println(i);                 //prints: 4

在第 1 行和第 4 行,强制转换是隐式进行的。我们将在原始类型和引用类型之间的装箱和拆箱部分中更详细地讨论这种转换(也称为转换或装箱和拆箱)。

表达式

正如我们在本节开头所说的,表达式只存在于语句的一部分,后者是完整的动作(我们将在下一小节中讨论)。这意味着表达式可以是一个动作的构建块。一些表达式甚至可以在添加分号后成为一个完整的动作(表达式语句)。

表达式的区别特征在于它可以被评估,这意味着它可以产生执行结果。这个结果可以是三种之一:

  • 一个变量,比如i = 2

  • 一个值,比如2*2

  • 当表达式是返回空(void)的方法的调用时,什么都没有。这样的表达式只能是完整的动作——带有分号的表达式语句。

表达式通常包括一个或多个运算符并进行求值。它可以产生一个变量,一个值(包含在进一步的求值中),或者可以调用一个返回空(void)的方法。

表达式的求值也可能产生副作用。也就是说,除了变量赋值或返回值之外,它还可以执行其他操作,例如:

int x = 0, y;
y = x++;                  //line 2
System.out.println(y);    //prints: 0
System.out.println(x);    //prints: 1

第 2 行的表达式给变量y赋值,但也具有将1添加到变量x的值的副作用。

根据其形式,表达式可以是:

  • 主表达式:

  • 字面量(某个值)

  • 对象创建(使用new运算符或{}数组初始化器)

  • 字段访问(使用外部类的点运算符或者不使用该运算符来访问此实例)

  • 方法调用(使用外部类的点运算符或者不使用该运算符来调用此实例)

  • 方法引用(在 lambda 表达式中使用::运算符)

  • 数组访问(使用[]符号,其中包含要访问的元素的索引)

  • 一元运算符表达式(x++-y,例如)

  • 二元运算符表达式(x+yx*y,例如)

  • 三元运算符表达式(例如x > y ? "x>y" : "x<=y"

  • 一个 lambda 表达式 i -> i + 1(见第十七章,Lambda 表达式和函数式编程

这些表达式根据它们产生的动作命名:对象创建表达式、强制类型转换表达式、方法调用表达式、数组访问表达式、赋值表达式等等。

由其他表达式组成的表达式称为复杂表达式。通常使用括号来清楚地标识每个子表达式,而不是依赖于运算符优先级(参见稍后的运算符优先级和操作数的求值顺序部分)。

语句

我们实际上在第二章,Java 语言基础中定义了一条语句。它是一个可以执行的完整动作。它可以包括一个或多个表达式,并以分号;结束。

Java 语句描述一个动作。它是一个可以执行的最小结构。它可能包括一个或多个表达式,也可能不包括。

Java 语句的可能种类有:

  • 一个类或接口声明语句,比如class A {...}

  • 只包含一个符号的空语句,;

  • 局部变量声明语句,int x;

  • 同步语句-超出本书范围

  • 表达式语句,可以是以下之一:

  • 方法调用语句,比如method();

  • 赋值语句,比如x = 3;

  • 对象创建语句,比如new SomeClass();

  • 一个一元递增或递减语句,比如++x ; --x; x++; x--;

  • 控制流语句(见第十章,控制流语句):

  • 选择语句:if-elseswitch-case

  • 迭代语句:forwhiledo-while

  • 异常处理语句,比如try-catch-finallythrow

  • 分支语句,比如breakcontinuelabel:returnassert

通过在语句前面放置标识符和冒号:标记语句。这个标签可以被分支语句breakcontinue使用来重定向控制流。在第十章,控制流语句中,我们将向您展示如何做到这一点。

通常,语句组成一个方法体,这就是程序的编写方式。

运算符优先级和操作数的求值顺序

当在同一个表达式中使用多个运算符时,如果没有已建立的规则,可能不明显如何执行它们。例如,在评估以下右侧表达式后,将分配给变量x的值是什么:

int x = 2 + 4 * 5 / 6 + 3 + 7 / 3 * 11 - 4;

我们知道如何做,因为我们在学校学习了运算符优先级-从左到右首先应用乘法和除法运算符,然后从左到右进行加法和减法。但是,事实证明作者实际上想要这个运算符执行顺序:

int x = 2 + 4 * 5 / 6 + ( 3 + 7 / 3 * (11 - 4));

这将产生不同的结果。

运算符优先级和括号决定了表达式的各部分的计算顺序。操作数的评估顺序为每个操作定义了其操作数的计算顺序。

括号有助于识别复杂表达式的结构并建立评估顺序,这将覆盖运算符优先级。

运算符优先级

Java 规范没有在一个地方提供运算符优先级。必须从各个部分整理出来。这就是为什么互联网上的不同来源有时对运算符执行顺序有点不同,所以不要感到惊讶,如果有疑问,可以进行实验或者只需设置括号以指导所需的计算顺序。

以下列表显示了从最高(第一个执行)到最低优先级(最后)的运算符优先级。具有相同优先级的运算符按其在表达式中的位置从左到右执行(如果没有使用括号):

  • 计算数组元素的索引的表达式,如x = 4* arr[i+1];字段访问和方法调用点运算符.,如x = 3*someClass.COUNTx = 2*someClass.method(2, "b")

  • 一元后缀递增++和递减--运算符,如x++x--,如int m = 5*n++; 请注意,这种运算符返回变量在递增/递减其值之前的旧值,因此具有递增值的副作用

  • 一元前缀与++--运算符,如++x--x;一元+-运算符,如+x-x;逻辑运算符 NOT,如!b,其中 b 是布尔变量;一元位 NOT ~(超出本书范围)

  • 转换运算符(),如double x = (double)11/3,其中 11 首先转换为double,从而避免了整数除法丢失小数部分的问题;实例创建运算符new,如new SomeClass()

  • 乘法运算符*, /, %

  • 加法运算符+, -, 字符串连接+

  • 位移运算符<<, >>, >>>;

  • 关系运算符<, >, >=, <=, instanceof

  • 相等运算符==, !=

  • 逻辑和位运算符&

  • 位运算符^

  • 逻辑和位运算符|

  • 条件运算符&&

  • 条件运算符||

  • 条件运算符?:(三元)

  • 箭头运算符->

  • 赋值运算符=, +=, -=, *=, /=, %=, >>=, <<=, >>>=, &=, ^=, |=

如果存在括号,则首先计算最内层括号内的表达式。例如,看一下这段代码片段:

int p1 = 10, p2 = 1;
int q = (p1 += 3)  +  (p2 += 3);
System.out.println(q);         //prints: 17
System.out.println(p1);        //prints: 13
System.out.println(p2);        //prints: 4

赋值运算符的优先级最低,但如果在括号内,它们将首先执行,如前面的代码。为了证明这一点,我们可以删除第一组括号,然后再次运行相同的代码:

p1 = 10;
p2 = 1;
q = p1 += 3  +  (p2 += 3);
System.out.println(q);         //prints: 17
System.out.println(p1);        //prints: 17
System.out.println(p2);        //prints: 4

正如你所看到的,现在第一个操作符赋值+=在右侧表达式中最后执行。

使用括号可以增加复杂表达式的可读性。

您可以利用运算符优先级并编写一个表达式,其中几乎没有括号,如果有的话。但是,代码的质量不仅取决于其正确性。易于理解,以便其他程序员(也许不太熟悉运算符优先级)可以维护它也是良好编写代码的标准之一。此外,即使是代码的作者,在一段时间后,也可能难以理解结构不清晰的表达式。

操作数的评估顺序

在评估表达式时,首先考虑括号和运算符优先级。然后,评估具有相同执行优先级的表达式部分,因为它们在从左到右移动时出现。

使用括号可以改善对复杂表达式的理解,但太多嵌套的括号可能会使其变得模糊。如果有疑问,考虑将复杂表达式分解为几个语句。

最终,评估归结为每个运算符及其操作数。二元运算符的操作数从左到右进行评估,以便在右操作数的评估开始之前完全评估左操作数。正如我们所见,左操作数可能具有影响右操作数行为的副作用。这里是一个简单的例子:

int a = 0, b = 0;
int c = a++ + (a * ++b);       //evaluates to: 0 + (1 * 1);
System.out.println(c);         //prints: 1

在现实生活中的例子中,表达式可以包括具有复杂功能和广泛副作用的方法。左操作数甚至可以抛出异常,因此右操作数永远不会被评估。但是,如果左操作数的评估在没有异常的情况下完成,Java 保证在执行运算符之前会完全评估两个操作数。

这个规则不适用于条件运算符&&||?:(参见条件运算符:&& || ? : (三元)部分)。

扩展和缩小引用类型

在引用类型的情况下,将子对象引用分配给父类类型的变量称为扩展引用转换或向上转换。将父类类型引用分配给子类类型的变量称为缩小引用转换或向下转换。

扩展

例如,如果一个类SomeClass扩展了SomeBaseClass,则以下声明和初始化也是可能的:

SomeBaseClass someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();

而且,由于每个类默认都扩展了java.lang.Object类,因此以下声明和初始化也是可能的:

Object someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();             //line 2

在第 2 行,我们将子类实例引用分配给了超类类型的变量。子类中存在但在超类中不存在的方法无法通过超类类型的引用访问。第 2 行的赋值被称为引用的扩展,因为它变得不太专业化。

缩小

将父对象引用分配给子类类型的变量称为缩小引用转换或向下转换。只有在应用了扩展引用转换之后才可能发生。

下面是一个演示情况的代码示例:

class SomeBaseClass{
  void someMethod(){
    ...
  }
} 
class SomeClass extends SomeBaseClass{
  void someOtherMethod(){
    ...
  }
}
SomeBaseClass someBaseClass = new SomeBaseClass();
someBaseClass = new SomeClass();
someBaseClass.someMethod();                  //works just fine
//someBaseClass.someOtherMethod();           //compilation error
((SomeClass)someBaseClass).someOtherMethod(); //works just fine
//The following methods are available as they come from Object:
int h = someBaseClass.hashCode();
Object o = someBaseClass.clone();
//All other public Object's methods are accessible too

缩小转换需要转换,当我们讨论转换运算符时,我们已经详细讨论过这一点(参见转换运算符部分),包括转换为接口,这是另一种向上转换的形式。

原始类型的扩展和缩小转换

当一个数值类型的值(或变量)被赋给另一个数值类型的变量时,新类型可能包含一个更大的数字或更小的最大数字。如果目标类型可以容纳更大的数字,则转换是扩展的。否则,它是一个缩小的转换,通常需要使用转换运算符进行类型转换。

扩展

数值类型可以容纳的最大数字由分配给该类型的位数确定。为了提醒您,这里是每种数值类型表示的位数:

  • byte:8 位

  • char:16 位

  • short:16 位

  • int:32 位

  • long:64 位

  • float:32 位

  • double:64 位

Java 规范定义了 19 种扩展原始转换:

  • byteshortintlongfloat,或 double

  • shortintlongfloat,或 double

  • charintlongfloat,或 double

  • intlongfloat,或 double

  • longfloatdouble

  • floatdouble

在整数类型之间的扩展转换和一些整数类型到浮点值的一些转换中,结果值保持与原始值相同。但是,从 intfloat,或从 longfloat,或从 longdouble,根据规范可能会导致:

“在精度损失方面 - 也就是说,结果可能会丢失一些值的最低有效位。在这种情况下,得到的浮点值将是整数值的正确舍入版本,使用 IEEE 754 最接近模式。”

让我们通过代码示例来看一下这种效果,首先从 int 类型转换到 floatdouble 开始:

int n = 1234567899;
float f = (float)n;
int r = n - (int)f;
System.out.println(r);    //prints: -46

double d = (double)n;
r = n - (int)d;
System.out.println(r);    //prints: 0

正如规范所述,只有从 intfloat 的转换丢失了精度。从 intdouble 的转换很好。现在,让我们转换 long 类型:

long l = 1234567899123456L;
float f = (float)l;
long rl = l - (long)f;
System.out.println(rl);    //prints: -49017088

double d = (double)l;
rl = l - (long)d;
System.out.println(rl);    //prints: 0

l = 12345678991234567L;
d = (double)l;
rl = l - (long)d;
System.out.println(rl);    //prints: -1

longfloat 的转换严重丢失了精度。规范警告了我们。但是从 longdouble 的转换一开始看起来很好。然后,我们将 long 值增加了大约十倍,得到了 -1 的精度损失。所以,这也取决于值有多大。

尽管如此,Java 规范不允许由扩展转换引起的任何运行时异常。在我们的例子中,我们也没有遇到异常。

缩小

数值原始类型的缩小转换是相反的,从更宽的类型到更窄的类型,通常需要转换。Java 规范确定了 22 种缩小的原始转换:

  • shortbytechar

  • charbyteshort

  • intbyteshort,或 char

  • longbyteshortchar,或 int

  • floatbyteshortcharint,或 long

  • doublebyteshortcharintlong,或 float

它可能导致值的大小和可能导致精度的损失。缩小过程比扩展过程更复杂,讨论它超出了入门课程的范围。至少可以做的是确保原始值小于目标类型的最大值:

double dd = 1234567890.0;
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
if(dd < Integer.MAX_VALUE){
  int nn = (int)dd;
  System.out.println(nn);              //prints: 1234567890
} else {
  System.out.println(dd - Integer.MAX_VALUE);
}

dd = 2234567890.0;
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
if(dd < Integer.MAX_VALUE){
  int nn = (int)dd;
  System.out.println(nn);            
} else {
  System.out.println(dd - Integer.MAX_VALUE); //prints: 8.7084243E7
}

从这些示例中可以看出,当数字适合目标类型时,缩小转换就可以很好地进行,但是如果原始值大于目标类型的最大值,我们甚至不会尝试进行转换。

在进行强制转换之前,考虑一下目标类型可以容纳的最大值,特别是在缩小值类型时。

但是,避免完全丢失值并不是全部。在char类型和byteshort类型之间的转换中,事情变得特别复杂。其原因在于char类型是无符号数值类型,而byteshort类型是有符号数值类型,因此可能会丢失一些信息。

原始类型转换的方法

强制转换并不是将一个原始类型转换为另一个类型的唯一方法。每种原始类型都有一个对应的引用类型 - 称为原始类型的包装类的类。

所有包装类都位于java.lang包中:

  • java.lang.Boolean

  • java.lang.Byte

  • java.lang.Character

  • java.lang.Short

  • java.lang.Integer

  • java.lang.Long

  • java.lang.Float

  • java.lang.Double

它们中的大多数(除了BooleanCharacter类)都扩展了java.lang.Number类,该类具有以下抽象方法声明:

  • byteValue()

  • shortValue()

  • intValue()

  • longValue()

  • floatValue()

  • doubleValue()

这意味着每个Number类子类都必须实现所有这些方法。这些方法也在Character类中实现,而Boolean类具有booleanValue()方法。这些方法也可以用于扩大和缩小原始类型。

此外,每个包装类都有方法,允许将数值的String表示转换为相应的原始数值类型或引用类型,例如:

byte b = Byte.parseByte("3");
Byte bt = Byte.decode("3");
boolean boo = Boolean.getBoolean("true");
Boolean bool = Boolean.valueOf("false");
int n = Integer.parseInt("42");
Integer integer = Integer.getInteger("42");
double d1 = Double.parseDouble("3.14");
Double d2 = Double.valueOf("3.14");

之后,可以使用先前列出的方法(byteValue()shortValue()等)将值转换为另一种原始类型。

每个包装类都有静态方法toString(原始值),将原始类型值转换为其String表示:

String s1 = Integer.toString(42);
String s2 = Double.toString(3.14);

包装类有许多其他有用的方法,可以将一种原始类型转换为另一种原始类型,以及不同的格式和表示形式。因此,如果需要这样的功能,请首先查看java.lang包中的其数值类型类包装器。

其中一种类型转换允许从相应的原始类型创建包装类对象,反之亦然。我们将在下一节讨论这样的转换。

原始类型和引用类型之间的装箱和拆箱

装箱将原始类型的值转换为相应包装类的对象。拆箱将包装类的对象转换为相应原始类型的值。

装箱

装箱原始类型可以通过自动方式(称为自动装箱)或显式地使用每个包装类型中可用的valueOf()方法来完成:

int n = 12;
Integer integer = n; //an example of autoboxing
System.out.println(integer);      //prints: 12
integer = Integer.valueOf(n);
System.out.println(integer);      //prints: 12

Byte b = Byte.valueOf((byte)n);
Short s = Short.valueOf((short)n);
Long l = Long.valueOf(n);
Float f = Float.valueOf(n);
Double d = Double.valueOf(n);

请注意,ByteShort包装器的valueOf()方法的输入值需要转换,因为它是原始类型的缩小,这是我们在上一节中讨论的。

拆箱

拆箱可以使用每个包装类中实现的Number类的方法来完成:

Integer integer = Integer.valueOf(12);
System.out.println(integer.intValue());    //prints: 12
System.out.println(integer.byteValue());   //prints: 12
System.out.println(integer.shortValue());  //prints: 12
System.out.println(integer.longValue());   //prints: 12
System.out.println(integer.floatValue());  //prints: 12.0
System.out.println(integer.doubleValue()); //prints: 12.0

类似于自动装箱,也可以自动拆箱:

Long longWrapper = Long.valueOf(12L);
long lng = longWrapper;    //implicit unboxing
System.out.println(lng);   //prints: 12

但是,它不被称为自动装箱。而是使用隐式拆箱这个术语。

引用类型的 equals()方法

当应用于引用类型时,等式运算符比较引用值,而不是对象的内容。只有当两个引用(变量值)指向同一个对象时,它才返回true。我们已经多次证明了这一点:

SomeClass o1 = new SomeClass();
SomeClass o2 = new SomeClass();
System.out.println(o1 == o2);  //prints: false
System.out.println(o1 == o1);  //prints: true
o2 = o1;
System.out.println(o1 == o2);  //prints: true

这意味着即使比较具有相同字段值的相同类的两个对象时,等式运算符也会返回false。这通常不是程序员所需要的。相反,我们通常需要在两个对象具有相同类型和相同字段值时将它们视为相等。有时,我们甚至不想考虑所有字段,而只想考虑那些在程序逻辑中唯一标识对象的字段。例如,如果一个人改变了发型或服装,我们仍然认为他或她是同一个人,即使描述该人的对象具有字段hairstyledress

使用基类 Object 的实现

对于这种对象的比较-按照它们的字段值-应使用equals()方法。在第二章中,Java 语言基础,我们已经确定所有引用类型都扩展(隐式)java.lang.Object类,该类已实现了equals()方法:

public boolean equals(Object obj) {
  return (this == obj);
}

正如你所看到的,它只使用相等运算符比较引用,这意味着如果一个类或其父类没有实现equals()方法(覆盖Object类的实现),使用equals()方法的结果将与使用相等运算符==相同。让我们来演示一下。以下类没有实现equals()方法:

class PersonNoEquals {
  private int age;
  private String name;

  public PersonNoEquals(int age, String name) {
    this.age = age;
    this.name = name;
  }
}

如果我们使用它并比较equals()方法和==运算符的结果,我们将看到以下结果:

PersonNoEquals p1 = new PersonNoEquals(42, "Nick");
PersonNoEquals p2 = new PersonNoEquals(42, "Nick");
PersonNoEquals p3 = new PersonNoEquals(25, "Nick");
System.out.println(p1.equals(p2));     //false
System.out.println(p1.equals(p3));     //false
System.out.println(p1 == p2);          //false
p1 = p2;
System.out.println(p1.equals(p2));     //true
System.out.println(p1 == p2);          //true

正如我们所预期的,无论我们使用equals()方法还是==运算符,结果都是相同的。

覆盖 equals()方法

现在,让我们实现equals()方法:

class PersonWithEquals{
  private int age;
  private String name;
  private String hairstyle;

  public PersonWithEquals(int age, String name, String hairstyle) {
    this.age = age;
    this.name = name;

    this.hairstyle = hairstyle;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PersonWithEquals person = (PersonWithEquals) o;
    return age == person.age && Objects.equals(name, person.name);
  }
}

请注意,当建立对象的相等性时,我们忽略了“发型”字段。需要评论的另一个方面是使用java.utils.Objects类的equals()方法。以下是它的实现:

public static boolean equals(Object a, Object b) {
  return (a == b) || (a != null && a.equals(b));
}

如您所见,它首先比较引用,然后确保一个不是null(以避免NullPointerException),然后使用java.lang.Object基类的equals()方法或可能存在的子类中的重写实现作为参数值传递。在我们的情况下,我们传递了类型为String的参数对象,它们已经实现了equals()方法,用于比较String类型的值,而不仅仅是引用(我们将很快讨论它)。因此,PersonWithEquals对象的任何字段的任何差异都将导致该方法返回 false。

如果我们再次运行测试,我们将看到这个:

PersonWithEquals p11 = new PersonWithEquals(42, "Kelly", "Ponytail");
PersonWithEquals p12 = new PersonWithEquals(42, "Kelly", "Pompadour");
PersonWithEquals p13 = new PersonWithEquals(25, "Kelly", "Ponytail");
System.out.println(p11.equals(p12));    //true
System.out.println(p11.equals(p13));    //false
System.out.println(p11 == p12);         //false
p11 = p12;
System.out.println(p11.equals(p12));    //true
System.out.println(p11 == p12);         //true

现在,equals()方法不仅在引用相等时返回 true(因此它们指向相同的对象),而且在引用不同但它们引用的对象具有相同类型和包含在对象标识中的某些字段的相同值时也返回 true。

使用在父类中实现的标识

我们可以创建一个基类Person,它只有两个字段“年龄”和“名字”,以及equals()方法,如前所述实现。然后,我们可以用PersonWithHair类扩展它(它有额外的字段“发型”):

class Person{
  private int age;
  private String name;
  public Person(int age, String name) {
    this.age = age;
    this.name = name;
  }
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Person person = (Person) o;
    return age == person.age && Objects.equals(name, person.name);
  }
}
class PersonWithHair extends Person{
  private String hairstyle;
  public PersonWithHair(int age, String name, String hairstyle) {
    super(age, name);
    this.hairstyle = hairstyle;
  }
}

PersonWithHair的对象将与PersonWithEquals的先前测试中的方式进行比较。

PersonWithHair p21 = new PersonWithHair(42, "Kelly", "Ponytail");
PersonWithHair p22 = new PersonWithHair(42, "Kelly", "Pompadour");
PersonWithHair p23 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p21.equals(p22));    //true
System.out.println(p21.equals(p23));    //false
System.out.println(p21 == p22);         //false
p21 = p22;
System.out.println(p21.equals(p22));    //true
System.out.println(p21 == p22);         //true

这是可能的,因为PersonWithHair的对象也是Person的类型,所以接受这一行:

Person person = (Person) o;

equals()方法中的前一行不会抛出ClassCastException

然后我们可以创建PersonWithHairDresssed类:

PersonWithHairDressed extends PersonWithHair{
  private String dress;
  public PersonWithHairDressed(int age, String name, 
                               String hairstyle, String dress) {
    super(age, name, hairstyle);
    this.dress = dress;
  }
}

如果我们再次运行相同的测试,结果将是一样的。但我们认为服装和发型不是身份识别的一部分,所以我们可以运行测试来比较Person的孩子们:

Person p31 = new PersonWithHair(42, "Kelly", "Ponytail");
Person p32 = new PersonWithHairDressed(42, "Kelly", "Pompadour", "Suit");
Person p33 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p31.equals(p32));    //false
System.out.println(p31.equals(p33));    //false
System.out.println(p31 == p32);         //false

这不是我们期望的!孩子们被认为不相等,因为在Person基类的equals()方法中有这行:

if (o == null || getClass() != o.getClass()) return false;

前面的行失败了,因为getClass()o.getClass()方法返回的是子类名 - 使用new操作符实例化的类。为了摆脱这个困境,我们使用以下逻辑:

  • 我们的equals()方法的实现位于Person类中,所以我们知道当前对象是Person类型

  • 要比较类,我们只需要确保另一个对象也是Person类型

如果我们替换这行:

if (o == null || getClass() != o.getClass()) return false;

使用以下代码:

if (o == null) return false;
if(!(o instanceof Person)) return false;

结果将是这样的:

Person p31 = new PersonWithHair(42, "Kelly", "Ponytail");
Person p32 = new PersonWithHairDressed(42, "Kelly", "Pompadour", "Suit");
Person p33 = new PersonWithHair(25, "Kelly", "Ponytail");
System.out.println(p31.equals(p32));    //true
System.out.println(p31.equals(p33));    //false
System.out.println(p31 == p32);         //false

这就是我们想要的,不是吗?这样,我们已经实现了最初的想法,即不包括发型和服装在人的身份识别中。

在对象引用的情况下,等号运算符==!=比较的是引用本身 - 而不是对象字段(状态)的值。如果需要比较对象状态,请使用重写了Object类中的equals()方法。

String类和原始类型的包装类也重写了equals()方法。

String 类的 equals()方法

在第五章中,Java 语言元素和类型,我们已经讨论过这个问题,甚至审查了源代码。这里是源代码:

public boolean equals(Object anObject) {
  if (this == anObject) {
    return true;
  }
  if (anObject instanceof String) {

    String aString = (String)anObject;
    if (coder() == aString.coder()) {
      return isLatin1() ? 
               StringLatin1.equals(value, aString.value)
               : StringUTF16.equals(value, aString.value);
    }
  }
  return false;
}

如你所见,它重写了Object类的实现,以便比较值,而不仅仅是引用。这段代码证明了这一点:

String sl1 = "test1";
String sl2 = "test2";
String sl3 = "test1";

System.out.println(sl1 == sl2);              //1: false
System.out.println(sl1.equals(sl2));         //2: false

System.out.println(sl1 == sl3);              //3: true
System.out.println(sl1.equals(sl3));         //4: true

String s1 = new String("test1");
String s2 = new String("test2");
String s3 = new String("test1");

System.out.println(s1 == s2);                //5: false
System.out.println(s1.equals(s2));           //6: false

System.out.println(s1 == s3);                //7: false
System.out.println(s1.equals(s3));           //8: true

System.out.println(sl1 == s1);               //9: false
System.out.println(sl1.equals(s1));          //10: true

你可以看到等号运算符==有时会正确比较String对象的值,有时则不会。然而,equal()方法总是正确比较值,即使它们被包装在不同的对象中,而不仅仅是引用文字。

我们在测试中包含了等号运算符,以澄清在互联网上经常读到的关于String值的不正确解释的情况。这种不正确的解释基于支持String实例不可变性的 JVM 实现(在第五章中阅读关于String不可变性及其动机的内容)。JVM 不会两次存储相同的String值,并且会重用已存储在称为字符串池的区域中的值,这个过程称为字符串池化。了解了这一点后,有些人认为使用equals()方法与String值是不必要的,因为相同的值无论如何都会有相同的引用值。我们的测试证明,在String类中包装的String值的情况下,等号运算符无法正确比较其值,必须使用equals()方法。还有其他情况,String值没有存储在字符串池中。

要比较两个String对象的值,总是使用equals()方法,而不是等号==

一般来说,equals()方法不如==运算符快。但是,正如我们在第五章中指出的那样,Java 语言元素和类型,String 类的equals()方法首先比较引用,这意味着在调用equals()方法之前没有必要尝试节省性能时间并比较引用。只需调用equals()方法。

String类型行为的模糊性 - 有时像原始类型,有时像引用类型 - 让我想起了物理学中基本粒子的双重性质。粒子有时表现得像小而集中的物体,但有时像波。背后到底发生了什么?那里也是不可变的吗?

原始类型的包装类中的 equals()方法

如果我们对包装类运行测试,结果将是:

long ln = 42;
Integer n = 42;
System.out.println(n.equals(42));      //true

System.out.println(n.equals(ln));      //false
System.out.println(n.equals(43));      //false

System.out.println(n.equals(Integer.valueOf(42)));  //true
System.out.println(n.equals(Long.valueOf(42)));     //false

根据我们对Person的子类的经验,我们可以相当自信地假设包装类的equals()方法包括类名的比较。让我们看看源代码。这是Integer类的equals()方法:

public boolean equals(Object obj) {
  if (obj instanceof Integer) {
    return value == ((Integer)obj).intValue();
  }
  return false;
}

这正是我们所期望的。如果一个对象不是Integer类的实例,即使它携带完全相同的数值,也永远不能被认为等于另一个类的对象。这看起来就像古代社会阶级制度一样,不是吗?

练习 - 命名语句

以下语句称为什么?

  • i++;

  • String s;

  • s = "I am a string";

  • doSomething(1, "23");

答案

以下语句称为:

  • 递增语句:i++;

  • 变量声明语句:String s;

  • 赋值语句:s = "I am a string";

  • 方法调用语句:doSomething(1, "23");

总结

在本章中,我们学习了 Java 编程的三个核心元素——运算符、表达式和语句——以及它们之间的关系。我们为您介绍了所有的 Java 运算符,讨论了一些最受欢迎的运算符,并通过示例解释了它们的潜在问题。本章的相当部分专门讨论了数据类型转换:扩宽和缩窄、装箱和拆箱。还演示了引用类型的equals()方法,并针对各种类和实现进行了具体示例的测试。String类被广泛使用,并解决了关于其行为的流行错误解释。

在下一章中,我们将开始编写程序逻辑——任何执行流程的支柱——使用控制流语句,这些语句将被定义、解释并通过许多示例进行演示:条件语句、迭代语句、分支语句和异常。

第十章:控制流语句

本章描述了一种特定类型的 Java 语句,称为控制语句,它允许根据实现的算法的逻辑构建程序流程,其中包括选择语句、迭代语句、分支语句和异常处理语句。

在本章中,我们将涵盖以下主题:

  • 什么是控制流?

  • 选择语句:ifif....elseswitch...case

  • 迭代语句:forwhiledo...while

  • 分支语句:breakcontinuereturn

  • 异常处理语句:try...catch...finallythrowassert

  • 练习-无限循环

什么是控制流?

Java 程序是一系列可以执行并产生一些数据或/和启动一些操作的语句。为了使程序更通用,一些语句是有条件执行的,根据表达式评估的结果。这些语句称为控制流语句,因为在计算机科学中,控制流(或控制流)是执行或评估单个语句的顺序。

按照惯例,它们被分为四组:选择语句、迭代语句、分支语句和异常处理语句。

在接下来的章节中,我们将使用术语块,它表示一系列用大括号括起来的语句。这是一个例子:

{ 
  x = 42; 
  y = method(7, x); 
  System.out.println("Example"); 
}

一个块也可以包括控制语句-一个娃娃里面的娃娃,里面的娃娃,依此类推。

选择语句

选择语句组的控制流语句基于表达式的评估。例如,这是一种可能的格式:if(expression) do something。或者,另一种可能的格式:if(expression) {do something} else {do something else}

表达式可能返回一个boolean值(如前面的例子)或一个可以与常量进行比较的特定值。在后一种情况下,选择语句的格式为switch语句,它执行与特定常量值相关联的语句或块。

迭代语句

迭代语句执行某个语句或块,直到达到某个条件。例如,它可以是一个for语句,它执行一个语句或一组值的集合的每个值,或者直到某个计数器达到预定义的阈值,或者达到其他某些条件。执行的每个循环称为迭代。

分支语句

分支语句允许中断当前执行流程并从当前块后的第一行继续执行,或者从控制流中的某个(标记的)点继续执行。

方法中的return语句也是分支语句的一个例子。

异常处理语句

异常是表示程序执行过程中发生的事件并中断正常执行流程的类。我们已经看到了在相应条件下生成的NullPointerExceptionClassCastExceptionArrayIndexOutOfBoundsException的示例。

Java 中的所有异常类都有一个共同的父类,即java.lang.Exception类,它又扩展了java.lang.Throwable类。这就是为什么所有异常对象都有共同的行为。它们包含有关异常条件的原因和其起源位置(类源代码的行号)的信息。

每个异常都可以被自动(由 JVM)抛出,或者由应用程序代码使用throw关键字。方法调用者可以使用异常语句捕获异常,并根据异常类型和它(可选地)携带的消息执行某些操作,或者让异常进一步传播到方法调用堆栈的更高层。

如果堆栈中的应用程序方法都没有捕获异常,最终将由 JVM 捕获异常,并用错误中止应用程序执行。

因此,异常处理语句的目的是生成(throw)和捕获异常。

选择语句

选择语句有四种变体:

  • if语句

  • if...else语句

  • if...else if-...-else语句

  • switch...case语句

if

简单的if语句允许有条件地执行某个语句或块,仅当表达式求值结果为true时:

if(booelan expression){
  //do something
} 

以下是一些例子:

if(true) System.out.println("true");    //1: true
if(false) System.out.println("false");  //2:

int x = 1, y = 5;
if(x > y) System.out.println("x > y");  //3:
if(x < y) System.out.println("x < y");  //4: x < y

if((x + 5) > y) {                       //5: x + 5 > y
  System.out.println("x + 5 > y");    
  x = y;
}

if(x == y){                             //6: x == y
  System.out.println("x == y");       
}

语句 1 打印true。语句 2 和 3 什么也不打印。语句 4 打印x < y。语句 5 打印x + 5 > y。我们使用大括号创建了一个块,因为我们希望x = y语句仅在此if语句的表达式求值为true时执行。语句 6 打印x == y。我们可以避免在这里使用大括号,因为只有一个语句需要执行。我们这样做有两个原因:

  • 为了证明大括号也可以与单个语句一起使用,从而形成一个语句块。

  • 良好的实践是,在if后面总是使用大括号{};这样读起来更好,并有助于避免这种令人沮丧的错误:在if后添加另一个语句,假设它只在表达式返回true时执行:

       if(x > y) System.out.println("x > y"); 
       x = y;

但是,此代码中的语句x = y是无条件执行的。如果您认为这种错误并不经常发生,您会感到惊讶。

始终在if语句后使用大括号{}是一个好习惯。

正如我们已经提到的,可以在选择语句内包含选择语句,以创建更精细的控制流逻辑:

if(x > y){
  System.out.println("x > y");
  if(x == 3){
    System.out.println("x == 3");
  }
  if(y == 3){
    System.out.println("y == 3");
    System.out.println("x == " + x);
  }
}

它可以根据逻辑要求深入(嵌套)。

if...else

if...else结构允许在表达式求值为true时执行某个语句或块;否则,将执行另一个语句或块:

if(Boolean expression){
  //do something
} else {
  //do something else
}

以下是两个例子:

int x = 1, y = 1; 
if(x == y){                        
  System.out.println("x == y");  //prints: x == y
  x = y - 1;
} else {
  System.out.println("x != y");  
}

if(x == y){                        
  System.out.println("x == y");
} else {
  System.out.println("x != y");  //prints: x != y
}

当大括号{}被一致使用时,您可以看到阅读此代码有多容易。并且,就像简单的if语句的情况一样,每个块都可以有另一个嵌套块,其中包含另一个if语句,依此类推 - 可以有多少块和多么深的嵌套。

if...else if-...-else

您可以使用此形式来避免创建嵌套块,并使代码更易于阅读和理解。例如,看下面的代码片段:

  if(n > 5){
    System.out.println("n > 5");
  } else {
    if (n == 5) {
      System.out.println("n == 5");
    } else {
      if (n == 4) {
        System.out.println("n == 4");
      } else {
        System.out.println("n < 4");
      }
    }
  }
}

这些嵌套的if...else语句可以被以下if...else...if语句替换:

if(n > 5){
  System.out.println("n > 5");
} else if (n == 5) {
  System.out.println("n == 5");
} else if (n == 4) {
  System.out.println("n == 4");
} else {
  System.out.println("n < 4");
}

这样的代码更容易阅读和理解。

如果n < 4时不需要执行任何操作,则可以省略最后的else子句:

if(n > 5){
  System.out.println("n > 5");
} else if (n == 5) {
  System.out.println("n == 5");
} else if (n == 4) {
  System.out.println("n == 4");
} 

如果您需要针对每个特定值执行某些操作,可以编写如下:

if(x == 5){
  //do something
} else if (x == 7) {
  //do something else
} else if (x == 12) {
  //do something different
} else if (x = 50) {
  //do something yet more different
} else {
  //do something completely different
}

但是,对于这种情况有一个专门的选择语句,称为switch...case,更容易阅读和理解。

switch...case

上一节的代码示例可以表示为switch语句,如下所示:

switch(x){
  case 5:
    //do something
    break;
  case 7:
    //do something else
    break;
  case 12:
    //do something different
    break;
  case 50:
    //do something yet more different
    break;
  default:
    //do something completely different
}

返回x变量值的表达式的类型可以是charbyteshortintCharacterByteShortIntegerStringenum类型。注意break关键字。它强制退出switch...case语句。如果没有它,接下来的语句do something将被执行。我们将在分支语句部分后面讨论break语句。

可以在switch语句中使用的类型有charbyteshortintCharacterByteShortIntegerStringenum类型。在 case 子句中设置的值必须是常量。

让我们看一个利用switch语句的方法:

void switchDemo(int n){
  switch(n + 1){
    case 1:
      System.out.println("case 1: " + n);
      break;
    case 2:
      System.out.println("case 2: " + n);
      break;
    default:
      System.out.println("default: " + n);
      break;
  }
}

以下代码演示了switch语句的工作原理:

switchDemo(0);     //prints: case1: 0
switchDemo(1);     //prints: case2: 1
switchDemo(2);     //prints: default: 2

if语句中的else子句类似,如果在程序逻辑中不需要switch语句中的默认子句,则默认子句是不需要的:

switch(n + 1){
  case 1:
    System.out.println("case 1: " + n);
    break;
  case 2:
    System.out.println("case 2: " + n);
}

迭代语句

迭代语句对于 Java 编程和选择语句一样重要。您很有可能经常看到并使用它们。每个迭代语句可以是whiledo...whilefor中的一种形式。

while

while语句执行布尔表达式和语句或块,直到表达式的值评估为false

while (Boolean expression){
  //do something
}

有两件事需要注意:

  • 当只有一个语句需要重复执行时,大括号{}是不必要的,但为了一致性和更好的代码理解,建议使用。

  • 该语句可能根本不会执行(当第一个表达式评估为false时)

让我们看一些示例。以下循环执行打印语句五次:

int i = 0;
while(i++ < 5){
  System.out.print(i + " ");   //prints: 1 2 3 4 5
}

注意使用的不同的打印方法:print()而不是println()。后者在打印行之后添加了一个转义序列\n(我们已经解释了转义序列是什么,位于第五章,Java 语言元素和类型),它将光标移动到下一行。

以下是调用返回某个值并累积直到达到所需阈值的方法的示例:

double result = 0d;
while (result < 1d){
  result += tryAndGetValue();
  System.out.println(result);
}

tryAndGetValue() 方法非常简单和不切实际,只是为了演示目的而编写的:

double tryAndGetValue(){
  return Math.random();
}

如果我们运行最后一个 while 语句,我们将看到类似于以下内容:

确切的值会因运行而异,因为 Math.random() 方法生成大于或等于 0.0 且小于 1.0 的伪随机 double 值。一旦累积值等于 1.0 或超过 1.0,循环就会退出。

让这个循环变得更简单是很诱人的:

double result = 0d;
while ((result += tryAndGetValue()) < 1d){
  System.out.println(result);
}

甚至更简单:

double result = 0d;
while ((result += Math.random()) < 1d){
  System.out.println(result);
}

但如果我们运行最后两个 while 语句的变体中的任何一个,我们将得到以下内容:

打印的值永远不会等于或超过 1.0,因为新累积值的表达式在进入执行块之前被评估。当计算包含在表达式中而不是在执行块中时,这是需要注意的事情。

do...while

类似于 while 语句,do...while 语句重复执行布尔表达式和语句或块,直到布尔表达式的值评估为 false

do {
  //statement or block
} while (Boolean expression)

但它在评估表达式之前首先执行语句或块,这意味着语句或块至少会被执行一次。

让我们看一些例子。以下代码执行打印语句六次(比类似的 while 语句多一次):

int i = 0;
do {
  System.out.print(i + " ");   //prints: 0 1 2 3 4 5
} while(i++ < 5);

以下代码的行为与 while 语句相同:

double result = 0d;
do {
  result += tryAndGetValue();
  System.out.println(result);
} while (result < 1d);

如果我们运行此代码,我们将看到类似于以下内容:

这是因为值在累积后被打印,然后在再次进入执行块之前评估表达式。

简化的 do...while 语句的行为不同。以下是一个例子:

double result = 0d;
do {
  System.out.println(result);
} while ((result += tryAndGetValue()) < 1d);

这里是相同的代码,但没有使用 tryAndGetValue() 方法:

double result = 0d;
do {
  System.out.println(result);
} while ((result += Math.random()) < 1d);

如果我们运行前两个示例中的任何一个,我们将得到以下截图中的内容:

result 变量的初始值总是首先打印,因为在第一次评估表达式之前至少执行一次该语句。

for

基本 for 语句的格式如下:

for(ListInit; Boolean Expression; ListUpdate) block or statement

但是,我们将从最流行的、更简单的版本开始,并在稍后的带有多个初始化器和表达式的 For部分回到完整版本。更简单的基本 for 语句格式如下:

for(DeclInitExpr; Boolean Expression; IncrDecrExpr) block or statement

这个定义由以下组件组成:

  • DeclInitExpr 是一个声明和初始化表达式,比如 x = 1,它只在 for 语句执行的最开始被评估一次

  • Boolean Expression 是一个布尔表达式,比如 x < 10,它在每次迭代开始时被评估 - 在执行块或语句之前每次都会被评估;如果结果是 falsefor 语句就会终止

  • IncrDecrExpr是增量或递减一元表达式,如++x--xx++x-,它在每次迭代结束后评估-在执行块或语句后

请注意,我们谈论的是表达式,而不是语句,尽管添加了分号,它们看起来像语句。这是因为分号在for语句中作为表达式之间的分隔符。让我们看一个例子:

for (int i=0; i < 3; i++){
  System.out.print(i + " ");  //prints: 0 1 2
}

在这段代码中:

  • int i=0是声明和初始化表达式,仅在一开始时评估一次

  • i < 3是布尔表达式,在每次迭代开始时评估-在执行块或语句之前;如果结果为false(在这种情况下为i >= 3),则for语句的执行终止

  • i++是增量表达式,在执行块或语句后评估

并且,与while语句的情况一样,当只有一个语句需要执行时,大括号{}是不需要的,但最好有它们,这样代码就更一致,更容易阅读。

for语句中的任何表达式都不是必需的:

int k = 0;
for (;;){
  System.out.print(k++ + " ");     //prints: 0 1 2
  if(k > 2) break;
}

但在语句声明中使用表达式更方便和常规,因此更容易理解。以下是其他示例:

for (int i=0; i < 3;){
  System.out.print(i++ + " "); //prints: 0 1 2
}

for (int i=2; i > 0; i--){
  System.out.print(i + " "); //prints: 2 1
}

请注意,在最后一个示例中,递减运算符用于减小初始i值。

在使用for语句或任何迭代语句时,请确保达到退出条件(除非您故意创建无限循环)。这是迭代语句构建的主要关注点。

用于增强

正如我们已经提到的,for语句是访问数组组件(元素)的一种非常方便的方式:

int[] arr = {21, 34, 5};
for (int i=0; i < arr.length; i++){
  System.out.print(arr[i] + " ");  //prints: 21 34 5
}

注意我们如何使用数组对象的公共属性length来确保我们已经到达了所有的数组元素。但在这种情况下,当需要遍历整个数组时,最好(更容易编写和阅读)使用以下格式的增强for语句:

<Type> arr = ...;              //an array or any Iterable
for (<Type> a: arr){
  System.out.print(a + " ");  
}

从注释中可以看出,它适用于数组或实现接口Iterable的类。该接口具有一个iterator()方法,返回一个Iterator类的对象,该类又有一个名为next()的方法,允许按顺序访问类成员。我们将在第十三章中讨论这样的类,称为集合,Java 集合。因此,我们可以重写最后的for语句示例并使用增强的for语句:

int[] arr = {21, 34, 5};
for (int a: arr){
  System.out.print(a + " ");  //prints: 21 34 5
}

对于实现List接口(List扩展Iterable)的集合类,对其成员的顺序访问看起来非常相似:

List<String> list = List.of("Bob", "Joe", "Jill");
for (String s: list){
  System.out.print(s + " ");  //prints: Bob Joe Jill
}

但是,当不需要访问数组或集合的所有元素时,可能有其他形式的迭代语句更适合。

另外,请注意,自 Java 8 以来,许多数据结构可以生成流,允许编写更紧凑的代码,并且完全避免使用for语句。我们将在第十八章中向您展示如何做到这一点,流和管道

用于多个初始化程序和表达式

现在,让我们再次回到基本的for语句格式。它允许使用的变化比许多程序员知道的要多得多。这不是因为缺乏兴趣或专业好奇心,而可能是因为通常不需要这种额外的功能。然而,偶尔当你阅读别人的代码或在面试中,你可能会遇到需要了解全貌的情况。因此,我们决定至少提一下。

for语句的完整格式建立在表达式列表周围:

for(ListInit; Boolean Expression; ListUpdate) block or statement

这个定义由以下组件组成:

  • ListInit: 可包括声明列表和/或表达式列表

  • Expression: 布尔表达式

  • ListUpdate: 表达式列表

表达式列表成员,用逗号分隔,可以是:

  • 赋值x = 3

  • 前/后缀递增/递减表达式++x --x x++ x--

  • 方法调用method(42)

  • 对象创建表达式new SomeClass(2, "Bob")

以下两个for语句产生相同的结果:

for (int i=0, j=0; i < 3 && j < 3; ++i, ++j){
  System.out.println(i + " " + j);
}
for (int x=new A().getInitialValue(), i=x == -2 ? x + 2 : 0, j=0;
  i < 3 || j < 3 ; ++i, j = i) {
  System.out.println(i + " " + j);
}

getInitialValue()方法的代码如下:

class A{
  int getInitialValue(){ return -2; }
}

正如你所看到的,即使是这样一个简单的功能,当过多地使用多个初始化程序、赋值和表达式时,它看起来可能非常复杂甚至令人困惑。如果有疑问,保持你的代码简单易懂。有时候这并不容易,但根据我们的经验,总是可以做到的,而易于理解是良好代码质量的最重要标准之一。

分支语句

你已经在我们的例子中看到了分支语句breakreturn。我们将在本节中定义和讨论它们以及该组的第三个成员——分支语句continue

中断和标记中断

你可能已经注意到,break语句对于switch...case选择语句能够正常工作是至关重要的(有关更多信息,请参阅switch...case部分)。如果包含在迭代语句的执行块中,它会立即终止forwhile语句。

它在迭代语句中被广泛使用,用于在数组或集合中搜索特定元素。为了演示它的工作原理,例如,假设我们需要在社区学院的学生和教师中通过年龄和姓名找到某个人。首先创建PersonStudentTeacher类:

class Person{
  private int age;
  private  String name;
  public Person(int age, String name) {
    this.age = age;
    this.name = name;
  }
  @Override
  public Boolean equals(Object o) {
    if (this == o) return true;
    Person person = (Person) o;
    return age == person.age &&
              Objects.equals(name, person.name);
  }
  @Override
  public String toString() {
    return "Person{age=" + age +
              ", name='" + name + "'}";
  }
}
class Student extends Person {
  private int year;

  public Student(int age, String name, int year) {
    super(age, name);
    this.year = year;
  }

  @Override
  public String toString() {
    return "Student{year=" + year +
        ", " + super.toString() + "}";
  }
}
class Teacher extends Person {
  private String subject;
  public Teacher(int age, String name, String subject) {
    super(age, name);
    this.subject = subject;
  }
  @Override
  public String toString() {
    return "Student{subject=" + subject +
           ", " + super.toString() + "}";
  }
}

注意,equals()方法只在基类Person中实现。我们只通过姓名和年龄来识别一个人。还要注意使用关键字super,它允许我们访问父类的构造函数和toString()方法。

假设我们被指派在社区学院数据库中查找一个人(按姓名和年龄)。因此,我们已经创建了一个List类型的集合,并将在其中进行迭代,直到找到匹配项:

List<Person> list = 
  List.of(new Teacher(32, "Joe", "History"),
          new Student(29,"Joe", 4),
          new Student(28,"Jill", 3),
          new Teacher(33, "ALice", "Maths"));
Person personOfInterest = new Person(29,"Joe");
Person person = null;
for (Person p: list){
  System.out.println(p);
  if(p.equals(personOfInterest)){
    person = p;
    break;
  }
}
if(person == null){
  System.out.println("Not found: " + personOfInterest);
} else {
  System.out.println("Found: " + person);
}

如果我们运行这个程序,结果将是:

我们已经找到了我们要找的人。但是如果我们改变我们的搜索并寻找另一个人(只相差一岁):

Person personOfInterest = new Person(30,"Joe");

结果将是:

正如你所看到的,break语句允许在找到感兴趣的对象时立即退出循环,从而不浪费时间在迭代整个可能相当大的集合上。

在第十八章中,流和管道,我们将向您展示另一种(通常更有效)搜索集合或数组的方法。但在许多情况下,迭代元素仍然是一种可行的方法。

break语句也可以用于在多维数据结构中搜索特定元素。假设我们需要搜索一个三维数组,并找到其元素之和等于或大于 4 的最低维度数组。这是这样一个数组的示例:

int[][][] data = {
        {{1,0,2},{1,2,0},{2,1,0},{0,3,0}},
        {{1,1,1},{1,3,0},{2,0,1},{1,0,1}}};

我们要找的最低维度数组是{1,3,0}。如果第一维是x,第二维是y,那么这个数组的位置是x=1y=1,或[1][1]。让我们编写一个程序来找到这个数组:

int[][][] data = {
        {{1,0,2},{1,2,0},{2,1,0},{0,3,0}},
        {{1,1,1},{1,3,0},{2,0,1},{1,0,1}}};
int threshold = 4;
int x = 0, y = 0;
Boolean isFound = false;
for(int[][] dd: data){
  y = 0;
  for(int[] d: dd){
    int sum = 0;
    for(int i: d){
      sum += i;
      if(sum >= threshold){
        isFound = true;
        break;
      }
    }
    if(isFound){
      break;
    }
    y++;
  }
  if(isFound){
    break;
  }
  x++;
}
System.out.println("isFound=" + isFound + ", x=" + x + ", y=" + y); 
//prints: isFound=true, x=1, y=1

正如你所看到的,我们使用一个名为isFound的布尔变量来方便地从最内层循环中退出,一旦在内部循环中找到了期望的结果。检查isFound变量的值的无聊需要使 Java 作者引入了一个标签 - 一个标识符后跟着一个冒号(:),可以放在语句的前面。break语句可以利用它。以下是如何使用标签更改先前的代码:

int[][][] data = {
        {{1,0,2},{1,2,0},{2,1,0},{0,3,0}},
        {{1,1,1},{1,3,0},{2,0,1},{1,0,1}}};
int threshold = 4;
int x = 0, y = 0;
Boolean isFound = false;
exit:
for(int[][] dd: data){
  y = 0;
  for(int[] d: dd){
    int sum = 0;
    for(int i: d){
      sum += i;
      if(sum >= threshold){
        isFound = true;
        break exit;
      }
    }
    y++;
  }
  x++;
}
System.out.println("isFound=" + isFound + ", x=" + x + ", y=" + y); 
//prints: isFound=true, x=1, y=1

我们仍然使用变量isFound,但仅用于报告目的。exit:标签允许break语句指定哪个语句必须停止执行。这样,我们就不需要编写检查isFound变量值的样板代码。

继续和标记继续

continue语句支持与break语句支持的功能类似。但是,它不是退出循环,而是强制退出当前迭代,所以循环继续执行。为了演示它的工作原理,让我们假设,就像前一节中break语句的情况一样,我们需要搜索一个三维数组,并找到其元素总和等于或大于 4 的最低维度的数组。但是这次,总和不应包括等于 1 的元素。这是数组:

int[][][] data = {
        {{1,1,2},{0,3,0},{2,4,1},{2,3,2}},
        {{0,2,0},{1,3,4},{2,0,1},{2,2,2}}};

我们的程序应该找到以下数组:

  • data[0][2] = {2,4,1}, sum = 6 (因为 1 必须被跳过)

  • data[0][3] = {2,3,2}, sum = 7

  • data[1][1] = {1,3,4}, sum = 7 (因为 1 必须被跳过)

  • data[1][3]={2,2,2}, sum = 6

如果跳过 1,则其他数组元素的总和不会达到 4。

这是程序:

int[][][] data = {
        {{1,1,2},{0,3,0},{2,4,1},{2,3,2}},
        {{0,2,0},{1,3,4},{2,0,1},{2,2,2}}};
int threshold = 4;
int x = 0, y;
for(int[][] dd: data){
  y = 0;
  for(int[] d: dd){
    int sum = 0;
    for(int i: d){
      if(i == 1){
        continue;
      }
      sum += i;
    }
    if(sum >= threshold){
      System.out.println("sum=" + sum + ", x=" + x + ", y=" + y);
    }
    y++;
  }
  x++;
}

如果我们运行它,结果将是:

如您所见,结果正如我们所期望的那样:所有元素 1 都被跳过了。

为了演示如何使用带标签的continue语句,让我们改变要求:不仅要跳过元素 1,还要忽略包含这样一个元素的所有数组。换句话说,我们需要找到不包含 1 并且元素的总和等于或大于 4 的数组。

我们的程序应该只找到两个数组:

  • data[0][3] = {2,3,2}, sum = 7

  • data[1][3] = {2,2,2}, sum = 6

这是实现它的代码:

int[][][] data = {
        {{1,1,2},{0,3,0},{2,4,1},{2,3,2}},
        {{0,2,0},{1,3,4},{2,0,1},{2,2,2}}};
int threshold = 4;
int x = 0, y;
for(int[][] dd: data){
  y = 0;
  cont: for(int[] d: dd){
    int sum = 0;
    for(int i: d){
      if(i == 1){
        y++;
        continue cont;
      }
      sum += i;
    }
    if(sum >= threshold){
      System.out.println("sum=" + sum + ", x=" + x + ", y=" + y);
    }
    y++;
  }
  x++;
}

如您所见,我们添加了一个名为cont:的标签,并在continue语句中引用它,因此内部循环的当前迭代和下一个外部循环的迭代停止执行。外部循环然后继续执行下一个迭代。如果我们运行代码,结果将是:

所有其他数组都被跳过,因为它们包含 1 或其元素的总和小于 4。

返回

return语句只能放在方法或构造函数中。它的功能是返回控制权给调用者,有或没有值。

在构造函数的情况下,不需要return语句。如果放在构造函数中,它必须是最后一条不返回值的语句:

class ConstructorDemo{
  private int field;
  public ConstructorDemo(int i) {
    this.field = i;
    return;
  }
}

试图将return语句放在构造函数的最后一条语句之外,或者使其返回任何值,都会导致编译错误。

在方法的情况下,如果方法被声明为返回某种类型:

  • return语句是必需的

  • return语句必须有效地(见下面的示例)是方法的最后一条语句

  • 可能有几个返回语句,但其中一个必须有效地(见下面的示例)是方法的最后一条语句,而其他的必须在选择语句内部;否则,将生成编译错误

  • 如果return语句不返回任何内容,将导致编译错误

  • 如果return语句返回的类型不是方法定义中声明的类型,也不是其子类型,它会导致编译错误

  • 装箱、拆箱和类型扩宽是自动执行的,而类型缩窄需要类型转换

以下示例演示了return语句有效地成为方法的最后一条语句:

public String method(int n){
  if(n == 1){
    return "One";
  } else {
    return "Not one";
  }
}

方法的最后一条语句是选择语句,但return语句是选择语句内最后执行的语句。

这是一个具有许多返回语句的方法的示例:

public static String methodWithManyReturns(){
  if(true){
    return "The only one returned";
  }
  if(true){
    return "Is never reached";
  }
  return "Is never reached";
}

尽管在方法中,只有第一个return语句总是返回,但编译器不会抱怨,方法会在没有运行时错误的情况下执行。它只是总是返回一个唯一返回的文字。

以下是具有多个返回语句的更现实的方法示例:

public Boolean method01(int n){
  if(n < 0) {
    return true;
  } else {
    return false;
  }
}

public Boolean sameAsMethod01(int n){
  if(n < 0) {
    return true;
  }
  return false;
}

public Boolean sameAsAbove(int n){
  return n < 0 ? true : false;
}

public int method02(int n){
  if(n < 0) {
    return 1;
  } else if(n == 0) {
    return 2;
  } else if (n == 1){
    return 3;
  } else {
    return 4;
  }
}
public int methodSameAsMethod02(int n){
  if(n < 0) {
    return 1;
  }
  switch(n) {
    case 0:
      return 2;
    case 1:
      return 3;
    default:
      return 4;
  }
}

这里有关于装箱、拆箱、类型扩宽和缩窄的示例:

public Integer methodBoxing(){
  return 42;
}

public int methodUnboxing(){
  return Integer.valueOf(42);
}

public int methodWidening(){
  byte b = 42;
  return b;
}

public byte methodNarrowing(){
  int n = 42;
  return (byte)n;
}

我们还可以重新审视程序,该程序在教师和学生名单中寻找特定的人:

List<Person> list = 
  List.of(new Teacher(32, "Joe", "History"),
          new Student(29,"Joe", 4),
          new Student(28,"Jill", 3),
          new Teacher(33, "ALice", "Maths"));
Person personOfInterest = new Person(29,"Joe");
Person person = null;
for (Person p: list){
  System.out.println(p);
  if(p.equals(personOfInterest)){
    person = p;
    break;
  }
}
if(person == null){
  System.out.println("Not found: " + personOfInterest);
} else {
  System.out.println("Found: " + person);
}

使用返回语句,我们现在可以创建findPerson()方法:

Person findPerson(List<Person> list, Person personOfInterest){
  Person person = null;
  for (Person p: list){
    System.out.println(p);
    if(p.equals(personOfInterest)){
      person = p;
      break;
    }
  }
  return person;
}

这个方法可以这样使用:

List<Person> list = List.of(new Teacher(32, "Joe", "History"),
        new Student(29,"Joe", 4),
        new Student(28,"Jill", 3),
        new Teacher(33, "ALice", "Maths"));
Person personOfInterest = new Person(29,"Joe");
Person person = findPerson(list, personOfInterest);
if(person == null){
  System.out.println("Not found: " + personOfInterest);
} else {
  System.out.println("Found: " + person);
}

利用新的代码结构,我们可以进一步改变findPerson()方法,并展示return语句使用的更多变化:

Person findPerson(List<Person> list, Person personOfInterest){
  for (Person p: list){
    System.out.println(p);
    if(p.equals(personOfInterest)){
      return p;
    }
  }
  return null;
}

正如您所看到的,我们已经用返回语句替换了break语句。现在代码更易读了吗?一些程序员可能会说不,因为他们更喜欢只有一个return语句是返回结果的唯一来源。否则,他们认为,人们必须研究代码,看看是否有另一个——第三个——return语句,可能会返回另一个值。如果代码不那么简单,人们永远不确定是否已经识别了所有可能的返回。相反派的程序员可能会反驳说,方法应该很小,因此很容易找到所有的返回语句。但是,将方法变得很小通常会迫使创建深度嵌套的方法,这样就不那么容易理解了。这个争论可能会持续很长时间。这就是为什么我们让您自己尝试并决定您更喜欢哪种风格。

如果方法的返回类型定义为void

  • 不需要return语句

  • 如果存在return语句,则不返回任何值

  • 如果return语句返回一些值,会导致编译错误

  • 可能有几个返回语句,但其中一个必须有效地成为方法的最后一个语句,而其他语句必须在选择语句内部;否则,将生成编译错误

为了演示没有值的return语句,我们将再次使用findPerson()方法。如果我们只需要打印结果,那么方法可以更改如下:

void findPerson2(List<Person> list, Person personOfInterest){
  for (Person p: list){
    System.out.println(p);
    if(p.equals(personOfInterest)){
      System.out.println("Found: " + p);
      return;
    }
  }
  System.out.println("Not found: " + personOfInterest);
  return;  //this statement is optional
}

并且客户端代码看起来更简单:

List<Person> list = List.of(new Teacher(32, "Joe", "History"),
        new Student(29,"Joe", 4),
        new Student(28,"Jill", 3),
        new Teacher(33, "ALice", "Maths"));
Person personOfInterest = new Person(29,"Joe");
findPerson(list, personOfInterest);

或者它甚至可以更紧凑:

List<Person> list = List.of(new Teacher(32, "Joe", "History"),
        new Student(29,"Joe", 4),
        new Student(28,"Jill", 3),
        new Teacher(33, "ALice", "Maths"));
findPerson(list, new Person(29, "Joe");

与先前的讨论一样,有不同的风格将参数传递到方法中。有些人更喜欢更紧凑的代码风格。其他人则认为每个参数都必须有一个变量,因为变量的名称携带了额外的信息,有助于传达意图(比如personOfInterest的名称)。

这样的讨论是不可避免的,因为同样的代码必须由不同的人理解和维护,每个开发团队都必须找到适合所有团队成员需求和偏好的风格。

异常处理语句

正如我们在介绍中解释的那样,意外条件可能会导致 JVM 创建并抛出异常对象,或者应用程序代码可以这样做。一旦发生这种情况,控制流就会转移到异常处理try语句(也称为try-catchtry-catch-finally语句),如果异常是在try块内抛出的。这是一个捕获异常的例子:

void exceptionCaught(){
  try {
    method2();
  } catch (Exception ex){
    ex.printStackTrace();
  }
}

void method2(){
  method1(null);
}

void method1(String s){
  s.equals("whatever");
}

方法exceptionCaught()调用method2()method2()调用method1()并将null传递给它。行s.equals("whatever")抛出NullPointerException,它通过方法调用堆栈传播,直到被exceptionCaught()方法的try-catch块捕获,并打印其堆栈跟踪(哪个方法调用了哪个方法以及类的哪一行):

从堆栈跟踪中,您可以看到所有涉及的方法都属于同一个类ExceptionHandlingDemo。从下往上阅读,您可以看到:

  • 方法main()ExceptionHandlingDemo的第 5 行调用了方法exceptionCaught()

  • 方法exceptionCaught()在同一类的第 10 行调用了method2()

  • method2()在第 17 行调用了method1()

  • method1()在第 21 行抛出了java.lang.NullpointerException

如果我们不看代码,我们就不知道这个异常是故意抛出的。例如,method1()可能如下所示:

void method1(String s){
  if(s == null){
    throw new NullPointerException();
  }
}

但通常,程序员会添加一条消息来指示问题是什么:

void method1(String s){
  if(s == null){
    throw new NullPointerException("Parameter String is null");
  }
}

如果是这种情况,堆栈跟踪将显示一条消息:

但是消息并不是自定义异常的可靠指标。一些标准异常也携带自己的消息。异常包是自定义异常的更好证据,或者异常是基类之一(java.lang.Exceptionjava.langRuntimeException)并且其中有一条消息。例如,以下代码自定义了RuntimeException

void method1(String s){
  if(s == null){
    throw new RuntimeException("Parameter String is null");
  }
}

以下是使用此类自定义异常的堆栈跟踪:

稍后我们将在自定义异常部分更多地讨论异常定制。

如果异常在try...catch块之外抛出,则程序执行将由 JVM 终止。以下是一个未被应用程序捕获的异常的示例:

void exceptionNotCaught(){
  method2();
}

void method2(){
  method1(null);
}

void method1(String s){
  s.equals("whatever");
}

如果我们运行此代码,结果是:

现在,让我们谈谈异常处理语句,然后再回到关于处理异常的最佳方法的讨论。

throw

throw语句由关键字throwjava.lang.Throwable的变量或引用类型的值,或null引用组成。由于所有异常都是java.lang.Throwable的子类,因此以下任何一个throw语句都是正确的:

throw new Exception("Something happened");

Exception ex = new Exception("Something happened");
throw ex;

Throwable thr = new Exception("Something happened");
throw thr;

throw null;

如果抛出null,就像在最后一条语句中一样,那么 JVM 会将其转换为NullPointerException,因此这两条语句是等价的:

throw null;

throw new NullPointerException;

另外,提醒一下,包java.lang不需要被导入。您可以通过名称引用java.lang包的任何成员(接口或类),而无需使用完全限定名称(包括包名)。这就是为什么我们能够写NullPointerException而不导入该类,而不是使用其完全限定名称java.lang.NullPointerException。我们将在第十二章 Java 标准和外部库中查看java.lang包的内容。

您还可以通过扩展Throwable或其任何子类来创建自己的异常,并抛出它们,而不是抛出java.lang包中的标准异常:

class MyNpe extends NullPointerException{
  public MyNpe(String message){
    super(message);
  }
  //whatever code you need to have here
}

class MyRuntimeException extends RuntimeException{
  public MyRuntimeException(String message){
    super(message);
  }
  //whatever code you need to have here
}

class MyThrowable extends Throwable{
  public MyThrowable(String message){
    super(message);
  }
  //whatever code you need to have here
}

class MyException extends Exception{
  public MyException(String message){
    super(message);
  }
  //whatever code you need to have here
}

为什么要这样做将在阅读自定义异常部分后变得清晰。

尝试...捕获

当在try块内抛出异常时,它将控制流重定向到其第一个catch子句(在下面的示例中捕获NullPointerException):

void exceptionCaught(){
  try {
    method2();
  } catch (NullPointerException ex){
    System.out.println("NPE caught");
    ex.printStackTrace();
  } catch (RuntimeException ex){
    System.out.println("RuntimeException caught");
    ex.printStackTrace();
  } catch (Exception ex){
    System.out.println("Exception caught");
    ex.printStackTrace();
  }
}

如果有多个catch子句,编译器会强制您安排它们,以便子异常在父异常之前列出。在我们之前的示例中,NullPointerException扩展了RuntimeException扩展了Exception。如果抛出的异常类型与最顶层的catch子句匹配,此catch块处理异常(我们将很快讨论它的含义)。如果最顶层子句不匹配异常类型,则下一个catch子句获取控制流并处理异常(如果匹配子句类型)。如果不匹配,则控制流传递到下一个子句,直到异常被处理或尝试所有子句。如果没有一个子句匹配,异常将被抛出直到它被某个 try-catch 块处理,或者它传播到程序代码之外。在这种情况下,JVM 终止程序执行(准确地说,它终止线程执行,但我们将在第十一章,JVM 进程和垃圾回收中讨论线程)。

让我们通过运行示例来演示这一点。如果我们像之前展示的那样在exceptionCaught()方法中使用三个catch子句,并在method1()中抛出NullPointerException

void method1(String s){
  throw new NullPointerException("Parameter String is null");
}

结果将如下截图所示:

您可以看到最顶层的catch子句按预期捕获了异常。

如果我们将method1()更改为抛出RuntimeException

void method1(String s){
  throw new RuntimeException("Parameter String is null");
}

您可能不会感到惊讶,看到第二个catch子句捕获它。因此,我们不打算演示它。我们最好再次更改method1(),让它抛出ArrayIndexOutOfBoundsException,它是RuntimeException的扩展,但未列在任何捕获子句中:

void method1(String s){
  throw new ArrayIndexOutOfBoundsException("Index ... is bigger " +
                                        "than the array length ...");
}

如果我们再次运行代码,结果将如下所示:

正如您所看到的,异常被第一个匹配其类型的catch子句捕获。这就是编译器强制您列出它们的原因,以便子类通常在其父类之前列出,因此最具体的类型首先列出。这样,第一个匹配的子句总是最佳匹配。

现在,您可能完全希望看到任何非RuntimeException都被最后一个catch子句捕获。这是一个正确的期望。但在我们抛出它之前,我们必须解决已检查未检查(也称为运行时)异常之间的区别。

已检查和未检查(运行时)异常

为了理解为什么这个主题很重要,让我们尝试在method1()中抛出Exception类型的异常。为了进行这个测试,我们将使用InstantiationException,它扩展了Exception。假设有一些输入数据的验证(来自某些外部来源),结果证明它们不足以实例化某些对象:

void method1(String s) {
  //some input data validation 
  throw new InstantiationException("No value for the field" +
                                   " someField of SomeClass.");
}

我们编写了这段代码,突然编译器生成了一个错误,Unhandled exception java.lang.InstantiationException,尽管我们在客户端代码中有一个catch子句,它将匹配这种类型的异常(在方法exceptionCaught()中的最后一个catch子句)。

错误的原因是所有扩展Exception类但不是其子类RuntimeException的异常类型在编译时都会被检查,因此得名。编译器会检查这些异常是否在其发生的方法中得到处理:

  • 如果在异常发生的方法中有一个try-catch块捕获了这个异常并且不让它传播到方法外部,编译器就不会抱怨

  • 否则,它会检查方法声明中是否有列出此异常的throws子句;这里是一个例子:

        void method1(String s) throws Exception{
          //some input data validation 
          throw new InstantiationException("No value for the field" +
                                           " someField of SomeClass.");
        }

throws子句必须列出所有可能传播到方法外部的已检查异常。通过添加throws Exception,即使我们决定抛出任何其他已检查异常,编译器也会满意,因为它们都是Exception类型,因此都包含在新的throws子句中。

在下一节Throws中,您将阅读一些使用throws子句中基本异常类的优缺点,在稍后的异常处理的一些最佳实践部分中,我们将讨论一些其他可能的解决方案。

与此同时,让我们继续讨论已检查异常的使用。在我们的演示代码中,我们决定在method1()的声明中添加throws Exception子句。这个改变立即在method2()中触发了相同的错误Unhandled exception java.lang.InstantiationException,因为method2()调用了method1()但没有处理Exception。因此,我们不得不在method2()中也添加一个throws子句:

void method2() throws Exception{
  method1(null);
}

只有method2()的调用者——exceptionCaught()方法——不需要更改,因为它处理Exception类型。代码的最终版本是:

void exceptionCaught(){
  try {
    method2();
  } catch (NullPointerException ex){
    System.out.println("NPE caught");
    ex.printStackTrace();
  } catch (RuntimeException ex){
    System.out.println("RuntimeException caught");
    ex.printStackTrace();
  } catch (Exception ex){
    System.out.println("Exception caught");
    ex.printStackTrace();
  }
}

void method2() throws Exception{
  method1(null);
}

void method1(String s) throws Exception{
  throw new InstantiationException("No value for the field" +
                                           " someField of SomeClass.");
}

如果我们现在调用exceptionCaught()方法,结果将是:

这正是我们所期望的。Exception类型的最后一个catch子句匹配了InstantiationException类型。

未检查的异常——RuntimeExceptions类的后代——在编译时不会被检查,因此得名,并且不需要在throws子句中列出。

一般来说,已检查异常(应该)用于可恢复的条件,而未检查异常用于不可恢复的条件。我们将在稍后的什么是异常处理?一些最佳实践 异常处理部分中更多地讨论这个问题。

抛出

throws子句必须列出方法或构造函数可以抛出的所有已检查异常类(Exception类的后代,但不是RuntimeException类的后代)。在throws子句中列出未检查的异常类(RuntimeException类的后代)是允许的,但不是必需的。以下是一个例子:

void method1(String s) 
           throws InstantiationException, InterruptedException {
  //some input data validation 
  if(some data missing){
    throw new InstantiationException("No value for the field" +
                                     " someField of SomeClass.");
  }
  //some other code
  if(some other reason){
    throw new InterruptedException("Reason..."); //checked exception 
  }
}

或者,可以只列出throws子句中的基类异常,而不是声明抛出两种不同的异常:

void method1(String s) throws Exception {
  //some input data validation 
  if(some data missing){
    throw new InstantiationException("No value for the field" +
                                     " someField of SomeClass.");
  }
  //some other code
  if(some other reason){
    throw new InterruptedException("Reason..."); //checked exception 
  }
}

然而,这意味着潜在失败的多样性和可能的原因将隐藏在客户端,因此一个人必须要么:

  • 在方法内处理异常

  • 假设客户端代码将根据消息的内容来确定其行为(这通常是不可靠的并且可能会发生变化)

  • 假设客户端无论实际的异常类型是什么都会表现相同

  • 假设该方法永远不会抛出任何其他已检查异常,如果确实抛出,客户端的行为不应该改变

有太多的假设让人感到不舒服,只声明throws子句中的基类异常。但有一些最佳实践可以避免这种困境。我们将在异常处理的一些最佳实践部分中讨论它们。

自定义异常

在这一部分,我们承诺讨论自定义异常创建的动机。以下是两个例子:

//Unchecked custom exception
class MyRuntimeException extends RuntimeException{
  public MyRuntimeException(String message){
    super(message);
  }
  //whatever code you need to have here
}

//Checked custom exception
class MyException extends Exception{
  public MyException(String message){
    super(message);
  }
  //whatever code you need to have here
}

直到你意识到注释这里需要任何代码允许你在自定义类中放入任何数据或功能,并利用异常处理机制将这样的对象从任何代码深度传播到任何你需要的级别,这些示例看起来并不特别有用。

由于这只是 Java 编程的介绍,这些情况超出了本书的范围。我们只是想确保你知道这样的功能存在,所以当你需要它或构建你自己的创新解决方案时,你可以在互联网上搜索。

然而,在 Java 社区中有关利用异常处理机制进行业务目的的讨论仍在进行中,我们将在异常处理的一些最佳实践部分中稍后讨论。

什么是异常处理?

正如我们已经提到的,检查异常最初被认为是用于可恢复的条件,当调用者代码可能会自动执行某些操作并根据捕获的异常类型和可能携带的数据采取另一个执行分支时。这就是异常处理的主要目的和功能。

不幸的是,这种利用异常的方式被证明并不是非常有效,因为一旦发现异常条件,代码就会得到增强,并使这样的条件成为可能的处理选项之一,尽管并不经常执行。

次要功能是记录错误条件和所有相关信息,以供以后分析和代码增强。

异常处理的第三个同样重要的功能是保护应用程序免受完全失败。意外情况发生了,但希望这种情况很少,主流处理仍然可用于应用程序继续按设计工作。

异常处理的第四个功能是在其他手段不够有效的特殊情况下提供信息传递的机制。异常处理的这最后一个功能仍然存在争议,且并不经常使用。我们将在下一节讨论它。

异常处理的一些最佳实践

Java 异常处理机制旨在解决可能的边缘情况和意外的程序终止。预期的错误类别是:

  • 可恢复的:可以根据应用逻辑自动修复的异常

  • 不可恢复的:无法自动纠正并导致程序终止的异常

通过引入已检查的异常(Exception类的后代)来解决第一类错误,而第二类错误则成为未经检查的异常领域(RuntimeException类的后代)。

不幸的是,这种分类方法在编程实践中并不符合实际情况,特别是对于与开发旨在在各种环境和执行上下文中使用的库和框架无关的编程领域。典型的应用程序开发总是能够直接在代码中解决问题,而无需编写复杂的恢复机制。这种区别很重要,因为作为库的作者,你永远不知道你的方法将在何处以及如何被使用,而作为应用程序开发人员,你确切地了解环境和执行上下文。

即使在写作时,Java 的作者们间接地确认了这一经验,向java.lang包中添加了 15 个未经检查的异常和仅九个已检查的异常。如果原始期望得到了实践的确认,人们会期望只有少数不可恢复的(未经检查的)异常和更多类型的可恢复的(已检查的)异常。与此同时,甚至java.lang包中的一些已检查的异常看起来也不太可恢复:

  • ClassNotFoundException:当 JVM 无法找到所引用的类时抛出

  • CloneNotSupportedException:指示对象类中的克隆方法未实现Cloneable接口

  • IllegalAccessException:当当前执行的方法无法访问指定类、字段、方法或构造函数的定义时抛出

实际上,很难找到一种情况,其中编写自动恢复代码比只是在主流处理中添加另一个逻辑分支更值得。

考虑到这一点,让我们列举一些被证明是有用和有效的最佳实践:

  • 始终捕获所有异常

  • 尽可能接近源头处理每个异常

  • 除非必须,否则不要使用已检查的异常

  • 通过重新抛出它们作为带有相应消息的RuntimeException,将第三方已检查的异常转换为未经检查的异常

  • 除非必须,否则不要创建自定义异常

  • 除非必须,否则不要使用异常处理机制来驱动业务逻辑

  • 通过使用消息系统和可选的枚举类型自定义通用的RuntimeException,而不是使用异常类型来传达错误的原因

最后

finally块可以添加到带有或不带有catch子句的try块中。格式如下:

try {
  //code of the try block
} catch (...){
  //optional catch block code
} finally {
  //code of the finally block
}

如果存在,则finally块中的代码总是在方法退出之前执行。无论try块中的代码是否抛出异常,以及这个异常是否在catch块中的一个中被处理,或者try块中的代码是否没有抛出异常,finally块都会在方法返回控制流到调用者之前执行。

最初,finally块用于关闭try块中需要关闭的一些资源。例如,如果代码已经打开了到数据库的连接,或者已经在磁盘上与文件建立了读取或写入连接,那么在操作完成或抛出异常时必须关闭这样的连接。否则,未及时关闭的连接会使资源(维护连接所需的资源)被锁定而不被使用。我们将在[第十一章](e8c37d86-291d-4500-84ea-719683172477.xhtml)JVM 进程和垃圾回收中讨论 JVM 进程。

因此,典型的代码看起来像这样:

Connection conn = null;
try {
  conn = createConnection();
  //code of the try block
} catch (...){
  //optional catch block code
} finally {
  if(conn != null){
    conn.close();
  }
}

它运行得很好。但是,一个名为try...with...resources的新的 Java 功能允许在连接类实现AutoCloseable时自动关闭连接(大多数流行的连接类都是这样)。我们将在[第十六章](d77f1f16-0aa6-4d13-b9a8-f2b6e195f0f1.xhtml)数据库编程中讨论try...with...resources结构。这一发展降低了finally块的实用性,现在它主要用于处理一些不能使用AutoCloseable接口执行的代码,但必须在方法无条件返回之前执行。例如,我们可以通过利用finally块来重构我们的exceptionCaught()方法,如下所示:

void exceptionCaught(){
  Exception exf = null;
  try {
    method2();
  } catch (NullPointerException ex){
    exf = ex;
    System.out.println("NPE caught");
  } catch (RuntimeException ex){
    exf = ex;
    System.out.println("RuntimeException caught");
  } catch (Exception ex){
    exf = ex;
    System.out.println("Exception caught");
  } finally {
    if(exf != null){
      exf.printStackTrace();
    }
  }

还有其他情况下的finally块使用,基于它在控制流返回给方法调用者之前的保证执行。

Assert 需要 JVM 选项-ea

分支assert语句可用于验证应用程序测试中的数据,特别是用于访问很少使用的执行路径或数据组合。这种能力的独特之处在于,除非 JVM 使用选项-ea运行,否则不会执行代码。

本书不讨论assert语句的功能和可能的应用。我们只会演示它的基本用法以及如何在 IntelliJ IDEA 中打开它。

看看下面的代码:

public class AssertDemo {
  public static void main(String... args) {
    int x = 2;
    assert x > 1 : "x <= 1";
    assert x == 1 : "x != 1";
  }
}

第一个assert语句评估表达式x>1,如果表达式x>1评估为false,则停止程序执行(并报告x<=1)。

第二个assert语句评估表达式x == 1,如果表达式x == 1评估为false,则停止程序执行(并报告x!= 1)。

如果我们现在运行这个程序,将不会执行任何assert语句。要打开它们,请单击 IntelliJ IDEA 菜单中的 Run 并选择 Edit Configurations,如下面的屏幕截图所示:

![](img / 4cfd5dda-e07c-45ec-b9bd-13c4e4b6ac33.png)

运行/调试配置屏幕将打开。在 VM 选项字段中键入-ea,如下面的屏幕截图所示:

![](img / 8019cb61-0d4f-4d29-8d28-d10aef60490e.png)

然后,点击屏幕底部的确定按钮。

如果现在运行AssertDemo程序,结果将是:

-ea选项不应该在生产中使用,除非可能是为了测试目的而临时使用,因为它会增加开销并影响应用程序的性能。

练习-无限循环

写一个或两个无限循环的例子。

答案

以下是一个可能的无限循环实现:

while(true){
  System.out.println("try and stop me"); //prints indefinitely
}

以下是另一个:

for (;;){
  System.out.println("try and stop me"); //prints indefinitely
}

这也是一个无限循环:

for (int x=2; x > 0; x--){
  System.out.println(x++ + " "); //prints 2 indefinitely
}

在这段代码中,布尔表达式x > 0总是被评估为true,因为x被初始化为2,然后在每次迭代中递增和递减1

总结

本章描述了 Java 语句,让您根据实现的算法逻辑构建程序流,使用条件语句、迭代语句、分支语句和异常处理。对 Java 异常的广泛讨论帮助您在这个复杂且经常正确使用的领域中进行导航。为最有效和最少混淆的异常处理提供了最佳实践。

在下一章中,我们将深入了解 JVM 的内部工作机制,讨论其进程和其他重要方面,包括线程和垃圾回收机制,这些对于有效的 Java 编程非常重要,它们帮助应用程序重新获得不再使用的内存。

第十一章:JVM 进程和垃圾回收

本章使读者能够深入了解 JVM 并了解其进程。 JVM 的结构和行为比仅仅按照编码逻辑执行一系列指令的执行器更复杂。 JVM 会找到并加载应用程序请求的.class文件到内存中,对其进行验证,解释字节码(将其转换为特定平台的二进制代码),并将生成的机器代码传递给中央处理器(或处理器)进行执行,除了应用程序线程外,还使用几个服务线程。其中一个名为垃圾回收的服务线程执行重要任务,即释放未使用对象的内存。

在本章中,我们将涵盖以下主题:

  • JVM 进程是什么?

  • JVM 架构

  • 垃圾回收

  • 线程

  • 练习-在运行应用程序时监视 JVM

JVM 进程是什么?

正如我们在第一章中已经确定的那样,计算机上的 Java 虚拟机(JVM),JVM 对 Java 语言和源代码一无所知。 它只知道如何读取字节码。 它从.class文件中读取字节码和其他信息,解释它(将其转换为特定微处理器的二进制代码序列),并将结果传递给执行它的计算机。

在谈论它时,程序员经常将 JVM 称为* JVM 实例进程*。 这是因为每次执行java命令时,都会启动一个新的 JVM 实例,专门用于在单独的进程中运行特定应用程序,并分配内存大小(默认或作为命令选项传递)。 在这个 JVM 进程内部,多个线程正在运行,每个线程都有自己分配的内存; 一些是 JVM 创建的服务线程,而其他是应用程序创建和控制的应用程序线程。

线程是轻量级进程,需要比 JVM 执行进程更少的资源分配。

这是 JVM 执行编译代码的大局观。 但是,如果您仔细观察并阅读 JVM 规范,您会发现与 JVM 相关的“进程”一词被重复使用了很多次。 JVM 规范确定了 JVM 内部运行的其他几个进程,程序员通常不提及它们,除了可能是类加载过程。

这是因为大多数情况下,人们可以成功地编写和执行 Java 程序,而无需了解 JVM 的更多信息。 但是偶尔,对 JVM 内部工作原理的一些一般了解有助于确定某些相关问题的根本原因。 这就是为什么在本节中,我们将简要概述 JVM 内部发生的所有进程。 然后,在接下来的几节中,我们将更详细地讨论 JVM 的内存结构和 JVM 功能的其他一些方面,这可能对程序员有用。

有两个子系统运行所有 JVM 内部进程:

  • 类加载器,读取.class文件并使用类相关数据填充 JVM 内存中的方法区域:

  • 静态字段

  • 方法字节码

  • 描述类的类元数据

  • 执行引擎,使用以下内容执行字节码:

  • 堆区用于对象实例化

  • Java 和本地方法堆栈用于跟踪调用的方法

  • 垃圾回收过程以回收内存

运行在主 JVM 进程内部的进程列表包括:

  • 类加载器执行的进程:

  • 类加载

  • 类链接

  • 类初始化

  • 执行引擎执行的进程:

  • 类实例化

  • 方法执行

  • 垃圾回收

  • 应用程序终止

JVM 架构可以描述为具有两个子系统 - 类加载器和执行引擎 - 它们使用运行时数据内存区域运行服务进程和应用程序线程:方法区域,堆和应用程序线程堆栈。

前面的列表可能会让你觉得这些过程是按顺序执行的。在某种程度上,如果我们只谈论一个类的话,这是正确的。在加载之前无法对类做任何操作。只有在完成所有先前的过程之后,方法的执行才能开始。然而,例如垃圾回收并不会在对象停止使用后立即发生(请参阅下一节,垃圾回收)。此外,应用程序可能在发生未处理的异常或其他错误时随时退出。

JVM 规范只对类加载器进程进行了规定。执行引擎的实现在很大程度上取决于每个供应商。它基于语言语义和实现作者设定的性能目标。

执行引擎的过程不受 JVM 规范的约束。有常识、传统、已知和经过验证的解决方案,以及 Java 语言规范可以指导 JVM 供应商的实现决策,但没有单一的监管文件。好消息是,最流行的 JVM 使用类似的解决方案,或者至少从入门课程的高层来看是这样的。有关特定供应商的详细信息,请参阅维基百科上的Java 虚拟机比较en.wikipedia.org/wiki/Comparison_of_Java_virtual_machines)和其他互联网上可用的来源。

有了这个理解,让我们更详细地描述之前列出的七个过程中的每一个。

加载

根据 JVM 规范,加载阶段包括通过其名称找到.class文件并在内存中创建其表示。

要加载的第一个类是在命令行中传递的带有main(String[])方法的类。我们之前在第四章中描述过它,你的第一个 Java 项目。类加载器读取.class文件,根据内部数据结构解析它,并用静态字段和方法字节码填充方法区。它还创建了描述该类的java.lang.Class的实例。然后,类加载器链接(见链接部分)和初始化(见初始化部分)该类,并将其传递给执行引擎以运行其字节码。

在第四章中的第一个项目,你的第一个 Java 项目中,main(String[])方法没有使用任何其他方法或类。但在实际应用程序中,main(String[])方法是应用程序的入口。如果它调用另一个类的方法,那么必须在类路径上找到该类并读取、解析和初始化;只有这样它的方法才能被执行。依此类推。这就是 Java 应用程序的启动和运行方式。

在接下来的部分如何执行 main(String[])方法中,我们将展示 Java 应用程序可以启动的几种方式,包括使用带有清单的可执行.jar文件。

每个类都允许有一个main(String[])方法,通常也有。这样的方法用于独立运行类作为独立应用程序进行测试或演示。这样的方法的存在并不使类成为主类。只有在java命令行或.jar文件清单中标识为主类时,该类才成为主类。

说了这些,让我们继续讨论加载过程。

如果查看java.lang.Class的 API,你不会在那里看到公共构造函数。类加载器会自动创建它的实例,并且顺便说一句,这是由getClass()方法返回的相同实例,你可以在任何对象上调用该方法。它不携带类的静态数据(这些数据在方法区中维护)或状态(它们在执行期间创建的对象中)。它也不包含方法的字节码(这也存储在方法区中)。相反,Class实例提供描述类的元数据 - 其名称、包、字段、构造函数、方法签名等。这就是为什么它不仅对 JVM 有用,对应用程序代码也有用,正如我们已经在一些示例中看到的。

类加载器在内存中创建并由执行引擎维护的所有数据称为类型的二进制表示。

如果.class文件存在错误或不符合特定格式,该过程将被终止。这意味着加载过程会对加载的类格式及其字节码进行一些验证。但更多的验证将在下一个称为链接的过程开始时进行。

以下是加载过程的高级描述。它执行三项任务:

  • 查找并读取.class文件

  • 根据内部数据结构将其解析到方法区

  • 创建一个携带类元数据的java.lang.Class的实例

链接

根据 JVM 规范,链接是解析已加载类的引用,以便执行类的方法。

虽然 JVM 可以合理地期望.class文件是由 Java 编译器生成的,并且所有指令都满足语言的约束和要求,但无法保证加载的文件是由已知的编译器实现或根本没有编译器生成的。这就是为什么链接过程的第一步是验证,以确保类的二进制表示在结构上是正确的:每个方法调用的参数与方法描述符兼容,返回指令与其方法的返回类型匹配,依此类推。

验证成功完成后,下一步是准备。接口或类(静态)变量在方法区中创建,并初始化为其类型的默认值。其他类型的初始化(由程序员指定的显式赋值和静态初始化块)被推迟到称为初始化的过程中(请参阅下一节初始化)。

如果加载的字节码引用其他方法、接口或类,则符号引用将被解析为指向方法区的具体引用,这是通过解析过程完成的。如果所引用的接口和类尚未加载,类加载器会找到它们并根据需要加载它们。

以下是链接过程的高级描述。它执行三项任务:

  • 验证类或接口的二进制表示

  • 在方法区中准备静态字段

  • 将符号引用解析为指向方法区的具体引用

初始化

根据 JVM 规范,初始化是通过执行类初始化方法来完成的。

这是程序员定义的初始化(在静态块和静态赋值中)进行的时候,除非类已经在另一个类的请求下进行了初始化。

这个陈述的最后一部分很重要,因为该类可能会被不同(已加载)方法多次请求,并且因为 JVM 进程由不同线程执行(参见线程部分中线程的定义),可能会同时访问同一个类。因此,需要在不同线程之间进行协调(也称为同步),这大大复杂了 JVM 的实现。

实例化

从技术上讲,由new操作符触发的实例化过程是执行的第一步,这一部分可能不存在。但是,如果main(String[])方法(静态方法)只使用其他类的静态方法,实例化就永远不会发生。这就是为什么将这个过程与执行分开是合理的。此外,这个活动有非常具体的任务:

  • 在堆区为对象(其状态)分配内存

  • 将实例字段初始化为默认值

  • 为 Java 和本地方法创建线程堆栈

执行从第一个方法(不是构造函数)准备执行开始。为每个应用程序线程创建一个专用的运行时堆栈,在其中捕获每个方法调用的堆栈帧。如果发生异常,我们可以从当前堆栈帧中调用printStackTrace()方法获取数据。

执行

main(String[])方法开始执行时,将创建第一个应用程序线程(称为线程)。它可以创建其他应用程序线程。执行引擎读取字节码,解释它们,并将二进制代码发送到微处理器执行。它还维护了每个方法被调用的次数和频率的计数。如果计数超过一定阈值,执行引擎将使用一个称为 JIT 编译器的编译器,将方法的字节码编译成本地代码。下次调用该方法时,它将准备好而无需解释。这大大提高了代码的性能。

当前正在执行的指令和下一条指令的地址都保存在程序计数器PC)寄存器中。每个线程都有自己专用的 PC 寄存器。这也提高了性能并跟踪执行情况。

垃圾收集

垃圾收集器GC)运行的过程是识别不再被引用的对象,因此可以从内存中删除。有一个 Java 静态方法System.gc(),可以通过编程方式触发垃圾收集,但不能保证立即执行。每次 GC 循环都会影响应用程序的性能,因此 JVM 必须在内存可用性和执行字节码的速度之间保持平衡。

应用程序终止

应用程序可以通过多种方式(以及通过编程方式)终止(并停止 JVM):

  • 正常终止而没有错误状态码

  • 由于未处理的异常或强制的编程方式退出而导致的异常终止,无论是否带有错误状态码

如果没有异常和无限循环,main(String[])方法将通过return语句或在执行其最后一条语句后完成。一旦发生这种情况,主应用程序线程将控制流返回给 JVM,JVM 也停止执行。

这是一个幸福的结局,许多应用程序在现实生活中也享受着这种结局。除了我们展示了异常或无限循环的例外情况,大多数示例也都成功结束了。

然而,Java 应用程序还有其他退出方式,其中一些方式也相当优雅。其他方式则不那么优雅。

如果主应用程序线程创建了子线程,或者换句话说,程序员编写了生成其他线程的代码,即使优雅地退出也可能不那么容易。这完全取决于创建的子线程的类型。如果其中任何一个是用户线程(默认情况下),那么即使主线程退出后,JVM 实例也会继续运行。

只有在所有用户线程完成后,JVM 实例才会停止。主线程可以请求子用户线程完成(我们将在下一节线程中讨论这一点)。但在退出之前,JVM 会继续运行,这意味着应用程序仍在运行。

但是,如果所有子线程都是守护线程(请参阅下一节线程),或者没有正在运行的子线程,那么一旦主应用程序线程退出,JVM 实例就会停止运行。

在没有强制终止的情况下,JVM 实例会继续运行,直到主应用程序线程和所有子用户线程完成。如果没有子用户线程或者所有子线程都是守护线程,那么一旦主应用程序线程退出,JVM 就会停止运行。

在异常情况下应用程序如何退出取决于代码设计。我们在上一章讨论异常处理的最佳实践时已经提到过。如果线程在main(String[])或类似高级方法中的try...catch块中捕获了所有异常,那么控制流将返回到应用程序代码,并由应用程序(以及编写代码的程序员)决定如何继续——尝试恢复、记录错误并继续,或者退出。

另一方面,如果异常仍未处理并传播到 JVM 代码中,那么发生异常的线程将停止执行并退出。然后,将发生以下情况之一:

  • 如果没有其他线程,则 JVM 停止执行并返回错误代码和堆栈跟踪

  • 如果出现未处理的异常的线程不是主线程,则其他线程(如果存在)会继续运行

  • 如果主线程抛出未处理的异常,并且子线程(如果存在)是守护线程,则它们也会退出

  • 如果至少有一个用户子线程,JVM 会继续运行,直到所有用户线程退出

还有一些编程方法可以强制应用程序停止:

  • System.exit(0);

  • Runtime.getRuntime().exit(0);

  • Runtime.getRuntime().halt(0);

所有前述方法都会强制 JVM 停止执行任何线程,并以作为参数传递的状态代码(在我们的示例中为 0)退出:

  • 零表示正常终止

  • 非零值表示异常终止

如果 Java 命令是由某个脚本或其他系统启动的,则状态代码的值可用于自动化决定下一步的操作。但这已经超出了应用程序和 Java 代码的范围。

前两种方法具有相同的功能,因为System.exit()的实现方式如下:

public static void exit(int status) {
  Runtime.getRuntime().exit(status);
}

要在 IDE 中查看源代码,只需单击该方法。

当某个线程调用RuntimeSystem类的exit()方法,或者Runtime类的halt()方法,并且退出或中止操作被安全管理器允许时,Java 虚拟机退出。

exit()halt()之间的区别在于halt()会立即强制 JVM 退出,而exit()会执行可以使用Runtime.addShutdownHook()方法设置的附加操作。

但所有这些选项在主流编程中很少使用,因此我们已经超出了本书的范围。

JVM 架构

JVM 架构可以用内存中的运行时数据结构和使用运行时数据的两个子系统——类加载器和执行引擎来描述。

运行时数据区

JVM 内存的每个运行时数据区都属于两个类别之一:

  • 共享区域,包括以下内容:

  • 方法区:类元数据,静态字段,方法字节码

  • 堆区:对象(状态)

  • 不共享区域,专门为每个应用程序线程而设,包括以下内容:

  • Java 堆栈:当前和调用者帧,每个帧保持 Java(非本地)方法调用的状态:

  • 本地变量的值

  • 方法参数值

  • 中间计算的操作数的值(操作数栈)

  • 方法返回值(如果有)

  • 程序计数器(PC)寄存器:下一条要执行的指令

  • 本地方法堆栈:本地方法调用的状态

我们已经讨论过,程序员在使用引用类型时必须小心,不要修改对象本身,除非需要这样做。在多线程应用程序中,如果对象的引用可以在线程之间传递,就必须特别小心,因为可能会同时修改相同的数据。

光明的一面是,这样的共享区域可以并且经常被用作线程之间的通信手段。我们将在即将到来的Threads部分讨论这个问题。

类加载器

类加载器执行以下三个功能:

  • 读取.class文件

  • 填充方法区

  • 初始化程序员未初始化的静态字段

执行引擎

执行引擎执行以下操作:

  • 在堆区实例化对象

  • 使用程序员编写的初始化器初始化静态和实例字段

  • 向 Java 堆栈添加/删除帧

  • 更新 PC 寄存器以执行下一条指令

  • 维护本地方法堆栈

  • 保持方法调用的计数并编译流行的方法

  • 完成对象

  • 运行垃圾回收

  • 终止应用程序

线程

正如我们已经提到的,主应用程序线程可以创建其他 - 子 - 线程,并让它们并行运行,无论是通过时间切片共享同一个核心,还是为每个线程分配一个专用的 CPU。可以使用实现了功能接口Runnable的类java.lang.Thread来实现。如果接口只有一个抽象方法,就称为功能接口(我们将在第十七章中讨论功能接口,Lambda 表达式和函数式编程)。Runnable接口包含一个方法run()

有两种方法创建新线程:

  • 扩展Thread

  • 实现Runnable接口,并将实现的对象传递到类Thread的构造函数中

扩展 Thread 类

无论使用什么方法,最终我们都会得到一个具有start()方法的Thread类对象。这个方法调用开始线程执行。让我们看一个例子。让我们创建一个名为AThread的类,它扩展了Thread并重写了它的run()方法:

public class AThread extends Thread {
  int i1, i2;
  public AThread(int i1, int i2) {
    this.i1 = i1;
    this.i2 = i2;
  }
  public void run() {
    for (int i = i1; i <= i2; i++) {
      System.out.println("child thread " + (isDaemon() ? "daemon" : "user") + " " + i);
      try {
        TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
}

重写run()方法很重要,否则线程将不执行任何操作。Thread类实现了Runnable接口,并且有run()方法的实现,但它看起来如下:

public void run() {
  if (target != null) {
    target.run();
  }
}

变量target保存在构造函数中传递的值:

public Thread(Runnable target) {
  init(null, target, "Thread-" + nextThreadNum(), 0);
}

但是我们的AThread类没有向父类Target传递任何值;变量 target 是null,所以Thread类中的run()方法不执行任何操作。

现在让我们使用我们新创建的线程。我们期望它将变量ii1增加到i2(这些是通过构造函数传递的参数),并打印其值以及isDaemon()方法返回的布尔值,然后等待(休眠)1 秒并再次增加变量i

什么是守护进程?

“守护”一词源自古希腊语,意思是介于神和人之间的神性或超自然存在,以及内在或随从精神或激励力量。但在计算机科学中,这个术语有更加平凡的用法,用于指代作为后台进程运行的计算机程序,而不是受交互式用户直接控制。这就是为什么 Java 中有两种类型的线程:

  • 用户线程(默认),由应用程序发起(主线程就是这样的一个示例)

  • 在支持用户线程活动的后台运行的守护线程(垃圾收集是守护线程的一个示例)

这就是为什么所有守护线程在最后一个用户线程退出或 JVM 在未处理的异常后终止之后立即退出。

扩展线程运行

让我们使用我们的新类AThread来演示我们所描述的行为。这是我们首先要运行的代码:

Thread thr1 = new AThread(1, 4);
thr1.start();

Thread thr2 = new AThread(11, 14);
thr2.setDaemon(true);
thr2.start();

try {
  TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
  e.printStackTrace();
}
System.out.println("Main thread exists");

在前面的代码中,我们创建并立即启动了两个线程-用户线程thr1和守护线程thr2。实际上,还有一个名为main的用户线程,所以我们运行了两个用户线程和一个守护线程。每个子线程将打印递增的数字四次,每次打印后暂停 1 秒。这意味着每个线程将运行 4 秒。主线程也会暂停 1 秒,但只有一次,所以它将运行大约 1 秒。然后,它打印“主线程存在”并退出。如果我们运行此代码,将看到以下输出:

![](img / 42afcacb-82d0-414b-afd4-e5d36be0c2d5.png)

我们在一个共享的 CPU 上执行此代码,因此,尽管所有三个线程都在同时运行,但它们只能顺序使用 CPU。因此,它们不能并行运行。在多核计算机上,每个线程可以在不同的 CPU 上执行,输出可能略有不同,但差别不大。无论如何,您会看到主线程首先退出(大约 1 秒后),子线程运行直到完成,每个线程总共运行大约 4 秒。

让用户线程只运行 2 秒:

Thread thr1 = new AThread(1, 2);
thr1.start();

结果是:

![](img / ab8a6642-440f-4a0b-af2e-1589b74c8613.png)

如您所见,守护线程没有完全运行。它成功打印了 13,可能仅因为它在 JVM 响应最后一个用户线程退出之前已将消息发送到输出设备。

实现 Runnable

创建线程的第二种方法是使用实现Runnable的类。以下是一个几乎与类AThread具有完全相同功能的类的示例:

public class ARunnable implements Runnable {
  int i1, i2;

  public ARunnable(int i1, int i2) {
    this.i1 = i1;
    this.i2 = i2;
  }

  public void run() {
    for (int i = i1; i <= i2; i++) {
      System.out.println("child thread "  + i);
      try {
        TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
}

唯一的区别是Runnable接口中没有isDaemon()方法,因此我们无法打印线程是否为守护线程。

运行实现 Runnable 的线程

以下是如何使用此类创建两个子线程-一个用户线程和另一个守护线程-与我们之前所做的完全相同:

Thread thr1 = new Thread(new ARunnable(1, 4));
thr1.start();

Thread thr2 = new Thread(new ARunnable(11, 14));
thr2.setDaemon(true);
thr2.start();

try {
  TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
  e.printStackTrace();
}

System.out.println("Main thread exists");

如果我们运行前面的代码,结果将与基于扩展Thread类的线程运行相同。

扩展 Thread 与实现 Runnable

实现Runnable的优点(在某些情况下,也是唯一可能的选项)是允许实现扩展另一个类。当您想要向现有类添加类似线程的行为时,这是特别有帮助的。

public class BRunnable extends SomeClass implements Runnable {
  int i; 
  BRunnable(int i, String s) {
    super(s);
    this.i = i;
  }
  public int calculateSomething(double x) {
    //calculate result
    return result;
  }
  public void run() {
    //any code you need goes here
  }
}

您甚至可以直接调用方法run(),而不将对象传递到 Thread 构造函数中:

BRunnable obj = new BRunnable(2, "whatever");
int i = obj.calculateSomething(42d);
obj.run(); 
Thread thr = new Thread (obj);
thr.start(); 

在上面的代码片段中,我们展示了执行实现Runnable的类的方法的许多不同方式。因此,实现Runnable允许更灵活地使用。但是,与扩展Thread相比,在功能上没有区别。

Thread类有几个构造函数,允许设置线程名称和它所属的组。对线程进行分组有助于在许多线程并行运行的情况下对其进行管理。Thread类还有几种方法,提供有关线程状态和属性的信息,并允许我们控制其行为。

线程——以及任何对象——也可以使用基类java.lang.Objectwait()notify()notifyAll()方法相互通信。

但所有这些都已经超出了入门课程的范围。

如何执行 main(String[])方法

在深入讨论垃圾收集过程之前,我们想要回顾并总结如何从命令行运行应用程序。在 Java 中,以下语句用作同义词:

  • 运行/执行主类

  • 运行/执行/启动应用程序

  • 运行/执行/启动主方法

  • 运行/执行/启动/启动 JVM 或 Java 进程

这是因为列出的每个操作都会在执行其中一个操作时发生。还有几种方法可以做到这一点。我们已经向您展示了如何使用 IntelliJ IDEA 和java命令行运行main(String[])方法。现在,我们将重复已经说过的一些内容,并添加其他可能对您有帮助的变体。

使用 IDE

任何 IDE 都允许运行主方法。在 IntelliJ IDEA 中,有三种方法可以做到这一点:

  • 通过单击方法名称旁边的绿色箭头

  • 通过从下拉菜单中选择类名(在顶部行的左侧,绿色箭头的左侧)并单击菜单右侧的绿色箭头:

  • 通过单击运行菜单并选择类的名称:

在上面的截图中,您还可以看到选项“编辑配置”。我们已经使用它来设置可以在启动时传递给主方法的参数。但是还有更多的设置可能:

正如您所看到的,还可以设置:

  • VM 选项:Java 命令选项(我们将在下一节中进行)

  • 环境变量:设置一些参数,不仅可以在主方法中读取,还可以在应用程序的任何地方使用System.getenv()方法

例如,看看以下截图:

我们已经设置了java命令选项-Xlog:gc和环境变量myprop1=whatever。IDE 将使用这些设置来形成以下java命令:

java -Xlog:gc -Dmyprop1=whatever com.packt.javapath.ch04demo.MyApplication 2

选项-Xlog:gc告诉 JVM 显示来自垃圾回收过程的日志消息。我们将在下一节中使用此选项来演示垃圾回收的工作原理。可以使用以下语句在应用程序的任何位置检索变量myprop1的值:

String myprop = System.getenv("myprop1");     //returns: "whatever"

我们已经看到参数 2 如何在主方法中读取:

public static void main(String[] args) {
  String p1 = args[0];          //returns: "2"
}

带有类路径上的类的命令行

让我们使用我们在第四章中创建的第一个程序,Your First Java Project,来演示如何使用命令行。以下是我们当时编写的程序:

package com.packt.javapath.ch04demo;
import com.packt.javapath.ch04demo.math.SimpleMath;
public class MyApplication {
  public static void main(String[] args) {
    int i = Integer.parseInt(args[0]);
    SimpleMath simpleMath = new SimpleMath();
    int result = simpleMath.multiplyByTwo(i);
    System.out.println(i + " * 2 = " + result);
  }
}

要从命令行运行它,必须首先使用javac命令对其进行编译。使用 Maven 的 IDE 将.class文件放在目录target/classes中。如果进入项目的根目录或单击 Terminal(IntelliJ IDEA 左下角),可以运行以下命令:

java -cp target/classes com.packt.javapath.ch04demo.MyApplication 2

结果应显示为2 * 2 = 4

带有类路径上的.jar 文件的命令行

创建一个带有编译应用程序代码的.jar文件,转到项目根目录并运行以下命令:

cd target/classes
jar -cf myapp.jar com/packt/javapath/ch04demo/**

创建了一个带有类MyApplicationSimpleMath.jar文件。现在我们可以将其放在类路径上并再次运行应用程序:

java -cp myapp.jar com.packt.javapath.ch04demo.MyApplication 2

结果将显示相同;2 * 2 = 4

带有可执行.jar 文件的命令行

可以避免在命令行中指定主类。相反,可以创建一个“可执行”的.jar文件。可以通过将主类的名称(需要运行的类,包含主方法的类)放入清单文件中来实现。以下是步骤:

  • 创建一个文本文件manifest.txt(实际名称并不重要,但它可以清楚地表达意图),其中包含以下一行:Main-Class: com.packt.javapath.ch04demo.MyApplication。冒号(:)后必须有一个空格,并且末尾必须有一个不可见的换行符号,因此请确保您按下了Enter键并且光标已跳转到下一行的开头。

  • 执行命令cd target/classes并进入目录classes

  • 执行以下命令:jar -cfm myapp.jar  manifest.txt  com/packt/javapath/ch04demo/**

注意jar命令选项fm的顺序和以下文件的顺序;myapp.jar manifest.txt。它们必须相同,因为f代表jar命令将要创建的文件,m代表清单源。如果将选项放置为mf,则文件必须列为manifest.txt myapp.jar

现在,运行以下命令:

java -jar  myapp.jar  2

结果将再次显示为2 * 2 = 4

具备运行应用程序的知识后,我们现在可以继续到下一节,那里将需要它。

垃圾回收

自动内存管理是 JVM 的一个重要方面,它使程序员无需以编程方式进行内存管理。在 Java 中,清理内存并允许您重用它的过程称为垃圾回收GC)。

响应性、吞吐量和停顿时间

垃圾收集的有效性影响着两个主要应用程序特征 - 响应性和吞吐量。响应性是指应用程序对请求的快速响应(提供必要数据)的度量。例如,网站返回页面的速度,或者桌面应用程序对事件的快速响应。响应时间越短,用户体验就越好。另一方面,吞吐量表示应用程序在单位时间内可以完成的工作量。例如,一个 Web 应用程序可以提供多少请求,或者一个数据库可以支持多少交易。数字越大,应用程序可能产生的价值就越大,可以支持的用户请求也就越多。

与此同时,垃圾收集器需要移动数据,这在允许数据处理的同时是不可能完成的,因为引用将会发生变化。这就是为什么垃圾收集器需要偶尔停止应用程序线程的执行一段时间,这段时间被称为停顿时间。这些停顿时间越长,垃圾收集器完成工作的速度就越快,应用程序冻结的时间也就越长,最终可能会足够大以至于影响应用程序的响应性和吞吐量。幸运的是,可以使用java命令选项来调整垃圾收集器的行为,但这超出了本书的范围,本书更多地是介绍而不是解决复杂问题。因此,我们将集中讨论垃圾收集器主要活动的高层视图;检查堆中的对象并删除那些在任何线程堆栈中没有引用的对象。

对象年龄和代

基本的垃圾收集算法确定了每个对象的年龄。年龄指的是对象存活的收集周期数。当 JVM 启动时,堆是空的,并被分为三个部分:年轻代、老年代或终身代,以及用于容纳大小为标准区域的 50%或更大的对象的巨大区域。

年轻代有三个区域,一个伊甸园空间和两个幸存者空间,如幸存者 0(S0)和幸存者 1(S1)。新创建的对象被放置在伊甸园中。当它填满时,会启动一个次要的垃圾收集过程。它会移除无引用和循环引用的对象,并将其他对象移动到S1区域。在下一次次要收集时,S0S1会交换角色。引用对象会从伊甸园和S1移动到S0

在每次次要收集时,已经达到一定年龄的对象会被移动到老年代。由于这个算法的结果,老年代包含了比一定年龄更老的对象。这个区域比年轻代要大,因此垃圾收集在这里更昂贵,不像在年轻代那样频繁。但最终会进行检查(经过几次次要收集);无引用的对象将从那里删除,并且内存会被整理。这种老年代的清理被认为是一次主要收集。

当无法避免停顿时间时

老年代中的一些对象收集是并发进行的,而另一些则使用停顿时间进行。具体步骤包括:

  • 对可能在老年代中引用对象的幸存者区域(根区域)进行初始标记,使用停顿时间进行

  • 扫描幸存者区域以查找对老年代的引用,与此同时应用程序继续运行

  • 并发标记整个堆中的活动对象,与此同时应用程序继续运行

  • 标记 - 完成对活动对象的标记,使用停顿时间进行

  • 清理 - 计算活动对象的年龄并释放区域(使用停顿时间),并将其返回到空闲列表(并发进行)

前面的序列可能会与年轻一代的疏散交错,因为大多数对象的生命周期很短,通过更频繁地扫描年轻一代来释放大量内存更容易。还有一个混合阶段(当 G1 收集已标记为大部分垃圾的区域,既在年轻一代又在旧一代)和巨大分配(将大对象移动到或从巨大区域疏散)。

为了演示 GC 的工作原理,让我们创建一个产生比我们通常的示例更多垃圾的程序:

public class GarbageCollectionDemo {
  public static void main(String... args) {
    int max = 99888999;
    List<Integer> list = new ArrayList<>();
    for(int i = 1; i < max; i++){
      list.add(Integer.valueOf(i));
    }
  }
}

此程序生成接近 100,000,000 个占用大量堆空间的对象,并迫使 GC 将它们从 Eden 移动到 S0、S1 等。正如我们已经提到的,要查看 GC 的日志消息,必须在java命令中包含选项-Xlog:gc。我们选择使用 IDE,正如我们在上一节中描述的那样:

然后,我们运行了程序GarbageCollectionDemo并得到了以下输出(我们只显示了其开头):

正如您所看到的,GC 过程经过循环,并根据需要移动对象,暂停一小段时间。我们希望您了解了 GC 的工作原理。我们唯一想提到的是,在几个场合下会执行完全 GC,使用停止-世界暂停:

  • 并发故障:如果在标记阶段旧一代变满。

  • 提升失败:如果在混合阶段旧一代空间不足。

  • 疏散失败:当收集器无法将对象提升到幸存者空间和旧一代时。

  • 巨大分配:当应用程序尝试分配一个非常大的对象时。如果调整正确,您的应用程序应该避免完全 GC。

为了帮助 GC 调优,JVM 提供了平台相关的默认选择,用于垃圾收集器、堆大小和运行时编译器。但幸运的是,JVM 供应商一直在改进和调优 GC 过程,因此大多数应用程序都可以很好地使用默认的 GC 行为。

练习-在运行应用程序时监视 JVM

阅读 Java 官方文档,并命名几个随 JDK 安装提供的工具,可用于监视 JVM 和 Java 应用程序。

答案

例如 Jcmd、Java VisualVM 和 JConsole。Jcmd 特别有帮助,因为它易于记忆,并为您列出当前正在运行的所有 Java 进程。只需在终端窗口中键入jcmd。这是一个不可或缺的工具,因为您可能正在尝试运行几个 Java 应用程序,其中一些可能因为缺陷或故意设计而无法退出。Jcmd 为每个正在运行的 Java 进程显示一个进程 IDPID),您可以使用该 ID 通过键入命令kill -9 <PID>来停止它。

摘要

在本章中,您已经了解了支持任何应用程序执行的主要 Java 进程,程序执行的步骤以及组成执行环境的 JVM 架构的主要组件;运行时数据区域,类加载器和执行引擎。您还了解了称为线程的轻量级进程以及它们如何用于并发处理。有关运行 Java 应用程序的方法总结以及垃圾收集过程的主要特点结束了有关 JVM 的讨论。

在下一章中,我们将介绍几个经常使用的库-标准库(随 JDK 一起提供)和外部开源库。很快,您将非常了解它们中的大部分,但要到达那里,您需要开始,我们将在评论和示例中帮助您。

第十二章:Java 标准库和外部库

即使我们在本书中编写的第一个程序也使用了 JDK 中包含的库,称为标准库。不使用标准库无法编写非平凡程序。这就是为什么对这些库的熟悉程度对于成功编程与语言本身的知识一样重要。

还有非标准库,被称为外部库或第三方库,因为它们不包含在 JDK 发行版中,但它们几乎和标准库一样经常被使用。它们早已成为任何程序员工具包的固定成员。与 Java 本身保持同步并不容易跟踪这些库中所有可用的功能。这是因为 IDE 可以提示您有关语言功能,但无法提供有关尚未导入的包的功能的建议。唯一自动导入且无需导入的包是java.lang,这将是我们在本章中首先要概述的内容。

本章讨论的主题有:

  • 什么是标准库和外部库?

  • Java 标准库概述

  • java.lang

  • java.util

  • java.iojava.nio

  • java.sqljavax.sql

  • java.net

  • java.math

  • java.awtjavax.swingjavafx

  • Java 外部库概述

  • org.junit

  • org.mockito

  • org.log4jorg.slf4j

  • org.apache.commons

  • 练习-使用java.time.LocalDateTime

什么是标准库和外部库?

标准库(也称为类标准库)是一组对语言的所有实现都可用的类和接口。简单来说,这意味着它是包含在 JDK 中的.class文件的集合,并且可以立即使用。一旦安装了 Java,您就可以将它们作为安装的一部分,并且可以开始使用标准库的类作为构建块来构建应用程序代码,这些类可以处理许多低级管道。标准库的丰富性和易用性大大促进了 Java 的流行。

这些集合是按包组织的。这就是为什么程序员称它们为 Java 标准库,因为为了使用它们,您必须根据需要导入库包,因此它们被视为许多库。

它们也是标准库,因为 Maven 会自动将它们添加到类路径中,因此我们不需要在pom.xml文件中列出它们作为依赖项。这就是标准库和外部库的区别;如果您需要将库(通常是.jar文件)作为依赖项添加到 Maven 配置文件pom.xml中,这个库就是外部库,也称为第三方库。否则,它就是标准库。

在接下来的章节中,我们将为每个类别提供概述,并更仔细地查看一些最受欢迎的标准和外部库。

Java 标准库

如果在互联网上搜索“Java API”,您将找到 JDK 中包含的所有包的在线描述。一些包名称以java开头。它们传统上被称为核心 Java 包,而以javax开头的包曾被称为扩展。这样做可能是因为扩展被认为是可选的,甚至可能独立于 JDK 发布。还有一次尝试将以前的扩展库提升为核心包,但这将需要将包的名称从 Java 更改为 Javax,这将破坏已经存在的应用程序。因此,这个想法被放弃了,扩展成为 JDK 的标准部分,核心和扩展之间的区别逐渐消失。

这就是为什么如果你在 Oracle 网站上查看官方 Java API,你会看到标准不仅列出了javajavax包,还列出了jdkcom.sunorg.xml和其他一些包。这些额外的包主要被工具或其他专门的应用程序使用。在我们的书中,我们将主要集中讨论主流的 Java 编程,并且只谈论javajavax包。

java.lang

这个包对于所有 Java 类库来说是如此基础,以至于不仅不需要在 Maven 配置的pom.xml文件中列出它作为依赖项(Java 标准库的所有其他包也不需要列出作为依赖项),而且其成员甚至不需要被导入才能使用。任何包的任何成员,无论是标准的还是非标准的,都必须被导入或者使用其完全限定名,除了java.lang包的类和接口。原因是它包含了 Java 中最重要和最常用的两个类:

  • Object:任何其他 Java 类的基类(参见第二章,Java 语言基础

  • Class:其实例在运行时携带每个加载类的元数据(参见第十一章,JVM 进程和垃圾回收

此外,java.lang包包括:

  • StringStringBufferStringBuilders类,支持String类型的操作(有关更多详细信息和用法示例,请参见第十五章,管理对象、字符串、时间和随机数

  • 所有基本类型的包装类:ByteBooleanShortCharacterIntegerLongFloatDouble(有关包装类及其用法的更多详细信息,请参见第九章,运算符、表达式和语句

  • Number类,前面列出的数字包装类的基类

  • System类,提供对重要系统操作和标准输入输出的访问(我们在本书的每个代码示例中都使用了System.out对象)

  • Runtime类,提供对执行环境的访问

  • Thread类和Runnable接口,用于创建 Java 线程的基础

  • Iterable接口,用于迭代语句(参见第九章,运算符、表达式和语句

  • Math类,提供基本数值操作的方法

  • Throwable类 - 所有异常的基类

  • 异常类Error及其所有子类,用于传达不应被应用程序捕获的系统错误

  • Exception类及其许多子类,代表已检查的异常(参见第十章,控制流语句

  • RuntimeException类及其许多子类,代表未经检查的异常,也称为运行时异常(参见第十章,控制流语句

  • ClassLoader类,允许加载类并可用于构建自定义类加载器

  • ProcessProcessBuilder类,允许创建外部进程

  • 许多其他有用的类和接口

java.util

这是另一个非常常用的包。它的大部分内容都是用于支持集合:

  • Collection接口 - 许多集合接口的基本接口。它包含管理集合元素所需的所有基本方法:size()add()remove()contains()iterator()stream()等。请注意,Collection接口扩展了Iterable接口,并从中继承了iterator()方法。这意味着Collection接口的任何实现都可以在迭代语句中使用。

  • 扩展Collection接口的接口:ListSetQueueDeque等。

  • 实现上述接口的许多类:ArrayListLinkedListHashSetAbstractQueueArrayDeque等。

  • Map接口及其实现它的类:HashMapTreeMap等。

  • Collections类提供了许多用于操作和转换集合的静态方法。

  • 许多其他集合接口、类和相关实用程序。

我们将在《第十三章》《Java 集合》中更多地讨论集合,并查看它们的使用示例。

java.util包还包括其他几个有用的类:

  • Objects类提供了各种与对象相关的实用方法,包括两个对象的空安全equals()方法

  • Arrays类包含 200 多个静态方法来操作数组

  • Formatter类允许格式化任何原始类型,如StringDate和其他类型

  • OptionalOptionalIntOptionalLongOptionalDouble通过包装实际值(可空或非空)来帮助避免NullPointerException

  • Properties类有助于读取和创建用于配置和类似目的的键值对

  • Random类通过生成伪随机数流来补充Math.random()方法

  • Stack类允许创建对象的后进先出LIFO)堆栈

  • StringTokeneizer类将String对象分解为由指定分隔符分隔的标记

  • StringJoiner类构造由指定分隔符分隔并可选地由指定前缀和后缀包围的字符序列

  • 许多其他有用的实用程序类,包括国际化支持类和 base64 编码和解码

java.time

这是管理日期、时间、瞬间和持续时间的主要 Java API。该包包括:

  • 枚举Month

  • 枚举DayOfWeek

  • Clock类可以立即返回使用时区的当前日期和时间

  • DurationPeriod类表示和比较不同时间单位的时间量

  • LocalDateLocalTimeLocalDateTime类表示没有时区的日期和时间

  • ZonedDateTime类表示带有时区的日期时间

  • ZoneId类标识诸如 America/Chicago 之类的时区

  • 支持日期和时间处理的其他一些类

java.time.format.DateTimeFormatter类允许您按照国际标准化组织ISO)格式呈现日期和时间,并基于诸如 YYYY-MM-DD 等模式。

我们将在《第十五章》《管理对象、字符串、时间和随机数》中更多地讨论日期和时间处理,并查看代码示例。

java.io 和 java.nio

java.iojava.nio包含支持使用流、序列化和文件系统读写数据的类。这两个包之间的区别如下:

  • java.io允许我们在读写数据时不进行缓存

  • java.nio创建一个缓冲区,并允许程序在缓冲区中来回移动

  • java.io的类方法阻塞流,直到所有数据都被读取或写入

  • java.nio代表一种非阻塞式的数据读写方式

java.sql 和 javax.sql

这两个包组成了Java 数据库连接JDBC)API,它允许访问和处理存储在数据源中的数据,通常是关系数据库。包javax.sql通过提供对以下内容的支持来补充包java.sql

  • DataSource接口作为DriverManager的替代方案

  • 连接池和语句池

  • 分布式事务

  • 行集

我们将在《第十六章》《数据库编程》中更多地讨论使用这些包,并查看代码示例。

java.net

java.net包含支持两个级别的应用程序网络的类:

  • 基于低级网络:

  • IP 地址

  • 套接字,这是基本的双向数据通信机制

  • 各种网络接口

  • 基于高级网络:

  • 统一资源标识符URI

  • 统一资源定位符URL

  • 与 URL 指向的资源的连接

java.math

这个包通过允许使用BigDecimalBigInteger类来处理更大的数字,来补充 Java 原始类型和java.lang包的包装类。

java.awt、javax.swing 和 javafx

支持为桌面应用程序构建图形用户界面GUI)的第一个 Java 库是java.awt包中的抽象窗口工具包AWT)。它提供了一个接口到执行平台的本地系统,允许创建和管理窗口、布局和事件。它还具有基本的 GUI 小部件(如文本字段、按钮和菜单),提供对系统托盘的访问,并允许用户从 Java 代码中启动 Web 浏览器和电子邮件客户端。它对本地代码的重度依赖使得基于 AWT 的 GUI 在不同平台上看起来不同。

1997 年,Sun Microsystems 和 Netscape Communication Corporation 推出了 Java 基础类,后来称为 Swing,并放在javax.swing包中。使用 Swing 构建的 GUI 组件可以模拟一些本地平台的外观和感觉,但也允许用户插入不依赖于其运行的平台的外观和感觉。它通过添加选项卡面板、滚动窗格、表格和列表扩展了 GUI 可以拥有的小部件列表。Swing 组件被称为轻量级,因为它们不依赖于本地代码,完全由 Java 实现。

2007 年,Sun Microsystems 宣布了 JavaFX,它最终成为一个用于在许多不同设备上创建和交付桌面应用程序的软件平台,旨在取代 Swing 成为 Java SE 的标准 GUI 库。它位于以javafx开头的包中,支持所有主要的桌面操作系统和多个移动操作系统,包括塞班操作系统、Windows Mobile 和一些专有的实时操作系统。

JavaFX 为 GUI 开发人员增加了对平滑动画、Web 视图、音频和视频播放以及基于层叠样式表CSS)的样式的支持。然而,Swing 具有更多的组件和第三方库,因此使用 JavaFX 可能需要创建自定义组件和在 Swing 中长时间前已实现的管道。这就是为什么,尽管 JavaFX 被推荐为桌面 GUI 实现的首选,但根据 Oracle 网站上的官方回应(www.oracle.com/technetwork/java/javafx/overview/faq-1446554.html#6),Swing 将在可预见的未来仍然是 Java 的一部分。因此,可以继续使用 Swing,但如果可能的话,最好切换到 JavaFX。

Java 外部库

各种统计数据在 20 或 100 个最常用的第三方库的列表中包含不同的名称。在本节中,我们将讨论其中大多数都包含在这些列表中的库。所有这些都是开源项目。

org.junit

JUnit 是一个开源的测试框架,其根包名称为org.junit。它在本书中的多个代码示例中都有使用。正如你所看到的,它非常容易设置和使用(我们在第四章 你的第一个 Java 项目中描述了步骤)。

  • 向 Maven 配置文件pom.xml添加依赖

  • 手动创建一个测试,或右键单击您想要测试的类名,选择 Go To,然后选择 Test,然后选择 Create New Test,然后检查您想要测试的类的方法

  • 为生成的测试方法编写带有注解@Test的代码

  • 根据需要添加带有注解@Before@After的方法

“单元”是可以进行测试的最小代码片段,因此得名。最佳的测试实践将方法视为最小可测试单元。这就是为什么单元测试通常是测试方法。

org.mockito

单元测试经常面临的问题之一是需要测试使用第三方库、数据源或另一个类的方法。在测试时,您希望控制所有输入,以便可以准确预测所测试代码的预期结果。这就是模拟或模拟所测试代码与之交互的对象的行为技术派上用场的地方。

开源框架 Mockito(根包名称为org.mockito)允许您正是这样做 - 创建模拟对象。它非常容易和直接。以下是一个简单的案例:

  • 在 Maven 配置文件pom.xml中添加依赖项

  • 调用mock()方法以模拟您需要模拟的类:SomeClass mo = Mockito.mock(SomeClass.class)

  • 设置您需要从方法返回的值:Mockito.when(mo.doSomething(10)).thenReturn(20)

  • 现在,将模拟对象作为参数传递到您正在测试的方法中,该方法调用了模拟的方法

  • 模拟的方法返回您预定义的结果

Mockito 有一些限制。例如,您不能模拟静态方法和私有方法。否则,这是一种可靠地预测所使用方法的结果来隔离您正在测试的代码的绝佳方式。该框架的名称和标志基于单词mojitos - 一种饮料。

org.apache.log4j 和 org.slf4j

在本书中,我们使用System.out对象来显示中间和最终结果的输出。在实际应用程序中,也可以这样做,并将输出重定向到文件,例如,以供以后分析。做了一段时间后,您会注意到您需要更多关于每个输出的细节 - 每个语句的日期和时间,或生成此语句的类名,例如。随着代码库的增长,您会发现希望将来自不同子系统或包的输出发送到不同的文件,或在一切正常工作时关闭一些消息,并在检测到问题并需要更详细的代码行为信息时重新打开它们。

可以编写自己的程序来完成所有这些,但是有几个框架可以根据配置文件中的设置来实现,您可以在需要更改消息行为时随时更改配置文件。这些消息称为应用程序日志消息,或应用程序日志,或日志消息,用于此目的最流行的两个框架称为log4j(发音为LOG-FOUR-JAY)和slf4j(发音为S-L-F-FOUR-JAY)。

实际上,这两个框架并不是竞争对手。slf4j是一个外观,提供对底层实际日志框架的统一访问 - 其中之一也可以是log4j。在库开发期间,这样的外观特别有帮助,因为程序员事先不知道使用库的应用程序将使用什么样的日志框架。通过使用slf4j编写代码,程序员允许用户以后配置它以使用任何日志系统。

因此,如果您的代码只会被您的团队开发的应用程序使用,并且将在生产中得到支持,那么只使用log4j就足够了。否则,请考虑使用slf4j

并且,与任何第三方库一样,在您可以使用任何日志框架之前,您必须向 Maven 配置文件pom.xml添加相应的依赖项。

org.apache.commons

在前一节中,我们谈到了一个带有org.apache根名称的包 - 包org.apache.log4j

org.apache.commons包是另一个流行的库,代表了一个名为 Apache Commons 的项目,由名为 Apache Software Foundation 的开源程序员社区维护。该组织于 1999 年从 Apache Group 成立。Apache Group 自 1993 年以来一直围绕 Apache HTTP 服务器的开发而成长。Apache HTTP 服务器是一个开源跨平台的网络服务器,自 1996 年 4 月以来一直保持最受欢迎的地位。来自维基百科的一篇文章:

“截至 2016 年 7 月,据估计,它为所有活跃网站的 46%和前 100 万个网站的 43%提供服务。名称“Apache”是出于对美洲印第安纳州阿帕奇族的尊重,他们以卓越的战争策略和不竭的耐力而闻名。它也对“一个补丁式的网络服务器”进行了双关语——一个由一系列补丁组成的服务器——但这并不是它的起源”

Apache Commons 项目有三个部分:

  • Commons Sandbox:Java 组件开发的工作空间;您可以在那里为开源做出贡献

  • Commons Dormant:一个存储当前不活跃组件的仓库;您可以使用那里的代码,但必须自己构建组件,因为这些组件可能在不久的将来不会发布

  • Commons Proper:可重用的 Java 组件,构成了实际的org.apache.commons

在接下来的小节中,我们将只讨论 Commons Proper 最受欢迎的四个包:

  • org.apache.commons.io

  • org.apache.commons.lang

  • org.apache.commons.lang3

  • org.apache.commons.codec.binary

然而,在org.apache.commons下还有许多包,其中包含了成千上万个有用的类,可以轻松使用,并且可以帮助使您的代码更加优雅和高效。

org.apache.commons.io

org.apache.commons.io包的所有类都包含在根包和五个子包中:

  • 根包org.apache.commons.io包含了一些实用类,其中包含了执行常见任务的静态方法,比如一个叫做FileUtils的流行类,它允许执行所有可能需要的文件操作:

  • 写入文件

  • 从文件中读取

  • 创建目录,包括父目录

  • 复制文件和目录

  • 删除文件和目录

  • 转换为 URL 和从 URL 转换

  • 通过过滤器和扩展名列出文件和目录

  • 比较文件内容

  • 文件最后更改日期

  • 计算校验和

  • org.apache.commons.io.input包包含支持基于InputStreamReader实现的数据输入的类,例如XmlStreamReaderReversedLinesFileReader

  • org.apache.commons.io.output包包含支持基于OutputStreamWriter实现的数据输出的类,例如XmlStreamWriterStringBuilderWriter

  • org.apache.commons.io.filefilter包包含作为文件过滤器的类,例如DirectoryFileFilterRegexFileFilter

  • org.apache.commons.io.comparato包包含java.util.Comparator的各种实现,例如NameFileComparator

  • org.apache.commons.io.monitor包提供了一个用于监视文件系统事件(目录和文件创建、更新和删除事件)的组件,例如FileAlterationMonitor,它实现了Runnable并生成一个监视线程,在指定的间隔触发任何注册的FileAlterationObserver

org.apache.commons.lang 和 lang3

org.apache.commons.lang3包实际上是org.apache.commons.lang包的第 3 个版本。创建新包的决定是由于第 3 版引入的更改是不向后兼容的。这意味着使用先前版本的org.apache.commons.lang包的现有应用程序在升级到第 3 版后可能会停止工作。但是,在大多数主流编程中,将 3 添加到导入语句(作为迁移到新版本的方式)可能不会破坏任何东西。

根据文档 "the package org.apache.commons.lang3 provides highly reusable static utility methods, chiefly concerned with adding value to the java.lang classes." 这里有一些值得注意的例子:

  • ArrayUtils类允许搜索和操作数组。

  • ClassUtils类提供有关类的一些元数据。

  • ObjectUtils类在数组对象中检查null,比较对象,并以空安全的方式计算数组对象的中位数和最小/最大值。

  • SystemUtils类提供有关执行环境的信息。

  • ThreadUtils类查找有关当前运行线程的信息。

  • Validate类验证单个值和集合:比较它们,检查null,匹配,并执行许多其他验证。

  • RandomStringUtils类从各种字符集的字符生成String对象。

  • StringUtils类是许多程序员的最爱。以下是它提供的空安全操作列表:

  • isEmpty/isBlank:检查String值是否包含文本

  • trim/strip:删除前导和尾随空格

  • equals/compare:空安全地比较两个字符串

  • startsWith:空安全地检查String值是否以特定前缀开头

  • endsWith:空安全地检查String值是否以特定后缀结尾

  • indexOf/lastIndexOf/contains:提供空安全的索引检查

  • indexOfAny/lastIndexOfAny/indexOfAnyBut/lastIndexOfAnyBut:提供一组String值中任何一个的索引

  • containsOnly/containsNone/containsAny:检查String值是否仅包含/不包含/包含任何特定字符

  • substring/left/right/mid:支持空安全的子字符串提取

  • substringBefore/substringAfter/substringBetween:相对于其他字符串执行子字符串提取

  • split/join:将String值按特定分隔符拆分为子字符串数组,反之亦然

  • remove/delete:删除String值的一部分

  • replace/overlay:搜索String值并用另一个String值替换

  • chomp/chop:删除String值的最后一部分

  • appendIfMissing:如果不存在,则将后缀附加到String值的末尾

  • prependIfMissing:如果不存在,则将前缀添加到String值的开头

  • leftPad/rightPad/center/repeat:填充String

  • upperCase/lowerCase/swapCase/capitalize/uncapitalize:更改String值的大小写

  • countMatches:计算另一个String值在另一个中出现的次数

  • isAlpha/isNumeric/isWhitespace/isAsciiPrintable:检查String值中的字符

  • defaultString:保护免受null输入的String

  • rotate:旋转(循环移位)String值中的字符

  • reverse/reverseDelimited:反转String值中的字符或分隔的字符组

  • abbreviate:使用省略号或另一个给定的String值缩写String

  • difference:比较String值并报告它们的差异

  • levenshteinDistance:将一个String值更改为另一个所需的更改次数

org.apache.commons.codec.binary

此库的内容超出了本入门课程的范围。因此,我们只会提到该库提供对 Base64、Base32、二进制和十六进制字符串编码和解码的支持。

编码是必要的,以确保您在不同系统之间发送的数据不会因不同协议中字符范围的限制而在传输过程中发生更改。此外,一些系统将发送的数据解释为控制字符(例如调制解调器)。

练习-比较 String.indexOf()和 StringUtils.indexOf()

String类的indexOf()方法和StringUtils类的indexOf()方法有什么区别?

答案

String类的indexOf()方法不处理null。这是一些演示代码:

String s = null;
int i = StringUtils.indexOf(s, "abc");     //return -1
s.indexOf("abc");                          //throws NullPointerException

总结

在本章中,读者已经了解了 JDK 中包含的 Java 标准库的内容,以及一些最受欢迎的外部库或第三方库。特别是,我们仔细研究了标准包java.langjava.util;比较了包java.iojava.niojava.sqljavax.sqljava.awtjavax.swingjavafx;并回顾了包java.netjava.math

我们还概述了一些流行的外部库,如org.junitorg.mockitoorg.apache.log4jorg.slf4j,以及 Apache Commons 项目的几个包:org.apache.commons.ioorg.apache.commons.langorg.apache.commons.lang3,以及org.apache.commons.codec.binary

下一章将帮助读者更详细地了解最常用的 Java 类。代码示例将说明对集合类的功能进行讨论:ListArrayListSetHashSet,以及MapHashMap。我们还将讨论类ArraysArrayUtilsObjectsObjectUtilsStringBuilderStringBufferLocalDateLocalTimeLocalDateTime

第十三章:Java 集合

本章将帮助读者更熟悉最常用的 Java 集合。代码示例说明了它们的功能,并允许进行实验,强调了不同集合类型及其实现之间的差异。

在本章中,我们将涵盖以下主题:

  • 什么是集合?

  • 列表和 ArrayList

  • Set 和 HashSet

  • Map 和 HashMap

  • 练习-EnumSet 方法

什么是集合?

当你阅读关于 Java 集合框架的内容时,你可能会认为这样的集合有些特殊。与此同时,框架这个词被滥用了,就像技术这个词一样,我们已经拒绝使用了。在英语中,框架这个词的意思是“系统、概念或文本的基本结构”。在计算机编程中,框架是一个软件系统,它的功能可以通过额外的用户编写的代码或配置设置来定制,以满足特定应用程序的要求。

但是当我们仔细研究 Java 集合框架的内容时,我们意识到它的所有成员都属于java.util包,这是 Java 类库的一部分,正如我们在前一章中所描述的。而另一方面,java.awtjavax.swingjavafx包中的图形用户界面具有框架的所有特征;它们只提供小工具和其他图形元素,这些元素必须由特定应用程序的内容填充。然而,它们也属于 Java 类库。

这就是为什么我们避免使用框架这个词,并且只在这里提到它,以解释 Java 集合框架标题背后隐藏的东西。

java.util

java.util包中的以下接口和类组成了 Java 集合框架:

  • 扩展java.util.Collection接口的接口(它又扩展了java.lang.Iterable接口):ListSetQueue,这些是最流行的接口之一

  • 实现上述接口的类:ArrayListHashSetStackLinkedList,作为示例

  • 实现java.util.Map接口及其子类的类:HashMapHashTableTreeMap,只是其中最常用的三个

正如你所看到的,Java 集合框架,或者只是 Java 集合,由扩展java.util.Collection接口或java.util.Map接口的接口和实现这些接口的类组成-所有这些都包含在java.util包中。

请注意,那些直接或间接实现Collection接口的类也实现了Iterable接口,因此可以在迭代语句中使用,如第十章中所述的“控制流语句”。

Apache Commons 集合

Apache Commons 项目包含了(在org.apache.commons.collections包中)多个 Java 集合接口的实现,这些实现补充了java.util包中的实现。但是,除非你在一个需要特定集合算法的应用程序上工作,否则你可能不需要使用它们。尽管如此,我们建议你浏览一下org.apache.commons.collections包的 API,这样你就知道它的内容,以防将来遇到需要使用它的情况。

集合与数组

所有集合都是类似于数组的数据结构,因为它们也包含元素,并且每个元素都由一个类的对象表示。不过,数组和集合之间有两个重要的区别:

  • 数组在实例化时需要分配一个大小,而集合在添加或删除元素时会自动增加和减少大小。

  • 集合的元素不能是原始类型的值,而只能是引用类型,包括包装类,如IntegerDouble。好消息是您可以添加原始值:

       List list = new ArrayList();
       list.add(42);

在前面的语句中,装箱转换(请参阅第九章,“运算符、表达式和语句”)将自动应用于原始值。

尽管数组在访问其元素时预计会提供更好的性能,但实际上,现代算法使数组和集合的性能差异可以忽略不计,除了一些非常专业的情况。这就是为什么您必须使用数组的唯一原因是当一些算法或方法需要它时。

这是我们将要讨论的内容

在接下来的小节中,我们将讨论 Java 集合标准库中最受欢迎的接口和类:

  • List接口和ArrayList类-它们保留元素的顺序

  • Set接口和HashSe类-它们不允许重复元素

  • MapHashMap接口-它们通过键存储对象,因此允许键值映射

请注意,以下小节中描述的大多数方法都来自java.util.Collection接口-几乎所有集合的父接口,除了实现java.util.Map接口的集合。

List-ArrayList 保留顺序

List是一个接口,ArrayList类是其最常用的实现。两者都驻留在java.util包中。ArrayList类有一些额外的方法-除了List接口中声明的方法之外。例如,removeRange()方法在List接口中不存在,但在ArrayListAPI 中可用。

更喜欢变量类型 List

在创建ArrayList对象时,将其引用分配给List类型的变量是一个很好的做法:

List listOfNames = new ArrayList();

很可能,在您的程序中使用ArrayList类型的变量不会改变任何内容,无论是今天还是将来:

ArrayList listOfNames = new ArrayList();

前面的引用仍然可以传递给接受List类型参数的任何方法。但是,通常编码为接口(当我们将变量设置为接口类型时)是一个很好的习惯,因为您永远不知道代码的要求何时可能会更改,您可能需要使用另一个List的实现,例如LinkedList类。如果变量类型是List,切换实现很容易。但是,如果变量类型是ArrayList,将其更改为ListLinkedList需要跟踪变量使用的所有位置并运行各种测试,以确保没有在任何地方调用ArrayList方法。如果代码很复杂,人们永远无法确定是否已检查了所有可能的执行路径,并且代码不会在生产中中断。这就是为什么我们更喜欢使用接口类型来保存对对象的引用的变量,除非您确实需要它成为类类型。我们在第八章中广泛讨论了这一点,“面向对象设计(OOD)原则”。

为什么叫 ArrayList?

ArrayList类之所以被命名为 ArrayList,是因为它的实现是基于数组的。它实际上在幕后使用数组。如果在 IDE 中右键单击ArrayList并查看源代码,您将看到以下内容:

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
  this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

它只是数组Object[]的包装器。例如,这是方法add(E)的实现方式:

public boolean add(E e) {
  modCount++;
  add(e, elementData, size);
  return true;
}
private void add(E e, Object[] elementData, int s) {
  if (s == elementData.length)
    elementData = grow();
  elementData[s] = e;
  size = s + 1;
}

And if you study the source code more and look inside the method grow(), you will see how it increases the size of the array when new elements are added to the list:

private Object[] grow() {  return grow(size + 1); }

private Object[] grow(int minCapacity) {
  return elementData = Arrays.copyOf(elementData,
                                    newCapacity(minCapacity));
}
private static final int DEFAULT_CAPACITY = 10;
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private int newCapacity(int minCapacity) {
  // overflow-conscious code
  int oldCapacity = elementData.length;
  int newCapacity = oldCapacity + (oldCapacity >> 1);
  if (newCapacity - minCapacity <= 0) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
      return Math.max(DEFAULT_CAPACITY, minCapacity);
    if (minCapacity < 0) // overflow
      throw new OutOfMemoryError();
    return minCapacity;
  }
  return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
}

As you can see, when the allocated array size is not enough for storing another element, the new array is created with a minimum capacity of 10. All the already existing elements are copied to the new array using the Arrays.copyOf() method (we will talk about the Arrays class later in this chapter).

And that is why ArrayList was so named.

For using List and ArrayList, you do not need to know all that, unless you have to process really big lists of elements and the frequent copying of the underlying array affects the performance of your code. In such a case, consider using different data structures that have been designed specifically for the type of processing you need. But that is already outside the scope of this book. Besides, the vast majority of mainstream programmers have probably never used any collections that are not in the java.util package.

Adding elements

The List interface provides two methods for adding an element:

  • add(E): This adds the element to the end of the list

  • add(index, E): This inserts the element into the specified (by index, starting with zero) position in the list by shifting the element the specified position (if any) and any subsequent elements to the right by adding 1 to their indices

Both methods can throw a RuntimeException if something goes wrong. So, putting a try-catch block around the method makes the code more robust (if the catch block does not simply rethrow the exception but does something meaningful). Read the documentation of the List interface API online and see what the names of the exceptions these methods can throw are and under which conditions they can happen.

The add(E) method also returns a Boolean value (true/false) that indicates the success of the operation. This method overrides the method in the Collection interface, so all Java collections that extend or implement the Collection interface have it. In the case of List implementations, this method most likely always returns true because list allows duplicate entries. By contrast, the implementations of the Set interface return false if such an element is present already because Set does not allow duplicates. We will discuss this in subsequent sections, as well as how the code determines if two elements are the same.

Now, let's look at the examples of the add() method of the List interface's usage:

List list = new ArrayList();
list.add(null);
list.add(1);
list.add("ss");
list.add(new A());
list.add(new B());
System.out.println(list);  //prints: [null, 1, ss, A, B]
list.add(2, 42);
System.out.println(list);  //prints: [null, 1, 42, ss, A, B]

In the preceding list, we have mixed up in the same list values of different types. The classes A and B, used in the preceding code, have parent-child relations:

class A {
  @Override
  public String toString() { return "A"; }
}
class B extends A {
  @Override
  public String toString() { return "B"; }
}

如您所见,我们已经为它们的每个添加了toString()方法,这样我们就可以看到它们的对象以预期的格式打印出来。

size(), isEmpty(), clear()

这三种方法很简单:

  • size(): 这返回列表中的元素数量

  • isEmpty(): 如果列表中没有元素,则返回truesize()返回 0)

  • clear(): 这将从列表中移除所有元素,使isEmpty()返回truesize()返回 0

遍历和流

实现Collection接口(它扩展了Iterable接口)的每个集合都可以使用第十章中讨论的增强for语句进行迭代。以下是一个示例:

List list = new ArrayList();
list.add(null);
list.add(1);
list.add("ss");
list.add(new A());
list.add(new B());
for(Object o: list){
  //code that does something with each element   
} 

Iterable接口还向List接口添加了以下三种方法:

  • forEach(Consumer function): 它将提供的函数应用于每个集合元素

  • iterator(): 它返回一个Iterator类的对象,允许遍历集合的每个元素并根据需要操作每个元素

  • splititerator(): 它返回一个Splititerator类的对象,允许将集合拆分以进行并行处理(讨论此功能的范围超出了本书的范围)

在第十七章中,Lambda 表达式和函数式编程,我们将解释如何将函数作为参数传递,所以现在我们只展示forEach()方法的用法示例(如果我们重用前面示例中创建的列表):

list.forEach(System.out::println);

如您所见,传入的函数会获取forEach()方法生成的每个元素并将其打印出来。它被称为Consumer,因为它获取(消耗)输入并不返回任何值,只是打印。如果我们运行上述代码,结果将如下所示:

forEach()方法提供了与for语句相同的功能(参见前面的示例),但需要编写更少的代码。这就是为什么程序员喜欢函数式编程(当函数可以被视为对象时),因为在多次编写相同的样板代码之后,可以享受简写的风格。

iterator()方法返回的Iterator接口具有以下方法:

  • next(): 它返回迭代中的下一个元素

  • hasNext(): 如果迭代还有更多元素,则返回true

  • forEachRemaining (Consumer<? super E> function): 它将提供的函数应用于剩余的每个元素

  • remove(): 它从基础集合中移除此迭代器返回的最后一个元素

next()hasNext()方法由for语句在后台使用。您也可以使用它们,实际上可以重现for语句的功能。但是为什么呢?for语句已经在做这个了。我们能想到使用Iterator接口的唯一原因是在遍历列表时删除一些对象(使用remove()方法)。这让我们讨论一个初学者经常犯的错误。

假设我们想要从以下列表中删除所有类型为String的对象:

List list = new ArrayList();
list.add(null);
list.add(1);
list.add("ss");
list.add(new A());
list.add(new B());

以下是尝试执行此操作的代码,但存在缺陷:

for(Object o: list){
  System.out.println(o);
  if(o instanceof String){
    list.remove(o);
  }
}

如果我们运行上述代码,结果将如下所示:

ConcurrentModificationException是因为我们在迭代集合时尝试修改它。Iterator类有助于避免这个问题。以下代码可以正常工作:

System.out.println(list);  //prints: [null, 1, ss, A, B]
Iterator iter = list.iterator();
while(iter.hasNext()){
  Object o = iter.next();
  if(o instanceof String){
    iter.remove();
  }
}
System.out.println(list);  //prints: [null, 1, A, B]

我们不打算讨论为什么Iterator允许在迭代过程中删除元素,而集合在类似情况下抛出异常的原因有两个:

  • 这需要比入门课程允许的更深入地了解 JVM 实现。

  • 在第十八章中,流和管道,我们将演示使用 Java 函数式编程更紧凑的方法。这样的代码看起来如此清晰和优雅,以至于许多使用 Java 8 及更高版本的程序员在处理生成流的集合和其他数据结构时几乎不再使用for语句。

还有其他四种遍历元素列表的方法:

  • listIterator()listIterator(index):两者都返回ListIterator,它与Iterator非常相似,但允许沿着列表来回移动(Iterator只允许向前移动,正如你所见)。这些方法很少使用,所以我们将跳过它们的演示。但是如果你需要使用它们,看一下前面的Iterator示例。ListIterator的使用非常相似。

  • stream()parallelStream():两者都返回Stream对象,我们将在第十八章中更详细地讨论流和管道

使用泛型添加

有时,在同一个列表中具有不同类型的情况正是我们想要的。但是,大多数情况下,我们希望列表包含相同类型的值。同时,代码可能存在逻辑错误,允许添加不同类型到列表中,这可能会产生意想不到的后果。如果导致抛出异常,那就不像一些默认转换和不正确的结果那么糟糕,这可能很长时间甚至永远不会被注意到。

为了避免这样的问题,可以使用允许定义集合元素期望类型的泛型,这样编译器可以检查并在添加不同类型时失败。这里是一个例子:

List<Integer> list1 = new ArrayList<>();
list1.add(null);
list1.add(1);
//list1.add("ss");          //compilation error
//list1.add(new A());       //compilation error
//list1.add(new B());       //compilation error
System.out.println(list1);  //prints: [null, 1]
list1.add(2, 42);
System.out.println(list1);  //prints: [null, 1, 42]

正如你所看到的,null值无论如何都可以被添加,因为它是任何引用类型的默认值,而正如我们在本节开头已经指出的那样,任何 Java 集合的元素只能是引用类型。

由于子类具有任何其父类的类型,泛型<Object>并不能帮助避免先前描述的问题,因为每个 Java 对象都将Object类作为其父类:

List<Object> list2= new ArrayList<>();
list2.add(null);
list2.add(1);
list2.add("ss");
list2.add(new A());
list2.add(new B());
System.out.println(list2);    //prints: [null, 1, ss, A, B]
list2.add(2, 42);
System.out.println(list2);    //prints: [null, 1, 42, ss, A, B]

但是,以下泛型更加严格:

List<A> list3= new ArrayList<>();
list3.add(null);
//list3.add(1);            //compilation error
//list3.add("ss");         //compilation error
list3.add(new A());
list3.add(new B());
System.out.println(list3); //prints: [null, A, B]
list3.add(2, new A());
System.out.println(list3); //prints: [null, A, A, B]

List<B> list4= new ArrayList<>();
list4.add(null);
//list4.add(1);            //compilation error
//list4.add("ss");         //compilation error
//list4.add(new A());      //compilation error
list4.add(new B());
System.out.println(list4); //prints: [null, B]
list4.add(2, new B());
System.out.println(list4); //prints: [null, B, B]

唯一的情况是当您可能使用泛型<Object>的情况是,当您希望允许添加不同类型的值到列表中,但不希望允许列表本身的引用引用具有其他泛型的列表时:

List list = new ArrayList();
List<Integer> list1 = new ArrayList<>();
List<Object> list2= new ArrayList<>();
list = list1;
//list2 = list1;   //compilation error

正如您所看到的,没有泛型的列表(称为原始类型)允许其引用引用任何其他具有任何泛型的列表,而具有泛型<Object>的列表不允许其变量引用具有任何其他泛型的列表。

Java 集合还允许通配符泛型<?>,它只允许将null分配给集合:

List<?> list5= new ArrayList<>();
list5.add(null);
//list5.add(1);            //compilation error
//list5.add("ss");         //compilation error
//list5.add(new A());      //compilation error
//list5.add(new B());      //compilation error
System.out.println(list5); //prints: [null]
//list5.add(1, 42);        //compilation error

可以演示通配符泛型的用法示例。假设我们编写一个具有List(或任何集合)作为参数的方法,但我们希望确保此列表在方法内部不会被修改,而这会更改原始列表。这是一个例子:

void doSomething(List<B> list){
  //some othe code goes here
  list.add(null);
  list.add(new B());
  list.add(0, new B());
  //some other code goes here
}

如果使用前面的方法,我们会得到一个不良的副作用:

List<B> list= new ArrayList<>();
System.out.println(list); //prints: [B]
list.add(0, null);
System.out.println(list); //prints: [null, B]
doSomething(list);
System.out.println(list); //[B, null, B, null, B]

为了避免副作用,可以编写:

void doSomething(List<?> list){
  list.add(null);
  //list.add(1);            //compilation error
  //list.add("ss");         //compilation error
  //list.add(new A());      //compilation error
  //list.add(new B());      //compilation error
  //list.add(0, 42);        //compilation error
}

正如您所看到的,这种方式列表无法修改,除了添加null。好吧,这是以删除泛型<B>的代价。现在,可能传入的列表包含不同类型的对象,类型转换(B)将抛出ClassCastException。没有免费的东西,但可能性是可用的。

就像封装一样,最佳实践建议尽可能使用尽可能窄(或专门的)类型的泛型。这可以确保意外行为的机会大大降低。

为了防止集合在方法内部被修改,可以使集合不可变。可以在方法内部或外部(在将其作为参数传递之前)进行。我们将向您展示如何在第十四章中执行此操作,管理集合和数组

添加集合

List接口的两种方法允许将整个对象集合添加到现有列表中:

  • addAll(Collection<? extends E> collection): 它将提供的对象集合添加到列表的末尾。

  • addAll(int index, Collection<? extends E> collection): 它将提供的元素插入到列表的指定位置。该操作将当前在该位置的元素(如果有)和任何后续元素向右移动(将它们的索引增加提供的集合的大小)。

如果出现问题,这两种方法都会抛出几个RuntimeExceptions。此外,这两种方法都返回一个布尔值:

  • false:如果此方法调用后列表未更改

  • true:如果列表已更改

与添加单个元素的情况一样,这些方法的所有List实现很可能总是返回 true,因为List允许重复,而Set不允许(我们将很快讨论这一点)。

如果您在几页前阅读了泛型的描述,您可以猜到符号Collection<? extends E>的含义。泛型<? extends E>表示的是EE的子类类型,其中E是用作集合泛型的类型。与我们之前的例子相关,观察以下类AB

class A {
  @Override
  public String toString() { return "A"; }
}
class B extends A {
  @Override
  public String toString() { return "B"; }
}

我们可以向List<A>对象添加A类和B类的对象。

符号addAll(Collection<? extends E> collection)表示此方法允许向List<E>对象添加类型为EE的任何子类型的对象。

例如,我们可以这样做:

List<A> list = new ArrayList<>();
list.add(new A());
List<B> list1 = new ArrayList<>();
list1.add(new B());
list.addAll(list1);
System.out.println(list);    //prints: [A, B]

addAll(int index, Collection<? extends E> collection)方法的作用非常相似,但是只从指定的索引开始。当然,提供的索引值应该等于 0 或小于列表的长度。

对于addAll(int index, Collection<? extends E> collection)方法,提供的索引值应该等于 0 或小于列表的长度。

实现 equals()和 hashCode()

这是一个非常重要的子部分,因为在创建类时,程序员往往会专注于主要功能,忘记实现equals()hashCode()方法。直到使用equals()方法比较对象或将对象添加到集合并进行搜索或假定为唯一(在Set的情况下)时,才会出现问题。

正如我们在第九章中所演示的,运算符、表达式和语句equals()方法可用于对象标识。如果一个类没有覆盖基类Objectequals()方法的默认实现,那么每个对象都是唯一的。即使两个相同类的对象具有相同的状态,默认的equals()方法也会将它们报告为不同。因此,如果您需要将相同状态的同一类的两个对象视为相等,您必须在该类中实现equals()方法,以覆盖Object中的默认实现。

由于每个 Java 集合在搜索其元素时都使用equals()方法,因此您必须实现它,因为典型的业务逻辑要求在对象标识过程中包含对象状态或至少一些状态值。您还必须决定在类继承链的哪个级别应该将两个子类视为相等,就像我们在第九章中讨论的那样,运算符、表达式和语句,比较PersonWithHairPersonWithHairDressed类的对象时;两者都扩展了Person类。我们当时决定,如果这些类的对象根据Person类中实现的equals()方法相等,则这些类的对象代表同一个人。我们还决定仅考虑agename字段,尽管Person类可能有其他几个字段(例如currentAddress),这些字段对于人的标识并不相关。

因此,如果您期望创建的类将用于生成将用作某些 Java 集合成员的对象,则最好实现equals()方法。要做到这一点,您必须做出两个决定:

  • 在类继承层次结构的哪个级别实现该方法

  • 对象的哪些字段(换句话说,对象状态的哪些方面)包括在考虑中

equals()方法被 Java 集合用于元素识别。在实现equals()方法时,考虑在一个父类中进行,并决定在比较两个对象时使用哪些字段。

hashCode()方法未被List实现使用,因此我们将在接下来的代码中更详细地讨论它与接口SetMap的实现相关。但是,由于我们正在讨论这个话题,我们想提到最佳的 Java 编程实践建议在实现equals()方法时每次都实现hashCode()方法。在这样做时,使用equals()方法使用的相同字段。例如,我们在第九章中实现的Person类,应该如下所示:

class Person{
  private int age;
  private String name, currentAddress;
  public Person(int age, String name, String currAddr) {
    this.age = age;
    this.name = name;
    this.currentAddress = currAddr;
  }
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null) return false;
    if(!(o instanceof Person)) return false;
      Person person = (Person)o;
      return age == person.getAge() &&
                Objects.equals(name, person.getName());
  }
  @Override
  public int hashCode(){
    return Objects.hash(age, name);
  }
}

如您所见,我们已经根据Objects类的hash()方法添加了hashCode()方法的实现,我们将在本章后面讨论。我们还添加了一个新字段,但是在equals()方法和hashCode()方法中都不会使用它,因为我们认为它与人的身份无关。

每次实现equals()方法时,也要实现hashCode()方法,因为您创建的类的对象可能不仅在List中使用,还可能在SetMap中使用,这就需要实现hashCode()

我们将在本章的相应部分讨论SetMaphashCode()方法实现的动机。我们还将解释为什么我们不能像使用equals()方法一样使用hashCode()来进行对象识别的目的。

定位元素

List接口中有三种方法允许检查列表中元素的存在和位置:

  • contains(E):如果列表中存在提供的元素,则返回true

  • indexOf(E):它返回列表中提供的元素的索引(位置)。如果列表中有几个这样的元素,则返回最小的索引-等于提供的元素的最左边的第一个元素的索引。

  • lastIndexOf(E):它返回列表中提供的元素的索引(位置)。如果列表中有几个这样的元素,则返回最大的索引-等于提供的元素的最后一个元素的索引。

以下是显示如何使用这些方法的代码:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s1");

System.out.println(list.contains("s1"));    //prints: true
System.out.println(list.indexOf("s1"));     //prints: 0
System.out.println(list.lastIndexOf("s1")); //prints: 2

有两件事值得注意:

  • 列表中的第一个元素的索引为 0(也像数组一样)

  • 前面的方法依赖于equals()方法的实现来识别列表中提供的对象

检索元素

List接口中有两种允许检索元素的方法:

  • get(index): 它返回具有提供索引的元素

  • sublist(index1, index2): 它返回从index1开始的元素列表,直到index2之前的元素

以下代码演示了如何使用这些方法:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s3");

System.out.println(list.get(1));       //prints: s2
System.out.println(list.subList(0,2)); //prints: [s1, s2]

删除元素

List接口中有四种方法可以删除列表中的元素:

  • remove(index): 它删除具有提供索引的元素并返回被删除的元素

  • remove(E): 它删除提供的元素并返回true,如果列表包含它

  • removeAll(Collection): 它删除提供的元素并返回true,如果列表已更改

  • retainAll(Collection): 它删除不在提供的集合中的所有元素,并返回true,如果列表已更改

我们想要提出这些方法的两点:

  • 最后三种方法使用equals()方法来识别要删除或保留的元素

  • 如果列表中有一个或多个元素被移除,剩余元素的索引将被重新计算

现在让我们看看代码示例:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s3");
list.add("s1");

System.out.println(list.remove(1));    //prints: s2
System.out.println(list);              //prints: [s1, s3, s1]
//System.out.println(list.remove(5));  //throws IndexOutOfBoundsException
System.out.println(list.remove("s1")); //prints: true
System.out.println(list);              //prints: [s3, s1]
System.out.println(list.remove("s5")); //prints: false
System.out.println(list);              //prints: [s3, s1]

在前面的代码中,值得注意的是列表有两个元素s1,但是只有左边的第一个被语句list.remove("s1")移除:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s3");
list.add("s1");

System.out.println(list.removeAll(List.of("s1", "s2", "s5")));   //true
System.out.println(list);                                        //[s3]
System.out.println(list.removeAll(List.of("s5")));               //false
System.out.println(list);                                        //[s3]

为了节省空间,我们使用of()方法创建一个列表,我们将在第十四章中讨论,管理集合和数组。与前面的例子相比,在前面的代码中语句list.removeAll("s1","s2","s5")移除了列表中的两个元素s1

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s3");
list.add("s1");

System.out.println(list.retainAll(List.of("s1","s2","s5"))); //true
System.out.println(list);                                    //[s1, s2, s1]
System.out.println(list.retainAll(List.of("s1","s2","s5"))); //false
System.out.println(list);                                    //[s1, s2, s1]
System.out.println(list.retainAll(List.of("s5")));           //true
System.out.println(list);                                    //[]

请注意在前面的代码中,retainAll()方法第二次返回false,因为列表没有更改。还要注意语句list.retainAll(List.of("s5")如何清除列表,因为它的元素都不等于提供的元素。

替换元素

List接口中有两种允许替换列表中元素的方法:

  • set(index, E): 它用提供的元素替换具有提供索引的元素

  • replaceAll(UnaryOperator<E>): 它用提供的操作返回的结果替换列表的每个元素

以下是使用set()方法的示例:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");

list.set(1, null);
System.out.println(list);    //prints: [s1, null]

这很简单,似乎不需要任何评论。

第二种方法replaceAll()基于函数UnaryOperator <E>-Java 8 中引入的 Java 功能接口之一。我们将在第十七章中讨论它,Lambda 表达式和函数式编程。现在,我们只是想展示代码示例。它们似乎相当简单,所以您应该能够理解它是如何工作的。假设我们从以下列表开始:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");
list.add("s3");
list.add("s1");

以下是一些可能的元素修改(只需记住replaceAll()方法用提供的函数返回的结果替换每个元素):

list.replaceAll(s -> s.toUpperCase()); //cannot process null
System.out.println(list);    //prints: [S1, S2, S3, S1]

list.replaceAll(s -> ("S1".equals(s) ? "S5" : null));
System.out.println(list);    //prints: [S5, null, null, S5]

list.replaceAll(s -> "a");
System.out.println(list);    //prints: [a, a, a, a]

list.replaceAll(s -> {
  String result;
  //write here any code you need to get the value
  // for the variable result based in the value of s
  System.out.println(s);   //prints "a" four times
  result = "42";
  return result;
});
System.out.println(list);    //prints: [42, 42, 42, 42]

在最后一个示例中,我们将操作的主体放在大括号{}中,并添加了一个显式的return语句,这样您就可以看到我们所说的操作返回的结果。

在使用equals()方法将集合的元素与String文字或任何其他对象进行比较时,习惯上在文字上调用equals(),例如"s1"。equals(element),或者在您用来比较的对象上调用equals(element),例如someObject.equals(element)。这有助于避免NullPointerException,以防集合具有null值。

可以将上述函数的示例重写如下:

UnaryOperator<String> function = s -> s.toUpperCase();
list.replaceAll(function);

function = s -> ("S1".equals(s) ? "S5" : null);
list.replaceAll(function);

function = s -> "a";
list.replaceAll(function);

function = s -> {
  String result;
  //write here any code you need to get the value
  // for the variable result based in the value of s
  System.out.println(s);   //prints "a" four times
  result = "42";
  return result;
};
list.replaceAll(function);

这样,它们可以像任何其他参数一样传递,这就是函数式编程的威力。但是,我们将在第十七章中更多地讨论它,并解释所有的语法,Lambda 表达式和函数式编程

排序字符串和数字类型

正如我们已经提到的,类型为List的集合保留了元素的顺序,因此自然地,它也有对元素进行排序的能力,sort(Comparator <E>)方法就是为此而服务的。这种方法是在 Java 8 引入函数式编程后才可能的。我们将在第十七章中讨论它,Lambda 表达式和函数式编程

现在,我们将向您展示一些示例,并指出标准比较器的位置。我们从以下列表开始:

List<String> list = new ArrayList<>();
list.add("s3");
list.add("s2");
list.add("ab");
//list.add(null); //throws NullPointerException for sorting
                  //     String.CASE_INSENSITIVE_ORDER
                  //     Comparator.naturalOrder()
                  //     Comparator.reverseOrder()
list.add("a");
list.add("Ab");
System.out.println(list);                //[s3, s2, ab, a, Ab]

以下是一些排序的示例:

list.sort(String.CASE_INSENSITIVE_ORDER);
System.out.println(list);                //[a, ab, Ab, s2, s3]

list.sort(Comparator.naturalOrder());
System.out.println(list);               //[Ab, a, ab, s2, s3]

list.sort(Comparator.reverseOrder());
System.out.println(list);               //[Ab, a, ab, s2, s3]

前述的排序不是空安全的,正如前述的注释所指出的。您可以通过阅读有关前述比较器的 API 文档或仅通过尝试来了解这一点。即使在阅读文档后,人们通常也会尝试各种边缘情况,以更好地理解所描述的功能,并查看自己是否正确理解了描述。

还有处理null值的比较器:

list.add(null);

list.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(list);              //[null, Ab, a, ab, s2, s3]

list.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(list);              //[Ab, a, ab, s2, s3, null]

正如您所看到的,许多流行的比较器都可以在java.util.Comparator类的静态方法中找到。但是,如果您找不到所需的现成比较器,也可以编写自己的比较器。例如,假设我们需要对空值进行排序,使其像String值“null”一样。对于这种情况,我们可以编写一个自定义比较器:

Comparator<String> comparator = (s1, s2) ->{
  String s = (s1 == null ? "null" : s1);
  return s.compareTo(s2);
};
list.sort(comparator);
System.out.println(list);              //[Ab, a, ab, null, s2, s3]

Comparator类中还有各种数字类型的比较器:

  • comparingInt(ToIntFunction<? super T> keyExtractor)

  • comparingLong(ToLongFunction<? super T> keyExtractor)

  • comparingDouble(ToDoubleFunction<? super T> keyExtractor)

我们将它们留给读者自行研究,如果需要使用这些方法进行数字比较。但是,似乎大多数主流程序员从不使用它们;我们演示的现成比较器通常已经足够了。

排序自定义对象

其中一个经常遇到的情况是需要对自定义对象进行排序,例如CarPerson类型。为此,有两种选择:

  • 实现Comparable接口。它只有一个方法compareTo(T),它接受相同类型的对象并返回负整数,零或正整数,如果此对象小于,等于或大于指定对象。这样的实现称为自然排序,因为实现Comparable接口的对象可以通过集合的sort()方法进行排序。前一小节的许多示例演示了它如何适用于String类型的对象。比较器由方法naturalOrder()reverseOrder()nullFirst()nullLast()返回-它们都基于使用compareTo()实现。

  • 实现一个外部比较器,使用Comparator类的静态comparing()方法比较集合元素类型的两个对象。

让我们看看每个前述选项的代码示例,并讨论每种方法的利弊。首先,增强PersonPersonWithHairPersonWithHairDressed类,并实现Comparable接口:

class Person implements Comparable<Person> {
  private int age;
  private String name, address;
  public Person(int age, String name, String address) {
    this.age = age;
    this.name = name == null ? "" : name;
    this.address = address;
  }
  @Override
  public int compareTo(Person p){
    return name.compareTo(p.getName());
  }
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null) return false;
    if(!(o instanceof Person)) return false;
      Person person = (Person)o;
      return age == person.getAge() &&
                Objects.equals(name, person.getName());
  }
  @Override
  public int hashCode(){ return Objects.hash(age, name); }
  @Override
  public String toString() { return "Person{age=" + age +
                                   ", name=" + name + "}";
  }
}

As you can see, we have added another instance field, address, but do not use it in either the  equals()hashCode(), or compareTo() methods. We did it just to show that it is completely up to you how to define the identity of the object of class Person and its children.  We also implemented the toString() method (which prints only the fields included in the identity), so we can identify each object when they are displayed. And we have implemented the method of the Comparable interface, compareTo(), which is going to be used for sorting. Right now it takes into account only the name, so when sorted, the objects will be ordered by name.

The children of class Person did not change:

class PersonWithHair extends Person {
  private String hairstyle;
  public PersonWithHair(int age, String name, 
                        String address, String hairstyle) {
    super(age, name, address);
    this.hairstyle = hairstyle;
  }
}

class PersonWithHairDressed extends PersonWithHair{
  private String dress;
  public PersonWithHairDressed(int age, String name, 
           String address, String hairstyle, String dress) {
    super(age, name, address, hairstyle);
    this.dress = dress;
  }
}

Now we can create the list that we are going to sort:

List<Person> list = new ArrayList<>();
list.add(new PersonWithHair(45, "Bill", "27 Main Street", 
                                                       "Pompadour"));
list.add(new PersonWithHair(42, "Kelly","15 Middle Street",  
                                                        "Ponytail"));
list.add(new PersonWithHairDressed(34, "Kelly", "10 Central Square",  
                                               "Pompadour", "Suit"));
list.add(new PersonWithHairDressed(25, "Courtney", "27 Main Street",  
                                              "Ponytail", "Tuxedo"));

list.forEach(System.out::println);

Execution of the preceding code produces the following output:

Person{age=45, name=Bill}
Person{age=42, name=Kelly}
Person{age=34, name=Kelly}
Person{age=25, name=Courtney}

The persons are printed in the order they were added to the list. Now, let's sort them:

list.sort(Comparator.naturalOrder());
list.forEach(System.out::println);

The new order looks as follows:

Person{age=45, name=Bill}
Person{age=25, name=Courtney}
Person{age=42, name=Kelly}
Person{age=34, name=Kelly}

The objects are ordered alphabetically by name – that is how we have implemented the compareTo() method.

If we use the reverseOrder() comparator, the order shown be reversed:

list.sort(Comparator.reverseOrder());
list.forEach(System.out::println);

This is what we see if we run the preceding code:

Person{age=42, name=Kelly}
Person{age=34, name=Kelly}
Person{age=25, name=Courtney}
Person{age=45, name=Bill}

The order was reversed.

We can change our implementation of the compareTo() method and order the objects by age:

@Override
public int compareTo(Person p){
  return age - p.getAge();
}

Or we can implement it so that the Person objects will be sorted by both fields – first by name, then by age:

@Override
public int compareTo(Person p){
  int result = this.name.compareTo(p.getName());
  if (result != 0) {
    return result;
  }
  return this.age - p.getAge();
}

If we sort the list in natural order now, the result will be:

Person{age=45, name=Bill}
Person{age=25, name=Courtney}
Person{age=34, name=Kelly}
Person{age=42, name=Kelly}

You can see that the objects are ordered by name, but two persons with the same name Kelly are ordered by age too.

That is the advantage of implementing the Comparable interface – the sorting is always performed the same way. But this is also a disadvantage because to change the order, one has to re-implement the class. Besides, it might be not possible if the Person class comes to us from a library, so we cannot modify its code.

In such cases, the second option—using the Comparator.comparing() method—comes to the rescue. And, by the way, we can do it even when the Person class does not implement the Comparable interface.

Comparator.comparing()方法接受一个函数作为参数。我们将在第十七章中更详细地讨论函数式编程,Lambda 表达式和函数式编程。现在,我们只会说Comparator.comparing()方法基于传递的字段(要排序的类的字段)生成一个比较器。让我们看一个例子:

list.sort(Comparator.comparing(Person::getName));
list.forEach(System.out::println);

上面的代码按名称对Person对象进行排序。我们唯一需要做的修改是向Person类添加getName()方法。同样,如果我们添加getAge()方法,我们可以按年龄对Person对象进行排序:

list.sort(Comparator.comparing(Person::getAge));
list.forEach(System.out::println);

或者我们可以按照两个字段对它们进行排序 - 正如我们在实现Comparable接口时所做的那样:

list.sort(Comparator.comparing(Person::getName).thenComparing(Person::getAge));
list.forEach(System.out::println);

您可以看到在前面的代码中如何使用thenComparing()链接排序方法。

大多数类通常都有 getter 来访问字段值,因此通常不需要添加 getter,任何库类都可以这样排序。

与另一个集合进行比较

每个 Java 集合都实现了equals()方法,用于将其与另一个集合进行比较。在List的情况下,当两个列表被认为是相等的(方法list1.equals(list2)返回true)时:

  • 每个集合的类型都是List

  • 一个列表的每个元素都等于另一个列表中相同位置的元素

以下是说明它的代码:

List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s2");

List<String> list2 = new ArrayList<>();
list2.add("s1");
list2.add("s2");

System.out.println(list1.equals(list2)); //prints: true
list2.sort(Comparator.reverseOrder());
System.out.println(list2);               //prints: [s2, s1]
System.out.println(list1.equals(list2)); //prints: false

如果两个列表相等,它们的hashCode()方法返回相同的整数值。但是hashCode()结果的相等并不能保证列表相等。我们将在下一节讨论Set集合中元素的hashCode()方法实现时讨论这个原因。

List接口(或实现Collection接口的任何集合)的containsAll(Collection)方法只有在提供的集合的所有元素都存在于列表中时才返回true。如果列表的大小和提供的集合的大小相等,我们可以确定比较的每个集合由相同(好吧,相等)的元素组成。但是它并不保证元素是相同类型的,因为它们可能是具有equals()方法的不同代的子类。

如果没有,我们可以使用前面在本节中描述的retainAll(Collection)removeAll(Collection)方法找到差异。假设我们有两个如下的列表:

List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s1");
list1.add("s2");
list1.add("s3");
list1.add("s4");

List<String> list2 = new ArrayList<>();
list2.add("s1");
list2.add("s2");
list2.add("s2");
list2.add("s5");

我们可以找出一个列表中哪些元素不在另一个列表中:

List<String> list = new ArrayList<>(list1);
list.removeAll(list2);
System.out.println(list);    //prints: [s3, s4]

list = new ArrayList<>(list2);
list.removeAll(list1);
System.out.println(list);    //prints: [s5]

请注意我们创建了一个临时列表以避免破坏原始列表。

但是这个差异并不能告诉我们每个列表中可能存在的重复元素。为了找到它,我们可以使用retainAll(Collection)方法:

List<String> list = new ArrayList<>(list1);
list.retainAll(list2);
System.out.println(list);    //prints: [s1, s1, s2]

list = new ArrayList<>(list2);
list.retainAll(list1);
System.out.println(list);    //prints: [s1, s2, s2]

现在我们完整地了解了这两个列表之间的区别。

另外,请注意,retainAll(Collection)方法可以用于识别属于每个列表的元素。

但是retainAll(Collection)removeAll(Collection)都不能保证比较的列表和传入的集合包含相同类型的元素。它们可能是具有共同父级的子级混合,只在父级中实现了equals()方法,而父类型是列表和传入集合的类型。

转换为数组

有两种方法允许将列表转换为数组:

  • toArray():它将列表转换为数组Object[]

  • toArray(T[]):它将列表转换为数组T[],其中T是列表中元素的类型

这两种方法都保留了元素的顺序。这是演示代码,显示了如何做到这一点:

List<String> list = new ArrayList<>();
list.add("s1");
list.add("s2");

Object[] arr1 = list.toArray();
for(Object o: arr1){
  System.out.print(o);       //prints: s1s2
}

String[] arr2 = list.toArray(new String[list.size()]);
for(String s: arr2){
  System.out.print(s);      //prints: s1s2
}

然而,还有另一种将列表或任何集合转换为数组的方法 - 使用流和函数式编程:

Object[] arr3 = list.stream().toArray();
for (Object o : arr3) {
  System.out.print(o);       //prints: s1s2
}

String[] arr4 = list.stream().toArray(String[]::new);
for (String s : arr4) {
  System.out.print(s);       //prints: s1s2
}

流和函数式编程使许多传统的编码解决方案过时了。我们将在第十七章 Lambda 表达式和函数式编程和第十八章 流和管道中讨论这一点。

列表实现

有许多类实现了List接口,用于各种目的:

  • ArrayList:正如我们在本节中讨论的那样,它是迄今为止最受欢迎的List实现

  • LinkedList:提供快速添加和删除列表末尾的元素

  • Stack:为对象提供后进先出LIFO)存储

  • List接口的在线文档中引用了许多其他类。

Set - HashSet 不允许重复

Set接口的创建是有原因的;它的设计目的是不允许重复元素。重复是使用equals()方法来识别的,该方法在类中实现,该类的对象是集合的元素。

更喜欢变量类型 Set

List一样,对于保存对实现Set接口的类的对象引用的变量,使用类型Set是一种良好的编程实践,称为编码到接口。它确保客户端代码独立于任何特定的实现。因此,例如,编写Set<Person> persons = new HashSet<>()是一个好习惯。

为什么叫 HashSet?

在编程中,哈希值是一个代表一些数据的 32 位有符号整数。它在HashTable等数据结构中使用。在HashTable中创建记录后,其哈希值可以在以后快速找到和检索存储的数据。哈希值也称为哈希码、摘要或简单哈希。

在 Java 中,基本Object类中的hashCode()方法返回一个哈希值作为对象表示,但它不考虑任何子级字段的值。这意味着如果需要哈希值包括子对象状态,需要在该子类中实现hashCode()方法。

哈希值是一个代表一些数据的整数。在 Java 中,它代表一个对象。

HashSet类使用哈希值作为唯一键来存储和检索对象。尽管可能的整数数量很大,但对象的种类更多。因此,两个不相等的对象可能具有相同的哈希值。这就是为什么HashSet中的每个哈希值不是指向单个对象,而是潜在地指向一组对象(称为桶)。HashSet使用equals()方法解决这种冲突。

例如,HashSet对象中存储了类A的多个对象,你想知道类A的特定对象是否存在。你可以在HashSet对象上调用contains(object of A)方法。该方法计算提供的对象的哈希值,并查找具有这样一个键的桶。如果没有找到,则contains()方法返回false。但是,如果存在具有这样的哈希值的桶,它可能包含多个类A的对象。这时就需要使用equals()方法。代码使用equals()方法将桶中的每个对象与提供的对象进行比较。如果其中一个equals()调用返回truecontains()方法返回true,从而确认已经存在这样的对象。否则,它返回false

因此,正如我们在讨论List接口时已经提到的那样,如果你创建的类的对象将成为集合的元素,那么实现equals()hashCode()方法并使用相同的实例字段非常重要。由于List不使用哈希值,因此可以使用List接口来处理在Object的子类中没有实现hashCode()方法的对象。但是,任何在名称中带有“Hash”的集合如果没有实现hashCode()方法将无法正常工作。因此,名称。

添加元素

Set接口只提供了一个方法来添加单个元素:

  • add(E):如果集合中不存在这样一个元素E2,使得语句Objects.equals(E1, E2)返回true,则将提供的元素E1添加到集合中

Objects类是一个位于java.util包中的实用类。它的equals()方法以一种空安全的方式比较两个对象,当两个对象都为null时返回true,否则使用equals()方法。我们将在本章的后续部分更多地讨论实用类Objects

add()方法可能会在出现问题时抛出RuntimeException。因此,在该方法周围放置 try-catch 块可以使代码更加健壮(如果 catch 块不仅仅是重新抛出异常,而是执行一些有意义的操作)。在线上阅读Set接口 API 的描述,看看这个方法抛出的异常的名称以及它们可能发生的条件。

add(E)方法也返回一个布尔值(true/false),表示操作的成功。这个方法覆盖了Collection接口中的方法,因此所有扩展或实现Collection接口的 Java 集合都有这个方法。让我们看一个例子:

Set<String> set = new HashSet<>();
System.out.println(set.add("s1"));  //prints: true
System.out.println(set.add("s1"));  //prints: false
System.out.println(set.add("s2"));  //prints: true
System.out.println(set.add("s3"));  //prints: true
System.out.println(set);            //prints: [s3, s1, s2]  

注意,当我们尝试第二次添加元素s1时,add()方法返回false。然后看上面代码的最后一行,观察以下内容:

  • 只有一个元素s1被添加到集合中

  • 元素的顺序不被保留

最后一点观察很重要。Java 规范明确规定,与List相反,Set不保证元素的顺序。当在不同的 JVM 实例上运行相同的代码,甚至在同一实例的不同运行时,顺序可能会有所不同。工厂方法Set.of()在创建无序集合时会对其进行轻微的洗牌(我们将在第十四章中讨论集合工厂方法和数组)。这样,可以在将代码部署到生产环境之前更早地发现对Set和其他无序集合元素特定顺序的不恰当依赖。

size(),isEmpty()和 clear()

这三种方法很直接:

  • size(): 它返回集合中元素的数量

  • isEmpty(): 如果列表中没有元素,则返回truesize()返回 0)

  • clear(): 它从列表中删除所有元素,使isEmpty()返回truesize()返回 0

迭代和流

这个Set功能与之前描述的List没有区别,因为实现Collection接口的每个集合也实现了Iterable接口(因为Collection扩展了Iterable)。可以使用传统的增强for语句或其自己的方法forEach()来迭代Set

Set set = new HashSet();
set.add(null);
set.add(1);
set.add("ss");
set.add(new A());
set.add(new B());
for(Object o: set){
  System.out.println(o);
}
set.forEach(System.out::println);

在第十七章中,Lambda 表达式和函数式编程,我们将解释如何将函数作为forEach()方法的参数传递。两种迭代样式的结果是相同的:

List一样,来自Iterable接口的其他相关方法也与List接口中的方法相同:

  • iterator(): 它返回一个Iterator类的对象,允许遍历(迭代)集合的每个元素并根据需要操作每个元素

  • splititerator(): 它返回一个Splititerator类的对象,允许对集合进行并行处理(讨论此功能超出了本书的范围)

iterator()方法返回的Iterator接口具有以下方法:

  • next(): 它返回迭代中的下一个元素

  • hasNext (): 如果迭代还有更多元素,则返回true

  • forEachRemaining (Consumer<? super E> function): 它将提供的函数应用于剩余的每个元素

  • remove(): 它从基础集合中删除迭代器返回的最后一个元素

next()hasNext()方法是由for语句在后台使用的。

还可以使用Stream类的对象对集合元素进行迭代,这可以通过stream()parallelStream()方法获得。我们将在第十八章中展示如何做到这一点,流和管道

使用泛型添加

List一样,泛型也可以与Set一起使用(或者任何集合都可以)。泛型的规则和Set的行为与在List - ArrayList 保留顺序部分中描述的List完全相同。

添加集合

addAll(Collection<? extends E> collection)方法将提供的对象集合添加到集合中,但仅添加那些尚未存在于集合中的对象。如果集合发生了变化,则该方法返回布尔值true,否则返回false

泛型<? extends E>表示类型EE的任何子类型。

例如,我们可以这样做:

Set<String> set1 = new HashSet<>();
set1.add("s1");
set1.add("s2");
set1.add("s3");

List<String> list = new ArrayList<>();
list.add("s1");

System.out.println(set1.addAll(list)); //prints: false
System.out.println(set1);              //prints: [s3, s1, s2]

list.add("s4");
System.out.println(set1.addAll(list)); //prints: true
System.out.println(set1);              //prints: [s3, s4, s1, s2] 

实现 equals()和 hashCode()

我们已经多次谈到了实现equals()hashCode()方法,这里只会重复一遍,如果你的类要作为Set元素使用,那么这两个方法都必须被实现。在前面的为什么叫 HashSet?部分有解释。

定位元素

List相比,Set与直接定位特定元素相关的唯一功能是由contains(E)方法提供的,如果提供的元素存在于集合中,则返回true。您也可以迭代并以这种方式定位元素,使用equals()方法,但这不是直接定位。

检索元素

List相比,不可能直接从Set中检索元素,因为您不能使用索引或其他方式指向对象。但是可以像之前描述的那样遍历集合,即在迭代和流子节中。

删除元素

Set接口中有四种删除元素的方法:

  • remove(E): 它删除提供的元素并返回true如果列表包含它

  • removeAll(Collection): 它删除提供的元素并返回true如果列表已更改

  • retainAll(Collection): 它删除不在提供的集合中的所有元素并返回true如果列表已更改

  • removeIf(Predicate<? super E> filter): 它删除所有满足提供的谓词返回true的元素

前三种方法的行为方式与List集合相同,因此我们不会重复解释(请参见删除元素部分的List - ArrayList 保留顺序部分)。

至于列出的方法中的最后一个,谓词是返回布尔值的函数。这是函数接口(只有一个抽象方法的接口)和函数式编程的另一个例子。

符号Predicate<? super E>表示接受类型为E或其基类(父类)的参数并返回布尔值的函数。

我们将在第十七章中更多地讨论函数,Lambda 表达式和函数式编程。与此同时,以下示例显示了如何使用removeIf()方法:

Set<String> set = new HashSet();
set.add(null);
set.add("s1");
set.add("s1");
set.add("s2");
set.add("s3");
set.add("s4");
System.out.println(set);    //[null, s3, s4, s1, s2]

set.removeIf(e -> "s1".equals(e));
System.out.println(set);   //[null, s3, s4, s2]

set.removeIf(e -> e == null);
System.out.println(set);    //[s3, s4, s2] 

请注意,当尝试查找等于s1的元素e时,我们将s1放在第一位。这与我们在英语中表达的方式不一样,但它有助于避免NullPointerException,以防其中一个元素是null(就像我们的情况一样)。

替换元素

List相反,不可能直接替换Set中的元素,因为您不能使用索引或其他方式指向对象。但是可以像之前描述的那样遍历集合,或者使用Stream对象(我们将在第十八章中讨论这一点,流和管道),检查每个元素并查看这是否是您要替换的元素。那些不符合条件的元素,您可以添加到一个新的集合。而那些您想要替换的元素,则跳过并将另一个对象(将替换您跳过的对象)添加到新集合中:

Set<String> set = new HashSet();
set.add(null);
set.add("s2");
set.add("s3");
System.out.println(set);    //[null, s3, s2]

//We want to replace s2 with s5
Set<String> newSet = new HashSet<>();
set.forEach(s -> {
  if("s2".equals(s)){
    newSet.add("s5");
  } else {
     newSet.add(s);
  }
});
set = newSet;
System.out.println(set);    //[null, s3, s5]

在我们从原始集合切换到新集合的引用后(set = newSet),原始集合最终将被垃圾收集器从内存中删除,结果将与我们只是替换原始集合中的一个元素一样。

排序

Set接口不允许排序,也不保证顺序保留。如果需要将这些功能添加到集合中,可以使用接口java.util.SortedSetjava.util.NavigableSet及其实现java.util.TreeSetjava.util.ConcurrentSkipListSet

与另一个集合进行比较

每个 Java 集合都实现了equals()方法,用于将其与另一个集合进行比较。在Set的情况下,当两个集合被认为是相等的(set1.equals(set2)返回true)时:

  • 每个集合的类型都是Set

  • 它们的大小相同

  • 一个集合的每个元素都包含在另一个集合中

以下代码说明了定义:

Set<String> set1 = new HashSet<>();
set1.add("s1");
set1.add("s2");

List<String> list = new ArrayList<>();
list.add("s2");
list.add("s1");

System.out.println(set1.equals(list)); //prints: false 

前面的集合不相等,因为它们的类型不同。现在,让我们比较两个集合:

Set<String> set2 = new HashSet<>();
set2.add("s3");
set2.add("s1");

System.out.println(set1.equals(set2)); //prints: false

set2.remove("s3");
set2.add("s2");
System.out.println(set1.equals(set2)); //prints: true

前面的集合根据其元素的组成而不同,即使集合的大小相同。

如果两个集合相等,则它们的hashCode()方法返回相同的整数值。但hashCode()结果的相等并不保证集合相等。我们已经在前面的子节实现 equals()和 hashCode()中讨论了这个原因。

Set接口的containsAll(Collection)方法(或者任何实现Collection接口的集合)仅在提供的集合的所有元素都存在于集合中时才返回true。如果集合的大小和提供的集合的大小相等,我们可以确定比较的集合的每个元素是相同的(好吧,相等的)。但这并不保证元素是相同类型的,因为它们可能是具有equals()方法的不同代的子代。

如果不是,我们可以使用retainAll(Collection)removeAll(Collection)方法来查找差异,这些方法在本节前面已经描述过。假设我们有两个列表如下:

Set<String> set1 = new HashSet<>();
set1.add("s1");
set1.add("s1");
set1.add("s2");
set1.add("s3");
set1.add("s4");

Set<String> set2 = new HashSet<>();
set2.add("s1");
set2.add("s2");
set2.add("s2");
set2.add("s5"); 

我们可以找到一个集合中不在另一个集合中的元素:

Set<String> set = new HashSet<>(set1);
set.removeAll(set2);
System.out.println(set);    //prints: [s3, s4]

set = new HashSet<>(set2);
set.removeAll(set1);
System.out.println(set);    //prints: [s5] 

请注意,我们创建了一个临时集合以避免破坏原始集合。

由于Set不允许重复元素,因此无需使用retainAll(Collection)方法来查找集合之间的更多差异,就像我们为List所做的那样。相反,retainAll(Collection)方法可用于查找两个集合中的公共元素:

Set<String> set = new HashSet<>(set1);
set.retainAll(set2);
System.out.println(set);    //prints: [s1, s2]

set = new HashSet<>(set2);
set.retainAll(set1);
System.out.println(set);    //prints: [s1, s2]

正如您从前面的代码中可以看到的,要找到两个集合之间的公共元素,只需要使用retainAll()方法一次就足够了,无论哪个集合是主集合,哪个是参数集合。

另外,请注意,retainAll(Collection)方法和removeAll(Collection)方法都不能保证比较的集合和传入的集合包含相同类型的元素。它们可能是具有共同父类的子类的混合体,只有父类中实现了equals()方法,而父类类型是集合和传入的集合的类型。

转换为数组

有两种方法允许将集合转换为数组:

  • toArray(): 将集合转换为数组Object[]

  • toArray(T[]): 将集合转换为数组T[],其中T是集合中元素的类型

这两种方法只有在集合保持顺序的情况下才能保留元素的顺序,例如SortedSetNavigableSet。以下是演示代码,显示了如何执行此操作:

Set<String> set = new HashSet<>();
set.add("s1");
set.add("s2");

Object[] arr1 = set.toArray();
for(Object o: arr1){
  System.out.print(o);       //prints: s1s2
}

String[] arr2 = set.toArray(new String[set.size()]);

for(String s: arr2){
  System.out.print(s);     //prints: s1s2
}

然而,还有另一种将集合或任何集合转换为数组的方法——使用流和函数式编程:

Object[] arr3 = set.stream().toArray();
for (Object o : arr3) {
  System.out.print(o);       //prints: s1s2
}

String[] arr4 = set.stream().toArray(String[]::new);
for (String s : arr4) {
  System.out.print(s);       //prints: s1s2
}

流和函数式编程使许多传统的编码解决方案过时。我们将在第十七章 Lambda 表达式和函数式编程和第十八章 流和管道中讨论它们。

集合实现

有许多实现Set接口的类,用于各种目的:

  • 我们在本节讨论了HashMap类;它是迄今为止最受欢迎的Set实现

  • LinkedHashSet类按顺序存储唯一元素

  • TreeSet类根据其值的自然顺序或在创建时提供的Comparator对其元素进行排序

  • Set接口的在线文档中引用了许多其他类

Map – HashMap 通过键存储/检索对象

Map接口本身与Collection接口没有直接关联,但它使用Set接口作为其键和Collection作为其值。例如,对于Map<Integer, String> map

  • Set<Integer> keys = map.keySet();

  • Collection<String> values = map.values();

每个值都存储在一个具有唯一键的映射中,当添加到映射中时,该键与值一起传递。在Map<Integer, String> map的情况下:

map.put(42, "whatever");        //42 is the key for the value "whatever"

然后,稍后可以通过其键检索值:

String v = map.get(42);
System.out.println(v);     //prints: whatever

这些是传达Map接口目的的基本映射操作——提供键值对的存储,其中键和值都是对象,并且用作键的类实现了equals()hashCode()方法,这些方法覆盖了Object类中的默认实现。

现在,让我们更仔细地看看Map接口、它的实现和用法。

更喜欢变量类型 Map

ListSet一样,使用类型Map来保存对实现Map接口的类的对象的引用的变量是一种良好的编程实践,称为编码到接口。它确保客户端代码不依赖于任何特定的实现。因此,将所有人员存储为值的Map<String, Person> persons = new HashMap<>()是一个很好的习惯-以他们的键-例如社会安全号码。

为什么称为 HashMap?

到目前为止,你应该已经很明显地意识到HashMap类之所以有“Hash”在其名称中,是因为它使用它们的哈希值存储键,这些哈希值是由hashCode()方法计算得出的。由于我们在前面的章节中已经对此进行了详细讨论,所以我们不打算在这里讨论哈希值及其用法;你可以回到本章的前一节为什么称为 HashSet?中参考。

添加和可能替换

Map接口存储键值对,也称为条目,因为在Map中,每个键值对也由Entry接口表示,它是Map的嵌套接口,因此被称为Map.Entry

可以使用以下方法向Map添加键值对或条目:

  • V put(K, V): 它添加一个键值对(或创建一个键值关联)。如果提供的键已经存在,则该方法覆盖旧值并返回它(如果旧值为null,则返回null)。如果提供的键尚未在映射中,则该方法返回null

  • V putIfAbsent(K, V): 它添加一个键值对(或创建一个键值关联)。如果提供的键已经存在并且关联的值不是null,则该方法不会覆盖旧值,而只是返回它。如果提供的键尚未在映射中或者关联的值为null,则该方法将覆盖旧值并返回null

以下代码演示了所描述的功能:

Map<Integer, String> map = new HashMap<>();
System.out.println(map.put(1, null));  //prints: null
System.out.println(map.put(1, "s1"));  //prints: null
System.out.println(map.put(2, "s1"));  //prints: null
System.out.println(map.put(2, "s2"));  //prints: s1
System.out.println(map.put(3, "s3"));  //prints: null
System.out.println(map);               //prints: {1=s1, 2=s2, 3=s3}

System.out.println(map.putIfAbsent(1, "s4"));  //prints: s1
System.out.println(map);               //prints: {1=s1, 2=s2, 3=s3}

System.out.println(map.put(1, null));  //prints: s1
System.out.println(map);               //prints: {1=null, 2=s2, 3=s3}

System.out.println(map.putIfAbsent(1, "s4"));  //prints: null
System.out.println(map);               //prints: {1=s4, 2=s2, 3=s3}

System.out.println(map.putIfAbsent(4, "s4"));  //prints: null
System.out.println(map);               //prints: {1=s4, 2=s2, 3=s3, 4=s4}

请注意,在返回值为null的情况下,结果存在一些歧义-新条目是否已添加(并替换了值为null的条目)或者没有。因此,在使用描述的方法时,必须注意可能包含null值的映射。

还有compute()merge()方法,它们允许您向映射中添加和修改数据,但它们的使用对于入门课程来说过于复杂,因此我们将它们排除在讨论之外。此外,它们在主流编程中并不经常使用。

size(),isEmpty()和 clear()

这三种方法很简单:

  • size(): 它返回映射中键值对(条目)的计数

  • isEmpty(): 如果映射中没有键值对(条目),则返回truesize()返回 0)

  • clear(): 它从映射中删除所有键值对(条目),使得isEmpty()返回truesize()返回 0

迭代和流

有几种方法可以使用以下Map方法对映射内容进行迭代,例如:

  • Set<K> keySet(): 它返回与地图中存储的值相关联的键。可以遍历此集合,并从地图中检索每个键的值(请参阅前面的部分,了解如何遍历集合)。

  • Collection<V> values(): 它返回地图中存储的值。可以遍历此集合。

  • Set<Map.Entry<K,V>> entrySet(): 它返回地图中存储的条目(键值对)。可以遍历此集合,并从每个条目中获取键和值。

  • forEach (BiConsumer<? super K,? super V> action): 它遍历存储在地图中的键值对,并将它们作为输入提供给函数BiConsumer,该函数接受地图键和值类型的两个参数,并返回void

以下是如何阅读符号BiConsumer<? super K,? super V>的方法:它描述了一个函数。函数名称中的Bi表示它接受两个参数:一个是类型为K或其任何超类的参数,另一个是类型为V或其任何超类的参数。函数名称中的Consumer表示它不返回任何内容(void)。

我们将在第十七章中更多地讨论函数式编程,Lambda 表达式和函数式编程

为了演示前面的方法,我们将使用以下地图:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");

以下是如何迭代此地图的方法:

for(Integer key: map.keySet()){
  System.out.println("key=" + key + ", value=" + map.get(key));
}
map.keySet().stream()
   .forEach(k->System.out.println("key=" + k + ", value=" + map.get(k)));
for(String value: map.values()){
  System.out.println("value=" + value);
}
map.values().stream().forEach(System.out::println);
map.forEach((k,v) -> System.out.println("key=" + k + ", value=" + v));
map.entrySet().forEach(e -> System.out.println("key=" + e.getKey() + 
                                          ", value=" + e.getValue()));

所有前面的方法产生相同的结果,除了values()方法,它只返回值。使用哪一个取决于风格,但似乎map.forEach()需要较少的按键来实现迭代。

使用泛型添加

从我们的示例中,您已经看到了如何将泛型与Map一起使用。它通过允许编译器检查地图和尝试存储在其中的对象之间的匹配,为程序员提供了宝贵的帮助。

添加另一个地图

putAll(Map<? extends K, ? extends V> map)方法从提供的地图中添加每个键值对,就像put(K, V)方法对一个键值对一样:

Map<Integer, String> map1 = new HashMap<>();
map1.put(1, null);
map1.put(2, "s2");
map1.put(3, "s3");

Map<Integer, String> map2 = new HashMap<>();
map2.put(1, "s1");
map2.put(2, null);
map2.put(4, "s4");

map1.putAll(map2);
System.out.println(map1); //prints: {1=s1, 2=null, 3=s3, 4=s4}

正如您所看到的,putAll()方法添加了一个新的键值对,或者覆盖了现有键值对中的值(基于键),并且不返回任何内容。

实现 equals()和 hashCode()

如果您要将您编写的类用作Map中的键,实现equals()hashCode()方法非常重要。请参阅前面部分中的解释,为什么称为 HashSet?在我们的示例中,我们已经使用了Integer类的对象作为键。该类根据类的整数值实现了这两种方法。

作为Map中的值存储的类必须至少实现equals()方法(请参阅下一小节定位元素)。

定位元素

以下两种方法回答了特定键或值是否存在于地图中的问题:

  • containsKey(K): 如果提供的键已经存在,则返回true

  • containsValue(V): 如果提供的值已经存在,则返回true

这两种方法都依赖于equals()方法来识别匹配项。

检索元素

要从Map中检索元素,可以使用以下四种方法之一:

  • V get(Object K):它返回提供的键的值,如果提供的键不在地图中,则返回null

  • V getOrDefault(K, V):它返回提供的键的值,如果提供的键不在地图中,则返回提供的(默认)值

  • Map.Entry<K,V> entry(K,V):将提供的键-值对转换为Map.Entry的不可变对象的静态方法(不可变意味着可以读取,但不能更改)

  • Map<K,V> ofEntries(Map.Entry<? extends K,? extends V>... entries):它基于提供的条目创建一个不可变的地图

以下代码演示了这些方法:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");

System.out.println(map.get(2));                 //prints: s2
System.out.println(map.getOrDefault(2, "s4"));  //prints: s2
System.out.println(map.getOrDefault(4, "s4"));  //prints: s4

Map.Entry<Integer, String> entry = Map.entry(42, "s42");
System.out.println(entry);      //prints: 42=s42

Map<Integer, String> entries = 
                Map.ofEntries(entry, Map.entry(43, "s43"));   
System.out.println(entries);   //prints: {42=s42, 43=s43}

并且始终可以通过迭代来检索地图的元素,就像我们在迭代和流的子节中描述的那样。

删除元素

两种方法允许直接删除 Map 元素:

  • V remove(Object key):它删除与键关联的对象并返回其值,或者,如果地图中不存在这样的键,则返回null

  • boolean remove(Object key, Object value):它只有在当前与键关联的值等于提供的值时,才删除与键关联的对象;如果元素被删除,则返回true

以下是说明所述行为的代码:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");
System.out.println(map.remove(2));        //prints: s2
System.out.println(map);                  //prints: {1=null, 3=s3}
System.out.println(map.remove(4));        //prints: null
System.out.println(map);                  //prints: {1=null, 3=s3}
System.out.println(map.remove(3, "s4"));  //prints: false
System.out.println(map);                  //prints: {1=null, 3=s3}
System.out.println(map.remove(3, "s3"));  //prints: true
System.out.println(map);                  //prints: {1=null}

还有另一种通过键删除Map元素的方法。如果从地图中删除了键,则相应的值也将被删除。以下是演示它的代码:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");

Set<Integer> keys = map.keySet();

System.out.println(keys.remove(2));      //prints: true
System.out.println(map);                 //prints: {1=null, 3=s3}

System.out.println(keys.remove(4));      //prints: false
System.out.println(map);                 //prints: {1=null, 3=s3}

同样,还可以使用Set接口中的removeAll(Collection)retainAll(Collection)removeIf(Predicate<? super E> filter)方法,这些方法在删除元素的子节中有描述,Set - HashSet 不允许重复**,也可以使用。

替换元素

要替换Map的元素,可以使用以下方法:

  • V replace(K, V):它只有在提供的键存在于地图中时,才用提供的值替换值;如果这样的键存在,则返回先前(替换的)值,如果这样的键不存在,则返回null

  • boolean  replace(K, oldV, newV) :它只有在提供的键存在于地图中并且当前与提供的值oldV关联时,才用新值newV替换当前值(oldV);如果值被替换,则返回true

  • void replaceAll(BiFunction<? super K, ? super V, ? extends V> function): 它允许您使用提供的函数替换值,该函数接受两个参数 - 键和值 - 并返回一个新值,该新值将替换此键值对中的当前值

以下是如何阅读符号BiFunction<? super K, ? super V, ? extends V>:它描述了一个函数。Bi中的函数名称表示它接受两个参数:一个是类型K或任何其超类,另一个是类型 V 或任何其超类。函数名称中的Function部分表示它返回某些东西。返回的值是最后列出的。在这种情况下,它是<? extends V>,这意味着类型V或其子类的值。

让我们假设我们要更改的地图如下:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");

然后,说明前两种方法的代码如下:

System.out.println(map.replace(1, "s1"));   //prints: null
System.out.println(map);                    //prints: {1=s1, 2=s2, 3=s3}

System.out.println(map.replace(4, "s1"));   //prints: null
System.out.println(map);                    //prints: {1=s1, 2=s2, 3=s3}

System.out.println(map.replace(1, "s2", "s1"));   //prints: false
System.out.println(map);                    //prints: {1=s1, 2=s2, 3=s3}

System.out.println(map.replace(1, "s1", "s2"));   //prints: true
System.out.println(map);                    //prints: {1=s2, 2=s2, 3=s3}

这是帮助理解列出的最后一个替换方法的代码:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, null);
map.put(3, "s3");

map.replaceAll((k,v) -> v == null? "s" + k : v);
System.out.println(map);                 //prints: {1=s1, 2=s2, 3=s3}

map.replaceAll((k,v) -> k == 2? "n2" : v);
System.out.println(map);                 //prints: {1=s1, 2=n2, 3=s3}

map.replaceAll((k,v) -> v.startsWith("s") ? "s" + (k + 10) : v);
System.out.println(map);                 //prints: {1=s11, 2=n2, 3=s13}

请注意,我们只能在用其他东西替换所有null值之后才能使用v.startsWith()方法。否则,这行可能会抛出NullPointerException,我们需要将其更改为以下行:

map.replaceAll((k,v) -> (v != null && v.startsWith("s")) ? 
                                          "s" + (k + 10) : v);

排序

Map接口不允许排序,也不保证顺序保留。如果您需要将这些功能添加到地图中,可以使用接口java.util.SortedMapjava.util.NavigableMap,以及它们的实现java.util.TreeMapjava.util.ConcurrentSkipListMap

与另一个集合比较

每个 Java 集合都实现了equals()方法,它将其与另一个集合进行比较。在Map的情况下,当两个地图被认为是相等的(map1.equals(map2)返回true)时:

  • 两者都是Map对象

  • 一个地图具有与另一个地图相同的键值对集

这是说明定义的代码:

Map<Integer, String> map1 = new HashMap<>();
map1.put(1, null);
map1.put(2, "s2");
map1.put(3, "s3");

Map<Integer, String> map2 = new HashMap<>();
map2.put(1, null);
map2.put(2, "s2");
map2.put(3, "s3");

System.out.println(map2.equals(map1)); //prints: true

map2.put(1, "s1");
System.out.println(map2.equals(map1)); //prints: false

如果你仔细想一想,map1.equals(map2)方法返回的结果与map1.entrySet().equals(map2.entrySet())方法返回的结果完全相同,因为entrySet()方法返回“Set<Map.Entry<K,V>`,我们知道(请参见子部分与另一个集合比较)两个集合相等当一个集合的每个元素都包含在另一个集合中。

如果两个地图相等,则它们的hashCode()方法返回相同的整数值。但是,hashCode()结果的相等并不保证地图相等。我们在讨论hashCode()方法的实现时已经谈到了这一点,这是在上一节中讨论Set集合的元素时。

如果两个地图不相等,并且需要找出确切的差异,有多种方法可以做到这一点:

map1.entrySet().containsAll(map2.entrySet());
map1.entrySet().retainAll(map2.entrySet());
map1.entrySet().removeAll(map2.entrySet());

map1.keySet().containsAll(map2.keySet());
map1.keySet().retainAll(map2.keySet());
map1.keySet().removeAll(map2.keySet());

map1.values().containsAll(map2.values());
map1.values().retainAll(map2.values());
map1.values().removeAll(map2.values());

使用这些方法的任意组合,可以全面了解两个地图之间的差异。

地图实现

有许多类实现了Map接口,用于各种目的:

  • 我们在本节中讨论的HashMap;它是迄今为止最流行的Map实现

  • LinkedHashMap类按其插入顺序存储其键值对

  • TreeMap类根据键的自然顺序或在创建时提供的Comparator对其键值对进行排序

  • 许多其他类在Map接口的在线文档中有所提及。

练习-EnumSet 方法

我们没有讨论java.util.EnumSet集合。它是一个较少人知道但非常有用的类,在需要使用一些enum值时。在线查找其 API 并编写代码来演示其四种方法的用法:

  • of()

  • complementOf()

  • allOf()

  • range()

答案

假设enum类看起来像下面这样:

enum Transport { AIRPLANE, BUS, CAR, TRAIN, TRUCK }

然后,演示EnumSet的四种方法的代码可能如下所示:

EnumSet<Transport> set1 = EnumSet.allOf(Transport.class);
System.out.println(set1);   //prints: [AIRPLANE, BUS, CAR, TRAIN, TRUCK]

EnumSet<Transport> set2 = EnumSet.range(Transport.BUS, Transport.TRAIN);
System.out.println(set2);   //prints: [BUS, CAR, TRAIN]

EnumSet<Transport> set3 = EnumSet.of(Transport.BUS, Transport.TRUCK);
System.out.println(set3);   //prints: [BUS, TRUCK]

EnumSet<Transport> set4 = EnumSet.complementOf(set3);
System.out.println(set4);   //prints: [AIRPLANE, CAR, TRAIN]

总结

本章使读者熟悉了 Java 集合和最流行的集合接口-ListSetMap。代码示例使它们的功能更加清晰。代码的注释吸引了读者对可能的陷阱和其他有用细节的注意。

在下一章中,我们将继续概述 Java 标准库和 Apache Commons 中最流行的类。其中大多数是实用程序,例如ObjectsCollectionsStringUtilsArrayUtils。其他只是类,例如StringBuilderStringBufferLocalDateTime。有些帮助管理集合;其他帮助管理对象。它们的共同之处在于它们属于每个 Java 程序员在成为有效编码人员之前必须掌握的一小组工具。

第十四章:管理集合和数组

我们将在本章中讨论的类允许我们创建、初始化和修改 Java 集合和数组的对象。它们还允许创建不可修改和不可变集合。这些类中的一些属于 Java 标准库,其他属于流行的 Apache Commons 库。了解这些类并熟悉它们的方法对于任何 Java 程序员都是必不可少的。

我们将涵盖以下功能领域:

  • 管理集合

  • 管理数组

概述的类列表包括:

  • java.util.Collections

  • org.apache.commons.collections4.CollectionUtils

  • java.util.Arrays

  • org.apache.commons.lang3.ArrayUtils

管理集合

在本节中,我们将回顾如何创建和初始化集合对象,什么是不可变集合,以及如何对集合执行基本操作——复制、排序和洗牌,例如。

初始化集合

我们已经看到了一些不带参数的集合构造函数的示例。现在,我们将看到创建和初始化集合对象的其他方法。

集合构造函数

每个集合类都有一个接受相同类型元素集合的构造函数。例如,这是如何使用ArrayList(Collection collection)构造函数创建ArrayList类的对象,以及如何使用HashSet(Collection collection)构造函数创建HashSet类的对象:

List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s1");

List<String> list2 = new ArrayList<>(list1);
System.out.println(list2);      //prints: [s1, s1]

Set<String> set = new HashSet<>(list1);
System.out.println(set);        //prints: [s1]

List<String> list3 = new ArrayList<>(set);
System.out.println(list3);      //prints: [s1]

我们将在使用其他对象和流子部分中展示更多使用这些构造函数的示例。

实例初始化程序(双括号)

可以使用双括号初始化器进行集合初始化。当集合是实例字段的值时,它特别适用,因此在对象创建期间会自动初始化。这是一个例子:

public class ManageCollections {
  private List<String> list = new ArrayList<>() {
        {
            add(null);
            add("s2");
            add("s3");
        }
  };
  public List<String> getThatList(){
      return this.list;
  }
  public static void main(String... args){
    ManageCollections mc = new ManageCollections();
    System.out.println(mc.getThatList());    //prints: [null, s2, s3]
  }
}

我们添加了一个 getter,并在main()方法运行时使用它。不幸的是,双括号初始化器与构造函数中的传统集合初始化相比并没有节省任何输入时间:

public class ManageCollections {
  private List<String> list = new ArrayList<>();
  public ManageCollections(){
        list.add(null);
        list.add("s2");
        list.add("s3");
  }
  public List<String> getThatList(){
      return this.list;
  }
  public static void main(String... args){
    ManageCollections mc = new ManageCollections();
    System.out.println(mc.getThatList());    //prints: [null, s2, s3]
  }
}

唯一的区别是每次调用add()方法时都需要为list变量输入。此外,双括号初始化器有一个额外的开销,它创建了一个只有实例初始化程序和对封闭类的引用的匿名类。它也可能有更多的问题,因此应该避免使用。

好消息是,有一种更短、更方便的初始化集合的方法,作为字段值或局部变量值:

private List<String> list = Arrays.asList(null, "s2", "s3");

java.util.ArraysasList()静态方法非常受欢迎(我们将很快更详细地讨论Arrays类)。唯一的潜在缺点是这样的列表不允许添加元素:

List<String> list = Arrays.asList(null, "s2", "s3");
list.add("s4");    // throws UnsupportedOperationException

但是,我们总是可以通过将初始化的列表传递给构造函数来创建一个新的集合:

List<String> list = new ArrayList(Arrays.asList(null, "s2", "s3"));
list.add("s4");   //works just fine

Set<String> set = new HashSet<>(Arrays.asList(null, "s2", "s3"));
set.add("s4");   //works just fine as well

请注意,集合类的构造函数接受实现Collection接口的任何对象。它允许从集合创建列表,反之亦然。但是,Map接口不扩展Collection,因此Map实现只允许从另一个映射创建映射:

Map<Integer, String> map = new HashMap<>();
map.put(1, null);
map.put(2, "s2");
map.put(3, "s3");

Map<Integer, String> anotherMap = new HashMap<>(map);

新映射的键和值的类型必须与提供的映射中的类型相同,或者必须是提供的映射类型的父类型:

class A{}
class B extends A{}
Map<Integer, B> mb = new HashMap<>();
Map<Integer, A> ma = new HashMap<>(mb);

例如,这是一个可以接受的赋值:

Map<Integer, String> map1 = new HashMap<>();
Map<Integer, Object> map2 = new HashMap<>(map1);

这是因为HashMap构造函数将类型限制在映射元素的子类型之间:

HashMap(Map<? extends K,? extends V> map)

还有以下代码也有类似的问题:

class A {}
class B extends A {}
List<A> l1 = Arrays.asList(new B());
List<B> l2 = Arrays.asList(new B());
//List<B> l3 = Arrays.asList(new A()); //compiler error

前面的代码是有意义的,不是吗?class B有(继承)class A的所有非私有方法和字段,但可以有其他非私有方法和字段,这些方法和字段在class A中不可用。即使今天两个类都是空的,就像我们的例子一样,明天我们可能决定向class B中添加一些方法。因此,编译器保护我们免受这种情况的影响,并且不允许将具有父类型元素的集合分配给子类型的集合。这就是泛型在以下构造函数定义中的含义,正如您在 Java 标准库 API 的java.util包中看到的那样:

ArrayList(Collection<? extends E> collection)

HashSet(Collection<? extends E> collection)

HashMap(Map<? extends K,? extends V> map)

我们希望到目前为止,您对这样的泛型更加熟悉。如果有疑问,请阅读上一章关于泛型的部分。

静态初始化块

静态字段初始化也有类似的解决方案。静态块可以包含必要的代码,用于生成必须用于静态字段初始化的值:

class SomeClass{
   public String getThatString(){
      return "that string";
   }
}
public class ManageCollections {
  private static Set<String> set = new HashSet<>();
   static {
        SomeClass someClass = new SomeClass();
        set.add(someClass.getThatString());
        set.add("another string");
  }
  public static void main(String... args){
    System.out.println(set); //prints: [that string, another string]
  }
}

由于set是一个静态字段,它不能在构造函数中初始化,因为构造函数只有在创建实例时才会被调用,而静态字段可以在不创建实例的情况下被访问。我们也可以将前面的代码重写如下:

private static Set<String> set = 
    new HashSet<>(Arrays.asList(new SomeClass().getThatString(), 
                                                "another string"));

但是,您可以说它看起来有些笨拙和难以阅读。因此,如果它允许编写更易读的代码,静态初始化块可能是更好的选择。

of()的工厂方法

自 Java 9 以来,每个接口中都有另一种创建和初始化集合的选项,包括Map——of()工厂方法。它们被称为工厂,因为它们生成对象。有 11 种这样的方法,它们接受 0 到 10 个参数,每个参数都是要添加到集合中的元素,例如:

List<String> iList0 = List.of();
List<String> iList1 = List.of("s1");
List<String> iList2 = List.of("s1", "s2");
List<String> iList3 = List.of("s1", "s2", "s3");

Set<String> iSet1 = Set.of("s1", "s2", "s3", "s4");
Set<String> iSet2 = Set.of("s1", "s2", "s3", "s4", "s5");
Set<String> iSet3 = Set.of("s1", "s2", "s3", "s4", "s5", "s6", 
                                              "s7", "s8", "s9", "s10");

Map<Integer, String> iMap = Map.of(1, "s1", 2, "s2", 3, "s3", 4, "s4");

请注意地图是如何构建的:从一对值到 10 对这样的值。

我们决定从上面的变量开始使用"i"作为标识符,以表明这些集合是不可变的。我们将在下一节中讨论这一点。

这些工厂方法的另一个特点是它们不允许null作为元素值。如果添加,null元素将导致运行时错误(NullPointerException)。之所以不允许null是因为很久以前就不得不禁止它出现在大多数集合中。这个问题对Set尤为重要,因为集合为Map提供键,而null键没有太多意义,对吧?例如,看下面的代码:

Map<Integer, String> map = new HashMap<>();
map.put(null, "s1");
map.put(2, "s2");
System.out.println(map.get(null));     //prints: s1

您可能还记得Map接口的put()方法,如果提供的键没有关联的值,或者旧值为null,则返回null。这种模棱两可很烦人,不是吗?

这就是为什么 Java 9 的作者决定开始从集合中排除null。可能总会有允许null的特殊集合实现,但是最常用的集合最终将不允许null,我们现在描述的工厂方法是朝着这个方向迈出的第一步。

这些工厂方法添加的另一个期待已久的特性是集合元素顺序的随机化。这意味着每次执行相同的集合创建时顺序都不同。例如,如果我们运行这些行:

Set<String> iSet3 = Set.of("s1", "s2", "s3", "s4", "s5", "s6", 
                                       "s7", "s8", "s9", "s10");
System.out.println(iSet3);

输出可能如下:

但是,如果我们再次运行相同的两行,输出将不同:

每次执行集合创建都会导致元素的不同顺序。这就是随机化的作用。它有助于及早发现程序员对顺序的某种依赖在不保证顺序的地方。

使用其他对象和流

构造函数子部分中,我们演示了List<T> Arrays.asList(T...a)方法如何用于生成值列表,然后可以将其传递给实现Collection接口的任何类的构造函数(或者扩展Collection的任何接口,例如ListSet)。作为提醒,我们想提一下(T...a)表示法称为可变参数,意味着可以以以下两种方式之一传递参数:

  • 作为 T 类型的无限逗号分隔值序列

  • 作为任何大小的 T 类型数组

因此,以下两个语句都创建了相等的列表:

List<String> x1 = Arrays.asList(null, "s2", "s3");
String[] array = {null, "s2", "s3"};
List<String> x2 = Arrays.asList(array);
System.out.println(x1.equals(x2));       //prints: true

Java 8 增加了另一种创建集合的方法,引入了流。这是一个可能的列表和集合对象生成的例子(我们将在第十八章中更多地讨论流和管道):

List<String> list2 = Stream.of(null, "s2", "s3")
                           .collect(Collectors.toList());
System.out.println(list2);               //prints: [null, s2, s3]

Set<String> set2 = Stream.of(null, "s2", "s3")
                         .collect(Collectors.toSet());
System.out.println(set2);               //prints: [null, s2, s3]

如果你阅读关于Collectors.toList()Collectors.toSet()方法的文档,你会发现它说“返回的列表的类型、可变性、可序列化性或线程安全性没有保证;如果需要对返回的列表有更多的控制,使用 toCollection(Supplier)。”它们指的是Collectors类的toCollection(Supplier<C> collectionFactory)方法。

Supplier<C>表示一个不带参数并产生类型为C的值的函数,因此得名。

在许多情况下(如果不是大多数情况),我们不关心返回的是哪个类(ListSet的实现)。这正是面向接口编程的美妙之处。但如果我们关心,这里是如何使用toCollection()方法的一个例子,根据之前的建议,这是比toList()toSet()更好的选择:

List<String> list3 = Stream.of(null, "s2", "s3")
               .collect(Collectors.toCollection(ArrayList::new));
System.out.println(list3);               //prints: [null, s2, s3]

Set<String> set3 = Stream.of(null, "s2", "s3")
                 .collect(Collectors.toCollection(HashSet::new));
System.out.println(set3);               //prints: [null, s2, s3]

如果你觉得我们创建一个集合,然后流它,再次生成相同的集合看起来很奇怪,但请记住,在实际编程中,你可能只会得到Stream对象,而我们创建一个流是为了让示例工作,并向你展示期望得到的值。

Map的情况下,文档中还提到了以下代码,没有关于类型的保证:

Map<Integer, String> m = new HashMap<>();
m.put(1, null);
m.put(2, "s2");
Map<Integer, String> map2 = m.entrySet().stream()
  .map(e -> e.getValue() == null ? Map.entry(e.getKey(), "") : e)
  .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
System.out.println(map2);    //prints: {1=, 2=s2} 

请注意我们如何处理null,通过用空的String文字""替换它,以避免可怕的NullPointerException。这里是类似于之前的toCollection()方法的代码,使用我们选择的实现,这里是HashMap类:

Map<Integer, String> map3 = m.entrySet().stream()
   .map(e -> e.getValue() == null ? Map.entry(e.getKey(), "") : e)
   .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue(),
                                         (k,v) -> v, HashMap::new));
System.out.println(map3);    //prints: {1=, 2=s2}

如果提供的示例对你来说看起来太复杂,你是对的;即使对有经验的程序员来说,它们也很复杂。原因有两个:

  • 函数式编程是一种与 Java 存在的头二十年中使用的编码方式不同的编码方式

  • 它是最近才在 Java 中引入的,没有太多围绕它构建的实用方法,使代码看起来更简单

好消息是,过一段时间,你会习惯它,流和函数式编程会开始变得简单。甚至有很大的机会你会更喜欢它,因为使用函数和流使代码更紧凑,更强大,更清晰,特别是在需要高效处理大量数据(大数据)的情况下,这似乎是当前的趋势,延伸到未来。

我们将在第十七章、Lambda 表达式和函数式编程;第十八章、流和管道;以及第十九章、响应式系统中更多地讨论这个问题。

不可变集合

在日常语言中,形容词不可变不可修改是可以互换使用的。但是在 Java 集合的情况下,不可修改的集合是可以更改的。好吧,这也取决于你对更改这个词的理解。这就是我们的意思。

不可变与不可修改

Collections类中有八个静态方法可以使集合不可修改

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

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

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

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

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

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

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

  • NavigableMap<K,V> unmodifiableNavigableMap(NavigableMap<K,? extends V> navigableMap)

以下是创建不可修改列表的代码示例:

List<String> list = Arrays.asList("s1", "s1");
System.out.println(list);          //prints: [s1, s1]

List<String> unmodfifiableList = Collections.unmodifiableList(list);
//unmodfifiableList.set(0, "s1"); //UnsupportedOperationException
//unmodfifiableList.add("s2");    //UnsupportedOperationException

正如你可能期望的那样,我们既不能更改元素的值,也不能向不可修改的列表中添加新元素。尽管如此,我们仍然可以更改底层列表,因为我们仍然持有对它的引用。之前创建的不可修改列表将捕获到这种更改:

System.out.println(unmodfifiableList);      //prints: [s1, s1]
list.set(0, "s0");
//list.add("s2");       //UnsupportedOperationException
System.out.println(unmodfifiableList);      //prints: [s0, s1] 

通过改变原始列表,我们成功地改变了之前创建的不可修改列表中元素的值。这就是创建不可修改集合的这种方式的弱点,因为它们基本上只是常规集合的包装器。

of()工厂方法的集合没有这个弱点,因为它们没有像不可修改集合那样的两步集合创建。这就是为什么无法更改of工厂方法创建的集合的原因。无法更改集合的组成部分或任何元素。以这种方式创建的集合称为"不可变"。这就是 Java 集合世界中不可修改不可变之间的区别。

不使用 of()方法的不可变

公平地说,即使不使用of()工厂方法,也有办法创建不可变集合。以下是一种方法:

List<String> iList =
        Collections.unmodifiableList(new ArrayList<>() {{
            add("s1");
            add("s1");
        }});
//iList.set(0, "s0");       //UnsupportedOperationException
//iList.add("s2");          //UnsupportedOperationException
System.out.println(iList);  //prints: [s1, s1]

关键是不要引用用于创建不可修改集合的原始集合(值的来源),因此不能用于更改底层来源。

这是另一种创建不可变集合的方法,而不使用of()工厂方法:

String[] source = {"s1", "s2"};
List<String> iList2 =
        Arrays.stream(source).collect(Collectors.toList());
System.out.println(iList2);      //prints: [s1, s2]

source[0]="s0";
System.out.println(iList2);      //prints: [s1, s2] 

看起来好像我们在这里有对原始值的source引用。但是,流不会保持值与其源之间的引用。它在处理之前会复制每个值,从而打破值与其源的连接。这就是为什么我们尝试通过更改source数组的元素来更改iList2的元素并没有成功。我们将在第十八章中更多地讨论流,流和管道

需要不可变集合是为了在将其作为参数传递到方法中时保护集合对象免受修改。正如我们已经提到的,这样的修改将是一个可能引入意外和难以追踪的副作用。

请注意,of()工厂方法不带参数时会创建空的不可变集合。当您需要调用一个需要集合作为参数的方法,但又没有数据,并且也不想给方法修改传入的集合的机会时,它们也可能是需要的。

Collections类中还有三个常量,提供了不可变的空集合:

List<String> list1 = Collections.EMPTY_LIST;
//list1.add("s1");       //UnsupportedOperationException
Set<String> set1 = Collections.EMPTY_SET;
Map<Integer, String> map1 = Collections.EMPTY_MAP;

此外,Collections类中还有七种方法可以创建不可变的空集合:

List<String> list2 = Collections.emptyList();
//list2.add("s1");       //UnsupportedOperationException
Set<String> set2 = Collections.emptySet();
Map<Integer, String> map2 = Collections.emptyMap();

SortedSet<String> set3 = Collections.emptySortedSet();
Map<Integer, String> map3 = Collections.emptySortedMap();
NavigableSet<String> set4 = Collections.emptyNavigableSet();
NavigableMap<Integer, String> map4 = Collections.emptyNavigableMap();

Collections类的以下方法创建只有一个元素的不可变集合:

  • Set<T> singleton(T object)

  • List<T> singletonList(T object)

  • Map<K,V> singletonMap(K key, V value)

您可以在以下代码片段中看到它是如何工作的:

List<String> singletonS1 = Collections.singletonList("s1");
System.out.println(singletonS1);
//singletonS1.add("s1");        //UnsupportedOperationException

所有这些都可以使用of()工厂方法来完成。我们已经为您描述了这一点,以便您对不可变集合创建的可用选项有一个完整的了解。

但是Collections类的List<T> nCopies(int n, T object)方法以比of()方法更紧凑的方式创建了n个相同对象的不可变列表:

List<String> nList = Collections.nCopies(3, "s1");
System.out.println(nList);
//nList.add("s1");        //UnsupportedOperationException

使用of()方法的类似代码更冗长:

List<String> nList = List.of("s1", "s1", "s1");

如果这对你来说不是太糟糕,想象一下你需要创建一个包含 100 个相同对象的列表。

add()和 put()方法的混淆

不可变集合使用的一个方面是偶尔会引起混淆的源。从我们的例子中可以看出,不可变集合,就像任何 Java 集合一样,都有add()put()方法。编译器不会生成错误,只有运行时的 JVM 才会这样做。因此,使用不可变集合的代码应该经过充分测试,以避免在生产中出现这种错误。

java.util.Collections 类

java.util.Collections类的所有方法都是静态且无状态的。后者意味着它们不会在任何地方维护任何状态,它们的结果不依赖于调用历史,而只依赖于作为参数传递的值。

Collections类中有许多方法,您已经在上一节中看到了其中一些。我们鼓励您查阅此类的在线文档。在这里,我们为您整理了其中一些方法,以便您更好地了解Collections类的方法。

复制

void copy(List<T> dest, List<T> src)方法将src列表的元素复制到dest列表并保留元素顺序。如果需要将一个列表作为另一个列表的子列表,这个方法非常有用:

List<String> list1 = Arrays.asList("s1","s2");
List<String> list2 = Arrays.asList("s3", "s4", "s5");
Collections.copy(list2, list1);
System.out.println(list2);    //prints: [s1, s2, "s5"]

在执行此操作时,copy()方法不会消耗额外的内存 - 它只是将值复制到已分配的内存上。这使得这个方法对于传统的复制相同大小的列表的情况非常有帮助:

List<String> list1 = Arrays.asList("s1","s2");
List<String> list2 = Arrays.asList("s3", "s4");
list2 = new ArrayList(list1);
System.out.println(list2);    //prints: [s1, s2]

这段代码放弃了最初分配给list2的值,并分配了新的内存来保存list1的值的副本。被放弃的值会一直留在内存中,直到垃圾收集器将它们移除并允许重用内存。想象一下,这些列表的大小是可观的,您就会明白在这种情况下使用Collections.copy()会减少很多开销。它还有助于避免OutOfMemory异常。

排序和相等()

Collections类的两个静态排序方法是:

  • void sort(List<T> list)

  • void sort(List<T> list, Comparator<T> comparator)

第一个sort(List<T>)方法只接受实现Comparable接口的元素的列表,这要求实现compareTo(T)方法。每个元素实现的compareTo(T)方法建立的顺序称为“自然排序”。

第二个sort()方法不需要列表元素实现任何特定的接口。它使用传入的Comparator类的对象来使用Comparator.compare(T o1, T o2)方法建立所需的顺序。如果列表的元素实现了Comparable,那么它们的方法compareTo(T)会被忽略,顺序只由Comparator.compare(T o1, T o2)方法建立。

Comparator对象定义的顺序(compare(T o1, T o2)方法)会覆盖Comparable接口定义的自然顺序(compareTo(T)方法)。

例如,这是类String如何实现接口Comparable

List<String> no = Arrays.asList("a","b", "Z", "10", "20", "1", "2");
Collections.sort(no);
System.out.println(no);     //prints: [1, 10, 2, 20, Z, a, b]

对于许多人来说,10排在2前面,大写Z排在小写a前面可能看起来并不“自然”,但这个术语并不是基于人类的感知。它是基于对象在没有提供比较器时将如何排序的。在这种情况下,它们是基于实现的方法compareTo(T)排序的。这个实现的方法可以被认为是内置在元素中的。这就是为什么这样的排序被称为“自然”的原因。

自然排序是由接口Comparable的实现定义的(方法compareTo(T))。

虽然对人类来说看起来有些意外,但StringcompareTo(T)方法的实现在许多排序情况下非常有帮助。例如,我们可以用它来实现Person类中Comparable接口的实现:

class Person implements Comparable<Person>{
    private String firstName = "", lastName = "";
    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    @Override
    public int compareTo(Person person){
        int result = this.firstName.compareTo(person.firstName);
        if(result == 0) {
            return this.lastName.compareTo(person.lastName);
        }
        return result;
    }
}

我们首先比较名字,如果它们相等,再比较姓。这意味着我们希望Person对象按名字顺序排列,然后按姓氏排列。

StringcompareTo(T)方法的实现返回第一个(或 this)和第二个对象的排序位置之间的差异。例如,ac的排序位置之间的差异是2,这是它们比较的结果:

System.out.println("a".compareTo("c"));   //prints: -2
System.out.println("c".compareTo("a"));   //prints: 2

这是有道理的:ac之前,所以它的位置在我们从左到右计算时更小。

请注意,IntegercompareTo(T)实现并不返回排序位置的差异。相反,当对象相等时,它返回0,当此对象小于方法参数时,它返回-1,否则返回1

System.out.println(Integer.valueOf(3)
                          .compareTo(Integer.valueOf(3))); //prints: 0
System.out.println(Integer.valueOf(3)
                          .compareTo(Integer.valueOf(4))); //prints: -1
System.out.println(Integer.valueOf(3)
                          .compareTo(Integer.valueOf(5))); //prints: -1
System.out.println(Integer.valueOf(5)
                          .compareTo(Integer.valueOf(4))); //prints: 1
System.out.println(Integer.valueOf(5)
                          .compareTo(Integer.valueOf(3))); //prints: 1

我们使用Comparator及其方法compare(T o1, T o2)得到相同的结果:

Comparator<String> compStr = Comparator.naturalOrder();
System.out.println(compStr.compare("a", "c"));  //prints: -2

Comparator<Integer> compInt = Comparator.naturalOrder();
System.out.println(compInt.compare(3, 5));     //prints: -1

但是,请注意,方法Comparable.compareTo(T)Compartor.compare(T o1, T o2)的文档只定义了以下返回:

  • 0表示对象相等

  • -1表示第一个对象小于第二个对象

  • 1表示第一个对象大于第二个对象

String的情况下,smallerbigger根据它们的排序位置进行定义——在有序列表中,smaller放在bigger前面。正如您所看到的,API 文档并不保证对所有类型的对象都返回排序位置的差异。

重要的是要确保方法equals()与方法Comparable.compareTo(T)对齐,以便对于相等的对象,方法Comparable.compareTo(T)返回 0。否则,可能会得到不可预测的排序结果。

这就是为什么我们在我们的类Person中添加了以下方法equals()

@Override
public boolean equals(Object other) {
    if (other == null) return false;
    if (this == other) return true;
    if (!(other instanceof Person)) return false;
    final Person that = (Person) other;
    return this.firstName.equals(that.getFirstName()) &&
            this.lastName.equals(that.getLastName());
}

现在方法equals()与方法compareTo(T)对齐,因此对于相等的Person对象,compareTo(T)返回 0:

Person joe1 = new Person("Joe", "Smith");
Person joe2 = new Person("Joe", "Smith");
Person bob = new Person("Bob", "Smith");

System.out.println(joe1.equals(joe2));    //prints: true
System.out.println(joe1.compareTo(joe2)); //prints: 0

System.out.println(joe1.equals(bob));     //prints: false
System.out.println(joe1.compareTo(bob));  //prints: 8
System.out.println(joe2.compareTo(bob));  //prints: 8

返回值8是因为这是BJ在字母顺序中的位置之间的差异。

我们还在我们的类Person中添加了以下toString()方法:

@Override
public String toString(){
    return this.firstName + " " + this.lastName;
}

它将允许我们更好地展示排序结果,这正是我们现在要做的。以下是演示代码:

Person p1 = new Person("Zoe", "Arnold");
Person p2 = new Person("Alex", "Green");
Person p3 = new Person("Maria", "Brown");
List<Person> list7 = Arrays.asList(p1, p2, p3);
System.out.println(list7);  //[Zoe Arnold, Alex Green, Maria Brown]
Collections.sort(list7);
System.out.println(list7);  //[Alex Green, Maria Brown, Zoe Arnold]

如您所见,在排序后元素的顺序(前一个示例的最后一行)与compareTo(T)方法中定义的顺序相匹配。

现在,让我们创建一个以不同方式对Person类的对象进行排序的比较器:

class OrderByLastThenFirstName implements Comparator<Person> {
    @Override
    public int compare(Person p1, Person p2){
        return (p1.getLastName() + p1.getFirstName())
                .compareTo(p2.getLastName() + p2.getFirstName());
    }
}

如您所见,前面的比较器首先根据姓氏的自然顺序,然后根据名字的自然顺序建立了一个顺序。如果我们使用相同的列表和对象与此比较器,我们将得到以下结果:

Collections.sort(list7, new OrderByLastThenFirstName());
System.out.println(list7);  //[Zoe Arnold, Maria Brown, Alex Green]

正如预期的那样,compareTo(T)方法被忽略,传入的Comparator对象的顺序被强制执行。

反转和旋转

Collections中有三个静态的与反转相关的方法,以及一个与旋转相关的方法:

  • void reverse(List<?> list): 反转元素的当前顺序

  • void rotate(List<?> list, int distance) : 将元素的顺序旋转,将每个元素向右移动指定数量的位置(距离)

  • Comparator<T> reverseOrder(): 返回一个创建与自然顺序相反的顺序的比较器;仅适用于实现了Comparable接口的元素

  • Comparator<T> reverseOrder(Comparator<T> comparator): 返回一个反转传入比较器定义的顺序的比较器

以下是演示列出的方法的代码:

Person p1 = new Person("Zoe", "Arnold");
Person p2 = new Person("Alex", "Green");
Person p3 = new Person("Maria", "Brown");
List<Person> list7 = Arrays.asList(p1,p2,p3);
System.out.println(list7);  //[Zoe Arnold, Alex Green, Maria Brown]

Collections.reverse(list7);
System.out.println(list7);  //[Maria Brown, Alex Green, Zoe Arnold]

Collections.rotate(list7, 1);
System.out.println(list7);  //[Zoe Arnold, Maria Brown, Alex Green]

Collections.sort(list7, Collections.reverseOrder());
System.out.println(list7);  //[Zoe Arnold, Maria Brown, Alex Green]

Collections.sort(list7, new OrderByLastThenFirstName());
System.out.println(list7);  //[Zoe Arnold, Maria Brown, Alex Green]

Collections.sort(list7, 
         Collections.reverseOrder(new OrderByLastThenFirstName()));
System.out.println(list7);  //[Alex Green, Maria Brown, Zoe Arnold]

搜索和 equals()

Collections中有五个静态的与搜索相关的方法:

  • int binarySearch(List<Comparable<T>> list, T key)

  • int binarySearch(List<T> list, T key, Comparator<T> comparator)

  • int indexOfSubList(List<?> source, List<?> target) 

  • int lastIndexOfSubList(List<?> source, List<?> target)

  • int frequency(Collection<?> collection, Object object)

binarySearch()方法在提供的列表中搜索key值。需要注意的重要一点是,由于二分搜索的性质,提供的列表必须按升序排列。算法将key与列表的中间元素进行比较;如果它们不相等,就会忽略掉key不可能存在的那一半,并且算法将key与列表另一半的中间元素进行比较。搜索将继续,直到找到与key相等的元素,或者只剩一个元素需要搜索而且它不等于key

indexOfSubList()lastIndexOfSubList()方法返回提供列表中提供子列表的位置:

List<String> list1 = List.of("s3","s5","s4","s1");
List<String> list2 = List.of("s4","s5");
int index = Collections.indexOfSubList(list1, list2);
System.out.println(index);  //prints: -1

List<String> list3 = List.of("s5","s4");
index = Collections.indexOfSubList(list1, list3);
System.out.println(index);   //prints: 1

请注意,子列表应该按照完全相同的顺序。否则,它是无法被找到的。

最后一个方法,frequency(Collection, Object),返回提供的对象在提供的集合中出现的次数:

List<String> list4 = List.of("s3","s4","s4","s1");
int count = Collections.frequency(list4, "s4");
System.out.println(count);         //prints: 2

如果你打算使用这些方法(或者任何其他搜索集合的方法),如果集合中包含自定义类的对象,那么你必须要实现方法equals()。典型的搜索算法使用方法equals()来识别对象。如果你没有在自定义类中实现方法equals(),那么基类Object中的方法equals()会被使用,它只比较对象的引用,而不是它们的状态(字段的值)。以下是这种行为的演示:

class A{}
class B extends A{}

List<A> list5 = List.of(new A(), new B());
int c = Collections.frequency(list5, new A());
System.out.println(c);         //prints: 0

A a = new A();
List<A> list6 = List.of(a, new B());
c = Collections.frequency(list6, a);
System.out.println(c);         //prints: 1

如你所见,只有当类A的对象确实是同一个对象时才能找到。但是如果我们实现了方法equals(),那么根据我们在方法equals()实现中的标准,类A的对象就能被找到:

class A{
    @Override
    public boolean equals(Object o){
        if (o == null) return false;
        return (o instanceof A);
    }
}
class B extends A{}

List<A> list5 = List.of(new A(), new B());
int c = Collections.frequency(list5, new A());
System.out.println(c);         //prints: 2

A a = new A();
List<A> list6 = List.of(a, new B());
c = Collections.frequency(list6, a);
System.out.println(c);         //prints: 2

现在,每种情况下对象A的计数都是2,因为B扩展了A,因此具有BA两种类型。

如果我们更喜欢仅以当前类名来标识对象而不考虑其父类,我们应该以不同的方式实现方法equals()

class A{
    @Override
    public boolean equals(Object o){
        if (o == null) return false;
        return o.getClass().equals(this.getClass());
    }
}
class B extends A{}

List<A> list5 = List.of(new A(), new B());
int c = Collections.frequency(list5, new A());
System.out.println(c);         //prints: 1

A a = new A();
List<A> list6 = List.of(a, new B());
c = Collections.frequency(list6, a);
System.out.println(c);         //prints: 1

方法getClass()返回对象通过new运算符创建时使用的类名。这就是为什么现在两种情况下计数都是1的原因。

在本章的其余部分,我们将假设集合和数组的元素实现了equals()方法。大多数情况下,我们将在示例中使用String类的对象。正如我们在第九章中提到的那样,运算符、表达式和语句String类具有基于字符串字面值的equals()方法实现,而不仅仅是基于对象引用。并且,正如我们在前一小节中解释的那样,String类还实现了Comparable接口,因此它提供了自然排序。

比较两个集合

Collections类中有一个简单的静态方法用于比较两个集合:

boolean disjoint(Collection<?> c1, Collection<?> c2): 如果一个集合的元素都不等于另一个集合的任何元素,则返回true

你可能已经猜到,这个方法使用equals()方法来识别相等的元素。

最小和最大元素

以下Collections类的方法可用于选择提供的集合中的最大最小元素:

  • T min(Collection<? extends T> collection)

  • T max(Collection<? extends T>collection)

  • T min(Collection<? extends T>collection, Comparator<T> comparator)

  • T max(Collection<? extends T>collection, Comparator<T> comparator)

前两个方法要求集合元素实现Comparable(方法compareTo(T)),而另外两个方法使用Comparator类的对象来比较元素。

最小的元素是在排序后的列表中首先出现的元素;最大的元素在排序后的列表的另一端。以下是演示代码:

Person p1 = new Person("Zoe", "Arnold");
Person p2 = new Person("Alex", "Green");
Person p3 = new Person("Maria", "Brown");
List<Person> list7 = Arrays.asList(p1,p2,p3);
System.out.println(list7);  //[Zoe Arnold, Alex Green, Maria Brown]

System.out.println(Collections.min(list7)); //prints: Alex Green
System.out.println(Collections.max(list7)); //prints: Zoe Arnold

Person min = Collections.min(list7, new OrderByLastThenFirstName());
System.out.println(min);                    //[Zoe Arnold]

Person max = Collections.max(list7, new OrderByLastThenFirstName());
System.out.println(max);                    //[Alex Green]

前两个方法使用自然排序来建立顺序,而后两个方法使用作为参数传递的比较器。

添加和替换元素

以下是Collections类的三个静态方法,用于向集合中添加或替换元素:

  • boolean addAll(Collection<T> c, T... elements): 将所有提供的元素添加到提供的集合中;如果提供的元素是Set,则只添加唯一的元素。它的执行速度比相应集合类型的addAll()方法要快得多。

  • boolean replaceAll(List<T> list, T oldVal, T newVal): 用newValue替换提供的列表中等于oldValue的每个元素;当oldValuenull时,该方法将提供的列表中的每个null值替换为newValue。如果至少替换了一个元素,则返回true

  • void fill(List<T> list, T object): 用提供的对象替换提供的列表中的每个元素。

洗牌和交换元素

Collections类的以下三个静态方法可以对提供的列表进行洗牌和交换元素:

  • void shuffle(List<?> list): 使用默认的随机源来打乱提供的列表中元素的位置

  • void shuffle(List<?> list, Random random): 使用提供的随机源(我们将在后面的相应部分讨论这样的源)来打乱提供的列表中元素的位置

  • void swap(List<?> list, int i, int j): 将提供的列表中位置i的元素与位置j的元素交换

转换为已检查的集合

Collections的以下九个静态方法将提供的集合从原始类型(没有泛型)转换为某种元素类型。名称checked意味着转换后,每个新添加的元素的类型都将被检查:

  • Set<E> checkedSet(Set<E> s, Class<E> type)

  • List<E> checkedList(List<E> list, Class<E> type)

  • Queue<E> checkedQueue(Queue<E> queue, Class<E> type)

  • Collection<E> checkedCollection(Collection<E> collection, Class<E> type)

  • Map<K,V> checkedMap(Map<K,V> map, Class<K> keyType, Class<V> valueType)

  • SortedSet<E> checkedSortedSet(SortedSet<E> set, Class<E> type)

  • NavigableSet<E> checkedNavigableSet(NavigableSet<E> set, Class<E> type)

  • SortedMap<K,V> checkedSortedMap(SortedMap<K,V> map, Class<K> keyType, Class<V> valueType)

  • NavigableMap<K,V> checkedNavigableMap(NavigableMap<K,V> map, Class<K> keyType, Class<V> valueType)

以下是演示代码:

List list = new ArrayList();
list.add("s1");
list.add("s2");
list.add(42);
System.out.println(list);    //prints: [s1, s2, 42]

List cList = Collections.checkedList(list, String.class);
System.out.println(list);   //prints: [s1, s2, 42]

list.add(42);
System.out.println(list);   //prints: [s1, s2, 42, 42]

//cList.add(42);           //throws ClassCastException

您可以观察到转换不会影响集合的当前元素。我们已经向同一个列表添加了String类的对象和Integer类的对象,并且能够将其转换为一个检查过的列表cList,没有任何问题。我们可以继续向原始列表添加不同类型的对象,但是尝试向检查过的列表添加非 String 对象会在运行时生成ClassCastException

转换为线程安全的集合

Collections中有八个静态方法,可以将常规集合转换为线程安全的集合:

  • Set<T> synchronizedSet(Set<T> set)

  • List<T> synchronizedList(List<T> list)

  • Map<K,V> synchronizedMap(Map<K,V> map)

  • Collection<T> synchronizedCollection(Collection<T> collection)

  • SortedSet<T> synchronizedSortedSet(SortedSet<T> set)

  • SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> map)

  • NavigableSet<T> synchronizedNavigableSet(NavigableSet<T> set)

  • NavigableMap<K,V> synchronizedNavigableMap(NavigableMap<K,V> map)

线程安全的集合是这样构造的,以便两个应用程序线程只能顺序地修改它,而不会互相干扰。但是,多线程处理超出了本书的范围,所以我们就此打住。

转换为另一种集合类型

将一种类型的集合转换为另一种类型的四个静态方法包括:

  • ArrayList<T> list(Enumeration<T> e)

  • Enumeration<T> enumeration(Collection<T> c)

  • Queue<T> asLifoQueue(Deque<T> deque)

  • Set<E> newSetFromMap(Map<E,Boolean> map)

接口java.util.Enumeration是一个遗留接口,它是在 Java 1 中引入的,与使用它的遗留类java.util.Hashtablejava.util.Vector一起。它与Iterator接口非常相似。实际上,可以使用Enumeration.asIterator()方法将Enumeration类型对象转换为Iterator类型。

所有这些方法在主流编程中很少使用,所以我们只是为了完整性而在这里列出它们。

创建枚举和迭代器

以下也是不经常使用的静态方法,允许创建一个空的EnumerationIteratorListIterator - 都是java.util包的接口:

  • Iterator<T> empty iterator``()

  • ListIterator<T> emptyListIterator()

  • Enumeration<T> emptyEnumeration()

Class collections4.CollectionUtils

Apache Commons 项目中的org.apache.commons.collections4.CollectionUtils类包含了与java.util.Collections类的方法相辅相成的静态无状态方法。它们有助于搜索、处理和比较 Java 集合。要使用这个类,您需要将以下依赖项添加到 Maven 配置文件pom.xml中:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-collections4</artifactId>
  <version>4.1</version>
</dependency>

这个类中有很多方法,而且随着时间的推移,可能会添加更多的方法。刚刚审查的Collections类可能会涵盖大部分您的需求,特别是当您刚刚进入 Java 编程领域时。因此,我们不会花时间解释每个方法的目的,就像我们为Collections类所做的那样。此外,CollectionUtils的方法是作为Collections方法的补充而创建的,因此它们更加复杂和微妙,不适合本书的范围。

为了让您了解CollectionUtils类中可用的方法,我们将它们按相关功能进行了分组:

  • 检索元素的方法:

  • Object get(Object object, int index)

  • Map.Entry<K,V> get(Map<K,V> map, int index)

  • Map<O,Integer> getCardinalityMap(Iterable<O> collection)

  • 添加元素或一组元素到集合的方法:

  • boolean addAll(Collection<C> collection, C[] elements)

  • boolean addIgnoreNull(Collection<T> collection, T object)

  • boolean addAll(Collection<C> collection, Iterable<C> iterable)

  • boolean addAll(Collection<C> collection, Iterator<C> iterator)

  • boolean addAll(Collection<C> collection, Enumeration<C> enumeration)

  • 合并Iterable元素的方法:

  • List<O> collate(Iterable<O> a, Iterable<O> b)

  • List<O> collate(Iterable<O> a, Iterable<O> b, Comparator<O> c)

  • List<O> collate(Iterable<O> a, Iterable<O> b, boolean includeDuplicates)

  • List<O> collate(Iterable<O> a, Iterable<O> b, Comparator<O> c, boolean includeDuplicates)

  • 删除或保留具有或不具有标准的元素的方法:

  • Collection<O> subtract(Iterable<O> a, Iterable<O> b)

  • Collection<O> subtract(Iterable<O> a, Iterable<O> b, Predicate<O> p)

  • Collection<E> removeAll(Collection<E> collection, Collection<?> remove)

  • Collection<E> removeAll(Iterable<E> collection, Iterable<E> remove, Equator<E> equator)

  • Collection<C> retainAll(Collection<C> collection, Collection<?> retain)

  • Collection<E> retainAll(Iterable<E> collection, Iterable<E> retain, Equator<E> equator)

  • 比较两个集合的方法:

  • boolean containsAll(Collection<?> coll1, Collection<?> coll2)

  • boolean containsAny(Collection<?> coll1, Collection<?> coll2)

  • boolean isEqualCollection(Collection<?> a, Collection<?> b)

  • boolean isEqualCollection(Collection<E> a, Collection<E> b, Equator<E> equator)

  • boolean isProperSubCollection(Collection<?> a, Collection<?> b)

  • 转换集合的方法:

  • Collection<List<E>> permutations(Collection<E> collection)

  • void transform(Collection<C> collection, Transformer<C,C> transformer)

  • Collection<E> transformingCollection(Collection<E> collection, Transformer<E,E> transformer)

  • Collection<O> collect(Iterator<I> inputIterator, Transformer<I,O> transformer)

  • Collection<O> collect(Iterable<I> inputCollection, Transformer<I,O> transformer)

  • Collection<O> R collect(Iterator<I> inputIterator, Transformer<I,O> transformer, R outputCollection)

  • Collection<O> R collect(Iterable<I> inputCollection, Transformer<I,O> transformer, R outputCollection)

  • 选择和过滤集合的方法:

  • Collection<O> select(Iterable<O> inputCollection, Predicate<O> predicate)

  • Collection<O> R select(Iterable<O> inputCollection, Predicate<O> predicate, R outputCollection)

  • Collection<O> R select(Iterable<O> inputCollection, Predicate<O> predicate, R outputCollection, R rejectedCollection)

  • Collection<O> selectRejected(Iterable<O> inputCollection, Predicate<O> predicate)

  • Collection<O> R selectRejected(Iterable<O> inputCollection, Predicate<O> predicate, R outputCollection)

  • E extractSingleton(Collection<E> collection)

  • boolean filter(Iterable<T> collection, Predicate<T> predicate)

  • boolean filterInverse(Iterable<T> collection, Predicate<T> predicate)

  • Collection<C> predicatedCollection(Collection<C> collection, Predicate<C> predicate)

  • 生成两个集合的并集、交集或差集的方法:

  • Collection<O> union(Iterable<O> a, Iterable<O> b)

  • Collection<O> disjunction(Iterable<O> a, Iterable<O> b)

  • Collection<O> intersection(Iterable<O> a, Iterable<O> b)

  • 创建不可变空集合的方法:

  • <T> Collection<T> emptyCollection()

  • Collection<T> emptyIfNull(Collection<T> collection)

  • 检查集合大小和是否为空的方法:

  • int size(Object object)

  • boolean sizeIsEmpty(Object object)

  • int maxSize(Collection<Object> coll)

  • boolean isEmpty(Collection<?> coll)

  • boolean isNotEmpty(Collection<?> coll)

  • boolean isFull(Collection<Object> coll)

  • 反转数组的方法:

  • void reverseArray(Object[] array)

这个最后的方法可能应该属于处理数组的实用类,这就是我们现在要讨论的内容。

管理数组

在本节中,我们将回顾如何创建和初始化数组对象,以及在哪里可以找到允许我们对数组执行一些操作的方法——例如复制、排序和比较。

尽管数组在一些算法和旧代码中有它们的用武之地,但在实践中,ArrayList()可以做任何数组可以做的事情,并且不需要提前设置大小。事实上,ArrayList也使用数组来存储其元素。因此,数组和ArrayList的性能也是可比较的。

因此,我们不打算过多地讨论数组管理,只是基本的创建和初始化。我们将提供一个简短的概述和参考资料,告诉您在哪里可以找到数组实用方法,以防您需要它们。

初始化数组

我们已经看到了一些数组构造的例子。现在,我们将回顾它们并介绍创建和初始化数组对象的其他方法。

创建表达式

数组创建表达式包括:

  • 数组元素类型

  • 嵌套数组的级数

  • 至少在第一级上的数组长度

以下是一级数组创建示例:

int[] ints = new int[10];
System.out.println(ints[0]);     //prints: 0

Integer[] intW = new Integer[10];
System.out.println(intW[0]);     //prints: null

boolean[] bs = new boolean[10];
System.out.println(bs[0]);       //prints: false

Boolean[] bW = new Boolean[10];
System.out.println(bW[0]);       //prints: 0

String[] strings = new String[10];
System.out.println(strings[0]);  //prints: null

A[] as = new A[10];
System.out.println(as[0]);       //prints: null 
System.out.println(as.length);   //prints: 10

正如我们在第五章中所展示的,Java 语言元素和类型,每种 Java 类型都有一个默认的初始化值,在对象创建时使用,当没有明确分配值时。因为数组是一个类,它的元素被初始化——就像任何类的实例字段一样——即使程序员没有明确地为它们分配值。数字原始类型的默认值为 0,布尔原始类型为 false,而所有引用类型的默认值为 null。在前面的示例中使用的类 A 被定义为class A {}。数组的长度被捕获在最终的公共属性length中。

多级嵌套初始化如下所示:

    //A[][] as2 = new A[][10];             //compilation error
    A[][] as2 = new A[10][];
    System.out.println(as2.length);        //prints: 10
    System.out.println(as2[0]);            //prints: null
    //System.out.println(as2[0].length);   //NullPointerException
    //System.out.println(as2[0][0]);       //NullPointerException

    as2 = new A[2][3];
    System.out.println(as2[0]); //prints: ManageArrays$A;@282ba1e
    System.out.println(as2[0].length); //prints: 3
    System.out.println(as2[0][0]);     //prints: null

首先要注意的是,尝试创建一个没有定义第一级数组长度的数组会生成编译错误。第二个观察是多级数组的length属性捕获了第一(顶级)级数组的长度。第三个是顶级数组的每个元素都是一个数组。如果不是最后一级,下一级数组的元素也是数组。

在我们之前的示例中,我们没有设置第二级数组的长度,因此顶级数组的每个元素都被初始化为null,因为这是任何引用类型的默认值(数组也是引用类型)。这就是为什么尝试获取第二级数组的长度或任何值会生成NullPointerException

一旦我们将第二级数组的长度设置为三,我们就能够得到它的长度和第一个元素的值(null,因为这是默认值)。奇怪的打印ManageArrays$A;@282ba1e是数组二进制引用,因为对象数组没有实现toString()方法。您可以得到的最接近的是实用类java.util.Arrays的静态方法toString()(请参见下一节)。它返回所有数组元素的String表示:

System.out.println(Arrays.toString(as2));   
        //prints: [[ManageArrays$A;@282ba1e, [ManageArrays$A;@13b6d03]
System.out.println(Arrays.toString(as2[0])); //[null, null, null]

对于最后(最深层)嵌套的数组,它可以正常工作,但对于更高级别的数组仍然打印二进制引用。如果要打印所有嵌套数组的所有元素,请使用Arrays.deepToString(Object[])方法:

System.out.println(Arrays.deepToString(as2)); 
           //the above prints: [[null, null, null], [null, null, null]]

请注意,如果数组元素没有实现toString()方法,则对于那些不是null的元素,将打印二进制引用。

数组初始化程序

数组初始化程序由逗号分隔的表达式列表组成,括在大括号{}中。允许并忽略最后一个表达式后面的逗号:

String[] arr = {"s0", "s1", };
System.out.println(Arrays.toString(arr)); //prints: [s0, s1]

我们经常在示例中使用这种初始化数组的方式,因为这是最紧凑的方式。

静态初始化块

与集合一样,当需要执行一些代码时,可以使用静态块来初始化数组静态属性:

class ManageArrays {
private static A[] AS_STATIC;
  static {
    AS_STATIC = new A[2];
    for(int i = 0; i< AS_STATIC.length; i++){
        AS_STATIC[i] = new A();
    }
    AS_STATIC[0] = new A();
    AS_STATIC[1] = new A();
  }
  //... the rest of class code goes here
}

静态块中的代码在每次加载类时都会执行,甚至在调用构造函数之前。但是,如果字段不是静态的,则可以将相同的初始化代码放在构造函数中:

class ManageArrays {
  private A[] as;
  public ManageArrays(){
    as = new A[2];
    for(int i = 0; i< as.length; i++){
        as[i] = new A();
    }
    as[0] = new A();
    as[1] = new A();
  }
  //the reat of class code goes here
}

从收集

如果有一个可以用作数组值源的集合,它有一个toArray()方法,可以按如下方式调用:

List<Integer> list = List.of(0, 1, 2, 3);
Integer[] arr1 = list.toArray(new Integer[list.size()]);
System.out.println(Arrays.toString(arr1)); //prints: [0, 1, 2, 3]

其他可能的方法

在不同的上下文中,可能会使用一些其他方法来创建和初始化数组。这也是你喜欢的风格问题。以下是您可以选择的各种数组创建和初始化方法的示例:

String[] arr2 = new String[3];
Arrays.fill(arr2, "s");
System.out.println(Arrays.toString(arr2));      //prints: [s, s, s]

String[] arr3 = new String[5];
Arrays.fill(arr3, 2, 3, "s");
System.out.println(Arrays.toString(arr3)); 
                              //prints: [null, null, s, null, null]
String[] arr4 = {"s0", "s1", };
String[] arr4Copy = Arrays.copyOf(arr4, 5);
System.out.println(Arrays.toString(arr4Copy)); 
                                //prints: [s0, s1, null, null, null]
String[] arr5 = {"s0", "s1", "s2", "s3", "s4" };
String[] arr5Copy = Arrays.copyOfRange(arr5, 1, 3);
System.out.println(Arrays.toString(arr5Copy));    //prints: [s1, s2]

Integer[] arr6 = {0, 1, 2, 3, 4 };
Object[] arr6Copy = Arrays.copyOfRange(arr6,1, 3, Object[].class);
System.out.println(Arrays.toString(arr6Copy));      //prints: [1, 2]

String[] arr7 = Stream.of("s0", "s1", "s2").toArray(String[]::new);
System.out.println(Arrays.toString(arr7));    //prints: [s0, s1, s2] 

在上面的六个例子中,有五个使用了java.util.Arrays类(见下一节)来填充或复制数组。所有这些例子都使用了Arrays.toString()方法来打印结果数组的元素。

第一个例子为数组arr2的所有元素分配了值s

第二个例子仅为索引 2 到索引 3 的元素分配了值s。请注意,第二个索引不包括在内。这就是为什么数组arr3的一个元素被赋予了值。

第三个例子复制了arr4数组,并使新数组的大小更长。这就是为什么新数组的其余元素被初始化为String的默认值,即null。请注意,我们在arr4数组初始化器中放置了一个尾随逗号,以演示它是允许的并被忽略的。这看起来不像是一个非常重要的特性。我们只是提出来,以防你在其他人的代码中看到它并想知道它是如何工作的。

第四个例子使用其元素从索引 1 到 3 创建了一个数组的副本。再次强调,第二个索引不包括在内,因此只复制了两个元素。

第五个例子不仅创建了元素范围的副本,还将它们转换为Object类型,这是可能的,因为源数组是引用类型。

最后一个例子使用了Stream类,我们将在第十八章中讨论流和管道

类 java.util.Arrays

我们已经多次使用了java.util.Arrays类。它是数组管理的主要工具。但是,它曾经非常受到那些使用集合的人的欢迎,因为asList(T...a)方法是创建和初始化集合的最紧凑的方法:

List<String> list = Arrays.asList("s0", "s1");
Set<String> set = new HashSet<>(Arrays.asList("s0", "s1");

但是在每个集合中引入了of()工厂方法之后,Arrays类的流行度大大下降。以下是创建集合的更自然的方法:

List<String> list = List.of("s0", "s1");
Set<String> set = Set.of("s0", "s1");

这个集合的对象是不可变的。但是,如果需要一个可变的集合,可以按照以下方式创建:

List<String> list = new ArrayList<>(List.of("s0", "s1"));
Set<String> set1 = new HashSet<>(list);
Set<String> set2 = new HashSet<>(Set.of("s0", "s1"));

我们之前在管理集合部分详细讨论过这个问题。

但是如果您的代码管理数组,那么您肯定需要使用Arrays类。它包含了 160 多种方法。其中大多数都是使用不同参数和数组类型进行重载。如果我们按方法名称对它们进行分组,将会有 21 组。如果我们进一步按功能对它们进行分组,只有以下 10 组将涵盖所有Arrays类的功能:

  • asList(): 基于提供的数组创建一个ArrayList对象(请参见上一节中的示例)

  • binarySearch(): 允许搜索数组或其部分(由索引范围指定)

  • compare(), mismatch()equals(), and deepEquals(): 比较两个数组或它们的部分(由索引范围)

  • copyOf() and copyOfRange(): 复制所有数组或其中的一部分(由索引范围)

  • hashcode() and deepHashCode(): 根据提供的数组内容生成哈希码值

  • toString() and deepToString(): 创建数组的String表示(请参见上一节中的示例)

  • fill()setAll()parallelPrefix(), and parallelSetAll(): 设置数组的每个元素的值(由提供的函数生成的固定值或值)或由索引范围指定的元素的值

  • sort() and parallelSort(): 对数组的元素进行排序或仅对部分元素进行排序(由索引范围指定)

  • splititerator(): 返回用于并行处理数组或其部分(由索引范围指定)的Splititerator对象

  • stream(): 生成数组元素或其中一些元素的流(由索引范围指定);请参见第十八章,流和管道

所有这些方法都很有用,但我们想要吸引您注意的是equals(a1, a2)deepEquals(a1, a2)方法。它们对于数组比较特别有帮助,因为数组对象不允许实现自定义方法equals(a),因此总是使用类Object的实现来比较只有引用。

相比之下,equals(a1, a2)deepEquals(a1, a2)方法不仅比较引用a1a2,而且在数组的情况下使用equals(a)方法来比较元素。这意味着非嵌套数组是通过它们的元素的值进行比较的,并且当两个数组都为null或它们的长度相等且方法a1[i].equals(a2[i])对于每个索引返回true时被认为是相等的:

Integer[] as1 = {1,2,3};
Integer[] as2 = {1,2,3};
System.out.println(as1.equals(as2));               //prints: false
System.out.println(Arrays.equals(as1, as2));       //prints: true
System.out.println(Arrays.deepEquals(as1, as2));   //prints: true

对于嵌套数组,equals(a1, a2)方法使用equals(a)方法来比较下一级的元素。但是嵌套数组的元素是数组,因此它们仅通过引用而不是它们的元素的值进行比较。如果需要比较所有嵌套级别上的元素的值,请使用方法deepEquals(a1, a2)

Integer[][] aas1 = {{1,2,3}, {4,5,6}};
Integer[][] aas2 = {{1,2,3}, {4,5,6}};
System.out.println(Arrays.equals(aas1, aas2));       //prints: false
System.out.println(Arrays.deepEquals(aas1, aas2));   //prints: true

Integer[][][] aaas1 = {{{1,2,3}, {4,5,6}}, {{7,8,9}, {10,11,12}}};
Integer[][][] aaas2 = {{{1,2,3}, {4,5,6}}, {{7,8,9}, {10,11,12}}};
System.out.println(Arrays.deepEquals(aaas1, aaas2)); //prints: true

Class lang3.ArrayUtils

org.apache.commons.lang3.ArrayUtils是类java.util.Arrays的补充。它为数组管理工具包添加了新的方法,并能够在否则会抛出NullPointerException的情况下处理null

Arrays类类似,ArrayUtils类有许多(大约 300 个)重载方法,可以分为 12 组:

  • add(), addAll(), and insert(): 向数组添加元素

  • clone(): 克隆数组,类似于java.util.Arrays中的copyOf()方法和java.lang.Systemarraycopy()方法

  • getLength(): 返回数组长度并处理null(当数组为null时,尝试读取属性length会抛出NullPointerException

  • hashCode():计算数组的哈希值,包括嵌套数组

  • contains()indexOf()lastIndexOf():搜索数组

  • isSorted()isEmptyisNotEmpty():检查数组并处理null

  • isSameLength()isSameType():比较数组

  • nullToEmpty(): 将null数组转换为空数组

  • remove()removeAll()removeElement()removeElements()removeAllOccurances():移除元素

  • reverse()shift()shuffle()swap():改变数组元素的顺序

  • subarray(): 通过索引范围提取数组的一部分

  • toMap()toObject()toPrimitive()toString()toStringArray():将数组转换为另一种类型并处理null

练习- 对对象列表进行排序

列出两种允许对对象列表进行排序的方法,以及它们的使用先决条件。

答案

java.util.Collections类的两个静态方法:

  • void sort(List<T> list): 对实现了Comparable接口的对象列表进行排序(使用compareTo(T)方法)

  • void sort(List<T> list, Comparator<T> comparator): 根据提供的Comparator对对象进行排序(使用compare(T o1, T o2)方法)

总结

在本章中,我们向读者介绍了 Java 标准库和 Apache Commons 中的类,这些类允许操作集合和数组。每个 Java 程序员都必须了解java.util.Collectionsjava.util.Arraysorg.acpache.commons.collections4.CollectionUtilsorg.acpache.commons.lang3.ArrayUtils类的功能。

在下一章中,我们将讨论与本章讨论的类一起属于最受欢迎的实用程序组的类,每个程序员都必须掌握这些类,以成为有效的编码人员。

第十五章:管理对象、字符串、时间和随机数

在本章中我们将讨论的类,与前几章讨论的 Java 集合和数组一起属于每个程序员都必须掌握的一类(主要是来自 Java 标准库和 Apache Commons 的工具类),以成为一名高效的编码人员。它们也展示了各种软件设计和解决方案,具有指导意义,并可作为最佳编码实践的模式。

我们将涵盖以下功能领域:

  • 管理对象

  • 管理字符串

  • 管理时间

  • 管理随机数

概述的类列表包括:

  • java.util.Objects

  • org.apache.commons.lang3.ObjectUtils

  • java.lang.String

  • org.apache.commons.lang3.StringUtils

  • java.time.LocalDate

  • java.time.LocalTime

  • java.time.LocalDateTime

  • java.lang.Math

  • java.util.Random

管理对象

你可能不需要管理数组,甚至可能一段时间内不需要管理集合,但你无法避免管理对象,这意味着本节描述的类你可能每天都会使用。

尽管java.util.Objects类是在 2011 年(Java 7 发布时)添加到 Java 标准库中的,而ObjectUtils类自 2002 年以来就存在于 Apache Commons 库中,但它们的使用增长缓慢。这可能部分地可以解释它们最初的方法数量很少-2003 年ObjectUtils只有 6 个方法,2011 年Objects只有 9 个方法。然而,它们是非常有用的方法,可以使代码更易读、更健壮,减少错误的可能性。因此,为什么这些类从一开始就没有被更频繁地使用至今仍然是个谜。我们希望你能立即在你的第一个项目中开始使用它们。

类 java.util.Objects

Objects只有 17 个方法-全部是静态的。在前一章中,当我们实现了类Person时,我们已经使用了其中的一些方法:

class Person implements Comparable<Person> {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name == null ? "" : name;
    }
    public int getAge(){ return this.age; }
    public String getName(){ return this.name; }
    @Override
    public int compareTo(Person p){
        int result = this.name.compareTo(p.getName());
        if (result != 0) {
            return result;
        }
        return this.age - p.getAge();
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null) return false;
        if(!(o instanceof Person)) return false;
        Person person = (Person)o;
        return age == person.getAge() &&
               Objects.equals(name, person.getName()); //line 25
    }
    @Override
    public int hashCode(){
        return Objects.hash(age, name);
    }
    @Override
    public String toString() {
        return "Person{age=" + age + ", name=" + name + "}";
    }
}

我们以前在equals()hashCode()方法中使用了Objects类。一切都运行良好。但是,请注意我们如何检查前一个构造函数中的参数name。如果参数是null,我们将空的String值赋给字段name。我们这样做是为了避免第 25 行的NullPointerException。另一种方法是使用 Apache Commons 库中的ObjectUtils类。我们将在下一节中进行演示。ObjectUtils类的方法处理null值,并使将null参数转换为空的String变得不必要。

但首先,让我们回顾一下Objects类的方法。

equals()和 deepEquals()

我们详细讨论了equals()方法的实现,但一直假设它是在一个非null的对象obj上调用的,obj.equals(anotherObject)的调用不会产生NullPointerException

然而,有时我们需要比较两个对象ab,当它们中的一个或两个可以是null时。以下是这种情况的典型代码:

boolean equals(Object a, Object b) {
    return (a == b) || (a != null && a.equals(b));
}

这是boolean Objects.equals(Object a, Object b)方法的实际源代码。它允许使用方法equals(Object)比较两个对象,并处理其中一个或两个为null的情况。

Objects类的另一个相关方法是boolean deepEquals(Object a, Object b)。以下是其源代码:

boolean deepEquals(Object a, Object b) {
    if (a == b)
        return true;
    else if (a == null || b == null)
        return false;
    else
        return Arrays.deepEquals0(a, b);
}

正如您所见,它是基于我们在前一节中讨论的Arrays.deepEquals()。这些方法的演示代码有助于理解它们之间的区别:

Integer[] as1 = {1,2,3};
Integer[] as2 = {1,2,3};
System.out.println(Arrays.equals(as1, as2));        //prints: true
System.out.println(Arrays.deepEquals(as1, as2));    //prints: true

System.out.println(Objects.equals(as1, as2));        //prints: false
System.out.println(Objects.deepEquals(as1, as2));    //prints: true

Integer[][] aas1 = {{1,2,3},{1,2,3}};
Integer[][] aas2 = {{1,2,3},{1,2,3}};
System.out.println(Arrays.equals(aas1, aas2));       //prints: false
System.out.println(Arrays.deepEquals(aas1, aas2));   //prints: true

System.out.println(Objects.equals(aas1, aas2));       //prints: false
System.out.println(Objects.deepEquals(aas1, aas2));   //prints: true

在上述代码中,Objects.equals(as1, as2)Objects.equals(aas1, aas2)返回false,因为数组无法覆盖Object类的equals()方法,而是通过引用而不是值进行比较。

方法Arrays.equals(aas1, aas2)返回false的原因相同:因为嵌套数组的元素是数组,通过引用进行比较。

总而言之,如果您想比较两个对象ab的字段值,则:

  • 如果它们不是数组且a不是null,请使用a.equals(b)

  • 如果它们不是数组且两个对象都可以是null,请使用Objects.equals(a, b)

  • 如果两者都可以是数组且都可以是null,请使用Objects.deepEquals(a, b)

也就是说,我们可以看到方法Objects.deepEquals()是最安全的方法,但这并不意味着您必须总是使用它。大多数情况下,您将知道要比较的对象是否可以为null或可以是数组,因此您也可以安全地使用其他equals()方法。

hash()和 hashCode()

方法hash()hashCode()返回的哈希值通常用作将对象存储在使用哈希的集合中的键,例如HashSet()。在Object超类中的默认实现基于内存中的对象引用。对于具有相同类的两个对象且具有相同实例字段值的情况,它返回不同的哈希值。因此,如果需要两个类实例具有相同状态的相同哈希值,则重写默认的hashCode()实现使用以下方法至关重要:

  • int hashCode(Object value): 计算单个对象的哈希值

  • int hash(Object... values): 计算对象数组的哈希值(请看我们在前面示例中的Person类中如何使用它)

请注意,当将同一对象用作方法Objects.hash()的单个输入数组时,这两种方法返回不同的哈希值:

System.out.println(Objects.hash("s1"));           //prints: 3645
System.out.println(Objects.hashCode("s1"));       //prints: 3614

仅一个值会从两种方法中返回相同的哈希值:null

System.out.println(Objects.hash(null));      //prints: 0
System.out.println(Objects.hashCode(null));  //prints: 0

当作为单个非空参数使用时,相同的值从方法Objects.hashCode(Object value)Objects.hash(Object... values)返回的哈希值不同。值null从这些方法中返回相同的哈希值0

使用类Objects进行哈希值计算的另一个优点是它能容忍null值,而在尝试对null引用调用实例方法hashCode()时会生成NullPointerException

isNull() 和 nonNull()

这两个方法只是对布尔表达式obj == nullobj != null的简单包装:

  • boolean isNull(Object obj): 返回与obj == null相同的值。

  • boolean nonNull(Object obj): 返回与obj != null相同的值。

这是演示代码:

String object = null;

System.out.println(object == null);           //prints: true
System.out.println(Objects.isNull(object));   //prints: true

System.out.println(object != null);           //prints: false
System.out.println(Objects.nonNull(object));  //prints: false

requireNonNull()

Objects的以下方法检查第一个参数的值,如果值为null,则抛出NullPointerException或返回提供的默认值:

  • T requireNonNull(T obj): 如果参数为null,则抛出没有消息的NullPointerException
      String object = null;
      try {
          Objects.requireNonNull(object);
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());  //prints: null
      }
  • T requireNonNull(T obj, String message): 如果第一个参数为null,则抛出带有提供消息的NullPointerException
      String object = null;
      try {
          Objects.requireNonNull(object, "Parameter 'object' is null");
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());  
          //Parameter 'object' is null
      }
  • T requireNonNull(T obj, Supplier<String> messageSupplier): 如果第一个参数为null,则返回由提供的函数生成的消息,如果生成的消息或函数本身为null,则抛出NullPointerException
      String object = null;
      Supplier<String> msg1 = () -> {
          String msg = "Msg from db";
          //get the corresponding message from database
          return msg;
      };
      try {
          Objects.requireNonNull(object, msg1);
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());  //prints: Msg from db
      }
      Supplier<String> msg2 = () -> null;
      try {
          Objects.requireNonNull(object, msg2);
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());  //prints: null
      }
      Supplier<String> msg3 = null;
      try {
          Objects.requireNonNull(object, msg3);
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());  //prints: null
      }
  • T requireNonNullElse(T obj, T defaultObj): 如果第一个参数非空,则返回第一个参数的值,如果第二个参数非空,则返回第二个参数的值,如果都为空,则抛出带有消息defaultObjNullPointerException
      String object = null;
      System.out.println(Objects.requireNonNullElse(object, 
                              "Default value"));   
                              //prints: Default value
      try {
          Objects.requireNonNullElse(object, null);
      } catch (NullPointerException ex){
          System.out.println(ex.getMessage());     //prints: defaultObj
      }
  • T requireNonNullElseGet(T obj, Supplier<? extends T> supplier): 如果第一个参数非空,则返回第一个参数的值,否则返回由提供的函数生成的对象,如果都为空,则抛出带有消息defaultObjNullPointerException
      String object = null;
      Supplier<String> msg1 = () -> {
          String msg = "Msg from db";
          //get the corresponding message from database
          return msg;
      };
      String s = Objects.requireNonNullElseGet(object, msg1);
      System.out.println(s);                //prints: Msg from db

      Supplier<String> msg2 = () -> null;
      try {
       System.out.println(Objects.requireNonNullElseGet(object, msg2));
      } catch (NullPointerException ex){
       System.out.println(ex.getMessage()); //prints: supplier.get()
      }
      try {
       System.out.println(Objects.requireNonNullElseGet(object, null));
      } catch (NullPointerException ex){
       System.out.println(ex.getMessage()); //prints: supplier
      }

checkIndex()

以下一组方法检查集合或数组的索引和长度是否兼容:

  • int checkIndex(int index, int length): 如果提供的index大于length - 1,则抛出IndexOutOfBoundsException

  • int checkFromIndexSize(int fromIndex, int size, int length): 如果提供的index + size大于length - 1,则抛出IndexOutOfBoundsException

  • int checkFromToIndex(int fromIndex, int toIndex, int length): 如果提供的fromIndex大于toIndex,或toIndex大于length - 1,则抛出IndexOutOfBoundsException

这是演示代码:

List<String> list = List.of("s0", "s1");
try {
    Objects.checkIndex(3, list.size());
} catch (IndexOutOfBoundsException ex){
    System.out.println(ex.getMessage());  
                         //prints: Index 3 out-of-bounds for length 2
}
try {
    Objects.checkFromIndexSize(1, 3, list.size());
} catch (IndexOutOfBoundsException ex){
    System.out.println(ex.getMessage());  
                //prints: Range [1, 1 + 3) out-of-bounds for length 2
}

try {
    Objects.checkFromToIndex(1, 3, list.size());
} catch (IndexOutOfBoundsException ex){
    System.out.println(ex.getMessage());  
                    //prints: Range [1, 3) out-of-bounds for length 2
}

compare()

Objects的方法int compare(T a, T b, Comparator<T> c)使用提供的比较器的方法compare(T o1, T o2)来比较两个对象。我们已经在谈论排序集合时描述了compare(T o1, T o2)方法的行为,因此应该期望以下结果:

int diff = Objects.compare("a", "c", Comparator.naturalOrder());
System.out.println(diff);  //prints: -2
diff = Objects.compare("a", "c", Comparator.reverseOrder());
System.out.println(diff);  //prints: 2
diff = Objects.compare(3, 5, Comparator.naturalOrder());
System.out.println(diff);  //prints: -1
diff = Objects.compare(3, 5, Comparator.reverseOrder());
System.out.println(diff);  //prints: 1

如前所述,方法compare(T o1, T o2)返回String对象中对象o1o2在排序列表中的位置之间的差异,而对于Integer对象,则返回-101。API 描述它返回0当对象相等时,返回负数当第一个对象小于第二个对象时;否则,它返回正数。

为了演示方法compare(T a, T b, Comparator<T> c)的工作原理,假设我们要按照Person类对象的名称和年龄以分别按照StringInteger类的自然排序方式进行排序:

@Override
public int compareTo(Person p){
    int result = Objects.compare(this.name, p.getName(),
                                         Comparator.naturalOrder());
    if (result != 0) {
        return result;
    }
    return Objects.compare(this.age, p.getAge(), 
                                         Comparator.naturalOrder());
}

下面是Person类中compareTo(Object)方法的新实现的结果:

Person p1 = new Person(15, "Zoe");
Person p2 = new Person(45, "Adam");
Person p3 = new Person(37, "Bob");
Person p4 = new Person(30, "Bob");
List<Person> list = new ArrayList<>(List.of(p1, p2, p3, p4));
System.out.println(list);//[{15, Zoe}, {45, Adam}, {37, Bob}, {30, Bob}]
Collections.sort(list);
System.out.println(list);//[{45, Adam}, {30, Bob}, {37, Bob}, {15, Zoe}] 

正如您所看到的,Person对象首先按照它们的名称的自然顺序排序,然后按照它们的年龄的自然顺序排序。如果我们需要反转名称的顺序,例如,我们将compareTo(Object)方法更改为以下内容:

@Override
public int compareTo(Person p){
    int result = Objects.compare(this.name, p.getName(),
                                         Comparator.reverseOrder());
    if (result != 0) {
        return result;
    }
    return Objects.compare(this.age, p.getAge(), 
                                         Comparator.naturalOrder());
}

结果的样子就像我们期望的一样:

Person p1 = new Person(15, "Zoe");
Person p2 = new Person(45, "Adam");
Person p3 = new Person(37, "Bob");
Person p4 = new Person(30, "Bob");
List<Person> list = new ArrayList<>(List.of(p1, p2, p3, p4));
System.out.println(list);//[{15, Zoe}, {45, Adam}, {37, Bob}, {30, Bob}]
Collections.sort(list);
System.out.println(list);//[{15, Zoe}, {30, Bob}, {37, Bob}, {45, Adam}] 

方法compare(T a, T b, Comparator<T> c)的弱点在于它不能处理null值。将new Person(25, null)对象添加到列表中,在排序时会触发NullPointerException异常。在这种情况下,最好使用org.apache.commons.lang3.ObjectUtils.compare(T o1, T o2)方法,我们将在下一节中演示。

toString()

有时需要将一个Object对象(它是对某个类类型的引用)转换为它的String表示。当引用obj被赋予null值(对象还未创建)时,编写obj.toString()会生成NullPointerException异常。对于这种情况,使用Objects类的以下方法是更好的选择:

  • String toString(Object o): 当第一个参数值不为null时,返回调用第一个参数的toString()的结果,否则返回null

  • String toString(Object o, String nullDefault): 当第一个参数值不为null时,返回调用第一个参数的toString()的结果,否则返回第二个参数值nullDefault

这是演示如何使用这些方法的代码:

List<String> list = new ArrayList<>(List.of("s0 "));
list.add(null);
for(String e: list){
    System.out.print(e);                   //prints: s0 null
}
System.out.println();
for(String e: list){
    System.out.print(Objects.toString(e)); //prints: s0 null
}
System.out.println();
for(String e: list){
    System.out.print(Objects.toString(e, "element was null")); 
                                        //prints: s0 element was null
}

顺便提一下,与当前讨论无关的是,请注意我们如何使用print()方法而不是println()方法来显示所有结果在一行中,因为print()方法不会添加行结束符。

ObjectUtils

Apache Commons 库的org.apache.commons.lang3.ObjectUtils类补充了先前描述的java.util.Objects类的方法。本书的范围和分配的大小不允许详细审查ObjectUtils类的所有方法,因此我们将根据相关功能进行简要描述,并仅演示那些与我们已经提供的示例相关的方法。

ObjectUtils类的所有方法可以分为七个组:

  • 对象克隆方法:

    • T clone(T obj): 如果提供的对象实现了Cloneable接口,则返回提供的对象的副本;否则返回null

    • T cloneIfPossible(T obj): 如果提供的对象实现了Cloneable接口,则返回提供的对象的副本;否则返回原始提供的对象。

  • 支持对象比较的方法:

    • int compare(T c1, T c2): 比较实现Comparable接口的两个对象的新排序位置;允许任意参数或两个参数都为null;将一个null值放在非空值的前面。

    • int compare(T c1, T c2, boolean nullGreater): 如果参数nullGreater的值为false,则行为与前一个方法完全相同;否则,将一个null值放在非空值的后面。我们可以通过在我们的Person类中使用最后两个方法来演示这两个方法:

@Override
public int compareTo(Person p){
    int result = ObjectUtils.compare(this.name, p.getName());
    if (result != 0) {
        return result;
    }
    return ObjectUtils.compare(this.age, p.getAge());
}

这种改变的结果使我们可以为name字段使用null值。

Person p1 = new Person(15, "Zoe");
Person p2 = new Person(45, "Adam");
Person p3 = new Person(37, "Bob");
Person p4 = new Person(30, "Bob");
Person p5 = new Person(25, null);
List<Person> list = new ArrayList<>(List.of(p1, p2, p3, p4, p5));
System.out.println(list);  //[{15, Zoe}, {45, Adam}, {37, Bob}, {30, Bob}, {25, }]
Collections.sort(list);
System.out.println(list);  //[{25, }, {45, Adam}, {30, Bob}, {37, Bob}, {15, Zoe}]

由于我们使用了Objects.compare(T c1, T c2)方法,null值被放在非空值的前面。顺便问一下,你是否注意到我们不再显示null了?那是因为我们已经按照以下方式更改了类Person的方法toString()

@Override
public String toString() {
    //return "{" + age + ", " + name + "}";
    return "{" + age + ", " + Objects.toString(name, "") + "}";
}

不仅仅显示字段name的值,我们还使用了方法Objects.toString(Object o, String nullDefault),当对象为null时,用提供的nullDefault值替换对象。在这种情况下,是否使用此方法是一种风格问题。许多程序员可能会认为我们必须显示实际值,而不是将其替换为其他内容。但是,我们这样做只是为了展示方法Objects.toString(Object o, String nullDefault)的用法。

如果我们现在使用第二个compare(T c1, T c2, boolean nullGreater)方法,那么类PersoncompareTo()方法将如下所示:

@Override
public int compareTo(Person p){
    int result = ObjectUtils.compare(this.name, p.getName(), true);
    if (result != 0) {
        return result;
    }
    return ObjectUtils.compare(this.age, p.getAge());
}

接着,具有其name设置为nullPerson对象将显示在排序列表的末尾:

Person p1 = new Person(15, "Zoe");
Person p2 = new Person(45, "Adam");
Person p3 = new Person(37, "Bob");
Person p4 = new Person(30, "Bob");
Person p5 = new Person(25, null);
List<Person> list = new ArrayList<>(List.of(p1, p2, p3, p4, p5));
System.out.println(list);  
               //[{15, Zoe}, {45, Adam}, {37, Bob}, {30, Bob}, {25, }]
Collections.sort(list);
System.out.println(list);  
               //[{45, Adam}, {30, Bob}, {37, Bob}, {15, Zoe}, {25, }]

为了完成关于null值的讨论,当将null对象添加到列表中时,上述代码将抛出NullPointerException异常:list.add(null)。为了避免异常,可以使用一个特殊的Comparator对象来处理列表的null元素:

Person p1 = new Person(15, "Zoe");
Person p2 = new Person(45, "Adam");
Person p3 = new Person(37, "Bob");
Person p4 = new Person(30, "Bob");
Person p5 = new Person(25, null);
List<Person> list = new ArrayList<>(List.of(p1, p2, p3, p4, p5));
list.add(null);
System.out.println(list);  
        //[{15, Zoe}, {45, Adam}, {37, Bob}, {30, Bob}, {25, }, null]
Collections.sort(list, 
 Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(list);  
        //[{45, Adam}, {30, Bob}, {37, Bob}, {15, Zoe}, {25, }, null]

在这段代码中,你可以看到我们已经表明希望在列表的末尾看到null对象。相反,我们可以使用另一个将空对象放在排序列表开头的Comparator

Collections.sort(list, 
                   Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(list);  
        //[null, {45, Adam}, {30, Bob}, {37, Bob}, {15, Zoe}, {25, }]

  • notEqual

    • boolean notEqual(Object object1, Object object2): 比较两个对象是否不相等,其中一个或两个对象都可以是null
  • identityToString

    • String identityToString(Object object): 返回提供的对象的String表示,就好像是由基类Object的默认方法toString()生成的一样。

    • void identityToString(StringBuffer buffer, Object object): 将所提供对象的String表示追加到提供的StringBuffer对象上,就好像由基类Object的默认方法toString()生成一样。

    • void identityToString(StringBuilder builder, Object object): 将所提供对象的String表示追加到提供的StringBuilder对象上,就好像由基类Object的默认方法toString()生成一样。

    • void identityToString(Appendable appendable, Object object): 将所提供对象的String表示追加到提供的Appendable对象上,就好像由基类Object的默认方法toString()生成一样。

以下代码演示了其中的两种方法:

String s = "s0 " + ObjectUtils.identityToString("s1");
System.out.println(s);  //prints: s0 java.lang.String@5474c6c

StringBuffer sb = new StringBuffer();
sb.append("s0");
ObjectUtils.identityToString(sb, "s1");
System.out.println(s);  //prints: s0 java.lang.String@5474c6c

  • allNotNullanyNotNull

    • boolean allNotNull(Object... values): 当所提供数组中所有值都不为null时返回true

    • boolean anyNotNull(Object... values): 当所提供数组中至少有一个值不为null时返回true

  • firstNonNulldefaultIfNull

    • T firstNonNull(T... values): 返回所提供数组中第一个不为null的值。

    • T defaultIfNull(T object, T defaultValue): 如果第一个参数为null,则返回提供的默认值。

  • maxminmedianmode

    • T max(T... values): 返回所提供值列表中实现了Comparable接口的最后一个值;仅当所有值都为null时返回null

    • T min(T... values): 返回所提供值列表中实现了Comparable接口的第一个值;仅当所有值都为null时返回null

    • T median(T... items): 返回所提供值列表中实现了Comparable接口的有序列表中位于中间的值;如果值的计数是偶数,则返回中间两个中较小的一个。

    • T median(Comparator<T> comparator, T... items): 返回根据提供的Comparator对象对提供的值列表排序后位于中间的值;如果值的计数是偶数,则返回中间两个中较小的一个。

    • T mode(T... items): 返回提供的项目中出现频率最高的项目;当没有出现最频繁的项目或没有一个项目最频繁地出现时返回null;下面是演示此最后一个方法的代码:

String s = ObjectUtils.mode("s0", "s1", "s1");
System.out.println(s);     //prints: s1

s = ObjectUtils.mode("s0", "s1", "s2");
System.out.println(s);     //prints: null

s = ObjectUtils.mode("s0", "s1", "s2", "s1", "s2");
System.out.println(s);     //prints: null

s = ObjectUtils.mode(null);
System.out.println(s);     //prints: null

s = ObjectUtils.mode("s0", null, null);
System.out.println(s);     //prints: null

管理字符串

String经常被使用。因此,您必须对其功能有很好的掌握。我们已经在第五章中讨论了String值的不可变性,Java 语言元素和类型。我们已经表明,每次“修改”String值时,都会创建一个新副本,这意味着在多次“修改”的情况下,会创建许多String对象,消耗内存并给 JVM 带来负担。

在这种情况下,建议使用类java.lang.StringBuilderjava.lang.StringBuffer,因为它们是可修改的对象,不需要创建String值的副本。我们将展示如何使用它们,并在本节的第一部分解释这两个类之间的区别。

之后,我们会回顾类String的方法,然后提供一个对org.apache.commons.lang3.StringUtils类的概述,该类补充了类String的功能。

StringBuilderStringBuffer

StringBuilderStringBuffer具有完全相同的方法列表。不同之处在于类StringBuilder的方法执行速度比类StringBuffer的相同方法更快。这是因为类StringBuffer不允许不同应用程序线程同时访问其值,所以如果你不是为多线程处理编码,就使用StringBuilder

StringBuilderStringBuffer中有许多方法。但是,我们将展示如何只使用方法append(),这显然是最受欢迎的方法,用于需要多次修改String值的情况。它的主要功能是将一个值追加到已存储在StringBuilder(或StringBuffer)对象中的值的末尾。

方法append()被重载为所有原始类型和类StringObjectCharSequenceStringBuffer,这意味着传入任何这些类的对象的String表示都可以追加到现有值中。为了演示,我们将只使用append(String s)版本,因为这可能是你大部分时间都会使用的。这里是一个例子:

List<String> list = 
  List.of("That", "is", "the", "way", "to", "build", "a", "sentence");
StringBuilder sb = new StringBuilder();
for(String s: list){
    sb.append(s).append(" ");
}
String s = sb.toString();
System.out.println(s);  //prints: That is the way to build a sentence

StringBuilder(和StringBuffer)中还有replace()substring()insert()方法,允许进一步修改值。虽然它们不像方法append()那样经常使用,但我们不打算讨论它们,因为它们超出了本书的范围。

类 java.lang.String

String有 15 个构造函数和近 80 个方法。在这本书中详细讨论和演示每一个方法对来说有点过分,所以我们只会评论最受欢迎的方法,其他的只会提到。当你掌握了基础知识后,你可以阅读在线文档,看看类String的其他方法还可以做什么。

构造函数

如果您担心应用程序创建的字符串消耗过多的内存,则String类的构造函数很有用。问题在于,String字面值(例如abc)存储在内存的特殊区域中,称为“字符串常量池”,并且永远不会被垃圾回收。这样设计的理念是,String字面值消耗的内存远远超过数字。此外,处理这样的大型实体会产生开销,可能会使 JVM 负担过重。这就是设计者认为将它们存储并在所有应用程序线程之间共享比分配新内存然后多次清理相同值更便宜的原因。

但是,如果String值的重用率较低,而存储的String值消耗过多内存,则使用构造函数创建String对象可能是解决问题的方法。这里是一个例子:

String veryLongText = new String("asdakjfakjn akdb aakjn... akdjcnak");

以这种方式创建的String对象位于堆区(存储所有对象的地方),并且在不再使用时进行垃圾回收。这就是String构造函数发挥作用的时候。

如有必要,您可以使用String类的intern()方法,在字符串常量池中创建堆String对象的副本。它不仅允许我们与其他应用程序线程共享值(在多线程处理中),还允许我们通过引用(使用运算符==)将其与另一个字面值进行比较。如果引用相等,则意味着它们指向池中的相同String值。

但是,主流程序员很少以这种方式管理内存,因此我们将不再进一步讨论这个话题。

format()

方法String format(String format, Object... args)允许将提供的对象插入字符串的指定位置,并根据需要进行格式化。在java.util.Formatter类中有许多格式说明符。我们这里只演示%s,它通过调用对象的toString()方法将传入的对象转换为其String表示形式:

String format = "There is a %s in the %s";
String s = String.format(format, "bear", "woods");
System.out.println(s); //prints: There is a bear in the woods

format = "Class %s is very useful";
s = String.format(format, new A());
System.out.println(s);  //prints: Class A is very useful

replace()

String 类中的方法String replace(CharSequence target, CharSequence replacement),该方法会用第二个参数的值替换第一个参数的值:

String s1 = "There is a bear in the woods";
String s2 = s1.replace("bear", "horse").replace("woods", "field");
System.out.println(s2);     //prints: There is a horse in the field

还有一些方法,比如String replaceAll(String regex, String replacement)String replaceFirst(String regex, String replacement),它们具有类似的功能。

compareTo()

我们已经在示例中使用了int compareTo(String anotherString)方法。它返回此String值和anotherString值在有序列表中的位置差异。它用于字符串的自然排序,因为它是Comparable接口的实现。

方法int compareToIgnoreCase(String str)执行相同的功能,但会忽略比较字符串的大小写,并且不用于自然排序,因为它不是Comparable接口的实现。

valueOf(Object j)

静态方法 String valueOf(Object obj) 如果提供的对象为 null,则返回 null,否则调用提供对象的 toString() 方法。

valueOf(基本类型或字符数组)

任何基本类型的值都可以作为参数传递给静态方法 String valueOf(primitive value),该方法返回所提供值的字符串表示形式。例如,String.valueOf(42) 返回42。该组方法包括以下静态方法:

  • String valueOf(boolean b)

  • String valueOf(char c)

  • String valueOf(double d)

  • String valueOf(float f)

  • String valueOf(int i)

  • String valueOf(long l)

  • String valueOf(char[] data)

  • String valueOf(char[] data, int offset, int count)

copyValueOf(char[])

方法 String copyValueOf(char[] data) 等效于 valueOf(char[]),而方法 String copyValueOf(char[] data, int offset, int count) 等效于 valueOf(char[], int, int)。它们返回字符数组或其子数组的 String 表示形式。

而方法 void getChars(int srcBegin, int srcEnd, char[] dest, int dstBegin) 将此 String 值中的字符复制到目标字符数组中。

indexOf() 和 substring()

各种 int indexOf(String str) 和 int lastIndexOf(String str) 方法返回字符串中子字符串的位置:

String s = "Introduction";
System.out.println(s.indexOf("I"));      //prints: 0
System.out.println(s.lastIndexOf("I"));  //prints: 0
System.out.println(s.lastIndexOf("i"));  //prints: 9
System.out.println(s.indexOf("o"));      //prints: 4
System.out.println(s.lastIndexOf("o"));  //prints: 10
System.out.println(s.indexOf("tro"));    //prints: 2

注意位置计数从零开始。

方法 String substring(int beginIndex) 返回从作为参数传递的位置(索引)开始的字符串的剩余部分:

String s = "Introduction";
System.out.println(s.substring(1));        //prints: ntroduction
System.out.println(s.substring(2));        //prints: troduction

位置为 beginIndex 的字符是前一个子字符串中存在的第一个字符。

方法 String substring(int beginIndex, int endIndex) 返回从作为第一个参数传递的位置开始到作为第二个参数传递的位置的子字符串:

String s = "Introduction";
System.out.println(s.substring(1, 2));        //prints: n
System.out.println(s.substring(1, 3));        //prints: nt

与方法 substring(beginIndex) 一样,位置为 beginIndex 的字符是前一个子字符串中存在的第一个字符,而位置为 endIndex 的字符不包括在内。 endIndex - beginIndex 的差等于子字符串的长度。

这意味着以下两个子字符串相等:

System.out.println(s.substring(1));              //prints: ntroduction
System.out.println(s.substring(1, s.length()));  //prints: ntroduction

contains() 和 matches()

方法 boolean contains(CharSequence s) 在提供的字符序列(子字符串)存在时返回 true

String s = "Introduction";
System.out.println(s.contains("x"));          //prints: false
System.out.println(s.contains("o"));          //prints: true
System.out.println(s.contains("tro"));        //prints: true
System.out.println(s.contains("trx"));        //prints: false

其他类似的方法有:

  • boolean matches(String regex): 使用正则表达式(本书不讨论此内容)

  • boolean regionMatches(int tOffset, String other, int oOffset, int length): 比较两个字符串的区域

  • boolean regionMatches(boolean ignoreCase, int tOffset, String other, int oOffset, int length): 与上述相同,但使用标志 ignoreCase 指示是否忽略大小写

split(), concat() 和 join()

方法String[] split(String regex)String[] split(String regex, int limit)使用传入的正则表达式将字符串拆分成子字符串。我们在本书中不解释正则表达式。但是,有一个非常简单的正则表达式,即使您对正则表达式一无所知也很容易使用:如果您只是将字符串中存在的任何符号或子字符串传递到此方法中,该字符串将被拆分为以传入的值分隔的部分,例如:

String[] substrings = "Introduction".split("o");
System.out.println(Arrays.toString(substrings)); 
                                       //prints: [Intr, ducti, n]
substrings = "Introduction".split("duct");
System.out.println(Arrays.toString(substrings)); 
                                      //prints: [Intro, ion] 

此代码仅说明了功能。但是以下代码片段更实用:

String s = "There is a bear in the woods";
String[] arr = s.split(" ");
System.out.println(Arrays.toString(arr));  
                       //prints: [There, is, a, bear, in, the, woods]
arr = s.split(" ", 3);
System.out.println(Arrays.toString(arr));  
                          //prints: [There, is, a bear in the woods]

正如您所见,split()方法中的第二个参数限制了生成的子字符串的数量。

方法String concat(String str)将传入的值添加到字符串的末尾:

String s1 =  "There is a bear";
String s2 =  " in the woods";
String s = s1.concat(s2);
System.out.println(s);  //prints: There is a bear in the woods

concat()方法创建一个新的String值,其中包含连接的结果,因此非常经济。但是,如果您需要添加(连接)许多值,则使用StringBuilder(或StringBuffer,如果需要保护免受并发访问)将是更好的选择。我们在前一节中讨论过这个问题。另一个选择是使用运算符+

String s =  s1 + s2;
System.out.println(s);  //prints: There is a bear in the woods

当与String值一起使用时,运算符+是基于StringBuilder实现的,因此允许通过修改现有的值来添加String值。使用 StringBuilder 和仅使用运算符+添加String值之间没有性能差异。

方法String join(CharSequence delimiter, CharSequence... elements)String join(CharSequence delimiter, Iterable<? extends CharSequence> elements)也基于StringBuilder。它们使用传入的delimiter将提供的值组装成一个String值,以在创建的String结果中分隔组装的值。以下是一个示例:

s = String.join(" ", "There", "is", "a", "bear", "in", "the", "woods");
System.out.println(s);  //prints: There is a bear in the woods

List<String> list = 
             List.of("There", "is", "a", "bear", "in", "the", "woods");
s = String.join(" ", list);
System.out.println(s);  //prints: There is a bear in the woods

startsWith() 和 endsWith()

以下方法在字符串值以提供的子字符串prefix开始(或结束)时返回true

  • boolean startsWith(String prefix)

  • boolean startsWith(String prefix, int toffset)

  • boolean endsWith(String suffix)

这是演示代码:

boolean b = "Introduction".startsWith("Intro");
System.out.println(b);             //prints: true

b = "Introduction".startsWith("tro", 2);
System.out.println(b);             //prints: true

b = "Introduction".endsWith("ion");
System.out.println(b);             //prints: true

equals() 和 equalsIgnoreCase()

我们已经多次使用了String类的boolean equals(Object anObject)方法,并指出它将此String值与其他对象进行比较。此方法仅在传入的对象是具有相同值的String时返回true

方法boolean equalsIgnoreCase(String anotherString)也执行相同的操作,但还忽略大小写,因此字符串AbCABC被视为相等。

contentEquals() 和 copyValueOf()

方法boolean contentEquals(CharSequence cs)将此String值与实现接口CharSequence的对象的String表示进行比较。流行的CharSequence实现包括CharBufferSegmentStringStringBufferStringBuilder

方法 boolean contentEquals(StringBuffer sb) 仅对 StringBuffer 有效。它的实现略有不同于 contentEquals(CharSequence cs),在某些情况下可能具有一些性能优势,但我们不打算讨论这些细节。此外,当你在 String 值上调用 contentEquals() 时,你可能甚至不会注意到使用了哪种方法,除非你努力利用差异。

length()、isEmpty() 和 hashCode()

方法 int length() 返回 String 值中字符的数量。

方法 boolean isEmpty() 在 String 值中没有字符且方法 length() 返回零时返回 true

方法 int hashCode() 返回 String 对象的哈希值。

trim()、toLowerCase() 和 toUpperCase()

方法 String trim() 从 String 值中删除前导和尾随空格。

以下方法更改 String 值中字符的大小写:

  • String toLowerCase()

  • String toUpperCase()

  • String toLowerCase(Locale locale)

  • String toUpperCase(Locale locale)

getBytes()、getChars() 和 toCharArray()

以下方法将 String 值转换为字节数组,可选择使用给定的字符集进行编码:

  • byte[] getBytes()

  • byte[] getBytes(Charset charset)

  • byte[] getBytes(String charsetName)

这些方法将所有或部分 String 值转换为其他类型:

  • IntStream chars()

  • char[] toCharArray()

  • char charAt(int index)

  • CharSequence subSequence(int beginIndex, int endIndex)

按索引或流获取代码点

以下一组方法将 String 值的全部或部分转换为其字符的 Unicode 代码点:

  • IntStream codePoints()

  • int codePointAt(int index)

  • int codePointBefore(int index)

  • int codePointCount(int beginIndex, int endIndex)

  • int offsetByCodePoints(int index, int codePointOffset)

我们在第五章 Java 语言元素和类型中解释了 Unicode 代码点。当你需要表示不能适应 char 类型的两个字节时,这些方法特别有用。这样的字符具有大于 Character.MAX_VALUE 的代码点,即  65535

类 lang3.StringUtils

Apache Commons 库的 org.apache.commons.lang3.StringUtils 类具有 120 多个静态实用方法,这些方法补充了我们在前一节中描述的 String 类的方法。

最受欢迎的是以下静态方法:

  • boolean isBlank(CharSequence cs): 当传入的参数为空字符串""、null 或空格时返回 true

  • boolean isNotBlank(CharSequence cs): 当传入的参数不为空字符串""、null 或空格时返回 true

  • boolean isAlpha(CharSequence cs): 当传入的参数只包含 Unicode 字母时返回 true

  • boolean isAlphaSpace(CharSequence cs): 当传入的参数仅包含 Unicode 字母和空格(' ')时返回true

  • boolean isNumeric(CharSequence cs): 当传入的参数仅包含数字时返回true

  • boolean isNumericSpace(CharSequence cs): 当传入的参数仅包含数字和空格(' ')时返回true

  • boolean isAlphaNumeric(CharSequence cs): 当传入的参数仅包含 Unicode 字母和数字时返回true

  • boolean isAlphaNumericSpace(CharSequence cs): 当传入的参数仅包含 Unicode 字母、数字和空格(' ')时返回true

我们强烈建议您查看该类的 API 并了解您可以在其中找到什么。

管理时间

java.time包及其子包中有许多类。它们被引入作为处理日期和时间的其他旧包的替代品。新类是线程安全的(因此更适合多线程处理),而且同样重要的是,设计更一致,更容易理解。此外,新实现遵循国际标准组织(ISO)的日期和时间格式,但也允许使用任何其他自定义格式。

我们将描述主要的五个类,并演示如何使用它们:

  • java.util.LocalDate

  • java.util.LocalTime

  • java.util.LocalDateTime

  • java.util.Period

  • java.util.Duration

所有这些以及java.time包及其子包中的其他类都具有丰富的各种功能,涵盖了所有实际情况和任何想象得到的情况。但我们不打算覆盖所有内容,只是介绍基础知识和最常见的用例。

java.time.LocalDate

LocalDate类不包含时间。它表示 ISO 8601 格式的日期,即 yyyy-MM-DD:

System.out.println(LocalDate.now());   //prints: 2018-04-14

正如您所见,方法now()返回当前日期,即它设置在您计算机上的日期:April 14, 2018是撰写本节时的日期。

类似地,您可以使用静态方法now(ZoneId zone)获取任何其他时区的当前日期。ZoneId对象可以使用静态方法ZoneId.of(String zoneId)构造,其中String zoneId是方法ZonId.getAvailableZoneIds()返回的任何String值之一:

Set<String> zoneIds = ZoneId.getAvailableZoneIds();
for(String zoneId: zoneIds){
    System.out.println(zoneId);     
}

该代码打印了许多时区 ID,其中之一是Asia/Tokyo。现在,我们可以找出当前日期在该时区的日期:

ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalDate.now(zoneId));   //prints: 2018-04-15

LocalDate的对象也可以表示过去或未来的任何日期,使用以下方法:

  • LocalDate parse(CharSequence text): 从 ISO 8601 格式的字符串 yyyy-MM-DD 构造对象

  • LocalDate parse(CharSequence text, DateTimeFormatter formatter) : 根据对象DateTimeFormatter指定的格式从字符串构造对象,该对象有许多预定义格式

  • LocalDate of(int year, int month, int dayOfMonth): 从年、月和日构造对象

  • LocalDate of(int year, Month month, int dayOfMonth):根据年份、月份(作为enum常量)和日期构造对象

  • LocalDate ofYearDay(int year, int dayOfYear):根据年份和年份中的日数构造对象

以下代码演示了这些方法:

LocalDate lc1 =  LocalDate.parse("2020-02-23");
System.out.println(lc1);                     //prints: 2020-02-23

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate lc2 =  LocalDate.parse("23/02/2020", formatter);
System.out.println(lc2);                     //prints: 2020-02-23

LocalDate lc3 =  LocalDate.of(2020, 2, 23);
System.out.println(lc3);                     //prints: 2020-02-23

LocalDate lc4 =  LocalDate.of(2020, Month.FEBRUARY, 23);
System.out.println(lc4);                     //prints: 2020-02-23

LocalDate lc5 = LocalDate.ofYearDay(2020, 54);
System.out.println(lc5);                     //prints: 2020-02-23

使用LocalDate对象,可以获取各种值:

System.out.println(lc5.getYear());          //prints: 2020
System.out.println(lc5.getMonth());         //prints: FEBRUARY
System.out.println(lc5.getMonthValue());    //prints: 2
System.out.println(lc5.getDayOfMonth());    //prints: 23

System.out.println(lc5.getDayOfWeek());     //prints: SUNDAY
System.out.println(lc5.isLeapYear());       //prints: true
System.out.println(lc5.lengthOfMonth());    //prints: 29
System.out.println(lc5.lengthOfYear());     //prints: 366

LocalDate对象可以被修改:

System.out.println(lc5.withYear(2021));     //prints: 2021-02-23
System.out.println(lc5.withMonth(5));       //prints: 2020-05-23
System.out.println(lc5.withDayOfMonth(5));  //prints: 2020-02-05
System.out.println(lc5.withDayOfYear(53));  //prints: 2020-02-22

System.out.println(lc5.plusDays(10));       //prints: 2020-03-04
System.out.println(lc5.plusMonths(2));      //prints: 2020-04-23
System.out.println(lc5.plusYears(2));       //prints: 2022-02-23

System.out.println(lc5.minusDays(10));      //prints: 2020-02-13
System.out.println(lc5.minusMonths(2));     //prints: 2019-12-23
System.out.println(lc5.minusYears(2));      //prints: 2018-02-23 

LocalDate对象也可以进行比较:

LocalDate lc6 =  LocalDate.parse("2020-02-22");
LocalDate lc7 =  LocalDate.parse("2020-02-23");
System.out.println(lc6.isAfter(lc7));       //prints: false
System.out.println(lc6.isBefore(lc7));      //prints: true

LocalDate类中还有许多其他有用的方法。如果您需要处理日期,我们建议您阅读该类及其他java.time包及其子包的 API。

java.time.LocalTime

LocalTime类包含没有日期的时间。它具有类似于LocalDate类的方法。

以下是如何创建LocalTime类的对象的方法:

System.out.println(LocalTime.now());         //prints: 21:15:46.360904

ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalTime.now(zoneId));   //prints: 12:15:46.364378

LocalTime lt1 =  LocalTime.parse("20:23:12");
System.out.println(lt1);                     //prints: 20:23:12

LocalTime lt2 =  LocalTime.of(20, 23, 12);
System.out.println(lt2);                     //prints: 20:23:12

LocalTime对象的每个时间值组件可以按以下方式提取:

System.out.println(lt2.getHour());          //prints: 20
System.out.println(lt2.getMinute());        //prints: 23
System.out.println(lt2.getSecond());        //prints: 12
System.out.println(lt2.getNano());          //prints: 0

该对象可以被修改:

System.out.println(lt2.withHour(3));        //prints: 03:23:12
System.out.println(lt2.withMinute(10));     //prints: 20:10:12
System.out.println(lt2.withSecond(15));     //prints: 20:23:15
System.out.println(lt2.withNano(300));      //prints: 20:23:12:000000300

System.out.println(lt2.plusHours(10));      //prints: 06:23:12
System.out.println(lt2.plusMinutes(2));     //prints: 20:25:12
System.out.println(lt2.plusSeconds(2));     //prints: 20:23:14
System.out.println(lt2.plusNanos(200));     //prints: 20:23:14:000000200

System.out.println(lt2.minusHours(10));      //prints: 10:23:12
System.out.println(lt2.minusMinutes(2));     //prints: 20:21:12
System.out.println(lt2.minusSeconds(2));     //prints: 20:23:10
System.out.println(lt2.minusNanos(200));     //prints: 20:23:11.999999800

LocalTime类的两个对象也可以进行比较:

LocalTime lt3 =  LocalTime.parse("20:23:12");
LocalTime lt4 =  LocalTime.parse("20:25:12");
System.out.println(lt3.isAfter(lt4));       //prints: false
System.out.println(lt3.isBefore(lt4));      //prints: true

LocalTime类中还有许多其他有用的方法。如果您需要处理时间,请阅读该类及其他java.time包及其子包的 API。

java.time.LocalDateTime

LocalDateTime类同时包含日期和时间,并且具有LocalDate类和LocalTime类拥有的所有方法,因此我们不会在此重复。我们只会展示如何创建LocalDateTime对象:

System.out.println(LocalDateTime.now());  //2018-04-14T21:59:00.142804
ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalDateTime.now(zoneId));  
                                   //prints: 2018-04-15T12:59:00.146038
LocalDateTime ldt1 =  LocalDateTime.parse("2020-02-23T20:23:12");
System.out.println(ldt1);                 //prints: 2020-02-23T20:23:12
DateTimeFormatter formatter = 
          DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
LocalDateTime ldt2 =  
       LocalDateTime.parse("23/02/2020 20:23:12", formatter);
System.out.println(ldt2);                 //prints: 2020-02-23T20:23:12
LocalDateTime ldt3 = LocalDateTime.of(2020, 2, 23, 20, 23, 12);
System.out.println(ldt3);                 //prints: 2020-02-23T20:23:12
LocalDateTime ldt4 =  
        LocalDateTime.of(2020, Month.FEBRUARY, 23, 20, 23, 12);
System.out.println(ldt4);                     //prints: 2020-02-23T20:23:12
LocalDate ld = LocalDate.of(2020, 2, 23);
LocalTime lt =  LocalTime.of(20, 23, 12);
LocalDateTime ldt5 = LocalDateTime.of(ld, lt);
System.out.println(ldt5);                     //prints: 2020-02-23T20:23:12

LocalDateTime类中还有许多其他有用的方法。如果你需要处理日期和时间,我们建议你阅读该类及其他java.time包及其子包的 API。

PeriodDuration

java.time.Periodjava.time.Duration类的设计目的是包含一定的时间量:

  • Period对象包含以年、月和日为单位的时间量

  • Duration对象包含以小时、分、秒和纳秒为单位的时间量

以下代码演示了它们如何在LocalDateTime类中创建和使用,但是对于Period来说,相同的方法也存在于LocalDate类中,而对于Duration来说,相同的方法也存在于LocalTime类中:

LocalDateTime ldt1 = LocalDateTime.parse("2020-02-23T20:23:12");
LocalDateTime ldt2 = ldt1.plus(Period.ofYears(2));
System.out.println(ldt2); //prints: 2022-02-23T20:23:12

//The following methods work the same way:
ldt.minus(Period.ofYears(2));
ldt.plus(Period.ofMonths(2));
ldt.minus(Period.ofMonths(2));
ldt.plus(Period.ofWeeks(2));
ldt.minus(Period.ofWeeks(2));
ldt.plus(Period.ofDays(2));
ldt.minus(Period.ofDays(2));

ldt.plus(Duration.ofHours(2));
ldt.minus(Duration.ofHours(2));
ldt.plus(Duration.ofMinutes(2));
ldt.minus(Duration.ofMinutes(2));
ldt.plus(Duration.ofMillis(2));
ldt.minus(Duration.ofMillis(2));

在以下代码中还演示了创建和使用Period对象的其他方法:

LocalDate ld1 =  LocalDate.parse("2020-02-23");
LocalDate ld2 =  LocalDate.parse("2020-03-25");

Period period = Period.between(ld1, ld2);
System.out.println(period.getDays());       //prints: 2
System.out.println(period.getMonths());     //prints: 1
System.out.println(period.getYears());      //prints: 0
System.out.println(period.toTotalMonths()); //prints: 1

period = Period.between(ld2, ld1);
System.out.println(period.getDays());       //prints: -2

Duration对象可以类似地创建和使用:

LocalTime lt1 =  LocalTime.parse("10:23:12");
LocalTime lt2 =  LocalTime.parse("20:23:14");
Duration duration = Duration.between(lt1, lt2);
System.out.println(duration.toDays());     //prints: 0
System.out.println(duration.toHours());    //prints: 10
System.out.println(duration.toMinutes());  //prints: 600
System.out.println(duration.toSeconds());  //prints: 36002
System.out.println(duration.getSeconds()); //prints: 36002
System.out.println(duration.toNanos());    //prints: 36002000000000
System.out.println(duration.getNano());    //prints: 0

PeriodDuration类中还有许多其他有用的方法。如果您需要处理时间量,请阅读这些类及其他java.time包及其子包的 API。

管理随机数

生成一个真正的随机数是一个大问题,不属于本书。但是对于绝大多数实际目的来说,Java 提供的伪随机数生成器已经足够好了,这就是我们将在本节中讨论的内容。

在 Java 标准库中生成随机数有两种主要方式:

  • java.lang.Math.random()方法

  • java.util.Random

还有 java.security.SecureRandom 类,它提供了一个加密强度很高的随机数生成器,但超出了入门课程的范围。

方法 java.lang.Math.random()

Math 的静态方法 double random() 返回一个大于或等于 0.0 且小于 1.0double 类型值:

for(int i =0; i < 3; i++){
    System.out.println(Math.random());
    //0.9350483840148613
    //0.0477353019234189
    //0.25784245516898985
}

在前面的注释中我们已经捕获了结果。但在实践中,更多时候需要的是某个范围内的随机整数。为了满足这样的需求,我们可以编写一个方法,例如,生成一个从 0(包含)到 10(不包含)的随机整数:

int getInteger(int max){
    return (int)(Math.random() * max);
}

以下是前述代码的一次运行结果:

for(int i =0; i < 3; i++){
    System.out.print(getInteger(10) + " "); //prints: 2 5 6
}

如你所见,它生成一个随机整数值,可以是以下 10 个数字之一:0、1、...、9。以下是使用相同方法的代码,并生成从 0(包含)到 100(不包含)的随机整数:

for(int i =0; i < 3; i++){
    System.out.print(getInteger(100) + " "); //prints: 48 11 97
}

当你需要一个介于 100(包含)和 200(不包含)之间的随机数时,你可以直接将前述结果加上 100:

for(int i =0; i < 3; i++){
    System.out.print(100 + getInteger(100) + " "); //prints: 114 101 127
}

将范围的两个端点包括在结果中可以通过四舍五入生成的 double 值来实现:

int getIntegerRound(int max){
    return (int)Math.round(Math.random() * max);
}

当我们使用前述方法时,结果为:

for(int i =0; i < 3; i++){
    System.out.print(100 + getIntegerRound(100) + " "); //179 147 200
}

如你所见,范围的上限(数字 200)包含在可能的结果集中。可以通过将所请求的上限范围加 1 来达到同样的效果:

int getInteger2(int max){
    return (int)(Math.random() * (max + 1));
}

如果我们使用前述方法,我们可以得到以下结果:

for(int i =0; i < 3; i++){
    System.out.print(100 + getInteger2(100) + " "); //167 200 132
}

但是,如果你查看 Math.random() 方法的源代码,你会看到它使用了 java.util.Random 类及其 nextDouble() 方法来生成一个随机的 double 值。因此,让我们看看如何直接使用 java.util.Random 类。

java.util.Random

Random 的方法 doubles() 生成一个大于或等于 0.0 且小于 1.0double 类型值:

Random random = new Random();
for(int i =0; i < 3; i++){
    System.out.print(random.nextDouble() + " "); 
    //prints: 0.8774928230544553 0.7822070124559267 0.09401796000707807 
}

我们可以像在上一节中使用 Math.random() 一样使用方法 nextDouble()。但是在需要某个范围内的随机整数值时,类还有其他方法可用,而无需创建自定义的 getInteger() 方法。例如,nextInt() 方法返回介于 Integer.MIN_VALUE(包含)和 Integer.MAX_VALUE(包含)之间的整数值:

for(int i =0; i < 3; i++){
    System.out.print(random.nextInt() + " "); 
                        //prints: -2001537190 -1148252160 1999653777
}

并且带有参数的相同方法允许我们通过上限(不包含)限制返回值的范围:

for(int i =0; i < 3; i++){
    System.out.print(random.nextInt(11) + " "); //prints: 4 6 2
}

该代码生成一个介于 0(包含)和 10(包含)之间的随机整数值。以下代码返回介于 11(包含)和 20(包含)之间的随机整数值:

for(int i =0; i < 3; i++){
    System.out.print(11 + random.nextInt(10) + " "); //prints: 13 20 15
}

从范围中生成随机整数的另一种方法是使用方法 ints(int count, int min, int max) 返回的 IntStream 对象,其中 count 是所请求的值的数量,min 是最小值(包含),max 是最大值(不包含):

String result = random.ints(3, 0, 101)
        .mapToObj(String::valueOf)
        .collect(Collectors.joining(" ")); //prints: 30 48 52

此代码从 0(包含)到 100(包含)返回三个整数值。我们将在第十八章中更多地讨论流,流和管道

练习 - Objects.equals() 结果

有三个类:

public class A{}
public class B{}
public class Exercise {
    private A a;
    private B b;
    public Exercise(){
        System.out.println(java.util.Objects.equals(a, b));
    }
    public static void main(String... args){
        new Exercise();
    }
}

当我们运行Exercise类的main()方法时,会显示什么? 错误

答案

显示将只显示一个值:。原因是两个私有字段——ab——都初始化为null

总结

在本章中,我们向读者介绍了 Java 标准库和 Apache Commons 库中最受欢迎的实用程序和一些其他类。每个 Java 程序员都必须对它们的功能有很好的理解,才能成为有效的编码者。研究它们还有助于了解各种软件设计模式和解决方案,这些模式和解决方案具有指导意义,并且可以用作任何应用程序中最佳编码实践的模式。

在下一章中,我们将向读者演示如何编写能够操作数据库中数据的 Java 代码——插入、读取、更新和删除。它还将提供 SQL 和基本数据库操作的简要介绍。

第十六章:数据库编程

本章介绍如何编写 Java 代码,可以操作数据库中的数据——插入、读取、更新、删除。它还提供了 SQL 语言和基本数据库操作的简要介绍。

在本章中,我们将涵盖以下主题:

  • 什么是Java 数据库连接JDBC)?

  • 如何创建/删除数据库

  • 结构化查询语言SQL)简要概述

  • 如何创建/删除/修改数据库表

  • 创建、读取、更新和删除CRUD)数据库数据

  • 练习-选择唯一的名字

什么是 Java 数据库连接(JDBC)?

Java 数据库连接JDBC)是 Java 功能,允许我们访问和修改数据库中的数据。它由 JDBC API(java.sqljavax.sqljava.transaction.xa包)和数据库特定的接口实现(称为数据库驱动程序)支持,每个数据库供应商都提供了与数据库访问的接口。

当人们说他们正在使用 JDBC 时,这意味着他们编写代码,使用 JDBC API 的接口和类以及知道如何将应用程序与特定数据库连接的数据库特定驱动程序来管理数据库中的数据。使用此连接,应用程序可以发出用结构化查询语言SQL)编写的请求。当然,我们这里只谈论了理解 SQL 的数据库。它们被称为关系(或表格)数据库,并且占当前使用的数据库的绝大多数,尽管也使用一些替代方案——如导航数据库和 NoSql。

java.sqljavax.sql包包含在 Java 平台标准版(Java SE)中。从历史上看,java.sql包属于 Java 核心,而javax.sql包被认为是核心扩展。但后来,javax.sql包也被包含在核心中,名称没有更改,以避免破坏使用它的现有应用程序。javax.sql包包含支持语句池、分布式事务和行集的DataSource接口。我们将在本章的后续部分更详细地讨论这些功能。

与数据库一起工作包括八个步骤:

  1. 按照供应商的说明安装数据库。

  2. 创建数据库用户、数据库和数据库模式——表、视图、存储过程等。

  3. 在应用程序上添加对.jar的依赖项,其中包含特定于数据库的驱动程序。

  4. 从应用程序连接到数据库。

  5. 构造 SQL 语句。

  6. 执行 SQL 语句。

  7. 使用执行结果。

  8. 释放(关闭)在过程中打开的数据库连接和其他资源。

步骤 1-3 只在应用程序运行之前的数据库设置时执行一次。步骤 4-8 根据需要由应用程序重复执行。步骤 5-7 可以重复多次使用相同的数据库连接。

连接到数据库

以下是连接到数据库的代码片段:

String URL = "jdbc:postgresql://localhost/javaintro";
Properties prop = new Properties( );
//prop.put( "user", "java" );
//prop.put( "password", "secretPass123" );
try {
  Connection conn = DriverManager.getConnection(URL, prop);
} catch(SQLException ex){
  ex.printStackTrace();
}

注释行显示了如何使用java.util.Properties类为连接设置用户和密码。上述只是一个示例,说明如何直接使用DriverManger类获取连接。传入属性的许多键对于所有主要数据库都是相同的,但其中一些是特定于数据库的。因此,请阅读您的数据库供应商文档以获取此类详细信息。

或者,仅传递用户和密码,我们可以使用重载版本DriverManager.getConnection(String url,String user,String password)

保持密码加密是一个好的做法。我们不会告诉你如何做,但是互联网上有很多指南可用。

另一种连接到数据库的方法是使用DataSource接口。它的实现包含在与数据库驱动程序相同的.jar中。在 PostgreSQL 的情况下,有两个实现了DataSource接口的类:org.postgresql.ds.PGSimpleDataSourceorg.postgresql.ds.PGConnectionPoolDataSource。我们可以使用它们来代替DriverManager。以下是使用org.postgresql.ds.PGSimpleDataSource类创建数据库连接的示例:

PGSimpleDataSource source = new PGSimpleDataSource();
source.setServerName("localhost");
source.setDatabaseName("javaintro");
source.setLoginTimeout(10);
Connection conn = source.getConnection();

要使用org.postgresql.ds.PGConnectionPoolDataSource类连接到数据库,我们只需要用以下内容替换前面代码中的第一行:

PGConnectionPoolDataSource source = new PGConnectionPoolDataSource();

使用PGConnectionPoolDataSource类允许我们在内存中创建一个Connection对象池。这是一种首选的方式,因为创建Connection对象需要时间。池化允许我们提前完成这个过程,然后根据需要重复使用已经创建的对象。池的大小和其他参数可以在postgresql.conf文件中设置。

但无论使用何种方法创建数据库连接,我们都将把它隐藏在getConnection()方法中,并在所有的代码示例中以相同的方式使用它。

有了Connection类的对象,我们现在可以访问数据库来添加、读取、删除或修改存储的数据。

关闭数据库连接

保持数据库连接活动需要大量的资源内存和 CPU-因此关闭连接并释放分配的资源是一个好主意,一旦你不再需要它们。在池化的情况下,Connection对象在关闭时会返回到池中,消耗更少的资源。

在 Java 7 之前,关闭连接的方法是通过在finally块中调用close()方法,无论是否有 catch 块:

Connection conn = getConnection();
try {
  //use object conn here 
} finally {
  if(conn != null){
    conn.close();
  }
}

finally块中的代码总是会被执行,无论 try 块中的异常是否被抛出。但自 Java 7 以来,try...with...resources结构可以很好地处理实现了java.lang.AutoCloseablejava.io.Closeable接口的任何对象。由于java.sql.Connection对象实现了AutoCloseable,我们可以将上一个代码片段重写如下:

try (Connection conn = getConnection()) {
  //use object conn here
}
catch(SQLException ex) {
  ex.printStackTrace();
}

捕获子句是必要的,因为可自动关闭的资源会抛出java.sql.SQLException。有人可能会说,这样做并没有节省多少输入。但是Connection类的close()方法也可能会抛出SQLException,所以带有finally块的代码应该更加谨慎地编写:

Connection conn = getConnection();
try {
  //use object conn here 
} finally {
  if(conn != null){
    try {
      conn.close();
    } catch(SQLException ex){
      //do here what has to be done
    }
  }
}

前面的代码块看起来确实像是更多的样板代码。更重要的是,如果考虑到通常在try块内,一些其他代码也可能抛出SQLException,那么前面的代码应该如下所示:

Connection conn = getConnection();
try {
  //use object conn here 
} catch(SQLException ex) {
  ex.printStackTrace();
} finally {
  if(conn != null){
    try {
      conn.close();
    } catch(SQLException ex){
      //do here what has to be done
    }
  }
}

样板代码增加了,不是吗?这还不是故事的结束。在接下来的章节中,您将了解到,要发送数据库请求,还需要创建一个java.sql.Statement,它会抛出SQLException,也必须关闭。然后前面的代码会变得更多:

Connection conn = getConnection();
try {
  Statement statement = conn.createStatement();
  try{
    //use statement here
  } catch(SQLException ex){
    //some code here
  } finally {
    if(statement != null){
      try {
      } catch (SQLException ex){
        //some code here
      }
    } 
  }
} catch(SQLException ex) {
  ex.printStackTrace();
} finally {
  if(conn != null){
    try {
      conn.close();
    } catch(SQLException ex){
      //do here what has to be done
    }
  }
}

现在我们可以充分欣赏try...with...resources结构的优势,特别是考虑到它允许我们在同一个子句中包含多个可自动关闭的资源:

try (Connection conn = getConnection();
  Statement statement = conn.createStatement()) {
  //use statement here
} catch(SQLException ex) {
  ex.printStackTrace();
}

自 Java 9 以来,我们甚至可以使其更简单:

Connection conn = getConnection();
try (conn; Statement statement = conn.createStatement()) {
  //use statement here
} catch(SQLException ex) {
  ex.printStackTrace();
}

现在很明显,try...with...resources结构是一个无可争议的赢家。

结构化查询语言(SQL)

SQL 是一种丰富的语言,我们没有足够的空间来涵盖其所有特性。我们只想列举一些最受欢迎的特性,以便您了解它们的存在,并在需要时查找它们。

与 Java 语句类似,SQL 语句表达了像英语句子一样的数据库请求。每个语句都可以在数据库控制台中执行,也可以通过使用 JDBC 连接在 Java 代码中执行。程序员通常在控制台中测试 SQL 语句,然后再在 Java 代码中使用它,因为在控制台中的反馈速度要快得多。在使用控制台时,无需编译和执行程序。

有 SQL 语句可以创建和删除用户和数据库。我们将在下一节中看到此类语句的示例。还有其他与整个数据库相关的语句,超出了本书的范围。

创建数据库后,以下三个 SQL 语句允许我们构建和更改数据库结构 - 表、函数、约束或其他数据库实体:

  • CREATE:此语句创建数据库实体

  • ALTER:此语句更改数据库实体

  • DROP:此语句删除数据库实体

还有各种 SQL 语句,允许我们查询每个数据库实体的信息,这也超出了本书的范围。

并且有四种 SQL 语句可以操作数据库中的数据:

  • INSERT:此语句向数据库添加数据

  • SELECT:此语句从数据库中读取数据

  • UPDATE:此语句更改数据库中的数据

  • DELETE:此语句从数据库中删除数据

可以向前述语句添加一个或多个不同的子句,用于标识请求的数据(WHERE-子句)、结果返回的顺序(ORDER-子句)等。

JDBC 连接允许将前述 SQL 语句中的一个或多个组合包装在提供数据库端不同功能的三个类中:

  • java.sql.Statement:只是将语句发送到数据库服务器以执行

  • java.sql.PreparedStatement:在数据库服务器上的某个执行路径中缓存语句,允许以高效的方式多次执行具有不同参数的语句

  • java.sql.CallableStatement:在数据库中执行存储过程

我们将从创建和删除数据库及其用户的语句开始我们的演示。

创建数据库及其结构

查找如何下载和安装您喜欢的数据库服务器。数据库服务器是一个维护和管理数据库的软件系统。对于我们的演示,我们将使用 PostgreSQL,一个免费的开源数据库服务器。

安装数据库服务器后,我们将使用其控制台来创建数据库及其用户,并赋予相应的权限。有许多方法可以构建数据存储和具有不同访问级别的用户系统。在本书中,我们只介绍基本方法,这使我们能够演示主要的 JDBC 功能。

创建和删除数据库及其用户

阅读数据库说明,并首先创建一个java用户和一个javaintro数据库(或选择任何其他您喜欢的名称,并在提供的代码示例中使用它们)。以下是我们在 PostgreSQL 中的操作方式:

CREATE USER java SUPERUSER;
CREATE DATABASE javaintro OWNER java;

如果您犯了一个错误并决定重新开始,您可以使用以下语句删除创建的用户和数据库:

DROP USER java;
DROP DATABASE javaintro;

我们为我们的用户选择了SUPERUSER角色,但是良好的安全实践建议只将这样一个强大的角色分配给管理员。对于应用程序,建议创建一个用户,该用户不能创建或更改数据库本身——其表和约束——但只能管理数据。此外,创建另一个逻辑层,称为模式,该模式可以具有自己的一组用户和权限,也是一个良好的实践。这样,同一数据库中的几个模式可以被隔离,每个用户(其中一个是您的应用程序)只能访问特定的模式。在企业级别上,通常的做法是为数据库模式创建同义词,以便没有应用程序可以直接访问原始结构。

但是,正如我们已经提到的,对于本书的目的,这是不需要的,所以我们把它留给数据库管理员,他们为每个企业的特定工作条件建立规则和指导方针。

现在我们可以将我们的应用程序连接到数据库。

创建、修改和删除表

表的标准 SQL 语句如下:

CREATE TABLE tablename (
  column1 type1,
  column2 type2,
  column3 type3,
  ....
);

表名、列名和可以使用的值类型的限制取决于特定的数据库。以下是在 PostgreSQL 中创建表 person 的命令示例:

CREATE TABLE person (
  id SERIAL PRIMARY KEY,
  first_name VARCHAR NOT NULL,
  last_name VARCHAR NOT NULL,
  dob DATE NOT NULL
);

正如您所看到的,我们已经将dob(出生日期)列设置为不可为空。这对我们的Person Java 类施加了约束,该类将表示此表的记录:其dob字段不能为null。这正是我们在第六章中所做的,当时我们创建了我们的Person类,如下所示:

class Person {
  private String firstName, lastName;
  private LocalDate dob;
  public Person(String firstName, String lastName, LocalDate dob) {
    this.firstName = firstName == null ? "" : firstName;
    this.lastName = lastName == null ? "" : lastName;
    if(dob == null){
      throw new RuntimeException("Date of birth is null");
    }
    this.dob = dob;
  }
  public String getFirstName() { return firstName; }
  public String getLastName() { return lastName; }
  public LocalDate getDob() { return dob; }
}

我们没有设置VARCHAR类型的列的大小,因此允许这些列存储任意长度的值,而整数类型允许它们存储从公元前 4713 年到公元 5874897 年的数字。添加了NOT NULL,因为默认情况下列将是可空的,而我们希望确保每条记录的所有列都被填充。我们的Person类通过将名字和姓氏设置为空的String值来支持它,如果它们是null,作为Person构造函数的参数。

我们还将id列标识为PRIMARY KEY,这表示该列唯一标识记录。SERIAL关键字表示我们要求数据库在添加新记录时生成下一个整数值,因此每条记录将有一个唯一的整数编号。或者,我们可以从first_namelast_namedob的组合中创建PRIMARY KEY

CREATE TABLE person (
  first_name VARCHAR NOT NULL,
  last_name VARCHAR NOT NULL,
  dob DATE NOT NULL,
  PRIMARY KEY (first_name, last_name, dob)
);

但有可能有两个人有相同的名字,并且出生在同一天,所以我们决定不这样做,并添加了Person类的另一个字段和构造函数:

public class Person {
  private String firstName, lastName;
  private LocalDate dob;
  private int id;
  public Person(int id, String firstName, 
                                  String lastName, LocalDate dob) {
    this(firstName, lastName, dob);
    this.id = id;
  }   
  public Person(String firstName, String lastName, LocalDate dob) {
    this.firstName = firstName == null ? "" : firstName;
    this.lastName = lastName == null ? "" : lastName;
    if(dob == null){
      throw new RuntimeException("Date of birth is null");
    }
    this.dob = dob;
  }
  public String getFirstName() { return firstName; }
  public String getLastName() { return lastName; }
  public LocalDate getDob() { return dob; }
}

我们将使用接受id的构造函数来基于数据库中的记录构建对象,而另一个构造函数将用于在插入新记录之前创建对象。

我们在数据库控制台中运行上述 SQL 语句并创建这个表:

如果必要,可以通过DROP命令删除表:

DROP table person;

可以使用ALTER命令更改现有表。例如,我们可以添加一个address列:

ALTER table person add column address VARCHAR;

如果您不确定这样的列是否已经存在,可以添加 IF EXISTS 或 IF NOT EXISTS:

ALTER table person add column IF NOT EXISTS address VARCHAR;

但这种可能性只存在于 PostgreSQL 9.6 之后。

数据库表创建的另一个重要考虑因素是是否必须添加索引。索引是一种数据结构,可以加速表中的数据搜索,而无需检查每条表记录。索引可以包括一个或多个表的列。例如,主键的索引会自动生成。如果您已经创建了表的描述,您将看到:

如果我们认为(并通过实验已经证明)它将有助于应用程序的性能,我们也可以自己添加任何索引。例如,我们可以通过添加以下索引来允许不区分大小写的搜索名字和姓氏:

CREATE INDEX idx_names ON person ((lower(first_name), lower(last_name));

如果搜索速度提高,我们会保留索引。如果没有,可以删除它:

drop index idx_names;

我们删除它,因为索引会增加额外的写入和存储空间开销。

我们也可以从表中删除列:

ALTER table person DROP column address;

在我们的示例中,我们遵循了 PostgreSQL 的命名约定。如果您使用不同的数据库,建议您查找其命名约定并遵循,以便您创建的名称与自动创建的名称对齐。

创建,读取,更新和删除(CRUD)数据

到目前为止,我们已经使用控制台将 SQL 语句发送到数据库。可以使用 JDBC API 从 Java 代码执行相同的语句,但是表只创建一次,因此没有必要为一次性执行编写程序。

但是管理数据是另一回事。这是我们现在要编写的程序的主要目的。为了做到这一点,首先我们将以下依赖项添加到pom.xml文件中,因为我们已经安装了 PostgreSQL 9.6:

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.2.2</version>
</dependency>

INSERT 语句

在数据库中创建(填充)数据的 SQL 语句具有以下格式:

INSERT INTO table_name (column1,column2,column3,...)
   VALUES (value1,value2,value3,...);

当必须添加多个表记录时,它看起来像这样:

INSERT INTO table_name (column1,column2,column3,...)
 VALUES (value1,value2,value3,...), (value11,value21,value31,...), ...;

在编写程序之前,让我们测试我们的INSERT语句:

![](img/c87f8461-b463-4dcb-a806-01b2bac288c7.png)

它没有错误,返回的插入行数为 1,所以我们将创建以下方法:

void executeStatement(String sql){
  Connection conn = getConnection();
  try (conn; Statement st = conn.createStatement()) {
    st.execute(sql);
  } catch (SQLException ex) {
    ex.printStackTrace();
  }
}

我们可以执行前面的方法并插入另一行:

executeStatement("insert into person (first_name, last_name, dob)" +
                             " values ('Bill', 'Grey', '1980-01-27')");

我们将在下一节中看到此前INSERT语句执行的结果以及SELECT语句的演示。

与此同时,我们想讨论java.sql.Statement接口的最受欢迎的方法:

  • boolean execute(String sql):如果执行的语句返回数据(作为java.sql.ResultSet对象),则返回true,可以使用java.sql.Statement接口的ResultSet getResultSet()方法检索数据。如果执行的语句不返回数据(SQL 语句可能正在更新或插入某些行),则返回false,并且随后调用java.sql.Statement接口的int getUpdateCount()方法返回受影响的行数。例如,如果我们在executeStatement()方法中添加了打印语句,那么在插入一行后,我们将看到以下结果:
        void executeStatement(String sql){
          Connection conn = getConnection();
          try (conn; Statement st = conn.createStatement()) {
            System.out.println(st.execute(sql));      //prints: false
            System.out.println(st.getResultSet());    //prints: null
            System.out.println(st.getUpdateCount());  //prints: 1
          } catch (SQLException ex) {
            ex.printStackTrace();
          }
        }
  • ResultSet executeQuery(String sql):它将数据作为java.sql.ResultSet对象返回(预计执行的 SQL 语句是SELECT语句)。可以通过随后调用java.sql.Statement接口的ResultSet getResultSet()方法检索相同的数据。java.sql.Statement接口的int getUpdateCount()方法返回-1。例如,如果我们更改我们的executeStatement()方法并使用executeQuery(),则executeStatement("select first_name from person")的结果将是:
        void executeStatement(String sql){
          Connection conn = getConnection();
          try (conn; Statement st = conn.createStatement()) {
             System.out.println(st.executeQuery(sql)); //prints: ResultSet
             System.out.println(st.getResultSet());    //prints: ResultSet
             System.out.println(st.getUpdateCount());  //prints: -1
          } catch (SQLException ex) {
             ex.printStackTrace();
          }
        }
  • int executeUpdate(String sql): 它返回受影响的行数(执行的 SQL 语句预期为UPDATE语句)。java.sql.Statement接口的int getUpdateCount()方法的后续调用返回相同的数字。java.sql.Statement接口的ResultSet getResultSet()方法的后续调用返回null。例如,如果我们更改我们的executeStatement()方法并使用executeUpdate()executeStatement("update person set first_name = 'Jim' where last_name = 'Adams'")的结果将是:
        void executeStatement4(String sql){
          Connection conn = getConnection();
          try (conn; Statement st = conn.createStatement()) {
            System.out.println(st.executeUpdate(sql));//prints: 1
            System.out.println(st.getResultSet());    //prints: null
            System.out.println(st.getUpdateCount());  //prints: 1
          } catch (SQLException ex) {
            ex.printStackTrace();
          }
        }

SELECT 语句

SELECT语句的格式如下:

SELECT column_name, column_name
FROM table_name WHERE some_column = some_value;

当需要选择所有列时,格式如下:

SELECT * FROM table_name WHERE some_column=some_value;

这是WHERE子句的更一般的定义:

WHERE column_name operator value
Operator:
   =   Equal
   <>  Not equal. In some versions of SQL, !=
   >   Greater than
   <   Less than
   >=  Greater than or equal
   <=  Less than or equal
   IN  Specifies multiple possible values for a column
   LIKE  Specifies the search pattern
   BETWEEN  Specifies the inclusive range of vlaues in a column

column_name operator value 构造可以使用ANDOR逻辑运算符组合,并用括号( )分组。

在前面的语句中,我们执行了一个select first_name from personSELECT语句,返回了person表中记录的所有名字。现在让我们再次执行它并打印出结果:

Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
  ResultSet rs = st.executeQuery("select first_name from person");
  while (rs.next()){
    System.out.print(rs.getString(1) + " "); //prints: Jim Bill
  }
} catch (SQLException ex) {
  ex.printStackTrace();
}

ResultSet接口的getString(int position)方法从位置1SELECT语句中列的第一个)提取String值。对于所有原始类型,如getInt()getByte(),都有类似的获取器。

还可以通过列名从ResultSet对象中提取值。在我们的情况下,它将是getString("first_name")。当SELECT语句如下时,这是特别有用的:

select * from person;

但请记住,通过列名从ResultSet对象中提取值效率较低。性能差异非常小,只有在操作发生多次时才变得重要。只有实际的测量和测试才能告诉您这种差异对您的应用程序是否重要。通过列名提取值尤其有吸引力,因为它提供更好的代码可读性,在应用程序维护期间可以得到很好的回报。

ResultSet接口中还有许多其他有用的方法。如果您的应用程序从数据库中读取数据,我们强烈建议您阅读SELECT语句和ResultSet接口的文档。

UPDATE 语句

数据可以通过UPDATE语句更改:

UPDATE table_name SET column1=value1,column2=value2,... WHERE-clause;

我们已经使用这样的语句来改变记录中的名字,将原始值John改为新值Jim

update person set first_name = 'Jim' where last_name = 'Adams'

稍后,使用SELECT语句,我们将证明更改是成功的。没有WHERE子句,表的所有记录都将受到影响。

DELETE 语句

数据可以通过DELETE语句删除:

DELETE FROM table_name WHERE-clause;

没有WHERE子句,表的所有记录都将被删除。在person表的情况下,我们可以使用delete from person SQL 语句删除所有记录。以下语句从person表中删除所有名为 Jim 的记录:

delete from person where first_name = 'Jim';

使用 PreparedStatement 类

PreparedStatement对象——Statement接口的子接口——旨在被缓存在数据库中,然后用于有效地多次执行 SQL 语句,以适应不同的输入值。与Statement对象类似(由createStatement()方法创建),它可以由同一Connection对象的prepareStatement()方法创建。

生成Statement对象的相同 SQL 语句也可以用于生成PreparedStatement对象。事实上,考虑使用PreparedStatement来调用多次的任何 SQL 语句是一个好主意,因为它的性能优于Statement。要做到这一点,我们只需要更改前面示例代码中的这两行:

try (conn; Statement st = conn.createStatement()) {
  ResultSet rs = st.executeQuery(sql);

或者,我们可以以同样的方式使用PreparedStatement类:

try (conn; PreparedStatement st = conn.prepareStatement(sql)) {
  ResultSet rs = st.executeQuery();

但是PreparedStatement的真正用处在于它能够接受参数-替换(按照它们出现的顺序)?符号的输入值。例如,我们可以创建以下方法:

List<Person> selectPersonsByFirstName(String sql, String searchValue){
  List<Person> list = new ArrayList<>();
  Connection conn = getConnection();
  try (conn; PreparedStatement st = conn.prepareStatement(sql)) {
    st.setString(1, searchValue);
    ResultSet rs = st.executeQuery();
    while (rs.next()){
      list.add(new Person(rs.getInt("id"),
               rs.getString("first_name"),
               rs.getString("last_name"),
               rs.getDate("dob").toLocalDate()));
    }
  } catch (SQLException ex) {
    ex.printStackTrace();
  }
  return list;
}

我们可以使用前面的方法从person表中读取与WHERE子句匹配的记录。例如,我们可以找到所有名为Jim的记录:

String sql = "select * from person where first_name = ?";
List<Person> list = selectPersonsByFirstName(sql, "Jim");
for(Person person: list){
  System.out.println(person);
}

结果将是:

Person{firstName='Jim', lastName='Adams', dob=1999-08-23, id=1}

Person对象以这种方式打印,因为我们添加了以下toString()方法:

@Override
public String toString() {
  return "Person{" +
          "firstName='" + firstName + '\'' +
          ", lastName='" + lastName + '\'' +
          ", dob=" + dob +
          ", id=" + id +
          '}';
}

我们可以通过运行以下代码获得相同的结果:

String sql = "select * from person where last_name = ?";
List<Person> list = selectPersonsByFirstName(sql, "Adams");
for(Person person: list){
    System.out.println(person);
}

总是使用准备好的语句进行 CRUD 操作并不是一个坏主意。如果只执行一次,它们可能会慢一点,但您可以测试看看这是否是您愿意支付的代价。使用准备好的语句可以获得一致的(更易读的)代码、更多的安全性(准备好的语句不容易受到 SQL 注入攻击的影响)以及少做一个决定-只需在任何地方重用相同的代码。

练习-选择唯一的名字

编写一个 SQL 语句,从人员表中选择所有的名字,而不重复。例如,假设人员表中有三条记录,这些记录有这些名字:JimJimBill。您编写的 SQL 语句必须返回JimBill,而不重复两次的Jim

我们没有解释如何做; 您必须阅读 SQL 文档,以找出如何选择唯一的值。

答案

使用distinct关键字。以下 SQL 语句返回唯一的名字:

select distinct first_name from person;

摘要

本章介绍了如何编写能够操作数据库中的数据的 Java 代码。它还对 SQL 语言和基本数据库操作进行了简要介绍。读者已经学会了 JDBC 是什么,如何创建和删除数据库和表,以及如何编写一个管理表中数据的程序。

在下一章中,读者将学习函数式编程的概念。我们将概述 JDK 附带的功能接口,解释如何在 lambda 表达式中使用它们,并了解如何在数据流处理中使用 lambda 表达式。

第十七章:Lambda 表达式和函数式编程

本章解释了函数式编程的概念。它提供了 JDK 附带的功能接口的概述,解释了如何在 Lambda 表达式中使用它们,以及如何以最简洁的方式编写 Lambda 表达式。

在本章中,我们将介绍以下主题:

  • 函数式编程

  • 函数式接口

  • Lambda 表达式

  • 方法引用

  • 练习——使用方法引用创建一个新对象

函数式编程

函数式编程允许我们像处理对象一样处理一段代码(一个函数),将其作为参数传递或作为方法的返回值。这个特性存在于许多编程语言中。它不需要我们管理对象状态。这个函数是无状态的。它的结果只取决于输入数据,不管它被调用多少次。这种风格使结果更可预测,这是函数式编程最具吸引力的方面。

没有函数式编程,Java 中将功能作为参数传递的唯一方式是通过编写一个实现接口的类,创建其对象,然后将其作为参数传递。但即使是最简单的样式——使用匿名类——也需要编写太多的样板代码。使用函数式接口和 Lambda 表达式使代码更短、更清晰、更具表现力。

将其添加到 Java 中增加了并行编程的能力,将并行性的责任从客户端代码转移到库中。在此之前,为了处理 Java 集合的元素,客户端代码必须遍历集合并组织处理。在 Java 8 中,添加了新的(默认)方法,接受一个函数(函数式接口的实现)作为参数,然后根据内部处理算法并行或顺序地将其应用于集合的每个元素。因此,组织并行处理是库的责任。

在本章中,我们将定义和解释这些 Java 特性——函数式接口和 Lambda 表达式,并演示它们在代码示例中的适用性。它们将函数作为语言中与对象同等重要的一等公民。

什么是函数式接口?

实际上,您在我们的演示代码中已经看到了函数式编程的元素。一个例子是forEach(Consumer consumer)方法,适用于每个Iterable,其中Consumer是一个函数式接口。另一个例子是removeIf(Predicate predicate)方法,适用于每个Collection对象。传入的Predicate对象是一个函数——函数式接口的实现。类似地,List接口中的sort(Comparator comparator)replaceAll(UnaryOperator uo)方法以及Map中的几个compute()方法都是函数式编程的例子。

一个函数接口是一个只有一个抽象方法的接口,包括那些从父接口继承的方法。

为了帮助避免运行时错误,在 Java 8 中引入了@FunctionalInterface注解,告诉编译器关于意图,因此编译器可以检查被注解接口中是否真正只有一个抽象方法。让我们一起审查下面的与同一继承线的接口:

@FunctionalInterface
interface A {
  void method1();
  default void method2(){}
  static void method3(){}
}

@FunctionalInterface
interface B extends A {
  default void method4(){}
}

@FunctionalInterface
interface C extends B {
  void method1();
}

//@FunctionalInterface  //compilation error
interface D extends C {
  void method5();
}

接口A是一个函数接口,因为它只有一个抽象方法:method1()。接口B也是一个函数接口,因为它也只有一个抽象方法 - 从接口A继承的同一个method1()。接口C是一个函数接口,因为它只有一个抽象方法,method1(),它覆盖了父接口A的抽象method1()方法。接口D不能是一个函数接口,因为它有两个抽象方法 - 从父接口A继承的method1()method5()

当使用@FunctionalInterface注解时,它告诉编译器只检查存在一个抽象方法,并警告程序员读取代码时,这个接口只有一个抽象方法是有意的。否则,程序员可能会浪费时间完善接口,最后发现无法完成。

出于同样的原因,自 Java 早期版本以来存在的RunnableCallable接口在 Java 8 中被注释为@FunctionalInterface。这明确表明了这种区别,并提醒其用户以及可能尝试添加另一个抽象方法的人:

@FunctionalInterface
interface Runnable { 
  void run(); 
} 
@FunctionalInterface
interface Callable<V> { 
  V call() throws Exception; 
}

可以看到,创建一个函数接口很容易。但在这之前,考虑使用java.util.function包中提供的 43 个函数接口之一。

准备好使用的标准函数接口

java.util.function包中提供的大多数接口都是以下四个接口的专业化:FunctionConsumerSupplierPredicate。让我们对它们进行审查,然后简要概述其余 39 个标准函数接口。

Function<T, R>

这个和其他函数接口的标记包括输入数据类型(T)和返回数据类型(R)的列举。因此,Function<T, R>表示该接口的唯一抽象方法接受类型为T的参数并产生类型为R的结果。您可以通过阅读在线文档找到该抽象方法的名称。在Function<T, R>接口的情况下,它的方法是R apply(T)

在学习所有内容后,我们可以使用匿名类创建该接口的实现:

Function<Integer, Double> multiplyByTen = new Function<Integer, Double>(){
  public Double apply(Integer i){
    return i * 10.0;
  }
};

由程序员决定T(输入参数)将是哪种实际类型,以及R(返回值)将是哪种类型。在我们的示例中,我们已经决定输入参数将是Integer类型,结果将是Double类型。正如你现在可能已经意识到的那样,类型只能是引用类型,并且原始类型的装箱和拆箱会自动执行。

现在我们可以按照需要使用我们新的Function<Integer, Double> multiplyByTen函数。我们可以直接使用它,如下所示:

System.out.println(multiplyByTen.apply(1)); //prints: 10.0

或者我们可以创建一个接受这个函数作为参数的方法:

void useFunc(Function<Integer, Double> processingFunc, int input){
  System.out.println(processingFunc.apply(input));
}

然后我们可以将我们的函数传递给这个方法,并让方法使用它:

useFunc(multiplyByTen, 10);     //prints: 100.00

我们还可以创建一个方法,每当我们需要一个函数时就会生成一个函数:

Function<Integer, Double> createMultiplyBy(double num){
  Function<Integer, Double> func = new Function<Integer, Double>(){
    public Double apply(Integer i){
      return i * num;
    }
  };
  return func;
}

使用前述方法,我们可以编写以下代码:

Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
System.out.println(multiplyByFive.apply(1)); //prints: 5.0
useFunc(multiplyByFive, 10);                 //prints: 50.0

在下一节中,我们将介绍 lambda 表达式,并展示如何使用它们以更少的代码来表示函数接口实现。

Consumer<T>

通过查看Consumer<T>接口的定义,你可以猜到这个接口有一个接受T类型参数的抽象方法,而且不返回任何东西。从Consumer<T>接口的文档中,我们了解到它的抽象方法是void accept(T),这意味着,例如,我们可以这样实现它:

Consumer<Double> printResult = new Consumer<Double>() {
  public void accept(Double d) {
    System.out.println("Result=" + d);
  }
};
printResult.accept(10.0);         //prints: Result=10.0

或者我们可以创建一个生成函数的方法:

Consumer<Double> createPrintingFunc(String prefix, String postfix){
  Consumer<Double> func = new Consumer<Double>() {
    public void accept(Double d) {
      System.out.println(prefix + d + postfix);
    }
  };
  return func;
}

现在我们可以像下面这样使用它:

Consumer<Double> printResult = createPrintingFunc("Result=", " Great!");
printResult.accept(10.0);    //prints: Result=10.0 Great!

我们还可以创建一个新方法,不仅接受一个处理函数作为参数,还接受一个打印函数:

void processAndConsume(int input, 
                       Function<Integer, Double> processingFunc, 
                                          Consumer<Double> consumer){
  consumer.accept(processingFunc.apply(input));
}

然后我们可以编写以下代码:

Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
Consumer<Double> printResult = createPrintingFunc("Result=", " Great!");
processAndConsume(10, multiplyByFive, printResult); //Result=50.0 Great! 

正如我们之前提到的,在下一节中,我们将介绍 lambda 表达式,并展示如何使用它们以更少的代码来表示函数接口实现。

Supplier<T>

这是一个诡计问题:猜猜Supplier<T>接口的抽象方法的输入和输出类型。答案是:它不接受参数,返回T类型。正如你现在理解的那样,区别在于接口本身的名称。它应该给你一个提示:消费者只消耗而不返回任何东西,而供应者只提供而不需要任何输入。Supplier<T>接口的抽象方法是T get()

与前面的函数类似,我们可以编写生成供应者的方法:

Supplier<Integer> createSuppplier(int num){
  Supplier<Integer> func = new Supplier<Integer>() {
    public Integer get() { return num; }
  };
  return func;
}

现在我们可以编写一个只接受函数的方法:

void supplyProcessAndConsume(Supplier<Integer> input, 
                             Function<Integer, Double> process, 
                                      Consumer<Double> consume){
  consume.accept(processFunc.apply(input.get()));
}

注意input函数的输出类型与process函数的输入类型相同,返回类型与consume函数消耗的类型相同。这使得以下代码成为可能:

Supplier<Integer> supply7 = createSuppplier(7);
Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
Consumer<Double> printResult = createPrintingFunc("Result=", " Great!");
supplyProcessAndConsume(supply7, multiplyByFive, printResult); 
                                            //prints: Result=35.0 Great!

到此为止,我们希望你开始欣赏函数式编程带来的价值。它允许我们传递功能块,可以插入到算法的中间而不需要创建对象。静态方法也不需要创建对象,但它们由于在 JVM 中是唯一的,所以会被所有应用线程共享。与此同时,每个函数都是一个对象,可以在 JVM 中是唯一的(如果赋值给静态变量),或者为每个处理线程创建一个(这通常是情况)。它几乎没有编码开销,并且在 lambda 表达式中使用时可以更少地使用管道 - 这是我们下一节的主题。

到目前为止,我们已经演示了如何将函数插入现有的控制流表达式中。现在我们将描述最后一个缺失的部分 - 一个表示决策构造的函数,也可以作为对象传递。

Predicate

这是一个表示具有单个方法boolean test(T)的布尔值函数的接口。这里是一个创建Predicate<Integer>函数的方法示例:

Predicate<Integer> createTestSmallerThan(int num){
  Predicate<Integer> func = new Predicate<Integer>() {
    public boolean test(Integer d) {
      return d < num;
    }
  };
  return func;
}

我们可以使用它来为处理方法添加一些逻辑:

void supplyDecideProcessAndConsume(Supplier<Integer> input, 
                                  Predicate<Integer> test, 
                                   Function<Integer, Double> process, 
                                            Consumer<Double> consume){
  int in = input.get();
  if(test.test(in)){
    consume.accept(process.apply(in));
  } else {
    System.out.println("Input " + in + 
                     " does not pass the test and not processed.");
  }
}

下面的代码演示了它的使用方法:

Supplier<Integer> input = createSuppplier(7);
Predicate<Integer> test = createTestSmallerThan(5);
Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
Consumer<Double> printResult = createPrintingFunc("Result=", " Great!");
supplyDecideProcessAndConsume(input, test, multiplyByFive, printResult);
             //prints: Input 7 does not pass the test and not processed.

例如,让我们将输入设置为 3:

Supplier<Integer> input = createSuppplier(3)

前面的代码将导致以下输出:

Result=15.0 Great!

其他标准的函数式接口

java.util.function包中的其他 39 个函数接口是我们刚刚审查的四个接口的变体。这些变体是为了实现以下目的之一或任意组合:

  • 通过明确使用整数、双精度或长整型原始类型来避免自动装箱和拆箱,从而获得更好的性能

  • 允许两个输入参数

  • 更简短的记法

这里只是一些例子:

  • IntFunction<R>提供了更简短的记法(不需要输入参数类型的泛型)并且避免了自动装箱,因为它要求参数为int原始类型。

  • BiFunction<T,U,R>R apply(T,U)方法允许两个输入参数

  • BinaryOperator<T>T apply(T,T)方法允许两个T类型的输入参数,并返回相同的T类型的值

  • IntBinaryOperatorint applAsInt(int,int)方法接受两个int类型的参数,并返回int类型的值

如果你打算使用函数接口,我们鼓励你学习java.util.functional包中接口的 API。

链接标准函数

java.util.function包中的大多数函数接口都有默认方法,允许我们构建一个函数链(也称为管道),将一个函数的结果作为另一个函数的输入参数传递,从而组合成一个新的复杂函数。例如:

Function<Double, Long> f1 = d -> Double.valueOf(d / 2.).longValue();
Function<Long, String> f2 = l -> "Result: " + (l + 1);
Function<Double, String> f3 = f1.andThen(f2);
System.out.println(f3.apply(4.));            //prints: 3

如您从前面的代码中所见,我们通过使用 andThen() 方法将 f1f2 函数组合成了一个新的 f3 函数。这就是我们将要在本节中探讨的方法的思想。首先,我们将函数表示为匿名类,然后在以下部分中,我们介绍了前面示例中使用的 lambda 表达式。

链两个 Function<T,R>

我们可以使用 Function 接口的 andThen(Function after) 默认方法。我们已经创建了 Function<Integer, Double> createMultiplyBy() 方法:

Function<Integer, Double> createMultiplyBy(double num){
  Function<Integer, Double> func = new Function<Integer, Double>(){
    public Double apply(Integer i){
      return i * num;
    }
  };
  return func; 

我们还可以编写另一个方法,该方法创建具有 Double 输入类型的减法函数,以便我们可以将其链接到乘法函数:

private static Function<Double, Long> createSubtractInt(int num){
  Function<Double, Long> func = new Function<Double, Long>(){
    public Long apply(Double dbl){
      return Math.round(dbl - num);
    }
  };
  return func;
}

现在我们可以编写以下代码:

Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
System.out.println(multiplyByFive.apply(2));  //prints: 10.0

Function<Double, Long> subtract7 = createSubtractInt(7);
System.out.println(subtract7.apply(11.0));   //prints: 4

long r = multiplyByFive.andThen(subtract7).apply(2);
System.out.println(r);                          //prints: 3

如您所见,multiplyByFive.andThen(subtract7) 链有效地作为 Function<Integer, Long> multiplyByFiveAndSubtractSeven

Function 接口还有另一个默认方法 Function<V,R> compose(Function<V,T> before),它也允许我们链两个函数。必须先执行的函数可以作为 before 参数传递到第二个函数的 compose() 方法中:

boolean r = subtract7.compose(multiplyByFive).apply(2);
System.out.println(r);                          //prints: 3         

链两个 Consumer

Consumer 接口也有 andThen(Consumer after) 方法。我们已经编写了创建打印函数的方法:

Consumer<Double> createPrintingFunc(String prefix, String postfix){
  Consumer<Double> func = new Consumer<Double>() {
    public void accept(Double d) {
      System.out.println(prefix + d + postfix);
    }
  };
  return func;
}

现在我们可以创建和链两个打印函数,如下所示:

Consumer<Double> print21By = createPrintingFunc("21 by ", "");
Consumer<Double> equalsBy21 = createPrintingFunc("equals ", " by 21");
print21By.andThen(equalsBy21).accept(2d);  
//prints: 21 by 2.0 
//        equals 2.0 by 21

如您在 Consumer 链中所见,两个函数按链定义的顺序消耗相同的值。

链两个 Predicate

Supplier 接口没有默认方法,而 Predicate 接口有一个静态方法 isEqual(Object targetRef) 和三个默认方法:and(Predicate other)negate()or(Predicate other)。为了演示 and(Predicate other)or(Predicate other) 方法的用法,例如,让我们编写创建两个 Predicate<Double> 函数的方法。一个函数检查值是否小于输入:

Predicate<Double> testSmallerThan(double limit){
  Predicate<Double> func = new Predicate<Double>() {
    public boolean test(Double num) {
      System.out.println("Test if " + num + " is smaller than " + limit);
      return num < limit;
    }
  };
  return func;
}

另一个函数检查值是否大于输入:

Predicate<Double> testBiggerThan(double limit){
  Predicate<Double> func = new Predicate<Double>() {
    public boolean test(Double num) {
      System.out.println("Test if " + num + " is bigger than " + limit);
      return num > limit;
    }
  };
  return func;
}

现在我们可以创建两个 Predicate<Double> 函数并将它们链在一起:

Predicate<Double> isSmallerThan20 = testSmallerThan(20d);
System.out.println(isSmallerThan20.test(10d));
     //prints: Test if 10.0 is smaller than 20.0
     //        true

Predicate<Double> isBiggerThan18 = testBiggerThan(18d);
System.out.println(isBiggerThan18.test(10d));
    //prints: Test if 10.0 is bigger than 18.0
    //        false

boolean b = isSmallerThan20.and(isBiggerThan18).test(10.);
System.out.println(b);
    //prints: Test if 10.0 is smaller than 20.0
    //        Test if 10.0 is bigger than 18.0
    //        false

b = isSmallerThan20.or(isBiggerThan18).test(10.);
System.out.println(b);
    //prints: Test if 10.0 is smaller than 20.0
    //        true

如您所见,and() 方法需要执行每个函数,而 or() 方法在链中的第一个函数返回 true 后就不执行第二个函数。

identity() 和其他默认方法

java.util.function 包的功能接口有其他有用的默认方法。其中一个显著的是 identity() 方法,它返回一个始终返回其输入参数的函数:

Function<Integer, Integer> id = Function.identity();
System.out.println(id.apply(4));          //prints: 4

identity()方法在某些过程需要提供特定函数,但你不希望提供的函数改变任何东西时非常有用。在这种情况下,你可以创建一个具有必要输出类型的身份函数。例如,在我们之前的代码片段中,我们可能决定multiplyByFive函数在multiplyByFive.andThen(subtract7)链中不改变任何东西:

Function<Double, Double> multiplyByFive = Function.identity();
System.out.println(multiplyByFive.apply(2.));  //prints: 2.0

Function<Double, Long> subtract7 = createSubtractInt(7);
System.out.println(subtract7.apply(11.0));    //prints: 4

long r = multiplyByFive.andThen(subtract7).apply(2.);
System.out.println(r);                       //prints: -5

正如你所看到的,multiplyByFive函数未对输入参数2做任何操作,因此结果(减去7后)是-5

其他默认方法大多涉及转换和装箱和拆箱,但也提取两个参数的最小值和最大值。如果你感兴趣,可以查看java.util.function包接口的 API,并了解可能性。

Lambda 表达式

前一节中的例子(使用匿名类实现函数接口)看起来庞大,并且显得冗长。首先,无需重复接口名称,因为我们已经将其声明为对象引用的类型。其次,在只有一个抽象方法的功能接口的情况下,不需要指定需要实现的方法名称。编译器和 Java 运行时可以自行处理。我们所需做的就是提供新的功能。Lambda 表达式就是为了这个目的而引入的。

什么是 Lambda 表达式?

术语 lambda 来自于 lambda 演算——一种通用的计算模型,可用于模拟任何图灵机。它是数学家阿隆佐·丘奇在 20 世纪 30 年代引入的。Lambda 表达式是一个函数,在 Java 中实现为匿名方法,还允许我们省略修饰符、返回类型和参数类型。这使得它具有非常简洁的表示。

Lambda 表达式的语法包括参数列表、箭头符号->和主体部分。参数列表可以是空的(),没有括号(如果只有一个参数),或者用括号括起来的逗号分隔的参数列表。主体部分可以是单个表达式或语句块。

让我们看几个例子:

  • () -> 42; 总是返回42

  • x -> x + 1; 将变量x增加1

  • (x, y) -> x * y;x乘以y并返回结果

  • (char x) -> x == '$'; 比较变量x和符号$的值,并返回布尔值

  • x -> {  System.out.println("x=" + x); }; 打印带有x=前缀的x

重新实现函数

我们可以使用 lambda 表达式重新编写前一节中创建的函数,如下所示:

Function<Integer, Double> createMultiplyBy(double num){
  Function<Integer, Double> func = i -> i * num;
  return func;
}
Consumer<Double> createPrintingFunc(String prefix, String postfix){
  Consumer<Double> func = d -> System.out.println(prefix + d + postfix);
  return func;
}
Supplier<Integer> createSuppplier(int num){
  Supplier<Integer> func = () -> num;
  return func;
}
Predicate<Integer> createTestSmallerThan(int num){
  Predicate<Integer> func = d -> d < num;
  return func;
}

我们不重复实现接口的名称,因为它在方法签名中指定为返回类型。我们也不指定抽象方法的名称,因为它是唯一必须实现的接口方法。编写这样简洁高效的代码变得可能是因为 lambda 表达式和函数接口的组合。

通过前面的例子,你可能意识到不再需要创建函数的方法了。让我们修改调用supplyDecideProcessAndConsume()方法的代码:

void supplyDecideProcessAndConsume(Supplier<Integer> input, 
                                   Predicate<Integer> test, 
                                   Function<Integer, Double> process, 
                                            Consumer<Double> consume){
  int in = input.get();
  if(test.test(in)){
    consume.accept(process.apply(in));
  } else {
    System.out.println("Input " + in + 
                 " does not pass the test and not processed.");
  }
}

让我们重新审视以下内容:

Supplier<Integer> input = createSuppplier(7);
Predicate<Integer> test = createTestSmallerThan(5);
Function<Integer, Double> multiplyByFive = createMultiplyBy(5);
Consumer<Double> printResult = createPrintingFunc("Result=", " Great!");
supplyDecideProcessAndConsume(input, test, multiplyByFive, printResult);

我们可以将前面的代码更改为以下内容而不改变功能:

Supplier<Integer> input = () -> 7;
Predicate<Integer> test = d -> d < 5.;
Function<Integer, Double> multiplyByFive = i -> i * 5.;;
Consumer<Double> printResult = 
                     d -> System.out.println("Result=" + d + " Great!");
supplyDecideProcessAndConsume(input, test, multiplyByFive, printResult); 

我们甚至可以内联前面的函数,并像这样一行写出前面的代码:

supplyDecideProcessAndConsume(() -> 7, d -> d < 5, i -> i * 5., 
                    d -> System.out.println("Result=" + d + " Great!")); 

注意定义打印函数的透明度提高了多少。这就是 lambda 表达式与函数接口结合的力量和美丽所在。在第十八章,流和管道,你将看到 lambda 表达式实际上是处理流数据的唯一方法。

Lambda 的限制

有两个我们想指出和澄清的 lambda 表达式方面,它们是:

  • 如果 lambda 表达式使用在其外部创建的局部变量,则此局部变量必须是 final 或有效 final(在同一上下文中不可重新赋值)

  • lambda 表达式中的 this 关键字引用的是封闭上下文,而不是 lambda 表达式本身

有效 final 局部变量

与匿名类一样,创建在 lambda 表达式外部并在内部使用的变量将变为有效 final,并且不能被修改。你可以编写以下内容:

int x = 7;
//x = 3;       //compilation error
int y = 5;
double z = 5.;
supplyDecideProcessAndConsume(() -> x, d -> d < y, i -> i * z,
            d -> { //x = 3;      //compilation error
                   System.out.println("Result=" + d + " Great!"); } );

但是,正如你所看到的,我们不能改变 lambda 表达式中使用的局部变量的值。这种限制的原因在于函数可以被传递并在不同的上下文中执行(例如,不同的线程),尝试同步这些上下文会破坏状态无关函数和表达式的独立分布式评估的原始想法。这就是为什么 lambda 表达式中使用的所有局部变量都是有效 final 的原因,这意味着它们可以明确声明为 final,也可以通过它们在 lambda 表达式中的使用变为 final。

这个限制有一个可能的解决方法。如果局部变量是引用类型(但不是 String 或原始包装类型),即使该局部变量用于 lambda 表达式中,也可以更改其状态:

class A {
  private int x;
  public int getX(){ return this.x; }
  public void setX(int x){ this.x = x; }
}
void localVariable2(){
  A a = new A();
  a.setX(7);
  a.setX(3);
  int y = 5;
  double z = 5.;
  supplyDecideProcessAndConsume(() -> a.getX(), d -> d < y, i -> i * z,
               d -> { a.setX(5);
    System.out.println("Result=" + d + " Great!"); } );
}

但是,只有在真正需要的情况下才应该使用这种解决方法,并且必须谨慎进行,因为存在意外副作用的危险。

关于 this 关键字的解释

匿名类和 lambda 表达式之间的一个主要区别是对this关键字的解释。在匿名类内部,它引用匿名类的实例。在 lambda 表达式内部,this引用包围表达式的类实例,也称为包围实例包围上下文包围范围

让我们编写一个演示区别的ThisDemo类:

class ThisDemo {
  private String field = "ThisDemo.field";
  public void useAnonymousClass() {
    Consumer<String> consumer = new Consumer<>() {
      private String field = "AnonymousClassConsumer.field";
      public void accept(String s) {
        System.out.println(this.field);
      }
    };
    consumer.accept(this.field);
  }
  public void useLambdaExpression() {
    Consumer<String> consumer = consumer = s -> {
      System.out.println(this.field);
    };
    consumer.accept(this.field);
  }

}

正如您所看到的,匿名类中的this指的是匿名类实例,而 lambda 表达式中的this指的是包围表达式的类实例。Lambda 表达式确实没有字段,也不能有字段。 如果执行前面的方法,输出将确认我们的假设:

ThisDemo d = new ThisDemo();
d.useAnonymousClass();   //prints: AnonymousClassConsumer.field
d.useLambdaExpression(); //prints: ThisDemo.field

Lambda 表达式不是类的实例,不能通过this引用。根据 Java 规范,这种方法通过将[this]与所在上下文中的相同方式来处理, 允许更多实现的灵活性

方法引用

让我们再看一下我们对supplyDecidePprocessAndConsume()方法的最后一个实现:

supplyDecideProcessAndConsume(() -> 7, d -> d < 5, i -> i * 5., 
                    d -> System.out.println("Result=" + d + " Great!")); 

我们使用的功能相当琐碎。在现实代码中,每个都可能需要多行实现。在这种情况下,将代码块内联会使代码几乎不可读。在这种情况下,引用具有必要实现的方法是有帮助的。让我们假设我们有以下的Helper类:

public class Helper {
  public double calculateResult(int i){
    // Maybe many lines of code here
    return i* 5;
  }
  public static void printResult(double d){
    // Maybe many lines of code here
    System.out.println("Result=" + d + " Great!");
  }
}

Lambdas类中的 lambda 表达式可以引用HelperLambdas类的方法,如下所示:

public class Lambdas {
  public void methodReference() {
    Supplier<Integer> input = () -> generateInput();
    Predicate<Integer> test = d -> checkValue(d);
    Function<Integer, Double> multiplyByFive = 
                                  i -> new Helper().calculateResult(i);
    Consumer<Double> printResult = d -> Helper.printResult(d);
    supplyDecideProcessAndConsume(input, test, 
                                           multiplyByFive, printResult);
  }
  private int generateInput(){
    // Maybe many lines of code here
    return 7;
  }
  private static boolean checkValue(double d){
    // Maybe many lines of code here
    return d < 5;
  }
}

前面的代码已经更易读了,函数还可以再次内联:

supplyDecideProcessAndConsume(() -> generateInput(), d -> checkValue(d), 
            i -> new Helper().calculateResult(i), Helper.printResult(d));

但在这种情况下,表示法可以做得更紧凑。当一个单行 lambda 表达式由对现有方法的引用组成时,可以通过使用不列出参数的方法引用进一步简化表示法。

方法引用的语法为Location::methodName,其中Location表示methodName方法所在的位置(对象或类),两个冒号(::)用作位置和方法名之间的分隔符。如果在指定位置有多个同名方法(因为方法重载的原因),则通过 lambda 表达式实现的函数接口抽象方法的签名来标识引用方法。

使用方法引用,Lambdas类中methodReference()方法下的前面代码可以重写为:

Supplier<Integer> input = this::generateInput;
Predicate<Integer> test = Lambdas::checkValue;
Function<Integer, Double> multiplyByFive = new Helper()::calculateResult;;
Consumer<Double> printResult = Helper::printResult;
supplyDecideProcessAndConsume(input, test, multiplyByFive, printResult);

内联这样的函数更有意义:

supplyDecideProcessAndConsume(this::generateInput, Lambdas::checkValue, 
                    new Helper()::calculateResult, Helper::printResult);

您可能已经注意到,我们有意地使用了不同的位置和两个实例方法以及两个静态方法,以展示各种可能性。

如果觉得记忆负担过重,好消息是现代 IDE(例如 IntelliJ IDEA)可以为您执行此操作,并将您正在编写的代码转换为最紧凑的形式。

练习 - 使用方法引用创建一个新对象

使用方法引用来表示创建一个新对象。假设我们有class A{}。用方法引用替换以下的Supplier函数声明,以另一个使用方法引用的声明替代:

Supplier<A> supplier = () -> new A();

答案

答案如下:

Supplier<A> supplier = A::new;

摘要

本章介绍了函数式编程的概念。它提供了 JDK 提供的函数式接口的概述,并演示了如何使用它们。它还讨论并演示了 lambda 表达式,以及它们如何有效地提高代码可读性。

下一章将使读者熟悉强大的数据流处理概念。它解释了什么是流,如何创建它们和处理它们的元素,以及如何构建处理流水线。它还展示了如何轻松地将流处理组织成并行处理。

第十八章:流和管道

在前一章描述和演示的 lambda 表达式以及功能接口中,为 Java 增加了强大的函数式编程能力。它允许将行为(函数)作为参数传递给针对数据处理性能进行优化的库。这样,应用程序员可以专注于开发系统的业务方面,将性能方面留给专家:库的作者。这样的一个库的例子是java.util.stream包,它将成为本章的重点。

我们将介绍数据流处理的概念,并解释流是什么,如何处理它们以及如何构建处理管道。我们还将展示如何轻松地组织并行流处理。

在本章中,将涵盖以下主题:

  • 什么是流?

  • 创建流

  • 中间操作

  • 终端操作

  • 流管道

  • 并行处理

  • 练习 - 将所有流元素相乘

什么是流?

理解流的最好方法是将其与集合进行比较。后者是存储在内存中的数据结构。在将元素添加到集合之前,会计算每个集合元素。相反,流发出的元素存在于其他地方(源)并且根据需要进行计算。因此,集合可以是流的源。

在 Java 中,流是java.util.stream包的StreamIntStreamLongStreamDoubleStream接口的对象。Stream接口中的所有方法也可以在IntStreamLongStreamDoubleStream专门的数值流接口中使用(相应类型更改)。一些数值流接口有一些额外的方法,例如average()sum(),专门用于数值。

在本章中,我们将主要讨论Stream接口及其方法。但是,所介绍的一切同样适用于数值流接口。在本章末尾,我们还将回顾一些在数值流接口中可用但在Stream接口中不可用的方法。

流代表一些数据源 - 例如集合、数组或文件 - 并且按顺序生成(产生、发出)一些值(与流相同类型的流元素),一旦先前发出的元素被处理。

java.util.stream包允许以声明方式呈现可以应用于发出元素的过程(函数),也可以并行进行。如今,随着机器学习对大规模数据处理的要求以及对操作的微调变得普遍,这一特性加强了 Java 在少数现代编程语言中的地位。

流操作

Stream接口的许多方法(具有函数接口类型作为参数的方法)被称为操作,因为它们不是作为传统方法实现的。它们的功能作为函数传递到方法中。方法本身只是调用分配为方法参数类型的函数接口的方法的外壳。

例如,让我们看一下Stream<T> filter (Predicate<T> predicate)方法。它的实现基于对Predicate<T>函数的boolean test(T)方法的调用。因此,程序员更喜欢说,“我们应用filter操作,允许一些流元素通过,跳过其他元素”,而不是说“我们使用Stream对象的filter()方法来选择一些流元素并跳过其他元素”。这听起来类似于说“我们应用加法操作”。它描述了动作(操作)的性质,而不是特定的算法,直到方法接收到特定函数为止。

因此,Stream接口中有三组方法:

  • 创建Stream对象的静态工厂方法。

  • 中间操作是返回Stream对象的实例方法。

  • 终端操作是返回Stream之外的某种类型的实例方法。

流处理通常以流畅(点连接)的方式组织(参见流管道部分)。Stream工厂方法或另一个流源开始这样的管道,终端操作产生管道结果或副作用,并结束管道(因此得名)。中间操作可以放置在原始Stream对象和终端操作之间。它处理流元素(或在某些情况下不处理),并返回修改的(或未修改的)Stream对象,以便应用下一个中间或终端操作。

中间操作的示例如下:

  • filter(): 这将选择与条件匹配的元素。

  • map(): 这将根据函数转换元素。

  • distinct(): 这将删除重复项。

  • limit(): 这将限制流的元素数量。

  • sorted(): 这将把未排序的流转换为排序的流。

还有一些其他方法,我们将在中间操作部分讨论。

流元素的处理实际上只有在开始执行终端操作时才开始。然后,所有中间操作(如果存在)开始处理。流在终端操作完成执行后关闭(并且无法重新打开)。终端操作的示例包括forEach()findFirst()reduce()collect()sum()max()Stream接口的其他不返回Stream的方法。我们将在终端操作部分讨论它们。

所有的 Stream 方法都支持并行处理,这在多核计算机上处理大量数据时特别有帮助。必须确保处理管道不使用可以在不同处理环境中变化的上下文状态。我们将在并行处理部分讨论这一点。

创建流

有许多创建流的方法——Stream类型的对象或任何数字接口。我们已经按照创建 Stream 对象的方法所属的类和接口对它们进行了分组。我们之所以这样做是为了方便读者,提供更好的概览,这样读者在需要时更容易找到它们。

流接口

这组Stream工厂由属于Stream接口的静态方法组成。

empty(), of(T t), ofNullable(T t)

以下三种方法创建空的或单个元素的Stream对象:

  • Stream<T> empty(): 创建一个空的顺序Stream对象。

  • Stream<T> of(T t): 创建一个顺序的单个元素Stream对象。

  • Stream<T> ofNullable(T t): 如果t参数非空,则创建一个包含单个元素的顺序Stream对象;否则,创建一个空的 Stream。

以下代码演示了前面方法的用法:

Stream.empty().forEach(System.out::println);    //prints nothing
Stream.of(1).forEach(System.out::println);      //prints: 1

List<String> list = List.of("1 ", "2");
//printList1(null);                             //NullPointerException
printList1(list);                               //prints: 1 2

void printList1(List<String> list){
    list.stream().forEach(System.out::print);;
}

注意,当列表不为空时,第一次调用printList1()方法会生成NullPointerException并打印1 2。为了避免异常,我们可以将printList1()方法实现如下:

void printList1(List<String> list){
     (list == null ? Stream.empty() : list.stream())
                                         .forEach(System.out::print);
}

相反,我们使用了ofNullable(T t)方法,如下面的printList2()方法的实现所示:

printList2(null);                                //prints nothing
printList2(list);                                //prints: [1 , 2]

void printList2(List<String> list){
      Stream.ofNullable(list).forEach(System.out::print);
}

这就是激发ofNullable(T t)方法创建的用例。但是您可能已经注意到,ofNullable()创建的流将列表作为一个对象发出:它被打印为[1 , 2]

在这种情况下处理列表的每个元素,我们需要添加一个中间的Stream操作flatMap(),将每个元素转换为Stream对象:

Stream.ofNullable(list).flatMap(e -> e.stream())
                       .forEach(System.out::print);      //prints: 1 2

我们将在Intermediate operations部分进一步讨论flatMap()方法。

在前面的代码中传递给flatMap()操作的函数也可以表示为方法引用:

Stream.ofNullable(list).flatMap(Collection::stream)
                       .forEach(System.out::print);      //prints: 1 2

iterate(Object, UnaryOperator)

Stream接口的两个静态方法允许我们使用类似传统for循环的迭代过程生成值流:

  • Stream<T> iterate(T seed, UnaryOperator<T> func): 根据第一个seed参数的迭代应用第二个参数(func函数)创建一个无限顺序Stream对象,生成seedf(seed)f(f(seed))值的流。

  • Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next): 根据第三个参数(next函数)对第一个seed参数的迭代应用,生成seedf(seed)f(f(seed))值的有限顺序Stream对象,只要第三个参数(hasNext函数)返回true

以下代码演示了这些方法的用法:

Stream.iterate(1, i -> ++i).limit(9)
        .forEach(System.out::print);        //prints: 123456789

Stream.iterate(1, i -> i < 10, i -> ++i)
        .forEach(System.out::print);        //prints: 123456789

请注意,我们被迫在第一个管道中添加一个limit()中间操作,以避免生成无限数量的值。

concat(Stream a, Stream b)

Stream<T> concatenate (Stream<> aStream<T> b) Stream 接口的静态方法基于传递的两个Stream对象ab创建一个值流。新创建的流由第一个参数a的所有元素组成,后跟第二个参数b的所有元素。以下代码演示了Stream对象创建的这种方法:

Stream<Integer> stream1 = List.of(1, 2).stream();
Stream<Integer> stream2 = List.of(2, 3).stream();

Stream.concat(stream1, stream2)
        .forEach(System.out::print);        //prints: 1223

请注意,原始流中存在2元素,并且因此在生成的流中出现两次。

generate(Supplier)

Stream<T> generate(Supplier<T> supplier) Stream 接口的静态方法创建一个无限流,其中每个元素由提供的Supplier<T>函数生成。以下是两个示例:

Stream.generate(() -> 1).limit(5)
        .forEach(System.out::print);       //prints: 11111

Stream.generate(() -> new Random().nextDouble()).limit(5)
        .forEach(System.out::println);     //prints: 0.38575117472619247
                                           //        0.5055765386778835
                                           //        0.6528038976983277
                                           //        0.4422354489467244
                                           //        0.06770955839148762

由于流是无限的,我们已经添加了limit()操作。

of(T... values)

Stream<T> of(T... values) 方法接受可变参数或值数组,并使用提供的值作为流元素创建Stream对象:

    Stream.of("1 ", 2).forEach(System.out::print);      //prints: 1 2
    //Stream<String> stringStream = Stream.of("1 ", 2); //compile error

    String[] strings = {"1 ", "2"};
    Stream.of(strings).forEach(System.out::print);      //prints: 1 2

请注意,在上述代码的第一行中,如果在Stream引用声明的泛型中没有指定类型,则Stream对象将接受不同类型的元素。在下一行中,泛型将Stream对象的类型定义为String,相同的元素类型混合会生成编译错误。泛型绝对有助于程序员避免许多错误,并且应该在可能的地方使用。

of(T... values)方法也可用于连接多个流。例如,假设我们有以下四个流,并且我们想要将它们连接成一个:

Stream<Integer> stream1 = Stream.of(1, 2);
Stream<Integer> stream2 = Stream.of(2, 3);
Stream<Integer> stream3 = Stream.of(3, 4);
Stream<Integer> stream4 = Stream.of(4, 5);

我们期望新流发出值12233445。首先,我们尝试以下代码:

Stream.of(stream1, stream2, stream3, stream4)
     .forEach(System.out::print); 
           //prints: java.util.stream.ReferencePipeline$Head@58ceff1j

上述代码并没有达到我们的期望。它将每个流都视为java.util.stream.ReferencePipeline内部类的对象,该内部类用于Stream接口实现。因此,我们添加了一个flatMap()操作,将每个流元素转换为流(我们将在中间操作部分中描述它):

Stream.of(stream1, stream2, stream3, stream4)
     .flatMap(e -> e).forEach(System.out::print);   //prints: 12233445
```java

我们将作为参数传递给`flatMap()`的函数(`e -> e`)可能看起来好像什么都没做,但这是因为流的每个元素已经是一个流,所以我们不需要对其进行转换。通过将元素作为`flatMap()`操作的结果返回,我们已经告诉管道将其视为`Stream`对象。已经完成了这一点,并且显示了预期的结果。

# Stream.Builder 接口

`Stream.Builder<T> builder()`静态方法返回一个内部(位于`Stream`接口中的)`Builder`接口,可用于构造`Stream`对象。`Builder`接口扩展了`Consumer`接口,并具有以下方法:

+   `void accept(T t)`: 将元素添加到流中(此方法来自`Consumer`接口)。

+   `default Stream.Builder<T> add(T t)`: 调用`accept(T)`方法并返回`this`,从而允许以流畅的点连接样式链接`add(T)`方法。

+   `Stream<T> build()`: 将此构建器从构造状态转换为构建状态。调用此方法后,无法向流中添加新元素。

使用`add()`方法很简单:

```java
Stream.<String>builder().add("cat").add(" dog").add(" bear")
        .build().forEach(System.out::print);  //prints: cat dog bear

只需注意我们在builder()方法前面添加的<String>泛型。这样,我们告诉构建器我们正在创建的流将具有String类型的元素。否则,它将将它们添加为Object类型。

当构建器作为Consumer对象传递时,或者不需要链接添加元素的方法时,使用accept()方法。例如,以下是构建器作为Consumer对象传递的方式:

Stream.Builder<String> builder = Stream.builder();
List.of("1", "2", "3").stream().forEach(builder);
builder.build().forEach(System.out::print);        //prints: 123

还有一些情况不需要在添加流元素时链接方法。以下方法接收String对象的列表,并将其中一些对象(包含字符a的对象)添加到流中:

Stream<String> buildStream(List<String> values){
    Stream.Builder<String> builder = Stream.builder();
    for(String s: values){
        if(s.contains("a")){
            builder.accept(s);
        }
    }
    return builder.build();
}

请注意,出于同样的原因,我们为Stream.Builder接口添加了<String>泛型,告诉构建器我们添加的元素应该被视为String类型。

当调用前面的方法时,它会产生预期的结果:

List<String> list = List.of("cat", " dog", " bear");
buildStream(list).forEach(System.out::print);        //prints: cat bear

其他类和接口

在 Java 8 中,java.util.Collection接口添加了两个默认方法:

  • Stream<E> stream(): 返回此集合的元素流。

  • Stream<E> parallelStream(): 返回(可能)此集合元素的并行流。这里的可能是因为 JVM 会尝试将流分成几个块并并行处理它们(如果有几个 CPU)或虚拟并行处理(使用 CPU 的时间共享)。这并非总是可能的;这在一定程度上取决于所请求处理的性质。

这意味着扩展此接口的所有集合接口,包括SetList,都有这些方法。这是一个例子:

List<Integer> list = List.of(1, 2, 3, 4, 5);
list.stream().forEach(System.out::print);    //prints: 12345

我们将在并行处理部分进一步讨论并行流。

java.util.Arrays类还添加了八个静态重载的stream()方法。它们从相应的数组或其子集创建不同类型的流:

  • Stream<T> stream(T[] array): 从提供的数组创建Stream

  • IntStream stream(int[] array): 从提供的数组创建IntStream

  • LongStream stream(long[] array): 从提供的数组创建LongStream

  • DoubleStream stream(double[] array): 从提供的数组创建DoubleStream

  • Stream<T> stream(T[] array, int startInclusive, int endExclusive): 从提供的数组的指定范围创建Stream

  • IntStream stream(int[] array, int startInclusive, int endExclusive): 从提供的数组的指定范围创建IntStream

  • LongStream stream(long[] array, int startInclusive, int endExclusive): 从提供的数组的指定范围创建LongStream

  • DoubleStream stream(double[] array, int startInclusive, int endExclusive): 从提供的数组的指定范围创建DoubleStream

这是一个从数组的子集创建流的示例:

int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr, 2, 4).forEach(System.out::print);    //prints: 34

请注意,我们使用了Stream<T> stream(T[] array, int startInclusive, int endExclusive)方法,这意味着我们创建了Stream而不是IntStream,尽管创建的流中的所有元素都是整数,就像IntStream一样。不同之处在于,IntStream提供了一些数字特定的操作,而Stream中没有(请参阅数字流接口部分)。

java.util.Random类允许我们创建伪随机值的数字流:

  • IntStream ints()LongStream longs(): 创建相应类型的无限伪随机值流。

  • DoubleStream doubles(): 创建一个无限流的伪随机双精度值,每个值都介于零(包括)和一(不包括)之间。

  • IntStream ints(long streamSize)LongStream longs(long streamSize): 创建指定数量的相应类型的伪随机值流。

  • DoubleStream doubles(long streamSize): 创建指定数量的伪随机双精度值流,每个值都介于零(包括)和一(不包括)之间。

  • IntStream ints(int randomNumberOrigin, int randomNumberBound), LongStream longs(long randomNumberOrigin, long randomNumberBound), 和 DoubleStream doubles(long streamSize, double randomNumberOrigin, double randomNumberBound): 创建一个无限流,包含对应类型的伪随机值,每个值大于或等于第一个参数,小于第二个参数。

以下是前述方法的示例之一:

new Random().ints(5, 8)
            .limit(5)
            .forEach(System.out::print);    //prints: 56757

java.nio.File类有六个静态方法,用于创建行和路径流:

  • Stream<String> lines(Path path): 从提供的路径指定的文件创建一行流。

  • Stream<String> lines(Path path, Charset cs): 从提供的路径指定的文件创建一行流。使用提供的字符集将文件的字节解码为字符。

  • Stream<Path> list(Path dir): 创建指定目录中的条目流。

  • Stream<Path> walk(Path start, FileVisitOption... options): 创建以给定起始文件为根的文件树条目流。

  • Stream<Path> walk(Path start, int maxDepth, FileVisitOption... options): 创建以给定起始文件为根的文件树条目流,到指定深度。

  • Stream<Path> find(Path start, int maxDepth, BiPredicate<Path, BasicFileAttributes> matcher, FileVisitOption... options): 创建以给定起始文件为根的文件树条目流,到指定深度匹配提供的谓词。

其他创建流的类和方法包括:

  • IntStream stream() of the java.util.BitSet class: 创建一个索引流,其中BitSet包含设置状态的位。

  • Stream<String> lines() of the java.io.BufferedReader class: 创建从BufferedReader对象读取的行流,通常来自文件。

  • Stream<JarEntry> stream() of the java.util.jar.JarFile class: 创建 ZIP 文件条目的流。

  • IntStream chars() of the java.lang.CharSequence interface: 从此序列创建int类型的流,零扩展char值。

  • IntStream codePoints() of the java.lang.CharSequence interface: 从此序列创建代码点值的流。

  • Stream<String> splitAsStream(CharSequence input) of the java.util.regex.Pattern class: 创建一个围绕此模式匹配的提供序列的流。

还有java.util.stream.StreamSupport类,其中包含库开发人员的静态低级实用方法。这超出了本书的范围。

中间操作

我们已经看到了如何创建代表源并发出元素的Stream对象。正如我们已经提到的,Stream接口提供的操作(方法)可以分为三组:

  • 基于源创建Stream对象的方法。

  • 接受函数并生成发出相同或修改的值的Stream对象的中间操作。

  • 终端操作完成流处理,关闭它并生成结果。

在本节中,我们将回顾中间操作,这些操作可以根据其功能进行分组。

过滤

此组包括删除重复项、跳过一些元素和限制处理元素数量的操作,仅选择所需的元素:

  • Stream<T> distinct(): 使用Object.equals(Object)方法比较流元素,并跳过重复项。

  • Stream<T> skip(long n): 忽略前面提供的数量的流元素。

  • Stream<T> limit(long maxSize): 仅允许处理提供的流元素数量。

  • Stream<T> filter(Predicate<T> predicate): 仅允许通过提供的Predicate函数处理的结果为true的元素。

  • 默认Stream<T> dropWhile(Predicate<T> predicate): 跳过流的第一个元素,该元素在通过提供的Predicate函数处理时结果为true

  • 默认Stream<T> takeWhile(Predicate<T> predicate): 仅允许流的第一个元素在通过提供的Predicate函数处理时结果为true

以下代码演示了前面的操作是如何工作的:

Stream.of("3", "2", "3", "4", "2").distinct()
                            .forEach(System.out::print);  //prints: 324
List<String> list = List.of("1", "2", "3", "4", "5");
list.stream().skip(3).forEach(System.out::print);         //prints: 45
list.stream().limit(3).forEach(System.out::print);        //prints: 123
list.stream().filter(s -> Objects.equals(s, "2"))
                            .forEach(System.out::print);  //prints: 2
list.stream().dropWhile(s -> Integer.valueOf(s) < 3)
                            .forEach(System.out::print);  //prints: 345
list.stream().takeWhile(s -> Integer.valueOf(s) < 3)
                            .forEach(System.out::print);  //prints: 12

请注意,我们能够重用List<String>源对象,但无法重用Stream对象。一旦关闭,就无法重新打开。

Mapping

这组包括可能是最重要的中间操作。它们是唯一修改流元素的中间操作。它们map(转换)原始流元素值为新值:

  • Stream<R> map(Function<T, R> mapper): 将提供的函数应用于此流的T类型的每个元素,并生成R类型的新元素值。

  • IntStream mapToInt(ToIntFunction<T> mapper): 将此流转换为Integer值的IntStream

  • LongStream mapToLong(ToLongFunction<T> mapper): 将此流转换为Long值的LongStream

  • DoubleStream mapToDouble(ToDoubleFunction<T> mapper): 将此流转换为Double值的DoubleStream

  • Stream<R> flatMap(Function<T, Stream<R>> mapper): 将提供的函数应用于此流的T类型的每个元素,并生成一个发出R类型元素的Stream<R>对象。

  • IntStream flatMapToInt(Function<T, IntStream> mapper): 使用提供的函数将T类型的每个元素转换为Integer值流。

  • LongStream flatMapToLong(Function<T, LongStream> mapper): 使用提供的函数将T类型的每个元素转换为Long值流。

  • DoubleStream flatMapToDouble(Function<T, DoubleStream> mapper): 使用提供的函数将T类型的每个元素转换为Double值流。

以下是这些操作的用法示例:

List<String> list = List.of("1", "2", "3", "4", "5");
list.stream().map(s -> s + s)
             .forEach(System.out::print);        //prints: 1122334455
list.stream().mapToInt(Integer::valueOf)
             .forEach(System.out::print);             //prints: 12345
list.stream().mapToLong(Long::valueOf)
             .forEach(System.out::print);             //prints: 12345
list.stream().mapToDouble(Double::valueOf)
             .mapToObj(Double::toString)
             .map(s -> s + " ")
             .forEach(System.out::print);//prints: 1.0 2.0 3.0 4.0 5.0 
list.stream().mapToInt(Integer::valueOf)
             .flatMap(n -> IntStream.iterate(1, i -> i < n, i -> ++i))
             .forEach(System.out::print);        //prints: 1121231234
list.stream().map(Integer::valueOf)
             .flatMapToInt(n -> 
                           IntStream.iterate(1, i -> i < n, i -> ++i))
             .forEach(System.out::print);        //prints: 1121231234
list.stream().map(Integer::valueOf)
             .flatMapToLong(n ->  
                          LongStream.iterate(1, i -> i < n, i -> ++i))
             .forEach(System.out::print);        //prints: 1121231234;
list.stream().map(Integer::valueOf)
             .flatMapToDouble(n -> 
                        DoubleStream.iterate(1, i -> i < n, i -> ++i))
             .mapToObj(Double::toString)
             .map(s -> s + " ")
             .forEach(System.out::print);  
                    //prints: 1.0 1.0 2.0 1.0 2.0 3.0 1.0 2.0 3.0 4.0 

在前面的示例中,对于Double值,我们将数值转换为String,并添加空格,因此结果将以空格分隔的形式打印出来。这些示例非常简单——只是进行最小处理的转换。但是在现实生活中,每个mapflatMap操作都可以接受一个(任何复杂程度的函数)来执行真正有用的操作。

排序

以下两个中间操作对流元素进行排序。自然地,这样的操作直到所有元素都被发射完毕才能完成,因此会产生大量的开销,降低性能,并且必须用于小型流:

  • Stream<T> sorted(): 按照它们的Comparable接口实现的自然顺序对流元素进行排序。

  • Stream<T> sorted(Comparator<T> comparator): 按照提供的Comparator<T>对象的顺序对流元素进行排序。

以下是演示代码:

List<String> list = List.of("2", "1", "5", "4", "3");
list.stream().sorted().forEach(System.out::print);  //prints: 12345
list.stream().sorted(Comparator.reverseOrder())
             .forEach(System.out::print);           //prints: 54321

Peeking

Stream<T> peek(Consumer<T> action)中间操作将提供的Consumer函数应用于每个流元素,并且不更改此Stream(返回它接收到的相同元素值),因为Consumer函数返回void,并且不能影响值。此操作用于调试。

以下代码显示了它的工作原理:

List<String> list = List.of("1", "2", "3", "4", "5");
list.stream().peek(s-> {
    if("3".equals(s)){
        System.out.print(3);
    }
}).forEach(System.out::print);  //prints: 123345

终端操作

终端操作是流管道中最重要的操作。不需要任何其他操作就可以轻松完成所有操作。我们已经使用了forEach(Consumer<T>)终端操作来打印每个元素。它不返回值;因此,它用于其副作用。但是Stream接口还有许多更强大的终端操作,它们会返回值。其中最重要的是collect()操作,它有两种形式,R collect(Collector<T, A, R> collector)R collect(Supplier<R> supplier, BiConsumer<R, T> accumulator, BiConsumer<R, R> combiner)。这些允许我们组合几乎可以应用于流的任何过程。经典示例如下:

List<String> asList = stringStream.collect(ArrayList::new, 
                                           ArrayList::add, 
                                           ArrayList::addAll);

如您所见,它是为并行处理而实现的。它使用第一个函数基于流元素生成值,使用第二个函数累积结果,然后结合处理流的所有线程累积的结果。

然而,只有一个这样的通用终端操作会迫使程序员重复编写相同的函数。这就是为什么 API 作者添加了Collectors类,它可以生成许多专门的Collector对象,而无需为每个collect()操作创建三个函数。除此之外,API 作者还添加了更多专门的终端操作,这些操作更简单,更容易使用Stream接口。

在本节中,我们将回顾Stream接口的所有终端操作,并在Collecting子部分中查看Collectors类生成的大量Collector对象的种类。

我们将从最简单的终端操作开始,它允许逐个处理流的每个元素。

处理每个元素

这个组中有两个终端操作:

  • void forEach(Consumer<T> action): 对流的每个元素应用提供的操作(处理)。

  • void forEachOrdered(Consumer<T> action): 对流的每个元素应用提供的操作(处理),其顺序由源定义,无论流是顺序的还是并行的。

如果您的应用程序对需要处理的元素的顺序很重要,并且必须按照源中值的排列顺序进行处理,那么使用第二种方法是很重要的,特别是如果您可以预见到您的代码将在具有多个 CPU 的计算机上执行。否则,使用第一种方法,就像我们在所有的例子中所做的那样。

这种操作被用于任何类型的流处理是很常见的,特别是当代码是由经验不足的程序员编写时。对于下面的例子,我们创建了Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.name = name;
        this.age = age;
    }
    public String getName() { return this.name; }
    public int getAge() {return this.age; }
    @Override
    public String toString() {
        return "Person{" + "name='" + this.name + "'" +
                         ", age=" + age + "}";
    }
}

我们将在终端操作的讨论中使用这个类。在这个例子中,我们将从文件中读取逗号分隔的值(年龄和姓名),并创建Person对象。我们已经将以下persons.csv文件(逗号分隔值(CSV))放在resources文件夹中:

 23 , Ji m
 2 5 , Bob
15 , Jill
 17 , Bi ll

请注意我们在值的外部和内部添加的空格。我们这样做是为了借此机会向您展示一些简单但非常有用的处理现实数据的技巧。以下是一个经验不足的程序员可能编写的代码,用于读取此文件并创建Person对象列表:

List<Person> persons = new ArrayList<>();
Path path = Paths.get("src/main/resources/persons.csv");
try (Stream<String> lines = Files.newBufferedReader(path).lines()) {
    lines.forEach(s -> {
        String[] arr = s.split(",");
        int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
        persons.add(new Person(age, StringUtils.remove(arr[1], ' ')));
    });
} catch (IOException ex) {
    ex.printStackTrace();
}
persons.stream().forEach(System.out::println);  
                                 //prints: Person{name='Jim', age=23}
                                 //        Person{name='Bob', age=25}
                                 //        Person{name='Jill', age=15}
                                 //        Person{name='Bill', age=17}

您可以看到我们使用了String方法split(),通过逗号分隔每一行的值,并且我们使用了org.apache.commons.lang3.StringUtils类来移除每个值中的空格。前面的代码还提供了try-with-resources结构的真实示例,用于自动关闭BufferedReader对象。

尽管这段代码在小例子和单核计算机上运行良好,但在长流和并行处理中可能会产生意外的结果。也就是说,lambda 表达式要求所有变量都是 final 的,或者有效地是 final 的,因为相同的函数可以在不同的上下文中执行。

相比之下,这是前面代码的正确实现:

List<Person> persons = new ArrayList<>();
Path path = Paths.get("src/main/resources/persons.csv");
try (Stream<String> lines = Files.newBufferedReader(path).lines()) {
    persons = lines.map(s -> s.split(","))
       .map(arr -> {
          int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
          return new Person(age, StringUtils.remove(arr[1], ' '));
       }).collect(Collectors.toList());
} catch (IOException ex) {
    ex.printStackTrace();
}
persons.stream().forEach(System.out::println);

为了提高可读性,可以创建一个执行映射工作的方法:

public List<Person> createPersons() {
   List<Person> persons = new ArrayList<>();
   Path path = Paths.get("src/main/resources/persons.csv");
   try (Stream<String> lines = Files.newBufferedReader(path).lines()) {
        persons = lines.map(s -> s.split(","))
                .map(this::createPerson)
                .collect(Collectors.toList());
   } catch (IOException ex) {
        ex.printStackTrace();
   }
   return persons;
}
private Person createPerson(String[] arr){
    int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
    return new Person(age, StringUtils.remove(arr[1], ' '));
}

正如你所看到的,我们使用了collect()操作和Collectors.toList()方法创建的Collector函数。我们将在Collect子部分中看到更多由Collectors类创建的Collector函数。

计算所有元素

long count()终端操作的Stream接口看起来很简单,也很温和。它返回这个流中的元素数量。习惯于使用集合和数组的人可能会毫不犹豫地使用count()操作。下面是一个例子,证明它可以正常工作:

long count = Stream.of("1", "2", "3", "4", "5")
        .peek(System.out::print)
        .count();
System.out.print(count);                 //prints: 5

正如你所看到的,实现计数方法的代码能够确定流的大小,而不需要执行整个管道。元素的值并没有被peek()操作打印出来,这证明元素并没有被发出。但是并不总是能够在源头确定流的大小。此外,流可能是无限的。因此,必须谨慎使用count()

既然我们正在讨论计算元素的话,我们想展示另一种可能的确定流大小的方法,使用collect()操作:

int count = Stream.of("1", "2", "3", "4", "5")
        .peek(System.out::print)         //prints: 12345
        .collect(Collectors.counting());
System.out.println(count);                //prints: 5

你可以看到collect()操作的实现甚至没有尝试在源头计算流的大小(因为,正如你所看到的,管道已经完全执行,并且每个元素都被peek()操作打印出来)。这是因为collect()操作不像count()操作那样专门化。它只是将传入的收集器应用于流,而收集器则计算由collect()操作提供给它的元素。你可以将这看作是官僚近视的一个例子:每个操作符都按预期工作,但整体性能仍然有所欠缺。

匹配所有、任意或没有

有三个(看起来非常相似的)终端操作,允许我们评估流中的所有、任意或没有元素是否具有特定值:

  • boolean allMatch(Predicate<T> predicate): 当流中的每个元素返回true时,作为提供的Predicate<T>函数的参数时返回true

  • boolean anyMatch(Predicate<T> predicate): 当流中的一个元素返回true时,作为提供的Predicate<T>函数的参数时返回true

  • boolean noneMatch(Predicate<T> predicate): 当流中没有元素返回true时,作为提供的Predicate<T>函数的参数时返回true

以下是它们的使用示例:

List<String> list = List.of("1", "2", "3", "4", "5");
boolean found = list.stream()
        .peek(System.out::print)          //prints: 123
        .anyMatch(e -> "3".equals(e));
System.out.print(found);                  //prints: true   <= line 5
found = list.stream()
        .peek(System.out::print)          //prints: 12345
        .anyMatch(e -> "0".equals(e));
System.out.print(found);                  //prints: false  
boolean noneMatches = list.stream()       
        .peek(System.out::print)          //prints: 123
        .noneMatch(e -> "3".equals(e));
System.out.print(noneMatches);            //prints: false
noneMatches = list.stream()
        .peek(System.out::print)          //prints: 12345
        .noneMatch(e -> "0".equals(e));
System.out.print(noneMatches);            //prints: true  <= line 17
boolean allMatch = list.stream()          
        .peek(System.out::print)          //prints: 1
        .allMatch(e -> "3".equals(e));
System.out.print(allMatch);               //prints: false

让我们更仔细地看一下前面示例的结果。这些操作中的每一个都触发了流管道的执行,每次至少处理流的一个元素。但是看看anyMatch()noneMatch()操作。第 5 行说明至少有一个元素等于3。结果是在处理了前三个元素之后返回的。第 17 行说明在处理了流的所有元素之后,没有元素等于0

问题是,当您想要知道流不包含v值时,这两个操作中的哪一个应该使用?如果使用noneMatch()所有元素都将被处理。但是如果使用anyMatch(),只有在流中没有v时,所有元素才会被处理。似乎noneMatch()操作是无用的,因为当anyMatch()返回true时,它的含义与noneMatch()返回false相同,而anyMatch()操作只需处理更少的元素即可实现。随着流大小的增长和存在v值的机会增加,这种差异变得更加重要。似乎noneMatch()操作的唯一原因是代码可读性,当处理时间不重要时,因为流大小很小。

allMatch()操作没有替代方案,与anyMatch()类似,当遇到第一个不匹配的元素时返回,或者需要处理所有流元素。

查找任何或第一个

以下终端操作允许我们找到流的任何元素或第一个元素:

  • Optional<T> findAny(): 返回流的任何元素的值的Optional,如果流为空,则返回一个空的Optional

  • Optional<T> findFirst(): 返回流的第一个元素的值的Optional,如果流为空,则返回一个空的Optional

以下示例说明了这些操作:

List<String> list = List.of("1", "2", "3", "4", "5");

Optional<String> result = list.stream().findAny();
System.out.println(result.isPresent());    //prints: true
System.out.println(result.get());          //prints: 1

result = list.stream().filter(e -> "42".equals(e)).findAny();
System.out.println(result.isPresent());    //prints: true
//System.out.println(result.get());        //NoSuchElementException

result = list.stream().findFirst();
System.out.println(result.isPresent());    //prints: true
System.out.println(result.get());          //prints: 1

如您所见,它们返回相同的结果。这是因为我们在单个线程中执行管道。这两个操作之间的差异在并行处理中更加显著。当流被分成几个部分进行并行处理时,如果流不为空,findFirst()操作总是返回流的第一个元素,而findAny()操作只在一个处理线程中返回第一个元素。

让我们更详细地讨论java.util.Optional类。

Optional 类

java.util.Optional对象用于避免返回null,因为它可能会导致NullPointerException。相反,Optional对象提供了可以用来检查值是否存在并在没有值的情况下替换它的方法。例如:

List<String> list = List.of("1", "2", "3", "4", "5");

String result = list.stream().filter(e -> "42".equals(e))
       .findAny().or(() -> Optional.of("Not found")).get();
System.out.println(result);                       //prints: Not found

result = list.stream().filter(e -> "42".equals(e))
                            .findAny().orElse("Not found");
System.out.println(result);                        //prints: Not found

Supplier<String> trySomethingElse = () -> {
    //Code that tries something else
    return "43";
};
result = list.stream().filter(e -> "42".equals(e))
                   .findAny().orElseGet(trySomethingElse);
System.out.println(result);                          //prints: 43

list.stream().filter(e -> "42".equals(e))
    .findAny().ifPresentOrElse(System.out::println, 
            () -> System.out.println("Not found"));  //prints: Not found

如您所见,如果Optional对象为空,则:

  • Optional类的or()方法允许返回另一个带有值的Optional对象。

  • orElse()方法允许返回一种替代值。

  • orElseGet()方法允许提供Supplier函数,该函数返回一种替代值。

  • ifPresentOrElse()方法允许提供两个函数:一个从Optional对象中消费值,另一个在Optional对象为空时执行某些操作。

最小值和最大值

以下终端操作如果存在则返回流元素的最小或最大值:

  • Optional<T> min(Comparator comparator):使用提供的 Comparator 对象返回此流的最小元素。

  • Optional<T> max(Comparator comparator):使用提供的 Comparator 对象返回此流的最大元素。

下面是演示代码:

List<String> list = List.of("a", "b", "c", "c", "a");
String min = list.stream().min(Comparator.naturalOrder()).orElse("0");
System.out.println(min);     //prints: a

String max = list.stream().max(Comparator.naturalOrder()).orElse("0");
System.out.println(max);     //prints: c

如您所见,在非数值值的情况下,最小元素是从左到右排序时的第一个元素,根据提供的比较器;相应地,最大值是最后一个元素。在数值值的情况下,最小值和最大值就是流元素中的最大数和最小数:

int mn = Stream.of(42, 33, 77).min(Comparator.naturalOrder()).orElse(0);
System.out.println(mn);    //prints: 33
int mx = Stream.of(42, 33, 77).max(Comparator.naturalOrder()).orElse(0);
System.out.println(mx);    //prints: 77

让我们看另一个例子,假设有一个Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age + "}";
    }
}

任务是在以下列表中找到最年长的人:

List<Person> persons = List.of(new Person(23, "Bob"),
                               new Person(33, "Jim"),
                               new Person(28, "Jill"),
                               new Person(27, "Bill"));

为了做到这一点,我们可以创建以下的Compartor<Person>

Comparator<Person> perComp = (p1, p2) -> p1.getAge() - p2.getAge();

然后,使用这个比较器,我们可以找到最年长的人:

Person theOldest = persons.stream().max(perComp).orElse(null);
System.out.println(theOldest);  //prints: Person{name:Jim,age:33}

toArray()操作

这两个终端操作生成一个包含流元素的数组:

  • Object[] toArray():创建一个包含该流每个元素的对象数组。

  • A[] toArray(IntFunction<A[]> generator): 使用提供的函数创建流元素的数组。

让我们看一个例子:

List<String> list = List.of("a", "b", "c");
Object[] obj = list.stream().toArray();
Arrays.stream(obj).forEach(System.out::print);    //prints: abc

String[] str = list.stream().toArray(String[]::new);
Arrays.stream(str).forEach(System.out::print);    //prints: abc

第一个例子很直接。它将元素转换为相同类型的数组。至于第二个例子,IntFunction作为String[]::new的表示可能不够明显,所以让我们逐步来看一下。

String[]::new是一个方法引用,代表以下 lambda 表达式:

String[] str = list.stream().toArray(i -> new String[i]);
Arrays.stream(str).forEach(System.out::print);    //prints: abc

这已经是IntFunction<String[]>,根据其文档,它接受一个int参数并返回指定类型的结果。可以通过使用匿名类来定义,如下所示:

IntFunction<String[]> intFunction = new IntFunction<String[]>() {
    @Override
    public String[] apply(int i) {
        return new String[i];
    }
};

您可能还记得(来自第十三章,Java 集合)我们如何将集合转换为数组:

str = list.toArray(new String[list.size()]);
Arrays.stream(str).forEach(System.out::print);    //prints: abc

您可以看到Stream接口的toArray()操作具有非常相似的签名,只是它接受一个函数,而不仅仅是一个数组。

reduce 操作

这个终端操作被称为reduce,因为它处理所有流元素并产生一个值。它将所有流元素减少为一个值。但这不是唯一的操作。collect操作也将流元素的所有值减少为一个结果。而且,在某种程度上,所有终端操作都会减少。它们在处理所有元素后产生一个值。

因此,您可以将reducecollect视为帮助为Stream接口中提供的许多操作添加结构和分类的同义词。此外,reduce组中的操作可以被视为collect操作的专门版本,因为collect()也可以被定制以提供相同的功能。

有了这个,让我们看看reduce操作组:

  • Optional<T> reduce(BinaryOperator<T> accumulator): 使用提供的定义元素聚合逻辑的可关联函数来减少此流的元素。如果可用,返回带有减少值的Optional

  • T reduce(T identity, BinaryOperator<T> accumulator): 提供与先前reduce()版本相同的功能,但使用identity参数作为累加器的初始值,或者如果流为空则使用默认值。

  • U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner): 提供与先前reduce()版本相同的功能,但另外使用combiner函数在应用于并行流时聚合结果。如果流不是并行的,则不使用组合器函数。

为了演示reduce()操作,我们将使用之前的Person类:

class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int getAge() { return this.age; }
    public String getName() { return this.name; }
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + this.age + "}";
    }
}

我们还将使用相同的Person对象列表作为我们流示例的来源:

List<Person> list = List.of(new Person(23, "Bob"),
                            new Person(33, "Jim"),
                            new Person(28, "Jill"),
                            new Person(27, "Bill"));

现在,使用reduce()操作,让我们找到此列表中年龄最大的人:

Person theOldest = list.stream()
  .reduce((p1, p2) -> p1.getAge() > p2.getAge() ? p1 : p2).orElse(null);
System.out.println(theOldest);         //prints: Person{name:Jim,age:33}

这个实现有点令人惊讶,不是吗?我们在谈论“累加器”,但我们没有累加任何东西。我们只是比较了所有的流元素。显然,累加器保存了比较的结果,并将其作为下一个比较(与下一个元素)的第一个参数提供。可以说,在这种情况下,累加器累积了所有先前比较的结果。无论如何,它完成了我们希望它完成的工作。

现在,让我们明确地累积一些东西。让我们将人员名单中的所有名称组合成一个逗号分隔的列表:

String allNames = list.stream().map(p->p.getName())
                      .reduce((n1, n2) -> n1 + ", " + n2).orElse(null);
System.out.println(allNames);            //prints: Bob, Jim, Jill, Bill

在这种情况下,积累的概念更有意义,不是吗?

现在,让我们使用身份值提供一个初始值:

String allNames = list.stream().map(p->p.getName())
                    .reduce("All names: ", (n1, n2) -> n1 + ", " + n2);
System.out.println(allNames);       //All names: , Bob, Jim, Jill, Bill

请注意,这个版本的reduce()操作返回值,而不是Optional对象。这是因为通过提供初始值,我们保证该值将出现在结果中,即使流为空。

但是,结果字符串看起来并不像我们希望的那样漂亮。显然,提供的初始值被视为任何其他流元素,并且累加器创建的后面添加了逗号。为了使结果再次看起来漂亮,我们可以再次使用reduce()操作的第一个版本,并通过这种方式添加初始值:

String allNames = "All names: " + list.stream().map(p->p.getName())
                      .reduce((n1, n2) -> n1 + ", " + n2).orElse(null);
System.out.println(allNames);         //All names: Bob, Jim, Jill, Bill

我们决定使用空格作为分隔符,而不是逗号,以进行演示:

String allNames = list.stream().map(p->p.getName())
                     .reduce("All names:", (n1, n2) -> n1 + " " + n2);
System.out.println(allNames);        //All names: Bob, Jim, Jill, Bill

现在,结果看起来更好了。在下一小节中演示collect()操作时,我们将向您展示另一种使用前缀创建逗号分隔值列表的方法。

现在,让我们看看如何使用reduce()操作的第三种形式——具有三个参数的形式,最后一个称为组合器。将组合器添加到前面的reduce()操作中不会改变结果:

String allNames = list.stream().map(p->p.getName())
                      .reduce("All names:", (n1, n2) -> n1 + " " + n2, 
                                            (n1, n2) -> n1 + " " + n2 );
System.out.println(allNames);          //All names: Bob, Jim, Jill, Bill

这是因为流不是并行的,并且组合器仅与并行流一起使用。

如果我们使流并行,结果会改变:

String allNames = list.parallelStream().map(p->p.getName())
                      .reduce("All names:", (n1, n2) -> n1 + " " + n2, 
                                            (n1, n2) -> n1 + " " + n2 );
System.out.println(allNames);   
         //All names: Bob All names: Jim All names: Jill All names: Bill

显然,对于并行流,元素序列被分成子序列,每个子序列都是独立处理的;它们的结果由组合器聚合。这样做时,组合器将初始值(身份)添加到每个结果中。即使我们删除组合器,并行流处理的结果仍然是相同的,因为提供了默认的组合器行为:

String allNames = list.parallelStream().map(p->p.getName())
                      .reduce("All names:", (n1, n2) -> n1 + " " + n2);
System.out.println(allNames);   
        //All names: Bob All names: Jim All names: Jill All names: Bill

在前两种reduce()操作中,标识值被累加器使用。在第三种形式中,使用了U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner)签名,标识值被组合器使用(注意,U类型是组合器类型)。

为了消除结果中重复的标识值,我们决定从 combiner 的第二个参数中删除它:

allNames = list.parallelStream().map(p->p.getName())
    .reduce("All names:", (n1, n2) -> n1 + " " + n2,
        (n1, n2) -> n1 + " " + StringUtils.remove(n2, "All names:"));
System.out.println(allNames);       //All names: Bob, Jim, Jill, Bill

如您所见,结果现在看起来好多了。

到目前为止,我们的例子中,标识不仅起到了初始值的作用,还起到了结果中的标识(标签)的作用。当流的元素是数字时,标识看起来更像是初始值。让我们看下面的例子:

List<Integer> ints = List.of(1, 2, 3);
int sum = ints.stream().reduce((i1, i2) -> i1 + i2).orElse(0);
System.out.println(sum);                          //prints: 6

sum = ints.stream().reduce(Integer::sum).orElse(0);
System.out.println(sum);                          //prints: 6

sum = ints.stream().reduce(10, Integer::sum);
System.out.println(sum);                         //prints: 16

sum = ints.stream().reduce(10, Integer::sum, Integer::sum);
System.out.println(sum);                         //prints: 16

前两个流管道完全相同,只是第二个管道使用了方法引用而不是 lambda 表达式。第三个和第四个管道也具有相同的功能。它们都使用初始值 10。现在第一个参数作为初始值比标识更有意义,不是吗?在第四个管道中,我们添加了一个组合器,但它没有被使用,因为流不是并行的。

让我们并行处理一下,看看会发生什么:

List<Integer> ints = List.of(1, 2, 3);
int sum = ints.parallelStream().reduce(10, Integer::sum, Integer::sum);
System.out.println(sum);                                   //prints: 36

结果为 36,因为初始值 10 被添加了三次-每次都有部分结果。显然,流被分成了三个子序列。但情况并非总是如此,随着流的增长和计算机上 CPU 数量的增加而发生变化。因此,不能依赖于一定数量的子序列,最好不要在这种情况下使用它,如果需要,可以添加到结果中:

List<Integer> ints = List.of(1, 2, 3);

int sum = ints.parallelStream().reduce(0, Integer::sum, Integer::sum);
System.out.println(sum);                                   //prints: 6

sum = 10 + ints.parallelStream().reduce(0, Integer::sum, Integer::sum);
System.out.println(sum);                                   //prints: 16

收集操作

collect()操作的一些用法非常简单,适合任何初学者,而其他情况可能复杂,即使对于经验丰富的程序员也难以理解。除了已经讨论过的操作之外,我们在本节中介绍的collect()的最受欢迎的用法已经足够满足初学者的所有需求。再加上我们将在数字流接口部分介绍的数字流操作,覆盖的内容可能很容易是未来主流程序员所需的一切。

正如我们已经提到的,collect 操作非常灵活,允许我们自定义流处理。它有两种形式:

  • R collect(Collector<T, A, R> collector):使用提供的Collector处理此T类型的流的元素,并通过A类型的中间累积产生R类型的结果

  • R collect(Supplier<R> supplier, BiConsumer<R, T> accumulator, BiConsumer<R, R> combiner): 使用提供的函数处理T类型的流的元素:

  • Supplier<R>: 创建一个新的结果容器

  • BiConsumer<R, T> accumulator: 一个无状态的函数,将一个元素添加到结果容器中

  • BiConsumer<R, R> combiner:一个无状态的函数,将两个部分结果容器合并在一起,将第二个结果容器的元素添加到第一个结果容器中。

让我们看看collect()操作的第二种形式。它与reduce()操作非常相似,具有我们刚刚演示的三个参数。最大的区别在于collect()操作中的第一个参数不是标识或初始值,而是容器——一个对象,将在函数之间传递,并维护处理的状态。对于以下示例,我们将使用Person1类作为容器:

class Person1 {
    private String name;
    private int age;
    public Person1(){}
    public String getName() { return this.name; }
    public void setName(String name) { this.name = name; }
    public int getAge() {return this.age; }
    public void setAge(int age) { this.age = age;}
    @Override
    public String toString() {
        return "Person{name:" + this.name + ",age:" + age + "}";
    }
}

正如你所看到的,容器必须有一个没有参数的构造函数和 setter,因为它应该能够接收和保留部分结果——迄今为止年龄最大的人的姓名和年龄。collect()操作将在处理每个元素时使用这个容器,并且在处理完最后一个元素后,将包含年龄最大的人的姓名和年龄。这是人员名单,你应该很熟悉:

List<Person> list = List.of(new Person(23, "Bob"),
                            new Person(33, "Jim"),
                            new Person(28, "Jill"),
                            new Person(27, "Bill"));

这是应该在列表中找到最年长的人的collect()操作:

Person1 theOldest = list.stream().collect(Person1::new,
    (p1, p2) -> {
        if(p1.getAge() < p2.getAge()){
            p1.setAge(p2.getAge());
            p1.setName(p2.getName());
        }
    },
    (p1, p2) -> { System.out.println("Combiner is called!"); });

我们尝试在操作调用中内联函数,但看起来有点难以阅读,所以这是相同代码的更好版本:

BiConsumer<Person1, Person> accumulator = (p1, p2) -> {
    if(p1.getAge() < p2.getAge()){
        p1.setAge(p2.getAge());
        p1.setName(p2.getName());
    }
};
BiConsumer<Person1, Person1> combiner = (p1, p2) -> {
    System.out.println("Combiner is called!");        //prints nothing
};
theOldest = list.stream().collect(Person1::new, accumulator, combiner);
System.out.println(theOldest);        //prints: Person{name:Jim,age:33}

Person1容器对象只创建一次——用于第一个元素的处理(在这个意义上,它类似于reduce()操作的初始值)。然后将其传递给比较器,与第一个元素进行比较。容器中的age字段被初始化为零的默认值,因此,迄今为止,容器中设置了第一个元素的年龄和姓名作为年龄最大的人的参数。

当流的第二个元素(Person对象)被发出时,它的age字段与容器(Person1对象)中当前存储的age值进行比较,依此类推,直到处理完流的所有元素。结果如前面的注释所示。

组合器从未被调用,因为流不是并行的。但是当我们并行时,我们需要实现组合器如下:

BiConsumer<Person1, Person1> combiner = (p1, p2) -> {
    System.out.println("Combiner is called!");   //prints 3 times
    if(p1.getAge() < p2.getAge()){
        p1.setAge(p2.getAge());
        p1.setName(p2.getName());
    }
};
theOldest = list.parallelStream()
                .collect(Person1::new, accumulator, combiner);
System.out.println(theOldest);  //prints: Person{name:Jim,age:33}

组合器比较了所有流子序列的部分结果,并得出最终结果。现在我们看到Combiner is called!消息打印了三次。但是,与reduce()操作一样,部分结果(流子序列)的数量可能会有所不同。

现在让我们来看一下collect()操作的第一种形式。它需要一个实现java.util.stream.Collector<T,A,R>接口的类的对象,其中T是流类型,A是容器类型,R是结果类型。可以使用Collector接口的of()方法来创建必要的Collector对象:

  • static Collector<T,R,R> of(Supplier<R> supplier, BiConsumer<R,T> accumulator, BinaryOperator<R> combiner, Collector.Characteristics... characteristics)

  • static Collector<T,A,R> of(Supplier<A> supplier, BiConsumer<A,T> accumulator, BinaryOperator<A> combiner, Function<A,R> finisher, Collector.Characteristics... characteristics).

前面方法中必须传递的函数与我们已经演示过的函数类似。但我们不打算这样做有两个原因。首先,这涉及的内容更多,超出了本入门课程的范围,其次,在这之前,必须查看提供了许多现成收集器的java.util.stream.Collectors类。正如我们已经提到的,加上本书讨论的操作和我们将在数字流接口部分介绍的数字流操作,它们涵盖了主流编程中绝大多数处理需求,很可能你根本不需要创建自定义收集器。

类收集器

java.util.stream.Collectors类提供了 40 多种方法来创建Collector对象。我们将仅演示最简单和最流行的方法:

  • Collector<T,?,List<T>> toList():创建一个收集器,将流元素收集到一个List对象中。

  • Collector<T,?,Set<T>> toSet():创建一个收集器,将流元素收集到一个Set对象中。

  • Collector<T,?,Map<K,U>> toMap (Function<T,K> keyMapper, Function<T,U> valueMapper):创建一个收集器,将流元素收集到一个Map对象中。

  • Collector<T,?,C> toCollection (Supplier<C> collectionFactory):创建一个收集器,将流元素收集到由集合工厂指定类型的Collection对象中。

  • Collector<CharSequence,?,String> joining():创建一个收集器,将元素连接成一个String值。

  • Collector<CharSequence,?,String> joining (CharSequence delimiter):创建一个收集器,将元素连接成一个以提供的分隔符分隔的String值。

  • Collector<CharSequence,?,String> joining (CharSequence delimiter, CharSequence prefix, CharSequence suffix):创建一个收集器,将元素连接成一个以提供的前缀和后缀分隔的String值。

  • Collector<T,?,Integer> summingInt(ToIntFunction<T>):创建一个计算由提供的函数应用于每个元素生成的结果的总和的收集器。相同的方法也适用于longdouble类型。

  • Collector<T,?,IntSummaryStatistics> summarizingInt(ToIntFunction<T>):创建一个收集器,计算由提供的函数应用于每个元素生成的结果的总和、最小值、最大值、计数和平均值。相同的方法也适用于longdouble类型。

  • Collector<T,?,Map<Boolean,List<T>>> partitioningBy (Predicate<? super T> predicate):创建一个收集器,根据提供的Predicate函数将元素分区。

  • Collector<T,?,Map<K,List<T>>> groupingBy(Function<T,U>):创建一个收集器,将元素分组到由提供的函数生成的Map中。

The following demo code shows how to use the collectors created by these methods. First, we demonstrate usage of the  toList()toSet()toMap(), and toCollection() methods:

List<String> ls = Stream.of("a", "b", "c").collect(Collectors.toList());
System.out.println(ls);                //prints: [a, b, c]

Set<String> set = Stream.of("a", "a", "c").collect(Collectors.toSet());
System.out.println(set);                //prints: [a, c]

List<Person> persons = List.of(new Person(23, "Bob"),
                               new Person(33, "Jim"),
                               new Person(28, "Jill"),
                               new Person(27, "Bill"));
Map<String, Person> map = persons.stream()
    .collect(Collectors.toMap(p->p.getName() + "-" + p.getAge(), p->p));
System.out.println(map); //prints: {Bob-23=Person{name:Bob,age:23}, 
                                    Bill-27=Person{name:Bill,age:27}, 
                                    Jill-28=Person{name:Jill,age:28}, 
                                    Jim-33=Person{name:Jim,age:33}}
Set<Person> personSet = persons.stream()
                        .collect(Collectors.toCollection(HashSet::new));
System.out.println(personSet);  //prints: [Person{name:Bill,age:27}, 
                                           Person{name:Jim,age:33}, 
                                           Person{name:Bob,age:23}, 
                                           Person{name:Jill,age:28}]

The joining() method allows concatenating the Character and String values in a delimited list with a prefix and suffix:

List<String> list = List.of("a", "b", "c", "d");
String result = list.stream().collect(Collectors.joining());
System.out.println(result);           //abcd

result = list.stream().collect(Collectors.joining(", "));
System.out.println(result);           //a, b, c, d

result = list.stream()
             .collect(Collectors.joining(", ", "The result: ", ""));
System.out.println(result);          //The result: a, b, c, d

result = list.stream()
      .collect(Collectors.joining(", ", "The result: ", ". The End."));
System.out.println(result);          //The result: a, b, c, d. The End.

The summingInt() and summarizingInt() methods create collectors that calculate the sum and other statistics of the int values produced by the provided function applied to each element:

List<Person> list = List.of(new Person(23, "Bob"),
                            new Person(33, "Jim"),
                            new Person(28, "Jill"),
                            new Person(27, "Bill"));
int sum = list.stream().collect(Collectors.summingInt(Person::getAge));
System.out.println(sum);  //prints: 111

IntSummaryStatistics stats = 
      list.stream().collect(Collectors.summarizingInt(Person::getAge));
System.out.println(stats);     //IntSummaryStatistics{count=4, sum=111, 
                               //    min=23, average=27.750000, max=33}
System.out.println(stats.getCount());    //4
System.out.println(stats.getSum());      //111
System.out.println(stats.getMin());      //23
System.out.println(stats.getAverage());  //27.750000
System.out.println(stats.getMax());      //33

There are also summingLong()summarizingLong() , summingDouble(), and summarizingDouble() methods.

The partitioningBy() method creates a collector that groups the elements by the provided criteria and put the groups (lists) in a Map object with a boolean value as the key:

List<Person> list = List.of(new Person(23, "Bob"),
                            new Person(33, "Jim"),
                            new Person(28, "Jill"),
                            new Person(27, "Bill"));
Map<Boolean, List<Person>> map = 
   list.stream().collect(Collectors.partitioningBy(p->p.getAge() > 27));
System.out.println(map);  
              //{false=[Person{name:Bob,age:23}, Person{name:Bill,age:27}], 
              //  true=[Person{name:Jim,age:33}, Person{name:Jill,age:28}]}

As you can see, using the p.getAge() > 27 criteria, we were able to put all the people in two groups—one is below or equals 27 years of age (the key is false), and the other is above 27 (the key is true).

And, finally, the groupingBy() method allows us to group elements by a value and put the groups (lists) in a Map object with this value as a key:

List<Person> list = List.of(new Person(23, "Bob"),
                            new Person(33, "Jim"),
                            new Person(23, "Jill"),
                            new Person(33, "Bill"));
Map<Integer, List<Person>> map = 
           list.stream().collect(Collectors.groupingBy(Person::getAge));
System.out.println(map);  
              //{33=[Person{name:Jim,age:33}, Person{name:Bill,age:33}], 
              // 23=[Person{name:Bob,age:23}, Person{name:Jill,age:23}]}

为了演示前面的方法,我们通过将每个人的年龄设置为 23 或 33 来改变了Person对象的列表。结果是按年龄分成两组。

还有重载的toMap()groupingBy()partitioningBy()方法,以及以下通常也重载的方法,它们创建相应的Collector对象:

  • counting()

  • reducing()

  • filtering()

  • toConcurrentMap()

  • collectingAndThen()

  • maxBy() 和 minBy()

  • mapping() 和 flatMapping()

  • averagingInt()averagingLong(), 和 averagingDouble()

  • toUnmodifiableList()toUnmodifiableMap()和 toUnmodifiableSet()

如果在本书中找不到所需的操作,请先搜索CollectorsAPI,然后再构建自己的Collector对象。

数字流接口

正如我们已经提到的,所有三个数字接口,IntStreamLongStreamDoubleStream,都有类似于Stream接口的方法,包括Stream.Builder接口的方法。这意味着我们在本章中讨论的所有内容同样适用于任何数字流接口。因此,在本节中,我们只会讨论Stream接口中不存在的那些方法:

  • IntStreamLongStream接口中的range(lower,upper)rangeClosed(lower,upper)方法。它们允许我们从指定范围内的值创建流。

  • boxed()mapToObj()中间操作,将数字流转换为Stream

  • mapToInt()mapToLong()mapToDouble()中间操作,将一个类型的数字流转换为另一个类型的数字流。

  • flatMapToInt()flatMapToLong()flatMapToDouble()中间操作,将流转换为数字流。

  • sum()average()终端操作,计算数字流元素的和和平均值。

创建流

除了创建流的Stream接口方法外,IntStreamLongStream接口还允许我们从指定范围内的值创建流。

range(),rangeClosed()

range(lower, upper)方法按顺序生成所有值,从lower值开始,以upper值之前的值结束:

IntStream.range(1, 3).forEach(System.out::print);  //prints: 12
LongStream.range(1, 3).forEach(System.out::print);  //prints: 12

rangeClosed(lower, upper) 方法按顺序生成所有值,从lower值开始,以upper值结束:

IntStream.rangeClosed(1, 3).forEach(System.out::print);  //prints: 123
LongStream.rangeClosed(1, 3).forEach(System.out::print);  //prints: 123

中间操作

除了Stream中间操作外,IntStreamLongStreamDoubleStream接口还具有特定于数字的中间操作:boxed()mapToObj()mapToInt()mapToLong()mapToDouble()flatMapToInt()flatMapToLong()flatMapToDouble()

boxed()和 mapToObj()

boxed() 中间操作将原始数值类型的元素转换(装箱)为相应的包装类型:

//IntStream.range(1, 3).map(Integer::shortValue)        //compile error
//                     .forEach(System.out::print);  
IntStream.range(1, 3).boxed().map(Integer::shortValue)
                             .forEach(System.out::print);  //prints: 12
//LongStream.range(1, 3).map(Long::shortValue)          //compile error
//                      .forEach(System.out::print);  
LongStream.range(1, 3).boxed().map(Long::shortValue)
                              .forEach(System.out::print);  //prints: 12
//DoubleStream.of(1).map(Double::shortValue)            //compile error
//                  .forEach(System.out::print);  
DoubleStream.of(1).boxed().map(Double::shortValue)
                          .forEach(System.out::print);      //prints: 1

在上述代码中,我们已经注释掉了生成编译错误的行,因为range()方法生成的元素是原始类型。通过添加boxed()操作,我们将原始值转换为相应的包装类型,然后可以将它们作为引用类型进行处理。

mapToObj()中间操作进行了类似的转换,但它不像boxed()操作那样专门化,并且允许使用原始类型的元素来生成任何类型的对象:

IntStream.range(1, 3).mapToObj(Integer::valueOf)
                     .map(Integer::shortValue)
                     .forEach(System.out::print);       //prints: 12
IntStream.range(42, 43).mapToObj(i -> new Person(i, "John"))
                       .forEach(System.out::print);  
                                   //prints: Person{name:John,age:42}
LongStream.range(1, 3).mapToObj(Long::valueOf)
                      .map(Long::shortValue)
                      .forEach(System.out::print);      //prints: 12
DoubleStream.of(1).mapToObj(Double::valueOf)
                  .map(Double::shortValue)
                  .forEach(System.out::print);          //prints: 1

在上述代码中,我们添加了map()操作,只是为了证明mapToObj()操作可以按预期执行工作并创建包装类型对象。此外,通过添加生成Person对象的流管道,我们演示了如何使用mapToObj()操作来创建任何类型的对象。

mapToInt()、mapToLong()和 mapToDouble()

mapToInt()mapToLong()mapToDouble()中间操作允许我们将一个类型的数值流转换为另一种类型的数值流。在演示代码中,我们通过将每个String值映射到其长度,将String值列表转换为不同类型的数值流:

list.stream().mapToInt(String::length)
                   .forEach(System.out::print); //prints: 335
list.stream().mapToLong(String::length)
                   .forEach(System.out::print); //prints: 335
list.stream().mapToDouble(String::length)
    .forEach(d -> System.out.print(d + " "));   //prints: 3.0 3.0 5.0

创建的数值流的元素是原始类型的:

//list.stream().mapToInt(String::length)
//             .map(Integer::shortValue)   //compile error
//             .forEach(System.out::print); 

既然我们在这个话题上,如果您想将元素转换为数值包装类型,map()中间操作就是这样做的方法(而不是mapToInt()):

list.stream().map(String::length)
             .map(Integer::shortValue)
             .forEach(System.out::print);  //prints: 335

flatMapToInt()、flatMapToLong()和 flatMapToDouble()

flatMapToInt()flatMapToLong()flatMapToDouble()中间操作会生成相应类型的数值流:

List<Integer> list = List.of(1, 2, 3);

list.stream().flatMapToInt(i -> IntStream.rangeClosed(1, i))
                        .forEach(System.out::print);    //prints: 112123
list.stream().flatMapToLong(i -> LongStream.rangeClosed(1, i))
                        .forEach(System.out::print);    //prints: 112123
list.stream().flatMapToDouble(DoubleStream::of)
        .forEach(d -> System.out.print(d + " "));  //prints: 1.0 2.0 3.0

如您所见,在上述代码中,我们在原始流中使用了int值。但它可以是任何类型的流:

List<String> str = List.of("one", "two", "three");
str.stream().flatMapToInt(s -> IntStream.rangeClosed(1, s.length()))
                      .forEach(System.out::print);  //prints: 12312312345

终端操作

数值流的附加终端操作非常简单。它们中有两个:

  • sum(): 计算数值流元素的总和

  • average(): 计算数值流元素的平均值

sum()和 average()

如果您需要计算数值流元素的总和或平均值,则流的唯一要求是它不应该是无限的。否则,计算永远不会完成:

int sum = IntStream.empty().sum();
System.out.println(sum);          //prints: 0

sum = IntStream.range(1, 3).sum();
System.out.println(sum);          //prints: 3

double av = IntStream.empty().average().orElse(0);
System.out.println(av);           //prints: 0.0

av = IntStream.range(1, 3).average().orElse(0);
System.out.println(av);           //prints: 1.5

long suml = LongStream.range(1, 3).sum();
System.out.println(suml);         //prints: 3

double avl = LongStream.range(1, 3).average().orElse(0);
System.out.println(avl);          //prints: 1.5

double sumd = DoubleStream.of(1, 2).sum();
System.out.println(sumd);         //prints: 3.0

double avd = DoubleStream.of(1, 2).average().orElse(0);
System.out.println(avd);          //prints: 1.5

正如您所看到的,对空流使用这些操作不是问题。

并行处理

我们已经看到,从顺序流切换到并行流可能会导致不正确的结果,如果代码没有为处理并行流而编写和测试。以下是与并行流相关的一些其他考虑。

无状态和有状态的操作

有无状态的操作,比如filter()map()flatMap(),在从一个流元素的处理转移到下一个流元素的处理时不会保留数据(不维护状态)。还有有状态的操作,比如distinct()limit()sorted()reduce()collect(),可能会将先前处理的元素的状态传递给下一个元素的处理。

无状态操作通常在从顺序流切换到并行流时不会造成问题。每个元素都是独立处理的,流可以被分成任意数量的子流进行独立处理。

对于有状态的操作,情况是不同的。首先,对无限流使用它们可能永远无法完成处理。此外,在讨论reduce()collect()有状态操作时,我们已经演示了如果初始值(或标识)在没有考虑并行处理的情况下设置,切换到并行流可能会产生不同的结果。

而且还有性能方面的考虑。有状态的操作通常需要使用缓冲区多次处理所有流元素。对于大流,这可能会消耗 JVM 资源并减慢甚至完全关闭应用程序。

这就是为什么程序员不应该轻易从顺序流切换到并行流。如果涉及有状态的操作,代码必须被设计和测试,以便能够在没有负面影响的情况下执行并行流处理。

顺序或并行处理?

正如我们在前一节中所指出的,并行处理可能会产生更好的性能,也可能不会。在决定使用之前,必须测试每个用例。并行处理可能会产生更好的性能,但代码必须被设计和可能被优化。每个假设都必须在尽可能接近生产环境的环境中进行测试。

然而,在决定顺序处理和并行处理之间可以考虑一些因素:

  • 通常情况下,小流在顺序处理时处理速度更快(对于您的环境来说,“小”是通过测试和测量性能来确定的)

  • 如果有状态的操作无法用无状态的操作替换,那么必须仔细设计代码以进行并行处理,或者完全避免它。

  • 考虑对需要大量计算的程序进行并行处理,但要考虑将部分结果合并为最终结果

练习 - 将所有流元素相乘

使用流来将以下列表的所有值相乘:

List<Integer> list = List.of(2, 3, 4);

答案

int r = list.stream().reduce(1, (x, y) -> x * y);
System.out.println(r);     //prints: 24

总结

本章介绍了数据流处理的强大概念,并提供了许多函数式编程使用示例。它解释了流是什么,如何处理它们以及如何构建处理管道。它还演示了如何可以并行组织流处理以及一些可能的陷阱。

在下一章中,我们将讨论反应式系统,它们的优势以及可能的实现。您将了解异步非阻塞处理、反应式编程和微服务,所有这些都有代码示例,演示了这些反应式系统所基于的主要原则。

第十九章:响应式系统

在本书的最后一章中,我们将打破连贯叙述的流程,更接近真实的专业编程。随着处理的数据越来越多,服务变得更加复杂,对更具适应性、高度可扩展和分布式应用程序的需求呈指数级增长。这就是我们将在本章中讨论的内容——这样的软件系统在实践中可能是什么样子。

在本章中,我们将涵盖以下主题:

  • 如何快速处理大量数据

  • 微服务

  • 响应式系统

  • 练习-创建io.reactivex.Observable

如何快速处理大量数据

可以应用许多可测量的性能特征到一个应用程序中。要使用哪些取决于应用程序的目的。它们通常被列为非功能性要求。最典型的集合包括以下三个:

  • 吞吐量:单位时间内处理的请求数。

  • 延迟:从提交请求到接收响应的第一个字节所经过的时间。以秒、毫秒等为单位进行测量。

  • 内存占用:应用程序消耗的内存量——最小、最大或平均。

实际上,延迟通常被计算为吞吐量的倒数。这些特性随着负载的增长而变化,因此非功能性要求通常包括它们在平均和最大负载下的最大值。

通常,吞吐量和延迟的改进只是以内存为代价,除非增加更快的 CPU 可以改善这三个特性。但这取决于处理的性质。例如,与性能低下的设备进行输入/输出(或其他交互)可能会施加限制,代码的任何更改都无法改善应用程序性能。

对于每个特性的测量也有微妙的细微差别。例如,我们可以使用最快(最少延迟)请求中 99%的最大延迟来代替将延迟作为所有请求的平均值。否则,它看起来像是通过将亿万富翁和收入金字塔底部的人的财富除以二获得的平均财富数字。

在评估应用程序性能时,必须回答以下问题:

  • 请求的延迟上限是否可能被超过?如果是,多久一次,超过多少?

  • 延迟不良的时间段可以有多长,允许发生多少次?

  • 谁/什么在生产中测量延迟?

  • 预期的峰值负载是多少,预计会持续多长时间?

只有在回答了所有这些问题(和类似的问题),并且已经确定了非功能性要求之后,我们才能开始设计系统,测试它,微调并再次测试。有许多编程技术证明在以可接受的内存消耗实现所需的吞吐量方面是有效的。

在这种情况下,异步非阻塞分布式可扩展响应式响应式弹性消息驱动等术语变得无处不在,只是高性能的同义词。我们将讨论每个术语,以便读者可以理解为什么会出现微服务响应式系统,这将在本章的下两节中介绍。

异步

异步意味着请求者立即获得响应,但结果还没有。相反,请求者收到一个带有方法的对象,允许我们检查结果是否准备就绪。请求者定期调用此方法,当结果准备就绪时,使用同一对象上的另一个方法检索它。

这种解决方案的优势在于请求者可以在等待时做其他事情。例如,在第十一章中,JVM 进程和垃圾回收,我们演示了如何创建一个子线程。因此,主线程可以创建一个子线程,发送一个非异步(也称为阻塞)请求,并等待其返回,什么也不做。与此同时,主线程可以继续执行其他操作,定期调用子线程对象以查看结果是否准备好。

这是最基本的异步调用实现。事实上,当我们处理并行流时,我们已经使用了它。并行流操作在后台创建子线程,将流分成段,并将每个段分配给一个专用线程,然后将每个段的结果聚合到最终结果中。在上一章中,我们编写了执行聚合工作的函数。作为提醒,这些函数被称为组合器。

让我们比较处理顺序和并行流时相同功能的性能。

顺序与并行流

为了演示顺序和并行处理之间的差异,让我们想象一个从 10 个物理设备(传感器)收集数据并计算平均值的系统。这样一个系统的接口可能如下所示:

interface MeasuringSystem {
    double get(String id);
}

它只有一个方法,get(),它接收传感器的 ID 并返回测量结果。使用这个接口,我们可以实现许多不同的系统,这些系统能够调用不同的设备。为了演示目的,我们不打算写很多代码。我们只需要延迟 100 毫秒(模拟从传感器收集测量数据所需的时间)并返回一些数字。我们可以这样实现延迟:

void pauseMs(int ms) {
    try{
        TimeUnit.MILLISECONDS.sleep(ms);
    } catch(InterruptedException ex){
        ex.printStackTrace();
    }
}

至于结果数字,我们将使用Math.random()来模拟从不同传感器接收到的测量值的差异(这就是为什么我们需要找到一个平均值——以抵消个别设备的误差和其他特性)。因此,我们的演示实现可能如下所示:

class MeasuringSystemImpl implements MeasuringSystem {
    public double get(String id){
         demo.pauseMs(100);
         return 10\. * Math.random();
    }
}

现在,我们意识到我们的MeasuringInterface是一个函数接口,因为它只有一个方法。这意味着我们可以使用java.util.function包中的标准函数接口之一;即Function<String, Double>

Function<String, Double> mSys = id -> {
    demo.pauseMs(100);
    return 10\. + Math.random();
};

因此,我们可以放弃我们的MeasuringSystem接口和MeasuringSystemImpl类。但是我们可以保留mSysMeasuring System)标识符,它反映了这个函数背后的想法:它代表一个提供对其传感器的访问并允许我们从中收集数据的测量系统。

现在,让我们创建一个传感器 ID 列表:

List<String> ids = IntStream.range(1, 11)
        .mapToObj(i -> "id" + i).collect(Collectors.toList());

同样,在现实生活中,我们需要收集真实设备的 ID,但是为了演示目的,我们只是生成它们。

最后,我们将创建collectData()方法,它调用所有传感器并计算接收到的所有数据的平均值:

Stream<Double> collectData(Stream<String> stream, 
                         Function<String, Double> mSys){
    return  stream.map(id -> mSys.apply(id));
}

正如你所看到的,这个方法接收一个提供 ID 的流和一个使用每个 ID 从传感器获取测量值的函数。

这是我们将如何从averageDemo()方法中调用这个方法,使用getAverage()方法:

void averageDemo() {
    Function<String, Double> mSys = id -> {
         pauseMs(100);
         return 10\. + Math.random();
    };
    getAverage(() -> collectData(ids.stream(), mSys)); 
}

void getAverage(Supplier<Stream<Double>> collectData) {
    LocalTime start = LocalTime.now();
    double a = collectData.get()
                    .mapToDouble(Double::valueOf).average().orElse(0);
    System.out.println((Math.round(a * 100.) / 100.) + " in " + 
         Duration.between(start, LocalTime.now()).toMillis() + " ms");
}   

如您所见,我们创建了代表测量系统的函数,并将其传递给collectData()方法,以及 ID 流。然后,我们创建了SupplierStream<Double>>函数作为() -> collectData(ids.stream(), mSys) lambda 表达式,并将其作为collectData参数传递给getAverage()方法。在getAverage()方法内部,我们调用供应商的get(),从而调用collectData(ids.stream(), mSys),它返回Stream<Double>。然后我们使用mapToDouble()操作将其转换为DoubleStream,以便应用average()操作。average()操作返回一个Optional<Double>对象,我们调用它的orElse(0)方法,它返回计算出的值或零(例如,如果测量系统无法连接到任何传感器并返回空流)。getAverage()方法的最后一行打印了结果和计算所需的时间。在实际代码中,我们会返回结果并将其用于其他计算。但是对于我们的演示,我们只是打印它。

现在,我们可以将顺序流处理的性能与并行流处理进行比较:

List<String> ids = IntStream.range(1, 11)
              .mapToObj(i -> "id" + i).collect(Collectors.toList());
Function<String, Double> mSys = id -> {
        pauseMs(100);
        return 10\. + Math.random();
};
getAverage(() -> collectData(ids.stream(), mSys));    
                                             //prints: 10.46 in 1031 ms
getAverage(() -> collectData(ids.parallelStream(), mSys));  
                                             //prints: 10.49 in 212 ms

如您所见,处理并行流比处理顺序流快五倍。

尽管在幕后,并行流使用了\异步处理,但这并不是程序员在谈论异步处理请求时所指的。从应用程序的角度来看,它只是并行(也称为并发)处理。它比顺序处理快,但主线程必须等待所有调用完成并检索所有数据。如果每个调用至少需要 100 毫秒(就像我们的情况一样),那么所有调用的处理时间不可能少于这个时间。

当然,我们可以创建一个子线程,让它进行所有调用,并等待它们完成,而主线程则做其他事情。我们甚至可以创建一个执行此操作的服务,因此应用程序只需告诉这样的服务要做什么(在我们的情况下传递传感器 ID)并继续做其他事情。稍后,主线程可以再次调用服务,并获取结果或在约定的地方获取结果。这就是程序员所说的真正的异步处理。

但在编写这样的代码之前,让我们看看位于java.util.concurrent包中的CompletableFuture类。它可以做我们描述的一切,甚至更多。

使用 CompletableFuture 类

使用CompletableFuture对象,我们可以将向测量系统发送数据请求(并创建CompletableFuture对象)与从CompletableFuture对象获取结果分开。这正是我们在解释异步处理时描述的场景。让我们在代码中演示它。类似于我们提交请求到测量系统的方式,我们可以使用CompletableFuture.supplyAsync()静态方法来完成:

List<CompletableFuture<Double>> list = ids.stream()
        .map(id -> CompletableFuture.supplyAsync(() -> mSys.apply(id)))
        .collect(Collectors.toList());

不同之处在于supplyAsync()方法不会等待调用测量系统返回。相反,它立即创建一个CompletableFuture对象并返回,以便客户端可以随时使用该对象检索测量系统返回的值。还有一些方法可以让我们检查值是否已经返回,但这不是这个演示的重点,重点是展示CompletableFuture类如何用于组织异步处理。

创建的CompletableFuture对象列表可以存储在任何地方。我们选择将其存储在一个Map中。事实上,我们创建了一个sendRequests()方法,可以向任意数量的测量系统发送任意数量的请求:

Map<Integer, List<CompletableFuture<Double>>> 
                  sendRequests(List<List<String>> idLists, 
                               List<Function<String, Double>> mSystems){
   LocalTime start = LocalTime.now();
   Map<Integer, List<CompletableFuture<Double>>> requests 
                                                       = new HashMap<>();
   for(int i = 0; i < idLists.size(); i++){
      for(Function<String, Double> mSys: mSystems){
         List<String> ids = idLists.get(i);
         List<CompletableFuture<Double>> list = ids.stream()
          .map(id -> CompletableFuture.supplyAsync(() -> mSys.apply(id)))
          .collect(Collectors.toList());
         requests.put(i, list);
      }
   }
   long dur = Duration.between(start, LocalTime.now()).toMillis();
   System.out.println("Submitted in " + dur + " ms");
   return requests;
}

正如您所看到的,前面的方法接受了两个参数:

  • List<List<String>> idLists:传感器 ID 列表的集合(列表),每个列表特定于特定的测量系统。

  • List<Function<String, Double>> mSystems:测量系统的列表,每个系统都表示为Function<String, Double>,具有一个接受传感器 ID 并返回双精度值(测量结果)的apply()方法。此列表中的系统与第一个参数中的传感器 ID 列表的顺序相同,因此我们可以通过它们的位置将 ID 与系统匹配。

然后,我们创建了一个Map<Integer, List<CompletableFuture<Double>>>对象来存储CompletableFuture对象的列表。我们在for循环中生成它们,然后将它们存储在一个带有顺序号的Map中。Map被返回给客户端,可以存储在任何地方,任意时间段(好吧,有一些可以修改的限制,但我们不打算在这里讨论它们)。稍后,当客户端决定获取请求的结果时,可以使用getAverage()方法来检索它们:

void getAverage(Map<Integer, List<CompletableFuture<Double>>> requests){
    for(List<CompletableFuture<Double>> list: requests.values()){
        getAverage(() -> list.stream().map(CompletableFuture::join));
    }
}

前面的方法接受了sendRequests()方法创建的Map对象,并迭代存储在Map中的所有值(CompletableFuture对象的列表)。对于每个列表,它创建一个流,将每个元素(CompletableFuture对象)映射到调用该元素的join()方法的结果。此方法检索从相应调用测量系统返回的值。如果值不可用,该方法会等待一段时间(可配置的值),然后要么退出(并返回null),要么最终接收来自测量系统的值(如果可用)。同样,我们不打算讨论围绕故障的所有保护措施,以便专注于主要功能。

()-> list.stream().map(CompletableFuture::join)函数实际上被传递到getAverage()方法中(这对您来说应该是熟悉的),我们在前面的示例中处理流时使用过:

void getAverage(Supplier<Stream<Double>> collectData) {
    LocalTime start = LocalTime.now();
    double a = collectData.get()
                    .mapToDouble(Double::valueOf).average().orElse(0);
    System.out.println((Math.round(a * 100.) / 100.) + " in " + 
         Duration.between(start, LocalTime.now()).toMillis() + " ms");
}

这个方法计算传入流发出的所有值的平均值,打印出来,并且还捕获了处理流(和计算平均值)所花费的时间。

现在,让我们使用新的方法,看看性能如何提高:

Function<String, Double> mSys = id -> {
     pauseMs(100);
     return 10\. + Math.random();
 };
 List<Function<String, Double>> mSystems = List.of(mSys, mSys, mSys);
 List<List<String>> idLists = List.of(ids, ids, ids);

 Map<Integer, List<CompletableFuture<Double>>> requestLists = 
        sendRequests(idLists, mSystems);  //prints: Submitted in 13 ms

 pauseMs(2000);  //The main thread can continue doing something else
                 //for any period of time
 getAverage(requestLists);               //prints: 10.49 in 5 ms
                                         //        10.61 in 0 ms
                                         //        10.51 in 0 ms

为了简单起见,我们重用了相同的测量系统(及其 ID)来模拟与三个测量系统一起工作。您可以看到所有三个系统的请求在 13 毫秒内提交。sendRequests()方法存在,主线程至少有两秒的空闲时间去做其他事情。这是实际发送所有请求并接收响应所需的时间,因为每次调用测量系统都使用pauseMs(100)。然后,我们为每个系统计算平均值,几乎不需要时间。这就是程序员在谈论异步处理请求时的意思。

CompletableFuture类有许多方法,并且得到了几个其他类和接口的支持。例如,使用线程池可以减少收集所有数据的两秒暂停时间:

Map<Integer, List<CompletableFuture<Double>>> 
                  sendRequests(List<List<String>> idLists, 
                               List<Function<String, Double>> mSystems){
   ExecutorService pool = Executors.newCachedThreadPool();
   LocalTime start = LocalTime.now();
   Map<Integer, List<CompletableFuture<Double>>> requests 
                                                       = new HashMap<>();
   for(int i = 0; i < idLists.size(); i++){
      for(Function<String, Double> mSys: mSystems){
         List<String> ids = idLists.get(i);
         List<CompletableFuture<Double>> list = ids.stream()
          .map(id -> CompletableFuture.supplyAsync(() -> mSys.apply(id), 
 pool))
          .collect(Collectors.toList());
         requests.put(i, list);
      }
   }
   pool.shutdown();
   long dur = Duration.between(start, LocalTime.now()).toMillis();
   System.out.println("Submitted in " + dur + " ms");
   return requests;
}

有各种各样的这样的池,用于不同的目的和不同的性能。但所有这些都不会改变整体系统设计,因此我们将忽略这些细节。

所以,异步处理的威力是巨大的。但谁从中受益呢?

如果您创建了一个应用程序,根据需要收集数据并计算每个测量系统的平均值,那么从客户端的角度来看,仍然需要很长时间,因为暂停(两秒,或者如果我们使用线程池则更少)仍然包括在客户端的等待时间中。因此,除非您设计了 API,以便客户端可以提交请求并离开做其他事情,然后稍后获取结果,否则客户端将失去异步处理的优势。

这就是同步(或阻塞)API 和异步*API 之间的区别,当客户端等待(阻塞)直到结果返回时,以及当客户端提交请求并离开做其他事情,然后稍后获得结果时。

异步 API 的可能性增强了我们对延迟的理解。通常,程序员所说的延迟是指在同一次调用 API 时,从提交请求到接收到响应的第一个字节所花费的时间。但如果 API 是异步的,延迟的定义就会变成“请求提交和结果可供客户端收集的时间”。在这种情况下,每次调用的延迟被假定要比发出请求和收集结果之间的时间要小得多。

还有一个非阻塞API 的概念,我们将在下一节讨论。

非阻塞

对于应用程序的客户端来说,非阻塞 API 的概念只告诉我们应用程序可能是可扩展的、反应灵敏的、响应快速的、具有弹性的和消息驱动的。在接下来的章节中,我们将讨论所有这些术语,但现在,我们希望您可以从这些名称本身中得出它们各自的含义。

这样的陈述意味着两件事:

  • 非阻塞不会影响客户端和应用程序之间的通信协议:它可以是同步(阻塞)或异步的。非阻塞是一个实现细节;它是从应用程序内部的 API 视角来看的。

  • 非阻塞是帮助应用程序成为以下所有特性的实现:可扩展、反应灵敏、响应快速、具有弹性和消息驱动。这意味着它是许多现代应用程序基础的一个非常重要的设计概念。

众所周知,阻塞 API 和非阻塞 API 并不是对立的。它们描述了应用程序的不同方面。阻塞 API 描述了客户端与之交互的方式:客户端调用并保持连接,直到提供响应。非阻塞 API 描述了应用程序的实现方式:它不为每个请求分配执行线程,而是提供多个轻量级工作线程,以异步和并发的方式进行处理。

非阻塞这个术语是随着提供对密集输入/输出(I/O)操作支持的java.nio(NIO 代表非阻塞输入/输出)包的使用而出现的。

java.io 与 java.nio 包

向外部存储器(例如硬盘)写入和读取数据比在内存中进行的其他进程要慢得多。java.io包中已经存在的类和接口运行良好,但偶尔性能会出现瓶颈。新的java.nio包被创建出来以提供更有效的 I/O 支持。

java.io的实现是基于流处理的,正如我们在前一节中看到的,即使在幕后进行了某种并发操作,它基本上仍然是一个阻塞操作。为了提高速度,java.nio的实现是基于在内存中读取/写入缓冲区。这样的设计使我们能够将填充/清空缓冲区的缓慢过程与从中快速读取/写入的过程分开。在某种程度上,这类似于我们在CompletableFuture类使用示例中所做的。拥有缓冲区中的数据的额外优势是可以检查它,来回沿着缓冲区进行操作,而从流中顺序读取时是不可能的。这使得在数据处理过程中更加灵活。

此外,java.nio实现引入了另一个中间过程,称为通道,它提供了与缓冲区的批量数据传输。读取线程从通道获取数据,并且只接收当前可用的数据,或者根本没有数据(如果通道中没有数据)。如果数据不可用,线程可以做其他事情,而不是保持阻塞状态,例如读取/写入其他通道。就像我们的CompletableFuture示例中的主线程在测量系统从传感器中读取数据时可以自由进行其他操作。这样,与将一个线程专用于一个 I/O 进程不同,几个工作线程可以为多个 I/O 进程提供服务。

这样的解决方案被称为非阻塞 I/O,后来被应用于其他进程,其中最突出的是事件循环中的事件处理,也称为运行循环。

事件循环,或运行循环

许多非阻塞处理系统都基于事件(或运行)循环——一个不断执行的线程,接收事件(请求、消息),然后将它们分派给相应的事件处理程序。事件处理程序没有什么特别之处。它们只是由程序员专门用于处理特定事件类型的方法(函数)。

这种设计被称为反应器设计模式,定义为用于处理并发传递给服务处理程序的服务请求的事件处理模式。它还为反应式编程反应式系统提供了名称,这些系统对某些事件做出反应并相应地处理它们。我们将在专门的部分中稍后讨论反应式系统。

基于事件循环的设计在操作系统和图形用户界面中被广泛使用。它在 Spring 5 的 Spring WebFlux 中可用,并在 JavaScript 及其流行的执行环境 Node.js 中实现。最后一个使用事件循环作为其处理骨干。Vert.x 工具包也是围绕事件循环构建的。我们将在“微服务”部分展示后者的一些示例。

在采用事件循环之前,每个传入请求都分配了一个专用线程,就像我们在流处理演示中所做的那样。每个线程都需要分配一定数量的资源,这些资源与请求无关,因此一些资源(主要是内存分配)被浪费了。然后,随着请求数量的增加,CPU 需要更频繁地切换上下文,以允许更多或更少的并发处理所有请求。在负载下,上下文切换的开销变得足够大,以至于影响应用程序的性能。

实现事件循环解决了这两个问题:

  • 它通过避免为每个请求创建一个专用线程并保持线程直到请求被处理,从而消除了资源的浪费。有了事件循环,每个请求只需要一个更小的内存分配来捕获其具体信息。这使得可以在内存中保留更多的请求,以便可以并发处理它们。

  • CPU 上下文切换的开销也变得更小了,因为上下文大小减小了。

非阻塞 API 是如何实现请求处理的。有了它,系统能够处理更大的负载(更具可伸缩性和弹性),同时保持高度的响应和弹性。

分布式

随着时间的推移,分布式的概念也发生了变化。它曾经意味着在多台计算机上运行的应用程序,通过网络连接。它甚至有一个同义词叫做并行计算,因为应用程序的每个实例都在做同样的事情。这样的应用程序提高了系统的弹性。一台计算机的故障不会影响整个系统。

然后,又添加了另一层含义:一个应用程序分布在多台计算机上,因此其每个组件都对应用程序整体产生的结果有所贡献。这种设计通常用于需要大量 CPU 计算能力或需要来自许多不同来源的大量数据的计算或数据密集型任务。

当单个 CPU 变得足够强大,可以处理成千上万台旧计算机的计算负载,尤其是云计算,特别是像 AWS Lambda 无服务器计算平台这样的系统,它们完全消除了个人计算机的概念;分布式可能意味着一个应用程序或其组件在一个或多台计算机上运行的任何组合。

分布式系统的例子包括大数据处理系统、分布式文件或数据存储系统以及分类帐系统,如区块链或比特币,也可以包括在智能数据存储系统的子类别下的数据存储系统组中。

当程序员今天称一个系统为分布式时,他们通常指的是以下内容:

  • 系统可以容忍其构成组件的一个或甚至多个失败。

  • 每个系统组件只能看到系统的有限不完整视图。

  • 系统的结构是动态的,并且在执行过程中可能会发生变化。

  • 系统是可扩展的。

可扩展

可扩展性是在不显著降低延迟/吞吐量的情况下承受不断增加的负载的能力。传统上,这是通过将软件系统分解为层来实现的:前端层、中间层和后端层,例如。每个层由负责特定类型处理的相同组件组的多个部署副本组成。

前端组件负责基于请求和从中间层接收到的数据进行呈现。中间层组件负责基于来自前端层的数据和它们可以从后端层读取的数据进行计算和决策。它们还将数据发送到后端进行存储。后端层存储数据,并将其提供给中间层。

通过添加组件的副本,每个层允许我们跟上不断增加的负载。过去,只能通过向每个层添加更多计算机来实现。否则,新部署的组件副本将没有可用资源。

但是,随着云计算的引入,尤其是 AWS Lambda 服务,可扩展性是通过仅添加软件组件的新副本来实现的。增加了更多计算机到层中(或者没有)对部署者来说是隐藏的。

分布式系统架构中的另一个最近的趋势允许我们通过扩展不仅通过层,而且通过特定的小型功能部分来微调可扩展性,并提供一种或多种特定类型的服务,称为微服务。我们将在微服务部分讨论这一点,并展示一些微服务的示例。

在这样的架构下,软件系统变成了许多微服务的组合;每个微服务可以根据需要复制多次,以支持所需的处理能力增加。在这个意义上,我们只能谈论一个微服务的可扩展性。

反应式

术语反应式通常用于反应式编程和反应式系统的上下文中。反应式编程(也称为 Rx 编程)是基于使用异步数据流(也称为反应式流)进行编程。它在 Java 9 中引入了java.util.concurrent包。它允许Publisher生成数据流,Subscriber可以异步订阅。

正如您所见,即使没有这个新的 API,我们也能够异步处理数据,使用CompletableFuture。但是,写了几次这样的代码后,人们会注意到其中大部分只是管道工作,因此人们会觉得一定有更简单、更方便的解决方案。这就是 Reactive Streams 倡议(www.reactive-streams.org)的诞生。该努力的范围定义如下:

Reactive Streams 的范围是找到一组最小的接口、方法和协议,描述必要的操作和实体,以实现异步数据流和非阻塞背压。

术语非阻塞背压指的是异步处理的问题之一——协调传入数据的速率与系统处理数据的能力,而无需停止(阻塞)数据输入。解决方案是通知源,消费者在跟上输入的速率方面有困难,但处理应该对传入数据速率的变化做出更灵活的反应,而不仅仅是阻塞流(因此称为反应式)。

除了标准的 Java 库,已经存在几个实现了 Reactive Streams API 的其他库:RxJava、Reactor、Akka Streams 和 Vert.x 是其中最知名的。我们将在我们的示例中使用 RxJava 2.1.13。您可以在reactivex.io找到 RxJava 2.x API,名称为 ReactiveX,代表 Reactive Extension。

让我们首先比较使用java.util.stream包和 RxJava 2.1.13 的io.reactivex包实现相同功能的两种方式,可以通过以下依赖项添加到项目中:

<dependency>
    <groupId>io.reactivex.rxjava2</groupId>
    <artifactId>rxjava</artifactId>
    <version>2.1.13</version>
</dependency> 

示例程序将非常简单:

  • 创建一个整数流:1、2、3、4、5。

  • 仅过滤偶数(2 和 4)。

  • 计算每个过滤后的数字的平方根。

  • 计算所有平方根的和。

以下是使用java.util.stream包实现的方式:

double a = IntStream.rangeClosed(1, 5)
        .filter(i -> i % 2 == 0)
        .mapToDouble(Double::valueOf)
        .map(Math::sqrt)
        .sum();
System.out.println(a); //prints: 3.414213562373095

使用 RxJava 实现相同功能的方式如下:

Observable.range(1, 5)
        .filter(i -> i % 2 == 0)
        .map(Math::sqrt)
        .reduce((r, d) -> r + d)
        .subscribe(System.out::println); //prints: 3.414213562373095
RxJava is based on the Observable object (which plays the role of Publisher) and Observer that subscribes to the Observable and waits for data to be emitted. 

除了Stream功能外,Observable具有显著不同的功能。例如,流一旦关闭,就无法重新打开,而Observable对象可以再次使用。这是一个例子:

Observable<Double> observable = Observable.range(1, 5)
        .filter(i -> i % 2 == 0)
        .doOnNext(System.out::println)    //prints 2 and 4 twice
        .map(Math::sqrt);
observable
        .reduce((r, d) -> r + d)
        .subscribe(System.out::println);  //prints: 3.414213562373095
observable
        .reduce((r, d) -> r + d)a
        .map(r -> r / 2)
        .subscribe(System.out::println);  //prints: 1.7071067811865475

在前面的示例中,从注释中可以看出,doOnNext()操作被调用了两次,这意味着observable对象发出了两次值。但是,如果我们不希望Observable运行两次,我们可以通过添加cache()操作来缓存其数据:

Observable<Double> observable = Observable.range(1,5)
        .filter(i -> i % 2 == 0)
        .doOnNext(System.out::println)  //prints 2 and 4 only once
        .map(Math::sqrt)
        .cache();
observable
        .reduce((r, d) -> r + d)
        .subscribe(System.out::println); //prints: 3.414213562373095
observable
        .reduce((r, d) -> r + d)
        .map(r -> r / 2)
        .subscribe(System.out::println);  //prints: 1.7071067811865475

正如你所看到的,同一个Observable的第二次使用利用了缓存数据,从而提高了性能。Observable接口和 RxJava 中还有更多功能,但本书的格式不允许我们进行描述。但我们希望你能理解。

使用 RxJava 或其他异步流库编写代码构成了反应式编程。它实现了反应式宣言中所宣布的目标,即构建具有响应性、弹性、弹性和消息驱动的反应式系统。

响应式

这个术语似乎是不言自明的。及时响应的能力是每个客户对任何系统的首要要求之一。可以通过许多不同的方法来实现这一点。即使传统的阻塞 API 也可以通过足够的服务器和其他基础设施来支持,以在非常大的负载下提供预期的响应性。反应式编程只是帮助使用更少的硬件来实现这一点。

这是有代价的,因为反应式代码需要改变我们过去的做法,甚至是五年前的做法。但过一段时间,这种新的思维方式就会变得和任何其他已经熟悉的技能一样自然。我们将在接下来的章节中看到更多反应式编程的例子。

弹性

失败是不可避免的。硬件崩溃,软件有缺陷,接收到意外数据,或者采取了意外和未经充分测试的执行路径——任何这些事件或它们的组合都可能随时发生。弹性是系统在这种情况下继续提供预期结果的能力。

可以通过部署组件和硬件的冗余、系统各部分的隔离(减少多米诺效应的可能性)、设计系统使得丢失的部分可以自动替换或者引发适当的警报以便合格人员干预等措施来实现。

我们已经谈论过分布式系统。这样的架构通过消除单点故障使系统更具弹性。此外,将系统分解为许多专门的组件,并使用消息相互通信,可以更好地调整最关键部分的复制,并为其隔离和潜在故障容纳创造更多机会。

弹性

承受最大负载的能力通常与可伸缩性相关。但在不同负载下保持相同性能特征的能力被称为弹性。弹性系统的客户不应该注意到空闲时期和高峰负载时期之间的任何差异。

非阻塞的反应式实现风格有助于实现这一质量。此外,将程序分解为更小的部分并将其转换为可以独立部署和管理的服务,可以进行资源分配的微调。这些小服务被称为微服务,许多微服务可以组成一个既可扩展又具有弹性的反应式系统。我们将在接下来的章节中更详细地讨论这些解决方案。

消息驱动

我们已经确定了组件的隔离和系统分布是保持系统响应、弹性和弹性的两个方面。松散和灵活的连接也是支持这些特性的重要条件。而反应式系统的异步性质简单地不给设计者留下其他选择,只能在组件之间建立消息通信。

它为每个组件创建了一个“呼吸空间”,没有这个空间,系统将成为一个紧密耦合的单体,容易受到各种问题的影响,更不用说维护上的噩梦了。

有了这些,我们将研究可以用来构建应用程序的架构风格,作为提供所需业务功能的松散耦合服务的集合——微服务。

微服务

为了使一个可部署的代码单元有资格成为微服务,它必须具备以下特征:

  • 一个微服务的源代码大小应该小于传统应用程序的大小。另一个大小标准是一个程序员团队应该能够编写和支持其中的几个。

  • 它必须能够独立部署。通常,一个微服务通常会合作并期望其他系统的合作,但这不应该妨碍我们部署它的能力。

  • 如果一个微服务使用数据库存储数据,它必须有自己的模式或一组表。这个说法仍在争论中,特别是在几个服务修改相同数据集或相互依赖的数据集的情况下。如果同一个团队拥有所有相关服务,那么更容易实现。否则,有几种可能的策略来确保独立的微服务开发和部署。

  • 它必须是无状态的,即其状态不应保存在内存中,除非内存是共享的。如果服务的一个实例失败了,另一个实例应该能够完成服务所期望的工作。

  • 它应该提供一种检查其健康的方式——即服务是否正常运行并准备好执行工作。

说到这里,让我们来看看微服务实现的工具包领域。一个人肯定可以从头开始编写微服务,但在这之前,值得看看已经存在的东西,即使你发现没有什么符合你特定需求的。

两个最流行的工具包是 Spring Boot(projects.spring.io/spring-boot)和原始的 J2EE。J2EE 社区成立了 MicroProfile(microprofile.io)倡议,旨在优化企业 Java 以适应微服务架构。KumuluzEE(ee.kumuluz.com)是一个轻量级的符合 MicroProfile 标准的开源微服务框架。

一些其他框架、库和工具包的列表如下(按字母顺序排列):

  • Akka:用于构建高并发、分布式和具有弹性的 Java 和 Scala 消息驱动应用程序的工具包(akka.io/)。

  • Bootique:用于可运行 Java 应用程序的最小化框架(bootique.io/)。

  • Dropwizard:用于开发友好运维、高性能、RESTful Web 服务的 Java 框架(www.dropwizard.io/)。

  • Jodd:一组 Java 微框架、工具和实用程序,不到 1.7 MB(jodd.org/)。

  • Lightbend Lagom:基于 Akka 和 Play 构建的一种倾向性微服务框架(www.lightbend.com/)。

  • Ninja:用于 Java 的全栈 Web 框架(www.ninjaframework.org/)。

  • Spotify Apollo:Spotify 用于编写微服务的一组 Java 库(spotify.github.io/apollo/)。

  • Vert.x:用于在 JVM 上构建反应式应用程序的工具包(vertx.io/)。

所有列出的框架、库和工具包都支持微服务之间的 HTTP/JSON 通信。其中一些还有额外的消息发送方式。如果没有,可以使用任何轻量级的消息系统。我们在这里提到它,因为你可能还记得,基于消息驱动的异步处理是由微服务组成的反应式系统的弹性、响应性和韧性的基础。

为了演示微服务构建的过程,我们将使用 Vert.x,这是一个事件驱动的非阻塞轻量级多语言工具包(组件可以用 Java、JavaScript、Groovy、Ruby、Scala、Kotlin 或 Ceylon 编写)。它支持异步编程模型和分布式事件总线,可达到浏览器 JavaScript,从而实现实时 Web 应用程序的创建。

Vert.x 基础知识

在 Vert.x 世界中的构建块是实现io.vertx.core.Verticle接口的类:

package io.vertx.core;
public interface Verticle {
  Vertx getVertx();
  void init(Vertx vertx, Context context);
  void start(Future<Void> future) throws Exception;
  void stop(Future<Void> future) throws Exception;
}

上述接口的实现称为垂直线。上述接口的大多数方法名称都是不言自明的。getVertex()方法提供对Vertx对象的访问——这是进入 Vert.x Core API 的入口点,该 API 具有允许我们构建微服务构建所需的以下功能的方法:

  • 创建 DNS 客户端

  • 创建周期性服务

  • 创建数据报套接字

  • 部署和取消部署垂直线

  • 提供对共享数据 API 的访问

  • 创建 TCP 和 HTTP 客户端和服务器

  • 提供对事件总线和文件系统的访问

所有部署的垂直线都可以通过标准的 HTTP 协议或使用io.vertx.core.eventbus.EventBus相互通信,形成一个微服务系统。我们将展示如何使用垂直线和来自io.vertx.rxjava包的 RxJava 实现构建一个响应式微服务系统。

可以通过扩展io.vertx.rxjava.core.AbstractVerticle类轻松创建Verticle接口实现:

package io.vertx.rxjava.core;
import io.vertx.core.Vertx;
import io.vertx.core.Context;
import io.vertx.core.AbstractVerticle
public class AbstractVerticle extends AbstractVerticle {
   protected io.vertx.rxjava.core.Vertx vertx;
   public void init(Vertx vertx, Context context) {
      super.init(vertx, context);
      this.vertx = new io.vertx.rxjava.core.Vertx(vertx);
   } 
}

如您所见,上述类扩展了io.vertx.core.AbstractVerticle类:

package io.vertx.core;
import java.util.List;
import io.vertx.core.Verticle;
import io.vertx.core.json.JsonObject;
public abstract class AbstractVerticle implements Verticle {
   protected Vertx vertx;
   protected Context context;
   public void init(Vertx vertx, Context context) {
      this.vertx = vertx;
      this.context = context;
   }
   public Vertx getVertx() { return vertx; }
   public JsonObject config() { return context.config(); }
   public String deploymentID() { return context.deploymentID(); }
   public List<String> processArgs() { return context.processArgs(); }
   public void start(Future<Void> startFuture) throws Exception {
      start();
      startFuture.complete();
   }
   public void stop(Future<Void> stopFuture) throws Exception {
      stop();
      stopFuture.complete();
   }
   public void start() throws Exception {}
   public void stop() throws Exception {}
}

如您所见,您只需要扩展io.vertx.rxjava.core.AbstractVerticle类并实现start()方法。新的垂直线将是可部署的,即使没有实现start()方法,但它将不会执行任何有用的操作。start()方法中的代码是应用功能的入口点。

要使用 Vert.x 并执行示例,必须将以下依赖项添加到项目中:

<dependency>
    <groupId>io.vertx</groupId>
    <artifactId>vertx-web</artifactId>
    <version>${vertx.version}</version>
</dependency>
<dependency>
    <groupId>io.vertx</groupId>
    <artifactId>vertx-rx-java</artifactId>
    <version>${vertx.version}</version>
</dependency>

vertx.version属性可以在pom.xml文件的properties部分中设置:

<properties>
    <vertx.version>3.5.1</vertx.version>
</properties>

使垂直反应的是事件循环(线程)的基础实现,它接收事件(请求)并将其传递给处理程序 - 垂直中的方法或另一个专用类,该类正在处理此类型的事件。程序员通常将它们描述为与每种事件类型关联的函数。当处理程序返回时,事件循环调用回调,实现了我们在上一节中讨论的反应器模式。

对于某些天生具有阻塞性质的程序(例如 JDBC 调用或长时间计算),可以通过工作人员垂直异步执行,而不是通过事件循环(因此不会阻塞它),而是通过单独的线程,使用vertx.executeBlocking()方法。基于事件循环的应用程序设计的黄金法则是,不要阻塞事件循环!违反此规则会使应用程序停滞不前。

作为微服务的 HTTP 服务器

例如,这是一个充当 HTTP 服务器的垂直:

package com.packt.javapath.ch18demo.microservices;
import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.http.HttpServer;
public class HttpServer1 extends AbstractVerticle{
   private int port;
   public HttpServer1(int port) {
       this.port = port;
   }
   public void start() throws Exception {
      HttpServer server = vertx.createHttpServer();
      server.requestStream().toObservable()
         .subscribe(request -> request.response()
             .end("Hello from " + Thread.currentThread().getName() + 
                                         " on port " + port + "!\n\n"));
      server.rxListen(port).subscribe();
      System.out.println(Thread.currentThread().getName() + 
                                 " is waiting on port " + port + "...");
   }
}

在上述代码中,创建了服务器,并将可能请求的数据流包装成Observable。由Observable发出的数据传递给处理请求并生成必要响应的函数(请求处理程序)。我们还告诉服务器要监听的端口,并且现在可以部署此垂直的多个实例,以侦听不同的端口:

vertx().getDelegate().deployVerticle(new HttpServer1(8082));
vertx().getDelegate().deployVerticle(new HttpServer1(8083));

还有一个io.vertx.rxjava.core.RxHelper助手类,可用于部署。它处理了一些对当前讨论不重要的细节:

RxHelper.deployVerticle(vertx(), new HttpServer1(8082));
RxHelper.deployVerticle(vertx(), new HttpServer1(8083));

无论使用哪种方法,您都将看到以下消息:

vert.x-eventloop-thread-0 is waiting on port 8082...
vert.x-eventloop-thread-0 is waiting on port 8083...

这些消息确认了我们的预期:同一事件循环线程正在两个端口上监听。现在,我们可以使用标准的curl命令向任何正在运行的服务器发送请求:

curl localhost:8082

响应将是我们硬编码的响应:

Hello from vert.x-eventloop-thread-0 on port 8082!

周期性服务作为微服务

Vert.x 还允许我们创建一个定期服务,该服务会定期执行某些操作。这是一个例子:

package com.packt.javapath.ch18demo.microservices;
import io.vertx.rxjava.core.AbstractVerticle;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class PeriodicService1 extends AbstractVerticle {
  public void start() throws Exception {
     LocalTime start = LocalTime.now();
     vertx.setPeriodic(1000, v-> {
         System.out.println("Beep!");
         if(ChronoUnit.SECONDS.between(start, LocalTime.now()) > 3 ){
             vertx.undeploy(deploymentID());
         }
     });
     System.out.println("Vertical PeriodicService1 is deployed");
  }
  public void stop() throws Exception {
     System.out.println("Vertical PeriodicService1 is un-deployed");
  }
}

如您所见,此垂直一旦部署,就会每秒打印一次“Beep!”消息,并且在三秒后会自动取消部署。如果我们部署此垂直,我们将看到:

Vertical PeriodicService1 is deployed
Beep!
Beep!
Beep!
Beep!
Vertical PeriodicService1 is un-deployed

当垂直开始时,第一个“嘟嘟声!”响起,然后每秒钟会有三条消息,然后垂直被卸载,正如预期的那样。

作为微服务的 HTTP 客户端

我们可以使用周期性服务垂直向服务器垂直发送消息,使用 HTTP 协议。为了做到这一点,我们需要一个新的依赖项,所以我们可以使用WebClient类:

<dependency>
    <groupId>io.vertx</groupId>
    <artifactId>vertx-web-client</artifactId>
    <version>${vertx.version}</version>
</dependency>

有了这个,向 HTTP 服务器垂直发送消息的周期性服务看起来是这样的:

package com.packt.javapath.ch18demo.microservices;
import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.buffer.Buffer;
import io.vertx.rxjava.ext.web.client.HttpResponse;
import io.vertx.rxjava.ext.web.client.WebClient;
import rx.Single;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class PeriodicService2 extends AbstractVerticle {
    private int port;
    public PeriodicService2(int port) {
        this.port = port;
    }
    public void start() throws Exception {
        WebClient client = WebClient.create(vertx);
        Single<HttpResponse<Buffer>> single = client
                .get(port, "localhost", "?name=Nick")
                .rxSend();
        LocalTime start = LocalTime.now();
        vertx.setPeriodic(1000, v-> {
           single.subscribe(r-> System.out.println(r.bodyAsString()),
                             Throwable::printStackTrace);
           if(ChronoUnit.SECONDS.between(start, LocalTime.now()) >= 3 ){
              client.close(); 
              vertx.undeploy(deploymentID());
              System.out.println("Vertical PeriodicService2 undeployed");
           }
        });
        System.out.println("Vertical PeriodicService2 deployed");
    }
}

正如您所看到的,这个周期性服务接受端口号作为其构造函数的参数,然后每秒向本地主机的此端口发送一条消息,并在三秒后卸载自己。消息是name参数的值。默认情况下,它是 GET 请求。

我们还将修改我们的服务器垂直以读取name参数的值:

public void start() throws Exception {
    HttpServer server = vertx.createHttpServer();
    server.requestStream().toObservable()
          .subscribe(request -> request.response()
             .end("Hi, " + request.getParam("name") + "! Hello from " + 
          Thread.currentThread().getName() + " on port " + port + "!"));
    server.rxListen(port).subscribe();
    System.out.println(Thread.currentThread().getName()
                               + " is waiting on port " + port + "...");
}

我们可以部署两个垂直:

RxHelper.deployVerticle(vertx(), new HttpServer2(8082));
RxHelper.deployVerticle(vertx(), new PeriodicService2(8082));

输出将如下所示:

Vertical PeriodicService2 deployed
vert.x-eventloop-thread-0 is waiting on port 8082...
Hi, Nick! Hello from vert.x-eventloop-thread-0 on port 8082!
Hi, Nick! Hello from vert.x-eventloop-thread-0 on port 8082!
Vertical PeriodicService2 undeployed
Hi, Nick! Hello from vert.x-eventloop-thread-0 on port 8082!

其他微服务

原则上,整个微服务系统可以基于使用 HTTP 协议发送的消息构建,每个微服务都实现为 HTTP 服务器或者将 HTTP 服务器作为消息交换的前端。或者,可以使用任何其他消息系统进行通信。

在 Vert.x 的情况下,它有自己基于事件总线的消息系统。在下一节中,我们将演示它,并将其用作反应式系统可能看起来的一个例子。

我们的示例微服务的大小可能会给人留下微服务必须像对象方法一样细粒度的印象。在某些情况下,值得考虑特定方法是否需要扩展。事实上,这种架构风格足够新颖,可以提供明确的大小建议,并且现有的框架、库和工具包足够灵活,可以支持几乎任何大小的独立部署服务。如果可部署的独立服务与传统应用程序一样大,那么它可能不会被称为微服务,而是外部系统或类似的东西。

反应式系统

熟悉事件驱动架构EDA)概念的人可能已经注意到它与反应式系统的想法非常相似。它们的描述使用非常相似的语言和图表。不同之处在于 EDA 只涉及软件系统的一个方面——架构。另一方面,反应式系统更多地涉及代码风格和执行流程,包括强调使用异步数据流。因此,反应式系统可以具有 EDA,而 EDA 可以实现为反应式系统。

让我们看另一组示例,以了解使用 Vert.x 实现的反应式系统可能是什么样子。请注意,Vert.x API 有两个源树:一个以io.vertx.core开头,另一个以io.vertx.rxjava开头。由于我们正在讨论反应式编程,我们将使用io.vertx.rxjava下的包,称为 rx-fied Vert.x API。

消息驱动系统

Vert.x 具有直接支持消息驱动架构和 EDA 的功能。它被称为事件总线。任何 verticle 都可以访问事件总线,并且可以使用io.vertx.core.eventbus.EventBus类或其类似物io.vertx.rxjava.core.eventbus.EventBus向任何地址(只是一个字符串)发送任何消息。我们只会使用后者,但是io.vertx.core.eventbus.EventBus中也提供了类似(非 rx-fied)的功能。一个或多个 verticle 可以注册自己作为某个地址的消息消费者。如果有多个 verticle 是相同地址的消费者,那么EventBusrxSend()方法使用循环算法仅将消息传递给这些消费者中的一个,以选择下一条消息的接收者。或者,publish()方法会将消息传递给具有相同地址的所有消费者。以下是将消息发送到指定地址的代码:

vertx.eventBus().rxSend(address, msg).subscribe(reply -> 
    System.out.println("Got reply: " + reply.body()), 
    Throwable::printStackTrace );

rxSend()方法返回表示可以接收的消息的Single<Message>对象,并且subscribe()方法...嗯...订阅它。Single<Message>类实现了单个值响应的反应式模式。subscribe()方法接受两个Consumer函数:第一个处理回复,第二个处理错误。在前面的代码中,第一个函数只是打印回复:

reply -> System.out.println("Got reply: " + reply.body())

第二个操作打印异常的堆栈跟踪,如果发生异常:

Throwable::printStackTrace

如您所知,前面的结构称为方法引用。作为 lambda 表达式的相同函数将如下所示:

e -> e.printStackTrace()

publish()方法的调用看起来很相似:

vertx.eventBus().publish(address, msg)

它将消息发布给许多消费者,因此该方法不会返回Single对象或任何其他可用于获取回复的对象。相反,它只返回一个EventBus对象;如果需要,可以调用更多的事件总线方法。

消息消费者

在 Vert.x 中的消息消费者是一个 verticle,它在事件总线上注册为指定地址发送或发布的消息的潜在接收者:

package com.packt.javapath.ch18demo.reactivesystem;
import io.vertx.rxjava.core.AbstractVerticle;
public class MsgConsumer extends AbstractVerticle {
    private String address, name;
    public MsgConsumer(String id, String address) {
        this.address = address;
        this.name = this.getClass().getSimpleName() + 
                                    "(" + id + "," + address + ")";
    }
    public void start() throws Exception {
        System.out.println(name + " starts...");
        vertx.eventBus().consumer(address).toObservable()
         .subscribe(msg -> {
            String reply = name + " got message: " + msg.body();
            System.out.println(reply);
            if ("undeploy".equals(msg.body())) {
                vertx.undeploy(deploymentID());
                reply = name + " undeployed.";
                System.out.println(reply);
            }
            msg.reply(reply);
        }, Throwable::printStackTrace );
        System.out.println(Thread.currentThread().getName()
                + " is waiting on address " + address + "...");
    }
}

consumer(address)方法返回一个io.vertx.rxjava.core.eventbus.MessageConsumer<T>对象,表示提供的地址的消息流。这意味着可以将流转换为Observable并订阅它以接收发送到此地址的所有消息。Observable对象的subscribe()方法接受两个Consumer函数:第一个处理接收到的消息,第二个在发生错误时执行。在第一个函数中,我们包含了msg.reply(reply)方法,它将消息发送回消息的来源。您可能还记得,如果原始消息是通过rxSend()方法发送的,发送方可以获得此回复。如果使用了publish()方法,那么由msg.reply(reply)方法发送的回复将无处可去。

还要注意,当接收到undeploy消息时,消息消费者会取消部署自身。通常只在自动部署期间使用此方法,当旧版本被新版本替换而不关闭系统时。

因为我们将部署几个具有相同地址的消息消费者进行演示,所以我们添加了id参数并将其包含在name值中。此值用作所有消息中的前缀,因此我们可以跟踪消息在系统中的传播。

您可能已经意识到,前面的实现只是一个可以用来调用一些有用功能的外壳。接收到的消息可以是执行某些操作的命令,要处理的数据,要存储在数据库中的数据,或者其他任何内容。回复可以是收到消息的确认,或者其他预期的结果。如果是后者,处理应该非常快,以避免阻塞事件循环(记住黄金法则)。如果处理不能很快完成,回复也可以是一个回调令牌,稍后由发送方用来检索结果。

消息发送者

我们将演示的消息发送者基于我们在微服务部分演示的 HTTP 服务器实现。不一定非要这样做。在实际代码中,垂直通常会自动发送消息,要么获取它需要的数据,要么提供其他垂直需要的数据,要么通知另一个垂直,要么将数据存储在数据库中,或者出于任何其他原因。但是出于演示目的,我们决定发送方将侦听某个端口以接收消息,并且我们将手动(使用curl命令)或自动(通过微服务部分描述的某个周期性服务)发送消息给它。这就是为什么消息发送者看起来比消息消费者复杂一些:

package com.packt.javapath.ch18demo.reactivesystem;
import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.http.HttpServer;
public class EventBusSend extends AbstractVerticle {
    private int port;
    private String address, name;
    public EventBusSend(int port, String address) {
       this.port = port;
       this.address = address;
       this.name = this.getClass().getSimpleName() + 
                      "(port " + port + ", send to " + address + ")";
    }
    public void start() throws Exception {
       System.out.println(name + " starts...");
       HttpServer server = vertx.createHttpServer();
       server.requestStream().toObservable().subscribe(request -> {
         String msg = request.getParam("msg");
         request.response().setStatusCode(200).end();
 vertx.eventBus().rxSend(address, msg).subscribe(reply -> {
            System.out.println(name + " got reply:\n  " + reply.body());
         },
         e -> {
            if(StringUtils.contains(e.toString(), "NO_HANDLERS")){
                vertx.undeploy(deploymentID());
                System.out.println(name + " undeployed.");
            } else {
                e.printStackTrace();
            }
         }); });
       server.rxListen(port).subscribe();
       System.out.println(Thread.currentThread().getName()
                               + " is waiting on port " + port + "...");
    }
}

大部分前面的代码与 HTTP 服务器功能相关。发送消息(由 HTTP 服务器接收)的几行是这些:

        vertx.eventBus().rxSend(address, msg).subscribe(reply -> {
            System.out.println(name + " got reply:\n  " + reply.body());
        }, e -> {
            if(StringUtils.contains(e.toString(), "NO_HANDLERS")){
                vertx.undeploy(deploymentID());
                System.out.println(name + " undeployed.");
            } else {
                e.printStackTrace();
            }
        });

发送消息后,发送者订阅可能的回复并打印它(如果收到了回复)。如果发生错误(在发送消息期间抛出异常),我们可以检查异常(转换为String值)是否包含文字NO_HANDLERS,如果是,则取消部署发送者。我们花了一段时间才弄清楚如何识别没有分配给此发送者发送消息的消费者的情况。如果没有消费者(很可能都取消部署了),那么发送者就没有必要了,所以我们取消部署它。

清理和取消部署所有不再需要的 verticle 是一个好习惯。但是,如果在 IDE 中运行 verticle,很有可能一旦停止创建 verticle 的主进程(已在 IDE 中创建 verticle),所有 verticle 都会停止。如果没有,请运行jcmd命令,并查看是否仍在运行 Vert.x verticle。列出的每个进程的第一个数字是进程 ID。识别不再需要的 verticle,并使用kill -9 <process ID>命令停止它们。

现在,让我们部署两个消息消费者,并通过我们的消息发送者向它们发送消息:

String address = "One";
Vertx vertx = vertx();
RxHelper.deployVerticle(vertx, new MsgConsumer("1",address));
RxHelper.deployVerticle(vertx, new MsgConsumer("2",address));
RxHelper.deployVerticle(vertx, new EventBusSend(8082, address));

运行前面的代码后,终端显示以下消息:

MsgConsumer(1,One) starts...
MsgConsumer(2,One) starts...
EventBusSend(port 8082, send to One) starts...
vert.x-eventloop-thread-1 is waiting on address One...
vert.x-eventloop-thread-0 is waiting on address One...
vert.x-eventloop-thread-2 is waiting on port 8082...

注意运行以支持每个 verticle 的不同事件循环。

现在,让我们使用终端窗口中的以下命令发送几条消息:

curl localhost:8082?msg=Hello!
curl localhost:8082?msg=Hi!
curl localhost:8082?msg=How+are+you?
curl localhost:8082?msg=Just+saying...

加号(+)是必需的,因为 URL 不能包含空格,必须编码,这意味着,除其他外,用加号+%20替换空格。作为对前述命令的响应,我们将看到以下消息:

MsgConsumer(2,One) got message: Hello!
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(2,One) got message: Hello!
MsgConsumer(1,One) got message: Hi!
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(1,One) got message: Hi!
MsgConsumer(2,One) got message: How are you?
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(2,One) got message: How are you?
MsgConsumer(1,One) got message: Just saying...
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(1,One) got message: Just saying...

正如预期的那样,消费者根据循环算法轮流接收消息。现在,让我们部署所有的垂直线:

curl localhost:8082?msg=undeploy
curl localhost:8082?msg=undeploy
curl localhost:8082?msg=undeploy

以下是对前述命令的响应中显示的消息:

MsgConsumer(1,One) got message: undeploy
MsgConsumer(1,One) undeployed.
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(1,One) undeployed.
MsgConsumer(2,One) got message: undeploy
MsgConsumer(2,One) undeployed.
EventBusSend(port 8082, send to One) got reply:
 MsgConsumer(2,One) undeployed.
EventBusSend(port 8082, send to One) undeployed.

根据前面的消息,我们所有的垂直线都未部署。如果我们再次提交undeploy消息,我们将看到:

curl localhost:8082?msg=undeploy
curl: (7) Failed to connect to localhost port 8082: Connection refused

这是因为发送者已被取消部署,并且本地主机的端口8082没有监听的 HTTP 服务器。

消息发布者

我们实现了消息发布者与消息发送者非常相似:

package com.packt.javapath.ch18demo.reactivesystem;

import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.http.HttpServer;

public class EventBusPublish extends AbstractVerticle {
    private int port;
    private String address, name;
    public EventBusPublish(int port, String address) {
        this.port = port;
        this.address = address;
        this.name = this.getClass().getSimpleName() + 
                    "(port " + port + ", publish to " + address + ")";
    }
    public void start() throws Exception {
        System.out.println(name + " starts...");
        HttpServer server = vertx.createHttpServer();
        server.requestStream().toObservable()
                .subscribe(request -> {
                    String msg = request.getParam("msg");
                    request.response().setStatusCode(200).end();
 vertx.eventBus().publish(address, msg);
                    if ("undeploy".equals(msg)) {
 vertx.undeploy(deploymentID());
                        System.out.println(name + " undeployed.");
                    }
                });
        server.rxListen(port).subscribe();
        System.out.println(Thread.currentThread().getName()
                + " is waiting on port " + port + "...");
    }
}

发布者与发送者的区别仅在于此部分:

            vertx.eventBus().publish(address, msg);
            if ("undeploy".equals(msg)) {
                vertx.undeploy(deploymentID());
                System.out.println(name + " undeployed.");
            }

由于在发布时无法获得回复,因此前面的代码比发送消息的代码简单得多。此外,由于所有消费者同时收到undeploy消息,我们可以假设它们都将被取消部署,并且发布者可以取消部署自己。让我们通过运行以下程序来测试它:

String address = "One";
Vertx vertx = vertx();
RxHelper.deployVerticle(vertx, new MsgConsumer("1",address));
RxHelper.deployVerticle(vertx, new MsgConsumer("2",address));
RxHelper.deployVerticle(vertx, new EventBusPublish(8082, address));

作为对前面的代码执行的响应,我们得到以下消息:

MsgConsumer(1,One) starts...
MsgConsumer(2,One) starts...
EventBusPublish(port 8082, publish to One) starts...
vert.x-eventloop-thread-2 is waiting on port 8082...

现在,我们在另一个终端窗口中发出以下命令:

curl localhost:8082?msg=Hello!

在运行垂直线的终端窗口中的消息如下:

MsgConsumer(1,One) got message: Hello!
MsgConsumer(2,One) got message: Hello!

如预期的那样,具有相同地址的两个消费者都会收到相同的消息。现在,让我们将它们取消部署:

curl localhost:8082?msg=undeploy

垂直线对这些消息做出响应:

MsgConsumer(1,One) got message: undeploy
MsgConsumer(2,One) got message: undeploy
EventBusPublish(port 8082, publish to One) undeployed.
MsgConsumer(1,One) undeployed.
MsgConsumer(2,One) undeployed.

如果我们再次提交undeploy消息,我们将看到:

curl localhost:8082?msg=undeploy
curl: (7) Failed to connect to localhost port 8082: Connection refused

通过这样,我们已经完成了一个由微服务组成的反应式系统的演示。添加能够执行有用操作的方法和类将使其更接近实际系统。但我们将把这留给读者作为练习。

现实检查

我们在一个 JVM 进程中运行了所有之前的示例。如果需要,Vert.x 实例可以部署在不同的 JVM 进程中,并通过在run命令中添加-cluster选项来进行集群化,当垂直部署不是从 IDE,而是从命令行时。集群化的垂直共享事件总线,地址对所有 Vert.x 实例可见。这样,如果某些地址的消费者无法及时处理请求(消息),就可以部署更多的消息消费者。

我们之前提到的其他框架具有类似的功能。它们使微服务的创建变得容易,并可能鼓励将应用程序分解为微小的、单方法的操作,期望组装一个非常具有弹性和响应性的系统。然而,这些并不是良好软件的唯一标准。系统分解增加了部署的复杂性。此外,如果一个开发团队负责许多微服务,那么在不同阶段(开发、测试、集成测试、认证、暂存和生产)对这么多部分进行版本控制的复杂性可能会导致混乱。部署过程可能变得如此复杂,以至于减缓变更速度是必要的,以使系统与市场需求保持同步。

除了开发微服务之外,还必须解决许多其他方面,以支持反应式系统:

  • 必须建立监控系统以提供对应用程序状态的洞察,但其开发不应该如此复杂,以至于将开发资源从主要应用程序中分散开来。

  • 必须安装警报以及及时警告团队可能和实际问题,以便在影响业务之前解决这些问题。

  • 如果可能的话,必须实施自我纠正的自动化流程。例如,必须实施重试逻辑,并在宣布失败之前设定合理的尝试上限。

  • 一层断路器必须保护系统免受多米诺效应的影响,当一个组件的故障剥夺了其他组件所需的资源时。

  • 嵌入式测试系统应该能够引入干扰并模拟负载增加,以确保应用程序的弹性和响应性不会随着时间的推移而降低。例如,Netflix 团队引入了混沌猴——一个能够关闭生产系统的各个部分并测试其恢复能力的系统。他们甚至在生产中使用它,因为生产环境具有特定的配置,而在另一个环境中的测试无法保证找到所有可能的问题。

正如你现在可能已经意识到的那样,在承诺采用反应式系统之前,团队必须权衡所有的利弊,以确切地理解他们为什么需要反应式系统,以及其开发的代价。有一句古老的格言说:“没有免费的午餐”。反应式系统的强大力量伴随着复杂性的相应增长,不仅在开发过程中,而且在系统调优和维护过程中也是如此。

然而,如果传统系统无法解决您面临的处理问题,或者如果您对一切反应式并且热爱这个概念,那么请尽管去做。旅程将充满挑战,但回报将是值得的。正如另一句古老的格言所说,“轻而易举地实现不值得努力”。

练习-创建 io.reactivex.Observable

编写代码演示创建io.reactivex.Observable的几种方法。在每个示例中,订阅创建的Observable对象并打印发出的值。

我们没有讨论这一点,因此您需要学习 RxJava2 API 并在互联网上查找示例。

答案

以下是允许您创建io.reactivex.Observable的六种方法:

//1
Observable.just("Hi!").subscribe(System.out::println); //prints: Hi!
//2
Observable.fromIterable(List.of("1","2","3"))
          .subscribe(System.out::print); //prints: 123
System.out.println();
//3
String[] arr = {"1","2","3"};
Observable.fromArray(arr).subscribe(System.out::print); //prints: 123
System.out.println();
//4
Observable.fromCallable(()->123)
          .subscribe(System.out::println); //prints: 123
//5
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<String> future = pool
        .submit(() -> {
            Thread.sleep(100);
            return "Hi!";
        });
Observable.fromFuture(future)
          .subscribe(System.out::println); //prints: Hi!
pool.shutdown();
//6
Observable.interval(100, TimeUnit.MILLISECONDS)
          .subscribe(v->System.out.println("100 ms is over")); 
                                     //prints twice "100 ms is over"
try { //this pause gives the above method a chance to print the message
    TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
    e.printStackTrace();
}

摘要

在本书的最后一章中,我们向读者提供了一个真实专业编程的一瞥,并简要概述了这一行业的挑战。我们重新审视了许多现代术语,这些术语与使用高度可扩展、响应迅速和具有弹性的反应式系统相关,这些系统能够解决现代时代的具有挑战性的处理问题。我们甚至提供了这些系统的代码示例,这可能是您真实项目的第一步。

我们希望您保持好奇心,继续学习和实验,并最终建立一个能够解决真实问题并为世界带来更多幸福的系统。

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报