Java-项目学习指南-全-

Java 项目学习指南(全)

原文:zh.annas-archive.org/md5/213b34f3b33252c99b2c0bd8aff6356f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 Java 编程的世界!Java 是世界上最多才多艺和最广泛使用的编程语言之一。它的平台独立性、面向对象的特点以及广泛的库支持使其成为开发各种应用程序的理想选择,从桌面到移动和企业解决方案。

无论你是渴望学习基础知识的初学者,还是寻求提升技能的经验丰富的开发者,这本书都是为了帮助你掌握 Java 编程语言而设计的全面指南。我们从基础知识开始:如何设置编辑器;原始数据类型;然后系统地进步到更高级的概念,如 lambda 表达式、流和并发。

这本书不仅仅是一本指南;它是你在掌握 Java 旅程上的伴侣。我们的目标是让这段旅程既愉快又有效。本书采用了一种动手实践的方法,将理论概念与大量的实践练习和综合项目相结合。无论你是自学成才者还是正规教育项目的一部分,动手练习和综合项目都将帮助你巩固知识,使学习体验既吸引人又实用。

实践出真知 在掌握任何编程语言中至关重要,我们在本书中对此深信不疑。在每个章节的结尾,你将有一些练习和一个项目,帮助你获得一些 Java 编程的经验。练习通常是较小的任务,而项目则稍微大一些。在所有这些中,你将有相当大的自由度来选择如何具体实现。为什么?因为这就是现实生活中的样子!我们将提供一些示例解决方案,但如果我们的解决方案与我们的不同,这并不意味着你的解决方案不好。如果你有疑问,可以询问一些 AI 助手,比如 ChatGPT,看看他们认为你的解决方案如何。还是不清楚?我们随时愿意帮助你!

好吧,回到练习和项目上来。我们希望为我们的练习和项目选择一个共同的主题。信不信由你,但我们的其中一位作者(提示:是那位女性)在写书的时候被微型巨型史前爬行动物复制品包围着。事实上,现在打字都很困难,因为这只巨大的电池供电的霸王龙试图摧毁我的笔记本电脑。

我不禁从中获得了不少灵感。所以,是的,所有的练习和项目都将围绕恐龙主题,基于我 5 岁儿子收集和生动的幻想游戏。 (我通常在 5 点到 6 点之间醒来。不是被闹钟叫醒的,当然也不是因为我加入了 5 点起床俱乐部。不,是有一个(最神奇)兴奋的孩子在告诉我关于恐龙的有趣事实。不妨把这种知识用起来!)

因此,这是你的背景:恭喜你,你被录用了!你现在正在为我们特殊的恐龙动物园:中生代伊甸园工作。这是一个史前荒野与现代舒适的独特结合,人类和恐龙在这里共存。人们可以在这里参观一天,或者在这里露营几周。

在中生代伊甸园,我们拥有丰富的恐龙物种,每种都有其独特的行为和生活方式,从庞大的雷龙到敏捷的迅猛龙,威严的霸王龙,以及许多其他恐龙。我们甚至有一个最先进的实验室,我们在这里继续发现和研究新的恐龙品种。

作为我们团队的一员,你的角色不仅是要照顾这些雄伟的生物并确保它们的健康,还要维护我们客人的安全和安保。我们的公园采用尖端技术和严格的协议,以确保为所有人提供一个安全的环境。

在练习和项目中,你将作为中生代伊甸园的一名员工承担各种软件开发任务,从编写喂食计划的软件到处理紧急警报的应用程序,确保公园运营顺利,最重要的是,为我们的游客创造难忘的体验。

这本书面向的对象

这本书适合想要开始编程之旅的初学者,以及正在转向 Java 的资深开发者。无论你是学生还是专业人士,内容都旨在满足不同受众的需求。

如果你对 Java 8 OCA Oracle 认证感兴趣,那么这本书非常有帮助,因为它通过深入内存来解释正在发生的事情,涵盖了众多重要的基本概念。两位作者都是 Oracle OCP 认证的,这并非巧合。

这本书涵盖的内容

第一章Java 入门,首先讨论了 Java 的主要特性,如面向对象编程。还探讨了如何在各种操作系统上安装 Java,以及如何使用 IDE 或不使用 IDE 编写你的第一个 Java 程序。

第二章变量和原始数据类型,解释了什么是变量,以及 Java 使用“强类型”(你必须声明变量的类型)。本章还涵盖了 Java 的原始数据类型,它们的字节大小(在讨论类型转换时需要了解),以及它们的范围。

第三章运算符和类型转换,探讨了 Java 的运算符如何通过优先级和结合性协同工作。我们讨论了 Java 的各种运算符,并解释了在 Java 中进行类型转换时的宽化和窄化。

第四章条件语句,侧重于作用域和条件语句。我们首先检查 Java 的块作用域的使用。然后解释了if语句的各种形式;并以switch语句和switch表达式结束本章。

第五章, 理解迭代,讨论了循环,包括whiledo-whilefor和增强型 for 循环。本章还探讨了breakcontinue语句。

第六章, 使用数组,描述了为什么需要数组。我们展示了如何声明和初始化各种原始类型的数组,包括使用简写语法。我们讨论了如何遍历数组,处理每个元素。也涵盖了多维数组;以及Arrays类。

第七章, 方法,讨论了方法的重要性以及方法定义和方法执行之间的区别。讨论了方法重载,并解释了可变参数格式。最后,解释了按值调用的重要概念。

第八章, 类、对象和枚举,是一个重要的面向对象编程章节,详细介绍了以下内容:类和对象之间的区别;this引用;访问修饰符;基本和高级封装;对象生命周期;instanceof关键字;枚举和记录。

第九章**,继承和多态,解释了继承和多态。我们详细说明了重写意味着什么,并讨论了superprotectedabstractfinal关键字。我们还探讨了sealed类和向上转型/向下转型。

第十章**,接口和抽象类,涵盖了abstract类和接口。我们解释了staticdefaultprivate接口方法,以及sealed接口。

第十一章处理异常,解释了异常及其目的。我们解释了检查型异常和非检查型异常之间的区别。我们深入探讨了抛出异常以及如何创建自己的自定义异常。讨论了重要的捕获或声明原则;以及try-catchtry-catch-finallytry-with-resources块。

第十二章, Java 核心 API,介绍了 API 中的重要类/接口,例如Scanner。我们比较和对比了StringStringBuilder,并讨论了如何创建自定义不可变类型。我们举例说明了List接口及其流行的实现ArrayList。最后,我们探讨了日期 API。

第十三章, 泛型和集合,讨论了集合框架及其接口ListSetMapQueue。我们检查了每个接口的几个实现和基本操作。我们解释了使用ComparableComparator接口进行排序。最后,我们检查了泛型和基本哈希概念。

第十四章Lambda 表达式,解释了 lambda 表达式是什么以及它们与函数式接口的关系。检查了 API 中的几个函数式接口。最后,概述了方法引用及其在理解中的作用。

第十五章流:基础,是我们关于流的第一个章节。在本章中,我们讨论了流管道是什么以及流惰性的含义。我们展示了创建有限和无限流的不同方法。我们检查了启动流过程的终端操作,包括如 collect() 这样的聚合操作,这对于从流中提取信息非常有用。

第十六章流:高级概念,首先检查了中间操作,如 filter()map()sorted()。我们探讨了原始流,然后讨论了如何将一个流映射到另一个流,无论类型如何。解释了 Optional 类型,并以并行流的讨论结束。

第十七章并发,首先解释了并发是什么。我们检查了线程的使用,并讨论了并发访问的问题。讨论了解决这些问题的机制;即:原子类、synchronized 块和 Lock 接口。接下来探讨了并发集合和 ExecutorService。我们以讨论线程问题,如数据竞争、死锁和活锁结束。

为了充分利用这本书

尽管大部分代码可以在更早的 Java 版本上运行,但我们建议安装或升级到 Java 21 以避免版本相关的编译错误。

如果您目前系统上没有任何东西,以下设置将非常理想:

  • JDK 21 或更高版本(Oracle 的 JDK 或 OpenJDK)

  • IntelliJ IDEA(社区版已经足够好)或 Eclipse 或 Netbeans

本书涵盖的软件/硬件 操作系统要求
Java 21+ Windows, macOS, 或 Linux

查看 第一章 了解如何在各种操作系统上安装 Java 和 IDE 的说明。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

免责声明

为了出版者和作者的本意,本书中包含的一些图形展示了大屏幕示例,其中文本内容与图形示例不相关。我们鼓励读者下载购买中包含的数字副本,以满足放大和可访问的内容需求。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Learn-Java-with-Projects。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

代码实战

本书的相关代码实战视频可在bit.ly/3GdtYeC查看。

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781837637188

我们使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“因此,main()方法现在已将控制权交给simpleExample()方法,并且只有在simpleExample()退出之前,控制权不会返回到main()”。

代码块设置如下:

public class HelloWorld { public static void main(String[] args) { 
System.out.println("Hello world!"); 
} 
} 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

//        int age = 25;        System.out.println(age);

任何命令行输入或输出都应如下编写:

Enter a number (negative number to exit) -->1
Enter a number (negative number to exit) -->
2

小贴士或重要注意事项

看起来是这样的。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/support/errata并填写表格。

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

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

分享您的想法

读完《通过项目学习 Java》后,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件访问权限

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/9781837637188

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

第一部分:Java 基础

在这部分,我们将首先探讨 Java 的特点以及如何使用 Java 和 IDE 进行设置。我们将检查变量和 Java 的八个原始数据类型。随后,我们将讨论 Java 的运算符和类型转换。然后,我们将继续探讨 Java 的条件语句和循环结构。之后,我们将查看如何使用数组,最后以方法结束。

本节包含以下章节:

  • 第一章Java 入门

  • 第二章变量和原始数据类型

  • 第三章运算符和类型转换

  • 第四章条件语句

  • 第五章理解迭代

  • 第六章与数组一起工作

  • 第七章方法

第一章:Java 入门

欢迎来到 Java 的精彩世界!Java 是一种非常流行的编程语言。它是一种多用途、强大且流行的编程语言,被全球数百万开发者使用,以创建各种应用程序。是的,它确实是多用途的,因为它可以用来创建各种应用程序,从 Web 和移动应用,到游戏开发以及其他更多。

因此,您已经选择了一种(新)语言。我们将带您踏上(希望是)令人着迷的旅程,这将为您提供宝贵的技能,并在不断发展的技术领域中开辟新的机会。

我们在等待什么?在本章中,我们将涵盖以下主要主题:

  • Java 特性

  • 安装 Java

  • 编译和运行 Java 程序

  • 使用集成开发环境IDE)进行工作

  • 使用 IDE 创建和运行程序

技术要求

在深入 Java 编程的神奇世界之前,让我们确保您拥有合适的硬件。如果您的硬件不符合这些要求,请不要担心;本章后面将讨论在线替代方案。如果您正在使用工作笔记本电脑,请确保您有下载权限。以下是要求的简要概述:

  • 操作系统:Java 可以在包括 Windows、macOS 和 Linux 在内的各种操作系统上运行。请确保您在计算机上安装了这些操作系统的最新版本。

  • Java 开发工具包JDK):要编译和运行 Java 程序,您需要在您的系统上安装 JDK。JDK 包括Java 运行环境JRE),其中包含运行 Java 应用程序所需的库和组件。我们将在稍后介绍如何安装它。

  • 系统资源:越多越好,但 Java 的要求并不高。它不需要高端硬件,但仍然建议拥有一个资源充足的系统,以便获得流畅的开发体验。以下是最小和推荐的系统要求:

    • 最低要求

      • CPU:1 GHz 或更快的处理器

      • RAM:2 GB

      • 磁盘空间:1 GB(用于 JDK 安装和附加文件)

    • 推荐要求

      • CPU:2 GHz 或更快的多核处理器

      • RAM:4 GB 或更多

      • 磁盘空间:2 GB 或更多(用于 JDK 安装、附加文件和项目)

请记住,这些要求可能会随着 JDK 和相关工具的未来更新而改变。我们已经将这些文件放在 GitHub 仓库中。您可以使用 Git 克隆项目,并以此方式将它们导入到您的计算机上。这里不涉及如何使用 Git 的说明,但建议您独立研究。您可以通过以下链接访问本书中使用的文件和示例:github.com/PacktPublishing/Learn-Java-with-Projects

探索 Java 特性

Java 是由 James Gosling 在 20 世纪 90 年代中期在 Sun Microsystems 开发的。当 Java 被创造出来时,它最初被设计为一种用于消费电子产品的语言。它试图支持复杂的宿主架构,专注于可移植性,并支持安全网络。然而,Java 超出了自己的野心。它迅速成为一种多用途的语言,用于创建企业、Web 和移动应用程序。如今,Java 不再属于 Sun Microsystems。Oracle Corporation 在 2010 年收购了 Sun Microsystems。随着这次收购,Java 成为了 Oracle 软件生态系统的一个组成部分。

Java 在它被创造的时候非常独特。Java 的巨大成功可以归因于其一些核心特性。这些特性在当时是非常创新的,但现在在许多其他(竞争)语言中也能找到。其中一个核心特性是面向对象编程。OOP 允许我们以一种整洁的方式组织代码,这有助于代码的可重用性和可维护性。我们将通过查看面向对象编程OOP)来开始讨论这些核心特性。

Java 中的 OOP

不可否认,Java 最重要的特性是其对 OOP 的支持。如果你问任何 Java 开发者 Java 是什么,答案通常是它是一种 OOP 语言。

可以肯定地说,OOP 是一个关键特性。这个 OOP 是什么东西?你可能想知道。OOP 是一种编程范式。它将应用程序结构化,以模拟现实世界对象及其交互和行为。让我们回顾一下 OOP 的主要概念:

  • 对象:这可能是显而易见的,但在 OOP 中,对象是程序的主要构建块。对象是现实世界实体的表示,例如用户、电子邮件或银行账户。每个对象都有自己的属性(数据字段)和行为(方法)。

  • Car类可能定义了诸如颜色、制造商和型号等属性,以及启动、加速和制动等方法。

  • Car可以继承自Vehicle类。我们在这里不会详细介绍细节,但继承有助于更好地组织代码。代码更可重用,相关类的层次结构为我们使用类型打开了大门。

  • 封装:封装是给予一个类对其自身数据的控制。这是通过捆绑数据(属性)和操作这些数据的方法来实现的。属性只能通过这些特殊方法从外部访问。封装有助于保护对象的内部状态,并允许你控制对象的数据如何被访问或修改。如果你觉得这听起来仍然很棘手,不要担心,我们将在稍后更详细地讨论这个问题。

  • 多态抽象:这些是 OOP 的两个关键概念,将在你准备好时进行解释。

使用 OOP

我可以想象现在这一切听起来可能非常抽象,但很快你就会自己创建类和实例化对象。面向对象编程有助于使代码更易于维护、结构更清晰、可重用。这些因素确实有助于在需要时能够对应用程序进行更改、解决问题和扩展。

面向对象编程(OOP)只是 Java 的一个关键特性。另一个关键特性是它是一种编译型语言。让我们确保你现在理解这个意思。

编译型语言

Java 是一种编译型编程语言,这意味着你编写的源代码必须在被解释之前转换为机器可读格式。这种机器可读格式称为字节码。这个过程与解释型语言不同,解释型语言是在运行时逐行读取、解释和执行的。当编译型语言运行时,计算机在运行时解释字节码。当我们准备编译自己的代码时,我们将在稍后深入了解编译过程。现在,让我们看看编译型语言的优点是什么。

Java 作为编译型语言的优点

首先编译代码需要额外的一步,并且一开始会花费一些时间,但会带来优势。首先,编译型语言的性能通常比解释型语言更好。这是因为字节码被优化以在目标平台上高效执行。

编译的另一个优点是在代码执行之前可以提前检测到语法错误和某些其他类型的错误。这使得开发者能够在部署应用程序之前识别和修复问题,从而降低运行时错误的可能性。

Java 代码通过编译器转换为字节码——一种二进制代码的形式。这种字节码是平台无关的。这意味着它允许 Java 应用程序在不同的操作系统上运行而无需修改。平台无关性实际上是我们接下来将要讨论的关键特性。

编写一次,到处运行

Java 的一次编写,到处运行WORA)原则是另一个关键特性。这曾经使 Java 与其他许多编程语言区分开来,但现在,这已经相当普遍,许多竞争语言也实现了这一特性。这个原则确保 Java 代码可以在不同的平台上运行,而无需为每个平台提供不同的 Java 代码版本。这意味着 Java 程序不会绑定到任何特定的操作系统或硬件架构。

当你为每个平台有不同的代码版本时,这意味着你必须维护所有这些代码版本。假设你有一个 Linux、macOS 和 Windows 的代码库。当需要新功能或变更时,你需要将其添加到三个地方!你可以想象,当 Java 出现时,WORA 是一个颠覆性的变化。它导致你的应用程序的覆盖范围增加——任何可以运行 Java 应用程序的设备都可以运行你的应用程序。

理解 WORA 元素

WORA(一次编写,到处运行)原则得以实现得益于字节码和Java 虚拟机JVM)。字节码是编译后的 Java 程序。编译器将 Java 代码转换成这种字节码,而这种字节码是平台无关的。它可以在任何能够运行字节码执行器的设备上运行。

这个字节码执行器被称为 JVM。每个平台(Windows、macOS、Linux 等)都有自己的 JVM 实现,它是专门为将该平台的字节码转换为本地机器代码而设计的。由于字节码在平台之间保持不变,JVM 处理操作系统和硬件架构之间的差异。WORA 原则在图 1.1中解释。

图 1.1 – 图解 WORA 原则

图 1.1 – 图解 WORA 原则

你可以看到编译器创建了字节码,并且这个字节码可以被 JVM 捕获。JVM 是平台特定的,并且将其翻译成它所在的平台。JVM 为我们做了更多的事情,那就是自动内存管理。让我们接下来探讨这一点。

自动内存管理

另一个使 Java 变得伟大的关键特性是其自动内存管理,它简化了开发并防止了常见的内存相关错误。Java 为你处理内存分配和垃圾回收。开发者不需要手动管理内存。

现在,这已经成为规则而不是例外。大多数其他现代语言也有自动内存管理。然而,了解自动内存管理意味着什么很重要。内存的分配和释放是自动完成的。这实际上简化了代码。没有只关注内存分配和释放的样板代码。这也导致内存相关错误更少。

让我们确保你理解内存分配和释放的含义。

内存分配

在代码中,你创建变量。有时,这些变量不是简单的值,而是具有许多数据字段的复杂对象。当你创建一个对象时,这个对象需要存储在运行它的设备内存中。这被称为 内存分配。在 Java 中,当你创建一个对象时,设备内存会自动分配以存储对象的属性和相关数据。这与 C 和 C++ 等语言不同,在这些语言中,开发者必须手动分配和释放内存。Java 的自动内存分配简化了开发过程,并减少了内存泄漏或悬挂指针的可能性,这些可能会导致意外的行为或崩溃。它还使代码更易于阅读,因为你不需要处理任何分配或释放代码。

垃圾回收

当内存块不再被应用程序使用时,它需要被释放。Java 用于此的过程称为 垃圾回收。垃圾回收是识别和回收程序不再使用的内存的过程。在 Java 中,当一个对象不再可访问或不再需要时,垃圾回收器会自动释放该对象占用的内存。这个过程确保内存得到有效利用,并防止内存泄漏及其相关问题。

JVM 定期运行垃圾回收器以识别和清理不可达的对象。Java 的垃圾回收机制使用许多不同的复杂算法来确定何时对象不再需要。

现在我们已经介绍了基础知识,接下来让我们继续学习如何安装 Java。

安装 Java

在你开始编写和运行 Java 程序之前,你需要在计算机上设置 JDK。JDK 包含 Java 开发所需的必需工具和库,例如 Java 编译器、JRE 以及其他有助于开发的实用工具。

我们将指导你如何在 Windows、macOS 和 Linux 上安装 Java,并为你提供一些在没有这些系统之一的情况下的一些建议。但在开始安装 Java 之前,检查系统是否已安装 Java 是一个好主意。

检查系统是否已安装 Java

Java 可能已经预装,或者你可能之前已经安装过,但没有意识到。要检查 Java 是否已安装,请按照以下简单步骤操作。第一步取决于你的操作系统。

第一步 – 打开终端

对于 Windows,按 Windows 键,输入 cmd,然后按 Enter 打开 命令提示符

对于 macOS,按 Command + Space 打开 终端,然后按 Enter 打开 终端

对于 Linux,打开终端窗口。打开终端窗口的方法取决于你的 Linux 发行版(例如,在 Ubuntu 中,按 Ctrl + Alt + T)。

第二步 – 检查 Java 版本

在命令提示符或终端窗口中,输入以下命令并按 Enter

java -version

步骤 3 – 解读响应

如果已安装 Java,您将看到显示的版本信息。如果没有安装,命令提示符将显示错误消息,指示 Java 不可识别或未找到。

如果您发现系统上已经安装了 Java,请确保它是 21 版本或更高版本,以确保与现代 Java 功能的兼容性。如果是较旧版本或未安装,请按照以下章节中描述的针对您特定平台的过程进行安装。如果已安装较旧版本,您可能希望首先卸载它,以避免设置过于复杂。您可以使用操作系统的常见程序卸载方式来安装它。

图 1**.2图 1**.6 中,您将看到安装 Java 时可以预期的输出示例。

图 1.2 – 安装了 Java 19 的 macOS 终端输出

图 1.2 – 安装了 Java 19 的 macOS 终端输出

现在,让我们看看如何在每个操作系统上安装 Java。

在 Windows 上安装 Java

要在 Windows 操作系统上安装 Java,请按照以下步骤操作:

  1. 访问 www.oracle.com/java/technologies/downloads/ 上的 Oracle Java SE 下载 页面。此软件可以免费用于教育目的,但在生产中需要许可证。您可以考虑切换到 OpenJDK 以在生产环境中运行程序而不需要许可证:openjdk.org/install/

  2. 选择适合您 Windows 操作系统的适当安装程序(例如,Windows x64 安装程序)。

  3. 通过点击文件链接下载安装程序。

  4. 运行下载的安装程序(.exe 文件)并按照屏幕上的说明完成安装。

  5. 要将 Java 添加到系统的 PATH 环境变量中,在 开始 菜单中搜索 环境变量 并选择 编辑系统环境变量。您应该看到一个类似于 图 1**.3 的屏幕。

图 1.3 – 系统属性窗口

图 1.3 – 系统属性窗口

  1. 系统属性 窗口中,点击 环境变量… 按钮。将弹出一个类似于 图 1**.4 的屏幕。

  2. 系统变量 下,找到 Path 变量,选择它,然后点击 编辑。您可以在以下 图 1**.4 中看到一个选择示例:

图 1.4 – 环境变量窗口

图 1.4 – 环境变量窗口

  1. 点击 Java 安装目录下的 bin 文件夹(例如,C:\Program Files\Java\jdk-21\bin)。在 图 1**.5 中,这一步已经完成。

图 1.5 – 将 Java 路径添加到 Path 变量中

图 1.5 – 将 Java 路径添加到 Path 变量中

  1. 点击 确定 保存更改并关闭 环境变量 窗口。

  2. 通过打开命令提示符(如果已经打开,请重新打开)并输入以下内容来验证 Java 是否已安装:

    java -version
    
  3. 输出应该看起来像 图 1**.6 中所示的那样。然而,你的版本应该是 21 或更高,以跟上这本书中所有的代码片段!img/B19793_01_6.jpg

=

图 1.6 – 安装 Java 后检查 Java 版本后的命令提示符

在 macOS 上安装 Java

要在 macOS 操作系统上安装 Java,请按照以下步骤操作:

  1. 访问 www.oracle.com/java/technologies/javase-jdk16-downloads.html 上的 Oracle Java SE 下载 页面。

  2. 选择 macOS 安装程序(例如,macOS x64 安装程序)。

  3. 通过点击文件链接下载安装程序。

  4. 运行下载的安装程序(.dmg 文件)并按照屏幕上的说明完成安装。

  5. Java 应该自动添加到你的系统 PATH 环境变量中。为了验证安装,打开终端并运行以下命令:

    java -version
    
  6. 你应该能看到你刚刚安装的 Java 版本,类似于 图 1**.2

在 Linux 上安装 Java

在 Linux 上安装可能需要几步才能解释清楚。不同的 Linux 发行版需要不同的安装步骤。在这里,我们将看看如何在 Linux Ubuntu 系统上安装 Java:

  1. 打开 终端 并通过运行以下命令更新你的软件包仓库:

    sudo apt-get update
    
  2. 通过运行以下命令安装默认的 JDK 软件包:

    sudo apt install default-jdk
    
  3. 为了验证安装,运行 java -version 命令。你应该能看到你刚刚安装的 Java 版本。

  4. 如果你需要设置 JAVA_HOME 环境变量(你不需要通过这本书的工作方式来完成,但你需要为更复杂的 Java 项目做这个),你首先需要通过运行以下命令确定安装路径:

    sudo update-alternatives --config java
    
  5. 记下显示的路径(例如,/usr/lib/jvm/java-19-openjdk-amd64/bin/java)。

  6. 使用具有 root 权限的文本编辑器打开 /etc/environment 文件:

    sudo nano /etc/environment
    
  7. 在文件的末尾添加以下行,将路径替换为你在 步骤 4 中记录的路径(不包括 /``bin/java 部分):

    JAVA_HOME="/usr/lib/jvm/java-19-openjdk-amd64"
    
  8. 保存并关闭文件。然后,运行以下命令以应用更改:

    source /etc/environment
    

现在,Java 应该已经安装并配置在你的 Linux 操作系统上了。

在线运行 Java

如果你没有访问 macOS、Linux 或 Windows 计算机的权限,网上有一些解决方案。免费选项可能不是完美的,但例如,尝试在浏览器中运行 Java 的 w3schools 解决方案并不差。网上有很多这样的解决方案。

为了处理多个文件,可能有一些免费工具可用,但大多数都是付费的。我们目前推荐的一个免费工具是 replit.com。你可以在这里找到它:replit.com/languages/java

你需要注册,但你可以免费使用多个文件并将它们保存在你的账户上。如果你例如只有平板电脑来跟随这本书,这是一个很好的替代方案。

另一个选择是使用 GitHub Codespaces:github.com/codespaces。他们有机会进入一个仓库(例如我们用于这本书的仓库)并直接尝试仓库中可用的示例,并调整它们来尝试新事物。

在导航完 Java 的安装后,现在是时候讨论编译和运行程序了。

编写我们的第一个程序

在深入编译和运行 Java 程序的过程之前,让我们使用基本的文本编辑器创建一个简单的 Java 程序。这将帮助你理解 Java 程序的结构以及如何编写和保存 Java 源代码文件。在这个例子中,我们将创建一个 “Hello world!”程序,用于演示编译和执行的过程。

Hello world

你可能已经听说过向控制台输出 "Hello world!"。编写这个程序将帮助你获得对 Java 语法的基本理解,并且它将帮助你熟悉编写、编译和运行 Java 代码的过程。

创建程序的步骤

好的,让我们开始编码。以下是步骤:

  1. 首先,在你的电脑上打开一个基本的文本编辑器。Windows 上的 记事本、macOS 上的 文本编辑器 或 Linux 上的 Gedit 都是合适的选择。

  2. 在你的文本编辑器中写下以下 Java 代码:

    public class HelloWorld {    public static void main(String[] args) {        System.out.println("Hello world!");    }}
    
  3. 将文件保存为 HelloWorld.java 到你选择的目录中。保存文件时不要忘记 .java 扩展名。这表示文件包含 Java 源代码。代码不应该在 .java 后面有 .txt。在 Windows 中有时会发生这种情况,所以请确保不要在文件类型下拉菜单中选择文本文件。

TextEdit – 文件扩展名问题

macOS 的较新版本与 TextEdit 有点问题。默认情况下,你不能将其保存为 Java 文件。为了启用此功能,你需要转到 格式 | 制作纯文本 并选择 UTF-8

在此之后,你可以将其保存为 .java 文件。你可能仍然会遇到编码错误;问题在于编码,修复它可能需要很多努力,但可能会错过这个练习的目标。可能更好的是,从我们的 GitHub 仓库下载 HelloWorld.java 文件。

理解程序

让我们看看我们刚刚使用的代码。首先,请注意,这是 区分大小写的。这意味着当你查看代码时,如果你混淆了大小写,大多数事情可能不会像你预期的那样工作。

首先,我们创建了一个名为HelloWorld的类,其中包含一个main方法。当然,我们还会更详细地介绍类和方法。但类是 Java 应用程序的基本构建块,它可以包含方法。方法可以被执行以执行某些操作——操作是指执行语句。

main方法是一个特殊的方法。它是我们的 Java 程序的入口点,包含当程序运行时将被执行的代码。带有System.out.println("Hello world!");的行将Hello world!消息写入控制台。请注意,println代表打印行,所以它使用小写的L,而不是大写的i

在保存了HelloWorld.java文件之后,我们现在可以继续到下一节,我们将学习如何使用命令行和 IDE 编译和运行 Java 程序。

编译和运行 Java 程序

现在我们已经编写了第一个程序,让我们讨论如何编译和运行它。我们将涵盖编译过程的基础、JVM 的作用以及如何使用命令行和 IDE 编译和运行 Java 代码。

理解编译过程

源代码是用 Java 编程语言编写的可读格式。至少,我们希望在这本书之后,这也是你的观点。在代码可以执行之前,它必须转换成计算机可以理解的格式。你已经知道 Java 是一种编译型语言,这个过程被称为编译。

在编译过程中,.java文件被转换成字节码(.class文件)。一旦生成了字节码,它就可以由 JVM 执行。我们已经了解到 JVM 是字节码执行器,并且每个平台都有自己的定制 JVM,这实现了 Java 的 WORA 特性。

使用命令行上的 javac 编译代码

要使用命令行编译 Java 程序,请按照以下步骤操作:

  1. 打开终端(Windows 上的命令提示符,macOS 或 Linux 上的终端)。

  2. 导航到包含你的 Java 源代码文件的目录(例如,你之前创建的HelloWorld.java文件的目录)。如果你不知道如何操作,可以使用cd命令,它代表更改目录。例如,如果我在一个名为documents的目录中,我想进入名为java programs的子文件夹,我会运行cd "java programs"命令。引号只在目录名中有空格时需要。本书的范围不涉及解释如何在任何平台上更改目录。互联网上有许多关于如何使用命令行在各个平台上导航文件夹结构的优秀解释。

  3. 一旦你进入了包含 Java 文件的文件夹,请输入以下命令来编译 Java 源代码:

    javac HelloWorld.java
    

    如果编译成功,将在同一目录下创建一个具有相同名称但.class扩展名的新文件(例如,HelloWorld.class)。这个文件包含可以被 JVM 执行的字节码。

让我们看看如何运行这段编译后的代码。

在命令行上使用 Java 运行编译后的代码

要运行编译后的 Java 程序,请按照以下步骤操作:

  1. 在终端中,确保你仍然位于包含.class文件的目录中。

  2. 输入以下命令以执行字节码:

    java HelloWorld
    

JVM 将加载并运行字节码,你应该能看到程序的输出。在这种情况下,输出如下:

Hello world!

我们可以在记事本中编写 Java 代码并在命令行中运行它,这确实很酷,但现代 Java 开发者的生活要舒适得多。让我们把 IDE 加入进来,亲自看看。

使用 IDE 进行工作

在文本编辑器中创建文件有点过时了。当然,你仍然可以这样操作——这实际上是一个成为杰出程序员的极好方法,但也是一个非常令人沮丧的方法。有一些工具可以帮助我们完成大量繁重的工作,并在编写代码时提供协助。这些工具被称为集成开发环境(IDE)。

什么是 IDE?

集成开发环境(IDE)是一个软件应用程序,它包含了编写、编译、运行和测试代码所需的一切。使用 IDE 可以使开发各种程序变得更加容易。不仅如此,调试和管理代码也更加方便。相对而言,你可以将 IDE 比作我写这本书时使用的 Microsoft Office Word。虽然我可以用记事本写,但使用 Word 提供了显著的优势。它可以帮助检查拼写错误,并允许我轻松添加和可视化布局,以及其他有用的功能。这个类比描绘了 IDE 不仅仅提供了一个编写代码的平台,还提供了一套工具来简化和增强你的编码体验。

选择 IDE

在 Java 开发中,有几种 IDE 可供选择,每种都有其独特的功能和能力。在本节中,我们将讨论选择 IDE 时需要考虑的因素,并帮助你设置一些流行的 Java IDE。在整个书中,我们将使用IntelliJ。其他同样出色的选择包括VS CodeEclipse

选择 IDE 时需要考虑的因素

大多数现代 IDE 都具有代码补全、调试、版本控制集成以及支持第三方工具和框架等功能。其中一些在这方面比其他 IDE 做得更好。在选择或切换 IDE 时,比较和对比你偏好的功能。

一些 IDE 需要更强大的系统来运行,而其他 IDE 则较轻量。例如,VS Code 相对轻量,而 IntelliJ 则相对重量级。此外,VS Code 可以用于多种语言,包括 Java。使用 IntelliJ 进行非 Java 开发的其他事情相对较少。选择一个在功能和性能之间提供平衡的 IDE,尤其是如果您系统资源有限的话。

当然,您偏好的 IDE 可能不适合您所使用的平台。请确保它适用于您的系统,并且稳定可靠。

最后,非常重要的一点是,考虑成本。一些 IDE 是免费的,而其他 IDE 则需要付费许可证。幸运的是,许多需要付费许可证的 IDE 都提供了非商业用途的免费版。因此,在选择 IDE 时,请确保考虑您的预算和所需的许可证。

在接下来的子节中,我们将向您介绍设置当前(目前)最常用的三个 Java 开发 IDE 的步骤:

  • IntelliJ

  • Eclipse

  • Visual Studio Code

注意

在本书的剩余部分,我们将使用 IntelliJ。

设置 IntelliJ

那么,让我们从它开始。IntelliJ IDEA 是由 JetBrains 开发的一款流行的 Java 集成开发环境(IDE)。它提供免费 社区版 和付费 专业版。它提供了一系列功能,包括智能代码补全、调试工具、版本控制集成以及支持各种 Java 框架。

下面是安装 IntelliJ 的步骤:

  1. 访问 IntelliJ IDEA 下载页面,请点击www.jetbrains.com/idea/download/

  2. 选择您偏好的版本:免费的 社区版 或付费的 专业版。对于初学者来说,社区版已经非常优秀了。

  3. 下载适用于您操作系统的安装程序(Windows、macOS 或 Linux)。

  4. 运行安装程序,并按照说明完成安装。

  5. 启动 IntelliJ IDEA。如果您使用的是专业版,您可能需要输入您的 JetBrains 账户凭证或许可证密钥。

  6. 欢迎 界面中,您可以创建一个新的 项目、导入现有的 项目,或探索可用的教程和文档。

设置 Eclipse

Eclipse 是一个免费的开源 Java 集成开发环境(IDE),在 Java 社区中广泛使用。它已经存在很长时间了,并且许多公司仍在使用它。它提供了各种功能,就像 IntelliJ 一样。Eclipse 可以根据您的需求进行定制,但其界面可能不如其他 IDE 直观。

要设置 Eclipse,请按照以下步骤操作:

  1. 访问 Eclipse 下载页面,请点击www.eclipse.org/downloads/

  2. 下载适用于您操作系统的 Eclipse 安装程序(Windows、macOS 或 Linux)。

  3. 运行安装程序,并从可用包列表中选择 Eclipse IDE for Java Developers

  4. 选择安装文件夹,并按照说明完成安装。

  5. 启动 Eclipse 并选择工作区目录。你的项目和设置将存储在这里。

  6. 欢迎 界面中,您可以创建一个新的 Java 项目,导入现有的 项目,或探索可用的教程和文档。

设置 Visual Studio Code

Visual Studio Code,通常简称为 VS Code,是由微软开发的一个轻量级、免费且开源的代码编辑器。它因其支持广泛的编程语言而受到各种任务的欢迎。它是那些更喜欢更简约和快速性能环境的开发者的首选。可以通过扩展添加各种功能。

这里是安装 VS Code 并为其 Java 开发做准备的分步指南:

  1. 访问 Visual Studio Code 下载页面 https://code.visualstudio.com/download

  2. 下载适用于您操作系统的安装程序(Windows、macOS 或 Linux)。

  3. 运行安装程序,按照屏幕上的说明完成安装。

  4. 启动 Visual Studio Code。

  5. 通过点击窗口左侧的 扩展 图标(四个方块)打开 扩展 视图。

  6. 扩展市场 中搜索 Java 扩展包 并点击 安装 按钮。此扩展包包括各种用于 Java 开发的扩展,例如 Red Hat 提供的 Java (TM) 语言支持Java 调试器Java 的 Maven

  7. 安装了 Java 扩展包 后,您现在可以创建或导入 Java 项目。如果它没有直接加载,您可能需要重新打开 VS Code。

现在您已经设置了一个 IDE,让我们用它来创建和运行一个程序。

使用 IDE 创建和运行程序

与使用纯文本编辑器相比,使用 IDE(如 IntelliJ)工作要容易得多。现在我们将指导您使用 IntelliJ 创建、运行和调试程序。我们将创建与使用文本编辑器时相同的程序。

在 IDE 中创建程序

当你使用 IDE 编写代码时,你会看到它不断地帮助你完成代码。大多数人认为这非常有帮助,我们希望您也会喜欢这个功能。

为了开始使用 IntelliJ,我们首先需要创建一个项目。以下是再次创建我们的 Hello World 程序的步骤:

  1. 启动 IntelliJ IDEA 并从 欢迎 界面点击 新建项目,或转到 文件 | 新建 | 项目

图 1.7 – IntelliJ 的初始屏幕

图 1.7 – IntelliJ 的初始屏幕

  1. 将项目命名为 HelloWorld

  2. 选择Java作为语言,并确保已选择正确的项目 SDK。点击下一步

  3. 不要勾选创建 Git 仓库复选框,也不要勾选添加示例 代码复选框。

  4. 点击 创建 以创建项目。

图 1.8 – 创建新项目的向导

图 1.8 – 创建新项目的向导

  1. 一旦创建了项目,展开 src 文件夹中的 src 文件夹,并选择 新建 | Java 类。如果其下有另一个文件夹,那么可能有一个包含 Java 文件夹的主文件夹。右键单击 Java 文件夹并选择 新建 | Java 类。如果名称不同,只需右键单击蓝色文件夹。

图 1.9 – 创建新的 Java 类

图 1.9 – 创建新的 Java 类

  1. 将新类命名为 HelloWorld 并单击带有类定义的 .java 文件。

图 1.10 – 调用类 “HelloWorld”

图 1.10 – 调用类 “HelloWorld”

  1. HelloWorld 类中编写我们的 main 方法:

    public class HelloWorld {    public static void main(String[] args) {        System.out.println("Hello world!");    }}
    

图 1.11 – HelloWorld.java 中的代码

图 1.11 – HelloWorld.java 中的代码

既然我们已经编写了第一个程序,请确保它已保存。默认情况下,IntelliJ 会自动保存我们的文件。让我们看看我们是否也能运行这个程序。

运行程序

虽然我们创建程序时不得不采取一些额外步骤,首先需要创建一个项目。好消息是,运行程序更容易!以下是操作方法:

  1. 如果您还没有这样做,请通过按 Ctrl + S(Windows/Linux)或 Cmd + S(macOS)确保保存您的更改。默认情况下,自动保存已启用。

  2. 要运行程序,请右键单击 HelloWorld 类中的任何位置,并选择 Run 'HelloWorld.main()'。或者,您也可以单击主方法旁边的绿色三角形图标,并选择 Run 'HelloWorld.main()'。IntelliJ 将编译并运行程序。

图 1.12 – 运行程序

图 1.12 – 运行程序

  1. 验证程序输出 "Hello world!" 是否显示在屏幕底部的 运行工具 窗口中。

图 1.13 – 程序的输出

图 1.13 – 程序的输出

已保存和未保存的文件

在大多数集成开发环境(IDE)中,你可以通过查看打开文件的标签来了解文件是否已保存。如果文件未保存,其旁边会有一个点或星号。如果已保存,则点会缺失。

程序调试

我们现在的程序相当简单,但我们可能想逐行逐步执行程序。我们可以通过调试程序来实现这一点。让我们给我们的文件添加一些额外的调试内容。这样我们可以看到如何检查变量、理解执行流程,以及这样找到我们代码中的缺陷:

  1. 使用以下代码更新 HelloWorld.java 文件:

    public class HelloWorld {    public static void main(String[] args) {        String greeting = "Hello, World!";        System.out.println(greeting);        int number = 5;        int doubled = doubleNumber(number);        System.out.println("The doubled number is: " +          doubled);    }    public static int doubleNumber(int input) {        return input * 2;    }}
    
  2. 在这个程序的更新版本中,我们添加了一个名为 doubleNumber 的新方法,它接受一个整数作为输入并返回其两倍。在 main 方法中,我们调用此方法并打印结果。如果您不完全理解这一点,请不要担心——我们只是想向您展示如何逐步执行您的代码。

  3. 通过按 Ctrl + S(Windows/Linux)或 Cmd + S(macOS)保存您的更改。

    现在,让我们调试更新后的程序。

  4. 通过在编辑器中行号旁边的空白区域单击来在你想暂停执行的行上设置断点。会出现一个红色圆点,表示断点。例如,在行int doubled = doubleNumber(number);上设置断点。例如,请参阅图 1.7

图 1.14 – 在第 7 行添加断点

图 1.14 – 在第 7 行添加断点

  1. 通过在HelloWorld类上右键单击并选择Debug 'HelloWorld.main()'或点击main方法旁边的绿色播放图标并选择调试选项来启动调试器。IntelliJ 将编译并以调试模式启动程序。

  2. 当即将执行带有断点的行时,程序将暂停。在暂停期间,你可以使用位于屏幕底部的调试工具窗口。在这里,你可以查看程序的状态,包括局部变量和字段的值。例如,请参阅图 1.8

图 1.15 – IntelliJ 中的调试工具窗口。此截图的目的是显示布局,不需要考虑文本可读性。

图 1.15 – IntelliJ 中的调试工具窗口。此截图的目的是显示布局,不需要考虑文本可读性。

  1. 使用调试工具窗口中的步骤控件来逐步执行代码(图 1.8中的蓝色箭头带有角度),进入被调用的方法(蓝色箭头向下),或继续执行(图 1.8中左侧的绿色箭头)。

通过遵循这些步骤,你可以使用 IntelliJ IDEA 调试 Java 程序并逐步执行代码以查看正在发生的事情。这将在理解你的代码中派上用场。这个过程在其他 Java IDE 中也将类似,尽管具体的步骤和界面元素可能会有所不同。

练习

这就是本章的所有理论!所以,卷起袖子,让我们开始你的中生代伊甸园的第一天。欢迎加入!中生代伊甸园是一个著名的动物园,那里的恐龙是通过高端基因操控技术被带到这里生活的。以下是一些练习,以测试你到目前为止的知识:

  1. 你的第一个任务涉及欢迎我们的客人。修改以下代码片段,使其输出"Welcome to" "Mesozoic Eden"

    public class Main {    public static void main(String[] args) {        System.out.println("Hello world");    }}
    
  2. 通过填写空白处完成以下程序,以便打印出你希望在 5 年后的中生代伊甸园拥有的名字和职位:

    public class Main {    public static void main(String[] args) {        __________________________;        String position = "Park Manager";        System.out.println("My name is " + name + "          and I want to be a " ______ " in Mesozoic            Eden.");    }}
    
  3. 我们收到了一些关于开放时间的问题。完成以下程序,以便打印出公园的开放和关闭时间:

    public class Main {    public static void main(String[] args) {        String openingHours = "08:00";        String closingHours = "20:00";    }}
    
  4. 创建一个名为dinosaur的 Java 项目。你可以通过在src/main/java文件夹上右键单击,选择“新建”并选择“包”来创建一个包。

  5. 修改练习 1 中的代码,使其打印出"Welcome, [YourName] to Mesozoic Eden!",其中[YourName]将被惊喜惊喜地替换为你的名字。加分项:尝试创建一个单独的 String 变量,如第二和第三练习所示。

  6. 一些游客报告说在霸王龙附近感到不安全。让我们通过在练习 5 的程序中添加另一个System.out.println来解决这一问题。它应在欢迎信息后打印短语"Mesozoic Eden is safe and secure."

项目

创建一个程序,模拟 Mesozoic Eden 入口处的标志。这个标志通过向控制台打印输出进行模拟。标志应显示欢迎信息、营业时间和简短的安全信息。

摘要

你已经完成了第一章!我们已经做了很多。我们首先探索了 Java 的关键特性,例如它的 OOP 方法、(曾经独特的)WORA 原则、它的编译性质以及超级有用的自动内存管理。这些特性使 Java 成为一个极其灵活且强大的语言——是不同编程任务(如 Web 开发、桌面应用、移动应用等等)的一个很好的选择!

接下来,我们向您介绍了在 Windows、macOS 和 Linux 等平台上安装 Java 的过程。我们还讨论了如何检查 Java 是否已经安装在你的系统上。在这一部分之后,你可以确信你已经拥有了启动 Java 编程冒险的所有必需工具。

在你设置好 Java 之后,我们解密了编译过程,并介绍了 JVM,它是 Java 生态系统的一个关键组件,使得 Java 代码具有可移植性。然后我们演示了如何使用javacjava命令行工具编译和运行 Java 代码。这些工具为使用 Java 程序的核心工作奠定了基础。

当然,使用命令行来做这个很好。但如今,我们更经常使用 IDE,我们只需按一个按钮就可以完成所有这些。因此,我们提到了使用 IDE 的一些优点和良好特性,例如代码补全、调试和项目管理。我们讨论了在选择 IDE 时需要权衡的因素,并提供了设置流行的 IDE(如 IntelliJ IDEA、Eclipse 和 VS Code)的指导。在这本书中,我们将使用 IntelliJ 作为示例。

在介绍了 IDE 的基础知识之后,我们深入探讨了使用 IDE 创建和运行 Java 程序的过程。我们解释了典型 Java 程序的结构,并逐步引导你创建、运行和调试你的第一个 Java 程序。

在此之后,你准备好进行第一个实战项目了。现在你在这里!一切准备就绪,准备在 Java 之旅上迈出下一步。这一步将是处理变量和基本数据类型。祝你好运!

第二章:变量和原始数据类型

第一章中,我们介绍了编译器和 JVM。当我们编写第一个 Java 程序HelloWorld时,我们学习了如何从命令行使用它们。我们还介绍了IntelliJ,一个强大且友好的 IDE,并且也从那里运行了HelloWorld

所有编程语言都需要变量并提供内置的原始数据类型。它们是甚至最简单程序操作的基础。在本章结束时,你将能够使用 Java 的原始数据类型声明变量。此外,你将了解不同原始数据类型之间的区别以及在特定情况下应使用哪些类型。

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

  • 理解和声明变量

  • 探索 Java 的原始数据类型

技术要求

本章的代码可以在 GitHub 上找到,网址为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch2

理解和声明变量

如果你想要存储一个用于以后使用的值,你需要一个变量。因此,每种编程语言都通过变量提供这个功能。在本节中,我们将学习什么是变量以及如何声明它。你可以在代码中使用的特定变量的区域称为变量的作用域。这是一个非常重要的概念,将在第四章中详细讨论。

什么是变量?

变量是内存中的位置,具有一个名称(称为标识符)和一个类型。它们类似于带名称的鸽巢或邮政信箱(参见图 2.1)。变量的名称是必需的,这样我们才能引用变量并将其与其他变量区分开来。

变量的类型指定它可以存储/保留的值的类型。例如,变量是用来存储像 4 这样的整数还是像 2.98 这样的十进制数?对这个问题的回答决定了变量的类型

声明变量

假设我们想要将数字 25 存储在一个变量中。我们将假设这个数字代表一个人的年龄,因此我们将使用age标识符来引用它。第一次引入变量被称为“声明”变量。

一个整数(正数或负数)是一个整数,Java 提供了一个专门用于整数的内置原始类型,称为int。我们将在下一节中更详细地讨论原始数据类型。在 Java 中声明变量时,我们必须指定变量的类型。这是因为 Java 被称为强类型语言,这意味着你必须立即在声明变量时指定它的类型。

让我们声明一个变量,给它一个类型,并初始化它:

int age;age = 25;

第一行将age声明为int,第二行将其赋值为25

注意,行尾的分号(;)是分隔符,告诉编译器 Java 语句在哪里结束。等号(=)是赋值运算符,将在第三章中介绍。现在,只需明白25被“赋值给”age变量。

赋值运算符

Java 中的=符号与数学中的等于符号(=)不同。Java 使用==符号表示等于,这被称为等价。

我们可以将前面的两行代码写在一行中:

int age = 25;

图 2**.1显示了这两个代码段的内存表示:

图 2.1 – 命名为 age 的整型变量,其值为 25

图 2.1 – 命名为 age 的整型变量,其值为 25

图 2**.1所示,age是变量的名称,25是存储在变量位置中的整数值。

变量命名

标识符只是你给正在编写的 Java 结构起的名字;例如,命名变量、方法、类等都需要标识符(名称)。

标识符

标识符由字母、数字、下划线和货币符号组成。标识符不能以数字开头,也不能包含空白(空格、制表符和换行符)。在以下示例中,逗号分隔了不同的标识符。

一些不寻常但有效的标识符示例有 a£€_23, _29, Z, thisIsAnExampleOfAVeryLongVariableName, €2_, 和 $``4 ;.

无效的标识符示例有9ageabc def;.

仔细命名你的变量。这有助于使代码更易读,从而减少错误并便于维护。驼峰式命名法在这方面非常流行。关于变量名,驼峰式命名法意味着所有第一个单词都是小写。此外,变量名中每个后续单词的第一个字母都是大写字母。以下是一个例子:

int ageOfCar;int numberOfChildren;

在这个代码段中,我们有两个整数变量,它们的名称/标识符遵循驼峰式命名法。

访问变量

要访问变量的值,只需输入变量的名称。当我们输入 Java 中的变量名称时,编译器将首先确保存在具有该名称的变量。假设存在,JVM 将在运行时返回该变量的内部值。因此,以下代码将在屏幕上输出25

int age = 25;System.out.println(age);

第一行声明了age变量并将其初始化为25。第二行访问变量的位置并输出其值。

System.out.println()

System.out.println()在屏幕上显示括号()内的任何内容。

访问未声明的变量

如前所述,Java 是一种强类型语言。这意味着你必须在声明变量时立即指定其类型。如果编译器遇到一个变量而不知道其类型,它将生成错误。例如,考虑以下代码行:

age = 25;

假设没有其他代码声明 age,编译器将生成一个错误,指出 cannot resolve symbol 'age'。这是因为编译器正在寻找与 age 关联的 类型,但它找不到它(因为我们没有指定类型)。

这里有一个稍微不同的例子。在这个例子中,我们试图将 age 变量输出到屏幕上:

        int length = 25;        System.out.println(age);

在这个例子中,我们声明了一个名为 length 的变量,因此没有声明 age。当我们尝试在 System.out.println() 中访问 age 变量时,编译器会寻找 age。编译器找不到 age 并生成一个错误,指出 cannot resolve symbol 'age'。实际上,编译器所说的是我们尝试使用编译器找不到的名为 age 的变量。这是因为我们甚至没有声明该变量,更不用说指定其类型了。

注释

注释非常有用,因为它们帮助我们解释代码中的内容。

// 是单行注释。当编译器看到 // 时,它会忽略该行剩余的部分。

./* some text */ 是多行注释。在 /* 开头和 */ 结尾之间的任何内容都被忽略。这种格式可以节省在每个行首插入 //

这里有一些例子:

int age; // from here to the rest of the line is ignored

// this whole line is ignored

/* all

of

these lines

are

ignored */

由于 Java 作为一种强类型语言,要求所有变量都有数据类型,我们将现在讨论 Java 对原始数据类型的支持。

理解 Java 的原始数据类型

Java 提供了八个内置数据类型。内置意味着这些数据类型与语言一起提供。这些原始数据类型是本节的主题。

Java 的原始数据类型

所有的原始数据类型都只使用小写字母命名;例如,intdouble。当我们稍后创建自己的数据类型,即类、记录和接口时,我们将遵循不同的命名约定。例如,我们可能有一个名为 PersonCat 的类。这只是一个广泛采用的编码约定,编译器不会区分命名约定。然而,很容易识别任何原始数据类型,因为它们总是只使用小写字母。在我们讨论原始数据类型本身之前,有一些重要的事项需要说明。

数字原始数据类型是有符号的

在 Java 中,所有数字原始数据类型都表示为一系列位。此外,它们也是有符号的。最高有效位(最左边的位)用于表示符号;1 表示负数,0 表示正数。

整数字面量

文字值是在键盘上键入的值(与计算值相对)。整数文字可以用不同的数制表示:十进制(基数 10)、十六进制(基数 16)、八进制(基数 8)和二进制(基数 2)。然而,十进制是使用最广泛的表示方式并不令人惊讶。为了信息目的,以下所有声明都表示十进制数字 10:

int a = 10; // 十进制,默认

int b = 0b1010; // 二进制,以 0b 或 0B 开头

int c = 012; // 八进制,以 0 开头

int d = 0xa; // 十六进制,以 0x 或 0X 开头

符号位影响范围

符号位的存在意味着 byte 的范围是 -27 到 27-1(-128 到 +127,包括 +127)。正数范围内的 -1 是为了允许在 Java 中 0 被视为正数的事实。在任何范围内都没有一个更少的正数。例如,对于 byte,你有 128 个负数(-1 到 -128)和 128 个正数(0 到 +127),结果有 256 种表示(2⁸)。为了通过一个简单的例子来强调这一点,-1 到 -8 是 8 个数字,0 到 7(包括 0)也是 8 个数字。

讨论了这些观点后,让我们来看看各种原始类型。表 2.1 列出了八个原始数据类型、它们的字节大小和它们的范围(所有这些都是包括在内的):

表 2.1 – Java 的原始类型

下面是从前面表格中的一些有趣的观点:

  • byteshortcharintlong 被称为 整型 类型,因为它们具有整数值(整数,正数或负数)。例如,-8、17 和 625 都是整数。

  • char 用于字符 – 例如 a,‘b’,‘?’ 和 ‘+’。注意字符被单引号包围。在代码中,char c = 'a'; 表示变量 c 代表字母 a。由于计算机最终将所有字符(在键盘上)作为内部数字(二进制)存储,我们需要一个编码系统来将字符映射到数字,反之亦然。Java 使用 Unicode 编码标准,它确保每个字符都有一个唯一的数字,无论平台、语言、脚本等。这就是为什么 char 使用 2 个字节而不是 1 个。实际上,从计算机的角度来看,char c = 'a'; 与 char c = 97; 是相同的,其中 97 是 Unicode 中 a 的十进制值。显然,我们作为人类更喜欢字母表示法。

  • shortchar 都需要 2 个字节,但范围不同。注意 short 可以表示负数,而 char 不能。相比之下,char 可以存储如 65,000 这样的数字,而 short 不能。

  • floatdouble 用于浮点数 – 换句话说,有小数位的数字,例如 23.78 和 -98.453。这些浮点数可以使用科学记数法 – 例如,130000.0 可以表示为 double d1=1.3e+5;,而 0.13 可以表示为 double d2=1.3e-1;

各种类型的表示

从上一个调用中扩展,我们可以使用以下数制表示整数字面量:

  • 十进制:基数为 10;数字 0..9。这是默认值。

  • 十六进制:基数为 16;数字 0..9 和字母 a..f(或 A..F)。在字面量前加上 0x0X 以指示这是一个十六进制字面量。

  • 二进制:基数为 2;数字 0..1。在字面量前加上 0b0B 以指示这是一个二进制字面量。

这里有一些示例代码片段,使用不同的数制初始化 int 变量为 30。首先使用十进制,然后是十六进制,最后是二进制:

// decimalint dec = 30;
// hexadecimal = 16 + 14
int hex = 0x1E;
// binary = 16 + 8 + 4 + 2
int bin = 0b11110;

虽然有几种初始化 int 的方法,但使用十进制是最常见的。

默认情况下,字面量数字,如 22,被视为 int。如果你想让 22 被视为 long(而不是 int),必须在字面量后加上大写或小写 L。以下是一个示例:

int x  = 10;long n = 10L;

根据 表 2.1,使用 long 而不是 int,可以访问更大和更小的数字。为了表示 long,建议使用大写 L 而不是小写 l,因为小写 l 与数字 1(one)相似。

浮点数的行为类似。默认情况下,十进制数字是 double。要将任何十进制数字视为 float(而不是 double),必须在字面量后加上大写或小写 F。假设范围不是问题,那么使用 float 而不是 double 的一个原因是为了节省内存(因为 float 需要 4 个字节,而 double 需要 8 个字节)。以下是一个示例:

double d = 10.34;float f  = 10.34F;

char 类型的变量用单引号括起来的字面量初始化。以下是一个示例:

char c = 'a';

boolean 类型的变量只能存储 truefalse。这些 boolean 字面量仅使用小写,因为它们是 Java 中的保留字,Java 是区分大小写的:

boolean b1 = true;boolean b2 = false;

这部分关于 Java 原始类型系统的内容到此结束,我们考察了各种类型、它们的尺寸/范围以及一些代码片段。

现在,让我们将变量和原始类型理论付诸实践!但在那之前,这里有一些作弊代码可以帮助你完成练习。

屏幕输出

如我们所知,System.out.println() 输出括号内的内容。为了进行练习,我们想要在此基础上进行扩展。首先,这里有一些代码:

String name = "James";    // line 1int age = 23; // line 2
double salary = 50_000.0; // line 3
String out = "Details: "  +  name  +  ", "  +  age  +  ", "  +  salary;//line 4
System.out.println(out);  // line 5

第 1 行声明了一个字符串字面量 "James" 并用它初始化了 name 变量。字符串字面量是一系列字符(包括数字),用双引号括起来。我们将在 第十二章 中详细讨论 String 类。

第 2 行和第 3 行应该没问题。我们声明了一个名为 ageint 类型和一个名为 salarydouble 类型,并使用字面量初始化它们。第 3 行中使用的下划线使我们能够使大数字更容易阅读。

第 4 行构建要输出的字符串,即 out。我们想要输出变量的值,以及一些有用的文本来解释输出。Java 从左到右构建字符串。

在这里,+ 不是常规的数学加法。我们将在 第三章 中详细讨论这个问题,但暂时明白,当你有一个字符串变量或字面量位于 + 的左边或右边时,操作就变成了字符串连接(而不是数学加法)。

这个 append 属性与加法有一个共同点,即 + 的两边必须是同一类型。由于本例中并非所有变量都是字符串变量(或字面量),Java 在后台需要做一些工作(将它们都转换为同一类型)。Java 将数值变量的值复制到新的字符串位置,以便在构建字符串时使用。例如,内存中某个位置已经创建了一个字符串字面量“23”(除了 int 类型的 age 位置)。对于 double 类型的 salary 变量也是如此。现在,Java 准备构建字符串并将其分配给 out(第 4 行)。

在后台,Java 执行以下操作:

"Details: " + "James" => "Details: James""Details: James" + ", " => "Details: James, "
"Details: James, " + "23" => "Details: James, 23"
"Details: James, 23" + ", " => "Details: James, 23,
"Details: James, 23, " + "50000.0" => "Details: James, 23,
  50000.0"

因此,"Details: James, 23, 50000.0" 用于初始化 out,这是执行第 5 行时屏幕上显示的内容。

练习

在我们可爱的恐龙公园里,一切都很顺利。然而,我们确实需要做一些行政工作:

  1. 我们需要跟踪公园中的恐龙。在主方法中声明变量来表示一只恐龙的品种、身高、长度和体重。给这些变量赋值并打印它们。

  2. 现在,我们想要做与练习 1 类似的事情,并打印恐龙的年龄、姓名以及它是否是肉食性动物。这需要在主方法中完成。给这些变量赋值并打印它们。

  3. 我们的公园运营得很好!但有时会变得有点拥挤。消防部门建议我们引入在任何给定时间内允许的最大游客数量。声明一个变量来表示公园每天允许的最大游客数量。你可以为该变量选择一个合理的值。然后,在句子“Mesozoic Eden 允许的最大人数为 [x] 人。”中打印它。

  4. 我们的团队是 Mesozoic Eden 不可或缺的一部分。让我们为一名员工创建一个档案。声明变量来表示 Mesozoic Eden 员工的姓名和年龄。赋值并打印它们。

  5. 我们想知道在任何时候我们有多少只恐龙。声明一个变量来表示公园中的恐龙数量。给它赋值并打印它。

  6. 安全是我们的首要任务。我们维护一个安全评级量表以确保我们的标准。声明一个变量来表示公园的安全评级,范围从 1 到 10。给它赋值并打印它。

  7. 现在,让我们在一个语句中汇总一些恐龙信息。创建一个程序,使用字符串连接来打印出恐龙的名字、年龄和食性(值为carnivoreherbivore的字符串)。

  8. 每种恐龙物种都有一个独特的名字。为了快速参考系统,我们使用恐龙物种的首字母。声明一个代表恐龙物种首字母的字符变量,赋值并打印它。

项目 – 恐龙档案生成器

作为你在中生代伊甸园责任的一部分,你被要求创建一个包含公园内所有恐龙的详尽数据库。目前,你只需要完成第一步:档案生成器。这些档案不仅有助于跟踪我们的史前居民,还为科学研究、医疗保健、饮食管理和游客互动提供了必要的数据。

在这个项目中,我们将专注于开发一个可以模拟单个恐龙档案的程序。

档案应包括以下特征:

  • 名字

  • 年龄

  • 物种

  • 食性(肉食性或草食性)

  • 重量

每个特征都应该作为程序中的一个变量来存储。这是你发挥创意和思考你想要描述的恐龙类型的机会。是高耸的霸王龙,还是友好的甲龙?也许它是一只敏捷、可怕的迅猛龙,或者是一头强大的三角龙?

一旦你声明并给这些变量赋值,程序应该打印出恐龙的完整档案。输出可以是类似这样的内容:"遇见[名字],一只[年龄]岁的[物种]。作为一种[食性],它拥有健壮的体重[重量]千克。"

摘要

在本章中,我们了解到变量只是一个带有名称和值的内存位置。为了利用变量,我们必须知道如何声明和访问它们。

声明变量时,我们指定变量的名称和类型——例如,int countOfTitles=5;。这一行代码声明了一个名为countOfTitlesint变量,其值为5。使用驼峰命名法正确命名它们,对于使代码更易于阅读和维护大有裨益。要访问变量,我们只需指定变量的名称——例如,System.out.println(countOfTitles);

由于 Java 是一种强类型语言,我们在声明变量时必须指定其类型。Java 为我们提供了八个内置的基本数据类型供我们使用。它们由于使用小写字母而易于识别。在上面的代码行中,intcountOfTitles变量的基本数据类型。我们看到了原始数据类型的大小(以字节为单位),这决定了它们的值域。所有数值类型都是有符号的,最高位用于表示符号。char类型是无符号的,大小为 2 字节,这样 Java 就可以支持世界上任何语言的任何字符。通过使用代码片段,我们看到了不同类型变量在实际中的应用。

现在我们已经知道了如何声明和使用变量,接下来让我们继续学习那些能够让我们组合变量的运算符。

第三章:运算符和类型转换

第二章中,我们了解到变量只是命名的小鸽笼,并包含值。这些值是变化的,Java 相应地提供了八个原始数据类型。这些原始类型包括整数(bytecharshortintlong)、小数(floatdouble)以及布尔字面量(boolean)。

我们还学习了如何声明一个变量。由于 Java 是一种强类型语言,这意味着你必须在声明变量时立即为其指定一个数据类型。这正是原始数据类型非常有用的地方。

现在我们已经知道了如何声明变量,让我们用它们做一些有趣的事情。到本章结束时,你将能够使用 Java 的各种运算符组合变量。此外,你将理解 Java 类型转换,包括它是什么,以及何时以及为什么发生。

在本章中,我们将涵盖以下主要内容:

  • 学习 Java 运算符的协作方式

  • 理解 Java 的运算符

  • 解释 Java 类型转换

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch3

学习 Java 运算符的协作方式

Java 为我们提供了大量的运算符来使用。根据定义,如果我们有一个表达式 3 + 4,那么 +运算符,而 34操作数。由于 +两个 操作数,它被称为 二元 运算符。

在我们讨论运算符本身之前,我们必须首先讨论与 Java 运算符相关的两个重要特性,即优先级顺序结合性

优先级顺序

优先级顺序指定了操作数如何与运算符组合。当你在复杂表达式中共享操作数时,这一点变得很重要。在下面的代码段中,我们有一个表达式 2 + 3 * 4,其中 * 代表乘法,+ 代表加法:

int a = 2 + 3 * 4;System.out.println(a);

在前面的代码中,3 同时被 24 使用。因此,问题出现了,我们是将 32 组合在一起,表达式为 (2 + 3) * 4,得到 20;还是将 34 组合在一起,表达式为 2 + (3 * 4),得到 14?这就是优先级顺序发挥作用的地方。由于 * 的优先级高于 +34 组合在一起,因此表达式计算为 2 + (3 * 4)。请注意,计算顺序仍然是左到右;只是 34 组合在一起,而不是与 2 组合在一起。

表达式中的括号

注意,括号可以改变运算符的默认优先级顺序。正如我们所见,默认的优先级顺序,其中 * 的优先级高于 +,意味着 2 + 3 * 4 的结果是 14。这等同于 2 + (3 * 4)

然而,(2 + 3) * 4 的结果是 20。在这种情况下,括号将 32 组合在一起,因此表达式计算为 5 * 4 = 20

这就引出了一个问题,如果你正在评估一个包含具有相同优先级的运算符的表达式,这时结合性就适用了。

结合性

当一个表达式包含两个具有相同优先级的运算符时,运算符的结合性决定了运算符和操作数的分组。例如,在以下代码段中,我们正在评估一个涉及两个除法(它们具有相同的优先级)的简单表达式:

int x =  72 / 6 / 3;

由于除法运算符从左到右结合,6 将与 72 结合,而不是与 3 结合。因此,表达式等同于 (72 / 6) / 3,其结果为 12 / 3 = 4。括号也可以用来改变默认的结合顺序。例如,以下代码:

int x =  72 / (6 / 3);

在这种情况下,6 现在将与 3 结合,表达式计算结果为 72 / 2 = 36

表 3.1 概述了优先级和结合性规则:

表 3.1 – 优先级和结合性规则顺序

表 3.1 – 优先级和结合性规则顺序

注意,表 3.1 是简化的,因为它只提到了常用的运算符。例如,无符号右移运算符 >>> 被省略,因为它很少使用。另外,请注意,instanceof 运算符将在 第八章 中讨论。

有趣的是,赋值运算符,即 =,在优先级表中位于底部。这意味着无论赋值右侧的表达式是什么,赋值总是最后进行的。这是有道理的。此外,虽然大多数运算符的结合性是从左到右的,但赋值运算符的结合性是从右到左的。这在下述代码段中得到了演示:

boolean b1 = false;boolean b2;
boolean b3;
b3 = b2 = b1;
System.out.println(b1);
System.out.println(b2);
System.out.println(b3);

上述代码段输出了 false 三次。关键行是 b3 = b2 = b1;。由于赋值运算符的结合性是从右到左的,所以 b1 中的值,即 false,被赋值给 b2;然后,b2 中的值,现在是 false,被赋值给 b3

现在我们已经了解了这些属性,让我们来检查运算符本身。

理解 Java 的运算符

运算符可以分为以下几类:

  • 一元运算符

  • 算术运算符

  • 关系运算符

  • 逻辑运算符

  • 三元运算符

  • 复合赋值运算符

我们现在将依次讨论每一类。

一元运算符

一元运算符只有一个操作数,因此称为一元。让我们来检查它们。

前缀和后缀一元运算符

++-- 表示这些运算符,它们分别增加和减少 1。如果运算符出现在变量之前,它被称为前缀,而如果运算符出现在变量之后,它被称为后缀。例如,++x 是前缀递增,而 y-- 是后缀递减。

根据 ++-- 是出现在变量之前还是之后,在某些情况下可能会影响整个表达式的结果。这最好通过代码示例来解释,如 图 3**.1 所示:

图 3.1 – 前缀和后缀递增和递减运算符

图 3.1 – 前缀和后缀递增和递减运算符

在图 3.1 中,第 25 行我们可以看到x被初始化为3。在第 26 行,x增加 1 变为4。第 26 行是一个简单语句,因此,无论是前缀还是后缀表示法,都没有关系。第 27 行输出x的值,以显示此时它的值是4

第 28 行是事情变得有趣的地方。第 28 行的后缀表示法对屏幕输出有实际影响。因为它是在System.out.println命令中的后缀表示法,所以输出的是x的当前值,然后x增加 1。所以,屏幕上的输出是4,然后x增加到5。第 29 行演示了x5

在第 31 行,变量y被初始化为4。在第 32 行,y减去 1 变为3。再次强调,由于第 32 行是一个简单语句,前缀或后缀表示法没有区别。第 33 行输出y的值,以显示此时它的值是3

第 34 行的前缀表示法对屏幕输出没有实际影响。因为它是在System.out.println命令中的前缀表示法,所以先递减y的值,然后输出到屏幕的值匹配(两者都是2)。最后,第 35 行演示了y的当前值是2

一元加减运算符

现在我们已经讨论了前缀和后缀运算符,让我们来讨论其他一元运算符。图 3.2 中的代码将有所帮助:

图 3.2 – 其他一元运算符

图 3.2 – 其他一元运算符

在图 3.2 中,第 37 行使用一元加号+x初始化为6。在这里,+是默认的,因为不带符号的数字被认为是正数。第 38 行使用一元减号-y初始化为x的相反数。第 39 和第 40 行演示了xy分别是6-6

类型转换运算符

在图 3.2 中,第 42 行使用了类型转换运算符。我们将在本章后面更详细地讨论类型转换。现在,3.45是一个double字面量(8 字节),不能存储在大小为 4 字节的int变量z中。编译器会检测到这一点并生成错误。为了绕过这个错误,我们可以使用类型转换,其形式为(cast type)。这种转换使我们能够覆盖编译器错误。在这种情况下,我们将3.45转换为一个int变量,这意味着我们失去了小数位。因此,我们将3存储在z中,如第 43 行的输出所示。

逻辑非运算符

在图 3.2 中,第 45 行声明了一个布尔变量b并将其初始化为true。在第 46 行,我们通过使用逻辑非运算符输出b的相反值。请注意,我们并没有改变b的值,也就是说,b的值仍然是true。这可以通过第 47 行的输出得到证明。

现在,让我们来检查算术运算符。

算术运算符

有五种算术运算符,我们将在下面逐一考察。

加法/减法运算符

如同数学一样,+ 运算符代表加法,- 运算符代表减法。两者都是二元运算符;换句话说,有两个操作数,一个在运算符的左侧,一个在右侧。以下代码示例展示了这一点:

int res = 6 + 4 - 2;System.out.println(res); // 8

在此代码段中,res 被分配了 6 + 4 – 2 的值,即 8

乘法/除法运算符

* 运算符代表乘法,/ 运算符代表除法。两者都是二元运算符。请注意,整数除法会截断。以下代码段展示了这一点:

System.out.println(10/3); // 3

此代码段输出 3,因为整数除法会截断。我们正在将一个整数 10 除以另一个整数 3,余数被简单地丢弃。

余数运算符

% 运算符用于计算余数(余数)。以下代码示例展示了余数运算符的作用:

int mod1 = 10 % 3;System.out.println(mod1); // 1
int mod2 = 0 % 3;
System.out.println(mod2); // 0

第一行将 mod1 初始化为 10 除以 3 的余数,即 1。换句话说,3 可以整除 10 三次,剩下 1。因此,1 被分配给 mod1

mod2 的初始化很有趣:3 不能整除 0,没有剩余。因此,0 被分配给 mod2

算术运算符的优先级

根据 表 3.1*/% 的优先级高于 + 运算符,赋值运算符的优先级最低。图 3**.3 展示了这如何影响代码中表达式的计算:

图 3.3 – 算术运算符优先级

图 3.3 – 算术运算符优先级

第 61 行展示了 * 的优先级高于 +,因为表达式的计算结果是 3 + (2 * 4) = 3 + 8 = 11

第 63 行展示了括号会改变分组。现在,共享值 23 分组(而不是第 61 行的情况,那时是 4)。表达式现在计算结果为 5 * 4 = 20

第 65 行展示了 +- 也是从左到右结合的。表达式的计算结果为

10 - 2 = 8

第 67 行展示了 */% 也从左到右结合。表达式计算结果为 2 * 6 % 10,这又转换为 12 % 10,结果是 2

涉及 int 变量或更小类型的数学运算结果为 int

有趣的是,任何涉及 int 类型或更小类型的数学运算都会得到 int 类型。以下代码段展示了这一点:

byte b1=2, b2=3;

byte b3 = b1 + b2; // 编译器错误

byte b4 = (byte)(b1 + b2);// 正确

第一行声明了两个字节,即 b1b2。请注意,尽管 23 是整数文字,但编译器知道这些值在 byte 的范围(-128 到 +127)内,因此允许这些声明。

然而,下一行是一个问题。编译器有一个规则,即所有涉及 int 类型或更小类型的数学运算的结果都是 int。因此,即使两个字节之和 5 在 byte 范围内,编译器也会报错,说“从 int 转换到 byte 可能会丢失数据”。

最后一行通过在赋值之前将加法的结果(int 类型)强制转换为 byte 来解决这个问题。这意味着 int 类型的额外 3 个字节(无法放入 byte 中)将被简单地丢弃。因此,b1 + b2 的和从 int 转换为 byte,结果 byte 被赋值给 b4。在后面的章节中会更详细地讨论强制转换。

我们将通过考察 + 在不同上下文中的用法来结束对算术运算符的讨论。

字符串连接

如我们所见,Java 使用 + 进行数学加法。然而,这仅在两个操作数都是数字时才会发生。例如,3 + 4 的结果是 7,因为两个操作数 3 和 4 都是数字。

然而,如果任一操作数(或两个都是)是字符串,Java 会执行 String 连接。字符串字面量用双引号括起来——例如,"abc""123""Sean""Maaike" 都是 String 字面量。所以,为了清楚地了解何时执行何种操作,让我们看看一些示例:

  • 3 + 4 是数学加法。因此,结果是 7。

  • “3” + 4 是一个字符串连接操作,因为 + 的左边有一个字符串。结果是字符串 “34。”

  • 3 + “4” 是一个字符串连接操作,因为 + 的右边有一个字符串。再次,结果是字符串 “34。”

  • “3” + “4” 是一个字符串连接操作,因为 + 的两边都有字符串。结果也是字符串 “34。”

那么,在字符串连接操作中到底发生了什么?Java 当操作数类型不同时无法执行任何数学运算。让我们通过一段示例代码来分析:

String s = "3" + 4;System.out.println(s); // "34"

首先要注意的是,第一行代码之所以能编译,是因为 "3" + 4 的结果是字符串字面量。当 Java 遇到 + 的左边/右边/两边都有字符串时,它会执行字符串连接(追加)。本质上,由于 + 是从左到右结合的,Java 会将 + 右边的字符串追加到 + 左边的字符串的末尾。

在这个例子中,Java 看到了字符串字面量 "3"+ 操作符,并意识到它必须执行字符串连接。为此,在内存中,它创建了一个 4 的字符串版本——换句话说,"4"。整数字面量 4 没有被修改。因此,在底层创建了一个新的变量——它是一个 String 变量,其值为 "4"。现在表达式是 "3" + "4"。由于 + 的两边现在都是相同类型的操作数(都是字符串),Java 可以执行连接。新的字符串是 "3" + "4" 的结果,即 "34"。这就是被赋值给 s 的内容。第二行通过输出 s"34" 来演示这一点。

图 3**.4 中,提供了一个更复杂的示例:

图 3.4 – 字符串连接操作

图 3.4 – 字符串连接操作

在第 79 行,由于两个操作数 ab 都是整数,Java 将 res 初始化为 532 的和)。

第 82 行的评估方式如下:3 + "abc" = "3" + "abc" = "3abc"。换句话说,Java 认识到由于 + 的右侧存在 "abc",它必须执行字符串连接。因此,在内存的某个地方,创建了一个包含 a 值的字符串版本。换句话说,创建了一个值为 "3" 的变量。请注意,a 仍然是一个值为 3 的 int 类型。现在,Java 可以继续执行,因为两个操作数都是同一类型(字符串):"3" + "abc" 结果为 "3abc"

第 83 行演示了字符串在 + 的哪一侧并不重要。此外,字符串是字面量还是字符串变量也不重要。第 83 行的表达式评估方式如下:"abc" + 3 = "abc" + "3" = "abc3"。这就是 s2 的初始化值。第 84 行输出 s1s2 的值,它们之间有一个空格。请注意,System.out.println 期望一个字符串。第 84 行的字符串输出构造方式如下:"3abc" + " " = "3abc " + "abc3" = "3abc abc3"

第 86 和 87 行需要特别说明。第 86 行的问题在于输出字符串的构造方式如下:"Output is "+ 3 = "Output is " + "3" = "Output is 3" + 2 = "Output is 3" + "2" = "Output is 32"。这不是我们想要的结果。

第 87 行通过使用括号来确保 a + b 被分组,从而纠正了这个问题。因此,字符串的构造方式如下:"Output is "+ 5 = "Output is "+ "5" = "Output is 5"

这样就完成了算术运算符的介绍。接下来,我们将研究关系运算符。

关系运算符

Java 有六个关系运算符,它们都返回 boolean 类型的 truefalse 值。具体如下:

  • == 是等价运算符

  • != 是不等价运算符

  • > 是大于运算符

  • >= 是大于或等于运算符

  • < 是小于运算符

  • <= 是小于或等于运算符

图 3**.5 展示了关系运算符在代码中的实际应用:

图 3.5 – 代码中的关系运算符

图 3.5 – 代码中的关系运算符

第 89 行声明了两个 int 类型的变量,分别是 xy,并将它们分别初始化为 34。第 90 行使用 Java 的等价运算符 == 来检查 xy 是否等价。由于它们不等价,第 90 行输出 false。第 91 行检查相反的情况。由于 x 不等价于 y,第 91 行输出 true

第 92 行输出 x 是否大于 y。当然,这是 false,因为 3 不大于 4。同样,第 93 行输出 x 是否大于或等于 y。这同样是 false

第 94 行输出 x 是否小于 y。这是 true,因为 3 小于 4。第 95 行输出 x 是否小于或等于 y。这同样是 true

关系运算符及其布尔返回值在以后将非常有用,尤其是当我们查看第四章(B19793_04.xhtml#_idTextAnchor087)中的条件语句时。

隐式提升

虽然 Java 的运算符不需要操作数必须是完全相同的类型,但操作数必须是兼容的。考虑以下代码片段:

System.out.println(3 + 4.0); // 7.0

System.out.println(4 == 4.0); // true

第一行尝试将一个int类型的3变量加到一个double类型的变量上。Java 意识到这两个类型并不相同。然而,Java 可以找出一个安全的解决方案而不打扰我们。这就是隐式提升的作用。int类型需要 4 个字节的存储空间,而double类型需要 8 个字节的存储空间。在后台,内存的某个地方,Java 声明了一个临时的double变量,并将int 3提升为double 3.0,然后将3.0存储在这个临时位置。现在,Java 可以将3.0加到4.0上(因为两者都是double类型),得到的结果是7.0

第二行比较int 4double 4.0。发生同样的过程。Java 隐式地将4提升为4.0(在新的临时位置),然后比较4.04.0。这导致输出true

现在,我们将注意力转向逻辑运算符。

逻辑运算符

逻辑运算符使我们能够通过组合子表达式来构建复杂的boolean表达式。这些运算符如下:

  • &&是逻辑与

  • ||是逻辑或

  • &是位与

  • |是位或

  • ^是位异或(XOR)

我们将依次通过代码示例来检查这些运算符,但在这样做之前,回顾一下表 3.2中的真值表是有益的,如下所示:

表 3.2 – 布尔真值表

表 3.2 – 布尔真值表

表 3.2中,前两列,PQ,代表两个表达式,其中T表示真,F表示假。例如,逻辑与列(P && Q列)表示整体表达式P && Q的结果,这取决于PQ的值。所以,如果P为真且QT,那么P && Q也为真。

在这个表格的指导下,我们依次检查运算符。

逻辑与(&&)

逻辑与运算符表示两个布尔操作数都必须为真,整个表达式才为真。这由表 3.2中的P && Q列表示。

注意,这个操作符被称为短路操作符。例如,在一个表达式 P && Q 中,如果 P 评估为假,那么 &&不会评估表达式 Q,因为整体表达式将评估为假。这是因为 F && F 是假,F && T 也是假。实际上,Java 知道一旦在 && 表达式的左侧表达式 P 为假,整体表达式必须是假。因此,没有必要评估右侧的表达式 Q,所以它短路了。这最好用一个代码示例来解释:

boolean b1 = false, b2 = true;boolean res = b1 && (b2=false); // F &&
System.out.println(b1 + " " + b2 + " " + res);// false true
  false

第一行初始化了两个布尔变量,b1b2,分别设置为 falsetrue。第二行是重要的一行。请注意,在 b2=false 子表达式中需要括号来使代码能够编译(否则,你会得到一个语法错误)。因此,当我们为 b1 插入 false 时,表达式计算为 F && (b2=false)。由于评估顺序是从左到右,这将导致 && 短路,因为无论表达式中剩下什么,整体表达式都无法评估为真。这意味着 (b2=false) 子表达式不会被执行。

最后一行输出了变量的值。输出分别是 falsetruefalse,对应于 b1b2res。关键的是,b2true,这证明了 && 短路了。

逻辑或(||)

逻辑或表示,只要有一个或两个布尔操作数是真,整体表达式就是真。这由 表 3.2 中的 P || Q 列表示。

这个操作符也是一个短路操作符。例如,在一个表达式 P || Q 中,如果 P 评估为真,那么 ||不会评估表达式 Q,因为整体表达式将评估为真。这是因为 T || F 是真,T || T 也是真。实际上,Java 知道一旦在 || 表达式的左侧表达式 P 为真,整体表达式必须是真。因此,没有必要评估右侧的表达式 Q,所以它短路了。再次用一个代码示例来帮助说明:

boolean b1=false, b2=true;boolean res = b2 || (b1=true);  // T ||
System.out.println(b1 + " "+ b2 + " "+res);// false true
  true

第一行初始化了两个布尔变量,b1b2,分别设置为 falsetrue。第二行是重要的一行。再次请注意,在 b1=true 子表达式中需要括号来使代码能够编译。因此,当我们为 b2 插入 true 时,表达式计算为 T || (b1=true)。由于评估顺序是从左到右,这将导致 || 短路,因为无论表达式中剩下什么,整体表达式都无法评估为假。

最后一行输出了变量的值。输出分别是 falsetruetrue,对应于 b1b2res。关键的是,b1false,这证明了 || 短路了。

评估顺序与优先级

这个主题经常引起混淆,最好通过一些示例代码来解释。让我们从一个看似简单的例子开始:

int x=2, y=3, z=4;

int res = x + y * z; // x + (y * z)

System.out.println(res); // 14

由于 * 的优先级高于 +,公共元素 yz 而不是 x 组合在一起。因此,整个表达式是 x + (y * z) = 2 + 12 = 14。

这里要注意的重要一点是评估顺序是从左到右的,并且由于评估顺序高于优先级,x(y * z) 子表达式之前被评估。虽然在这个例子中这没有区别,但让我们看看一个有区别的例子:

boolean a=false, b=false, c=false;

// a || (b && c)

// 下行计算结果为 T ||

boolean bool = (a = true) || (b = true) && (c = true);

System.out.print(a + ", " + b + ", " + c); // true, false, false

由于 && 的优先级高于 ||,表达式计算结果为 (a = true) || ( (b = true) && (c = true) )

换句话说,常见的子表达式 (b = true)(c = true) 而不是 (a = true) 组合在一起。现在来谈谈关键点:评估顺序高于优先级。因此,(a = true) 首先被评估,结果为 T || ((b = true) && (c = true))

由于 || 是短路运算符,表达式右侧的其余部分(|| 右侧)为 truefalsefalse,分别对应 abc。这里要注意的关键点是 bc 仍然是 false

既然我们已经讨论了逻辑运算符,我们将继续讨论位运算符。

位运算符

虽然一些位运算符看起来与逻辑运算符非常相似,但它们的操作方式却截然不同。主要区别在于位运算符可以与布尔和整型(byteshortintlongchar)操作数一起工作。此外,位运算符不会短路。

让我们先检查布尔位运算符。

位与(&)

比较位与(&)与逻辑与(&&),区别在于位与不会短路。这由 表 3.2 中的 P & Q 列表示。如果我们使用逻辑与的示例代码,但将其更改为使用位与运算符,你将看到输出结果的不同:

boolean b1 = false, b2 = true;boolean res = b1 & (b2=false); // F & F
System.out.println(b1 + " " + b2 + " " + res);// false
  false false

在这种情况下,(b2=false) 子表达式被执行,因为 & 没有短路。因此,我们得到 false & false,结果是 false。因此,所有变量的输出都是 false

位或(|)

比较位或(|)与逻辑或(||),区别在于位或不会短路。这由 表 3.2 中的 P | Q 列表示。如果我们使用逻辑或的示例代码,但将其更改为使用位或运算符,你将看到输出结果的不同:

boolean b1=false, b2=true;boolean res = b2 | (b1=true);  // T | T
System.out.println(b1 + " "+ b2 + " "+res);// true true
  true

在这种情况下,(b1=true) 子表达式被执行,因为 | 没有短路。所以,我们有:true | true,结果是 true。因此,所有变量的输出都是 true

按位异或 (^)

这是一个非短路运算符。按位异或运算符,用 ^ 表示,当且仅当其中一个操作数是 true不是 两个都为 true 时,结果为 true。这由 表 3.2 中的 P ^ Q 列表示。让我们通过代码来看一些示例:

boolean b1 = (5 > 1)  ^ (10 < 20);   // T ^ T == Fboolean b2 = (5 > 10) ^ (10 < 20);   // F ^ T == T
boolean b3 = (5 > 1)  ^ (10 < 2);    // T ^ F == T
boolean b4 = (5 > 10) ^ (10 < 2);    // F ^ F == F
// false true true false
System.out.println(b1 + " " + b2 + " " + b3 + " " + b4);

boolean 变量 b1 被初始化为 false,因为两个子表达式——(5 > 1)(10 < 20)——都是 true。同样,b4 也被初始化为 false,因为 (5 > 10)(10 < 2) 都是 false

然而,b2true,因为尽管 (5 > 10)false(10 < 20)true,且 F ^ Ttrue。同样,b3true,因为 (5 > 1)true(10 < 2)F,且 T ^ Ftrue

现在我们已经检查了按位运算符与 boolean 操作数一起使用的情况,我们现在将简要地检查当操作数是整数时,这些运算符是如何工作的。

按位运算符(整数操作数)

虽然不常用,但我们为了完整性而包括它们。一个代码示例在这里很有用:

byte b1 = 6 & 8;       // both bits must be 1byte b2 = 7 | 9;       // one or the other or both
byte b3 = 5 ^ 4;       // one or the other but not both
System.out.println(b1 + ", " + b2 + ", "+b3); // 0, 15, 1

当操作数是整数(而不是布尔值)时,位模式在评估结果时变得重要。对于 & 运算符,结果中的该位必须两个位都为 1 才能是 1:

6 & 8 (in binary) = 0110 & 1000 = 0000 = 0

对于 | 运算符,结果中的该位必须为 1,至少有一个位或两个位必须为 1:

7 | 9 (in binary) = 0111 | 1001 = 1111 = 15

对于 ^ 运算符,结果中的该位必须至少有一个位为 1,但不能两个都为 1:

5 ^ 4 (in binary) = 0101 ^ 0100 = 0001 = 1

这样就完成了按位运算符。现在,让我们来介绍三元运算符。

三元运算符

如其名所示,三元运算符是一个接受三个操作数的运算符。三元运算符用于评估布尔表达式,并根据结果相应地给变量赋值。换句话说,由于布尔表达式只评估为 truefalse,三元运算符的目标是决定将哪个值赋给变量。

语法形式如下:

variable = boolean expression ? value to assign if true :value to assign if false

让我们来看一个示例:

int x = 4;String s = x % 2 == 0 ? " is an even number" : " is an odd
  number";
System.out.println(x + s); // 4 is an even number

在这个例子中,要评估的布尔表达式是 x % 2==0,由于 x = 4,它评估为 true。因此,是偶数 被分配给字符串 s 并输出。如果 x 是 5,那么布尔表达式将是 false,因此,是奇数 将被分配给 s 并输出。

我们将要检查的最后一批运算符是复合赋值运算符。

复合赋值运算符

这些运算符作为更冗长的表达式的简写而存在。例如,假设 xy 都是整数,x = x + y 可以写成 x += y。所有数学运算符都有相应的复合赋值运算符:

  • += 示例:x += y 等同于 x = x + y

  • -= 示例:x -= y 等同于 x = x - y

  • *= 示例:x *= y 等同于 x = x * y

  • /= 示例:x /= y 等同于 x = x / y

  • %= 示例:x %= y 等同于 x = x % y

的确,对于位运算符也有复合赋值运算符——例如,x &= 3 等同于 x = x & 3,但它们很少使用,所以我们只是提一下它们的存在。

有一些细微之处需要注意。如前所述,任何涉及 int 类型或更小类型的数学运算都会得到 int 类型。这可能会导致需要类型转换才能使代码编译。对于复合赋值运算符,类型转换是内置的,因此不需要显式转换。以下代码为例:

byte b1 = 3, b2 = 4;//  b1 = b1 + b2;         // compiler error
b1 = (byte)(b1 + b2);     // ok
b1 += b2;         // ok, no cast required

第一行初始化了两个字节,b1b2,分别设置为 34。第二行被注释掉,因为它会生成编译器错误。b1b2 的相加结果是一个 int 类型,不能直接赋值给 byte 变量,除非你将其从 int 类型转换为 byte 类型。这就是第三行所做的工作——使用类型转换(byte)来覆盖编译器错误。我们很快就会介绍类型转换,但到目前为止,只需意识到,使用类型转换,你正在覆盖编译器错误,实际上是在说“我知道我在做什么,继续。”

最后一行很有趣,因为在幕后,它与第三行相同。换句话说,编译器将 b1 += b2 转换为 b1 = (byte) (b1 + b2)

另一个需要注意的细微之处是,复合赋值运算符右侧的内容将被分组,无论其优先级如何。以下是一个例子。考虑以下内容:

int x = 2;x *= 2 + 5;                   // x = x * (2 + 5) = 2 * 7 = 14
System.out.println(x); // 14

我们知道 * 的优先级高于 +,且评估顺序是从左到右。话虽如此,*= 右侧的内容被编译器通过将 2 + 5 括起来(在幕后)进行分组。因此,表达式变为 2 * (2 + 5) = 2 * 7 = 14。为了进一步说明这一点,如果编译器没有插入括号,表达式将被评估为 9。换句话说,由于运算符优先级,表达式将被评估为 (2 * 2) + 5 = 4 + 5 = 9。然而,正如我们所看到的,这不是情况。

让我们看看另一个更复杂的例子:

int k=1;k += (k=4) * (k+2);
System.out.println(k); // 25

在这个例子中,右侧再次被括号包围:

k += (right hand side) where the right hand side is (k=4) *  (k+2)

+= 转换为其较长的形式,我们得到以下输出:

k = k + (right hand side)

评估顺序是从左到右,所以插入当前 k 的值,即 1,结果如下:

k = 1 + (right hand side)

现在,通过插入右侧的表达式,我们得到以下结果:

k = 1 + ( (k=4) * (k+2) )

由于评估顺序是从左到右,我们在加上 2 之前将 k 改为 4

k = 1 + ( 4 * 6 )k = 1 + 24
k = 25

这就结束了我们对 Java 运算符的处理。现在,让我们来探讨 Java 类型转换,这是我们已经在本章中提到过的主题。

解释 Java 类型转换

为了正确地讨论类型转换,我们需要解释 Java 原始数据类型的类型提升和类型缩小。考虑到这一点,记住原始数据类型的大小(以字节为单位)是有帮助的。表 3.3 表示了这些信息:

表 3.3 – Java 的原始数据类型大小

表 3.3 – Java 的原始数据类型大小

前面的表格展示了 Java 各种原始数据类型的大小(以字节为单位)。这将在我们讨论类型提升和类型缩小时有所帮助。

类型提升

类型提升是自动完成的;换句话说,不需要类型转换。由于提升是在后台完成的,因此类型提升也被称为 隐式提升。考虑到 表 3.3,类型提升的规则如下:

byte → short/char → int → long → float → double

根据表 3.3 中的大小,大多数这些规则应该是有意义的。例如,byte 可以自动适应 short,因为 1 个字节可以自动适应 2 个字节。唯一有趣的一个是 longfloat,这是从 8 个字节到 4 个字节的类型提升。这是可能的,因为尽管 long 需要 8 个字节,而 float 只需要 4 个字节,但它们的范围不同 – 即 float 类型可以容纳任何 long 值,但反之则不行。这在下述代码片段中显示:

System.out.println("Float: " + Float.MAX_VALUE);// Float:  3.4028235E38
System.out.println("Float: " + Float.MIN_VALUE);// Float:
  1.4E-45
System.out.println(Long.MAX_VALUE);  //   9223372036854775807
System.out.println(Long.MIN_VALUE);   //  -9223372036854775808

注意用于浮点数的科学记数法 Efloat 占用的空间更少,但由于其表示方式,它可以容纳比 long 更大和更小的数字。

科学记数法

科学记数法是一种表示十进制数字的简写方式,可以用于表示非常大和/或非常小的数字。以下是一些示例:

double d1 = .``00000000123;

double d2 = 1.23e-9;

System.out.println(d1==d2); // true

double d3 = 120_000_000;

double d4 = 1.2e+8;

System.out.println(d3==d4); // true

由于这两个比较都返回 true,这意味着 d1d2 的内部表示。同样,d3d4 也是等效的。

让我们通过代码来检查类型提升。图 3**.6 展示了这一点:

图 3.6 – 隐式类型提升示例

图 3.6 – 隐式类型提升示例

第 14 行是一个常规的 char 赋值 – 换句话说,没有类型提升。注意字符(由 char 表示)只是简单的数字(0..65,535)。为了表示一个字符,我们将字符放在单引号内。相比之下,String(字符序列)用双引号表示。因此,"a" 是一个 String,而 'a' 是一个字符。

第 15 行是从char(2 字节)到int(4 字节)的扩展。第 16 行是从intfloat的扩展。尽管intfloat都需要 4 字节,如前所述,与long一样,float有更大的范围,所以这里没有问题。第 17 行是从floatdouble的扩展。最后,第 18 行是从longfloat的扩展。注意,任何地方都没有编译器错误,并且在这些赋值中不需要使用转换运算符。

现在,让我们讨论需要转换的窄化。

窄化

转换运算符是一个括号内的类型 – 例如,(int)(byte)都是转换运算符,分别将值转换为intbyte。考虑到表 3.3,以下图,图 3**.7,展示了需要转换的赋值:

图 3.7 – 转换示例

图 3.7 – 转换示例

在前面的图中,第 23 行试图将3.3,一个double类型(8 字节),赋值给一个int类型(4 字节)。没有转换,这将是一个编译器错误。有了转换,你将覆盖编译器错误。因此,在第 23 行,我们将3.3转换为int,并将这个int赋值给i变量。因此,赋值完成后,i的值为3

第 24 行是将int类型的233转换为byte变量b。这个字面值超出了byte的范围(-128 到+127),因此需要转换。第 25 行是将double类型的3.5转换为float。记住,默认情况下,十进制数是double;要将其视为float而不是double,必须后缀fF。例如,3.3ffloat

第 26 行的输出是i为 3,b为-23,f为 3.5。注意,在输出中,float变量没有f

我们是如何得到-23的,将在以下说明中解释。

字节溢出

记住,byte的范围是-128(10000000)到+127(01111111)。最左边的位是符号位,其中 1 表示负数,0 表示正数。

在前面的例子中,我们做了以下操作:

byte b = (byte) 233;

233(一个整数)的值对于byte来说太大,但b是如何被赋予-23 的值的呢?将233映射为int类型会得到以下位模式:

11101001 = 1 + 8 + 32 + 64 + 128 = 233 (int)

注意,由于int是 4 字节,233的位模式是 00000000000000000000000011101001。将该位模式映射为byte(高阶 3 字节被截断)会得到以下输出:

11101001 = 1 + 8 + 32 + 64 + (-128) = -23 (byte)

记住,最左边的位是符号位。这就是为什么计算中包含-128的原因。它是-(2⁷) = -128

我们将通过查看一些需要/不需要转换的非常规示例来结束本节。

是要转换还是要不转换,这是一个问题

有一些情况下,由于编译器在后台应用规则,不需要进行类型转换。让我们通过代码示例来检查这些情况。图 3.8展示了代码:

图 3.8 – 不总是需要类型转换的情况

图 3.8 – 不总是需要类型转换的情况

第 32 行声明并初始化了一个名为cchar变量,将其赋值为int类型的12。记住,char变量本质上是很小的正数。尽管我们将一个int类型的值(4 字节)赋给了一个char变量(2 字节),但由于字面值在char的范围内(0 到 65,535),编译器允许这样做。如果字面值超出了char的范围,编译器将生成错误——这就是第 33 行发生的情况。

第 34 行声明并初始化了一个名为sshort变量,将其赋值为int类型的12。同样,尽管short只能存储 2 字节,但编译器意识到它可以存储字面值12,并允许这样做。

注意,从编译器的角度来看,将字面值赋给变量与将变量赋给变量是不同的。例如,第 32 和 37 行是相当不同的。当我们讨论图中的下一几行时,这一点将变得明显。

第 35 到 38 行展示了尽管charshort都需要 2 字节,但它们的范围不同:char(0 到 65,535)和short(-32,768 到+32,767)。这意味着short变量可以存储负值,例如-15,而char变量则不能。相反,char变量可以存储如 65,000 这样的值,但short变量则不能。因此,正如第 35 和 37 行所展示的,你不能直接将char变量赋给short变量,反之亦然。在这两种情况下,你需要进行类型转换。第 36 和 38 行展示了这一点。

编译时常量

然而,第 40 到 42 行展示了绕过我们刚才概述的类型转换要求的方法。如果你将变量声明为编译时常量(并且假设值在范围内),编译器将允许变量到变量的赋值。第 40 行使用final关键字声明了一个编译时常量。我们将在第九章中详细讨论final,但在这个上下文中,它意味着c1将始终具有12的值。对于c1来说,值是固定的(或常量),这是在编译时完成的。如果你尝试更改c1的值,你将得到编译器错误。现在编译器知道c1将始终具有12作为其值,编译器可以应用与字面值相同的规则;换句话说,值是否在范围内?这就是为什么第 42 行不会生成编译器错误。

这就结束了我们对运算符的讨论。现在,让我们应用它们!

练习

侏罗纪乐园做得很好。恐龙很健康,客人也很高兴。现在你有一些新技能,让我们继续进行稍微复杂一些的任务!

  1. 饲养员们希望能够跟踪恐龙的体重。你的任务是编写一个程序,计算两只恐龙的平均体重。这将帮助我们的营养师团队规划正确的食物分量。

  2. 适当的营养对于我们恐龙的健康至关重要。饲养员们希望有一个大致的指导,了解应该给恐龙喂食多少食物。编写一个程序,根据恐龙的体重来确定所需的食物量。你可以根据恐龙每单位体重的食物需求量来计算。

  3. 对于我们的公园,我们需要有一个闰年检查器。在我们对科学准确性的承诺中,使用取模运算符来确定当前年份是否是闰年。我们想确保我们的以日历为主题的展览总是最新的。

  4. 创建一个程序,检查公园的最大容量是否已达到。程序只需在“最大容量达到:”之后打印出 true 或 false。这对于维护安全标准和确保良好的游客体验至关重要。

  5. 有时游客想要比较恐龙的年龄。我们理解——这可能在教育目的上很有趣。编写一个程序,计算两只恐龙之间的年龄差异。

  6. 在中生代伊甸园,我们有一个非常强的以安全为第一的政策。编写一个程序,检查公园的安全评级是否高于某个阈值。保持良好的安全评级是我们的首要任务。

项目 - 恐龙餐食规划器

作为中生代伊甸园的饲养员,关键任务包括为我们心爱的恐龙规划餐食。虽然我们还没有使用条件语句和循环,但我们仍然可以计算一些基本需求!

开发一个简单的程序,帮助饲养员为不同的恐龙规划餐食分量。该程序应使用恐龙的体重来计算每餐需要吃多少食物。

如果你需要更多指导,以下是你可以这样做的方法:

  • 为恐龙的体重和它每天需要摄入的体重比例声明变量。例如,如果一个恐龙每天需要摄入其体重的 5%,而它重 2,000 公斤,那么它就需要摄入 100 公斤的食物。

  • 现在,假设你每天给恐龙喂食两次。声明一个变量来表示喂食次数,并计算每次喂食需要提供多少食物。在这个例子中,每次喂食将是 50 公斤。

  • 以有意义的方式打印出结果——例如,“我们的 2,000 公斤恐龙每天需要吃 100 公斤食物,这意味着我们每次喂食需要提供 50 公斤。”

摘要

在这一章中,我们学习了 Java 运算符的工作原理以及它们是如何协作的。此外,我们还学习了如何在 Java 中进行类型转换。

最初,我们讨论了与运算符相关的两个重要属性:优先级和结合性。我们了解到优先级决定了常见项是如何分组的。当运算符具有相同的优先级顺序时,结合性就会发挥作用。

然后,我们检查了运算符本身。我们首先查看一元运算符,它们有一个操作数,例如前缀/后缀增量/减量运算符++--

然后,我们转向算术运算符:+-*/%。我们指出,整数除法会截断。此外,我们还讨论了涉及int类型或更小类型的任何数学运算都会导致结果为int。最后,我们详细讨论了当其中一个或两个操作数是字符串时+运算符的工作方式。在这些情况下,执行字符串连接操作。

接下来,我们讨论了关系运算符。这些运算符的结果始终是布尔值,当我们在第四章中构建条件语句时将使用它们。

由于 Java 无法执行不同类型之间的操作,因此尽可能进行隐式提升。这就是 Java 在内存中的某个位置将较小的类型提升为较大的类型的方式。这是 Java 在操作中无意识地继续下去的方式。

然后,我们讨论了逻辑运算符:&&||&|^。为了帮助理解,我们展示了真值表。逻辑&&和逻辑||运算符都是短路运算符。理解这一点很重要,因为求值的顺序优于优先级。

位运算符,位与运算符(&)和位或运算符(|),与&&||类似,但不同之处在于,与&&||不同,&|永远不会短路,并且也可以与整型操作数一起工作。

三元运算符有三个操作数。它评估一个布尔表达式,并根据布尔表达式是true还是false将两个值之一赋给一个变量。

关于运算符,我们最后讨论的是复合赋值运算符,每个数学运算符都有一个。

在我们讨论类型转换时,我们涵盖了宽化和狭义转换。宽化是在后台进行的,通常被称为隐式提升。这里没有风险,因为被提升的类型可以轻松地适应目标类型。

狭义转换需要使用类型转换。这是因为,既然你正在从一个需要更多存储空间的类型转换到一个需要较少存储空间的类型,那么可能会丢失数据。

现在我们知道了如何使用运算符,在下一章中,我们将转向条件语句,其中经常使用运算符。

第四章:条件语句

第三章 中,我们学习了 Java 运算符。我们讨论了运算符的两个重要属性,即优先级和结合性。优先级有助于分组共享的操作数。当优先级级别相同时,结合性用于分组。

我们讨论了一元运算符——前缀和后缀增量/减量、类型转换和逻辑非。我们还涵盖了二元运算符——算术、关系、逻辑、位运算和复合赋值。我们学习了当操作数(或两者)为字符串时 + 符号的行为。我们讨论了逻辑与 (&&) 和逻辑或 (||) 以及它们的短路特性。最后,我们介绍了具有三个操作数的三元运算符。

我们还学习了 Java 的类型转换。这可以隐式地进行,称为隐式提升宽化。另一种选择是显式转换,称为窄化。在窄化时,我们必须将类型转换为目标类型,以消除编译器错误。最后,我们讨论了编译时常量,由于它们的值永远不会改变,因此使编译器能够应用不同的规则。

现在我们已经了解了运算符,让我们用它们做一些有趣的事情。到本章结束时,你将能够使用 Java 的运算符来创建条件语句。条件语句使我们能够做出决策。此外,你将理解 Java 中的一个基本概念,即作用域。

本章我们将涵盖以下主要内容:

  • 理解作用域

  • 探索 if 语句

  • 掌握 switch 语句和表达式

技术要求

本章的代码可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch4

理解作用域

在编程中,作用域定义了变量在程序中的可用性。这通常被称为变量的可见性。换句话说,变量在代码中的“可见”位置在哪里。Java 使用块作用域。为了解释 Java 的作用域,我们首先必须了解什么是块。

什么是块?

大括号分隔代码块。换句话说,一个块从开括号 { 开始,以闭括号 } 结束。注意,大括号面对面,如 {}。一个变量从其声明的地方开始,到该块的闭括号 },都是可见并可用的。图 4**.1 展示了一个代码示例,以帮助解释:

图 4.1 – Java 中的块作用域

图 4.1 – Java 中的块作用域

在前面的图中,我们在第 5 行声明了一个 int 类型的变量 x 并将其初始化为 1。当前代码块是包围在 {} 中的 Java 语句组。因此,x 变量的代码块从第 4 行开始,即开括号所在的位置,到第 15 行结束,即闭括号所在的位置。因此,x 的作用域从声明它的第 5 行开始,到当前作用域的闭括号第 15 行。当我们第 6 行引用 x 时,没有问题,因为 x 在作用域内。

在第 8 行,我们使用 { 开始一个新的代码块/作用域。尽管有些不寻常,因为第 8 行之前没有代码,但第 8 行到第 12 行定义了一个有效的代码块。注意,外部作用域中的变量可以在内部(嵌套)作用域中可见。这可以在第 11 行看到,在内部作用域中,我们引用了在外部作用域中声明的变量,即 x,没有任何问题。

然而,情况并非总是如此;在内部作用域中定义的变量在外部作用域中不可见。y 变量的作用域从其声明处(内部作用域的第 9 行)到第 12 行(该作用域的闭括号)。因此,我们在第 14 行得到编译错误,因为在外部作用域中我们引用了 y 变量。

缩进

缩进确实有助于识别代码块和作用域。我们使用的风格是在行的末尾开始代码块。例如,在 图 4.1 中,注意第 3 行的开括号 {。该代码块的闭括号 }(以及作用域)在第 16 行。从缩进的角度来看,闭括号 } 直接位于第 3 行的 public 关键字下方。更具体地说,闭括号直接位于 public 中的 'p' 下方。虽然这对编译不是必需的,但它确实使代码更容易阅读和维护——作用域从第 3 行开始,要找到作用域的结束,只需扫描程序以找到匹配的闭括号 },该括号与 public(第 3 行)对齐。

第 4 行也定义了一个代码块和作用域。第 15 行包含该作用域的匹配闭括号 }——注意闭括号与关键字 public(第 4 行)对齐。幸运的是,编辑器在保持代码正确缩进方面非常有帮助。

总结来说,代码块是用 {} 定义的。由于 Java 使用代码块作用域,代码块定义了变量可以使用的范围。变量在嵌套作用域中是可见的,但反之则不然。

现在我们已经了解了 Java 中的作用域,让我们来检查 Java 中的条件逻辑。我们将从 if 语句开始。

探索 if 语句

如其名所示,条件语句基于条件的评估。这个条件的结果是 truefalse——换句话说,是一个布尔值。图 4.2 介绍了整体 if 语句的语法:

图 4.2 – if 语句的语法

图 4.2 – if 语句的语法

前一个图中的方括号 [] 表示某项是可选的。例如,else if 语句和 else 语句都是可选的。if 语句本身是必需的。三个省略号 ... 表示你可以有任意数量的 else if 语句(或者一个都没有)。

现在我们有了整体语法,让我们将其分解成更小的部分。

if 语句本身

如前所述,if 语句评估一个 boolean 表达式。这个 boolean 表达式被括号包围。如果 if 子句之后只有一个语句,则界定代码块的括号是可选的。然而,始终显式声明一个代码块被认为是良好的实践。图 4**.3 展示了两种风格:

图 4.3 – 简单的  语句

图 4.3 – 简单的 if 语句

首先,让我们解释一下代码。注意缩进,这是由代码编辑器自动提供的。这使得更容易看到受各种 if 语句控制的语句。第 9 行和第 11 行都展示了简单的 if 语句,它们只控制一个语句。第 9 行控制第 10 行。这意味着如果第 9 行是真的,则第 10 行将执行。如果第 9 行是假的,则跳过第 10 行。同样,如果第 11 行是真的:第 12 行将执行;如果第 11 行是假的,则跳过第 12 行。

然而,如果你希望在 if 语句为真时执行两个或更多语句,则需要一个代码块。这由第 13 行到第 16 行的代码块演示。如果第 13 行的布尔表达式评估为真,则第 14 行和第 15 行的两个语句都将执行。这是因为它们在一个代码块中。

关于运行程序,当 x 初始化为 5y4 时,当第 9 行执行,它为真(因为 5 > 4)。因此,第 10 行执行,因此 图 4**.3 的输出是 5 > 4。第 11 行和第 13 行都执行了,但它们都评估为假,所以屏幕上没有其他输出。

假设在第 8 行,我们将变量初始化如下:

int x=4, y=5;

现在,if(x > y) 为假且第 10 行未执行;if(x < y) 为真且第 12 行将 4 < 5 输出到屏幕上;if(x == y) 也为假,因此第 14 行和第 15 行未执行。

最后,让我们通过以下方式更改第 8 行,使变量相等:

int x=4, y=4;

现在,if(x > y) 为假,所以第 10 行未执行;if(x < y) 也为假,所以第 12 行未执行;然而,if(x == y) 为真,所以第 14 行将字符串 s 构建为 “4 == 4”,第 15 行将其输出。

注意缩进,这是由代码编辑器自动提供的。这使得更容易看到受各种 if 语句控制的语句。

else if 语句

图 4**.3 中,没有代码来处理 if 表达式评估为假的情况。这就是 else if 语句发挥作用的地方。图 4**.4 展示了代码中的 else if

图 4.4 – 语句

图 4.4 – else if语句

由于x是 4 而y是 5(第 19 行),第 20 行的if表达式评估为假,因此控制跳转到第 22 行,那里第一个else if被评估。由于这评估为真,第 23 行被执行。现在,不会评估其他分支。换句话说,在执行第 23 行之后的下一行代码是第 27 行。请注意,根据良好的编码实践,每个分支都应编码为一个块,即使每个块中只有一个语句。

如果x被初始化为5,那么第 20 行和第 22 行都会评估为假。第 24 行将是真,因此第 25 行将被执行。

对于ifelse if语句不匹配的情况,我们可以使用else语句。现在让我们来讨论这个问题。

else语句

图 4.4中的代码评估了比较xy时所有可能的情况。x可能大于、小于或等于y。这种逻辑非常适合引入else语句。else语句是一个通配符。正如图 4.2中的[]所示,else子句是可选的。如果存在,它必须在任何if和/或else if子句之后编码。图 4.5是使用else子句重构的图 4.4,除了图 4.5中的xy的值现在相同。

图 4.5 – 语句

图 4.5 – else语句

图 4.5中,由于xy都是4,第 31 行和第 33 行评估为false。然而,第 35 行没有条件,因为它只是一个else语句(而不是else if)。这意味着从第 35 行开始的代码块将自动执行,第 36 行将被执行。

关于if else语句,了解可能出现的微妙问题很重要,这个问题被称为“悬挂的else”问题。

悬挂的else

考虑以下未缩进的代码,它没有使用代码块:

boolean flag=false;                // line 1if (flag)                          // line 2
if (flag)                          // line 3
System .out.println("True True");  // line 4
else                               // line 5
System.out.println("True False");  // line 6

这段代码有两个if语句但只有一个else语句。else与哪个if匹配?规则是:当else语句寻找与if匹配时,它将与它向上回溯代码时遇到的最近的未匹配if匹配。

依照这个规则,else语句与第二个if(第 3 行)匹配,因为那个if语句尚未匹配。这意味着第 2 行的if语句仍然未匹配。当使用适当的缩进编写代码时,代码会更加清晰:

if (flag)                    // line 2    if (flag)                // line 3
        System.out.println("True True");     // line 4
    else                    // line 5
        System.out.println("True False");    // line 6

这一点通过输出得到证实。如果flag为真,输出将是"True True";然而,如果flag为假,屏幕上则没有任何输出。有趣的是,现在无法到达第 6 行(因为布尔变量只有两个值:truefalse)。

使用代码块可以使代码更容易理解,如下所示:

if (flag) {    if (flag) {
        System.out.println("True True");
    }
    else {
        System.out.println("True False");
    }
}

这就是为什么即使对于一条语句,使用代码块也是非常有帮助的。在整个书中,我们将使用适当的缩进和代码块来帮助清晰和易于理解。

现在,让我们来看一个更复杂的例子。这个例子(以及随后的其他例子)使用了 Java 应用程序编程接口API)中预定义的Scanner类。API 是一套预定义的类型(例如类),可供我们使用。随着我们在书中继续前进,我们将涵盖这些主题,但可以说 API 非常有用,因为它为我们提供了预定义和经过良好测试的代码。

Scanner类位于java.util包中。因此,我们需要简要讨论这两个包和Scanner类。

包是一组相关的类型,例如类,我们可以使用。方便的是,许多已经为我们准备好了,可以在 API 中使用。

要访问这些类型,我们需要将它们“导入”到我们的代码中。为此,Java 提供了import关键字。任何import语句都放在文件的顶部。我们可以使用*通配符导入整个包;例如:import java.util.*;。我们也可以通过在import语句中命名它来显式地导入特定类型;例如:import java.util.Scanner;

当你在类型前加上它的包名,例如java.util.Scanner,这被称为“完全限定名”。我们可以省略import语句,每次简单地使用其完全限定名来引用Scanner;换句话说,在提到Scanner的地方,将其替换为java.util.Scanner。然而,一般来说,导入类型并使用其非限定名是首选的。

有一个包是自动对我们可用的(已导入),那就是java.lang包。例如,String类位于java.lang中,这就是为什么我们永远不需要导入任何东西就能访问String类。

Scanner 类

了解这一点是有帮助的,即虽然Scanner是一个多功能的类,但就我们的目的而言,我们只需使用Scanner来使我们能够从用户那里检索键盘输入。考虑到这一点,请注意,在示例中使用的System.in指的是已经打开并准备好提供输入数据的标准输入流。通常,这对应于键盘。因此,System.in非常适合通过键盘从用户那里获取输入数据。Scanner类提供了各种解析/解释键盘输入的方法。例如,当用户在键盘上输入一个数字时,nextInt()方法会以int原生的形式提供这个数字给我们。我们将在示例中使用这些方法。

嵌套 if 语句

现在我们已经讨论了包和Scanner,在图 4.6图 4.7中,我们讨论了一个更复杂的if-else示例,该示例处理来自用户的输入(通过键盘)。这两个图都与一个示例相关。图 4.6侧重于声明常量以使代码更易读。此外,图 4.6还侧重于声明和使用Scanner。另一方面,图 4.7*侧重于随后的if-else结构。

图 4.6 – 使用 Scanner 从键盘获取输入

图 4.6 – 使用 Scanner 从键盘获取输入

第 11 到 14 行使用final关键字定义常量。这意味着它们的值不能改变。使用大写标识符(单词之间用下划线分隔)作为常量的命名习惯是好的。这将使图 4.7*中的代码更易读;换句话说,我们不会比较month1(在这个上下文中表示一月),而是将monthJAN进行比较,这样读起来更好。为了简洁起见,我们每行声明了三个常量,但你也可以轻松地每行声明一个。

图 4.6*的第 16 行创建我们的Scanner对象引用,sc。本质上,我们创建了一个引用,即sc,它指向使用new关键字创建的Scanner对象。正如之前所述,System.in意味着sc正在查看键盘。这个引用是我们用来与Scanner交互的,就像遥控器用来与电视交互一样。

第 17 行提示用户在键盘上输入一个月份(1..12)。这实际上非常重要,因为没有提示,光标就会闪烁,用户会想知道他们应该输入什么。第 18 行是Scanner真正发挥作用的地方。在这里,我们使用nextInt()方法获取一个数字。目前,只需知道当我们调用sc.nextInt()时,Java 不会返回到我们的代码中,直到用户输入了一些内容并按下了回车键。目前,我们将做出(方便的)假设,它是一个整数。我们将返回的int原始值存储在我们的int原始值month中。现在,在我们的代码中,我们可以使用用户输入的内容。图 4.7*展示了这一过程。

图 4.7 – 一个复杂的 if 语句

图 4.7 – 一个复杂的 if 语句

注意,前面图像中显示的代码是图 4.6延续。在第 20 行,我们声明了一个int变量,即numDays,并将其初始化为0。第 21 到 22 行是if语句的开始。使用boolean逻辑或运算符,if语句检查month值是否与图 4.6中定义的任何常量匹配。在后台,使用常量值,所以实际上,if语句如下:

if(month == 1 || month == 3 || month == 5 || month == 7 ||   month == 8 || month == 10 || month == 12)

注意,每次都必须指定month变量。换句话说,if(month == JAN || MAR || MAY || JUL || AUG || OCT || DEC)无法编译。

假设用户输入1(代表一月),month变为1,因此第 21 到 22 行评估为真,第 23 行将numDays设置为31。如果用户输入35781012,分别代表三月、五月、七月、八月、十月和十二月,逻辑相同。

如果用户输入4(代表四月),第 21 到 22 行评估为假,第 24 行的else if语句将被评估。第 24 行评估为真,第 25 行将numdays设置为30。如果用户输入6911,分别代表六月、九月和十一月,逻辑相同。

现在,让我们处理用户输入的2,代表二月。第 21 到 22 行的if条件和第 24 行的else if条件都评估为假。第 26 行评估为真。现在,我们需要回顾闰年的逻辑。当然,二月每年都有 28 天(所有月份都是这样!),但在闰年时会有额外的一天。判断是否为闰年的逻辑如下:

  • year是 400 的倍数 => 闰年

  • year是 4 的倍数且year不是 100 的倍数 => 闰年

以下都是闰年:2000 年(满足情况 A),2012 年和 2016 年(都满足情况 B)。

由于闰年算法依赖于年份,我们首先需要从用户那里获取年份。第 27 到 28 行完成了这个任务。然后,我们遇到了从第 30 到 34 行的嵌套if语句,它根据前面概述的逻辑确定用户输入的年份是否为闰年。第 30 行实现了 A 和 B 两种情况下的逻辑。判断year是否是 400 的倍数是通过(year % 400 == 0)实现的。year是 4 的倍数且不是 100 的倍数的条件是通过

(year % 4 == 0 && !( year % 100 == 0))。通过在它们之间使用逻辑或运算符,使得任一条件满足闰年计算。假设年份为 2000,第 30 行将为真,numDays将被设置为29。假设年份为 1900,第 30 行的if语句为假,由于第 32 行没有条件(它只是一个else语句),因此执行第 33 行,将numDays设置为28

无效的month值,例如25-3,会导致第 35 行的else分支被执行。结果,第 36 行将输出错误消息到屏幕。

第 38 到 40 行在numDays从其初始值0改变时输出天数。第 38 行的if语句防止在用户输入无效的month值时在屏幕上显示消息"Number of days is: 0"

这就结束了我们对if语句的处理。现在,让我们来检查两个switch语句和表达式,在某些情况下,它们可能是一个更优雅的选择。

掌握switch语句和表达式

复杂的 if 语句,带有许多 else if 分支和一个 else 分支可能会很冗长。在许多情况下,switch 结构可以更加简洁和优雅。让我们从 switch 语句开始。

switch 语句

首先,让我们来检查 switch 语句的语法。图 4.8 介绍了语法。

图 4.8 – switch 语句语法

图 4.8 – switch 语句语法

switch 语句评估一个表达式。从 Java 21 开始,表达式可以是一个整型原始值(不包括 long)或任何引用类型。这意味着我们可以对 bytecharshortint 类型的原始变量进行 switch,也可以对类类型、枚举类型、记录类型和数组类型进行 switch。现在 case 标签可以包括一个 null 标签。Java 21 还引入了 switch 的模式匹配。当涉及到这些主题时,我们将展示另一个 switch 示例来演示这个功能(第九章)。在此之前,我们将专注于更传统的 switch

包装类型

对于每种原始类型,都有一个相应的类,称为“包装类型”:byte(由 Byte 包装),short (Short),int (Integer),long (Long),float (Float),double (Double),boolean (Boolean),和 char (Character)。它们之所以被称为包装类型,是因为它们代表封装原始值的对象。由于它们是类类型,因此有可用的一些有用方法。例如,int val = Integer.parseInt("22");String "22" 转换为数字 22,存储在 val 中,我们可以在其中执行算术运算。

刚刚评估的表达式与 case 标签进行比较。case 标签与 switch 表达式具有相同类型的编译时常量。如果与 case 标签匹配,则执行相关的代码块(注意:在 casedefault 块中不需要花括号)。要退出 case 块,请确保插入一个 break 语句。break 语句退出 switch 块。然而,break 语句是可选的。如果您省略了 break 语句,即使没有匹配,代码也会 跌入 到下一个 case 标签(或 default)。

default 关键字用于指定在没有任何 case 标签匹配时执行的代码块。通常,它被编码在 switch 块的末尾,但这不是强制性的。实际上,default 可以出现在 switch 块的任何位置,具有类似的语义(然而,这并不是一个好的编程实践)。

图 4.9 展示了一个示例。

图 4.9 – 在 String 上进行 switch 的示例

图 4.9 – 在 String 上进行 switch 的示例

使用 Scanner 类,第 18 到 20 行会询问并从用户那里检索一项运动。请注意,在这种情况下使用的 Scanner 方法是 next(),它返回一个 String 类型(与原始类型相反)。

第 21 行到第 31 行展示了 switch 块。请注意,第 22 行和第 25 行的 case 标签都是 String 编译时常量。如果用户输入 "Soccer",则第 22 行的 case 标签匹配,并且第 23 行和第 24 行都将执行。有趣的是,尽管有两个语句要执行,但这里不需要花括号。这是 switch 块的一个特性。由于第 22 行匹配,第 23 行将执行,并且 "I play soccer" 将被显示在屏幕上。第 24 行的 break 语句确保退出 switch 块,并且不执行 "Rugby" 部分。

如果用户输入 "Rugby",则第 25 行匹配,并且 "I play Rugby" 将被显示在屏幕上。同样,这次在第 27 行的 break 语句确保退出 switch 块,并且不执行 default 部分。

如果用户输入 "Tennis",则第 22 行和第 25 行的 case 标签都不会匹配。这时 default 部分就派上用场了。当没有任何 case 标签匹配时,将执行 default 部分。通常,default 部分被编码在 switch 块的末尾,并且传统上插入 break 语句以确保完整性。

case 标签是区分大小写的

注意,case 标签是区分大小写的。换句话说,在 图 4**.9 中,如果用户输入 "soccer",则第 22 行的 case 标签不会匹配。自然地,"Rugby"case 标签也不会匹配。因此,将执行 default 部分,并且 "Unknown sport" 将被显示(打印)在屏幕上。

让我们来看另一个例子。图 4**.10 是一个基于整数的 switch 语句。

图 4.10 – 整数开关示例

图 4.10 – 整数开关示例

在前面的图中,第 37 行声明了一个编译时常量 two,并将其初始化为整数文字 2。第 38 行通过切换名为 numberint 变量开始 switch 块,该变量在第 36 行根据用户输入声明并初始化。现在 switch 块中的所有 case 标签都必须是整数——无论是第 39 到 43 行和第 47 行的文本值,还是第 46 行的编译时常量。请注意,如果 two 变量不是 final 的,将生成编译时错误(因为它不再是常量)。

在这个例子中,我们有多个标签一起,例如,第 39-43 行。这段代码可以读作 如果数字是 1 或 3 或 5 或 7 或 9,则执行以下操作。例如,如果用户输入 1,第 39 行的 case 标签匹配。这被称为 switch 语句的 入口点。由于第 39 行没有 break 语句,代码会 穿透 到第 40 行,尽管第 40 行有一个 case 标签用于 3。同样,第 40 行没有 break 语句,代码会 穿透 到第 41 行。实际上,代码会从入口点一直执行到遇到 break 语句(或 switch 语句本身的末尾)。这种 穿透 行为使得 如果是 1 或 3 或 5 或 7 或 9 类型的逻辑能够工作。如果没有这种 穿透 行为,我们就必须为每个 case 标签复制第 44 至 45 行!第 44 行使用 String 追加输出输入的数字是奇数,通过将 "is odd" 追加到数字上——例如,"7 is odd"。第 45 行是确保我们退出 switch 块的 break 语句。

第 46 行只是为了演示编译时常量对 case 标签有效。第 47 行显示,如果需要,case 标签可以按水平方式组织。记住,缩进和间距的使用只是为了提高人类可读性——编译器只看到一长串字符。因此,第 46 至 47 行与数字 2、4、6、8 和 10 匹配。同样,使用 穿透 逻辑来保持代码简洁。第 48 至 49 行输出该数字是偶数,并退出 switch 块。

第 50 行是 default 部分,用于处理 1..10 范围之外的任何数字。第 51 行输出输入的数字超出范围,第 52 行是 break 语句。虽然在这里 break 语句不是严格必需的(因为 defaultswitch 语句的底部),但包括它是良好的实践。

让我们用 switch 语句重写 图 4.7 中的代码,而不是复杂的 if-else 语句。请注意,图 4.6 仍然适用于声明 Scanner 和使用的常量;这就是为什么我们将其分开(这样我们就可以同时使用 ifswitch 代码)。图 4.11 表示使用 switch 语句重构的 图 4.7

图 4.11 – 使用 switch 语句重构 if 语句

图 4.11 – 使用 switch 语句重构 if 语句

在前面的图中,我们可以看到 switch 语句从第 23 行开始。第一组 case 标签在第 24 行,第二组在第 27 行。二月,作为特例,有一个单独的 case 标签(第 30 行)。最后,default 标签在第 40 行。就我个人而言,由于没有像 图 4.7(第 21 至 22 行和第 24 行)中所需的多个逻辑 OR 表达式,我认为在这个例子中使用 switch 更为可取。

现在我们已经介绍了有效的 switch 语句,让我们在 图 4.12 中检查一些可能导致编译器错误的情况。

图 4.12 – 一些 switch 编译器错误

图 4.12 – 一些 switch 编译器错误

在前面的图中,我们正在根据 byte 变量 b 进行切换。回想一下,有效的 byte 范围是 -128+127。第 60 行演示了最小和最大值是正常的。第 63 行显示,由于 128 超出了我们的 byte 类型 b 的范围,超出 switch 变量范围的 case 标签会导致编译器错误。第 64 行原本是好的,直到第 65 行使用了相同的 case 标签——不允许 case 标签重复。

我们将通过讨论 switch 表达式来结束对 switch 的讨论。

switch 表达式

与所有表达式一样,switch 表达式计算出一个单一值,因此使我们能够返回值。到目前为止的所有 switch 示例都一直是 switch 语句,它们不返回任何内容。请注意,在 switch 表达式中不允许使用 break 语句。

另一方面,switch 表达式 会返回一些内容——无论是隐式地还是显式地(使用 yield)。我们很快就会解释 yield,但请注意,yield 不能在 switch 语句中使用(因为它们不返回任何内容)。此外,switch 语句可以 穿透,而 switch 表达式则不能。这些差异在 表 4.1 中得到了封装。

表 4.1 – switch 语句与 switch 表达式的比较

表 4.1 – switch 语句与 switch 表达式的比较

让我们看看一些示例代码来展示这些差异。图 4.13 是传统的 switch 语句。

图 4.13 – 一个传统的 switch 语句

图 4.13 – 一个传统的 switch 语句

在前面的图中,我们正在根据 String 变量 name 进行切换。由于它被初始化为 "Jane",第 19 行为真,第 23 行将 nLetters 设置为 4("Jane" 中的字母数量)。第 24 行的 break 语句确保没有穿透到第 27 行。第 39 行将 4 输出到屏幕上。

注意,代码相当冗长,需要正确使用 break 语句来防止穿透。此外,这些 break 语句编写起来很繁琐,很容易忘记。图 4.14 表示使用 switch 表达式编写的 图 4.13。

图 4.14 – switch 表达式

图 4.14 – switch 表达式

前面的图示显示了新的case标签,其中标签以逗号分隔,并且一个箭头符号将标签与表达式(或代码块)分开。任何地方都不需要break语句,因为没有需要担心的穿透行为。由于name仍然是"Jane"(来自图 4.13,第 17 行),第 42 行被执行,将4初始化/返回到nLetters中。因此,第 50 行输出4。请注意,switch表达式中的case标签列表必须是详尽的。在几乎所有情况下,这意味着需要一个default子句。在这个例子中,default子句执行一个代码块(第 45-48 行),其中将错误输出到屏幕上,并使用yield关键字,将nLetters初始化为(错误值)-1

我们可以通过直接将表达式值返回到System.out.println()语句来省略对nLetters变量的需求。图 4.15展示了这一点。

图 4.15 – 直接返回到 System.out.println()的 switch 表达式

图 4.15 – 一个直接返回到 System.out.println()的 switch 表达式

在前面的图示中,没有变量用于存储switch表达式的结果。这使得代码更加简洁。switch表达式的结果直接返回到System.out.println()语句中。同样,4被输出到屏幕上。

yield 关键字

图 4.14图 4.15是简单的switch表达式,其中(大部分情况下),箭头符号右侧是返回的值。然而,如果你不仅想返回一个值,还想执行一个代码块,那么yield就派上用场了。图 4.16展示了在switch表达式中使用yield

图 4.16 – 使用 yield 的 switch 表达式

图 4.16 – 使用 yield 的 switch 表达式

前面的图示强调了这样一个事实,如果你需要在switch表达式中执行多个语句,你必须提供一个代码块。这通过第 61-64 行、65-68 行、69-72 行和 73-76 行的花括号来展示。要从代码块返回一个表达式结果,我们使用yield。这就是第 63、67、71 和 75 行所做的工作,分别返回456-1。由于name仍然是"Jane",第 63 行从switch表达式中返回4,将nLetters初始化为4。因此,第 78 行输出4

为了增加灵活性,你可以在一定程度上混合语法。换句话说,在switch表达式中可以使用常规的case标签,在switch语句中可以使用新的case标签语法。然而,如前所述,break只出现在switch语句中,而yield(如果需要)只出现在switch表达式中。图 4.17是对图 4.13中冗长的switch语句的重构,其中使用了新的case标签。

图 4.17 – 使用新标签和箭头标记的语句

图 4.17 – 使用新case标签和箭头标记的switch语句

在前面的图中,标签是逗号分隔的,并且存在箭头标记。和之前一样,在箭头标记的右侧,我们有nLetters的初始化,即名字中的字母数。然而,与图 4.13不同,因为我们使用了箭头标记,所以不需要break语句。然而请注意,根据default子句(第 86-89 行),代码块需要大括号。

我们也可以使用常规的case标签与switch表达式一起使用。这如图 4.18 所示,它是图 4.16 的重构版本。

图 4.18 – 使用旧式标签的表达式

图 4.18 – 使用旧式case标签的switch表达式

在前面的图中,使用了旧式的case标签。这意味着关键字case必须在每个标签之前。然而,代码块的括号可以省略。由于这是一个switch表达式,当找到匹配时,会有多个语句需要执行,因此我们需要使用yield来返回表达式结果。由于name变量在整个示例中从未从"Jane"改变,因此在第 95 行进行匹配,导致第 99 行将"There are 4 letters in: Jane"输出到屏幕。第 100 行的yield返回4,因此nLetters被初始化为4。最后,第 114 行将4输出到屏幕。

这完成了我们对switch表达式以及一般switch语句的处理。现在,我们将所学应用到实践中。

练习

我们最终拥有了做出决策的编码能力。中生代伊甸园将因此受益匪浅。让我们展示我们新获得的本领,怎么样?

  1. 我们需要确定恐龙是肉食性还是草食性。编写一个if语句,根据布尔变量打印恐龙是肉食性还是草食性。这些信息对于饲养和护理指南至关重要。

  2. 不同的物种需要不同的护理策略,并表现出独特的行为特征。编写一个switch语句,根据恐龙的物种打印恐龙的描述。这将有助于教育员工和公园游客。

  3. 有些恐龙比其他恐龙更难处理。编写一个if语句,检查是否有足够的经验来处理某种类型的恐龙。这确保了我们的恐龙和员工的安全。

  4. 我们正在与美丽但危险的生物打交道。所以,安全第一。编写一个程序,如果公园的安全评级低于某个阈值,则打印警告信息。我们必须始终保持警惕,以防止可能伤害我们的员工、游客或恐龙的问题。

  5. 适当的住所对于恐龙的幸福至关重要。编写一个switch语句,根据恐龙的大小(XS、S、M、L 或 XL)将其分配到特定的围栏。

  6. 适当的营养对于维持我们恐龙的健康至关重要。编写一个if语句,根据恐龙的体重确定其每天所需的喂食次数。

  7. 正确地委派任务是保持运营顺利的重要因素。创建一个程序,使用switch语句根据员工的职位分配不同的职责。

  8. 公园不是全天 24 小时对日间游客开放。编写一个if语句,根据时间检查公园是否对日间游客开放。他们从上午 10 点到下午 7 点对日间游客开放。这有助于管理游客的期望和员工的时间表。

项目 – 任务分配系统

中生代伊甸园的经理需要一个系统化的方法来管理团队并确保所有任务都高效完成。

设计一个简单的程序,根据员工的角色(例如,喂食、清洁、安全和导游)将任务分配给中生代伊甸园的员工。该程序应根据时间、员工的角色和其他因素(如公园的安全评级)来决定任务。

这个程序不仅可以帮助简化运营,还可以确保我们员工、游客以及最重要的是恐龙的安全和满意度!

摘要

在本章中,我们首先解释了 Java 使用块作用域。一个块由{}界定。一个变量从声明点可见到该块的结束}。由于块(以及因此作用域)可以嵌套,这意味着在块中定义的变量对任何内部/嵌套块都是可见的。然而,反之则不成立。在内部块中声明的变量在外部块中不可见。

条件语句使我们能够做出决定,并基于条件的评估结果为真或假。if语句允许评估多个分支。一旦某个分支评估为真并执行,则不会评估其他分支。if语句可以单独编码,而不需要任何else ifelse子句。else ifelse子句是可选的。然而,如果存在else子句,它必须是最后一个子句。我们看到了一个复杂的if示例如何导致代码冗长。

我们简要讨论了包和Scanner类。Scanner类位于java.util包中,对于从用户那里获取键盘输入非常有用。

我们还讨论了switch语句和switch表达式。一个表达式可以返回一个值,但一个语句不能。我们看到了switch语句如何使复杂的if语句更加简洁和优雅。你switch上的表达式(通常是变量)可以是原始类型bytecharshortint;或者是一个引用类型。case标签必须是编译时常量,并且必须在switch变量的范围内。switch语句有一个穿透特性,这使得多个case标签可以使用相同的代码段而不需要重复。然而,这种穿透行为需要break语句来退出switch语句。这需要小心,因为break语句很容易忘记。

switch表达式可以返回一个值。它们没有穿透逻辑,所以不需要break语句。这使得代码更加简洁且错误更少。如果你想在switch表达式中执行一个代码块,请使用yield来返回值。

switch语句不支持yield(因为它们不返回任何内容),而switch表达式不支持break(因为它们必须返回某些内容)。然而,两种case标签,即旧式的case X:和新式的case A, B, C ->,都可以与switch语句或switch表达式一起使用。

现在我们知道了如何做出决策,我们将在下一章中继续讨论迭代,我们将检查 Java 结构,这些结构使我们能够重复执行语句。

第五章:理解迭代

第四章 中,我们学习了 Java 中的作用域和条件语句。作用域决定了标识符的可见性——换句话说,你可以在哪里使用它们。Java 使用块作用域,由花括号 {} 定义。作用域可以嵌套,但不能反过来。

我们讨论了 if 语句的变体。这些语句中的每一个都会评估一个布尔条件,结果为真或假。如果为真,则执行该分支,不评估其他分支。如果为假,则评估下一个分支。除非存在 else 子句,否则可能根本不会执行任何分支。

对于复杂的 if 语句,Java 支持更优雅的 switch 结构。我们考察了 switch 语句及其 穿透 行为,以及 break 语句的使用。此外,我们还讨论了 switch 表达式,其中可以返回一个值,以及它们使用 yield 的方式。

现在我们已经了解了条件逻辑,让我们来考察迭代(循环)。循环结构使我们能够重复执行语句和/或代码块有限次数,直到布尔条件为真或直到数组/集合中还有更多条目。到本章结束时,你将能够使用 Java 的循环结构。

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

  • while 循环

  • do-while 循环

  • for 循环

  • 增强的 for (for-each) 循环

  • breakcontinue 语句

技术要求

本章的代码可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch5

while 循环

任何编程语言的一个重要特性是能够重复执行一个动作。这被称为“循环”。我们可能想要有限次数地重复一段代码,或者直到满足某个条件;例如,用户输入一个值表示循环应该终止。在大多数情况下,可以使用布尔表达式来确定循环是否继续。

while 循环是这样一种循环结构。只要布尔表达式为真,它就会重复执行一个语句或代码块。一旦布尔表达式为假,循环就会退出,然后执行 while 循环之后的下一个语句。

图 5.1 –  循环语法

图 5.1 – while 循环语法

在前面的图中,我们假设了一段代码块,因此有花括号 {}。当然,你可以省略花括号 {},循环将只重复执行一个语句(以分号结束)。有趣的是,由于布尔表达式可能一开始就是假的,while 循环可能根本不会执行。更正式地说,while 循环执行 次或多次。让我们看看一些例子。图 5.2 展示了一个简单的 while 循环:

图 5.2 – 一个简单的 while 循环

图 5.2 – 一个简单的 while 循环

在前一个图中的第 9 行,局部变量x被初始化为1。第 11 行评估布尔表达式x <= 3。由于x1,布尔表达式为 true,循环执行。第 12 行输出"Loop: 1",第 13 行将x增加到2。第 14 行的}符号被到达,循环条件(第 11 行)会自动重新检查是否仍然成立。由于x现在是22 <= 3为 true,条件为 true,我们重新进入循环。第 12 行输出"Loop: 2",第 13 行将x增加到3。在第 14 行达到代码块的末尾,再次,循环继续表达式被重新评估。由于表达式3 <= 3为 true,循环再次执行。第 12 行输出"Loop: 3",第 13 行将x增加到4。再次,达到代码块的末尾并重新评估循环继续表达式。由于x现在是44 <= 3为 false,循环退出。这由第 15 行输出"Final x value is: 4"所示。

注意,如果在第 9 行,x被初始化为11(而不是1),那么第 11 行的初始布尔表达式将被评估为 false,循环将根本不会执行。

当你不知道循环将迭代多少次时,while循环非常有用。例如,循环继续表达式可能基于用户输入。图 5.3就是这样一种循环。

图 5.3 – 基于用户输入结束的 while 循环

图 5.3 – 基于用户输入结束的 while 循环

在前一个图中,展示了一个求和算法,该算法用于累加用户输入的正数序列。循环将一直进行,累加用户输入的数字,直到输入一个负数。这个负数自然地,不是总和的一部分。让我们更详细地讨论一下。

第 19 行声明了一个局部int变量sum并将其初始化为0。第 20 行声明了一个局部布尔变量keepGoing并将其设置为 true。第 21 行的布尔表达式评估为 true(由于第 20 行),因此循环块执行。第 22 行声明了我们的Scanner引用sc指向键盘。第 23 行提示用户输入一个数字,同时告知用户任何负数将终止循环。第 24 行使用Scanner方法nextInt()从用户那里获取一个整数(整数)。这个数字被存储在局部变量n中。第 25 行检查是否输入了一个负数。如果是这样,第 26 行将keepGoing标志设置为 false,这样循环就不会再次执行。如果用户输入了一个非负整数,那么输入的数字n将被添加到运行总和sum中。

让我们通过一个例子来了解一下。我们将添加以下数字:123,总共是 6。这是屏幕输出的样子:

Enter a number (negative number to exit) -->1
Enter a number (negative number to exit) -->
2
Enter a number (negative number to exit) -->
3
Enter a number (negative number to exit) -->
-1
Sum of numbers is: 6

让我们来检查代码中正在发生的事情。循环(第 21 行)被进入,因为第 20 行将keepGoing布尔值设置为 true。然后我们被提示输入第一个数字(第 23 行)。我们输入1,导致第 24 行将n初始化为1。由于n1,第 25 行的if语句为假,执行else块(第 27-29 行);将sum设置为1(0 + 1)。

达到循环块末尾(第 30 行),循环继续表达式自动重新评估(第 21 行)。由于keepGoing仍然为 true,循环继续。我们被提示输入第二个数字;我们输入2sum变为3(1 + 2)。

再次达到循环块末尾,并且由于keepGoing仍然为 true,循环继续。我们被提示输入下一个数字;我们输入3sum变为6(3 + 3)。

再次,达到循环块末尾,并且由于keepGoing仍然为 true,循环继续。我们被提示输入下一个数字。这次我们输入-1。由于n现在是负数,第 25 行的if语句为真,keepGoing被设置为 false(第 26 行)。现在,当下一次评估循环继续表达式时,由于keepGoing为 false,循环退出。

最后,第 31 行输出"数字之和是:6"

现在我们已经介绍了while循环,让我们来考察它的近亲,do-while循环。

do-while 循环

正如我们在while循环中看到的那样,布尔循环继续表达式位于循环的开始。尽管与while循环相似,但do-while循环在一点上有所不同:在do-while循环中,循环继续表达式位于循环的末尾。因此,do-while循环至少执行一次。更正式地说,do-while循环执行一次或多次

图 5.4展示了do-while循环的语法。

图 5.4 – do-while 循环语法

图 5.4 – do-while 循环语法

如前图所示,循环继续表达式位于循环的末尾,在第一次循环迭代之后。注意在)之后有一个分号,;

图 5.5展示了图 5.2do-while版本。

图 5.5 – 基于用户输入结束的 do-while 循环

图 5.5 – 基于用户输入结束的 do-while 循环

在前面的图中,与图 5.2相比,唯一的区别是第 21 行和第 30 行。在第 21 行,我们简单地进入循环,因为在while循环中,没有条件阻止我们这样做。第 30 行检查是否可以重新进入循环。其余的代码相同,执行也相同。

虽然(请原谅这个双关语)给出的两个示例在结果上没有实质性的区别,但让我们考察一个使用while循环而不是do-while循环更可取的情况。

while 与 do-while

如前所述,do-while循环至少执行一次,而while循环可能根本不执行。这在某些情况下非常有用。让我们看看这样一个例子。图 5.6展示了一个检查一个人是否达到法定年龄购买酒精(在爱尔兰是 18 岁)的while循环。

图 5.6 – 防止未成年购买酒精的循环

图 5.6 – 防止未成年购买酒精的while循环

在前面的图中,第 49 行声明了Scanner并将其指向键盘,这样我们就可以获取用户输入。第 50 行提示用户输入他们的年龄。第 51 行接收用户输入并将其存储在局部变量age中。第 52 行很重要。该条件防止循环使用无效的age执行。循环本身很简单,只是输出包含age的消息,这样我们就可以验证循环是否正确执行。

第 57 到 58 行非常重要,因为它们使我们能够提示并从用户那里获取一个新的age。代码故意覆盖了age变量。如果我们不这样做,那么age将保持为用户首次输入的值,我们将有一个无限循环。因此,第一个年龄是在进入while循环之前输入的,而其他每个年龄都是在循环的末尾输入的。这是while循环中的常见模式。第 52 行的条件防止任何小于 18 岁的age输入进入循环。

这是图 5.6中代码的第一次运行:

Please enter your age -- >21
As you are 21 years of age, you can purchase alcohol.
Please enter your age -- >
12

前两行:提示和用户输入,在while循环之前,由于 21 岁大于等于 18 岁,我们进入了循环。消息As you are 21 years of age, you can purchase alcohol.是完全正确的。最后两行:重复提示和用户输入,来自循环的底部。我们输入了12,这导致while循环终止。

如果在提示输入第一个年龄时我们输入12,以下是将显示的输出:

Please enter your age -- >12
Process finished with exit code 0

重要的是,关于购买酒精的消息没有出现。

现在,让我们看看do-while版本。图 5.7展示了图 5.6while循环的do-while版本。

图 5.7 – 防止未成年购买酒精的循环

图 5.7 – 防止未成年购买酒精的do-while循环

为了使代码尽可能相似,第 49 到 51 行保持不变。第 52 和 59 行是唯一有所更改的地方。现在条件位于循环的末尾,循环迭代一次之后。当我们从12岁开始时,这会产生影响,如输出所示:

Please enter your age -- >12
As you are 12 years of age, you can purchase alcohol.
Please enter your age -- >
12
Process finished with exit code 0

输出的第三行存在问题。显然,12 岁太小,不能购买酒精,但do-while循环需要if语句来保护其代码,而while循环则自动提供这种保护。因此,在这个例子中,使用while循环而不是do-while循环具有实质性的优势。

现在我们已经介绍了while循环和do-while循环,让我们现在讨论for循环。

for 循环

for循环有两种风格:传统的for循环和增强的for循环。增强的for循环也被称为for-each循环,它专门设计用来与数组集合一起工作。我们将首先检查传统的for循环。

传统 for 循环

这种类型的for循环在你事先知道要执行多少次迭代时非常有用。其语法在图 5**.8中有详细说明。

图 5.8 – 传统的 for 循环

图 5.8 – 传统的 for 循环

上一个图中的代码块是可选的。我们可以简单地控制一个语句,例如System.out.println("Looping");,并省略{}for头是()内的部分。它由三个部分组成,由分号分隔:

  • ijk等等。

  • while循环和传统的for循环可以互换使用。

  • 增量/减量部分:这是你增加/减少循环控制变量(在初始化部分声明)的地方,以便循环终止。

我们必须理解循环执行的顺序。换句话说,哪个部分被执行,何时执行。图 5**.9,展示了一个简单的for循环,将有助于这一点。

图 5.9 – 一个简单的传统 for 循环

图 5.9 – 一个简单的传统 for 循环

在这个图中,代码执行的顺序用数字顺序表示如下:

  1. i被声明并初始化为1

  2. 布尔表达式:评估布尔表达式以确定是否可以执行循环。因为 1 <= 3,所以可以进入循环。

  3. 显示到屏幕上。

  4. i1增加到2,然后执行跳转到布尔表达式。

  5. 评估布尔表达式:因为 2 <= 3,所以循环被执行。

  6. 2显示到屏幕上。

  7. 从 2 增加到 3 增加 i:然后跳转到布尔表达式。

  8. 评估布尔表达式:因为 3 <= 3,所以循环被执行。

  9. 3显示到屏幕上。

  10. 从 3 增加到 4 增加 i:然后跳转到布尔表达式。

  11. 评估布尔表达式:因为 4 不是<= 3,所以循环退出。

总结来说,初始化部分仅在循环开始时执行一次。布尔表达式被评估,并且假设它是真的,那么循环体将被执行,随后是增量/减量部分。布尔表达式再次被评估,并且再次假设它是真的,那么循环体将被执行,随后是增量/减量部分。这种循环体执行后跟增量/减量部分的重复执行会一直持续到布尔表达式失败,循环退出。

图 5**.10展示了一个从3递减到1for循环,每次递减 1:

图 5.10 – 一个简单的递减顺序 for 循环

图 5.10 – 一个按降序操作的简单 for 循环

在前面的图中,我们将 i 初始化为 3 并检查布尔表达式。由于 3 >= 1,我们进入循环并输出 3。然后我们将 i 减少到 2 并再次检查布尔表达式。由于 2 >= 1,我们输出 2 并将 i 减少到 1。由于布尔表达式仍然为真,我们输出 1 并将 i 减少到 0。此时,由于 i0,布尔表达式为假,循环终止。

图 5**.11 展示了一些代码示例,使我们能够进一步讨论这个循环结构。

图 5.11 – 附加的传统 for 循环

图 5.11 – 附加的传统 for 循环

在前面图中的第一个循环(第 20-22 行)中,需要注意的重要事情是 ; 符号,它位于 for 标头的 ) 符号之后。这个循环控制一个空语句!尽管缩进可能暗示了其他情况,但随后的代码块与循环没有任何关系,因此输出中只出现一次 "Looping"。实际上,循环迭代了三次,每次都没有做任何事情。第 21 行周围的代码块不基于任何条件,并且只执行一次(像正常一样)。

在第二个循环(第 24-26 行)中,循环控制变量 i10 开始,每次增加 10,直到达到 60,此时循环终止。每个有效的 i 值都会输出到屏幕上——换句话说,1020304050。请注意,第 27 行无法编译,因为前面循环中声明的每个 i 变量只在其各自的循环范围内有效。例如,第 20 行声明的 i 变量仅在直到第 22 行可用;同样,第 24 行声明的 i 变量仅在直到第 26 行可用。注意:显然,为了使代码编译和运行,第 27 行必须被注释掉。

最后一个循环(第 29-31 行)显示我们可以声明多个循环控制变量并在整个循环中使用它们。在这个循环中,我们声明了 ij 并将它们都初始化为 0。布尔表达式为真,因为 i < 1j < 1 都为真(true && true == true)。因此,我们执行循环并输出 00。然后 ij 都增加到 1。循环条件失败,循环终止。

虽然 for 循环将在 第六章 中详细讨论,但由于 for 循环与数组如此自然地匹配,我们在此也插入了一些示例。让我们首先检查传统的 for 循环如何用于处理数组。

处理数组

任何for循环都适用于遍历数组。数组简单地说是一个分配了内存并赋予标识符名称的区域,以便于引用。数组由元素组成,这些元素按连续的内存位置组织 – 换句话说,数组元素在内存中紧挨着。这使得使用循环处理数组变得容易。

数组中的每个元素都通过索引来访问。关键的是,数组索引从0开始,每次递增 1。因此,最后一个有效的索引是数组大小减一。例如,大小为5的数组有有效的索引01234图 5.12是一个处理数组的循环。

图 5.12 – 使用传统 for 循环处理数组

图 5.12 – 使用传统 for 循环处理数组

在这个图中,第 33 行声明了一个包含值123int数组,分别位于索引012。数组的长度,可以通过length属性访问,是3for循环(第 34-35 行)处理数组,逐个输出每个位置。因此,当i0时,ia[0]1输出到屏幕上;当i1时,ia[1]输出2,当i2时,ia[2]输出3

现在我们已经介绍了传统的for循环,让我们来考察增强型for循环。

增强型 for 循环

如前所述,增强型for循环,也称为for-each循环,非常适合处理数组以及/或者集合。我们将在第十三章中详细讨论集合。目前,只需想象一个集合是一个列表的项目。增强型for循环允许你逐个元素遍历列表。增强型for循环的语法在图 5.13中概述。

图 5.13 – 增强型 for 循环语法

图 5.13 – 增强型 for 循环语法

在前面的图中,我们可以看到一个变量的声明。变量的类型与数组/集合的类型相匹配。例如,如果数组是一个String数组,那么String就是变量的数据类型。变量名当然由我们决定。再次强调,代码块是可选的。

让我们通过一个例子来进一步解释。图 5.14图 5.12中展示的传统for循环的增强型for循环版本。

图 5.14 – 使用增强型 for 循环处理数组

图 5.14 – 使用增强型 for 循环处理数组

在这个图中,第 38 行的内容如下:对于数组(ia)中的每个 int n。因此,在第一次迭代中,n1;在第二次迭代中,n2;在最后一次迭代中,n3。在增强的for循环中,我们不必自己跟踪循环控制变量。虽然这很有用,但请注意,你被限制从数组的开始处开始,逐个元素前进,直到达到末尾。使用传统的for循环,这些限制都不适用。

然而,使用传统的for循环,如果你错误地编码了增量/减量部分,你可能会陷入无限循环。在增强的for版本中这是不可能的。

嵌套循环

当然,循环可以嵌套。换句话说,循环可以编码在其他循环内。图 5.15展示了这样一个例子。

**图 5.15** – 嵌套循环

图 5.15 – 嵌套循环

此程序的输出以图 5.16展示。在前面的图中,我们表示一个int值数组,即data,为一个直方图(表示为一行星号)。数组在 12 行声明。第 14 行输出一行文本,以便程序输出更容易解释。输出有三个列:当前数组索引、该索引处data数组中的值,以及直方图。注意,输出是制表符分隔的。这是通过使用\t转义序列实现的。

转义序列

转义序列是前面带有反斜杠的字符。例如,\t是一个有效的转义序列。当编译器看到\时,它会向前查看下一个字符,并检查这两个字符是否组成一个有效的转义序列。常见的转义序列如下:

\t: 在文本此点插入制表符

\b: 在文本此点插入退格符

\n: 在文本此点插入换行符

\": 在文本此点插入双引号

\\: 在文本此点插入反斜杠

在某些情况下,它们非常有用。例如,如果我们想将文本我的名字是“Alan”(包括双引号)输出到屏幕上,我们会说:

System.out.println("My name is \"Alan\"");

如果我们没有在Alan中的A之前转义双引号(换句话说,如果我们尝试System.out.println("My name is "Alan");),那么Alan前的双引号将与字符串开头的第一个"匹配。这将导致编译器错误,错误出现在Alan中的A

通过在Alan中的A之前跳过双引号,编译器不再将那个双引号视为字符串结束的双引号,而是将"插入到要输出的字符串中。同样,在Alan中的n之后的双引号也是如此——它也被跳过,因此被视为字符串结束的双引号并插入到要输出的字符串中。然而,在)之前的双引号没有被跳过,它被用来匹配字符串的开头双引号,即(之后的那个。

外层循环(第 15-21 行)遍历data数组。由于数组有 4 个元素,有效的索引是0123。这些是第 15 行声明的i循环控制变量将表示的值。第 16 行输出了两列:当前数组索引和data数组中该索引的值。例如,当i0时,data[0]9,所以输出"0\t9\t";当i1时,data[1]3,所以输出"1\t3\t",依此类推。

内层循环(第 17-19 行)输出实际的直方图,作为一排水平的星号。内层循环控制变量j1data[i]的值。例如,如果i0data[i]9;因此,j19,每次输出一个星号。请注意,使用的是print()方法而不是println()——这是因为println()会自动将你带到下一行,而print()则不会。由于我们希望星号水平输出,print()正是我们需要的。当我们输出一排星号后,我们执行System.out.println()(第 20 行),这会将我们带到下一行。

图 5.16表示了图 5.15中的代码输出。

图 5.16 – 图 5.15 中的代码输出

图 5.16 – 图 5.15 中的代码输出

在这个图中,你可以看到第一列是数组索引。第二列是data数组在该索引处的值,第三列是基于第二列的星号直方图。例如,当i2时,data[2]5,我们输出一个包含5个星号的直方图。

现在我们已经了解了循环,我们将继续讨论两个与循环特别相关的关键字,即breakcontinue

break 和 continue 语句

breakcontinue语句都可以用在循环中,但它们的语义非常不同。在提供的代码示例中,我们将使用嵌套循环来对比带标签的版本和不带标签的版本。我们将从break语句开始。

break 语句

我们已经在switch语句中遇到了break。当在循环中使用时,循环会立即退出。图 5.17展示了嵌套的for循环,其中内层循环有一个break

图 5.17 – 展示循环中的 break

图 5.17 – 展示循环中的 break

在这个图中,外循环由i控制,从1开始循环到3,每次增加 1。内循环由j控制,从1开始循环到5,每次增加 1。

第 16 行的if语句在j3时变为真。此时,执行第 17 行的break语句。没有标签的break语句退出最近的包围循环。换句话说,第 17 行的break指的是第 15 行(由j控制)的循环。由于两个循环的结束}之间没有代码(第 20 行和第 21 行),当在这个程序中执行break时,接下来执行的代码是外循环的}(第 21 行)。自动地,外循环的下一个迭代i++(第 14 行)开始。实际上,输出中永远不会出现j值为3或更高的值,因为当j3时,我们跳出内循环并从i的下一个值开始。输出反映了这一点:

i, j1, 1
1, 2
2, 1
2, 2
3, 1
3, 2

没有任何break语句,换句话说,如果我们注释掉第 16 到 18 行,输出将如下(注意j的值从 1 到 5):

i, j1, 1
1, 2
1, 3
1, 4
1, 5
2, 1
2, 2
2, 3
2, 4
2, 5
3, 1
3, 2
3, 3
3, 4
3, 5

在我们讨论带标签的break之前,我们将简要讨论标签本身。

标签

标签是一个区分大小写的标识符,后跟一个冒号,紧接在要标识的循环之前。例如,以下代码为i控制的循环定义了一个有效的标签OUTER

OUTER:for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 5; j++) {

现在,让我们看看带标签的break本身。

带标签的断点

使用标签的break语句退出由该标签标识的循环。带标签的break语句必须在该循环的范围内。换句话说,你不能将break到代码中的其他地方,与当前作用域完全不相关。图 5**.18图 5**.17中的代码密切相关,但这次使用了标签和带标签的break

图 5.18 – 带标签的断点

图 5.18 – 带标签的断点

在前面的图中,我们在第 26 行将外循环标记为OUTERLOOP。是的,想出这个标识符花了一些时间!请注意,在标签和循环之间有任何代码都是编译错误。这就是为什么第 25 行在标签之前的原因。

循环控制变量ij的行为与之前相同;i1开始,每次增加 1,直到3;在i的每个步骤中,j1开始,每次增加 1,直到5。然而,这次当内循环中的j3时,我们不是跳出内循环,而是跳出外循环。在执行带标签的break(第 30 行)之后,不再有i的迭代,接下来执行的是第 35 行的System.out.println("here")。因此,输出如下:

i, j1, 1
1, 2
here

如所示,一旦j达到3,外循环就会退出,并输出here

现在,让我们看看continue语句。

continue 语句

continue语句只能出现在循环内部。当执行时,continue表示“跳转到循环的下一个迭代”。当前迭代中剩余的任何其他语句都将被跳过。还有一个带标签的版本。我们将首先检查无标签版本。图 5**.19展示了continue的一个示例。

图 5.19 – 示例

图 5.19 – continue示例

在前面的图中,嵌套循环与之前相同 – 外部循环从1迭代到3;在其内部,内部循环从1迭代到5。这次,当j3时,我们执行continue。这意味着我们会跳到循环的末尾,接下来要执行的语句是j++。这意味着第 38 行将被跳过,值为 3 的j将永远不会被输出。输出展示了这一点:

i, j1, 1
1, 2
1, 4
1, 5
2, 1
2, 2
2, 4
2, 5
3, 1
3, 2
3, 4
3, 5

如所示,值为3j永远不会被输出。现在,让我们检查带标签的continue

带标签的continue

使用标签的continue语句会继续执行由该标签标识的循环的下一个迭代。所有其他语句都将被跳过。与带标签的break语句一样,带标签的continue语句必须位于标识的循环的作用域内。图 5**.20图 5**.19中的代码密切相关,但这次使用了标签和带标签的continue语句。

图 5.20 – 带标签的示例

图 5.20 – 带标签的continue示例

在这个图中,第 29 行将OUTERLOOP标签赋予从第 30 行开始的循环。现在,当j3且执行continue OUTERLOOP时,接下来要执行的代码行是i++。因此,每次j达到3时,我们都会从i的下一个值开始。所以,没有大于2j值被输出,如输出所示:

i, j1, 1
1, 2
2, 1
2, 2
3, 1
3, 2

这就完成了我们对各种循环结构和与它们一起使用的breakcontinue语句的解释。现在让我们将这些知识付诸实践,以巩固这些概念。

练习

现在我们能够迭代了,是时候做一些与之前章节类似但迭代多个值的任务了!

在实现这些时要有创意,并在需要的地方添加上下文。就像往常一样,没有唯一正确的答案:

  1. 我们所有的恐龙都是独一无二的。好吧,我们克隆了它们的 DNA,但仍然如此。假设它们有独特的个性。这就是为什么我们所有恐龙的 ID 都是独一无二的:它们被称为dino1dino2dino3等等。编写一个for循环,打印出公园前 100 只恐龙的 ID。

  2. 我们的一些恐龙食量很大!编写一个do-while循环,持续给恐龙喂食,直到它不再饿。

  3. 我们都喜欢等待公园开门的刺激感。使用while循环打印出公园开门时间的倒计时。

  4. 为了规划目的,知道特定围栏中所有恐龙的总重量是至关重要的。编写一个for循环来计算这个值。

  5. 在旺季,售票可能会变得非常繁忙。编写一个while循环来模拟公园的售票过程,直到票售罄。

  6. 安全是我们最优先考虑的事项。使用do-while循环来模拟一个安全检查过程,直到所有安全措施都得到满足。

项目 – 恐龙餐食计划

恐龙不是容易饲养的动物。这是一项非常高级的宠物饲养。正确的营养管理很困难,但对于它们的健康和福祉至关重要。因此,你需要创建一个系统来管理我们各种恐龙居民的喂食时间表。

该项目的首要目标是创建一个程序,计算每种恐龙的餐食分量和喂食时间。由于我们还没有介绍数组,我们现在将专注于单个恐龙。

下面是如何做到这一点:

  1. 首先声明一个变量来保存当前时间;假设它是一个整数,从0(午夜)到23(一天中的最后一个小时)。

  2. 为每种恐龙定义不同的喂食时间变量。例如,霸王龙可以在早上 8 点、下午 2 点以及晚上 8 点进食,而长颈龙可以在早上 7 点、上午 11 点、下午 3 点以及晚上 7 点进食。

  3. 接下来,建立一个条件语句(例如if-else块)来检查是否是每种动物的喂食时间,比较当前时间与它们的喂食时间。

  4. 现在,让我们定义恐龙的喂食分量。我们可以假设每种动物需要的食物量不同,这取决于它们的体型。例如,霸王龙每餐需要 100 公斤的食物,而长颈龙每餐需要 250 公斤的食物。

  5. 类似地,使用if-else块,检查你正在处理哪种动物,并相应地分配食物分量。

  6. 最后,打印结果。例如,"现在是 8:00 - 给重达 100kg 的霸王龙喂食时间"

  7. 将所有上述信息包裹在一个从023的循环中,模拟一天中的 24 小时。

代表公园里饥饿的恐龙们:非常感谢您将您的 Java 技能付诸实践!

概述

在本章中,我们讨论了 Java 如何实现迭代(循环)。我们从while循环开始,由于条件在循环的开始处,它将执行零次或多次。相比之下,do-while循环的条件在循环的末尾,它将执行一次或多次。whiledo-while循环在你不知道循环将迭代多少次时非常有用。

相比之下,当你知道循环需要执行的频率时,传统的for循环非常有用。传统的for循环的头部由三部分组成:初始化部分、布尔表达式和增量/减量部分。因此,我们可以迭代一个离散的次数。这使得传统的for循环非常适合处理数组。

增强的forfor-each)循环在处理数组(和集合)方面更加合适,前提是你不关心当前循环的迭代索引。它简洁、简明且易于编写,是一个更优雅的for循环。

实际上,如果你需要循环特定次数,请使用传统的for循环。如果你需要从开始到结束处理数组/集合,而不关心循环索引,请使用增强的for版本。

当然,所有循环都可以嵌套,我们查看了一个这样的例子。我们定义标签为一个区分大小写的标识符,后跟一个冒号,该冒号紧接在循环之前。

嵌套循环和标签为我们讨论breakcontinue关键字做好了准备。break也可以在switch语句中使用,而continue只能用于循环内部。两者都有带标签和不带标签的版本。关于break,不带标签的版本会退出当前循环,而带标签的版本会退出指定的循环。至于continue,不带标签的版本会继续当前循环的下一个迭代,而带标签的版本会继续指定循环的下一个迭代。

这样,我们就完成了对迭代的讨论。在本章中,我们简要提到了数组。接下来,我们将进入下一章,第六章,我们将详细讲解数组。

第六章:与数组一起工作

数组是一种基本的数据结构,你可以用它在一个变量中存储多个值。掌握数组不仅会使你的代码更加有序和高效,而且会打开通往更高级编程技术的大门。一旦你加入了数组,你就可以提升你应用程序的数据结构。

在本章中,我们将探讨数组,并为你提供有效使用这种基本数据结构所需的技术。你将学习如何创建、操作和遍历数组,以解决各种编程挑战。

下面是本章我们将涵盖的内容概述:

  • 数组是什么以及如何使用它们

  • 声明和初始化数组

  • 访问数组元素

  • 获取数组的长度并理解边界

  • 遍历数组及其元素的不同方式

  • 与多维数组一起工作

  • 使用Arrays类执行数组的常见操作

到本章结束时,你将拥有处理数组的坚实基础,这将使你能够自信地应对更复杂的编程任务。所以,让我们深入探讨吧!

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch6

数组——是什么,何时,为什么?

到目前为止,我们只看到了单个值,比如intdoubleString。想象一下,我们想要计算一个平均值。那看起来可能像这样:

double result1 = 7.0;double result2 = 8.6;
double result3 = 9.0;
double total = result1 + result2 + result3;
double average = total / 3;
System.out.println(average);

这段代码的可扩展性不高。如果我们想添加第四个结果,我们需要做三件事才能让它工作:

  • 声明并初始化第四个变量

  • 将这个第四个变量加到总数中

  • 4除而不是用3

这很麻烦,而且容易出错。如果我们了解数组,我们只需更改代码中的一个元素就能改变这一点。让我们看看数组是什么。然后,当我们能够遍历数组时,我们将重新编写这个示例。

Java 不能做基本的数学运算吗?!

如果你运行前面的代码片段,你会看到一些有趣的东西。如果我问你计算平均值,你会说 8.2,而且你会是对的。如果我们让 Java 来做,它会说 8.200000000000001。

你可能会想,如果 Java 不能进行基本的计算,那么学习 Java 还有什么用呢?这不仅仅是一个 Java 问题;这是一个普遍的计算机问题。它必须将十进制数字转换为二进制数字——就像你不能精确地用十进制数表示 1/3(0.33333)一样。

数组解释

好吧,所以数组可以在某些特定情况下更好地结构化我们的代码。但它们是什么?数组是一种数据结构,可以存储固定大小的、有序的相同数据类型的元素集合。数组中的元素存储在连续的内存位置中,这使得计算机更容易访问和操作数据。

到目前为止,我们还没有看到很多需要它们的情况。现在,我们将真正提高我们逻辑的复杂性,因为我们学习如何处理数组。

何时使用数组

因此,让我们谈谈何时使用数组。在我们之前的示例中,当我们计算平均值时,使用数组意味着我们不需要三个单独的变量来存储我们的三个结果。我们可以将它们存储在一个双精度数组类型的单个变量中。这使得处理数据更加容易。

数组(以及我们稍后将看到的存储多个值的其他类型方式)用于各种原因:

  • 组织数据:数组可以帮助以结构化的方式组织和管理工作量大的数据

  • 简化代码:使用数组可以通过减少存储和操作数据所需的变量数量来简化代码

  • 提高性能:访问和修改数组中的元素比使用其他数据结构更快,因为元素存储在连续的内存位置

能够处理数组将成为你的 Java 工具箱中的强大工具!让我们看看我们如何声明和初始化它们。

声明和初始化数组

在 Java 中声明和初始化数组有不同的方法。你需要什么将很大程度上取决于具体情况。所以,让我们先从声明数组的基础知识开始。

声明数组

在 Java 中声明数组时,你需要指定元素的数据类型,然后是方括号([])和数组的名称。以下是一个示例:

int[] ages;

在这里,int[]是数组的数据类型,而ages是数组的名称。目前,我们无法向数组添加任何值,因为它尚未初始化。这与我们迄今为止所看到的初始化变量不同。让我们看看如何初始化数组。

初始化数组

在声明数组之后,需要对其进行初始化。我们通过指定其大小并为元素分配内存来完成此操作。我们可以使用new关键字来完成此操作,然后指定数据类型,并在方括号内指定数组的大小。以下是一个示例:

ages = new int[5];

此代码将ages变量初始化为包含大小为5的整数数组的数组。

我们也可以在一行代码中声明和初始化数组:

int[] ages = new int[5];

在这里,我们首先在左侧声明数组,然后在右侧初始化它。我们还可以使用特殊的简短语法直接分配其值,我们将在下面探讨。

数组初始化的简短语法

我们可以使用 Java 的快捷语法来声明和初始化具有特定值的数组。我们不需要分别声明和初始化数组,而是可以使用花括号({})直接指定元素。请看以下示例:

int[] ages = {31, 7, 5, 1, 0};

此代码创建了一个整数数组,并使用指定的值对其进行初始化。数组的大小由花括号内的元素数量确定。

实际上,我们之前的数组也有值,因为当你使用new关键字创建数组时,Java 会自动根据其数据类型使用默认值初始化元素。默认值如下:

  • 数值类型(byteshortintlongfloatdouble):00.0

  • char: ‘\u0000’(Unicode 中的null字符)

  • booleanfalse

  • 引用类型(对象和数组):null

例如,假设你创建了一个大小为3的整数数组:

int[] results = new int[3];

Java 使用默认值0初始化元素,因为int是数值类型。到目前为止,我们已经看到了如何声明和初始化数组。现在是时候学习如何访问数组中的元素并更新值了。

访问数组中的元素

为了访问数组中的元素,我们需要使用它们的索引。索引表示数组中的位置。这使我们能够检索特定位置的值并将其赋予新的值。让我们首先谈谈索引。

理解索引

在 Java 中,数组使用基于 0 的索引,这意味着第一个元素的索引是0,第二个元素的索引是1,依此类推。看看我们的ages数组示例:

int[] ages = {31, 7, 5, 1, 0};

这意味着第一个元素(31)的索引是0,最后一个元素的索引是4

图 6.1 – 使用年龄数组解释索引

图 6.1 – 使用年龄数组解释索引

我们像通常那样计算数组的长度,从1开始。因此,这个数组的长度将是5。数组中的最后一个元素的索引等于数组的长度减去1。对于一个长度为 N 的数组,有效的索引范围是 0 到 N-1。

了解如何使用索引很重要,因为这样我们可以访问数组中的元素。

访问数组元素

要访问数组中的元素,你可以使用数组名称,后跟方括号内所需元素的索引。例如,要访问名为ages的数组的第一个元素,你可以使用以下代码:

int maaikesAge = ages[0];

这将在age变量中存储值31。为了访问第二个元素,你必须这样做:

int gaiasAge = ages[1];

我们还可以使用索引访问元素,并在该元素中存储另一个值。

打印数组

如果我们打印包含数组的变量,我们可以得到类似以下的内容:[I@28a418fc

这不会很有帮助。所以请注意,你正在打印toString()方法返回的内容。这不是针对数组定制的,也不是很有用。我们最可能想看到的是数组中的元素。有一种打印数组内容的方法。我们将在介绍处理数组的内置方法时看到这一点。

修改数组元素

修改元素也是通过索引完成的。这看起来很像我们之前所做的变量赋值。例如,要更改我们数组中名为ages的最后一个元素的值,我们可以使用以下代码:

ages[4] = 37;

我们只能访问存在的元素。如果我们尝试获取不存在的元素,我们会得到一个异常(错误)消息。

处理长度和边界

为了避免得到异常,我们需要保持在数组的边界内。索引始终从0开始,结束于数组长度减 1。如果你尝试访问这个范围之外的元素,你会得到ArrayIndexOutOfBoundsException。避免这种情况的关键是处理数组的长度。

确定数组长度

我们可以使用length属性来确定数组的长度。length属性返回数组中的元素数量。例如,要获取ages数组的长度,我们可以使用以下代码:

int arrLength = ages.length;

数组的长度从1开始计数。因此,我们的ages数组长度为5。最大索引是4

处理数组的边界

如果你尝试使用无效索引(小于 0 或大于或等于数组长度的索引)访问或修改数组元素,Java 将抛出ArrayIndexOutOfBoundsException异常。这个异常是一个运行时错误,这意味着它在程序运行时发生,而不是在编译时。我们将在第十一章中了解更多关于异常的内容。

为了防止ArrayIndexOutOfBoundsExceptions,我们应该在使用数组索引访问或修改数组元素之前始终验证索引。我们可以通过检查索引是否在有效范围内(0到数组长度减 1)来完成此操作。以下是一个演示如何验证数组索引的示例:

String[] names = {"Maria", "Fatiha", "Pradeepa", "Sarah"};int index = 5;
if (index >= 0 && index < names.length) {
    System.out.println("Element at index " + index + ": " +
      names[index]);
} else {
    System.out.println("Invalid index: " + index);
}

输出将如下所示:

Invalid index: 5

这段代码片段在访问数组元素之前检查索引是否在有效范围内。如果索引无效,程序将打印错误消息而不是抛出异常。

我们还可以使用我们在上一章中学到的循环来遍历数组中的元素,访问或修改它们的值。

遍历数组

遍历数组有不同的方法。我们将查看传统for循环和增强型for循环(也称为for-each循环)的使用。

使用 for 循环

我们可以使用传统的for循环通过索引变量遍历数组。循环从索引0开始,直到索引达到数组的长度。以下是一个演示如何使用for循环遍历数组并打印其元素的示例:

int[] results = {10, 20, 30, 40, 50};for (int i = 0; i < results.length; i++) {
    System.out.println("Element at " + i + ": " +
      results[i]);
}

输出将如下所示:

Element at 0: 10Element at 1: 20
Element at 2: 30
Element at 3: 40
Element at 4: 50

到目前为止,我们已经足够了解如何回顾本章开头看到的示例,计算几个结果的平均值。现在,我们不再有单独的原始数据类型,而是将有一个数组。它看起来是这样的:

double[] results = {7.0, 8.6, 9.0};double total = 0;
for(int i = 0; i < results.length; i++) {
    total += results[i];
}
double average = total / results.length;
System.out.println(average);

如果我们现在想要添加一个结果,我们只需要在一个地方修改它。我们只需将结果添加到 results 数组中。由于我们遍历了所有元素,我们不需要添加一个额外的变量来计算总结果。此外,由于我们使用了长度,我们不需要将 3 改为 4

我们也可以使用循环来修改数组的值。以下是一个示例,演示了如何使用 for 循环将数组中每个元素的值加倍:

int[] results = {10, 20, 30, 40, 50};// Double the value of each element
for (int i = 0; i < results.length; i++) {
    results[i] = results[i] * 2;
}
// Print the updated array elements
for (int i = 0; i < results.length; i++) {
    System.out.println("Element at " + i + ": " +
      results[i]);
}

输出将如下所示:

Element at 0: 20Element at 1: 40
Element at 2: 60
Element at 3: 80
Element at 4: 100

如你所见,在第一个 for 循环中,数组元素被加倍。在第二个 for 循环中,它们被打印出来。正如输出所示,值确实加倍了!

让我们看看增强型 for 循环以及我们如何使用它来遍历数组。

使用 for-each 循环

我们还可以使用 for-each 循环,也称为增强型循环,来遍历数组。这个特殊的 for 循环简化了遍历数组(以及其他可迭代对象)的过程。for-each 循环会自动遍历数组中的元素,并且不需要索引变量。以下是一个示例,演示了如何使用 for-each 循环遍历数组并打印其元素:

int[] results = {10, 20, 30, 40, 50};for (int result : results) {
    System.out.println("Element: " + result);
}

输出将如下所示:

Element: 10Element: 20
Element: 30
Element: 40
Element: 50

for-each 循环需要一个临时变量,用于在每次迭代中存储当前元素。在我们的例子中,这是 int result。将其称为 result 是有逻辑的,因为它是在 results 数组中的一个元素。但这不是功能所必需的;我也可以将其称为 x,如下所示:

int[] results = {10, 20, 30, 40, 50};for (int x : results) {
    System.out.println("Element: " + x);
}

输出将完全相同。我喜欢在心中这样阅读代码行 for (int x : results):对于 results 中的每个元素 x,执行代码块中的任何操作。

因此,有两种遍历数组的方法,让我们谈谈何时选择哪一种。

在常规循环和增强型循环之间进行选择

我们可以使用常规 for 循环和(增强型)for-each 循环来遍历数组。这两种方法有一些区别,选择其中一种的原因。

当你需要索引可用时,你应该使用传统的 for 循环,因为这个循环使用索引变量来访问数组中的元素,而 for-each 循环直接访问元素而不使用索引变量。

for-each 循环不允许你在迭代过程中修改数组元素,因为它不提供访问索引变量的权限。如果你需要在迭代过程中修改数组元素,你应该使用传统的 for 循环。

如果你只想读取变量而不需要索引,通常你想要选择 for-each 循环,因为其语法更简单。

好的,现在我们知道了如何遍历数组。让我们使数据结构稍微复杂一些,并学习关于多维数组的内容。

处理多维数组

多维数组是数组的数组。在 Java 中,你可以创建具有两个或更多维度的数组。最常见类型的多维数组是二维数组,也称为矩阵或表格,其中元素按行和列排列。

让我们看看如何创建多维数组。

声明和初始化多维数组

要声明二维数组,您需要指定元素的数据类型,然后是两对方括号([][])和数组名。以下是一个示例:

int[][] matrix;

就像一维数组一样,我们使用new关键字初始化二维数组,后面跟着方括号内每个维度的数据类型和大小,如下所示:

matrix = new int[3][4];

此代码初始化了一个 3 行 4 列的矩阵。类型是int,因此我们知道矩阵的值是整数。

我们也可以在一行中声明和初始化多维数组:

int[][] matrix = new int[3][4];

我们也可以使用简短语法。要使用特定值初始化多维数组,我们使用嵌套花括号({}):

int[][] matrix = {    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

就像一维数组一样,Java 通过提供的值来确定长度。这个矩阵有三个内部数组(三行),每个数组包含四个元素(四列)。访问和修改多维数组中的元素与之前类似,但现在我们需要提供两个索引。

访问和修改多维数组的元素

要访问或修改多维数组的元素,您需要在方括号内指定每个维度的索引。例如,要访问名为matrix的二维数组的第一行第二列的元素,可以使用以下代码:

int element = matrix[0][1];

要修改相同的元素,可以使用以下代码:

matrix[0][1] = 42;

图 6**.2展示了我们的二维数组matrix的索引工作方式。

图 6.2 – 数组矩阵的行和列索引

图 6.2 – 数组矩阵的行和列索引

因此,如果我们想获取值为12的元素并将其存储在last变量中,我们的代码如下所示:

int last = matrix[2][3];

我们也可以遍历多维数组中的所有变量。让我们看看这是如何实现的。

遍历多维数组

由于多维数组只是数组中的数组,我们可以使用嵌套循环来遍历多维数组。以下是一个示例,展示了我们如何使用嵌套for循环遍历二维数组:

int[][] matrix = {    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};
for (int i = 0; i < matrix.length; i++) {
    for (int j = 0; j < matrix[i].length; j++) {
        System.out.print(matrix[i][j] + " ");
    }
    System.out.println();
}

输出将如下所示:

1 2 3 45 6 7 8
9 10 11 12

目前我们做的只是打印这个元素。这也是我们可以使用增强的for循环遍历多维数组时可以做到的。以下是一个示例,展示了如何做到这一点:

int[][] matrix = {    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};
for (int[] row : matrix) {
    for (int element : row) {
        System.out.print(element + " ");
    }
    System.out.println();
}

输出将与上一个示例相同:

1 2 3 45 6 7 8
9 10 11 12

如你所见,外部的 for-each 循环遍历二维数组的行。行本身也是一个数组,这就是为什么类型是 int[]。内部的 for-each 循环遍历每一行中的元素。这些是整数。

传统的嵌套 for 循环和嵌套 for-each 循环都可以用来遍历多维数组。这取决于个人喜好以及你是否需要访问元素的索引。

数组可以有很多层级,但这并不真正改变基本原理。例如,对于四维数组,类型后面会有 [][][][],你需要四个级别的嵌套循环来遍历所有元素。

Java 以不同的方式帮助我们处理数组。让我们看看我们可以使用的数组的一些内置方法。

使用 Java 的内置数组方法

处理数组非常常见。通常,对于非常常见的事情,Java 有内置的功能。我们可以使用内置 Arrays 类的方法来做许多我们想对数组做的事情。

用于处理数组的内置 Arrays

内置的 Arrays 类是 java.util 包中的一个辅助类。它提供了许多实用方法,帮助我们高效地处理数组。我们将使用 Arrays 类来探索一些常见的数组操作任务。

toString() 方法

你可能想在数组上执行的一个非常有用的操作是将它转换成一个 String,这对于调试和日志记录非常有价值。为了实现这一点,Arrays 类提供了一个专门的方法,称为 toString()。需要注意的是,这个方法是静态的,允许我们直接在 Arrays 类上调用它。

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results = {30, 10, 50, 20, 40};
        // Convert the array to a string representation
        String arrayAsString = Arrays.toString(results);
        System.out.println("Array: " + arrayAsString);
    }
}

输出将如下所示:

Array: [30, 10, 50, 20, 40]

如你所见,results 数组被转换成一个表示数组元素的字符串,这些元素被方括号包围并由逗号分隔。Arrays 类上有许多这样的方法!接下来,让我们探索 sort 方法。

sort() 方法

你想在数组上执行的一个常见操作是对数组进行排序。以下是一个示例,展示了如何使用 Arrays 类的 sort 方法对数组的值进行排序:

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results = {30, 10, 50, 20, 40};
        // Sort the array
        Arrays.sort(results);
        System.out.println(Arrays.toString(results));
    }
}

输出将如下所示:

[10, 20, 30, 40, 50]

如你所见,results 数组最初是无序的。我们可以直接在 Arrays 类上调用 Arrays 类的方法,因为它们是静态的。对于整数值,默认情况下按从低到高的顺序排序。我们可以改变这种行为,但我们还没有掌握所需的知识。

我们使用另一个内置方法 toString 来打印数组。这会将数组转换成我们可以理解的形式。

当数组排序后,我们可以使用 binarySearch 方法来查找一个值。

binarySearch() 方法

我们还可以在数组中搜索一个值。我们将使用内置的 binarySearch 方法来完成此操作。非常重要的一点是,由于搜索算法的工作方式,这只能在排序数组中完成。以下是如何做到这一点的示例:

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results = {10, 20, 30, 40, 50};
        int target = 30;
        int index = Arrays.binarySearch(results, target);
        System.out.println("Index of " + target + ": " +
          index);
    }
}

输出将如下所示:

Index of 30: 2

binarySearch 方法要求输入数组在之前已经排序。binarySearch 算法旨在在排序数组中查找目标值。它不是逐个搜索数组元素,而是将数组分成两半,直到找到目标或剩余要搜索的部分变为空。当中间的值更大时,它知道需要向数组的左侧移动,当它更小时,它知道需要向右侧移动。这就是为什么数组必须排序。如果找到了目标值,binarySearch 方法返回目标值的索引。如果目标值未找到,它返回一个负值,这表示插入点。所以,假设我们更新了我们的代码如下:

        int[] results = {10, 20, 30, 40, 50};        int target = 31;
        int index = Arrays.binarySearch(results, target);
        System.out.println("Index of " + target + ": " +
          index);

这将导致以下结果:

Index of 31: -4

这是因为它原本将在数组的第四个位置(而不是第四个索引!)。

让我们看看如何使用 fill 方法给数组中的所有元素赋予一个特定的值。

fill() 方法

有时,您可能需要以编程方式创建具有相同值的数组。以下是如何做到这一点的示例。我们使用 Arrays 类中的 fill 方法。以下是操作方法:

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results = new int[5];
        Arrays.fill(results, 42);
        System.out.println(Arrays.toString(results));
    }
}

输出将如下所示:

[42, 42, 42, 42, 42]

fill 方法将数组中的所有元素设置为指定的值。有时我们需要创建数组的副本或调整它的大小。在这种情况下,我们可以使用 copyOf 方法。

copyOf() 方法

有时您需要创建数组的副本,例如,当您想在应用程序的另一个位置结束它时,但不想影响您的原始数组。

这是一个示例,说明我们如何创建数组的副本:

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results = {10, 20, 30, 40, 50};
        int[] copiedResults = Arrays.copyOf(results,
          results.length);
        System.out.println(Arrays.toString(copiedResults));
    }
}

输出将如下所示:

[10, 20, 30, 40, 50]

我们可以用以下代码证明我们已经复制了数组:

copiedResults[0] = 1000;System.out.println(Arrays.toString(copiedResults));
System.out.println(Arrays.toString(results));

如果我们没有创建副本,而是将其存储在另一个变量中,它将同时改变两个数组。前面的代码片段将给出以下输出:

[1000, 20, 30, 40, 50][10, 20, 30, 40, 50]

但假设我们有以下代码:

int[] copiedResults = results;copiedResults[0] = 1000;
System.out.println(Arrays.toString(copiedResults));
System.out.println(Arrays.toString(results));

它将给出以下输出:

[1000, 20, 30, 40, 50][1000, 20, 30, 40, 50]

如您所见,这改变了持有数组的两个变量。这是因为这两个变量,copiedResultsresults,都指向相同的数组对象。所以,如果您在一个地方更改它,它就会对两个都进行更改。这就是为什么有时您需要创建数组的副本。

因此,copyOf 方法创建了一个与原始数组具有相同元素的新数组,而第二种方法只是创建了一个指向相同数组对象的新变量。我们还可以通过传递第二个参数来调整数组的大小。

使用 copyOf() 方法调整数组大小

数组的大小是固定的,但有时你仍然需要更改大小。我们刚刚看到的 Arrays.copyOf() 方法也适用于调整数组大小。要调整数组大小,你可以创建一个具有所需大小的新数组,并将原始数组中的元素复制到新数组中。你只需要提供一个第二个参数。

这里有一个示例,演示了如何调整大小:

import java.util.Arrays;int[] originalArray = {10, 20, 30, 40, 50};
int newLength = 7;
int[] resizedArray = Arrays.copyOf(originalArray, newLength);
System.out.println("Original array: " + Arrays.toString(originalArray));
System.out.println("Resized array: " + Arrays.toString(resizedArray));

输出将如下所示:

Original array: [10, 20, 30, 40, 50]Resized array: [10, 20, 30, 40, 50, 0, 0]

在这个例子中,我们将长度为 5originalArray 调整大小到新的长度 7。新数组包含原始数组中的元素,后面跟着默认值(对于 int0)来填充剩余的位置。

这不是你应该经常做的事情。在性能方面可能效率低下。如果您需要经常调整数组大小,那么查看 第十三章,在那里我们学习了集合,是值得的。

equals() 方法

我们将要讨论的最后一个内置方法是 equals() 方法。此方法可以确定两个数组是否具有相同的值。使用此内置方法,您可以比较两个数组是否相等。以下是操作方法:

import java.util.Arrays;public class ArrayHelperMethods {
    public static void main(String[] args) {
        int[] results1 = {10, 20, 30, 40, 50};
        int[] results2 = {10, 20, 30, 40, 50};
        boolean arraysEqual = Arrays.equals(results1,
          results2);
        System.out.println("Are the arrays equal? " +
          arraysEqual);
    }
}

输出将如下所示:

Are the arrays equal? true

equals() 方法逐个元素比较两个数组,以检查它们是否具有相同顺序的相同值。如果数组相等,则返回 true;否则返回 false

干得好!

你在数组学习方面做得很好!在这个阶段,你准备好理解这个编程笑话了:

为什么 Java 开发者辞职了?

因为他们无法得到“数组!”

练习

数组对于存储和管理类似类型的数据非常有用,例如恐龙名称列表、恐龙重量和游客最喜欢的零食。数组很有用,并且使我们能够管理 Mesozoic Eden 中的更复杂数据。尝试以下操作:

  1. 我们公园的独特吸引力在于我们恐龙种类的多样性。(而且我们确实有恐龙。)创建一个数组来存储公园中所有恐龙种类的名称。这份清单将帮助我们进行库存管理。

  2. 每位游客都有自己的最爱恐龙,对许多人来说,那就是最重的那个。编写一个程序来找出这个明星在恐龙重量数组中的重量。然后,我们可以将此信息突出显示在我们的公园游览和教育项目中。

  3. 恐龙有各种大小,最小的那些在孩子们的心中占有特殊的位置。编写一个程序来找出这个最小的恐龙在恐龙重量数组中的位置。

  4. 经营一个恐龙公园不是一个人的表演,需要一支专门的员工团队。创建一个公园员工名称数组,并使用增强型 for 循环打印出这些名称。这将帮助我们更有效地欣赏和管理我们的员工。

  5. 为了确保我们恐龙居民的福祉,监测它们的平均年龄是至关重要的。这些数据可以帮助我们更好地调整我们的护理和喂养计划,以适应我们恐龙的年龄特征。编写一个程序,使用恐龙年龄数组来计算这个值。

  6. 我们的公园被细致地划分为各个区域,以方便游客导航和恐龙的饲养。创建一个表示公园地图的二维数组,每个单元格包含一个字符串数组,表示某个区域的围栏或设施。

  7. 公园之旅的乐趣在很大程度上取决于舒适的座位安排。使用嵌套循环从二维数组中打印出公园游览巴士的座位图。这将帮助我们确保每位游客在整个公园的旅程中都有愉快的体验。

项目 – 恐龙追踪器

安全总是第一位的。这就是为什么跟踪我们所有的恐龙居民至关重要。公园管理者需要有一个易于使用的系统来管理他们这些略带异国情调的宠物的信息。

对于这个项目,你将创建一个恐龙追踪器。这是一个简单的追踪系统,用于记录每只恐龙的名称、年龄、物种和围栏编号。这将通过固定数组来完成——总共四个数组,每个属性一个。

假设你公园里现在有 10 个恐龙的空间,所以每个数组应该有 10 个长度。每个恐龙将对应数组中的一个索引。例如,如果恐龙“Rex”在名称数组的第一个位置,它的年龄、物种和围栏编号也将分别位于它们各自数组的第一个位置。

你将打印有关所有恐龙的信息,并在之后打印它们的平均年龄和体重。

我意识到这可能会很多。如果你需要一些额外的指导,以下是一些指导你通过这个过程的步骤:

  1. dinoNamesdinoAgesdinoSpeciesdinoEnclosures。每个数组的大小应为 10。

  2. 名称使用Dinosaur1Dinosaur2等。

  3. 显示详情:编写一个循环遍历数组,并以可读的格式打印出每只恐龙的详细信息。

  4. dinoAges数组除以恐龙的数量。当然,这个过程对于体重也是类似的,但使用体重数组。

概述

在本章中,我们探讨了 Java 中的数组。数组是数据结构,允许我们在连续的内存块中存储相同数据类型的多个值。它们提供了一种有效的方式来组织数据列表。

我们首先讨论了数组的声明和初始化。我们学习了不同的声明和初始化数组的方法,包括使用数组初始化的快捷语法。我们还介绍了如何使用默认值初始化数组。

之后,我们讨论了如何使用索引访问和修改数组元素。我们了解了数组长度的重要性,以及我们可以通过使用length属性来找出长度。我们还讨论了通过验证数组索引来避免ArrayIndexOutOfBoundsExceptions

然后,我们探讨了使用传统的for循环和增强的for循环(即for-each循环)遍历数组的方法。

在此之后,我们探讨了多维数组,即数组的数组,并学习了如何声明、初始化和访问它们的元素。我们还讨论了如何遍历多维数组。

最后,我们使用Arrays类及其内置方法介绍了常见的数组操作。我们看到了如何排序数组,在排序数组中搜索元素,用特定值填充数组,复制和调整数组大小,以及比较数组。

通过掌握这些概念,你现在有了在 Java 中使用数组的坚实基础。这种理解将帮助你更有效地在 Java 程序中存储和处理数据。我们最后查看了一些内置方法。在下一章中,你将学习如何编写自己的方法。

第七章:方法

第六章中,我们学习了 Java 中的数组。我们了解到数组是固定大小的数据结构。它们存储在连续的内存位置中,每个位置的数据类型相同。我们还看到了如何声明、初始化和处理数组。传统和增强的for循环都非常适合处理数组。

此外,我们还讨论了多维数组,包括它们的组织方式和处理方法。最后,由于数组非常常见,我们讨论了Arrays类,它有几个用于处理数组的实用方法。

在本章中,我们将介绍方法。方法使我们能够创建一个可以执行于代码其他部分的命名代码块。首先,我们将解释为什么方法如此普遍。您将学习方法定义和方法调用的区别。我们将探讨方法签名是什么,以及方法重载如何使方法具有相同的名称,而不产生冲突。我们还将解释变量参数(varargs),它允许方法以 0 个或多个参数执行。最后,我们将概述 Java 的按值传递参数(和返回值)的原则。到本章结束时,您将能够编写和执行方法。此外,您将理解方法重载、varargs和 Java 的按值调用机制。

本章涵盖了以下主要主题:

  • 解释为什么方法很重要

  • 理解方法定义和方法执行之间的区别

  • 探索方法重载

  • 解释varargs

  • 掌握按值调用

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch7

解释为什么方法很重要

方法是为便于引用而命名的代码块。它们可以接受输入并返回输出。输入和输出都是可选的。方法应该完成一个任务并且做得很好。将方法保持简短(少于 20 行)被认为是良好的实践。方法越长,它做得多的事情就越多。这里的“保持简单”的原则适用。

控制流程

简而言之,当方法被调用(执行)时,执行的控制流程会发生变化。让我们讨论一个简单的例子,这将有助于展示这一点。这是一个重要的观点,特别是对于经验不足的开发者来说。图 7.1展示了代码:

图 7.1 – 一个非常简单的函数

图 7.1 – 一个非常简单的函数

在这个例子中,我们有两个方法:main()方法(第 4 至 8 行)和simpleExample()方法(第 9 至 11 行)。它们都存在于Methods类中(第 3 至 12 行)。

在 Java 中,每个程序都以main()方法开始。JVM 代表我们调用它;我们不必自己调用(或执行)它。因此,在这个例子中,main()中的第一行,第 5 行,是第一条要执行的行。

第 6 行很重要——这就是我们所说的方法调用。第 9 行的simpleExample()方法定义与第 6 行的方法调用之间存在直接关联。我们将在稍后讨论这种关系。目前,只需理解方法调用改变了程序的执行顺序。通常,Java 从上到下执行代码行,这是真的。然而,方法调用改变了这一点。在本例中,当第 6 行执行时,接下来要执行的行是第 10 行(在simpleExample()方法内部)。

因此,main()方法现在已经将控制权交给了simpleExample()方法,并且只有在simpleExample()方法退出后,控制才会返回到main()。这可以在执行到simpleExample()方法末尾的闭合}时发生(第 11 行)。这正是本例中的情况。或者,一个方法可以通过使用return关键字来退出。

因此,第 6 行调用了simpleExample()方法,导致其代码执行。第 10 行将一些文本输出到屏幕上。第 11 行的闭合}导致simpleExample()退出,现在控制权返回到main(),执行从第 7 行恢复。

总结来说,这个程序的执行顺序可以通过输出来说明:

main: before call to simpleExample()    Executing simpleExample() method...
main: after call to simpleExample()

在这里,你可以看到println(),在simpleExample()方法内部,被main()方法中的两个println()语句所包围。这表明控制流在第六行的方法调用中被改变。

那么,调用方法,例如main(),如何在simpleExample()方法返回后简单地恢复到它离开的地方?main()的局部变量又是如何的呢?

方法能够在调用它的方法返回后精确地恢复到它离开的地方,这需要使用一种称为的内存结构。我们将在本章后面讨论栈。

回到我们关于“为什么方法很重要”的讨论,方法的主要优势有两个:它们提供了抽象并避免了代码重复。我们将依次探讨这些优势。

抽象

抽象是软件工程中的一个原则,其中服务的客户端被从服务实现中抽象出来。这解耦了使用服务的客户端,使他们不必知道服务是如何实现的。因此,如果服务实现发生变化,客户端不会受到影响。

以麦当劳的免下车服务为例,你开车到那里并下订单。在这种情况下,你是麦当劳服务的客户。你不在乎麦当劳如何处理你的订单;你只是想下订单并收到食物/饮料。如果麦当劳更改其内部实现,你会受到保护(抽象化)免受这些更改的影响。这被称为抽象。

对于我们的目的而言,方法本身就像是麦当劳的服务。方法调用就像是麦当劳的客户。方法调用抽象了方法代码的内部变化。

代码重复

方法可以帮助我们避免代码重复。这还有一个额外的优点,就是简化调试。让我们来看一个简单的例子。图 7.2 展示了重复的代码:

图 7.2 – 重复的代码

图 7.2 – 重复的代码

在前面的图中,第 8 行到第 12 行在第 14 行到第 18 行重复,第 20 行到第 24 行也重复。这些部分中的每一个都提示用户输入一个数字,将用户输入存储在名为 number 的变量中,并检查该数字是否在范围内。如果数字超出范围,则标记错误。虽然循环会是一个明显的改进,但请记住,这些代码行可能位于程序的单独部分。此外,对于这个简单的例子,我们只对突出代码重复感兴趣。我们只是提示输入一个数字,接受用户的输入,并验证它。结果是五行代码重复了三次。

现在,假设我们想要将有效范围从 10 调整到 100。我们必须修改第 8、14 和 20 行的提示。此外,第 10、16 和 22 行的 if 语句也需要修改。因此,简单的范围调整导致了相当多的代码更改,我们很容易忘记进行一个或多个必要的更改。让我们将代码重构为一个方法。图 7.3 展示了重构后的代码:

图 7.3 – 将图 7.2 中的代码重构为使用方法

图 7.3 – 将图 7.2 中的代码重构为使用方法

在前面的图中,方法本身是从第 9 行到第 17 行编写的,将在下一节中详细解释。图 7.2 中的五行重复代码只在第 11 行到第 15 行编写了一次。方法的执行调用在第 5、6 和 7 行;每行一个执行调用。如果我们想将有效范围从 10 调整到 100,我们只需修改方法即可——即第 11 和第 13 行。这两个更改会自动反映在整个代码中。实际上,第 5、6 和 7 行的三个方法调用会自动反映方法中的更改。

如您所想象,这种情况非常易于扩展。例如,如果在图 7.2 中我们重复了代码 10 次,我们就需要在代码的 10 个区域进行更改。然而,有了方法实现,仍然只有一个地方需要更改,那就是方法本身。

既然我们已经解释了为什么需要方法,让我们来检查方法本身和方法调用之间的区别。

理解方法定义和方法执行之间的区别

对于编程新手来说,可能会惊讶地知道,要让方法某事有两个部分。首先,我们必须编写方法(方法定义)。这类似于街上的自动取款机 – 它只是静静地坐着,什么也不做,等待被使用。其次,我们必须执行方法(方法执行)。这类似于客户“使用”自动取款机。记住,main方法是唯一由 JVM 自动执行的方法。任何其他方法调用都必须明确编码。

现在,让我们依次检查方法定义和方法执行。

方法定义

方法定义(声明)本身就是方法代码 - 这是在调用方法时执行的代码块。图 7.4 展示了语法:

图 7.4 – 方法定义的语法

图 7.4 – 方法定义的语法

在前面的图中,与其他图一样,方括号表示可选元素。access-modifierstatic 元素将在第八章中讨论。throws someException 元素将在第十一章中介绍。在本章中,我们将重点关注加粗的元素;即,return-type(必需)、methodName(必需)和parameters(可选)。

方法的返回类型可以是原始类型、引用类型或voidvoid关键字表示该方法不返回任何内容。如果是这种情况,你不能简单地省略返回类型;你必须指定void。此外,当你从方法中不返回任何内容时,你可以指定return;或完全省略return关键字(这是我们为所有main()方法所做的那样)。

让我们检查一个接受输入并返回结果的方法。图 7.5 展示了这样一个例子:

图 7.5 – 示例方法定义

图 7.5 – 示例方法定义

在前面的图中,我们有一个接受两个整数和一个要使用这两个整数作为操作数执行数学运算的方法。例如,如果传入"+",则两个数字相加并返回结果。让我们回顾一下方法是如何做到这一点的。

第 15 行非常重要。目前,如前所述,第八章将解释public(访问修饰符)和staticreturn-typeint – 这意味着这个方法返回整数。方法的名字是performCalc。方法名通常以动词开头,并遵循驼峰命名法。

注意,圆括号跟在方法名后面。圆括号是方法可选输入参数的分隔符。对于每个参数,你必须指定参数的数据类型(因为 Java 是一种强类型语言)和参数的标识符名称。如果你有两个或更多参数,用逗号分隔它们。这些参数是方法接受输入的方式。在 图 7.5 中,我们有两个整数 xy,后面跟着一个名为 operationString。在这种情况下,任何方法参数(例如 xyoperation)的作用域是整个方法。

第 16-26 行封装了一个 switch 表达式。实际上,根据传入的数学 operation,该操作会在两个输入 xy 上执行。局部 int 变量 result 根据相应地初始化。第 27 行返回 result 变量。由于第 15 行声明的返回类型是 int,返回 result(它也是一个 int),这是可以的。

方法定义本身并不做任何事情。它只是定义了一块代码。如前所述,这类似于街上的自动取款机 – 它只是坐在那里,什么也不做,等待被使用。为了让自动取款机有用,你必须“使用”它。同样,我们必须“使用”方法 – 这就是我们所说的执行方法。

方法执行

执行方法也称为调用或调用方法。调用方法的方法称为“调用”方法(或调用者方法)。所以,你有调用方法和被调用方法。当你调用一个方法时,如果你有,你会传递所需的参数。被调用方法将在这一点上执行。当被调用方法完成后,控制权返回到调用方法。如果被调用方法有结果,该结果也会返回。这使得被调用方法能够将数据返回给调用方法,在那里它可以输出到屏幕、存储在变量中,或者简单地被忽略。

方法参数与方法参数

方法定义定义了参数,而方法调用传递参数。这些术语通常被交替使用。

图 7.6 展示了一个代码示例,以帮助进一步解释这一点:

图 7.6 – 展示方法调用的示例代码

图 7.6 – 展示方法调用的示例代码

IntelliJ IDEA 内联提示

注意,当你编码时,IntelliJ 编辑器会插入内联提示。在上一幅图中,performCalc 方法签名(第 13 行)指定参数为 xyoperation。这就是为什么在每个方法调用中,内联提示都使用这些参数名称。例如,在第 5 行,我们输入了 10 作为第一个参数;然而,IntelliJ 在检查方法签名后意识到 10 映射到 x,这就是为什么你会看到“performCalc(10, 2, "+")”以及 IntelliJ 将其转换为 performCalc(x: 10, y: 2, operation: "+") 的原因。

图 7.6 中,performCalc方法(第 13-26 行)与图 7.5 中的没有变化。然而,我们现在可以看到各种方法调用(第 5 行和第 7-11 行)。

让我们从第 5 行开始。在赋值的右侧,我们有performCalc(10, 2, "+")方法的调用。这个方法调用比赋值有更高的优先级,所以它首先被执行。IntelliJ IDE 非常出色地突出显示了10将被作为x传递给方法,2将被作为y传递给方法,而"+"将作为operation传递。非常重要的一点是,一旦我们到达第 5 行的方法调用,接下来执行的代码是第 14 行——所以,从第 5 行开始,我们跳入performCalc方法,并开始执行第 14 行的switch表达式。

由于这次方法调用中的operation"+",第 15 行将 10 + 2(12)赋值给result。第 25 行将result的值返回到调用方法(第 5 行),其中值 12 被赋值给result。第 6 行输出了第 5 行performCalc调用的返回值,即 12。

不同的作用域

注意,两个result变量(第 5 行和第 14 行)完全不同,因为它们在不同的作用域中——一个在main()方法中,另一个在performCalc方法中。因此,没有任何冲突或歧义。

第 7 行在println()内执行了System.out.println()方法调用。在这种情况下,Java 将执行println()内的方法调用,然后方法返回的内容将被输出到屏幕。所以,对于第 7 行,传递给performCalc的参数是102"-"。因此,在performCalc中,x是 10,y是 2,operation"-"switch表达式现在执行第 16 行,导致result变为 8(10 - 2)。这个result被返回(第 25 行)到调用方法(第 7 行),其中8被输出到屏幕。

第 8 行和第 9 行的操作与第 7 行类似,除了switch表达式中执行的代码行不同。第 8 行的方法调用执行了switch表达式中的第 17 行,导致result被初始化为 20。这个值被返回到调用方法(第 8 行),其中20被输出到屏幕。第 9 行的方法调用执行了switch表达式中的第 18 行,导致result被初始化为 5,因此5被输出到屏幕。

第 10 行导致switch表达式中的第 19 行被执行,初始化result0(10 % 2)。这个result被返回到调用方法,由于它没有被存储在变量中,所以它只是被丢失/忽略。

第 11 行的performCalc调用传递了"&",这执行了switch表达式的default分支。这导致屏幕上显示错误消息“未识别的操作:&”,并返回-1。然后-1 被输出到屏幕。

现在我们已经知道了如何定义和执行方法,我们将继续讨论方法重载,其中不同的方法可以具有相同的标识符名称。

探索方法重载

考虑一个场景,你有一个算法,通过一个方法实现,它在各种输入类型上操作相似——例如Stringint。如果为每种输入类型分别构造两个不同的方法名,比如doStuffForString(String)doStuffForInt(int),那就太遗憾了。如果两个方法都有相同的名称——即doStuff——并通过它们的输入类型区分,即doStuff(String)doStuff(int),那就好多了。这样就不会有构造的方法名。这正是方法重载提供的。为了正确地讨论方法重载,我们首先必须定义方法签名。

方法签名

方法签名由方法名和可选参数组成。它包括返回类型。让我们通过一个例子来进一步解释这一点:

图 7.7 – 方法签名

图 7.7 – 方法签名

在前面的图中,方法签名用虚线矩形突出显示。它由方法名、参数的类型和顺序组成。这意味着图7.7中的方法签名是performCalc,它接受两个整数和一个String按此顺序。请注意,参数名并不重要。因此,实际上,从编译器的角度来看,方法签名是performCalc(int, int, String)

方法重载

当两个或多个方法具有相同的名称但参数类型和/或顺序不同时,方法就是重载的。如果你从编译器的角度来看,这很有意义。如果你调用一个有两个或多个定义的方法,编译器将如何知道你指的是哪一个?为了定位正确的方法定义,编译器会将方法调用与重载的方法签名进行比较和匹配。图 7.8展示了一个具有各种签名的重载方法:

图 7.8 – 方法签名对重载的影响

图 7.8 – 方法签名对重载的影响

在这个图中,someMethod方法被重载了多次。第 6 到第 10 行的方法签名分别是someMethod()someMethod(int)someMethod(double)someMethod(String)someMethod(double, int)

有趣的案例是第 11-13 行的编译器错误。第 11 行的错误是编译器的一个误导性错误。换句话说,如果我们注释掉第 12 和第 13 行,第 11 行的编译器错误就会消失。第 11 行没有问题,因为这是编译器第一次看到这个特定的方法签名——即someMethod(int, double)。问题是第 12 到第 13 行有相同的方法签名,编译器正在标记所有具有该签名的行。

第 12 行强调了参数名称并不重要,因为它们不是方法签名的一部分。因此,它们在 11 行被命名为xy,在 12 行被命名为ab,这根本无关紧要。

同样,第 13 行表明返回类型不是方法签名的一部分。第 13 行是一个编译器错误,因为它的签名someMethod(int, double)与第 11 行和第 12 行的签名相同,尽管这两个方法有不同的返回类型(分别是intvoid)。

总结来说,返回类型和参数名称都不是方法签名的一部分。现在我们已经了解了方法签名中包含什么(以及不包含什么),让我们看看一个简单的方法重载示例。图 7.9展示了代码:

图 7.9 – 方法重载示例

图 7.9 – 方法重载示例

在这个图中,我们有一个重载的add方法。第一个版本(第 10 到 13 行)接受两个int参数;第二个版本(第 14-17 行)接受两个double参数。它们各自的签名分别在第 10 行和第 14 行捕获。因此,当我们第 5 行调用add并传递两个整数时,编译器将调用与第 10 行的add版本匹配,因为该版本的add接受两个整数。同样,第 7 行的add调用与第 14 行的add匹配,因为调用和方法签名都匹配(两者都是两个double类型)。

现在我们已经了解了方法参数类型及其顺序如何影响方法重载,让我们来看看 Java 如何使我们能够执行参数数量可变的函数。

解释可变参数

考虑以下情况:你想调用一个方法m1,但参数的数量可能不同。你是否需要为每个版本的方法重载,每个版本的方法都多接受一个参数?例如,假设参数类型是String类型,你是否在每次新版本中多接受一个String参数时重载m1?在这种情况下,你将不得不编写m1(String)m1(String, String)m1(String, String, String)等等。这并不具有可扩展性。

这就是varargs发挥作用的地方。varargs是 Java 中一个非常灵活的语言特性,专门为此用例提供。语法是类型名称后跟省略号(三个点)。图 7.10显示了varargs的实际应用:

图 7.10 – 可变参数示例

图 7.10 – 可变参数示例

在这个图中,在第 10 行,m1(int… )m1方法定义了一个方法签名,定义了 0 个或多个int参数。这与第 4 行定义的mainString[]非常不同。实际上,你根本不需要向m1传递任何参数;或者你可以传递 1 个、2 个、3 个或更多的整数。这通过方法调用(第 5-8 行)显示出来。在m1方法内部,varargs被视为一个数组。第 12-14 行的for循环展示了这一点。

图 7.10的输出如下:

01
3
6

第 5 行没有任何输出。第 6 行生成1;第 7 行生成3;第 8 行生成6

让我们通过图 7.11来检查一些varargs的边缘情况:

图 7.11 – varargs 编译器错误

图 7.11 – varargs 编译器错误

在前面的图中,我们可以看到varargs必须是方法定义中的最后一个参数。第 10 行是正确的,因为它将varargs参数定义为最后一个参数。然而,第 11 行是编译器错误,因为它试图在varargs参数之后定义一个参数。这是有道理的,因为所有其他参数都是强制性的;所以,如果varargs可以定义 0 个或多个参数,它必须是最后一个参数。

由于varargs被当作数组处理,这引发了一个问题:我们能否用数组代替varargs?答案是:不可以。编译器错误(第 5-8 行)都与这样一个事实有关,即尽管在第 12 行有m1(int[])的定义,但编译器无法找到与这些方法调用匹配的方法定义。

方法中的最后一个重要主题是按值调用。我们现在将讨论这个主题。

掌握按值调用

Java 在向方法传递参数和从方法返回结果时使用按值调用。简而言之,这意味着 Java会复制某个东西。实际上,当你向方法传递一个参数时,会复制该参数;当你从方法返回一个结果时,会复制该结果。我们为什么要关心这个?好吧,根据你复制的内容——原始类型或引用类型——int是一个引用类型的例子。

在方法中,当参数是原始类型和引用类型时,改变的效果有明显的区别。我们将通过一个代码示例来演示这一点,但首先,为了欣赏这些差异,我们需要理解内存中发生了什么。

内存中的原始类型与引用类型

数组是一个对象,而原始类型不是。我们将在第八章中详细讨论对象,但现在,让我们检查图 7.12中的代码:

图 7.12 – 包含原始类型和数组的示例代码

图 7.12 – 包含原始类型和数组的示例代码

要理解前图中代码在内存中的样子,我们需要讨论栈、堆和引用。

栈是方法使用的一个特殊内存区域。每次调用一个新的方法 A 时,就会在栈上 push(创建)一个新的帧。该帧包含诸如 A 的局部变量及其值等内容。每个帧像盘子一样一个压一个地堆叠起来。如果 A 调用了另一个方法 B,则 A 的现有帧会被保存,并在栈上为 B 推入一个新的帧,从而创建一个新的上下文。当 B 执行完毕后,其栈帧会被从栈中 pop(移除),然后恢复 A 的帧(包括所有局部变量及其值,就像在调用 B 之前一样)。这就是为什么栈被称为 后进先出LIFO)结构。有关栈和 Java 内存管理的更多详细信息,请参阅我们之前的书籍:www.amazon.com/Java-Memory-Management-comprehensive-collection/dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+management+maaike&qid=1699112145&sprefix=java+memory+management+maaike%2Caps%2C148&sr=8-1

)

对于我们这里的讨论,我们需要意识到局部变量(原始数据类型和/或引用)存储在栈上。对象 存储在栈上;对象存储在堆上。

堆是为对象和数组预留的内存区域,而数组也是对象。这意味着数组存储在堆上。要访问一个对象,我们使用引用。

参考文献

用于访问对象的命名标识符称为引用。引用类似于指针。考虑一台没有按钮来更换频道但有一个遥控器的电视。引用是遥控器,而电视是对象。

在理解了这些定义之后,让我们回顾一下 图 7.12 中的代码。第 5 行声明了一个原始数据类型 int 叫做 x 并将其初始化为 19。第 6 行声明了一个 int 数组,即 arr,并将 arr[0] 初始化为 1arr[1] 初始化为 2。数组引用是 arr图 7.13 显示了当我们到达第 7 行时 图 7.12 的内存表示:

图 7.13 – 图 7.12 代码的内存表示

图 7.13 – 图 7.12 代码的内存表示

在前面的图中,我们可以看到有一个 main 方法的栈帧,其中包含局部变量 xarr。请注意,为了简化,省略了 main 中的 String[] args 参数。立即,你可以看到原始数据类型(即 x)和引用(即 arr)的存储方式之间的区别 – x 和其值存储在栈上;而 arr 的值则指向堆上的对象。

考虑到这一点,我们现在可以检查一个合适的代码示例,以展示在传递原始数据类型和引用时按值传递的实际影响。图 7.14代表了我们将要使用的代码示例:

图 7.14 – 按值传递原始数据类型和引用

图 7.14 – 按值传递原始数据类型和引用

在这个图中,callByValue方法定义在第 13-17 行:该方法按顺序接受一个int类型和一个int数组,并返回一个int。第 14 行将int参数的值改为-1,第 15 行将数组的索引 0 改为-1。最后,该方法在第 16 行返回x的值。

让我们检查对callByValue的第一次调用,传递xarr参数。重要的是要注意,在main中声明的xarr变量与在callByValue方法中声明的xarr参数是完全不同的变量。这是因为它们在两个不同的作用域(方法)中。由于 Java 使用按值传递,原始数据x和引用arr的副本被创建,并且传递到callByValue方法的是这些副本

复制一个原始数据类型就像复印一张空白纸——如果你把复印的纸给某人,他们可以在上面写字,你的原始空白纸仍然是空的。复制一个引用就像复制一个遥控器——如果你把第二个遥控器(复制的那个)给另一个人,他们可以改变电视的频道。关键在于,这里只有一个电视——复制的是遥控器,而不是电视。

注意

这样做可以节省内存,因为复制一个引用的内存占用远小于复制一个可能很大的对象。

图 7.15代表了在即将从callByValue的第一次调用return时的内存表示:

图 7.15 - 基于 7.14 行 16 的内存视图(基于第一次调用 callByValue 行 7)

图 7.15 - 基于 7.14 行 16 的内存视图(基于第一次调用 callByValue 行 7)

如前一个图中的堆栈所示,当我们从main中调用callByValue(x, arr)时,现有的main帧被保存,并在main帧的上面压入一个callByValue帧。然后执行callByValue的代码:

x = -1:arr[0] = -1;
return x;

首先,在callByValue帧中改变了x的值。这是从main中复制来的x的副本。注意,main中的x的值保持不变(仍然是 19)。因此,main中的x输出为 19(第 8 行)。因此,被调用的方法不能(直接)改变调用方法的基本值。我们很快会回到这个点上。

然而,callByValue 中的 arr[0] = 1; 行确实对 main 有实质性影响。当 callByValue 使用其 arr 引用,这是从 main 复制的 arr 引用时,它会改变两个方法共享的一个对象。实际上,main 正在查看的数组对象被改变了。这可以在 callByValue 方法返回后的 main 中看到:

System.out.println(arr[0] + ", " + arr[1]); // -1, 2

关键的是,arr[0] 的值输出为 -1。因此,请注意,在传递方法引用时,方法可以更改你正在查看的对象。

让我们回顾一下原始情况。如果我们想让被调用的方法改变传递下来的原始值,这是为什么 callByValue 会返回 x。第一次对 callByValue 的调用完全忽略了返回值:

callByValue(x, arr);

然而,第二次调用并不:

x = callByValue(x, arr);

callByValue 返回的 -1 用于覆盖 mainx 的值。因此,main 中的 x 输出为 -1(第 11 行)。

这就完成了我们对方法的讨论。现在,让我们将这些知识付诸实践,以巩固这些概念。

练习

维护恐龙公园需要的不仅仅是热情。它包括对我们恐龙的定期健康检查,确保我们的客人感到舒适,以及公园有足够的员工。所有这些任务都涉及系统化的过程。幸运的是,我们现在知道了方法!

您可以将这些方法添加到同一个类中:

  1. 恐龙所处的生命阶段可以显著影响其行为和需求。编写一个接受恐龙年龄并返回其是孵化幼体、幼崽还是成年恐龙的方法。

  2. 重要的是要记住,我们的恐龙实际上并不是宠物——它们是体型庞大、通常很重的生物,有着很大的胃口。编写一个接受恐龙体重并计算其每日所需食物量的方法。

  3. 了解我们恐龙的平均年龄有助于我们规划未来。设计一个接受恐龙年龄数组的数组并计算平均年龄的方法。

  4. 公园不是全天 24 小时对日间游客开放。我们需要一些时间来清理爆米花和修复围栏的任何轻微损坏。编写一个根据当前时间检查公园是否开放或关闭的方法。(提示:此方法不需要任何输入。)

  5. 个性化是让我们的客人感到特别的关键。创建一个使用恐龙的名字和游客的名字来制作个性化问候信息的方法。

  6. 正如你所意识到的,安全是我们的首要任务。我们需要一种方法来确定我们是否可以让另一组客人(一定数量的人)进入公园,这基于当前游客数量和允许的最大游客数量。

项目 - 中生代伊甸园助手

这将是我们的最大项目。所以,系好安全带!

让我们从高级描述开始。中生代伊甸园助手是一个交互式控制台应用程序,用于管理恐龙公园。助手应该具备以下功能:

  • 添加或删除恐龙

  • 检查公园的营业时间

  • 欢迎客人并提供公园信息

  • 跟踪游客数量以确保公园不会过于拥挤

  • 管理公园员工详细信息

由于我们不会让你淹死,如果你需要,这里有一个逐步指南。起始项目将遵循以下步骤:

  1. DinosaurGuestEmployee。包括适当的属性和方法。

  2. Scanner类。

  3. Scanner类。

  4. 创建菜单:创建一个菜单,允许用户与公园管理系统交互。

  5. 处理操作:每个菜单项应触发特定的操作,例如添加恐龙、检查公园营业时间或欢迎客人。

  6. 退出程序:提供一个选项让用户退出程序。

这里有一个代码片段来帮助你开始:

import java.util.Scanner;public class Main {
    // Use Scanner for reading input from the user
    Scanner scanner = new Scanner(System.in);
    public static void main(String[] args) {
        Main main = new Main();
        main.start();
    }
    public void start() {
        // This is the main loop of the application. It
          will keep running until the user decides to exit.
        while (true) {
            displayMenu();
            int choice = scanner.nextInt();
            handleMenuChoice(choice);
        }
    }
    public void displayMenu() {
        System.out.println("Welcome to Mesozoic Eden
          Assistant!");
        System.out.println("1\. Add Dinosaur");
        System.out.println("2\. Check Park Hours");
        System.out.println("3\. Greet Guest");
        System.out.println("4\. Check Visitors Count");
        System.out.println("5\. Manage Staff");
        System.out.println("6\. Exit");
        System.out.print("Enter your choice: ");
    }
    public void handleMenuChoice(int choice) {
        switch (choice) {
            case 1:
                // addDinosaur();
                break;
            case 2:
                // checkParkHours();
                break;
            case 3:
                // greetGuest();
                break;
            case 4:
                // checkVisitorsCount();
                break;
            case 5:
                // manageStaff();
                break;
            case 6:
                System.out.println("Exiting...");
                System.exit(0);
        }
    }
}

因此,这是一个很好的起点!但还没有完成。在前面的代码片段中,addDinosaur()checkParkHours()greetGuest()checkVisitorsCount()manageStaff()是方法占位符,你需要根据你的数据结构和功能实现这些方法。Scanner类用于从控制台读取用户的菜单选择。

你可以通过添加额外的功能和增强来使项目变得尽可能复杂。

摘要

在本章中,我们通过说明方法只是被赋予名称以便于引用的代码块来开始对方法的讨论。方法之所以重要,是因为它们使我们能够抽象出实现,同时帮助我们避免不必要的代码重复。

一个方法有两个部分:方法定义(或声明)和方法调用(或调用)。方法定义声明(包括其他内容)了方法名称、输入参数和返回类型。方法名称和参数类型(包括它们的顺序)构成了方法签名。方法调用将(如果有的话)传递参数作为方法的输入。如果方法有返回值,可以通过将方法调用赋值给变量来捕获返回值。

方法重载是在几个不同的方法中使用相同的方法名称。区分各种方法的是它们有不同的签名——参数类型和/或它们的顺序将不同。参数名称和返回类型并不重要。

在方法声明中使用省略号(三个点)指定了一个varargs(可变参数)参数。这意味着在调用此方法时,对应该参数的参数是可变的——你可以传递 0 个或更多参数。在方法内部,varargs参数被视为一个数组。

当向方法传递参数时,Java 使用按值传递。这意味着会创建参数的副本。根据你传递的是原始数据类型还是引用类型,对被调用方法对调用方法产生的影响有重大影响。如果是原始数据类型,被调用方法不能改变调用方法中的原始数据类型(除非调用方法故意用返回值覆盖变量)。如果是引用类型,被调用方法可以改变调用方法正在查看的对象。

现在我们已经完成了对方法的探讨,让我们继续到我们的第一个严格意义上的面向对象编程OOP)章节,我们将探讨类、对象和枚举。

第二部分:面向对象编程

在这部分,我们将深入探讨抽象类和极其重要的接口结构。随后,我们将研究 Java 的异常框架。最后,我们将探索 Java 核心 API 中的选定类,例如StringStringBuilder

本节包含以下章节:

  • 第八章, 类、对象和枚举

  • 第九章, 继承和多态

  • 第十章, 接口和抽象类

  • 第十一章, 处理异常

  • 第十二章, Java 核心 API

第八章:类、对象和枚举

第七章中,我们学习了 Java 中的方法。在理解了方法为什么有用之后,我们了解到方法有两个部分——方法定义和方法调用。我们看到,方法定义是在通过方法调用调用方法时执行的代码。我们讨论了方法签名如何实现方法重载。我们还学习了varargs如何帮助我们用零个或多个参数调用方法。最后,我们讨论了 Java 的按值调用机制,其中传递给方法的参数在内存中被复制。根据传递的参数类型,原始类型或引用类型,将影响调用方法对调用方法传递的参数所做的更改的效果。

第七章总结了本书的 Java 基础知识部分。该部分的内容在许多编程语言中都是通用的,包括非面向对象编程(OOP)语言,如 C。第八章开始介绍本书的 OOP 部分。

在本章中,我们将涵盖类、对象、记录和枚举。类和对象是面向对象编程语言(如 Java)独有的;换句话说,非 OOP 语言(如 C)不支持它们。尽管它们密切相关,理解类和对象之间的区别很重要。我们将讨论类与类对象之间的关系。要访问对象,我们必须使用引用。将引用与对象分离将在以后证明非常有用。将实例成员与类成员进行讨论,以及何时使用任一或两者。本章还将解释'this'引用及其与当前执行实例方法的对象之间的关系。

我们还将解释 Java 中的访问修饰符。这些访问修饰符是实现面向对象编程(OOP)的关键基石之一,即封装。尽管基本的封装可以轻易实现,但正确封装你的类需要额外的注意。这将在高级封装部分进行说明。

理解对象生命周期,考虑到程序执行时内存中的情况,对于避免许多细微的错误至关重要。这个主题将通过图表进行解释。

在本章的末尾,鉴于我们对引用(以及它们所引用的对象的分离)的理解,我们将讨论instanceof关键字。最后,我们将介绍类的一种变体,即枚举,其中对象实例的数量受到限制。

本章涵盖了以下主要主题:

  • 理解类和对象之间的区别

  • 对比实例与类成员

  • 探索'this'引用

  • 应用访问修饰符

  • 实现封装

  • 掌握高级封装

  • 深入探讨对象生命周期

  • 解释instanceof关键字

  • 理解枚举

  • 欣赏记录

技术要求

本章的代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch8

理解类和对象之间的区别

由于类和对象对于面向对象编程至关重要,理解它们之间的区别是至关重要的。在本节中,我们将讨论类与其对象之间的关系。由于创建对象需要使用new关键字,这也会被涵盖。我们还将探讨构造函数及其作用。所有这些主题都是相互关联的:对象是类的内存表示(模板);要创建对象,使用构造函数,要调用构造函数,我们使用new关键字。让我们逐一检查这些内容。

在 Java 中,类是如此重要,以至于你无法编写任何不定义类的程序!类是你对象的蓝图或模板。它类似于房屋的设计图——使用房屋设计图,你可以讨论房屋的所有内容;然而,你不能走进厨房泡上一杯茶/咖啡。在这一点上,房屋设计图是抽象的,类也是如此。类定义了字段(属性)和操作这些字段的方法。字段是你的数据,方法使你可以操作这些数据。

对象

对象是你对类的内存表示。如果类是你的房屋设计图,那么对象就是你的建成房屋。现在,你可以走进厨房,泡上一杯茶/咖啡。就像房屋和房屋设计图一样,你可以基于类创建许多对象。这些对象被称为对象实例,强调每个对象都是其独特的实例。

总结来说,类是模板,对象是类的内存表示。如果你想执行其(实例)方法,你需要一个对象(实例)。那么,我们如何创建对象呢?我们使用new关键字。

熟悉new关键字

Java 中的new关键字使我们能够创建对象。对象在堆上创建,堆是内存中为对象保留的特殊区域。返回对象的引用(类似于指针)。这个引用使我们能够操作对象;例如,执行实例方法。让我们来检查图 8**.1中显示的代码示例:

图 8.1 – 创建对象

图 8.1 – 创建对象

在前面的图中,第 3 行定义了一个Person类。目前它没有任何内容;随着我们的进展,我们将对其进行扩展。第 6 行很重要——我们正在使用new关键字创建一个Person对象。除了new关键字外,第 6 行与任何方法调用非常相似。p引用(在栈上)被初始化为指向堆上的Person类型对象。名为pPerson引用正指向一个Person对象;然而,在前进的过程中,这种情况很少见。当使用new关键字“构造”对象时,被调用的方法是一个特殊的方法,称为构造函数

构造函数

构造函数是一个特殊的方法,它由new关键字调用。它有两个独特的属性,使其与其他方法区分开来:它具有与类相同的名称,并且不定义任何返回类型,甚至不是void。(Java 在后台返回对象的引用)。

每个类都包含一个构造函数,即使你没有自己编写。如果你没有为你的类编写构造函数,Java 会为你自动生成(或定义)一个“默认构造函数”。默认构造函数将具有与常规构造函数相同的属性;即,与类相同的名称且没有返回类型。然而,默认构造函数不会定义任何参数;它将具有与类相同的访问修饰符,并且只包含一行代码,即super();。我们将在本章后面讨论访问修饰符,并在第九章中讨论super()

注意,如果你插入了一个构造函数,默认构造函数就不会被自动生成。这就像编译器说:“好吧,你已经有了构造函数(们),你知道你在做什么,所以我不介入。”

既然我们知道默认构造函数何时由编译器生成,我们就可以看到在图 8**.1PersonPersonExample都需要默认构造函数。图 8**.2表示编译器插入默认构造函数后的代码:

图 8.2 – 插入的默认构造函数

图 8.2 – 插入的默认构造函数

前面图中的红色矩形表示编译器插入的默认构造函数。这发生在两个类上,因为两个类都没有定义任何构造函数,而每个类都需要一个构造函数。除了具有与类相同的名称且不返回任何内容(甚至不是void)之外,默认构造函数不定义任何参数(第 4 行和第 9 行),并且简单地调用super();。正如前一个提示中所述,super()将在我们讨论第九章中的继承时进行讨论。

我们将在后面详细讨论访问修饰符,但请注意,默认构造函数的访问权限与它们各自类的访问权限相匹配。例如,PersonExample 是一个 public 类,因此它的构造函数也是 public(分别在第 8 和第 9 行)。Person 类没有提及任何 显式 访问修饰符,其构造函数也是如此(分别在第 3 和第 4 行)。

现在,你可以看到为什么第 13 行的 new Person(); 不会生成编译器错误。为了清楚起见,第 13 行没有编译器错误,因为编译器插入了 Person 类的默认构造函数(第 4 至 6 行),因此 new Person() 能够找到构造函数并因此编译。

PersonExample 的默认构造函数(第 9 至 11 行)在这个程序中没有实质性的影响。JVM 在每个程序的 main 方法中启动程序。

我们现在将转向讨论实例成员与类成员的区别。请注意,局部变量(在方法中)也不是。

对比实例成员与类成员

一个对象更准确地可以称为对象 实例。这就是 实例 成员(方法/数据)获得名称的地方:每个对象都会获得一个实例成员的副本。然而,类成员是不同的,因为每个类只有一个副本,无论创建了多少对象实例。我们现在将讨论这两个主题。

实例成员(方法/数据)

这一点通过先展示一个代码示例更容易解释。图 8.3 展示了一个具有实例成员的类:

图 8.3 – 具有实例成员的类

图 8.3 – 具有实例成员的类

当你使用 new 创建一个对象时,你正在创建一个对象 实例。每个实例都获得实例成员(变量和方法)的副本。关于实例变量,我们需要定义实例变量在哪里声明以及它们的范围。实例变量是在类内部定义的,但不在类中编写的每个方法之外。因此,实例变量的范围是类本身;这意味着,类中的每个实例方法都可以访问实例变量。

现在我们来讨论代码示例。在前面的图中,Person 类定义了实例变量和实例方法。由于实例变量是在每个方法外部声明的,因此它们的范围是类级别的。实例变量被标记为 private 以及实例方法被标记为 public 的原因将在本章后面解释。构造函数如下:

Person(String aName) { // constructor    name = aName;
    count++;
}

这个构造函数使我们能够传入一个 String 并根据该 String 初始化实例变量。例如,当我们按照以下方式实例化一个对象时:

Person p1 = new Person("Maaike");

我们将"Maaike"传递给构造函数,因此p1引用的对象中的name实例变量引用了"Maaike"。构造函数还通过每次调用构造函数时增加count来记录创建的对象数量。请注意,在这个例子中,编译器没有插入默认的Person构造函数,因为类中已经编写了构造函数。

我们还使用p1p2引用调用了getName()实例方法,如下所示:

System.out.println(p1.getName()); // MaaikeSystem.out.println(p2.getName()); // Sean

这种refName.instanceMethod()的语法称为点表示法。根据代码中的注释,"Maaike""Sean"将按顺序输出到屏幕上。(图 8**.4)显示了在创建了两个对象,分别由p1p2引用后,代码在内存中的表示:

图 8.4 – 图 8.3 的内存表示(行 27 开始)

图 8.4 – 图 8.3 的内存表示(行 27 开始)

如前图所示,我们有堆栈上的两个引用,即p1p2p1引用堆上的第一个Person对象——即第 23 行创建的对象。p1的实例变量值(其“状态”)是name"Maaike"count1。由于字符串是对象,name是一个引用另一个对象的引用,即一个值为"Maaike"String对象。同样,p2引用指向第 24 行创建的对象。从图中可以看出,p2的实例变量值是name"Sean"count1

注意,堆上的每个Person对象实例都有一个实例变量的副本。这就是为什么它们被称为实例变量。

第 27 和 28 行将name实例变量的值分别更改为"Maaike van Putten""Sean Kennedy",对应于p1p2。(图 8**.5)显示了这些更改:

图 8.5 – 图 8.3 的内存表示(行 29 开始)

图 8.5 – 图 8.3 的内存表示(行 29 开始)

此图显示,两个String对象已被更改:p1的实例变量name引用了"Maaike van Putten",而p2的实例变量name引用了"Sean Kennedy"。因此,第 29 到 30 行分别输出了"Maaike van Putten""Sean Kennedy"

字符串不可变性

字符串是不可变对象。这意味着一旦创建了String对象,就不能更改。永远不能。虽然看起来它们已经改变了,因为产生了变化的效果,但实际上已经创建了一个全新的对象,而原始对象保持未变。我们将在第十二章中更详细地回顾String不可变性。

因此,原始的String对象,"Sean""Maaike",仍然在堆上占用空间。它们没有用处,因为我们没有对这些对象的引用,所以我们无法访问它们。记住,p1p2name实例变量分别引用了新创建的包含"Maaike van Putten""Sean Kennedy"String对象。

那么,这些不再使用的对象会怎样呢?它们会被“垃圾回收”。我们很快就会讨论这个问题,但首先,只需知道 JVM 在后台运行一个名为垃圾回收器的进程来整理(回收)所有无法访问的对象。我们无法控制这个进程何时运行,但有一个垃圾回收器可以让我们免于自己清理(而与其他 OOP 语言如 C++不同,你必须这样做!)。

图 8.3 中的代码存在问题 – count 的值为 1,而它应该是 2。实例变量默认初始化为0。在每次构造函数调用中,我们将count0增加到1。我们希望第一次构造函数调用将count0增加到1,而第二次构造函数调用将count1增加到2。这就是类成员发挥作用的地方。

类成员(方法/数据)

要将字段和/或方法标记为类成员,而不是实例成员,可以在成员的声明中插入static关键字。类成员由类的所有实例共享。这意味着你不需要创建对象实例来访问类的static成员。

访问static成员的语法与访问实例成员的语法不同。而不是使用引用,使用类名,如className.staticMember。这强调了被访问成员的类性质。例如,JVM 使用PersonExample.main()图 8.3中启动程序。这就是 JVM 以节省构建对象及其内存占用为代价启动每个程序的方式。

让我们回到我们的count问题(它的值应该是 2 而不是 1)。图 8.6表示必须进行的更改以解决这个问题:

图 8.6 – 使“count”静态化

图 8.6 – 使“count”静态化

图 8.6中的代码与图 8.3中的代码进行对比,我们可以看到count被声明为static(第 5 行)。因此,只有一个count的副本,它在所有Person实例之间共享。因此,p1p2正在查看相同的count

在构造函数(第 9 行)中,虽然不是必需的,但我们使用正确的语法来强调 countstatic 特性。同样,由于 getCount(第 17 行)只是返回一个 static 成员,我们将其标记为 static。此外,我们使用了 Person.count 静态语法(第 18 行)。最后,第 25 行使用正确的语法 Person.getCount 访问了 private 类变量 count。我们可以看到它输出了 2,这是正确的。比较代码中的其他差异,main图 8**.3)中的一些额外代码已被删除,以帮助我们关注这里讨论的内容。

实例到静态,但反之则不行

如果您在一个实例方法中,您可以访问一个 static 成员,但反之则不行。当我们解释 this 引用时,我们将讨论为什么。这意味着,在 图 8**.6 中,您可以使用 p1 引用来访问 getCount 方法(第 25 行)。因此,p1.getCount() 是有效的,但这是一种 较差 的编程实践,因为它给人一种 getCount 是实例方法的印象,而实际上它是一个 static 方法 - 按照代码使用 Person.getCount()

图 8**.7 显示了 图 8**.6 代码的内存表示:

图 8.7 – 图 8.6 代码的内存表示

图 8.7 – 图 8.6 代码的内存表示

如前一个图例的右下角所示,Person 类的 static/class 成员与实例本身存储在分开的地方。现在只有一个 count 的副本,它被 p1p2 共享。因此,count 的值为 2 是正确的。

类和实例变量的默认值

每次创建一个新类时,实例变量都会初始化为默认值。

类变量在类首次加载时初始化为默认值。这可能在使用 new 或引用类成员(使用类语法)时发生。

类和实例变量的默认值如下:

类型 默认值
byte, short,int 0
long 0L
float 0.0f
double 0.0d
char \u0000’ (Unicode 零)
String (或任何对象的引用) null
boolean false

表 8.1 – 类和实例变量的默认值

在之前的提示中,我们强调了您可以从实例方法中访问类成员,但反之则不行。现在让我们深入探讨这一点。

探索“this”引用

当您调用一个实例方法时,编译器会秘密地将调用该方法的对象引用的副本传递到方法中。这个引用作为 this 引用对实例方法可用。

类方法不会获得 this 引用。这就是为什么如果你在一个 static 方法(上下文)中尝试直接访问实例成员(没有对象引用),你会得到编译器错误。实际上,每个实例成员在访问时都需要一个对象引用。这很有意义,因为实例成员是实例特定的,因此你需要一个实例(引用)来说明,“我想访问这个特定的实例/对象,而不是那个特定的对象。”

让我们重构 图 8.3 中的代码,以便 Person 类显式地使用 this 引用。此外,所有对不正确工作的 count 实例变量的引用都已删除,以便我们可以专注于 this 引用。图 8.8 包含重构后的 Person 类(PersonExample 类保持不变):

图 8.8 – 使用“this”引用

图 8.8 – 使用“this”引用

在前面的图中,第 7、11 和 15 行被注释掉,分别替换为第 8、12 和 16 行。让我们更仔细地对比一下被注释掉的 7 行和新的 8 行:

// name = aName; // line 7this.name = aName; // line 8

首先,假设第 7 行被取消注释。第 7 行是如何协调其变量的?最初,编译器检查当前作用域(代码的构造块),并将 aName 协调为构造函数的参数。然而,编译器还没有协调 name,因此它检查下一个外部作用域,即类作用域,其中定义了实例/类变量。在这里,它找到一个名为 name 的实例变量,因此第 7 行可以编译。

第 8 行的操作有些不同。是的,它以类似的方式协调 aName,但现在,它遇到了 this.name(而不是 name)。看到 this 后,编译器立即检查已声明的实例变量。它找到一个名为 name 的实例变量,因此第 8 行可以编译。第 7 行和第 8 行实际上是相同的。

第 16 行与第 8 行相同,因为我们使用了相同的参数标识符,aName。第 12 行只是简单地返回 name 实例变量。

因此,这涵盖了如何在类中使用 this,但我们是怎样将实例与 this 关联起来的呢?

将实例与“this”引用关联

幸运的是,编译器会自动完成这项工作。正如之前所述,this 引用仅(秘密地)传递给实例方法,并且它指向当时调用该方法的实例。例如,当执行 p1.getName() 时,getName 中的 this 引用指向 p1,而当执行 p2.getName() 时,getName 中的 this 引用指向 p2。因此,this 引用会根据调用方法的实例而变化。图 8.9 表示了 this 引用动态特性的实际应用:

图 8.9 – “this”引用的动态特性

图 8.9 – “this”引用的动态特性

此图表示我们在执行第 24 行方法调用时,从 图 8**.8 中的代码执行第 12 行时的内存表示。当第 24 行的 getNamep2 上被调用——换句话说,p2.getName();——getName 中的 this 引用指向与 p2 指向相同的对象。这由从 this 引用指向与 p2 指向相同对象的实线表示。

虚线表示第 12 行方法调用中 this 引用所指向的内容,即 p1。因此,this 引用是动态地指向由 p1p2 引用的实例。

正如我们在 图 8**.8 中的代码所看到的,this 引用是不必要的。让我们考察一个需要 this 引用的情况。

阴影或隐藏实例变量

当一个变量与实例变量具有相同的标识符时,就会发生实例变量的阴影。图 8**.10 展示了这种情况的代码,以便我们可以观察它造成的问题:

图 8.10 – 实例变量的阴影

图 8.10 – 实例变量的阴影

在前面的图中,构造函数存在逻辑问题;换句话说,代码可以编译,但代码没有按预期工作。第 7 行是问题所在。记住,如果一个变量没有用 this 限定,则检查当前作用域以查看是否在该作用域中声明了该变量。在第 6 行,我们声明了一个使用 name 标识符的构造函数参数,它与第 4 行的实例变量具有相同的标识符。因此,第 7 行实际上是将局部变量赋值给自己,而实例变量保持未更改。由于实例变量是 String 类型,其默认值是 null。因此,第 19 行输出的是 null 而不是 "Maaike"

为了解决这个问题,我们有两种选择。第一种选择是为构造函数参数使用不同的标识符,并使用这个新标识符。这就是 setName 所做的(第 12-13 行):使用一个不与 name 实例标识符阴影的方法参数 aName。第二种选择是使用 this 引用来指定正在初始化的变量是实例变量。图 8**.11 展示了这一点:

图 8.11 – 使用 “this” 解决阴影问题

图 8.11 – 使用 “this” 解决阴影问题

在此图中,第 7 行很重要:this.name 指向 name 实例变量,而 name 单独使用时,则指向方法参数。因此,阴影已被消除,第 19 行现在输出预期的 "Maaike"

我们知道只有非静态(实例)方法接收 this 引用。让我们考察这个问题如何影响我们以及如何解决它。图 8**.12 展示了在 static 上下文(方法)中,我们尝试直接访问实例变量的代码:

图 8.12 – 从“静态”上下文访问实例变量

图 8.12 – 从“静态”上下文访问实例变量

在前面的图中,我们有一个名为x的实例变量(第 4 行),一个名为m的实例方法(第 5 行),以及一个名为mainstatic方法(第 6-19 行)。正如我们所知,static方法如main不会自动获得this引用(因为它们是类方法而不是实例方法)。

第 9、10、11 和 12 行存在编译错误。当你直接访问实例成员时,如第 9 和 11 行,编译器会在成员前插入this。换句话说,当编译器完成第 9 和 11 行时,从内部来看,它们看起来与第 10 和 12 行相同。因此,由于main没有this引用,编译器会对第 9、10、11 和 12 行提出抱怨。

第 15-18 行封装了解决这个问题的方法。当你处于static上下文并且想要访问实例成员(变量或方法)时,你需要创建一个对象实例来引用实例成员。因此,在第 15 行,我们创建了一个包含实例成员的类(对象)的实例,即PersonExample,并将引用存储在一个标识符pe中。现在我们有了实例,我们可以访问实例成员,我们在第 16、17 和 18 行这样做。第 16 行成功将x从(其默认值)0 更改为 999。这是第 18 行输出的内容。第 17 行显示访问m也没有问题。请注意,在代码编译和运行之前,你必须注释掉第 9-12 行。

在这些示例中,我们使用了privatepublic访问修饰符。让我们更详细地讨论这些内容。

应用访问修饰符

面向对象编程(OOP)的一个基石是封装(数据抽象)原则。封装可以通过访问修饰符来实现。在我们讨论封装之前,我们必须理解访问修饰符本身。

访问修饰符决定了类、字段或方法的可见性和可用性。你正在注解的级别决定了可用的访问修饰符:

  • public或包私有(无关键字)

  • private、包私有、protectedpublic

让我们依次讨论这些内容。

private

被标记为private的成员只能在它自己的类中访问。换句话说,类的块作用域定义了边界。当在一个类(作用域)中时,你不能访问另一个类的private成员,即使你有指向包含private成员的类的对象引用。

包私有

对于package-private没有特殊的关键字。如果一个类型(类、接口、记录或枚举)没有访问修饰符,则应用package-privatepackage-private的类型仅在同一包内可见。回想一下,包只是一个相关类型的命名组。

*图 8**.11 中的Person类(第 3 行)是一个包私有类,这意味着Person不能被导入到另一个包中。此外,Person构造函数(第 6 行)是包私有的,这意味着你无法从不同的包中创建Person类型的对象。

在成员级别,有一些例外情况需要你在省略访问修饰符时注意:

  • 类/记录成员默认是包私有

  • 接口成员默认是public

  • 枚举常量(成员)默认是public staticfinal。枚举构造函数默认是private。我们将在本章后面讨论枚举。

默认包

默认包也被称为无名称包或未命名的包。在文件顶部没有显式包声明的类型会被放入这个包中。这就是放置了*图 8**.11 中的PersonPersonExample类的包。

这个问题的含义是,由于包没有名称,如果我们处于不同的(命名)包中,我们就无法导入PersonPersonExamplePersonExamplepublic(第 16 行)的事实并没有区别。因此,只有同一(默认)包中的其他类型可以访问它们。

protected

被标记为protected的成员意味着它在自己的包内可见(与包私有类似),但也可以被包外部的子类访问。当我们讨论继承时,我们将在第九章中更详细地讨论子类和protected

public

被标记为public的类型或成员在所有地方都是可见的。因此,没有边界限制。

表 8.2总结了访问修饰符及其可见性:

表 8.2 – 访问修饰符及其可见性

表 8.2 – 访问修饰符及其可见性

让我们水平地检查表 8.2。只有类可以访问标记为private的成员。如果一个类或成员没有访问修饰符(包私有),那么这个类或成员只能在类和包内部可见。如果成员被标记为protected,那么这个成员对类、包以及该类的子类都是可见的,无论包如何。最后,如果一个类或成员被标记为public,那么这个类或成员在所有地方都是可见的。

为了进一步解释表 8.2,让我们绘制一个示例类集及其相关包的图,并为其制作另一个专门的可见性表。*图 8**.13 展示了这一点:

图 8.13 – 示例访问修饰符图

图 8.13 – 示例访问修饰符图

在这个图中,Dog 类被加粗并下划线,因为下面的表格,表 8.3,代表了其成员的可见性。例如,当阅读 private 行时,假设我们在 Dog 中标记了一个成员为 private,并正在确定它在其他类中的可见性。让我们来查看 表 8.3

表 8.3 – 当应用于 Dog 成员时修饰符的可见性

表 8.3 – 当应用于 Dog 成员时修饰符的可见性

因此,如果一个 Dog 成员是 private,只有 Dog 可以看到它。如果 Dog 成员是包私有,只有 DogCat 可以看到它。如果 Dog 成员是 protectedDogCat 可以看到它。最后,如果一个 Dog 成员是 public,每个类都可以看到它。

(*) 我们将在继承章节回顾 protected 时完成这个表格。

访问级别如何影响你?

访问级别将以两种方式影响你。首先,你可能会使用一个外部类(例如 Java API 中的类)并想知道你是否可以在你的代码中使用该类及其成员。其次,当编写一个类时,你将想要决定每个类和成员的访问级别。一个很好的经验法则是尽可能地将成员保持为 private 以避免误用。此外,除非它们是常量,否则请避免使用 public 字段。我们将在讨论封装时进一步讨论这一点。

让我们来看看这些访问修饰符在代码中的应用。特别是,我们将关注包,并学习如何创建一个包以及边界如何影响访问。

回想一下,完全限定类型名称包括包名。包定义了一个命名空间。例如,在 图 8**.13 中,ch8.animals 包中的 Dog 类完全限定为 ch8.animals.Dog。因此,一个名为 kennel 的包中的 Dog 类将有完全限定的名称 kennel.Dog;这与 ch8.animals.Dog 完全不同。因此,Java 可以区分这两种 Dog 类型,并且不会发生名称冲突。正如我们将看到的,包结构也被用作你的 java 文件的目录结构。Oracle 提供了非常好的指南(见 docs.oracle.com/javase/tutorial/java/package/namingpkgs.html),说明如何命名你的包,以便你的类型不会与其他人的冲突。包名全部使用小写字母书写,以区分类型名称。随后,公司应使用反向互联网域名来开始它们的包名。例如,如果你在名为 somecompany.com 的公司工作,并且你正在创建一个名为 somepackage 的包,那么完整的包名应该是 com.somecompany.somepackage。在公司内部,命名可以遵循公司惯例,例如包括地区:com.somecompany.region.somepackage

让我们检查 图 8**.13 中的包。我们将从 ch8.animals 开始:

图 8.14 – 图 8.13 中的“ch8.animals”包

图 8.14 – 图 8.13 中的“ch8.animals”包

在这个图中,为了简单起见,我们将包中的两个类组合到一个 Java 文件中。这个文件叫做Dog.java(因为public类是Dog)。第一行很重要:package ch8.animals表示在这里定义的类型(类等)将放入这个包中。此外,Dog.java 文件将被放入硬盘上名为ch8\animals的文件夹中。

在这个图中,第 4 行定义了一个名为dogNameprivate实例变量。这个变量只能在类内部访问(如第 6 行和第 9 行所示),但不能在类外部访问(如第 18 行所示)。

第 5 行定义了一个名为ageprotected实例变量,我们可以在包内的另一个类中访问它(第 19 行)。第 12 行定义了一个名为pkgPrivate()的包私有方法,第 20 行显示我们可以从同一包内的另一个类中访问它。注意,Cat类及其构造函数都是包私有(分别在第 14 行和第 15 行)。

图 8**.15显示了另一个包,ch8.farm

图 8.15 – 图 8.13 中的“ch8.farm”包

图 8.15 – 图 8.13 中的“ch8.farm”包

再次注意,第 1 行声明了包名——这是ch8.farm包。文件名为Pig.java(因为public类是Pig),文件将被放入硬盘上名为ch\farm的文件夹中。

注意导入时使用完全限定名(第 3 行和第 4 行)。因为我们想访问位于单独包中的Dog类,我们必须导入它。导入Dog没有问题,因为它是public的。然而,我们无法导入Cat,因为Cat是包私有(而且我们处于不同的包中)。

第 8 行演示了Pig可以创建一个Dog对象。注意这里有两个访问点:Dog类是public的(因此我们可以import它);Dog构造函数也是public的(因此我们可以从不同包中的代码创建Dog的实例)。这就是为什么类和构造函数的访问修饰符应该匹配。第 9 行显示,当我们处于不同的包中时,我们没有权限访问来自另一个包的包私有成员。

现在我们已经了解了访问修饰符,我们可以讨论封装。

封装

如前所述,封装是面向对象编程中的一个关键概念。这里的原理是保护你类中的数据,并确保数据只能通过你的代码进行操作(检索和/或更改)。换句话说,你控制外部类如何与你的内部状态(数据)交互。那么,我们如何做到这一点呢?

实现封装

基本封装很容易实现。你只需将你的数据标记为 private,并通过 public 方法来操作数据。因此,外部类不能直接访问数据(因为它被标记为 private);这些外部类必须通过你的 public 方法来检索或更改数据。

这些 public 方法构成了类的“接口”;换句话说,你如何与类交互。这个“接口”(一组 public 方法)与语言构造 interface(第十章)非常不同,不应混淆。图 8.16 展示了一个代码示例,以帮助我们进一步探讨这个主题:

图 8.16 – 基本封装的实际应用

图 8.16 – 基本封装的实际应用

在前面的图中,Adult 类有两个 private 实例变量,分别是 nameage(分别在第 4 行和第 5 行)。因此,这些实例变量只能在 Adult 代码块内部访问。请注意,即使有一个 Adult 对象引用也无法绕过这个访问规则——第 29 行(注释掉的)上的编译错误证明了这一点。

公共类名与文件名的关系

在 Java 中,public 类的名称必须与文件名匹配。在 图 8.16 中,public 类是 BasicEncapsulation。这意味着文件名必须是 BasicEncapsulation.java,它确实是这样的。这个规则意味着你不能在同一个文件中有两个 public 类——这就是为什么 Adult 类不是 public 的。

如果我们处于不同的包中,并且想要创建一个如图 8.16 所示的 Adult 对象,会发生什么?这是一个问题,因为 Adult 是包私有的(第 3 行)。为了解决这个问题,我们需要将 Adult 类设为 public,这样我们就可以在不同的包中 import 它。这意味着我们需要将 Adult 类移动到一个单独的文件中,命名为 Adult.java。此外,Adult 及其构造函数都需要是 public 的。为什么?因为我们处于不同的包中时,类是 public 的使我们能够 import 类,构造函数是 public 的使我们能够创建 Adult 类型的对象。

Adult 构造函数(第 7 行)没有访问修饰符,因此是包私有的。因此,只有同一包内的类可以调用这个构造函数。换句话说,只有 ch8 包(第 1 行)中的类可以创建 Adult 对象。由于 BasicEncapsulation 也在 ch8 包中,所以第 26 行的对象创建是正确的。

Adult 类的其余部分(第 11-20 行)提供了用于操作对象状态(实例变量)的 getter/setter 方法对。这些 getter/setter 方法也分别被称为访问器/修改器方法。通常每个实例变量都有一个对应的方法对,它们遵循以下格式(注意这只是一个示例):

public int getAge(){    return age;
}
public void setAge(int age){    
    this.age = age;
}

图 8.16的第 26 行创建Adult对象后,我们使用public访问器方法getNamegetAge(第 27-28 行)输出对象状态。由于这些访问器方法是public的,这些方法对任何包中的任何类都是可用的。鉴于输出了'John'20,我们知道我们的对象被正确创建。

假设我们是Adult类的开发者,并要求成年人的年龄必须是 18 岁或以上。此外,我们还将假设我们不知道BasicEncapsulation类的开发者。第 29 行展示了,由于我们的Adult数据是private的,它被保护免受直接外部破坏。这正是封装提供的东西;这正是它的存在理由!

第 30 行展示了对象的状态仍然可能被破坏。然而,第 30 行通过 set/mutator 方法进行的破坏与第 29 行的直接破坏非常不同。作为Adult类的作者,我们可以控制和因此修复set(mutator)方法中的破坏错误。我们set(mutator)方法的问题在构造函数中也被复制。图 8.17在构造函数和 mutator 方法中解决了这个(内部)破坏问题:

图 8.17 – 确保年龄至少为 18 岁

图 8.17 – 确保年龄至少为 18 岁

由于BasicEncapsulation保持不变,它不包括在先前的图中。请注意,已经引入了一个新的isAgeOk方法(第 27-29 行)。该方法接受一个int参数age并检查它是否>= 18。如果是这样,该方法返回true;否则,它返回false

isAgeOk方法是从setAgemutator 方法(第 21 行)调用的。由于构造函数调用setAge(第 8 行),它也利用了年龄检查逻辑。如果将无效的年龄传递给构造函数或setAge,则设置错误值为-1。请注意,有更好的方法来做这件事,但现在这样是可以的。当我们现在运行程序时,由于传递给setAgeage值是-99(john.setAge(-99)),实例变量age被设置为错误值-1。

这涵盖了基本的封装。我们现在将讨论基本封装的一个特定问题以及如何通过高级封装来解决它。

掌握高级封装

“私有数据,公共方法”的简单原则(其中public方法操作数据)在确保数据适当封装方面走得很远。然而,你仍然不完全安全。在本节中,我们将回顾 Java 的传值原则,该原则用于向方法传递参数和从方法返回值。我们将检查这可能会带来一个微妙的问题。最后,我们将检查如何保护你的代码,防止遇到这个问题。

传值调用再探讨

第七章,我们讨论了当向方法传递参数时,Java 的按值调用机制会创建那些参数的副本。我们看到了意识到当参数是一个引用时,例如数组,被调用方法现在可以操作调用方法正在查看的数组对象的必要性。

类似地,当一个方法正在返回某些内容时,按值调用再次适用。换句话说,你返回的内容会创建一个副本。根据副本的内容,这可能会导致封装被破坏或不被破坏。如果你返回的是private原始数据,那么就没有问题——原始数据会返回一个副本,客户端可以对副本做任何它想做的事情;你的private原始数据是安全的。你可能还记得,从第七章,复制原始数据就像复印一张纸。复印的纸张可以被写上内容,而不会影响原始副本。

问题

如果你的private数据是一个引用(到一个对象),问题就会出现。如果客户端收到这个引用的副本,那么客户端就可以操作你的private对象!从第七章,你可能还记得,复制一个引用就像复制电视的遥控器一样。新的遥控器可以改变同一台电视的频道。图 8**.18展示了破坏封装的代码:

图 8.18 – 破坏封装的代码

图 8.18 – 破坏封装的代码

在前面的图中,Seniors类有两个private实例变量(第 6-7 行),即agesnum。构造函数(第 9-13 行)初始化实例变量。我们有一个公共的getNum访问器方法,它返回private实例变量num(第 14 行)。请注意,我们为了节省空间,把这个方法放在了一行。

我们还有一个名为getAges的访问器方法(第 15-17 行),它返回一个名为agesprivate数组。第 16 行是问题所在,因为它破坏了封装。当我们讨论main中的代码时,我们将解释原因。

main中,我们首先做的事情是创建一个Seniors的实例(第 21 行)。这样我们就可以访问Seniors中定义的实例方法。main的其余部分分为两部分:一部分(第 23-27 行)演示了返回private原始数据是可行的;另一部分(第 30-37 行)演示了仅仅返回private引用会破坏封装。

让我们检查第一部分。第 23 行根据seniors.getNum()的返回值初始化局部变量num。由于privateSeniors实例变量numSeniors构造函数(第 10 行)中被初始化为2,因此(完全独立的)局部变量num也被初始化为2。我们在第 24 行输出了这一事实。然后我们将局部num变量的值更改为-100(第 25 行)。现在的问题是,当我们更改局部变量num时,privateSeniors实例变量num是否也被更改?为了找出答案,我们可以简单地使用公共访问器方法getNum(第 26 行)再次检索num。第 27 行输出2,证明private原始数据nummain中做出的更改是安全的。

第二部分是事情变得有趣的地方。第 30 行根据public访问器方法seniors.getAges()的返回值初始化局部变量copyAges。由于getAges只是返回(一个副本的)privateages引用,我们现在有两个引用指向同一个数组对象。这些引用是private实例变量ages和局部变量copyAges。第 31 行输出了copyAges的值,对于索引01分别是3040。这些是privateages数组在Seniors构造函数(第 11-12 行)中初始化时的相同值。

现在,在第 34-35 行,我们更改了copyAges数组中的值:索引0被设置为-9,索引1被设置为-19。与第一部分一样,我们现在想知道,更改局部数组是否对Seniors中的private实例数组有任何影响?答案是肯定的!为了证明这一点,我们可以再次使用getAges(第 36 行)检索private数组并输出其值(第 37 行)。输出值-9-19表明客户端AdvancedEncapsulation能够操作(更改)所谓的Seniorsprivate数据。因此,Seniors最终并没有实现封装。

图 8**.19展示了内存中的情况,揭示了为什么Seniors没有实现封装:

图 8.19 – 图 8.18(第 37 行)的内存表示

图 8.19 – 图 8.18(第 37 行)的内存表示

在前面的图中,局部变量num位于栈上。它是privateSeniors实例变量num的一个副本,并且随着我们通过main的进展,其不同的值以删除线字体反映出来。第 25 行(图 8**.18)将局部变量更改为-100。如所见,这种更改不会影响Seniors中的private实例变量num,它仍然为2

问题出在 private 数组对象 ages 的引用上。因为 getAges(第 15 行)只是返回引用,所以这个引用的副本被存储在局部变量 copyAges(第 30 行)。由于局部引用 copyAgesprivate 引用 ages 现在指向同一个对象,副本引用可以更改 private 数组对象。这就是为什么数组对象在索引 01 分别有 -9-19 的值。copyAges2 引用只是用来证明这一点。

解决方案

既然我们已经知道了问题,修复它就相当直接了。关键是,在返回引用时,确保你简单地不要返回 private 引用(因为按值调用将返回该引用的副本)。解决方案是 复制你希望返回的对象,并返回对新对象的引用。这样,外部类(客户端)就可以操作这个新对象,而不会影响你的私有内部对象。图 8.20 是正确封装、重构后的 图 8.18 版本:

图 8.20 – 正确封装的代码

图 8.20 – 正确封装的代码

在前面的图中,我们用一个新的版本(第 15-18 行)替换了访问器方法 getAges。这个新版本是正确封装的。在第 16 行,我们不是简单地返回数组实例变量(或其引用),而是将数组 ages 复制到一个新的数组,即 newArr。我们使用 Arrays.copyOf 方法实现这一点。我们返回新数组对象的(副本)引用。

现在,在第 24 行,当我们初始化 copyAges 时,它指的是在第 16 行创建的副本数组。这个引用 newArr 已经超出作用域(因为我们从 getAges 返回),但新的数组对象仍然在堆上,copyAges 指向它。这里的重要点是,在第 25 行,我们有两个不同的数组引用:ages 实例和局部 copyAges。这些引用现在指向两个 不同 的对象。

第 25 行输出了副本数组的详细信息;索引 030,索引 140。这是预期的。第 26 和 27 行将副本数组索引 01 的内容分别更改为 -9-19。现在,我们需要检查一下:当我们更改 copyAges 数组的内容时,private 内部 Seniors 数组的 ages 内容是否也改变了?为了检查,在第 28 行,我们可以使用 private 数组 ages 的(副本)内容初始化一个 copyAges2 数组。当我们输出第 29 行的 copyAges2 的详细信息时,我们得到 3040,从而证明当我们更改局部 copyAges 数组(第 26-27 行)时,private 内部数组 ages 并没有改变。现在,Seniors 已经被正确封装。

图 8.21 展示了在执行第 29 行时内存中的这种情况:

图 8.21 – 图 8.20 的内存表示

图 8.21 – 图 8.20 的内存表示

在前面的图中,就在Seniors对象构建之后(第 22 行),堆栈上有一个seniors引用,指向堆上的Seniors对象。Seniors对象包含一个设置为 2 的num原始值(第 10 行)和一个指向数组对象的ages数组引用(第 11-12 行)。

当我们调用getAges(第 24 行)时,复制数组newArr被创建(第 16 行),尽管这里没有显示,但新数组最初包含3040的值(分别对应索引01),如第 25 行所示。当newArrgetAges返回(第 17 行)时,(副本的)引用被分配给copyAges(第 24 行)。如前图所示,copyAges局部变量和ages实例变量指向两个不同的数组对象。这正是我们想要的。使用 copyAges 所做的任何更改都不会影响私有数组 ages

这正是第 26-27 行所做的更改所展示的内容。使用copyAges引用所做的更改反映在图中。为了证明第 26-27 行的更改没有影响private数组ages,我们再次调用getAges。一个新的数组,代表private数组的副本,再次被创建(第 16 行),并且(副本的)新数组引用被返回并分配给局部引用copyAges2。当我们输出第 29 行的新数组内容时,我们得到3040,这表明private数组没有受到局部数组(第 26-27 行)更改的影响。

现在我们已经理解了按值传递和高级封装,我们处于一个很好的位置来讨论对象生命周期。

深入了解对象生命周期

要理解 Java,了解后台在内存中发生的事情非常有帮助。本节将帮助我们巩固在调用方法、声明局部/实例变量等时堆栈和堆上发生的事情。

局部变量保存在堆栈上(以便快速访问),而实例变量和对象则存在于堆上(一大块内存区域)。正如我们所知,我们使用new关键字来创建 Java 对象。new关键字在堆上为对象分配空间,并返回对象的引用。如果对象不再可访问会发生什么?例如,引用可能已经超出作用域。我们如何回收那块内存?这就是垃圾回收发挥作用的地方。

垃圾回收

如前所述,垃圾回收会回收不再被使用的对象占用的内存;也就是说,没有引用指向这些对象。这个垃圾回收过程是 JVM 在后台运行的一个进程。JVM 可能会在空闲时决定运行垃圾回收,也可能不会。简单来说,我们无法控制垃圾回收何时运行。即使我们调用了System.gc(),这也只是向 JVM 提出运行垃圾回收的建议——JVM 可以自由地忽略这个建议。垃圾回收的主要优势是我们不需要自己进行清理;而在像 C++这样的语言中,我们必须这样做。

想要了解更多关于 Java 内存管理的细节,请参阅我们之前出版的书籍:[www.amazon.com/Java-Memory-Management-comprehensive-collection/
dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+management+maaike&qid=1699112145&sprefix=java+memory+
management+maaike%2Caps%2C148&sr=8-1](https://www.amazon.com/Java-Memory-Management-comprehensive-collection/dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+ management+maaike&qid=1699112145&sprefix=java+memory+management+maaike%2Caps%2C148&sr=8-1).

对象生命周期示例

在这个阶段,一个示例程序会有所帮助。图 8**.22 展示了一个符合我们目的的程序:

图 8.22 – 解释对象生命周期的示例程序

图 8.22 – 解释对象生命周期的示例程序

当这个(简单且非常人为的)程序执行时,有三个方法被推入栈中,分别是maintagAnimalsetCountry图 8**.23 表示我们即将退出setCountry方法(第 19 行)时的内存表示:

图 8.23 – 图 8.22 中代码的内存表示

图 8.23 – 图 8.22 中代码的内存表示

让我们更详细地看看这个例子。

主方法

如前两个图所示,第 9 行在堆上创建了Cow对象,在main帧的栈上的局部引用cow1指向它。此时,堆上的Cow对象的实例变量,即tagcountry,将是null

第 10 行将cow1中的值赋给main中的另一个局部引用,即cow2。现在,在第 11 行,我们在栈上有一个main的帧,包含两个局部引用变量,即cow1cow2,它们都指向堆上的一个Cow对象。

第 11 行使用cow2引用来执行实例方法tagAnimal。因此,当在tagAnimal方法内部(这次调用期间),this引用将指向cow2所指向的内容(即堆上的Cow对象)。此外,cow1引用被作为参数传递给tagAnimal方法。这并不是必要的,因为tagAnimal已经有一个指向Cow对象的引用(使用this),但这个程序只是为了示例目的。

tagAnimal 方法

与任何方法调用一样,tagAnimal 的栈帧被压入栈中。根据按值调用规则,tagAnimal(第 13 行)将方法参数 cow 别名为来自第 11 行的 cow1。因此,tagAnimal 中的 cow 引用和 main 中的 cow1 引用指向同一个 Cow 对象,该对象是在第 9 行创建的。

如我们所知,this 引用指向负责方法调用的对象实例——在这种情况下,cow2(第 11 行)。因此,第 14 行的 tag 引用(实际上是 this.tag)是指向可以通过 cow2 访问的 tag 实例变量。因此,第 14 行在堆上创建了一个新的 Tag 对象,并将其引用存储在 Cow 对象的 tag 实例变量中,覆盖了其先前的默认值 null。请注意,鉴于这个例子是人为设计的,Cow 对象通过三个不同的引用被引用:main 中的 cow1cow2;以及 tagAnimal 中的 cow

第 15 行指定了一个 "France"String 文字。由于 String 文字是对象,因此在堆上创建了一个 String 对象。使用 cow 引用,调用 setCountry 方法,传递 String 文字 "France"

setCountry 方法

setCountry 的栈帧被压入栈中。setCountry 声明将方法参数 country 别名为引用 String 文字 "France",它在方法调用中传递下来(第 15 行)。第 18 行将 country 实例变量初始化为传递下来的参数,即 "France"。第 18 行明确使用了 this 引用,因为参数名和实例变量具有相同的标识符 countrythis 引用指向 cow 所指向的内容,即堆上的 Cow 对象。这是因为 setCountry 方法调用(第 15 行)是在 cow 引用上执行的。

既然我们已经知道了方法是如何压入栈中的,那么让我们在从这些方法调用返回时检查内存——换句话说,当我们弹出栈时。图 8**.24 表示在退出 setCountry 方法但退出 tagAnimal 方法之前内存中的表示:

图 8.24 – “setCountry”方法完成后内存中的表示

图 8.24 – “setCountry”方法完成后内存中的表示

如前图所示,setCountry 帧已从栈中弹出。然而,String 对象 "France" 仍然位于堆上,因为 Cow 实例对象中的 country 实例变量仍然指向它。只有没有任何引用指向它们的对象才有资格进行垃圾回收。

图 8**.25 表示在 tagAnimal 方法完成但 main 方法完成之前内存中的表示:

图 8.25 – “tagAnimal”方法完成后内存中的表示

图 8.25 – “tagAnimal”方法完成后内存表示

与前一个图相比,这个图几乎没有变化,只是 tagAnimal 的栈帧已经被弹出。堆上的 Cow 对象不能被垃圾回收,因为 main 中的两个引用 cow1cow2 都指向它。此外,由于 Cow 对象不能被移除,TagString 对象也不能被移除。这是因为 Cow 实例变量 tagcountry 指向它们。这个图表示了内存中的情况,直到 main 退出,此时可以回收一切。

这就结束了我们对对象生命周期的讨论。现在,我们将继续讨论 instanceof 关键字。

解释 instanceof 关键字

instanceof 关键字使我们能够确定引用所引用的对象类型。这就是为什么将引用与对象分开是如此关键的原因。引用的类型和对象类型通常非常不同。事实上,在大多数情况下,它们是不同的。当我们讨论继承(第九章)以及接口(第十章)时,我们将更详细地讨论 instanceof

因此,目前我们将保持简单 – 当引用类型和对象类型相同时。图 8.26 展示了一个这样的代码示例:

图 8.26 – 基本的“instanceof”示例

图 8.26 – 基本的“instanceof”示例

在这个图中,第 7 行创建了一个名为 dogDog 引用,它引用了一个 Dog 对象。第 8 行创建了一个名为 catCat 引用,它引用了一个 Cat 对象。第 9 行检查 dog 引用末尾的对象是否是 Dog 的“实例”。它是,所以第 10 行执行。同样,第 12 行检查 cat 引用所引用的对象是否是 Cat 类型。它是,所以第 13 行执行。

第 15 行被注释掉了,因为它会生成编译器错误。由于 CatDog 是完全不相关的类(第 3-4 行),编译器知道没有一种方法可以让 Cat 引用,即 cat,引用 Dog 对象。相反,Dog 引用,如 dog,也不能引用 Cat 对象。

我们将在本章后面讨论 instanceof。现在,让我们继续讨论下一个主题,它与类密切相关:即枚举。

理解枚举

枚举,或简称为 enum,是一种特殊类型的类。在类中,你可以有任意多的实例(类的实例);而在枚举中,实例是预定义的,因此受到限制。枚举在适用于有限值集的情况下非常有用 – 例如,一周中的日子、一年的季节和方向。

这确保了类型安全,因为有了编译器的帮助,只允许定义的实例。在编译时发现问题总是比在运行时更好。例如,如果你有一个定义了String参数的方法,即direction,那么有人可以用"WESTT"(注意拼写错误)调用该方法。编译器不会捕获这个错误,因为它是一个有效的String,所以错误会在运行时显现。然而,如果方法参数是一个枚举,编译器就会捕获它。我们很快就会看到这一点。

枚举有两种类型:简单和复杂。我们现在来讨论它们。

简单枚举

一个简单的枚举之所以被命名为“简单”,是因为它确实很简单。从某种意义上说,当你查看枚举时,代码很少。图 8.27展示了使用简单枚举的代码:

图 8.27 – 简单枚举

图 8.27 – 简单枚举

在前面的图中,定义了Water枚举(第 3-5 行)。枚举的值用大写字母表示(类似于常量)。这不是强制性的,但这是常见的做法。这个枚举所表达的意思是我们有一个名为Water的枚举,只允许有两个实例,即STILLSPARKLING。实际上,STILLSPARKLING是对唯一允许的对象实例的引用。第 4 行末尾的分号对于简单枚举是可选的。对于复杂枚举,相应的分号是强制性的。枚举值从0开始给出序号值。因此,对于WaterSTILL的序号值为0,而SPARKLING的序号值为1

如前所述,枚举是一种特殊类型的类。然而,也有一些区别。其中之一是枚举构造函数默认是private的。这包括编译器生成的默认构造函数(如图 8.27中的Water)。与类的默认构造函数相比,它具有与类相同的访问权限。因此,你不能像普通对象那样实例化枚举。这就是为什么第 8 行不会编译——编译器生成的默认枚举构造函数是private的,因此对外部类型不可访问。

因此,如果我们不能使用new创建枚举实例,我们该如何创建枚举实例呢?换句话说,构造函数调用在哪里?枚举值的声明,即 STILL SPARKLING ,(第 4 行)就是构造函数调用! 因为它们在类内部,所以它们可以访问private构造函数。这些枚举值只初始化一次——也就是说,当枚举首次使用时。

因此,要创建枚举(对象),请使用相关的枚举值。这是在第 11 行完成的,我们现在有一个引用 stillWater,它引用了 STILL 实例。将第 11 行与第 9 行(无法编译)进行对比。尝试使用任何其他值,如 EXTRA_SPARKLING,将无法编译。这就是我们之前讨论的类型安全。只允许两个 Water 实例,STILLSPARKLING,编译器强制执行此规则。

第 12 行和第 13 行演示了只创建了一个 Water.STILL 的实例。由于等价运算符和 equals 方法都返回 true,因此只能有一个实例。

继承的方法

虽然继承将在第九章*中详细讨论,但我们需要深入了解这个主题以理解枚举。Java 中的每个类都隐式继承自一个名为 Object 的类。这意味着你可以默认获得 Object 中的方法。这就是 Java 确保每个类都有某些重要方法的方式。你可以接受来自 Object 的版本或替换它(称为 重写 方法)。

Object 继承来的这些方法之一是 equalsObject 中的版本比较引用以查看它们是否相等,并根据该比较返回 truefalse。本质上,这与使用 == 来比较引用相同。

枚举隐式继承自 Enum 类(Enum 继承自 Object,因此无法避免 Object!)。因此,枚举可以访问 valueOfvaluesordinalname 等方法。

switch 语句(第 14-20 行)根据 Water 引用切换,即 stillWater(第 14 行)。case 标签是不带限定符的枚举值(STILL,第 15 行)。第 18 行显示带限定符的枚举值是不正确的。第 19 行(和第 21 行)演示了即使枚举值有序号,枚举也是类型而不是整数。

由于继承,Enum 类型中存在一些有趣的方法可供我们使用。让我们从 valueOf(String) 开始。

valueOf(String) 方法

这是一个隐式声明的方法,当给定一个枚举常量名称时,返回该枚举实例(第 22 行)。因此,此方法提供了一种快速简单的方法来创建枚举实例,一旦你知道常量名称。

让我们看看如何使用 values() 方法遍历所有枚举实例。

values() 方法

这是一种隐式方法。在第 25 行,我们使用增强型 for 循环按枚举声明的顺序遍历枚举,即第 4 行声明的 STILL 后跟 SPARKLING。一旦我们实例化了一个枚举,我们就可以使用其他方法来获取该特定枚举的详细信息。

让我们看看 ordinal() 方法是如何为枚举提供序号的。

ordinal() 方法

ordinal() 方法(第 28 行)返回此枚举的序数值。初始枚举常量被赋予序数值 0;因此,STILLordinal() 返回 0,而 SPARKLINGordinal() 返回 1。

要确定枚举的名称,我们可以使用 name() 方法。

name() 方法

name() 方法(第 28 行)返回此枚举的名称,与在枚举中声明的完全一致(第 4 行)。例如,STILLname() 返回 "STILL",而 SPARKLINGname() 返回 "SPARKLING"。请注意,与其使用 name 方法,更好的选择是重写 toString() 方法,因为你可以自定义显示(给用户)的 String,使其更友好。我们将在继承中做很多这样的操作(第九章)。

现在我们已经检查了简单的枚举,让我们继续讨论复杂枚举。

复杂枚举

如前所述,枚举是一种特殊的类类型,其中实例是有限的。由于简单枚举非常直接,因此很难看到类/枚举之间的关系。对于复杂枚举,确定枚举与类之间的关系要容易得多。

复杂枚举具有实例变量、构造函数和方法,因此它们与类非常相似。图 8**.28 展示了一个用于讨论的复杂枚举:

图 8.28 – 一个复杂枚举

图 8.28 – 一个复杂枚举

在此图中,我们声明了 WorkDay 枚举(第 3-25 行)。这个 enum 封装了我们在办公室从周一到周五 9 点到 5 点工作,周六在家从 10 点到 1 点工作的情况。假设我们在周日休息!

枚举常量从第 5-13 行声明。有一个名为 hoursOfWorkprivate 实例变量(第 15 行),它由构造函数(第 16-18 行)初始化。请注意,构造函数默认是 private 的。访问器方法 getHoursOfWork(第 19-21 行)是外部类获取对 private 实例变量 hoursOfWork 访问的方式。另一个访问器方法 getWorkLocation(第 22-24 行)假设我们每天都在办公室工作(当然,这是大流行前的假设!)。SATURDAY 常量(第 10-13 行)值得讨论,我们很快就会谈到这一点。

让我们仔细检查第 5 行:这是一个对已声明(第 16-18 行)的构造函数的调用。换句话说,hoursOfWork 实例变量被设置为 "9-5" 用于 MONDAY。其他常量 – TUESDAYWEDNESDAYTHURSDAYFRIDAY(第 6-9 行) – 以类似方式初始化。

那么SATURDAY呢?由于我们还没有介绍继承,这可能会有些棘手。我们说的是,对于周六,我们只在家工作。要做到这一点,我们必须替换(覆盖)默认的getWorkLocation方法(第 22-24 行)。默认的getWorkLocation方法返回"Office",但我们的自定义getWorkLocation(第 12 行)对于SATURDAY返回"Home"SATURDAY常量定义了一个“特定于常量的类体”,它从第 10 行的花括号开始,到第 13 行的花括号结束。

注意,第 13 行的分号在复杂枚举常量末尾必需的,无论它们是否声明特定于常量的类体。那个特定的分号(第 13 行)告诉编译器,“我们现在已经完成了枚举常量的定义,所以你可以从现在开始期望实例变量或构造函数或方法。”

现在我们已经定义了我们的enum,让我们来使用它。第 28 行实例化MONDAY,导致枚举常量(第 5 行)执行构造函数(第 16-18 行),从而初始化MONDAY实例的hoursOfWork"9-5"。第 29 行通过输出"9-5"来证明这一点。第 30 行调用(默认)版本的getWorkLocation(第 22-24 行),从而将"Office"输出到屏幕。

第 31 行实例化SATURDAY,并将hoursOfWork输出为"10-1",因为这是从第 10 行传递到构造函数的内容。第 32 行调用SATURDAY的特定版本getWorkLocation,将"Home"输出到屏幕。

这就完成了我们对枚举的讨论。现在让我们讨论一个非常有用的特性,即记录。

欣赏记录

记录是一种特殊的类,被认为是“数据载体”。它们帮助我们避免输入大量的模板代码。记录通过记录声明来指定,其中列出记录的组件。在后台隐式生成的是规范构造函数;toStringequalshashCode方法以及为每个指定的组件生成public访问器方法。访问器方法采用与组件相同的名称(与更传统的get方法相反)。记录最好通过与常规类进行对比来解释。图 8**.29展示了一个带有大量模板代码的正常类:

图 8.29 - 一个有很多模板代码的类

图 8.29 - 一个有很多模板代码的类

在前一个图中的Person类进行了一些定制,以便更容易地映射到一个记录。例如,类本身是final(第 5 行),实例变量,即nameage(第 6-7 行),也是final。实例变量是blank final(声明为final但不在声明时初始化)的事实意味着实例变量必须在构造函数中初始化。这正是构造函数所做的事情(第 10-11 行)。

有两个访问器方法用于检索实例变量,即name(第 13-15 行)和age(第 16-18 行)。请注意,方法名前面没有get,换句话说,不是getNamegetAge。这是因为记录使用组件标识符来命名实例变量和访问器方法。

此外,这个类还有自定义的equalshashCodetoString版本,分别位于第 20-26 行、第 28-30 行和第 32-36 行。这些方法中的每一个都是通过提供特定的自定义版本来覆盖继承的版本。覆盖这个主题在继承部分有详细的讨论(见第九章)。toString方法的作用是返回包含实例变量值(组件值)的字符串。equals方法确保如果两个记录类型相同且包含相等的组件值,则它们被认为是相等的。hashCode方法确保相等的对象返回相同的哈希码值(关于这一点,请参阅第十三章)。

现在我们来检查图 8**.30中的等价记录:

图 8.30 - 类 8.29 的等价记录

图 8.30 - 类 8.29 的等价记录

是的——只需要一行代码!正如你所见,这使我们免去了很多样板代码。实际上,图 8.29图 8.30是等价的(编译器完成时)。这两个参数被称为组件,前面的单行代码导致在后台生成以下代码:

  • 以记录命名的final class(在这个例子中是Person)。

  • private final实例变量,每个组件一个,以组件命名。

  • 用于初始化组件(实例变量)的规范构造函数。

  • 访问器方法,每个组件一个,以组件命名。

  • 自定义的toStringequalshashCode方法。

记录是可定制的。换句话说,如果我们愿意,我们可以覆盖(替换)所有默认版本。图 8**.31展示了这种情况。

图 8.31 - 规范和紧凑构造函数

图 8.31 - 规范和紧凑构造函数

在这个图中,我们正在自定义规范构造函数(第 7-13 行),因为我们想验证人的age组件——如果他们小于 18 岁,那将是一个错误,我们将生成自定义错误值。再次注意,有更好的方法来处理错误值,但到目前为止,这已经足够了。否则,组件初始化为传入的值。

然而,这个规范构造函数可以以更简洁的方式编写。紧凑构造函数(第 15-19 行)正在替换规范构造函数。紧凑构造函数是规范构造函数的一种变体,并且是特定于记录的。注意,第 15 行甚至没有一对圆括号——组件可以从组件列表(第 5 行)推断出来。此外,没有必要根据第 11-12 行初始化组件;编译器可以为我们完成这项工作。

第 23-26 行演示了如何使用我们声明的记录 Person。第 23 行声明了一个名为 p1Person 实例。第 24 行调用了 Record 类提供的隐式 toString 方法(每个记录都从该类继承)。第 25-26 行调用了两个访问器方法;注意它们的名称分别是 name()age()。输出在每行的右侧注释中(第 24-26 行)。

由于记录与类密切相关,记录可以用 instanceof 关键字使用,这并不奇怪。这就是我们将要检查的记录模式。

记录模式

在过去的几年里,instanceof 关键字已经从简单的 instanceof-and-cast 习语发展到支持类型模式和记录模式。让我们首先讨论一下“类型模式”和“模式匹配”是什么。

类型模式和模式匹配

在 Java 16 中,instanceof 被扩展为接受类型模式并执行模式匹配。在 Java 16 之前,以下代码很常见:

if(obj instanceof String){ // 'obj' is of type Object    String s = (String)obj;
    System.out.println(s.toUpperCase());
}

这段代码检查 Object 引用 obj 是否指向一个 String 对象,如果是,则(安全地)将引用转换为 String,这样我们就可以访问 String 方法。记住,你可以访问的方法基于引用类型。然而,如果引用末尾的对象是一个 String 对象,那么我们可以安全地将引用转换为 String,从而使用新的 String 引用访问 String 方法。我们将在继承部分(第九章)中更详细地讨论这一点。

截至 Java 16,我们可以更简洁、更安全地编写之前的代码段:

if(obj instanceof String s){ // "String s" - type pattern//    String s = (String)obj; // no longer needed
    System.out.println(s.toUpperCase());
}

有两个变化需要注意。第一个变化是使用类型模式 String s 作为 instanceof 的一部分。模式匹配在运行时发生,其中 instanceof 将类型与提供的类型模式进行比较,如果匹配,则为我们执行类型转换。第二个变化是,由于 instanceof 代表我们执行类型转换,我们不再需要自己进行转换。这导致了一种更声明性的风格(你只需说明你想要什么,而不是如何得到你想要的东西)。

这很自然地引出了 Java 21 中引入的记录模式。在记录模式之前,需要以下代码(假设使用 图 8**.30 中的 Person 记录):

if(obj instanceof Person p){ // type pattern    String name = p.name(); // accessor
    int age     = p.age();  // accessor
    System.out.println(name + "," + age);
}

使用记录模式,之前的代码可以更简洁地表达:

if(obj instanceof Person(String sName, Integer nAge))    System.out.println(sName + "," + nAge);
}

在这段代码中,Person(String sName, Integer nAge) 是一个记录模式。记录模式由一个类型、一个组件模式列表(可能为空)和一个可选的标识符组成。记录模式为我们做了两件事:首先,检查对象是否通过 instanceof 测试;其次,将记录实例分解为其组件。所以,在我们的例子中,假设 obj 指的是一个 Person 对象,那么局部变量 sName 将初始化为 name() 访问器方法的返回值,局部变量 nAge 将初始化为 age() 访问器方法的返回值。我们故意使用不同的标识符来强调它们不必与 图 8**.30 中使用的组件标识符匹配。然而,需要注意的是,类型的顺序必须匹配;换句话说,记录模式必须指定一个 String 变量,然后是一个 Integer 变量,正如 图 8**.30 中组件列表的顺序。

这样,我们就完成了对记录的讨论,并且确实结束了 第八章。现在,让我们将所学知识付诸实践,以巩固我们学到的概念。

练习

类、对象和枚举非常适合增强我们的中生代伊甸园软件。在这些练习中,你将创建类来表示公园中的不同实体,并使用枚举来定义一组固定的常量:

  1. 我们公园里有很多种类的恐龙,每种恐龙都有其独特的特征。定义一个名为 Dinosaur 的类,具有诸如名称、年龄和物种等属性。

  2. 我们公园的灵魂和核心在于其员工。创建一个名为 Employee 的类,用于封装诸如名称、职位和经验年数等属性。

  3. 在有了这些类之后,创建一些 DinosaurEmployee 的实例,并练习操作这些对象。我很难为这个练习提供更多细节,但例如,你可以创建一个新的类 App。然后,在这个类中,你可以创建一些 DinosaurEmployee 的实例。如果你想发挥创意,你可以添加一个接受 Dinosaur 作为参数的方法,然后打印出这个恐龙的信息(如它的名称、年龄等)。当然,你也可以为 Employee 做同样的事情。

  4. “公园”本身可以被视为一个具有自身属性和行为的对象。设计一个 Park 类,其中包含打开和关闭公园、添加或删除恐龙等方法。你也可以考虑给它一个员工数组和一个恐龙数组。

  5. 我们提供给恐龙的食物种类繁多。定义一个 Food 类,具有诸如名称、营养价值和成本等属性。

  6. 如你所知,安全是我们的首要任务。出于明显的安全原因,我们的恐龙被安置在不同的围栏中。创建一个 Enclosure 类,其中包含一个 Dinosaur 对象数组。

  7. 为了增加清晰度,让我们定义一个用于恐龙类型的枚举,例如草食性、肉食性和杂食性。

  8. 没有门票的公园参观是不完整的。创建一个具有价格、访客姓名和访问日期等属性的Ticket类。

项目 - Mesozoic Eden 公园管理员

在这个项目中,你将创建一个名为 Mesozoic Eden 公园管理员的完全交互式控制台应用程序。这个应用程序允许公园管理员监督和管理恐龙公园的各个方面。公园管理员可以使用这个应用程序来高效地管理多个恐龙、公园员工和公园门票。这个系统的关键特性应该包括以下内容:

  1. 创建、编辑或删除恐龙档案、公园员工档案和公园门票的能力。

  2. 一个实时跟踪系统,用于监控公园内恐龙的位置和状态。

  3. 一个基本的名单系统,用于组织和管理工作人员的排班。

  4. 一个强大的票务系统,用于管理访客入场并确保公园保持最佳容量。

  5. 系统还应处理特殊场景,如紧急情况或 VIP 访客访问。

这可能听起来很多。所以,这里有一个逐步指南来实现这一点:

  1. DinosaurEmployee类。另外,添加一个名为Guest的类。每个类都应该包含更多的属性和方法。

  2. DinosaurGuestEmployee对象。

  3. Scanner类。这个接口应该为公园管理员提供管理公园的各种选项。

  4. 增强菜单创建:菜单现在应包括管理多个恐龙、员工和票证的选项。每个选项都应该对应程序中的特定功能。

  5. 处理操作:每个菜单项都应该触发一个函数。例如,选择管理恐龙选项可以触发一个函数来添加、删除或编辑恐龙档案。

  6. 退出程序:提供一个选项让用户退出程序。

这里是一个起始代码片段:

import java.util.Scanner;public class Main {
    // Use Scanner for reading input from the user
    Scanner scanner = new Scanner(System.in);
    public static void main(String[] args) {
        Main main = new Main();
        main.start();
    }
    public void start() {
        // This is the main loop of the application. It
          will keep running until the user decides to exit.
        while (true) {
            displayMenu();
            int choice = scanner.nextInt();
            handleMenuChoice(choice);
        }
    }
    public void displayMenu() {
        System.out.println("Welcome to Mesozoic Eden Park
          Manager!");
        System.out.println("1\. Manage Dinosaurs");
        System.out.println("2\. Manage Park Employees");
        System.out.println("3\. Manage Tickets");
        System.out.println("4\. Check Park Status");
        System.out.println("5\. Handle Special Events");
        System.out.println("6\. Exit");
        System.out.print("Enter your choice: ");
    }
    public void handleMenuChoice(int choice) {
        switch (choice) {
            case 1:
                // manageDinosaurs();
                break;
            case 2:
                // manageEmployees();
                break;
            case 3:
                // manageTickets();
                break;
            case 4:
                // checkParkStatus();
                break;
            case 5:
                // handleSpecialEvents();
                break;
            case 6:
                System.out.println("Exiting...");
                System.exit(0);
        }
    }
}

注释掉的方法调用是您需要根据数据结构和功能实现的方法的占位符。

概述

在本章中,我们通过区分对象和类开始了我们的讨论。类类似于房子的设计图,而对象则是(建造的)房子本身。我们使用new关键字创建对象,并使用其引用来操作对象。区分引用和对象在以后非常重要。一个有用的类比是,引用就像遥控器,而对象就像电视。

构造函数是用于构建对象时使用的特殊方法。构造函数是一个与类名相同但没有返回类型的方法。始终存在构造函数 - 如果您没有提供,编译器会介入并插入默认构造函数。构造函数通常用于初始化实例变量。

每个对象都获得实例成员(变量和方法)的副本。类成员被标记为 static,并由所有实例共享。当访问实例成员时,我们使用引用,而当访问类成员时,我们使用类名。点符号适用于这两种语法。

this 引用是我们实例方法中可用的一种特殊引用。它指向负责方法调用的对象实例。因此,它是动态的,因为它的值取决于调用方法时使用的引用。它对类(静态)方法不可用。

访问修饰符既适用于顶级(类/接口/记录)级别,也适用于成员级别。在顶级级别,public 或包私有访问适用。包私有通过完全不指定任何关键字来实现,并确保顶级构造仅在同一个包内可见。如果顶级构造是 public,则它在任何地方都可用;没有限制。

除了 public 和包私有(具有相同的语义)之外,成员(变量/方法)还可以是 privateprotectedprivate 意味着成员仅在类内部可见。protected 与包私有类似,但子类(无论包如何)都可以访问成员。

封装是面向对象编程的基石之一。这意味着一个类可以隐藏其数据以防止外部滥用;这通常被称为“数据隐藏”。在 Java 中,这是通过将数据标记为 private 并提供 public 访问器/修改器(get/set)方法来操纵数据来实现的。这里的重要概念是外部代码必须通过你的 public 方法访问 private 数据。因此,通过在 public 方法中使用条件逻辑,你可以防止你的数据被破坏。

然而,“私有数据,公有方法”的原则仅到此为止。当返回对 private 对象的引用时,Java 的按值调用机制返回该引用的副本。因此,private 对象现在可以通过外部代码直接访问。高级封装通过复制 private 对象并返回副本对象的引用来对抗这种情况。因此,你的 private 对象仍然是私有的,并且免受外部干扰。

理解一个对象的生命周期非常有用。局部变量存在于栈上,而对象和实例变量存在于堆上。当一个对象不再有任何引用指向它时,它就有资格进行垃圾回收。垃圾回收是由 JVM 在 JVM 选择的时间自动运行的过程。当垃圾回收器运行时,有资格进行垃圾回收的对象将被移除,堆空间将被回收。

instanceof 关键字使我们能够确定引用所指向的对象类型。这将在以后非常有用。

枚举(enums)与类紧密相关,因为枚举实际上就是类,其实例数量是有限且已指定的。它们在确保类型安全方面非常有用,编译器会标记错误而不是在运行时发现错误。

枚举分为两种类型:简单和复杂。简单枚举仅指定常量值;编译器会自动生成默认构造函数。所有枚举构造函数默认为private。因此,外部类不能new它们——实际上定义的常量是构造函数的调用。复杂枚举看起来与类非常相似,因为它们有实例变量、(显式)构造函数和方法。

当你拥有大量样板代码的类时,记录(Records)非常有用。记录的组件在记录声明中指定。编译器在后台生成实例变量、规范构造函数、访问器方法、toStringequalshashCode方法。记录是final的,实例变量(组件)也是如此。紧凑构造函数是规范构造函数的一种更简洁的变体。

这就完成了我们对类、对象和枚举的讨论。我们现在将转向另一个重要的面向对象编程(OOP)章节:继承。

第九章:继承和多态

第八章中,我们学习了类、对象和枚举。最初,我们探讨了类与对象之间的关系以及将引用类型与对象类型分开的需要。我们对比了实例成员与类成员,并看到使用static关键字将类作用域应用于成员。我们讨论了this引用,并演示了在实例方法内部,this引用指向负责方法调用的对象实例。我们还涵盖了各种访问修饰符:private、包私有(无关键字)、protectedpublic。这些修饰符使我们能够应用面向对象编程的基石之一,即封装。虽然封装通常被称为“私有数据,公有方法”,但我们演示了这还不够,因为 Java 在将引用传递进方法和从方法返回时采用值传递机制。我们展示了如何使用称为“防御性复制”的技术来应用适当的(高级)封装。为了提高我们对后台发生的事情的理解,我们详细介绍了对象生命周期,并简要提到了垃圾回收。我们还涵盖了instanceof关键字,它用于确定引用所引用的对象类型。我们涵盖了类的变体,即枚举枚举)。枚举使我们能够限制实例的数量,从而促进类型安全。我们涵盖了简单和复杂的枚举。最后,我们涵盖了另一种类变体,即记录,它可以节省我们编写大量样板代码。

在本章中,我们将探讨继承,这是面向对象编程的另一个核心原则。最初,我们将概述继承的好处以及要使用的 Java 关键字。这导致多态,这是面向对象编程的另一个核心支柱。我们将解释多态,并通过示例说明如何实现多态。由于多态需要“方法重写”,我们将解释如何使用instanceof,以确保向下转型时的类型安全。

我们还将对比方法重写与方法重载。我们将解释super关键字及其用法。正如在第八章中所承诺的,我们将重新探讨protected,这是 Java 访问修饰符中最容易被误解的一个。

之后,我们将讨论abstractfinal关键字及其在继承中的作用。我们还将展示如何使用sealed类来限制继承的范围。此外,我们还将涵盖继承层次结构中的static和实例块。最后,我们将讨论向上转型和向下转型继承树,以及一个简单的经验法则如何帮助防止ClassCastException错误。

本章涵盖了以下主要主题:

  • 理解继承

  • 应用继承

  • 探索多态

  • 对比方法重写与方法重载

  • 探索super关键字

  • 重新探讨protected访问修饰符

  • 解释abstractfinal关键字

  • 应用sealed

  • 理解实例和static

  • 掌握向上转型和向下转型

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch9

理解继承

Java 有三个核心支柱:多态性、继承和封装(数据隐藏)。使用缩写“PIE”(Polymorphism, Inheritance, 和 Encapsulation)来记忆它们很容易。现在让我们来考察继承。

继承是一种代码重用机制,通过在相关类型之间形成关系来利用它们之间的共同属性。Java 中的继承关系是通过从一个类扩展或通过实现一个接口来创建的。我们将在第十章中介绍接口,所以现在我们将假设类。为了理解为什么面向对象的继承很重要,我们将检查其优点(和缺点)。由于我们尚未介绍所使用的术语,这次讨论将有些抽象。

继承的优点

继承的一个主要优点是代码重用。可以基于现有类编写新类,而不是从头开始编写新类。换句话说,新类可以继承已经编写(并测试)的代码。这被称为代码重用,减少了冗余。

继承自然地促进了多态性,我们稍后讨论。这个特性给你的代码带来了灵活性。例如,你可以有一个处理Animal引用的方法,但在运行时,执行的代码是在Dog类型(或Cat或任何其他Animal类型的层次结构中的类型)。实际上,一个方法可以与所有Animal类型一起工作。

继承将代码组织成层次结构。这可以提高生产力并简化代码的维护,因为对继承代码所做的更改会立即在整个层次结构中反映出来。

继承的缺点

尽管有优点,继承确实有其缺点。基类型(源类型)和派生类型(目标类型)之间的紧密耦合是一个缺点。对基类型所做的任何更改都会影响所有派生类型。

代码膨胀是另一个缺点。可能对基类型进行更改,而许多派生类型并不需要这些更改,这可能导致代码库不必要地庞大。

既然我们已经对继承及其用途有了认识,让我们讨论在讨论继承时使用的术语(名词)。

基类

“基”类也被称为“超”类或“父”类。这是定义继承成员的地方。由于类是一种类型,因此术语类型通常可以与类互换使用。请注意,在 Java 中,Object类是每个层次结构的顶层。

派生类

子类也被称为“子”或“派生”类。因此,子类从基类继承功能(和/或数据)。再次强调,由于类是一种类型,因此术语 子类型 常与子类互换使用。一个类可以既是基类也是子类。Java 确保每个(继承)层次结构的顶部都是 Object。因此,我们编写的每个类都是隐式子类型(即使你没有这么说)。

“是”关系

继承生成所谓的 “是” 关系。图 9.1 将帮助我们解释这一点。随着本章的进展,我们将扩展这个图:

图 9.1 – 车辆层次结构的 UML 图

图 9.1 – 车辆层次结构的 UML 图

统一建模语言 (UML)

UML 是一种在软件设计中使用的建模语言,它利用了“一图胜千言”的原则。UML 使得理解诸如继承等主题变得非常直接,因此在这里我们将简要介绍 UML。更多详细信息请参阅:en.wikipedia.org/wiki/Unified_Modeling_Language

在考虑 图 9.1 的基础上,以下是所使用的符号概述:

  • 包名:包名位于左上角 (ch9)

  • :类在有三个部分的框中,顶部框是类名;中间框用于实例/类变量;底部框用于方法

  • 访问修饰符public (+), private (-), 包私有 (~), 和 protected (#)

  • 静态:下划线用于表示成员是 static

  • 方法返回类型:UML 中方法签名中的最后一部分

  • 类继承:带有实线的箭头;例如,CarVehicle 继承

  • 接口:这些在带有虚线的框中表示 (第十章)

  • 接口继承:带有虚线的箭头 (第十章)

  • 关联:一条实线;例如,TestVehicleVehicle 相关联的简单原因是我们将在 main() 函数中基于 Vehicle 层次创建对象。

图 9.1 所示,我们有一个包,即 ch9。在 Vehicle 层次结构中有五个类:VehicleCarSaloonConvertibleBoat。在这个层次结构中,从基类角度来看,VehicleCarBoat 的基类;而 CarSaloonConvertible 的基类。从子类角度来看,CarBoatVehicle 的子类,而 SaloonConvertibleCar 的子类。无论使用哪种视角,每个 Car 都是 Vehicle 的“是”,每个 Boat 也是 Vehicle 的“是”。此外,每个 SaloonCar 的“是”,每个 Convertible 也是 Car 的“是”。

这也意味着,因为SaloonCar的一种,而Car又是Vehicle的一种,所以Saloon也是Vehicle的一种。同样的情况也适用于Convertible;换句话说,ConvertibleCar的一种,CarVehicle的一种;因此,Convertible也是Vehicle的一种。

然而,“是一个”的关系只在一个方向上起作用(从底部向上读取图)。例如,虽然每个Car都是Vehicle的一种,但不是每个Vehicle都是Car的一种;有些是Boat。这有一个非常好的原因,我们将在讨论向上转型和向下转型时进一步探讨。

Vehicle类中有一个方法,即toString(),因为它被声明为public,所以被所有子类型继承;也就是说,CarSaloonConvertibleBoat。因此,Vehicle中的toString()版本在整个层次结构中都是可用的。最后,其他类TestVehicle包含main()方法,这样我们就可以测试这个层次结构。

现在我们已经理解了继承的概念,让我们在代码中应用它。

应用继承

如我们在上一节所学,继承创建了一个“是一个”的关系层次结构。这使得基类的功能可以被继承,因此对子类可用,而无需额外的编码。Java 在应用继承时使用两个关键字:extendsimplements。现在让我们来讨论它们。

extends

这是用于类和接口的原理关键字。关于类,我们声明class Sub extends Base {}。在这种情况下,Base类中所有非private成员都将被继承到Sub类中。请注意,private成员和构造函数不会被继承——这是有道理的,因为private成员和构造函数都是类特定的。此外,Java 禁止多重类继承。这意味着你一次不能从一个以上的类中扩展。关于接口,我们声明interface ChildInt extends ParentInt {}

implements

虽然我们将在第十章中详细讨论接口,但在这里简要概述是合适的。接口是一种结构,它使 Java 能够确保如果一个类实现了接口,那么这个类实际上是在签署一份合同。这个合同通常表示,该类将为接口中的abstract方法提供代码。abstract方法,我们将在稍后详细讨论,是一种没有实现代码的方法;换句话说,没有花括号。

关于继承,与类不同,Java 允许接口一次扩展多个接口。例如,interface C extends A, B {},其中ABC都是接口,这是可以的。请注意,截至 Java 8,接口中的defaultstatic方法都有实现代码。

一个类使用 class Dog implements Walkable 语法实现接口。这样,Walkable 中的 staticdefault 方法对 Dog 可用。

现在,让我们看看继承的实际应用。图 9.2 展示了 图 9.1 中的 UML 图对应的 Java 代码:

图 9.2 – 继承的实际应用

图 9.2 – 继承的实际应用

在这个图中,第 3 行和第 4 行是等效的。Vehicle 是这个特定层次结构的最顶层,为了确保每个类都继承自 Object,编译器在第 3 行简单地插入 extends Object。第 5-7 行是对从 Object 继承的 toString() 方法的自定义实现。这被称为 重写,我们将在稍后详细讨论这个话题。第 9-12 行代表了继承层次结构的其余部分:一个 Car 是一个 Vehicle;一个 Boat 是一个 Vehicle;一个 Saloon 是一个 Car;一个 Convertible 是一个 Car

在第 16 行,我们创建了一个 Vehicle 对象,并使用一个名为 vehicleVehicle 引用来引用它。在第 17 行,我们调用定义在第 5-7 行的 toString() 方法,输出 Vehicle::toString()

在第 18 行,我们创建了一个 Car 对象,并使用一个名为 carCar 引用来引用它。在第 20 行,我们只需将 car 引用插入到 System.out.println() 中。当 Java 遇到这样的引用在 System.out.println() 内部时,它会查找对象类型(在这个例子中是 Car)并调用其 toString() 方法。由于每个类都继承自 Object,而 Object 定义了一个基本的(不友好的)toString(),因此将存在一个 toString() 版本。然而,在这个层次结构中,Vehicle 用自己的自定义版本(第 5-7 行)替换了从 Object 继承的 toString()。这个来自 Vehicle 的自定义版本被 Car 继承。发生的情况是,Java 检查 Car 中是否定义了自定义的 toString();由于没有定义,Java 然后检查其父类,即 Vehicle。如果 Vehicle 中没有 toString(),将使用来自 Object 的版本。由于 toString()Vehicle 中定义,这是 Car 继承并用于第 20 行的版本。因此,输出再次是 Vehicle::toString()

在第 21 行,我们创建了一个 Saloon 对象,并使用一个名为 saloonSaloon 引用来引用它。同样,在第 22 行,我们只需将 saloon 引用插入到 System.out.println() 中。由于 Saloon 没有自定义的 toString(),其父类 Car 也没有自定义版本,因此使用从 Vehicle 继承的版本。这导致输出到屏幕的是 Vehicle::toString()

第 24 行用于演示当使用Object类的toString()方法时的输出。在第 24 行,我们创建了一个TestVehicle的实例并调用其toString()方法。由于TestVehicle没有明确地从任何类继承(使用extends),它隐式地继承了Object。此外,由于TestVehicle没有用自己的自定义版本覆盖toString(),因此使用从Object继承的版本。这可以通过第 24 行的输出得到证明:ch9.TestVehicle@378bf509Object中的toString()方法的输出格式为package_name.class name@hash code。在这个例子中,包名是ch9(第 1 行),类名是TestVehicle(第 24 行),而哈希码是一个用于散列集合的十六进制数(第十三章)。

现在我们已经看到了基本继承的作用,让我们继续探讨面向对象编程的另一个基石,即多态性。

探索多态性

多态性起源于希腊术语 poly(许多)和 morphe(形式)。任何通过一个以上的“is-a”测试的对象都可以被认为是多态的。因此,只有Object类型的对象不是多态的,因为任何类型都会通过Object和它自己的“is-a”测试。

在本节中,我们将讨论为什么将引用类型与对象类型分开是如此重要。此外,我们还将检查方法覆盖及其在启用多态性中的关键作用。

将引用类型与对象类型分开

现在我们有了继承层次结构,我们将经常区分引用类型和对象类型。引用类型可以是类、记录、枚举或接口。换句话说,我们在引用类型方面有灵活性。对象类型更为严格:对象类型仅基于非抽象类、记录和枚举。换句话说,我们不能基于抽象类或接口创建对象。

例如,给定图 9.2中的层次结构,以下说法是完全合法的:

Vehicle v = new Car();

这是因为每个Car都是Vehicle的“is-a”类型(从右到左阅读,因为赋值是从右到左关联的)。在这个例子中,引用vVehicle类型,它指向一个Car类型的对象。这被称为向上转型,因为我们正在向上继承树(再次从右到左阅读,从Car向上到Vehicle)。我们正在向上转型Car引用,由new Car()创建,并将其转换为Vehicle引用v

为什么这行得通?这行得通是因为,由于继承,Vehicle可用的所有可继承方法都将存在于Car中。这是一个保证。无论Car是否用其自定义的任何/所有Vehicle方法替换了这些方法,都是无关紧要的。鉴于编译器查看引用类型(而不是对象类型),我们可以使用Vehicle引用v调用的方法是在Vehicle(和Object)中定义的,并且将在Car(对象类型)中存在。

因此,这是需要记住的第一个要点——编译器始终查看引用类型。正如我们很快就会看到的,对象类型在运行时发挥作用。所以,一个简单但有效的经验法则是一个引用可以指向其自身类型的对象或子类对象。实际上,一个引用可以指向(UML)层次结构的“上方和下方”。

如果一个引用始终指向层次结构的“上方”,那么就会出现ClassCastException错误。为什么会出现这种情况呢?好吧,子类从其父类继承。除了替换继承的功能(重写)之外,子类还可以添加额外的方法。所以,如果你有一个子类类型的引用,你可以调用这些额外添加的方法。但是,如果你的对象是父类类型,这些方法将不存在!这对 JVM 来说是一个严重的问题,它会立即抛出一个异常(第十一章)。

因此,引用类型决定了可以调用对象的方法。此外,虽然引用类型不能改变,但它所指向的对象类型可以改变。

现在,让我们讨论如何利用多态性。

应用多态性

多态性仅适用于实例(非静态)方法,因为只有实例方法可以被重写。在编译时,编译器决定绑定哪个方法签名;然而,将提供实际要执行的方法的对象是在运行时决定的!这就是多态性的含义。这也是为什么多态性也被称为“运行时绑定”或“后期绑定”的原因。

如果你正在访问一个静态成员怎么办?

一个static成员(方法或数据)与类相关联,因此不涉及多态性。以下适用:如果你正在访问任何类型的数据(static或非静态)或static方法,JVM 使用引用类型。只有当它是实例方法时,才会使用对象类型(多态性)。

因此,为了使多态性起作用,我们需要在基类和子类中实现实例方法,其中子类重写了基类版本。为了实现这一点,子类必须编写一个与父类具有相同签名的函数。

好吧,理论就到这里为止——让我们看看一个例子,以巩固我们迄今为止所学的一切。

代码中的多态性——示例 1

图 9.3显示了以下代码的 UML 图:

图 9.3 – 多态性的 UML 示例

图 9.3 – 多态性的 UML 示例

在这个图中,Vehicle类有一个move()方法。它是一个实例方法,返回类型为voidCarBoat都扩展了Vehicle并重写了move()方法。Car添加了一个名为wheels()的方法,而Boat添加了一个名为floats()的方法。SaloonConvertible都扩展了CarSaloon重写了move()方法,而Convertible没有。

图 9**.4 展示了此 UML 的代码,并展示了多态的实际应用:

@Override注释

注释是一种元数据形式,它提供了关于程序的信息,但这些信息本身并不包含在程序中。在 Java 中,注释由@符号开头,并且有多种用途。例如,注释被编译器用来检测错误,或者被运行时用来生成代码。

当重写基类方法时,我们可以在子类方法之前插入@Override注释。虽然这不是强制的,但它非常有用,因为如果我们应用这个注释,编译器将确保我们正确地重写了方法。

图 9.4 – 多态示例

图 9.4 – 多态示例

在这个图中,CarBoat都扩展了VehicleSaloonConvertible都扩展了Car。请注意,Vehicle中的move()方法(第 4 行)是一个非静态/实例方法,因此是多态的。此外,由于move()不是private的,它是可继承的。Vehicle中的move()方法被Car(第 7 行)、Boat(第 11 行)和Saloon(第 15 行)重写。为了强调这一点,我们在这些行的每一行都使用了@Override注释。这意味着父类move()方法被相应的子类版本重写。

第 21 行创建了一个Car对象,并使用一个Vehicle引用,即v,来引用它。值得重复的是,这种从CarVehicle的上转型,仅因为通过继承,每个 Car 都是 Vehicle 的子类。因此,任何对Vehicle引用可用的方法都将存在于Car中。因此,由于上转型从不构成风险,编译器会隐式地执行它;换句话说,你不需要在代码中显式地声明(上)转型,如下所示:

Vehicle v = (Vehicle) new Car();

编译时

第 22 行调用了多态的v.move()方法。每次在代码中进行方法调用时,都需要考虑两个视角:编译时和运行时。正如我们所知,编译器关注的是引用类型。因此,在这种情况下,编译器检查引用v,并确定它是Vehicle类型。然后编译器检查在Vehicle类中是否存在具有该确切签名的move()方法,无论是定义在Vehicle中还是从Object(在这个例子中)继承到Vehicle中。由于在Vehicle中定义了move()方法,编译器对此感到满意。

多态的实际应用

在运行时,由于move()是一个非静态的多态方法,被引用的对象,即v,适用。由于v指的是一个Car对象,所以执行了Car版本的move()。这是多态的实际应用!我们有一个方法,但有很多这个方法的实现。编译器确保方法存在,并在运行时动态地触发多态,并执行被引用对象中的版本。由于v指的是一个Car对象,所以第 22 行的输出结果是Car::move()

第 23 行重复使用了Vehicle引用,v(这是完全有效的),来指代一个Boat对象。因为BoatVehicle的一种(即“is-a”关系),所以这是可以的。第 24 行对v.move()进行了与第 22 行相同的多态调用。然而,这次v指的是一个Boat对象,并且由于Boat重写了move()方法,所以在运行时执行了Boat版本的move()。因此,输出结果是Boat::move()

第 25 行展示了编译器查看引用类型。正如我们所知,v的类型是Vehicle。然而,Vehicle没有floats()方法;这是一个特定于Boat的方法。因此,编译器在第 25 行对v.floats()提出了抱怨,所以这一行被注释掉了。

第 26 行重复使用了Vehicle引用,v,来指代一个Saloon对象。因为SaloonVehicle的一种(即“is-a”关系),所以这是可以的。第 27 行对v.move()进行了相同的多态调用,就像第 22 行和第 24 行的情况一样。由于v现在指的是一个具有重写move()方法的Saloon对象,所以在运行时以多态方式执行了Saloon版本的move()。因此,输出结果是Saloon::move()

第 28 行创建了一个Convertible对象,并使用v来引用它。这没问题,因为ConvertibleVehicle的一种(通过Car间接)。换句话说,因为ConvertibleVehicle的一种,而VehicleCar的一种,所以Convertible也是Car的一种。第 29 行进行了相同的多态调用,即v.move(),就像第 22 行、第 24 行和第 27 行的情况一样。然而,需要注意的是,Convertible没有重写move()方法。Convertible有一个空的类体。因此,Convertible中的方法是从Car继承来的move()wheels()方法,以及从Object继承来的方法,如toString()。所以,在运行时,当调用v.move()时,JVM 执行了Car中的move()版本,结果是Car::move()。你也可以这样理解:运行时在Convertible中查找move(),但没有找到;然后 JVM 检查其父类Car,并找到了一个,然后执行它。请注意,如果Car没有提供move()方法,它的父类Vehicle将会是下一个搜索的对象。所以,这是一个“逐级向上,一次一代”的有序搜索。

为什么我们会得到ClassCastException错误?

第 31 行展示了向下转型和ClassCastException错误。异常将在第十一章中讨论,所以这里不会详细说明。向下转型将在本章后面更详细地讨论,但这个例子太好了,不容错过!让我们更详细地检查第 31 行:

Saloon s = (Saloon) new Vehicle(); // ClassCastException

首先要注意的是,转型(Saloon)是必需的。编译器不会允许以下代码:

Saloon s = new Vehicle(); // Compiler error

这是一个编译器错误,因为并非每个Vehicle都是Saloon类;有些是Boat。实际上,即使没有Boat类,这一行代码也无法编译。为什么?因为,从右向左阅读,你是在从VehicleSaloon的层次结构中向下移动。由于Saloon可能(实际上确实如此)有不在Vehicle类中的额外方法,这种情况必须被阻止。例如,Saloon引用s可以访问从Car继承来的wheels()方法,而Vehicle中没有这个方法。

现在,我们可以通过使用(向下)转型来覆盖编译器。这就是第 31 行使用(Saloon)转型的做法。实际上,通过插入转型并覆盖编译器错误,你是在告诉编译器:“让我继续,我知道我在做什么。”因此,代码在放置转型后可以编译。然而,在运行时,JVM 意识到它有一个指向继承树中Vehicle对象的Saloon引用。这是大忌,因为如果 JVM 允许Saloon引用s指向一个Vehicle对象,那么在随后的s.wheels()方法调用中会发生什么?记住,我们会看到一个没有这种方法的Vehicle对象!因此,JVM 生成一个ClassCastException错误。

让我们重构这段代码,从另一个角度展示多态性。

代码中的多态性 – 示例 2

图 9.5显示了从图 9.4重构的代码:

图 9.5 – 重构的多态性示例

图 9.5 – 重构的多态性示例

注意,在这个图中,继承层次结构从图 9.4保持不变。TestVehicle类(第 19-30 行)已经被重构。我们引入了一个新方法,即doAction()(第 20-22 行),它接受一个Vehicle引用。在doAction()方法中,我们简单地调用move()方法(第 21 行)。由于Vehicle有一个move()方法,这是可以的。

第 24 行与之前相同;它创建了一个Car对象,并将引用向上转换为Vehicle引用v。因此,v引用了一个Car对象。第 25 行调用doAction()方法,传入引用v。这个在第 24 行声明的引用v被复制到另一个(不同的作用域)但名称相似的引用v,它在第 20 行声明。现在,在doAction()中,我们有一个局部v引用,它引用了第 24 行创建的同一个Car对象。因此,当我们第 21 行调用v.move()时,多态就起作用了,我们得到Car版本的move(),结果是Car::move()

第 26 行在一行代码中做了与前面两行代码(第 24-25 行)相同的事情。在第 26 行,创建了Boat对象,对doAction()的方法调用导致向上转换为Vehicle引用v(第 20 行)。之后,第 21 行以多态方式执行,我们得到Boat版本的move(),结果是Boat::move()

第 27 行与第 26 行相同,只是我们正在创建一个Saloon对象。因此,doAction()中的Vehicle引用v执行了move()方法作为Saloon,结果是Saloon::move()

第 28 行与第 27 行相同,只是我们正在创建一个Convertible对象。因此,doAction()中的Vehicle引用v试图在Convertible中执行move()方法。由于没有找到,检查Convertible的父类,即CarCar确实有一个move()版本,结果是Car::move()

为了清楚地了解何时应用多态以及何时不应用,我们将重新审视之前提出的调用框。

JVM – 引用类型与对象类型的使用

如前一个调用框中简要讨论的,如果你处理任何类型的数据(static或非静态),则应用引用类型;当处理实例方法时,应用对象类型(多态)。图 9.6 展示了代码示例:

图 9.6 – JVM 使用引用类型与对象类型的情况

图 9.6 – JVM 使用引用类型与对象类型的情况

在这个图中,Vehicle类声明了一个实例变量,即cost(第 4 行),以及一个类变量,即age(第 5 行)。此外,Vehicle还声明了一个名为move()的实例方法(第 6-8 行)和一个名为sm()的类方法(第 9-11 行)。因此,在Vehicle中,我们既有实例和static数据,也有实例和static方法。

Car类从Vehicle扩展(第 13-23 行)并简单地复制Vehicle。换句话说,Car具有与父类Vehicle相同的数据和方法。

Car中,我们分别声明了实例变量和非实例变量,即costage(第 14-15 行)。这些变量在Car中的类型和标识符与父类Vehicle中的对应变量相同。换句话说,Vehicle有一个名为cost的实例变量,它是一个doubleCar也有一个名为cost的实例变量,它也是一个doubleVehicle中的age类变量也有相同的情况——Car子类中也有一个名为age的类变量。这被称为隐藏(或遮蔽)。

Vehicle定义了一个实例方法move()(第 6-8 行),它被Car中的版本(第 17-19 行)重写。由于这是一个实例方法,如果调用move(),则在运行时应用多态。

Vehicle还定义了一个名为sm()的类方法(第 9-11 行),它被Car中的sm()版本(第 20-22 行)隐藏(遮蔽)。

第 26 行创建了一个Car对象,并使用一个Vehicle引用v来引用它。

第 27 行输出v.cost。由于cost是数据(一个实例变量),引用类型适用。因此,我们得到100.0,这是Vehicle中的cost实例变量(而不是20_000.0,这是Car中的cost实例变量)。

在访问静态成员时使用类名

第 28 行和第 29 行展示了你应该永远不要使用的语法:使用引用来访问static成员。在访问static成员时,你应该在成员前加上类名。例如,第 28 行应该使用Vehicle.age,而第 29 行应该使用Vehicle.sm(),因为这强调了成员的static属性。在这里使用引用是令人困惑的,因为它暗示成员是非静态的。我们仅为了演示目的使用引用来访问static成员!

第 28 行输出v.age。由于age是一个static成员,编译器会检查v的类型(即Vehicle),并将v.code更改为Vehicle.code。因此,使用的是Vehicle中的age,而不是Car中的age。换句话说,输出是 1,而不是 2。

第 29 行是对v.sm()的调用。由于sm()也是static的,编译器将其转换为Vehicle.sm(),因此输出是Vehicle::sm()

最后,第 30 行是对move()的多态调用,因此使用的是对象类型Car。这导致输出Car::move()

现在我们已经理解了多态,让我们确保我们理解了两个经常被混淆的术语之间的区别,即方法重写和方法重载。

对比方法重写和方法重载

这两个术语经常被混淆,但在本节中,我们将比较和对比这两个术语。我们将展示,在方法重载的情况下,方法签名必须不同;而在方法重写的情况下,方法签名必须相同。回想一下,方法签名由方法名和参数类型组成,包括它们的顺序。返回类型和参数标识符不是方法签名的一部分。所以,例如,考虑图 9**.5中的方法:

public static void doAction(Vehicle v){…}

签名为doAction(Vehicle)

在这个前提下,我们将首先讨论方法重载。

方法重载

回想一下,方法签名由方法名和参数类型组成。方法重载是你有相同的方法名,但参数不同,无论是类型和/或顺序。这意味着即使方法名相同,方法签名也不同。它们必须这样,否则编译器如何选择绑定哪个方法?因此,方法重载完全是关于编译时间的。

规则

考虑到方法签名必须不同(除了方法名之外),规则相当简单:

  • 重载方法必须使用不同的参数列表;要么使用的类型必须不同,要么类型的顺序必须不同

  • 由于方法签名只与方法名和参数列表相关,重载方法可以更改返回类型和访问修饰符,并使用新的或更广泛的检查异常

  • 重载方法可以在同一类型或子类型中进行重载

现在,让我们看看代码中方法重载的一个例子。

方法重载示例

图 9**.7 展示了示例代码:

图 9.7 – 方法重载

图 9.7 – 方法重载

在这个图中,我们将首先讨论第 10-17 行之间的方法重载。为了帮助理解,每行上的注释中都包含了方法签名。第 10 行定义了一个calc方法,该方法接受一个int和一个double类型的参数,顺序如下。因此,签名如下:

calc(int, double)

我们对返回类型或用于intdouble参数的标识符不感兴趣。只要我们不在同一个类中编码另一个calc(int, double)方法,我们就没问题。注意,如果我们在一个子类型中编码了一个具有相同签名的函数,这将是重写!由于第 11-14 行之间的方法签名不同,它们是可行的。

让我们分析为什么第 16 和 17 行无法编译。第 16 行试图只更改参数使用的标识符。这并没有改变方法签名。因此,这个签名与方法第 10 行的签名完全匹配,因此编译器会报错。同样,第 17 行更改了返回类型(以及参数列表中的标识符)。再次,由于这个签名与第 10 行的签名重复,编译器会报错。

继承层次结构很有趣。我们有一个父类Animal(第 2-4 行)和一个子类Cow(第 5-8 行)。在第 3 行,Animal定义了一个eat()方法。在第 7 行,Cow使用eat(String)方法重载了这个方法。父类Animal版本不接受任何参数,而子类版本接受String参数。编译器很高兴。

但第 6 行呢,Cow类定义了一个不接受任何参数的eat()方法?这是在覆盖父类版本(多态),所以没有冲突。编译器将绑定到使用的引用类型,无论是Animal还是Cow,因为两者都有一个eat()方法。在运行时,根据对象类型,JVM 将执行相关代码。

让我们检查这个过程以确保它清晰。第 20 行创建了一个Animal对象,并使用一个Animal引用aa来引用它。第 21 行调用aa.eat()。在编译时,编译器检查Animal类中是否有具有该确切签名的eat()方法,因为Animalaa的类型。由于有,编译器很高兴。在运行时,由于方法是实例方法,多态适用,JVM 将执行Animal版本(因为这是对象类型)。

注意第 22 行为什么无法编译。这是因为Animal类中没有eat(String)方法。记住,编译器只查看引用类型,而aa的类型是Animal,它检查Animal类。

第 24-26 行进一步说明了问题。第 24 行创建了一个Cow对象,并使用一个名为acAnimal引用来引用它。第 25 行进行了多态调用eat(),这将执行运行时的Cow版本。第 26 行很有趣,它存在是为了证明编译器正在查看引用类型。尽管我们的对象类型是CowCow有一个eat(String)方法,但ac.eat("Grass")类仍然无法编译(因为ac的类型是Animal)。

那么,我们如何访问eat(String)方法呢?我们需要一个Cow引用。这正是第 28-30 行所展示的。第 30 行成功使用第 28 行声明的cc引用调用了cc.eat("Grass")

这段代码展示的是,一个Animal引用只能访问它定义的eat()方法。另一方面,一个Cow引用可以访问eat()eat(String)Cow类型继承(并覆盖)了eat()并自己定义了eat(String)。注意,Cow类不需要覆盖eat()就能访问继承的版本。

方法覆盖

当你在父类和子类中都有相同的方法签名时,就会发生方法重写。方法重写对于启用(运行时)多态至关重要。记住,一个方法必须首先被继承才能被重写。例如,定义为privatestaticfinal的方法不会被继承,因为private方法仅限于类内部;static方法不是多态的,将方法标记为final是声明“这个方法不应该被重写”。

要理解规则,关键是要记住编译器是根据引用来编译代码的。因此,运行时多态方法必须与编译器验证的行为一致。例如,重写方法上的访问修饰符不能更严格。

在我们讨论规则之前,我们必须首先解释协变返回。

协变返回

当你在子类中重写父类的方法时,如果返回类型是原始类型,那么重写方法的返回类型必须匹配。然而,如果返回类型是非原始类型,那么有一个例外:协变返回。

协变返回的含义是,如果你在父方法中返回类型X,那么你可以在重写方法中返回X及其任何子类型。例如,如果父方法返回Animal,那么重写方法可以返回Animal(自然地),以及Animal的任何子类型;例如,Cow

规则

在我们讨论规则时,记住编译器是针对引用类型进行检查是有帮助的。这些重写规则确保运行时对象不能做编译器(以及你的代码)没有期望的事情。规则如下:

  • 方法签名必须在父类和子类中完全匹配;否则,你只是在重载方法。

  • 返回类型也必须匹配,除了协变返回。

  • 重写方法上的访问修饰符不能更严格。所以,如果父方法将方法定义为public,子类不能使用private方法来重写它。这很有意义,因为你的代码,经过编译器的验证,期望可以访问该方法。然而,如果你被允许在重写时降低访问级别,编译器会说“可以访问这个方法”,而 JVM 则不会!这条规则有助于保持编译器和 JVM 同步。

  • 再次强调,为了保持编译器和 JVM 同步,重写方法不能抛出(生成)新的或更广泛的已检查异常(第十一章)。简而言之,异常是一个错误,已检查异常必须有代码来处理它们。这是由编译器强制执行的。如果在运行时,重写方法抛出/生成了一个没有代码来处理的异常,JVM 就会遇到麻烦。因此,编译器介入并阻止这种情况发生。

现在,让我们看看代码中方法重写的一个例子。

方法重写示例

图 9.8展示了示例代码:

图 9.8 – 方法重写

图 9.8 – 方法重写

该图中的代码演示了在重写方法时可以做什么和不能做什么。在Dog类(第 5-8 行)中,我们有一个返回无内容(void)的walk()方法。还有一个返回Dogrun()方法。

Terrier类从Dog类继承(第 9 行)。因此,任何Terrier都是Dog的子类。由于Dog中的两个方法都是publicTerrier自动继承它们。

让我们依次检查Terrier中的行。

第 10 行无法编译,因为虽然方法签名匹配(两者都是walk()),但返回类型不同。父类返回类型是void,因此,重写返回类型必须匹配;它不匹配,是String,导致编译器错误。

第 11 行无法编译,因为在重写时不能弱化访问修饰符。Dog中的walk()方法是public,所以Terrier中的walk()不能是private如果这被允许,那么当 JVM 执行Terrier中的walk()方法时(如第 24 行所示),将会有严重问题。编译器查看publicDog版本,会说“一切正常;”但 JVM 会多态地遇到Terrier中的private版本!

第 12 行无法编译,因为被重写的方法没有抛出任何异常,但重写的方法正在尝试抛出一个新的已检查异常(IOException)。这与之前的访问问题类似——编译器将检查Dog中的walk()版本,并且因为它没有抛出异常(错误),没有代码来处理(适应)这些异常。如果重写方法被允许抛出新的已检查异常,JVM 将如何处理它们(因为没有代码来处理它们)?

第 13 行只是一个重载。Dog定义了一个walk()方法;Terrier定义了一个walk(int)方法。两个不同的方法签名意味着两个不同的方法。由于方法具有相同的名称,这属于方法重载。

第 14 行是一个正确的方法重写。我们使用了@Override注解来确保我们已经正确地重写了(例如,没有打字错误)。

第 16 行是第 7 行定义的run()方法的精确复制。我们只是为了演示目的而包含它。

第 17 行展示了协变返回,因为它定义了Terrier返回类型。这是一个有效的协变返回,因为Terrier是父类返回类型Dog(第 7 行)的子类型。重写方法的代码(第 17 行)简单地返回一个Terrier对象。

第 18 行几乎与第 17 行相同,除了返回类型现在是Dog。因此,在后台发生了向上转型。第 18 行的walk()代码是以下代码的简写:

Dog d = new Terrier():return d;

现在,让我们看看OverridingTest中的main()方法。

第 23 行创建了一个可以通过 Dog 引用 dt 访问的 Terrier 对象。第 24 行在 Terrier 中调用了多态的 walk() 方法。由于 Terrier 覆盖了从 Dog 继承的 walk() 方法,因此在运行时动态执行的是 Terrier 版本的 walk(),导致输出 Terrier::walk()

第 25 行使用第 23 行创建的 dt 引用执行了 run() 方法。由于 run() 是一个实例方法,其中 Terrier 覆盖了从 Dog 继承的版本,因此执行的是 Terrier 中的版本,导致第 25 行的 d 引用指向一个 Terrier 对象(第 18 行)。这通过使用 instanceof 操作符(第 26 行)得到了证明。由于 Dog 引用 d 确实指向一个 Terrier 对象,因此 if 语句为 true,导致在屏幕上输出 Terrier 对象

这就结束了我们对方法重载和方法覆盖的讨论。现在,让我们考察一个在继承中至关重要的关键字:super

探索 super 关键字

super 关键字在子类中有两个特定的使用场景:调用父构造函数和访问父成员(通常是方法)。当对象被构造时,构造函数调用的顺序非常重要。考虑到我们现在有可能在继承层次结构中有许多类,构造函数调用的顺序是从上到下的。这意味着父构造函数总是先于子类构造函数被调用。如果你有一个 ToyotaCar 的子类,而 Car 又是 Vehicle 的子类,那么当你创建一个 Toyota 对象时,构造函数调用的顺序如下:首先调用 Vehicle 的构造函数,然后是 Car,最后是 Toyota

这有一个很好的原因。首先,记住构造函数的作用是初始化类的实例成员。现在,考虑到子类构造函数在初始化自己的成员时可能会使用从其父类继承的成员,因此可以合理地认为父类必须首先有机会初始化这些成员。

让我们讨论 super 关键字经常被使用的情景。然后我们将展示代码,并辅以 UML 图形,以演示这两种上下文。

super()

当你在 super 后面使用括号,如 super() 时,你是在调用父构造函数。如果需要,你可以在括号内传递参数,因为构造函数只是(特殊的)方法。使用 super() 有两个规则:

  • 调用 super() 只能在构造函数内部出现,而不能在普通方法中

  • 如果存在,super() 的调用必须是构造函数中的第一行(有一个例外——见注释)

我们已经编写了几个构造函数,但它们都没有包含对super()的调用。这是怎么工作的呢?好吧,如果你没有提供任何构造函数,编译器会为你生成默认构造函数,并且其第一行代码是super();。请参考图 8.1 和图 8.2 中的示例。如果你提供了构造函数,编译器也会插入super();作为第一行(除非第一行已经是super()this()的调用)。

任何构造函数的第一行

任何构造函数的第一行必须是this()super()。两者不能同时存在。对this()的调用是对同一类中另一个构造函数的调用。从继承层次的角度来看,这是一个横向调用。请记住,在子类构造函数之前必须调用父类构造函数。无论是否有this(),构造函数调用的顺序都是从上到下。现在,如果子类构造函数中存在this()调用,它只会延迟对super()的调用。在某个时刻,无论是显式还是隐式,对super()的调用都将执行。注意,与super()一样,对this()的调用可以包含参数。

因此,super()仅与构造函数相关,并且必须是代码的第一行(假设this()已经存在)。现在,让我们考察另一种情况。

super.

要访问父成员(不是构造函数),你可以使用super.点符号语法。与this关键字一样,super关键字与实例相关,因此不能在static上下文中使用(static方法或static块)。这在你想要利用父功能时非常有用。例如,子类方法可以先调用其父版本,然后再执行自己的版本。这正是我们将通过示例来展示的。

因此,与其从子类构造函数中调用父类构造函数(这正是super()的作用),super.为我们提供了访问其他(非构造函数)成员的能力。

使用 super 的示例

让我们通过代码来考察super()super.。图 9.9 展示了 UML 继承图:

图 9.9 – 展示 super()和 super.的 UML 图

图 9.9 – 展示 super()和 super.的 UML 图

在这个图中,我们有三个类代表一个类继承层次结构。Employee位于层次结构的顶部。Manager“是”EmployeeDirector“是”Manager。间接地,Director“是”Employee。每个类都有其各自的构造函数将初始化的private实例变量。例如,Employee构造函数接受两个参数,int后跟String;这些参数将被用来初始化Employee实例变量,即empIdint)和nameString)。

EmployeeTest只是一个驱动程序,以确保代码按预期工作。让我们检查一下代码。图 9**.10图 9**.9中 UML 的代码:

图 9.10 – 展示 super 的代码

图 9.10 – 展示 super 的代码

在这个图中,Employee类初始化其实例变量(第 8-9 行)。EmployeetoString()方法(第 11 行)返回一个String,概述了empIdname实例变量的值。第 11 行还使用了@Override注解,因为它正在重写从Object继承来的toString()方法。

Manager类“是”Employee(第 13 行)。Manager包含(由String实例变量组成)名为deptName的变量。这被称为组合。

组合与继承

组合定义了一个“具有”关系,而继承定义了一个“是”关系。组合是指一个对象由其他对象“组成”。例如,Car具有Engine。在图 9**.10中,Manager“是”Employee(第 13 行),但Manager“具有”一个部门,这由String实例变量deptName(第 14 行)表示。

Manager构造函数(第 16-19 行)是事情变得有趣的地方。第 17 行,super(empId, name),是调用Employee中的父构造函数,传递了Employee构造函数所需的员工 ID(empId)和员工姓名(name)。这就是为什么Manager构造函数最初需要这些参数的原因——它需要员工 ID 和员工姓名,以便调用其父Employee构造函数。Manager构造函数还需要部门名称,以便初始化其自己的实例变量deptName。因此,当执行Manager构造函数时,首先执行Employee构造函数,然后执行Manager构造函数。

注意,如果第 17 行被注释掉,代码将无法编译。为什么?因为编译器现在将插入super();它试图调用不带参数的Employee构造函数(即no-args构造函数,即Employee())。在Employee中没有这样的构造函数。此外,由于Employee已经定义了一个构造函数,编译器将不会插入默认的(不带参数的)构造函数。

Manager类的toString()方法(第 21-24 行)覆盖了从Employee继承来的版本。然而,Manager仍然可以访问Employee的版本,它是通过在第 23 行使用super.toString()来实现的。因此,Manager中的toString()方法首先执行Employee中的toString()方法,该方法返回员工 ID 和员工姓名。然后,Manager类的toString()方法将它的自己的实例变量deptName追加到要返回的整体String中。

Director 类的行为与 Manager 类相似。构造函数“向上传递”(第 30 行)了 Manager 构造函数所需的数据;反过来,Manager 构造函数向上传递了 Employee 构造函数所需的数据。因此,在创建 Director 对象时,构造函数调用的顺序如下:首先 Employee,然后 Manager,最后 Director。在第 31 行,Director 初始化其自己的实例数据。

在第 33 行,Director 版本的 toString() 首先使用 super.toString() 调用 Manager 版本的 toString()Manager 版本(第 23 行)然后调用 Employee 类的 toString() 方法,该方法在第 11 行。因此,员工的 ID 和姓名是字符串中的第一个员工详细信息。接下来,将经理数据(deptName)附加到字符串中(在调用 Employee 类的 toString() 方法返回之后)。最后,将 Director 数据(budget)附加到字符串中(在调用 Manager 类的 toString() 方法返回之后)。请注意,您不能绕过层次结构中的任何一级;这意味着不允许使用 super.super.

EmployeeTest 是驱动类。在 main() 函数的第 39 行,我们创建了一个 Director 对象,该对象可以通过 emplDirEmployee 引用访问(隐式向上转型)。按照概述使用 super(),这将导致首先执行 Employee 构造函数,然后是 Manager 构造函数,最后执行 Director 构造函数。

第 40 行将 emplDir 引用传递给 System.out.println(),导致对 Director 类的 toString() 方法的多态调用。使用 super.toString()Director 调用 Manager 类的 toString() 方法,该方法也有一个 super.toString() 方法,导致首先执行 EmployeetoString() 方法。然后,Manager 类的 toString() 方法完成,最后,Director 类的 toString() 方法完成。输出显示如下:

ID: 754, Name: Joe Bloggs, Department: Marketing, Budget:10000.0

关于输出,ID: 754, Name: Joe Bloggs 是从 Employee toString() 输出的,Department: Marketing 是从 Department toString() 输出的,而 Budget: 10000.0 是从 Director toString() 输出的。

这就结束了我们对 super 的讨论。现在,正如在 第八章 中所承诺的,我们已经理解了继承,让我们回到 protected 访问修饰符。

回顾 protected 访问修饰符

回想一下,一个protected成员可以从其自身包内以及包外的任何子类中访问:protected = package + children。表面上,这似乎非常简单。然而,一些细微差别会导致混淆。访问protected成员的子类(通过继承),只能以非常具体的方式进行。来自包外部的子类不能使用超类引用来访问protected成员!此外,来自包外部的无关类也不能使用对包外子类的引用来访问protected成员。实际上,一旦包外部的子类继承了protected成员,该成员对该子类(以及子类的子类)来说就变成了private。这相当棘手,确实需要举例说明。

UML 图

图 9.11显示了此例的 UML 图:

图 9.11 – “protected”代码的 UML

图 9.11 – “protected”代码的 UML

在这个图中,我们有两个包,即ch9.pkgAch9.pkgB。在ch9.pkgA中,我们有一个Book类及其子类NonFictionBookBook中的read()方法用#符号标记,这意味着它是protected的。Magnifier类与Book无关,只是同一包中的另一个类。

ch9.pkgB中,FictionBookch9.pkgABook类中继承,并提供了doThings()方法,我们将用它来演示允许和不允许的情况。此外,SpaceFictionBookFictionBook继承,并覆盖了从FictionBook继承的doThings()方法。最后,Reader是一个完全独立的类,与Book层次结构无关;它的doThings()方法也是一个用于演示目的的示例方法。

回想一下上一章,我们没有完全完成访问修饰符表(因为我们当时还没有涉及继承)。表 9.1表示完成的访问修饰符表。请注意,该表表示在Book类中注释一个成员。

表 9.1 - 填充了“protected”行的访问修饰符表

表 9.1 - 填充了“protected”行的访问修饰符表

检查protected行,我们现在可以看到,无论子类属于哪个包,都可以访问继承的受保护的成员。

现在,让我们依次检查每个包的代码。首先,我们将检查定义protected成员的包。

包含受保护成员的包

图 9.12显示了来自图 9.11的第一个包ch9.pkgA的代码:

图 9.12 – 来自 UML 的“ch9.pkgA”代码

图 9.12 – 来自 UML 的“ch9.pkgA”代码

在这个图中,有一个名为Book的类(第 3-5 行),它定义了一个protectedread()方法(第 4 行)。NonFictionBookBook的子类,并有自己的doThings()方法(第 7-9 行)。此外,还有一个与Book完全无关的类,即Magnifier

首先要注意的是,由于read()方法是protected的,同一包中的其他代码可以访问它,即使代码不是子类。这由第 14 行演示,其中从完全无关的类Magnifier访问了Book中的read()方法。

当然,无论包是什么,子类都可以访问protected成员。这在第 8 行显示,其中NonFictionBook子类调用了read()。记住,第 8 行实际上是this.read()。所以,无论使用哪个NonFictionBook对象在第 7 行调用doThings(),都会用来在第 4 行调用继承的(并且是protected的)read()方法。

有趣的代码在另一个包中,即ch9.pkgB。现在让我们检查一下。

另一个包

图 9**.13 展示了代码:

图 9.13 – 来自 UML 的“ch9.pkgB”代码

图 9.13 – 来自 UML 的“ch9.pkgB”代码

在这个图中,我们可以看到FictionBook“是”Book(第 5 行),而SpaceFictionBook“是”FictionBook(第 19 行)。为了使这个层次结构成为可能,Book类需要从另一个包中导入(第 3 行)。我们之所以能够从另一个包中导入Book,是因为Book是一个public类。此外,还有一个完全无关的类叫做Reader(第 27-36 行)。

现在,让我们来点有趣的!让我们检查一下FictionBook中的dothings()方法(第 6-17 行)。第 7 行和第 8 行实际上是等价的,显示了当子类在包外部使用直接继承时,可以访问protected成员。

第 9-10 行也显示,当在包外部的子类内部时,如果你创建该特定子类的实例(在这个例子中是FictionBook),那么一切正常。这很有道理,因为用于调用read()而不出问题的两个引用,即thisfb,都是FictionBook类型,代码就在那里。

注意第 15 行,我们实例化了一个Book对象,它可以编译,因为Book类(图 9.12,第 3 行)是public的。Book类没有定义构造函数,因此为我们创建了一个默认构造函数。这个默认构造函数具有与类相同的访问权限,即public,因此我们可以从不同的包中调用构造函数。

第 16 行无法编译,非常有趣。当在包外部的子类内部时,你不能使用超类引用来访问protected成员——即使protected成员位于那个超类中!记住,一旦超出包,protected成员对子类(及其子类)来说就变成了private。换句话说,你必须非常具体地使用继承。

SpaceFictionBook (第 19-26 行) 显示,包外子类的子类可以访问权限。第 21 行与第 7 行相同,只是它们位于两个不同的类中。当这一行编译时,它证明了包外子类的子类可以访问基类中的protected成员。

第 22 行和第 23 行都无法编译。第 22 行试图通过Book引用访问protected成员,而第 23 行试图通过FictionBook引用访问它。两者都失败了。与第 24 行形成对比,它使用当前类的实例,即SpaceFictionBook,这是可行的。注意,第 24 行与第 21 行相似,因为在两种情况下都使用了SpaceFictionBook引用(因为第 21 行等价于this.read())。此外,第 24 行与第 9-10 行非常相似。因此,当在包外的子类中,直接访问protected成员,就像第 7 行、第 21 行那样;或者使用当前子类的引用,就像第 10 行、第 24 行那样。

Reader类(第 27-36 行)与Book层次结构完全独立。第 30 行试图使用定义protected成员的类的引用来访问protected成员,即Book,并失败了。第 34 行试图使用继承protected成员的包外子类的引用来访问它,即FictionBook,也失败了。

所以protected有些棘手。当我们回顾先前的主题时,这是一个回顾switch的理想机会。更具体地说,讨论switch的模式匹配。

switch的表达式模式匹配

正如从第四章中承诺的,现在我们理解了继承和多态,我们将重新审视switch。考虑到以下代码:

public static void patternMatchingSwitch(Vehicle v) {    System.out.println(
        switch(v){
case Boat b -> "It's a Boat";
            case Train t -> "It's a Train";
            case Car c when c.getNumDoors() == 4  ->
                "Saloon "+ c.onRoad(); // custom Car method
            case Car c when c.getNumDoors() == 2  ->
               "Convertible: " + c.onRoad();
            case null, default -> "Invalid type";
        }
);
}

假设CarBoatTrain都扩展自Vehicle,并且Car有一个自定义方法onRoad()。正如你所见,在这个switch表达式中,选择器表达式v可以是任何引用类型(BoatTrainCar等等)。case标签展示了类型模式和模式匹配;例如,Boat b

此外,Car的两个case标签都被称为保护模式。保护模式是位于when子句右侧的“保护器”上的case标签。保护器是一个条件表达式,其结果为真或假。注意使用自定义的Car方法onRoad(),以及不需要进行类型转换的事实,因为类型转换已经在后台为我们完成了(前提是我们处理的是Car)。

最后一个包含defaultcase标签确保了穷举性,从而让编译器满意。换句话说,所有可能的Vehicle都被考虑到了。注意,null也被用作一个有效的标签,以及nulldefault可以用逗号分隔。

现在,让我们考察两个特定关键字对继承的影响,即abstractfinal

解释抽象final关键字

如我们所知,在编写方法时,我们可以应用访问修饰符关键字,即privateprotectedpublic和包私有(没有关键字)。还有两个关键字在继承方面具有特殊意义:abstractfinal。两者互为对立,这就是为什么它们不能同时应用于一个方法。现在让我们来讨论它们,从abstract开始。

抽象关键字

抽象关键字应用于类和方法。虽然抽象类将在第十章中更详细地讨论,但我们也会在这里讨论它们(原因很快就会变得明显)。一个抽象方法没有实现(代码)。换句话说,方法签名,而不是跟随大括号{},它代表实现,一个抽象方法签名只是简单地跟随一个分号。将方法标记为抽象意味着以下内容:

  • 类必须也是抽象

  • 第一个具体(非抽象)子类必须为抽象方法提供实现

让我们更详细地讨论这个问题。当你将一个方法(或多个方法)标记为抽象时,你是在说这个方法没有实现代码。由于有“缺失”的部分,类本身也必须被标记为抽象。这告诉编译器该类是不完整的,因此,你不能基于一个抽象类实例化(创建)对象。换句话说,你不能在抽象类上执行new操作(尽管引用是完全可行的)。抽象方法(以及因此抽象类)的全部理由是让子类覆盖它们,在子类中提供“缺失”的实现代码。现在,如果直接子类没有为继承的抽象方法提供实现代码,那么该子类也必须是抽象的。因此,一个抽象类的第一个非抽象(具体)子类必须为抽象方法提供实现代码。图 9**.14展示了这些原则:

图 9.14 – “抽象”关键字的作用

图 9.14 – “抽象”关键字的作用

在这个图中,我们在第 4 行有一个抽象方法,即write()。注意方法中没有花括号;我们只是在括号后立即有一个分号。由于Pencil类(第 3-5 行)包含一个抽象方法,因此该类本身也必须是抽象的;它确实是(第 3 行)。

在第 6 行,CharcoalPencil试图继承Pencil。但是因为(a)它没有为从Pencil继承的抽象方法write()提供实现,并且(b)CharcoalPencil本身不是抽象的,所以CharcoalPencil无法编译。

将第 6 行与第 7 行进行对比。正如我们所见,第 6 行无法编译。然而,第 7 行WaterColorPencil可以编译。为什么?因为WaterColorPencilabstract的;它没有为abstract方法write()提供实现,这并不成问题。

抽象类不必有抽象方法

正如我们所知,如果你有 1(或更多)abstract方法,那么这个类必须是abstract的。然而,情况并非总是如此。换句话说,一个abstract类根本不必有任何abstract方法!注意,WaterColorPencil图 9.14 中的第 7 行)是这样的一个类的例子。它是abstract的,但没有任何方法。这是可以的。这可能是设计决策,即使类只包含具体方法,你仍然希望这个类被用作引用类型而不是对象类型(因为你不能new它)。

GraphitePencil类(第 8-13 行)是一个具体、非抽象类。因为它extendsabstractPencil,它必须为abstract方法write()提供实现。这是在第 10 到 12 行完成的,我们使用@Override注解来强调这一点。

第 17 行演示了你不能实例化一个abstract类的对象。Pencil pp语句的引用部分是好的。问题是new Pencil()部分。

第 18 行显示了允许的情况。同样,我们再次使用Pencil引用,但这次我们引用的是一个GraphitePencil对象。GraphitePencil是一个具体类(第 8 行)。第 19 行多态地调用了GraphitePencil(第 10-12 行)提供的write()方法。假设第 6 行和第 17 行被注释掉(这样代码就可以编译),第 19 行输出GraphitePencil::write()

现在我们已经理解了abstract方法和类,让我们来检查final关键字。

final关键字

final关键字可以应用于各种上下文。继承是这里的主要焦点,但我们也将检查其他情况。我们将逐一检查它们,然后查看演示它们的代码。我们将从final方法开始。

final方法

一个final方法不能在子类中被重写。这防止了子类的不当更改。我们可以通过final类进一步探讨这一点。

final

被标记为final的类不能用作基类。这意味着你不能从一个final类扩展。类中的所有方法都是隐式final的。Java 在其 API 中使用这一点来保证行为。例如,String类是final的,这样就没有人可以扩展它并提供自定义实现。因此,Java 总是知道字符串的行为。现在,我们将检查final方法参数。

final方法参数

final方法参数是一个不能更改的参数。然而,请注意,根据参数类型,其语义可能会有细微差别。如果参数类型是原始类型,例如int,那么你不能更改int参数的值。

然而,如果相关的参数是一个引用(而不是原始类型),final应用于引用,因此,不能更改的是引用本身。换句话说,引用指向的对象是可修改的,但引用本身不是。这意味着,例如,如果方法接受一个Dog引用,即dog,那么使用dog引用,你可以改变对象的属性,例如dog.setAge(10)。然而,你不能将dog改为指向不同的对象,例如dog = new Dog()

final(常量)

常量是一个不能改变的值。使用大写字母作为常量的标识符是一种习惯,并且是良好的实践,每个单词之间用下划线分隔。这使得它们更加突出,开发者知道它们不能更改。Java API 中的一个例子是Math类(在自动导入的java.lang包中)的*PI*常量。它是final的,因此不能更改。为了提供方便的访问,PI也是publicstatic的。

现在,让我们通过一个代码示例来加强final的使用。图 9**.15展示了以下代码:

图 9.15 – “final”关键字的作用

图 9.15 – “final”关键字的作用

在这个图中,我们有一个名为Earthfinal类(第 3 行)。第 5 行通过编译器错误演示了你不能从一个final类扩展。

第 8 行在Pen类中定义了一个名为write()final方法。因此,当尝试覆盖write()时,FountainPen类会遇到编译器错误(第 15 行)。

第 11 行显示你不能将一个方法同时标注为abstractfinalabstract意味着这个方法将在子类中被覆盖;final意味着这个方法不能被覆盖。

第 18 行声明了一个名为ONE_YEAR的常量,并将其设置为1。第 27 行尝试更改常量值 – 由于不允许这样做,编译器会报错。

print()方法(第 19-28 行)概述了final对方法参数的含义。方法参数(第 19 行)分别是final String namefinal int ageString是一个非原始类型,因此name是一个引用。换句话说,name内部的价值是对象在堆上的内存位置(引用)。另一方面,age只是一个原始的int类型,其值是一个简单的整数,例如1。当你将value视为final时,很容易理解你可以和不能做什么。因此,如果1age中,它不能被更改,name中的引用(地址)也不能更改。然而,name引用的对象可以被修改。

第 21 行是一个编译器错误,展示了final原始数据类型不能被更改。

第 23 行显示我们能够访问(如果需要的话可以修改)引用所指向的对象。请注意,在这个特定的例子中,因为String是不可变对象,所以toUpperCase()方法返回新的大写String,而不是改变原始的。我们将在第十二章中更多地讨论String。重要的是要注意,编译器对第 23 行没有问题。

第 25 行尝试将String引用name改为指向不同的String。由于引用是final的,编译器会报错。再一次,引用和对象的分离使得事情更容易理解。

到目前为止,我们知道如何创建(无限的)继承层次结构(使用extends)。我们还知道final禁用了继承。如果我们想要一个“中间地带”,在这个地带中我们可以根据某些类型定制我们的层次结构,那会是什么样子?这就是密封类所允许的。现在让我们来讨论它们。

应用密封类

密封类是在 Java 17 中引入的。我们在这里要讨论的内容与类相关,但同样的逻辑也适用于接口(第十章)。使用继承,你可以使用extends关键字从任何类(或接口)扩展,当然,前提是这个类不是final

注意

接口不能是final的,因为它们的整个理由就是要被实现。

考虑以下场景:如果你希望你的类可以被继承,但仅限于某些类?换句话说,你想要限定允许的子类范围。到目前为止,使用extends关键字进行继承使得每个类都可以成为子类,而final关键字则阻止一个类有子类。

这就是密封类有用的地方——它们允许你指定允许哪些子类。只是为了重申,这也适用于接口,我们可以指定允许实现接口的类。

在我们看例子之前,有一些新的关键字我们需要理解。

密封和允许

这些关键字共同工作。为了声明一个类是密封的,你可以简单地指定它就是密封的,即sealed。然而,一旦你这样做了,你必须指定哪些类可以从这个类扩展。为此,你使用permits关键字,后面跟着用逗号分隔的类列表。

非密封

当你开始限定/限制一个层次结构时,在指定子类时必须使用某些关键字。参与密封层次结构的子类必须声明以下之一:

  • 它也是密封的。这意味着我们还有进一步的限定要执行,因此我们必须在这个子类上使用sealed/permits配对来指定允许的子类。

  • 这是层次结构中的final类(不允许有更多的子类)。

  • 这结束了限定。实际上,你希望再次打开层次结构以进行扩展。为此,我们使用non-sealed关键字,因为non-sealed类可以被继承。

现在,让我们来看一个例子。

使用密封、允许和非密封的示例

图 9.16展示了我们将使用的代码示例的 UML 图:

图 9.16 – “密封”类的 UML 图

图 9.16 – “密封”类的 UML 图

在这个图中,我们有一个Vehicle层次结构。我们将要限制(密封)的部分是VehicleCarSaloon类。因此,唯一可以成为Vehicle子类的类是Car;唯一可以成为Car子类的类是Saloon。请注意,尽管图表暗示TruckVehicle的子类,而ConvertibleCar的子类,但在这个示例中,我们将通过代码防止这种情况发生。

代码的目标是确保我们感兴趣的Vehicle只有Car,我们感兴趣的Car只有Saloon。此外,所有SaloonFordVolvo)都是感兴趣的。图 9.17展示了代码。

图 9.17 – “密封”代码

图 9.17 – “密封”代码

在前面的图中,第 3 行声明我们有一个名为Vehicle的密封类,并且允许的唯一子类(允许的)是Car。在此阶段,Car类必须存在;否则,编译器会报错。

第 4 行定义了一个名为Car的密封类,它是Vehicle的子类(由于第 3 行的原因,它必须这样做),并且允许的唯一子类是Saloon。请注意,当我们定义Car时,我们必须指定Carsealednon-sealed还是final

第 5 行是尝试将Truck类作为Vehicle子类的Truck类。然而,由于我们已经将Vehicle密封,只允许Car作为子类,这会生成编译器错误。

第 6 行将Saloon定义为Car的子类(正如第 4 行所预期的)。在这种情况下,我们选择通过声明Saloonnon-sealed来打开层次结构以供进一步扩展(由任何类)。第 7 行和第 8 行通过允许VolvoFord分别从它扩展来展示Saloon是一个非密封类。

最后,在第 9 行,Convertible尝试将Car类作为子类。这不被允许,因为第 4 行声明,允许的Car类的唯一子类是Saloon

现在让我们继续讨论实例和static块。

理解实例和静态块

如我们所知,在 Java 中,代码块由大括号{}界定,这些代码块没有不同。实例和static代码块的不同之处在于它们出现的位置,换句话说,它们的范围。这两个代码块都出现在每个方法之外,但出现在类内部。

我们将逐一讨论它们,然后通过代码示例来展示它们的工作原理。我们将从实例块开始。

实例块

实例块是一组大括号,它出现在任何方法之外但出现在类内部。假设一个类中存在实例块,每次创建对象(使用new)时,实例块都会被执行。请注意,实例块在构造函数之前执行。为了技术上的准确性,super()首先执行,这样父构造函数就有机会执行;然后是实例块,之后是构造函数的其余部分执行。使用“sic”(super, instance block, constructor)这个缩写可以帮助记住顺序。你可以认为编译器在调用super()之后将实例块插入到构造函数代码中。如果一个类中存在多个实例块,它们将按照出现的顺序执行,从上到下。

作为一个例子,实例块作为每个构造函数的一部分执行,因此是插入你希望每个构造函数都拥有的代码的理想位置。换句话说,所有构造函数中通用的代码应该放入实例块中。这样可以避免在构造函数之间重复代码。

正如我们所知,父构造函数必须在子构造函数之前执行。实例块也是如此。换句话说,父实例块必须在子实例块之前执行。我们将在代码示例中看到这一点。

静态块

static块是一组大括号,它由static关键字开头,出现在任何方法之外但出现在类内部。static块只执行一次,即在类第一次被加载时执行。这可能会发生在创建类的第一个对象或第一次访问static成员时。static块在实例块之前执行(因为我们必须在执行构造函数之前加载类文件/字节码)。一旦执行,由于类文件现在已加载到内存中,static块将不再执行。

与实例块类似,如果一个类中存在多个static块,它们将按照出现的顺序执行,从上到下。同样,如果涉及到继承,那么父static块将在子static块之前执行。

这一切都会在代码示例中变得更加清晰,我们将能够比较和对比继承层次结构中这两种类型的代码块。

图 9.18 展示了以下代码:

图 9.18 – 实例和“static”代码块示例

图 9.18 – 实例和“static”代码块示例

在这个图中,我们有一个名为Parent的父类和一个名为Child的子类(想出这些名字花了一些时间!)。这两个类都有两个实例初始化块、两个static初始化块和一个构造函数。请注意,static初始化块(第 7、12、16 和 21 行)都是简单地用static关键字前缀的代码块。同时,注意它们的位置/作用域——在方法之外但在类内部。实例初始化块(第 5、11、15 和 20 行)也是如此,只是实例块前面没有关键字。

主要驱动类InitializationBlocks也有一个static初始化块和一个实例初始化块(分别在第 24 和 25 行)。

这些块简单地输出跟踪消息,以便我们知道当前正在执行哪个代码块。跟踪消息用递增的数字标注,以便我们更容易地跟踪执行顺序。图 9**.19展示了图 9**.18中的代码输出:

图 9.19 – 图 9.18 中的代码输出

图 9.19 – 图 9.18 中的代码输出

注意

为了避免在图 9**.19中代表输出的数字与图 9**.18中的行号混淆,这里提到的所有数字都指的是图 9**.19中的输出数字。任何与图 9**.18相关的行号将明确标注为“行....”

所有 Java 程序都以main()方法开始。因此,JVM 必须使用CLASSPATH环境变量查找包含main().class文件,即InitializationBlocks.class。当 JVM 加载类时,如果类有父类,它会先加载父类。在这个例子中,因为InitializationBlocks不是一个子类,所以这不适用。然而,有一个static块,这给了我们第一条输出。注意,InitializationBlocks的实例块从未被执行。这是因为从未创建过InitializationBlocks的实例。换句话说,代码中没有new InitializationBlocks()

第 27 行简单地输出了"---> Creating first Child object…"。值得注意的是,这不是屏幕上输出的第一行——static块的输出先于它。

第 28 行创建了一个Child对象。它的输出用数字 2-11 表示。由于这是第一次创建Child对象(因为在此之前没有访问Child中的任何static成员),所以会加载Child的类文件。在这个过程中,JVM 意识到ChildParent的子类,因此它会先加载Parent类。因此,Parent中的static块会首先执行,按照出现的顺序(2 和 3);然后是Childstatic块,也按照出现的顺序(4 和 5)。

现在 static 块已经完成,实例块和构造函数将被执行。首先,按照出现的顺序执行超类 Parent 的实例块(6 和 7),然后是 Parent 构造函数(8)。然后,按照出现的顺序执行子类 Child 的实例块(9 和 10),然后是 Child 构造函数(11)。从简单的 new Child() 代码行来看,这需要大量的处理。

第 29 行简单地输出了 "---> 创建第二个 Child 对象…"`。

第 30 行创建了另一个 Child 对象。由于类之前已经被加载,Child 及其超类 Parentstatic 块已经运行。因此,它们不会再次运行。所以我们运行 Parent 实例块(6 和 7),然后是 Parent 构造函数(8)。然后,我们运行 Child 实例块(9 和 10),然后是 Child 构造函数(11)。

注意在创建 Child 对象时行号 6-11 的重复。Parent 实例块按顺序执行;然后是 Parent 构造函数。Child 实例块和构造函数以类似的方式执行。

这涵盖了 static 和实例初始化块。在我们结束关于继承的这一章之前,我们只想深入探讨一下我们之前提到的一个主题:向上转型和向下转型。

掌握向上转型和向下转型

之前,我们提到了为什么会出现 ClassCastException 错误。规则是,引用可以指向其自身类型的对象或子类的对象。实际上,引用可以指向继承层次结构中的任何位置,但不能向上。如果引用确实指向了继承层次结构的上方,你将得到一个 ClassCastException 错误。回想一下,这种情况发生的原因是子类引用可能具有任何超类对象都没有代码的额外方法。无论这种情况是否成立,可能存在 就足够了。

请记住,赋值是从右向左进行的;因此,在阅读涉及向上转型/向下转型的代码时,继承层次的方向也是从右向左。此外,请记住编译器始终在查看引用类型。

现在,让我们借助代码示例来讨论向上转型和向下转型。让我们从向上转型开始。

向上转型

使用向上转型,你将从更具体的类型“向上”到更通用的类型。例如,让我们看看以下代码行:

Vehicle vc = new Car()

在这里,我们是从 Car 向上Vehicle。更具体类型(Car)在层次结构中更低,并且可能具有额外的功能。由于继承,父引用可以访问的方法,子类也会拥有。因此,任何对 Vehicle 引用 vc 可用的方法都将存在于 Car 对象中!因此,向上转型永远不会成为问题,不需要显式转换。

图 9**.20 展示了代码中的向上转型:

图 9.20 – 向上转型动作

图 9.20 – 向上转型动作

在此图中,我们有一个名为Machine的类(第 3-5 行)和一个名为Tractor的子类(第 6-9 行)。Tractor中的on()方法(第 7 行)覆盖了Machine中的on()方法(第 4 行)。

第 15 行涉及隐式向上转型。从右到左阅读(因为赋值是从右到左的),我们是从Tractor“向上”到Machine。这是可能的,因为每个Tractor都是Machine的子类。因此,第 15 行产生一个指向Tractor对象的Machine引用。

第 16 行调用了doAction()方法,同时传入第 15 行创建的引用,即mt。这个mt引用被复制(记住 Java 是按值传递的)到第 11 行的Machine引用,即machine。因此,main()方法中的mt引用和doAction()方法中的machine引用都指向同一个对象,该对象是在第 15 行创建的。

doAction()方法内部,我们使用machine引用调用on()方法(第 12 行)。由于machine引用的类型,即Machine,有一个on()方法,编译器很高兴。在运行时,machine引用到的对象,即Tractor,被使用。换句话说,Tractor中的on()方法被动态执行(多态)。

第 17 行在一行中完成了在第 15 行和第 16 行中编码的内容。在第 17 行调用doAction()时,向上转型如下:

Machine machine = new Tractor()

Machine引用,即machine,由doAction()签名(第 11 行)提供,而Tractor实例的创建来自第 17 行。

第 16 行和第 17 行产生了相同的结果:Tractor::on()。现在,让我们讨论两个中较难的一个:向下转型。

向下转型

使用向下转型,你是在从更一般的类型“向下”到更具体的类型。例如,让我们看看以下代码行:

Car cv = (Car) new Vehicle(),

从右到左阅读,我们是从Vehicle 向下Car。同样,更具体的类型(Car)在层次结构中更低,可能还有额外的功能。编译器发现了这一点,并报错。我们可以通过插入一个(向下)转型,(Car)来覆盖编译器。这正是我们在这里所做的事情。然而,在运行时,这一行代码会导致ClassCastException错误。这是因为,在赋值语句的右侧,我们试图创建一个指向继承树中Vehicle对象的Car引用!

图 9**.21 展示了代码中的向下转型:

图 9.21 – 向下转型动作

图 9.21 – 向下转型动作

此图中的代码与图 9**.20中的代码非常相似。继承层次结构是相同的。变化在于doAction()main()方法。第 12 行正常工作,我们已将其注释掉以关注向下转型。

我们的目标,如第 14 行所述,是安全地调用Tractor对象的drive()方法。请注意,此方法仅针对Tractor。让我们一步一步地看看变化。

首先,由于drive()方法特指Tractor(而不是Machine),这意味着我们需要一个Tractor引用来使代码编译通过。第 15 行无法编译的事实证明了这一点——machine引用是Machine类型的,而Machine没有drive()方法。

第 16 行解决了第 15 行的编译错误。第 16 行可以编译,因为它在调用drive()方法之前将machine引用(向下)转换为Tractor引用。这就是为什么需要额外的括号——方法调用比类型转换有更高的优先级,所以我们通过使用括号来改变优先级顺序。如果没有额外的括号,我们将有(Tractor)machine.drive(),这不会编译(与第 15 行不编译的原因相同)。然而,额外的括号强制首先执行从MachineTractor的类型转换,因此编译器会在Tractor中查找drive()方法。

然而,我们仍然没有“走出困境”。是的,编译器很高兴,但 JVM 在运行时容易受到ClassCastException错误的攻击。如果取消注释第 16 行,那么第 22 行将在运行时引发ClassCastException错误。这是因为第 22 行传递了一个Machine对象,因此在doAction()方法内部,machine引用指向一个Machine对象。因此,在第 16 行,我们试图创建一个指向Machine对象的Tractor引用,这整天都是ClassCastException

第 17 行使用了instanceof关键字,结合类型模式和模式匹配。只有当引用machine指向一个Tractor对象时,第 17 行才是真的;当它是时,后台会为我们完成类型转换,并将t初始化为指向Tractor对象。这就是为什么第 22 行没有输出任何内容——传递进来的Machine对象未能通过instanceof测试,因此第 18 行没有执行。然而,由于第 23 行传递了一个Tractor对象,它通过了instanceof测试。这意味着第 18 行被执行并输出了Tractor::drive()

这完成了另一个非常重要的章节。现在,让我们应用我们所学到的知识!

练习

我们的公园充满了多样性,不仅包括恐龙的种类,还包括我们员工的角色。为了模拟这种多样性,我们将在我们的应用程序中引入继承的概念:

  1. 并非所有恐龙都相同。有的小,有的大。有的食草,有的食肉。为不同类型的恐龙创建至少三个子类,这些子类继承自基类Dinosaur

    如果你需要灵感,你可以从Dinosaur类创建一个FlyingDinosaur子类和一个AquaticDinosaur子类,每个子类都有其独特的属性。(这不是建模的最佳方式,但现在不用担心这个。)

  2. 就像我们的恐龙一样,我们的员工也有不同的角色。有些是公园管理员,而有些是保安或兽医。为这些员工角色创建继承自Employee基类的子类。至少想出三个子类。

  3. 继承不仅限于属性和方法。甚至某些方法的行为也可以在子类中进行自定义。在DinosaurEmployee类(来自练习 1 和 2)及其子类中提供自定义的toString()方法实现,以显示每个对象的详细信息。

  4. 此外,覆盖DinosaurEmployee类中的equals()方法,以比较这些类的对象。

  5. 创建一个名为App的类,其中包含一个main方法。在其中,添加功能以检查员工是否有资格在特定围栏工作,考虑到员工的角色和围栏的安全级别。

  6. 公园提供普通门票和季票。创建一个扩展Ticket类的SeasonTicket类,并添加起始日期和结束日期等属性。

项目

你将开发一个更高级的中生代伊甸园公园管理控制台应用程序的版本。你的任务是实现多态概念来处理不同类型的恐龙和员工。通过引入多态,应用程序可以容纳更广泛的恐龙物种和员工角色。系统的主要功能现在应包括以下内容:

  • 管理代表各种恐龙档案的能力,代表各种物种。

  • 管理代表各种角色的不同类型的公园员工档案,例如公园管理员、清洁工、兽医等。

  • 所有以前的功能,如编辑和删除档案、实时恐龙追踪、员工排班、客人登记和处理特殊事件,现在应适应这些新种类。

这里是你需要做的,如果需要的话,可以分解成更小的步骤:

  1. DinosaurEmployee类划分为不同的子类,以表示不同类型的恐龙和员工角色。确保这些子类展示了多态原则。

  2. DinosaurEmployee对象,其中每个对象可以是任何子类的实例。

  3. 更新交互:修改你的基于交互的控制台界面以处理新的恐龙和员工类型。你可能需要添加更多选项或子菜单。

  4. 增强菜单创建:你的菜单现在应处理不同类型的恐龙和员工。确保每个选项对应程序中的特定功能。

  5. 处理操作:每个菜单项应触发一个现在能够处理不同类型恐龙和员工的函数。例如,“管理恐龙”选项现在可以触发一个函数来添加、删除或编辑任何恐龙物种的档案。

  6. 退出程序:确保程序继续为用户提供退出程序的选择。

起始代码片段将基本上与上一个相同。然而,在实现manageDinosaurs()manageEmployees()和其他类似函数时,你需要处理不同类型的恐龙和员工:

public void handleMenuChoice(int choice) {    switch (choice) {
        case 1:
            manageDinosaurs();  // This function now needs
              to handle different types of dinosaurs
            break;
        case 2:
            manageEmployees();  // This function now needs
               to handle different types of employees
            break;
        case 3:
            // manageTickets();
            break;
        case 4:
            // checkParkStatus();
            break;
        case 5:
            // handleSpecialEvents();
            break;
        case 6:
            System.out.println("Exiting...");
            System.exit(0);
    }
}

现在需要更新manageDinosaurs()manageEmployees()manageTickets()checkParkStatus()handleSpecialEvents()方法,以便能够处理增加的复杂性。

摘要

在本章中,我们探讨了面向对象编程的一个基石,即继承。继承定义了子类和父类之间的“是”关系——例如,Fox“是”AnimalTrain“是”Vehicle。继承通过使可继承的基类成员自动对子类可用来促进代码重用。类继承是通过extends关键字启用的,接口继承是通过implements关键字启用的。

关于方法,子类可以自由地覆盖(替换)基类的实现。这就是我们如何启用面向对象编程的另一个基石,即多态性。

多态性是一种特性,其中对象实例的方法仅在运行时选择。因此,多态性的其他术语包括“后期绑定”、“运行时绑定”和“动态绑定”。为了使多态性工作,子类型中实例方法的签名必须与父方法匹配。该规则的唯一例外是协变返回,在覆盖方法中,允许使用父返回类型的子类型。与父版本相比,覆盖方法不得降低访问权限或添加额外的已检查异常。

另一方面,方法重载是指方法签名必须不同(除了匹配的方法名)。因此,参数的数量、它们的类型和/或它们的顺序必须不同。返回类型和参数名称不重要(因为它们不是方法签名的一部分)。方法重载可以在层次结构的任何级别发生。

使用继承时,引用类型和对象类型通常不同。由于赋值是从右到左工作的,当我们讨论向上转型和向下转型时,我们指的是沿着继承树“向上”或“向下”。向上转型总是安全的,因为子类型将始终通过超类型引用访问方法。然而,向下转型是不安全的,需要转型以使编译器满意。即使如此,如果你最终创建了一个指向继承树顶部的引用,你将在运行时得到ClassCastException错误。指向继承树顶部是不允许的,因为子类的引用类型可能有父类型对象没有代码的方法。

super关键字在两种情况下使用。第一种是使用super()访问父构造函数。这个调用仅允许在任何构造函数中的第一行进行。如果没有明确编码,编译器将插入super()以确保父构造函数在子类型构造函数之前执行。构造是从基类向下发生的,因为子类型可能依赖于父成员,因此父类必须有机会首先初始化它们。第二种情况是从子类型代码中访问父成员,使用super.parentMember

我们从第八章中已经知道,protected访问修饰符确保成员在包内可用,并且对任何子类都可用,无论包如何。我们回顾了这一点,并证明了,当从一个不同包的子类访问protected成员时,你必须通过继承以非常具体的方式进行。

abstract方法是一种没有代码(实现)的方法。即使一个类本身不需要有任何abstract方法也可以是abstract的;一旦类中有一个abstract方法,该类就必须是abstract的。任何abstract类的子类都必须提供继承的abstract方法(们)的实现代码,或者该子类也必须是abstract的。

关于继承,final类不能被继承。final方法不能被重写。final的其他用途是定义常量并确保(方法参数的)值是常量。

封闭类的使用使我们能够将层次结构的一部分限制为某些类型。而不是通用的extends,它允许一个类从它想要的任何基类中子类化;并且没有完全关闭继承使用final;封闭类通过使用sealednon-sealedpermits关键字实现自定义限制。

实例和static初始化块在方法外部但类内部编写。static块在带有static关键字的块之前。实例不使用任何关键字(实例语义是隐含的)。两者都允许在各个点进行初始化。静态初始化仅发生一次——当类首次被加载时。实例初始化每次调用构造函数时都会发生。因此,实例块是插入对所有构造函数都通用的代码的完美位置。

最后,我们深入探讨了向上转型和向下转型。这有助于加深我们对为什么向上转型不是问题、为什么向下转型需要转型以及为什么我们会得到ClassCastException错误的理解。此外,使用instanceof运算符确保我们防止ClassCastException错误发生。

这就完成了我们对继承的讨论——这是一个很大的章节!我们现在将转向接口和abstract类。

第十章:接口和抽象类

第九章中,我们学习了面向对象编程的另一个核心支柱,即继承。我们了解到 Java 使用extends关键字来定义子类和父类之间的“是”继承关系。子类从其父类继承功能,这实现了代码重用,这是继承的核心好处。Java 通过确保一次只能从一个类扩展来防止多重类继承。

我们还深入探讨了面向对象编程(OOP)的另一个支柱——多态。多态是通过子类重写父类实例方法来实现的。我们了解到,在层次结构中,引用可以指向它们自己的类型对象(跨类型)和子类对象(向下)。如果引用尝试指向层次结构中的父对象,则会发生异常。

接下来,我们比较和对比了方法重载和方法重写。在方法重写中,方法签名必须匹配(除了协变返回)。在方法重载中,虽然方法名称相同,但方法签名必须不同。

我们还发现构造函数调用的顺序是从上(基类)到下。这是由super()关键字实现的。要访问父(非构造函数)成员,我们可以使用super.语法。

然后,我们回顾了protected访问修饰符,并演示了对于包外部的子类要访问受保护的成员,它们必须以非常具体的方式通过继承来这样做。实际上,一旦包外部,受保护的成员对子类(包含受保护成员的类)来说就变成了私有。

然后,我们介绍了两个对继承有影响的关键字:abstractfinal。由于抽象方法没有实现代码,它旨在被重写。第一个非抽象(具体)子类必须为任何继承的abstract方法提供实现代码。final关键字可以在几种情况下应用。关于继承,final方法不能被重写,final类不能被继承。

接下来,我们讨论了sealed类,它使我们能够限制继承树的部分。使用sealedpermits关键字,我们可以声明一个类只能被某些其他命名的类继承。non-sealed关键字结束范围任务,因此使我们能够正常地继承。

我们还检查了继承层次结构中的instancestatic块。static块仅在类首次加载时执行一次。另一方面,instance块在每次创建对象实例时执行,这使得它成为插入对所有构造函数都通用的代码的理想位置。

最后,我们探讨了向上转型和向下转型。向上转型永远不会成为问题,而向下转型可能会导致异常。使用instanceof关键字有助于防止这种异常。

在本章中,我们将介绍abstract类和接口。我们将比较和对比它们。接口在多年中经历了几次变化。通过示例,我们将检查这些变化。Java 8 为接口引入了staticdefault方法,从而使得代码第一次可以存在于接口中。在 Java 9 中,为了减少代码重复并提高封装性,接口中引入了private方法。最后,Java 17 引入了sealed接口,这使得我们可以自定义哪些类可以实现我们的接口。

本章涵盖了以下主要主题:

  • 理解abstract

  • 掌握接口

  • 检查defaultstatic接口方法

  • 解释private接口方法

  • 探索sealed接口

技术要求

本章的代码可以在 GitHub 上找到,网址为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch10

理解abstract

第九章中,我们介绍了abstract关键字。让我们回顾一下我们讨论的一些关键点。一个abstract方法正是如此——它是抽象的。它没有代码。甚至没有花括号——{}。这通常是一个设计决策。包含abstract方法的类希望子类提供代码。这意味着该类本身是“不完整”的,因此任何定义abstract方法的类本身也必须是abstract。任何abstract类的子类必须要么覆盖abstract方法,要么声明它本身也是abstract。否则,编译器会报错。

然而,情况并非总是如此——一个abstract类根本不需要有任何abstract方法。同样,这也是一个设计决策。由于类被标记为abstract,它被认为是“不完整”的(即使它可能包含所有方法的代码)。这阻止了基于abstract类的对象被实例化。换句话说,你不能基于abstract类创建对象。然而,你可以有一个基于abstract类型的引用。

请参阅图 9**.14以获取abstract方法和类的代码示例。

掌握接口

默认情况下,接口是一个abstract结构。在 Java 8 之前,接口中的所有方法都是abstract的。一般来说,当你创建一个接口时,你是在定义一个关于类可以做什么的合同,而没有说关于类将如何做的任何事情。一个类在实现接口时签署了这个合同。实现接口的类是同意“遵守”接口中定义的合同。“遵守”在这里意味着,如果一个具体的(非抽象)类实现了接口,编译器将确保该类为接口中的每个abstract方法都有实现代码。正如 Oracle 教程所述,“实现接口允许一个类更正式地承诺它将提供的行为。”

与类不同,你只能(直接)从另一个类继承,但一个类可以实现多个接口。因此,接口实现了多重继承。让我们看看一个例子:

class Dog extends Animal implements Moveable, Loveable {}

这行代码表明Dog“是”AnimalMoveableLoveable。接口名称通常是形容词,因为它们通常描述名词的性质。因此,接口名称通常以“able”结尾。例如,IterableCallable是 Java API 中的接口名称。

在上一行代码中,我们受限于只能从一个类扩展,但我们可以实现任意多的接口。这种灵活性非常强大,因为我们可以在不强制人工类关系的情况下链接到层次结构。这是接口的核心原因之一——能够将类型转换为多个基础类型*。

abstract类一样,由于接口也是abstract的,你不能new一个接口类型。此外,类似于abstract类,你可以(并且经常这样做)有接口类型的引用。

在后面的章节中,我们将讨论具有实现代码的staticdefaultprivate方法。在此之前,我们将处理我们可以在接口中使用的另一种类型的方法:abstract方法。此外,我们还将讨论接口常量。

接口中的抽象方法

在 Java 8 之前,接口中的所有方法默认都是publicabstract的。那时,你可以认为接口是一个“纯粹的抽象类”。

关于public访问修饰符,尽管 Java 9 引入了private方法,但这仍然适用。这意味着你可以在接口中显式地将一个方法标记为publicprivate。然而,如果你没有指定任何访问修饰符,public是默认的。

他们的抽象性质如何呢?嗯,任何未标记为staticdefaultprivate的方法默认情况下都是abstract的。图 10.1概括了这一点:

图 10.1 – 接口中的抽象方法

图 10.1 – 接口中的抽象方法

在这个图中,我们可以看到m2()方法是publicabstract的,尽管这些关键字都没有明确编码。唯一的其他有效访问修饰符是private,正如在第 6 行声明m3()时所示。m4()无法编译(第 7 行)的事实表明,protected不是接口方法的有效访问修饰符。

我们能否在接口中声明变量?是的,我们可以。现在让我们来讨论它们。

接口常量

接口中指定的任何变量默认都是publicstaticfinal。实际上,它们是常量,因此,它们的初始值不能被更改。通过将这些常量放在接口中,任何实现该接口的类都可以访问它们(通过继承),但它们是只读的。图 10**.2展示了某些接口常量:

图 10.2 – 接口常量

图 10.2 – 接口常量

在前面的图中,我们有两个变量,即VALUE1VALUE2。它们都是常量。VALUE1明确声明它是publicstaticfinal,而VALUE2隐式地做了同样的事情(没有使用关键字)。

现在,让我们来看一个类实现接口的例子。

图 10**.3表示一个实现接口的类:

图 10.3 – 实现接口的类

图 10.3 – 实现接口的类

在这个图中,第 3-6 行代表一个名为Moveable的接口,它声明了一个常量HOW和一个方法move()。第 7 行的Dog类声明它实现了Moveable。因此,由于Dog是一个具体的、非抽象类,它必须为move()提供实现。

如我们所知,接口方法默认是public的。然而,对于类来说并非如此。在类中,方法默认是包私有(package-private);这意味着,如果你在类中的方法上没有提供访问修饰符,该方法是包私有的。因此,当在类中重写接口方法时,请确保该方法为public。因为package-private(第 9 行)比public(第 5 行)的权限弱,所以我们得到编译器错误——因此这一行被注释掉了。第 11 行显示,在Dog中必须显式声明move()public

第 15 行显示,在第 4 行声明的HOW是一个常量。如果取消注释,第 15 行将给出编译器错误,因为常量一旦赋值后就不能更改。

第 16 行和第 17 行展示了我们可以访问HOW常量的两种方式——要么在接口名称前加上它(第 16 行),要么直接访问(第 17 行)。

第 19 行显示,一旦进入一个static方法,例如main()方法,就不能直接访问实例方法,例如move()方法。这是因为实例方法秘密地传递了一个指向调用它的(对象)实例的引用,即this引用。由于static方法与类相关,而不是与类的特定实例相关,因此在static方法中没有this引用。因此,根据第 20 行,我们需要创建一个实例,然后使用该实例来调用move()

当我们运行这个程序时,第 16 行和第 17 行都输出了walk常量的值。第 20 行输出了Dog::move(),这是Dogmove()(第 12 行)的实现的结果。

注意

自 Java 8 以来,允许在default方法中编写代码。由于default方法是可继承的,编译器必须介入以防止接口中的多重继承导致问题。我们将在讨论接口中的default方法时回到这个问题。

现在,让我们看看多重接口继承。

多重接口继承

与类不同,在 Java 中不允许多重继承,但在接口中允许多重继承。请注意,多重类继承的问题在于,如果允许多重类继承,你可能会继承两个不同的同一方法实现。

图 10**.4 展示了多重接口继承的一个例子:

图 10.4 – 多重接口继承

图 10.4 – 多重接口继承

在这个图中,第 2 行的MoveableObject接口是一个没有任何方法的接口。这被称为标记接口。标记接口用于使用instanceof进行类型信息。例如,如果你想检查一个对象是否是实现了MoveableObject的类的实例,你会编写以下代码:

if (objectRef instanceof MoveableObject) {}

第 3-5 行定义了一个名为Spherical的接口。在这个阶段,我们可以简单地定义一个类,直接实现这两个接口,如下所示:

class BallGame implements MoveableObject, Spherical{     @Override doSphericalThings(){}
}

第 6 行很有趣——我们可以定义一个接口(在这个例子中是Bounceable),它从其他两个接口(即MoveableObjectSpherical)中扩展(继承)。因此,Bounceable有两个abstract方法:一个它自己定义的,称为bounce(),另一个它从Spherical继承的,称为doSphericalThings()

由于Volleyball类实现了Bounceable(第 11 行),它必须重写bounce()doSphericalThings()这两个方法。当Volleyball这样做时,它就可以编译了。

注意,在第 17 行,abstractBeachball声明它也实现了Bounceable。然而,由于Beachballabstract的,所以“合同”不必遵守;这意味着Beachball可以自由实现Bounceable中所有、一些或没有任何abstract方法。在这个例子中,Beachball没有实现Bounceable所要求的任何abstract方法。

现在我们已经了解了接口中的abstract方法对实现类的影响,让我们来检查接口中的两个非abstract方法——defaultstatic方法。

检查默认和静态接口方法

在 Java 8 之前,接口中只允许有abstract方法。这意味着如果你向现有的接口中引入一个新的abstract方法,已经实现该接口的类将会出错。这对 Java 开发者来说不方便,对 Java 的设计者来说也是如此。

所有这些都在 Java 8 中发生了变化,引入了defaultstatic方法。引入default方法的主要驱动力是能够在接口中引入代码,而不破坏现有的客户端基础。这保持了向后兼容性。此外,新代码自动对实现该接口的客户端可用。

引入static方法的主要驱动力是将实用代码保留在接口内部,而不是像引入之前那样放在一个单独的类中。

让我们依次讨论它们,首先是default方法。

‘默认’接口方法

接口使用default关键字来标记一个方法,使其可以被实现类继承。正如之前所述,如果你没有指定访问修饰符,它们默认是public(请原谅这个双关语!)。默认方法必须有一个实现 - 必须存在一组花括号(即使它们是空的花括号)。实现接口的类会继承任何default方法。这些类可以覆盖继承的版本,但这不是必需的。

让我们看看一些示例代码:

图 10.5 – 接口默认方法

图 10.5 – 接口默认方法

在这个图中,我们有一个名为Moveableinterface(第 3-8 行)。如果第 4 行取消注释,它将无法编译,因为default(或static)接口方法必须具有代码体。第 5 行定义了一个名为move()default方法。由于Moveable中没有abstract方法,实现Moveable的类不需要提供任何特定的方法。

Cheetah类(第 9-14 行)实现了Moveable并覆盖了move()方法。Elephant类(第 15 行)也实现了Moveable,但没有覆盖move()方法。

因此,Cheetah对象将有一个自定义的move()实现,而Elephant对象将使用从Moveable继承的版本。

第 20 行显示,与abstract类一样,你不能new一个接口类型。

第 21 行创建了一个名为Cheetah的对象,通过一个Moveable引用进行引用,即cheetah。这完全没问题,原因有两个。首先,引用可以是接口类型,在许多情况下确实是。其次,只要对象类型实现了接口类型,无论是直接实现(如这里的情况)还是间接实现(通过从实现了接口的类继承),这个程序就可以编译。由于Cheetah类实现了Moveable接口,所以一切正常。

第 22 行以多态方式执行了Cheetah类的move()方法,结果在屏幕上输出了Moving very fast!

第 23 行创建了一个名为Elephant的对象,通过一个Moveable引用进行引用,即elephant。由于Elephant实现了Moveable接口,这是可以的。

第 24 行很有趣。由于Elephant没有提供自定义的move()版本,因此使用了Moveable接口(Elephant实现了该接口)的默认版本。因此,屏幕上输出了Moving

现在,让我们讨论一下静态接口方法。

‘静态’接口方法

接口使用static关键字来标记一个方法作为工具方法。与default方法一样,static方法默认是public的。同样,与default方法一样,static方法必须有实现。然而,实现接口的类不会继承static方法。要访问static方法,你必须使用InterfaceName.staticMethodName()语法。

让我们通过代码示例来看看:

图 10.6 – 接口静态方法

图 10.6 – 接口静态方法

在前面的图中,我们有一个接口I,它在第 5 行有一个名为m1()的静态方法。请注意,第 4 行被注释掉了,因为与default方法一样,静态方法也必须有代码体。

TestStaticMethods类实现了I接口。由于接口中没有抽象方法,因此没有实现特定的方法。第 9 行显示了错误的语法,因此会生成编译器错误。第 10 行显示了正确的语法,并在运行时输出了3

之前,我们提到多接口继承可能存在关于default方法的潜在问题。现在让我们来探讨一下。

多接口继承

死亡菱形(en.wikipedia.org/wiki/Multiple_inheritance#:~:text=The%20”diamond%20problem”%20(sometimes,from%20both%20B%20and%20C)出现在一个类发现它继承了两个同名方法时;它应该使用哪一个?这在允许多重继承的 C++中是一个问题,并且是禁止 Java 多重继承的一个影响因素。

然而,Java 始终允许一个类实现多个接口。然而,现在 Java 8 允许有代码体的可继承的default方法,Java 8 不可能遇到“死亡菱形”场景吗?一个类是否可以实现两个(或更多)具有相同default方法的接口?那会怎样呢?好消息是编译器介入并强制你的类覆盖“违规”的default方法。

那么,这就留下了一个问题,如果我们想访问每个default方法怎么办?例如,假设我们在接口A中有一个名为foo()default方法,在接口B中也有一个名为foo()default方法。如果在我们自己的类中,我们想执行foo()的三个不同版本——来自A的、来自B的以及编译器强制我们创建的来自我们自己的类,会怎样呢?

图 10.7 展示了如何在代码中实现这一点:

图 10.7 – 访问多个默认代码实现

图 10.7 – 访问多个默认代码实现

在这个图中,接口A在第 4 行定义了它的foo()方法,而接口B在第 7 行定义了它的foo()方法。TestMultipleInheritance类实现了AB。由于AB都有foo()代码,编译器必须介入以防止“死亡菱形”。因此,TestMultipleInheritance中的foo()方法(第 11-16 行)是强制性的;否则,代码将无法编译。由于default方法是实例方法,当我们覆盖接口版本的foo()时,我们必须确保它是非静态的。

第 13 行展示了调用A中的foo()的语法。这个语法是InterfaceName.super.methodName()。所以在这个例子中,它是A.super.foo()。由于使用了super,方法必须是实例方法。这是因为只有实例方法才能访问(使用super引用的父实例)super引用(以及使用this引用的当前实例)。

类似地,第 15 行使用B.super.foo()B中调用foo()

注意,第 14 行无法编译,因此被注释掉了。这是因为,使用A.foo()语法,编译器正在寻找接口A中名为foo()static方法。然而,A中的foo()方法是非静态的(第 4 行)。

有趣的是,第 18 行无法编译。这是因为,由于main()是一个static方法(一个static上下文),我们不能使用super

第 19 行展示了如何在类本身中执行自定义的foo()方法。回想一下,当我们从static方法中调用非静态(实例)方法时,我们需要一个实例,因此有new TestMultipleInheritance()

有了这些,我们已经涵盖了两种非抽象方法的类型,即default方法和static方法。还有一个:private接口方法。

解释“私有”接口方法

接口也可以有带有代码实现的private方法。它们被引入以减少代码重复并提高封装性。这些private方法可以是static的也可以是非static的。由于它们是private的,它们只能从接口内部访问。与类一样,你不能从一个static方法中访问一个非static方法。

让我们看看一个代码示例。首先,我们将检查有代码重复的代码。图 10.8显示了这样的接口:

图 10.8 – 带有代码重复的接口

图 10.8 – 带有代码重复的接口

正如图中所示,第 6、11 和 16 行是相同的。此外,第 8、13 和 18 行也是相同的。我们将通过使用private方法重构这个接口来解决这个代码重复问题。图 10.9显示了相应的代码:

图 10.9 – 带有私有方法的接口

图 10.9 – 带有私有方法的接口

在这个图中,我们有一个名为hit(String)private``static方法,它接受要执行的击球(射击)。首先要注意的是,与defaultstatic方法一样,预期并存在一个代码体。

第 25 行,在图 10.8中重复了三次,现在只出现一次。第 27 行也是如此。第 26 行输出正在进行的击球动作。请注意hit(String)static的。这使得方法可以从static方法(如第 32 行的forehand())中调用。

接口中存在defaultstaticprivate方法的混合,以促进进一步的讨论。首先,第 29 行是一个default方法,它调用了privatehit(String)方法,传递了backhand字符串。请注意,default方法不能也被标记为private,因为它们的语义相反——private方法,就像类一样,是不可继承的,而default方法是可继承的。

其次,forehand()方法(第 30-33 行)表示从static上下文(第 32 行)调用hit(String),传递forehand。第 31 行表示尝试从一个static方法中调用一个名为smash()的非static``private方法。与类一样,这是不允许的,因此已被注释掉。

最后,我们可以从其他private方法中调用private方法(第 34 行)。

第 35 行是一个提醒,即未标记为defaultstaticprivate的方法默认是abstract的,因此不允许有代码。

让我们来看看如何从一个实现了它的类中如何使用EfficientTennis接口:

图 10.10 – 带有私有方法的接口

图 10.10 – 带有私有方法的接口

首先,要注意的是SportTest类没有要实现的方法。这是因为EfficientTennis没有声明任何abstract方法,只有defaultstaticprivate方法。

第 41 行执行了名为backhand()default方法,第 42 行执行了名为forehand()static方法。请注意,第 43 行尝试访问名为hit(String)private方法。由于该方法是对接口私有的,这是不允许的,因此第 43 行被注释掉了。这表明hit(String)被封装在外部世界之外。实际上,SportTest不知道hit(String)方法,因此不依赖于它。如果hit(String)被更改或甚至删除,只要backhand()forehand()方法仍然工作,SportTest就不会受到影响。

现在,让我们继续我们的最后一个主题:sealed接口。

探索封闭接口

第九章中,我们了解到sealed类使我们能够通过指定哪些类可以成为我们的类的子类型来范围我们的继承层次结构。我们使用sealedpermits关键字作为一对来完成此操作。一旦一个类被封闭,该类的每个子类都必须是sealednon-sealedfinal——也就是说,我们继续封闭层次结构(sealed)、结束封闭层次结构(non-sealed)或完全结束层次结构(final)。

封闭接口也是可能的。我们将使用来自第九章的例子,并进行一些小的修改。首先,图 10.11显示了相关的 UML 图,这将有助于解释代码:

图 10.11 – 封闭接口 UML 图

图 10.11 – 封闭接口 UML 图

在这个图中,我们有一个接口,用<<interface>>表示,称为Driveable。在 UML 中,为了指定一个类实现一个接口,使用<<realize>>关键字(加上指向接口的虚线箭头)。

在这个例子中,我们将对层次结构进行如下范围定义:允许实现Driveable的唯一类是Vehicle,允许的Vehicle子类是Car,允许的Car子类是Saloon

当我们到达Saloon时,我们希望再次打开层次结构——如果你愿意的话,就是解封它。这允许FordVolvoSaloon扩展。请注意,这只是为了演示目的,因为现在任何类都可以成为Saloon的子类。

ChairTableWindow类都是无关的,并且不是封闭层次结构的一部分。

图 10.12显示了使用封闭接口的一些代码:

图 10.12 – 封闭接口代码

图 10.12 – 封闭接口代码

此图中的重要行是第 3-4 行。第 3 行说明Driveable接口是sealed的,并且只允许一个类实现它,即VehicleVehicle现在必须实现Driveable;否则,代码将无法编译。Vehicle确实实现了Driveable(第 4 行),所以一切正常。此外,Vehiclesealed的,允许的唯一子类是Car

第 6 行指出CarVehicle的子类,而Saloon是唯一允许的子类型。

第 7 行指出Saloon是预期的Car子类。Saloonnon-sealed的事实打开了层次结构,并允许Volvo(第 8 行)和Ford(第 9 行)从Saloon扩展。

第 11-13 行都无法编译。第 11 行提醒我们Vehicle只允许Car子类型。同样,第 12 行提醒我们Car只允许Saloon子类型。第 13 行显示,根据第 3 行的内容,唯一可以实现Driveable的类是Vehicle

这就完成了我们对接口和abstract类的讨论。现在,让我们应用我们所学到的知识!

练习

通过接口和abstract类,我们可以进一步改进我们的应用程序结构!查看以下练习以测试你的知识:

  1. 恐龙,无论具体物种如何,都有共同的行为,如进食和移动。定义一个封装这些行为的接口,并为它想出一个合理的名字,然后在Dinosaur类中实现它。

  2. 我们公园使用不同类型的车辆来完成不同的目的。设计一个名为Vehicleabstract类,并从中派生出如JeepHelicopter等具体类。

  3. 修改Vehicle类,使其包含一个名为travel()abstract方法,并在其子类中提供不同的实现。

  4. 通过实现Comparable接口来比较恐龙的年龄,使我们的Dinosaur类可排序。

  5. 同样,我们的员工也有共同的行为。定义一个Worker接口,其中包含代表这些行为的函数,并在Employee类中实现它。

  6. 我们把恐龙安置在不同的围栏里。使用ArrayList实现List接口来管理围栏中的恐龙。

  7. 恐龙的进食行为根据它们的饮食不同。创建CarnivoreHerbivore接口,并在适当的恐龙子类中实现它们。

项目 - 统一公园管理系统

在这个相对高级的项目中,你将把中生代伊甸园公园管理应用提升到下一个层次。你可以通过利用你之前创建的类来实现这一点。你可以继续进行上一个项目,或者从头开始。

增强后的系统将实现多态性,以便可以管理不同类型的恐龙和员工。这将增加公园管理的灵活性和功能性,允许多样化的恐龙物种和员工角色。增强后的系统应包括以下内容:

  • 管理各种恐龙物种档案的能力,扩大了你公园的多样性

  • 管理不同类型员工档案的能力,例如兽医、导游、维修工人和安全人员

  • 所有其他功能也应适应这些新变化,包括编辑和删除档案、追踪恐龙、管理员工日程、管理游客入场和处理特殊事件

这是一个逐步计划来实现这一点:

  1. DinosaurEmployee类扩展到各种子类中,以表示不同类型的恐龙和员工。请确保您使用多态原则。

  2. DinosaurEmployee对象,每个都可以是任何子类的实例。

  3. 更新交互:调整您的基于控制台的交互界面,使其能够处理新的恐龙和员工类型。您可能需要添加新选项或子菜单。

  4. 更新菜单创建:您的菜单现在应提供管理各种类型恐龙和员工的选择。确保每个选项对应程序中的特定功能。

  5. Manage Dinosaurs选项现在可以触发一个函数,用于添加、删除或编辑任何恐龙物种的配置文件。

  6. 退出程序:提供一个选项让用户退出程序。

您的起始代码将与最后两个章节中展示的代码非常相似。一些方法,如manageDinosaurs()manageEmployees(),需要更新并变得更加复杂:

public void handleMenuChoice(int choice) {    switch (choice) {
        case 1:
            manageDinosaurs();  // This method now needs
              to handle different types of dinosaurs
            break;
        case 2:
            manageEmployees();  // This method now needs
              to handle different types of employees
            break;
        case 3:
            // manageTickets();
            break;
        case 4:
            // checkParkStatus();
            break;
        case 5:
            // handleSpecialEvents();
            break;
        case 6:
            System.out.println("Exiting...");
            System.exit(0);
    }
}

manageDinosaurs()manageEmployees()manageTickets()checkParkStatus()handleSpecialEvents()方法需要处理增加的复杂性。

摘要

我们在本章开始时考察了抽象类。一个抽象类可以有零个或多个抽象方法。然而,如果任何方法是抽象的,那么该类必须是抽象的。虽然抽象类不能被实例化,但引用可以是抽象类型。

在 Java 8 之前,接口只包含抽象方法(和常量)。我们关于接口的讨论始于这一点,当时所有方法都是抽象的。虽然一个类只能从一个类扩展,但一个类可以实现多个接口。这是引入接口的主要原因之一——能够将类型转换为多个基类型。

实现接口的类签订了一个“合同”,为接口中(如果有)的每个抽象方法提供代码。如果接口中有一个抽象方法,而具体的非抽象类没有为其提供代码实现,编译器会报错。因此,接口是保证某些方法存在于类中的绝佳方式。接口中的变量默认为常量。这些常量对实现类可用,但只读。我们注意到,允许多接口继承,即一个接口可以继承自多个其他接口。这与类形成对比,无论是抽象类还是具体类,多重继承都是被禁止的。

在 Java 8 中,接口中引入了具有代码体的default方法和static方法。这是第一次允许在接口中添加代码。关于继承,default方法会被实现类继承,而static方法则不会。因此,访问这两种方法需要不同的语法。由于default方法可以被继承,实现类可以覆盖它们。这两种方法类型,与abstract方法一样,默认情况下都是public的。

接下来,我们看到了编译器如何防止我们遇到“死亡菱形”问题。当两个接口具有相同的default方法名称时,可能会出现这个问题。实现这两个接口的类被迫提供自定义实现以避免歧义。这很自然地引出了语法(使用super),它使我们能够在两个接口中实现default方法,并在类中实现自定义(非默认)版本。

Java 9 引入了具有代码体的private接口方法。它们被引入以减少代码重复并提高封装性。我们详细说明了通过引入private接口方法重构代码的例子。

我们通过讨论在 Java 17 中引入的sealed接口来结束本章。与sealed类(第九章)类似,sealed接口使我们能够限制层次结构——也就是说,当我们声明一个sealed接口时,我们指定允许实现它的类。我们提供了一个 UML 图和一些代码来更详细地解释这一点。

这就完成了我们对接口和abstract类的讨论。在下一章中,我们将介绍异常。

第十一章:处理异常

错误处理是软件开发中的另一个基本概念。当程序无法或不知道如何对某种情况进行反应时,就会发生错误。错误处理允许你优雅地应对程序中的意外事件。如果没有错误处理,当错误发生时,应用程序会崩溃并停止运行。

在 Java 中,我们有不同类型的错误。我们处理最多的一种错误称为异常。在 Java 术语中,我们稍后会学到,异常并不是错误。这与类层次结构有关。然而,从日常语言学的角度来看,将异常视为某种错误并不奇怪。

但是,我们通常不谈论错误,而是谈论异常。错误也会发生,但错误通常是应用程序无法恢复的情况。你的应用程序应该能够从异常中恢复。

Java 中的异常处理允许你管理程序中的问题和意外事件。掌握异常处理不仅会提高你代码的健壮性,还能帮助你更有效地维护和调试应用程序。通过理解异常的工作原理,你可以编写处理意外情况而不会崩溃或产生错误结果的代码。

确保你具备管理应用程序中异常的必要技能正是我们将在本章中学习的内容。以下是我们将涵盖的概述:

  • 理解异常及其目的

  • 异常类型 – 检查和不检查

  • 基本 I/O 操作

  • 抛出异常

  • 创建自定义异常

  • 抓捕或声明原则

  • 使用 try-catch 块、try-catch-finally 和 try-with-resources

  • 在方法签名中处理继承和异常

因此,让我们深入探索异常的世界!

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch11

理解异常

在日常生活中,我们必须执行许多过程。而且,我们总是在发生一些小插曲,这些不应该破坏我们的日子。这些小插曲不被认为是事件中的快乐路径,但它们经常发生,我们从它们中恢复过来,继续正常工作。

还有一些更严重的问题可能会发生,我们需要有正式的备份计划,比如在火灾中疏散建筑物。

Java 中的异常就像这样。这些是不应该发生的事情;有时,我们控制它们发生,有时,我们则不。在某些情况下,我们有义务指定一个备份计划,而在其他情况下,我们则没有。首先,让我们再谈谈什么是异常。

异常是什么?

异常是中断程序正常流程的事件。它们通常由程序在运行过程中遇到的错误或意外条件引起。Java 中的异常是对象。这些异常由Exception类或其子类的实例表示。Exception类是Throwable类的子类。

当发生异常时,Java 运行时系统会创建一个包含错误信息的异常对象,例如错误的类型和错误发生时程序的状态。这个过程被称为抛出异常。处理异常被称为捕获异常

如果程序没有捕获并处理异常,Java 运行时系统将终止程序,通常显示错误信息和堆栈跟踪。因此,让我们谈谈异常处理的需求。

堆栈跟踪

你可能还不知道这个术语,但你很可能已经遇到过。当异常发生时,会出现堆栈跟踪。它显示了代码到达错误所在的“路径”。以下是一个示例:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0

at javabook.Example.printFirstValueArray(Example.java:21)

at javabook.Example.main(Example.java:8)

正如你在本例中看到的,最终触发异常的行是第 21 行,方法名为printFirstValueArray。该方法在main方法的第 8 行被调用。

异常处理的需求

由于我们不希望每次程序抛出异常时都停止运行,因此异常处理是编程中的一个关键方面。我们通常将代码逻辑与异常处理逻辑分开。这有助于我们创建一个可维护和有弹性的应用程序。当我们设置了适当的异常处理时,我们的程序可以从意外情况中优雅地恢复。这比程序崩溃并停止,甚至产生错误的结果要更好。

由于这非常常见,Java 提供了一个内置的异常处理机制,允许我们捕获和处理异常。这样,我们可以从异常中恢复,并继续执行程序。这个机制鼓励(甚至强制)我们思考程序可能遇到的可能的异常条件,并编写代码来有效地处理这些异常。让我们谈谈需要异常处理的一些情况。

需要异常处理的常见情况

有许多可能导致异常的情况。其中一些在我们控制范围内,但最重要的是,我们必须处理无法完全控制的情况下的异常可能性。在查看异常代码之前,我们将讨论一些常见的情况。

文件 I/O 操作

需要异常处理的一个非常常见的情况是处理文件 I/O 操作的逻辑。当处理文件 I/O 操作时,可以使用异常来处理文件找不到或无法读取或写入的情况。这些都是程序员无法控制的情况。程序可能没有正确的权限,文件可能已被删除,或者文件可能已经被使用——许多其他超出你控制的情况也可能发生。

Java 有特定的子类来处理这些类型的异常。处理 I/O 操作的主要子类是 IOException。它有自己的子类,例如 FileNotFoundException

数据库操作

我们依赖外部部分的另一种类型的情况是各种数据库操作。数据库可能会宕机或被修改,作为开发者,这超出了你的控制。因此,我们需要处理在连接到、查询或更新数据库时可能发生的异常。例如,当数据库连接有问题或执行了无效的 SQL 查询,或者违反了数据库约束(数据库特定的规则)时,可能会抛出 SQLException。适当的异常处理允许你的程序从这些问题中恢复,例如通过重新建立连接或回滚事务。

用户输入验证

当你的应用程序需要用户输入时,可以使用异常来处理输入无效或不符合预期格式的情况。例如,在尝试将非数字字符串解析为整数时,可能会抛出 NumberFormatException。妥善处理这类异常可以帮助你的应用程序向用户提供有用的反馈,并确保他们输入有效数据,同时将核心逻辑与错误处理分离。

资源管理

你的程序依赖于外部资源,如内存和系统资源。这些资源也可以是第三方服务,如 API。在这些所有情况下,都可能发生异常。我们需要处理这些资源不可用或耗尽的情况。例如,当抛出 OutOfMemoryError 时,可以使用 InterruptedException 来处理在等待资源时线程被中断的情况。在这些场景中,适当的处理可以帮助你的应用程序恢复或优雅地降低其功能。

可能令人印象深刻的是,我们有一个用于处理内存不足情况的 错误,但到目前为止,我们一直在谈论 异常 而不是错误。让我们看看层次结构,以了解这里发生了什么。

理解异常层次结构

Java 是一种面向对象的语言,对象可以形成层次结构。在 Java 中,所有异常都是 Throwable 类的子类。在出现问题时,应用程序可以抛出的任何内容都是 Throwable 类型。Throwable 类有两个主要的子类:ErrorException

错误 表示在运行时系统操作期间发生的严重问题,通常表明 JVM 或应用程序环境存在关键问题。例如包括 OutOfMemoryErrorStackOverflowError。错误通常是不可恢复的,并且不建议在代码中捕获和处理它们。

另一方面,Exception 类及其子类代表程序可以处理的异常条件。异常主要有两大类:检查异常和未检查异常。

检查异常

IOExceptionFileNotFoundExceptionSQLException

检查异常是 Exception 类的子类,但不包括 RuntimeException 及其子类。

未检查异常

未检查异常 代表不需要显式处理的编程错误。这些异常通常是由于编程错误或正常程序执行期间预期不会发生的情况而抛出的。

由于未检查异常通常表明代码中的错误,Java 编译器假设你的程序不需要显式地捕获或声明它们。然而,你仍然可以选择捕获和处理未检查异常。当你想要提供更友好的错误消息或记录错误以供调试时,这可能会很有用。

未检查异常的例子包括 NullPointerExceptionIndexOutOfBoundsExceptionIllegalArgumentException。这些未检查异常是 RuntimeException 的子类。这个类是 Exception 的子类。与 Exception 的所有其他子类不同,RuntimeException 及其子类不需要被处理。(你可以说是…一个异常。)

图 11.1 中,你可以以图表的形式看到这个层次结构:

图 11.1 – 可抛出类层次结构

图 11.1 – 可抛出类层次结构

理解异常层次结构对于有效地处理异常至关重要。正如你所见,存在不同类型的异常。其中一些(检查异常)需要处理,而另一些则不需要(未检查异常)。

在本章中,我们将使用 I/O 操作来演示异常。这是我们之前没有见过的。所以,让我们首先介绍 I/O 操作。

使用基本 I/O 操作

我们将通过 I/O 操作来展示异常是如何工作的。因此,在深入异常处理之前,我们将简要介绍基本的 I/O 操作。有好多方法可以做到这一点,但我们将使用 FileReaderFileWriter - FileReaderFileWriterjava.io 包中的类,允许你读取和写入字符。我们选择这两个类是因为它们提供了在 Java 中处理文本文件的一种简单方法,并且在现实世界的文件 I/O 操作中也经常被使用。首先,让我们用 FileReader 来读取。

其他 I/O 操作类

在常见情况下,通常使用其他类进行 I/O 操作。例如,如果你要从文件中读取行,你可能想使用BufferedReader。这不是本章的重点。我们只想了解足够的 I/O 操作,以便演示一些异常处理的实际场景。

使用 FileReader 从文件中读取

要使用FileReader从文本文件中读取,你首先需要创建一个FileReader对象,并将文件路径作为参数传递。然后你可以使用read()方法从文件中读取字符。使用FileReader后,你必须关闭它,以确保你不锁定文件,并且不使用任何不必要的资源。下面是使用FileReader读取文件的示例:

import java.io.FileReader;import java.io.IOException;
public class ReadFileExample {
    public static void main(String[] args) {
        try {
            FileReader reader = new
            FileReader("input.txt");
            int character;
            while ((character = reader.read()) != -1) {
                System.out.print((char) character);
            }
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这段代码是从名为input.txt的文件中读取的。try-catch块是我们将在本章后面看到的;它是用于异常处理的,你现在不需要理解它。

我们创建了一个新的FileReader实例,并传递了输入文件的路径。为了使读取操作生效,input.txt已经被放置在项目文件夹中。对我来说,它看起来就像图 11.2所示的结构:

图 11.2 – 项目中 input.txt 的位置

图 11.2 – 项目中 input.txt 的位置

读取文件的最复杂的代码片段可能是以下这个:

int character;while ((character = reader.read()) != -1) {
    System.out.print((char) character);
}

FileReader将逐字符读取输入文件。read()方法读取一个字符并移动光标。光标是它开始读取下一个字符的位置。因此,我们需要将读取的结果存储在一个变量中,以免丢失字符。当达到文件末尾时,read()将返回-1。这意味着我们需要读取直到达到-1。这正是while ((character = reader.read()) != -1)所做的事情。

我们将要打印的input.txt文件将显示在输出中。当然,我们可以用文件内容做更多有趣的事情,但这不是这里的重点。我们只想看到如何处理异常。如果代码只是这样,它将不会运行:

FileReader reader = new FileReader("input.txt");  int character;
while ((character = reader.read()) != -1) {
  System.out.print((char) character);
}
reader.close();

这就是读取文件的方法。接下来,我们将学习如何将内容写入文件。

使用 FileWriter 写入文件

这听起来可能相当直观,但要将内容写入文本文件,我们可以使用FileWriter。步骤与使用FileReader类似:

  1. 首先,你需要创建一个FileWriter对象,并将文件路径作为参数传递。

  2. 接下来,你可以使用write()方法将字符或字符串写入文件。

  3. 最后,关闭FileWriter

下面是使用FileWriter写入文件的示例:

import java.io.FileWriter;import java.io.IOException;
public class WriteFileExample {
    public static void main(String[] args) {
        try {
            FileWriter writer = new
              FileWriter("output.txt");
            String content = "I can write!";
            writer.write(content);
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如您所见,首先,我们创建了一个FileWriter的实例。然后,我们创建了一个名为contentString类型变量。我们使用write()方法将这个变量写入output.txt文件。再次忽略 try-catch 部分。我们很快就会了解到这一点。

现在我们已经介绍了基本的文件 I/O 操作,我们可以继续介绍异常和异常处理。我们将使用FileReaderFileWriter作为处理各种类型异常的现实世界示例。

抛出异常

当出现问题时,程序会抛出异常。这是因为创建 Java 或你正在使用的库的人,在某个时刻,这样编写了代码。Java 库中的许多部分都是编程为抛出异常的,例如在以下情况下:

  • 当你尝试访问 null 实例的字段或方法时,会抛出NullPointerException

  • 当你尝试除以 0 时,会抛出ArithmeticException

  • 当你尝试访问数组中不属于数组范围的索引时,会抛出ArrayIndexOutOfBoundsException

这是抛出异常的代码输出的示例:

int x = 2 / 0;

这是输出:

Exception in thread "main" java.lang.ArithmeticException: /by zero
    at ThrowingExceptions.main(ThrowingExceptions.java:3)

你可以在输出中看到异常的名称(java.lang.ArithmeticException),以及消息,指出/ by zero

在异常下面,我们可以看到堆栈跟踪。堆栈跟踪是应用程序到达异常的步骤。堆栈跟踪的顶部显示了触发异常的行。这是一个非常短的堆栈跟踪,因为它直接在主方法中出错,所以我们只有一行。

当出现问题时,Java 库会抛出异常。这是通过throw关键字实现的。在下一节中,我们将看到如何使用这个throw关键字来自己抛出异常。

throw关键字

我们可以使用throw关键字显式地抛出异常。这通常用于当你的代码检测到异常条件或当你想在代码中强制执行特定约束时。

这是抛出异常的语法:

throw new IllegalArgumentException("Age cannot benegative.");

我们从throw关键字开始;之后,有一个Throwable实例。在这种情况下,我们抛出一个新的IllegalArgumentException实例,并在消息中指定年龄不能是负值。

当抛出异常时,程序的正常执行被中断,控制权转移到最近的匹配的 catch 块。如果没有,程序停止并显示异常和堆栈跟踪。

创建和抛出自定义异常

Java 有很多内置的异常,但在某些情况下,你可能需要更具体的异常。好消息是,你还可以创建和抛出你自己的自定义异常!自定义异常在你想提供有关发生问题的更具体信息时很有用,或者当你想在 catch 块中不同地处理某些类型的异常时。

要创建自定义异常,你需要定义一个新的类,该类扩展了Exception类或其子类。以下是一个自定义异常类的示例:

public class InvalidAgeException extends Exception {    public InvalidAgeException() {
        super();
    }
    public InvalidAgeException(String message) {
        super(message);
    }
    public InvalidAgeException(Exception e) {
        super(e);
    }
}

我们重写了以下三个构造函数。这是推荐的做法以支持约定:

  • 无参数构造函数

  • 包含消息的String参数的构造函数

  • 接受另一个异常的构造函数

InvalidAgeException 自定义类扩展了 Exception 类。因此,InvalidAgeException 是一个需要处理的检查型异常。如果它扩展了 RuntimeException 或其子类,它就是一个非检查型异常,不需要处理。让我们谈谈如何捕获和处理异常。

catch 或 declare 原则

catch 或 declare 原则指出,当一个方法可以抛出检查型异常时,该方法必须使用 try-catch 语句捕获异常或在它的方法签名中声明它抛出异常。这个规则确保了检查型异常得到适当的处理或传播到调用栈,以便调用方法可以处理它们。

理解原理

对于检查型异常,catch 或 declare 原则适用。如果一个检查型异常没有被声明或捕获,代码将无法编译。对于非检查型异常,catch 或 declare 规则不适用。它们通常由编程错误或无法预测或预料到的情况引起。非检查型异常可以被捕获和处理,但这不是强制性的。让我们看看我们如何声明异常。

现在我们已经看到了如何声明异常,让我们看看如何使用 try-catch 语句处理异常。

使用 throws 声明异常

throws 关键字用于声明一个方法可能会抛出某种异常。通过使用 throws 关键字,你可以表明一个方法可能会抛出一个或多个检查型异常。调用声明异常的其他方法的方法有责任处理它们。

声明异常并不困难。你只需在方法签名后简单添加 throws 并跟随着异常类型即可。以下是一个使用 FileReader 的代码示例:

 public static void read(String fileName) throws   IOException {
    FileReader = new FileReader(fileName);
}

在这个例子中,read 方法声明它可能会抛出 IOException。当另一个方法调用这个方法时,它有责任处理这个异常。当你知道你想要如何处理一个异常时,你可以使用 try-catch 语句来处理它,而不是声明它。

使用 try-catch 处理异常

当一个方法声明了一个检查型异常时,调用该方法的方法有义务处理它。这可以通过捕获异常或在其自己的方法签名中声明异常来完成。

让我们看看如何使用 try-catch 块处理异常。try-catch 块有多种形式,但我们将从最基本的形式开始。

基本的 try-catch 块

try-catch 块用于处理在执行特定代码块期间可能抛出的异常。可能抛出异常的代码放置在 try 块内,处理异常的代码放置在相应的 catch 块内。以下是 try-catch 块的语法:

try {  //... code that might throw an exception ...
} catch(SomeException e) {
  //... code that handles the exception ...
}

以下是一个基本的 try-catch 块示例,其中包含可能抛出异常的代码和一些基本的处理。我们在学习FileReader时看到了这个例子:

import java.io.FileReader;import java.io.IOException;
public class ReadingFile {
    public static void main(String[] args) {
        try {
            FileReader fr = new FileReader("input.txt");
            int character;
            while ((character = fr.read()) != -1) {
                System.out.print((char) character);
            }
            fr.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在这段代码中,FileReader可能会抛出多个异常。例如,当文件不存在时,它会抛出FileNotFoundException。这个异常是一个IOException,而IOException又是一个Exception。因此,FileReader可能会抛出一个检查型异常。检查型异常需要被处理。因此,我们必须将可能抛出异常(s)的代码放在try块中。我们通过打印堆栈跟踪在捕获块中处理Exception。处理完异常后,程序会继续正常执行。

如果我们需要为不同类型的异常指定特定的处理,我们也可以指定多个捕获块。

多个捕获块

一段代码可能会抛出多种类型的异常。我们可以使用多个捕获块来处理不同的异常。将最具体的异常放在最上面是很重要的。如果我们首先捕获Exception,例如,它总是会进入那个捕获块。这是因为所有异常都继承自Exception,并且会是Exception类型。catch(Exception e)会捕获所有可能的异常,使得其余的捕获子句不可达。因此,如果你尝试这样做,它将无法编译。

这里是一个使用多个捕获块的例子:

public class MultipleCatchExample {    public static void main(String[] args) {
        try {
            FileReader fr= new FileReader("input.txt");
            int character;
            while ((character = fr.read()) != -1) {
                System.out.print((char) character);
            }
            fr.close();
        } catch (FileNotFoundException e) {
            System.out.println("Not found:" +
              e.getMessage());
        } catch (IOException e) {
            System.out.println("IO error:" +
              e.getMessage());
        }
    }
}

在这个例子中,我们有两个捕获块——一个用于FileNotFoundException,另一个用于IOException。如果抛出异常,将根据异常类型执行相应的捕获块。

有时,我们希望在捕获之后清理资源或执行其他类型的操作。我们可以通过finally块来实现这一点。

try-catch-finally

finally块是一个可选的代码块,它跟在 try-catch 块之后。它无论是否抛出异常都会被执行。finally块通常用于清理资源。这些资源可能是需要关闭的文件流或网络连接。

这里是一个使用finally块的例子:

public class TryCatchFinallyExample {    public static void main(String[] args) {
        try {
            // Code that might throw an exception
        } catch (Exception e) {
            System.out.println("Oops: " + e.getMessage());
        } finally {
            System.out.println("This code will always
              run.");
        }
    }
}

在这个例子中,finally块在try-catch块之后执行,无论是否发生异常。唯一不执行finally块的方法是在try-catch块完成之前完全停止程序。

finally块的使用场景

finally块可以用来清理资源。这确保了即使在抛出异常的情况下,它们也能被正确释放。以下是一个使用finally块关闭FileReader实例的例子:

import java.io.*; public class FileResourceCleanup {
    public static void main(String[] args) {
        FileReader reader = null;
        try {
            reader = new FileReader("input.txt");
            int character;
            while ((character = reader.read()) !=-1){
                System.out.print((char) character);
            }
        } catch (IOException e) {
            System.out.println("Err: " + e.getMessage());
        } finally {
            if (reader != null){
                try {
                    reader.close();
                } catch (IOException e) {
                    System.out.println("Err closing: " +
                      e.getMessage());
                }
            }
        }
    }
}

这个例子有一点不同。让我们从最显著的不同之处开始:我们现在在finally块中关闭了readerin。这确保了readergets即使在try块中发生异常之前也会被关闭。

为了使 readerfinally 块中仍然有效,我们必须在 try 块之外声明它。这就是为什么我们在 try 块上方有这一行:

FileReader reader = null;

我们不能在 try 块之外初始化它,因为那部分需要位于 try 块中,因为它可能会抛出异常。

当没有异常发生时,代码的流程如下:

  1. try: 初始化 FileReader 并读取文件。

  2. finally: 关闭 reader.

  3. finally 块之后继续执行剩余的代码。

当发生异常时,代码的流程如下:

  1. try: 初始化 FileReader 并读取文件。

  2. catch: 处理异常。

  3. finally: 关闭 reader。

  4. finally 块之后继续执行剩余的代码。

无论是否抛出异常,finally 块都确保 reader 被关闭。

关闭 reader 可能会抛出另一个异常,这就是为什么我们在 finally 块中又有一个 try-catch 语句。可以说,这不是一个非常漂亮的语法。对于许多这种情况的解决方案是使用 try-with-resources 语句。

使用 try-with-resources 处理异常

Java 7 引入了 finally 块,用于清理许多类型的类。try-with-resources 语句可以在没有 catch 或 finally 块的情况下使用。正常的 try 语句必须至少有一个这些块。

什么是 try-with-resources?

try-with-resources 语句为您处理资源管理。资源是一个特殊的 Java 对象,它打开一个需要关闭的通道,以便资源可以被 Java 标记为清理。我们已经看到,FileReader 对象是这种资源的例子。

在 try-with-resources 语句中声明的资源将在 try 块完成后自动关闭。当然,就像 finally 块一样,无论是否抛出异常,资源都将被关闭。

这里是一个使用 try-with-resources 的例子:

try (FileReader fileReader = new FileReader("input.txt")) {    int character;
    StringBuilder content = new StringBuilder();
    while ((character = fileReader.read()) != -1) {
        content.append((char) character);
    }
    System.out.println(content.toString());
} catch (IOException e) {
    System.out.println("Oops: " + e.getMessage());
}

资源需要在 try 块之后的括号内打开。在 try 块结束时,资源将被关闭。

您可以使用分号分隔打开多个资源,如下所示:

try (FileReader fileReader = new FileReader("input.txt");     BufferedReader bufferedReader = new BufferedReader
       (fileReader);
     FileWriter fileWriter = new FileWriter("output.txt");
     BufferedWriter bufferedWriter = new BufferedWriter
       (fileWriter)) {
    String line;
    while ((line = bufferedReader.readLine()) != null) {
        String uppercaseLine = line.toUpperCase();
        bufferedWriter.write(uppercaseLine);
        bufferedWriter.newLine();
    }
} catch (IOException e) {
    System.out.println("Oops: " + e.getMessage());
}

由于我们没有讨论 BufferedReaderBufferedWriter,所以您不需要理解这个例子中代码的细节。这些类是提供缓冲功能的实用类,用于读写文本文件。通过缓冲,我们可以通过最小化系统调用的次数来提高 I/O 操作的性能。

前面的代码片段使用 FileReaderBufferedReader 来读取文件内容,而 FileWriterBufferedWriter 用于将内容(全部大写)转换为 output.txt

try-with-resources 块确保在资源使用后自动关闭所有资源。它是按照与声明相反的顺序关闭的,所以它首先关闭最后一个。这很重要,因为正如你所看到的,我们正在使用fileWriter来创建bufferedWriter。以不同的顺序关闭它们可能会导致问题。

请不要忘记,并不是所有的类都可以自动关闭。为了让 Java 能够自动关闭一个类,这个类需要实现AutoCloseable接口。

实现AutoCloseable接口

要能够使用一个(自定义)类与 try-with-resources 语句一起使用,这个类应该实现AutoCloseable接口并重写close()方法。

我们可以创建自己的类,这些类可以自动关闭。以下是一个实现AutoCloseable的自定义资源的示例:

public class SomeResource implements AutoCloseable {    public void doSomething() {
        System.out.println("Doing something...");
    }
    @Override
    public void close() {
        System.out.println("Resource closed.");
    }
}

这个资源现在可以在 try-with-resources 语句中使用:

public class SomeResourceExample {    public static void main(String[] args) {
        try (SomeResource resource = new SomeResource()) {
            resource.doSomething();
        }
    }
}

这段代码在 try-with-resources 语句中打开SomeResource。然后我们调用doSomething()方法,该方法向控制台打印一行。在代码块结束时,资源被关闭。我们在必须为AutoCloseable接口实现close()方法时打印另一行。

这是输出:

Doing something...Resource closed.

如你所见,它打印了doSomething()方法的行。close()方法也被触发。正如我们所看到的,它在输出中打印的消息。我们不自己触发close()方法,这是由try-with-resources语句的机制完成的。

这就是 try-with-resources 语句的基础,这样你就可以开始使用它了。现在是我们讨论一个通常被认为相当具有挑战性的主题的时候了:处理继承和异常。

使用继承和异常处理

当一个类继承自另一个类时,它可以重写这个其他类中的方法。处理声明的异常和重写方法有一些特殊的规则。理解这些规则对于成功重写声明异常的方法非常重要。

在方法签名中声明异常

当一个方法可以抛出一个未被该方法中的 try-catch 处理的检查异常时,它会在方法签名中声明。我们刚刚学到,这是通过throws关键字后跟异常类型(s)来完成的。

这里是一个示例:

public void readFile(String filename) throws IOException {    // Read file code
}

readFile方法的签名声明它可以抛出IOException。当我们扩展这个方法所在的类时,我们可以重写readFile方法。处理声明的异常有一些重要的规则。

重写方法和异常处理

让我们暂时放下代码,同时更抽象和具体地思考一下。假设你和我下周在你办公室见面讨论一个软件应用,我告诉你由于日托问题我必须带上我的小孩子。你知道可能会发生某些异常:孩子的闹脾气、孩子之间的争吵、头发和衣服上的食物,等等。然而,你同意和我见面。

如果我计划也带来我的三只罗威纳犬,因为我的宠物看护者取消了,我可能想提前通知你,这样你可以决定是否仍然可以让我在这些新条件下过来。你已经在你做出的决定中包含了孩子异常,但你还没有决定你是否也接受狗异常。这包括泥泞的爪子、口水、狗毛,以及可能不小心和这些温柔的巨兽分享你的饼干。

在此之前通知你带来可爱的保护者可能被认为是有礼貌的。然而,如果最终我有一个保姆并且我自己来,我可能不需要提前提这件事,因为这会使事情更加方便。(不,我不讨厌我的孩子。)

好吧——在我们回到 Java 的过程中,请记住这一点。

当你在子类中重写一个方法时,重写的方法必须遵循有关异常的某些规则:

  • 它不能抛出在父类方法签名中未声明的已检查异常。(我们不能不通知就带来狗。)

  • 如果被重写的方法声明了一个已检查异常,重写的方法可以声明相同的异常、该异常的子类或异常的子集。(只带一个孩子而不是两个。)

  • 什么也可以被认为是一个子集。因此,我们也可以选择不在重写方法的孩子类中声明任何异常。(不带孩子们。)

这里有一个声明子类的重写示例:

class Parent {    public void readStuff() throws IOException {
        // Parent implementation
    }
}
class Child extends Parent {
    @Override
    public void readStuff () throws FileNotFoundException {
        // Child implementation
    }
}

Child类重写了Parent类的readStuff方法。由于被重写的方法声明了IOException,重写的方法可以声明相同的异常或其子类(例如,FileNotFoundException)或者根本不声明任何异常。

未检查异常总是可以添加的。它们对调用代码没有任何影响。同时,通常声明它们也没有太多意义,因为它们没有义务处理它们。

练习

让我们处理一些我们应用中常见的令人不快的路径场景。当这些发生时,我们需要我们的应用能够从中恢复:

  1. 在读取和写入恐龙数据时,由于不同情况,文件可能无法打开。也许有人移动了它,它在使用中,或者发生了其他事情。你的任务是模拟一个尝试从文件(可能不存在)中读取并处理已检查异常的情况。

  2. 在更新恐龙数据时,有时可能会提供无效的值。编写一个 updateDinosaurWeight 方法,该方法接受一个重量值和一个 Dinosaur 对象。如果重量值小于零,该方法应抛出 IllegalArgumentException。使用 try-catch 块来处理这个异常。目前,处理可以是简单的 System.out.println

  3. 即使在异常情况下,某些操作也应该始终执行。例如,无论是否发生异常(例如由于体重过低),都应该进行恐龙的健康每日审计。在你的程序中使用 finally 块来演示这一点。编写逻辑,即使更新恐龙健康记录时出现错误,也应该打印出每日审计完成的消息。

  4. 在我们的恐龙公园中,恐龙饮食数据存储在外部资源中。在这种情况下,外部资源是一个文件。编写一个程序,其中你使用 try-with-resources 块从该文件中读取数据,确保在使用后正确关闭文件,即使数据检索过程中发生错误。以下是一个名为 DinoDiet.txt 的示例文件,你可以使用它:

    Tyrannosaurus: CarnivoreBrachiosaurus: HerbivoreTriceratops: HerbivoreVelociraptor: CarnivoreStegosaurus: HerbivoreSpinosaurus: CarnivoreAnkylosaurus: Herbivore
    
  5. 如果恐龙的健康评分低于某个临界值,程序应抛出一个名为 CriticalHealthException 的自定义异常。创建这个自定义异常并在你的程序中使用它来处理这个特定的问题条件。

项目 – 恐龙护理系统

经营一个恐龙公园充满了意外情况。有些是小事,比如奶酪味薯片用完了。有些是大事,比如霸王龙逃跑了。我们恐龙和游客的幸福、健康和安全很重要,因此我们的系统应该能够处理异常情况。

为中生代伊甸园设计一个“恐龙护理系统”,该系统可以处理诸如恐龙生病、围栏被破坏等异常情况。使用适当的异常来表示各种错误条件,并正确处理它们。

这里是执行这些步骤的方法:

  1. 设置你的项目:

    1. 在你选择的 IDE 中创建一个新的 Java 项目。

    2. 创建一个名为 exception 的新包。

  2. 创建自定义异常:

    1. 在异常包中创建一个名为 DinosaurIllException 的新类。这个类应该扩展 Exception 类,并代表恐龙生病时的错误条件。

    2. 类似地,为围栏被破坏的错误条件创建 EnclosureBreachedException

  3. 创建恐龙护理系统:

    1. 创建一个名为 DinosaurCareSystem 的新类。

    2. 在这个类中,创建一个名为 handleDinosaurHealth() 的方法,该方法抛出 DinosaurIllException。你可以模拟恐龙的随机健康状况。

    3. 类似地,创建一个名为 handleEnclosureSecurity() 的方法,该方法抛出 EnclosureBreachedException。使用这个方法,你可以模拟恐龙围栏的随机安全状态。

摘要

我们刚刚探讨了异常处理的重要性。我们现在知道它如何使我们能够将代码逻辑与错误处理逻辑分离。我们深入了解了两种主要的异常类型:检查异常和非检查异常。检查异常是需要显式处理的异常,而非检查异常通常由编程错误引起,不需要显式捕获或声明。

我们讨论了捕获或声明原则,该原则要求检查异常必须在 try-catch 块中捕获或在方法签名中声明。try-catch 块允许我们在发生异常时执行替代代码来处理异常。我们还学习了如何使用多个 catch 块来处理不同类型。

接下来,我们看到了finally块,无论是否发生异常都会被执行。这个块用于清理资源并确保某些操作始终被执行。自从 Java 7 以来,finally块变得不那么常见,因为尽可能使用try-with-resources。这通过在try块执行完毕后自动关闭资源来简化资源管理。

最后,我们检查了方法异常签名以及它们与继承的关系,同时关注了在覆盖方法时检查异常的规则。

到目前为止,你应该对 Java 异常处理有了稳固的理解。现在,是时候学习更多关于 Java 核心 API 的知识了。

第十二章:Java 核心 API

在本章中,我们将更深入地探讨 Java API 中流行的类和接口。我们将从常用的 Scanner 类开始,该类常用于从键盘(用户)等来源扫描和解析文本。然后,我们将检查非常流行的 StringStringBuilder 类。我们将讨论它们之间的区别,这需要对比可变和不可变类型。我们还将向您展示如何设计不可变类型,并查看 List 接口及其流行的实现类 ArrayList。最后,我们将检查在 Java 8 中进行重写的 Date API。

关于本章涵盖的类型更详细的信息,请参阅 Java Docs API:docs.oracle.com/en/java/javase/21/docs/api/index.html

本章涵盖了以下主要主题:

  • 理解 Scanner

  • 比较 StringStringBuilder

  • 设计不可变类型

  • 检查 ListArrayList

  • 探索 Date API

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch12

理解 Scanner

Scanner(来自 java.util 包)是一个文本扫描器,可以使用正则表达式解析基本类型和字符串。正则表达式是一个允许字符串操作的模式。正如 Java API 中所明确指出的:“Scanner 使用分隔符模式将其输入拆分为标记,默认情况下匹配空白。然后可以使用各种 next 方法 将这些标记转换为不同类型的值。

这些 nextXXX() 方法将输入流中的标记转换为基本类型。例如,如果用户输入了 23,则 nextInt() 会返回 23int 值;如果用户输入了 45.89,则 nextDouble() 会返回 45.89double 值。

然而,如果输入流中的标记不是整数并且调用了 nextInt(),则会抛出 InputMismatchException 错误。这可能会发生在用户输入 "abc" 并调用 nextInt() 的情况下。为了防止这种情况,每个 nextXXX() 方法都有一个相应的守护天使方法,即 hasNextXXX()。例如,nextInt() 有一个相应的 hasNextInt() 方法,nextDouble() 有一个相应的 hasNextDouble() 方法,依此类推。hasNextXXX() 方法都会预先查看输入流中的下一个标记(不消耗它)并检查该标记是否可以成功转换为所需类型。它们相应地返回 truefalse。如果返回 true,则可以安全地使用相应的 nextXXX() 方法而不会引发异常。

表 12.1 展示了一些更重要的 Scanner 方法。请注意,我们只列出了一种 hasNextXXX() 方法,即 hasNextDouble(),以及其对应的 nextXXX() 方法,即 nextDouble()。所有以下类型都遵循相同的模式:booleanbytefloatintlongshort

方法名称 描述
Scanner(InputStream source) 创建一个 Scanner 类,它从指定的输入流中生成值 - 例如,键盘
Scanner(File source) 创建一个 Scanner 类,它从指定的文件中生成值
Scanner(String source) 创建一个 Scanner 类,它从指定的字符串中生成值
String next() 返回下一个标记
boolean hasNextDouble() 返回 true 当且仅当下一个标记是一个有效的 double
double nextDouble() 将下一个标记扫描为 double
String nextLine() 返回(行的剩余部分)
Scanner useDelimiter(String pattern) 根据传递的参数设置 Scanner 的分隔模式

表 12.1 – “Scanner” API 方法示例

现在,让我们看看一些代码示例。

使用 Scanner 从键盘读取

标准输入流可以通过 System.in 访问。通常,这是键盘。在创建我们的 Scanner 时,我们必须将输入源(在这种情况下为 System.in)作为 Scanner 构造函数的参数传递。图 12.1 展示了一个示例:

图 12.1 – “Scanner” 从键盘获取输入

图 12.1 – “Scanner” 从键盘获取输入

在这个图中,第 23 行我们创建了一个 Scanner 对象,通过将 System.in 传递给 Scanner 构造函数来引用键盘。第 24 行简单地提示用户输入年龄。第 25 行是守护天使方法,用于防止异常。如果输入的下一个标记是 int 类型,那么第 25 行的条件将为 true,我们就可以安全地在第 26 行执行 nextInt()。第 27 行回显在键盘上输入的整数。

关闭 Scanner 资源

关闭已经完成使用的资源是明智的,因为它可以防止资源泄漏。然而,围绕 System.in 包装的 Scanner 对象略有不同。实际上,如果我们关闭了一个围绕 System.in 包装的 Scanner 对象,我们将无法再次从标准输入读取。

这正是 图 12.1 中的第 30-34 行所展示的。如果我们关闭 Scanner 对象(第 30 行),即使第 31-34 行在本质上与第 23-27 行相同(除了 hasNextInt()),第 33 行将抛出异常。这是因为我们正在尝试访问一个已关闭的资源。

现在,让我们看看一个将进一步解释标记和分隔符的示例:

图 12.2 – 由空白分隔的 next()

图 12.2 – 由空白分隔的 next()

在这个图中,我们使用next()尝试从输入流(键盘)中解析Sean Kennedy。然而,(默认)分隔符是空白字符,因此返回的是Sean。请注意,Kennedy仍然在输入流中。我们可以再次调用next()来消费额外的Kennedy标记。但是,有一个方法可以解决这个问题:nextLine()图 12.3展示了nextLine()的作用:

图 12.3 – 由行尾分隔的 nextLine()

图 12.3 – 由行尾分隔的 nextLine()

在这个图中,我们使用nextLine(),它使用不同的分隔符。与next()不同,它不是用空白字符分隔标记(如next()),而是用换行符分隔nextLine()。实际上,nextLine()读取一行文本,而next()读取单词。第 17 行通过输出Sean Kennedy来演示这一点。

Scanner可以重定向到其他输入源。其中一个源是文件。

使用 Scanner 从文件中读取

让我们看看如何将Scanner重定向到从文件中读取,而不是从键盘读取。图 12.4展示了这样一个例子:

图 12.4 – “Scanner”从文件中读取输入

图 12.4 – “Scanner”从文件中读取输入

在前面的图中,所涉及的文件(ages.txt)是一个简单的文本文件,包含数字12,后面跟着一个回车符。我们将File对象传递给Scanner构造函数(第 34-36 行)。传递给File构造函数的String对象(第 35-36 行)是一个相对路径。换句话说,它附加到当前工作目录(与包含从根目录的完整路径的绝对路径相对)。String中的\\是我们对反斜杠(\)进行转义的地方。Java 将\\内部转换为单个反斜杠。因此,"out\\production"变为"out\production"hasNextInt()nextInt()方法(第 37 和 38 行)与之前的工作方式相同。

由于我们使用了 try-with-resources,我们不需要记住显式关闭ScannerFile资源(它们会为我们隐式关闭)。

Scanner输入的另一个可能来源是String对象。现在让我们看看这一点。

使用 Scanner 从字符串中读取

使用String作为输入Scanner源也是可能的。图 12.5展示了这样一个例子:

图 12.5 – “Scanner”从字符串中读取输入

图 12.5 – “Scanner”从字符串中读取输入

在这个图中,我们声明了一个String对象(第 14 行)并将其传递给Scanner构造函数(第 15 行)。然后我们将useDelimiter(String)方法链接到返回的Scanner对象。此方法接受String作为参数,并代表解析输入所需的正则表达式模式。双反斜杠,正如之前一样,只是简单地转义反斜杠。换句话说,\\变为\

正则表达式 \s* 转换为 0 个或多个空白字符* 代表 0 个或多个\s 代表 单个空白字符delim 字符串是硬编码的。这意味着输入标记由 0 个或多个空格、delim 标记以及 0 个或多个空格分隔。

当我们将此分隔符模式应用于给定的输入字符串(第 14 行)时,next() 返回的第一个标记是 Maaike。这是在第 16 行输出的。由于 Maaike 已经从输入流中消耗掉,第 17 行的 next() 方法调用返回 van 标记,然后输出。同样,第 18 行的 next() 方法返回 Putten 以供输出。最后,第 19 行使用 nextInt() 返回 22 作为 int 类型,然后输出到屏幕。

现在我们知道了如何从用户那里获取输入,让我们来介绍两个处理字符串的最重要类,即 StringStringBuilder

比较 String 与 StringBuilder

当处理字符串时,这两个类是你的首选类。它们的主要区别在于 String 对象是不可变的,而 StringBuilder 对象是可变的。这意味着对于字符串,一旦创建了 String 对象,就不能再更改该对象。Java 可能会让你看起来改变了对象,但你并没有;一个新的对象,反映了你的更改,已经被创建。另一方面,StringBuilder 对象是可变的。这意味着你始终在处理一个对象。我们将在稍后的示例中深入探讨这一点。

为什么是不可变的?

从安全角度来说,不可变性很有吸引力,因为不可变对象不能被更改。此外,在多线程环境中,不可变对象是线程安全的。在多线程环境中,对(非不可变)对象的更改必须逐个同步,以防止更改相互干扰。不可变对象按照定义是受到保护的。参见第十七章(并发)中关于多线程的讨论。

目前,让我们从 String 类开始。

字符串类

String 类位于 java.lang 包中,表示字符序列。由于该类位于 java.lang 中,它将自动为你导入。String 类实现了 Comparable 接口,这意味着在排序字符串时定义了自然排序。这种排序是字母顺序。

正如所述,String 是一个不可变类型。一旦创建了 String 类型的对象,就不能对其进行修改。String 也是一个 final 类,这意味着你不能对其进行子类化。这是故意的——Java 设计者期望字符串以某种方式行为。如果我们被允许子类化 String 并覆盖其行为,可能会出现意外结果。因此,通过确保字符串行为的可预测性,将类设置为 final 防止了这种情况。

所有String字面量都是String类的实例。String字面量存储在堆的一个特殊区域,称为字符串池(或字符串常量池)。这被称为字符串的内部化。如果遇到具有相同字符序列的另一个字符串字面量,将重用字符串池中的字符串。这可以节省内存。然而,如果您使用new关键字来创建您的String对象,即使该对象在字符串池中可用,也会在堆的另一个部分创建一个新的具有字符序列的对象。换句话说,如果使用new,则忽略字符串池。

让我们通过代码示例以及一个内存图来查看一个示例。

字符串示例(代码)

图 12.6 表示以下代码:

图 12.6 – “字符串”示例在代码中

图 12.6 – 代码中的“字符串”示例

在这个图中,第 5 行,我们使用s1字符串引用来引用"abc"字符串字面量。当 JVM 遇到字符串字面量时,它首先检查是否在字符串池中存在相同的字符串字面量。如果存在,它将重用池中的那个。由于第 5 行是第一次遇到"abc",因此带有"abc"String对象被插入到池中。请注意,字符串池只是堆中的一个特殊区域。

第 6 行是字符串池对象被重用的地方。现在,s1s2都引用了同一个String对象(在池中)。这就是为什么第 7 行输出true的原因。回想一下,当使用==运算符与引用一起使用时,我们是在比较引用。换句话说,s1s2是否都指向内存中的同一个对象?是的,它们是。

第 8 行使用new关键字创建一个String对象。一旦使用new,无论是否使用相同的字面量"abc",都会在堆的另一个部分创建一个全新的对象。由于第 8 行创建了一个新对象,当我们第 9 行比较s1s3时,结果是false。这是因为s1s3引用了两个不同的对象。

String对象的equals()方法与等价运算符==操作不同。equals()不是比较引用,而是比较对象的内容。由于第 10 行返回true,这表明s1s2引用的对象内容是相同的。这不应该令人惊讶,因为s1s2都引用了同一个对象。

然而,第 11 行也返回true。尽管s1s3引用了不同的对象,但这表明equals()比较的是对象的内容,而不是引用。

第 12 行很有趣。我们可以通过使用其intern()方法来内部化一个字符串。第 12 行所说的就是“将s3引用指向的内容在字符串池中内部化,并使s3引用指向字符串池对象。”第 13 行返回true,表明s1s3现在都引用了同一个字符串对象。注意,第 13 行与之前返回false的第 9 行是相同的代码。

这里的图表肯定会很有帮助,所以让我们检查内存中发生了什么。

String 示例(图表)

图 12**.7 表示了 图 12**.6 中的代码的内存表示:

图 12.7 – “String”在内存中的示例

图 12.7 – “String”在内存中的示例

如前图所示,s1s2都指向字符串池中的"abc"对象。从s3发出的虚线代表由new关键字(第 8 行)创建的对象。因此,我们有两个独立的字符串对象:一个在堆上,一个在池中(池也是堆的一部分)。

s3发出的实线代表第 12 行上 intern 操作的结果。现在,很容易看出为什么在第 12 行之后,s1 == s3返回true

因此,当使用字符串时,如果您想比较内容,请使用equals()方法。请注意,equals()是区分大小写的。有一个方法,即equalsIgnoreCase(),它是不区分大小写的。

String对象的一个重要属性是它们是不可变的。现在让我们来讨论这一点。

String 不可变性

如果您想创建不可变类型或希望获得 Java 认证,这个主题非常重要。我们将在稍后讨论如何创建自定义不可变类型。关于String类,它是不可变对象,而不是引用。这意味着您可以更改引用以指向不同的字符串,但不能更改字符串对象的内容。此外,请注意,所有“包装”类型,如IntegerDoubleFloatCharacter,也都是不可变的。

让我们通过代码示例和相关的内存图表来查看。图 12**.8 表示了代码:

图 12.8 – “String”不可变性(代码)

图 12.8 – “String”不可变性(代码)

在这个图表中,第 18 行使用"The "字面量创建了一个由s引用的String对象。由于这是一个字面量,对象进入字符串池。第 19 行使用+=运算符将"quick "追加到s。由于第 20 行输出"The quick ",您可能会认为由s引用的字符串对象已经改变。事实并非如此。由于String对象是不可变的,这是不允许的。发生的情况是创建了一个新的String对象,反映了所需的变化。因此,在第 20 行我们有三个String对象:两个字面量"The ""quick "在字符串池中,新创建的"The quick "对象在堆上。

第 21 行是揭示性的。许多 String API 方法返回一个 String(引用)。由于 String 对象是不可变的,因此返回的这个 String 引用是到新创建的 String 对象的引用。这个对象在后台创建,反映了请求的更改。由于我们没有在第 21 行存储引用,因此这个对象对我们来说是丢失的,并且立即可以回收。当我们第 22 行输出 s 时,你可以看到它没有改变;其内容仍然是 "The quick "

第 23 行显示了第 21 行应该做的事情。通过重新初始化 s,我们重定向了引用到新创建的对象。因此,当我们第 24 行输出 s 时,我们得到完整的字符串,即 "The quick brown fox"

一个表示内存中发生情况的图表将有助于理解。图 12.9 表示 图 12.8 代码的内存表示:

图 12.9 – “String”不可变性(图表)

图 12.9 – “String”不可变性(图表)

在前面的图中,虚线表示被覆盖的引用。我们总共有六个 String 对象 – 池中的三个字符串字面量以及使用 += 操作符和 concat() 方法的三个构造的字符串对象。

注意到中间的字符串对象(在底部),没有引用指向它。它从未有过,因此没有虚线指向它。这就是第 21 行创建的对象,但由于引用从未分配给变量,它已经丢失了。

另一方面,第 23 行确实存储了新创建的字符串对象的引用。它覆盖了 s 引用中的内容。这就是为什么 s 指向 "The quick brown fox"

当我们讨论 StringBuilder 时,我们将重新编写 图 12.8 中的代码。目前,我们将查看 String API 中更重要的方法。这些方法反映在 表 12.2 中:

方法名称 描述
char charAt(int index) 返回指定索引处的字符。索引范围从 0(与数组相同)到 length()-1。
int compareTo(String anotherString) 按字典顺序逐字符比较两个字符串。换句话说,this.charAt(k)-anotherString.charAt(k)。例如,"ace""bat" 之前,"and""at" 之前,以此类推。如果所有字符都匹配但两个字符串长度不同,则较短的字符串排在较长的字符串之前。例如,"bat""battle" 之前。让我们看一下:"ace".compareTo("bat") 返回 -1"and".compareTo("at") 返回 -6"bat".compareTo("battle") 返回 -3
String concat(String str) 将参数字符串连接到这个字符串上。"abc".concat("def") 返回 "abcdef"
boolean endsWith(String suffix) 此字符串是否以指定的后缀结束?因为它使用equals(Object),所以它是区分大小写的。“abc.endsWith("bc")”返回true。“abc.endsWith("BC")”返回false
int hashCode() 返回此字符串的哈希码。哈希码用于存储/检索用于基于哈希的集合(如HashMap)中的对象。
int indexOf(String str) 返回指定子字符串首次出现处的索引。它是区分大小写的,并且是重载的。“abcdef.indexOf("b")”返回1。“abcdef.indexOf("B")”返回-1
int length() 返回字符串的长度。
String substring(int beginIndex) 返回此字符串的子字符串,从指定的beginIndex开始,并继续到字符串的末尾。索引从 0 开始。“abcdef.substring(3)”返回"def"
String substring(int beginIndex, int endIndex) 返回此字符串的子字符串。子字符串从指定的beginIndex开始,延伸到endIndex-1 处的字符。索引从 0 开始。思考: “给我endIndex-startIndex个字符,从startIndex开始。” 例如,"Sean Kennedy".substring(3,8)意味着“给我 5 个字符,从索引 3 开始”,它返回"n Ken"
String toLowerCase()``String toUpperCase() 分别将字符串转换为小写和大写。
String trim() trim()方法从字符串的两端删除空白字符 – 例如," lots of spaces here".trim()返回" lots of spaces here"

表 12.2 – “String”API 方法示例

现在,让我们将注意力转向StringBuilder类。

StringBuilder

StringBuilder类也在java.lang包中,表示一个可变的字符序列。StringBuilder的 API 与StringBuffer类相同。在单线程环境中使用StringBuilder,在多线程环境中使用StringBufferStringBuilder还实现了Comparable接口,其中定义的自然排序顺序也是字母顺序。

StringBuilder是一个可变类型。StringBuilder也是一个final类,这意味着你不能从它派生子类。再次强调,这是故意的,因为 Java 设计者希望确保StringBuilder对象的行为可预测。

如前所述,我们将重构图 12**.8,使用StringBuilder代替String。此外,我们还将绘制内存中的差异图。

StringBuilder示例(代码)

图 12**.10 表示的代码:

图 12.10 – 代码中的“StringBuilder”示例

图 12.10 – 代码中的“StringBuilder”示例

在这个图中,第 49 行创建了一个新的StringBuilder对象并将其初始化为"The "。第 50 行使用append()方法将"quick "追加到由sb引用的对象中。由于StringBuilder对象是可变的,我们可以忽略返回的引用(因为我们已经在sb中有了这个引用)。第 51 行输出了"The quick ",从而证明了(唯一的)StringBuilder对象已被更改。第 52 行将"brown fox"追加到StringBuilder对象中,第 53 行再次显示始终只有一个对象。

让我们看看*图 12.10**的内存表示。

StringBuilder 示例(图表)

图 12.11代表了图 12.10中代码的内存表示:

图 12.11 – 内存中的“StringBuilder”示例

图 12.11 – 内存中的“StringBuilder”示例

从这个图中可以看出,除了字符串池对象(因为它们是字符串字面量)之外,我们只有一个StringBuilder对象。每次我们调用append()时,那个可变的StringBuilder对象都会被更改。

让我们看看StringBuilder中更常用的 API 方法。表 12.3反映了这个 API:

方法名称 描述
StringBuilder append(String str) 将指定的字符串追加到StringBuilder。有可用的重载版本(见 API)。
char charAt(int index) 返回指定索引处的字符。索引范围从 0。
int indexOf(String str) 返回指定子字符串首次出现的索引。
StringBuilder insert(int offset, String str) 在指定的偏移量处将给定的字符串插入到StringBuilder对象中,并将该位置以上的任何字符向上移动。
String substring(int beginIndex) 返回一个新的字符串,从指定的beginIndex开始,直到此字符串构建器的末尾。索引从 0 开始。
String substring(int beginIndex, int endIndex) 返回一个新的字符串,从指定的beginIndex开始,并扩展到endIndex-1处的字符。索引从 0 开始。
String toString() 返回字符序列的字符串表示。

表 12.3 – 样本“StringBuilder”API 方法

正如我们所见,StringStringBuilder之间的主要区别在于String对象是不变的,而StringBuilder对象是可变的。让我们看看一个将有助于将这种差异清晰地聚焦的示例。

String 与 StringBuilder 示例

我们将使用一个示例代码来演示这一点。此代码将帮助我们突出String对象的不变性和StringBuilder对象的可变性。作为额外的好处,因为我们使用了方法,所以代码将帮助我们复习按值传递的原则。图 12.12显示了代码:

图 12.12 – “String”与“StringBuilder”代码示例

图 12.12 – “String”与“StringBuilder”代码示例

在这个图中,在第 11 行,我们声明了一个指向"Hi"String引用s和一个包含"Hi"StringBuilder引用sb。在第 13 行,我们调用whatHappens()方法,分别传入ssb

由于 Java 使用按值调用,每个引用都会创建一个副本。因此,方法声明(第 4 行)中的ssb引用分别指向第 11 行和第 12 行声明的相同对象。虽然不是必需的,但保持相同的标识符ssb有助于强调这一点。

第 5 行然后将" there!"连接到由s引用的字符串。由于字符串是不可变的,该对象不能被更改,因此 JVM 创建了一个具有字符序列(字符串值)为"Hi there!"的新对象。第 7 行输出这个新字符串。

第 6 行将" there!"追加到StringBuilder对象。由于它是可变的,对象只是被修改。修改后,第 8 行输出sb

在第 13 行的方法调用返回后,我们输出由s引用的字符串对象的值和由sb引用的字符串构建器对象的值。记住,因为我们传递了引用,并且由于按值调用,whatHappens()方法可以直接访问第 11 行和第 12 行在main()中声明的对象。然而,当我们输出字符串对象(第 14 行)时,我们看到它仍然是"Hi",这证明了String对象是不可变的。另一方面,当我们输出StringBuilder对象时,它已变为"Hi there!",这证明了StringBuilder对象的可变性。

这里有一个图表会有帮助。然而,为了使图表简单并专注于可变/不可变,省略了字符串池。图 12.13图 12.12中代码的内存表示:

图 12.13 - 内存中的“String”与“StringBuilder”

图 12.13 - 内存中的“String”与“StringBuilder”

此图表示我们即将离开whatHappens()方法(第 9 行)时的内存图片。虚线箭头是重要的箭头。当我们进入whatHappens()方法时,两个s引用都指向同一个String对象。第 5 行将局部s引用更改为指向新的String对象,而原始String对象保持未变(因为它是不可变的)。要注意的另一件事是StringBuilder对象已被修改(我们使用删除线来突出这一点)。

因此,在调用方法(第 13 行)后返回main()s引用指向未更改的包含"Hi"String对象,而sb引用指向已修改的包含"Hi there!"StringBuilder对象。

关于不可变类型的讨论引出一个自然的问题,我如何创建一个自定义的不可变类型?这就是下一节的主题。

设计一个自定义的不可变类型

在 API 中,有可变类型,如 StringBuilderArrayList,以及不可变类型,如 StringInteger。当某物是“不可变”的时候,意味着它不能改变。我们可以使用 final 关键字来使原始类型不可变。当我们对引用应用 final 时,是不可变的引用而不是对象。

如果我们想要创建自己的类型(类)并使其不可变呢?换句话说,我们希望基于我们的自定义类的 对象 是不可变的。涉及哪些考虑因素?这就是我们将在本节中讨论的内容。

在我们展示清单之前,请记住,Java 在向方法传递参数和从方法中检索值时使用按值传递。按值传递意味着会创建参数的副本,并且方法使用这个副本。对于原始类型,这意味着被调用方法不能改变从调用方法传递过来的原始类型值。这类似于传递一张纸的复印件;复印的纸张可以被写上内容,而不会改变原件。然而,对于引用来说,情况就不同了。向方法传递引用意味着被调用方法可以改变调用方法正在查看的对象。这类似于传递一个遥控器的副本;遥控器的副本也可以改变电视频道。这在清单中得到了体现。让我们来检查这个清单。

清单

应用的清单如下:

  • 不要提供任何“setter”方法

  • 将所有字段 private 化并设置为 final

  • 防止子类化(这样方法就不能被重写):

    • 将类 final

    • 将构造函数 private 化并提供一个 public static 工厂方法,例如 createNewInstance

  • 关于实例字段,请注意以下几点:

    • 像字符串 String 这样的不可变类型是可以的

    • 对于像 StringBuilder 这样的可变类型,不要 共享引用 – 使用在 第八章 中概述的高级封装技术。这项技术也被称为“防御性复制”。

这个清单最好通过代码示例来解释。我们将从一个看起来不错但存在细微问题的示例开始。我们将通过内存中的问题来进一步解释这个问题。最后,我们将通过代码来解决这个问题,并展示为什么它在内存中是可行的。

不可变类型(破坏封装)

图 12.14 展示了这样一个示例:

图 12.14 – 一个破坏封装的自定义不可变类型

图 12.14 – 一个破坏封装的自定义不可变类型

在这个图中,我们有一个名为Farm的不可变类型。该类是final(第 6 行),因此不能被继承。所有的字段都是privatefinal(第 8-10 行)。将它们标记为private确保没有外部类可以在不知情的情况下更改它们的值(基本的封装)。将它们标记为final意味着一旦赋予初始值,这些值就不能改变。在这个例子中,由于它们在声明点没有赋予初始值,因此它们被称为空白 final。空白 final 必须在构造函数完成之前初始化,这正是我们所做的(第 14-17 行)。

我们的构造函数在第 13 行被标记为private。因此,没有外部类可以通过这个构造函数直接new一个Farm对象。这是防止继承的另一种方式,因为子类无法访问这个构造函数,并且因为我们已经编写了构造函数,编译器也不会插入默认构造函数。我们还把构造函数参数标记为final,以防意外更改。

createNewInstance()工厂方法(第 20-23 行)是我们如何使外部类能够创建Farm对象。我们提供了一个public static方法,代表它们调用private构造函数。将其标记为public使得每个类都可以访问这个方法;将其标记为static确保客户端不需要创建一个对象来创建Farm(他们直接也无法做到!)。

注意,没有set方法,只有get方法(第 25-34 行)。每个实例变量都有一个get方法。

注意,这个类打破了封装。这是因为,在构造函数(第 17 行)中,我们存储了传入的引用。此外,我们的getAnimals()方法返回我们存储的引用。我们将在稍后的内存中看到这一点的含义。

然而,现在让我们看看一个利用“看似不可变”的Farm类的客户端类。图 12.15突出了一个问题:

图 12.15 – 使用弱封装的自定义不可变类型的类

图 12.15 – 使用弱封装的自定义不可变类型的类

在这个图中,我们声明了一个List(接口)引用,即animals,它指向一个ArrayList对象(第 54 行)。通过声明引用是List<String>类型,我们告诉编译器只允许字符串。这为我们提供了类型安全,因为我们不能,例如,向我们的列表中添加一个Integer对象。由于ArrayList是可变类型,它非常适合我们的例子。第 55 行将"Cattle"添加到我们的ArrayList中。

第 57 行使用了createNewInstance()工厂方法,传递了"Small Farm"25和我们的animals数组列表。第 58 行证明了对象被正确创建。

第 61-63 行是初始化局部变量的地方,这些变量基于Farm对象的状态(实例变量的值)。第 64-65 行检查它们是否按预期设置。

第 68-70 行是我们更改局部变量的地方。这是关键测试。更改局部变量不应该影响我们的Farm对象的状态。在第 73 行,我们再次通过隐式调用toString()输出实例变量。输出在上一行的注释中,即第 72 行。从输出中可以看出,实例的String变量名未受影响(仍然是"Small Farm"),而numAnimals实例原始值也未受影响(仍然是 25)。然而,animals实例变量已更改!这里的ArrayList对象类型是问题所在。最初,列表中只有"Cattle";现在,它变成了"Cattle""Sheep""Horses"。这种变化通过矩形突出显示。这是怎么发生的?查看内存中的情况将揭示问题。

内存表示(破坏封装)

图 12.16显示了内存中的情况(因为我们即将退出程序)。请注意,图 12.16代表整个程序,即图 12.14图 12.15

图 12.16 – 被自定义“不可变”类型破坏的封装

图 12.16 – 被自定义“不可变”类型破坏的封装

在前面的图中,虚线表示原始状态或值。例如,从name变量到main()中栈上的虚线表示代码中的第 61 行。相比之下,从同一变量的实线表示第 68 行。

让我们先讨论栈。局部farm引用指向堆上的Farm对象,在那里初始化了相应的namenumAnimalsanimals实例变量。正如所述,main()中的局部name变量初始化(第 61 行)为引用与Farm对象中的实例变量查看相同的String对象。局部numAnimals变量初始化为同名实例变量的值(第 62 行)。请注意,局部副本表示为矩形而不是箭头;这反映了纸张复印件的类比。第 63 行初始化局部animals引用指向堆上Farm对象的animals实例变量相同的ArrayList对象。这正是问题所在,正如我们很快就会看到的。

正当我们开始执行第 68 行时,两个name引用,栈上的局部引用和堆上的实例引用,都指向相同的String对象。第 68 行将name局部变量更改为"Big Farm"。然而,由于String对象是不可变的,堆上创建了一个新的String对象来反映这些更改。换句话说,创建了一个新的"Big Farm"String对象,而name(在栈上)指向它。虚线和实线表示这一点(从栈上的name)。

注意,name实例变量完全不受此更改的影响。这正是不可变类型的优势。其他类无法更改它们的值。

第 69 行将局部变量numAnimals(在栈上)更改为 500。旧值的删除线字体和新的 500 代表这一点。再次强调,numAnimals实例变量未受影响,这证明了在自定义不可变类型中原始数据类型是安全的。

问题在第 70 行变得明显,我们在局部数组列表中添加了"Sheep""Horses"。这不应该改变实例变量所查看的所谓private列表。但它确实改变了!

因此,我们知道存在问题,但我们该如何修复它呢?

不可变类型(正确封装)

这里的问题是传入并返回的可变类型引用。自定义不可变类型不应直接存储或返回引用。一旦这样做,外部类就会查看同一个对象,由于它是可变的,你无法从 JVM 中获得保护。这就是为什么在图 12.16中,17 行和 33 行被加粗。它们是导致问题的行。

那么,我们该如何解决这个问题呢?好吧,解决方案是回顾我们在第八章中讨论的内容,精通高级封装。总的来说,我们应该使用一种称为“防御性复制”的技术来处理这种情况。

只需对我们的不可变Farm类型进行两个代码更改。一个是在构造函数中;另一个是在相关的get方法中,即getAnimals()图 12.17显示了代码更改:

图 12.17 – 正确封装的自定义不可变类型

图 12.17 – 正确封装的自定义不可变类型

而不是展示未更改的代码,此图展示了一个类段,以便我们可以关注更改。之前被注释掉的 16 行现在被取消注释,而存在问题的 17 行现在被注释掉。通过对比它们,我们可以看到,我们现在是创建一个基于传入列表内容的ArrayList对象(第 17 行),而不是直接存储传入的引用。然后我们将新ArrayList对象的引用存储在我们的private实例变量中。

另一个更改与第 32 行和 33 行有关。存在问题的第 33 行已被注释掉,而具有修复功能的第 32 行已被取消注释。同样,我们不是返回我们的private实例变量的副本(第 33 行),而是基于我们的数组列表内容创建一个ArrayList对象,并返回该引用。新对象的内容可以与我们的私有副本完全相同,只要外部类不能更改我们的私有副本。这些更改实现了这一点。让我们看看内存中的情况。

内存表示(正确封装)

为了在图中保持清晰,我们只展示了ArrayList对象及其引用。图 12**.16 已经展示了String对象和原始数据类型是正常的,所以没有必要再次查看这些元素。

图 12**.18 展示了当前内存中的情况(在程序结束前,在我们退出之前):

   图 12.18 - 正确封装的自定义不可变类型的内存表示

图 12.18 - 正确封装的自定义不可变类型的内存表示

在这个图中,标记了创建三个ArrayList对象的每个方法。例如,底部的ArrayList对象(标记为 A)是在main()中创建的。暂时检查该对象,我们可以看到堆栈上的animals引用最初(虚线)指向它。它里面只有一个String,即"Cattle"

此对象通过工厂方法传递到构造函数中,其中其内容("Cattle"”)被用来创建一个新的数组列表对象并初始化animals实例变量,使其指向新对象(第 16 行)。这在图中通过标记为Farm()(以及字母 B)的ArrayList`对象来表示。

getAnimals()的调用也会导致创建一个新的数组列表对象(第 32 行)。这个新对象由创建它的方法名称标记,即getAnimals(),以及字母 C。最初,它只包含"Cattle",因为这正是实例变量包含的内容。虚线矩形表示这一点。

然而,现在当我们使用局部动物引用将“羊”和“马”都插入到数组列表(标记为 D)中时,私有实例数组列表不受影响。因此,这个类被正确封装。

这就完成了我们对创建自定义不可变类型的覆盖。接下来几个主题是我们最近示例中提到的,即ListArrayList。现在让我们更详细地讨论这两个主题。

检查 List 和 ArrayList

List是由ArrayList类实现的一个接口。因此,List中的任何 API 方法都自动在ArrayList中。正如我们所知,使用接口引用(List)来引用对象(ArrayList)是良好的编码实践。由于编译器查看引用类型,这使你在未来可以使用不同的List实现,例如LinkedList

ListArrayList都在java.util包中。在 API 中,两者都使用泛型E(代表Element)进行类型化,这意味着我们可以自由指定我们想要存储在列表中的类型。未能遵循声明的类型会导致编译器错误。我们将在第十三章中详细介绍泛型。

List 属性

列表是有序集合(有时称为序列)。我们可以精确控制元素在列表中的插入位置。索引(与数组一样)从 0 开始,允许重复元素。列表保持的顺序是插入顺序。换句话说,如果你简单地添加两个元素,第二个元素将位于第一个元素之后。因此,列表保持顺序并允许重复。图 12**.19展示了捕获这些属性的小段代码:

   图 12.19 – 展示 List 属性的代码

图 12.19 – 展示 List 属性的代码

在这个图中,第 11 行声明了一个名为listList引用,它引用一个ArrayList对象。List引用被类型化为字符串,这意味着我们只能向列表中添加String对象。由于列表是通过ArrayList实现的,因此这里概述的属性也适用于ArrayList实现。

第 12-15 行按顺序添加了JAVA字符串。当我们输出列表(第 17 行)时,我们可以看到插入顺序得到保持,并且允许重复元素。

为了演示对元素插入位置的精确控制,第 18-19 行将"O"插入到两个不同的位置,即索引 1 和 3。当我们再次输出列表(第 21 行)时,我们可以看到字符串已插入到它们正确的位置。

让我们看看另一个示例,展示其他List/ArrayList API 调用:

   图 12.20 – 展示“List”和”ArrayList”的代码

图 12.20 – 展示“List”和”ArrayList”的代码

在这个图中,我们有几个 API 调用及其相应的输出以注释形式显示在每行的右侧。在这个图中,我们的列表包含"Joe""Mary""Joe",顺序如下。我们有以下 API 调用序列:

  • contains(Object o)检查"Mary"是否在列表中。这返回true

  • get(int index)返回索引 0 处的元素,即"Joe"

  • indexOf(Object o)返回2第一次出现的位置。这将作为Integer类型装箱,它是Object的子类。然而,由于列表中没有这样的对象,返回-1

  • 0,因为这将是列表中"Joe"第一次出现的位置。

  • remove(Object o)从列表中移除对象的第一次出现,并根据对象是否找到返回 true/false。由于"Joe"在列表中,返回true

  • "Mary""Joe"

  • remove(int index)移除索引 0 处的对象,即"Mary"

  • "Joe"(第二个"Joe")。

  • set(int index, E element)将给定索引的内容更改为传递的对象。因此,"Paul"现在位于索引0

  • 第 36 行:这表明第 35 行按预期操作。

现在我们已经讨论了一些 API 方法,让我们再讨论一些其他的。表 12.4展示了这些信息:

方法名称 描述
void add(int index, E element) 在指定索引处添加元素
boolean add(E e) 将元素添加到列表的末尾
void clear() 从列表中移除所有元素
boolean contains(Object o) 如果对象在列表中,则返回 true
E get(int index) 返回指定索引处的元素
boolean isEmpty() 如果列表为空,则返回 true
int indexOf(Object o) 返回指定元素的第一个出现的索引;如果列表中不存在此类元素,则返回 -1
E remove(int index) 移除指定索引处的元素
boolean remove(Object o) 移除指定的第一个对象
E set(int index, E element) 使用给定的元素替换指定索引处的元素
int size() 返回列表中的元素数量

表 12.4 – “List” 和 “ArrayList” API 方法示例

这就结束了关于检查 ListArrayList 的本节。欲了解更多信息,请参阅 Java 文档 docs.oracle.com/en/java/javase/21/docs/api/index.html。现在,让我们继续探索日期 API。

探索日期 API

java.time 包是在 Java 8 中引入的,旨在取代之前的 java.util.Datejava.util.Calendarjava.text.DateFormat 类。java.time 中的类代表日期、时间、时区、瞬间、期间和持续时间。遵循 ISO 日历系统,这是事实上的世界日历(遵循格里高利规则)。所有类都是不可变的且线程安全的。

这是一个大型 API (docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/package-summary.html),包含大量处理日期的类,而处理时间的类相对较少。幸运的是,尽管有大量方法可用,但方法前缀的一致使用使得管理变得可行。我们很快就会查看这些 API 前缀。但在我们这样做之前,让我们讨论一下更重要的日期和时间类。

协调世界时(UTC)

UTC 是世界调节时钟和时间的标准。它实际上是格林尼治平均时间(GMT)的继任者。UTC 不对夏令时进行调整。

时区使用 UTC+/-00:00,有时用字母 Z 表示,这是对等效航海时区(GMT)的引用。由于北约语音字母表中的 Z 对应的单词是“Zulu”,因此 UTC 有时被称为“Zulu 时间。”

日期和时间

这里有几个重要的类。让我们逐一检查:

  • Instant: 一个瞬间是一个数值时间戳。它对日志记录和持久化很有用。历史上,System.currentTimeMillis()会被使用。System.currentTimeMillis()返回自“纪元日”(1970 年 1 月 1 日 00:00:00 UTC)以来的毫秒数。纪元是一个固定的时间点,所有时间戳都从这个时间点开始计算。

  • LocalDate: 存储不带时间的日期。这对于表示生日,如 2000-10-21 很有用。因为它遵循 ISO-8601,格式为年-月-日。

  • LocalTime: 存储不带日期的时间。这对于表示营业/关闭时间,如 09:00 很有用。

  • LocalDateTime: 存储日期和时间,如 2000-10-21T17:00。注意用作日期和时间分隔符的“T”。这对于表示预定事件的日期和时间,如音乐会很有用。

  • ZonedDateTime: 表示带有时区和从 UTC 解析的偏移的“完整”日期时间。例如,2023-02-14T16:45+01:00[Europe/Zurich]是欧洲/Zurich 时区的日期时间,并且比 UTC 快 1 小时。

持续时间和周期

除了日期和时间之外,该 API 还表示持续时间和时间周期。现在让我们来看看这些。

  • Duration: 表示时间量,以秒(和纳秒)为单位;例如,“54 秒。”

  • Period: 表示对人类更有意义的时间单位,如年或天。例如,“3 年,6 个月和 12 天。”

其他有趣类型

其他类型也很有趣。现在让我们来检查其中的一些。

  • Month: 表示一个月份;例如,JANUARY。

  • DayOfWeek: 表示一周中的某一天;例如,FRIDAY。

  • YearMonth: 表示年份和月份,没有日期或时间;例如,2025-12. 这可能对信用卡到期日很有用。

  • MonthDay: 表示月份和日期,没有年份或时间;例如,--08-09。这可能对年度事件,如周年纪念很有用。

  • ZoneOffset: 表示从 GMT/UTC 的时间区域偏移,例如+2:00。

如前所述,类中存在大量方法。然而,由于前缀应用一致,这还是可以管理的。表 12.5 表示了这些前缀。

方法前缀 描述注意:在这些示例中使用到的ld2等都是相关的。
of 创建实例的静态工厂方法 - 例如,LocalDate ld1 = LocalDate.of(2023, 3, 17);
parse 创建实例的静态工厂方法 - 例如,LocalDate ld2 = LocalDate.parse("2023-03-17");
get 获取某个值 - 例如,int dayOfMonth = ld2.getDayOfMonth(); // 17
is 检查某物是否为真 - 例如,boolean isLeapYear = ld2.isLeapYear(); // false
with setter 方法的不可变等效 - 例如,LocalDate ld3 = ld2.withDayOfMonth(25); // 2023-03-25
plus 向对象添加一个量 - 例如,LocalDate ld4 = ld3.plusDays(2); // 2023-03-27
minus 从对象中减去一个量 – 例如,LocalDate ld5 = ld4.minusMonths(2); // 2023-01-27
at 将此对象与另一个对象组合 – 例如,LocalDateTime ldt1 = ld5.atTime(13, 45, 10); // 2023-01-27T13:45:10

表 12.5 – 日期 API 方法前缀

现在我们已经查看了一下 API 中的前缀,让我们看看一些示例代码来加强它们。图 12**.21显示了用于操作日期和时间的代码:

图 12.21 – 操作日期和时间的代码

图 12.21 – 操作日期和时间的代码

在这个图中,第 13 行使用now()工厂方法创建LocalDate。这基于默认区域设置的系统时钟设置创建一个LocalDate对象。同时,使用now()方法,第 14-15 行分别创建LocalTimeLocalDateTime对象。第 16 行显示了使用of()工厂方法通过传入LocalDateLocalTime对象来创建LocalDateTime对象的另一种方式。第 17 行显示了LocalDateTime对象的输出为yyyy-mm-ddThh:mm:ss:nnnnnnnnn。日期部分在前,然后是"T",它将日期与时间分开,其中时间部分中的n代表纳秒。

接下来,我们想要创建代表 2025 年圣帕特里克节(3 月 17 日)的LocalDate值。第 20 行使用of()工厂方法,并传入年、月和日的数值。请注意,月份从 1 开始,而不是 0。因此,三月表示为 3。

第 21 行使用了一个替代的工厂方法,即parse(String),它接受一个String并相应地创建一个LocalDate。如果字符串无法解析,将发生异常。

第 22 行输出 2025 年 3 月 17 日是星期几(星期一)。第 23 行“修改”月份,将其从 3 改为 5(三月改为五月)。由于 Date API 类型是不可变的,更改是在后台对新对象进行的(ld2保持不变)。ld3引用指向这个新对象(2025-05-17)。

第 25 行增加了一年,所以我们现在有 2026-05-17。第 27 行减去 5 天,所以我们现在有 2026-05-12。最后,在第 29 行,我们将我们的LocalDate转换为LocalDateTime。因为我们已经有了日期,所以我们只提供了时间元素。未提供的时间单位(纳秒)被设置为 0,并且结果中不显示。

现在,让我们看看图 12**.22中的ZonedDateTime示例:

图 12.22 – ZonedDateTime 示例

图 12.22 – ZonedDateTime 示例

在这个图中,一架航班在当地时间下午 1 点从都柏林飞往巴黎。飞行时长为 1 小时 45 分钟。我们正在尝试计算飞机降落时在巴黎的当地时间。这里提出的解决方案是其中一个选项。

第 34-38 行创建了一个LocalDateTime对象,用于出发日期和时间(2023 年 11 月 24 日,下午 1 点)。第 39 行使用atZone()方法将日期时间对象时区化,通过传递相关的时间区域(ZoneId)。要获取时间区域ZoneId对象,只需调用工厂of()方法,并传递相关的时间区域字符串。在这个例子中,它是"Europe/Dublin"。第 40 行显示了ZonedDateTime对象的格式。注意"Z"代表 Zulu 时间(UTC)。在这一年中的这个时候,由于夏令期已经结束,都柏林与 UTC 保持一致。

第 42-45 行表示在巴黎计算本地到达时间。第 43 行使用withZoneSameInstant()方法计算当航班从都柏林起飞时巴黎的时间。现在,我们只需要加上 1 小时 45 分钟的飞行时间。

第 47 行显示了到达时间的ZonedDateTime。时间和时区偏移量元素很有趣。本地时间允许巴黎比都柏林快 1 小时。这种时间差异反映在+1:00的偏移量中。因此,巴黎比 UTC 快 1 小时。

现在,让我们看看一些使用PeriodDuration的代码。图 12.23展示了示例:

图 12.23 – 使用 Period 和 Duration 的示例

图 12.23 – 使用 Period 和 Duration 的示例

在这个图中,PeriodDuration都得到了演示。Period适用于大于 1 天的时间块;例如,2 年、5 个月和 11 天。Duration更适合于小于 1 天的时间块;例如,8 小时和 20 秒。

第 57-63 行计算并输出美国内战持续了多少年、月和日。首先,我们创建起始和结束日期的LocalDate对象(第 57-58 行)。第 59 行使用静态Period.between()方法创建一个Period对象,传递相关起始和结束日期。第 60 行输出Period对象,P3Y11M28D,代表 3 年、11 个月和 28 天(周用天数表示)。第 61-63 行分别输出年、月和日值。

接下来,我们将看看Duration。在这种情况下,我们使用两个LocalTime对象;一个代表 12:00:20(第 66 行),另一个代表 14:45:40(第 67 行)。第 68 行计算两者之间的时间差,第 69 行输出结果。注意,与第 60 行(Period)上的 Y、M 或 D(年、月或日)不同,这里没有。现在,在第 69 行,我们有一个DurationPT2H45M20S,代表 2 小时、45 分钟和 20 秒。

最后,让我们看看如何格式化日期和时间。

格式化日期和时间

格式化器可以在两个方向上工作:将你的时间相关对象格式化为字符串或将字符串解析为时间对象。这两种方法都适用于格式化器。这可以通过以下 API 中的代码表示:

LocalDate date = LocalDate.now();String text = date.format(formatter);
LocalDate parsedDate = LocalDate.parse(text, formatter);

我们将关注如何为format()方法创建格式化工具。然而,由于格式化工具在格式化和解析中都是通用的,所以我们所说的适用于两者。

我们在指定日期和时间的格式方面有很多灵活性。首先,我们有可用的预定义标准格式。此外,我们可以指定自定义格式。在指定自定义格式时,字母 A-Z 和 a-z 是保留的,并且具有特定的语义。重要的是,格式字母的数量很重要——例如,MMM 将月份格式化为 Aug,而 MM 产生 08。

格式化日期和时间有两种常见的方法。一种是在LocalDateLocalTimeLocalDateTimeZonedDateTime时间类中使用format(DateTimeFormatter)。它的签名接受一个DateTimeFormatter类型的参数。另一种方法是在DateTimeFormatter类本身中使用format(TemporalAccessor)TemporalAccessor是一个接口,由前面提到的几个时间类实现。

在我们查看一些示例代码之前,我们必须介绍更受欢迎的预定义格式化工具和格式模式。它们有很多,我们鼓励您查阅 API 以获取更多详细信息。

预定义格式化工具

访问这些格式化工具最简单的方法是使用DateTimeFormatter类中的常量或通过调用DateTimeFormatter中的“of”工厂方法。表 12.6展示了更受欢迎的一些格式化工具的概述。请参阅 API 以获取更多详细信息。注意,ISO代表国际标准化组织

格式化工具 描述 示例
ofLocalizedDate (dateStyle) 使用地区日期样式的格式化工具 这取决于传入的样式。一个例子是“星期一 10 七月 2023”。
ofLocalizedTime (timeStyle) 使用地区时间样式的格式化工具 这取决于传入的样式。一个例子是“15:47”。
ofLocalizedDateTime (dateTimeStyle) 使用地区日期和时间样式的格式化工具 这取决于传入的样式。一个例子是“3 七月 2018 09:19”。
ISO_DATE ISO 日期(可能包含偏移) “2023-07-10”, “2023-07-10+01:00”。
ISO_TIME ISO 时间(可能包含偏移) “15:47:13”, “15:47:13+01:00”。
ISO_LOCAL_DATE ISO 本地日期(无偏移) “2023-07-10”。
ISO_LOCAL_TIME ISO 本地时间(无偏移) “16:00:03”。
ISO_ZONED_DATE_TIME 时区日期时间 “2023-07-12T09:33:03+01:00 [Europe/Dublin]”。

表 12.6 – 日期 API 预定义格式化工具

现在,让我们检查一些使用预定义格式化工具的代码。

图 12**.24展示了使用这些预定义格式化工具的代码:

图 12.24 – 使用预定义格式化工具的代码示例

图 12.24 – 使用预定义格式化工具的代码示例

在这个图中,我们根据DateTimeFormatter中可用的预定义格式,以各种格式表示当前日期(第 74 行)和当前时间(第 81 行)。首先是ISO_DATE(第 75 行)。其输出(在第 76 行的注释中)是2023-07-10,这是 yyyy-mm-dd 格式。

第 78 行使用ofLocalizedDate()工厂方法创建一个格式。通过传递FormatStyle.FULL枚举常量,我们请求尽可能多的细节。因此,这个格式输出(第 79 行)是Monday 10 July 2023。如所见,这比ISO_DATE格式更详细。

第 82 行创建了一个ISO_TIME格式化程序,并将其(第 83 行)应用于已经创建的时间对象(第 81 行)。第 85 行使用ofLocalizedTime()工厂方法。FormatStyle.SHORT枚举返回最少的细节,通常是数字。

这涵盖了预定义的格式化程序。现在,让我们讨论如何指定自定义格式化程序。

自定义格式化程序

自定义格式化程序使用模式字母定义,使用的字母数量很重要。让我们首先讨论最常用的模式字母,然后展示一些使用它们的代码。表 12.7展示了模式字母的总结:

字母 描述 示例
y 2023; 23
M 月份 8; 08; Aug; August
d 月份中的天数 16
E 星期中的天数 Wed; Wednesday
D 年中的天数 145
h 一天中的小时;12 小时制(1-12) 10
H 一天中的小时;24 小时制(0-23) 19
m 小时中的分钟 32
s 分钟中的秒 55
a A.M.或 P.M. PM
z 时区 GMT
G 时代 AD

表 12.7 – 日期 API 模式字母概述

这个表格最好通过例子来解释。图 12**.25展示了使用表 12.7中的模式字母的例子:

图 12.25 – 使用模式字母的代码示例

图 12.25 – 使用模式字母的代码示例

在这个图中,第 91 行获取该时区的当前日期和时间,这是爱尔兰标准时间IST)。

爱尔兰标准时间(IST)

这是爱尔兰使用的时区。在爱尔兰,我们使用夏令时(“夏令时”)。这意味着在夏季月份,我们将时钟向前调整 1 小时,以便黑暗在更晚的时钟时间降临。因此,在三月,我们将时钟向前调整 1 小时,而在十月,我们将时钟向后调整 1 小时。

UTC 中没有“夏令时”。由于这个原因,再加上现在是七月,IST 比 UTC 快 1 小时。

第 92 行的输出在右侧的注释中。日期和时间通常由“T.”分隔。时区偏移量为“+1:00”,表示此时区时间比 UTC 快 1 小时。时区 ID 是“[Europe/Dublin]”。

我们首先将查看一个与日期相关的格式化器。第 93 行使用yy-MMM-dd E D模式创建了一个格式化器。它生成的输出是23-Jul-11 Tue 192(第 94 行)。因此,当前年份 2023 以23输出,因为我们只提供了格式中的yy(而不是yyyy)。注意,如果格式中是yyyy,则输出将是2023。这就是为什么模式字母的数量很重要。大写字母M代表月份。M产生7MM产生07MMM(如模式所示)产生Jul,而MMMM产生July。再次,这表明模式字母的数量很重要。

dd模式输出月份的日期。这给我们11,对应于11thE给出星期几,这里是Tue。注意,EEEE返回TuesdayD代表一年中的天数;在这个例子中是第 192 天。

注意,破折号和空格只是简单地插入到输出中。这是因为,与字母不同,它们没有被保留。我们很快就会学习如何在不引起异常的情况下将单词(包含字母)插入到输出中。

现在,让我们考察一个与时间相关的格式化器。第 96 行使用hh:mm:ss a z G模式创建了一个格式化器,它生成了(第 97 行)09:05:50 a.m. IST AD的输出。hh:mm:ss模式以小时(12 小时制)、分钟和秒的格式返回当前时间。a返回是上午还是下午。现在,是上午,所以返回amz模式字母返回缩写的时区名称,IST。将其扩展到zzzz返回Irish Standard Time。最后,G返回时代,AD(公元)。

现在,让我们学习如何将文本插入到我们的格式化器中。正如我们所知,字母 a-z 和 A-Z 是保留的。那么,我们如何将字母作为常规字母插入,而不是模式字母呢?为了做到这一点,我们必须用单引号包围常规字母。第 101 行指定了一个使用常规字母和模式字母的模式。模式是“'Year: ‘*yyyy*’. Month: ‘*MMMM*’. Day: ‘*dd*’.’”。模式字母是斜体的。任何其他字符都包含在单引号中。

Year: 2023\. Month: July. Day: 11.被生成为输出。

如我们所见,年份值2023前面是文本"Year: "。这是通过将文本用单引号包围来实现的:'Year: '。在yyyy年模式之后,插入常规文本'. Month: '。因此,大写字母M被简单地视为一个大写字母M,而不是月份模式字母。之后,插入'. Day: '来在月份的日期11之前。最后,通过将其用单引号包围,在末尾插入一个句号。注意,没有单引号的句号也是可以的,因为它不是一个保留字符。

最后,让我们看看一个解析示例,我们可以从String值创建时间对象。第 105 行声明了一个字符串"2023-07-10 22:10"。第 106 行声明了一个可以解析这个字符串的模式。模式是"yyyy-MM-dd HH:mm"。注意,"HH"代表 24 小时制。这将使我们能够解析字符串中的"22"时间。

第 107 行通过提供的模式解析字符串来创建一个LocalDateTime对象。第 108 行输出LocalDateTime对象,生成"2023-07-10T22:10",这正是字符串所表示的。

这样我们就完成了对自定义格式器的讨论,并结束了第十二章。现在,让我们将所学知识付诸实践,以巩固我们所学过的概念。

练习

在本章中,我们学到了许多有趣的新知识。是时候用我们新掌握的一些新功能来启发 Mesozoic Eden 软件的用户了:

  1. 管理我们公园中恐龙的生日。将birthday属性添加到Dinosaur类中。

  2. 公园按照严格的日程表运行。使用日期 API 创建一个简单的系统来记录公园中的事件,如喂食时间、清洁和紧急演习。

  3. 在 Mesozoic Eden 中,我们有一个非常强的以安全为第一的政策。定期的检查帮助我们保持高标准的安全。创建一个程序,根据上次安全检查的日期计算公园下一次安全检查前还有多少天。安全检查需要每 45 天进行一次。

  4. 我们有一个新生的兽脚类恐龙。游客被要求为我们 Mesozoic Eden 最年轻的居民提交名字。选出了 10 个名字。为这 10 个名字创建一个列表。

  5. 我们想创建一个包含新生儿的全名的字符串。使用StringBuilder将每个名字附加到新名字上,完成后将其转换为字符串。(提示:使用循环结合StringBuilder。)

项目 – 恐龙护理系统

我们将继续完善我们的“恐龙护理系统”,通过使用 Java 核心 API 添加功能来记录恐龙的日常护理活动。这包括接受用户输入、维护活动历史记录以及随时间存储恐龙的健康数据。别担心——我们会一步步为您分解。

第一步:添加额外的 Java 类

  • 创建一个名为coreapi的新包。

  • 在这个包内部,创建一个名为Dinosaur的类。这个类应该具有名称、物种、健康状况等属性。

  • 此外,创建一个名为Activity的类,具有名称、日期、恐龙等属性。

第二步:扩展恐龙 护理系统

  • 在您的DinosaurCareSystem类中,创建一个List来保存Dinosaur对象,并创建另一个List来保存Activity对象。

  • 创建一个名为addDinosaur()的方法,该方法接受用户输入以创建一个新的Dinosaur对象并将其添加到恐龙列表中。

  • 创建一个名为logActivity()的方法,该方法也接受用户输入以创建一个新的Activity对象(包括从列表中选择恐龙)并将其添加到活动列表中。

这里有一些示例代码可以帮助你开始这一步:

import java.util.*;public class DinosaurCareSystem {
    private List<Dinosaur> dinosaurs;
    private List<Activity> activities;
    public DinosaurCareSystem() {
        dinosaurs = new ArrayList<>();
        activities = new ArrayList<>();
    }
    public void addDinosaur(Dinosaur dinosaur) {
        dinosaurs.add(dinosaur);
    }
    public void logActivity(Activity activity) {
        activities.add(activity);
    }
    //... existing methods for handling exceptions here
}

第 3 步:与 系统 交互

  • 在你的主类中,创建一个DinosaurCareSystem对象,并使用循环不断询问用户他们想做什么(添加恐龙、记录活动等)。使用Scanner对象从用户那里获取输入。

这里有一些代码可以帮助你开始:

import java.util.Scanner;public class Main {
    public static void main(String[] args) {
        DinosaurCareSystem system = new
          DinosaurCareSystem();
        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.println("What would you like to
              do?");
            System.out.println("1\. Add a dinosaur");
            System.out.println("2\. Log an activity");
            System.out.println("3\. Exit");
            int choice = scanner.nextInt();
            scanner.nextLine();  // consume newline
            if (choice == 1) {
                // add dinosaur
            } else if (choice == 2) {
                // log activity
            } else if (choice == 3) {
                break;
            }
        }
    }
}

如往常一样,你可以自由地扩展它,让你的创造力自由发挥!

摘要

在本章中,我们探讨了 Java 核心 API 中的流行类。我们从Scanner类开始,这是一个用于读取输入的有用类。Scanner可以指向从文件、String对象或键盘读取。从键盘读取特别有助于处理用户输入。

我们检查了String类及其 API。我们看到了字符串字面量如何使用字符串常量池来节省内存。我们检查了String对象的一个重要属性,即不可变性。一旦创建,String对象就不能被更改。

接下来,我们检查了StringBuilder及其 API。我们讨论了StringBuilder是一个可变类型,因此内存中只有一个对象。

由于String是不可变的,而StringBuilder是可变的,我们提供了一个详细的示例,包括代码和支持图表,以比较和对比StringStringBuilder

这引发了对如何创建我们自己的自定义不可变类型的讨论。我们检查了一个必须执行的步骤清单,以确保你的类是不可变的。然后,我们展示了一个例子,其中,非常微妙地,Java 的按值调用原则破坏了封装(从而破坏了不可变性)。我们讨论了如何使用防御性复制来修复此类问题。实际上,对于我们的private实例可变类型,我们必须确保传递给初始化它们的引用没有被直接存储;我们必须先复制它们。此外,我们还必须确保我们不返回我们的private实例可变类型的引用;我们也必须先复制它们。

从那里,我们检查了ListArrayList API。List是一个接口,ArrayListList的一个实现。ArrayList本质上是一个可扩展的数组。它维护插入顺序并允许重复。

然后,我们检查了 Java 8 中彻底重写的日期 API。我们讨论了InstantLocalDateLocalTimeLocalDateTimeZonedDateTimePeriodDuration。所有这些类型都是不可变的,这意味着我们可以使用工厂方法(如now()of())来创建实例。在一个大型 API 中,方法前缀名称的一致性是有帮助的。

最后,我们讨论了如何格式化时间对象以供输出,以及如何将字符串解析为时间对象。我们检查了可用的预定义格式化程序,并且还设计了使用保留模式字母的自定义格式化程序。

这完成了我们对 Java 核心 API 的讨论。我们将继续到下一章的泛型和集合。

第三部分:高级主题

在这部分,我们将探讨 Java 中的一些更高级的主题。我们将从 Java 集合框架开始。这包括其几个流行的接口及其常见实现。我们将讨论 Java 中的排序以及如何处理泛型。然后我们转向 lambda 表达式及其与函数式接口的关系。我们将查看 API 中的流行函数式接口以及方法引用。然后我们将讨论两章的流,包括基础和高级主题。基础部分将涵盖流管道、流惰性以及终端操作等主题。高级章节将讨论中间操作、原始流、Optionals 以及并行流。最后,我们将讨论并发,其中我们将解释多线程、数据竞争、ExecutorService以及并发集合。

本节包含以下章节:

  • 第十三章, 泛型和集合

  • 第十四章, Lambda 表达式

  • 第十五章, 流:基础

  • 第十六章, 流:高级概念

  • 第十七章, 并发

第十三章:泛型和集合

组织数据是另一个重要的软件开发主题。Java 为我们提供了集合来处理各种数据结构。它还提供了泛型来实现类型安全和避免在应用程序中重复代码。如果我们不了解如何使用集合和泛型,就不能说我们是 Java 的专家。

因此,我们专门用这一章来介绍 Java 集合框架。在本章中,我们将涵盖以下主题:

  • 集合框架及其接口 – ListSetMapQueue

  • 每种集合类型的不同实现及其基本操作

  • 使用自然排序和 Comparable 以及 Comparator 接口对集合进行排序

  • 使用泛型

  • 基本哈希概念及其相关性

到本章结束时,你将牢固地理解 Java 集合框架和泛型,并准备好在程序中管理数据和使用集合。

技术要求

本章的代码(练习部分)可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch13/exercises

了解集合

集合值得了解。集合是一种处理一个变量中多个值比数组更优雅的方式。一个常见的集合例子就是列表。

不使用集合编写任何合适的 Java 应用程序都会非常复杂。你可能首先会创建一些将充当 Java 内置集合的类。它们在软件开发中扮演着至关重要的角色,因为它们提供了一种管理和组织数据的方法。

我们需要它们的原因有很多,但让我们只列举(集合双关语)几个:

  • 管理大量数据:随着应用程序的复杂性增加,它们通常需要处理大量数据。集合帮助存储和管理这些数据集。它们还提供了一些有用的方法,使得对数据进行典型操作(如搜索和过滤)变得更加容易。

  • 存储和操作各种数据结构:不同的数据结构具有独特的特性,适用于特定的任务。集合提供了一系列的数据结构。这样,我们可以根据需求选择最合适的一个。

  • 确保高效的数据管理和访问:集合提供了一系列的功能。这有助于我们在应用程序中优化数据管理和数据访问。

由于存在不同的数据结构,我们还需要不同的集合类型。让我们来看看它们。

不同集合类型的概述

Java 集合框架提供了相当多的不同集合类型。这确保了开发者不会为各种问题构建自定义数据结构类。这将使得不同应用程序之间的通信变得非常困难,并且需要大量样板代码来完成许多任务。Java 内置了这些集合接口和实现是个好事。让我们首先看看主要接口。您不需要理解编码示例的每一个细节;我们将在之后更详细地解释所有内容。

列表

最常见的数据结构之一是列表。列表是有序且带索引的集合,允许重复元素。当元素的顺序很重要,并且需要根据索引访问元素时,它们非常有用。

这里有一个列表的例子,我们在这个班级中存储了一系列学生姓名,其中姓名的顺序很重要。这是一个只包含 String 类型元素的列表。如您所见,List 是一个接口。当我们实例化它时,我们需要选择一个实现 List 的类。在这种情况下,我们选择了 ArrayList。这是一个非常常见的选项,但还有其他选项,例如 LinkedList。有一些重要的区别,但在这里我们不会深入探讨:

List<String> studentNames = new ArrayList<>();studentNames.add("Sarah-Milou");
studentNames.add("Tjed");
studentNames.add("Fahya");

通过这样,我们已经看到 List 可以存储字符串,但集合可以存储任何类型的对象,包括自定义对象。假设我们有一个 Person 对象。这可能看起来是这样的:

List<Person> personNames = new ArrayList<>();personNames.add(new Person("Sarah-Milou", 4));
personNames.add(new Person("Tjed", 6));
personNames.add(new Person("Fahya", 8));

为了简单起见,我们将在示例中主要使用 String,但请记住,这可以是任何对象(包括其他集合)。

同样存在无序集合,不允许重复元素。这些是 Set 类型的。让我们来看看它们。

集合

集合(通常)是无序集合,不允许重复元素。当您需要存储唯一元素但不需要关心它们的顺序时,它们非常有用。

假设我们需要一个数据结构来存储所有需要发送时事通讯的电子邮件地址。我们不希望有任何重复项存在,因为这会导致接收者收到重复的邮件:

Set<String> emailAddresses = new HashSet<>();emailAddresses.add("sarahmilou@amsterdam.com");
emailAddresses.add("tjed@amsterdam.com");
emailAddresses.add("fahya@amsterdam.com");

您无需担心添加重复项,如果您尝试这样做,什么也不会发生。您将看到 Set 的不同实现,包括两种维护其元素特定顺序的类型。但让我们先看看另一种数据结构:映射。

映射

映射存储键值对,并基于键提供查找。当您需要将值与唯一键关联起来时,例如根据用户名存储用户信息时,它们非常有用:

Map<String, String> userInfo = new HashMap<>();userInfo.put("Sarah-Milou", "Sarah-Milou Doyle");
userInfo.put("Tjed", "Tjed Quist");
userInfo.put("Fahya", "Fahya Osei");

如您所见,映射使用不同的方法。尽管 Map 是集合框架的一部分,但它有点特别。Map 是唯一一个没有扩展 Collection 接口的主要接口。ListSetQueue 都扩展了。

有时,我们需要一个只允许访问集合开始和/或结束的有序集合。我们可以使用队列来完成这项任务。

队列和双端队列

队列允许您将元素添加到队列的起始位置,并访问末尾的元素。有一个特殊的队列允许在两端进行插入和删除。这被称为双端队列。双端队列代表双端队列。因此,队列遵循先进先出FIFO)原则,而双端队列可以用作队列(FIFO)和栈,后者遵循后进先出LIFO)原则。

它们对于需要按特定顺序处理元素的任务很有用,例如在实现任务调度器时。以下是一个打印作业队列的示例,其中任务按接收顺序进行处理:

Queue<String> printQueue = new LinkedList<>();printQueue.add("Document1");
printQueue.add("Document2");
printQueue.add("Document3");
String nextJob = printQueue.poll(); // "Document1"

让我们更详细地看看这些接口,再次从List开始。

列表

因此,List接口是 Java 集合框架的一部分,用于表示元素的有序集合。List接口中的元素可以通过其位置(索引)访问,并且可以包含重复项。由于List是一个接口,因此不能被实例化。List接口的两种常用实现是ArrayListLinkedList。由于这些是实现类,因此可以实例化。让我们来探讨它们是什么。

链接列表

ArrayListList接口的可调整大小的数组实现。它提供了对元素的快速随机访问,并且对于读取密集型操作非常高效。随机访问意味着直接快速地使用其索引到达任何项目。

ArrayList在添加或删除元素时动态调整自身大小。添加和删除元素的速度相对较慢。LinkedList对此进行了优化。

链接列表

LinkedList是基于双链表数据结构的List接口实现。它不仅实现了List,还实现了QueueDeque。它提供了在列表的开始和末尾快速插入和删除元素的功能,以及双向高效的遍历。然而,与ArrayList相比,在LinkedList中通过索引访问元素可能较慢,因为必须从列表的头部或尾部遍历元素。

即将提供的示例可以在ArrayListLinkedList上以相同的方式进行。区别在于性能(在这些示例中的小数据量中,这种差异并不显著)。

探索列表的基本操作

我们可以向列表中添加、删除、更改和访问项目。让我们看看如何执行这些日常操作。列表还有很多其他有用的方法,但我们将坚持使用必备的方法,并从向列表中添加元素开始。

向列表中添加元素

我们可以使用 add() 方法向 List 接口中添加元素。add() 方法有两种形式:add(E element)add(int index, E element)。第一种形式将元素添加到列表的末尾,而第二种形式将元素添加到指定的索引。这将使所有后续元素向上移动一个索引。这里的 E 是实际类型的占位符。如果它是一个 String 类型的列表,我们只能向列表中添加字符串。

让我们看看一个使用名字列表的简单例子:

List<String> names = new ArrayList<>();names.add("Julie"); // Adds "Julie" at the end of the list
names.add(0, "Janice"); // Inserts "Janice" at index 0

首先,我们创建了一个 ArrayList 的实例。这是一个 String 类型的列表,正如我们在尖括号 (<>) 之间的 String 一词所看到的。然后我们继续将 Julie 添加到列表中。之后,我们指定位置。我们不是在 Julie 后面添加 Janice,而是在索引 0 处添加 Janice。这使得 Julie 从索引 0 变为索引 1

在此之后,我们有一个包含两个 String 元素的列表。让我们看看我们如何访问这些元素。

从列表中获取元素

您可以使用 get() 方法从 List 接口中获取元素,该方法需要一个索引作为参数。我们将继续使用我们之前的例子。下面是如何做到这一点:

String name = names.get(1);

这将获取索引 1 处的元素,即 Julie,并将其存储在一个名为 name 的变量中。我们还可以通过 set() 方法更改列表中的元素。

更改列表中的元素

我们可以使用 set() 方法更改 List 接口中的元素,该方法需要一个索引和一个新元素作为参数。在这里,我们将更改索引 1 处的元素:

ames.set(1, "Monica");

这样,我们就更新了 Julie 的值为 Monica。如果我们想的话,我们也可以从列表中移除元素。

从列表中移除元素

我们可以使用 remove() 方法来移除元素。remove() 方法有两种形式:remove(int index)remove(Object o)。第一种形式移除特定位置的元素,而第二种形式移除具有特定值的元素:

names.remove(1); // Removes the element at index 1names.remove("Janice"); // Removes the first occurrence

到这一点,列表再次为空,因为我们已经移除了两个元素。我们通过索引 1 移除了 Monica,通过查找具有该值的元素移除了 Janice

遍历列表

遍历列表有不同的方法。我们将查看两种最常见的方法。

首先,我们可以使用一个普通的 for 循环来遍历一个列表。在这种情况下,我们正在遍历列表中的 names。假设我们刚才没有移除两个元素,列表中仍然包含 JaniceMonica

for (int i = 0; i < names.size(); i++) {    System.out.println(names.get(i));
}

输出将如下所示:

JaniceMonica

我们也可以通过使用 for-each 循环达到相同的效果:

for (String name : names) {    System.out.println(name);
}

正规 for 循环和 for-each 循环之间的区别在于,我们可以在常规 for 循环中访问索引。for-each 循环使得访问元素更容易,因为我们不需要确保我们保持在界限内,使用索引,并更新索引。

还有相当多的其他方法可用,但这些是您开始时最重要的方法。现在,让我们看一下Set接口。

注意

您可以在官方文档中找到有关所有集合的更多信息:docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/doc-files/coll-overview.html

Set

Set接口是 Java 集合框架的一部分,表示一个通常无序且唯一的元素集合。这意味着一个元素只能存在于集合中一次。Set接口的常用实现包括HashSetTreeSetLinkedHashSet。让我们快速看一下每个实现。

HashSet

让我们先看看最流行的集合:HashSet。这是基于哈希表的Set接口的广泛实现。哈希表以键值对的形式存储数据,通过计算项的键哈希值来实现快速查找。它为基本的操作(如addremovecontains——检查Set接口是否包含某个值)提供了常数时间性能。

常数时间复杂度意味着执行这些操作所需的时间不会随着集合中元素数量的增加而增加,前提是用于在桶之间分配元素哈希函数能够很好地完成其工作。我们将在本章末尾更详细地介绍哈希和桶分配,但哈希基本上是将某个值转换为另一个值的过程——例如,将字符串转换为数字。

请注意,基于哈希的数据结构,如HashSet,并不能保证存储在其中的元素具有任何特定的顺序。这是因为元素是根据它们的哈希值放入集合中的,而这些哈希值可能与人对顺序的理解(如升序或时间顺序)无关。

TreeSet

TreeSet是基于树实现的Set接口。它根据元素的天然顺序或实例化时提供的自定义比较器来维护元素的排序顺序。TreeSet为常见的操作(如addremovecontains)提供了对数时间性能。

对数时间复杂度意味着执行这些操作所需的时间随着输入大小的增加而对数增长,这使得TreeSet成为处理合理大数据集时的有效选择。

与不维护元素特定顺序的基于哈希的数据结构(如HashSet)相比,TreeSets在需要维护元素排序顺序的集合时是一个极佳的选择。这可以用于诸如维护有序列表中的唯一项目、快速查找集合中的最小或最大元素或对数据集执行范围查询等任务。

树的说明

计算机科学中的并不是你在后院就能拥有的东西。在计算机科学中,树是一种表示不同节点之间关系的分层数据结构。每个节点都是一个数据点。第一个节点,称为根节点,没有父节点。每个其他节点(直接或间接地)沿着单一路径从根节点衍生出来。路径末端的节点,没有子节点,被称为叶节点。这种结构非常适合表示分层关系,因为每个节点都有一个父节点(除了根节点)和可能有很多子节点,就像自然树中的分支和叶子一样。

在树中,你可以把从根节点到任何节点的路径看作是一次旅行。路径中的每一步代表父节点和子节点之间的关系。树的高度是从根节点到叶节点的最长路径中的步数。节点的深度是从根节点到该节点的路径中的步数。与包含的节点数量相比高度较小的树通常在查找节点或添加和删除节点时效率较高。它们对于几个用例很有价值,例如在文件系统中组织文件或存储用于高效查找的排序数据,例如在TreeSet中。

LinkedHashSet

LinkedHashSetSet接口的一个实现,它按插入顺序维护元素,并由哈希表和双向链表的组合支持。LinkedHashSet为基本操作提供常数时间性能,同时保留插入顺序。

当插入顺序很重要且元素不需要排序时,你通常会选择这种实现方式。而且,由于它是一个Set,当然,元素必须是唯一的(否则List可能更合理)。LinkedHashSet的一个用例示例是维护一个按访问顺序排列的唯一项目列表,例如网页浏览历史或按添加顺序排列的唯一歌曲播放列表。另一个示例是在应用程序中跟踪事件或用户操作,同时确保每个事件或操作只处理一次。

要完成所有这些,我们确实需要能够执行一些基本操作。所以,让我们看看如何做到这一点。

在集合上执行基本操作

Set接口上的操作与List上的操作非常相似。当然,我们在Set的方法上不使用索引。我们将从学习如何向集合中添加元素开始。

向集合中添加元素

就像我们在List中做的那样,我们可以使用add()方法向Set接口添加元素。以下是这样做的方法:

Set<String> names = new HashSet<>();names.add("Elizabeth");
names.add("Janie");

集合不能包含重复的值。添加相同的值两次不会产生错误,也不会再次添加该值。

同样容易,我们可以创建一个LinkedHashSet类,如下所示:

Set<String> names = new LinkedHashSet<>();

我们还可以创建一个TreeSet类:

Set<String> names = new TreeSet<>();

这些集合上的操作将是相同的。

修改集合中的元素

我们不能直接在 Set 中更改元素。要修改元素,我们必须删除旧元素并添加新元素。因此,让我们学习如何删除元素。

从集合中删除元素

我们可以使用 remove() 方法从 Set 接口中删除元素。我们不能像对 List 那样按索引删除,因为元素没有索引:

names.remove("Janie");

之后,集合将只剩下一个值,即 Elizabeth。由于集合没有索引,访问元素的方式也略有不同。我们可以通过迭代来访问元素。

遍历集合

我们可以使用 for-each 循环遍历集合。我们不能使用常规的 for 循环,因为我们没有索引。

这里有一个例子:

for (String name : names) {    System.out.println(name);
}

在删除之后,我们的 Set 接口只剩下一个名称了。因此,这个 for-each 循环将输出以下内容:

Elizabeth

对于 Set 来说,这就结束了。现在,让我们探索 Map 数据结构。

映射

集合框架的另一个成员是 Map 接口。此接口表示一组键值对。键是唯一的,而值可以重复。这就是为什么我们使用键来添加和访问映射中的键值对。我们将讨论的 Map 接口的常用实现是 HashMapTreeMap

HashMap

最受欢迎的可能就是 HashMap。这是一个基于哈希表的广泛使用的 Map 接口实现。就像 HashSet 一样,它为基本操作提供常数时间性能。然而,它不保证键的任何特定顺序。HashMap 适用于需要快速查找和修改的情况,例如存储配置设置或统计文本中的单词出现次数。当顺序很重要时,我们可以使用 TreeMap

TreeMap

TreeMap 是基于树实现的 Map 接口。它根据其自然排序或实例化时提供的自定义比较器对键值对进行排序。我们很快就会看到自定义比较器,但这基本上是指定它需要按何种顺序排序的一种方式。

TreeMap 为常见操作如从映射中获取元素和向映射中添加元素提供对数时间性能。TreeMap 适用于需要维护键值对排序集合的场景,例如管理排行榜或跟踪基于时间的事件。

LinkedHashMap

LinkedHashMapMap 接口的另一种实现。它通过提供类似于 HashMap 的常数时间性能来结合 HashMapTreeMap 的优点,同时保持键值对的插入顺序。这个顺序是键被添加到映射中的顺序。

LinkedHashMap本质上是一个带有附加链接列表的HashMap实现,该列表连接所有条目,这使得它可以记住插入顺序。这在数据序列很重要的情况下特别有用,例如缓存操作或维护用户活动记录。

它的使用与其他两个实现非常相似。我们不会在这里展示所有实现,因为每种实现的基本操作都是相同的。唯一的区别是,当你遍历它们时,它们有一个特定的顺序,但遍历的方式是相同的。

映射的基本操作

Map与其他集合有很大不同。让我们学习如何执行Map的基本操作。

向映射中添加元素

Map没有add()方法。我们可以使用put()方法向Map接口添加元素:

Map<String, Integer> gfNrMap = new HashMap<>();gfNrMap.put("Ross", 12);
gfNrMap.put("Chandler", 8);

这向Map添加了两个键值对。让我们看看我们如何再次获取值。

从映射中获取元素

我们可以使用get()方法从Map接口获取元素。这就是我们如何获取与Ross键关联的Integer值的方式:

int rossNrOfGfs = gfNrMap.get("Ross");

我们也可以使用键来修改映射的值。

更改映射的元素

我们可以使用带有现有键的put()方法在Map接口中更改元素:

gfNrMap.put("Chandler", 9);

以下代码将Chandler键的值从8更改为9。我们不能更改键。如果我们需要这样做,我们需要删除键值对并添加一个新的。

从映射中删除元素

键也用于从映射中删除元素。我们可以使用remove()方法来完成此操作。

gfNrMap.remove("Ross");

到目前为止,我们的映射只包含一个键值对。我们也可以遍历映射。这与我们对ListSet所做的不太一样。

遍历映射

我们可以使用 for-each 循环遍历键值对、值和键。我们需要在我们的映射对象上调用不同的方法来实现这一点。我们可以使用entrySet()keySet()values()方法来完成此操作。

假设我们映射中仍然有两个键值对,以RossChandler作为键。以下代码片段使用entrySet()方法遍历键值对:

for (Map.Entry<String, Integer> entry : gfNrMap.entrySet()) {    System.out.println(entry.getKey() + ": " +
      entry.getValue());
}

entrySet()提供了一组Map.Entry对象。在这个对象上,我们可以使用getKey()getValue()方法分别获取键和值。这将输出以下内容:

Ross: 12Chandler: 9

我们也可以遍历键:

for (String key : gfNrMap.keySet()) {    System.out.println(key + ": " + gfNrMap.get(key));
}

这将输出以下内容:

Ross: 12Chandler: 9

你可能会惊讶,这与前面的代码片段相同,并且包含值,但这是因为我们正在使用键来获取值。当我们遍历值时,这是不可能的。以下是如何做到这一点的方法:

for (Integer value : gfNrMap.values()) {    System.out.println(value);
}

这将输出以下内容:

129

现在,我们只能看到值,因为我们正在遍历这些值。接下来,让我们看看最后一个主要接口:Queue

队列

最后是 Queue 接口。它是 Java 集合框架的一部分,允许 FIFO 数据存储。队列的头部是最老的元素,而尾部是最新的元素。队列对于按接收顺序处理任务非常有用。还有一个名为 Deque 的子接口,它是一种特殊的队列,允许从队列的头部和尾部获取元素。这就是为什么它也可以用于 LIFO 系统。

我们将只简要地处理不同类型的队列,因为这是在野外最不常用的集合。

队列实现

Queue 接口扩展了 Collection 接口。有几种实现方式,其中一些最常见的是 PriorityQueueLinkedListArrayDeque。扩展了 Queue 接口的 Deque 接口增加了对双端队列的支持,允许从队列的两端插入和移除元素。LinkedListArrayDequeDeque 的实现。

队列接口的基本操作

Queue 接口的基本操作有点特殊,因为元素只能从队列的末端访问。

向队列中添加元素

我们可以使用 add()offer() 方法向队列中添加元素。如果队列达到最大容量,当 add() 方法无法向队列中添加元素时,会抛出异常。如果 offer() 方法无法将元素添加到队列中,它会返回 false。从动词来看,这似乎是合理的;offer 没有义务,当队列满时,队列可以拒绝这个请求,因此当队列满时不会抛出异常。如果无法将其附加到队列中,它只会返回 false。而 add 真正意图添加,如果它不起作用,则会抛出异常。

这是如何使用 LinkedList 的示例:

Queue<String> queue = new LinkedList<>();queue.add("Task 1");
queue.offer("Task 2");

对于 Deque 类型的对象,可以使用不同的方法在队列的头部添加元素。LinkedList 正好是 Deque 类型。addoffer 方法将元素添加到队列的末尾,Deque 类型的特殊方法 addLast()offerLast() 也是如此:

Deque<String> queue = new LinkedList<>();queue.addLast("Task 1"); // or add
queue.offer("Task 2"); // or offerLast

这是如何向队列的头部添加元素的方法:

queue.addFirst("Task 3");queue.offerFirst("Task 4");

队列中元素的顺序现在是(从头部到尾部)任务 4,任务 3,任务 1, 任务 2

从队列中获取元素

我们可以使用 peek()element() 方法从 Queue 接口获取队列头部的元素。它们只是返回值,而不会从队列中移除它。

这是使用 peek() 方法获取队列头部的方法:

String head = queue.peek();

head 的值变为 任务 4。当 element() 方法无法返回值时,会抛出异常,而 peek() 方法则不会。当队列空时,peek() 方法返回 null

对于Deque,我们可以在头部和尾部获取元素。对于头部,我们可以使用getFirst()peekFirst()。对于尾部,我们可以使用getLast()peekLast()。请注意,getFirst()DequeQueueelement()的等价物,尽管这些名称在相当大的程度上有所不同。

你可能会想知道,为什么对于所有这些我们都有两个做同样事情的方法。它们并不完全一样,有一个重要的区别。getFirst()getLast()element()方法试图检索队列的端点,但如果队列是空的,它会抛出NoSuchElementException。相比之下,peek()peekFirst()peekLast()方法也检索队列的端点,但如果队列是空的,它们会返回null,因此它们不会抛出异常。

更改队列中的元素

我们不能直接在Queue接口中更改元素。要修改元素,我们必须删除旧元素并添加新元素。所以,让我们看看如何删除元素。

从队列中删除元素

我们可以使用remove()poll()方法从队列中删除元素。这些方法做两件事:

  1. 返回队列的头部。

  2. 移除队列的头部。

这里有一个例子:

String removedElement = queue.poll();

这将把Task 4存储在removedElement中。此时,队列中的值将是Task 3Task 1Task 2

这可能不会让你感到惊讶,但对于Deque,我们可以从两端删除元素。对于头部,我们使用removeFirst()pollFirst()。对于尾部,我们可以使用removeLast()pollLast()

再次强调,区别在于它们如何处理null值:

  • remove()removeFirst()removeLast()如果队列是空的,会抛出NoSuchElementException

  • poll()pollFirst()pollLast()在抛出异常的情况下返回null,表示队列是空的。

现在我们知道了如何删除元素,让我们学习如何遍历Queue接口。

遍历队列或双端队列

我们可以使用 for-each 循环遍历队列或双端队列。这不会从队列中移除“正在遍历”的元素:

for (String element : queue) {    System.out.println(element);
}

这将输出以下内容:

Task 3Task 1
Task 2

它没有打印出Task 4的原因是我们之前已经删除了它。

我们现在已经涵盖了四个主要接口的基础知识以及一些最常见的实现。我们可以对集合做更多的事情,比如排序。让我们看看如何做到这一点。

排序集合

到目前为止,我们已经学习了如何创建集合以及如何在它们上执行基本操作。它们有很多有用的内置方法,其中之一帮助我们排序集合。我们之所以关注这个方法,是因为它不像其他一些方法那么直接。

一些类型具有自然顺序,例如数字。它们可以很容易地从大到小排序。字符串也是如此——我们可以按字母顺序排序。但如何对一个包含自定义Task类型对象的集合进行排序呢?

跟着我——不久,你将能够在使用集合中内置的sort方法时同时进行自然排序和自定义排序。

自然排序

当我们谈论自然排序时,我们指的是特定数据类型的默认排序顺序。例如,数字按升序排序,而字符串按字典顺序排序。但是,如果没有我们告诉 Java 我们想要这样,Java 就不会知道这一点。这就是为什么 Java 的内置类,如IntegerString,实现了Comparable接口。这就是告诉 Java 自然顺序是什么。与排序相关的两个接口是ComparableComparator。我们将在下一章中介绍这些。

ComparableComparator接口

当一个类实现Comparable接口时,我们需要实现compareTo()方法。以下是一个类如何实现该接口的示例:

public class Person implements Comparable<Person> {...}

代码被省略了,但正如你所见,它实现了接口。现在它需要重写compareTo方法。

此方法定义了如何对相同类型的两个对象进行排序。compareTo()方法接受另一个相同类型的对象作为参数,并根据两个对象比较的结果返回一个负数、零或正整数。

这些是结果值的含义:

  • 如果两个对象相等,则返回 0

  • 如果对象大于传入的对象,则返回正值

  • 如果调用方法的对象小于传入的对象,则返回负值

Comparator接口做的是类似的事情,但不建议由类实现。此接口用于动态创建自定义Comparator,通常使用 Lambda 表达式实现。我们还没有看到 Lambda 表达式,但将在下一章中介绍。Comparator可以传递给sort方法,以告诉sort方法如何排序项目。

Comparator不是用于自然排序顺序,而是用于“一次性”排序顺序。它包含一个方法,compare()。此方法接受两个对象作为参数,并根据比较结果返回一个负数、零或正整数。以下是比较结果值的含义:

  • 如果两个对象相等,则返回 0。

  • 如果第一个对象大于第二个对象(因此它们顺序错误),则返回正值。

  • 如果第一个对象小于第二个对象(因此它们顺序正确),则返回负值。

好了,别再说了。让我们看看ComparableComparator的一些实现。

实现compareTo()

因此,当我们想要对自定义类型进行排序时,大约有两种选择:

  • 通过使它们实现Comparable来给它们一个自然顺序。

  • 实现Comparator并将其传递给sort方法。

让我们从第一个开始。我们将给我们的Person类一个自然顺序。为了为自定义类实现自然排序,我们需要实现Comparable接口和compareTo()方法。下面是如何做到这一点:

public class Person implements Comparable<Person> {    int age; // not private to keep the example short
    String name;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public int compareTo(Person other) {
        return Integer.compare(this.age, other.age);
    }
}

在这里,Person类通过实现Comparable接口被赋予了一个自然顺序。

Person类现在实现了Comparable<Person>。这意味着Person对象现在可以根据自然顺序相互比较,这个顺序由compareTo()方法确定。此方法接受一个输入参数。它总是将此参数与compareTo()被调用的实例进行比较。如果对象相等,则应返回0;如果调用该方法的对象大于输入参数,则返回正值;如果输入参数大于,则返回负值。

Person类有两个属性:age(一个整数)和name(一个字符串)。构造函数使用给定的值初始化这些属性。compareTo()方法被定义为根据年龄比较Person对象,但我们也可以选择名称长度来举例。在这个compareTo()方法中,我们使用Integer.compare()方法进行比较。它接受两个整数作为参数,并返回以下内容:

  • 如果两个整数相等,则返回0

  • 如果第一个整数大于第二个整数,则返回正值。

  • 如果第一个整数小于第二个整数,则返回负值。

compareTo()方法的上下文中,这意味着以下内容:

  • 如果两个Person对象年龄相同,该方法将返回0

  • 如果当前Person对象的年龄大于另一个对象的年龄,则方法将返回正值。

  • 如果当前Person对象的年龄小于另一个对象的年龄,则方法将返回负值。

这些返回值决定了Person对象在排序时的自然顺序。在这种情况下,对象将按年龄排序。接下来,让我们看看如何实现这一点:

List<Person> personList = new ArrayList<>();personList.add(new Person("Huub", 1));
personList.add(new Person("Joep", 4));
personList.add(new Person("Anne", 3));
Collections.sort(personList);

在排序之前,元素按照它们被添加的顺序排列。在排序之后,它们按年龄从低到高排序,因此我们得到HuubAnneJoep

但再次强调,既然是我们写的,我们可以选择任何东西。我们选择的内容将决定自然顺序。自然顺序,例如,是按字母顺序排序字符串 A-Z 和数字 0-9。你的自定义类的自然顺序取决于你。它取决于你如何实现compareTo()方法。

有时候,我们可能需要比compareTo()方法中指定的顺序不同。例如,按单词长度对字符串进行排序。幸运的是,我们也可以创建一个与类无关的顺序。接下来,让我们看看如何进行自定义排序。

实现 compare()

有几种方法可以使用Comparator接口实现自定义排序:

  • 创建一个单独的类(不典型)

  • 使用匿名内部类(更好)

  • 使用 Lambda 表达式实现(最常见)

例如,要按名称对Person对象列表进行排序,我们可以创建这个匿名类:

Comparator<Person> nameComparator = new  Comparator<Person>() {
    @Override
    public int compare(Person p1, Person p2) {
        return p1.getName().compareTo(p2.getName());
    }
};

在这里,我们创建了一个新的Comparator对象nameComparator,该对象实现了Comparator接口。这个自定义比较器将用于根据姓名比较Person对象。compare()方法在匿名内部类中实现。在compare()方法内部,我们使用String类的compareTo()方法对两个Person对象的姓名进行字典比较。

Comparator接口中的compare()方法遵循与Comparable接口中的compareTo()方法相同的返回值规则:

  • 如果被比较的两个对象相等,该方法将返回0

  • 如果第一个对象大于第二个对象,该方法将返回一个正值。

  • 如果第一个对象小于第二个对象,该方法将返回一个负值。

要使用自定义比较器对Person对象列表进行排序,我们可以将nameComparator对象作为参数传递给Collections.sort()方法,如下所示:

List<Person> personList = new ArrayList<>();personList.add(new Person("Huub", 1));
personList.add(new Person("Joep", 4));
personList.add(new Person("Anne", 3));
Collections.sort(personList, nameComparator);

在这个例子中,personList将根据Person对象的名称按字母顺序排序,正如nameComparator所指定的。如果我们没有指定nameComparator,它将使用自然顺序并按年龄排序。在排序之前,元素按照添加的顺序排列。排序后,它们按名称排序,A-Z,所以我们得到AnneHuubJoep

使用 Lambda 表达式实现 Comparator

更常见的是使用 Lambda 表达式来实现Comparator接口。这样,我们就有了一个更短的语法来创建比较器,而不需要匿名内部类。你现在可能不需要理解这一点,但这里有一个使用 Lambda 表达式创建按姓名排序的Person对象比较器的例子:

Comparator<Person> nameComparatorLambda = (p1, p2) ->

p1.getName().compareTo(p2.getName());

这是一样的。我们可以将其作为参数传递给Collections.sort()方法:

Collections.sort(personList, nameComparatorLambda);

由于我们现在有了自定义比较器,我们可以创建尽可能多的比较器。这里有一个使用 Lambda 表达式按姓名长度排序Person对象的另一个例子:

Comparator<Person> nameLengthComparator = (p1, p2) ->

Integer.compare(p1.getName().length(),

p2.getName().length());

Collections.sort(personList, nameLengthComparator);

在这里,nameLengthComparator根据姓名长度比较Person对象。personList将按姓名长度的升序排序。我们的名字长度都是四个,因此它们将保持添加时的顺序。

使用Comparator而不是由Comparable接口定义的自然顺序的优势在于,你可以在不修改类本身的情况下为同一类定义多个自定义排序。此外,我们可以通过向Collections.sort()方法提供不同的Comparator对象来轻松地在运行时更改排序标准。

我们选择哪个选项取决于我们的需求。如果我们想给我们的对象一个自然顺序,我们必须实现 Comparable 接口。如果我们无法直接访问类,或者我们想要指定一个不是自然顺序的顺序,我们可以使用 Comparator

我们也可以在创建 TreeSetTreeMap 时使用比较器。这将确定这些集合中的值将如何排序。

TreeSets 和 TreeMaps

TreeSetTreeMap 是使用其元素的自然顺序或自定义比较器进行排序的有序集合。这就是为什么我们无法为没有自然顺序的对象(它们没有实现 Comparable 接口)创建 TreeSetTreeMap,除非在创建 TreeSetTreeMap 时提供一个自定义比较器。让我们看看如何为每个实现这个操作。

TreeSet 中元素的顺序

作为快速提醒,TreeSet 是一个 Set 实现类,它以排序顺序存储元素。这就是为什么 TreeSet 中的元素必须实现 Comparable 接口,或者必须在 TreeSet 的构造过程中传递一个自定义比较器。

这是一个使用自然顺序创建 TreeSet 类的 Person 对象的例子:

TreeSet<Person> personTreeSet = new TreeSet<>();personTreeSet.add(new Person("Huub", 1));
personTreeSet.add(new Person("Joep", 4));
personTreeSet.add(new Person("Anne", 3));

在这个例子中,Person 类实现了 Comparable 接口,所以 TreeSet 将使用 Person 类中定义的 compareTo() 方法所定义的自然顺序(这是按年龄排序的)。

如果你想要创建一个带有自定义比较器的 TreeSet 类,你可以将比较器作为参数传递给 TreeSet 构造函数,如下所示:

Comparator<Person> nameComparator = (p1, p2) ->  p1.getName().compareTo(p2.getName());
TreeSet<Person> personTreeSetByName = new
  TreeSet<>(nameComparator);
personTreeSetByName.add(new Person("Huub", 1));
personTreeSetByName.add(new Person("Joep", 4));
personTreeSetByName.add(new Person("Anne", 3));

在这个例子中,TreeSet 将根据 nameComparator 指定的名称对 Person 对象进行排序。我们可以对 TreeMap 做类似的事情。

TreeMap 中元素的顺序

如果你忘记了,TreeMap 是一个 Map 实现类,它根据键的排序顺序存储键值对。这就是为什么 TreeMap 中的键必须实现 Comparable 接口,或者我们在创建 TreeMap 时应该提供一个自定义比较器。

让我们从使用自然顺序作为键的 Person 对象和它们的年龄作为值的 TreeMap 类开始:

TreeMap<Person, Integer> personTreeMap = new TreeMap<>();personTreeMap.put(new Person("Huub", 1), 1);
personTreeMap.put(new Person("Joep", 4), 4);
personTreeMap.put(new Person("Anne", 3), 3);

在这个例子中,Person 类实现了 Comparable 接口,所以 TreeMap 将使用 Person 类中定义的 compareTo() 方法所定义的自然顺序。

如果你想要创建一个带有自定义比较器的 TreeMap 类,你可以将比较器作为参数传递给 TreeMap 构造函数,如下所示:

Comparator<Person> nameComparator = (p1, p2) ->  p1.getName().compareTo(p2.getName());
TreeMap<Person, Integer> personTreeMapByName = new
  TreeMap<>(nameComparator);
personTreeMapByName.put(new Person("Huub", 1), 1);
personTreeMapByName.put(new Person("Joep", 4), 4);
personTreeMapByName.put(new Person("Anne", 3), 3);

现在,这个 TreeMap 将根据 nameComparator 指定的名称对 Person 对象进行排序。

因此,TreeSetTreeMap 是使用其元素的自然顺序或自定义比较器来排序其内容的有序集合。

通过使用 Comparable 接口和自定义比较器,你可以为你的自定义类定义多个排序方式,并轻松控制集合的排序行为。

与泛型一起工作

我们在本章中一直在使用泛型。泛型是灵活的,用于(包括)集合。我们通过在尖括号之间指定类型来将这些值传递给这些集合。我们可以创建一个具有类型参数的集合,如下所示:

List<String> names = new ArrayList<>();

这是因为 List 接口和 ArrayList 类都是使用类型参数(泛型)创建的。这使得类更加灵活,同时仍然确保类型安全。让我们看看在泛型之前是如何做到这一点的,以了解为什么它们如此出色。

泛型之前的生活 – 对象

在我们没有泛型之前,所有集合都会有对象。你必须手动检查列表中的项是否是你希望的类型。如果是,你必须将其转换为这个类型才能使用,就像这样:

List = new ArrayList();list.add("Hello");
list.add("World");
list.add(123); // Integer inserted in a List of strings.
               //  Allowed, but not logical.
for (int i = 0; i < list.size(); i++) {
    Object item = list.get(i);
    if (item instanceof String) {
        String strItem = (String) item; // Type casting
                                        //  required
        System.out.println(strItem);
    } else {
        System.out.println("Item is not a String");
    }
}

在前面的代码中,我们创建了一个没有指定任何类型的列表。这创建了一个 Object 类型的列表。你可能还记得,所有 Java 对象都是 Object 类型。然后,我们向其中添加了两个字符串和一个整数。这在技术上是被允许的,因为列表接受任何类型的对象,但它可能导致你的代码中出现逻辑错误。

后来,当我们遍历列表时,在安全地将每个项目转换为字符串 (String) item 之前,我们必须使用 instanceof 手动检查每个项目的类型。如果我们尝试将错误类型的项转换为字符串,代码将在运行时抛出 ClassCastException 错误。这可能会很耗时且容易出错,这也是泛型被引入的主要原因之一。

让我们更详细地看看泛型,并看看它们在集合使用场景之外的应用。我们将学习如何创建一个泛型类,以及为什么我们会这样做。

泛型的用例

让我们先创建两种类型,我们将把它们放入一个包类中。我们首先不使用泛型来做这件事。

这里有一个名为 Laptop 的公共 class

public class Laptop {    private String brand;
    private String model;
    public Laptop(String brand, String model) {
        this.brand = brand;
        this.model = model;
    }
    // Getters and setters omitted
}

这里还有一个名为 Book 的公共 class

public class Book {    private String title;
    private String author;
    public Book(String title, String author) {
        this.title = title;
        this.author = author;
    }
    // Getters and setters omitted
}

书和笔记本电脑是典型的可以存放在包里的东西。让我们编写 Java 代码来完成这个任务。在不使用泛型的情况下,我们需要两个类。第一个将是为 Laptop

public class LaptopBag {    private Laptop;
    public LaptopBag(Laptop laptop) {
        this.laptop = laptop;
    }
    public Laptop getLaptop() {
        return laptop;
    }
    public void setLaptop(Laptop laptop) {
        this.laptop = laptop;
    }
}

第二个将是为 Book

public class BookBag {    private Book;
    public BookBag(Book book) {
        this.book = book;
    }
    public Book getBook() {
        return book;
    }
    public void setBook(Book book) {
        this.book = book;
    }
}

现在,我们有两个自定义类 LaptopBook,以及两个包类 LaptopBagBookBag,每个包类都包含特定类型的项。然而,在 LaptopBagBookBag 类中有很多重复的代码。我们可以通过,而不是让 Bag 对一个类型特定化,允许它持有 Object 类型,来解决这个问题,如下所示:

public class ObjectBag {    private Object;
    public ObjectBag(Object object) {
        this.object = object;
    }
    public Object getObject() {
        return object;
    }
    public void setObject(Object object) {
        this.object = object;
    }
}

这个类允许我们添加 LaptopBookPerson 类。几乎可以是任何东西——它并不关心。但这也带来了一些缺点。由于 ObjectBag 类可以存储任何类型的对象,因此在编译时无法确保类型安全。这可能会导致运行时异常,例如 ClassCastException,如果我们不小心在代码中混合了不同类型的对象。

非常相关的是,当我们从 ObjectBag 中检索对象时,我们需要进行的类型转换。为了访问所有方法和字段,我们需要显式地将它转换回其原始类型。这增加了代码的冗余,并增加了出现 ClassCastException 错误的可能性。

幸运的是,泛型出现了!泛型提供了一种创建灵活且类型安全的类的方法,可以处理不同类型,而不具有使用 Object 类型相关的缺点。那么,让我们看看我们如何使用泛型重写 ObjectBag 类。

泛型语法

泛型通过在尖括号内指定类型参数来使用,例如 <T>,其中 T 代表一个类型。以下是一个使用单个 Bag 类的泛型解决方案:

public class Bag<T> {    private T content;
    public Bag(T content) {
        this.content = content;
    }
    public T getContent() {
        return content;
    }
    public void setContent(T content) {
        this.content = content;
    }
}

通过使用泛型类型参数 <T>,我们现在可以创建一个更灵活的 Bag 类,它可以包含任何类型的项,例如 LaptopBook。同时,我们可以确保类型安全并避免显式转换的需要。以下是使用 Bag 类的方法:

Bag<Laptop> laptopBag = new Bag<>(new Laptop("Dell", "XPS  15"));
Bag<Book> bookBag = new Bag<>(new Book("Why Java is fun",
  "Maaike and Seán"));

总结来说,泛型在创建可重用类时增加了灵活性,同时保持了类型安全。然而,有时我们可能希望限制可以与泛型类一起使用的类型。这就是有界泛型发挥作用的地方。让我们看看。

有界泛型

没有有界泛型,我们可能会遇到需要在泛型类中调用特定类型或其子类的方法的情况。我们不能直接这样做,因为泛型类对其处理的类型的特定方法一无所知。以下是一个简短的例子来说明有界泛型的必要性。

假设我们有一个名为 Measurable 的接口:

public interface Measurable {    double getMeasurement();
}

我们希望有一个类似于 Bag 的类,但只接受实现了 Measurable 接口的泛型。这就是为什么我们需要创建一个只能包含实现了 Measurable 接口对象的泛型 MeasurementBag 类。我们可以使用有界泛型来实现这一点:

public class MeasurementBag<T extends Measurable> {    private T content;
    public MeasurementBag(T content) {
        this.content = content;
    }
    public T getContent() {
        return content;
    }
    public void setContent(T content) {
        this.content = content;
    }
    public double getContentMeasurement() {
        return content.getMeasurement();
    }
}

通过使用 <T extends Measurable>,我们指定泛型类型 T 必须是一个实现了 Measurable 接口类的实例。这确保了只有实现了 Measurable 接口类型的对象才能与 MeasurementBag 类一起使用。这就是为什么我们可以在 MeasurementBag 类中安全地调用 getMeasurement() 方法——因为我们知道 T 保证实现了 Measurable 接口。

因此,这些有界泛型允许我们限制在泛型类中使用的类型,并确保它们共享一组公共方法。这就是为什么在泛型类中调用这些方法是安全的。这听起来像集合所做的那样吗?例如,当我们只传递一个参数(集合)时,Collections.sort() 需要一个实现了 Comparable 接口的对象集合。泛型和有界类型参数在 Java 自身代码中实际上非常常见。

我们现在已经看到了限定泛型,它为泛型类型指定了一个上限(一个超类或接口)。这确保了只能使用该类型或其子类的对象与泛型类一起使用。也存在下限,但这里不涉及。你可能会在 Java 源代码中遇到这些,但你自己实际使用这些的可能性不大。

让我们深入探讨另一个在使用自定义对象与HashMapHashSet一起使用时很重要的概念。

哈希和重写hashCode()

哈希是 Java 中的一个重要概念。它用于在诸如HashMapsHashSets之类的各种数据结构中高效地存储和检索数据。它也是一个非常有趣的话题。即使不理解这个功能,你也能走得很远,但某个时候,你可能会对你的HashMap类的糟糕性能感到好奇。而要理解发生了什么,没有理解哈希是不可能的。所以,让我们讨论哈希的基本概念,hashCode()方法在集合中的作用,以及自定义类中重写hashCode()方法的最佳实践。

理解基本的哈希概念

哈希是一种将数据转换成称为哈希码的代码片段的方法。想象一下,将一大堆书分配一个唯一的数字。一个好的哈希函数应该给不同的书分配不同的数字,并均匀分布。这使得查找和组织书籍变得容易。Java 中的所有对象都有一个hashCode()方法。

hashCode()及其在集合中的作用

Object类定义了hashCode()方法。由于所有类都从Object(间接地)继承,所有对象都有hashCode()方法。此方法返回一个整数值。两个相同的对象应该具有相同的哈希码。

当你在HashMapHashSet类中使用一个对象时,它的hashCode()用于决定其在数据结构中的位置。当我们创建自定义类时,我们有时需要重写hashCode()

重写hashCode()和最佳实践

当我们创建一个自定义类并计划将其用作HashMap类中的键或HashSet类中的元素时,我们需要重写hashCode()方法。这确保了我们的类有一个一致且高效的哈希函数。

这里有一些重写hashCode()的最佳实践:

  • 包含在equals()方法中使用到的所有字段。这样,相等的对象将具有相同的哈希码。

  • 使用一个简单的算法来组合各个字段的哈希码,例如乘以一个素数并加上字段的哈希码。

这里是一个在我们的Person类中实现hashCode()的例子:

public class Person {    private String name;
    private int age;
    // Constructor, getters, and setters
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + (name == null ? 0 :
          name.hashCode());
        result = 31 * result + age;
        return result;
    }
}

如您所见,已经添加了hashCode()方法。

更详细地解释hashCode()

数字1731被用作Person类哈希码计算的一部分。这两个都是素数,在哈希码计算中使用素数有助于产生更好的哈希码分布,并减少哈希码冲突的可能性。17用作结果变量的初始值。这是一个任意的素数,有助于确保哈希码计算从非零值开始。

通过这样做,它降低了生成不同对象相似哈希码的可能性,这反过来又有助于最小化冲突。31在哈希码计算中用作乘数。在添加下一个字段的哈希码之前,将当前结果乘以一个素数(在这种情况下是31)有助于更有效地混合各个字段的哈希码。这导致哈希码在可能范围内的更好分布。31通常被选择,因为它可以使用位运算(即x * 31(x << 5) - x)相同)有效地计算。

在自定义泛型类型中使用 hashCode()

在创建自定义泛型类时,我们可能需要使用存储对象的hashCode()方法。为此,我们可以在对象上简单地调用hashCode()方法或使用Objects.hashCode()实用方法,该方法可以优雅地处理空值:

public class Bag<T> {    private T content;
    // Constructor, getters, and setters
    @Override
    public int hashCode() {
        return Objects.hashCode(content);
    }
}

在使用 Java 集合工作时,理解哈希和hashCode()方法很重要,尤其是在使用自定义类与哈希集合结合时。如果我们遵循重写hashCode()和使用自定义泛型类型的最佳实践,我们可以在添加和访问集合中的元素时实现更好的性能。

练习

你可能没有直接注意到,但我们一直在期待这个!我们终于可以将集合和泛型添加到我们应用程序的应用中。生活将变得更加容易。让我们看看一些练习:

  1. 我们公园里有各种恐龙及其相关数据。实现一个List接口来存储自定义的恐龙类。

  2. 我们需要确保首先照顾到最危险的恐龙。编写一个PriorityQueue类,根据自定义的Comparator接口(例如,它们的危险级别)对恐龙进行排序。

  3. 泛型可以使我们的代码更具可重用性。创建一个名为Crate的类,其中包含一个泛型,用于存储您想存储在该类中的内容。这可以是餐厅的食物或饮料,也可以是恐龙,如果我们需要重新安置它们。

  4. 在你的程序中创建三个Crate类的实例,使用不同的类 - 例如,DinosaurJeepDinosaurFood

  5. 哈希对于高效的数据处理至关重要。在你的恐龙类中重写hashCode()方法。

  6. 挑战:我们在寻找餐厅人员方面有一些问题。让我们自动化我们公园冰淇淋店的订购。编写一个程序来完成以下任务:

    • 询问客人会列出多少冰淇淋。

    • 对于每一款冰淇淋,询问他们想要什么口味(如果你敢的话,可以想出一些口味选择,并使其恐龙主题化)以及多少份。

    • 为了简单起见,让我们假设每位客人只能订购每种口味一次。将所有冰淇淋及其描述添加到一个包含映射的List接口中。这些映射将代表冰淇淋和冰淇淋球的份量。

  7. 挑战:详细阐述练习 13.6。打印订单(遍历列表!)并说明它将在当前时间加 10 分钟后准备好(你需要计算这个时间,而不是直接打印出来!)

项目 - 高级恐龙养护系统

随着我们公园中恐龙数量的增加,对更复杂的数据管理系统需求变得明显。泛型和集合来拯救!

我们将继续构建恐龙养护系统。该系统应能处理恐龙集合,允许根据各种参数对恐龙进行排序,确保恐龙的唯一性,等等。

这里是我们将要采取的步骤。

步骤 1:添加额外的 Java 类

  • 创建一个名为collections的新包。

  • 在这个包内部,创建一个名为DinosaurComparator的类。这个类应该实现Comparator<Dinosaur>接口。重写compare()方法,根据年龄、大小等参数对恐龙进行排序。

注意

通常你不会为比较器创建一个类,但我们直到下一章才会看到 lambda 表达式。

步骤 2:扩展恐龙 养护系统

  • DinosaurCareSystem类中持有Dinosaur对象的List接口更改为Set接口。这将确保恐龙的唯一性。

  • 创建一个名为sortDinosaurs()的方法,使用DinosaurComparatorDinosaur集合进行排序。

这里有一些示例代码供你开始:

import java.util.*;public class DinosaurCareSystem {
    private Set<Dinosaur> dinosaurs;
    private List<Activity> activities;
    public DinosaurCareSystem() {
        dinosaurs = new HashSet<>();
        activities = new ArrayList<>();
    }
    public void addDinosaur(Dinosaur dinosaur) {
        dinosaurs.add(dinosaur);
    }
    public void logActivity(Activity activity) {
        activities.add(activity);
    }
    public List<Dinosaur> sortDinosaurs() {
        List<Dinosaur> sortedDinosaurs = new
          ArrayList<>(dinosaurs);
        Collections.sort(sortedDinosaurs, new
          DinosaurComparator());
        return sortedDinosaurs;
    }
    //... existing methods for handling exceptions and
          other functionalities here
}

以下是你可以使用的DinosaurComparator类:

import java.util.Comparator;public class DinosaurComparator implements
  Comparator<Dinosaur> {
    @Override
    public int compare(Dinosaur d1, Dinosaur d2) {
    // assume Dinosaur has a getSize() method
        return d1.getSize().compareTo(d2.getSize());      }
}

步骤 3:与 系统 交互

  • 在你的main类中,你可以像之前步骤中那样与DinosaurCareSystem对象交互,但现在,添加根据参数对恐龙进行排序的功能。

你想要更多吗?你可以通过添加更多功能来扩展这个项目,例如根据不同参数进行排序,根据恐龙的特性进行搜索等。

概述

好的,你已经通过了另一个艰难的章节。在本章中,我们探讨了 Java 中集合和泛型的基础知识。我们首先讨论了编程中集合的需求,并概述了 Java 中可用的不同集合类型,包括ListSetMapQueueDeque。我们检查了每种集合类型的特定实现,例如ArrayListLinkedListHashSetTreeSetHashMapTreeMap等,以及它们的区别和适当的使用场景。我们还涵盖了基本操作,如向集合中添加、删除和遍历元素。

然后,我们转向了集合排序的学习。我们通过使用ComparableComparator接口区分了自然排序和自定义排序。我们学习了如何实现compareTo()compare()方法,以及如何使用Collections.sort()方法和TreeSetTreeMap类对列表、集合和映射进行排序。

接着,我们深入探讨了泛型,解释了它们在提供类型安全方面的重要性。我们演示了泛型的语法和基本用法,包括在有限泛型中使用extends关键字。

然后,我们继续学习如何通过定义泛型类来创建自定义泛型类型。我们还讨论了没有泛型时的含义,以及如何创建泛型类型的实例。

最后,我们讨论了基本哈希概念和在集合中hashCode()方法的作用。我们提供了重写hashCode()的指南和其实施的最佳实践,强调了它在自定义泛型类型中的重要性。

到目前为止,你应该已经对ListSetMapQueue之间的区别有了扎实的理解,并且对泛型和哈希的基本知识有所掌握。你现在可以准备学习下一个令人兴奋的主题:Lambda 表达式。

第十四章:Lambda 表达式

在本章中,我们将介绍 lambda 表达式,这是我最喜欢的特性之一。Java 8 引入的 lambda 表达式(lambdas)将函数式编程带到了 Java。首先,我们将定义功能接口及其与 lambda 的关系。我们将演示自定义和基于 API 的 lambda 表达式。我们还将解释关于 lambda 表达式内部使用的局部变量的“final 或实际上是 final”的概念。

在此之后,我们将介绍方法引用。我们将讨论并展示绑定、未绑定、静态和构造方法引用的示例代码。最后,我们将解释上下文在理解方法引用中的关键作用。

本章涵盖了以下主要主题:

  • 理解 lambda 表达式

  • 从 API 探索功能接口

  • 掌握方法引用

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch14

理解 lambda 表达式

Lambda 表达式节省了按键次数,因此使你的代码更加简洁,从而更易于阅读和维护。为了实现这一点,编译器必须能够生成你不再输入的代码。这把我们带到了我们的第一个主题:功能接口。为了理解 lambda 表达式,我们首先必须理解功能接口。

功能接口

回想一下,接口有defaultstaticprivateabstract方法。实现接口的具体(非抽象)类必须为所有abstract方法提供代码。功能接口是一个只有一个抽象方法的接口defaultstaticprivate方法不计入。也不计入从Object继承的任何方法。这个唯一的abstract方法被称为功能方法

Lambda 表达式

lambda 表达式是实现功能接口的类的实例。lambda 被简化为其基本要素。lambda 看起来很像方法(实际上在某些领域被称为“匿名方法”)。然而,lambda 是一个实例,除了方法之外,其他所有东西都有。

让我们从示例功能接口及其常规类的实现开始:

interface SampleFI{    void m();
}
class SampleClass implements SampleFI{
   @Override
   public void m(){System.out.println("m()");}
}

现在,让我们检查一下执行相同操作的 lambda 版本:

      SampleFI lambda = () -> System.out.println("m()");      lambda.m();

前两行代码可以出现在任何方法中。第一行声明/定义了 lambda 表达式,第二行执行它。请注意,在定义 lambda 时,没有提到实现功能接口SampleFI的类,也没有提到功能方法m()。实际上,在 lambda 声明中,()m()方法的参数列表,它不接受任何参数;->符号将方法头与方法体分开,System.out.println("m()")是方法m()的代码。别担心,我们很快会用更多的代码示例详细解释 lambda 语法。

请记住,lambda 表达式可以节省我们编写不必要的代码。为了实现这一点,编译器必须在后台为我们生成(缺失的)代码。这就是为什么 lambda 表达式只适用于功能接口——由于只有一个abstract方法的存在,编译器可以从接口定义中推断出很多信息。编译器看到这个abstract方法,就会立即知道 lambda 所需的签名。所以,为了总结:

  • Lambda 使你的代码更简洁

  • Lambda 表达式只与功能接口一起使用

  • Lambda 表达式是实现功能接口的类的实例

现在我们来看一些例子。

Lambda 表达式 – 示例 1

图 14.1 展示了一个与相关功能接口关联的自定义 lambda 表达式:

图 14.1 – 带有 lambda 表达式的功能接口

图 14.1 – 带有 lambda 表达式的功能接口

在这个图中,我们定义了一个功能接口SomeFunctionalInterface

interface SomeFunctionalInterface {    void m();
}

它有一个名为m()abstract方法。按照代码编写,这个功能接口SomeFunctionalInterface现在可以用于 lambda 表达式。

第 11-13 行定义了第一个 lambda 表达式,即lambda1

SomeFunctionalInterface lambda1 = () -> {   System.out.println("First lambda!");
};

引用类型是SomeFunctionalInterface类型,我们的功能接口类型。lambda1引用被分配(以引用)实现SomeFunctionalInterface的类的实例。

在赋值的右侧是圆括号()。这些是为接口中的m()方法,即SomeFunctionalInterface。在接口的方法声明中没有定义任何参数,因此没有传递任何参数。由于没有参数,所以需要()。请注意,没有必要提到方法名——这是因为,由于SomeFunctionalInterface是一个功能接口,编译器知道唯一的abstract方法是m()。而且由于m()没有定义参数,lambda 头只是()

箭头符号 -> 将方法头(如果有参数传入)与方法体分开。在这个例子中,方法体是一段代码块;换句话说,就像在普通方法中一样,有花括号 {}。一旦指定了一个代码块,就遵循通常的代码块规则——这意味着编译器会退后一步,不会为你做任何事情。例如,如果你想从代码块中返回某个值,你必须自己这样做。在下一个例子中,我们将看到,如果你不使用代码块,编译器会为你执行 return

这个例子中的 lambda 只是将 "First lambda!" 输出到屏幕上。第 13 行的分号是正常的语句结束标记。第 11-13 行只是 定义 lambda。到目前为止还没有执行任何代码。

第 15 行,lambda1.m() 执行了由 lambda1 指向的 lambda 表达式,导致屏幕上输出 "First lambda!"

第 17 行定义了一个类似的 lambda,但它更加简洁:

SomeFunctionalInterface lambda2 = () ->    System.out.println("Second lambda!");

这个 lambda,lambda2,利用了编译器可以为我们做更多工作的特点。如果你只有一个要执行的语句,那么,就像循环等其他结构一样,不需要花括号 {}。因为我们只执行 System.out.println(),所以我们不需要花括号。第 17 行末尾的分号实际上是赋值语句的结束,而不是 System.out.println() 的结束。换句话说,第 17 行末尾的分号与第 13 行末尾的分号相同(而不是第 12 行末尾的分号)。

再次,第 17 行只定义了 lambda,没有执行任何代码。第 18 行,lambda2.m() 执行了 lambda,导致屏幕上输出 "Second lambda!"

注意,@FunctionalInterface 注解(图 14.1 中的第 3 行)。这个注解确保接口只定义了一个 abstract 方法。尽管这个注解是可选的,但使用它是良好的实践,因为它向其他开发者表明了我们使用这个接口的意图。此外,使用这个注解允许编译器在我们未能提供确切的一个 abstract 方法时介入。

让我们看看另一个例子。这次,功能方法将接受一个参数并返回一个值。

Lambda 表达式——示例 2

图 14.2 展示了一个示例,这将使我们能够进一步讨论细微差别:

图 14.2 - 带有 lambda 表达式的更复杂功能接口

图 14.2 - 带有 lambda 表达式的更复杂功能接口

在这个图中,Evaluate函数接口泛型类型为<T>。这意味着我们可以为各种类型使用它,例如Integer(第 12 行)和String(第 16 行)。check功能方法(第 8 行)接受一个类型为T的参数,即t,并返回一个boolean值。这个特定的功能接口与我们稍后将在 Java API 中看到的非常相似,即Predicate。相比之下,第一个 lambda(第 12 行)的编码与第二个 lambda(第 16 行)的编码相当不同。

在第 12 行,我们声明了一个Evaluate引用,即isItPositive,它仅用于整数。使用 lambda 表达式时,上下文至关重要。由于我们将isItPositive的类型指定为Integer,这意味着括号中的标识符nInteger类型!我们在本例中明确指定了n的类型,但这是不必要的,因为编译器可以从上下文中推断出来。换句话说,我们本可以使用(n)或简单地使用n在 lambda 表达式中,它也会正常工作。我们只是将其留为(Integer n),以便 lambda 表达式(第 12 行)和check(T t)功能方法(第 8 行)之间的关系更清晰。

第 12 行的=右侧我们有(Integer n) -> {return n>0;}。这是实现Evaluate类的check(T t)方法的代码。因此,需要一个参数,由于Evaluate<Integer>声明,参数类型为Integer,并且必须返回一个boolean类型的值。

我们再次使用->符号来区分方法头和方法体。

在第 12 行,与所有 lambda 表达式一样,->符号的右侧是方法体。在这种情况下,我们有{return n>0;}。由于我们使用了花括号,我们必须在代码块内部遵循常规语法规则。鉴于check(T t)方法返回类型为boolean,我们必须从代码块中返回一个boolean值。此外,return语句需要像往常一样有一个分号作为结束符。整个赋值语句也需要一个分号作为结束符。这就是为什么在行尾附近有两个分号(第 12 行)。在这个 lambda 表达式中,我们说的是,如果传入的Integer类型大于 0,我们返回true;否则,返回false

第 13 行,isItPositive.check(-1) 执行了 lambda 表达式,传入-1,返回false。第 14 行,isItPositive.check(+1) 同样执行了 lambda 表达式,这次传入+1,返回true

第 16 行是:Evaluate<String> isMale = s -> s.startsWith("Mr.");。这定义了一个 Evaluate lambda,类型为 String,通过 isMale 引用。因为我们为 String 类型的 lambda 编写了代码,所以这次传入的参数 sString 类型。记住,我们在第 16 行定义的是 check(T t) 方法的代码。注意,这次我们没有指定 s 的类型,因为编译器可以从上下文中推断出来(Evaluate<String>)。另外,由于只有一个参数且我们没有指定类型,我们可以省略圆括号,()。然而,正如我们已经看到的,如果你没有任何参数,你必须指定 ()

此外,在第 16 行,请注意,由于我们没有使用代码块,我们不需要显式的 return 语句,因为编译器会为我们完成这个工作。由于 sString 类型,我们可以调用 String 方法;这就是为什么我们可以没有问题地调用 startsWith("Mr.")。行尾的分号是用于整个赋值语句的,而不是用于 lambda(因为不需要)。在这个 lambda 中,我们只是评估传入的字符串是否以“Mr.”开头,如果是,则返回 true;否则,返回 false

现在 lambda 已经定义,我们可以执行它。第 17 行,isMale.check("Mr. Sean Kennedy") 返回 true,第 18 行,isMale.check("Ms. Maaike van Putten") 返回 false

正如你所见,编译器推断了很多,这为我们节省了很多打字。适应 lambda 需要一段时间,但一旦适应了,你就会爱上它们。表 14.1 总结了语法:

功能接口 示例 Lambda 表达式
interface FI{``void m();``} FI fi1 = () -> System.out.println("lambda");``fi1.m(); // outputs "lambda"``FI fi2 = () -> { System.out.println("lambda"); } ;``fi2.m(); // outputs "lambda"
interface FI{``int m(int x);``} FI fi3 = (int x) -> { return x * x;};``System.out.println(fi3.m(5)); // 25``FI fi4 = x -> x * x;``System.out.println(fi4.m(6)); // 36
interface FI{``String m(String a, String b);``} FI fi5 = (s1, s2) -> s1 + s2;``// 下一行返回 '``Sean Kennedy'``System.out.println(fi5.m("Sean", " Kennedy"));``FI fi6 = (String s1 , String s2) -> {return s1 + s2; };``// 下一行返回 '``Sean Kennedy'``System.out.println(fi6.m("Sean", " Kennedy"));

表 14.1 – 功能接口及其相关 lambda 表达式的示例

较长的语法,包括参数类型、代码块和 return 语句,在语法上与常规方法类似(除了方法名被省略)。较短的、更简洁的语法展示了编译器可以从周围上下文中推断出多少。这是可能的,因为函数式接口中只有一个 abstract 方法。Lambda 不能也不适用于具有多个 abstract 方法的接口。由于接口可以相互继承,所以要小心继承一个 abstract 方法然后尝试定义自己的——这对 Lambda 是不起作用的。

现在我们已经了解了函数式接口以及如何使用 Lambda 表达式实现它们,让我们来探讨为什么局部变量必须是 final 或“实际上最终”。

final 或实际上最终

回想一下,通过声明一个变量 final,你正在将其声明为一个常量,这意味着变量的值一旦赋值后就不能更改。“实际上最终”意味着尽管在变量声明中没有使用 final 关键字,但编译器通过确保如果你尝试更改其值,你会得到编译器错误来使其成为“实际上最终”。请注意,这个 final 或“实际上最终”的规则仅适用于局部变量,不适用于实例或类变量。

图 14**.3 展示了使用 final 或“实际上最终”的代码示例。我们首先解释代码,然后解释为什么局部变量是“实际上最终”的。

图 14.3 – “final”或“实际上最终”代码示例

图 14.3 – “final”或“实际上最终”代码示例

在这个图中,算法从列表中删除了以 "Mr." 开头的任何名称。第 9-11 行声明并填充了一个 ArrayList 列表。

第 13 行声明了一个名为 title 的局部 String 变量。这个变量在 Lambda(第 21 行)中使用,因此,由于它没有明确声明为 final,它是“实际上最终”的。

第 14-15 行声明并更改了一个局部 int 变量 y。由于 y 在 Lambda 表达式中没有使用,这是可以的。

第 19-22 行展示了 Lambda 表达式:

Predicate<String> lambda = str -> {     return str.startsWith(title);
};

Lambda 是一个 Predicate,其类型为 StringPredicate 是一个 API 函数式接口,我们将在下一节中详细讨论。Predicate 的函数式方法是 boolean test(T t)。由于我们已经将 Predicate 类型化为 String,因此 T 以及随之而来的 str 都是 String 类型。Lambda 返回 truefalse,这取决于 str 是否以 "Mr." 开头,从而匹配 test 函数式方法的返回类型。这是一个重要的点——Lambda 已经捕捉了局部变量 title 中的值;即 "Mr."

第 27 行和第 30 行都调用了 filterData(people, lambda)。这是 Lambda 的一个真正优点——它们可以被传递!但记住,Lambda 中的 title 值是 "Mr."

第 32-34 行显示了 filterData() 方法:

public static void filterData(List<String> list,                              Predicate<String> lambda) {
     list.removeIf(lambda);
};

lambda 被传递到从 Collection 继承的 default 方法 removeIf(Predicate)CollectionList 的父接口。removeIf(Predicate) 从列表中删除所有满足传入的谓词(lambda)的元素。在这个例子中,任何以 "Mr." 开头的名字都被删除。

现在,您可以看到为什么 title(第 13 行)的值绝对不能改变——lambda 使用了 "Mr."(第 21 行)。如果我们允许在 lambda(第 20 行)或方法(第 26 行或第 29 行)中更改 title,那么方法中的 title 值和 lambda 中的 title 值就不会匹配!这种情况绝对不能发生。因此,无论是方法还是 lambda 中的 title 任何更改都是被禁止的。这就是为什么第 20 行、第 26 行和第 29 行都被注释出来的原因。取消注释任何一行都会导致编译器错误。

探索 API 中的函数式接口

现在,让我们检查 API 中定义的一些流行的函数式接口。有趣的是,来自第十三章的两个排序接口 ComparatorComparable 都是函数式接口。Comparable 定义了一个 abstract 方法,即 int compareTo(T o),而 Comparator 定义了两个 abstract 方法,即 int compare(T o1, T o2)boolean equals(Object o)。然而,请记住,从 Object 继承的方法在您决定一个接口是否是函数式接口时不计算在内。由于 boolean equals(Object o) 是从 Object 继承的,这意味着 Comparator 是一个函数式接口。

在本节中,我们将集中讨论在 java.util.function 包中定义的函数式接口(docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/function/package-summary.html)。此包包含大量通用函数式接口,这些接口由 JDK 使用,也对我们可用。表 14.2 展示了最常用的几个。请参考 API 获取更多详细信息。我们将很快检查这些函数式接口及其 lambda 表达式:

函数式接口 函数式方法 描述
Predicate<T> boolean test(T t) 适用于测试
BiPredicate<T, U> boolean test(T t, U u) 这是 Predicate 的双参数特殊化
Supplier<T> T get() 适用于您想要值而不提供输入时
Consumer<T> void accept(T t) 适用于您传入输入但不关心返回值时
BiConsumer<T, U> void accept(T t, U u) 这是 Consumer 的双参数特殊化
Function<T, R> R apply(T t) 将输入转换为输出(类型可以不同)
BiFunction<T, U, R> R apply(T t, U u) 这是 Function 的双参数特殊化
UnaryOperator<T> T apply(T t) Function 相同,但类型相同
BinaryOperator<T> T apply(T t, T t2) BiFunction 相同,但类型都是相同的

表 14.2 – API 中流行的函数式接口

现在,让我们检查前面提到的每个函数式接口及其相关的代码中的 lambda 表达式。让我们从 PredicateBiPredicate 开始。

谓词和双谓词

谓词是一个布尔值函数(一个将返回 boolean 的函数)。图 14.4 展示了 PredicateBiPredicate

图 14.4 – 代码中的谓词和双谓词

图 14.4 – 代码中的谓词和双谓词

在这个图中,我们首先想讨论 API 中函数式接口的泛型类型与其函数式方法之间的关系。理解这种关系对于理解示例和创建编译器使用的上下文至关重要。当我们讨论方法引用时,这个上下文将非常重要。

如第 32-33 行的注释所示,泛型类型与函数式方法使用的参数和返回类型之间存在直接关系。在这种情况下,Predicate 的泛型类型为 T(第 32 行),函数式方法的输入参数也针对 T 类型化(第 33 行)。因此,如果我们为 Predicate 类型化为 Integer,那么函数式方法中的参数将是 Integer。我们不能传递 CatDogString 或任何其他类型作为参数。现在,让我们看看例子。

第 34 行定义了一个 Predicate,泛型类型为 String,即 isCityInNamecityName -> cityName.contains("City")boolean test(T t) 函数式方法的代码。由于泛型类型是 StringT 在这个函数式方法中现在是 String,这意味着参数类型是 String。因此,第 34 行的 cityName 变量代表一个 String 变量。这就是为什么编译器在 lambda 表达式中对 cityName.contains("City") 没有问题。由于 cityName.contains("City") 是一个简单的表达式,我们不需要 {}return 语句——编译器会为我们填写所有这些。请注意,我们使用的表达式必须返回一个 boolean 值,因为 boolean test(T t) 函数式方法返回 booleanString 方法 boolean contains(CharSequnce) 正好做到这一点,所以我们没问题。因此,在我们的 lambda 表达式定义之后,让我们执行它。

第 35 行执行了第 34 行定义的isCityInName lambda。请注意,使用isCityInName引用调用的方法是boolean test(T t)函数式方法。由于我们将isCityInName泛型化为String,我们传入的参数必须是String类型的参数。这正是我们所做的,传入"Vatican City"。这意味着我们的 lambda(第 34 行)中的cityName参数变为"Vatican City",因此boolean test(T t)方法中的代码变为"Vatican City".contains("City")。因此,第 35 行输出true

第 39 行定义了一个泛型为String, IntegerBiPredicate,即checkStringLength。同样,注释(第 37-38 行)展示了函数式接口的泛型类型与函数式方法参数之间的紧密关系。BiPredicate只是Predicate的扩展,除了函数式方法现在有两个(输入)参数,而不是一个。函数式方法名称仍然是test,返回类型仍然是boolean

由于checkStringLength被定义为BiPredicate<String, Integer>,函数式方法的签名现在是boolean test(String str, Integer len)。然后 lambda 检查作为第一个参数传入的字符串的长度是否等于作为第二个参数传入的数字。

在第 40 行,我们测试BiPredicate,按照顺序传入"Vatican City"8。由于"Vatican City"字符串的长度是12(而不是8),lambda 返回false

如前所述,PredicateBiPredicate都对T进行了泛型化。这意味着它们的函数式方法消耗一个类型T,例如StringInteger等。这与消耗原始类型的谓词形成对比。以下表格,表 14.3,展示了 API 中为希望消耗原始类型的谓词定义的函数式接口:

功能接口 功能方法 示例
DoublePredicate boolean test(double value) DoublePredicate p1 = d -> d > 0;
IntPredicate boolean test(int value) IntPredicate p2 = i -> i > 0;
LongPredicate boolean test(long value) LongPredicate p3 = lg -> lg > 0;

表 14.3 – API 中 Predicate 的原始测试特殊化

从表中可以看出,函数式接口的名称中没有泛型类型,例如<T>。函数式方法具有原始参数(而不是泛型类型)。由于我们处理的是原始类型,lambda 不能在参数上调用方法(因为原始类型只是简单类型,没有方法)。

现在,让我们讨论Supplier函数式接口。

Supplier

图 14.5 展示了演示Supplier的代码:

图 14.5 – 代码中的 Supplier

图 14.5 – 代码中的 Supplier

当你想要一个新对象时,Supplier函数式接口非常有用。泛型类型决定了提供的结果。换句话说,第 47 行将supSB类型化为StringBuilder,其中函数式方法get()返回StringBuilder。第 47 行还演示了如果没有参数,你必须指定圆括号()

第 48 行执行了第 47 行定义的 lambda 表达式。请注意,我们将append("SK")链接到get()方法的返回值。这只有在get()方法返回StringBuilder对象时才能工作,它确实是这样。

第 50 行定义了一个针对LocalTimeSupplier函数式接口,称为supTime。lambda 表达式返回本地时间。第 51 行通过调用Supplier的函数式方法T get()来执行它。一个示例运行的输出包含在右侧的注释中。

第 53 行定义了一个针对DoubleSupplier函数式接口,称为sRandom,它返回一个随机数。Math.random()返回一个大于或等于 0.0 且小于 1.0 的double值。第 54 行通过右侧的注释中的示例输出来执行它。

泛型类型的Supplier函数式接口也有针对原始类型的变体。表 14.4展示了这些:

函数式接口 函数式方法 示例
BooleanSupplier boolean getAsBoolean() BooleanSupplier bS = () -> LocalDate.now().isLeapYear();
System.out.println(bS.getAsBoolean());
DoubleSupplier double getAsDouble() DoubleSupplier dS = () -> Math.random();
System.out.println(dS.getAsDouble());
IntSupplier int getAsInt() IntSupplier iS = () -> (``int)(Math.random()*20);
System.out.println(iS.getAsInt());
LongSupplier long getAsLong() LongSupplier lgS = () -> (``long)(Math.random()*100);
System.out.println(lgS.getAsLong());

表 14.4 – API 中供应商的原始生成专业化

在这个表中,函数式接口的名称标识了生成的原始类型。例如,BooleanSupplier生成一个boolean原始类型。相应的函数式方法也遵循类似模式;例如,BooleanSupplier有一个boolean getAsBoolean()方法。其他函数式接口也遵循类似的模式。

现在,让我们讨论ConsumerBiConsumer函数式接口。

Consumer 和 BiConsumer

我们将从Consumer开始,根据 API,“它表示一个接受单个输入但不返回结果的运算操作。”图 14.6*展示了使用Consumer的代码示例:

图 14.6 – 代码中的 Consumer

图 14.6 – 代码中的 Consumer

在这个图中,第 67 行(注释)说明了void accept(T t)这个功能方法确实按照 API 的要求执行:接受一个输入并返回空(void)。消费者对于输出集合非常有用。在这个例子中,Consumer(第 68 行)接受一个String类型的s,并将其回显到标准输出(屏幕)。我们执行 lambda(第 69 行),传入我们想要显示的字符串。因此,"To be or not to be, that is the question"这个字符串是void accept(T t)这个功能方法的参数。在这里,s参数(第 68 行)接受字符串值,然后输出。

Iterable 接口

Iterable接口被许多其他流行的接口继承,如ListSet,因此由大量类实现。在 Java 8 之前,接口只有abstract方法——没有defaultstaticprivate方法(它们都是在后来的 Java 版本中引入的)。这意味着如果你更改接口(方法签名或添加新方法),现有的代码库就会崩溃。引入default方法的主要原因之一是 Java 设计者希望在不会破坏现有代码库的情况下,将default方法forEach(Consumer<? super T> action)引入Iterable。默认实现是在集合中的每个元素上执行Consumer lambda。

现在,让我们看看 Java API 是如何使用消费者的。第 71 行声明了一个字符串ArrayList,名为names。第 72 行将"Maaike""Sean"添加到列表中。

第 73 行非常有趣。我们在列表上执行forEach()方法,传入在第 68 行创建的消费者 lambda,printCforEach()方法遍历列表中的每个String,并对每个String调用Consumer lambda,printC。实际上,在后台发生以下操作:

printC.accept("Maaike");printC.accept("Sean");

现在,让我们看看BiConsumer接口在实际中的应用示例。图 14**.7展示了这样一个例子:

图 14.7 – 代码中的 BiConsumer

图 14.7 – 代码中的 BiConsumer

在这个图中,第 75 行,我们声明了一个Map<String, String>,名为mapCapitalCities,由HashMap实现。地图中的键和值都是字符串。BiConsumer biCon在第 80-81 行声明。功能方法void accept(T t, U u)需要两个参数——我们称它们为keyvalue。由于上下文(biCon的声明),两者都是字符串。第 81 行的 lambda 只是将keyvalue插入到地图中。这被称为“副作用”(见说明)。第 82-83 行使用 lambda 填充地图,第 84 行输出地图。

副作用

在 Java 中,lambda 表达式被认为是函数式编程风格。虽然函数式编程超出了本书的范围,但遵循函数式编程风格的函数不应产生副作用。副作用是对程序状态的改变,这种改变没有反映在函数的输出中。Consumer 与 Java 中大多数其他函数式接口不同,预期通过副作用来操作(因为函数式方法的返回类型是 void)。有关更多详细信息,请参阅:en.wikipedia.org/wiki/Functional_programming

对于 Map 有没有 forEach() 方法?幸运的是,有。这是一个在 Map 接口中定义的 default 方法,其签名是 default void forEach(BiConsumer<? super K, ? super V) action)。第 86-88 行设置了 lambda 表达式以输出装饰后的字符串,指出 keyvalue 的首都(取决于键/值对)。第 89 行执行 forEach(),传入我们的 BiConsumerforEach() 方法遍历映射中的每个条目,并对每个条目调用 BiConsumer lambda,mapPrint。实际上,在后台发生以下操作:

mapPrint.accept("Dublin", "Ireland");mapPrint.accept("The Hague", "Holland");

泛型类型的 Consumer 函数式接口也有针对原始类型的变体。表 14.5 展示了这些:

函数式接口 函数式方法 示例
DoubleConsumer void accept(double value) DoubleConsumer dc = d -> System.out.println(d);
dc.accept(2.4);
IntConsumer void accept(int value) IntConsumer ic = i -> System.out.println(i);
ic.accept(2);
LongConsumer void accept(long value) LongConsumer lc = lg -> System.out.println(lg);
lc.accept(8L);

表 14.5 – API 中 Consumer 的原始接受特殊化

再次强调,原始名称嵌入到了函数式接口名称中。请注意,每次传递给 accept() 函数式方法的参数类型都是原始类型

现在,让我们讨论 FunctionBiFunction 函数式接口。

Function 和 BiFunction

一个函数接受一个参数并产生一个结果。图 14**.8 展示了一些代码,演示了如何使用 FunctionBiFunction

图 14.8 – 代码中的 Function 和 BiFunction

图 14.8 – 代码中的 Function 和 BiFunction

在这个图中,第 101-102 行的注释显示了 Function 函数式接口及其函数方法在 API 中的出现。Function 是泛型类型的,第一个类型 T 代表输入类型,第二个类型 R 代表输出类型。这意味着,当我们在第 103 行声明 Function<String, Integer> 时,函数方法是 Integer apply(String s)。这反映在第 103 行的 Lambda 表达式中,我们接受一个字符串 s 并返回其长度。请注意,字符串的 length() 方法返回一个 int 类型的值,但 Java 会自动装箱为我们返回一个 Integer 类型的值。

第 104 行执行了 Lambda 表达式,传入 "London",返回 6

BiFunction 函数式接口表示一个接受两个参数并产生结果的函数。第 106-107 行的注释显示了其在 API 中的签名,即 BiFunction<T, U, R>,以及其函数方法的签名,即 R apply(T t, U u)。因此,前两种类型是输入类型,最后一种类型是输出类型。

第 108-109 行定义了一个 BiFunction 接口,其中我们接受两个 String 类型的参数并返回一个 Integer 类型的结果。实现它的 Lambda 表达式接受两个 String 参数,即 s1s2,并返回它们长度的总和。

第 111 行调用 Lambda 表达式,传入 "William""Shakespeare" 字符串。它们的长度分别是 711,Lambda 表达式返回 18

第 113-114 行定义了一个 BiFunction 接口,其中我们再次接受两个 String 类型的参数,但这次返回一个 String 类型的结果。Lambda 表达式(第 114 行)简单地将第二个 String 连接到第一个 String 上并返回结果。第 116 行执行了 Lambda 表达式,同时传入相同的两个字符串,"William""Shakespeare"。这次,结果是两个字符串的连接,即 "William Shakespeare"

泛型类型的 Function 函数式接口也有针对原始类型的变体。表 14.6 展示了其中的一部分:

函数式接口 函数方法 示例
DoubleFunction<R> R apply(double value) DoubleFunction<String> df = (double dbl) -> "" + Math.pow(dbl, 2);
df.apply(2.0); // "4.0"
DoubleToIntFunction int applyAsInt(double value) DoubleToIntFunction dtoif = dbl -> (int)Math.round(dbl);
dtoif.applyAsInt(4.2);// 4
DoubleToLongFunction long applyAsLong(double value) DoubleToLongFunction dtolf = (dbl) -> Math.round(dbl);
dtolf.applyAsLong(4.0);// 4

表 14.6 – API 中 Function 的双精度(原始类型)特殊化

API 中比 表 14.6 展示的函数式接口要多得多。请参阅 API 获取更多详细信息。它们可能令人望而生畏,但请记住,函数式接口名称及其关联的函数方法名称中存在一种模式。这有助于理解它们的功能。

例如,在 表 14.6 中,double 原始类型由 DoubleFunction<R>DoubleToIntFunctionDoubleToLongFunction 提供。对于 intlong 也有相应的函数式接口。

int 函数式接口是 IntFunction<R>IntToDoubleFunctionIntToLongFunction。这些与 int 相关的函数式接口与它们的 double 对应者(如 表 14.6 中概述的)做相同的事情,除了输入是 int 而不是 double。相关的函数方法名称将取决于结果类型。例如,IntToDoubleFunction 的函数方法将是 double applyAsDouble(int value)

对于 long 原始类型也是同样的情况。long 函数式接口是 LongFunction<R>LongToDoubleFunctionLongToIntFunction。它们的函数方法名称遵循与 intdouble 相同的模式。

让我们通过检查 UnaryOperatorBinaryOperator 来完成对函数式接口的讨论。

UnaryOperator 和 BinaryOperator

这两个函数式接口都是其他接口的特殊化。我们先来讨论 UnaryOperator

UnaryOperator

在 API 中,Function 函数式接口定义为 Function<T, R>T 代表函数的输入,R 代表函数的输出。字母不同是很重要的。这意味着,虽然类型当然可以是相同的,但它们也可以,并且通常是不同的。

UnaryOperatorFunction 的一个特殊化,其中输入和输出类型都是相同的。在 API 中,UnaryOperator 定义为 UnaryOperator<T> extends Function<T, T>,其函数式方法是 T apply(T t)

图 14.9 展示了一个代码示例:

图 14.9 – 代码中的 UnaryOperator

图 14.9 – 代码中的 UnaryOperator

在这个图中,第 128 行定义了一个针对 String 类型的 UnaryOperator。这意味着输入和输出现在都是字符串。name 标识符是一个 String,我们只是在 name 前面添加 "My name is "

第 130 行通过传入 "Sean" 来执行 lambda 表达式。返回的 String 类型的 "My name is Sean" 输出到屏幕上。

现在,让我们来检查 BinaryOperator

BinaryOperator

BinaryOperator 函数式接口相当于 BiFunction 相当于 Function。换句话说,BiFunction 允许我们指定两个输入参数和一个输出结果,所有这些都可以是不同类型。BinaryOperator,它扩展了 BiFunction,要求两个输入类型和输出类型必须相同。

在 API 中,BinaryOperator 定义为 BinaryOperator<T> extends BiFunction<T, T, T>,其函数式方法是 T apply(T t1, T t2)

图 14.10 展示了一个代码示例:

图 14.10 – 代码中的 BinaryOperator

图 14.10 – 代码中的 BinaryOperator

在这个图中,第 134 行定义了一个针对String类型的BinaryOperator。这意味着输入参数和结果现在都是字符串。s1s2标识符是字符串,我们只是将s2连接到s1上。

第 136 行通过传递"William""Shakespeare"来执行 lambda 表达式。返回的String "William Shakespeare"被输出到屏幕上。

掌握方法引用

现在,让我们继续讨论另一个关于 lambda 表达式的重要主题,那就是方法引用。尽管 lambda 表达式已经很简洁,但在某些情况下,它们甚至可以更简洁!这就是方法引用适用的地方。如果你的 lambda 表达式只是调用一个方法,那么这就是使用方法引用的机会。此外,如果一个 lambda 参数只是传递给一个方法,那么指定变量的冗余也可以被移除。

让我们来看一个例子:

List<String> names = Arrays.asList("Maaike", "Sean");names.forEach(name -> System.out.println(name); // lambda
names.forEach(System.out::println); // method reference

在这段代码中,我们通过调用Arrays.asList()方法声明了一个字符串列表。第一个forEach(Consumer)展示了如何使用 lambda 表达式输出列表。回想一下,Consumer的功能方法是void accept(T t)

第二个forEach(Consumer)展示了方法引用的语法。注意双冒号操作符::(或方法引用操作符),以及方法名后面没有圆括号(),就像println一样。

时刻记住代码最终必须生成。如果我们已经指定了所有代码,那么编译器就没有事情可做。然而,如果我们使用了 lambda 表达式和/或方法引用,编译器就必须介入并生成省略的代码。编译器只有在理解了上下文的情况下才能这样做。这对于理解省略了大量代码的方法引用至关重要。此外,具有功能方法的函数式接口对于提供上下文至关重要。

方法引用有四种不同类型:

  • 绑定

  • 未绑定

  • 静态

  • 构造函数

这些内容最好通过代码示例来解释。关于示例,为了使它们更容易理解,我们为每个示例都编写了 lambda 表达式和方法引用版本。lambda 变量使用"L"后缀,方法引用变量使用"MR"后缀。此外,在每个示例之前的注释中,都有函数式接口及其相关功能方法的签名。

现在,让我们从第一种方法引用类型:绑定方法引用开始。

绑定方法引用

绑定引用之所以得名,是因为引用绑定到特定对象的实例上。绑定方法引用有时被称为“特定对象的引用”。让我们用一个例子来进一步解释这一点。图 14.11展示了绑定方法引用的例子:

图 14.11 – 绑定方法引用示例

图 14.11 – 绑定方法引用示例

在这个图中,第 21 行声明了一个名为nameString变量,初始化为"Mr. Joe Bloggs"。第 22-23 行概述了Supplier功能接口及其功能方法的签名T get()在 API 中。第 24 行声明了一个将name转换为小写的Supplier lambda 表达式。这个 lambda 表达式与第 21 行声明的name变量相同。因此,这个 lambda 在编译时绑定到name变量。由于 lambda 只是调用一个方法,这是一个引入方法引用的机会。

给定第 24 行的 lambda 表达式,第 25 行概述了等效的方法引用。注意name变量的使用;方法引用操作符::以及方法名之后省略的圆括号()。此外,注意name是一个String,而toLowerCase()方法是String类中的一个方法。

第 28 行和第 29 行分别执行了 lambda 表达式和方法引用版本,两种情况下都返回了"mr. joe bloggs"

图 14**.11中的第一个示例使用了Supplier功能接口,它不需要输入参数。如果我们想传递一个值呢?Supplier功能接口将不起作用,因为它的功能方法是T get(),不接受参数。然而,Predicate将起作用,因为它的功能方法boolean test(T t)接受输入参数。图 14**.11中的第二个示例展示了这一点。

第 37 行是 lambda 版本。由于Predicate被指定为String类型,title是一个String。再次,我们将name绑定并执行String方法startsWith(),传递输入参数。我们可以看到 lambda 中的冗余,因为title被提到了两次。结合 lambda 只是调用一个方法的事实,我们又有机会引入方法引用。

第 38 行是第 37 行 lambda 表达式的等效方法引用版本。然而,这个方法引用需要更多的解释,因为在String类中,startsWith()方法是重载的。重载版本有boolean startsWith(String, int)boolean startsWith(String)。编译器是如何决定使用哪个版本的startsWith()呢?这就是上下文重要性的地方!我们正在定义一个PredicatePredicate的功能方法是boolean test(T t) - 由于这个方法只接受一个参数,编译器选择了一个参数的方法startsWith(String)

第 40 行执行了 lambda 版本,传递了"Mr."。这导致 lambda 执行"Mr. Joe Bloggs".startsWith("Mr."),结果是 true。

第 41 行执行了方法引用版本,传递了"Ms."。由于编译器将方法引用在后台转换为 lambda 表达式,这导致 lambda 执行"Mr. Joe Bloggs".startsWith("Ms."),结果是 false。

现在,我们将检查未绑定方法引用。

未绑定方法引用

未绑定方法引用不会绑定到变量。相反,在运行时提供要使用的实例。未绑定方法引用有时被称为“特定类型的任意对象的实例的引用”。图 14.12展示了代码中的示例:

图 14.12 – 未绑定方法引用示例

图 14.12 – 未绑定方法引用示例

在这个图中,我们在第 48 行定义了一个 lambda 表达式。这个 lambda 表达式是Function<String, String>类型,这意味着功能方法是String apply(String)。因此,s是一个String,我们可以调用String方法toUpperCase()。请注意,s不是方法作用域中的变量。在图 4.11中,我们绑定到了方法中声明的name变量。然而,现在s只有 lambda 表达式的范围。这意味着方法引用是未绑定的。lambda 参数s将在运行时(当apply()方法被调用时)绑定,就像第 51 行所示。

由于 lambda 表达式只有一个方法调用,并且在->符号的两侧都有s的冗余,我们可以使用方法引用。第 49 行表示第 48 行的 lambda 表达式的引用版本。注意方法引用操作符::的使用以及方法名toLowerCase后面的括号()的缺失。由于toLowerCaseString方法,所以在方法引用中String位于::操作符之前。第 49 行的方法引用在语义上等同于第 48 行的 lambda 表达式。

第 57 行声明了一个BiFunction lambda。回想一下,BiFunction接受两个输入并返回一个结果。在这种情况下,所有都是String类型。传入的参数将被连接并返回。再次强调,我们在 lambda 表达式中只有一个方法调用和变量的冗余,所以我们可以编写一个方法引用。

第 58 行表示第 57 行的 lambda 表达式的引用版本。再次强调,上下文将是确定方法引用的关键。BiFunction<String, String, String>String::concat通知编译器这是一个未绑定方法引用,它将接受两个String参数并将它们连接起来。

这里隐含的另一条信息是 – 在apply()方法调用中提供的第一个参数是要用于concat()方法的实例;第二个参数是要传递给concat()方法作为参数。这意味着如下:

concatMR.apply("Orange", " Juice");

这可以转换为以下内容:

"Orange ".concat("Juice");

这可以在第 62 行和第 63 行中看到。第 63 行的方法引用执行转换为第 62 行注释中的代码。lambda 和方法的引用调用(分别在第 59 行和第 63 行)都返回"Sean Kennedy"

现在,让我们来探讨静态方法引用。

静态方法引用

一个static方法引用也被认为是未绑定的,因为我们没有绑定到外部作用域的变量。被调用的方法是static的,因此得名。让我们通过代码来检查一个static方法引用。图 14.13展示了这样的一个示例:

图 14.13 – 静态方法引用示例

图 14.13 – 静态方法引用示例

在这个图中,我们定义了一个Consumer lambda(第 110 行),它接受一个List<Integer>列表。正如我们所知,Consumer接受一个参数而不返回任何内容。副作用是调用staticCollections方法sort,传递要排序的列表。由于我们的 lambda 只有一个方法调用,并且存在冗余(->标记两边的list),我们可以将 lambda 重写得更简洁,作为一个方法引用。

第 111 行是第 110 行编写的 lambda 表达式的引用版本。Collections.sort()方法被重载了——一个版本是sort(List),另一个版本是sort(List, Comparator)。上下文决定了编译器选择哪一个。由于Consumer lambda 的函数式方法是void accept(T t),它只接受一个参数,因此使用了一个参数的sort(),即sort(List)

第 113 行使用Arrays.asList()方法生成一个List<Integer>。第 114 和 115 行执行并输出 lambda 版本。

第 117 行再次使用Arrays.asList()方法重新生成一个List<Integer>。第 118 和 119 行执行并输出方法引用版本。

我们最后的方法引用类型是构造方法引用。现在让我们来讨论它们。

构造方法引用

构造方法引用是一种特殊的方法引用类型,因为它不是调用一个(常规)方法,而是使用new关键字并实例化一个对象。供应商是构造方法引用的天然选择。图 14.14展示了代码中的示例:

图 14.14 – 构造方法引用示例

图 14.14 – 构造方法引用示例

在这个图中,第 75 行定义了一个Supplier<StringBuilder> lambda。Supplier lambda 的函数式方法是T get(),所以我们什么也没有传递。由于我们为StringBuilder输入了sbL,lambda 代码是new StringBuilder()。由于 lambda 中只有一个方法调用,我们可以编写一个方法引用版本。

第 76 行的方法引用是第 75 行定义的 lambda 表达式的构造方法引用等价物。注意在语法中::操作符后面的new关键字的使用。

第 77 和 78 行分别调用了 lambda 和方法引用。此外,创建的StringBuilder对象被填充并输出。

如前所述,Supplier是构造方法引用的完美匹配。但如果你想要传递一个参数怎么办?Supplier不接受参数(T get())。我们需要一个接受参数并返回结果的函数式接口。Function在这个用例中会很合适。

图 14.14中的第二个例子展示了基于Function的构造方法引用。ArrayList构造函数是重载的——其中一个版本接受int类型,用于指定初始容量。

第 84 行定义了一个基于Function的 lambda,它接受Integer类型并返回一个List<String>列表。lambda 接受一个Integer类型的x,并构造一个初始容量为xArrayListx的值将从 lambda 调用中获取(例如,第 86 行的 100)。

由于 lambda 中只有一个方法调用,并且x->符号的两侧重复(冗余),我们可以编写一个等效的方法引用。

第 85 行是第 84 行编写的 lambda 表达式的等效方法引用。ArrayList被指定以表明我们想要返回哪种List的实现。::new语法是构造方法引用特有的。第 89 行显示了方法引用的执行方式——在这个例子中,传递 200 调用apply()方法。

这就结束了我们对四种不同类型的方法引用的讨论。然而,在我们离开方法引用之前,我们想讨论一个例子,说明在尝试理解方法引用时上下文是多么重要。

方法引用和上下文

这个例子将展示三个 lambda 及其对应的方法引用。图 14.15显示了代码示例:

图 14.15 – 方法引用和上下文

图 14.15 – 方法引用和上下文

在这个图中,第 7-11 行定义了一个名为Person的类。第 8 行定义了一个statichowMany()方法,它返回Person数组中的对象数量。回想一下,varargs表示,在方法内部,它被当作数组处理(因此有length属性)。由于people参数是一个varargs参数,我们可以用 0 个或多个参数调用howMany()

第一个场景是没有任何Person对象调用howMany()并返回传递的对象计数,这将是一个 0。Supplier非常适合,因为我们不会向 lambda 传递任何东西,但会得到一个Integer结果。第 15 行是这个场景的 lambda。我们不传递任何东西,返回一个Integer计数,这是传递给howMany()Person对象的数量。当然,这是0

第 16 行是第 15 行 lambda 表达式的等效方法引用。我们很快就会回到这个话题进行讨论。

第二种情况是使用一个 Person 对象调用 howMany() 并返回传递的对象数量,这将会是 1Function 适配得很好,因为我们将会向 lambda 函数传递一个 Person 对象并接收 Integer 类型的计数。第 21 行是这个场景的 lambda 表达式。我们接受一个 Person 对象并返回一个 Integer,代表传递给 howMany()Person 对象数量。这是 1

第 22 行是第 21 行 lambda 表达式的等价方法引用。我们很快会回到这个话题进行讨论。

第三种情况是使用两个 Person 对象调用 howMany() 并返回传递的对象数量,这将会是 2BiFunction 适配得很好,因为我们将会向 lambda 函数传递两个 Person 对象并接收 Integer 类型的计数。第 27 行是这个场景的 lambda 表达式。我们接受两个 Person 对象并返回一个 Integer,代表传递给 howMany()Person 对象数量。这是 2

第 28 行是第 27 行 lambda 表达式的等价方法引用。

现在,让我们讨论方法引用(第 16、22 和 28 行)。注意它们都是相同的!再次强调,这是上下文至关重要的地方。编译器可以根据指定的功能接口和泛型类型生成相关的 lambda 表达式。以下是一个示例:

Supplier<Integer> mr1 = Person::howMany;

首先,由于 howMany()Person 类中的 static 方法,编译器知道 lambda 将会是 Person.howMany()。但是应该传递多少个对象呢?由于它是一个 Supplier 接口,其功能方法是 T get(),编译器知道不会有参数输入,因此它知道不需要向 howMany() 传递任何内容。至于返回值,Supplier 被指定为 Integer 类型,这与 howMany() 方法的返回类型相匹配。

如果我们想向 howMany() 函数传递一个对象,让我们检查第二个方法引用:

Function<Person, Integer> mr2     = Person::howMany;

这里的一个区别是我们声明了一个 Function 而不是之前的 SupplierFunction 接受一个参数并返回一个结果。我们知道返回类型必须是 Integer,因为那是 howMany() 方法的返回类型。所以,编译器在这里所做的就是接收输入并将其传递给 howMany() 方法。等价的 lambda(第 21 行)显示了背后的操作。

最后,如果我们想向 howMany() 函数传递两个对象,会怎样呢?最后一个方法引用示例展示了如何做到这一点:

BiFunction<Person, Person, Integer> mr3     =  Person::howMany;

编译器看到 BiFunction 并意识到 BiFunction 需要两个输入,因此它将两个输入传递给 howMany()。当然,这个特定的 BiFunction 返回类型 IntegerhowMany() 方法的返回类型相匹配。

因此,由于有三个不同的上下文,我们有了三个等价的方法引用映射到三个不同的 lambda 表达式。方法引用可能会有些棘手。检查上下文,并在可能的情况下,将方法引用映射到其等价的 lambda 表达式。一旦转换为 lambda 形式,就更容易理解了。

这完成了我们对方法引用的讨论,并结束了第十四章。现在,让我们将所学知识付诸实践,以巩固我们学到的概念。

练习

  1. 恐龙养护任务通常非常相似,但并不完全相同。为了使我们的代码更简洁,我们可以使用 Lambda 表达式。创建一个名为 DinosaurHandler 的自定义函数式接口,其中有一个名为 handle(Dinosaur dinosaur) 的方法。在 Lambda 表达式中实现它,该表达式将恐龙设置为睡眠或清醒(首先,如果需要,请向您的 Dinosaur 类添加一个属性)。

  2. Lambda 表达式与 java.util.function 接口结合使用非常有效。让我们使用它们来管理恐龙:

    • 编写一个检查恐龙是否为肉食性的 Predicate<Dinosaur> Lambda 表达式

    • 编写一个返回新恐龙的 Supplier<Dinosaur> Lambda 表达式

    • 编写一个打印恐龙名称的 Consumer<Dinosaur> Lambda 表达式

    • 编写一个返回恐龙饮食的 Function<Dinosaur, String> Lambda 表达式

  3. Lambda 表达式在变量使用方面有特定的规则。我们将创建一个 Lambda 表达式的示例,该表达式修改一个“有效最终”变量。添加一个跟踪恐龙数量的变量,并创建一个 Lambda 表达式来增加这个计数。

  4. 方法引用可以使我们的代码更易读。请编写在您的公园环境中使用方法引用的示例:

    • 使用 System.out::println 打印恐龙名称。

    • 使用 Dinosaur::getName(假设 Dinosaur 类有一个 getName() 方法)来获取每个恐龙的名称。

    • 使用 Collections::sort 对恐龙名称列表进行排序。

    • 使用 Dinosaur::new 创建一个新的恐龙(假设 Dinosaur 类有一个合适的构造函数)

项目 - 灵活的恐龙养护系统

我们的公园正在发展,需要完成的工作也在增加。Lambda 表达式可以简化我们的代码并提高操作效率。让我们将它们集成到我们的系统中!

将 Lambda 表达式纳入您的“恐龙养护系统”,用于排序、过滤和对恐龙集合执行操作。此外,设计一个使用方法引用的通知系统,以提醒公园工作人员各种事件,增强我们公园内的沟通和响应能力。

这里是步骤。我们假设某些方法存在。您必须根据您的 Dinosaur 类的设计创建这些方法:

  1. 定义具有 namespecieshealthStatus 等属性的 Dinosaur 类。您还希望有一个 DinosaurCareSystem 类,其中实现了处理恐龙的主要功能。

  2. 您想要按名称对 Dinosaur 对象进行排序。使用 List 接口的 sort 方法和一个 Lambda 表达式。以下是一个示例:dinosaurs.sort((d1, d2) -> d1.getName().compareTo(d2.getName()))

  3. List<Dinosaur> illDinosaurs = dinosaurs.stream().filter(d -> d.isIll()).collect(Collectors.toList())

  4. DinosaurCareSystem类中,创建一个名为sendNotification(String message)的方法。然后在另一个方法中,例如检查恐龙健康状况时,每次发现恐龙生病,就使用方法引用调用sendNotification。代码可能看起来像这样:dinosaurs.stream().filter(Dinosaur::isIll).forEach(d -> sendNotification(d.getName() + " is ill.")).

  5. healthboosting程序。使用 lambda 表达式,你可以在列表上直接这样做:dinosaurs.forEach(d -> d.increaseHealth(10)).

概述

在本章中,我们了解到 lambda 表达式可以使你的代码更加简洁。我们看到了功能接口是一个只有一个abstract方法的面接口。lambda 表达式是实现功能接口的类,除了最基本的部分之外,其他都实现了。

术语final和“effectively final”指的是在 lambda 表达式中使用的局部变量。任何被 lambda 使用的非final局部变量都不应改变其值,无论是在方法中还是在 lambda 表达式中本身。编译器强制执行此规则,从而使局部变量“实际上”是 final 的。这是为了确保方法对局部变量值的看法与 lambda 对局部变量值的看法一致。这不适用于实例或static变量,或者不在 lambda 内部使用的局部变量。

我们从 API 深入研究了功能接口。我们研究了断言(测试条件),例如Predicate<T>BiPredicate<T, U>,以及它们的原始消耗对应物,DoublePredicateIntPredicateLongPredicate

我们还研究了Supplier<T>(提供某些东西)及其原始消耗的特化,分别是BooleanSupplierDoubleSupplierIntSupplierLongSupplier

我们探讨了消费者(只接收但不返回),Consumer<T>BiConsumer<T, U>,以及它们的原始消耗特化,DoubleConsumerIntConsumerLongConsumer

我们还研究了函数(既接收又返回),Function<T, R>BiFunction<T, U, R>,以及它们的原始消耗对应物。

最后,我们研究了函数的变体。UnaryOperator<T>Function的一种变体,其中输入和输出类型都是相同的。同样,BinaryOperator<T>BiFunction的一种变体,其中两个输入类型和输出类型都是相同的。

为了使你的代码更加简洁,在某些情况下,你可以使用方法引用而不是 lambda 表达式。如果你的 lambda 只是调用一个方法,并且有关参数存在冗余,则可以编写方法引用。

方法引用有四种不同类型:绑定、未绑定、静态和构造器。绑定方法引用绑定到方法中的现有变量,该变量位于 lambda 的作用域之外。未绑定方法引用依赖于在运行时传递的实例。静态方法引用也被视为未绑定,并执行一个 static 方法。最后,构造器方法引用使用 ::``new 语法创建对象。

我们还探讨了在理解方法引用时上下文的重要性。我们看到了一个例子,由于三个不同的上下文,同一个方法引用在后台生成了三个不同的 lambda 表达式。

这完成了我们对 lambda 表达式的讨论。随着我们继续学习与 Stream 相关的下一章,它们将变得非常重要。

第十五章:流 - 基础知识

第十四章中,我们学习了 lambda 表达式。Lambda 表达式使我们能够编写更简洁的代码。然而,请注意,编译器在后台正在插入我们省略的代码。为了使其工作,编译器必须没有决策要做。这就是“函数式接口”发挥作用的地方。函数式接口是一个只有一个abstract方法的接口;这被称为“函数方法”。Lambda 表达式只能与函数式接口一起使用。

我们看到,如果一个局部变量在 lambda 表达式中被使用,那么这个变量必须是final或“实际上是 final”的。这保持了变量值(方法和 lambda)的两个视图的一致性。换句话说,方法和 lambda 在所有时间对变量的值都是相同的。

我们还检查了 API 中更流行的函数式接口,包括PredicateBiPredicateSupplierConsumerBiConsumerFunctionBiFunction。API 中还有许多其他函数式接口,包括针对原始数据类型(而不是对象)的变体。

接下来,我们讨论了方法引用,这可以使你的代码比 lambda 表达式更加简洁。方法引用是 lambda 表达式的简写。为了从方法引用生成 lambda 表达式,上下文是关键。上下文包括声明的函数式接口和使用的泛型类型。

我们还探讨了四种方法引用类型:绑定、非绑定、静态和构造器。绑定方法引用在编译时绑定到方法中的一个变量,而非绑定方法则依赖于在运行时传入的对象。静态方法引用是非绑定的,并调用一个static方法。构造器方法引用使用::new语法来创建对象。

我们通过讨论一个在三个不同上下文中使用相同方法引用的例子来结束本章。由于上下文的不同,每个方法引用都导致了不同的 lambda。这证明了在检查方法引用时上下文的重要性。

在本章中,我们将开始对流的介绍。这是一个庞大且重要的主题,需要两章来涵盖。Java 8 引入了 lambda 和流,以实现更函数式的编程风格。这可以导致代码更加清晰、更具表现力,因为我们不必纠结于如何做某事;我们只需表达我们想要它完成。

我们将首先讨论流管道。然后,我们将讨论流的“惰性”,接着展示创建流的方法。最后,我们将借助代码示例来检查终端操作。

本章涵盖了以下主要主题:

  • 理解流管道

  • 探索流的惰性

  • 创建流

  • 精通终端操作

技术要求

本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch15

理解流管道

Java 中的是一系列可以由操作处理的数据。流不是另一种组织数据的方式,例如使用数组或Collection,因为流不持有数据。流全部关于高效处理流动的数据。

让我们看看流管道。

流管道

流管道是一组在流上运行的运算,以产生结果。至少,流管道由一个源、零个或多个中间操作和一个终端操作组成,顺序如下。管道类似于工厂中的装配线。让我们看一个例子。

装配线类比

假设我们有一个任务,需要削和盖章目前放在盒子里的铅笔(盒子里有 100 支铅笔)。盖章意味着在铅笔上标记铅笔类型,例如 2B、2H 等等。铅笔必须按照顺序削尖、盖章,最后打包。声明一个流就是向主管下达指令。在这个装配线上,Java 是主管。没有人会做任何事情,直到主管喊“开始”。主管检查指令,并设置工作站和工人——一个从盒子里拿铅笔,一个削铅笔,一个盖章削好的铅笔,一个打包完成的铅笔。

从盒子里拿铅笔的工人是管道的。铅笔是数据。削铅笔和盖章是中间操作。最后一个操作,将铅笔打包,是终端操作。终端操作非常重要,因为主管不会在看到终端操作之前喊“开始”。然而,一旦看到,主管会喊“开始”,然后流程就会开始。

让我们检查这个过程。

第一个工人从盒子里拿出铅笔,交给第二个工人,第二个工人将其削尖。第二个工人将削好的铅笔交给下一个工人,下一个工人盖章,然后交给装配线上的最后一个工人,他将铅笔打包。

注意,铅笔(和数据)只能单向移动——一旦工人传递了铅笔,他们就不能再取回。从 Java 的角度来看,这使得流与数组或集合(你可以随时访问数据)不同。

此外,在流中还有一个“懒惰评估”的原则,我们必须在这里注意。我们将在下一节更详细地讨论懒惰评估,但就现在而言,请理解数据不是预先生成的;它只在实际需要时创建。这提高了性能,因为当你扩展要处理的数据量时。关于我们的装配线示例,这意味着第二支铅笔只有在需要时才会被检索。如果你只需要一支铅笔,为什么要多磨几支铅笔并盖章呢?主管拥有整体指令,会意识到这一点并确保第二支铅笔永远不会开始。

让我们回到我们的类比,在这个点上,我们已经有了一支铅笔被收好。假设我们只想磨两支铅笔并盖章。这需要一个新的工人在装配线上计数。主管会将这个新工人安排在盖章工人的后面。新工人的工作是数过(要打包)的铅笔,并在有两支铅笔通过时通知主管。主管随后指示第一个工人从盒子里取出第二支铅笔。这支铅笔被磨尖并盖章。新工人看到第二支铅笔通过并被打包,并通知主管这一事实。主管让最后一个工人完成第二支铅笔的打包,然后喊道“停止。”因此,其他 98 支铅笔从未从盒子里取出,因为它们并不需要。这是一种懒惰评估。

现在,让我们讨论构成流管道的内容。

流管道的元素

流管道由以下内容组成:

  • varargs

  • 中间操作:它们将流转换成另一个流。我们可以有任意多或任意少(零个或多个)。由于懒惰评估,它们不会运行,直到终端操作运行。

  • 终端操作:这是启动整个过程并产生结果所必需的。流只能使用一次——在终端操作完成后,流就不再可用(如果需要,重新生成流)。

让我们用一个例子来讨论管道。图 15.1展示了样本管道:

图 15.1 – 样本管道

图 15.1 – 样本管道

var关键字

var关键字被称为temps是一个List<Double>

上一图输出的结果如下:

98.4100.2
100.2
87.9
102.8
102.8
2

在这个图中,我们正在计算温度> 100的数量。由于流不保存数据,管道指定了我们想要如何操作源。我们首先做的事情是创建一个由temps表示的List<Double>列表:

var temps = Arrays.asList(98.4, 100.2, 87.9, 102.8);

然后,我们流式传输列表——换句话说,列表是我们的源:

temps.stream()

接下来,我们使用peek(Consumer)中间操作,这对于调试管道以及展示管道中的数据位置非常有用:

.peek(System.out::println)

在这一点上,我们想要过滤出大于 100 的温度。换句话说,只有大于100的温度才能通过过滤器:

.filter(temp -> temp > 100)

现在我们有一个大于 100 的温度,我们再次使用peek(Consumer)来确保我们的过滤器工作正常:

.peek(System.out::println)

最后,我们有终端操作count(),它启动了整个过程:

.count();

让我们讨论一下流处理过程是如何工作的。首先,98.4被流处理。由于98.4没有通过过滤器,它被从流中移除。接下来,100.2被流处理;它通过了过滤器,Java 将计数设置为 1。然后,下一个值87.9被流处理,但它没有通过过滤器。最后,102.8被流处理,它也通过了过滤器,因此计数增加到 2。因此,大于100的温度计数是 2(100.2102.8)。注意值从流中出来的顺序展示了流的惰性。

我们将在适当的时候讨论这个例子中的各种操作。目前,我们想更详细地介绍流惰性。

探索流惰性

惰性求值的原则是,你只在你需要的时候得到你需要的东西。例如,如果像亚马逊这样的购物网站要向用户显示 10,000 条记录,那么惰性求值的原则就是先检索前 50 条,当用户查看这些记录时,在后台检索下一条 50 条。急切求值就是一次性检索所有 10,000 条记录。对于流来说,这意味着直到终端操作被调用之前,不会发生任何事情。

管道指定了我们想要在源上执行的操作以及它们的顺序。由于直到终端操作运行之前不会发生任何事情,Java 知道整个管道。这使得 Java 能够在可能的情况下引入效率。例如,为什么要在不需要操作的数据上运行操作呢?这可能会出现在以下情况下:

  • 我们已经找到了我们正在寻找的数据项

  • 我们可能设置了一个元素数量的限制(就像铅笔的类比一样)

让我们考察一个示例,其中处理源中元素顺序展示了惰性求值。图 15.2Laziness.java)展示了这一点:

图 15.2 – 惰性求值 – 流管道示例

图 15.2 – 惰性求值 – 流管道示例

图中的算法获取以'B'或'C'开头且长度超过 3 个字符的第一个名字。在这个例子中,我们最初创建了一个名为namesList<String>

   List<String> names = Arrays.asList("April", "Ben",             "Charlie","David", "Benildus", "Christian");

由于 Java 在遇到终端操作之前不会进行任何流操作;在这个例子中,直到forEach(Consumer)操作(第 31 行)发生之前,没有任何事情发生。这意味着在开始时,names.stream()代码仅仅创建了一个对象,当流开始时,这个对象知道数据在哪里。

在这个管道中,我们首先使用peek(Consumer)中间操作输出当前字符串,该字符串代表使用的人名:

.peek(System.out::println)

接下来,我们使用filter(Predicate)中间操作来过滤出以“B”或“C”开头的名字:

.filter(s -> {    System.out.println("filter1 : "+s);
    return s.startsWith("B") || s.startsWith("C"); } )

紧接着,我们过滤出长度超过三个字符的名字:

.filter(s -> {    System.out.println("filter2 : "+s);
    return s.length() > 3; } )

之后,我们使用limit(long)中间操作来跟踪通过第二个过滤器的名字数量:

.limit(1)

在这个例子中,一旦一个名字通过,JVM 就会得到通知,并且不会从源中流式传输其他名字。最后,我们提供(必需的)终端操作:

.forEach(System.out::println);

图 15**.3 展示了来自 图 15**.2 代码的输出,这非常具有启发性:

图 15.3 – 图 15.2 的输出(右侧有注释)

图 15.3 – 图 15.2 的输出(右侧有注释)

第 35 行显示了第一个名字,April,从列表中流式传输。April通过了第一个过滤器并被移除(因为April不以“B”或“C”开头)。

第 37 行显示了下一个名字,Ben,正在流式传输。Ben通过了第一个过滤器并到达第二个过滤器。然而,由于Ben的长度只有 3 个字符,它被第二个过滤器移除。

第 40 行显示了下一个名字,Charlie,正在流式传输。Charlie通过了第一个过滤器(因为Charlie以“C”开头)并被传递到第二个过滤器。Charlie也通过了这个过滤器,因为Charlie的长度大于 3 个字符。因此,Charlie被传递到limit(long)中间操作,该操作指出这是第一个通过的名字。由于限制设置为 1,JVM 被通知。Charlie通过forEach(Consumer)终端操作打印出Charlie(第 43 行),并且流被关闭。

注意,其他名字 – DavidBenildusChristian – 都没有进行流式传输。这是一个小例子,但你可以想象当你处理数百万数据项时的效率。

我们现在将讨论如何创建流。

创建流

流可以从各种来源生成,无论是有限的还是无限的。例如,可以使用数组、集合、varargs和文件等作为来源。我们将依次检查这些来源。目前,我们将处理非原始类型;所有流都将串行(非并行)。在第第十六章中,我们将讨论原始流和并行流。

从数组中流式传输

我们将使用Stream<T> Arrays.stream(T[] array)来完成这个操作。这个static方法接受一个类型为T的数组,并返回Stream<T>图 15**.4 (CreatingStreams.java) 展示了一个示例:

图 15.4 – 流式传输数组

图 15.4 – 流式传输数组

在这个图中,我们声明了一个Double数组(注意这不是一个原始的double数组)。流对象是通过调用Arrays.stream(T[] array)方法创建的。我们使用终端操作count()开始流。最后,我们输出数组中的元素数量。请注意,这只是一个示例,而且有更直接的方法(使用length属性)来输出数组中的元素数量。

让我们看看如何从集合中流式传输。

流式传输集合

通过“集合”,我们指的是Collection接口层次结构。Collection接口有一个default Stream<E> stream()方法。图 15.5 (CreatingStreams.java)展示了从集合生成流的代码:

图 15.5 – 流式传输集合

图 15.5 – 流式传输集合

在这个图中,我们最初使用Arrays.asList(T… a)创建了一个List<String>

List<String> animalList = Arrays.asList("cat", "dog", "sheep");

然后,我们使用Collectionstream()方法创建流对象:

Stream<String> streamAnimals = animalList.stream();

要开始流,我们使用终端操作count()

System.out.println("Number of elements: "+streamAnimals.count()); // 3

如果你有Map并想流式传输它怎么办?记住,Map不是一个Collection,因为它没有实现它。这就是第二个示例所展示的。首先,让我们声明并填充这个映射:

Map<String, Integer> namesToAges = new HashMap<>();namesToAges.put("Mike", 22);
namesToAges.put("Mary", 24);
namesToAges.put("Alice", 31);

要从Map转换到Collection,我们将执行以下操作:

   namesToAges.entrySet()

Map中的entrySet()方法返回映射中条目的Set视图。由于SetCollection的子接口,Set“是”Collection。在这个点上,我们现在可以像平常一样流式传输集合:

.stream()

最后,我们使用终端操作count()开始过程,它返回3,表明流已成功工作。

现在,让我们看看Stream.of()方法。

Stream.of()

static <T> Stream<T> of(T… values)是一个非常有用的方法。虽然它的签名可能看起来有点令人困惑,但它非常容易使用。这是一个static方法,它具有泛型类型,因此是<T>。因此,编译器不会对签名中T的使用提出异议。它返回Stream<T>,而T取决于传入的内容。例如,如果你传入字符串,那么你将得到Stream<String>。参数是一个varargs列表,它非常灵活。

让我们看看一些示例。图 15.6 (BuildStreams.java)展示了代码:

图 15.6 – Stream.of()示例

图 15.6 – Stream.of()示例

在这个图中,我们最初声明了一个字符串数组:

String[] cities = {"Dublin", "Berlin", "Paris"};

使用Stream.of()方法,我们声明流源为数组:

Stream<String> citiesStream = Stream.of(cities);

声明后,我们使用count()终端操作开始流:

System.out.println(citiesStream.count()); // 3

接下来,Stream.of()从传入的整数varargs(作为Integer装箱)中获取流源:

Stream<Integer> streamI = Stream.of(1,2,3);

我们像以前一样开始流式传输过程,使用count()终端操作:

System.out.println(streamI.count()); // 3

之后,Stream.of()从字符串的varargs中获取流源并将它们流式传输:

Stream<String> streamS = Stream.of("a", "b", "c", "d");System.out.println(streamS.count()); // 4

最后,我们从Dogvarargs(只有一个)中获取流源并将它们流式传输:

Stream<Dog> streamD = Stream.of(new Dog());System.out.println(streamD.count()); // 1

现在,让我们看看如何从文件中流式传输数据。

从文件流式传输

要流式传输文件,我们可以使用 Files.lines() 方法。它的签名是 public static Stream<String> lines(Path path) throws IOException

Path 参数指的是我们想要处理的文件。这个文件需要被分隔;例如,使用正斜杠(/)字符。我们将使用的文件包含以下行:

Fido/BlackLily/White

返回的 Stream<String> 指的是文件的行,每行一个 String。我们可以使用在 Stream 接口中定义的 forEach(Consumer) 终端操作来处理返回的流。在消费者代码块内部,每行文件(一个 String)可以使用 String 类的 split() 方法解析为 String[] – 其中我们传递分隔符并返回一个包含元素的 String[]。一旦我们有了这个 String[],我们就可以轻松地创建我们的对象并将其添加到集合中,例如 ArrayList。这是一个 Consumer 副作用示例。

假设有一个 Cat 类,它有 namecolor 实例变量以及一个相关构造函数(ProcessFile.java),我们可以做以下操作:

try(Stream<String> stream =  Files.lines(Paths.get(filename))){
       stream.forEach(line -> {
           String[] catsArray = line.split("/");
           cats.add(new Cat(catsArray[0], catsArray[1]));
       });
  } catch (IOException ioe) {
            ioe.printStackTrace();
  }

forEach(Consumer)forEach(Consumer)

在 Java API 中,这两个版本的 forEach() 看起来非常相似,但实际上它们来自两个非常不同的层次结构。一个是 Iterable 接口(Collection 继承自它)中的 default 方法。另一个是 Stream 接口中的终端操作。

无限流

无限流可以很容易地使用 Stream 接口中的两个 static 方法创建,即 generate()iterate()。让我们逐一考察它们。

Stream generate(Supplier s)

根据 API,它“返回一个无限、顺序的无序流,其中每个元素都是由提供的 Supplier 生成的。”图 15.7 (InfiniteStreamsGenerate.java) 展示了一些我们可以讨论的代码:

图 15.7 – 使用 generate() 创建无限流

图 15.7 – 使用 generate() 创建无限流

如此图所示,提供的 Supplier 生成介于 0 和 9(包括 0 和 9)之间的随机数:

() -> (int) (Math.random() * 10);

我们使用 forEach(Consumer) 终端操作开始流处理过程:

infStream.forEach(System.out::println);

Consumer 接受一个方法引用以输出生成的数字。此流将持续进行,直到我们终止应用程序(例如,从 IDE 内部)。

Math.random()

回想一下,Math.random() 返回一个介于 0.0 <= x < 1.0double 类型。换句话说,一个介于 0 和小于 1 之间的数字。当我们把这个数字乘以 10,然后将其转换为 int 类型时,实际上是将它缩放到 0 <= x < 10

现在,让我们讨论生成无限流的另一种方法,即 iterate()

Stream iterate(T seed, UnaryOperator fn)

这种方法让你对生成的数字有更多的控制。第一个参数是种子,它是流中的第一个数字。第二个参数是一个UnaryOperator(一个输入和输出类型相同的Function)。这个UnaryOperator函数是一个 lambda 表达式,它接受前一个值并生成下一个值。图 15**.8 (InfiniteStreamsIterate.java)展示了我们进一步讨论这个问题的代码示例:

图 15.8 – 使用 iterate()创建无限流

图 15.8 – 使用 iterate()创建无限流

如此图所示,种子是2,lambda 表达式生成2之后的下一个偶数,依此类推。因此,这个流生成2468,等等,直到我们终止应用程序。

如果我们只想得到这么多的数字呢?例如,如果我们只想得到20(从2开始)以内的偶数呢?iterate()有一个重载版本可以满足这个需求——它的第二个参数是一个Predicate,它指定何时结束。图 15**.9 (InfiniteStreamsIterate.java)展示了这个示例:

图 15.9 – 使用 iterate()和 Predicate 创建无限/有限流

图 15.9 – 使用 iterate()和 Predicate 创建无限/有限流

Predicate条件:

n -> n <=20

这一行在这里很重要,它指定了流何时停止。因此,这是从无限流创建有限流的一种方法。如果Predicate条件持续返回true,流将不断生成数字,直到你终止应用程序。

在这个图中,2468101214161820都通过了Predicate条件并被输出。一旦生成22并且Predicate失败,流就会停止。

将无限流转换为有限流的另一种方法是使用limit()中间操作。图 15**.10 (InfiniteStreamsIterate.java)展示了这种情况:

图 15.10 – 使用 limit()创建有限流

图 15.10 – 使用 limit()创建有限流

在这个图中,我们通过使用limit()中间操作将生成的数字限制为 10。我们将在第十六章中讨论中间操作。在这个例子中,一旦第 10 个数字通过,limit()就会通知 JVM 这一事实,并且不再生成更多的数字。

现在我们已经知道了如何创建流,让我们来检查终端操作。

掌握终端操作

正如我们之前讨论的,直到终端操作执行之前不会发生流操作。这给了 JVM 一个流管道的整体视图,从而使得在后台引入效率成为可能。

终端操作可以在没有任何中间操作的情况下执行,但不能反过来。归约是终端操作的一种特殊类型,其中流的所有内容都被组合成一个单一的原语或Object(例如,一个Collection)。

表 15.1 代表我们将在本节中讨论的终端操作:

表 15.1 – 终端操作

表 15.1 – 终端操作

在我们依次讨论它们之前,先简要讨论一下表格。记住,归约必须查看流中的所有元素,然后返回一个原始类型或Object

一些终端操作,如allMatch(Predicate),可能不会查看流中的所有元素。例如,假设我们有以下代码:

List<String> names = Arrays.asList("Alan","Brian","Colin");Predicate<String> pred = name -> name.startsWith("A");
System.out.println(names.stream().allMatch(pred)); // false

Predicate条件在"Brian"上失败,因为它不以"A"开头,因此allMatch()返回false。因此,"Colin"永远不会被检查,因此allMatch()不是归约操作。

我们将在稍后讨论Optional,但到目前为止,Optional是在 Java 8 中引入的,以替换null返回值(从而有助于减少NullPointerException的数量)。如果流为空,则返回一个空的Optional(而不是null)。因此,Optional对象要么具有非null值,要么为空。导致空流的一种方式是在调用终端操作之前过滤掉其所有元素。

让我们依次处理这些操作。

count()

我们已经遇到了count(),因此已经在 15**.11 (TerminalOperations.java)中提供了一个快速示例:

图 15.11 – 代码中的 count()

图 15.11 – 代码中的 count()

count()方法与有限流一起工作,因为它永远不会为无限流终止。在这个例子中,两个字符串"dog""cat"被流式传输,并返回计数2。请注意,count()是一个归约操作,因为它查看流中的每个元素,并返回一个单一值。

min()和 max()

count()一样,min()max()都与有限流一起工作,并挂起无限流(以防可能还有其他值是最小值或最大值)。两者都是归约操作,因为它们在处理整个流之后返回一个单一值。鉴于流可能为空,Optional是返回类型。

15**.12 (TerminalOperations.java)展示了使用min()max()的一些代码:

图 15.12 – 代码中的 min()和 max()

图 15.12 – 代码中的 min()和 max()

在这个例子中,我们最初定义了一个自定义的Comparator,该Comparator将字符串列表按字符串长度升序排序。然后,这个Comparator被传递到min()方法中,其中"pig"被返回到Optional

.min((s1, s2) -> s1.length()-s2.length())

我们随后使用函数式风格的Optional方法ifPresent(),以确定Optional中是否存在非null值。由于"pig"存在(存在),因此输出如下:

min.ifPresent(System.out::println);// pig

接下来是一个不同的自定义Comparator,它将数字列表按升序排序。然后将其传递到max()方法中,其中12存储在Optional变量max中:

.max((i1, i2) -> i1-i2)

再次,我们使用ifPresent()方法来确定max中是否存在非null值。由于12存在,它被输出:

max.ifPresent(System.out::println);// 12

最后,我们演示了你可以使用Stream.empty()来创建一个空流:

Optional<Object> noMin = Stream.empty().min((x1, x2) -> 0)

在这个例子中,由于流为空,比较器(x1, x2) -> 0从未被调用,因此Optional中没有值。因此,isEmpty()返回trueisPresent()返回false

System.out.println(noMin.isEmpty());// trueSystem.out.println(noMin.isPresent());// false

findAny()和 findFirst()

这些终端操作不是归约操作,因为它们不处理整个流。正如其名称所暗示的,findAny()将返回任何元素——通常返回第一个元素,但这并不保证。另一方面,findFirst()正是这样做的——它返回第一个元素。不出所料,这些方法可以与无限流一起工作(因为它们不处理整个流)。在两种情况下都返回一个Optional(因为当它们被调用时流可能为空)。

定义一个短路终端操作为一个操作,当遇到无限输入时,可能在有限时间内终止。鉴于这些操作可以在处理完整个流之前返回,它们被认为是短路操作。

图 15**.13 (TerminalOperationsFindAnyFindFirst.java) 展示了它们在代码中的使用:

图 15.13 – 代码中的 findAny()和 findFirst()

图 15.13 – 代码中的 findAny()和 findFirst()

在这个图中,我们对字符串流"John""Paul"执行findAny()操作。这通常返回"John",但不保证;而当我们对同一流执行findFirst()时,总是返回"John"。正如这个示例所展示的,这两个操作都没有处理"Paul",因此它们不被认为是归约操作。

anyMatch(), allMatch, and noneMatch()

这三个终端操作都接受一个谓词条件并返回一个布尔值。就像"find"方法一样,它们也不是归约操作,因为它们可能不会查看所有的元素。根据数据的不同,这些操作在遇到无限流时可能终止也可能不终止。话虽如此,由于它们可能终止,它们被认为是短路操作。图 15**.14 (TerminalOperations.java) 展示了一个示例:

图 15.14 – 代码中的 anyMatch(), allMatch()和 noneMatch()

图 15.14 – 代码中的 anyMatch(), allMatch(), and noneMatch()

在这个图中,我们定义了一个包含String名称的有限流,如下所示:

List<String> names = Arrays.asList("Alan", "Brian", "Colin");

定义一个谓词来检查一个名称是否以"A"开头:

Predicate<String> pred = name -> name.startsWith("A");

然后,我们流式传输(源)名称列表,并使用anyMatch()检查是否有名称以"A"开头——由于"Alan"是,返回true

names.stream().anyMatch(pred); // true ("Alan")

接下来,我们重新流式传输列表并使用allMatch()检查所有名称是否以"A"开头——由于"Brian"不是,返回false

names.stream().allMatch(pred); // false ("Brian")

然后,我们重新流式传输列表并使用 noneMatch() 检查是否有任何名字以 "A" 开头——因为 "Alan""A" 开头,所以返回 false

names.stream().noneMatch(pred);// false ("Alan")

注意,我们必须重新流式传输源两次(对于 allMatch()noneMatch())。这是因为一旦执行了终端操作,流就被认为是消耗过的,不能再使用。如果你需要相同的数据,那么你必须回到源并获取一个新的流。这正是我们在这里所做的事情。在已关闭的源上尝试操作会生成一个 IllegalStateException 错误。

让我们更深入地探讨这些操作在处理无限数据时的短路特性。以下示例(图 15**.15)展示了代码(TerminalOperations.java),其中每个操作在给定的无限流中可能终止或不终止。它们是否终止取决于数据(以及与该数据测试的谓词):

图 15.15 – 代码中 anyMatch()、allMatch()和 noneMatch()的短路特性

图 15.15 – 代码中 anyMatch()、allMatch()和 noneMatch()的短路特性

在这个图中,我们生成了一串无限的 "abc" 字符串,并定义了两个谓词;一个检查字符串是否以 "a" 开头,另一个检查字符串是否以 "b" 开头。请注意,正如之前解释的那样,在使用之前必须重新打开关闭的流。因此,第 137-144 行是互斥的——一次只能使用其中之一。我们保留了所有未注释的行,因为这有助于图表的清晰度。当我们运行代码时,我们必须注释掉六行中的五行。

infStr.anyMatch(startsWithA) 检查是否有任何字符串以 "a" 开头——因为第一个是以 "a" 开头的,所以它短路并返回 true

infStr.anyMatch(startsWithB) 检查是否有任何字符串以 "b" 开头——第一个不是,所以它检查下一个;它也不是,以此类推。在这种情况下,我们不得不终止程序。

infStr.noneMatch(startsWithA) 检查是否有任何字符串以 "a" 开头——因为 "abc""a" 开头,noneMatch() 短路并返回 false

infStr.noneMatch(startsWithB) 检查是否有任何字符串以 "b" 开头——第一个不是,所以它检查下一个;它也不是,以此类推。这会一直进行下去,所以我们必须终止程序。那么,noneMatch() 何时返回 true?如果你有一个有限的流,其中没有任何元素匹配给定的谓词。

infStr.allMatch(startsWithA) 检查所有字符串是否以 "a" 开头。在这种情况下,这会一直进行下去,因为我们不断生成以 "a" 开头的字符串,确保 allMatch() 需要检查下一个,以此类推。

infStr.allMatch(startsWithB) 可以短路,因为 "abc" 不是以 "b" 开头,这使得 allMatch() 能够返回 false

forEach()

由于 forEach(Consumer) 没有返回值(返回 void),它不被视为一个减少操作。因为它不返回任何内容,所以你希望进行的任何更改都必须在 Consumer 中作为副作用发生。我们已经讨论了几个 forEach() 的例子,所以 图 15**.16(TerminalOperations.java)只显示了一个简单的例子:

图 15.16 – 代码中的 forEach()终端操作

图 15.16 – 代码中的 forEach()终端操作

在这个例子中,我们正在流式传输一个字符串列表,代表人们的名字,并将它们回显到屏幕上。

reduce()

reduce() 方法将流合并成一个单一的对象。因为它处理所有元素,所以它是一个减少操作。有三个重载版本。我们将依次通过示例来讨论它们。

T reduce(T identity, BinaryOperator accumulator)

这是最常见的减少方式 – 从一个初始值(初始值)开始,并持续将其与下一个值合并。除了初始值是初始值之外,如果流为空,它也是返回的值。这意味着总会有一个结果,因此 Optional 不是返回类型(在这个版本中)。

累加器将当前结果与流中的当前值结合。由于它是一个 BinaryOperator,这意味着它是一个两个输入和返回类型都是相同类型的函数。

图 15**.17 (TerminalOperations.java) 展示了一些例子以帮助解释这一点:

图 15.17 – T reduce(T identity, BinaryOperator acc) 代码中的操作

图 15.17 – T reduce(T identity, BinaryOperator acc) 代码中的操作

让我们来检查这个图中的第一次减少操作:

String name = Stream.of("s", "e", "a", "n")                    .reduce("", (s1, s2) -> s1 + s2);
System.out.println(name);// sean

这个减少操作将空字符串定义为初始值。这既是我们的起始字符串,也是如果流为空时返回的字符串。累加器接受两个字符串,即 s1s2。第一次循环时,s1""s2"s",结果是 "s"。下一次循环时,s1 是前一次运行的结果,即 "s"s2"e",结果是 "se"。之后,s1"se"s2"a",结果是 "sea"。最后,s1"sea"s2"n",结果是 "sean"。这就是累加器的工作方式。

第二次减少操作从重新流式传输源开始:

String name2 = Stream.of("s", "e", "a", "n")                .filter(s -> s.length()>2)
                .reduce("nothing", (s1, s2) -> s1 + s2);
System.out.println(name2);// nothing

然而,应用了一个过滤器中间操作。这个过滤器确保只有长度大于 2 的字符串被保留,导致 reduce() 的流为空。因此,reduce() 返回的 "``nothing" 是一个初始值。

最后的减少操作给出了另一个关于初始值和累加器在操作中的示例:

Integer product = Stream.of(2,3,4)                        .reduce(1, (n1, n2) -> n1 * n2);
System.out.println(product);// 24

值序列为 n11n22,结果是 2n12n23,结果是 6n16n24,结果是 24

Optional reduce(BinaryOperator accumulator)

这与第一个版本非常相似,只是没有提供恒等值。由于没有提供恒等值,Optional是返回类型(考虑到,在调用此方法之前,流可能为空)。有三种可能的返回值:

  1. 空的流——结果是一个空的Optional

  2. 流中的一个元素——返回该元素(在Optional中)

  3. 流中的多个元素——应用累加器

为什么会有两个如此相似的版本?为什么不只保留有恒等值的第一个版本呢?好吧,可能存在一种情况,尽管可能性很小,累加器返回的值与恒等值相同。在这种情况下,你将无法知道流是否为空(恒等值返回)或者不是(累加器应用)。这个第二个版本,通过使用Optional,确保你知道流是否为空。

现在,让我们来检查reduce()的第三个版本。

reduce(U identity, BiFunction accumulator, BinaryOperator combiner)

当我们处理不同类型且在最后合并中间减少的结果时,使用这个版本。在并行流中,这个版本非常有用,因为流可以被不同的线程分解和重新组装。图 15.18**.18 (TerminalOperations.java)展示了代码中的示例:

图 15.18 - U reduce(U identity, BiFunction accumulator, BinaryOperator combiner) 代码示例

图 15.18 - U reduce(U identity, BiFunction accumulator, BinaryOperator combiner) 代码示例

在这个例子中,我们正在流式传输一个字符串列表,我们想要计算所有字符串中的总字符数。reduce()方法的代码如下:

   stream.reduce( 0,  // identity              (n, str) -> n + str.length(), // n is Integer
              (n1, n2) -> n1 + n2); // both are Integers

并且有 3 个元素:

  • 0 是恒等值,它代表我们的起始值。

  • (n, str) -> n + str.length()BiFunction累加器。在这种情况下,第一个参数是Integer,第二个参数是String。返回类型与第一个参数匹配——换句话说,是Integer。我们没有在方法签名中突出显示这一点,因为所有字母有时可能会引起混淆。这个累加器将当前String的长度添加到当前总和中。

  • (n1, n2) -> n1 + n2代表合并器BinaryOperator(一个类型相同的函数)。它的 lambda 表达式简单地添加两个数字并返回总和。这个函数将累加器中的中间结果相加。

因此,使用并行流时,一个线程可以返回 6 的累积值,这是"car"和"bus"长度的总和,而另一个线程可以返回 14 的累积值,这是"train"和"aeroplane"长度的总和。这两个值随后由合并器合并,得到 20。

现在,我们将继续介绍一个强大的终端操作,即collect()

collect()

这是一种特殊的归约类型,称为可变归约,因为我们使用相同的可变对象进行累积。这使得它比常规归约更高效。常见的可变对象包括StringBuilderArrayList

这个操作对于获取数据MapListSet非常有用。

有两个版本 – 一个版本让你完全控制收集过程,另一个版本提供了 API 中的预定义收集器。我们将从第一个版本开始,你可以自己指定一切。

collect(Supplier, BiConsumer, BiConsumer)

这个方法最好通过一个代码示例来解释,请参阅图 15.19,它来自 repo 上的TerminalOperations.java

图 15.19 – 代码中的 collect(Supplier, BiConsumer, BiConsumer)操作

图 15.19 – 代码中的 collect(Supplier, BiConsumer, BiConsumer)操作

在这个图中,我们正在从一系列较小的单词构建一个长单词。请注意,等效的方法引用(用于 lambdas 的),位于每行的右侧注释中。

collect()的第一个参数是一个Supplier,它指定我们想要使用StringBuilder

() -> new StringBuilder()

累加器将当前的String添加到StringBuilder中:

(sb, str) -> sb.append(str)

组合器将两个StringBuilder合并:

(sb1, sb2) -> sb1.append(sb2)

在并行处理中,这很有用,因为不同的线程可以执行累积并将结果合并。在这个例子中,线程 1 可以返回"adjud",这是累积"ad""jud"的结果;线程 2 可以返回"icate",这是累积"i""cate"的结果。这两个结果合并成"adjudicate"

现在,让我们看看传递预定义 API 收集器的collect()版本。

collect(Collector)

这是接受预定义 API 收集器的版本。我们通过Collectors类中的static方法访问这些收集器。这些收集器本身不做任何事情 – 它们存在是为了传递给collect(Collector)方法。

我们将检查许多这样的方法,特别是那些帮助我们从流中提取数据到集合以供后续处理的方法。此外,我们还将探讨如何分组和分区信息。让我们从一些更基本的收集器开始。

Collectors.joining(CharSequence delimiter)

这个收集器返回一个Collector,它将输入元素连接起来,元素之间由指定的分隔符分隔。流顺序保持不变。图 15.20展示了示例(来自 repo 上的CollectorsExamples.java)。

图 15.20 – 代码中的 Collectors.joining()

图 15.20 - 代码中的 Collectors.joining()

在这个例子中,字符串被连接在一起,并用", "分隔。

Collectors.averagingInt(ToIntFunction)

这返回一个Collector,它产生由提供的函数生成的整数的平均值。图 15.21CollectorsExamples.java)展示了示例:

图 15.21 – 代码中的

图 15.21 – 代码中的Collectors.averagingInt(ToIntFunction)

在这个例子中,我们正在流式传输表示甜点的字符串。每个字符串都有一个长度,我们想要计算长度的平均值。函数s -> s.length()接收一个String,即s,并返回其整数长度。方法引用版本在右侧的注释中。然后输出平均值。

现在,让我们看看如何将流内容提取到ListSetMap中。我们将从List开始。

Collectors.toList()

这返回一个将元素累积到新ListCollector操作。List的类型没有保证。例如,不能保证ListArrayListLinkedList。为了获得这种程度的控制,必须使用toCollection(Supplier)方法(我们将在Set示例中使用它)。图 15.22展示了我们将用于下一个几个示例的Car类型(CollectorsExamples.java):

图 15.22 – 类

图 15.22 – Car

图 15.23展示了Collectors.toList()在代码中的示例(来自存储库中的CollectorsExamples.java):

图 15.23 – 代码中的

图 15.23 – 代码中的Collectors.toList()

在这个例子中,我们向我们的List中添加了三个Car对象。回想一下,List维护插入顺序。map(Function)方法是一个中间操作,它接收一个流并将其转换成另一个流。我们将在第十六章中更详细地讨论map()方法,但到目前为止,请认识到有Stream<Car>进入map(),并输出Stream<String>。这是因为Car中的brand是一个String。现在,我们有一个Stream<String>,可以用于collect()操作以提取成List格式。

如前所述,实现类型没有保证。如果我们想要一个特定的实现,并且不仅仅是这样的实现,而是一个在添加元素时对元素进行排序的实现呢?TreeSet会这样做。现在让我们看看这一点。

Collectors.toSet()Collectors.toCollection(Supplier)

Collectors.toSet()返回一个将元素累积到新SetCollectorSet的类型没有保证。然而,在这个例子中,我们想要一个特定的Set,即TreeSet。当我们想要一个特定的实现时,我们可以使用Collectors.toCollection(Supplier)图 15.24展示了代码(CollectorsExamples.java):

图 15.24 – 代码中的

图 15.24 – 代码中的Collectors.toCollection(Supplier)

在这个例子中,汽车被故意添加到我们的ArrayList中,品牌顺序未排序。下面的行是魔法发生的地方:

.collect(Collectors.toCollection(TreeSet::new));

我们传递一个Supplier方法引用来创建一个TreeSet,然后将其传递给Collectors.toCollection()方法。这导致了一个TreeSet实现。当我们输出treeSet时,我们得到:

[Audi, Ford, Tesla]

注意,品牌现在按字母顺序排序(字符串的默认排序顺序)。我们还可以从流中提取数据到Map中。现在让我们来考察一下。

Collectors.toMap(Function keyMapper, Function valueMapper)

这返回一个Collector,它将元素收集到一个Map中,其中键和值是应用提供的映射函数到流元素的结果。同样,没有返回Map类型的保证。图 15.25展示了代码中的例子(CollectorsExamples.java):

图 15.25 – 代码中的 Collectors.toMap(Function keyMapper, Function valueMapper)

图 15.25 – 代码中的 Collectors.toMap(Function keyMapper, Function valueMapper)

在这个例子中,我们正在流式传输一个甜点列表(作为字符串)。声明的Map表明我们的键是String类型,值是Integer类型。这是因为我们想要设置一个Map,使得甜点名称是键,甜点名称中的字符数是值。

Map中的键是通过以下Function设置的:

String::toString  // Function for key, same as: s -> s

回想一下,Function<T, R>接受一个类型为T的参数,并返回一个类型为R的结果。在这个例子中,我们的函数将是Function<String, String>,因为我们正在流式传输一个甜点(String),这是我们想要用作键的甜点。我们可以简单地使用 lambda 表达式s -> s或者使用String::toString方法引用。两种版本都可以工作。

Map 中的值是通过以下Function设置的:

String::length     // Same as: s -> s.length()

在这种情况下,我们的函数是Function<String, Integer>,因为我们希望函数返回甜点的长度。我们可以使用 lambda 表达式s -> s.length()或者String::length方法引用。

生成的输出是:

{biscuits=8, cake=4, apple tart=10}

在我们展示下一个版本之前,让我们看看一个会抛出异常的例子。图 15.26展示了代码中的例子(CollectorsExamples.java):

图 15.26 – Collectors.toMap(Function keyMapper, Function valueMapper)异常

图 15.26 – Collectors.toMap(Function keyMapper, Function valueMapper)异常

在这个图中,我们试图设置一个Map,其中键是甜点名称的长度,值是甜点名称本身。请注意,甜点名称与上一图略有不同。现在,我们不再是"apple tart",而是"tart"。这将导致问题。Map 不能有重复的键,而"cake""tart"都是 4 个字符长。这会导致IllegalStateException错误。

为了解决这个问题,我们需要使用toMap()的第二版。

Collectors.toMap(Function, Function, BinaryOperator mergeFunction)

此收集器的工作方式与之前的收集器类似,除了当我们遇到重复键时。在这种情况下,合并函数应用于 。合并函数是一个 BinaryOperator<T,>,它是一个 BiFunction<T,T,T>。换句话说,有两个输入和一个结果,它们都是同一类型。图 15**.27 展示了包含合并函数的代码(CollectorsExamples.java),用于处理重复键:

图 15.27 – Collectors.toMap(Function, Function, BinaryOperator)

图 15.27 – Collectors.toMap(Function, Function, BinaryOperator)

在这个例子中,唯一的区别是合并函数:

(s1, s2) -> s1 + "," + s2)

合并函数接收 s1s2,两个冲突键的值。在这个例子中,值之间用逗号分隔进行追加。

生成的输出是:

{4=cake,tart, 8=biscuits}

冲突键是 4,它们的值是 "cake""tart",结果为 "``4=cake, tart"

下一版本使我们能够指定我们想要的 Map 实现类型。

Collectors.toMap(Function, Function, BinaryOperator, Supplier mapFactory)

如我们所知,返回的 Map 实现没有保证。您可能会得到 HashMapTreeMap 实现之一。这个 toMap() 版本与上一个版本非常相似,只是多了一个额外的参数,我们可以指定我们的实现类型。图 15**.28 展示了使用构造方法引用确保 TreeMap 实现的代码(CollectorsExamples.java):

图 15.28 – Collectors.toMap(Function, Function, BinaryOperator, Supplier)

图 15.28 – Collectors.toMap(Function, Function, BinaryOperator, Supplier)

在这个图中,甜点名称是键,甜点名称的长度是值。"cake" 在源中出现了两次,导致重复键问题并调用合并函数。在这种情况下,重复键的值应该被添加。由于 "cake" 只出现两次,这意味着 "cake=8" 将在 Map 中。

在这个例子中,我们想要一个 TreeMap 实现类型。为了确保这一点,我们指定了一个额外的参数,以下 Supplier

   TreeMap::new

因此,我们的键将被排序。当我们输出我们的映射时,我们可以看到键是按字母顺序排序的,正如预期的那样:

   {apple tart=10, biscuits=8, cake=8}

此外,请注意 "cake" 映射到 8(4 + 4)。

我们还可以使用 getClass() 方法来证明我们确实有一个 TreeMap 实现:

   System.out.println(map.getClass());// java.util.TreeMap

现在,让我们来检查 groupingBy 终端操作。

Collectors.groupingBy(Function classifier)

groupingBy() 操作告诉 collect() 将所有元素分组到一个 Map 实现中。Function 参数确定 Map 中的键。值是一个 List(默认)中所有匹配该键的条目。将值作为 List 返回,如我们所见,可以更改。不保证使用的 MapList 实现类型。图 15**.29 展示了一个示例代码,取自存储库中的 CollectorsExamples.java

图 15.29 – 代码中的 Collectors.groupingBy(Function)

图 15.29 – 代码中的 Collectors.groupingBy(Function)

在这个例子中,我们正在流式传输一个姓名列表,并从流中提取一个Map<Integer, List<String>(如声明所示)。传递到groupingBy()Function参数String::length告诉collect(),映射中的键是String的长度(实际上是名字中的字符数)。值组织成一个List,列表中的每个条目都是一个String,其长度与键匹配。例如,根据输出:

   {3=[Tom, Tom, Ann], 5=[Peter], 6=[Martin]}

5映射到"Peter"6映射到"Martin"。注意,在输出中,"Tom"在列表中出现了两次。这是因为列表允许重复。

如果我们希望输出列表中只出现一次"Tom"呢?groupingBy()有一个重载版本可以帮助我们。

Collectors.groupingBy(Function keyMapper, Collector downstreamCollector)

回想一下,Set实现不允许重复,所以与默认的List相比,使用Set实现值将解决这个问题。这里的第二个参数被称为下游收集器。下游收集器的功能是对执行一些特殊操作。在这个例子中,我们希望值以Set实现的形式组织。图 15**.30展示了代码(CollectorsExamples.java)调整:

图 15.30 – 使用 Collectors.groupingBy(Function, Collector)进行 Set 实现

图 15.30 – 使用 Collectors.groupingBy(Function, Collector)进行 Set 实现

在这个例子中,Map中值的类型是Set<String>而不是List<String>。下游收集器:

   Collectors.toSet()

表明我们希望值以Set的形式组织。输出显示 "Tom" 现在只列了一次:

   {3=[Ann, Tom], 5=[Peter], 6=[Martin]}

注意,我们的Map的实现类型恰好是HashMap实现:

   System.out.println(map.getClass());// java.util.HashMap

此实现没有保证。如果我们想保证TreeMap实现呢?这里有一个重载版本可以帮助我们。

Collectors.groupingBy(Function, Supplier mapFactory, Collector)

此版本接受一个Supplier作为其第二个参数。此Supplier返回你想要的实现。

图 15**.31展示了代码调整(CollectorsExamples.java):

图 15.31 – 使用 Collectors.groupingBy(Function, Supplier, Collector)进行 TreeMap 实现

图 15.31 – 使用 Collectors.groupingBy(Function, Supplier, Collector)进行 TreeMap 实现

在这个例子中,我们正在将值重新转换为List类型:

Map<Integer, List<String>> map

要将流数据提取为List类型,我们必须使用适当的下游收集器:

Collectors.toList()

如输出所示:

{3=[Tom, Tom, Ann], 5=[Peter], 6=[Martin]}

"Tom" 再次重复(因为列表允许重复)。

我们还向groupingBy()传递了一个Supplier参数,表示我们想要TreeMap实现:

TreeMap::new

map.getClass() 调用输出:

java.util.TreeMap

这表明我们有一个 TreeMap 实现。

现在,我们将探讨分组的一个特殊情况,称为分区。

Collectors.partitioningBy(Predicate)

分区是分组的一个特殊情况,其中只有两个组 – true 和 false。因此,Map 实现中的键将是 Boolean 类型。值将默认为 List 类型。无法保证返回的 MapList 实现。

图 15.32 展示了一个代码示例(CollectorsExamples.java):

图 15.32 – 代码中的 Collectors.partitioningBy(Predicate)

图 15.32 – 代码中的 Collectors.partitioningBy(Predicate)

在这个图中,我们将数据从流中提取到 Map<Boolean, List<String>>。键将是 true 和 false。值将是基于提供的谓词为 true 或 false 的流中的元素。

使用以下代码行,我们告诉 collect() 根据字符串名称是否以 "T" 开头来分区流:

   Collectors.partitioningBy(s -> s.startsWith("T"))

如输出所示:

   {false=[Mike, Alan, Peter, Alan], true=[Thomas, Teresa]}

真分区包含 "Thomas""Teresa",而假分区包含所有其他名称。请注意,"Alan" 在假分区中出现了两次,因为列表允许重复。

partitioningBy() 有一个重载版本,使我们能够传递一个下游收集器。

Collectors.partitioningBy(Predicate, Collector downstreamCollector)

下游收集器对于指定不同的值集合非常有用。例如,我们可能想要一个 Set 视图,以便自动删除重复项。图 15.33 展示了一个代码示例(CollectorsExamples.java):

图 15.33 – 代码中的 Collectors.partitioningBy(Predicate, Collector)

图 15.33 – 代码中的 Collectors.partitioningBy(Predicate, Collector)

在这个示例中,请注意,名称 "Alan" 在源中出现了两次:

Stream.of("Alan", "Teresa", "Mike", "Alan", "Peter");

此外,我们还将数据收集到 Map<Boolean, Set<String>>

我们也仅仅为了做一些不同的事情而更改了谓词:

s -> s.length() > 4,// predicate

因此,如果字符串的字符数大于 4,则字符串将被放置在真分区中;否则,字符串将被放置在假分区中。

我们如下指定所需的下游收集器:

Collectors.toSet()

这意味着值应以 Set 的形式返回。如输出所示,"Alan" 仅出现一次(在 false 分区中):

{false=[Mike, Alan], true=[Teresa, Peter]}

这完成了我们对终端操作部分的讨论,同时也结束了第十五章。现在,让我们将所学知识付诸实践,以巩固我们学到的概念。

练习

  1. 创建一个恐龙名称的流(使用列表或数组)。使用 filter 方法创建一个新的流,该流仅包含肉食性恐龙的名称。然后,使用 forEach 方法打印出这些名称。

  2. 通过从恐龙年龄列表创建流来演示流的惰性。使用filter方法过滤掉大于 100 岁的年龄,然后使用map方法将剩余的每个年龄增加 10。然而,不要使用任何终端操作。解释为什么直到调用终端操作(如forEach)之前不会打印任何内容或执行任何操作。

  3. 使用恐龙重量流(作为双精度浮点数),使用过滤和计数终端操作来计算重量超过 5000 公斤的恐龙数量。

  4. 给定一个恐龙物种名称流(字符串),使用findFirst终端操作检索列表上的第一个名称。

项目 – 动态恐龙护理系统

将 Stream API 集成到您的恐龙护理系统中,以处理大量的恐龙数据,例如健康记录、喂食时间表等。系统还应适当地采用Optional和并行流,优化数据处理并最小化潜在的空指针异常。

这里有一些步骤可以帮助你达到那里:

  1. Dinosaur类具有namespecieshealthStatus等属性。还应该有一个DinosaurCareSystem类来实现主要功能。

  2. 从记录列表中Stream,并使用filter方法获取这些记录。以下是一个示例:List<HealthRecord> criticalRecords = records.stream().filter(r -> r.getHealthStatus() < CRITICAL_THRESHOLD).collect(Collectors.toList())

  3. 使用Stream过滤时间表。以下是一个示例:List<FeedingSchedule> morningFeeds = schedules.stream().filter(s -> s.getTime().isBefore(LocalTime.NOON)).collect(Collectors.toList())

  • 使用NullPointerExceptionOptional来避免NullPointerException错误。以下是一个示例:Optional.ofNullable(dinosaur.getTrainer()).map(Trainer::getName).orElse("No trainer assigned")。* 在前面的示例中使用stream()parallelStream()。然而,请注意,并非每个问题都适合并行处理。如果任务有依赖关系或需要按特定顺序处理,请坚持使用常规流。

摘要

在本章中,我们探讨了流和流终端操作的基本原理。流(以及 lambda 表达式)使一种称为函数式编程风格的编程方式成为可能,在这种风格中,你声明你想解决的问题,而不是如何解决它(命令式风格)。函数式编程风格通常更容易阅读,因为在命令式编程中,解决问题的细节可能会在实现中混淆。

我们使用流水线类比讨论了流管道。流管道由数据源、零个或多个中间操作以及一个终端操作组成,顺序依次排列。流是惰性评估的,这意味着数据仅在需要时才提供。这是可能的,因为 JVM 对整个管道有一个整体视图,因为直到终端操作执行之前,没有任何事情发生。

流源可以是从数组(Arrays.stream(arrayToUse))、集合(collectionToUse.stream())和文件(Files.lines(Path))到可变数量的参数(Stream.of(varargs))。可以使用 Stream API 中的两个static方法生成无限流:Stream.generate()Stream.iterate()

终端操作启动整个管道,每个管道都必须有一个终端操作。一旦在流上执行了终端操作,流就被关闭,必须重新流式传输才能重用。流行的终端操作包括forEach()count()min()max()findAny()findFirst()allMatch()anyMatch()noneMatch()reduce()collect()

减少是一种特殊的终端操作,其中所有流项目都被组合成一个原始类型或Objectreduce()方法有重载版本以方便这一操作。collect()方法在从流中提取数据到集合(如ListMap)中非常有用。collect()方法接受收集器,您可以自己定义,或者可以简单地使用 API 中预定义的许多收集器之一。

这就完成了我们对流的基本原理的讨论。在下一章中,我们将扩展到更高级的流概念。

第十六章:流:高级概念

第十五章中,我们学习了流的基本概念。我们通过类比流水线来讨论什么是流管道。我们了解到,项目只有在需要时才会进入流水线。这是惰性求值的原理。在这个类比中,有几个操作员在主管(Java)的监督下对数据进行操作。主管不会允许任何工作开始,直到终端操作就绪。由于 Java 现在知道整个管道,可以引入效率。一旦铅笔通过一个操作员,该操作员就无法将其取回。因此,流与数组或Collection在这一点上有所不同。铅笔可以由所需数量的操作员处理,但只有一个操作员是终端操作。其他操作员代表中间操作(本章的主题)。

我们探讨了如何创建流。流可以从各种来源创建:数组、集合、文件和可变参数。我们创建了有限和无限的流。无限流是通过Stream.generate()Stream.iterate()创建的。

我们深入研究了终端操作。直到终端操作执行,否则不会发生任何事情。一旦执行,流就被认为是关闭的,如果你想要再次使用它,必须重新流式传输。归约是一种检查整个流并产生单个输出(原始或Object)的操作。终端操作之一是重载的reduce()方法,它对流执行归约。collect()终端操作对于从流中提取数据(例如,到一个Map)以供以后使用非常有用。

在本章中,我们将继续对流的讨论。我们将通过代码示例来检查中间操作。随后,我们将讨论原始流以及如何映射流。我们还将讨论Optional,最后,我们将以并行流结束。

本章涵盖了以下主要主题:

  • 检查中间操作

  • 深入探讨原始流

  • 映射流

  • 解释Optional

  • 理解并行流

技术要求

本章的代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch16

检查中间操作

如我们所知,流管道由一个源,后面跟着零个或多个中间操作,然后是一个终端操作组成。虽然终端操作是强制性的,但中间操作不是。换句话说,中间操作是管道获得真正力量的地方,因为它们在数据流过时转换流数据。与终端操作不同,中间操作产生一个流作为结果。让我们从 filter() 开始,它来自 repo 上的 IntermediateOperations.java:

filter(Predicate)

filter() 操作返回一个包含匹配给定谓词的元素的流。图 16.1 展示了一个代码示例(来自 repo 上的 IntermediateOperations.java):

图 16.1 - 代码中的 filter(Predicate) 中间操作

图 16.1 - 代码中的 filter(Predicate) 中间操作

在这个图中,输出的是名字长度超过 5 个字符的国家。

distinct()

distinct() 操作返回一个移除了重复元素的流。内部,distinct() 使用 Objectequals() 方法进行比较。

它是一个 有状态的 中间操作,这意味着它需要保持一些状态以有效地操作。这个状态使 distinct() 能够按以下方式操作:如果这是 distinct() 第一次看到这个对象,它将其传递并记住它;如果 distinct() 已经看到这个对象,它将其过滤掉。

图 16.2 展示了一个代码示例(来自 repo 上的 IntermediateOperations.java):

图 16.2 - 代码中的 distinct() 中间操作

图 16.2 - 代码中的 distinct() 中间操作

在这个图中,我们正在流式传输一个字符串列表,其中 "eagle" 是重复的。我们使用了非常有用的 Stream<T> peek(Consumer) 中间操作。这个 peek() 操作在数据通过时执行消费者。这非常有帮助,因为它使我们能够查看流过的数据。distinct() 操作在我们的管道中,而 forEach() 终端操作开始流式传输。

当运行时,此代码生成以下输出:

// Output: Before: eagle, After: eagle//              Before: eagleBefore: EAGLE, After: EAGLE

第一个 "eagle" 被流式传输到管道中,其中 peek() 将其回显到屏幕上,并带有装饰 "Before: "。然后 peek()"eagle" 传递给 distinct()。由于这是 distinct() 第一次看到 "eagle",它将其传递并记住它。最后,forEach()"eagle" 与字符串 ", After:" 预先连接,然后输出一个换行符。

现在第二个 "eagle" 被流式传输。peek() 操作输出详细信息并将 "eagle" 传递下去。然而,distinct() 记住它已经看到这个元素,并将其过滤掉。这就是为什么在输出中只出现一次 ", After: eagle" 的原因。

最后,"EAGLE" 被流式传输。这与第一个 "eagle" 的处理方式相同。

limit(long)

limit()操作是一个短路、有状态的中间操作。我们看到了其短路特性在将无限流转换为有限流的过程中得到了很好的应用,这在第十五章中有所体现。显然,它需要维护一些状态以保持通过元素的数量。图 16.3展示了代码示例(IntermediateOperations.java):

图 16.3 - 代码中的 limit(long)中间操作

图 16.3 - 代码中的 limit(long)中间操作

在这个例子中,我们正在流式传输一个数字列表。这是一个懒加载的好例子。输出如下:

   A - 11 A - 22 A - 33 A - 44 B - 44 C - 44 A - 55 B - 55 C - 55

让我们看看这里发生了什么。

  • 11 被流式传输,第一个peek()输出它前面加上"A - ",然后传递给filter(),在那里失败(因为 11 不大于 40)

  • 22 被流式传输,表现与 11 相同

  • 33 被流式传输,操作方式与 11 和 22 相似

  • 44 被流式传输,通过了过滤器,因此输出了"B - 44";44 被传递给limit(),它记录这是它看到的第一个元素,然后再传递;forEach()输出前面加上"C - "的 44。

  • 55 被流式传输,操作方式与 44 相同,除了limit()通知 Java 这是它遇到的第二个元素,限制为 2。Java 允许forEach()完成,流关闭。

  • 注意,第一个peek()永远不会输出"A - 66""A - 77""A - 88""A - 99"。因此,66、77、88 和 99 永远不会被流式传输——因为它们不是必需的。这是另一个懒加载的例子。

现在我们来看一下map()

map(Function)

Stream<R> map(Function<T, R>)操作用于转换数据。它创建流中的元素与新返回的流中的元素之间的一对一映射。图 16.4展示了代码示例(IntermediateOperations.java):

图 16.4 - 代码中的 map(Function)中间操作

图 16.4 - 代码中的 map(Function)中间操作

map()操作接受一个Function,它接受一个类型并返回另一个类型,可能是不同的类型。在这个例子中,使用的 lambda 接受一个String,即s,并返回该StringInteger长度。forEach()输出流式传输的String的长度:"book"4"pen"3"ruler"5

flatMap(Function)

flatMap()操作“扁平化”了一个流。换句话说,多个集合/数组被合并为一个。例如,如果我们正在流式传输List<String>元素,它们将被扁平化为一个String的流,这“移除”或隐藏了每个单独的List。这在合并列表或删除空元素(flatMap()也这样做)时很有用。图 16.5展示了代码示例(IntermediateOperations.java):

图 16.5 - 代码中的 flatMap(Function)中间操作

图 16.5 - 代码中的 flatMap(Function)中间操作

在这个例子中,我们将对比两个流 - 一个使用flatMap(),另一个不使用flatMap()。让我们从非flatMap()流开始。

首先,我们创建列表,其中第一个是一个空列表:

   List<String> nothing = List.of();   List<String> list1 = Arrays.asList("Sean");
   List<String> list2 = Arrays.asList("Maike", "van", "Putten");

然后,我们流式传输这三个列表:

   Stream<List<String>> streamOfLists = Stream.of(nothing, list1, list2);

然后,我们使用forEach()流式传输并输出我们的streamOfLists

   streamOfLists.forEach(System.out::print);

这会输出:

   [][Sean][Maike, van, Putten]

注意,每个元素都是一个列表(通过方括号[ ]反映出来),并且空列表是存在的。

由于流已经被终端操作(forEach())处理,流已经关闭。为了避免异常,我们必须重新流式传输源。这就是我们做的:

   streamOfLists = Stream.of(nothing, list1, list2);

这个第二个管道包含flatMap()操作:

   streamOfLists.flatMap(list -> list.stream())

flatMap()的签名如下:

   Stream<R> flatMap(Function(T, R))

因此,flatMap()接受一个Function。函数输入T是一个List<String>,函数输出R是一个Stream<String>

再次使用forEach()来启动流式传输并输出流中的元素,我们得到以下结果:

   SeanMaikevanPutten

注意,它们都是Strings(没有List),并且空元素已经被移除。现在,列表中的String元素现在是流中的顶级元素。这就是前面解释的扁平化过程。

sorted() 和 sorted(Comparator)

重载的sorted()操作返回一个已排序元素的流。就像排序数组一样,Java 使用自然排序,除非我们提供Comparator。例如,数字的自然排序是升序数字顺序;String的自然排序是字母顺序。这个操作是一个有状态的中间操作,这意味着sorted()在排序之前需要看到所有数据。这两个排序示例都是基于 repo 中的 IntermediateOperations.java。图 16.6展示了sorted(Comparator)的代码示例。

图 16.6 - 代码中的 sorted(Comparator)中间操作

图 16.6 - 代码中的 sorted(Comparator)中间操作

在这个例子中,假设存在一个Person类,它既有String类型的name实例变量,也有Integer类型的age实例变量。我们首先流式传输Person对象;"Mary"是第一个,年龄 25 岁,"John"是第二个,年龄23岁。

sorted(Comparator)这一行很有趣:

   .sorted(Comparator.comparing(Person::getAge)) // p -> p.getAge()

Comparator.comparing(Function keyExtractor)静态方法是一种生成Comparator的非常有用方式。它接受一个Function,该函数提取一个Comparable排序键 - 也就是说,一个键的类型实现了Comparable接口。在这个例子中,Function输入是一个Person,而Function返回值是一个Integer(人的年龄)。由于Integer实现了Comparable,这是可以的。然后该方法返回一个通过该排序键比较的Comparator。这个管道很短,并没有清楚地展示sorted()的状态性。下一个例子将做到这一点。

当我们输出流时,"John" 首先出现,然后是 "Mary"(与它们被流出的顺序相反)。这是因为我们按 age 排序,而 John,23 岁,比 Mary(25 岁)年轻。

现在让我们看看另一个 sorted() 的例子。这个例子将演示 sorted() 的有状态特性,同时突出懒加载。图 16**.7 展示了代码。

图 16.7 -  的有状态特性

图 16.7 - sorted() 的有状态特性

在这个例子中,我们正在流式传输一个 String(名字)列表。长度为 3 的名字通过过滤器:

.filter(name -> name.length() == 3)

sorted() 操作是有状态的 - 它需要看到 所有 数据才能对数据进行排序。我们还有一个 limit(2) 操作,它既是有状态的又是短路操作。它将在两个名字通过后短路。最后,终端操作 forEach() 启动了流式传输过程,并按到达顺序输出名字。

输出如下:

   0.Tim 1.Tim 0.Jim 1.Jim 0.Peter 0.Ann 1.Ann 0.Mary 2.Ann 3.Ann 2.Jim 3.Jim

让我们看看这里发生了什么。注意,管道右侧的注释(第 49-55 行)表明每个名字到达的阶段。

  • "Tim" 被流出并通过了过滤器。"Tim" 进入 sorted() 并被存储。Java 告诉 sorted() 还有更多数据要流式传输,不要排序。这导致输出中有 "0. Tim 1. Tim"

  • "Jim" 接下来被流出,其行为与 "Tim" 完全一样,sorted() 记录了它将需要排序 "Tim""Jim"。再次,Java 告诉 sorted() 还有更多数据要来,不要排序。因此,输出中有 "0. Jim 1. Jim"

  • "Peter" 然后被流出,但未通过过滤器(输出中只有 "0. Peter" 而没有 "1. Peter")。

  • "Ann" 接下来被流出,其行为与 "Tim""Jim" 完全一样,sorted() 记录了它将需要排序 "Tim""Jim""Ann"。再次,Java 告诉 sorted() 还有更多数据要来,不要排序。因此,输出中有 "0. Ann 1. Ann"

  • "Mary" 是最后一个被流出的名字。"Mary" 也未通过过滤器(输出中只有 “0. Mary” 而没有 “1. Mary”)。

  • 由于流现在为空,Java 告诉 sorted() 它可以排序数据。排序后的名字是 "Ann""Jim""Tim"。因此,"Ann" 现在从 sorted() 流出,进入流管道的下一阶段。

  • sorted() 后的 peek() 输出 "2. Ann",显示 "Ann" 到达了这里。

  • limit() 操作将 “Ann” 传递下去,但记录了它已经处理了一个名字。

  • 终端操作 forEach(),它启动了整个流式传输过程,输出 "3. Ann" 以显示 "Ann" 已经到达这里。

  • "Jim" 现在从 sorted() 中流出。"Jim" 被窥视("2. Jim")并通过 limit()。然而,由于这是它处理的第二个名字,limit() 被短路。Java 被告知这一事实。

  • forEach() 操作允许完成输出 "3. Jim"

  • 注意,"Tim"从未从sorted()中出来,进入最后的peek() - 输出中没有“2. Tim”。

这就完成了关于中间操作的这一部分。现在让我们来考察原生流。

深入原生流

到目前为止,我们所有的流都是针对Object类型的。例如,Stream<Integer>为包装类Integer提供支持。Java 也有专门针对原生流类的。例如,假设有一个int原生流,而不是Stream<Integer>,我们使用IntStream。正如我们将很快看到的,原生流有一些非常实用的方法用于处理数值数据,例如sum()average()

表 16.1介绍了原生流类。

包装流 原生流 支持的 原生类型
Stream<Integer> IntStream int, short, byte, char
Stream<Double> DoubleStream double, float
Stream<Long> LongStream long

表 16.1 - 原生流类

在这个表中,第一列列出了包装类型流;第二列列出了相应的原生流,最后一列,列举了第二列原生流所支持的原生类型。

让我们考察如何创建原生流。

创建原生流

与创建Object流一样,我们也可以轻松地创建原生流。图 16.8展示了创建原生流的示例代码(基于 repo 中的 PrimitiveStreams.java 文件中的代码)。

图 16.8 - 创建原生流

图 16.8 - 创建原生流

在这个例子中,我们创建了不同原生类型的数组:

   int[] ia          = {1,2,3};   double[] da       = {1.1, 2.2, 3.3};
   long[] la         = {1L, 2L, 3L};

使用重载的Arrays.stream()方法,我们分别创建IntStreamDoubleStreamLongStream

   IntStream iStream1       = Arrays.stream(ia);   DoubleStream dStream1    = Arrays.stream(da);
   LongStream lStream1      = Arrays.stream(la);

例如,Arrays.stream(ia)接受一个int[]并返回一个以指定数组为源的IntStream

然后我们对每个流执行count()终端操作。每个流都返回3,因为每个数组源中都有3个原生值:

System.out.println(iStream1.count() + ", " + dStream1.count() + ", " + lStream1.count()); // 3, 3, 3

of()方法应该与我们使用Stream类创建常规流的方式相似。在IntStreamDoubleStreamLongStream中都有一个等效的方法。流中的值由 varargs 参数指定:

   IntStream iStream2       = IntStream.of(1, 2, 3);   DoubleStream dStream2    = DoubleStream.of(1.1, 2.2, 3.3);
   LongStream lStream2      = LongStream.of(1L, 2L, 3L);

再次,我们对每个流执行count()终端操作。和之前一样,每次返回3,因为每个流中都有3个原生值:

System.out.println(iStream2.count() + ", " + dStream2.count() + ", " + lStream2.count()); // 3, 3, 3

我们当然可以创建无限的原生流。图 16.9,来自 repo 中的 PrimitiveStreams.java 文件,展示了它们的使用及其在Stream类中的等效名称,即generate()iterate()

图 16.9 - 无限原生流

图 16.9 - 无限原生流

在这个例子中,我们开始时有两行代码:

   DoubleStream random    = DoubleStream.generate(() -> Math.random());   random.limit(5).forEach(System.out::println);

DoubleStream.generate(DoubleSupplier) 方法在 IntStreamLongStream 中有等效版本。它的参数 DoubleSupplier 是一个函数式接口,它产生一个 double。因此,它是 Supplier<T>double 原始版本。它的函数式方法 double getAsDouble() 强调了这一点。我们使用 limit(5) 来限制无限数字流的数量为 5,并且每个数字都通过终端操作 forEach() 输出。

接下来是下一行代码:

   IntStream even = IntStream.iterate(2, (n) -> n + 2);   even.limit(5).forEach(System.out::println);

IntStream.iterate() 方法在 DoubleStreamLongStream 中有等效版本。它接受两个参数,一个 int 种子(起始值)和一个 IntUnaryOperator 函数。这个 IntUnaryOperator 函数接受一个 int 并返回一个 int。它是 UnaryOperator<T>int 原始特化。生成的数字流是偶数,从 2 开始。由于数字序列是无限的,我们应用了 5 个数字的限制(246810)。

现在,让我们来考察常见的原始流方法。

常见的原始流方法

刚才提到的这些方法,即 of()generate()iterate(),在 Stream<T> 中也是通用的。表 16.2 展示了原始流中特有的常用方法。

表 16.2 - 常见的原始流方法

表 16.2 - 常见的原始流方法

此表有两列:方法的名称(包括其返回类型)和原始流。列出的每个方法都是归约和终端操作。回想一下,归约通过反复对一个输入结果序列应用操作来生成单个总结结果。我们在 Stream<T> 接口中的 reduce()collect() 方法中看到了归约的一般形式。此表中的归约是针对原始数据类型特化的。

让我们先来考察 sum() 方法。注意,它不返回 Optional,而其他所有方法都返回。这是因为对于空流的求和,0 是一个有效的返回值。换句话说,如果你在执行 sum() 时流为空——可能所有数据都被过滤掉了——那么 0 是一个有效的返回值。然而,表中的其他方法在这种情况下需要返回一个空的 OptionalIntStreamsum() 返回一个 intLongStream 中的版本返回一个 long,而 DoubleStream 中的版本返回一个 double

关于 min()max()IntStream 的两个版本都返回 OptionalIntLongStream 的两个版本都返回 OptionalLong,而 DoubleStream 的两个版本都返回 OptionalDouble

average() 方法略有不同,因为无论总计数类型如何,都存在小数位。所以三种原始流类型,即 IntStreamLongStreamDoubleStream 都返回 OptionalDouble

让我们通过代码(repo 中的 PrimitiveStreams.java)来检查它们。首先,图 16.10 展示了min()max()average()

图 16.10 – 代码中的 min()、max()和 average()操作

图 16.10 – 代码中的 min()、max()和 average()操作

在这个图中,我们从以下代码开始:

   OptionalInt max = IntStream.of(10, 20, 30)           .max(); // terminal operation
   max.ifPresent(System.out::println);// 30

首先,我们创建一个int原始流。然后执行终端操作max(),它启动流并计算流中的最大数,即 30。这里不需要任何Comparator或累加器!然后我们使用OptionalIntifPresent(IntConsumer)(对于OptionalDoubleOptionalLong也有等效方法)。这个方法的意思是,如果OptionalInt中存在值,则输出它。如果可选对象为空,则不打印任何内容。

下一段有趣的代码是:

   OptionalDouble min = DoubleStream.of(10.0, 20.0, 30.0)           .min(); // terminal operation
   // NoSuchElementException is thrown if no value present
   System.out.println(min.orElseThrow());// 10.0

在这个代码段中,我们根据提供的 varargs 参数创建一个DoubleStream。使用min(),我们流式传输值并计算最小值。orElseThrow()方法意味着:如果存在值,则返回该值;否则抛出NoSuchElementException

最后一段代码是:

   OptionalDouble average = LongStream.of(10L, 20L, 30L)           .average(); // terminal operation
   System.out.println(average.orElseGet(() -> Math.random())); // 20.0

在这里,我们根据提供的 varargs 参数创建一个LongStream。随后执行average(),它既流式传输值又计算它们的平均值。orElseGet(DoubleSupplier)方法意味着:如果存在值,则返回该值;否则返回供应函数(一个随机数)的值。

让我们现在检查sum()。很容易看出原始流在下一个示例(图 16**.11)中的有用性。

图 16.11 - sum()原始操作

图 16.11 - sum()原始操作

在这个图中,我们首先从以下内容开始:

   IntStream is = IntStream.of(4, 2, 3);   System.out.println(is.sum());// 9

这段代码直接使用IntStream.of()方法创建一个int原始流,并使用sum()终端方法流式传输数字并返回总和,即 9。

示例代码的其余部分,对比了Stream<T>reduce()IntStreamsum()。让我们首先关注reduce()

   Stream<Integer> numbers = Stream.of(1,2,3);  System.out.println(numbers.reduce(0, (n1, n2) -> n1 + n2)); // 6

最初,我们将一个Integer列表流式传输到一个Stream<Integer>中,然后通过传递累加函数参数给reduce()来求和。

现在我们将关注如何使用sum()做同样的事情:

   Stream<Integer> sInteger = Stream.of(1,2,3);   IntStream intS           = sInteger.mapToInt( n -> n); // unboxed
   System.out.println(intS.sum()); // 6

首先,我们再次以Stream<Integer>的形式流式传输相同的数字 - 目前我们还没有原始类型的流。第二行显示了将Stream<Integer>转换为int原始类型的Stream有多容易。使用Stream接口的mapToInt()函数;我们传递我们的函数,该函数接受一个Integer并返回由该Integer包装的int原始类型。在这段代码中,我们通过在箭头符号两侧简单地指定标识符n来利用自动拆箱。现在我们有了IntStream对象,我们可以使用sum()方法 - 该方法流式传输整数并返回 6 的总和。请注意,我们故意在代码中保留了返回类型。这有助于解释管道中发生的事情。实际上,你会更简洁地编写如下:

int sum = Stream.of(1,2,3)                           .mapToInt(n -> n)
                           .sum();
System.out.println(sum); // 6

对于每个原始流,你可以获取汇总统计信息(关于流中元素的汇总数据)。让我们看看这些在实际中的应用。图 16.12展示了IntSummaryStatistics

图 16.12 - 代码中的 IntSummaryStatistics

图 16.12 - 代码中的 IntSummaryStatistics

在这个例子中,流是通过以下方法调用来传递的:

   stats(IntStream.of(5, 10, 15, 20));   stats(IntStream.empty());

第一次调用传递了一个有效的整数流,而第二个流是空的。一旦进入stats()方法,终端操作summaryStatistics()就会在传入的IntStream上执行。现在可以检查IntSummaryStatistics对象以获取汇总数据:

   IntSummaryStatistics intStats = numbers.summaryStatistics(); // terminal op.

第一流的输出(5,10,15 和 20)如下:

520
12.5
4
50

getMin()输出5getMax()输出20getAverage()输出12.5getCount()输出4getSum()输出50

空流的输出如下:

2147483647-2147483648
0.0
0
0

2147483647(即Integer.MAX_VALUE)由getMin()输出;-2147483648Integer.MIN_VALUE)由getMax()输出;getAverage()输出0.0getCount()getSum()都输出0

在原始流中,现在有额外的功能接口需要我们注意。

新的原始流接口

有许多新的功能接口需要我们注意。幸运的是,它们遵循一致的命名模式。表 16.3概述了更常见的接口。有关更多详细信息,请参阅 JavaDocs:docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/function/package-summary.html

表 16.3(a)和(b)- 新的原始流功能接口

表 16.3(a)和(b)- 新的原始流功能接口

在这个图中,表 A 在左边,表 B 在右边。每个表都有两列 - 一列是功能接口名称,另一列是其功能方法。

我们故意包括了之前遇到的泛型标记的功能接口。这是为了帮助它们与它们的原生对应者进行对比。我们之前遇到的功能接口是:Supplier<T>、Consumer<T>、BiConsumer<T, U>、Predicate<T>、BiPredicate<T, U>、Function<T, R>、BiFunction<T, U, R>、UnaryOperator<T>BinaryOperator<T>。注意它们中的泛型类型。非常少的功能接口使用泛型,因为它们是为特定的原生类型类型化的。

我们对接口进行了颜色协调,以便将它们分组。例如,在表格 A 中,黄色标注的接口是供应商。Supplier<T>具有T get()功能方法——如前所述,这是为了比较目的而包含的。DoubleSupplier是生成double原生类型的接口。其功能方法是getAsDouble(),其返回类型是doubleIntSupplierLongSupplier接口遵循相同的模式。

仍然在表格 A 中,消费者接下来,用绿色标注。DoubleConsumer“接受”一个double原生类型并返回空值。IntConsumer接受一个int,返回空值;LongConsumer接受一个long,返回空值。所有功能方法都被称为accept()。注意命名模式:供应商使用DoubleSupplier;消费者使用DoubleConsumer

这种命名约定在谓词(蓝色)中继续。我们有一个DoublePredicate,它“测试”一个double并返回一个booleanIntPredicateLongPredicate以类似的方式表现——一个原生类型参数和一个返回类型boolean。所有功能方法都被称为test()

在表格 B 中,我们看到了函数,用黄色标注。我们有一个DoubleFunction<R>,它“应用”一个double并返回类型R。函数是一个使用泛型来表示返回类型的例子。然而,被应用的原生类型在这里是重要的方面。IntFunction<R>LongFunction<R>以类似的方式表现——一个原生类型参数和一个返回类型R。所有功能方法都被称为apply()

最后,在表格 B 中,我们有UnaryOperator<T>BinaryOperator<T>的原生版本。UnaryOperator<T>double原生版本是DoubleUnaryOperator(注意开头再次出现的单词Double)。回想一下,一元函数是接受一个参数并返回值的函数;其中两种类型都是相同的。因此,DoubleUnaryOperator有一个double参数和一个double返回类型。IntUnaryOperatorLongUnaryOperator遵循相同的模式。

DoubleBinaryOperatorIntBinaryOperatorLongBinaryOperator接口与它们的一元对应者仅在它们接受的参数数量上有所不同。因此,DoubleBinaryOperator接受两个doubleIntBinaryOperator接受两个intLongBinaryOperator接受两个long

创建流还有其他方法,那就是从其他流映射。现在让我们来探讨这一点。

流映射

再次,有许多新的函数式接口需要我们注意;幸运的是,它们遵循一致的命名模式。表 16.4 列出了更常见的接口。

表 16.4 - 流映射

表 16.4 - 流映射

在这个表中,行代表源流类,列代表目标流类。再次,我们使用颜色来帮助组织我们的解释。黄色框代表源和目标类相同的情况。例如,如果您从 DoubleStream 转换到另一个 DoubleStream,则方法为 map(DoubleUnaryOperator)。函数方法也列出了——在这个例子中,DoubleUnaryOperator 的函数方法是 double applyAsDouble(double)

让我们检查棕色框。这些中的每一个都使用 mapToObj() 方法作为源,因为源是原始流,目标是对象流。源流暗示了要使用的函数。例如,如果源是 DoubleStream,则适用 DoubleFunction 接口,因为您正在将 double 原始类型映射到类型 R。这指定在函数方法 R apply(double value) 中。

接下来是绿色框。目标流是 DoubleStream,因此方法名为 mapToDouble()。如果源流是对象流,则接口是 ToDoubleFunction<T>。其函数方法是 double applyAsDouble(T value),所以输入类型是 T,输出是 double 原始类型。正如从类型 T 的对象到原始 double 类型转换时预期的那样。

保持目标流为 DoubleStream,如果源是 IntStream,则涉及的原始类型在接口名称中:IntToDoubleFunction。它的函数方法 double applyAsDouble(int) 也没有什么意外。如果源是 LongStream,则涉及的原始类型再次在接口名称中:LongToDoubleFunction。它的函数方法 double applyAsDouble(long) 也没有什么意外。

蓝色框代表目标流为 IntStream 的情况。方法名为 mapToInt()。用作参数的函数式接口及其函数方法遵循为 DoubleStream 概述的相同命名模式。

最后,灰色框代表 LongStream 的目标流。方法名为 mapToLong()。类似的命名模式再次应用于函数式接口及其函数方法,如 DoubleStreamIntStream 中所示。

让我们看看一些代码示例。我们将从对象流映射开始。

从对象流映射

第一个例子将以 Stream<String> 作为源流,并相应地映射到各种其他流。图 16**.13 表示代码(存储库中的 MappingStreams.java)。

图 16.13 - 映射对象流

图 16.13 - 映射对象流

在这个图中,我们将 Stream<String> 映射到所有其他流类型,包括 Stream<String> 本身。第一个示例是:

// Stream<T> to Stream<T>Stream.of("ash", "beech", "sycamore")
        // map(Function<T,R>)
        //    Function<T,R> => Function<String, String>
        //       String apply(String s)
        .map(tree -> tree.toUpperCase())
        .forEach(System.out::println);// ASH, BEECH, SYCAMORE

在这种情况下,map(Function<T,R>)String 映射到 String。该函数将字符串转换为大写。forEach() 终端操作启动流式传输过程并输出字符串。

第二个示例是:

// Stream<T> to DoubleStreamDoubleStream dblStream = Stream.of("ash", "beech", "sycamore")
        // mapToDouble(ToDoubleFunction<T>)
        //   ToDoubleFunction<T> is a functional interface:
        //      double applyAsDouble(T value) => double applyAsDouble(String tree)
        .mapToDouble(tree -> tree.length()); // upcast in background
dblStream.forEach(System.out::println); // 3.0, 5.0, 8.0

这次,Stream<String> 被映射到 DoubleStreamdouble 原始类型)。请注意,我们必须重新流化源,因为之前的 forEach() 已经关闭了它。这个管道使用 mapToDouble(ToDoubleFunction<T>)String 映射到 double 原始类型。这次函数使用 Stringlength(),它是一个 int。这个 int 在后台被向上转型为 doubleforEach() 启动流并输出 double 值。

第三个示例是:

// Stream<T> to IntStreamIntStream intStream    = Stream.of("ash", "beech", "sycamore")
        // mapToInt(ToIntFunction<T>)
        //   ToIntFunction<T> is a functional interface:
        //      int applyAsInt(T value) => int applyAsInt(String tree)
        .mapToInt(tree -> tree.length());
intStream.forEach(System.out::println); // 3, 5, 8

这次,Stream<String> 被映射到 IntStream。我们再次需要重新流化源。这个管道使用 mapToInt(ToIntFunction<T>)String 映射到 int 原始类型。我们再次使用 Stringlength() 函数。由于这是一个 int,不需要在后台进行向上转型。使用 forEach() 终端操作来启动流并输出 int 值。

最后一个示例是:

// Stream<T> to LongStreamLongStream longStream = Stream.of("ash", "beech", "sycamore")
        // mapToLong(ToLongFunction<T>)
        //   ToLongFunction<T> is a functional interface:
        //      long applyAsLong(T value) => long applyAsLong(String tree)
        .mapToLong(tree -> tree.length()); // upcast in background
longStream.forEach(System.out::println); // 3, 5, 8

这里,Stream<String> 被映射到 LongStream。这个管道使用 mapToLong(ToLongFunction<T>)String 映射到 long 原始类型。由于 Stringlength() 返回一个 int,后台进行了向上转型。long 值作为 forEach() 终端操作的一部分输出。

现在我们来检查从原始数据流映射的代码示例。

从原始流映射

在这个例子中,我们是从原始数据流映射到其他流类型。图 16.14 展示了代码(MappingStreams.java)。

图 16.14 - 映射原始流

图 16.14 - 映射原始流

在这个例子中,我们使用 IntStream.of() 流式传输 int 原始类型,并依次将 IntStream 转换为 Stream<String>DoubleStreamIntStreamLongStream

这里是第一个示例:

// IntStream to Stream<T>Stream<String> streamStr = IntStream.of(1, 2, 3)
        // mapToObj(IntFunction<R>)
        //    IntFunction is a functional interface:
        //       R apply(int value)
        .mapToObj(n -> "Number:"+ n);
streamStr.forEach(System.out::println);// Number:1, Number:2, Number:3

这段代码代表了一个用于流式传输 int 原始值并将它们映射到 String 对象流的一个示例管道。mapToObj() 方法在这里很重要。它的签名是:Stream<R> mapToObj(IntFunction<R>)。当查看函数式接口 IntFunction<R> 的函数方法时,传入的 lambda 更容易理解。函数方法是 R apply(int value)。在我们的例子中,将 int 原始值作为 n 传入,返回的 String(在方法签名中用 R 表示)是在 int 前面添加 "Number:" 形成的字符串。回想一下,当你在 + 的左右两侧(或两侧都是)有一个字符串时,结果是 StringforEach() 流式传输 int 原始值并输出 Stream<String>

下一个例子是:

// IntStream to DoubleStreamDoubleStream dblStream = IntStream.of(1, 2, 3) // re-open closed stream
        // mapToDouble(IntToDoubleFunction)
        //   IntToDoubleFunction is a functional interface:
        //      double applyAsDouble(int value)
        .mapToDouble(n -> (double)n); // cast NOT necessary
dblStream.forEach(System.out::println); // 1.0, 2.0, 3.0

这段代码是将 IntStream 映射到 DoubleStreammapToDouble() 方法在这里很重要。它的签名是:

DoubleStream mapToDouble(IntToDoubleFunction)IntToDoubleFunction 的函数方法是 double applyAsDouble(int value)。因此,我们的 lambda 传入一个 int 并返回一个 double。强制类型转换不是必需的,它只是用来强调返回的是一个 double 原始值。

这是下一个例子:

// IntStream to IntStreamIntStream intStream = IntStream.of(1, 2, 3)
        //  map(IntUnaryOperator)
        //    IntUnaryOperator is a functional interface:
        //        int applyAsInt(int)
        .map(n -> n*2);
intStream.forEach(System.out::println);// 2, 4, 6

在这里,我们将 IntStream 映射到另一个 IntStream。使用 IntStream map(IntUnaryOperator) 方法。它的函数方法是:

int applyAsInt(int value) 因此我们传入一个 int 并返回一个 int。我们的 lambda 简单地将传入的 int 乘以 2 并返回结果。

最后一个例子:

// IntStream to LongStreamLongStream longStream = IntStream.of(1, 2, 3)
        // mapToLong(IntToLongFunction)
        //   IntToLongFunction is a functional interface:
        //      long applyAsLong(int value)
        .mapToLong(n -> (long)n); // cast NOT necessary
longStream.forEach(System.out::println); // 1, 2, 3

这段代码将 IntStream 映射到 LongStream。使用 LongStream mapToLong(IntToLongFunction) 方法。它的函数方法是:

long applyAsLong(int value) 因此我们传入一个 int 并返回一个 long。同样,强制类型转换不是必需的,它只是用来强调返回的是一个 long 原始值。

这样我们就完成了对映射流的覆盖。现在让我们转向考察 Optional

解释 Optional

可以将 Optional 视为一个可能为空也可能不为空的容器。根据 API,容器“可能包含或不包含非 null 值”。Optional 主要用作方法返回类型,当确实需要表示“无结果”且返回 null 可能导致错误时。在 Java 8 之前,程序员会返回 null,但现在,自从 Java 8 以来,我们可以返回一个 空的 Optional。这有几个优点:

  • 降低 NullPointerException 的风险

  • 通过使用 Optional 作为返回类型,API 现在可以清楚地声明可能不会返回值

  • Optional API 促进了函数式编程风格

除了 Optional<T>,还有原始类型的 Optional;即:OptionalIntOptionalDoubleOptionalLong。我们将在稍后考察它们。

让我们先看看如何创建 Optional

创建 Optional

API 提供了几个 static 方法用于此目的。让我们从 Optional.of(T) 开始。

Optional<T> Optional.of(T value)

value 参数被包裹在一个 Optional 中。传递的 value 必须是一个非 null 值。如果传递了 null,则会产生一个 NullPointerException

现在,让我们看看 Optional.empty()。这就是创建一个空的 Optional 实例的方法。

Optional.empty()

最后,我们将检查 Optional.ofNullable(T)

Optional.ofNullable(T value)

如果给定的 value 是非 null,此方法返回一个包裹在 Optional 中的 value。如果传递了 null,则返回一个空的 Optional。如果我们检查以下代码:

Optional opt1 = Optional.ofNullable(value);Optional opt2 = (value == null) ? Optional.empty() : Optional.of(value);

这两行做的是同一件事。第一行是第二行三目运算符的简写。三目运算符表示以下内容:如果 valuenull,则 opt2 被分配一个空的 Optional;否则,opt2 被分配包裹的 value

图 16.15 以代码形式(在仓库中的 Optionals.java)展示了它们。

图 16.15 - 创建 Optionals

图 16.15 - 创建 Optionals

在这里,第一个示例创建了一个空的 Optional

        Optional opt1 = Optional.empty();//        System.out.println(opt1.get()); // NoSuchElementException
        opt1.ifPresent(o -> System.out.println("opt1: "+o)); 
        // no exception

我们使用 Optional.empty() 方法来创建一个空的 Optional。下一行被注释掉了,因为如果你在一个空的 Optional 上执行 get(),你会得到一个 NoSuchElementException 异常。最后一行显示了函数式风格的 ifPresent(Consumer)。如果存在值,给定的消费者会被应用到该值上;否则,它什么都不做。在这种情况下,由于 Optional 是空的,所以它什么都不做。

下一个示例创建了一个非空的 Optional

        Optional opt2 = Optional.of(23);//        Optional.of(null); // NullPointerException
        opt2.ifPresent(o -> System.out.println("opt2: "+o)); 
        // opt2: 23

这次我们使用 Optional.of() 创建了一个 Optional,值为 23。第二行显示,如果你将 null 传递给 Optional.of(),你会得到一个 NullPointerException。现在 ifPresent() 执行传递的消费者,它输出了 "``opt2: 23"

下一个示例再次使用了 Optional.ofNullable()

Optional opt3 = Optional.ofNullable(23);opt3.ifPresent(o -> System.out.println("opt3: "+o)); // opt3: 23

我们在这里使用 Optional.ofNullable() 创建了一个 Optional,并且值是 23。由于 Optional 不是空的,传递给 ifPresent() 的消费者输出了 "``opt3: 23"

这里是最后一个示例:

Optional opt4 = Optional.ofNullable(null);opt4.ifPresent(o -> System.out.println("opt4: "+o));
if(opt4.isEmpty()){
    System.out.println("opt4 is empty!");           // opt4 is empty!
}

在这个例子中,我们再次使用了 Optional.ofNullable(),但这次我们传递了 null。而不是得到一个异常(这是 Optional.of(null) 会产生的),我们得到了一个 Optional。由于 Optional 是空的,ifPresent() 什么都不做。isEmpty() 证明 Optional 确实是空的,因此输出了 "opt4 is empty!"

现在我们知道了如何创建 Optionals,让我们探索可用的 API 方法。

使用 Optional API

表 16.5 表示 Optional 中的实例方法。

方法 如果 Optional 为空会发生什么 如果 Optional 有值会发生什么
get() 抛出 NoSuchElementException 返回值
isPresent() 返回 false 返回 true
ifPresent(Consumer) 什么都不做 使用值执行 Consumer
orElse(T otherValue) 返回 otherValue 返回值
orElseGet(Supplier) 返回执行 Supplier 的结果 返回值
orElseThrow() 抛出NoSuchElementException 返回值
orElseThrow(Supplier) 抛出由Supplier返回的异常。然而,如果Suppliernull,则抛出NullPointerException 返回值

表 16.5 - Optional 实例方法

许多这些方法使我们能够以更简洁和更富有表现力的方式编写代码。ifPresent(Consumer)是一个非常好的例子——与在if-else语句中相比,ifPresent(Consumer)消除了编写else部分的必要性。此外,ifPresent(Consumer)帮助我们更清楚地表达我们的意图——如果值存在,则执行此操作;否则不执行任何操作。

图 16.16 展示了Optional API 中的方法。

图 16.16 - 代码中的 Optional 方法

图 16.16 - 代码中的 Optional 方法

在这个例子中,我们将使用一个非空的Optional和一个空的Optional来测试各种方法。让我们从一个有效的非空Optional开始。

带有值的 Optional

首先,我们创建一个包裹着Double 60.0Optional

   Optional<Double> valueInOptional = Optional.ofNullable(60.0);

然后,我们使用isPresent()来确保执行get()方法是安全的,因为在对空的Optional执行get()操作会导致异常:

   if(valueInOptional.isPresent()){     System.out.println(valueInOptional.get());  // 60.0
   }

由于isPresent()返回true,执行get()是安全的,它返回60.0并输出到屏幕。

接下来的两行是:

   valueInOptional.ifPresent(System.out::println);// 60.0   System.out.println(valueInOptional.orElse(Double.NaN)); // 60.0

在此代码段中,由于valueInOptional中有一个非空值,ifPresent()的消费者参数将被执行,并将60.0输出到屏幕。此外,由于我们在valueInOptional中有值,orElse(T value)方法不会执行;这意味着60.0被输出到屏幕。

空的 Optional

首先,我们通过将null传递给ofNullable()来创建一个空的Optional

   Optional<Double> emptyOptional = Optional.ofNullable(null);

然后,我们有:

System.out.println(emptyOptional.orElse(Double.NaN)); // NaNSystem.out.println(emptyOptional.orElseGet(() -> Math.random())); 
// 0.8524556508038182

orElse(T value)返回NaN,而orElseGet(Supplier)执行Supplier,用于生成一个随机数。请注意,Supplier必须返回一个Double,因为这是emptyOptional的类型。

最后,我们有:

    System.out.println(emptyOptional.orElseThrow()); // NoSuchElementException
// System.out.println(emptyOptional.orElseThrow(() -> new RuntimeException()));

两行都执行orElseThrow(),它们是互斥的。这意味着,要看到第二行的异常,请注释掉第一行。由于Optional是空的,第一行抛出NoSuchElementException。假设我们注释掉第一行并取消注释第二行,传递给orElseThrow()Supplier将返回一个RuntimeException。请注意,我们不在Supplier中使用关键字throworElseThrow()方法会为我们做这件事——我们的工作是,通过Supplier给它提供一个要抛出的异常对象。

关于Optional的最后一部分,是原始的Optional

原始 Optional

如前所述,也有原始类型的Optional;即:OptionalIntOptionalDoubleOptionalLong。我们现在将研究它们。

表 16.6 突出了更常用的原始流方法。

OptionalInt OptionalDouble OptionalLong
int getAsInt() double getAsDouble() long getAsLong()
ifPresent(IntConsumer)``void accept(int) ifPresent (DoubleConsumer)``void accept(double) ifPresent(LongConsumer)``void accept(long)
OptionalInt of(int) OptionalDouble of(double) OptionalLong of(long)
int orElse(int other) double orElsedouble other long orElse(long other)
orElseGet(IntSupplier)``int getAsInt() orElseGet (DoubleSupplier)``double getAsDouble() orElseGet(LongSupplier)``long getAsLong()
IntStream stream() DoubleStream stream() LongStream stream()

表 16.6 - 常用的原始流方法

这个表格对比了原始流中更常用的方法。在适当的地方,也列出了函数式方法,位于函数式接口下方。例如,检查OptionalIntifPresent(IntConsumer),可以看到IntConsumer的函数式方法是void accept(int)

注意,orElseGet()方法的返回类型可以从下面的函数式方法推断出来。例如,检查OptionalIntorElseGet()方法,可以看到IntSupplier的函数式方法是int getAsInt()。因此,orElseGet(IntSupplier)的返回类型也是int

让我们通过代码检查其中的一些。图 16.17 是示例(Optionals.java):

图 16.17 - 代码中的原始流方法

图 16.17 - 代码中的原始流方法

在这个图中,我们开始如下:

   OptionalDouble optAvg = IntStream.rangeClosed(1, 10).average();   optAvg.ifPresent(d -> System.out.println(d));// 5.5

这第一行使用了IntSream方法的rangeClosed()来生成一个从 1 到 10(包括 10)的整数流,步长为 1。然后average()方法计算这些数字的平均值,为5.5(55/10)。请注意,optAvg的类型是OptionalDouble

第二次使用的是现在熟悉的ifPresent()方法。这次消费者参数是DoubleConsumer,这意味着函数式方法是void accept(double)。这正是我们正在做的事情 - 使用OptionalDouble的值(即d)并输出。

我们接下来有:

   System.out.println(optAvg.getAsDouble()); // 5.5

它使用getAsDouble()来返回double值。如果没有值存在,这个方法(就像Optional<T>中的get())会生成一个NoSuchElementException

接下来的两行是:

   double dblAvg = optAvg.orElseGet(() -> Double.NaN);   System.out.println(dblAvg);// 5.5

第一行使用了orElseGet()方法。我们传递了一个DoubleSupplier,这意味着没有输入参数(因此 lambda 中有()),并且返回一个double值(Double.NaN)。由于OptionDouble有值,该值用于初始化dblAvg,而DoubleSupplier被忽略。然后我们输出变量dblAvg

下面的代码段完成了示例:

   OptionalInt optInt = OptionalInt.of(35);   int age = optInt.orElseGet(() -> 0);
   System.out.println(age); // 35
   System.out.println(optInt.getAsInt()); // 35

第一行使用静态方法 OptionalInt.of() 创建了一个 OptionalInt。第二行使用了 orElseGet() 方法。我们传递了一个 IntSupplier,这意味着我们传递了空值并返回一个 int(即 0)。由于 optInt 有值,所以使用该值初始化 age,而 IntSupplier 被忽略。第三行输出了变量 age。最后一行使用 getAsInt() 返回 int 值。如果 optional 中没有值,此方法也会像 getAsDouble() 一样生成一个 NoSuchElementException。然而,由于 optInt 包含一个值(35),它被返回并输出。

这样就完成了 Optional 部分的讲解。本章最简短的部分是并行流。

理解并行流

到目前为止的所有流都是顺序流,其结果是有序的。在顺序流中,单个线程一次处理一个条目。并行流由多个线程并发执行(在多个 CPU 上运行)。流元素被分成子流,这些子流由多个线程中的流管道实例处理。然后将这些部分子流的结果组合成最终结果。为了并行执行子流,流使用 Java 的 fork/join 框架来管理线程。

创建并行流

要将一个流转换为并行流非常简单。我们有两种选择:我们可以使用 Collection API 中的 parallelStream() 方法,或者使用 Stream API 中的 parallel() 中间操作。

这里是两种方法的示例:

Stream<String> parallelFarmAnimals =    List.of("sheep", "pigs", "horses").parallelStream(); // Collection API
Stream<String> parallelHouseAnimals =
    Stream.of("cats", "dogs").parallel(); // Stream API

让我们通过一个对比顺序流和并行流的例子来看看创建并行流有多简单。图 16.18 是代码(在仓库中的 ParalledStreams.java):

图 16.18 - 创建并行流

图 16.18 - 创建并行流

让我们先来检查一下顺序流:

   int sum = Stream.of(10, 20, 30, 40, 50, 60)                              .mapToInt(Integer::intValue)
                              .sum();
   System.out.println("Sum == "+sum);  // 210

我们最初生成一个 Stream<Integer> 的流。第二行使用 mapToInt() 函数将 Stream<Integer> 映射到 IntStream。换句话说,将 Integer 对象的流映射到 int 原始值的流。这样我们就可以在 IntStream 中使用 sum() 方法。结果,210 被输出。

并行版本是:

   int sum = Stream.of(10, 20, 30, 40, 50, 60)                              .parallel()  // Stream<T> method
                              .mapToInt(Integer::intValue)
                              .sum();
   System.out.println("Sum == "+sum);  // 210

唯一的区别在于第二行对 parallel() 的调用。这是一个 Stream 方法。这是抽象的极致!数据分区和线程管理由 API 和 JVM 处理。

并行分解

创建并行流是容易的部分。当执行 并行分解 时,事情变得有趣——任务被分解成更小的任务,这些任务并发执行,然后汇总其结果。

对于串行流,结果是有序的,因此是可预测的。对于并行流和并行分解,情况并非如此,因为顺序没有保证,因此结果是不可预测的。这是因为线程以任何顺序接受子任务,并以任何顺序返回结果。

让我们看看一个简单的代码示例来展示这一点。图 16**.19 展示了代码(ParalledStreams.java):

图 16.19 - 串行流中的排序和并行流中的无排序

图 16.19 - 串行流中的排序和并行流中的无排序

此图展示了一个 dbAction() 方法,该方法通过使线程休眠 1 秒来模拟数据库操作。当 orderedSerialStreams() 方法执行时,输出是可预测的:

10 20 30 40 50Operation took: 5 seconds.

整数按照源顺序排序,操作耗时 5 秒,每个值 1 秒

unorderedParallelStreams() 方法与串行版本相同,只是我们现在正在创建一个并行流。让我们来检查它的输出:

40 20 30 50 10Operation took: 1 seconds.

可以看到并行处理的明显性能优势:1 秒与 5 秒。请注意,这种性能提升取决于可用的 CPU 数量——如果在这台机器上运行此代码,处理器较少,则收益会更少。

然而,输出现在是无序的,因为 map()forEach() 都是并发应用的。我们本可以使用 forEachOrdered() 终端操作。这个操作确保消费者以元素离开源时的遇到顺序应用元素。在我们的例子中,这将依次是 1020304050图 16**.20 展示了它在代码中的样子(ParalledStreams.java)。

图 16.20 - forEachOrdered() 方法

图 16.20 - forEachOrdered() 方法

在此图中,终端操作不再是 forEach(),而是 forEachOrdered()。此图的输出如下:

10 20 30 40 50Operation took: 1 seconds.

现在整数是有序的,由于 map() 是并发应用的,性能提升仍然非常显著。

使用 reduce() 进行并行归约

由于并行流不保证顺序,并行归约的结果可能会出乎意料。归约操作将流合并成一个单一的结果。回想一下,重载的 reduce() 操作接受三个参数:一个恒等元、一个累加器和一个组合器。组合器函数在并行环境中用于组合累加器的结果。以下示例将要展示的是,累加器和组合器必须能够在它们执行的任何顺序下工作。它们必须是结合律的。

结合律

如果以下条件成立,则运算符或函数被认为是结合律的:

(a op b) op c) == a op (b op c).

例如,加法是结合律的:

(2 + 3) + 4 == 2 + (3 + 4) == 9

然而,减法不是结合律的:

(2 - 3) - 4 == -52 - (3 - 4) == 3

这在并行处理中非常重要。例如:

a op b op c op d == (a op b) op (c op d)

如果op是结合律的,那么(a op b)(c op d)可以并行评估;然后对结果执行op操作。

让我们首先检查串行归约:

   int result = Stream.of(1,2,3,4,5)                                .reduce(0,
                                              (n1, n2) -> n1 - n2);
   System.out.println(result); // -15

由于这是一个串行归约,不需要组合器。结果是-15。现在让我们检查并行版本,看看我们是否得到相同的结果。图 16**.21表示代码(ParallelStreams.java)。

图 16.21 - 使用 reduce()的并行归约

图 16.21 - 使用 reduce()的并行归约

在这个图中,我们将累加器和组合器都扩展了,以显示它们出现的值:

(n1, n2) -> { // accumulator    System.out.print(n1 + ", " + n2 + "\n");
    return n1 - n2;
},
(subTask1, subTask2) -> { // combiner
    System.out.print("\t" +subTask1 + ", " + subTask2 + "\n");
    return subTask1 - subTask2;
}

输出如下(组合子任务值缩进):

0, 1           // (identity, 1) == -1        // line 10, 3           // (identity, 3) == -3        // line 2
0, 5           // (identity, 5) == -5        // line 3
0, 2           // (identity, 2) == -2        // line 4
0, 4           // (identity, 4) == -4        // line 5
    -1, -2     // (line 1, line 4)        // line 6
    -4, -5     // (line 5, line 3)        // line 7
    -3, 1      // (line 2, line 6)        // line 8
    1, -4      // (line 7, line 8)        // line 9
5                 // line 9

注意,最终结果是5,这是不正确的。这是因为减法不是结合律的。有趣的是,在并行过程中,恒等式被应用于流中的多个元素,从而产生了意外的结果。

使用 collect()的并行归约

collect()方法与reduce()一样,有一个三个参数的版本,它接受一个累加器和组合器。对于第一个参数,collect()使用一个Supplier而不是恒等式。同样的规则也适用于这里 - 累加器和组合器操作必须能够以任何顺序执行。

应该使用并发集合,以避免并发线程导致ConcurrentModificationException。另一个考虑因素是目标集合 - 如果它是有序的(例如,一个List),那么维护该顺序所需的背景处理可能会降低性能。图 16**.22展示了并发集合的一个例子,即代码中的ConcurrentMap(ParallelStreams.java)。

图 16.22 - collect()返回一个并发集合

图 16.22 - collect()返回一个并发集合

代码的输出如下:

   {P=Paula, J=John, M=Mike, Mary}   class java.util.concurrent.ConcurrentHashMap

因此,这里的ConcurrentMap实现是一个ConcurrentHashMap。这并不保证,但某些ConcurrentMap接口的实现是保证的。

我们映射的键是名称的第一个字母:

   name -> name.charAt(0),  // key

与键关联的值是名称本身:

   name -> name,            // value

如果多个名称以相同的字母开头,则名称将附加,名称之间用逗号分隔:

   (name1, name2) -> name1 + ", "+ name2));// key collisions

这完成了我们对并行流的讨论,并且确实结束了第十六章. 现在,让我们将这一知识付诸实践,以巩固这些概念。

练习

  1. 为了使公园运行顺畅,我们需要跟踪所有恐龙的健康状况。我们需要识别任何生病的恐龙。使用Dinosaur对象的流,过滤掉生病的恐龙(假设Dinosaur类中存在isIll()方法),将它们映射到它们的名称,并将结果收集到一个列表中。最后,打印出需要立即关注的恐龙名称列表。

  2. 管理这样一个规模的恐龙公园涉及到处理大量数据。为了在公园中关于恐龙喂食时间的公告,创建一个恐龙列表,将其转换为流,并使用map()函数获取恐龙名称列表。然后,使用forEach终端操作为每个恐龙的喂食时间打印出一条消息。

  3. 跟踪所有恐龙所需的总食物量可能很棘手。假设你有一个包含所有恐龙重量的数组。将其转换为IntStream并使用sum方法获取公园中所有恐龙的总重量。这可以帮助你估计总食物需求。

  4. 当处理关于恐龙或员工的数据时,我们可能会遇到空引用。为了避免NullPointerException错误,在从恐龙映射中按名称检索恐龙时使用Optional。如果提供的名称的恐龙不存在,Optional应返回一条消息,表明恐龙尚未找到。

  5. 计算恐龙的平均重量可能是一个耗时操作,尤其是在处理大量恐龙时。为了加快处理速度,使用并行流。将恐龙重量列表转换为并行流并使用平均方法计算平均重量。

项目 - 动态恐龙护理系统

将 Stream API 集成到你的恐龙护理系统中以处理大量的恐龙数据,例如健康记录、喂食时间表等。系统还应适当地采用Optional和并行流,优化数据处理并最小化潜在的空指针异常。

这里是让你达到目的的步骤:

  1. Dinosaur类具有namespecieshealthStatus等属性。还应该有一个DinosaurCareSystem类来实现主要功能。

  2. 从记录列表中Stream并使用filter方法获取这些记录。以下是一个示例:List<HealthRecord> criticalRecords = records.stream().filter(r -> r.getHealthStatus() < CRITICAL_THRESHOLD).collect(Collectors.toList()).

  3. 使用Stream来过滤时间表。以下是一个示例:List<FeedingSchedule> morningFeeds = schedules.stream().filter(s -> s.getTime().isBefore(LocalTime.NOON)).collect(Collectors.toList()).

  4. 使用NullPointerExceptionOptional来避免NullPointerException错误。以下是一个示例:Optional.ofNullable(dinosaur.getTrainer()).map(Trainer::getName).orElse("No trainer assigned").

  • 在前面的示例中使用stream()parallelStream()。然而,要注意,并非每个问题都适合并行处理。如果任务有依赖关系或需要按特定顺序处理,请坚持使用常规流。

摘要

在本章中,我们探讨了高级流概念。我们首先探索了中间操作,这些操作非常强大,因为它们将流转换成另一个流。流行的中间操作包括:filter()distinct()limit()map()flatMap()sorted()。其中一些被称为有状态的,因为它们需要维护一些状态才能有效地操作。例如,limit()sorted()limit()方法也是短路的,因为它可以在源中还有更多数据可用的情况下关闭管道。

然后,我们检查了 API 中的原始流类型,即IntStreamLongStreamDoubleStream。这些类型有一些非常实用的方法来操作数值类型,例如sum()average()。我们还解释了新的原始流功能接口及其功能方法的命名背后的模式。

我们可以通过将另一个流映射来创建流。有许多方法可以做到这一点,但它们的命名遵循一定的模式。我们检查了这些方法并解释了这些模式。

Optional是可能为空或不为空的盒子。它们主要用于方法返回类型,其中确实需要表示“无结果”。而不是返回null(及其陷阱),我们可以返回一个空的Optional。我们可以使用Optional.ofOptional.empty()Optional.ofNullable()来创建OptionalOptional API 支持函数式编程风格;例如,ifPresent()让我们可以清楚地表达我们想要的内容,而无需else语句。我们还检查了原始的Optional,即OptionalIntOptionalLongOptionalDouble

最后,我们研究了并行流,可以使用Collection API 方法parallelStream()Stream API 方法parallel()轻松创建。虽然顺序流是有序的,但并行流不是。这是由于并行分解,任务被分解并在稍后重新组装。在并行多线程环境中,线程可以以任何顺序接收子任务,并以任何顺序返回结果。这对于关联任务(如加法)是可以的,但不适合减法。

如果你正在使用并行减少方法reduce()collect(),请确保累加器和组合函数是关联的;因为它们必须在不考虑执行顺序的情况下正确工作。

这就完成了我们对流的讨论。下一章,并发,将进一步巩固这里关于并行流的最后部分。

第十七章:并发

在上一章中,我们探讨了利用现代多核处理器的强大功能进行数据操作和并行操作的细微差别。这已经是对本章主题的初步介绍:并发!

并发允许应用程序同时执行多个任务。这使得系统更加高效。任何可用的资源都可以更有效地利用,这导致整体性能的提高。为了在 Java 中同时做很多事情,我们需要了解很多。这正是本章的目的!

下面是我们将要涵盖的内容:

  • 并发的定义

  • 与线程一起工作

  • 原子类

  • 同步关键字

  • 使用锁进行线程独占访问

  • 并发集合

  • 使用 ExecutorService

  • 常见的线程问题及其避免方法

这通常是一个令人畏惧(或线程化的?)的话题,尤其是对于新开发者来说,所以如果你需要反复阅读本章的部分内容,请不要气馁。我将尽力仔细地引导你了解你需要知道的所有概念。与你的应用程序不同,请专注于本章,并且不要同时做其他事情。让我们开始吧!

技术要求

本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch17

理解并发

你是否曾经想过一台计算机可以真正同时运行多少个任务?说“几个”可能很有诱惑力,然而,实际上,单核计算机在某一时刻只能执行一个进程。这可能会因为 CPU 在进程之间切换的速度之快而显得像是同时多任务处理,从而产生多任务处理的错觉。

并发是指同时执行多个任务或线程的概念,而不是按顺序执行。在顺序系统中,任务一个接一个地执行,每个任务在开始之前都要等待其前一个任务完成。

对于我们的 Java 应用程序来说,并发指的是同时执行程序的不同部分。这里的“同时”可能有点模糊,因为它可能意味着多种含义——这是因为并发可以在硬件级别发生,例如在多核处理器中,或者可以在软件级别发生。操作系统可以安排线程在不同的核心上运行。

我们具体指的是哪种并发类型取决于所采用的并发方式。它们的一个概述可以在图 17**.1中找到。这些可以是以下任何一种:

  • 多进程

  • 多任务处理

  • 多线程

首先,让我们讨论多进程。

多进程

多进程的背景下,多个 CPU 的存在使得同时执行多种进程变得容易。每个 CPU 独立执行自己的进程。为了从我们的日常生活中找到类比,考虑两个人管理一个家庭的情况,其中一个人忙于照顾孩子,而另一个人外出购物。他们都是“CPU”,同时并行处理独特的任务。

多任务

接下来的概念是多任务,其中“同时”这个词获得了一个稍微不同的含义。它意味着快速交替执行,而不是字面上的同时执行。想象一个场景,一个人在做饭的同时,不时地出去晾衣服(当然是在远离孩子的安全地方)。他们是“CPU”,在两个(或更多)任务之间不断切换,给人一种同时进步的错觉。然而,这并不完全构成并行执行,但确实是一种非常有效的资源利用方式。

多线程

最后但同样重要的是,我们有多线程——这恰好是我们的主要关注点。多线程涉及程序的不同部分在不同的执行线程上运行。这可以在单 CPU 和多 CPU 环境中发生。前面提到的两个日常场景都可以作为多线程的例子。

图 17.1 - 多进程、多任务和多线程的示意图

图 17.1 - 多进程、多任务和多线程的示意图

我们很快将深入探讨线程的概念。首先,让我们谈谈为什么我们需要在我们的应用程序(或者实际上是我们生活中!)中实现并发。

现代应用程序中并发的重要性

为了帮助您可视化计算中的并发,考虑您的计算机同时运行多个程序的方式。您可能同时运行浏览器、电子邮件客户端、文本编辑器、代码编辑器和 Slack。这种操作需要能够同时管理多个进程的能力。这种操作也存在于应用程序中,例如 IDE 在执行代码的同时处理您的输入。如果没有某种并发机制,停止具有无限循环的脚本将是不可能的,因为 IDE 会过于专注于无限循环的执行,而无法处理您点击按钮的操作。

让我们再思考一下网络服务;想象一个网络服务器同时处理数百甚至数千个请求。没有并发机制,这样的操作将是不切实际的,所以可以说,并发是我们日常计算机使用甚至日常生活中的一个基本方面!

让我们总结一下优势:

  • 性能提升:应用程序可以更快地完成操作

  • 响应性:即使在执行资源密集型任务时,应用程序也能保持响应(因为后台线程可以处理这些任务,而不会阻塞主线程)

  • 资源利用:通过利用多核处理器和其他硬件资源,更有效地使用系统资源

这样的优势使得实时执行用例成为可能。在这个时候,你可能会非常热衷于并发。你应该这样!然而,在我们的 Java 应用程序中采用并发确实带来了一组自己的成本和复杂性。让我们来谈谈它。

并发编程的挑战

我以前说过,现在再说一遍:每个魔术技巧都有代价。虽然并发提供了许多好处,但它也引入了可能使并发编程复杂甚至容易出错的挑战。我们甚至有一些在并发环境中独有的错误。我们稍后会更详细地提到它们,但在深入之前,记住这些是有好处的:

  • 数据竞争:当多个线程以非同步方式访问同一内存位置,并且至少有一个线程执行写操作时。例如,一个线程想要读取值并得出结论该值是 5,但另一个线程将其增加到 6。这样,前面的线程就没有最新的值。

  • 竞态条件:由于事件的时间和顺序而出现的问题。这种问题的事件顺序可能会影响结果的正确性。竞态条件通常需要来自操作系统、硬件甚至用户的输入。例如,当两个用户试图使用相同的用户名同时注册时,这种情况可能会发生。如果处理不当,这可能会导致不可预测和不受欢迎的结果。

  • 死锁:当两个或更多线程都在等待对方释放资源时,可能会发生死锁,导致应用程序无响应。例如,当你认为你的朋友会给你打电话,你一直等到他们这样做,而你的朋友认为你会给他们打电话,他们也一直等到你这样做,什么也没有发生,友谊就陷入了僵局。

  • 活锁:与死锁类似,当两个或更多线程陷入循环,由于不断变化的条件而无法前进时,会发生活锁。比如说,你和你的朋友说你们会在市中心的一个教堂见面。你在教堂 a,你的朋友在教堂 b。你怀疑你的朋友是否在教堂 b,于是你走去那里。你的朋友怀疑你是否在教堂 a,也走去那里。(而你选择了不同的路线,没有碰到彼此。)你们在教堂没有找到对方,继续从教堂 a 走到教堂 b。这不是很有效的资源利用(但所有这些走路可能对你的健康都有好处!)

饥饿:当一个线程无法获得其进步所需的资源时,它可能会经历饥饿,导致应用程序性能下降和资源使用效率低下。一个现实生活中的例子可能是一个繁忙的酒吧,许多人试图从酒保那里获取饮料。酒吧里的人很多;这些人代表线程。酒保正在为那些喊得最响亮的人服务(相当于具有较高优先级的线程)。那个不出众的害羞的人会经历“饥饿”(或渴望),因为他无法访问共享资源(酒保)。

挑战是存在的!Java 提供了各种并发构造和工具,我们将在本章中探讨这些内容。我会不时地提到上述问题。在本章结束时,你甚至将看到一些破坏事物的例子!但首先,让我们谈谈并发的一个关键概念:线程!

线程的工作

让我们最终来解释一下线程。线程是一系列执行指令的序列,代表着执行的最基本单元。每个线程都遵循代码中的特定路径。线程在进程内执行特定任务。一个进程通常由多个线程组成。

例如,我们迄今为止创建的程序都有一个用户创建的线程(在这种情况下,用户是开发者)。线程按照一定的顺序遍历代码行;例如,当调用一个方法时,线程会在继续执行方法调用后直接位于方法之后的下一行代码之前执行该方法。这是线程的执行路径。

当多个线程运行时,代码中的多个执行路径正在被遍历,这就是为什么同一时间会发生多件事情的原因。

为了使这成为可能,我们需要某些 Java 结构的副本。例如,我们不能有两个线程使用相同的栈。这就是为什么每个线程都有自己的栈。我们不会深入探讨 Java 内存模型的细节。然而,至少要意识到,尽管每个线程都有自己的栈,但它们与其他线程共享堆。

为了让你们的大脑更容易消化,我们将用一些不太有趣但易于理解的例子来解释这个理论。我们将从线程开始。创建和启动线程有多种方式。让我们看看如何使用Thread类来创建一个线程。

Thread

创建线程最简单的方法之一是通过扩展Thread类。Thread类通过run()方法为线程的执行提供了一个入口点。要创建一个自定义线程,你需要定义一个Thread的子类,并用线程应该执行的代码覆盖run()方法。以下是一个用于演示的愚蠢示例:

class MyThread extends Thread {    @Override
    public void run() {
        System.out.println("Hello from MyThread!");
    }
}

然后在某个其他类(或者甚至是在同一个类中,但这可能会令人困惑),我们可以创建一个新的MyThread,并通过start()方法启动线程执行:

public class Main {    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start(); // starts the new thread
    System.out.println("Hello from Main!");
    }
}

这将输出以下两行,但我们无法确定它们的顺序:

Hello from MyThread!Hello from Main!

start()方法是继承自我们的Thread类的一部分,用于启动一个新的线程。你也可以通过调用myThread.run()来执行run()方法的内容,但这不会启动一个新的线程!这将与执行main方法的同一个线程相同,这将执行run()方法的内容。

我们以这种方式创建线程是因为它最容易理解。这绝对不是最常见的方式。更常见的是实现Runnable接口。让我们看看如何实现它。

可运行的接口

创建线程的另一种方法是实现Runnable接口。这是一个内置的函数式接口,可以用于在 Java 中创建线程。Runnable接口有一个单一的方法run(),当你扩展此接口时,必须在你的类中实现此方法。与扩展Thread类不同,你将你的Runnable实现实例传递给Thread对象。以下是一个示例:

class MyRunnable implements Runnable {    @Override
    public void run() {
        System.out.println("Hello from MyRunnable!");
    }
}

再次强调,我们现在可以在另一个位置实例化MyRunnable。然而,第二步是不同的;我们将实例化Thread类,并将我们的MyRunnable实例传递给其构造函数。这样,当我们启动线程实例时,我们在Runnable实例的run()方法中指定的任何内容都将被执行:

public class Main {    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start(); // starts the new thread
    }
}

这将输出以下内容:

Hello from MyRunnable!

再次强调,为了执行MyRunnablerun方法中的内容,我们本来可以写myRunnable.run(),但这也不会启动一个新的线程!让我们证明我们实际上启动了一个新的线程。每个线程都有一个唯一的 ID。通过在run方法中输出线程的 ID,我们可以证明它是一个不同的线程。以下是调整后的示例:

class MyRunnable implements Runnable {    @Override
    public void run() {
        System.out.println("Hello from thread: " + Thread.
          currentThread().threadId());
    }
}

下面是我们的调整后的Main类:

public class Main {    public static void main(String[] args) {
        System.out.println("Hello from main: " + Thread.
          currentThread().threadId());
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start(); // starts the new thread
    }
}

这将打印以下内容:

Hello from main: 1Hello from thread: 22

请注意,对于你来说,ID 可能不同,但它们也将是两个不同的线程。由于 Java 启动的一些后台线程(如垃圾收集器)的原因,线程 ID 在多次执行中保持一致。假设我们将start()方法改为run(),如下所示:

public static void main(String[] args) {        System.out.println("Hello from main: " + Thread.
          currentThread().threadId());
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.run(); // doesn't start a new thread
    }

ID 是相同的;这是结果:

Hello from main: 1Hello from thread: 1

这已经相当常见了,但更常见的情况是我们不创建一个Runnable的类,而是使用 Lambda 表达式实现Runnable。你可能还记得从第十五章和第十六章的 Lambda 表达式章节中,我们可以使用 Lambda 表达式实现任何函数式接口。让我们看看这是如何实现的。

带有 Runnable 的 Lambda 表达式

由于Runnable接口是一个只有一个方法的函数式接口,你可以使用 Lambda 表达式更简洁地创建和运行线程。以下是一个使用 Lambda 表达式的示例:

public static void main(String[] args) {    Runnable myRunnable = () -> System.out.println("Hello
      from a lambda Runnable!");
    Thread thread = new Thread(myRunnable);
    thread.start(); // starts the new thread
}

如你所见,我们不再需要为Runnable创建一个单独的类。我们可以在现场完成它。以下是输出:

Hello from a lambda Runnable!

正如我之前提到过几次,如果你使用run()而不是start(),在这种情况下你会得到相同的输出,但这并不是由一个新线程完成的。

这些是创建线程的基本方法。让我们看看我们如何通过sleep()join()来控制执行。所以,和我一起进入睡眠状态吧!

线程管理——sleep()和 join()

这可能是一个奇怪的说法,但线程可以进入睡眠状态。这意味着线程的执行会暂停一段时间。在我们深入探讨如何做到这一点之前,值得注意的是,这通常被认为是一种代码异味。这意味着它可能是解决数据竞争或加载时间挑战等问题的有问题的解决方案。然而,有时你将需要这样做——例如,为了减慢后台线程。只是确保在这里谨慎行事。现在让我们看看我们如何让我们的线程进入睡眠状态。

Thread.sleep()方法

Thread.sleep()方法是一个静态方法,它会导致当前正在执行的线程进入睡眠状态。这意味着暂停其执行一段时间。这对于模拟延迟、允许其他线程执行或执行基于时间的操作很有用。sleep()方法接受一个单一参数,即睡眠的持续时间(以毫秒为单位)。以下是一个示例:

public class Main {    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            try {
      // Next two lines represent the same Java line
                System.out.println("Thread will go to sleep
                  for 2 seconds...");
                Thread.sleep(2000);
                System.out.println("*Yawn...* I'm awake!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
    }
}

我们需要在这里使用try/catch块,因为sleep()可以被中断。这种中断会导致检查型异常InterruptedException被抛出。

处理InterruptedException

假设主线程决定执行时间过长并想要结束程序,它可以通过使用interrupt方法建议次级线程停止。如果实例被命名为 t,这可以通过t.interrupt()来实现。中断一个正在睡眠的线程会抛出InterruptedException

这是一个检查型异常,如果你使用Thread.sleep()方法,你必须处理它。我们也可以让我们的线程等待另一个线程完成。这是通过join()方法完成的。

使用join()方法

线程可以等待另一个线程完成。join()方法允许调用线程等待直到指定的线程完成其执行。这在需要确保在继续之前特定线程已完成其工作时很有用。以下是一个示例,其中主线程正在等待线程t1

public class Main {    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("t1 started");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread finished!");
        });
        t1.start();
        try {
            System.out.println("Main thread will be waiting
              for other t1 to be done...");
            t1.join();
            System.out.println("Main thread continues...");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

这将输出以下内容:

Main thread will be waiting for other t1 to be done...t1 started
t1 finished!
Main thread continues...

因此,如您所见,调用了t1.join()。这使得主线程等待t1完成执行(包括 2 秒的睡眠时间)之后,主线程才继续。主线程也可以通过调用t1.join(1000)来等待指定的时间,例如 1 秒。这要安全一些,因为如果t1由于某种原因无限期地挂起,我们的程序会卡住。你应该尝试移除join()并运行程序几次,以检查行为并看你是否能让它无限期地挂起。

如您所见,当我们使用join()方法时,也需要捕获InterruptedException。这是在调用线程在等待其他线程完成时被中断的情况下。

让我们看看如何避免(或解决)并发环境中读写操作的一些常见问题。

原子类

在并发程序中,数据完整性可能很容易成为问题。想象两个线程读取一个值,然后都更改它,并在之后立即覆盖彼此的更改。例如,这可能导致计数器最终只增加了一个,而它应该是增加两个。数据完整性丢失!这就是原子类发挥作用的地方。

原子类用于原子操作。这意味着读取(获取值)和写入(更改值)被视为一个操作,而不是两个独立的操作。这避免了我们刚刚演示的数据完整性问题。我们将简要讨论如何使用AtomicIntegerAtomicLongAtomicReference

AtomicInteger、AtomicLong 和 AtomicReference

有几个原子类用于基本数据类型。我们有AtomicInteger来表示整数值并支持对其执行原子操作。同样,我们有AtomicLong用于Long类型。我们有AtomicReference用于对象的引用并支持对其执行原子操作。

这些原子类提供了执行原子操作的方法,例如getsetcompareAndSet以及各种算术操作。让我们看看一个没有AtomicInteger会出问题的例子:

public class ExampleAtomicInteger {    private static AtomicInteger counter = new
      AtomicInteger(0);
    public static void main(String[] args) throws
      InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.getAndIncrement();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.getAndIncrement();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("Counter value: " + counter);
    }
}

此代码将打印以下输出:

Counter value: 20000

没有使用AtomicInteger,程序结束时counter的值可能会有所不同。它可能是 14387、15673、19876 等等。(它不能超过 20000)。这是因为多个线程会同时读取它(所以读取相同的值),然后在下一个操作中更新它,从而可能写入一个比counter当前值更低的值。

为了说明,想象一下。你和两个朋友在一个房间里。桌子上有一个帽子,里面有一张纸。纸是折叠的,上面有一个数字,数字 4。你们三个人都需要将值增加 1。如果你的朋友读取了值,然后把它放回帽子,然后开始在房子里找纸和笔。你的另一个朋友可能在你的朋友有机会增加数字之前就读取了值。你的另一个朋友有一张纸和一支笔可用(真是个好朋友,不与另一个朋友分享),并用新值 5 替换了纸条。然后你接着去,读取值,看到它是 5,拿出你的纸和笔,写下数字 6 并将其放入帽子。然后你的另一个朋友最后回来,并更新纸条的新值,根据他阅读时的知识,应该是 5。帽子中的最终值是 5。尽管之前是 6,但它又回到了 5。你和你的朋友的行为就像线程,它们将读取和写入视为两个不同的操作。

假设你们不仅是朋友,而且是原子朋友。这意味着你们会将读取和写入视为一个动作。所以,在读取之后,你们不会在更新新值之前将纸条放回帽子中。因此,现在,如果你们所有人都必须将其增加 1,就不会有混淆,值最终会是 7。

我们可以使用原子类以 Java 方式完成这项操作。在上面的代码片段中,getAndIncrement 方法确保两个线程不能同时访问计数器,并保证计数器将具有正确的值。这是因为获取和增加不是两个独立的操作,而是一个原子操作。这就是为什么原子类在需要确保在不使用显式同步的情况下消费共享资源的多线程环境中特别有用。然而,我们始终可以使用显式同步。接下来,让我们探讨 synchronized 关键字。

synchronized 关键字

正如我们刚才看到的,与许多线程一起工作可能会带来潜在的新问题,例如数据完整性。synchronized 关键字是 Java 中的一个关键字,它使用锁机制来实现同步。它用于控制不同线程对代码关键部分的访问。当一个线程在同步方法或块内部时,其他线程不能进入同一对象的任何同步方法。

为了理解同步的需求,让我们考虑另一个简单的并发计数场景,其中可能会出现意外结果。我们有一个名为 Count 的类,其中有一个静态的 counter 变量。这个类还有一个方法,incrementCounter,它将 counter 的值增加 1:

public class Count {    static int counter = 0;
    static void incrementCounter() {
        int current = counter;
        System.out.println("Before: " + counter + ",
          Current thread: " + Thread.currentThread()
            .threadId());
        counter = current + 1;
        System.out.println("After: " + counter);
    }
}

这个程序在单线程环境中以for循环运行 10 次,将按预期行为,将计数器顺序地从 0 增加到 10。线程的 ID 值也将相同,因为它是单线程。

Before: 0, Current thread: 1After: 1
Before: 1, Current thread: 1
After: 2
Before: 2, Current thread: 1
After: 3
Before: 3, Current thread: 1
After: 4
Before: 4, Current thread: 1
After: 5
Before: 5, Current thread: 1
After: 6
Before: 6, Current thread: 1
After: 7
Before: 7, Current thread: 1
After: 8
Before: 8, Current thread: 1
After: 9
Before: 9, Current thread: 1
After: 10

现在,想象一下,我们不是在一个单线程环境中,而是有 10 个线程,每个线程的任务是增加counter

public class Main {    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(Count::incrementCounter).start();
        }
    }
}

现在我们遇到了一个问题!这是我的输出(你的可能不同!):

Before: 0, Current thread: 26Before: 0, Current thread: 29
Before: 0, Current thread: 22
Before: 0, Current thread: 30
Before: 0, Current thread: 25
Before: 0, Current thread: 31
Before: 0, Current thread: 23
Before: 0, Current thread: 24
Before: 0, Current thread: 27
Before: 0, Current thread: 28
After: 1
After: 1
After: 1
After: 1
After: 1
After: 1
After: 1
After: 1
After: 1
After: 1

输出变得不可预测,这是因为一种称为线程干扰的现象。在多线程环境中,多个线程可能同时读取和增加counter的值。这种并发修改可能导致意外结果,造成数据完整性的丢失。这又是由于竞争条件引起的。我们已经看到如何通过使用原子类来解决这个问题,但我们也可以通过同步方法来解决。最佳选择是允许代码的大部分部分有多个线程,而不创建数据完整性问题。对于这种情况,那就是原子类。然而,这是一个很好的例子,可以展示同步关键字是如何工作的。

使用同步方法

要创建一个同步方法,你只需在方法定义之前添加同步关键字。这确保了对于给定的对象实例,一次只有一个线程可以执行该方法。以下是更新后的示例:

public class Count {    static int counter = 0;
    static synchronized void incrementCounter() {
        int current = counter;
        System.out.println("Before: " + counter + ",
          Current thread: " + Thread.currentThread()
            .threadId());
        counter = current + 1;
        System.out.println("After: " + counter);
    }
}

在这种情况下,如果多个线程同时调用incrementCounter()方法,同步关键字确保一次只有一个线程可以访问该方法。这防止了竞争条件。在不修改Main类的情况下,这将产生以下输出(你的线程 ID 可能不同):

Before: 0, Current thread: 22After: 1
Before: 1, Current thread: 31
After: 2
Before: 2, Current thread: 30
After: 3
Before: 3, Current thread: 29
After: 4
Before: 4, Current thread: 28
After: 5
Before: 5, Current thread: 27
After: 6
Before: 6, Current thread: 26
After: 7
Before: 7, Current thread: 25
After: 8
Before: 8, Current thread: 24
After: 9
Before: 9, Current thread: 23
After: 10

你可以想象,同步整个方法可能是不高效的。因为这会使所有线程在方法外部等待,并可能成为性能的瓶颈。很可能方法中的一部分代码可以在不威胁数据完整性的情况下由多个线程同时执行。有时,你可能只需要同步方法的一部分。这可以通过同步块来实现。

使用同步块

在某些情况下,你可能只想同步方法的一部分,而不是整个方法。为此,你可以使用一个同步块。同步块需要一个对象来锁定,并且代码块在持有锁的情况下执行。以下是一个示例:

class Counter {    private int count;
    public void increment() {
        synchronized (this) {
            count++;
        }
    }
    public int getCount() {
        synchronized (this) {
            return count;
        }
    }
}

在这个代码片段中,increment()getCount()方法使用同步块而不是同步方法。结果是相同的——在多线程环境中,count变量被安全地访问和修改。

同步方法与同步块的比较

最小化同步的范围是一种最佳实践,可以提高性能并减少线程之间的竞争。这个概念与锁粒度密切相关,它指的是被锁定代码的大小或范围。粒度越细,锁定的部分越小,允许更多的线程并行执行,而不必互相等待。

将大量代码或整个方法同步化被认为是粗粒度锁定,可能会导致性能下降。在这种情况下,多个线程可能会排队等待单个锁被释放,这可能会创建瓶颈。虽然粗粒度锁定对于确保数据完整性可能是必要的,但它应该谨慎使用,并且只有在没有其他选择的情况下才使用。

另一方面,细粒度锁定涉及使用同步块将同步的范围限制在最小的可能关键部分。这允许更好的并发性,因为线程不太可能因为等待锁而被阻塞,从而提高系统的吞吐量。

因此,为了在不牺牲数据完整性的情况下实现最佳性能,应尽可能使用同步块来实现细粒度锁定。这与最小化同步范围的原则相吻合。synchronized关键字提供了一种低级同步机制。对于更复杂的情况,考虑使用更高级的并发构造,例如Lock接口或并发集合。接下来,让我们看看Lock接口!

锁定接口

让我们谈谈Lock接口。这是处理并发控制的synchronized关键字的替代方案。虽然synchronized帮助我们实现线程安全,但它也引入了一些缺点:

  • 线程在等待锁时会被阻塞,这可能会浪费处理时间

  • 没有机制可以检查锁是否可用,或者如果锁被持有时间过长则超时

如果需要克服这些限制,可以使用提供更多同步控制的内置Lock接口的实现。我们将讨论最常见的一种实现:ReentrantLock

ReentrantLock

ReentrantLock类是Lock接口的一个流行实现。ReentrantLock用于保护代码的一部分,类似于synchronized,但通过其方法提供了额外的功能:

  • lock():此方法锁定锁

  • unlock():此方法释放锁

  • tryLock():此方法尝试获取锁,并返回一个布尔值,指示是否获取了锁

  • tryLock(time, unit):此方法尝试在指定的时间内获取锁

让我们更新我们用来演示ReentrantLock的同步关键字的示例:

import java.util.concurrent.locks.Lock;import java.util.concurrent.locks.ReentrantLock;
public class Count {
    static int counter = 0;
    static Lock lock = new ReentrantLock();
    static void incrementCounter() {
        try {
            lock.lock();
            int current = counter;
            System.out.println("Before: " + counter + ",
              Current thread: " + Thread.currentThread()
                .threadId());
            counter = current + 1;
            System.out.println("After: " + counter);
        } finally {
            lock.unlock();
        }
    }
}

在前面的代码片段中,我们用ReentrantLock替换了synchronized块。我们在临界区之前锁定Lock,并在finally块之后解锁。在finally块中的解锁至关重要;否则,当发生异常时,锁不会被释放。

Main类保持不变:

public class Main {    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(Count::incrementCounter).start();
        }
    }
}

这一切运作得就像魔法一样。以下是输出结果:

Before: 0, Current thread: 22After: 1
Before: 1, Current thread: 23
After: 2
Before: 2, Current thread: 24
After: 3
Before: 3, Current thread: 25
After: 4
Before: 4, Current thread: 26
After: 5
Before: 5, Current thread: 27
After: 6
Before: 6, Current thread: 28
After: 7
Before: 7, Current thread: 29
After: 8
Before: 8, Current thread: 30
After: 9
Before: 9, Current thread: 31
After: 10

但如果块已经被锁定呢?我们不想无限期地等待。在这种情况下,可能最好使用tryLock。如果锁不可用,线程可以继续执行其他任务。这是与使用synchronized关键字相比的一个优点!以下是更新后的代码:

public class Count {    static int counter = 0;
    static Lock lock = new ReentrantLock();
    static void incrementCounter() {
        if (lock.tryLock()) {
            try {
                int current = counter;
                System.out.println("Before: " + counter +
                  ", Current thread: " + Thread.
                    currentThread().threadId());
                counter = current + 1;
                System.out.println("After: " + counter);
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println("Thread didn't get the lock
              and is looking for a new task.");
        }
    }
}

如您所见,我们用tryLock()包围了try块。如果锁不可用,线程将继续执行其他工作。我们也可以使用tryLock(time, unit)方法等待特定时间的锁。

由于本书的范围,我们不会深入探讨,但还有其他锁可用——例如,ReadWriteLock接口。它将读操作和写操作分开,允许多个并发读取但独占写入。这可以在读取密集型工作负载中提高性能。

使用锁的最佳实践

当使用Lock接口工作时,需要牢记以下几点最佳实践:

  • 总是在finally块中解锁,以确保即使在异常情况下也能释放锁。

  • 使用tryLock()进行非阻塞操作,这有助于避免死锁并提高性能。

  • 尽管我们没有详细讨论,但考虑在读取密集型工作负载中使用ReadWriteLock。这允许并发读取和独占写入。这可以提高应用程序的吞吐量。

关于锁的话题就到这里吧!让我们谈谈在 Java 中处理并发性的另一个关键工具:并发集合!

并发集合

多线程环境对于性能很重要,但在任何多线程环境中,数据完整性都成为一个需要考虑的问题。想象一下这种情况:你有几个线程正在与一个共享数据结构(如ArrayListHashMap)交互。当一个线程可能正在尝试从结构中读取数据时,另一个线程可能正在写入它。这可能导致数据不一致和其他类型的错误。

在这种情况下出现的一个常见问题是并发修改异常。这发生在当一个线程正在遍历一个数据结构时,另一个线程试图修改它。Java 意识到这可能导致不一致性,并抛出异常以防止这种危险的操作。

考虑以下示例,其中使用了HashMap

Map<String, String> languageMap = new HashMap<>();languageMap.put("Maaike", "Java");
languageMap.put("Seán", "C#");
for (String key : languageMap.keySet()) {
    System.out.println(key + " loves coding");
    languageMap.remove(key);
}

在这个例子中,我们试图在过程中遍历HashMap并删除一个条目。这将抛出ConcurrentModificationException

你可能已经猜到了;这正是为什么我们有并发集合。并发集合,如 ConcurrentHashMap,是 HashMap 的线程安全替代品,这意味着它可以处理来自多个线程的并发读取和写入。使用 ConcurrentHashMap,你可以在遍历地图的同时修改它:

ConcurrentMap<String, String> languageMap = new  ConcurrentHashMap<>();
languageMap.put("Maaike", "Java");
languageMap.put("Seán", "C#");
for (String key : languageMap.keySet()) {
    System.out.println(key + " loves coding");
    languageMap.remove(key);
}

这次我们不会得到 ConcurrentModificationExceptionConcurrentHashMap 允许我们在迭代时移除项目。

不仅如此!并发集合还提供了另一个优势。它们允许我们基于段进行锁定。这意味着多个线程可以同时具有读取访问权限,这可以在不牺牲数据完整性的情况下提高性能。

并发集合接口

java.util.concurrent 包中,有几个接口旨在促进集合上的并发操作。我们将讨论的两个主要接口是 ConcurrentMapBlockingQueue

ConcurrentMap

ConcurrentMap 是标准 java.util.Map 的子接口。它提供了对键值对进行添加、删除和替换的原子操作,增强了线程安全性。ConcurrentMap 的两种主要实现是 ConcurrentHashMapConcurrentSkipListMap。它的工作方式与常规 Map 非常相似:

ConcurrentMap<String, String> map = new  ConcurrentHashMap<>();
map.put("Nadesh", "PHP");
String language = map.get("Nadesh");  // Returns "PHP"

ConcurrentHashMap 是一种线程安全的 Map 实现,其性能优于 Hashtable(一种较老的线程安全替代品)。它允许并发读取和写入,最小化竞争。

阻塞队列

BlockingQueue 是另一个接口,是 Queue 的子类型,针对多线程操作进行了优化。与标准队列不同,当尝试向满队列添加元素或从空队列中检索元素时,BlockingQueue 将会阻塞或超时:

BlockingQueue<String> queue = new LinkedBlockingQueue<>();queue.offer("Maria");
String name = queue.poll();

这些接口提供了在多线程环境中工作时非常有价值的功能,提高了性能和数据完整性。

在未来,你可能会遇到相当多的其他并发集合实现,它们的工作方式与它们的非并发对应物非常相似。我们将讨论两个类别:SkipListCopyOnWrite 集合。

理解 SkipList 集合

ConcurrentSkipList 集合表示自然排序的集合,这意味着它们以排序的方式维护其元素。ConcurrentSkipListSetConcurrentSkipListMap 是两种最常见的 ConcurrentSkipList 集合。它们的工作方式与我们习惯的集合非常相似。

ConcurrentSkipListSet

使用 ConcurrentSkipListSet 与使用 TreeSet 相同,但它针对并发使用进行了优化。让我们看看一个例子:

Set<String> set = new ConcurrentSkipListSet<>();set.add("Gaia");
set.add("Jonas");
set.add("Adnane");
for (String s : set) {
    System.out.println(s);
}

在前面的代码块中,当你打印集合时,元素将以它们的自然顺序显示:AdnaneGaiaJonas

ConcurrentSkipListMap

ConcurrentSkipListMap 的工作方式与 TreeMap 类似,但它是为并发操作设计的。像 ConcurrentSkipListSet 一样,映射条目按照其键的自然顺序维护:

Map<String, String> map = new ConcurrentSkipListMap<>();map.put("Flute", "Nabeel");
map.put("Bass", "Job");
map.put("Piano", "Malika");
for (String s : map.keySet()) {
    System.out.println(s + ": " + map.get(s));
}

在此代码中,映射条目按照键的字母顺序打印:BassFlutePiano

理解 CopyOnWrite 集合

如其名称所示,CopyOnWrite 集合在每次修改时都会创建集合的新副本。这意味着当读操作多于写操作时,它们表现良好,但当写操作多于读操作时,可能会效率低下。让我们讨论常见的实现。

CopyOnWriteArrayList

CopyOnWriteArrayList 的工作方式与常规 ArrayList 相同,但每次修改列表时都会创建列表的新副本:

List<String> list = new CopyOnWriteArrayList<>();list.add("Squirrel");
list.add("Labradoodle");
list.add("Bunny");
for (String item : list) {
    System.out.println(item);
    list.add(item);
}
System.out.println(list);

即使我们在迭代过程中修改列表,也不会导致 ConcurrentModificationException,因为修改时创建了一个列表的新副本。

CopyOnWriteArraySet

CopyOnWriteArraySetHashSet 类似,但每次修改集合时都会创建一个新的副本:

Set<String> set = new CopyOnWriteArraySet<>();set.add("Dog");
set.add("Cat");
set.add("Horse");
for (String s : set) {
    System.out.println(s);
    set.add(s);
}
System.out.println(set);

在前面的代码中,由于集合只包含唯一对象,因此循环结束后集合的大小保持不变。

同步集合

同步集合 是在多线程环境中使用集合的另一种方式。Collections 类提供了几个静态方法,用于返回常规集合(如 ListSetMap)的同步版本。以下是一个 List 的示例:

List<String> regularList = new ArrayList<>();List<String> syncList =
  Collections.synchronizedList(regularList);

在这个例子中,syncListregularList 的线程安全版本。当需要将现有的集合转换为线程安全集合时,这些同步集合是一个不错的选择,但如果知道集合在创建时将在多线程环境中使用,则最好使用并发集合,因为它们的性能更好。

同步集合和并发集合之间最重要的区别是,同步集合不能在循环中修改,否则会抛出 ConcurrentModificationException。在其他方面,它们是安全的,并且在使用多个线程时不会导致数据完整性问题。手动管理大量线程将是一项相当艰巨的任务。幸运的是,有一个特殊的接口 ExecutorService,可以帮助我们完成这项任务!

ExecutorService 和线程池

Java 的 ExecutorService 是异步执行任务的机制。作为 java.util.concurrent 包的一部分,ExecutorService 是一个用于在多线程环境中管理和控制线程执行的接口。我们迄今为止已经看到了如何手动控制线程,现在我们将看到如何使用 ExecutorService。我们将看到 ExecutorService 及其实现(如 SingleThreadExecutorScheduledExecutorService)的详细信息。让我们首先看看 SingleThreadExecutor

使用 SingleThreadExecutor 执行任务

首先,让我们从SingleThreadExecutor开始。这个ExecutorService有一个单独的工作线程来处理任务,保证任务按照提交的顺序执行。当我们需要顺序执行时,它很有用。

考虑一个选举场景,其中正在计算选票。为了模拟这个过程,我们将每个投票表示为一个任务。为了简单起见,让我们假设我们正在计算一个候选人的选票。

我们可以这样做到:

import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
public class VoteCounter {
    public static void main(String[] args) {
        ExecutorService executor = Executors.
          newSingleThreadExecutor();
        // Submitting tasks
        for(int i=1; i<=4; i++) {
      // We must create a new variable to use in the
      // lambda, because variables in lambdas must be
      // effectively final. And i is not.
            int voteId = i;
            executor.execute(() -> {
                System.out.println("Vote " + voteId + "
                  counted by " + Thread.currentThread().
                    threadId());
            });
        }
        // Remember to shutdown the executor
        executor.shutdown();
    }
}

在前面的代码块中,我们首先创建了一个SingleThreadExecutor实例。然后提交了四个任务,每个任务代表一个正在计算的投票。请注意,我们使用executor.execute(),传递一个Runnable lambda 函数作为参数。这个函数打印投票编号和处理的线程 ID。最后,我们使用executor.shutdown()关闭ExecutorService。这是至关重要的,因为要终止执行器的非守护线程,否则将阻止您的应用程序终止。非守护线程是一种阻止程序结束的线程。当你忘记这样做时,你会看到一旦运行程序,它就不会停止。停止按钮将保持可见。

这是我输出的结果(对我来说):

Vote 1 counted by 22Vote 2 counted by 22
Vote 3 counted by 22
Vote 4 counted by 22

如您所见,它将计算四个投票,每次打印相应的投票编号和相同的线程 ID,因为所有任务都由单个线程处理。实际上,我们也可以同时调用多个任务。在我们能够这样做之前,我们需要了解CallableFuture。那么,让我们首先看看这意味着什么——未来是在调用我们吗?

Callable 接口和 Future

虽然Runnable接口允许你并发执行代码,但它不会返回结果。相比之下,Callable接口允许并发任务产生结果。它有一个单一的call方法,返回一个值。因此,它也是一个函数式接口。

ExecutorService不仅执行Runnable任务,还执行返回结果的Callable任务。submit()方法用于执行Callable任务。这个submit()方法返回一个Future对象,可以在结果准备好后用来检索结果。如果你想用一个非代码的例子来思考,你可以将其比作在餐厅下订单:你收到一个代币(Future),你可以在它准备好时用它来取回你的订单(结果)。

Future对象代表一个正在进行的计算的结果——一种占位符。当你向ExecutorService提交一个Callable任务时,它返回一个Future对象。你可以使用这个Future对象来检查计算是否完成,等待其完成,并检索结果。现在是时候看看一个例子了!

提交任务和处理结果

让我们模拟使用Callable任务计算选票并维护计票。在这里,我们将使用submit()方法,它返回一个Future对象。

代码可能看起来像这样:

import java.util.concurrent.*;public class VoteCounter {
    private static final ExecutorService executorService =
      Executors.newSingleThreadExecutor();
    public static void main(String[] args) {
        try {
            Future<Integer> vote1 = getRandomVote(1);
            Future<Integer> vote2 = getRandomVote(2);
            Future<Integer> vote3 = getRandomVote(3);
            Future<Integer> vote4 = getRandomVote(4);
            // wait until all tasks are done
            while (!(vote1.isDone() && vote2.isDone() &&
              vote3.isDone() && vote4.isDone())) {
                Thread.sleep(10); // sleep for 10ms then
                                 //   try again
            }
            int totalVotes = vote1.get() + vote2.get() +
              vote3.get() + vote4.get();
            System.out.println("Total votes: " +
              totalVotes);
        } catch (InterruptedException |
            ExecutionException e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
    }
    public static Future<Integer> getRandomVote(int i) {
        return executorService.submit(() -> {
            Thread.sleep(1000); // simulate delay
            System.out.println("Vote " + i + " counted by "
              + Thread.currentThread().threadId());
            return 1; // each vote counts as 1
        });
    }
}

下面是它将输出的结果:

Vote 1 counted by 22Vote 2 counted by 22
Vote 3 counted by 22
Vote 4 counted by 22
Total votes: 4

在前面的代码中,我们仍在使用SingleExecutorService。在getRandomVote方法中,我们使用executorService.submit()向我们的ExecutorService提交了四个Callable任务。每个任务等待一秒钟(模拟投票计数)然后返回1(代表一票)。每个提交都返回一个Future对象,分别存储在vote1vote2vote3vote4中。

然后,我们循环等待直到所有Future对象报告它们已完成。一旦所有投票都已计数(所有Future对象已完成),我们就使用每个Future对象的get()方法检索结果。这被包裹在一个try/catch块中,以处理可能出现的异常。最后,我们将所有投票相加并打印总票数。在我们继续之前,让我们再谈谈Future类上的方法。

未来对象及其方法

Future对象提供了几个方法来处理异步计算的结果。我们已使用isDone()来检查任务是否完成并且结果已就绪。并且我们已经使用get()在任务完成时获取任务的结果。这个get()方法会等待直到任务执行完成。以下是一些其他重要的方法:

  • get(long timeout, TimeUnit unit): 只有在提供的超时时间内就绪时才检索结果

  • isCancelled(): 检查计算是否被取消

  • cancel(boolean mayInterruptIfRunning): 尝试取消任务

在最新的示例中,我们逐个提交了四个任务,但我们也可以同时提交多个任务。让我们看看这是如何完成的。

调用多个任务并处理结果

我们可以提交多个任务并处理它们的结果。为此,我们将使用invokeAny()invokeAll()方法,并将任务表示为Callable任务而不是Runnable任务。

invokeAny()方法接受一个Callable对象的集合,并返回一个成功执行的任务的结果(第一个完成),取消所有其他任务。相反,invokeAll()执行所有任务,并返回一个表示结果的Future对象列表。

考虑以下代码,它再次计算我们的投票。在这种情况下,人们可以为选项 1 或选项 2 投票。计数现在使用CallableFuture实现。我们将在这个代码片段中演示invokeAny(不太民主)和invokeAll的使用:

import java.util.Arrays;import java.util.List;
import java.util.concurrent.*;
public class VoteCounter {
    public static void main(String[] args) {
        ExecutorService executor = Executors.
          newSingleThreadExecutor();
        List<Callable<Integer>> callables = Arrays.asList(
                () -> { Thread.sleep(1000); return 1; },
                () -> { Thread.sleep(2000); return 2; }
        );
        try {
            // Invoking any task and printing result
            Integer result = executor.invokeAny(callables);
            System.out.println("Result of the fastest task:
              " + result);
            // Invoking all tasks and printing results
            List<Future<Integer>> futures = executor.
              invokeAll(callables);
            for (Future<Integer> future : futures) {
                System.out.println("Task result: " +
                  future.get());
            }
        } catch (InterruptedException |
             ExecutionException e) {
            e.printStackTrace();
        }
        executor.shutdown();
    }
}

这就是代码输出的内容:

Result of the fastest task: 1Task result: 1
Task result: 2

在代码中,我们首先定义了一个Callable任务的列表。每个Callable在一定的睡眠期后返回一个Integer(模拟任务完成的工作)。然后我们使用invokeAny()invokeAll()方法调用任务,并相应地显示结果。try/catch块是必要的,以处理在任务执行过程中可能出现的异常。

此示例让我们对使用 ExecutorService 调用任务和处理结果有了很好的理解。到目前为止,我们只看到了 SingleThreadExecutor。还有其他使用多个线程的 ExecutorService 可用。这将使事情变得更有趣(并且更复杂,所以请保持专注!)。让我们看看接下来的内容。

线程池和任务执行

线程池是并发编程的一个关键概念。线程池可以比作一群工人——多个线程等待任务。当有任务可用时,每个线程都可以从队列中取出它,执行它,并等待新任务,而不是被销毁。与为每个任务创建新线程相比,这要高效得多。

有不同的 ExecutorServices 来管理线程池,并且每个都有其特定的用例。让我们首先探索 FixedThreadPool

FixedThreadPool

FixedThreadPool 维护固定数量的线程。如果提交了一个任务且所有线程都处于活动状态,该任务将等待在队列中,直到有线程可用。

到目前为止,我们一直让单个线程为我们进行所有投票计数。相反,考虑一个选举场景,其中你有三个投票站来计数所有 100 个投票站的投票:

public static void main(String[] args) {    ExecutorService executorService = Executors.
      newFixedThreadPool(3);
    for (int i = 0; i < 100; i++) {
        final int stationId = i;
        executorService.submit(() -> {
            try {
                System.out.println("Counting votes from
                   station: " + stationId + ", Thread id: "
                     + Thread.currentThread().threadId());
                Thread.sleep((int) (Math.random() * 200));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
    executorService.shutdown();
}

这将输出类似以下内容:

Counting votes from station: 1, Thread id: 23Counting votes from station: 2, Thread id: 24
Counting votes from station: 0, Thread id: 22
Counting votes from station: 3, Thread id: 23
Counting votes from station: 4, Thread id: 23
[part omitted]
Counting votes from station: 97, Thread id: 22
Counting votes from station: 98, Thread id: 23
Counting votes from station: 99, Thread id: 24

每次运行程序时,它都会有所不同!每个投票的计数都是异步进行的,如随机分配的睡眠时间所示。这是因为我们在模拟一个场景,其中三个线程(在本例中为 id 222324)对应于开始计数的投票站。即使有大量的投票,也仍然只有三个线程(投票站)进行计数。

如您所见,投票站顺序不再相同。这是因为有多个线程同时工作。这并不是问题,因为它不会影响最终结果。

CachedThreadPool

另一方面,CachedThreadPool 会根据需要创建新线程,并在可用的情况下重用之前构建的线程。在此池中未使用一定时间的线程将被终止并从缓存中移除。

想象一个有众多移动投票站的选举,这些投票站会移动到不同的地点并按需计数投票:

public static void main(String[] args) {    ExecutorService executorService = Executors.
       newCachedThreadPool();
    for (int i = 0; i < 100; i++) {
        final int stationId = i;
        executorService.submit(() -> {
            try {
                System.out.println("Counting votes at
                   station: " + stationId + ", Thread id: "
                     + Thread.currentThread().threadId());
                Thread.sleep((int) (Math.random() * 200));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
    executorService.shutdown();
}

此代码可以输出以下内容:

Counting votes at station: 5, Thread id: 27Counting votes at station: 19, Thread id: 41
Counting votes at station: 24, Thread id: 46
Counting votes at station: 3, Thread id: 25
Counting votes at station: 6, Thread id: 28
Counting votes at station: 0, Thread id: 22
[middle omitted]
Counting votes at station: 97, Thread id: 125
Counting votes at station: 98, Thread id: 126
Counting votes at station: 99, Thread id: 127

在这种情况下,CachedThreadPool 会根据需要创建尽可能多的线程来同时处理投票,从而加快投票计数。然而,这会以系统资源为代价,因为可能会创建不受控制的线程数量。

我们还有另一个选择,即安排在给定延迟后或定期运行命令。这是通过 ScheduledExecutorService 实现的。让我们看看我们如何安排在特定延迟后或定期运行任务。

ScheduledExecutorServices

现在,我们将来看看ScheduledExecutorService。正如其名所示,ScheduledExecutorService允许你在一段时间后安排任务执行,或者周期性地执行任务。当你需要定期执行任务而不必每次都手动重新安排时,这非常有用。

要使用ScheduledExecutorService,你首先使用Executors类创建一个。有多种选择,但我们只会使用newScheduledThreadPool()

让我们看看一些示例代码。假设我们正在构建一个简单的投票系统,我们需要在一段时间后安排一个任务来关闭投票过程,比如说 1 小时。我们可以这样操作:

import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class VotingSystem {
    private static final ScheduledExecutorService scheduler
      = Executors.newScheduledThreadPool(1);
    public static void main(String[] args) {
        // Open voting
        System.out.println("Voting started!");
        // Schedule voting to close after 1 hour
        scheduler.schedule(VotingSystem::closeVoting, 1,
          TimeUnit.HOURS);
    }
    private static void closeVoting() {
        // Close voting
        System.out.println("Voting closed!");
        // Shut down the scheduler
        scheduler.shutdown();
    }
}

这会输出以下内容:

Voting started!Voting closed!

我们使用单个线程创建ScheduledExecutorService。然后我们使用schedule()方法安排closeVoting()方法在 1 小时后执行。schedule()方法接受三个参数:要执行的函数、执行前的延迟,以及延迟的时单位。

这是一个简单的例子。你也可以安排周期性执行的任务。例如,如果你想每 15 分钟提醒选民投票即将关闭,你可以这样做:

// Schedule reminders every 15 minutes  scheduler.scheduleAtFixedRate(VotingSystem::remindVoters,
  15, 15, TimeUnit.MINUTES);
// ...
private static void remindVoters() {
    // Remind voters
    System.out.println("Remember to vote! Voting will close
      soon!");
}

在这段代码中,我们使用scheduleAtFixedRate()方法来安排remindVoters()方法每 15 分钟执行一次。scheduleAtFixedRate()方法接受四个参数:要执行的函数、执行前的初始延迟、执行之间的周期,以及延迟和周期的时单位。

经过这些修改后,它输出的结果是:

Voting started!Remember to vote! Voting will close soon!
Remember to vote! Voting will close soon!
Remember to vote! Voting will close soon!
Remember to vote! Voting will close soon!
Voting closed!

记住,当你完成你的ScheduledExecutorService时,不要忘记关闭它。这将停止接受任何新任务,并允许现有任务完成。如果你不关闭ScheduledExecutorService,你的应用程序可能无法终止,因为池中的非守护线程会保持其运行。

这就是你需要了解的,以便开始使用ScheduledExecutorService。在继续介绍其他 Java 工具,如用于处理并发的原子类之前,让我们更详细地探讨数据竞争问题。

数据竞争

我们不如先从一个称为数据竞争的问题的例子开始解释,而不是从原子类开始解释。我们已经看到了如何使用原子类、同步关键字和锁来解决这个问题。你能在以下代码片段中找到问题吗?

public class Main {

    private static int counter = 0;    public static void main(String[] args) throws
      InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter++;
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("Counter value: " + counter);
    }
}

我们有一个静态的int计数器,它被两个线程增加了 10,000 次。你可能会预期计数器会是 20,000,对吧?然而,如果我们打印计数器的值,我们会得到以下结果:

Counter value: 12419

如果我们再次运行它,我们会得到以下结果:

Counter value: 13219

第三次了?我们得到的结果是:

Counter value: 15089

简而言之,我们有一个问题!但为什么呢?好吧,我们正在查看数据竞争的结果。数据竞争发生在两个或多个线程同时访问共享数据时,其中至少有一个线程修改了数据。所以,在我们的情况下,thread1读取值并想要增加它,但与此同时,thread2也在读取值并增加它。假设当时的值是 2,000。thread1thread2都将它增加到 2,001。还有其他一些可能的变体,例如,thread1写入 4,022,然后thread2用远低于该值的值覆盖它,比如 3,785。

这是因为这些++--运算符不是原子运算符。这意味着获取值和增加它是两个独立的操作,允许另一个线程介入。为了避免这种情况,我们可以使用原子类。正如我们所见,对于原子类,获取和修改值只是一个单一的操作,避免了这个问题。

非代码数据竞争

让我们给你讲一个真实的故事,给你一个非代码示例(以及一个第一世界问题),关于我亲身经历的数据竞争。我喜欢我的朋友们在生日时有愿望清单,这样我就可以给他们买他们想要的东西,而不是花很多时间去想送他们什么。所以显然,我和另一个朋友看到我们其中一个朋友的儿子想要一个充气弹跳独角兽。所以我们几乎同时查看清单,看到充气独角兽仍然是一个可选的礼物。我们俩都把它划掉,得到了独角兽。(好吧,说实话,我记得我实际上得划掉两次,但我认为是故障。)

结果是我们同时查看那个清单,订购独角兽并划掉它。不能说这最终成为一个真正的问题,因为对 6 岁的孩子来说,有什么比一个充气弹跳独角兽更好呢?是的,两个充气弹跳独角兽!

在结束这一章之前,让我们看看这里提到的常见问题,如数据竞争。

线程问题

在处理并发时,我们有提高性能的机会!然而,权力越大,责任越大;事情也可能出奇地糟糕。因此,我们必须意识到由于不正确或低效的同步可能出现的几个潜在问题。让我们讨论四个常见的线程问题:数据竞争、竞态条件、死锁、活锁和饥饿。

数据竞争

我们已经讨论了很多关于数据竞争的内容。数据竞争发生在两个或多个线程并发访问共享数据时,其中至少有一个线程修改了数据,导致结果不可预测。以下是一个看似无害的代码片段示例,在多线程环境中可能导致数据竞争:

class Counter {    private int count = 0;
    public void increment() {
        count++;
    }
    public int getCount() {
        return count;
    }
}

如果多个线程同时调用increment()方法,count变量的值可能不会正确更新,导致最终计数不正确。

防止数据竞争的策略

为了防止数据竞争,你可以使用我们在本章中看到的各种同步技术,例如以下内容:

  • 在方法或代码块上使用 synchronized 关键字

  • 使用原子类,例如 AtomicIntegerAtomicLongAtomicReference

  • 使用锁,例如 ReentrantLockReadWriteLock

竞态条件

竞态条件是在并发编程中,程序的结果可以根据线程调度的顺序或时间而改变的情况。当事件的时间或顺序影响程序的正确性时,就会发生这种缺陷。与数据竞争不同,数据竞争的问题在于对共享数据的并发访问,而竞态条件是关于多个线程错误地序列化它们的操作。

下面是一个示例代码片段来说明这个问题:

class Flight {    private int seatsAvailable;
    public Flight(int seats) {
        this.seatsAvailable = seats;
    }
    public void bookSeat() {
        if(seatsAvailable > 0) {
            try {
                // Simulate the time needed
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            }
            seatsAvailable--;
            System.out.println(Thread.currentThread().
                getName() + " successfully booked a seat.
                Remaining seats: " + seatsAvailable);
        } else {
            System.out.println("Sorry, " + Thread.
              currentThread().getName() + ". The flight is
                fully booked.");
        }
    }
    public int getSeatsAvailable() {
        return seatsAvailable;
    }
}

如果两个线程(代表两个客户)同时在只剩下一个座位时调用 bookSeat 方法,它们都可能通过 if(seatsAvailable > 0) 检查,在任何一个线程有机会减少 seatsAvailable 之前。结果,两个客户可能会预订最后一个座位,这是一个竞态条件。

这种情况是一个竞态条件的例子,因为操作的顺序(检查可用性然后减少座位数)对于正确性很重要。具体来说,有一个关键代码段(if(seatsAvailable > 0)seatsAvailable--;),它需要原子性地(无中断地)执行,以防止错误。

为了确保我们理解数据竞争的差异,数据竞争特别涉及对共享数据的并发访问,其中至少有一个操作是写操作。在我们的例子中,如果多个线程同时尝试减少 seatsAvailable,可能会导致一个线程在另一个线程完成减少之前读取 seatsAvailable 的值,从而引发数据竞争。

防止竞态条件的策略

为了避免这些类型的问题,我们需要确保关键代码段是原子性地执行的,这可以通过同步来实现。例如,我们可以使用 synchronized 关键字来防止多个线程同时执行关键部分。你应该考虑以下一般策略:

  • 使用 synchronized 关键字或显式锁来确保一次只有一个线程可以执行关键部分。

  • 原子操作:使用单步完成且不可能被中断的原子操作。

  • 顺序设计:设计你的程序,使得线程对共享数据的访问是有序的或协调的,从而消除事件的时间或顺序作为因素,减少竞态条件的发生机会

  • java.util.concurrent 包提供了高级同步工具,如 SemaphoresCountDownLatchesCyclicBarriers,这些工具可以用来协调线程间的操作,从而防止竞态条件。

死锁

当两个或更多线程等待其他线程释放资源,从而形成一个循环等待模式时,就会发生死锁。这里是一个死锁的例子:

Object resourceA = new Object();Object resourceB = new Object();
Thread thread1 = new Thread(() -> {
    synchronized (resourceA) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (resourceB) {
            System.out.println("Thread 1: Locked
              ResourceB");
        }
    }
});
Thread thread2 = new Thread(() -> {
    synchronized (resourceB) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (resourceA) {
            System.out.println("Thread 2: Locked
              ResourceA");
        }
    }
});
thread1.start();
thread2.start();

因此,请注意这里的问题是 synchronized 关键字的错误使用!thread1 变量获取了 resourceA 的锁,而 thread2 获取了 resourceB 的锁。然后,两个线程都试图获取另一个资源的锁,导致死锁。这意味着两个线程都会无限期地卡住。

防止和解决死锁的策略

为了防止和解决死锁,你可以采用以下策略:

  • 避免嵌套锁:确保一次只锁定一个资源,或者按照特定的顺序获取锁。

  • 使用锁超时:为获取锁设置超时,如果超时到期则释放锁。

活锁

当两个或更多线程陷入一个循环,反复释放和重新获取资源,而没有取得任何进展时,就会发生活锁。这里有一个荒谬的活锁例子:

public class ExampleLivelock {    public static void main(String[] args) {
        run();
    }
    public static void run(){
        final PhoneCall buddy1 = new PhoneCall("Patricia");
        final PhoneCall buddy2 = new PhoneCall("Patrick");
        final HangUpButton s = new HangUpButton(buddy1);
        new Thread(new Runnable() {
            public void run() { buddy1.callWith(s, buddy2); }
        }).start();
        new Thread(new Runnable() {
            public void run() { buddy2.callWith(s, buddy1); }
        }).start();
    }
    static class HangUpButton {
        private PhoneCall owner;
        public HangUpButton(PhoneCall d) { owner = d; }
        public PhoneCall getOwner() { return owner; }
        public synchronized void setOwner(PhoneCall d) {
            owner = d;
        }
        public synchronized void use() {
            System.out.printf("%s has hang up!",
              owner.name);
        }
    }
    static class PhoneCall {
        private String name;
        private boolean isDone;
        public PhoneCall(String n) {
            name = n; isDone = true;
        }
        public String getName() { return name; }
        public boolean isDone() { return isDone; }
        public void callWith(HangUpButton hangUpButton,
          PhoneCall buddy) {
            while (isDone) {
                if (hangUpButton.owner != this) {
                    try {
                        Thread.sleep(1);
                    }catch(InterruptedException e) {
                        continue;
                    }
                    continue;
                }
                if (buddy.isDone()) {
                    System.out.printf(
                            "%s: You hang up, buddy %s!%n",
                            name, buddy.getName());
                    hangUpButton.setOwner(buddy);
                    continue;
                }
            }
        }
    }
}

在这个例子中,两个 PhoneCall 对象,PatriciaPatrick,正在尝试使用一个共享的 hangUpButton 对象挂断电话。hangUpButton 对象一次只能有一个所有者。PatriciaPatrick 都似乎有这样一个规则:如果他们拥有 hangUpButton 而对方还没有挂断电话,他们就会将 hangUpButton 传递给对方。这导致他们两个不断地互相传递 hangUpButton,因为他们总是看到对方还没有挂断电话,这是一个活锁情况。

请注意,在这个特定的荒谬代码中,没有机制可以跳出活锁(callWith 方法中的无限 while 循环)。在实际场景中,应该实现一个检测和从活锁中恢复的机制。

防止和解决活锁的策略

为了防止和解决活锁,可以考虑以下策略:

  • 使用退避算法:在重试操作之前引入(非常小的)随机延迟或指数退避,以最小化活锁的可能性。

  • 优先级资源或线程:为资源或线程分配优先级,以避免竞争并确保高优先级任务可以继续进行。

  • 检测和从活锁中恢复:监控应用程序中的活锁,并采取纠正措施,例如重启线程或重新分配优先级。

活锁是资源饥饿的特殊情况。这是一种状态,其中两个或多个进程不断地根据其他进程(或多个进程)的变化改变自己的状态,而不做任何有用的工作。接下来,让我们谈谈饥饿问题。

饥饿

当一个线程无法在较长时间内访问共享资源时,就会发生饥饿,阻碍其进展。这通常发生在高优先级线程垄断资源,导致低优先级线程饥饿。以下是一个饥饿的例子:

Object sharedResource = new Object();Thread highPriorityThread = new Thread(() -> {
    synchronized (sharedResource) {
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
highPriorityThread.setPriority(Thread.MAX_PRIORITY);
highPriorityThread.start();
Thread lowPriorityThread = new Thread(() -> {
    synchronized (sharedResource) {
        System.out.println("Low priority thread accessed
          the shared resource.");
    }
});
lowPriorityThread.setPriority(Thread.MIN_PRIORITY);
lowPriorityThread.start();

在这个例子中,高优先级线程长时间垄断共享资源,导致低优先级线程饥饿。请注意,线程优先级只是给调度器的提示。具体由操作系统实现来决定。

防止和解决饥饿的策略

为了防止和解决饥饿,你可以采用以下策略:

  • ReentrantLockfair 参数设置为 true

  • 监控资源使用:跟踪资源使用情况,并调整线程优先级或访问模式以避免资源垄断。

  • 使用时间共享:限制线程持有资源的时间,或确保每个线程定期有机会访问资源。

到此为止就这些了!实际上关于并发还有很多知识要了解,我们甚至可以为此写一本书——但这些都足以让你开始。现在是时候卷起袖子,开始动手实践部分了!

练习

  1. FeedingActivityCleaningActivity。让 FeedingActivity 继承 Thread 类,而 CleaningActivity 实现 Runnable 接口。在这两个类中,重写 run 方法以打印出活动的名称和表示活动正在进行的消息。

  2. ParkOperations 类有两个线程,一个用于喂食,另一个用于清洁。启动这两个线程,然后使用 sleep() 模拟喂食活动的延迟。使用 join() 确保清洁只在喂食完成后进行。

  3. TaskAssigner 类,其中使用 ExecutorService 将任务分配给员工。任务可以用 RunnableCallable 对象表示,员工可以用线程表示。

  4. 解决以下代码片段中的竞态条件。updater1updater2 都试图更新同一个恐龙对象的状态。由于它们是并发运行的,可能会导致输出不一致。使用 synchronized 关键字或 AtomicReference 防止数据不一致:

    class Dinosaur {    private String status;    public Dinosaur(String status) {        this.status = status;    }    public String getStatus() {        return status;    }    public void setStatus(String status) {        this.status = status;    }}class DinosaurStatusUpdater implements Runnable {    private Dinosaur;    private String newStatus;    public DinosaurStatusUpdater(Dinosaur dinosaur,      String newStatus) {        this.dinosaur = dinosaur;        this.newStatus = newStatus;    }    @Override    public void run() {        dinosaur.setStatus(newStatus);        System.out.println("Dinosaur status set to: "          + dinosaur.getStatus());    }}public class Main {    public static void main(String[] args) {        Dinosaur dinosaur = new Dinosaur("Healthy");        Thread updater1 = new Thread(new          DinosaurStatusUpdater(dinosaur, "Feeding"));        Thread updater2 = new Thread(new          DinosaurStatusUpdater(dinosaur, "Resting"));        updater1.start();        updater2.start();    }}
    

项目 - 公园运营系统 - 暴风雨前的平静

当我们的公园因充满活力的恐龙和兴奋的游客而繁荣时,幕后的运营必须并发且无缝进行。使用并发可以确保像喂食恐龙、跟踪恐龙移动和安排员工班次等任务得到有效处理。

然而,尽管我们尽了最大努力,事情开始变得不顺利。一些恐龙变得不安分,安全系统开始出现故障,员工报告了神秘事件。这可能是暴风雨前的平静,我们是否会再次经历那个著名的竞争公园发生的事情?

更新以下 公园运营系统,以便它能够并发安全地处理不同的公园运营。使用低级线程、ExecutorService、原子类、同步块和 Lock 接口来管理对共享资源的并发访问。防止和处理竞争条件、死锁、活锁和饥饿场景,以保持事物在紧张局势上升时处于控制之下。

这是导致问题的有问题的代码:

import java.util.concurrent.*;class ParkStatus {
    private int foodStock;
    public ParkStatus(int foodStock) {
        this.foodStock = foodStock;
    }
    public int getFoodStock() {
        return this.foodStock;
    }
    public void reduceFood(int amount) {
        this.foodStock -= amount;
    }
}
class FeedingDinosaurs implements Runnable {
    private ParkStatus parkStatus;
    public FeedingDinosaurs(ParkStatus parkStatus) {
        this.parkStatus = parkStatus;
    }
    @Override
    public void run() {
        while (true) {
            parkStatus.reduceFood(1);
            System.out.println("Food stock after feeding: "
              + parkStatus.getFoodStock());
        }
    }
}
class TrackingMovements implements Runnable {
    private ParkStatus parkStatus;
    public TrackingMovements(ParkStatus parkStatus) {
        this.parkStatus = parkStatus;
    }
    @Override
    public void run() {
        while (true) {
            System.out.println("Current food stock: " +
              parkStatus.getFoodStock());
        }
    }
}
public class Main {
    public static void main(String[] args) {
        ParkStatus parkStatus = new ParkStatus(100);
        Thread feedingThread = new Thread(new
          FeedingDinosaurs(parkStatus));
        Thread trackingThread = new Thread(new
          TrackingMovements(parkStatus));
        feedingThread.start();
        trackingThread.start();
    }
}

如您所见,在访问和修改 foodStock 时存在竞争条件。此外,这些线程将无限运行,可能会造成系统中其他线程的饥饿。

下面是一些修改的提示:

  • Lock 接口,或一个原子类。记住,目标是确保 reduceFood() 方法和对 foodStock 的读取是原子性的。

  • ExecutorService:而不是直接创建线程,你可以使用 ExecutorService 来管理线程。这提供了更多的灵活性和线程处理实用方法。

  • FeedingDinosaursTrackingMovementsrun 方法会无限运行。你可以使用条件来控制这些循环,并确保在操作完成后 ExecutorService 关闭。

  • 死锁、活锁和饥饿:为了模拟和防止这些情况,请考虑添加更多共享资源和线程,并尝试不同的锁定顺序、锁定释放机制和线程优先级。

修改代码时请谨慎,以防止和处理这些并发问题。不正确的修改可能会造成比解决的问题更多的问题。感谢您挽救了这一天!

摘要

并发是现代软件开发中的一个基本概念,它允许应用程序同时执行多个任务,并有效地利用系统资源。在本章中,我们探讨了 Java 中并发编程的各个方面,从基本的线程创建和管理到处理同步和共享数据的高级技术。

我们首先介绍了并发及其重要性,然后通过使用 Thread 类、Runnable 接口以及使用 lambda 表达式实现 Runnable 接口来创建线程。然后,我们转向了两种线程管理方法:sleep()join()。接下来,我们讨论了 ExecutorService,它为管理线程执行提供了更高层次的抽象,使我们的生活变得稍微容易一些(在使其变得更难之后)。

并发编程的一个关键方面是避免数据竞争。我们演示了一个数据竞争示例,并讨论了解决策略,包括使用原子类和 synchronized 关键字。我们还探讨了 Lock 接口作为 synchronized 关键字的替代方案。这给了我们更多的灵活性和控制力。

并发集合,如ConcurrentHashMapCopyOnWriteArrayListConcurrentLinkedQueue,提供了对标准 Java 集合的线程安全替代方案。我们简要介绍了它们的优点和用例,并看到了一些它们的使用示例。

最后,我们探讨了常见的线程问题,包括数据竞争、竞态条件、死锁、活锁和饥饿。我们提供了示例和策略来预防和解决这些问题。到这一点,你应该对 Java 中的并发编程有了扎实的理解,并且具备了处理多线程应用程序的技能。

posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报