Java9-和-JShell-全-

Java9 和 JShell(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 绝对是本世纪最流行的编程语言之一。然而,每当我们需要快速探索新的算法或新的应用领域时,Java 并没有为我们提供一种简单的执行代码片段并打印结果的方式。由于这种限制,许多开发人员开始使用其他提供 REPL(读取-求值-打印-循环)实用程序的编程语言,如 Scala 和 Python。然而,许多时候,在探索阶段结束并且需求和算法清晰之后,需要回到 Java。

Java 9 引入了 JShell,一个新的实用程序,允许我们轻松运行 Java 9 代码片段并打印结果。这个实用程序是一个 REPL,使我们能够像开发者在 Scala 和 Python 中那样轻松地使用 Java。JShell 使学习 Java 9 及其最重要的特性变得更容易。

面向对象编程,也称为 OOP,是每个现代软件开发人员工作中必备的技能。这是非常有道理的,因为 OOP 允许您最大化代码重用并最小化维护成本。然而,学习面向对象编程是具有挑战性的,因为它包含太多抽象概念,需要现实生活的例子才能容易理解。此外,不遵循最佳实践的面向对象代码很容易变成维护的噩梦。

Java 是一种多范式编程语言,其中最重要的范式之一是面向对象编程。如果你想要使用 Java 9,你需要掌握 Java 中的面向对象编程。此外,由于 Java 9 还吸收了函数式编程语言中的一些优秀特性,因此了解如何将面向对象编程代码与函数式编程代码相结合是很方便的。

本书将使您能够使用 JShell 在 Java 9 中开发高质量可重用的面向对象代码。您将学习面向对象编程原则以及 Java 9 如何实现它们,结合现代函数式编程技术。您将学习如何从现实世界元素中捕捉对象并创建代表它们的面向对象代码。您将了解 Java 对面向对象代码的处理方式。您将最大化代码重用并减少维护成本。您的代码将易于理解,并且将与现实生活元素的表示一起工作。

此外,你将学习如何使用 Java 9 引入的新模块化功能组织代码,并准备创建复杂的应用程序。

本书内容包括

《第一章》JShell – A Read-Evaluate-Print-Loop for Java 9,开始我们的 Java 9 面向对象编程之旅。我们将学习如何启动并使用 Java 9 中引入的新实用程序:JShell,它将允许我们轻松运行 Java 9 代码片段并打印其结果。这个实用程序将使我们更容易学习面向对象编程。

《第二章》Real-World Objects to UML Diagrams and Java 9 via JShell,教我们如何从现实生活中识别对象。我们将了解使用对象编程更容易编写易于理解和重用的代码。我们将学习如何识别现实世界的元素,并将它们转化为 Java 支持的面向对象范式的不同组件。我们将开始使用 UML(统一建模语言)图表组织类。

第三章,“类和实例”,展示了类代表生成对象的蓝图或模板,这些对象也被称为实例。我们将设计一些代表现实对象蓝图的类。我们将学习对象的生命周期。我们将使用许多示例来理解初始化的工作原理。我们将声明我们的第一个类来生成对象的蓝图。我们将定制其初始化并在 JShell 中的实时示例中测试其个性化行为。我们将了解垃圾回收的工作原理。

第四章,“数据的封装”,教会你 Java 9 中类的不同成员以及它们如何反映在从类生成的实例的成员中。我们将使用实例字段、类字段、设置器、获取器、实例方法和类方法。我们将使用设置器和获取器生成计算属性。我们将利用访问修饰符隐藏数据。我们将使用静态字段创建所有类实例共享的值。

第五章,“可变和不可变类”,介绍了可变对象和不可变对象之间的区别。首先,我们将创建一个可变类,然后我们将构建这个类的不可变版本。我们将学习在编写并发代码时不可变对象的优势。

第六章,“继承、抽象、扩展和特化”,讨论了如何利用简单继承来专门化或扩展基类。我们将从上到下设计许多类,并使用链式构造函数。我们将使用 UML 图设计从另一个类继承的类。我们将在交互式 JShell 中编写类。我们将重写和重载方法。我们将运行代码以了解我们编写的所有东西是如何工作的。

第七章,“成员继承和多态”,教你如何控制子类是否可以覆盖成员。我们将利用最激动人心的面向对象特性之一:多态性。我们将利用 JShell 轻松理解类型转换。我们将声明执行与类实例操作的方法。

第八章,“接口的契约编程”,介绍了接口在 Java 9 中与类结合的工作原理。在 Java 9 中实现多重继承的唯一方法是通过接口的使用。我们将学习声明和组合多个蓝图以生成单个实例。我们将声明具有不同类型要求的接口。然后,我们将声明许多实现创建的接口的类。我们将结合接口和类以利用 Java 9 中的多重继承。我们将结合接口的继承和类的继承。

第九章,“接口的高级契约编程”,深入探讨了接口的契约编程。我们将使用接口作为参数的方法。我们将理解接口和类的向下转型,并将接口类型的实例视为不同的子类。JShell 将帮助我们轻松理解类型转换和向下转型的复杂性。我们将处理更复杂的场景,将类继承与接口继承相结合。

第十章,“泛型的代码重用最大化”,介绍了如何使用参数多态性。我们将学习如何通过编写能够处理不同类型对象的代码来最大化代码重用,即能够处理实现特定接口的类的实例或者其类层次结构包括特定超类的实例。我们将使用接口和泛型。我们将创建一个可以处理受限泛型类型的类。我们将利用泛型为多种类型创建一个泛型类。

第十一章,“高级泛型”,深入探讨了参数多态性。我们将声明一个可以使用两个受限泛型类型的类。我们将在 JShell 中使用具有两个泛型类型参数的泛型类。我们将利用 Java 9 中的泛型来泛化现有的类。

第十二章,“面向对象,函数式编程和 Lambda 表达式”,讨论了函数在 Java 9 中是一等公民。我们将在类中使用函数接口。我们将使用 Java 9 中包含的许多函数式编程特性,并将它们与我们在前几章中学到的关于面向对象编程的知识相结合。这样,我们将能够兼顾两者的优势。我们将分析许多算法的命令式和函数式编程方法之间的差异。我们将利用 lambda 表达式,并将 map 操作与 reduce 结合起来。

第十三章,“Java 9 中的模块化”,将所有面向对象的拼图拼在一起。我们将重构现有代码以利用面向对象编程。我们将理解 Java 9 中模块化源代码的用法。我们将使用模块创建一个新的 Java 9 解决方案,使用 Java 9 中的新模块化组织面向对象的代码,并学习许多调试面向对象代码的技巧。

你需要为这本书做什么

你需要一台双核 CPU 和至少 4GB RAM 的计算机,能够运行 JDK 9 Windows Vista SP2,Windows 7,Windows 8.x,Windows 10 或更高版本,或者 macOS 10.9 或更高版本,以及 JDK 9 支持的任何 Linux 发行版。任何能够运行 JDK 9 的 IoT 设备也将很有用。

这本书是为谁准备的

这本书可以被任何计算机科学专业的毕业生或刚开始从事软件工程师工作的人理解。基本上,对于像 Python、C++或者早期的 Java 版本这样的面向对象编程语言的理解就足够了。参与过完整的软件工程项目周期将是有帮助的。

约定

在这本书中,你会发现一些文本样式,用来区分不同类型的信息。以下是一些样式的例子,以及它们的含义。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:文本中的代码单词显示如下:“JShell 允许我们调用System.out.printf方法轻松格式化我们要打印的输出。”

代码块设置如下:

double getGeneratedRectangleHeight() {
    final Rectangle rectangle = new Rectangle(37, 87);
    return rectangle.height; 
}

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

double getGeneratedRectangleHeight() {
    final Rectangle rectangle = new Rectangle(37, 87);
    return rectangle.height; 
}

任何命令行输入或输出都以以下形式编写:

javac -version

新术语重要单词以粗体显示。例如,屏幕上看到的单词,菜单或对话框中的单词会以这样的形式出现在文本中:“单击接受,然后单击退出。”

注意

警告或重要说明会以这样的形式出现在框中。

提示

提示和技巧会以这样的形式出现。

读者反馈

我们始终欢迎读者的反馈。让我们知道您对本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们开发您真正受益的标题非常重要。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在消息主题中提及书名。

如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的自豪所有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

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

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

  1. 使用您的电子邮件地址和密码登录或注册到我们的网站。

  2. 将鼠标指针悬停在顶部的支持选项卡上。

  3. 单击代码下载和勘误

  4. 搜索框中输入书名。

  5. 选择您要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的地方。

  7. 单击代码下载

您还可以通过单击 Packt Publishing 网站上书籍页面上的代码文件按钮来下载代码文件。可以通过在搜索框中输入书名来访问该页面。请注意,您需要登录您的 Packt 帐户。

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/Java-9-with-JShell。我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载本书的彩色图片

我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。彩色图片将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/Java9withJShell_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误还是会发生。如果您在我们的书籍中发现错误——可能是文本或代码中的错误——我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该书籍的勘误列表中的勘误部分。

要查看先前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将显示在勘误表部分下。

盗版

互联网上盗版受版权保护的材料是一个持续存在的问题,涉及各种媒体。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

请通过<copyright@packtpub.com>与我们联系,并附上涉嫌盗版材料的链接。

我们感谢您帮助保护我们的作者和我们为您提供有价值内容的能力。

问题

如果您对本书的任何方面有问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章:JShell-用于 Java 9 的读取-求值-打印-循环

在本章中,我们将开始使用 Java 9 进行面向对象编程的旅程。您将学习如何启动并使用 Java 9 中引入的新实用程序:JShell,它将使您能够轻松运行 Java 9 代码片段并打印其结果。我们将执行以下操作:

  • 准备好使用 Java 9 进行面向对象编程的旅程

  • 在 Windows,macOS 或 Linux 上安装所需的软件

  • 了解使用REPL读取-求值-打印-循环)实用程序的好处

  • 检查默认导入并使用自动完成功能

  • 在 JShell 中运行 Java 9 代码

  • 评估表达式

  • 使用变量,方法和源代码

  • 在我们喜欢的外部代码编辑器中编辑源代码

  • 加载源代码

准备好使用 Java 9 进行面向对象编程的旅程

在本书中,您将学习如何利用 Java 编程语言第 9 版中包含的所有面向对象的特性,即 Java 9。一些示例可能与以前的 Java 版本兼容,例如 Java 8,Java 7 和 Java 6,但是必须使用 Java 9 或更高版本,因为该版本不向后兼容。我们不会编写向后兼容以前的 Java 版本的代码,因为我们的主要目标是使用 Java 9 或更高版本,并使用其语法和所有新功能。

大多数情况下,我们不会使用任何IDE集成开发环境),而是利用 JShell 和 JDK 中包含的许多其他实用程序。但是,您可以使用任何提供 Java 9 REPL 的 IDE 来使用所有示例。您将在接下来的章节中了解使用 REPL 的好处。在最后一章中,您将了解到使用 Java 9 引入的新模块化功能时,IDE 将给您带来的好处。

提示

无需具备 Java 编程语言的先前经验,即可使用本书中的示例并学习如何使用 Java 9 建模和创建面向对象的代码。如果您具有一些 C#,C ++,Python,Swift,Objective-C,Ruby 或 JavaScript 的经验,您将能够轻松学习 Java 的语法并理解示例。许多现代编程语言都从 Java 中借鉴了功能,反之亦然。因此,对这些语言的任何了解都将非常有用。

在本章中,我们将在 Windows,macOS 或 Linux 上安装所需的软件。我们将了解使用 REPL,特别是 JShell,学习面向对象编程的好处。我们将学习如何在 JShell 中运行 Java 9 代码以及如何在 REPL 中加载源代码示例。最后,我们将学习如何在 Windows,macOS 和 Linux 上从命令行或终端运行 Java 代码。

在 Windows,macOS 或 Linux 上安装所需的软件

我们必须从jdk9.java.net/download/下载并安装适用于我们操作系统的最新版本的JDK 9Java 开发工具包 9)。我们必须接受 Java 的许可协议才能下载软件。

与以前的版本一样,JDK 9 可用于许多不同的平台,包括但不限于以下平台:

  • Windows 32 位

  • Windows 64 位

  • macOS 64 位(以前称为 Mac OS X 或简称 OS X)

  • Linux 32 位

  • Linux 64 位

  • Linux on ARM 32 位

  • Linux on ARM 64 位

安装适用于我们操作系统的 JDK 9 的适当版本后,我们可以将 JDK 9 安装文件夹的bin子文件夹添加到PATH环境变量中。这样,我们就可以从我们所在的任何文件夹启动不同的实用程序。

提示

如果我们没有将 JDK 9 安装的文件夹的bin子文件夹添加到操作系统的PATH环境变量中,那么在执行命令时我们将始终需要使用bin子文件夹的完整路径。在启动不同的 Java 命令行实用程序的下一个说明中,我们将假设我们位于这个bin子文件夹中,或者PATH环境变量包含它。

一旦我们安装了 JDK 9,并将bin文件夹添加到PATH环境变量中,我们可以在 Windows 命令提示符或 macOS 或 Linux 终端中运行以下命令:

javac -version

上一个命令将显示包含在 JDK 中的主要 Java 编译器的当前版本,该编译器将 Java 源代码编译为 Java 字节码。版本号应该以 9 开头,如下一个示例输出所示:

javac 9-ea

如果上一个命令的结果显示的版本号不以 9 开头,我们必须检查安装是否成功。此外,我们必须确保PATH环境变量不包括 JDK 的旧版本路径,并且包括最近安装的 JDK 9 的bin文件夹。

现在,我们准备启动 JShell。在 Windows 命令提示符或 macOS 或 Linux 终端中运行以下命令:

jshell

上一个命令将启动 JShell,显示包括正在使用的 JDK 版本的欢迎消息,并且提示符将更改为jshell>。每当我们看到这个提示时,这意味着我们仍然在 JShell 中。下面的屏幕截图显示了在 macOS 的终端窗口中运行的 JShell。

在 Windows、macOS 或 Linux 上安装所需软件

提示

如果我们想随时离开 JShell,我们只需要在 Mac 中按Ctrl + D。另一个选项是输入/exit并按Enter

了解使用 REPL 的好处

Java 9 引入了一个名为 JShell 的交互式 REPL 命令行环境。这个工具允许我们执行 Java 代码片段并立即获得结果。我们可以轻松编写代码并查看其执行的结果,而无需创建解决方案或项目。我们不必等待项目完成构建过程来检查执行许多行代码的结果。JShell,像任何其他 REPL 一样,促进了探索性编程,也就是说,我们可以轻松地交互式地尝试和调试不同的算法和结构。

提示

如果您曾经使用过其他提供 REPL 或交互式 shell 的编程语言,比如 Python、Scala、Clojure、F#、Ruby、Smalltalk 和 Swift 等,您已经知道使用 REPL 的好处。

例如,假设我们必须与提供 Java 绑定的 IoT(物联网)库进行交互。我们必须编写 Java 代码来使用该库来控制无人机,也称为无人机(UAV)。无人机是一种与许多传感器和执行器进行交互的物联网设备,包括与发动机、螺旋桨和舵机连接的数字电子调速器。

我们希望能够编写几行代码来从传感器中检索数据并控制执行器。我们只需要确保事情按照文档中的说明进行。我们希望确保从高度计读取的数值在移动无人机时发生变化。JShell 为我们提供了一个适当的工具,在几秒钟内开始与库进行交互。我们只需要启动 JShell,加载库,并在 REPL 中开始编写 Java 9 代码。使用以前的 Java 版本,我们需要从头开始创建一个新项目,并在开始编写与库交互的第一行代码之前编写一些样板代码。JShell 允许我们更快地开始工作,并减少了创建整个框架以开始运行 Java 9 代码的需要。JShell 允许从 REPL 交互式探索 API(应用程序编程接口)。

我们可以在 JShell 中输入任何 Java 9 定义。例如,我们可以声明方法、类和变量。我们还可以输入 Java 表达式、语句或导入。一旦我们输入了声明方法的代码,我们就可以输入一个使用先前定义的方法的语句,并查看执行的结果。

JShell 允许我们从文件中加载源代码,因此,您将能够加载本书中包含的源代码示例并在 JShell 中评估它们。每当我们必须处理源代码时,您将知道可以从哪个文件夹和文件中加载它。此外,JShell 允许我们执行 JShell 命令。我们将在本章后面学习最有用的命令。

JShell 允许我们调用System.out.printf方法轻松格式化我们想要打印的输出。我们将在我们的示例代码中利用这个方法。

提示

JShell 禁用了一些在交互式 REPL 中没有用处的 Java 9 功能。每当我们在 JShell 中使用这些功能时,我们将明确指出 JShell 将禁用它们,并解释它们的影响。

在 JShell 中,语句末尾的分号(;)是可选的。但是,我们将始终在每个语句的末尾使用分号,因为我们不想忘记在编写项目和解决方案中的真实 Java 9 代码时必须使用分号。当我们输入要由 JShell 评估的表达式时,我们将省略语句末尾的分号。

例如,以下两行是等价的,它们都将在 JShell 中执行后打印"Object-Oriented Programming rocks with Java 9!"。第一行在语句末尾不包括分号(;),第二行包括分号(;)。我们将始终使用分号(😉,如第二行中所示,以保持一致性。

System.out.printf("Object-Oriented Programming rocks with Java 9!\n")
System.out.printf("Object-Oriented Programming rocks with Java 9!\n");

以下屏幕截图显示了在 Windows 10 上运行的 JShell 中执行这两行的结果:

理解使用 REPL 的好处

在一些示例中,我们将利用 JShell 为我们提供的网络访问功能。这个功能对于与 Web 服务交互非常有用。但是,您必须确保您的防火墙配置中没有阻止 JShell。

提示

不幸的是,在我写这本书的时候,JShell 没有包括语法高亮功能。但是,您将学习如何使用我们喜欢的编辑器来编写和编辑代码,然后在 JShell 中执行。

检查默认导入并使用自动完成功能

默认情况下,JShell 提供一组常见的导入,我们可以使用import语句从任何额外的包中导入必要的类型来运行我们的代码片段。我们可以在 JShell 中输入以下命令来列出所有导入:

/imports

以下行显示了先前命令的结果:

|    import java.io.*
|    import java.math.*
|    import java.net.*
|    import java.nio.file.*
|    import java.util.*
|    import java.util.concurrent.*
|    import java.util.function.*
|    import java.util.prefs.*
|    import java.util.regex.*
|    import java.util.stream.*

与我们在 JShell 之外编写 Java 代码时一样,我们不需要从java.lang包导入类型,因为它们默认被导入,并且在 JShell 中运行/imports命令时不会列出它们。因此,默认情况下,JShell 为我们提供了访问以下包中的所有类型:

  • java.lang

  • java.io

  • java.math

  • java.net

  • java.nio.file

  • java.util

  • java.util.concurrent

  • java.util.function

  • java.util.prefs

  • java.util.regex

  • java.util.stream

JShell 提供自动完成功能。我们只需要在需要自动完成功能的时候按下Tab键,就像在 Windows 命令提示符或 macOS 或 Linux 中的终端中工作时一样。

有时,以我们输入的前几个字符开头的选项太多。在这些情况下,JShell 会为我们提供一个包含所有可用选项的列表,以提供帮助。例如,我们可以输入S并按Tab键。JShell 将列出从先前列出的包中导入的以S开头的所有类型。以下屏幕截图显示了 JShell 中的结果:

检查默认导入并使用自动补全功能

我们想要输入System。考虑到前面的列表,我们只需输入Sys,以确保System是以Sys开头的唯一选项。基本上,我们在作弊,以便了解 JShell 中自动补全的工作原理。输入Sys并按下Tab键。JShell 将显示System

现在,在 JShell 中输入一个点(.),然后输入一个o(你将得到System.o),然后按下Tab键。JShell 将显示System.out

接下来,输入一个点(.)并按下Tab键。JShell 将显示在System.out中声明的所有公共方法。在列表之后,JShell 将再次包括System.out.,以便我们继续输入我们的代码。以下屏幕截图显示了 JShell 中的结果:

检查默认导入并使用自动补全功能

输入printl并按下Tab键。JShell 将自动补全为System.out.println(,即它将添加一个n和开括号(()。这样,我们只需输入该方法的参数,因为只有一个以printl开头的方法。输入"Auto-complete is helpful in JShell");并按下Enter。下一行显示完整的语句:

System.out.println("Auto-complete is helpful in JShell");

在运行上述行后,JShell 将显示 JShell 中的结果的屏幕截图:

检查默认导入并使用自动补全功能

在 JShell 中运行 Java 9 代码

Ctrl + *D* to exit the current JShell session. Run the following command in the Windows Command Prompt or in a macOS or Linux Terminal to launch JShell with a verbose feedback:
jshell -v

calculateRectangleArea. The method receives a width and a height for a rectangle and returns the result of the multiplication of both values of type float:
float calculateRectangleArea(float width, float height) {
    return width * height;
}

在输入上述代码后,JShell 将显示下一个消息,指示它已创建了一个名为calculateRectangleArea的方法,该方法有两个float类型的参数:

|  created method calculateRectangleArea(float,float)

提示

请注意,JShell 写的所有消息都以管道符号(|)开头。

在 JShell 中输入以下命令,列出我们在当前会话中迄今为止键入和执行的当前活动代码片段:

/list

 result of the previous command. The code snippet that created the calculateRectangleArea method has been assigned 1 as the snippet id.
 1 : float calculateRectangleArea(float width, float height) {
 return width * height;
 }

在 JShell 中输入以下代码,创建一个名为width的新的float变量,并将其初始化为50

float width = 50;

在输入上述行后,JShell 将显示下一个消息,指示它已创建了一个名为widthfloat类型的变量,并将值50.0赋给了这个变量:

width ==> 50.0
|  created variable width : float

在 JShell 中输入以下代码,创建一个名为height的新的float变量,并将其初始化为25

float height = 25;

在输入上述行后,JShell 将显示下一个消息,指示它已创建了一个名为heightfloat类型的变量,并将值25.0赋给了这个变量:

height ==> 25.0
|  created variable height : float

输入float area = ca并按下Tab键。JShell 将自动补全为float area = calculateRectangleArea(,即它将添加lculateRectangleArea和开括号(()。这样,我们只需输入该方法的两个参数,因为只有一个以ca开头的方法。输入width, height);并按下Enter。下一行显示完整的语句:

float area = calculateRectangleArea(width, height);

在输入上述行后,JShell 将显示下一个消息,指示它已创建了一个名为areafloat类型的变量,并将调用calculateRectangleArea方法并将先前声明的widthheight变量作为参数。该方法返回1250.0作为结果,并将其赋给area变量。

area ==> 1250.0
|  created variable area : float

在 JShell 中输入以下命令,列出我们在当前会话中迄今为止键入和执行的当前活动代码片段:

/list

 with the snippet id, that is, a unique number that identifies each code snippet. JShell will display the following lines as a result of the previous command:
 1 : float calculateRectangleArea(float width, float height) {
 return width * height;
 }
 2 : float width = 50;
 3 : float height = 25;
 4 : float area = calculateRectangleArea(width, height);

在 JShell 中输入以下代码,使用System.out.printf来显示widthheightarea变量的值。我们在作为System.out.printf的第一个参数传递的字符串中的第一个%.2f使得字符串后面的下一个参数(width)以两位小数的浮点数形式显示。我们重复两次%.2f来以两位小数的浮点数形式显示heightarea变量。

System.out.printf("Width: %.2f, Height: %.2f, Area: %.2f\n", width, height, area);

在输入上述行后,JShell 将使用System.out.printf格式化输出,并打印下一个消息,后面跟着一个临时变量的名称:

Width: 50.00, Height: 25.00, Area: 1250.00
$5 ==> java.io.PrintStream@68c4039c
|  created scratch variable $5 : PrintStream

评估表达式

JShell 允许我们评估任何有效的 Java 9 表达式,就像我们在使用 IDE 和典型的表达式评估对话框时所做的那样。在 JShell 中输入以下表达式:

width * height;

在我们输入上一行后,JShell 将评估表达式,并将结果分配给一个以$开头并后跟一个数字的临时变量。JShell 显示临时变量名称$6,分配给该变量的值指示表达式评估结果的1250.0,以及临时变量的类型float。下面的行显示在我们输入上一个表达式后 JShell 中显示的消息:

$6 ==> 1250.0
|  created scratch variable $6 : float

$6 variable as a floating point number with two decimal places. Make sure you replace $6 with the scratch variable name that JShell generated.
System.out.printf("The calculated area is %.2f", $6);

在我们输入上一行后,JShell 将使用System.out.printf格式化输出,并打印下一个消息:

The calculated area is 1250.00

我们还可以在另一个表达式中使用先前创建的临时变量。在 JShell 中输入以下代码,将10.5float)添加到$6变量的值中。确保用 JShell 生成的临时变量名称替换$6

$6 + 10.5f;

在我们输入上一行后,JShell 将评估表达式,并将结果分配给一个新的临时变量,其名称以$开头,后跟一个数字。JShell 显示临时变量名称$8,分配给该变量的值指示表达式评估结果的1260.5,以及临时变量的类型float。下面的行显示在我们输入上一个表达式后 JShell 中显示的消息:

$8 ==> 1250.5
|  created scratch variable $8 : float

提示

与之前发生的情况一样,临时变量的名称可能不同。例如,可能是$9$10,而不是$8

使用变量、方法和源

到目前为止,我们已经创建了许多变量,而且在我们输入表达式并成功评估后,JShell 创建了一些临时变量。在 JShell 中输入以下命令,列出迄今为止在当前会话中创建的当前活动变量的类型、名称和值:

/vars

以下行显示结果:

|    float width = 50.0
|    float height = 25.0
|    float area = 1250.0
|    PrintStream $5 = java.io.PrintStream@68c4039c
|    float $6 = 1250.0
|    float $8 = 1260.5

在 JShell 中输入以下代码,将80.25float)赋给先前创建的width变量:

width = 80.25f;

在我们输入上一行后,JShell 将显示下一个消息,指示它已将80.25float)分配给现有的float类型变量width

width ==> 80.25
|  assigned to width : float

在 JShell 中输入以下代码,将40.5float)赋给先前创建的height变量:

height = 40.5f;

在我们输入上一行后,JShell 将显示下一个消息,指示它已将40.5float)分配给现有的float类型变量height

height ==> 40.5
|  assigned to height : float

再次在 JShell 中输入以下命令,列出当前活动变量的类型、名称和值:

/vars

以下行显示了反映我们已经为widthheight变量分配的新值的结果:

|    float width = 80.25
|    float height = 40.5
|    float area = 1250.0
|    PrintStream $5 = java.io.PrintStream@68c4039c
|    float $6 = 1250.0
|    float $8 = 1260.5

在 JShell 中输入以下代码,创建一个名为calculateRectanglePerimeter的新方法。该方法接收一个矩形的width变量和一个height变量,并返回float类型的两个值之和乘以2的结果。

float calculateRectanglePerimeter(float width, float height) {
    return 2 * (width + height);
}

在我们输入上一行后,JShell 将显示下一个消息,指示它已创建一个名为calculateRectanglePerimeter的方法,该方法有两个float类型的参数:

|  created method calculateRectanglePerimeter(float,float)

在 JShell 中输入以下命令,列出迄今为止在当前会话中创建的当前活动方法的名称、参数类型和返回类型:

/methods

以下行显示结果。

|    calculateRectangleArea (float,float)float
|    calculateRectanglePerimeter (float,float)float

在 JShell 中输入以下代码,打印调用最近创建的calculateRectanglePerimeter的结果,其中widthheight作为参数:

calculateRectanglePerimeter(width, height);

在我们输入上一行后,JShell 将调用该方法,并将结果分配给一个以$开头并带有数字的临时变量。JShell 显示了临时变量名$16,分配给该变量的值表示方法返回的结果241.5,以及临时变量的类型float。下面的行显示了在我们输入调用方法的先前表达式后,JShell 中显示的消息:

$16 ==> 241.5
|  created scratch variable $16 : float

现在,我们想对最近创建的calculateRectanglePerimeter方法进行更改。我们想添加一行来打印计算的周长。在 JShell 中输入以下命令,列出该方法的源代码:

/list calculateRectanglePerimeter

以下行显示了结果:

 15 : float calculateRectanglePerimeter(float width, float height) {
 return 2 * (width + height);
 }

在 JShell 中输入以下代码,用新代码覆盖名为calculateRectanglePerimeter的方法,该新代码打印接收到的宽度和高度值,然后使用与内置printf方法相同的方式工作的System.out.printf方法调用打印计算的周长。我们可以从先前列出的源代码中复制和粘贴这些部分。这里突出显示了更改:

float calculateRectanglePerimeter(float width, float height) {
 float perimeter = 2 * (width + height);
 System.out.printf("Width: %.2f\n", width);
 System.out.printf("Height: %.2f\n", height);
 System.out.printf("Perimeter: %.2f\n", perimeter);
 return perimeter;
}

在我们输入上述行后,JShell 将显示下一个消息,指示它已修改并覆盖了名为calculateRectanglePerimeter的方法,该方法有两个float类型的参数:

|  modified method calculateRectanglePerimeter(float,float)
|    update overwrote method calculateRectanglePerimeter(float,float)

在 JShell 中输入以下代码,以打印调用最近修改的calculateRectanglePerimeter方法并将widthheight作为参数的结果:

calculateRectanglePerimeter(width, height);

在我们输入上一行后,JShell 将调用该方法,并将结果分配给一个以$开头并带有数字的临时变量。前几行显示了由我们添加到方法中的三次调用System.out.printf生成的输出。最后,JShell 显示了临时变量名$19,分配给该变量的值表示方法返回的结果241.5,以及临时变量的类型float

下面的行显示了在我们输入调用方法的先前表达式后,JShell 中显示的消息:

Width: 80.25
Height: 40.50
Perimeter: 241.50
$19 ==> 241.5
|  created scratch variable $19 : float

在我们喜爱的外部代码编辑器中编辑源代码

我们创建了calculateRectanglePerimeter方法的新版本。现在,我们想对calculateRectangleArea方法进行类似的更改。但是,这一次,我们将利用编辑器来更轻松地对现有代码进行更改。

在 JShell 中输入以下命令,启动默认的 JShell 编辑面板编辑器,以编辑calculateRectangleArea方法的源代码:

/edit calculateRectangleArea

JShell 将显示一个对话框,其中包含 JShell 编辑面板和calculateRectangleArea方法的源代码,如下面的屏幕截图所示:

在我们喜爱的外部代码编辑器中编辑源代码

JShell 编辑面板缺少我们从代码编辑器中喜欢的大多数功能,我们甚至不能认为它是一个体面的代码编辑器。事实上,它只允许我们轻松地编辑源代码,而无需从先前的列表中复制和粘贴。我们将在以后学习如何配置更好的编辑器。

在 JShell 编辑面板中输入以下代码,以用新代码覆盖名为calculateRectangleArea的方法,该新代码打印接收到的宽度和高度值,然后使用Sytem.out.printf方法调用打印计算的面积。这里突出显示了更改:

float calculateRectangleArea(float width, float height) {
 float area = width * height;
 System.out.printf("Width: %.2f\n", width);
 System.out.printf("Height: %.2f\n", height);
 System.out.printf("Area: %.2f\n", area);
 return area;
}

点击接受,然后点击退出。JShell 将关闭 JShell 编辑面板,并显示下一个消息,指示它已修改并覆盖了名为calculateRectangleArea的方法,该方法有两个float类型的参数:

|  modified method calculateRectangleArea(float,float)
|    update overwrote method calculateRectangleArea(float,float)

在 JShell 中输入以下代码,以打印调用最近修改的calculateRectangleArea方法并将widthheight作为参数的结果:

calculateRectangleArea(width, height);

输入上述行后,JShell 将调用该方法,并将结果赋给一个以$开头并带有数字的临时变量。前几行显示了通过对该方法添加的三次System.out.printf调用生成的输出。最后,JShell 显示了临时变量名$24,指示方法返回的结果的值3250.125,以及临时变量的类型float。接下来的几行显示了在输入调用方法的新版本的前一个表达式后,JShell 显示的消息:

Width: 80.25
Height: 40.50
Area: 3250.13
$24 ==> 3250.125
|  created scratch variable $24 : float

好消息是,JShell 允许我们轻松配置任何外部编辑器来编辑代码片段。我们只需要获取要使用的编辑器的完整路径,并在 JShell 中运行一个命令来配置我们想要在使用/edit命令时启动的编辑器。

例如,在 Windows 中,流行的 Sublime Text 3 代码编辑器的默认安装路径是C:\Program Files\Sublime Text 3\sublime_text.exe。如果我们想要使用此编辑器在 JShell 中编辑代码片段,必须运行/set editor命令,后跟用双引号括起来的路径。我们必须确保在路径字符串中用双反斜杠(\)替换反斜杠(\)。对于先前解释的路径,我们必须运行以下命令:

/set editor "C:\\Program Files\\Sublimet Text 3\\sublime_text.exe"

输入上述命令后,JShell 将显示一条消息,指示编辑器已设置为指定路径:

| Editor set to: C:\Program Files\Sublime Text 3\sublime_text.exe

更改编辑器后,我们可以在 JShell 中输入以下命令,以启动新编辑器对calculateRectangleArea方法的源代码进行更改:

/edit calculateRectangleArea

JShell 将启动 Sublime Text 3 或我们可能指定的任何其他编辑器,并将加载一个临时文件,其中包含calculateRectangleArea方法的源代码,如下截图所示:

在我们喜欢的外部代码编辑器中编辑源代码

提示

如果我们保存更改,JShell 将自动覆盖该方法,就像我们使用默认编辑器 JShell Edit Pad 时所做的那样。进行必要的编辑后,我们必须关闭编辑器,以继续在 JShell 中运行 Java 代码或 JShell 命令。

在任何平台上,JShell 都会创建一个带有.edit扩展名的临时文件。因此,我们可以配置我们喜欢的编辑器,以便在打开带.edit扩展名的文件时使用 Java 语法高亮显示。

在 macOS 或 Linux 中,路径与 Windows 中的不同,因此必要的步骤也不同。例如,在 macOS 中,为了在默认路径中安装流行的 Sublime Text 3 代码编辑器时启动它,我们必须运行/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl

如果我们想要使用此编辑器在 JShell 中编辑代码片段,必须运行/set editor命令,后跟完整路径,路径需用双引号括起来。对于先前解释的路径,我们必须运行以下命令:

/set editor "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl"

输入上述命令后,JShell 将显示一条消息,指示编辑器已设置为指定路径:

|  Editor set to: /Applications/Sublime Text.app/Contents/SharedSupport/bin/subl

更改编辑器后,我们可以在 JShell 中输入以下命令,以启动新编辑器对calculateRectangleArea方法的源代码进行更改:

/edit calculateRectangleArea

JShell 将在 macOS 上启动 Sublime Text 3 或我们可能指定的任何其他编辑器,并将加载一个临时文件,其中包含calculateRectangleArea方法的源代码,如下截图所示:

在我们喜欢的外部代码编辑器中编辑源代码

加载源代码

当然,我们不必为每个示例输入源代码。自动补全功能很有用,但我们将利用一个命令,允许我们在 JShell 中从文件加载源代码。

按下Ctrl + D退出当前的 JShell 会话。在 Windows 命令提示符中或 macOS 或 Linux 终端中运行以下命令,以启动具有详细反馈的 JShell:

jshell -v

以下行显示了声明calculateRectanglePerimetercalculateRectangleArea方法的最新版本的代码。然后,代码声明并初始化了两个float类型的变量:widthheight。最后,最后两行调用了先前定义的方法,并将widthheight作为它们的参数。示例的代码文件包含在java_9_oop_chapter_01_01文件夹中的example01_01.java文件中。

float calculateRectanglePerimeter(float width, float height) {
    float perimeter = 2 * (width + height);
    System.out.printf("Width: %.2f\n", width);
    System.out.printf("Height: %.2f\n", height);
    System.out.printf("Perimeter: %.2f\n", perimeter);
    return perimeter;
}

float calculateRectangleArea(float width, float height) {
    float area = width * height;
    System.out.printf("Width: %.2f\n", width);
    System.out.printf("Height: %.2f\n", height);
    System.out.printf("Area: %.2f\n", area);
    return area;
}

float width = 120.25f;
float height = 35.50f;
calculateRectangleArea(width, height);
calculateRectanglePerimeter(width, height);
If the root folder for the source code in Windows is C:\Users\Gaston\Java9, you can run the following command to load and execute the previously shown source code in JShell:
/open C:\Users\Gaston\Java9\java_9_oop_chapter_01_01\example01_01.java

如果 macOS 或 Linux 中源代码的根文件夹是~/Documents/Java9,您可以运行以下命令在 JShell 中加载和执行先前显示的源代码:

/open ~/Documents/Java9/java_9_oop_chapter_01_01/example01_01.java

在输入先前的命令后,根据我们的配置和操作系统,JShell 将加载和执行先前显示的源代码,并在运行加载的代码片段后显示生成的输出。以下行显示了输出:

Width: 120.25
Height: 35.50
Area: 4268.88
Width: 120.25
Height: 35.50
Perimeter: 311.50

现在,在 JShell 中输入以下命令,以列出到目前为止在当前会话中执行的来自源文件的当前活动代码片段:

/list

以下行显示了结果。请注意,JShell 使用不同的片段 ID 为不同的方法定义和表达式添加前缀,因为加载的源代码的行为方式与我们逐个输入片段一样:

 1 : float calculateRectanglePerimeter(float width, float height) {
 float perimeter = 2 * (width + height);
 System.out.printf("Width: %.2f\n", width);
 System.out.printf("Height: %.2f\n", height);
 System.out.printf("Perimeter: %.2f\n", perimeter);
 return perimeter;
 }
 2 : float calculateRectangleArea(float width, float height) {
 float area = width * height;
 System.out.printf("Width: %.2f\n", width);
 System.out.printf("Height: %.2f\n", height);
 System.out.printf("Area: %.2f\n", area);
 return area;
 }
 3 : float width = 120.25f;
 4 : float height = 35.50f;

 5 : calculateRectangleArea(width, height);
 6 : calculateRectanglePerimeter(width, height);

提示

确保在找到书中的源代码时,使用先前解释的/open命令,后跟代码文件的路径和文件名,以便在 JShell 中加载和执行代码文件。这样,您就不必输入每个代码片段,而且可以检查在 JShell 中执行代码的结果。

测试你的知识

  1. JShell 是:

  2. Java 9 REPL。

  3. 在以前的 JDK 版本中等同于javac

  4. Java 9 字节码反编译器。

  5. REPL 的意思是:

  6. 运行-扩展-处理-循环。

  7. 读取-评估-处理-锁。

  8. 读取-评估-打印-循环。

  9. 以下哪个命令列出了当前 JShell 会话中创建的所有变量:

  10. /variables

  11. /vars

  12. /list-all-variables

  13. 以下哪个命令列出了当前 JShell 会话中创建的所有方法:

  14. /methods

  15. /meth

  16. /list-all-methods

  17. 以下哪个命令列出了当前 JShell 会话中迄今为止评估的源代码:

  18. /source

  19. /list

  20. /list-source

摘要

在本章中,我们开始了使用 Java 9 进行面向对象编程的旅程。我们学会了如何启动和使用 Java 9 中引入的新实用程序,该实用程序允许我们轻松运行 Java 9 代码片段并打印其结果:JShell。

我们学习了安装 JDK 9 所需的步骤,并了解了使用 REPL 的好处。我们学会了使用 JShell 来运行 Java 9 代码和评估表达式。我们还学会了许多有用的命令和功能。在接下来的章节中,当我们开始使用面向对象的代码时,我们将使用它们。

现在我们已经学会了如何使用 JShell,我们将学会如何识别现实世界的元素,并将它们转化为 Java 9 中支持的面向对象范式的不同组件,这是我们将在下一章中讨论的内容。

第二章:通过 JShell 识别 UML 图表和 Java 9 中的现实世界对象

在本章中,我们将学习如何从现实生活中的情况中识别对象。我们将了解,使用对象使得编写更易于理解和重用的代码变得更简单。我们将学习如何识别现实世界的元素,并将它们转化为 Java 9 中支持的面向对象范式的不同组件。我们将:

  • 从应用程序需求中识别对象

  • 从现实世界中捕捉对象

  • 生成类以创建对象

  • 识别变量和常量以创建字段

  • 识别创建方法的动作

  • 使用 UML 图表组织类

  • 利用领域专家的反馈来改进我们的类

  • 在 JShell 中使用 Java 对象

从应用程序需求中识别对象

每当你在现实世界中解决问题时,你都会使用元素并与它们互动。例如,当你口渴时,你拿起一个玻璃杯,倒满水、苏打水或你最喜欢的果汁,然后喝掉。同样,你可以轻松地从现实世界的场景中识别称为对象的元素,然后将它们转化为面向对象的代码。我们将开始学习面向对象编程的原则,以便在 Java 9 编程语言中开发任何类型的应用程序。

现在,我们将想象我们需要开发一个 RESTful Web 服务,这个服务将被移动应用程序和网络应用程序所使用。这些应用程序将具有不同的用户界面和多样化的用户体验。然而,我们不必担心这些差异,因为我们将专注于 Web 服务,也就是说,我们将成为后端开发人员。

艺术家使用不同的几何形状和有机形状的组合来创作艺术品。当然,创作艺术比这个简单的定义要复杂一些,但我们的目标是学习面向对象编程,而不是成为艺术专家。

几何形状由点和线组成,它们是精确的。以下是几何形状的例子:圆形、三角形、正方形、长方形。

有机形状是具有自然外观和弯曲外观的形状。这些形状通常是不规则的或不对称的。我们通常将来自自然界的事物,如动物和植物,与有机形状联系在一起。

当艺术家想要创造通常需要有机形状的事物的抽象解释时,他们使用几何形状。想象一下,Vanessa Pitstop 是一位画家和手工艺品制作人。几年前,她开始在 Instagram 和 YouTube 上上传关于她的艺术作品的视频,并在她的艺术生涯中取得了重要的里程碑:旧金山现代艺术博物馆准备举办她最重要艺术作品的展览。这一特别事件在社交网络网站上产生了巨大的影响,正如通常发生的那样,与这一重要的知名度提升相关的新软件开发任务也随之而来。

Pitstop 是一位非常受欢迎的 YouTuber,她的频道拥有超过四百万的粉丝。许多好莱坞女演员购买了她的艺术品,并在 Instagram 上上传了自拍照,背景是她的艺术作品。她的展览引起了对她作品的巨大额外兴趣,其中一位赞助商想要创建基于几何形状的移动应用程序和网络应用程序,并提供关于所有工具和丙烯颜料的细节,用户需要购买这些工具和颜料来制作艺术品。

Pitstop 草图基本形状,然后用丙烯颜料涂抹它们以构建几何图案。移动应用程序和 Web 应用程序将使用我们的 Web 服务来构建 Pitstop 的预定义图案,基于用户选择的画布大小和一些预定义的颜色方案。我们的 Web 服务将接收画布大小和颜色方案,以生成图案和材料清单。具体来说,Web 服务将提供用户必须购买的不同工具和丙烯颜料管、罐或瓶的清单,以绘制所绘制的图案。最后,用户将能够下订单请求所有或部分建议的材料。

以下图片显示了 Pitstop 的艺术作品的第一个例子,其中包含几何图案。让我们看一下图片,并提取组成图案的物体。

从应用需求中识别对象

以下对象组成了几何图案,具体来说,从上到下的以下 2D 形状:

  • 12 个等边三角形

  • 6 个正方形

  • 6 个矩形

  • 28 个圆

  • 4 个椭圆

  • 28 个圆

  • 6 个矩形

  • 6 个正方形

  • 12 个等边三角形

相当简单地描述组成图案的 108 个物体或 2D 形状。我们能够识别所有这些物体,并指出每个物体的具体 2D 形状。如果我们测量每个三角形,我们会意识到它们是等边三角形。

以下图片显示了 Pitstop 的艺术作品的第二个例子,其中包含几何图案。让我们看一下图片,并提取组成图案的物体。

从应用需求中识别对象

以下对象组成了几何图案,具体来说,从上到下的以下 2D 形状:

  • 12 个等边三角形

  • 6 个正五边形

  • 6 个矩形

  • 24 个正六边形

  • 4 个椭圆

  • 24 个正六边形

  • 6 个矩形

  • 6 个正五边形

  • 12 个等边三角形

这一次,我们可以描述组成图案的 100 个物体或 2D 形状。我们能够识别所有这些物体,并指出每个物体的具体 2D 形状。如果我们测量每个五边形和六边形,我们会意识到它们是正五边形和六边形。

以下图片显示了 Pitstop 的艺术作品的第三个例子,其中包含几何图案。在这种情况下,我们有大量的 2D 形状。让我们看一下图片,只提取图案中包含的不同 2D 形状。这一次,我们不会计算物体的数量。

从应用需求中识别对象

该图案包括以下 2D 形状:

  • 等边三角形

  • 正方形

  • 正五边形

  • 正六边形

  • 正七边形

  • 正八边形

  • 正十边形

以下图片显示了 Pitstop 的艺术作品的第四个例子,其中包含几何图案。在这种情况下,我们也有大量的 2D 形状,其中一些与彼此相交。然而,如果我们留意,我们仍然能够识别不同的 2D 形状。让我们看一下图片,只提取图案中包含的不同 2D 形状。我们不会计算物体的数量。

从应用需求中识别对象

该图案包括以下 2D 形状:

  • 正五边形

  • 正十边形

  • 圆形

  • 等边三角形

  • 正方形

  • 正八边形

以下图片显示了 Pitstop 的艺术作品的第五个例子,其中包含几何图案。在这种情况下,我们将从左到右识别形状,因为图案有不同的方向。我们有许多形状相互交叉。让我们看一下图片,只提取图案中包含的不同 2D 形状。我们不会计算物体的数量。

从应用需求中识别对象

该图案包括以下 2D 形状:

  • 圆形

  • 正八边形

  • 等边三角形

  • 正方形

  • 正八边形

捕捉现实世界的物体

我们可以轻松地从 Pitstop 的艺术品中识别出对象。我们了解到每个模式由许多二维几何形状组成,并且我们在分析的所有示例中识别出了她使用的不同形状。现在,让我们专注于 Web 服务的核心需求之一,即计算所需的丙烯酸漆量以制作艺术品。我们必须考虑每个模式中包含的每种二维形状的以下数据,以便计算所需的材料和生产每种形状所需的丙烯酸漆的数量:

  • 线颜色

  • 周长

  • 填充颜色

  • 面积

可以使用特定颜色来绘制每个形状的边界线,因此,我们必须计算周长,以便将其用作估算用户必须购买的丙烯酸漆的数量之一,以绘制每个二维形状的边界。然后,我们必须计算面积,以便将其用作估算用户必须购买的丙烯酸漆的数量之一,以填充每个二维形状的区域。

我们必须开始为我们的 Web 服务后端代码进行工作,该代码计算我们在迄今为止分析的所有示例艺术品中识别出的不同二维形状的面积和周长。我们得出结论,Web 服务必须支持以下九种形状的模式:

  • 椭圆

  • 等边三角形

  • 正方形

  • 矩形

  • 正五边形

  • 正六边形

  • 正八边形

  • 正十边形

在进行一些关于二维几何的研究后,我们可以开始编写 Java 9 代码。具体来说,我们可能会编写九种方法来计算先前列举的二维形状的面积,另外九种方法来计算它们的周长。请注意,我们正在谈论将返回计算值的方法,也就是函数。我们停止了对对象的思考,因此,我们将在这条路上遇到一些问题,我们将用面向对象的方法来解决这些问题。

例如,如果我们开始考虑解决问题的方法,一个可能的解决方案是编写以下十八个函数来完成工作:

  • calculateCircleArea

  • calculateEllipseArea

  • calculateEquilateralTriangleArea

  • calculateSquareArea

  • calculateRectangleArea

  • calculateRegularPentagonArea

  • calculateRegularHexagonArea

  • calculateRegularOctagonArea

  • calculateRegularDecagonArea

  • calculateCirclePerimeter

  • calculateEllipsePerimeter

  • calculateEquilateralTrianglePerimeter

  • calculateSquarePerimeter

  • calculateRectanglePerimeter

  • calculateRegularPentagonPerimeter

  • calculateRegularHexagonPerimeter

  • calculateRegularOctagonPerimeter

  • calculateRegularDecagonPerimeter

先前列举的每种方法都必须接收每种形状的必要参数,并返回其计算出的面积或周长。这些函数没有副作用,也就是说,它们不会改变接收到的参数,并且只返回计算出的面积或周长的结果。

现在,让我们暂时忘记方法或函数。让我们回到我们被分配的 Web 服务需求中的真实世界对象。我们必须计算九个元素的面积和周长,这些元素是需求中代表真实物体的九个名词,具体来说是二维形状。我们已经建立了一个包含九个真实世界对象的列表。

在识别了现实生活中的对象并对其进行了一些思考之后,我们可以通过遵循面向对象的范例来开始设计我们的 Web 服务。我们可以创建代表列举的 2D 形状的状态和行为的软件对象,而不是创建一组执行所需任务的方法。这样,不同的对象模拟了现实世界的 2D 形状。我们可以使用这些对象来指定计算面积和周长所需的不同属性。然后,我们可以扩展这些对象以包括计算其他所需值所需的附加数据,例如绘制边界所需的丙烯酸漆的数量。

现在,让我们进入现实世界,思考之前列举的九种形状中的每一种。想象一下,我们必须在纸上绘制每种形状并计算它们的面积和周长。在我们绘制每种形状之后,我们将使用哪些值来计算它们的面积和周长?我们将使用哪些公式?

提示

我们在开始编码之前就开始了面向对象的设计,因此,我们将像不了解几何学的许多概念一样工作。例如,我们可以很容易地推广我们用来计算正多边形周长和面积的公式。然而,在大多数情况下,我们不会是该主题的专家,我们必须在可以用面向对象的方法概括行为之前获得一些应用领域的知识。因此,我们将深入研究这个主题,就好像我们对这个主题知之甚少。

下图显示了一个绘制的圆和我们将用来计算其周长和面积的公式。我们只需要半径值,通常标识为r

捕捉现实世界的对象

下图显示了一个绘制的椭圆和我们将用来计算其周长和面积的公式。我们需要半长轴(通常标记为a)和半短轴(通常标记为b)的值。请注意,提供的周长公式提供了一个不太精确的近似值。我们将稍后更深入地研究这个特定问题。

捕捉现实世界的对象

下图显示了一个绘制的等边三角形和我们将用来计算其周长和面积的公式。这种三角形的三条边相等,三个内角相等于 60 度。我们只需要边长值,通常标识为a

捕捉现实世界的对象

下图显示了一个绘制的正方形和我们将用来计算其周长和面积的公式。我们只需要边长值,通常标识为a

捕捉现实世界的对象

下图显示了一个绘制的矩形和我们将用来计算其周长和面积的公式。我们需要宽度和高度值,通常标识为wh

捕捉现实世界的对象

下图显示了一个绘制的正五边形和我们将用来计算其周长和面积的公式。我们只需要边长值,通常标记为a

捕捉现实世界的对象

下图显示了一个绘制的正六边形和我们将用来计算其周长和面积的公式。我们只需要边长值,通常标记为a

![捕捉现实世下图显示了一个绘制的正八边形和我们将用来计算其周长和面积的公式。我们只需要边长值,通常标记为a捕捉现实世界的对象

下图显示了一个绘制的正十边形和我们将用来计算其周长和面积的公式。我们只需要边长值,通常标记为a

捕捉现实世界的对象

以下表格总结了计算每种形状的周长和面积所需的数据:

形状 所需数据
半径
椭圆 半长轴和半短轴
等边三角形 边长
正方形 边长
矩形 宽度和高度
正五边形 边长
正六边形 边长
正八边形 边长
正十边形 边长

每个代表特定形状的对象都封装了我们确定的所需数据。例如,代表椭圆的对象将封装椭圆的半长轴和半短轴值,而代表矩形的对象将封装矩形的宽度和高度值。

注意

数据封装是面向对象编程的重要支柱之一。

生成类以创建对象

假设我们必须绘制和计算三个不同矩形的周长和面积。你最终会得到三个矩形,它们的宽度和高度值以及计算出的周长和面积。有一个蓝图来简化绘制每个具有不同宽度和高度值的矩形的过程将是很好的。

在面向对象编程中,是创建对象的模板定义或蓝图。类是定义对象状态和行为的模型。声明了定义矩形状态和行为的类之后,我们可以使用它来生成代表每个真实世界矩形状态和行为的对象。

注意

对象也被称为实例。例如,我们可以说每个矩形对象是Rectangle类的一个实例。

下图显示了两个名为rectangle1rectangle2的矩形实例。这些实例是根据它们指定的宽度和高度值绘制的。我们可以使用Rectangle类作为蓝图来生成这两个不同的Rectangle实例。请注意,rectangle1的宽度和高度值为3620rectangle2的宽度和高度值为2241。每个实例的宽度和高度值都不同。理解类和通过其使用生成的对象或实例之间的区别非常重要。Java 9 支持的面向对象编程特性允许我们发现我们用来生成特定对象的蓝图。我们将在接下来的章节中使用这些特性。因此,我们可以确定每个对象是否是Rectangle类的实例。

生成类以创建对象

下图显示了两个名为pentagon1pentagon2的正五边形实例。这些实例是根据它们指定的边长值绘制的。我们可以使用RegularPentagon类作为蓝图来生成这两个不同的RegularPentagon实例。请注意,pentagon1的边长值为20pentagon2的边长值为16。每个实例的边长值都不同。

生成类以创建对象

下图显示了四个名为ellipse1ellipse2ellipse3ellipse4的椭圆实例。这些实例是根据它们指定的半长轴和半短轴值绘制的。我们可以使用Ellipse类作为蓝图来生成这四个不同的Ellipse实例。请注意,每个椭圆都有其自己特定的半长轴和半短轴值。

生成类以创建对象

我们从 Web 服务需求中识别出了九个完全不同的真实世界对象,因此,我们可以生成以下九个类来创建必要的对象:

  • 椭圆

  • 等边三角形

  • 正方形

  • 矩形

  • 正五边形

  • 正六边形

  • 正八边形

  • 正十边形

提示

请注意类名使用Pascal case。Pascal case 意味着组成名称的每个单词的第一个字母大写,而其他字母小写。这是 Java 中的编码约定。例如,我们使用EquilateralTriangle名称来命名将允许我们生成多个等边三角形的蓝图类。

识别变量和常量

我们知道每个形状所需的信息以实现我们的目标。现在,我们必须设计类,包括提供所需数据给每个实例的必要字段。我们必须确保每个类都有必要的字段,封装了对象执行基于我们应用领域的所有任务所需的所有数据。

让我们从Circle类开始。我们需要为该类的每个实例,也就是每个圆形对象,知道半径。因此,我们需要一个封装的变量,允许Circle类的每个实例指定半径的值。

注意

在 Java 9 中,用于封装每个类实例的数据的变量被称为字段。每个实例都有其自己独立的字段值。字段允许我们为类的实例定义特征。在其他支持面向对象原则的编程语言中,这些在类中定义的变量被称为属性

Circle类定义了一个名为radius的浮点字段,其初始值对于该类的任何新实例都等于0。创建Circle类的实例后,可以更改radius属性的值。因此,我们创建后的圆形可以变得更小或更大。

提示

请注意字段名称使用Camel case。Camel case 意味着第一个字母小写,然后组成名称的每个单词的第一个字母大写,而其他字母小写。这是 Java 中的编码约定,适用于变量和字段。例如,我们使用radius名称来存储半径的字段值,而在其他需要这些数据的类中,我们将使用lengthOfSide来存储边长的属性值。

想象一下,我们创建了Circle类的两个实例。一个实例名为circle1,另一个实例名为circle2。实例名称允许我们访问每个对象的封装数据,因此,我们可以使用它们来更改暴露字段的值。

Java 9 使用点(.)来允许我们访问实例的属性。因此,circle1.radius提供了对名为circle1Circle实例的半径的访问,circle2.radius对名为circle2Circle实例也是如此。

提示

请注意,命名约定使我们能够区分实例名称(即变量)和类名称。每当我们看到大写字母或首字母大写时,这意味着我们正在谈论一个类,如CircleRectangle

我们可以将14分配给circle1.radius,将39分配给circle2.radius。这样,每个Circle实例将对radius字段有不同的值。

现在,让我们转到Rectangle类。我们必须为该类定义两个浮点字段:widthheight。它们的初始值也将为0。然后,我们可以创建四个Rectangle类的实例,分别命名为rectangle1rectangle2rectangle3rectangle4

我们可以将下表总结的值分配给Rectangle类的四个实例:

实例名称 width height
rectangle1 141 281
rectangle2 302 162
rectangle3 283 73
rectangle4 84 214

这样,rectangle1.width 将等于 141,而 rectangle4.width 将等于 84rectangle1 实例表示宽度为 141,高度为 281 的矩形。

以下表格总结了我们需要用于 Web 服务后端代码的九个类中定义的浮点字段:

类名 字段列表
半径
椭圆 半短轴半长轴
等边三角形 边长
正方形 边长
矩形 宽度高度
正五边形 边长
正六边形 边长
正八边形 边长
正十边形 边长

提示

这些字段是各自类的成员。然而,字段并不是类可以拥有的唯一成员。

请注意,这六个类中有六个具有相同字段:边长,具体来说,以下六个类:等边三角形正方形正五边形正六边形正八边形正十边形。我们稍后将深入研究这六个类的共同之处,并利用面向对象的特性来重用代码并简化我们的 Web 服务维护。然而,我们刚刚开始我们的旅程,随着我们学习 Java 9 中包含的其他面向对象特性,我们将进行改进。实际上,让我们记住我们正在学习应用领域,并且我们还不是 2D 形状的专家。

下图显示了一个带有九个类及其字段的UML统一建模语言)类图。这个图非常容易理解。类名出现在标识每个类的矩形的顶部。与类名相同形状下方的矩形显示了类暴露的所有字段名称,并以加号(+)作为前缀。这个前缀表示其后是 UML 中的属性名称和 Java 9 中的字段名称。请注意,下一个 UML 图并不代表我们类的最佳组织。这只是第一个草图。

识别变量和常量

识别创建方法的操作

到目前为止,我们设计了九个类,并确定了每个类所需的字段。现在,是时候添加与先前定义的字段一起工作的必要代码片段,以执行所有必要的任务,即计算周长和面积。我们必须确保每个类都有必要的封装函数,以处理对象中指定的属性值来执行所有任务。

让我们暂时忘记不同类之间的相似之处。我们将分别处理它们,就好像我们对几何公式没有必要的了解一样。我们将从类开始。我们需要一些代码片段,允许该类的每个实例使用半径属性的值来计算面积和周长。

提示

类中定义的用于封装类的每个实例行为的函数称为方法。每个实例都可以访问类暴露的方法集。方法中指定的代码可以使用类中指定的字段。当我们执行一个方法时,它将使用特定实例的字段。每当我们定义方法时,我们必须确保我们将它们定义在一个逻辑的地方,也就是所需数据所在的地方。

当一个方法不需要参数时,我们可以说它是一个无参数方法。在这种情况下,我们最初为类定义的所有方法都将是无参数方法,它们只是使用先前定义的字段的值,并使用先前在详细分析每个 2D 形状时显示的公式。因此,我们将能够在不带参数的情况下调用这些方法。我们将开始创建方法,但稍后我们将能够根据特定的 Java 9 功能探索其他选项。

Circle类定义了以下两个无参数方法。我们将在Circle类的定义中声明这两个方法的代码,以便它们可以访问radius属性的值,如下所示:

  • calculateArea:此方法返回一个浮点值,表示圆的计算面积。它返回 Pi(π)乘以radius字段值的平方(π * radius²或π * (radius ^ 2))。

  • calculatePerimeter:此方法返回一个浮点值,表示圆的计算周长。它返回 Pi(π)乘以 2 倍的radius字段值(π * 2 * radius)。

提示

在 Java 9 中,Math.PI为我们提供了 Pi 的值。Math.pow方法允许我们计算第一个参数的值的幂。我们将在以后学习如何在 Java 9 中编写这些方法。

这些方法没有副作用,也就是说,它们不会对相关实例进行更改。这些方法只是返回计算的值,因此我们认为它们是非变异方法。它们的操作自然由calculate动词描述。

Java 9 使用点(.)允许我们执行实例的方法。假设我们有两个Circle类的实例:circle1radius属性为5circle2radius属性为10

如果我们调用circle1.calculateArea(),它将返回π * 5²的结果,约为78.54。如果我们调用square2.calculateArea(),它将返回π * 10²的结果,约为314.16。每个实例的radius属性值不同,因此执行calculateArea方法的结果也不同。

如果我们调用circle1.calculatePerimeter(),它将返回π * 2 * 5的结果,约为31.41。另一方面,如果我们调用circle2.calculatePerimeter(),它将返回*π 2 * 10的结果,约为62.83

现在,让我们转到Rectangle类。我们需要两个与Circle类指定的相同名称的方法:calculateAreacalculatePerimeter。此外,这些方法返回相同的类型,不需要参数,因此我们可以像在Circle类中一样将它们都声明为无参数方法。然而,这些方法必须以不同的方式计算结果;也就是说,它们必须使用矩形的适当公式,并考虑widthheight字段的值。其他类也需要相同的两个方法。但是,它们每个都将使用相关形状的适当公式。

我们在Ellipse类生成的calculatePerimeter方法中遇到了特定的问题。对于椭圆来说,周长计算非常复杂,因此有许多提供近似值的公式。精确的公式需要无限系列的计算。我们将使用一个初始公式,它并不是非常精确,但我们以后会找到解决这个问题的方法,并改进结果。初始公式将允许我们返回一个浮点值,该值是椭圆周长的计算近似值。

以下图表显示了更新后的 UML 图表,其中包括九个类、它们的属性和方法。它显示了第二轮的结果:

识别创建方法的操作

使用 UML 图表组织类

到目前为止,我们的面向对象的解决方案包括九个类及其字段和方法。然而,如果我们再看看这九个类,我们会注意到它们都有相同的两个方法:calculateAreacalculatePerimeter。每个类中方法的代码是不同的,因为每个形状使用特殊的公式来计算面积或周长。然而,方法的声明、契约、接口或协议是相同的。这两个方法都有相同的名称,始终没有参数,并返回一个浮点值。因此,它们都返回相同的类型。

当我们谈论这九个类时,我们说我们在谈论九种不同的几何 2D 形状或简单的形状。因此,我们可以概括这九种形状的所需行为、协议或接口。这九种形状必须定义具有先前解释的声明的calculateAreacalculatePerimeter方法。我们可以创建一个接口来确保这九个类提供所需的行为。

接口是一个名为Shape的特殊类,它概括了我们应用程序中的几何 2D 形状的要求。在这种情况下,我们将使用一个特殊的类来工作,我们不会用它来创建实例,但将来我们会使用接口来实现相同的目标。Shape类声明了两个没有参数的方法,返回一个浮点值:calculateAreacalculatePerimeter。然后,我们将这九个类声明为Shape类的子类,它们将继承这些定义,并为这些方法的每一个提供特定的代码。

提示

Shape的子类(CircleEllipseEquilateralTriangleSquareRectangleRegularPentagonRegularHexagonRegularOctagonRegularDecagon)实现这些方法,因为它们提供了代码,同时保持了Shape超类中指定的相同方法声明。抽象层次结构是面向对象编程的两个主要支柱。我们只是在这个主题上迈出了第一步。

面向对象编程允许我们发现一个对象是否是特定超类的实例。当我们改变这九个类的组织结构,它们成为Shape的子类后,CircleEllipseEquilateralTriangleSquareRectangleRegularPentagonRegularHexagonRegularOctagonRegularDecagon的任何实例也是Shape类的实例。

事实上,解释抽象并不难,因为当我们说它代表现实世界时,我们说的是面向对象模型的真相。

说一个正十边形是一个形状是有道理的,因此,RegularDecagon的一个实例也是Shape类的一个实例。RegularDecagon的一个实例既是ShapeRegularDecagon的超类)又是RegularDecagon(我们用来创建对象的类)。

下图显示了 UML 图的更新版本,包括超类或基类(Shape)、它的九个子类以及它们的属性和方法。请注意,图中使用一条线以箭头结束,将每个子类连接到其超类。您可以将以箭头结束的线读作:线开始的类线结束的类的子类。例如,CircleShape的子类,RectangleShape的子类。该图显示了第三轮的结果。

使用 UML 图组织类

注意

一个类可以是多个子类的超类。

使用领域专家的反馈

现在,是时候与我们的领域专家进行会议了,也就是那些对二维几何有着出色知识的人。我们可以使用 UML 图来解释解决方案的面向对象设计。在我们解释了用于抽象行为的不同类之后,领域专家向我们解释了许多形状都有共同之处,并且我们可以进一步概括行为。以下六种形状都是正多边形:

  • 一个等边三角形(EquilateralTriangle类)有三条边

  • 一个正方形(Square类)有四条边

  • 一个正五边形(RegularPentagon类)有五条边

  • 一个正六边形(RegularHexagon类)有六条边

  • 一个正八边形(RegularOctagon类)有八条边

  • 一个正十边形(RegularDecagon类)有十条边

正多边形是既等角又等边的多边形。组成正多边形的所有边都具有相同的长度,并围绕一个共同的中心放置。这样,任意两条边之间的所有角度都是相等的。

以下图片显示了六个正多边形和我们可以用来计算它们周长和面积的通用公式。计算面积的通用公式要求我们计算余切,该余切在公式中缩写为cot

使用领域专家的反馈

提示

在 Java 9 中,Math类没有提供直接计算余切的方法。但是,它提供了计算正切的方法:Math.tanx的余切等于x的正切的倒数:1/ Math.tan(x)。因此,我们可以用这个公式轻松计算余切。

由于这三种形状使用相同的公式,只是参数(n)的值不同,我们可以为这六个正多边形概括所需的接口。该接口是一个名为RegularPolygon的特殊类,它定义了一个新的getSidesCount方法,返回一个整数值作为边数。RegularPolygon类是先前定义的Shape类的子类。这是有道理的,因为正多边形确实是一种形状。代表正多边形的六个类成为RegularPolygon的子类。然而,RegularPolygon类中编写了calculateAreacalculatePerimeter方法,使用了通用公式。子类编写了getSidesCount方法以返回正确的值,如下所示:

  • EquilateralTriangle: 3

  • Square: 4

  • RegularPentagon: 5

  • RegularHexagon: 6

  • RegularOctagon: 8

  • RegularDecagon: 10

RegularPolygon类还定义了lengthOfSide属性,该属性先前在代表正多边形的三个类中定义。现在,这六个类成为RegularPolygon的子类,并继承了lengthOfSide属性。以下图显示了 UML 图的更新版本,其中包括新的RegularPolygon类和代表正多边形的六个类的更改。代表正多边形的六个类不声明calculateAreacalculatePerimeter方法,因为这些类从RegularPolygon超类继承了这些方法,并且不需要对应用通用公式的这些方法进行更改。

该图显示了第四轮的结果。

使用领域专家的反馈

当我们分析椭圆时,我们提到在计算其周长时存在问题。我们与我们的领域专家交谈,他为我们提供了有关该问题的详细信息。有许多公式可以提供该形状周长的近似值。添加使用其他公式计算周长的附加方法是有意义的。他建议我们使得可以使用以下公式计算周长:

  • David W. Cantrell提出的一个公式

  • Srinivasa Aiyangar Ramanujan 开发的公式的第二个版本

我们将为Ellipse类定义以下两个额外的无参数方法。新方法将返回一个浮点值,并解决椭圆形状的特定问题:

  • calculatePerimeterWithRamanujanII

  • calculatePerimeterWithCantrell

这样,Ellipse类将实现Shape超类中指定的方法,并添加两个特定方法,这些方法不包括在Shape的任何其他子类中。下图显示了更新后的 UML 图中Ellipse类的新方法。

该图显示了第五轮的结果:

使用领域专家的反馈

测试您的知识

  1. 对象也被称为:

  2. 子类。

  3. 字段。

  4. 实例。

  5. 以下哪个类名遵循帕斯卡命名约定,并且是 Java 9 中类的适当名称:

  6. regularDecagon

  7. RegularDecagon

  8. Regulardecagon

  9. 在类的方法中指定的代码:

  10. 可以访问类中指定的字段。

  11. 无法与类的其他成员交互。

  12. 无法访问类中指定的字段。

  13. 在一个类中定义的函数,用于封装类的每个实例的行为,被称为:

  14. 子类。

  15. 字段。

  16. 方法。

  17. 子类:

  18. 仅从其超类继承方法。

  19. 仅从其超类继承字段。

  20. 继承其超类的所有成员。

  21. 在 Java 9 中,用于封装类的每个实例的数据的变量被称为:

  22. 字段。

  23. 方法。

  24. 子类。

  25. 在 Java 9 中,用于封装类的每个实例的数据的变量被称为:

  26. 字段。

  27. 方法。

  28. 子类。

  29. 以下哪个字段名称遵循驼峰命名约定,并且是 Java 9 中字段的适当名称:

  30. SemiMinorAxis

  31. semiMinorAxis

  32. semiminoraxis

摘要

在本章中,您学会了如何识别现实世界的元素,并将它们转化为 Java 9 中支持的面向对象范式的不同组件:类、字段、方法和实例。您了解到类代表了生成对象的蓝图或模板,也被称为实例。

我们设计了一些具有字段和方法的类,这些类代表了现实生活中的蓝图,具体来说是 2D 形状。然后,我们通过利用抽象的力量和专门化不同的类来改进了初始设计。随着我们添加了超类和子类,我们生成了初始 UML 图的许多版本。我们了解了应用领域,并随着知识的增加和我们意识到能够概括行为,我们对原始设计进行了更改。

现在您已经学会了面向对象范式的一些基础知识,我们准备在 Java 9 中使用 JShell 创建类和实例,这是我们将在下一章讨论的内容。是时候开始面向对象编码了!

第三章:类和实例

在本章中,我们将开始使用 Java 9 中如何编写类和自定义实例初始化的示例。我们将了解类如何作为生成实例的蓝图工作,并深入了解垃圾回收机制。我们将:

  • 在 Java 9 中理解类和实例

  • 处理对象初始化及其自定义

  • 了解对象的生命周期

  • 介绍垃圾回收

  • 声明类

  • 自定义构造函数和初始化

  • 了解垃圾回收的工作原理

  • 创建类的实例并了解其范围

在 Java 9 中理解类和实例

在上一章中,我们学习了面向对象范式的一些基础知识,包括类和对象。我们开始为与 2D 形状相关的 Web 服务的后端工作。我们最终创建了一个具有许多类结构的 UML 图,包括它们的层次结构、字段和方法。现在是利用 JShell 开始编写基本类并在 JShell 中使用其实例的时候了。

在 Java 9 中,类始终是类型和蓝图。对象是类的工作实例,因此对象也被称为实例

注意

类在 Java 9 中是一流公民,它们将是我们面向对象解决方案的主要构建块。

一个或多个变量可以持有对实例的引用。例如,考虑以下三个Rectangle类型的变量:

  • 矩形 1

  • 矩形 2

  • 矩形 10

  • 矩形 20

假设rectangle1变量持有对Rectangle类实例的引用,其width设置为36height设置为20rectangle10变量持有对rectangle1引用的相同实例。因此,我们有两个变量持有对相同的Rectangle对象的引用。

rectangle2变量持有对Rectangle类实例的引用,其width设置为22height设置为41rectangle20变量持有对rectangle2引用的相同实例。我们还有另外两个变量持有对相同的Rectangle对象的引用。

下图说明了许多Rectangle类型的变量持有对单个实例的引用的情况。变量名位于左侧,带有宽度和高度值的矩形代表Rectangle类的特定实例。

在 Java 9 中理解类和实例

在本章的后面,我们将在 JShell 中使用许多持有对单个实例的引用的变量。

处理对象初始化及其自定义

当您要求 Java 创建特定类的实例时,底层会发生一些事情。Java 创建指定类型的新实例,JVMJava 虚拟机)分配必要的内存,然后执行构造函数中指定的代码。

当 Java 执行构造函数中的代码时,类已经存在一个活动实例。因此,构造函数中的代码可以访问类中定义的字段和方法。显然,我们必须小心构造函数中放置的代码,因为我们可能会在创建类的实例时产生巨大的延迟。

提示

构造函数非常有用,可以执行设置代码并正确初始化新实例。

让我们忘记我们之前为代表 2D 形状的类工作的层次结构。想象一下,我们必须将Circle类编码为一个独立的类,不继承自任何其他类。在我们调用calculateAreacalculatePerimeter方法之前,我们希望每个新的Circle实例的半径字段都有一个初始化为代表圆的适当值的值。我们不希望创建新的Circle实例而不指定半径字段的适当值。

提示

当我们想要在创建实例后立即为类的实例的字段定义值,并在访问引用创建的实例的变量之前使用构造函数时,构造函数非常有用。事实上,创建特定类的实例的唯一方法是使用我们提供的构造函数。

每当我们需要在创建实例时提供特定参数时,我们可以声明许多不同的构造函数,其中包含必要的参数,并使用它们来创建类的实例。构造函数允许我们确保没有办法创建特定的类,而不使用提供必要参数的构造函数。因此,如果提供的构造函数需要一个半径参数,那么我们将无法创建类的实例,而不指定半径参数的值。

想象一下,我们必须将Rectangle类编码为一个独立的类,不继承自任何其他类。在我们调用calculateAreacalculatePerimeter方法之前,我们希望每个新的Rectangle实例的宽度高度字段都有一个初始化为代表每个矩形的适当值的值。我们不希望创建新的Rectangle实例而不指定宽度高度字段的适当值。因此,我们将为这个类声明一个需要宽度高度值的构造函数。

引入垃圾收集

在某个特定时间,您的应用程序将不再需要使用实例。例如,一旦您计算了圆的周长,并且已经在 Web 服务响应中返回了必要的数据,您就不再需要继续使用特定的Circle实例。一些编程语言要求您小心地保留活动实例,并且必须显式销毁它们并释放它们消耗的内存。

Java 提供了自动内存管理。JVM 运行时使用垃圾收集机制,自动释放不再被引用的实例使用的内存。垃圾收集过程非常复杂,有许多不同的算法及其优缺点,JVM 有特定的考虑因素,应该考虑避免不必要的巨大内存压力。然而,我们将专注于对象的生命周期。在 Java 9 中,当 JVM 运行时检测到您不再引用实例,或者最后一个保存对特定实例的引用的变量已经超出范围时,它会使实例准备好成为下一个垃圾收集周期的一部分。

例如,让我们考虑我们先前的例子,其中有四个变量保存对Rectangle类的两个实例的引用。考虑到rectangle1rectangle2变量都超出了范围。被rectangle1引用的实例仍然被rectangle10引用,而被rectangle2引用的实例仍然被rectangle20引用。因此,由于仍在被引用,没有一个实例可以从内存中删除。下图说明了这种情况。超出范围的变量在右侧有一个 NO 标志。

引入垃圾收集

rectangle10超出范围后,它引用的实例变得可处理,因此可以安全地添加到可以从内存中删除的对象列表中。以下图片说明了这种情况。准备从内存中删除的实例具有回收符号。

引入垃圾收集

rectangle20超出范围后,它引用的实例变得可处理,因此可以安全地添加到可以从内存中删除的对象列表中。以下图片说明了这种情况。这两个实例都准备从内存中删除,它们都有一个回收符号。

引入垃圾收集

注意

JVM 会在后台自动运行垃圾收集过程,并自动回收那些准备进行垃圾收集且不再被引用的实例所消耗的内存。我们不知道垃圾收集过程何时会发生在特定实例上,也不应该干预这个过程。Java 9 中的垃圾收集算法已经得到改进。

想象一下,我们必须分发我们存放在盒子里的物品。在我们分发所有物品之后,我们必须将盒子扔进回收站。当我们还有一个或多个物品在盒子里时,我们不能将盒子扔进回收站。我们绝对不想丢失我们必须分发的物品,因为它们非常昂贵。

这个问题有一个非常简单的解决方案:我们只需要计算盒子中剩余物品的数量。当盒子中的物品数量达到零时,我们可以摆脱盒子,也就是说,我们可以将其扔进回收站。然后,垃圾收集过程将移除所有被扔进回收站的物品。

提示

幸运的是,我们不必担心将实例扔进回收站。Java 会自动为我们做这些。对我们来说完全透明。

一个或多个变量可以持有对类的单个实例的引用。因此,在 Java 可以将实例放入准备进行垃圾收集的列表之前,有必要考虑对实例的引用数量。当对特定实例的引用数量达到零时,可以安全地从内存中删除该实例并回收该实例消耗的内存,因为没有人再需要这个特定的实例。此时,实例已准备好被垃圾收集过程移除。

例如,我们可以创建一个类的实例并将其分配给一个变量。Java 将知道有一个引用指向这个实例。然后,我们可以将相同的实例分配给另一个变量。Java 将知道有两个引用指向这个单一实例。

在第一个变量超出范围后,仍然可以访问持有对实例的引用的第二个变量。Java 将知道仍然有另一个变量持有对这个实例的引用,因此该实例不会准备进行垃圾收集。此时,实例仍然必须可用,也就是说,我们需要它存活。

在第二个变量超出范围后,没有更多的变量持有对实例的引用。此时,Java 将标记该实例为准备进行垃圾收集,因为没有更多的变量持有对该实例的引用,可以安全地从内存中删除。

声明类

以下行声明了一个新的最小Rectangle类在 Java 中。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_01.java文件中。

class Rectangle {
}

class关键字,后面跟着类名(Rectangle),构成了类定义的头部。在这种情况下,我们没有为Rectangle类指定父类或超类。大括号({})对在类头部之后包围了类体。在接下来的章节中,我们将声明从另一个类继承的类,因此它们将有一个超类。在这种情况下,类体是空的。Rectangle类是我们可以在 Java 9 中声明的最简单的类。

注意

任何你创建的新类,如果没有指定超类,将会是java.lang.Object类的子类。因此,Rectangle类是java.lang.Object的子类。

以下行代表了创建Rectangle类的等效方式。然而,我们不需要指定类继承自java.lang.Object,因为这会增加不必要的样板代码。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_02.java文件中。

class Rectangle extends java.lang.Object {
}

自定义构造函数和初始化

我们希望用新矩形的宽度和高度值来初始化Rectangle类的实例。为了做到这一点,我们可以利用之前介绍的构造函数。构造函数是特殊的类方法,在我们创建给定类型的实例时会自动执行。在类内部的任何其他代码之前,Java 会运行构造函数内的代码。

我们可以定义一个构造函数,它接收宽度和高度值作为参数,并用它来初始化具有相同名称的字段。我们可以定义尽可能多的构造函数,因此我们可以提供许多不同的初始化类的方式。在这种情况下,我们只需要一个构造函数。

以下行创建了一个Rectangle类,并在类体内定义了一个构造函数。此时,我们并没有使用访问修饰符,因为我们希望保持类声明尽可能简单。我们稍后会使用它们。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_03.java文件中。

class Rectangle {
    double width;
    double height;

    Rectangle(double width, double height) {
        System.out.printf("Initializing a new Rectangle instance\n");
        System.out.printf("Width: %.2f, Height: %.2f\n", 
            width, height);
        this.width = width;
        this.height = height;
    }
}

构造函数是一个使用与类相同名称的类方法:Rectangle。在我们的示例Rectangle类中,构造函数接收double类型的两个参数:widthheight。构造函数内的代码打印一条消息,指示代码正在初始化一个新的Rectangle实例,并打印widthheight的值。这样,我们将了解构造函数内的代码何时被执行。因为构造函数有一个参数,它被称为参数化构造函数

然后,以下行将作为参数接收的width双精度值分配给width双精度字段。我们使用this.width来访问实例的width字段,使用width来引用参数。this关键字提供了对已创建的实例的访问,我们希望初始化的对象,也就是正在构建的对象。我们使用this.height来访问实例的height字段,使用height来引用参数。

构造函数之前的两行声明了widthheight双精度字段。这两个字段是成员变量,在构造函数执行完毕后我们可以无限制地访问它们。

以下行创建了Rectangle类的四个实例,分别命名为rectangle1rectangle2rectangle3rectangle4。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_04.java文件中。

Rectangle rectangle1 = new Rectangle(31.0, 21.0);
Rectangle rectangle2 = new Rectangle(182.0, 32.0);
Rectangle rectangle3 = new Rectangle(203.0, 23.0);
Rectangle rectangle4 = new Rectangle(404.0, 14.0);

创建实例的每一行都指定了新变量(Rectangle)的类型,然后是将保存对新实例的引用的变量名(rectangle1rectangle2rectangle3rectangle4)。然后,每一行都分配了使用new关键字后跟由逗号分隔并括在括号中的widthheight参数的所需值的结果。

提示

在 Java 9 中,我们必须指定要保存对实例的引用的变量的类型。在这种情况下,我们使用Rectangle类型声明每个变量。如果您有其他编程语言的经验,这些语言提供了一个关键字来生成隐式类型的局部变量,比如 C#中的var关键字,您必须知道在 Java 9 中没有相应的关键字。

在我们输入了声明类和在 JShell 中创建了四个实例的所有行之后,我们将看到四条消息,这些消息说“正在初始化新的 Rectangle 实例”,然后是在构造函数调用中指定的宽度和高度值。以下截图显示了在 JShell 中执行代码的结果:

自定义构造函数和初始化

在执行了前面的行之后,我们可以检查我们创建的每个实例的widthheight字段的值。以下行显示了 JShell 可以评估的表达式,以显示每个字段的值。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_05.java文件中。

rectangle1.width
rectangle1.height
rectangle2.width
rectangle2.height
rectangle3.width
rectangle3.height
rectangle4.width
rectangle4.height

以下截图显示了在 JShell 中评估先前表达式的结果。

自定义构造函数和初始化

在 JShell 中输入以下表达式。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_06.java文件中。

rectangle1 instanceof Rectangle

JShell 将显示true作为对先前表达式的评估结果,因为rectangle1Rectangle类的一个实例。instanceof关键字允许我们测试对象是否为指定类型。使用此关键字,我们可以确定对象是否为Rectangle对象。

如前所述,Rectanglejava.lang.Object类的一个子类。JShell 已经从java.lang导入了所有类型,因此,我们可以将这个类简称为Object。在 JShell 中输入以下表达式。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_07.java文件中。

rectangle1 instanceof Object

JShell 将显示true作为对先前表达式的评估结果,因为rectangle1也是java.lang.Object类的一个实例。

在 JShell 中输入以下表达式。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_08.java文件中。

rectangle1.getClass().getName()

JShell 将显示"Rectangle"作为先前行的结果,因为rectangle1变量持有Rectangle类的一个实例。getClass方法允许我们检索对象的运行时类。该方法是从java.lang.Object类继承的。getName方法将运行时类型转换为字符串。

现在,我们将尝试创建一个Rectangle的实例,而不提供参数。以下行不会允许 Java 编译代码,并且将在 JShell 中显示构建错误,因为编译器找不到在Rectangle类中声明的无参数构造函数。对于这个类声明的唯一构造函数需要两个double参数,因此,Java 不允许创建未指定widthheight值的Rectangle实例。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_09.java文件中。

Rectangle rectangleError = new Rectangle();

下一张截图显示了详细的错误消息:

自定义构造函数和初始化

了解垃圾回收的工作原理

TipYou can follow best practices to release resources without having to add code to the `finalize` method. Remember that you don't know exactly when the `finalize` method is going to be executed. Even when the reference count reaches zero and all the variables that hold a reference have gone out of scope, the garbage collection algorithm implementation might keep the resources until the appropriate garbage collection destroys the instances. Thus, it is never a good idea to use the `finalize` method to release resources.

以下行显示了Rectangle类的新完整代码。新的行已经突出显示。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_10.java文件中。

class Rectangle {
    double width;
    double height;

    Rectangle(double width, double height) {
        System.out.printf("Initializing a new Rectangle instance\n");
        System.out.printf("Width: %.2f, Height: %.2f\n", 
            width, height);
        this.width = width;
        this.height = height;
    }

 // The following code doesn't represent a best practice
 // It is included just for educational purposes
 // and to make it easy to understand how the
 // garbage collection process works
 @Override
 protected void finalize() throws Throwable {
 try {
 System.out.printf("Finalizing Rectangle\n");
 System.out.printf("Width: %.2f, Height: %.2f\n", width, height);
 } catch(Throwable t){
 throw t;
 } finally{
 super.finalize();
 }
 }
}

新的行声明了一个finalize方法,覆盖了从java.lang.Object继承的方法,并打印一条消息,指示正在完成Rectangle实例,并显示实例的宽度和高度值。不要担心你尚不理解的代码片段,因为我们将在接下来的章节中学习它们。包含在类中的新代码的目标是让我们知道垃圾收集过程何时将对象从内存中删除。

提示

避免编写覆盖finalize方法的代码。Java 9 不鼓励使用finalize方法执行清理操作。

以下行创建了两个名为rectangleToCollect1rectangleToCollect2Rectangle类实例。然后,下一行将null分配给这两个变量,因此,两个对象的引用计数都达到了零,它们已准备好进行垃圾收集。这两个实例可以安全地从内存中删除,因为作用域中没有更多变量持有对它们的引用。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_11.java文件中。

Rectangle rectangleToCollect1 = new Rectangle(51, 121);
Rectangle rectangleToCollect2 = new Rectangle(72, 282);
rectangleToCollect1 = null;
rectangleToCollect2 = null;

以下截图显示了在 JShell 中执行上述行的结果:

理解垃圾收集的工作原理

两个矩形实例可以安全地从内存中删除,但我们没有看到消息表明对这些实例的finalize方法已被执行。请记住,我们不知道垃圾收集过程何时确定有必要回收这些实例使用的内存。

为了理解垃圾收集过程的工作原理,我们将强制进行垃圾收集。但是,非常重要的是要理解,在实际应用中我们不应该强制进行垃圾收集。我们必须让 JVM 在最合适的时机执行收集。

下一行显示了调用System.gc方法强制 JVM 执行垃圾收集的代码。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_12.java文件中。

System.gc();

以下截图显示了在 JShell 中执行上述行的结果。我们将看到表明两个实例的finalize方法已被调用的消息。

理解垃圾收集的工作原理

以下行创建了一个名为rectangle5Rectangle类实例,然后将一个引用分配给referenceToRectangle5变量。这样,对象的引用计数增加到两个。下一行将null分配给rectangle5,使得对象的引用计数从两个减少到一个。referenceToRectangle5变量仍然持有对Rectangle实例的引用,因此,下一行强制进行垃圾收集不会将实例从内存中删除,我们也不会看到在finalize方法中代码执行的结果。仍然有一个在作用域中持有对实例的引用的变量。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_13.java文件中。

Rectangle rectangle5 = new Rectangle(50, 550);
Rectangle referenceToRectangle5 = rectangle5;
rectangle5 = null;
System.gc();

以下截图显示了在 JShell 中执行上述行的结果:

理解垃圾收集的工作原理

现在,我们将执行一行代码,将null分配给referenceToRectangle5,以使引用实例的引用计数达到零,并在下一行强制运行垃圾收集过程。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_14.java文件中。

referenceToRectangle5 = null;
System.gc();

以下截图显示了在 JShell 中执行前几行的结果。我们将看到指示实例的finalize方法已被调用的消息。

了解垃圾回收的工作原理

提示

非常重要的是,你不需要将引用赋值为null来强制 JVM 从对象中回收内存。在前面的例子中,我们想要了解垃圾回收的工作原理。Java 会在对象不再被引用时自动以透明的方式销毁对象。

创建类的实例并了解它们的作用域

我们将编写几行代码,在getGeneratedRectangleHeight方法的作用域内创建一个名为rectangleRectangle类的实例。方法内的代码使用创建的实例来访问并返回其height字段的值。在这种情况下,代码使用final关键字作为Rectangle类型的前缀来声明对Rectangle实例的不可变引用

注意

不可变引用也被称为常量引用,因为我们不能用另一个Rectangle实例替换rectangle常量持有的引用。

在定义新方法后,我们将调用它并强制进行垃圾回收。示例的代码文件包含在java_9_oop_chapter_03_01文件夹中的example03_15.java文件中。

double getGeneratedRectangleHeight() {
    final Rectangle rectangle = new Rectangle(37, 87);
    return rectangle.height; 
}

System.out.printf("Height: %.2f\n", getGeneratedRectangleHeight());
System.gc();

以下截图显示了在 JShell 中执行前几行的结果。我们将看到在调用getGeneratedRectangleHeight方法后,指示实例的finalize方法已被调用,并在下一次强制垃圾回收时的消息。当方法返回一个值时,矩形会超出作用域,因为它的引用计数从 1 下降到 0。

通过不可变变量引用的实例是安全的垃圾回收。因此,当我们强制进行垃圾回收时,我们会看到finalize方法显示的消息。

创建类的实例并了解它们的作用域

练习

现在你了解了对象的生命周期,是时候在 JShell 中花一些时间创建新的类和实例了。

练习 1

  1. 创建一个新的Student类,其中包含一个需要两个String参数firstNamelastName的构造函数。使用这些参数来初始化与参数同名的字段。在创建类的实例时显示一个带有firstNamelastName值的消息。

  2. 创建Student类的实例并将其分配给一个变量。检查在 JShell 中打印的消息。

  3. 创建Student类的实例并将其分配给一个变量。检查在 JShell 中打印的消息。

练习 2

  1. 创建一个接收两个String参数firstNamelastName的函数。使用接收到的参数来创建先前定义的Student类的实例。使用实例属性打印一个带有名字和姓氏的消息。稍后你可以创建一个方法并将其添加到Student类中来执行相同的任务。但是,我们将在接下来的章节中了解更多相关内容。

  2. 使用必要的参数调用先前创建的函数。检查在 JShell 中打印的消息。

测试你的知识

  1. 当 Java 执行构造函数中的代码时:

  2. 我们无法访问类中定义的任何成员。

  3. 该类已经存在一个活动实例。我们可以访问类中定义的方法,但无法访问其字段。

  4. 该类已经存在一个活动实例,我们可以访问它的成员。

  5. 构造函数非常有用:

  6. 执行设置代码并正确初始化一个新实例。

  7. 在实例被销毁之前执行清理代码。

  8. 声明将对类的所有实例可访问的方法。

  9. Java 9 使用以下机制之一来自动释放不再被引用的实例使用的内存:

  10. 实例映射减少。

  11. 垃圾压缩。

  12. 垃圾收集。

  13. Java 9 允许我们定义:

  14. 一个主构造函数和两个可选的次要构造函数。

  15. 许多具有不同参数的构造函数。

  16. 每个类只有一个构造函数。

  17. 我们创建的任何不指定超类的新类都将是一个子类:

  18. java.lang.Base

  19. java.lang.Object

  20. java.object.BaseClass

  21. 以下哪行创建了Rectangle类的一个实例并将其引用分配给rectangle变量:

  22. var rectangle = new Rectangle(50, 20);

  23. auto rectangle = new Rectangle(50, 20);

  24. Rectangle rectangle = new Rectangle(50, 20);

  25. 以下哪行访问了rectangle实例的width字段:

  26. rectangle.field

  27. rectangle..field

  28. rectangle->field

摘要

在本章中,您了解了对象的生命周期。您还了解了对象构造函数的工作原理。我们声明了我们的第一个简单类来生成对象的蓝图。我们了解了类型、变量、类、构造函数、实例和垃圾收集是如何在 JShell 中的实时示例中工作的。

现在您已经学会了开始创建类和实例,我们准备在 Java 9 中包含的数据封装功能中分享、保护、使用和隐藏数据,这是我们将在下一章讨论的内容。

第四章:数据的封装

在本章中,我们将学习 Java 9 中类的不同成员以及它们如何在从类生成的实例的成员中反映出来。我们将使用实例字段、类字段、setter、getter、实例方法和类方法。我们将:

  • 理解 Java 9 中组成类的成员

  • 声明不可变字段

  • 使用 setter 和 getter

  • 在 Java 9 中理解访问修饰符

  • 结合 setter、getter 和相关字段

  • 使用 setter 和 getter 转换值

  • 使用静态字段和静态方法来创建所有类实例共享的值

理解组成类的成员

到目前为止,我们一直在使用一个非常简单的Rectangle类。我们在 JShell 中创建了许多这个类的实例,并且理解了垃圾回收的工作原理。现在,是时候深入了解 Java 9 中组成类的不同成员了。

以下列表列举了我们可以在 Java 9 类定义中包含的最常见元素类型。每个成员都包括其在其他编程语言中的等价物,以便于将我们在其他面向对象语言中的经验转化为 Java 9。我们已经使用了其中的一些成员:

  • 构造函数:一个类可能定义一个或多个构造函数。它们等价于其他编程语言中的初始化器。

  • 类变量或类字段:这些变量对类的所有实例都是共同的,也就是说,它们的值对所有实例都是相同的。在 Java 9 中,可以从类和其实例中访问类变量。我们不需要创建特定实例来访问类变量。类变量也被称为静态变量,因为它们在声明中使用static修饰符。类变量等价于其他编程语言中的类属性和类型属性。

  • 类方法:这些方法可以使用类名调用。在 Java 9 中,可以从类和其实例中访问类方法。我们不需要创建特定实例来访问类方法。类方法也被称为静态方法,因为它们在声明中使用static修饰符。类方法等价于其他编程语言中的类函数和类型方法。类方法作用于整个类,并且可以访问类变量、类常量和其他类方法,但它们无法访问任何实例成员,如实例字段或方法,因为它们在类级别上操作,根本没有实例。当我们想要包含与类相关的方法并且不想生成实例来调用它们时,类方法非常有用。

  • 常量:当我们用final修饰符声明类变量或类字段时,我们定义了值不可更改的常量。

  • 字段、成员变量、实例变量或实例字段:我们在之前的例子中使用了这些。类的每个实例都有自己独特的实例字段副本,具有自己的值。实例字段等价于其他编程语言中的属性和实例属性。

  • 方法或实例方法:这些方法需要一个实例来调用,并且它们可以访问特定实例的字段。实例方法等价于其他编程语言中的实例函数。

  • 嵌套类:这些类在另一个类中定义。静态嵌套类使用static修饰符。不使用static修饰符的嵌套类也被称为内部类。嵌套类在其他编程语言中也被称为嵌套类型。

声明不可变字段

Pokemon Go 是一款基于位置的增强现实游戏,玩家使用移动设备的 GPS 功能来定位、捕捉、训练和让虚拟生物进行战斗。这款游戏取得了巨大的成功,并推广了基于位置和增强现实的游戏。在其巨大成功之后,想象一下我们必须开发一个 Web 服务,供类似的游戏使用,让虚拟生物进行战斗。

我们必须进入虚拟生物的世界。我们肯定会有一个VirtualCreature基类。每种特定类型的虚拟生物都具有独特的特征,可以参与战斗,将是VirtualCreature的子类。

所有虚拟生物都将有一个名字,并且它们将在特定年份出生。年龄对于它们在战斗中的表现将非常重要。因此,我们的基类将拥有namebirthYear字段,所有子类都将继承这些字段。

当我们设计类时,我们希望确保所有必要的数据对将操作这些数据的方法是可用的。因此,我们封装数据。然而,我们只希望相关信息对我们的类的用户可见,这些用户将创建实例,更改可访问字段的值,并调用可用的方法。我们希望隐藏或保护一些仅需要内部使用的数据,也就是说,对于我们的方法。我们不希望对敏感数据进行意外更改。

例如,当我们创建任何虚拟生物的新实例时,我们可以将其名字和出生年份作为构造函数的两个参数。构造函数初始化了两个属性的值:namebirthYear。以下几行显示了声明VirtualCreature类的示例代码。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_01.java文件中。

class VirtualCreature {
    String name;
    int birthYear;

    VirtualCreature(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }
}

接下来的几行创建了两个实例,初始化了两个字段的值,然后使用System.out.printf方法在 JShell 中显示它们的值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_01.java文件中。

VirtualCreature beedrill = new VirtualCreature("Beedril", 2014);
System.out.printf("%s\n", beedrill.name);
System.out.printf("%d\n", beedrill.birthYear);
VirtualCreature krabby = new VirtualCreature("Krabby", 2012);
System.out.printf("%s\n", krabby.name);
System.out.printf("%d\n", krabby.birthYear);

以下屏幕截图显示了在 JShell 中声明类和执行先前行的结果:

声明不可变字段

我们不希望VirtualCreature类的用户能够在初始化实例后更改虚拟生物的名字,因为名字不应该改变。好吧,有些人改名字,但虚拟生物永远不会这样做。在我们之前声明的类中,有一种简单的方法可以实现这个目标。我们可以在类型(String)之前添加final关键字,以定义一个不可变的name字段,类型为String。当我们定义birthYear字段时,也可以在类型(int)之前添加final关键字,因为在初始化虚拟生物实例后,出生年份将永远不会改变。

以下几行显示了声明VirtualCreature类的新代码,其中包含两个不可变的实例字段:namebirthYear。请注意,构造函数的代码不需要更改,并且可以使用相同的代码初始化这两个不可变的实例字段。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_02.java文件中。

class VirtualCreature {
 final String name;
 final int birthYear;

    VirtualCreature(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }
}

注意

不可变的实例字段也被称为非变异的实例字段。

接下来的几行创建了一个实例,初始化了两个不可变的实例字段的值,然后使用System.out.printf方法在 JShell 中显示它们的值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_02.java文件中。

VirtualCreature squirtle = new VirtualCreature("Squirtle", 2014);
System.out.printf("%s\n", squirtle.name);
System.out.printf("%d\n", squirtle.birthYear);

接下来的两行代码尝试为namebirthYear不可变的实例字段分配新值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_03.java文件中。

squirtle.name = "Tentacruel";
squirtle.birthYear = 2017;

这两行将无法成功,因为 Java 不允许我们为使用final修饰符声明的字段赋值,这会将其转换为不可变字段。下一张截图显示了在每行尝试为不可变字段设置新值后在 JShell 中显示的错误:

声明不可变字段

提示

当我们使用final关键字声明一个实例字段时,我们可以初始化该字段,但在初始化后,它将变为不可变的,也就是常量。

使用 setter 和 getter

到目前为止,我们一直在使用字段来封装实例中的数据。我们可以像实例的成员变量一样访问这些字段,没有任何限制。然而,有时在现实世界的情况下,需要限制以避免严重问题。有时,我们希望限制访问或将特定字段转换为只读字段。我们可以将对底层字段的访问限制与称为 setter 和 getter 的方法相结合。

Setter是允许我们控制如何设置值的方法;也就是说,这些方法用于改变相关字段的值。Getter允许我们控制在想要检索相关字段的值时返回的值。Getter 不会改变相关字段的值。

提示

有些框架(比如 JavaBeans)强制你使用 setter 和 getter 来让每个相关字段都可以访问,但在其他情况下,setter 和 getter 是不必要的。在接下来的例子中,我们将使用可变对象。在下一章,第五章,“可变和不可变类”,我们将同时使用可变和不可变对象。当使用不可变对象时,getter 和 setter 是无用的。

如前所述,我们不希望VirtualCreature类的用户能够在初始化实例后更改虚拟生物的出生年份,因为虚拟生物不会在不同日期再次出生。实际上,我们希望计算并使虚拟生物的年龄对用户可用。因为我们只考虑出生年份,所以我们将计算一个近似的年龄。我们保持示例简单,以便专注于 getter 和 setter。

我们可以定义一个名为getAge的 getter 方法,而不定义 setter 方法。这样,我们可以检索虚拟生物的年龄,但我们无法改变它,因为没有 setter 方法。getter 方法返回基于当前年份和birthYear不可变实例字段的值计算出的虚拟生物年龄的结果。

下面的行显示了具有新getAge方法的VirtualCreature类的新版本。请注意,需要导入java.time.Year以使用在 Java 8 中引入的Year类。getAge方法的代码在下面的行中突出显示。该方法调用Year.now().getValue来检索当前日期的年份组件,并返回当前年份与birthYear字段的值之间的差值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中,名为example04_04.java

import java.time.Year;

class VirtualCreature {
    final String name;
    final int birthYear;

    VirtualCreature(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }

 int getAge() {
 return Year.now().getValue() - birthYear;
 }
}

下面的行创建一个实例,初始化了两个不可变实例字段的值,然后使用System.out.printf方法在 JShell 中显示getAge方法返回的值。在创建VirtualCreature类的新版本的代码之后输入这些行。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中,名为example04_04.java

VirtualCreature arbok = new VirtualCreature("Arbok", 2008);
System.out.printf("%d\n", arbok.getAge());
VirtualCreature pidgey = new VirtualCreature("Pidgey", 2015);
System.out.printf("%d\n", pidgey.getAge());

下一张截图显示了在 JShell 中执行前面几行的结果:

使用 setter 和 getter

在与虚拟生物专家的几次会议后,我们意识到其中一些虚拟生物会前往其他星球进化,并在进化后从蛋中再次诞生。由于进化发生在不同的星球,虚拟生物的出生年份会改变,以在地球上具有等效的出生年份。因此,有必要允许用户自定义虚拟生物的年龄或出生年份。我们将添加一个带有计算出生年份的代码的 setter 方法,并将这个值分配给birthYear字段。首先,我们必须在声明birthYear字段时删除final关键字,因为我们希望它成为一个可变字段。

提示

还有另一种处理虚拟生物进化的方法。我们可以创建另一个实例来代表进化后的虚拟生物。我们将在下一章第五章中使用这种不可变的方法,可变和不可变的类。在这种情况下,我们将使用一个可变对象。在了解所有可能性之后,我们可以根据我们的具体需求决定最佳选项。

下面的代码展示了带有新setAge方法的VirtualCreature类的新版本。setAge方法的代码在下面的代码中突出显示。该方法接收我们想要为虚拟生物设置的新年龄,并调用Year.now().getValue来获取当前日期的年份组件,并将当前年份与age参数中接收到的值之间的差值分配给birthYear字段。这样,birthYear字段将根据接收到的age值保存虚拟生物出生的年份。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_05.java文件中。

import java.time.Year;

class VirtualCreature {
    final String name;
 int birthYear;

    VirtualCreature(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }

    int getAge() {
        return Year.now().getValue() - birthYear;
    }

 void setAge(final int age) {
 birthYear = Year.now().getValue() - age;
 }
}

下面的代码创建了VirtualCreature类的新版本的两个实例,调用setAge方法并为虚拟生物设置所需的年龄,然后使用System.out.printf方法在 JShell 中显示getAge方法返回的值和birthYear字段的值。在创建VirtualCreature类的新版本的代码之后输入这些代码。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_05.java文件中。

VirtualCreature venusaur = new VirtualCreature("Venusaur", 2000);
System.out.printf("%d\n", venusaur.getAge());
VirtualCreature caterpie = new VirtualCreature("Caterpie", 2012);
System.out.printf("%d\n", caterpie.getAge());

venusaur.setAge(2);
System.out.printf("%d\n", venusaur.getAge());
System.out.printf("%d\n", venusaur.birthYear);

venusaur.setAge(14);
System.out.printf("%d\n", caterpie.getAge());
System.out.printf("%d\n", caterpie.birthYear);

调用setAge方法并传入新的年龄值后,该方法会改变birthYear字段的值。根据当前年份的值,运行代码的结果将会不同。下一张截图显示了在 JShell 中执行前几行代码的结果:

使用 setter 和 getter

getter 和 setter 方法都使用相同的代码来获取当前年份。我们可以添加一个新的方法来获取当前年份,并从getAgesetAge方法中调用它。在这种情况下,这只是一行代码,但是新方法为我们提供了一个示例,说明我们可以添加方法来在我们的类中使用,并帮助其他方法完成它们的工作。稍后,我们将学习如何避免从实例中调用这些方法,因为它们只用于内部使用。

下面的代码展示了带有新getCurrentYear方法的SuperHero类的新版本。getAgesetAge方法的新代码调用了新的getCurrentYear方法,而不是重复用于获取当前年份的代码。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_06.java文件中。

import java.time.Year;

class VirtualCreature {
    final String name;
    int birthYear;

    VirtualCreature(String name, int birthYear) {
        this.name = name;
        this.birthYear = birthYear;
    }

 int getCurrentYear() {
 return Year.now().getValue();
 }

    int getAge() {
 return getCurrentYear() - birthYear;
    }

    void setAge(final int age) {
 birthYear = getCurrentYear() - age;
    }
}

下面的代码创建了VirtualCreature类的两个实例,调用setAge方法设置虚拟生物的年龄,然后使用System.out.printf方法在 JShell 中显示getAge方法返回的值和birthYear字段的值。在创建VirtualCreature类的新版本的代码之后输入这些行。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_06.java文件中。

VirtualCreature persian = new VirtualCreature("Persian", 2005);
System.out.printf("%d\n", persian.getAge());
VirtualCreature arcanine = new VirtualCreature("Arcanine", 2012);
System.out.printf("%d\n", arcanine.getAge());

persian.setAge(7);
System.out.printf("%d\n", persian.getAge());
System.out.printf("%d\n", persian.birthYear);

arcanine.setAge(9);
System.out.printf("%d\n", arcanine.getAge());
System.out.printf("%d\n", arcanine.birthYear);

下一张截图显示了在 JShell 中执行前面几行的结果:

使用 setter 和 getter

在 Java 9 中探索访问修饰符

先前声明的VirtualCreature类公开了所有成员(字段和方法),没有任何限制,因为我们声明它们时没有使用任何访问修饰符。因此,我们的类的用户可以在创建类的实例后访问任何字段并调用任何已声明的方法。

Java 9 允许我们通过使用访问级别修饰符来控制对调用成员的访问。不同的关键字允许我们控制哪些代码可以访问类的特定成员。到目前为止,我们可以在类定义内部和类声明之外访问字段和方法。

我们可以使用以下任何访问修饰符来限制对任何字段的访问,而不是public

  • protected:Java 不允许用户在类定义之外访问成员。只有类内部或其派生类的代码才能访问字段。声明了带有protected访问修饰符的成员的类的任何子类都可以访问该成员。

  • private:Java 不允许用户在类定义之外访问字段。只有类内部的代码才能访问字段。它的派生类无法访问字段。因此,声明了带有private访问修饰符的成员的类的任何子类将无法访问该成员。

下一行显示了如何将birthYear实例字段的声明更改为protected字段。我们只需要在字段声明中添加protected关键字。

protected int birthYear;

每当我们在字段声明中使用protected访问修饰符时,我们限制对该字段的访问仅限于类定义内部和子类内部编写的代码。Java 9 为标记为protected的字段生成了真正的保护,没有办法在解释的边界之外访问它们。

下一行显示了如何将birthYear受保护的实例字段的声明更改为private字段。我们用private替换了protected访问修饰符。

private int birthYear;

每当我们在字段声明中使用private访问修饰符时,我们限制对该字段的访问仅限于类定义内部和子类内部编写的代码。Java 为标记为private的字段生成了真正的保护,没有办法在类定义之外访问它们。这个限制也适用于子类,因此,只有类内部编写的代码才能访问标记为私有的属性。

提示

我们可以对任何类型成员应用先前解释的访问修饰符,包括类变量、类方法、常量、字段、方法和嵌套类。

结合 setter、getter 和字段

有时,我们希望对设置到相关字段和从中检索的值有更多的控制,并且我们可以利用 getter 和 setter 来做到这一点。我们可以结合使用 getter、setter、存储计算值的相关字段以及访问保护机制,防止用户对相关字段进行更改。这样,我们将强制用户始终使用 getter 和 setter。

虚拟生物喜欢任何类型的帽子。虚拟生物的帽子可以随着时间改变。我们必须确保帽子的名称是大写字母,也就是大写的String。我们将定义一个setHat方法,始终从接收到的String生成一个大写的String并将其存储在私有的hat字段中。

我们将提供一个getHat方法来检索存储在私有hat字段中的值。下面的几行显示了VirtualCreature类的新版本,其中添加了一个hat私有实例字段和getHatsetHat方法。我们使用之前学到的访问修饰符来为类的不同成员设置。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中,名为example04_07.java

import java.time.Year;

public class VirtualCreature {
    public final String name;
    private int birthYear;
    private String hat = "NONE";

    VirtualCreature(String name, int birthYear, String hat) {
        this.name = name;
        this.birthYear = birthYear;
        setHat(hat);
    }

    private int getCurrentYear() {
        return Year.now().getValue();
    }

    public int getAge() {
        return getCurrentYear() - birthYear;
    }

    public void setAge(final int age) {
        birthYear = getCurrentYear() - age;
    }

    public String getHat() {
        return hat;
    }

    public void setHat(final String hat) {
        this.hat = hat.toUpperCase();
    }
}

如果你使用特定的 JDK 早期版本,在 JShell 中输入前面的代码时,你可能会看到以下警告消息:

|  Warning:
|  Modifier 'public'  not permitted in top-level declarations, ignored
|  public class VirtualCreature {
|  ^----^
|  created class VirtualCreature this error is corrected:
|      Modifier 'public'  not permitted in top-level declarations, ignored
|      public class VirtualCreature {
|      ^----^

JShell 不允许我们在顶层声明中使用访问修饰符,比如类声明。然而,我们指定访问修饰符是因为我们希望编写的代码就好像我们是在 JShell 之外编写类声明一样。JShell 只是忽略了类的public访问修饰符,而一些包含 JShell 的 JDK 版本会在 REPL 中显示先前显示的警告消息。如果你看到这些消息,你应该升级已安装的 JDK 到不再显示警告消息的最新版本。

我们将birthyearhat实例字段都声明为private。我们将getCurrentYear方法声明为protected。当用户创建VirtualCreature类的实例时,用户将无法访问这些private成员。这样,private成员将对创建VirtualCreature类实例的用户隐藏起来。

我们将name声明为public的不可变实例字段。我们将以下方法声明为publicgetAgesetAgegetHatsetHat。当用户创建VirtualCreature类的实例时,他将能够访问所有这些public成员。

构造函数添加了一个新的参数,为新的hat字段提供了一个初始值。构造函数中的代码调用setHat方法,将接收到的hat参数作为参数,以确保从接收到的String生成一个大写的String,并将生成的String分配给hat字段。

下面的几行创建了VirtualCreature类的两个实例,使用printf方法显示getHat方法返回的值,调用setHat方法设置虚拟生物的新帽子,然后使用System.out.printf方法再次显示getHat方法返回的值。在创建VirtualCreature类的新版本的代码之后输入这些行。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中,名为example04_07.java

VirtualCreature glaceon = 
    new VirtualCreature("Glaceon", 2009, "Baseball cap");
System.out.printf(glaceon.getHat());
glaceon.setHat("Hard hat")
System.out.printf(glaceon.getHat());
VirtualCreature gliscor = 
    new VirtualCreature("Gliscor", 2015, "Cowboy hat");
System.out.printf(gliscor.getHat());
gliscor.setHat("Panama hat")
System.out.printf(gliscor.getHat());

下一张截图显示了在 JShell 中执行前面几行的结果:

组合 setter、getter 和字段

提示

我们可以结合 getter 和 setter 方法,以及访问保护机制和作为底层字段的相关字段,来绝对控制可变对象中的值如何被设置和检索。然而,我们必须确保初始化也必须使用 setter 方法,就像我们在构造函数中设置初始值时所做的那样。

下面的几行将尝试访问我们创建的VirtualCreature类实例的私有字段和私有方法。这两行都将无法编译,因为我们不能在实例中访问私有成员。第一行尝试访问hat实例字段,第二行尝试调用getCurrentYear实例方法。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中,名为example04_08.java

System.out.printf(gliscor.hat);
System.out.printf("%d", glaceon.getCurrentYear());

下一个屏幕截图显示了在 JShell 中执行前面几行时生成的错误消息。

结合 setter、getter 和字段

使用 setter 和 getter 转换值

我们可以定义一个 setter 方法,将接收到的值转换为相关字段的有效值。getter 方法只需要返回相关字段的值。用户只能使用 setter 和 getter 方法,我们的相关字段将始终具有有效值。这样,我们可以确保每当需要该值时,我们将检索到有效的值。

每个虚拟生物都有一个可见级别,确定任何人能够多容易地看到虚拟生物的身体。我们将添加一个私有的visibilityLevel字段,一个setVisibility方法和一个getVisibility方法。我们将更改构造函数代码,调用setVisiblity方法来为visibilityLevel字段设置初始值。

我们希望确保可见级别是一个从0100(包括)的数字。因此,我们将编写 setter 方法来将低于0的值转换为0,将高于100的值转换为100setVisibility方法保存相关私有visibilityLevel字段中的转换后或原始值,该值在有效范围内。

编辑过的行和新行已经高亮显示。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_09.java文件中。

import java.time.Year;

public class VirtualCreature {
    public final String name;
    private int birthYear;
    private String hat = "NONE";
 private int visibilityLevel;

 VirtualCreature(String name, 
 int birthYear, 
 String hat, 
 int visibilityLevel) {
        this.name = name;
        this.birthYear = birthYear;
        setHat(hat);
 setVisibilityLevel(visibilityLevel);
    }

    private int getCurrentYear() {
        return Year.now().getValue();
    }

    public int getAge() {
        return getCurrentYear() - birthYear;
    }

    public void setAge(final int age) {
        birthYear = getCurrentYear() - age;
    }

    public String getHat() {
        return hat;
    }

    public void setHat(final String hat) {
        this.hat = hat.toUpperCase();
    }

    public int getVisibilityLevel() {
        return visibilityLevel;
    }

 public void setVisibilityLevel(final int visibilityLevel) {
 this.visibilityLevel = 
 Math.min(Math.max(visibilityLevel, 0), 100);
 }
}

下面的行创建了一个VirtualCreature的实例,指定150作为visibilityLevel参数的值。然后,下一行使用System.out.printf方法在 JShell 中显示getVisibilityLevel方法返回的值。然后,我们调用setVisibilityLevelgetVisibilityLevel三次,设置visibilityLevel的值,然后检查最终设置的值。在创建VirtualCreature类的新版本的代码之后输入这些行。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_09.java文件中。

VirtualCreature lairon = 
    new VirtualCreature("Lairon", 2014, "Sombrero", 150);
System.out.printf("%d", lairon.getVisibilityLevel());
lairon.setVisibilityLevel(-6);
System.out.printf("%d", lairon.getVisibilityLevel());
lairon.setVisibilityLevel(320);
System.out.printf("%d", lairon.getVisibilityLevel());
lairon.setVisibilityLevel(25);
System.out.printf("%d", lairon.getVisibilityLevel());

构造函数调用setVisibilityLevel方法来为visibilityLevel相关的私有字段设置初始值,因此,该方法确保值在有效范围内。代码指定了150,但最大值是100,因此setVisibilityLevel100分配给了visibilityLevel相关的私有字段。

在我们使用-6作为参数调用setVisibilityLevel后,我们打印了getVisibilityLevel返回的值,结果是0。在我们指定320后,实际打印的值是100。最后,在我们指定25后,实际打印的值是25。下一个屏幕截图显示了在 JShell 中执行前面几行的结果:

使用 setter 和 getter 转换值

使用静态字段提供类级别的值

有时,类的所有成员共享相同的属性,我们不需要为每个实例设置特定的值。例如,虚拟生物类型具有以下配置值:

  • 攻击力

  • 防御力

  • 特殊攻击力

  • 特殊防御力

  • 平均速度

  • 捕捉率

  • 增长率

对于这种情况,我们可能认为有用的第一种方法是定义以下类常量来存储所有实例共享的值:

  • ATTACK_POWER

  • DEFENSE_POWER

  • SPECIAL_ATTACK_POWER

  • SPECIAL_DEFENSE_POWER

  • AVERAGE_SPEED

  • CATCH_RATE

  • GROWTH_RATE

注意

请注意,在 Java 9 中,类常量名称使用大写字母和下划线(_)分隔单词。这是一种命名约定。

以下行显示了VirtualCreature类的新版本,该版本使用public访问修饰符定义了先前列出的七个类常量。请注意,finalstatic关键字的组合使它们成为类常量。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_10.java文件中。

import java.time.Year;

public class VirtualCreature {
 public final static int ATTACK_POWER = 45;
 public final static int DEFENSE_POWER = 85;
 public final static int SPECIAL_ATTACK_POWER = 35;
 public final static int SPECIAL_DEFENSE_POWER = 95;
 public final static int AVERAGE_SPEED = 85;
 public final static int CATCH_RATE = 25;
 public final static int GROWTH_RATE = 10;

    public final String name;
    private int birthYear;
    private String hat = "NONE";
    private int visibilityLevel;

    VirtualCreature(String name, 
        int birthYear, 
        String hat, 
        int visibilityLevel) {
        this.name = name;
        this.birthYear = birthYear;
        setHat(hat);
        setVisibilityLevel(visibilityLevel);
    }

    private int getCurrentYear() {
        return Year.now().getValue();
    }

    public int getAge() {
        return getCurrentYear() - birthYear;
    }

    public void setAge(final int age) {
        birthYear = getCurrentYear() - age;
    }

    public String getHat() {
        return hat;
    }

    public void setHat(final String hat) {
        this.hat = hat.toUpperCase();
    }

    public int getVisibilityLevel() {
        return visibilityLevel;
    }

    public void setVisibilityLevel(final int visibilityLevel) {
        this.visibilityLevel = 
            Math.min(Math.max(visibilityLevel, 0), 100);
    }
}

代码在同一行中初始化了每个类常量。以下行打印了先前声明的SPECIAL_ATTACK_POWERSPECIAL_DEFENSE_POWER类常量的值。请注意,我们没有创建VirtualCreature类的任何实例,并且在类名和点(.)之后指定了类常量名称。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_10.java文件中。

System.out.printf("%d\n", VirtualCreature.SPECIAL_ATTACK_POWER);
System.out.printf("%d\n", VirtualCreature.SPECIAL_DEFENSE_POWER);

Java 9 允许我们从实例中访问类常量,因此,我们可以使用类名或实例来访问类常量。以下行创建了一个名为golbat的新版本VirtualCreature类的实例,并打印了从这个新实例访问的GROWTH_RATE类常量的值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_10.java文件中。

VirtualCreature golbat = 
    new VirtualCreature("Golbat", 2015, "Baseball cap", 75);
System.out.printf("%d\n", golbat.GROWTH_RATE);

下一个屏幕截图显示了在 JShell 中执行先前行的结果。

使用静态字段提供类级值

使用静态方法提供可重写的类级值

类常量有一个很大的限制:我们不能在代表特定类型的虚拟生物的VirtualCreature类的未来子类中为它们提供新值。这是有道理的,因为它们是常量。这些子类需要为ATTACK_POWERAVERAGE_SPEED设置不同的值。我们可以创建以下类方法来返回每个配置文件值的平均值,而不是使用类常量。我们将能够使这些方法在VirtualCreature类的子类中返回不同的值。

  • getAttackPower

  • getDefensePower

  • getSpecialAttackPower

  • getSpecialDefensePower

  • getAverageSpeed

  • getCatchRate

  • getGrowthRate

以下行显示了VirtualCreature类的新版本,该版本使用public访问修饰符定义了先前列出的七个类方法。请注意,方法声明中static关键字的使用使它们成为类方法。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_11.java文件中。

import java.time.Year;

public class VirtualCreature {
 public static int getAttackPower() {
 return 45;
 }

 public static int getDefensePower() {
 return 85;
 }

 public static int getSpecialAttackPower() {
 return 35;
 }

 public static int getSpecialDefensePower() {
 return 95;
 }

 public static int getAverageSpeed() {
 return 85;
 }

 public static int getCatchRate() {
 return 25;
 }

 public static int getGrowthRate() {
 return 10;
 }

    public final String name;
    private int birthYear;
    private String hat = "NONE";
    private int visibilityLevel;

    VirtualCreature(String name, 
        int birthYear, 
        String hat, 
        int visibilityLevel) {
        this.name = name;
        this.birthYear = birthYear;
        setHat(hat);
        setVisibilityLevel(visibilityLevel);
    }

    private int getCurrentYear() {
        return Year.now().getValue();
    }

    public int getAge() {
        return getCurrentYear() - birthYear;
    }

    public void setAge(final int age) {
        birthYear = getCurrentYear() - age;
    }

    public String getHat() {
        return hat;
    }

    public void setHat(final String hat) {
        this.hat = hat.toUpperCase();
    }

    public int getVisibilityLevel() {
        return visibilityLevel;
    }

    public void setVisibilityLevel(final int visibilityLevel) {
        this.visibilityLevel = 
            Math.min(Math.max(visibilityLevel, 0), 100);
    }
}

以下行打印了先前声明的getSpecialAttackPowergetSpecialDefensePower类方法返回的值。请注意,我们没有创建VirtualCreature类的任何实例,并且在类名和点(.)之后指定了类方法名称。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_11.java文件中。

System.out.printf("%d\n", VirtualCreature.getSpecialAttackPower());
System.out.printf("%d\n", VirtualCreature.getSpecialDefensePower());

与类常量一样,Java 9 允许我们从实例中访问类方法,因此,我们可以使用类名或实例来访问类方法。以下行创建了一个名为vulpix的新版本VirtualCreature类的实例,并打印了从这个新实例访问的getGrowthRate类方法返回的值。示例的代码文件包含在java_9_oop_chapter_04_01文件夹中的example04_11.java文件中。

VirtualCreature vulpix = 
    new VirtualCreature("Vulpix", 2012, "Fedora", 35);
System.out.printf("%d\n", vulpix.getGrowthRate())

下一个屏幕截图显示了在 JShell 中执行先前行的结果:

使用静态方法提供可重写的类级值

测试你的知识

  1. 我们使用static关键字后跟方法声明来定义:

  2. 实例方法。

  3. 一个类方法。

  4. 一个类常量。

  5. 我们使用final static 关键字后跟初始化的变量声明来定义:

  6. 类常量。

  7. 类变量。

  8. 实例常量。

  9. 类常量:

  10. 对于类的每个实例都有自己独立的值。

  11. 对于类的所有实例具有相同的值。

  12. 除非通过类名后跟一个点(.)和常量名来访问,否则对于类的所有实例具有相同的值。

  13. 一个实例字段:

  14. 对于类的每个实例都有自己独立的值。

  15. 对于类的所有实例具有相同的值。

  16. 除非通过类名后跟一个点(.)和实例字段名来访问,否则对于类的所有实例具有相同的值。

  17. 在 Java 9 中,publicprotectedprivate是:

  18. java.lang中定义的三个不同的类。

  19. 三种等效的访问修饰符。

  20. 三种不同的访问修饰符。

总结

在本章中,您了解了 Java 9 中可以组成类声明的不同成员。我们使用实例字段、实例方法、类常量和类方法。我们使用 getter 和 setter,并利用访问修饰符来隐藏我们不希望类的用户能够访问的数据。

我们与虚拟生物一起工作。首先,我们声明了一个简单的类,然后通过添加功能使其进化。我们在 JShell 中测试了一切是如何工作的。

现在您已经了解了数据封装,可以开始在 Java 9 中使用可变和不可变版本的类,这是我们将在下一章中讨论的内容。

第五章:可变和不可变类

在本章中,我们将学习可变和不可变类。我们将了解它们在构建面向对象代码时的区别、优势和劣势。我们将:

  • 创建可变类

  • 在 JShell 中使用可变对象

  • 构建不可变类

  • 在 JShell 中使用不可变对象

  • 了解可变和不可变对象之间的区别

  • 学习在编写并发代码时不可变对象的优势

  • 使用不可变String类的实例

在 Java 9 中创建可变类

当我们声明实例字段时没有使用final关键字时,我们创建了一个可变的实例字段,这意味着我们可以在字段初始化后为每个新创建的实例更改它们的值。当我们创建一个定义了至少一个可变字段的类的实例时,我们创建了一个可变对象,这是一个在初始化后可以改变其状态的对象。

注意

可变对象也称为可变对象。

例如,假设我们必须开发一个 Web 服务,渲染 3D 世界中的元素并返回高分辨率的渲染场景。这样的任务要求我们使用 3D 向量。首先,我们将使用一个可变的 3D 向量,其中有三个可变字段:xyz。可变的 3D 向量必须提供以下功能:

  • 三个double类型的可变实例字段:xyz

  • 一个构造函数,通过提供xyz字段的初始值来创建一个实例。

  • 一个构造函数,创建一个所有值都初始化为0的实例,即x=0y=0z=0。具有这些值的 3D 向量称为原点向量

  • 一个构造函数,创建一个所有值都初始化为一个公共值的实例。例如,如果我们指定3.0作为公共值,构造函数必须生成一个x=3.0y=3.0z=3.0的实例。

  • 一个absolute方法,将 3D 向量的每个分量设置为其绝对值。

  • 一个negate方法,就地否定 3D 向量的每个分量。

  • 一个add方法,将 3D 向量的值设置为其自身与作为参数接收的 3D 向量的和。

  • 一个sub方法,将 3D 向量的值设置为其自身与作为参数接收的 3D 向量的差。

  • toString方法的实现,打印 3D 向量的三个分量的值:xyz

以下行声明了Vector3d类,表示 Java 中 3D 向量的可变版本。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_01.java文件中。

public class Vector3d {
    public double x;
    public double y;
    public double z;

 Vector3d(double x, double y, double z) {
 this.x = x;
 this.y = y;
 this.z = z;
 }

 Vector3d(double valueForXYZ) {
 this(valueForXYZ, valueForXYZ, valueForXYZ);
 }

 Vector3d() {
 this(0.0);
 }

    public void absolute() {
        x = Math.abs(x);
        y = Math.abs(y);
        z = Math.abs(z);
    }

    public void negate() {
        x = -x;
        y = -y;
        z = -z;
    }

    public void add(Vector3d vector) {
        x += vector.x;
        y += vector.y;
        z += vector.z;
    }

    public void sub(Vector3d vector) {
        x -= vector.x;
        y -= vector.y;
        z -= vector.z;
    }

    public String toString() {
        return String.format(
            "(x: %.2f, y: %.2f, z: %.2f)",
            x,
            y,
            z);
    }
}

新的Vector3d类声明了三个构造函数,它们的行在前面的代码列表中突出显示。第一个构造函数接收三个double参数xyz,并使用这些参数中接收的值初始化具有相同名称和类型的字段。

第二个构造函数接收一个double参数valueForXYZ,并使用this关键字调用先前解释的构造函数,将接收的参数作为三个参数的值。

提示

我们可以在构造函数中使用this关键字来调用类中定义的具有不同参数的其他构造函数。

第三个构造函数是一个无参数的构造函数,并使用this关键字调用先前解释的构造函数,将0.0作为valueForXYZ参数的值。这样,构造函数允许我们构建一个原点向量。

每当我们调用absolutenegateaddsub方法时,我们将改变实例的状态,也就是说,我们将改变对象的状态。这些方法改变了我们调用它们的实例的xyz字段的值。

在 JShell 中使用可变对象

以下行创建了一个名为vector1的新Vector3d实例,其初始值为xyz10.020.030.0。第二行创建了一个名为vector2的新Vector3d实例,其初始值为xyz1.02.03.0。然后,代码调用System.out.println方法,参数分别为vector1vector2。对println方法的两次调用将执行每个Vector3d实例的toString方法,以显示可变 3D 向量的String表示。然后,代码使用vector2作为参数调用vector1add方法。最后一行再次调用println方法,参数为vector1,以打印调用add方法后xyz的新值。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_01.java文件中。

Vector3d vector1 = new Vector3d(10.0, 20.0, 30.0);
Vector3d vector2 = new Vector3d(1.0, 2.0, 3.0);
System.out.println(vector1);
System.out.println(vector2);
vector1.add(vector2);
System.out.println(vector1);

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

在 JShell 中使用可变对象

vector1字段的初始值分别为10.020.030.0add方法改变了三个字段的值。因此,对象状态发生了变化:

  • vector1.x10.0变为10.0 + 1.0 = 11.0

  • vector1.y20.0变为20.0 + 2.0 = 22.0

  • vector1.z30.0变为30.0 + 3.0 = 33.0

在调用add方法后,vector1字段的值为11.022.033.0。我们可以说该方法改变了对象的状态。因此,vector1是一个可变对象,是可变类的一个实例。

以下行使用三个可用的构造函数创建了Vector3d类的三个实例,分别命名为vector3vector4vector5。然后,下一行调用System.out.println方法,以打印对象创建后的xyz的值。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_02.java文件中。

Vector3d vector3 = new Vector3d();
Vector3d vector4 = new Vector3d(5.0);
Vector3d vector5 = new Vector3d(-15.5, -11.1, -8.8);
System.out.println(vector3);
System.out.println(vector4);
System.out.println(vector5);

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

在 JShell 中使用可变对象

接下来的行调用了先前创建的实例的许多方法。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_02.java文件中。

vector4.negate();
System.out.println(vector4);
vector3.add(vector4);
System.out.println(vector3);
vector4.absolute();
System.out.println(vector4);
vector5.sub(vector4);
System.out.println(vector5);

vector4字段的初始值为5.0。对vector4.negate方法的调用将三个字段的值改变为-5.0

三个vector3字段(xyz)的初始值为0.0。对vector3.add方法的调用通过vector3vector4的每个分量的和的结果改变了三个字段的值。因此,对象状态发生了变化:

  • vector3.x0.0变为0.0 + (-5.0) = -5.0

  • vector3.y0.0变为0.0 + (-5.0) = -5.0

  • vector3.z0.0变为0.0 + (-5.0) = -5.0

vector3字段在调用add方法后被设置为-5.0。对vector4.absolute方法的调用将三个字段的值从-5.0改变为5.0

vector5字段的初始值分别为-15.5-11.1-8.8。对vector5.sub方法的调用通过vector5vector4的每个分量的减法结果改变了三个字段的值。因此,对象状态发生了变化:

  • vector5.x-15.5变为-15.5 - 5.0 = -20.5

  • vector5.y-11.1变为-11.1 - 5.0 = -16.1

  • vector5.z-8.8变为-8.8 - 5.0 = -13.8

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

在 JShell 中使用可变对象

在 Java 9 中构建不可变类

到目前为止,我们一直在使用可变类和变异对象。每当我们暴露可变字段时,我们都会创建一个将生成可变实例的类。在某些情况下,我们可能更喜欢一个对象,在初始化后无法更改其状态。我们可以设计类为不可变,并生成不可更改的实例,这些实例在创建和初始化后无法更改其状态。

不可变对象非常有用的一个典型场景是在处理并发代码时。不能更改其状态的对象解决了许多典型的并发问题,并避免了可能难以检测和解决的潜在错误。因为不可变对象不能更改其状态,所以在许多不同的线程修改它时,不可能出现对象处于损坏或不一致状态的情况,而没有适当的同步机制。

注意

不可变对象也被称为不可变对象。

我们将创建一个不可变版本的先前编码的Vector3d类,以表示不可变的 3D 向量。这样,我们将注意到可变类和其不可变版本之间的区别。不可变的 3D 向量必须提供以下功能:

  • 三个double类型的不可变实例字段:xyz。这些字段的值在实例初始化或构造后不能更改。

  • 通过为xyz不可变字段提供初始值来创建实例的构造函数。

  • 一个构造函数,创建一个所有值都设置为0的实例,即x = 0y = 0z = 0

  • 一个构造函数,创建一个所有值都初始化为公共值的实例。例如,如果我们指定3.0作为公共值,构造函数必须生成一个不可变实例,其中x = 3.0y = 3.0z = 3.0

  • 一个absolute方法,返回一个新实例,其中调用该方法的实例的每个分量的绝对值设置为该实例的每个分量的绝对值。

  • 一个negate方法,返回一个新实例,其中调用该方法的实例的每个分量的值设置为该方法的每个分量的否定值。

  • 一个add方法,返回一个新实例,其中调用该方法的实例的每个分量设置为该方法和作为参数接收的不可变 3D 向量的每个分量的和。

  • 一个sub方法,返回一个新实例,其中调用该方法的实例的每个分量设置为该方法和作为参数接收的不可变 3D 向量的每个分量的差。

  • toString方法的实现,打印 3D 向量的三个分量的值:xyz

以下行声明了ImmutableVector3d类,该类表示 Java 中 3D 向量的不可变版本。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_03.java文件中。

public class ImmutableVector3d {
    public final double x;
    public final double y;
    public final double z;

 ImmutableVector3d(double x, double y, double z) {
 this.x = x;
 this.y = y;
 this.z = z;
 }

 ImmutableVector3d(double valueForXYZ) {
 this(valueForXYZ, valueForXYZ, valueForXYZ);
 }

 ImmutableVector3d() {
 this(0.0);
 }

    public ImmutableVector3d absolute() {
        return new ImmutableVector3d(
            Math.abs(x),
            Math.abs(y),
            Math.abs(z));
    }

    public ImmutableVector3d negate() {
        return new ImmutableVector3d(
            -x,
            -y,
            -z);
    }

    public ImmutableVector3d add(ImmutableVector3d vector) {
        return new ImmutableVector3d(
            x + vector.x,
            y + vector.y,
            z + vector.z);
    }

    public ImmutableVector3d sub(ImmutableVector3d vector) {
        return new ImmutableVector3d(
            x - vector.x,
            y - vector.y,
            z - vector.z);
    }

    public String toString() {
        return String.format(
            "(x: %.2f, y: %.2f, z: %.2f)",
            x,
            y,
            z);
    }
}

新的ImmutableVector3d类通过使用final关键字声明了三个不可变实例字段:xyz。在此类声明的三个构造函数的行在前面的代码列表中突出显示。这些构造函数具有我们为Vector3d类分析的相同代码。唯一的区别在于执行,因为构造函数正在初始化不可变实例字段,这些字段在初始化后不会更改其值。

每当我们调用absolutenegateaddsub方法时,它们的代码将返回ImmutableVector3d类的新实例,其中包含每个操作的结果。我们永远不会改变我们的实例;也就是说,我们不会改变对象的状态。

在 JShell 中使用不可变对象

以下几行创建了一个名为vector10的新ImmutableVector3d实例,其xyz的初始值分别为100.0200.0300.0。第二行创建了一个名为vector20的新ImmutableVector3d实例,其xyz的初始值分别为11.012.013.0。然后,代码分别使用vector10vector20作为参数调用System.out.println方法。对println方法的两次调用将执行每个ImmutableVector3d实例的toString方法,以显示不可变 3D 向量的String表示。然后,代码使用vector10vector20作为参数调用add方法,并将返回的ImmutableVector3d实例保存在vector30中。

最后一行使用vector30作为参数调用println方法,以打印此实例的xyz的值,该实例包含了vector10vector20之间的加法操作的结果。在声明ImmutableVector3d类的代码之后输入这些行。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_03.java文件中。

ImmutableVector3d vector10 = 
    new ImmutableVector3d(100.0, 200.0, 300.0);
ImmutableVector3d vector20 = 
    new ImmutableVector3d(11.0, 12.0, 13.0);
System.out.println(vector10);
System.out.println(vector20);
ImmutableVector3d vector30 = vector10.add(vector20);
System.out.println(vector30);

以下屏幕截图显示了在 JShell 中执行先前代码的结果:

在 JShell 中使用不可变对象

由于add方法的结果,我们有另一个名为vector30的不可变实例,其字段值为111.0x)、212.0y)和313.0z)。调用每个计算操作的方法的结果,我们将得到另一个不可变实例。

以下几行使用三个可用的构造函数创建了ImmutableVector3d类的三个实例,分别命名为vector40vector50vector60。然后,下一行调用System.out.println方法,以打印对象创建后xyz的值。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_03.java文件中。

ImmutableVector3d vector40 = 
    new ImmutableVector3d();
ImmutableVector3d vector50 = 
    new ImmutableVector3d(-5.0);
ImmutableVector3d vector60 = 
    new ImmutableVector3d(8.0, 9.0, 10.0);
System.out.println(vector40);
System.out.println(vector50);
System.out.println(vector60);

以下屏幕截图显示了在 JShell 中执行先前代码的结果:

在 JShell 中使用不可变对象

接下来的几行调用了先前创建实例的许多方法,并生成了ImmutableVector3d类的新实例。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_03.java文件中。

ImmutableVector3d vector70 = vector50.negate();
System.out.println(vector70);
ImmutableVector3d vector80 = vector40.add(vector70);
System.out.println(vector80);
ImmutableVector3d vector90 = vector70.absolute();
System.out.println(vector90);
ImmutableVector3d vector100 = vector60.sub(vector90);
System.out.println(vector100);

vector50字段(xyz)的初始值为-5.0。对vector50.negate方法的调用返回一个新的ImmutableVector3d实例,代码将其保存在vector70中。新实例的三个字段(xyz)的值为5.0`。

vector40字段(xyz)的初始值为0。对vector40.add方法使用vector70作为参数的调用返回一个新的ImmutableVector3d实例,代码将其保存在vector80中。新实例的三个字段(xyz)的值为5.0

vector70.absolute方法的调用返回一个新的ImmutableVector3d实例,代码将其保存在vector90中。新实例的三个字段(xyz)的值为5.0。字段的绝对值与原始值相同,但代码仍然生成了一个新实例。

vector60字段的初始值分别为8.0x)、9.0y)和10.0z)。对vector60.sub方法使用vector90作为参数的调用返回一个新的ImmutableVector3d实例,代码将其保存在vector100中。vector100字段的值分别为3.0x)、4.0y)和5.0z)。

以下屏幕截图显示了在 JShell 中执行先前代码的结果:

在 JShell 中使用不可变对象

理解可变和不可变对象之间的区别

与可变版本相比,不可变版本增加了开销,因为调用absolutenegateaddsub方法时需要创建类的新实例。先前分析过的可变类Vector3D只是改变了字段的值,不需要生成新实例。因此,不可变版本的内存占用量高于可变版本。

与可变版本相比,名为ImmutableVector3d的不可变类在内存和性能方面都有额外的开销。创建新实例比改变少数字段的值更昂贵。然而,正如先前解释的那样,当我们使用并发代码时,为了避免可变对象可能引起的问题,为额外的开销付费是有意义的。我们只需要确保分析优势和权衡,以决定哪种方式是编写特定类最方便的方式。

现在,我们将编写一些使用可变版本的代码,并生成不可变版本的等效代码。这样,我们就能够简单而生动地比较这两段代码之间的区别。

以下行创建了一个名为mutableVector3d1的新的Vector3d实例,初始值为xyz的值分别为-30.5-15.5-12.5。然后,代码打印了新实例的String表示形式,调用了absolute方法,并打印了变异对象的String表示形式。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_04.java文件中。

// Mutable version
Vector3d mutableVector3d1 = 
    new Vector3d(-30.5, -15.5, -12.5);
System.out.println(mutableVector3d1);
mutableVector3d1.absolute();
System.out.println(mutableVector3d1);

以下截图显示了在 JShell 中执行先前代码的结果:

理解可变和不可变对象之间的区别

以下行创建了一个名为immutableVector3d1的新的ImmutableVector3d实例,初始值为xyz的值分别为-30.5-15.5-12.5。然后,代码打印了新实例的String表示形式,调用了absolute方法生成了一个名为immutableVector3d2的新的ImmutableVector3d实例,并打印了新对象的String表示形式。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_04.java文件中。

// Immutable version
ImmutableVector3d immutableVector3d1 = 
    new ImmutableVector3d(-30.5, -15.5, -12.5);
System.out.println(immutableVector3d1);
ImmutableVector3d immutableVector3d2 =
    immutableVector3d1.absolute();
System.out.println(immutableVector3d2);

以下截图显示了在 JShell 中执行先前代码的结果:

理解可变和不可变对象之间的区别

可变版本使用单个Vector3d实例。Vector3d类的构造函数只执行一次。当调用absolute方法时,原始实例会改变其状态。

不可变版本使用两个ImmutableVector3d实例,因此内存占用量高于可变版本。ImmutableVector3d类的构造函数被执行了两次。第一个实例在调用absolute方法时没有改变其状态。

学习在编写并发代码时不可变对象的优势

现在,让我们想象我们正在编写必须访问先前创建实例的字段的并发代码。首先,我们将分析可变版本的问题,然后我们将了解使用不可变对象的优势。

假设我们有两个线程,代码中引用了保存在mutableVector3d1中的实例。第一个线程调用这个可变对象的absolute方法。absolute方法的第一行代码将Math.abs的结果作为参数赋给x可变字段的实际值。

在这一点上,方法还没有完成执行,下一行代码将无法访问这些值。然而,在另一个线程中运行的并发代码可能会在absolute方法完成执行之前访问xyz字段的值。对象处于损坏状态,因为x字段的值为30.5y字段的值为-15.5z字段的值为-12.5。这些值不代表absolute方法执行完成后我们将拥有的 3D 向量。并发运行的代码片段并且可以访问相同实例而没有任何同步机制,这会产生问题。

并发编程和线程编程是复杂的主题,值得一整本书来讨论。有同步机制可以避免前面提到的问题,并使类成为线程安全的。然而,另一个解决方案是使用生成不可变对象的不可变类。

如果我们使用不可变版本,两个线程可以引用相同的初始实例。然而,当其中一个线程调用absolute方法时,原始的 3D 向量不会发生变化,因此之前的问题永远不会发生。另一个线程将继续使用对原始 3D 向量的引用,保持其原始状态。调用absolute方法的线程将生成一个完全独立于原始实例的新实例。

再次强调,理解这个主题需要一整本书。然而,了解为什么不可变类可能在实例将参与并发代码的特定场景中是一个特殊要求是很重要的。

使用不可变 String 类的实例

String类,特别是java.lang.String类,表示字符字符串,是一个生成不可变对象的不可变类。因此,String类提供的方法不会改变对象。

例如,以下行创建了一个新的String,也就是java.lang.String类的一个新实例,名为welcomeMessage,初始值为"Welcome to Virtual Creatures Land"。然后,代码对welcomeMessage进行了多次调用System.out.println,并将不同的方法作为参数。首先,我们调用toUpperCase方法生成一个所有字符都转换为大写的新String。然后,我们调用toLowerCase方法生成一个所有字符都转换为小写的新String。然后,我们调用replaceAll方法生成一个将空格替换为连字符(-)的新String。最后,我们再次调用System.out.println方法,并将welcomeMessage作为参数,以检查原始String的值。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_05.java文件中。

String welcomeMessage = "Welcome to Virtual Creatures Land";
System.out.println(welcomeMessage);
System.out.println(welcomeMessage.toUpperCase());
System.out.println(welcomeMessage.toLowerCase());
System.out.println(welcomeMessage.replaceAll(" ", "-"));
System.out.println(welcomeMessage);

以下截图显示了在 JShell 中执行前面代码的结果:

使用不可变 String 类的实例

welcomeMessage字符串从未改变其值。对toUpperCasetoLowerCasereplaceAll方法的调用为每个方法生成并返回了一个新的String实例。

提示

无论我们为String实例调用哪个方法,它都不会改变对象。因此,我们可以说String是一个不可变类。

创建现有可变类的不可变版本

在上一章中,我们创建了一个名为VirtualCreature的可变类。我们提供了 setter 方法来改变hatvisibilityLevelbirthYear字段的值。我们可以通过调用setAge方法来改变birthYear

虚拟生物在进化后会改变它们的年龄、帽子和可见性级别。当它们进化时,它们会变成不同的生物,因此在这种进化发生后生成一个新实例是有意义的。因此,我们将创建VirtualCreature类的不可变版本,并将其称为ImmutableVirtualCreature

以下行显示了新ImmutableVirtualCreature类的代码。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_06.java文件中。

import java.time.Year;

public class ImmutableVirtualCreature {
 public final String name;
 public final int birthYear;
 public final String hat;
 public final int visibilityLevel;

    ImmutableVirtualCreature(final String name, 
        int birthYear, 
        String hat, 
        int visibilityLevel) {
        this.name = name;
        this.birthYear = birthYear;
        this.hat = hat.toUpperCase();
        this.visibilityLevel = 
            getValidVisibilityLevel(visibilityLevel);
    }

    private int getCurrentYear() {
        return Year.now().getValue();
    }

    private int getValidVisibilityLevel(int levelToValidate) {
        return Math.min(Math.max(levelToValidate, 0), 100);
    }

    public int getAge() {
        return getCurrentYear() - birthYear;
    }

    public ImmutableVirtualCreature evolveToAge(int age) {
        int newBirthYear = getCurrentYear() - age;
        return new ImmutableVirtualCreature(
            name,
            newBirthYear,
            hat,
            visibilityLevel);
    }

    public ImmutableVirtualCreature evolveToVisibilityLevel(
        final int visibilityLevel) {
        int newVisibilityLevel =
            getValidVisibilityLevel(visibilityLevel);
        return new ImmutableVirtualCreature(
            name,
            birthYear,
            hat,
            newVisibilityLevel);
    }
}

ImmutableVirtualCreature类使用final关键字声明了四个公共不可变实例字段:namebirthYearhatvisibilityLevel。在实例被初始化或构造后,我们将无法更改这些字段的任何值。

构造函数从hat参数中接收的String生成大写的String并将其存储在公共的不可变字段hat中。我们对可见性级别有特定的验证,因此构造函数调用一个名为getValidVisibilityLevel的新私有方法,该方法使用visibilityLevel参数中接收的值来为具有相同名称的不可变字段分配一个有效值。

我们不再有 setter 方法,因为在初始化后我们无法更改不可变字段的值。该类声明了以下两个新的公共方法,它们返回一个新的ImmutableVirtualCreature实例:

  • evolveToAge:此方法接收age参数中进化虚拟生物的期望年龄。代码根据接收到的年龄和当前年份计算出出生年份,并返回一个具有新初始化值的新ImmutableVirtualCreature实例。

  • evolveToVisibilityLevel:此方法接收visibilityLevel参数中进化虚拟生物的期望可见性级别。代码调用getValidVisibilityLevel方法根据接收到的值生成一个有效的可见性级别,并返回一个具有新初始化值的新ImmutableVirtualCreature实例。

以下行创建了一个名为meowth1ImmutableVirtualCreature类的实例。然后,代码使用3作为age参数的值调用meowth1.evolveToAge方法,并将此方法返回的新ImmutableVirtualCreature实例保存在meowth2变量中。代码打印了meowth2.getAge方法返回的值。最后,代码使用25作为invisibilityLevel参数的值调用meowth2.evolveToVisibilityLevel方法,并将此方法返回的新ImmutableVirtualCreature实例保存在meowth3变量中。然后,代码打印了存储在meowth3.visibilityLevel不可变字段中的值。示例的代码文件包含在java_9_oop_chapter_05_01文件夹中的example05_06.java文件中。

ImmutableVirtualCreature meowth1 =
    new ImmutableVirtualCreature(
        "Meowth", 2010, "Baseball cap", 35);
ImmutableVirtualCreature meowth2 = 
    meowth1.evolveToAge(3);
System.out.printf("%d\n", meowth2.getAge());
ImmutableVirtualCreature meowth3 = 
    meowth2.evolveToVisibilityLevel(25);
System.out.printf("%d\n", meowth3.visibilityLevel);

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

创建现有可变类的不可变版本

测试你的知识

  1. 一个暴露可变字段的类将:

  2. 生成不可变实例。

  3. 生成可变实例。

  4. 生成可变类但不可变实例。

  5. 在构造函数中使用以下哪个关键字可以调用我们类中定义的具有不同参数的其他构造函数:

  6. self

  7. constructor

  8. this

  9. 在初始化后无法更改其状态的对象称为:

  10. 一个可变对象。

  11. 一个不可变对象。

  12. 一个接口对象。

  13. 在 Java 9 中,java.lang.String生成:

  14. 一个不可变对象。

  15. 一个可变对象。

  16. 一个接口对象。

  17. 如果我们为java.lang.String调用toUpperCase方法,该方法将:

  18. 将现有的String转换为大写字符并改变其状态。

  19. 返回一个新的String,其中包含原始String转换为大写字符的内容。

  20. 返回一个包含原始字符串内容的新的String

总结

在本章中,你学习了可变和不可变类之间的区别,以及它们生成的可变和不可变实例。我们在 Java 9 中声明了可变和不可变版本的 3D 向量类。

然后,我们利用 JShell 轻松地处理这些类的可变和不可变实例,并分析了改变对象状态和在需要改变其状态时返回一个新对象之间的区别。我们分析了可变和不可变类的优缺点,并理解了为什么在处理并发代码时后者是有用的。

现在你已经学习了可变和不可变类,你已经准备好学习继承、抽象、扩展和专门化,这些是我们下一章要讨论的主题。

第六章:继承,抽象,扩展和特殊化

在本章中,我们将学习 Java 9 中面向对象编程最重要的支柱之一:继承。我们将使用示例来学习如何创建类层次结构,覆盖和重载方法,并处理超类中定义的构造函数。我们将:

  • 创建类层次结构以抽象和特殊化行为

  • 理解继承

  • 创建一个抽象基类

  • 声明从另一个类继承的类

  • 重载构造函数

  • 覆盖实例方法

  • 重载实例方法

创建类层次结构以抽象和特殊化行为

在之前的章节中,我们一直在使用 Java 9 创建类来生成现实生活中对象的蓝图。我们声明了类,然后在 JShell 中创建了这些类的实例。现在是时候利用 Java 9 中包含的许多最先进的面向对象编程特性,开始设计一个类层次结构,而不是使用孤立的类。首先,我们将根据需求设计所有需要的类,然后使用 Java 9 中可用的功能来编写设计的类。

我们使用类来表示虚拟生物。现在,让我们想象一下,我们必须开发一个复杂的 Web 服务,需要我们处理数十种虚拟动物。在项目的第一阶段,许多这些虚拟动物将类似于宠物和家畜。需求规定,我们的 Web 服务将开始处理以下四种与家畜动物物种相似的虚拟动物:

  • Equus ferus caballus)。不要将其与野马(Equus ferus)混淆。我们将拥有雄性和雌性马,雌性马可能怀孕。此外,我们将需要处理以下三种特定的马种:美国四分之一马,夏尔马和纯种马。

  • 鹦鹉Nymphicus hollandicus)。这种鸟也被称为鹦鹉或维罗。

  • 缅因库恩。这是最大的家养猫品种之一(Felis silvestris catus)。

  • 家兔Oryctolagus cuniculus)。这种兔子也被称为欧洲兔。

前面的列表包括每种家畜动物物种的学名。我们肯定会使用每种物种的最常见名称,并将学名作为String类型的类常量。因此,我们不会有复杂的类名,比如VirtualEquusFerusCaballus,而是使用VirtualHorse

我们的第一个需求规定,我们必须处理先前列举的四种家畜动物物种的有限数量品种。此外,将来将需要处理其他列出的家畜动物物种的其他成员,其他家畜哺乳动物,额外的家禽,特定的马种,甚至不属于家畜动物物种的爬行动物和鸟类。我们的面向对象设计必须准备好为未来的需求进行扩展,就像在现实项目中经常发生的那样。事实上,我们将使用这个例子来理解面向对象编程如何轻松地扩展现有设计以考虑未来的需求。

我们不想模拟动物王国及其分类的完整表示。我们只会创建必要的类,以便拥有一个灵活的模型,可以根据未来的需求轻松扩展。动物王国非常复杂。我们将把重点放在这个庞大家族的一些成员上。

以下示例的主要目标之一是了解面向对象编程并不会牺牲灵活性。我们将从一个简单的类层次结构开始,随着所需功能的复杂性增加以及对这些新需求的更多了解,我们将扩展它。让我们记住,需求并不是固定的,我们总是必须根据这些新需求添加新功能并对现有类进行更改。

我们将创建一个类层次结构来表示虚拟动物及其品种的复杂分类。当我们扩展一个类时,我们创建这个类的子类。以下列表列举了我们将创建的类及其描述:

  • VirtualAnimal:这个类概括了动物王国的所有成员。马、猫、鸟、兔子和爬行动物有一个共同点:它们都是动物。因此,创建一个类作为我们面向对象设计中可能需要表示的不同类别的虚拟动物的基线是有意义的。

  • VirtualMammal:这个类概括了所有哺乳动物的虚拟动物。哺乳动物与昆虫、鸟类、两栖动物和爬行动物不同。我们已经知道我们可以有母马,并且它们可以怀孕。我们还知道我们将需要对爬行动物和鸟类进行建模,因此我们创建了一个扩展VirtualAnimal并成为其子类的VirtualMammal类。

  • VirtualBird:这个类概括了所有鸟类。鸟类与哺乳动物、昆虫、两栖动物和爬行动物不同。我们已经知道我们还将需要对爬行动物进行建模。鹦鹉是一种鸟,因此我们将在与VirtualMammal同级别创建一个VirtualBird类。

  • VirtualDomesticMammal:这个类扩展了VirtualMammal类。让我们进行一些研究,我们会意识到老虎(Panthera tigris)是目前最大和最重的猫科动物。老虎是一种猫,但它与缅因猫完全不同,缅因猫是一种小型家养猫。最初的需求规定我们要处理虚拟家养和虚拟野生动物,因此我们将创建一个概括所有虚拟家养哺乳动物的类。将来,我们将有一个VirtualWildMammal子类,它将概括所有虚拟野生哺乳动物。

  • VirtualDomesticBird:这个类扩展了VirtualBird类。让我们进行一些研究,我们会意识到鸵鸟(Struthio camelus)是目前最大的活鸟。鸵鸟是一种鸟,但它与鹦鹉完全不同,鹦鹉是一种小型家养鸟。我们将处理虚拟家养和虚拟野生鸟,因此我们将创建一个概括所有虚拟家养鸟的类。将来,我们将有一个VirtualWildBird类,它将概括所有虚拟野生鸟。

  • VirtualHorse:这个类扩展了VirtualDomesticMammal类。我们可以继续用额外的子类专门化VirtualDomesticMammal类,直到达到VirtualHorse类。例如,我们可以创建一个VirtualHerbivoreDomesticMammal子类,然后让VirtualHorse类继承它。然而,我们需要开发的 Web 服务不需要在VirtualDomesticMammalVirtualHorse之间有任何中间类。VirtualHorse类概括了我们应用程序中虚拟马所需的所有字段和方法。VirtualHorse类的不同子类将代表虚拟马品种的不同家族。

  • VirtualDomesticRabbit:这个类扩展了VirtualDomesticMammal类。VirtualDomesticRabbit类概括了我们应用程序中虚拟家养兔所需的所有字段和方法。

  • VirtualDomesticCat:这个类扩展了VirtualDomesticMammal类。VirtualDomesticCat类概括了我们应用程序中虚拟家养猫所需的所有字段和方法。

  • 美国四分之一马:这个类扩展了虚拟马类。美国四分之一马类概括了属于美国四分之一马品种的虚拟马所需的所有字段和方法。

  • ShireHorse:这个类扩展了虚拟马类。ShireHorse类概括了属于莱茵马品种的虚拟马所需的所有字段和方法。

  • Thoroughbred:这个类扩展了虚拟马类。Thoroughbred类概括了属于纯种马品种的虚拟马所需的所有字段和方法。

  • Cockatiel:这个类扩展了虚拟家禽类。Cockatiel类概括了属于鹦鹉家族的虚拟家禽所需的所有字段和方法。

  • MaineCoon:这个类扩展了虚拟家猫类。MaineCoon类概括了属于缅因库恩品种的虚拟家猫所需的所有字段和方法。

以下表格显示了前述列表中的每个类及其超类、父类或超类型。

子类、子类或子类型 超类、父类或超类型
虚拟哺乳动物 虚拟动物
虚拟鸟 虚拟动物
虚拟家畜哺乳动物 虚拟哺乳动物
虚拟家禽 虚拟鸟
虚拟马 虚拟家畜哺乳动物
虚拟家兔 虚拟家畜哺乳动物
虚拟家猫 虚拟家畜哺乳动物
美国四分之一马 虚拟马
ShireHorse 虚拟马
Thoroughbred 虚拟马
Cockatiel 虚拟家禽
MaineCoon 虚拟家猫

以下的 UML 图显示了以类层次结构组织的前述类。使用斜体文本格式的类名表示它们是抽象类。注意图表中不包括任何成员,只有类名。我们稍后会添加成员。

创建类层次结构以抽象和特殊化行为

理解继承

当一个类继承自另一个类时,它继承了组成父类的所有成员,这也被称为超类。继承元素的类被称为超类的子类。例如,VirtualBird子类继承了VirtualAnimal超类中定义的所有实例字段、类字段、实例方法和类方法。

提示

在 Java 9 中,子类不会从其超类那里继承任何构造函数。但是,可以调用超类中定义的构造函数,在下面的示例中我们将这样做。只有在超类中定义的任何构造函数中使用private访问修饰符才会使子类无法调用该构造函数。

VirtualAnimal抽象类是我们类层次结构的基线。我们说它是一个抽象类,因为我们不能创建VirtualAnimal类的实例。相反,我们必须创建VirtualAnimal的具体子类的实例,任何不是抽象类的子类。我们可以用来创建它们的类通常被称为具体类或在大多数情况下只是类。Java 9 允许我们声明类为抽象类,当它们不打算生成实例时。

注意

我们不能使用new关键字后跟类名来创建抽象类的实例。

我们要求每个VirtualAnimal指定它的年龄,但我们不需要为它们指定任何名字。我们只给家养动物取名字。因此,当我们创建任何VirtualAnimal,也就是任何VirtualAnimal子类的实例时,我们将不得不指定一个年龄值。该类将定义一个age字段,并在创建虚拟动物时打印一条消息。

但是等等;我们刚刚解释过,我们正在谈论一个抽象类,并且 Java 不允许我们创建抽象类的实例。我们不能创建VirtualAnimal抽象类的实例,但我们将能够创建具有VirtualAnimal作为超类的任何具体类的实例,这个子类最终可以调用VirtualAnimal抽象类中定义的构造函数。听起来有点复杂,但在我们编写类并在 JShell 中运行示例后,我们将很容易理解情况。我们将在我们定义的每个构造函数中打印消息,以便更容易理解当我们创建具有一个或多个超类的具体类的实例时会发生什么,包括一个或多个抽象超类。VirtualAnimal的所有子类的实例也将是VirtualAnimal的实例。

VirtualAnimal抽象类将定义抽象类方法和抽象实例方法。抽象类方法是声明而没有实现的类方法。抽象实例方法,也称为抽象方法,是声明而没有实现的实例方法。

提示

当我们声明任何两种类型的抽象方法时,我们只声明参数(如果有),然后放一个分号(;)。我们根本不使用花括号。我们只能在抽象类中声明抽象方法。任何抽象类的具体子类必须为所有继承的抽象方法提供实现,以成为我们可以使用new关键字创建实例的类。

VirtualAnimal类将声明以下七个抽象方法,满足特定家族或类型的所有成员的要求。该类只声明它们所需的参数,而不实现方法。子类将负责满足解释的要求。

  • isAbleToFly:返回一个布尔值,指示虚拟动物是否能飞。

  • isRideable:返回一个布尔值,指示虚拟动物是否可骑。可骑的动物能够被骑乘。

  • isHerbivore:返回一个布尔值,指示虚拟动物是否是食草动物。

  • isCarnivore:返回一个布尔值,指示虚拟动物是否是肉食动物。

  • getAverageNumberOfBabies:返回通常为虚拟动物类型一次出生的平均婴儿数量。

  • getBaby:返回虚拟动物类型的婴儿的String表示。

  • getAsciiArt:返回表示虚拟动物的 ASCII 艺术(基于文本的视觉艺术)的String

VirtualAnimal类将定义以下五个方法,满足每个实例的要求。这些将是具体方法,将在VirtualAnimal类中编码,并由其所有子类继承。其中一些方法调用先前解释的抽象方法。我们将在稍后详细了解这是如何工作的。

  • printAsciiArt:这将打印getAsciiArt方法返回的String

  • isYoungerThan:返回一个布尔值,指示VirtualAnimalage值是否低于作为参数接收的VirtualAnimal实例的年龄。

  • isOlderThan:返回一个布尔值,指示VirtualAnimal类的age值是否大于作为参数接收的VirtualAnimal实例的年龄。

  • printAge:打印虚拟动物的age值。

  • printAverageNumberOfBabies:打印通常为虚拟动物一次出生的平均婴儿数量的表示。该方法将考虑由不同具体子类中实现的getAverageNumberOfBabies方法返回的值。

VirtualMammal类继承自VirtualAnimal。当创建新的VirtualMammal实例时,我们将不得不指定其年龄和是否怀孕。该类从VirtualAnimal超类继承了age属性,因此只需要添加一个字段来指定虚拟哺乳动物是否怀孕。请注意,我们将不会在任何时候指定性别,以保持简单。如果我们添加了性别,我们将需要验证以避免雄性怀孕。现在,我们的重点是继承。该类将在创建虚拟哺乳动物时显示一条消息;也就是说,每当执行其构造函数时。

提示

每个类都继承自一个类,因此,我们将定义的每个新类都只有一个超类。在这种情况下,我们将始终使用单一继承。在 Java 中,一个类不能从多个类继承。

VirtualDomesticMammal类继承自VirtualMammal。当创建新的VirtualDomesticMammal实例时,我们将不得不指定其名称和最喜欢的玩具。我们给任何家养哺乳动物都起名字,它们总是会挑选一个最喜欢的玩具。有时它们只是选择满足它们破坏欲望的物品。在许多情况下,最喜欢的玩具并不一定是我们希望它们选择的玩具(我们的鞋子、运动鞋、拖鞋或电子设备),但让我们专注于我们的类。我们无法改变名称,但可以改变最喜欢的玩具。我们永远不会改变任何家养哺乳动物的名称,但我们绝对可以强迫它改变最喜欢的玩具。该类在创建虚拟家养哺乳动物时显示一条消息。

VirtualDomesticMammal类将声明一个talk实例方法,该方法将显示一条消息,指示虚拟家养哺乳动物的名称与消息“说了些什么”的连接。每个子类必须以不同的方式让特定的家养哺乳动物说话。鹦鹉确实会说话,但我们将把马的嘶鸣和兔子的牙齿咕噜声视为它们在说话。请注意,在这种情况下,talk实例方法在VirtualDomesticMammal类中具有具体的实现,而不是抽象的实例方法。子类将能够为此方法提供不同的实现。

VirtualHorse类继承自VirtualDomesticMammal,并实现了从VirtualAnimal超类继承的所有抽象方法,除了getBabygetAsciiArt。这两个方法将在VirtualHorse的每个子类中实现,以确定马的品种。

我们希望马能够嘶鸣和嘶鸣。因此,我们需要neighnicker方法。马通常在生气时嘶鸣,在快乐时嘶鸣。情况比这更复杂一些,但我们将为我们的示例保持简单。

neigh方法必须允许虚拟马执行以下操作:

  • 只嘶鸣一次

  • 特定次数的嘶鸣

  • 与另一个只有一次名字的虚拟家养哺乳动物相邻

  • 对另一个只有特定次数名字的虚拟家养哺乳动物嘶鸣

nicker方法必须允许虚拟马执行以下操作:

  • 只嘶鸣一次

  • 特定次数的嘶鸣

  • 只对另一个只有一次名字的虚拟家养哺乳动物嘶鸣

  • 对另一个只有特定次数名字的虚拟家养哺乳动物嘶鸣

此外,马可以愉快地或愤怒地嘶鸣或嘶鸣。我们可以有一个neigh方法,其中许多参数具有默认值,或者有许多neigh方法。Java 9 提供了许多机制来解决虚拟马必须能够嘶鸣的不同方式的挑战。我们将对neighnicker方法应用相同的解决方案。

当我们为任何虚拟马调用talk方法时,我们希望它开心地嘶鸣一次。我们不希望显示在VirtualDomesticMammal类中引入的talk方法中定义的消息。因此,VirtualHorse类必须用自己的定义覆盖继承的talk方法。

我们想知道虚拟马属于哪个品种。因此,我们将定义一个getBreed抽象方法。VirtualHorse的每个子类在调用此方法时必须返回适当的String名称。VirtualHorse类将定义一个名为printBreed的方法,该方法使用getBreed方法来检索名称并打印品种。

到目前为止,我们提到的所有类都是抽象类。我们不能创建它们的实例。AmericanQuarterHorseShireHorseThoroughbred类继承自VirtualHorse类,并实现了继承的getBabygetAsciiArtgetBreed方法。此外,它们的构造函数将打印一条消息,指示我们正在创建相应类的实例。这三个类都是具体类,我们可以创建它们的实例。

我们将稍后使用VirtualBirdVirtualDomesticBirdCockatielVirtualDomesticCatMaineCoon类。首先,我们将在 Java 9 中创建基类VirtualAnimal抽象类,然后使用简单的继承创建子类,直到VirtualHorse类。我们将重写方法和重载方法以满足所有要求。我们将利用多态性,这是面向对象编程中非常重要的特性,我们将在 JShell 中使用创建的类时了解到。当然,我们将深入研究分析不同类时引入的许多主题。

以下 UML 图显示了我们将在本章中编写的所有抽象类的成员:VirtualAnimalVirtualMammalVirtualDomesticMammalVirtualHorse。我们将在下一章中编写其他类,并稍后将它们的成员添加到图中。我们使用斜体文本格式表示抽象方法。请记住,公共成员以加号(+)作为前缀。一个类有一个受保护的成员,使用井号作为前缀(#)。我们将使用粗体文本格式表示覆盖超类中现有方法的方法。在这种情况下,VirtualHorse类覆盖了talk()方法。

理解继承

在上一个 UML 图中,我们将注意到以下约定。我们将在包括类成员的所有 UML 图中使用这些约定。

  • 构造函数与类名相同,不指定任何返回类型。它们始终是方法部分中列出的第一个方法。

  • 字段的类型在字段名称之后用冒号()分隔。

  • 每个方法的参数列表中的参数都用分号(;)分隔。

  • 方法的返回类型在参数列表之后用冒号()分隔。

  • 我们始终使用 Java 类型名称。

创建抽象基类

首先,我们将创建抽象类,该类将成为其他类的基类。以下是 Java 9 中VirtualAnimal抽象基类的代码。class之前的abstract关键字表示我们正在创建一个抽象类。示例的代码文件包含在java_9_oop_chapter_06_01文件夹中的example06_01.java文件中。

public abstract class VirtualAnimal {
    public final int age;

    public VirtualAnimal(int age) {
        this.age = age;
        System.out.println("VirtualAnimal created.");
    }

    public abstract boolean isAbleToFly();

    public abstract boolean isRideable();

    public abstract boolean isHerbivore();

    public abstract boolean isCarnivore();

    public abstract int getAverageNumberOfBabies();

    public abstract String getBaby();

    public abstract String getAsciiArt();

    public void printAsciiArt() {
        System.out.println(getAsciiArt());
    }

    public void printAverageNumberOfBabies() {
        System.out.println(new String(
            new char[getAverageNumberOfBabies()]).replace(
                "\0", getBaby()));
    }

    public void printAge() {
        System.out.println(
            String.format("I am %d years old", age));
    }

    public boolean isYoungerThan(VirtualAnimal otherAnimal) {
        return age < otherAnimal.age; 
    }

    public boolean isOlderThan(VirtualAnimal otherAnimal) {
        return age > otherAnimal.age;
    }
}

前面的类声明了一个名为ageint类型的不可变字段。构造函数需要一个age值来创建类的实例,并打印一条消息指示创建了一个虚拟动物。该类声明了以下抽象方法,这些方法在返回类型之前包含abstract关键字,以便让 Java 知道我们只想声明所需的参数,并且不会为这些方法提供实现。我们已经解释了这些方法的目标,它们将在VirtualAnimal的子类中实现。

  • isAbleToFly

  • isRideable

  • isHerbivore

  • isCarnivore

  • 获取平均婴儿数量

  • getBaby

  • getAsciiArt

此外,该类声明了以下五个方法:

  • 打印 AsciiArt:此方法调用System.out.println来打印getAsciiArt方法返回的String

  • printAverageNumberOfBabies:此方法创建一个新的char数组,其元素数量等于getAverageNumberOfBabies方法返回的值。然后,代码创建一个初始化为char数组的新String,并调用replace方法来用getBaby方法返回的String替换每个"\0"。这样,我们生成一个String,其中包含getBaby返回的StringgetAverageNumberOfBabies倍。代码调用System.out.println来打印生成的String

  • 打印年龄:此方法调用System.out.println来打印使用String.format生成的String,其中包括age不可变字段的值。

  • isYoungerThan:此方法在otherAnimal参数中接收一个VirtualAnimal实例,并返回在此实例的age字段值和otherAnimal.age之间应用小于运算符的结果。这样,只有当此实例的年龄小于otherAnimal的年龄时,该方法才会返回true

  • isOlderThan:此方法在otherAnimal参数中接收一个VirtualAnimal实例,并返回在此实例的age字段值和otherAnimal.age之间应用大于运算符的结果。这样,只有当此实例的年龄大于otherAnimal的年龄时,该方法才会返回true

如果我们在声明VirtualAnimal类之后在 JShell 中执行以下行,Java 将生成致命错误,并指出VirtualAnimal类是抽象的,不能被实例化。示例的代码文件包含在java_9_oop_chapter_06_01文件夹中的example06_02.java文件中。

VirtualAnimal virtualAnimal1 = new VirtualAnimal(5);

以下屏幕截图显示了在 JShell 中执行上一个代码的结果:

创建抽象基类

声明从另一个类继承的类

现在我们将创建另一个抽象类。具体来说,我们将创建一个最近创建的VirtualAnimal抽象类的子类。以下行显示了扩展VirtualAnimal类的VirtualMammal抽象类的代码。请注意abstract class关键字后面跟着类名VirtualMammalextends关键字和VirtualAnimal,即超类。

在类定义中,跟在extends关键字后面的类名表示新类从中继承的超类。示例的代码文件包含在java_9_oop_chapter_06_01文件夹中的example06_03.java文件中。

public abstract class VirtualMammal extends VirtualAnimal {
    public boolean isPregnant;

    public VirtualMammal(int age, boolean isPregnant) {
 super(age);
        this.isPregnant = isPregnant;
        System.out.println("VirtualMammal created.");
    }

    public VirtualMammal(int age) {
        this(age, false);
    }
}

VirtualMammal抽象类继承了先前声明的VirtualAnimal抽象类的成员,并添加了一个名为isPregnant的新的boolean可变字段。新的抽象类声明了两个构造函数。其中一个构造函数需要一个age值来创建类的实例,就像VirtualAnimal构造函数一样。另一个构造函数需要ageisPregnant值。

如果我们只用一个 age 参数创建这个类的实例,Java 将使用第一个构造函数。如果我们用两个参数创建这个类的实例,一个是 ageint 值,一个是 isPregnantboolean 值,Java 将使用第二个构造函数。

提示

我们已经重载了构造函数并提供了两个不同的构造函数。我们不会使用 new 关键字来使用这些构造函数,因为我们正在声明一个抽象类。但是,我们将能够通过使用 super 关键字从子类中调用这些构造函数。

需要 isPregnant 参数的第一个构造函数使用 super 关键字来调用基类或超类中的构造函数,也就是在 VirtualAnimal 类中定义的需要 age 参数的构造函数。在超类中定义的构造函数执行完毕后,代码会设置 isPregnant 可变字段的值,并打印一条消息,指示已创建了一个虚拟哺乳动物。

提示

我们使用 super 关键字来引用超类,并且可以使用这个关键字来调用超类中定义的任何构造函数。在 Java 9 中,子类不会继承其超类的构造函数。在其他编程语言中,子类会继承构造函数或初始化程序,因此,非常重要的是要理解在 Java 9 中这种情况并不会发生。

第二个构造函数使用 this 关键字来调用先前解释的构造函数,接收 agefalse 作为 isPregnant 参数的值。

我们将创建另一个抽象类。具体来说,我们将创建一个最近创建的 VirtualMammal 抽象类的子类。以下几行显示了扩展 VirtualMammal 类的 VirtualDomesticMammal 抽象类的代码。注意 abstract class 关键字后面跟着类名 VirtualDomesticMammalextends 关键字和 VirtualMammal,也就是超类。跟在 extends 关键字后面的类名指示了新类在类定义中继承的超类。示例的代码文件包含在 java_9_oop_chapter_06_01 文件夹中的 example06_04.java 文件中。

public abstract class VirtualDomesticMammal extends VirtualMammal {
    public final String name;
    public String favoriteToy;

    public VirtualDomesticMammal(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
 super(age, isPregnant);
        this.name = name;
        this.favoriteToy = favoriteToy;
        System.out.println("VirtualDomesticMammal created.");
    }

    public VirtualDomesticMammal(
        int age, 
        String name, 
        String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public void talk() {
        System.out.println(
            String.format("%s: says something", name));
    }
}

VirtualDomesticMammal 抽象类继承了先前声明的 VirtualMammal 抽象类的成员。重要的是要理解,新类也继承了超类从其超类继承的成员,也就是从 VirtualAnimal 抽象类继承的成员。例如,我们的新类继承了在 VirtualAnimal 抽象类中声明的 age 不可变字段以及在这个类中声明的所有其他成员。

VirtualDomesticMammal 类添加了一个名为 name 的新的不可变字段和一个名为 favoriteToy 的新的可变字段。这个新的抽象类声明了两个构造函数。其中一个构造函数需要四个参数来创建类的实例:ageisPregnantnamefavoriteToy。另一个构造函数需要除了 isPregnant 之外的所有参数。

需要四个参数的第一个构造函数使用 super 关键字来调用基类或超类中的构造函数,也就是在 VirtualMammal 类中定义的需要两个参数 ageisPregnant 的构造函数。在超类中定义的构造函数执行完毕后,代码会设置 namefavoriteToy 字段的值,并打印一条消息,指示已创建了一个虚拟家养哺乳动物。

第二个构造函数使用 this 关键字来调用先前解释的构造函数,接收参数和 false 作为 isPregnant 参数的值。

最后,这个类声明了一个talk方法,显示了一个以name值开头,后跟一个冒号(:)和says something的消息。请注意,我们可以在VirtualDomesticMammal的任何子类中覆盖这个方法,因为每个虚拟家养哺乳动物都有自己不同的说话方式。

覆盖和重载方法

Java 允许我们多次使用相同的方法名定义不同参数的方法。这个特性被称为方法重载。在之前创建的抽象类中,我们重载了构造函数。

例如,我们可以利用方法重载来定义VirtualHorse抽象类中必须定义的neighnicker方法的多个版本。然而,在重载方法时,避免代码重复是非常重要的。

有时,我们在一个类中定义一个方法,我们知道子类可能需要提供一个不同版本的方法。一个明显的例子就是我们在VirtualDomesticMammal类中定义的talk方法。当一个子类提供了一个与超类中同名、参数和返回类型相同的方法的不同实现时,我们称之为覆盖方法。当我们覆盖一个方法时,子类中的实现会覆盖超类中提供的代码。

VirtualHorse abstract class that extends the VirtualDomesticMammal class. Note the abstract class keywords followed by the class name, VirtualHorse, the extends keyword, and VirtualDomesticMammal, that is, the superclass. We will split the code for this class in many snippets to make it easier to analyze. The code file for the sample is included in the java_9_oop_chapter_06_01 folder, in the example06_05.java file.
public abstract class VirtualHorse extends VirtualDomesticMammal {
    public VirtualHorse(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
 super(age, isPregnant, name, favoriteToy);
        System.out.println("VirtualHouse created.");        
    }

    public VirtualHorse(
        int age, 
        String name, 
        String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public boolean isAbleToFly() {
        return false;
    }

    public boolean isRideable() {
        return true;
    }

    public boolean isHerbivore() {
        return true;
    }

    public boolean isCarnivore() {
        return false;
    }

    public int getAverageNumberOfBabies() {
        return 1;
    }
VirtualHorse abstract class that extends the VirtualDomesticMammal class. The code file for the sample is included in the java_9_oop_chapter_06_01 folder, in the example06_05.java file.
    public abstract String getBreed();

    public void printBreed() {
        System.out.println(getBreed());
    }

    protected void printSoundInWords(
        String soundInWords, 
        int times, 
        VirtualDomesticMammal otherDomesticMammal,
        boolean isAngry) {
        String message = String.format("%s%s: %s%s",
            name,
            otherDomesticMammal == null ? 
                "" : String.format(" to %s ", otherDomesticMammal.name),
            isAngry ?
                "Angry " : "",
            new String(new char[times]).replace("\0", soundInWords));
        System.out.println(message);
    }
VirtualHorse abstract class that extends the VirtualDomesticMammal class. The code file for the sample is included in the java_9_oop_chapter_06_01 folder, in the example06_05.java file.
    public void printNeigh(int times, 
        VirtualDomesticMammal otherDomesticMammal,
        boolean isAngry) {
        printSoundInWords("Neigh ", times, otherDomesticMammal, isAngry);
    }

    public void neigh() {
        printNeigh(1, null, false);
    }

    public void neigh(int times) {
        printNeigh(times, null, false);
    }

    public void neigh(int times, 
        VirtualDomesticMammal otherDomesticMammal) {
        printNeigh(times, otherDomesticMammal, false);
    }

    public void neigh(int times, 
        VirtualDomesticMammal otherDomesticMammal, 
        boolean isAngry) {
        printNeigh(times, otherDomesticMammal, isAngry);
    }

    public void printNicker(int times, 
        VirtualDomesticMammal otherDomesticMammal,
        boolean isAngry) {
        printSoundInWords("Nicker ", times, otherDomesticMammal, isAngry);
    }

    public void nicker() {
        printNicker(1, null, false);
    }

    public void nicker(int times) {
        printNicker(times, null, false);
    }

    public void nicker(int times, 
        VirtualDomesticMammal otherDomesticMammal) {
        printNicker(times, otherDomesticMammal, false);
    }

    public void nicker(int times, 
        VirtualDomesticMammal otherDomesticMammal, 
        boolean isAngry) {
        printNicker(times, otherDomesticMammal, isAngry);
    }

 @Override
 public void talk() {
 nicker();
 }
}

VirtualHorse类覆盖了从VirtualDomesticMammal继承的talk方法。代码只是调用了没有参数的nicker方法,因为马不会说话,它们会嘶叫。这个方法不会调用其超类中同名的方法;也就是说,我们没有使用super关键字来调用VirtualDomesticMammal中定义的talk方法。

提示

我们在方法声明之前使用@Override注解来通知 Java 9 编译器,该方法意在覆盖在超类中声明的同名方法。当我们覆盖方法时,添加这个注解并不是强制的,但是将其包括进去是一个好习惯,我们在覆盖方法时总是会使用它,因为它有助于防止错误。例如,如果我们在方法名和参数中写成了tak()而不是talk(),使用@Override注解会使 Java 9 编译器生成一个错误,因为标记为@Overridetalk方法未能成功覆盖其中一个超类中具有相同名称和参数的方法。

nicker方法被重载了四次,使用了不同的参数声明。以下几行展示了类体中包括的四个不同声明:

public void nicker()
public void nicker(int times) 
public void nicker(int times, 
    VirtualDomesticMammal otherDomesticMammal) 
public void nicker(int times, 
    VirtualDomesticMammal otherDomesticMammal, 
    boolean isAngry)

这样,我们可以根据提供的参数调用任何定义的nicker方法。这四个方法最终都会调用printNicker公共方法,使用不同的默认值来调用具有相同名称但未在nicker调用中提供的参数。该方法调用printSoundInWords公共方法,将"Nicker "作为soundInWords参数的值,并将其他参数设置为接收到的具有相同名称的参数。这样,printNicker方法根据指定的次数(times)、可选的目标虚拟家养哺乳动物(otherDomesticMammal)以及马是否生气(isAngry)来构建并打印嘶叫消息。

VirtualHorse类对neigh方法也使用了类似的方法。这个方法也被重载了四次,使用了不同的参数声明。以下几行展示了类体中包括的四个不同声明。它们使用了我们刚刚分析过的nicker方法的相同参数。

public void neigh()
public void neigh(int times) 
public void neigh(int times, 
    VirtualDomesticMammal otherDomesticMammal) 
public void neigh(int times, 
    VirtualDomesticMammal otherDomesticMammal, 
    boolean isAngry)

这样,我们可以根据提供的参数调用任何定义的neigh方法。这四种方法最终会使用不同的默认值调用printNeigh公共方法,这些默认值是与调用nicker时未提供的同名参数。该方法调用printSoundInWords公共方法,将"Neigh "作为soundInWords参数的值,并将其他参数设置为具有相同名称的接收参数。

测试你的知识

  1. 在 Java 9 中,一个子类:

  2. 继承其超类的所有构造函数。

  3. 不继承任何构造函数。

  4. 从其超类继承具有最大数量参数的构造函数。

  5. 我们可以声明抽象方法:

  6. 在任何类中。

  7. 只在抽象类中。

  8. 只在抽象类的具体子类中。

  9. 任何抽象类的具体子类:

  10. 必须为所有继承的抽象方法提供实现。

  11. 必须为所有继承的构造函数提供实现。

  12. 必须为所有继承的抽象字段提供实现。

  13. 以下哪行声明了一个名为Dog的抽象类,作为VirtualAnimal的子类:

  14. public abstract class Dog subclasses VirtualAnimal

  15. public abstract Dog subclasses VirtualAnimal

  16. public abstract class Dog extends VirtualAnimal

  17. 在方法声明之前指示 Java 9 编译器该方法意味着重写超类中同名方法的注解是:

  18. @Overridden

  19. @OverrideMethod

  20. @Override

总结

在本章中,您学习了抽象类和具体类之间的区别。我们学会了如何利用简单的继承来专门化基本抽象类。我们设计了许多类,从上到下使用链接的构造函数,不可变字段,可变字段和实例方法。

然后我们在 JShell 中编写了许多这些类,利用了 Java 9 提供的不同特性。我们重载了构造函数,重写和重载了实例方法,并利用了一个特殊的注解来重写方法。

现在您已经了解了继承,抽象,扩展和专门化,我们准备完成编写其他类,并了解如何使用类型转换和多态,这是我们将在下一章讨论的主题。

第七章:成员继承和多态

在本章中,我们将学习 Java 9 中面向对象编程最激动人心的特性之一:多态。我们将编写许多类,然后在 JShell 中使用它们的实例,以了解对象如何呈现许多不同的形式。我们将:

  • 创建从抽象超类继承的具体类

  • 使用子类的实例进行操作

  • 理解多态。

  • 控制子类是否可以覆盖成员

  • 控制类是否可以被子类化

  • 使用执行与不同子类实例的操作的方法

创建从抽象超类继承的具体类

在上一章中,我们创建了一个名为VirtualAnimal的抽象基类,然后编写了以下三个抽象子类:VirtualMammalVirtualDomesticMammalVirtualHorse。现在,我们将编写以下三个具体类。每个类代表不同的马种,是VirtualHorse抽象类的子类。

  • AmericanQuarterHorse: 这个类表示属于美国四分之一马品种的虚拟马。

  • ShireHorse: 这个类表示属于夏尔马品种的虚拟马。

  • Thoroughbred: 这个类表示属于纯种赛马品种的虚拟马。

这三个具体类将实现它们从抽象超类继承的以下三个抽象方法:

  • String getAsciiArt(): 这个抽象方法是从VirtualAnimal抽象类继承的。

  • String getBaby(): 这个抽象方法是从VirtualAnimal抽象类继承的。

  • String getBreed(): 这个抽象方法是从VirtualHorse抽象类继承的。

以下 UML 图表显示了我们将编写的三个具体类AmericanQuarterHorseShireHorseThoroughbred的成员:我们不使用粗体文本格式来表示这三个具体类将声明的三个方法,因为它们不是覆盖方法;它们是实现类继承的抽象方法。

创建从抽象超类继承的具体类

首先,我们将创建AmericanQuarterHorse具体类。以下行显示了 Java 9 中此类的代码。请注意,在class之前没有abstract关键字,因此,我们的类必须确保实现所有继承的抽象方法。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

public class AmericanQuarterHorse extends VirtualHorse {
    public AmericanQuarterHorse(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("AmericanQuarterHorse created.");
    }

    public AmericanQuarterHorse(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public String getBaby() {
        return "AQH baby ";
    }

    public String getBreed() {
        return "American Quarter Horse";
    }

    public String getAsciiArt() {
        return
            "     >>\\.\n" +
            "    /*  )`.\n" + 
            "   // _)`^)`.   _.---. _\n" +
            "  (_,' \\  `^-)''      `.\\\n" +
            "        |              | \\\n" +
            "        \\              / |\n" +
            "       / \\  /.___.'\\  (\\ (_\n" +
            "      < ,'||     \\ |`. \\`-'\n" +
            "       \\\\ ()      )|  )/\n" +
            "       |_>|>     /_] //\n" +
            "         /_]        /_]\n";
    }
}

现在我们将创建ShireHorse具体类。以下行显示了 Java 9 中此类的代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

public class ShireHorse extends VirtualHorse {
    public ShireHorse(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("ShireHorse created.");
    }

    public ShireHorse(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public String getBaby() {
        return "ShireHorse baby ";
    }

    public String getBreed() {
        return "Shire Horse";
    }

    public String getAsciiArt() {
        return
            "                        ;;\n" + 
            "                      .;;'*\\\n" + 
            "           __       .;;' ' \\\n" +
            "         /'  '\\.~~.~' \\ /'\\.)\n" +
            "      ,;(      )    /  |\n" + 
            "     ,;' \\    /-.,,(   )\n" +
            "          ) /|      ) /|\n" +    
            "          ||(_\\     ||(_\\\n" +    
            "          (_\\       (_\\\n";
    }
}

最后,我们将创建Thoroughbred具体类。以下行显示了 Java 9 中此类的代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

public class Thoroughbred extends VirtualHorse {
    public Thoroughbred(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("Thoroughbred created.");
    }

    public Thoroughbred(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public String getBaby() {
        return "Thoroughbred baby ";
    }

    public String getBreed() {
        return "Thoroughbred";
    }

    public String getAsciiArt() {
        return
            "             })\\-=--.\n" +  
            "            // *._.-'\n" +
            "   _.-=-...-'  /\n" +
            " {{|   ,       |\n" +
            " {{\\    |  \\  /_\n" +
            " }} \\ ,'---'\\___\\\n" +
            " /  )/\\\\     \\\\ >\\\n" +
            "   //  >\\     >\\`-\n" +
            "  `-   `-     `-\n";
    }
}

在我们编码的其他子类中发生的情况,我们为这三个具体类定义了多个构造函数。第一个构造函数需要四个参数,使用super关键字调用基类或超类中的构造函数,也就是在VirtualHorse类中定义的构造函数。在超类中定义的构造函数执行完毕后,代码会打印一条消息,指示已创建了每个具体类的实例。每个类中定义的构造函数会打印不同的消息。

第二个构造函数使用this关键字调用先前解释的构造函数,并使用false作为isPregnant参数的值。

每个类在getBabygetBreed方法的实现中返回不同的String。此外,每个类在getAsciiArt方法的实现中返回虚拟马的不同 ASCII 艺术表示。

理解多态性

我们可以使用相同的方法,即使用相同名称和参数的方法,根据调用方法的类来引起不同的事情发生。在面向对象编程中,这个特性被称为多态性。多态性是对象能够呈现多种形式的能力,我们将通过使用先前编写的具体类的实例来看到它的作用。

以下几行创建了一个名为americanAmericanQuarterHorse类的新实例,并使用了一个不需要isPregnant参数的构造函数。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

AmericanQuarterHorse american = 
    new AmericanQuarterHorse(
        8, "American", "Equi-Spirit Ball");
american.printBreed();

以下几行显示了我们在 JShell 中输入前面的代码后,不同构造函数显示的消息:

VirtualAnimal created.
VirtualMammal created.
VirtualDomesticMammal created.
VirtualHorse created.
AmericanQuarterHorse created.

AmericanQuarterHorse中定义的构造函数调用了其超类的构造函数,即VirtualHorse类。请记住,每个构造函数都调用其超类构造函数,并打印一条消息,指示创建了类的实例。我们没有五个不同的实例;我们只有一个实例,它调用了五个不同类的链接构造函数,以执行创建AmericanQuarterHorse实例所需的所有必要初始化。

如果我们在 JShell 中执行以下几行,它们都会显示true,因为american属于VirtualAnimalVirtualMammalVirtualDomesticMammalVirtualHorseAmericanQuarterHorse类。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

System.out.println(american instanceof VirtualAnimal);
System.out.println(american instanceof VirtualMammal);
System.out.println(american instanceof VirtualDomesticMammal);
System.out.println(american instanceof VirtualHorse);
System.out.println(american instanceof AmericanQuarterHorse);

前面几行的结果意味着AmericanQuarterHorse类的实例,其引用保存在类型为AmericanQuarterHorseamerican变量中,可以采用以下任何一个类的实例形式:

  • 虚拟动物

  • 虚拟哺乳动物

  • 虚拟家养哺乳动物

  • 虚拟马

  • 美国四分之一马

以下屏幕截图显示了在 JShell 中执行前面几行的结果:

理解多态性

我们在VirtualHorse类中编写了printBreed方法,并且我们没有在任何子类中重写此方法。以下是printBreed方法的代码:

public void printBreed() {
    System.out.println(getBreed());
}

代码打印了getBreed方法返回的String,在同一类中声明为抽象方法。继承自VirtualHorse的三个具体类实现了getBreed方法,它们每个都返回不同的String。当我们调用american.printBreed方法时,JShell 显示American Quarter Horse

以下几行创建了一个名为zeldaShireHorse类的实例。请注意,在这种情况下,我们使用需要isPregnant参数的构造函数。与创建AmericanQuarterHorse类的实例时一样,JShell 将显示每个执行的构造函数的消息,这是由我们编写的链接构造函数的结果。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

ShireHorse zelda =
    new ShireHorse(9, true, 
        "Zelda", "Tennis Ball");

接下来的几行调用了americanAmericanQuarterHorse的实例)和zeldaShireHorse的实例)的printAverageNumberOfBabiesprintAsciiArt实例方法。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

american.printAverageNumberOfBabies();
american.printAsciiArt();
zelda.printAverageNumberOfBabies();
zelda.printAsciiArt();

我们在VirtualAnimal类中编写了printAverageNumberOfBabiesprintAsciiArt方法,并且没有在任何子类中对它们进行重写。因此,当我们为americanZelda调用这些方法时,Java 将执行VirtualAnimal类中定义的代码。

printAverageNumberOfBabies方法使用getAverageNumberOfBabies返回的int值和getBaby方法返回的String来生成代表虚拟动物平均幼崽数量的StringVirtualHorse类实现了继承的getAverageNumberOfBabies抽象方法,其中的代码返回1AmericanQuarterHorseShireHorse类实现了继承的getBaby抽象方法,其中的代码返回代表虚拟马种类的幼崽的String:"AQH baby"和"ShireHorse baby"。因此,我们对printAverageNumberOfBabies方法的调用将在每个实例中产生不同的结果,因为它们属于不同的类。

printAsciiArt方法使用getAsciiArt方法返回的String来打印代表虚拟马的 ASCII 艺术。AmericanQuarterHorseShireHorse类实现了继承的getAsciiArt抽象方法,其中的代码返回适用于每个类所代表的虚拟马的 ASCII 艺术的String。因此,我们对printAsciiArt方法的调用将在每个实例中产生不同的结果,因为它们属于不同的类。

以下屏幕截图显示了在 JShell 中执行前几行的结果。两个实例对在VirtualAnimal抽象类中编写的两个方法运行相同的代码。然而,每个类为最终被调用以生成结果并导致输出差异的方法提供了不同的实现。

理解多态性

以下行创建了一个名为willowThoroughbred类的实例,然后调用了它的printAsciiArt方法。与之前一样,JShell 将显示每个构造函数执行的消息,这是我们编写的链式构造函数的结果。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

Thoroughbred willow = 
    new Thoroughbred(5,
        "Willow", "Jolly Ball");
willow.printAsciiArt();

以下屏幕截图显示了在 JShell 中执行前几行的结果。新实例来自一个提供了getAsciiArt方法不同实现的类,因此,我们将看到与之前对其他实例调用相同方法时所看到的不同 ASCII 艺术。

理解多态性

以下行调用了名为willow的实例的neigh方法,使用不同数量的参数。这样,我们利用了使用不同参数重载了四次的neigh方法。请记住,我们在VirtualHorse类中编写了这四个neigh方法,而Thoroughbred类通过其继承树从这个超类继承了重载的方法。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_01.java文件中。

willow.neigh();
willow.neigh(2);
willow.neigh(2, american);
willow.neigh(3, zelda, true);
american.nicker();
american.nicker(2);
american.nicker(2, willow);
american.nicker(3, willow, true);

以下屏幕截图显示了在 JShell 中使用不同参数调用neighnicker方法的结果:

理解多态性

我们为名为willowThoroughbred实例调用了VirtualHorse类中定义的neigh方法的四个版本。调用neigh方法的第三行和第四行指定了类型为VirtualDomesticMammalotherDomesticMammal参数的值。第三行指定american作为otherDomesticMammal的值,第四行指定相同参数的值为zeldaAmericanQuarterHorseShireHorse具体类都是VirtualHorse的子类,VirtualHorseVirtualDomesticMammal的子类。因此,我们可以在需要VirtualDomesticMammal实例的地方使用americanzelda作为参数。

然后,我们为名为americanAmericanQuarterHorse实例调用了VirtualHorse类中定义的nicker方法的四个版本。调用nicker方法的第三行和第四行指定了类型为VirtualDomesticMammalotherDomesticMammal参数的值为willowThoroughbred具体类也是VirtualHorse的子类,VirtualHorseVirtualDomesticMammal的子类。因此,我们可以在需要VirtualDomesticMammal实例的地方使用willow作为参数。

控制子类中成员的可覆盖性

我们将编写VirtualDomesticCat抽象类及其具体子类:MaineCoon。然后,我们将编写VirtualBird抽象类、其VirtualDomesticBird抽象子类和Cockatiel具体子类。最后,我们将编写VirtualDomesticRabbit具体类。在编写这些类时,我们将使用 Java 9 的功能,允许我们决定子类是否可以覆盖特定成员。

所有虚拟家猫都必须能够说话,因此,我们将覆盖从VirtualDomesticMammal继承的talk方法,以打印代表猫叫声的单词:“"Meow"”。我们还希望提供一个方法来指定打印"Meow"的次数。因此,此时我们意识到我们可以利用在VirtualHorse类中声明的printSoundInWords方法。

我们无法在VirtualDomesticCat抽象类中访问此实例方法,因为它不是从VirtualHorse继承的。因此,我们将把这个方法从VirtualHorse类移动到它的超类:VirtualDomesticMammal

提示

我们将在不希望在子类中被覆盖的方法的返回类型前使用final关键字。当一个方法被标记为最终方法时,子类无法覆盖该方法,如果它们尝试这样做,Java 9 编译器将显示错误。

并非所有的鸟类在现实生活中都能飞。然而,我们所有的虚拟鸟类都能飞,因此,我们将实现继承的isAbleToFly抽象方法作为一个返回true的最终方法。这样,我们确保所有继承自VirtualBird抽象类的类都将始终运行此代码以进行isAbleToFly方法,并且它们将无法对其进行覆盖。

以下 UML 图显示了我们将编写的新抽象和具体类的成员。此外,该图显示了从VirtualHorse抽象类移动到VirtualDomesticMammal抽象类的printSoundInWords方法。

控制子类中成员的可覆盖性

首先,我们将创建VirtualDomesticMammal抽象类的新版本。我们将添加在VirtualHorse抽象类中的printSoundInWords方法,并使用final关键字指示我们不希望允许子类覆盖此方法。以下行显示了VirtualDomesticMammal类的新代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public abstract class VirtualDomesticMammal extends VirtualMammal {
    public final String name;
    public String favoriteToy;

    public VirtualDomesticMammal(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant);
        this.name = name;
        this.favoriteToy = favoriteToy;
        System.out.println("VirtualDomesticMammal created.");
    }

    public VirtualDomesticMammal(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

 protected final void printSoundInWords(
 String soundInWords, 
 int times, 
 VirtualDomesticMammal otherDomesticMammal,
 boolean isAngry) {
        String message = String.format("%s%s: %s%s",
            name,
            otherDomesticMammal == null ? 
                "" : String.format(" to %s ", otherDomesticMammal.name),
            isAngry ?
                "Angry " : "",
            new String(new char[times]).replace("\0", soundInWords));
        System.out.println(message);
    }

    public void talk() {
        System.out.println(
            String.format("%s: says something", name));
    }
}

在输入上述行后,JShell 将显示以下消息:

|    update replaced class VirtualHorse which cannot be referenced until this error is corrected:
|      printSoundInWords(java.lang.String,int,VirtualDomesticMammal,boolean) in VirtualHorse cannot override printSoundInWords(java.lang.String,int,VirtualDomesticMammal,boolean) in VirtualDomesticMammal
|        overridden method is final
|          protected void printSoundInWords(String soundInWords, int times,
|          ^---------------------------------------------------------------...
|    update replaced class AmericanQuarterHorse which cannot be referenced until class VirtualHorse is declared
|    update replaced class ShireHorse which cannot be referenced until class VirtualHorse is declared
|    update replaced class Thoroughbred which cannot be referenced until class VirtualHorse is declared
|    update replaced variable american which cannot be referenced until class AmericanQuarterHorse is declared
|    update replaced variable zelda which cannot be referenced until class ShireHorse is declared
|    update replaced variable willow which cannot be referenced until class Thoroughbred is declared
|    update overwrote class VirtualDomesticMammal

JShell 告诉我们,VirtualHorse类及其子类在我们纠正该类的错误之前不能被引用。该类声明了printSoundInWords方法,并在VirtualDomesticMammal类中重写了最近添加的具有相同名称和参数的方法。我们在新声明中使用了final关键字,以确保任何子类都不能覆盖它,因此,Java 编译器生成了 JShell 显示的错误消息。

现在,我们将创建VirtualHorse抽象类的新版本。以下行显示了删除了printSoundInWords方法并使用final关键字确保许多方法不能被任何子类覆盖的新版本。在下面的行中,使用final关键字避免方法被覆盖的声明已经被突出显示。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public abstract class VirtualHorse extends VirtualDomesticMammal {
    public VirtualHorse(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("VirtualHorse created.");        
    }

    public VirtualHorse(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

 public final boolean isAbleToFly() {
        return false;
    }

 public final boolean isRideable() {
        return true;
    }

 public final boolean isHerbivore() {
        return true;
    }

 public final boolean isCarnivore() {
        return false;
    }

    public int getAverageNumberOfBabies() {
        return 1;
    }

    public abstract String getBreed();

 public final void printBreed() {
        System.out.println(getBreed());
    }

 public final void printNeigh(
 int times, 
 VirtualDomesticMammal otherDomesticMammal,
 boolean isAngry) {
        printSoundInWords("Neigh ", times, otherDomesticMammal, isAngry);
    }

 public final void neigh() {
        printNeigh(1, null, false);
    }

 public final void neigh(int times) {
        printNeigh(times, null, false);
    }

 public final void neigh(int times, 
 VirtualDomesticMammal otherDomesticMammal) {
        printNeigh(times, otherDomesticMammal, false);
    }

 public final void neigh(int times, 
 VirtualDomesticMammal otherDomesticMammal, 
 boolean isAngry) {
        printNeigh(times, otherDomesticMammal, isAngry);
    }

 public final void printNicker(int times, 
 VirtualDomesticMammal otherDomesticMammal,
 boolean isAngry) {
        printSoundInWords("Nicker ", times, otherDomesticMammal, isAngry);
    }

 public final void nicker() {
        printNicker(1, null, false);
    }

 public final void nicker(int times) {
        printNicker(times, null, false);
    }

 public final void nicker(int times, 
 VirtualDomesticMammal otherDomesticMammal) {
        printNicker(times, otherDomesticMammal, false);
    }

 public final void nicker(int times, 
 VirtualDomesticMammal otherDomesticMammal, 
 boolean isAngry) {
        printNicker(times, otherDomesticMammal, isAngry);
    }

 @Override
 public final void talk() {
        nicker();
    }
}

输入上述行后,JShell 将显示以下消息:

|    update replaced class AmericanQuarterHorse
|    update replaced class ShireHorse
|    update replaced class Thoroughbred
|    update replaced variable american, reset to null
|    update replaced variable zelda, reset to null
|    update replaced variable willow, reset to null
|    update overwrote class VirtualHorse

我们替换了VirtualHorse类的定义,并且子类也已更新。重要的是要知道,在 JShell 中声明的变量,它们持有VirtualHorse的子类实例的引用被设置为 null。

控制类的子类化

final关键字有一个额外的用法。我们可以在类声明中的class关键字之前使用final作为修饰符,告诉 Java 我们要生成一个final 类,即一个不能被扩展或子类化的类。Java 9 不允许我们为 final 类创建子类。

现在,我们将创建VirtualDomesticCat抽象类,然后我们将声明一个名为MaineCoon的具体子类作为 final 类。这样,我们将确保没有人能够创建MaineCoon的子类。以下行显示了VirtualDomesticCat抽象类的代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public abstract class VirtualDomesticCat extends VirtualDomesticMammal {
    public VirtualDomesticCat(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("VirtualDomesticCat created.");        
    }

    public VirtualDomesticCat(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public final boolean isAbleToFly() {
        return false;
    }

    public final boolean isRideable() {
        return false;
    }

    public final boolean isHerbivore() {
        return false;
    }

    public final boolean isCarnivore() {
        return true;
    }

    public int getAverageNumberOfBabies() {
        return 5;
    }

    public final void printMeow(int times) {
        printSoundInWords("Meow ", times, null, false);
    }

    @Override
    public final void talk() {
        printMeow(1);
    }
}

VirtualDomesticCat抽象类将从VirtualDomesticMammal超类继承的许多抽象方法实现为 final 方法,并用 final 方法重写了talk方法。因此,我们将无法创建一个覆盖isAbleToFly方法返回trueVirtualDomesticCat子类。我们将无法拥有能够飞行的虚拟猫。

以下行显示了从VirtualDomesticCat继承的MaineCoon具体类的代码。我们将MaineCoon声明为 final 类,并且它重写了继承的getAverageNumberOfBabies方法以返回6。此外,该 final 类实现了以下继承的抽象方法:getBabygetAsciiArt。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public final class MaineCoon extends VirtualDomesticCat {
    public MaineCoon(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("MaineCoon created.");        
    }

    public MaineCoon(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public String getBaby() {
        return "Maine Coon baby ";
    }

    @Override
    public int getAverageNumberOfBabies() {
        return 6;
    }

    public String getAsciiArt() {
        return
            "  ^_^\n" + 
            " (*.*)\n" +
            "  |-|\n" +
            " /   \\\n";
    }
}

提示

我们没有将任何方法标记为final,因为在 final 类中的所有方法都是隐式 final 的。

然而,当我们在 JShell 之外运行 Java 代码时,final 类将被创建,我们将无法对其进行子类化。

现在,我们将创建从VirtualAnimal继承的VirtualBird抽象类。以下行显示了VirtualBird抽象类的代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public abstract class VirtualBird extends VirtualAnimal {
    public String feathersColor;

    public VirtualBird(int age, String feathersColor) {
        super(age);
        this.feathersColor = feathersColor;
        System.out.println("VirtualBird created.");
    }

    public final boolean isAbleToFly() {
        // Not all birds are able to fly in real-life
        // However, all our virtual birds are able to fly
        return true;
    }

}

VirtualBird抽象类继承了先前声明的VirtualAnimal抽象类的成员,并添加了一个名为feathersColor的新的可变的String字段。新的抽象类声明了一个构造函数,该构造函数需要agefeathersColor的初始值来创建类的实例。构造函数使用super关键字调用来自基类或超类的构造函数,即在VirtualAnimal类中定义的构造函数,该构造函数需要age参数。在超类中定义的构造函数执行完毕后,代码设置了feathersColor可变字段的值,并打印了一条消息,指示已创建了一个虚拟鸟类。

VirtualBird抽象类实现了继承的isAbleToFly方法作为一个最终方法,返回true。我们希望确保我们应用程序领域中的所有虚拟鸟都能飞。

现在,我们将创建从VirtualBird继承的VirtualDomesticBird抽象类。以下行显示了VirtualDomesticBird抽象类的代码。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public abstract class VirtualDomesticBird extends VirtualBird {
    public final String name;

    public VirtualDomesticBird(int age, 
        String feathersColor, 
        String name) {
        super(age, feathersColor);
        this.name = name;
        System.out.println("VirtualDomesticBird created.");
    }
}

VirtualDomesticBird抽象类继承了先前声明的VirtualBird抽象类的成员,并添加了一个名为name的新的不可变的String字段。新的抽象类声明了一个构造函数,该构造函数需要agefeathersColorname的初始值来创建类的实例。构造函数使用super关键字调用来自超类的构造函数,即在VirtualBird类中定义的构造函数,该构造函数需要agefeathersColor参数。在超类中定义的构造函数执行完毕后,代码设置了name不可变字段的值,并打印了一条消息,指示已创建了一个虚拟家禽。

以下行显示了从VirtualDomesticBird继承的Cockatiel具体类的代码。我们将Cockatiel声明为最终类,并实现以下继承的抽象方法:isRideableisHerbivoreisCarnivoregetAverageNumberOfBabiesgetBabygetAsciiArt。如前所述,最终类中的所有方法都是隐式最终的。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public final class Cockatiel extends VirtualDomesticBird {
    public Cockatiel(int age, 
        String feathersColor, String name) {
        super(age, feathersColor, name);
        System.out.println("Cockatiel created.");
    }

    public boolean isRideable() {
        return true;
    }

    public boolean isHerbivore() {
        return true;
    }

    public boolean isCarnivore() {
        return true;
    }

    public int getAverageNumberOfBabies() {
        return 4;
    }

    public String getBaby() {
        return "Cockatiel baby ";
    }

    public String getAsciiArt() {
        return
            "     ///\n" +
            "      .////.\n" +
            "      //   //\n" +
            "      \\ (*)\\\n" +
            "      (/    \\\n" +
            "       /\\    \\\n" +
            "      ///     \\\\\n" +
            "     ///|     |\n" +
            "    ////|     |\n" +
            "   //////    /\n" +
            "  ////  \\   \\\n" +
            "  \\\\    ^    ^\n" +
            "   \\\n" +
            "    \\\n";
    }
}

以下行显示了从VirtualDomesticMammal继承的VirtualDomesticRabbit具体类的代码。我们将VirtualDomesticRabbit声明为最终类,因为我们不希望有额外的子类。我们只会在我们的应用程序领域中有一种虚拟家兔。最终类实现了以下继承的抽象方法:isAbleToFlyisRideableisHerbivoreisCarnivoregetAverageNumberOfBabiesgetBabygetAsciiArt。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

public final class VirtualDomesticRabbit extends VirtualDomesticMammal {
    public VirtualDomesticRabbit(
        int age, 
        boolean isPregnant, 
        String name, 
        String favoriteToy) {
        super(age, isPregnant, name, favoriteToy);
        System.out.println("VirtualDomesticRabbit created.");        
    }

    public VirtualDomesticRabbit(
        int age, String name, String favoriteToy) {
        this(age, false, name, favoriteToy);
    }

    public final boolean isAbleToFly() {
        return false;
    }

    public final boolean isRideable() {
        return false;
    }

    public final boolean isHerbivore() {
        return true;
    }

    public final boolean isCarnivore() {
        return false;
    }

    public int getAverageNumberOfBabies() {
        return 6;
    }

    public String getBaby() {
        return "Rabbit baby ";
    }

    public String getAsciiArt() {
        return
            "   /\\ /\\\n" + 
            "   \\ V /\n" + 
            "   | **)\n" + 
            "   /  /\n" + 
            "  /  \\_\\_\n" + 
            "*(__\\_\\\n";
    }
}

注意

JShell 忽略final修饰符,因此,使用final修饰符声明的类将允许在 JShell 中存在子类。

创建与不同子类实例一起工作的方法

在声明所有新类之后,我们将创建以下两个方法,这两个方法接收一个VirtualAnimal实例作为参数,即VirtualAnimal实例或VirtualAnimal的任何子类的实例。每个方法调用VirtualAnimal类中定义的不同实例方法:printAverageNumberOfBabiesprintAsciiArg。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

void printBabies(VirtualAnimal animal) {
    animal.printAverageNumberOfBabies();
}

void printAsciiArt(VirtualAnimal animal) {
    animal.printAsciiArt();
}

然后以下行创建了下列类的实例:CockatielVirtualDomesticRabbitMaineCoon。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

Cockatiel tweety = 
    new Cockatiel(3, "White", "Tweety");
VirtualDomesticRabbit bunny = 
    new VirtualDomesticRabbit(2, "Bunny", "Sneakers");
MaineCoon garfield = 
    new MaineCoon(3, "Garfield", "Lassagna");

以下截图显示了在 JShell 中执行先前行的结果。在我们输入代码创建每个实例后,我们将看到不同构造函数在 JShell 中显示的消息。这些消息将帮助我们轻松理解 Java 在创建每个实例时调用的所有链接构造函数。

创建与不同子类实例一起工作的方法

然后,以下行调用了printBabiesprintAsciiArt方法,并将先前创建的实例作为参数传递。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

System.out.println(tweety.name);
printBabies(tweety);
printAsciiArt(tweety);

System.out.println(bunny.name);
printBabies(bunny);
printAsciiArt(bunny);

System.out.println(garfield.name);
printBabies(garfield);
printAsciiArt(garfield);

这三个实例成为不同方法的VirtualAnimal参数,即它们采用VirtualAnimal实例的形式。然而,字段和方法使用的值并非在VirtualAnimal类中声明的。对printAverageNumberOfBabiesprintAsciiArt实例方法的调用考虑了所有在子类中声明的成员,因为每个实例都是VirtualAnimal的子类的实例:

提示

接受VirtualAnimal实例作为参数的printBabiesprintAsciiArt方法只能访问为它们接收的实例在VirtualAnimal类中定义的成员,因为参数类型是VirtualAnimal。如果需要,我们可以解开接收到的animal参数中的CockatielVirtualDomesticRabbitMaineCoon实例。然而,随着我们涵盖更高级的主题,我们将在以后处理这些情景。

以下截图显示了在 JShell 中为名为tweetyCockatiel实例执行先前行的结果。

创建与不同子类实例一起工作的方法

以下截图显示了在 JShell 中为名为bunnyVirtualDomesticRabbit实例执行先前行的结果。

创建与不同子类实例一起工作的方法

以下截图显示了在 JShell 中为名为garfieldMaineCoon实例执行先前行的结果。

创建与不同子类实例一起工作的方法

现在我们将创建另一个方法,该方法接收一个VirtualDomesticMammal实例作为参数,即VirtualDomesticMammal实例或VirtualDomesticMammal的任何子类的实例。以下函数调用了在VirtualDomesticMammal类中定义的talk实例方法。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

void makeItTalk(VirtualDomesticMammal domestic) {
    domestic.talk();
}

然后,以下两行调用了makeItTalk方法,并将VirtualDomesticRabbitMaineCoon实例作为参数:bunnygarfield。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

makeItTalk(bunny);
makeItTalk(garfield);

对接收到的VirtualDomesticMammal实例调用相同方法会产生不同的结果。VirtualDomesticRabbit没有覆盖继承的talk方法,而MaineCoon类继承了在VirtualDomesticCat抽象类中被覆盖的talk方法,使家猫发出喵喵的声音。以下截图显示了在 JShell 中进行的两个方法调用的结果。

创建与不同子类实例一起工作的方法

VirtualAnimal抽象类声明了两个实例方法,允许我们确定虚拟动物是比另一个虚拟动物更年轻还是更年长:isYoungerThanisOlderThan。这两个方法接收一个VirtualAnimal参数,并返回在实例的age值和接收实例的age值之间应用运算符的结果。

以下行调用printAge方法的三个实例:tweetybunnygarfield。此方法在VirtualAnimal类中声明。然后,下一行调用isOlderThanisYoungerThan方法,并将这些实例作为参数,以显示比较不同实例年龄的结果。示例的代码文件包含在java_9_oop_chapter_07_01文件夹中的example07_02.java文件中。

tweety.printAge();
bunny.printAge();
garfield.printAge();
tweety.isOlderThan(bunny);
garfield.isYoungerThan(tweety);
bunny.isYoungerThan(garfield);

以下屏幕截图显示了在 JShell 中执行前面行的结果:

创建可以与不同子类的实例一起工作的方法

测试您的知识

  1. 以下哪行声明了一个实例方法,不能在任何子类中被覆盖:

  2. public void talk(): final {

  3. public final void talk() {

  4. public notOverrideable void talk() {

  5. 我们有一个名为Shape的抽象超类。Circle类是Shape的子类,是一个具体类。如果我们创建一个名为circleCircle实例,这个实例也将是:

  6. Shape的一个实例。

  7. Circle的子类。

  8. Circle的一个抽象超类。

  9. 在 UML 图中,使用斜体文本格式的类名表示它们是:

  10. 具体类。

  11. 覆盖了至少一个从其超类继承的成员的具体类。

  12. 抽象类。

  13. 以下哪行声明了一个不能被子类化的类:

  14. public final class Dog extends VirtualAnimal {

  15. public final class Dog subclasses VirtualAnimal {

  16. public final Dog subclasses VirtualAnimal {

  17. 以下哪行声明了一个名为Circle的具体类,可以被子类化,其超类是Shape抽象类:

  18. public final class Shape extends Circle {

  19. public class Shape extends Circle {

  20. public concrete class Shape extends Circle {

总结

在本章中,我们创建了许多抽象和具体类。我们学会了控制子类是否可以覆盖成员,以及类是否可以被子类化。

我们使用了许多子类的实例,并且了解到对象可以采用许多形式。我们在 JShell 中使用了许多实例及其方法,以了解我们编写的类和方法是如何执行的。我们使用了执行与具有共同超类的不同类的实例的操作的方法。

现在您已经了解了成员继承和多态性,我们准备在 Java 9 中使用接口进行契约编程,这是我们将在下一章中讨论的主题。

第八章:接口的契约编程

在本章中,我们将处理复杂的场景,在这些场景中,我们将不得不使用属于多个蓝图的实例。我们将利用接口来进行契约编程。我们将:

  • 了解 Java 9 中的接口

  • 了解接口与类结合的工作原理

  • 在 Java 9 中声明接口

  • 声明实现接口的类

  • 利用接口的多重继承

  • 将类继承与接口结合

了解接口与类结合的工作原理

假设我们必须开发一个 Web 服务,在其中我们必须处理两种不同类型的角色:漫画角色和游戏角色。

漫画角色必须在漫画中可绘制。漫画角色必须能够提供昵称并执行以下任务:

  • 绘制一个带有消息的语音气泡,也称为语音气泡

  • 绘制一个带有消息的思想气泡,也称为思想气泡

  • 绘制带有消息的语音气泡和另一个漫画角色,在漫画中可绘制,作为目标。

游戏角色必须在游戏场景中可绘制。游戏角色必须能够提供全名和当前得分。此外,游戏角色必须能够执行以下任务:

  • 将其所需的位置设置为由xy坐标指示的特定 2D 位置

  • 为其x坐标提供值

  • 为其y坐标提供值

  • 在当前位置绘制自身

  • 检查它是否与另一个游戏角色相交,在游戏场景中可绘制

我们必须能够处理既是漫画角色又是游戏角色的对象;也就是说,它们既可以在漫画中绘制,也可以在游戏场景中绘制。然而,我们还将处理只是漫画或游戏角色的对象;也就是说,它们可以在漫画中绘制或在游戏场景中绘制。

我们不想编写执行先前描述的任务的通用方式。我们希望确保许多类能够通过一个公共接口执行这些任务。在漫画中声明自己为可绘制的每个对象必须定义与语音和思想气泡相关的任务。在游戏场景中声明自己为可绘制的每个对象必须定义如何设置其所需的 2D 位置,绘制自身,并检查它是否与另一个游戏角色相交,在游戏场景中可绘制。

SpiderDog是一种漫画角色,在漫画中可绘制,具有特定的绘制语音和思想气泡的方式。WonderCat既是漫画角色又是游戏角色,在漫画中可绘制,也在游戏场景中可绘制。因此,WonderCat 必须定义两种角色类型所需的所有任务。

WonderCat 是一个非常多才多艺的角色,它可以使用不同的服装参与游戏或漫画,并具有不同的名称。WonderCat 还可以是可隐藏的、可供能力的或可战斗的:

  • 可隐藏的角色能够被隐藏。它可以提供特定数量的眼睛,并且必须能够显示和隐藏自己。

  • 可供能力的角色能够被赋予能力。它可以提供一个法术能力分数值,并使用这个法术能力使一个可隐藏的角色消失。

  • 可战斗的角色能够战斗。它有一把剑,并且可以提供剑的力量和重量值。此外,可战斗的角色可以在有或没有可隐藏的角色作为目标时拔出剑。

假设 Java 9 支持多重继承。我们需要基本蓝图来表示漫画角色和游戏角色。然后,代表这些类型角色的每个类都可以提供其方法的实现。在这种情况下,漫画和游戏角色非常不同,它们不执行可能导致混乱和问题的相似任务,因此多重继承不方便。因此,我们可以使用多重继承来创建一个WonderCat类,该类实现了漫画和游戏角色的蓝图。在某些情况下,多重继承不方便,因为相似的蓝图可能具有相同名称的方法,并且使用多重继承可能会非常令人困惑。

此外,我们可以使用多重继承将WonderCat类与HideablePowerableFightable结合在一起。这样,我们将有一个Hideable + WonderCat,一个Powerable + WonderCat,和一个Fightable + WonderCat。我们可以使用任何一个,Hideable + WonderCatPowerable + WonderCat,或Fightable + WonderCat,作为漫画或游戏角色。

我们的目标很简单,但我们面临一个小问题:Java 9 不支持类的多重继承。相反,我们可以使用接口进行多重继承,或者将接口与类结合使用。因此,我们将使用接口和类来满足我们之前的要求。

在前几章中,我们一直在使用抽象类和具体类。当我们编写抽象类时,我们声明了构造函数、实例字段、实例方法和抽象方法。抽象类中有具体的实例方法和抽象方法。

在这种情况下,我们不需要为任何方法提供实现;我们只需要确保我们提供了具有特定名称和参数的适当方法。您可以将接口视为一组相关的抽象方法,类必须实现这些方法才能被视为接口名称标识的类型的成员。Java 9 不允许我们在接口中指定构造函数或实例字段的要求。还要注意接口不是类。

注意

在其他编程语言中,接口被称为协议。

例如,我们可以创建一个Hideable接口,该接口指定以下无参数方法并具有空体:

  • getNumberOfEyes()

  • appear()

  • disappear()

一旦我们定义了一个接口,我们就创建了一个新类型。因此,我们可以使用接口名称来指定参数的所需类型。这样,我们将使用接口作为类型,而不是使用类作为类型,并且我们可以使用实现特定接口的任何类的实例作为参数。例如,如果我们使用Hideable作为参数的所需类型,我们可以将实现Hideable接口的任何类的实例作为参数传递。

提示

我们可以声明继承自多个接口的接口;也就是说,接口支持多重继承。

但是,您必须考虑接口与抽象类相比的一些限制。接口不能指定构造函数或实例字段的要求,因为接口与方法和签名有关。接口可以声明对以下成员的要求:

  • 类常量

  • 静态方法

  • 实例方法

  • 默认方法

  • 嵌套类型

注意

Java 8 增加了向接口添加默认方法的可能性。它们允许我们声明实际提供实现的方法。Java 9 保留了这一特性。

声明接口

现在是时候在 Java 9 中编写必要的接口了。我们将编写以下五个接口:

  • DrawableInComic

  • DrawableInGame

  • Hideable

  • Powerable

  • Fightable

提示

一些编程语言,比如 C#,使用I作为接口的前缀。Java 9 不使用这种接口命名约定。因此,如果你看到一个名为IDrawableInComic的接口,那可能是由有 C#经验的人编写的,并将命名约定转移到了 Java 领域。

以下的 UML 图表显示了我们将要编码的五个接口,其中包括在图表中的必需方法。请注意,在声明接口的每个图表中,我们在类名前包含了<>文本。

声明接口

以下行显示了DrawableInComic接口的代码。public修饰符,后跟interface关键字和接口名DrawableInComic,构成了接口声明。与类声明一样,接口体被括在大括号({})中。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中,名为example08_01.java

public interface DrawableInComic {
    String getNickName();
    void drawSpeechBalloon(String message);
    void drawSpeechBalloon(DrawableInComic destination, String message);
    void drawThoughtBalloon(String message);
}

提示

接口中声明的成员具有隐式的public修饰符,因此不需要为每个方法声明指定public

DrawableInComic接口声明了一个getNickName方法要求,两次重载的drawSpeechBalloon方法要求,以及一个drawThoughtBalloon方法要求。该接口只包括方法声明,因为实现DrawableInComic接口的类将负责提供getNickName方法、drawThoughtBalloon方法和drawSpeechBalloon方法的两个重载的实现。请注意,没有方法体,就像我们为抽象类声明抽象方法时一样。不需要使用abstract关键字来声明这些方法,因为它们是隐式抽象的。

以下行显示了DrawableInGame接口的代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中,名为example08_01.java

public interface DrawableInGame {
    String getFullName();
    int getScore();
    int getX();
    int getY();
    void setLocation(int x, int y);
    void draw();
    boolean isIntersectingWith(DrawableInGame otherDrawableInGame);
}

DrawableInGame接口声明包括七个方法要求:getFullNamegetScoregetXgetYsetLocationdrawisIntersectingWith

以下行显示了Hideable接口的代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中,名为example08_01.java

public interface Hideable {
    int getNumberOfEyes();
    void show();
    void hide();
}

Hideable接口声明包括三个方法要求:getNumberOfEyesshowhide

以下行显示了Powerable接口的代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中,名为example08_01.java

public interface Powerable {
    int getSpellPower();
    void useSpellToHide(Hideable hideable);
}

Powerable接口声明包括两个方法要求:getSpellPoweruseSpellToHide。与先前声明的接口中包含的其他方法要求一样,在方法声明中,我们使用接口名作为方法声明中参数的类型。在这种情况下,useSpellToHide方法声明的hideable参数为Hideable。因此,我们将能够使用任何实现Hideable接口的类来调用该方法。

以下行显示了Fightable接口的代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中,名为example08_01.java

public interface Fightable {
    int getSwordPower();
    int getSwordWeight();
    void unsheathSword();
    void unsheathSword(Hideable hideable);
}

Fightable接口声明包括四个方法要求:getSwordPowergetSwordWeightunsheathSword方法的两个重载。

声明实现接口的类

现在,我们将在 JShell 中声明一个具体类,该类在其声明中指定实现DrawableInComic接口。类声明不包括超类,而是在类名(SiperDog)和implements关键字之后包括先前声明的DrawableInComic接口的名称。我们可以将类声明解读为“SpiderDog类实现DrawableInComic接口”。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_02.java文件中。

public class SpiderDog implements DrawableInComic {
}

Java 编译器将生成错误,因为SpiderDog类被声明为具体类,并且没有覆盖DrawableInComic接口中声明的所有抽象方法。JShell 显示以下错误,指示接口中的第一个方法声明没有被覆盖:

jshell> public class SpiderDog implements DrawableInComic {
 ...> }
|  Error:
|  SpiderDog is not abstract and does not override abstract method drawThoughtBalloon(java.lang.String) in DrawableInComic

现在,我们将用尝试实现DrawableInComic接口的类替换之前声明的空SuperDog类,但它仍未实现其目标。以下行显示了SuperDog类的新代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_03.java文件中。

public class SpiderDog implements DrawableInComic {
    protected final String nickName;

    public SpiderDog(String nickName) {
        this.nickName = nickName;
    }

    protected void speak(String message) {
        System.out.println(
            String.format("%s -> %s",
                nickName,
                message));
    }

    protected void think(String message) {
        System.out.println(
            String.format("%s -> ***%s***",
                nickName,
                message));
    }

    @Override
    String getNickName() {
        return nickName;
    }

    @Override
    void drawSpeechBalloon(String message) {
        speak(message);
    }

    @Override
    void drawSpeechBalloon(DrawableInComic destination, 
        String message) {
        speak(String.format("message: %s, %s",
            destination.getNickName(),
            message));
    }

    @Override
    void drawThoughtBalloon(String message) {
        think(message);
    }
}

Java 编译器将生成许多错误,因为SpiderDog具体类没有实现DrawableInComic接口。JShell 显示以下错误消息,指示接口需要许多方法声明为public方法。

|  Error:
|  drawThoughtBalloon(java.lang.String) in SpiderDog cannot implement drawThoughtBalloon(java.lang.String) in DrawableInComic
|    attempting to assign weaker access privileges; was public
|      @Override
|      ^--------...
|  Error:
|  drawSpeechBalloon(DrawableInComic,java.lang.String) in SpiderDog cannot implement drawSpeechBalloon(DrawableInComic,java.lang.String) in DrawableInComic
|    attempting to assign weaker access privileges; was public
|      @Override
|      ^--------...
|  Error:
|  drawSpeechBalloon(java.lang.String) in SpiderDog cannot implement drawSpeechBalloon(java.lang.String) in DrawableInComic
|    attempting to assign weaker access privileges; was public
|      @Override
|      ^--------...
|  Error:
|  getNickName() in SpiderDog cannot implement getNickName() in DrawableInComic
|    attempting to assign weaker access privileges; was public
|      @Override
|      ^--------...

公共DrawableInComic接口指定了隐式公共方法。因此,当我们声明一个类时,该类没有将所需成员声明为public时,Java 编译器会生成错误,并指出我们不能尝试分配比接口要求的更弱的访问权限。

注意

每当我们声明一个指定实现接口的类时,它必须满足接口中指定的所有要求。如果不满足,Java 编译器将生成错误,指示未满足哪些要求,就像在前面的示例中发生的那样。在使用接口时,Java 编译器确保实现接口的任何类都遵守其中指定的要求。

最后,我们将用真正实现DrawableInComic接口的类替换SpiderDog类的先前声明。以下行显示了SpiderDog类的新代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_04.java文件中。

public class SpiderDog implements DrawableInComic {
    protected final String nickName;

    public SpiderDog(String nickName) {
        this.nickName = nickName;
    }

    protected void speak(String message) {
        System.out.println(
            String.format("%s -> %s",
                nickName,
                message));
    }

    protected void think(String message) {
        System.out.println(
            String.format("%s -> ***%s***",
                nickName,
                message));
    }

    @Override
 public String getNickName() {
        return nickName;
    }

    @Override
 public void drawSpeechBalloon(String message) {
        speak(message);
    }

    @Override
 public void drawSpeechBalloon(DrawableInComic destination, 
 String message) {
        speak(String.format("message: %s, %s",
            destination.getNickName(),
            message));
    }

    @Override
 public void drawThoughtBalloon(String message) {
        think(message);
    }
}

SpiderDog类声明了一个构造函数,将所需的nickName参数的值分配给nickName不可变的受保护字段。该类实现了getNickName方法,该方法只返回nickName不可变的受保护字段。该类声明了两个版本的drawSpeechBalloon方法的代码。两种方法都调用受保护的speak方法,该方法打印一个包括nickName值作为前缀的特定格式的消息。此外,该类声明了drawThoughtBalloon方法的代码,该方法调用受保护的think方法,该方法也打印一个包括nickName值作为前缀的消息。

SpiderDog类实现了DrawableInComic接口中声明的方法。该类还声明了一个构造函数,一个protected的不可变字段和两个protected方法。

提示

只要我们实现了类声明中implements关键字后列出的接口中声明的所有成员,就可以向类添加任何所需的额外成员。

现在,我们将声明另一个类,该类实现了SpiderDog类实现的相同接口,即DrawableInComic接口。以下行显示了WonderCat类的代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_04.java文件中。

public class WonderCat implements DrawableInComic {
    protected final String nickName;
    protected final int age;

    public WonderCat(String nickName, int age) {
        this.nickName = nickName;
        this.age = age;
    }

    public int getAge() {
        return age;
    }

    @Override
 public String getNickName() {
        return nickName;
    }

    @Override
 public void drawSpeechBalloon(String message) {
        String meow = 
            (age > 2) ? "Meow" : "Meeoow Meeoow";
        System.out.println(
            String.format("%s -> %s",
                nickName,
                meow));
    }

    @Override
 public void drawSpeechBalloon(DrawableInComic destination, 
 String message) {
        System.out.println(
            String.format("%s ==> %s --> %s",
                destination.getNickName(),
                nickName,
                message));
    }

    @Override
 public void drawThoughtBalloon(String message) {
        System.out.println(
            String.format("%s thinks: '%s'",
                nickName,
                message));
    }
}

WonderCat类声明了一个构造函数,将所需的nickNameage参数的值分配给nickNameage不可变字段。该类声明了两个版本的drawSpeechBalloon方法的代码。只需要message参数的版本使用age属性的值,在age值大于2时生成不同的消息。此外,该类声明了drawThoughtBalloongetNickName方法的代码。

WonderCat类实现了DrawableInComic接口中声明的方法。但是,该类还声明了一个额外的不可变字段age和一个getAge方法,这些并不是接口所要求的。

提示

Java 9 中的接口允许我们确保实现它们的类定义接口中指定的所有成员。如果没有,代码将无法编译。

利用接口的多重继承

Java 9 不允许我们声明具有多个超类或基类的类,因此不支持类的多重继承。子类只能继承一个类。但是,一个类可以实现一个或多个接口。此外,我们可以声明从超类继承并实现一个或多个接口的类。因此,我们可以将基于类的继承与接口的实现结合起来。

我们希望WonderCat类实现DrawableInComicDrawableInGame接口。我们希望能够将任何WonderCat实例用作漫画角色和游戏角色。为了实现这一点,我们必须更改类声明,并将DrawableInGame接口添加到类实现的接口列表中,并在类中声明此添加接口中包含的所有方法。

以下行显示了新的类声明,指定WonderCat类实现DrawableInComicDrawableInGame接口。类主体保持不变,因此我们不重复代码。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_05.java文件中。

public class WonderCat implements 
    DrawableInComic, DrawableInGame {

更改类声明后,Java 编译器将生成许多错误,因为WonderCat具体类的新版本没有实现DrawableInGame接口。JShell 显示以下错误消息。

|  Error:
|  WonderCat is not abstract and does not override abstract method isIntersectingWith(DrawableInGame) in DrawableInGame
|  public class WonderCat implements
|  ^--------------------------------...

java_9_oop_chapter_08_01 folder, in the example08_06.java file.
public class WonderCat implements 
 DrawableInComic, DrawableInGame {
    protected final String nickName;
    protected final int age;
 protected int score;
 protected final String fullName;
 protected int x;
 protected int y;

 public WonderCat(String nickName, 
 int age, 
 String fullName, 
 int score, 
 int x, 
 int y) {
        this.nickName = nickName;
        this.age = age;
 this.fullName = fullName;
 this.score = score;
 this.x = x;
 this.y = y;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String getNickName() {
        return nickName;
    }

    @Override
    public void drawSpeechBalloon(String message) {
        String meow = 
            (age > 2) ? "Meow" : "Meeoow Meeoow";
        System.out.println(
            String.format("%s -> %s",
                nickName,
                meow));
    }

    @Override
    public void drawSpeechBalloon(DrawableInComic destination, 
        String message) {
        System.out.println(
            String.format("%s ==> %s --> %s",
                destination.getNickName(),
                nickName,
                message));
    }

    @Override
    public void drawThoughtBalloon(String message) {
        System.out.println(
            String.format("%s thinks: '%s'",
                nickName,
                message));
    }

 @Override
 public String getFullName() {
 return fullName;
 }

 @Override
 public int getScore() {
 return score;
 }

 @Override
 public int getX() {
 return x;
 }

 @Override
 public int getY() {
 return y;
 }

 @Override
 public void setLocation(int x, int y) {
 this.x = x;
 this.y = y;
 System.out.println(
 String.format("Moving WonderCat %s to x:%d, y:%d",
 fullName,
 this.x,
 this.y));
 }

 @Override
 public void draw() {
 System.out.println(
 String.format("Drawing WonderCat %s at x:%d, y:%d",
 fullName,
 x,
 y));
 }

 @Override
 public boolean isIntersectingWith(
 DrawableInGame otherDrawableInGame) {
 return ((x == otherDrawableInGame.getX()) &&
 (y == otherDrawableInGame.getY()));
 }
}

新的构造函数将额外需要的fullNamescorexy参数的值分配给同名的字段。因此,每当我们想要创建AngryCat类的实例时,我们将需要指定这些额外的参数。此外,该类添加了DrawableInGame接口中指定的所有方法的实现。

结合类继承和接口

我们可以将类继承与接口的实现结合起来。以下行显示了一个新的HideableWonderCat类的代码,它继承自WonderCat类并实现了Hideable接口。请注意,类声明在extends关键字后包括超类(WonderCat),在implements关键字后包括实现的接口(Hideable)。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_07.java文件中。

public class HideableWonderCat extends WonderCat implements Hideable {
    protected final int numberOfEyes;

    public HideableWonderCat(String nickName, int age, 
        String fullName, int score, 
        int x, int y, int numberOfEyes) {
        super(nickName, age, fullName, score, x, y);
        this.numberOfEyes = numberOfEyes;
    }

    @Override
    public int getNumberOfEyes() {
        return numberOfEyes;
    }

    @Override
    public void show() {
        System.out.println(
            String.format(
                "My name is %s and you can see my %d eyes.",
                getFullName(), 
                numberOfEyes));
    }

    @Override
    public void hide() {
        System.out.println(
            String.format(
                "%s is hidden.", 
                getFullName()));
    }
}

由于前面的代码,我们有了一个名为HideableWonderCat的新类,它实现了以下三个接口:

  • DrawableInComic:这个接口由WonderCat超类实现,并被HideableWonderCat继承

  • DrawableInGame:这个接口由WonderCat超类实现,并被HideableWonderCat继承

  • Hideable:这个接口由HideableWonderCat实现

HideableWonderCat类中定义的构造函数在构造函数中添加了一个numberOfEyes参数,该参数在WonderCat超类中声明的参数列表中。在这种情况下,构造函数使用super关键字调用超类中定义的构造函数,然后使用接收到的numberOfEyes参数初始化numberOfEyes不可变字段。该类实现了Hideable接口所需的getNumberOfEyesshowhide方法。

以下几行显示了一个新的PowerableWonderCat类的代码,该类继承自WonderCat类并实现了Powerable接口。请注意,类声明在extends关键字后包括超类(WonderCat),在implements关键字后包括实现的接口(Powerable)。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_07.java文件中。

public class PowerableWonderCat extends WonderCat implements Powerable {
    protected final int spellPower;

    public PowerableWonderCat(String nickName, 
        int age, 
        String fullName, 
        int score, 
        int x, 
        int y, 
        int spellPower) {
        super(nickName, age, fullName, score, x, y);
        this.spellPower = spellPower;
    }

    @Override
    public int getSpellPower() {
        return spellPower;
    }

    @Override
    public void useSpellToHide(Hideable hideable) {
        System.out.println(
            String.format(
                "%s uses his %d spell power to hide the Hideable with %d eyes.",
                getFullName(),
                spellPower,
                hideable.getNumberOfEyes()));
    }
}

就像HideableWonderCat类一样,新的PowerableWonderCat类实现了三个接口。其中两个接口由WonderCat超类实现,并被HideableWonderCat继承:DrawableInComicDrawableInGameHideableWonderCat类添加了Powerable接口的实现。

PowerableWonderCat类中定义的构造函数在构造函数中添加了一个spellPower参数,该参数在WonderCat超类中声明的参数列表中。在这种情况下,构造函数使用super关键字调用超类中定义的构造函数,然后使用接收到的spellPower参数初始化spellPower不可变字段。该类实现了Powerable接口所需的getSpellPoweruseSpellToHide方法。

hide方法接收一个Hideable作为参数。因此,任何HideableWonderCat的实例都可以作为该方法的参数,也就是符合Hideable实例的任何类的实例。

以下几行显示了一个新的FightableWonderCat类的代码,该类继承自WonderCat类并实现了Fightable接口。请注意,类声明在extends关键字后包括超类(WonderCat),在implements关键字后包括实现的接口(Fightable)。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_07.java文件中。

public class FightableWonderCat extends WonderCat implements Fightable {
    protected final int swordPower;
    protected final int swordWeight;

    public FightableWonderCat(String nickName, 
        int age, 
        String fullName, 
        int score, 
        int x, 
        int y, 
        int swordPower,
        int swordWeight) {
        super(nickName, age, fullName, score, x, y);
        this.swordPower = swordPower;
        this.swordWeight = swordWeight;
    }

    private void printSwordInformation() {
        System.out.println(
            String.format(
                "%s unsheaths his sword.", 
                getFullName()));
        System.out.println(
            String.format(
                "Sword power: %d. Sword weight: %d.", 
                swordPower,
                swordWeight));
    }

    @Override
    public int getSwordPower() {
        return swordPower;
    }

    @Override
    public int getSwordWeight() {
        return swordWeight;
    }

    @Override
    public void unsheathSword() {
        printSwordInformation();
    }

    @Override
    public void unsheathSword(Hideable hideable) {
        printSwordInformation();
        System.out.println(
            String.format("The sword targets a Hideable with %d eyes.",
                hideable.getNumberOfEyes()));
    }
}

就像之前编写的两个从WonderCat类继承并实现接口的类一样,新的FightableWonderCat类实现了三个接口。其中两个接口由WonderCat超类实现,并被FightableWonderCat继承:DrawableInComicDrawableInGameFightableWonderCat类添加了Fightable接口的实现。

FightableWonderCat类中定义的构造函数在构造函数中添加了swordPowerswordWeight参数,这些参数在WonderCat超类中声明的参数列表中。在这种情况下,构造函数使用super关键字调用超类中定义的构造函数,然后使用接收到的swordPowerswordWeight参数初始化swordPowerswordWeight不可变字段。

该类实现了getSpellPowergetSwordWeightFightable接口所需的两个版本的unsheathSword方法。两个版本的unsheathSword方法调用了受保护的printSwordInformation方法,而接收Hideable实例作为参数的重载版本则打印了一个额外的消息,该消息包含了Hideable实例的眼睛数量作为目标。

以下表格总结了我们创建的每个类实现的接口:

类名 实现以下接口
SpiderDog DrawableInComic
WonderCat DrawableInComicDrawableInGame
HideableWonderCat DrawableInComicDrawableInGameHideable
PowerableWonderCat DrawableInComicDrawableInGamePowerable
FightableWonderCat DrawableInComicDrawableInGameFightable

以下简化的 UML 图显示了类的层次结构树及其与接口的关系。该图表不包括任何接口和类的成员,以使其更容易理解关系。以虚线结束的带箭头的线表示类实现了箭头指示的接口。

Combining class inheritance and interfaces

以下 UML 图显示了接口和类及其所有成员。请注意,我们不重复类实现的接口中声明的成员,以使图表更简单,并避免重复信息。我们可以使用该图表来理解我们将在基于这些类和先前定义的接口的使用的下一个代码示例中分析的所有内容:

Combining class inheritance and interfaces

以下行创建了每个先前创建的类的一个实例。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_08.java文件中。

SpiderDog spiderDog1 = 
    new SpiderDog("Buddy");
WonderCat wonderCat1 = 
    new WonderCat("Daisy", 1, "Mrs. Daisy", 100, 15, 15);
HideableWonderCat hideableWonderCat1 =
    new HideableWonderCat("Molly", 5, "Mrs. Molly", 450, 20, 10, 3); 
PowerableWonderCat powerableWonderCat1 =
    new PowerableWonderCat("Princess", 5, "Mrs. Princess", 320, 20, 10, 7);
FightableWonderCat fightableWonderCat1 =
    new FightableWonderCat("Abby", 3, "Mrs. Abby", 1200, 40, 10, 7, 5);

以下表格总结了我们使用前面的代码片段创建的实例名称及其类名称:

实例名称 类名称
spiderDog1 SpiderDog
wonderCat1 WonderCat
hideableWonderCat1 HideableWonderCat
powerableWonderCat1 PowerableWonderCat
fightableWonderCat1 FightableWonderCat

现在,我们将评估许多使用instanceof关键字的表达式,以确定实例是指定类的实例还是实现特定接口的类的实例。请注意,所有表达式的评估结果都为true,因为在instanceof关键字后面的右侧指定的类型对于每个实例来说,都是它的主类、超类或主类实现的接口。

例如,powerableWonderCat1PowerableWonderCat 的一个实例。此外,powerableWonderCat1 属于 WonderCat,因为 WonderCatPowerableWonderCat 类的超类。同样,powerableWonderCat1 实现了三个接口:DrawableInComicDrawableInGamePowerablePowerableWonderCat 的超类 WonderCat 实现了以下两个接口:DrawableInComicDrawableInGame。因此,PowerableWonderCat 继承了接口的实现。最后,PowerableWonderCat 类不仅继承自 WonderCat,还实现了 Powerable 接口。

在第三章Classes and Instances中,我们学习了instanceof关键字允许我们测试对象是否是指定类型。这种类型可以是类,也可以是接口。如果我们在 JShell 中执行以下行,所有这些行的评估结果都将打印为true。示例的代码文件包含在java_9_oop_chapter_08_01文件夹中的example08_08.java文件中。

spiderDog1 instanceof SpiderDog
spiderDog1 instanceof DrawableInComic

wonderCat1 instanceof WonderCat
wonderCat1 instanceof DrawableInComic
wonderCat1 instanceof DrawableInGame

hideableWonderCat1 instanceof WonderCat
hideableWonderCat1 instanceof HideableWonderCat
hideableWonderCat1 instanceof DrawableInComic
hideableWonderCat1 instanceof DrawableInGame
hideableWonderCat1 instanceof Hideable

powerableWonderCat1 instanceof WonderCat
powerableWonderCat1 instanceof PowerableWonderCat
powerableWonderCat1 instanceof DrawableInComic
powerableWonderCat1 instanceof DrawableInGame
powerableWonderCat1 instanceof Powerable

fightableWonderCat1 instanceof WonderCat
fightableWonderCat1 instanceof FightableWonderCat
fightableWonderCat1 instanceof DrawableInComic
fightableWonderCat1 instanceof DrawableInGame
fightableWonderCat1 instanceof Fightable

以下两个屏幕截图显示了在 JShell 中评估先前表达式的结果:

Combining class inheritance and interfacesCombining class inheritance and interfaces

测试你的知识

  1. 一个类可以实现:

  2. 只有一个接口。

  3. 一个或多个接口。

  4. 最多两个接口。

  5. 当一个类实现一个接口:

  6. 它也可以继承自一个超类。

  7. 它不能从一个超类继承。

  8. 它只能从抽象超类继承,而不能从具体超类继承。

  9. 一个接口:

  10. 可以从一个超类继承。

  11. 不能继承自超类或另一个接口。

  12. 可以继承另一个接口。

  13. 哪一行声明了一个名为WonderDog的类,该类实现了Hideable接口:

  14. public class WonderDog extends Hideable {

  15. public class WonderDog implements Hideable {

  16. public class WonderDog: Hideable {

  17. 接口是:

  18. 一种方法。

  19. 一种类型。

  20. 抽象类。

总结

在本章中,您学习了声明和组合多个蓝图以生成单个实例。我们声明了指定所需方法的接口。然后,我们创建了许多实现单个和多个接口的类。

我们将类继承与接口实现结合在一起。我们意识到一个类可以实现多个接口。我们在 JShell 中执行代码,以了解单个实例属于类类型和接口类型。

现在您已经了解了接口和基本的契约编程知识,我们准备开始处理高级契约编程场景,这是我们将在下一章讨论的主题。

第九章:接口的高级契约编程

在本章中,我们将深入探讨接口的契约编程。我们将更好地理解接口作为类型的工作方式。我们将:

  • 使用接口作为参数的方法

  • 使用接口和类进行向下转型

  • 理解装箱和拆箱

  • 将接口类型的实例视为不同的子类

  • 利用 Java 9 中接口的默认方法

使用接口作为参数的方法

在上一章中,我们创建了以下五个接口:DrawableInComicDrawableInGameHideablePowerableFightable。然后,我们创建了实现不同接口的以下类,并且其中许多类还继承自超类:SpiderDogWonderCatHideableWonderCatPowerableWonderCatFightableWonderCat

在 JShell 中运行以下命令以检查我们创建的所有类型:

/types

以下截图显示了在 JShell 中执行上一个命令的结果。JShell 列举了我们在会话中创建的五个接口和五个类。

使用接口作为参数的方法

当我们使用接口时,我们使用它们来指定参数类型,而不是使用类名。多个类可能实现单个接口,因此,不同类的实例可能符合特定接口的参数。

现在我们将创建先前提到的类的额外实例,并调用指定其所需参数的方法,使用接口名称而不是类名。我们将了解在方法中使用接口作为参数类型时发生了什么。

在以下代码中,前两行创建了SpiderDog类的两个实例,分别命名为teddywinston。然后,代码调用了teddydrawSpeechBalloon方法的两个版本。对该方法的第二次调用将winston作为DrawableInComic参数传递,因为winstonSpiderDog的一个实例,而SpiderDog是实现DrawableInComic实例的类。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

SpiderDog teddy = new SpiderDog("Teddy");
SpiderDog winston = new SpiderDog("Winston");
teddy.drawSpeechBalloon(
    String.format("Hello, my name is %s", teddy.getNickName()));
teddy.drawSpeechBalloon(winston, "How do you do?");
winston.drawThoughtBalloon("Who are you? I think.");

以下代码创建了一个名为oliverWonderCat类的实例。在构造函数中为nickName参数指定的值为"Oliver"。下一行调用了新实例的drawSpeechBalloon方法,介绍了Oliver在漫画中,然后teddy调用了drawSpeechBalloon方法,并将oliver作为DrawableInComic参数传递,因为oliverWonderCat的一个实例,而WonderCat是实现DrawableInComic实例的类。因此,我们也可以在需要DrawableInComic参数时使用WonderCat的实例。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

WonderCat oliver = 
    new WonderCat("Oliver", 10, "Mr. Oliver", 0, 15, 25);
oliver.drawSpeechBalloon(
    String.format("Hello, my name is %s", oliver.getNickName()));
teddy.drawSpeechBalloon(oliver, 
    String.format("Hello %s", oliver.getNickName()));

以下代码创建了一个名为misterHideableHideableWonderCat类的实例。在构造函数中为nickName参数指定的值为"Mr. Hideable"。下一行检查了使用oliver作为参数调用isIntersectingWith方法是否返回true。该方法需要一个DrawableInComic参数,因此我们可以使用oliver。该方法将返回true,因为两个实例的xy字段具有相同的值。if块中的行调用了misterHideablesetLocation方法。然后,代码调用了show方法。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

HideableWonderCat misterHideable = 
    new HideableWonderCat("Mr. Hideable", 310, 
        "Mr. John Hideable", 67000, 15, 25, 3);
if (misterHideable.isIntersectingWith(oliver)) {
    misterHideable.setLocation(
        oliver.getX() + 30, oliver.getY() + 30);
}
misterHideable.show();

以下代码创建了一个名为merlinPowerableWonderCat类的实例。在构造函数中为nickName参数指定的值是"Merlin"。接下来的几行调用了setLocationdraw方法。然后,代码使用misterHideable作为Hideable参数调用了useSpellToHide方法。该方法需要一个Hideable参数,因此我们可以使用HideableWonderCat的先前创建的实例misterHideable,该实例实现了Hideable接口。然后,对misterHideableshow方法的调用使具有三只眼睛的Hideable再次出现。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

PowerableWonderCat merlin = 
    new PowerableWonderCat("Merlin", 35, 
        "Mr. Merlin", 78000, 30, 40, 200);
merlin.setLocation(
    merlin.getX() + 5, merlin.getY() + 5);
merlin.draw();
merlin.useSpellToHide(misterHideable);
misterHideable.show();

以下代码创建了一个名为spartanFightableWonderCat类的实例。在构造函数中为nickName参数指定的值是"Spartan"。接下来的几行调用了setLocationdraw方法。然后,代码使用misterHideable作为参数调用了unsheathSword方法。该方法需要一个Hideable参数,因此我们可以使用HideableWonderCat的先前创建的实现Hideable接口的实例misterHideable。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

FightableWonderCat spartan = 
    new FightableWonderCat("Spartan", 28, 
        "Sir Spartan", 1000000, 60, 60, 100, 50);
spartan.setLocation(
    spartan.getX() + 30, spartan.getY() + 10);
spartan.draw();
spartan.unsheathSword(misterHideable);

最后,代码调用了misterHideabledrawThoughtBalloondrawSpeechBalloon方法。我们可以调用这些方法,因为misterHideableHideableWonderCat的一个实例,而这个类从其超类WonderCat继承了DrawableInComic接口的实现。

drawSpeechBalloon方法的调用将spartan作为DrawableInComic参数,因为spartanFightableWonderCat的一个实例,它是一个类,也从其超类WonderCat继承了DrawableInComic接口的实现。因此,我们还可以在需要DrawableInComic参数时使用FightableWonderCat的实例,就像下面的代码中所做的那样。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_01.java文件中。

misterHideable.drawThoughtBalloon(
    "I guess I must be friendly...");
misterHideable.drawSpeechBalloon(
    spartan, "Pleased to meet you, Sir!");

在 JShell 中执行了前面解释的所有代码片段后,我们将看到以下文本输出:

Teddy -> Hello, my name is Teddy
Teddy -> message: Winston, How do you do?
Winston -> ***Who are you? I think.***
Oliver -> Meow
Teddy -> message: Oliver, Hello Oliver
Moving WonderCat Mr. John Hideable to x:45, y:55
My name is Mr. John Hideable and you can see my 3 eyes.
Moving WonderCat Mr. Merlin to x:35, y:45
Drawing WonderCat Mr. Merlin at x:35, y:45
Mr. Merlin uses his 200 spell power to hide the Hideable with 3 eyes.
My name is Mr. John Hideable and you can see my 3 eyes.
Moving WonderCat Sir Spartan to x:90, y:70
Drawing WonderCat Sir Spartan at x:90, y:70
Sir Spartan unsheaths his sword.
Sword power: 100\. Sword weight: 50.
The sword targets a Hideable with 3 eyes.
Mr. Hideable thinks: 'I guess I must be friendly...'
Spartan ==> Mr. Hideable --> Pleased to meet you, Sir!

使用接口和类进行向下转型

DrawableInComic接口定义了drawSpeechBalloon方法的一个方法要求,其参数为DrawableInComic类型的destination,这与接口定义的类型相同。以下是我们示例代码中调用此方法的第一行:

teddy.drawSpeechBalloon(winston, "How do you do?");

我们调用了SpiderDog类中实现的方法,因为teddySpiderDog的一个实例。我们将SpiderDog实例winston传递给destination参数。该方法使用destination参数作为实现DrawableInComic接口的实例。因此,每当我们引用destination变量时,我们只能看到DrawableInComic类型定义的内容。

当 Java 将类型从其原始类型向下转换为目标类型时,例如转换为类符合的接口,我们可以很容易地理解发生了什么。在这种情况下,SpiderDog被向下转换为DrawableInComic。如果我们在 JShell 中输入以下代码并按Tab键,JShell 将枚举名为winstonSpiderDog实例的成员:

winston.

JShell 将显示以下成员:

drawSpeechBalloon(    drawThoughtBalloon(   equals(
getClass()            getNickName()         hashCode()
nickName              notify()              notifyAll()
speak(                think(                toString()
wait(

每当我们要求 JShell 列出成员时,它将包括从java.lang.Object继承的以下成员:

equals(       getClass()    hashCode()    notify()      notifyAll()
toString()    wait(

删除先前输入的代码(winston.)。如果我们在 JShell 中输入以下代码并按Tab键,括号中的DrawableInComic接口类型作为winston变量的前缀将强制将其降级为DrawableInComic接口类型。因此,JShell 将只列举SpiderDog实例winston中作为DrawableInComic接口所需成员:

((DrawableInComic) winston).

JShell 将显示以下成员:

drawSpeechBalloon(    drawThoughtBalloon(   equals(
getClass()            getNickName()         hashCode()
notify()              notifyAll()           toString()
wait(

让我们看一下当我们输入winston.并按Tab键时的结果与最新结果之间的区别。上一个列表中显示的成员不包括在SpiderDog类中定义但在DrawableInComic接口中不是必需的两个方法:speakthink。因此,当 Java 将winston降级为DrawableInComic时,我们只能使用DrawableInComic接口所需的成员。

提示

如果我们使用支持自动补全功能的任何 IDE,我们会注意到在使用自动补全功能而不是在 JShell 中按Tab键时,成员的枚举中存在相同的差异。

现在我们将分析另一种情况,即将一个实例降级为其实现的接口之一。DrawableInGame接口为isIntersectingWith方法定义了一个对DrawableInGame类型的otherDrawableInGame参数的要求,这与接口定义的类型相同。以下是我们调用此方法的示例代码中的第一行:

if (misterHideable.isIntersectingWith(oliver)) {

我们调用了WonderCat类中定义的方法,因为misterHideableHideableWonderCat的一个实例,它继承了WonderCat类中isIntersectingWith方法的实现。我们将WonderCat实例oliver传递给了otherDrawableInGame参数。该方法使用otherDrawableInGame参数作为一个实现了DrawableInGame实例的实例。因此,每当我们引用otherDrawableInGame变量时,我们只能看到DrawableInGame类型定义的内容。在这种情况下,WonderCat被降级为DrawableInGame

如果我们在 JShell 中输入以下代码并按Tab键,JShell 将列举WonderCat实例oliver的成员:

oliver.

JShell 将显示oliver的以下成员:

age                   draw()                drawSpeechBalloon(
drawThoughtBalloon(   equals(               fullName
getAge()              getClass()            getFullName()
getNickName()         getScore()            getX()
getY()                hashCode()            isIntersectingWith(
nickName              notify()              notifyAll()
score                 setLocation(          toString()
wait(                 x                     y

删除先前输入的代码(oliver.)。如果我们在 JShell 中输入以下代码并按Tab键,括号中的DrawableInGame接口类型作为oliver变量的前缀将强制将其降级为DrawableInGame接口类型。因此,JShell 将只列举WonderCat实例oliver中作为DrawableInGame实例所需成员:

((DrawableInComic) oliver).

JShell 将显示以下成员:

draw()                equals(               getClass()
getFullName()         getScore()            getX()
getY()                hashCode()            isIntersectingWith(
notify()              notifyAll()           setLocation(
toString()            wait(

让我们看一下当我们输入oliver.并按Tab键时的结果与最新结果之间的区别。当 Java 将oliver降级为DrawableInGame时,我们只能使用DrawableInGame接口所需的成员。

我们可以使用类似的语法来强制将先前的表达式转换为原始类型,即WonderCat类型。如果我们在 JShell 中输入以下代码并按Tab键,JShell 将再次列举WonderCat实例oliver的所有成员:

((WonderCat) ((DrawableInGame) oliver)).

JShell 将显示以下成员,即当我们输入oliver.并按Tab键时,JShell 列举的所有成员,而没有任何类型的强制转换:

age                      draw()             drawSpeechBalloon(
drawThoughtBalloon(      equals(            fullName
getAge()                 getClass()         getFullName()
getNickName()            getScore()         getX()
getY()                   hashCode()         isIntersectingWith(
nickName                 notify()           notifyAll()
score                    setLocation(       toString()
wait(                    x                  y

将接口类型的实例视为不同的子类

在第七章中,成员继承和多态性,我们使用了多态性。下一个示例并不代表最佳实践,因为多态性是使其工作的方式。但是,我们将编写一些代码,这些代码并不代表最佳实践,只是为了更多地了解类型转换。

以下行创建了一个名为doSomethingWithWonderCat的方法在 JShell 中。我们将使用这个方法来理解如何将以接口类型接收的实例视为不同的子类。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_02.java文件中。

// The following code is just for educational purposes
// and it doesn't represent a best practice
// We should always take advantage of polymorphism instead
public void doSomethingWithWonderCat(WonderCat wonderCat) {
    if (wonderCat instanceof HideableWonderCat) {
        HideableWonderCat hideableCat = (HideableWonderCat) wonderCat;
        hideableCat.show();
    } else if (wonderCat instanceof FightableWonderCat) {
        FightableWonderCat fightableCat = (FightableWonderCat) wonderCat;
        fightableCat.unsheathSword();
    } else if (wonderCat instanceof PowerableWonderCat) {
        PowerableWonderCat powerableCat = (PowerableWonderCat) wonderCat;
        System.out.println(
            String.format("Spell power: %d", 
                powerableCat.getSpellPower()));
    } else {
        System.out.println("This WonderCat isn't cool.");
    }
}

doSomethingWithWonderCat方法在wonderCat参数中接收一个WonderCat实例。该方法评估了许多使用instanceof关键字的表达式,以确定wonderCat参数中接收的实例是否是HideableWonderCatFightableWonderCatPowerableWonder的实例。

如果wonderCatHideableWonderCat的实例或任何潜在的HideableWonderCat子类的实例,则代码声明一个名为hideableCatHideableWonderCat局部变量,以保存wonderCat转换为HideableWonderCat的引用。然后,代码调用hideableCat.show方法。

如果wonderCat不是HideableWonderCat的实例,则代码评估下一个表达式。如果wonderCatFightableWonderCat的实例或任何潜在的FightableWonderCat子类的实例,则代码声明一个名为fightableCatFightableWonderCat局部变量,以保存wonderCat转换为FightableWonderCat的引用。然后,代码调用fightableCat.unsheathSword方法。

如果wonderCat不是FightableWonderCat的实例,则代码评估下一个表达式。如果wonderCatPowerableWonderCat的实例或任何潜在的PowerableWonderCat子类的实例,则代码声明一个名为powerableCatPowerableWonderCat局部变量,以保存wonderCat转换为PowerableWonderCat的引用。然后,代码使用powerableCat.getSpellPower()方法返回的结果来打印咒语能量值。

最后,如果最后一个表达式评估为false,则表示wonderCat实例只属于WonderCat,代码将打印一条消息,指示WonderCat不够酷。

提示

如果我们必须执行类似于此方法中显示的代码的操作,我们必须利用多态性,而不是使用instanceof关键字基于实例所属的类来运行代码。请记住,我们使用这个示例来更多地了解类型转换。

现在我们将在 JShell 中多次调用最近编写的doSomethingWithWonderCat方法。我们将使用WonderCat及其子类的实例调用此方法,这些实例是在我们声明此方法之前创建的。我们将使用以下值调用doSomethingWithWonderCat方法作为wonderCat参数:

  • misterHideableHideableWonderCat类的实例

  • spartanFightableWonderCat类的实例

  • merlinPowerableWonderCat类的实例

  • oliverWonderCat类的实例

以下四行在 JShell 中使用先前枚举的参数调用doSomethingWithWonderCat方法。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_02.java文件中。

doSomethingWithWonderCat(misterHideable);
doSomethingWithWonderCat(spartan);
doSomethingWithWonderCat(merlin);
doSomethingWithWonderCat(oliver);

以下屏幕截图显示了 JShell 为前面的行生成的输出。每次调用都会触发不同的类型转换,并调用类型转换后的实例的方法:

将接口类型的实例视为不同的子类

利用 Java 9 中接口的默认方法

SpiderDogWonderCat类都实现了DrawableInComic接口。所有继承自WonderCat类的类都继承了DrawableInComic接口的实现。假设我们需要向DrawableInComic接口添加一个新的方法要求,并且我们将创建实现这个新版本接口的新类。我们将添加一个新的drawScreamBalloon方法,用于绘制一个带有消息的尖叫气泡。

我们将在SpiderDog类中添加新方法的实现。但是,假设我们无法更改实现DrawableInComic接口的某个类的代码:WonderCat。这会带来一个大问题,因为一旦我们更改了DrawableInComic接口的代码,Java 编译器将为WonderCat类生成编译错误,我们将无法编译这个类及其子类。

在这种情况下,Java 8 引入的接口默认方法以及 Java 9 中也可用的接口默认方法非常有用。我们可以为drawScreamBalloon方法声明一个默认实现,并将其包含在DrawableInComic接口的新版本中。这样,WonderCat类及其子类将能够使用接口中提供的方法的默认实现,并且它们将符合接口中指定的要求。

以下的 UML 图显示了DrawableInComic接口的新版本,其中包含了名为drawScreamBalloon的默认方法,以及覆盖默认方法的SpiderDog类的新版本。请注意,drawScreamBalloon方法是唯一一个不使用斜体文本的方法,因为它不是一个抽象方法。

利用 Java 9 中接口的默认方法

以下几行显示了声明DrawableInComic接口的新版本的代码,其中包括对drawScreamBalloon方法的方法要求和默认实现。请注意,在方法的返回类型之前使用default关键字表示我们正在声明一个默认方法。默认实现调用了每个实现接口的类将声明的drawSpeechBalloon方法。这样,实现这个接口的类默认情况下将在接收到绘制尖叫气泡的请求时绘制一个对话气泡。

示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_03.java文件中。

public interface DrawableInComic {
    String getNickName();
    void drawSpeechBalloon(String message);
    void drawSpeechBalloon(DrawableInComic destination, String message);
    void drawThoughtBalloon(String message);
 default void drawScreamBalloon(String message) {
 drawSpeechBalloon(message);
 }
}

提示

在我们创建接口的新版本后,JShell 将重置所有持有实现DrawableInComic接口的类实例引用的变量为null。因此,我们将无法使用我们一直在创建的实例来测试接口的更改。

以下几行显示了SpiderDog类的新版本的代码,其中包括新的drawScreamBalloon方法。新的行已经高亮显示。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_03.java文件中。

public class SpiderDog implements DrawableInComic {
    protected final String nickName;

    public SpiderDog(String nickName) {
        this.nickName = nickName;
    }

    protected void speak(String message) {
        System.out.println(
            String.format("%s -> %s",
                nickName,
                message));
    }

    protected void think(String message) {
        System.out.println(
            String.format("%s -> ***%s***",
                nickName,
                message));
    }

 protected void scream(String message) {
 System.out.println(
 String.format("%s screams +++ %s +++",
 nickName,
 message));
 }

    @Override
    public String getNickName() {
        return nickName;
    }

    @Override
    public void drawSpeechBalloon(String message) {
        speak(message);
    }

    @Override
    public void drawSpeechBalloon(DrawableInComic destination, 
        String message) {
        speak(String.format("message: %s, %s",
            destination.getNickName(),
            message));
    }

    @Override
    public void drawThoughtBalloon(String message) {
        think(message);
    }

 @Override
 public void drawScreamBalloon(String message) {
 scream(message);
 }
}

SpiderDog类覆盖了drawScreamBalloon方法的默认实现,使用了一个调用受保护的scream方法的新版本,该方法以特定格式打印接收到的message,并将nickName值作为前缀。这样,这个类将不使用DrawableInComic接口中声明的默认实现,而是使用自己的实现。

在下面的代码中,前几行创建了SpiderDog类的新版本实例rocky,以及FightableWonderCat类的新版本实例maggie。然后,代码调用drawScreamBalloon方法,并为两个创建的实例rockymaggie传递消息。示例的代码文件包含在java_9_oop_chapter_09_01文件夹中的example09_03.java文件中。

SpiderDog rocky = new SpiderDog("Rocky");
FightableWonderCat maggie = 
    new FightableWonderCat("Maggie", 2, 
        "Mrs. Maggie", 5000000, 10, 10, 80, 30);
rocky.drawScreamBalloon("I am Rocky!");
maggie.drawScreamBalloon("I am Mrs. Maggie!");

当我们调用rocky.drawScreamBalloon时,Java 执行了在SpiderDog类中声明的这个方法的重写实现。当我们调用maggie.drawScreamBalloon时,Java 执行了在DrawableInComic接口中声明的默认方法,因为WonderCatFightableWonderCat类都没有重写这个方法的默认实现。不要忘记FightableWonderCatWonderCat的子类。以下截图显示了在 JShell 中执行前面几行代码的结果:

利用 Java 9 中接口的默认方法

测试你的知识

  1. 默认方法允许我们声明:

  2. 一个默认的构造函数,当实现接口的类没有声明构造函数时,Java 会使用这个默认构造函数。

  3. 在实现接口的类的实例执行任何方法之前将被调用的方法。

  4. 在接口中的一个方法的默认实现,当实现接口的类没有提供自己的方法实现时,Java 会使用这个默认实现。

  5. 考虑到我们有一个现有的接口,许多类实现了这个接口,所有的类都能够编译通过而没有错误。如果我们向这个接口添加一个默认方法:

  6. 实现接口的类在提供新方法要求的实现之前不会编译。

  7. 实现接口的类在提供新构造函数要求的实现之前不会编译。

  8. 实现接口的类将会编译。

  9. 以下关键字中哪些允许我们确定一个实例是否是实现特定接口的类的实例:

  10. instanceof

  11. isinterfaceimplementedby

  12. implementsinterface

  13. 以下哪些代码片段强制将winston变量向下转型为DrawableInComic接口:

  14. (winston as DrawableInComic)

  15. ((DrawableInComic) < winston)

  16. ((DrawableInComic) winston)

  17. 以下哪些代码片段强制将misterHideable变量向下转型为HideableWonderCat类:

  18. (misterHideable as HideableWonderCat)

  19. ((HideableWonderCat) < misterHideable)

  20. ((Hid``eableWonderCat) misterHideable)

摘要

在本章中,你学会了当一个方法接收一个接口类型的参数时,在幕后发生了什么。我们使用了接收接口类型参数的方法,并且通过接口和类进行了向下转型。我们理解了如何将一个对象视为不同兼容类型的实例,以及当我们这样做时会发生什么。JShell 让我们能够轻松理解当我们使用类型转换时发生了什么。

我们利用了接口中的默认方法。我们可以向接口添加一个新方法并提供默认实现,以避免破坏我们无法编辑的现有代码。

现在你已经学会了在接口中使用高级场景,我们准备在 Java 9 中通过泛型最大化代码重用,这是我们将在下一章讨论的主题。

第十章:通过泛型最大化代码重用

在本章中,我们将学习参数多态以及 Java 9 如何通过允许我们编写通用代码来实现这一面向对象的概念。我们将开始创建使用受限泛型类型的类。我们将:

  • 理解参数多态

  • 了解参数多态和鸭子类型之间的区别

  • 理解 Java 9 泛型和通用代码

  • 声明一个用作类型约束的接口

  • 声明符合多个接口的类

  • 声明继承接口实现的子类

  • 创建异常类

  • 声明一个使用受限泛型类型的类

  • 使用一个通用类来处理多个兼容类型

理解参数多态、Java 9 泛型和通用代码

想象一下,我们开发了一个 Web 服务,必须使用特定野生动物聚会的组织表示。我们绝对不希望把狮子和鬣狗混在一起,因为聚会最终会以鬣狗吓唬一只孤狮而结束。我们希望一个组织有序的聚会,不希望有入侵者,比如龙或猫,出现在只有狮子应该参加的聚会中。

我们想描述启动程序、欢迎成员、组织聚会以及向聚会的不同成员道别的程序。然后,我们想在天鹅聚会中复制这些程序。因此,我们希望重用我们的程序来举办狮子聚会和天鹅聚会。将来,我们将需要使用相同的程序来举办其他野生动物和家养动物的聚会,比如狐狸、鳄鱼、猫、老虎和狗。显然,我们不希望成为鳄鱼聚会的入侵者。我们也不想参加老虎聚会。

在前几章中,第八章,“使用接口进行合同编程”,和第九章,“使用接口进行高级合同编程”,我们学习了如何在 Java 9 中使用接口。我们可以声明一个接口来指定可以参加聚会的动物的要求,然后利用 Java 9 的特性编写通用代码,可以与实现接口的任何类一起使用。

提示

参数多态允许我们编写通用和可重用的代码,可以处理值而不依赖于类型,同时保持完全的静态类型安全。

我们可以通过泛型在 Java 9 中利用参数多态,也称为通用编程。在我们声明一个指示可以参加聚会的动物要求的接口之后,我们可以创建一个可以与实现此接口的任何实例一起使用的类。这样,我们可以重用生成狮子聚会的代码,并创建天鹅、鬣狗或任何其他动物的聚会。具体来说,我们可以重用生成任何实现指定可以参加聚会的动物要求的接口的类的聚会的代码。

我们要求动物在聚会中要有社交能力,因此,我们可以创建一个名为Sociable的接口,来指定可以参加聚会的动物的要求。但要注意,我们将用作示例的许多野生动物并不太善于社交。

提示

许多现代强类型编程语言允许我们通过泛型进行参数多态。如果你有使用过 C#或 Swift,你会发现 Java 9 的语法与这些编程语言中使用的语法非常相似。C#也使用接口,但 Swift 使用协议而不是接口。

其他编程语言,如 Python、JavaScript 和 Ruby,采用一种称为鸭子类型的不同哲学,其中某些字段和方法的存在使对象适合于其用途作为特定的社交动物。使用鸭子类型,如果我们要求社交动物具有getNamedanceAlone方法,只要对象提供了所需的方法,我们就可以将任何对象视为社交动物。因此,使用鸭子类型,任何提供所需方法的任何类型的实例都可以用作社交动物。

让我们来看一个真实的情况,以理解鸭子类型的哲学。想象一下,我们看到一只鸟,这只鸟嘎嘎叫、游泳和走路都像一只鸭子。我们肯定可以称这只鸟为鸭子,因为它满足了这只鸟被称为鸭子所需的所有条件。与鸟和鸭子相关的类似例子产生了鸭子类型的名称。我们不需要额外的信息来将这只鸟视为鸭子。Python、JavaScript 和 Ruby 是鸭子类型极为流行的语言的例子。

在 Java 9 中可以使用鸭子类型,但这不是这种编程语言的自然方式。在 Java 9 中实现鸭子类型需要许多复杂的解决方法。因此,我们将专注于学习通过泛型实现参数多态性的通用代码编写。

声明一个接口用作类型约束

首先,我们将创建一个Sociable接口,以指定类型必须满足的要求,才能被视为潜在的聚会成员,也就是我们应用领域中的社交动物。然后,我们将创建一个实现了这个接口的SociableAnimal抽象基类,然后,我们将在三个具体的子类中专门化这个类:SocialLionSocialParrotSocialSwan。然后,我们将创建一个Party类,它将能够通过泛型与实现Sociable接口的任何类的实例一起工作。我们将创建两个新的类,它们将代表特定的异常。我们将处理一群社交狮子、一群社交鹦鹉和一群社交天鹅。

以下的 UML 图显示了接口,实现它的抽象类,以及我们将创建的具体子类,包括所有的字段和方法:

声明一个接口用作类型约束

以下几行显示了Sociable接口的代码。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public interface Sociable {
    String getName();
    int getAge();
    void actAlone();
    void danceAlone();
    void danceWith(Sociable partner);
    void singALyric(String lyric);
    void speak(String message);
    void welcome(Sociable other);
    void sayGoodbyeTo(Sociable other);
}

接口声明了以下九个方法要求:

  • getName:这个方法必须返回一个String,表示Sociable的名字。

  • getAge:这个方法必须返回一个int,表示Sociable的年龄。

  • actAlone:这个方法必须让Sociable独自行动。

  • danceAlone:这个方法必须让Sociable独自跳舞。

  • danceWith:这个方法必须让Sociable与另一个在 partner 参数中接收到的Sociable一起跳舞。

  • singALyric:这个方法必须让Sociable唱接收到的歌词。

  • speak:这个方法让Sociable说一条消息。

  • welcome:这个方法让Sociable向另一个在其他参数中接收到的Sociable说欢迎的消息。

  • sayGoodbyeTo:这个方法让Sociable向另一个在其他参数中接收到的Sociable说再见。

我们没有在接口声明中包含任何默认方法,因此实现Sociable接口的类负责实现之前列举的九个方法。

声明符合多个接口的类

SocialAnimal abstract class. The code file for the sample is included in the java_9_oop_chapter_10_01 folder, in the example10_01.java file.
public abstract class SocialAnimal implements Sociable, Comparable<Sociable> {
    public final String name;
    public final int age;

    public SocialAnimal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    protected void printMessageWithNameAsPrefix(String message) {
        System.out.println(
            String.format("%s %s", 
                getName(), 
                message));
    }

    public abstract String getDanceRepresentation();

    public abstract String getFirstSoundInWords();

    public abstract String getSecondSoundInWords();

    public abstract String getThirdSoundInWords();

    @Override
    public String getName() {
        return name;
    }

    @Override
    public int getAge() {
        return age;
    }
SocialAnimal class declares a constructor that assigns the value of the required name and age arguments to the immutable name and age protected fields. Then the class declares a protected printMessageWithNameAsPrefix method that receives a message and prints the name for the SocialAnimal followed by a space and this message. Many methods will call this method to easily add the name as a prefix for many messages.
SocialAnimal abstract class. The code file for the sample is included in the java_9_oop_chapter_10_01 folder, in the example10_01.java file.
    @Override
    public void actAlone() {
        printMessageWithNameAsPrefix("to be or not to be");
    }

    @Override
    public void danceAlone() {
        printMessageWithNameAsPrefix(
            String.format("dances alone %s", 
                getDanceRepresentation()));
    }

    @Override
    public void danceWith(Sociable partner) {
        printMessageWithNameAsPrefix(
            String.format("dances with %s %s", 
                partner.getName(),
                getDanceRepresentation()));
    }

    @Override
    public void singALyric(String lyric) {
        printMessageWithNameAsPrefix(
            String.format("sings %s %s %s %s", 
                lyric,
                getFirstSoundInWords(),
                getSecondSoundInWords(),
                getThirdSoundInWords()));
    }

    @Override
    public void speak(String message) {
        printMessageWithNameAsPrefix(
            String.format("says: %s %s", 
                message,
                getDanceRepresentation()));
    }

    @Override
    public void welcome(Sociable other) {
        printMessageWithNameAsPrefix(
            String.format("welcomes %s", 
                other.getName()));
    }

    @Override
    public void sayGoodbyeTo(Sociable other) {
        printMessageWithNameAsPrefix(
            String.format("says goodbye to %s%s%s%s", 
                other.getName(),
                getFirstSoundInWords(),
                getSecondSoundInWords(),
                getThirdSoundInWords()));
    }
 for the SocialAnimal class implements the other methods required by the Sociable interface:
  • actAlone:这个方法打印名字,后面跟着"to be or not to be"。

  • danceAlone:这个方法使用调用getDanceRepresentation方法检索到的String来打印名字,后面跟着指示社交动物正在跳舞的消息。

  • danceWith:此方法使用调用getDanceRepresentation方法获取的String来打印名称,然后是一条消息,指示社交动物正在与Sociable类型的 partner 参数指定的伙伴一起跳舞。消息中包括伙伴的名称。

  • singALyric:此方法使用调用getFirstSoundInWordsgetSecondSoundInWordsgetThirdSoundInWords获取的字符串以及作为参数接收到的歌词来打印名称,然后是一条消息,指示社交动物唱出歌词。

  • speak:此方法使用调用getDanceRepresentation获取的String和作为参数接收到的消息来打印名称,然后是动物说的话,再接着是它的舞蹈表示字符。

  • welcome:此方法打印一条消息,欢迎另一个在其他参数中接收到的Sociable。消息包括目的地的名称。

  • sayGoodbyeTo:此方法使用调用getFirstSoundInWordsgetSecondSoundInWordsgetThirdSoundInWords获取的字符串来构建并打印一条消息,向其他参数中接收到的另一个Sociable说再见。消息包括目的地的名称。

for the SocialAnimal class overrides the compareTo method to implement the Comparable<Sociable> interface. In addition, this last code snippet for the SocialAnimal class overrides the equals method. The code file for the sample is included in the java_9_oop_chapter_10_01 folder, in the example10_01.java file.
    @Override
    public boolean equals(Object other) {
        // Is other this object?
        if (this == other) {
            return true;
        }
        // Is other null?
        if (other == null) {
            return false;
        }
        // Does other have the same type?
        if (!getClass().equals(other.getClass())) {
            return false;
        }
        SocialAnimal otherSocialAnimal = (SocialAnimal) other;
        // Make sure both the name and age are equal
        return Objects.equals(getName(), otherSocialAnimal.getName())
        && Objects.equals(getAge(), otherSocialAnimal.getAge());
    }

    @Override
    public int compareTo(final Sociable otherSociable) {
        return Integer.compare(getAge(),otherSociable.getAge());
    }
}
SocialAnimal class overrides the equals method inherited from java.lang.Object that receives the instance that we must compare with the actual instance in the other argument. Unluckily, we must use the Object type for the other argument in order to override the inherited method, and therefore, the code for the method has to use typecasting to cast the received instance to the SocialAnimal type.

首先,代码检查接收到的Object是否是对实际实例的引用。在这种情况下,代码返回true,不需要再进行其他检查。

然后,代码检查other的值是否等于null。如果方法接收到null,则代码返回false,因为实际实例不是null

然后,代码检查实际实例的getClass方法返回的String是否与接收到的实例的相同方法返回的String匹配。如果这些值不匹配,则表示接收到的Object是不同类型的实例,因此不同,代码返回false

此时,我们知道实际实例与接收到的实例具有相同的类型。因此,可以安全地将其他参数强制转换为SocialAnimal,并将转换后的引用保存在SocialAnimal类型的otherSocialAnimal局部变量中。

最后,代码返回评估当前实例和otherSocialAnimalgetNamegetAgeObject.equals调用的结果是否都为true

提示

当我们重写从java.lang.Object继承的equals方法时,遵循先前解释的步骤是一个好习惯。如果您有 C#的经验,重要的是要了解 Java 9 没有提供与IEquatable<T>接口等效的内容。此外,请注意,Java 不支持用户定义的运算符重载,这是其他面向对象编程语言(如 C++、C#和 Swift)中包含的功能。

SocialAnimal抽象类还实现了Comparable<Sociable>接口所需的compareTo方法。在这种情况下,代码非常简单,因为该方法在otherSociable参数中接收到一个Sociable实例,并返回调用Integer.compare方法的结果,即java.lang.Integer类的compare类方法。代码使用当前实例的getAge返回的int值和otherSociable作为两个参数调用此方法。Integer.compare方法返回以下结果:

  • 如果第一个参数等于第二个参数,则为0

  • 如果第一个参数小于第二个参数,则小于0

  • 如果第一个参数大于第二个参数,则大于0

所有继承自SocialAnimal的具体子类都将能够使用SocialAnimal抽象类中实现的equalscompareTo方法。

声明继承接口实现的子类

我们有一个抽象类SocialAnimal,它实现了SociableComparable<Sociable>接口。我们不能创建这个抽象类的实例。现在,我们将创建SocialAnimal的一个具体子类,名为SocialLion。这个类声明了一个构造函数,最终调用了超类中定义的构造函数。该类实现了其超类中声明的四个抽象方法,以返回适合参加派对的狮子的适当值。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public class SocialLion extends SocialAnimal {
 public SocialLion(String name, int age) {
        super(name, age);
    }

    @Override
 public String getDanceRepresentation() {
        return "*-* ^\\/^ (-)";
    }

    @Override
 public String getFirstSoundInWords() {
        return "Roar";
    }

    @Override
 public String getSecondSoundInWords() {
        return "Rrooaarr";
    }

    @Override
 public String getThirdSoundInWords() {
        return "Rrrrrrrroooooaaarrrr";
    }
}

我们将创建另一个名为SocialParrotSocialAnimal的具体子类。这个新的子类也实现了SocialAnimal超类中定义的抽象方法,但在这种情况下,返回了鹦鹉的适当值。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public class SocialParrot extends SocialAnimal {
    public SocialParrot(String name, int age) {
        super(name, age);
    }

    @Override
 public String getDanceRepresentation() {
        return "/|\\ -=- % % +=+";
    }

    @Override
 public String getFirstSoundInWords() {
        return "Yeah";
    }

    @Override
 public String getSecondSoundInWords() {
        return "Yeeaah";
    }

    @Override
 public String getThirdSoundInWords() {
        return "Yeeeaaaah";
    }
}

最后,我们将创建另一个名为SocialSwanSocialAnimal的具体子类。这个新的子类也实现了SocialAnimal超类中定义的抽象方法,但在这种情况下,返回了天鹅的适当值。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public class SocialSwan extends SocialAnimal {
    public SocialSwan(String name, int age) {
        super(name, age);
    }

    @Override
 public String getDanceRepresentation() {
        return "^- ^- ^- -^ -^ -^";
    }

    @Override
 public String getFirstSoundInWords() {
        return "OO-OO-OO";
    }

    @Override
 public String getSecondSoundInWords() {
        return "WHO-HO WHO-HO";
    }

    @Override
 public String getThirdSoundInWords() {
        return "WHO-WHO WHO-WHO";
    }
}

我们有三个具体类,它们继承了两个接口的实现,这两个接口来自它们的抽象超类SociableAnimal。以下三个具体类都实现了SociableComparable<Sociable>接口,并且它们可以使用继承的重写的equals方法来比较它们的实例:

  • SocialLion

  • SocialParrot

  • SocialSwan

创建异常类

我们将创建两个异常类,因为我们需要抛出 Java 9 平台中没有表示的异常类型。具体来说,我们将创建java.lang.Exception类的两个子类。

以下行声明了InsufficientMembersException类,它继承自Exception。当一个派对的成员数量不足以执行需要更多成员的操作时,我们将抛出这个异常。该类定义了一个不可变的numberOfMembers私有字段,类型为int,它在构造函数中初始化为接收到的值。此外,该类声明了一个getNumberOfMembers方法,返回这个字段的值。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public class InsufficientMembersException extends Exception {
    private final int numberOfMembers;

    public InsufficientMembersException(int numberOfMembers) {
        this.numberOfMembers = numberOfMembers;
    }

    public int getNumberOfMembers() {
        return numberOfMembers;
    }
}

以下行声明了CannotRemovePartyLeaderException类,它继承自Exception。当一个方法试图从派对成员列表中移除当前的派对领袖时,我们将抛出这个异常。在这种情况下,我们只声明了一个继承自Exception的空类,因为我们不需要额外的功能,我们只需要新的类型。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

public class CannotRemovePartyLeaderException extends Exception {
}

声明一个与受限泛型类型一起工作的类

以下行声明了一个Party类,利用泛型来处理多种类型。 我们导入java.util.concurrent.ThreadLocalRandom,因为它是一个非常有用的类,可以轻松地在范围内生成伪随机数。 类名Party后面跟着一个小于号(<),一个标识泛型类型参数的Textends关键字,以及T泛型类型参数必须实现的接口名称Sociable,一个和号(&),以及T泛型类型必须实现的另一个接口名称Comparable<Sociable>。 大于号(>)结束了包含在尖括号(<>)中的类型约束声明。 因此,T泛型类型参数必须是一个既实现Sociable接口又实现Comparable<Sociable>接口的类型。 以下代码突出显示了使用T泛型类型参数的行。 示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

import java.util.concurrent.ThreadLocalRandom;

public class Party<T extends Sociable & Comparable<Sociable>> {
 protected final List<T> members;
 protected T partyLeader;

 public Party(T partyLeader) {
        this.partyLeader = partyLeader;
 members = new ArrayList<>();
        members.add(partyLeader);
    }

 public T getPartyLeader() {
        return partyLeader;
    }
 public void addMember(T newMember) {
        members.add(newMember);
        partyLeader.welcome(newMember);
    }

 public T removeMember(T memberToRemove) throws CannotRemovePartyLeaderException {
        if (memberToRemove.equals(partyLeader)) {
            throw new CannotRemovePartyLeaderException();
        }
        int memberIndex = members.indexOf(memberToRemove);
        if (memberIndex >= 0) {
            members.remove(memberToRemove);
            memberToRemove.sayGoodbyeTo(partyLeader);
            return memberToRemove;
        } else {
            return null;
        }
    }

    public void makeMembersAct() {
 for (T member : members) {
            member.actAlone();
        }
    }

    public void makeMembersDance() {
 for (T member : members) {
            member.danceAlone();
        }
    }

    public void makeMembersSingALyric(String lyric) {
 for (T member : members) {
            member.singALyric(lyric);
        }
    }

    public void declareNewPartyLeader() throws InsufficientMembersException {
        if (members.size() == 1) {
            throw new InsufficientMembersException(members.size());
        }
 T newPartyLeader = partyLeader;
        while (newPartyLeader.equals(partyLeader)) {
            int pseudoRandomIndex = 
                ThreadLocalRandom.current().nextInt(
                    0, 
                    members.size());
            newPartyLeader = members.get(pseudoRandomIndex);
        }
        partyLeader.speak(
            String.format("%s is our new party leader.", 
                newPartyLeader.getName()));
        newPartyLeader.danceWith(partyLeader);
        if (newPartyLeader.compareTo(partyLeader) < 0) {
            // The new party leader is younger
            newPartyLeader.danceAlone();
        }
        partyLeader = newPartyLeader;
    }
}

现在我们将分析许多代码片段,以了解包含在Party<T>类中的代码是如何工作的。 以下行开始了类体,声明了一个受保护的List<T>,即元素类型为T或实现T接口的元素列表。 List使用泛型来指定将被接受和添加到列表中的元素的类型。

protected final List<T> members;

以下行声明了一个受保护的partyLeader字段,其类型为T

protected T partyLeader;

以下行声明了一个接收partyLeader参数的构造函数,其类型为T。 该参数指定了第一位党领导者,也是党的第一位成员,即添加到membersList<T>的第一个元素。 创建新的ArrayList<T>的代码利用了 Java 7 中引入的类型推断,Java 8 中改进,并在 Java 9 中保留。 我们指定new ArrayList<>()而不是new ArrayList<T>(),因为 Java 9 可以使用空类型参数集(<>)从上下文中推断出类型参数。 members受保护字段具有List<T>类型,因此,Java 的类型推断可以确定T是类型,并且ArrayList<>()意味着ArrayList<T>()。 最后一行将partyLeader添加到members列表中。

public Party(T partyLeader) {
    this.partyLeader = partyLeader;
    members = new ArrayList<>();
    members.add(partyLeader);
}

提示

当我们使用空类型参数集调用泛型类的构造函数时,尖括号(<>)被称为diamond,并且该表示法称为diamond notation

以下行声明了getPartyLeader方法,指定T作为返回类型。 该方法返回partyLeader

public T getPartyLeader() {
    return partyLeader;
}

以下行声明了addMember方法,该方法接收一个类型为TnewMember参数。 该代码将接收到的新成员添加到members列表中,并调用partyLeader.sayWelcomeTo方法,将newMember作为参数,使得党领导者欢迎新成员:

public void addMember(T newMember) {
    members.add(newMember);
    partyLeader.welcome(newMember);
}

以下行声明了removeMember方法,该方法接收一个类型为TmemberToRemove参数,返回T,并且可能抛出CannotRemovePartyLeaderException异常。 方法参数后面的throws关键字,后跟异常名称,表示该方法可以抛出指定的异常。 代码检查要移除的成员是否与党领导者匹配,使用equals方法进行检查。 如果成员是党领导者,则该方法抛出CannotRemovePartyLeaderException异常。 代码检索列表中memberToRemove的索引,并在该成员是列表成员的情况下调用members.remove方法,参数为memberToRemove。 然后,代码调用成功移除成员的sayGoodbyeTo方法,参数为partyLeader。 这样,离开党的成员向党领导者道别。 如果成员被移除,则该方法返回被移除的成员。 否则,该方法返回null

public T removeMember(T memberToRemove) throws CannotRemovePartyLeaderException {
    if (memberToRemove.equals(partyLeader)) {
        throw new CannotRemovePartyLeaderException();
    }
    int memberIndex = members.indexOf(memberToRemove);
    if (memberIndex >= 0) {
        members.remove(memberToRemove);
        memberToRemove.sayGoodbyeTo(partyLeader);
        return memberToRemove;
    } else {
        return null;
    }
}

以下行声明了makeMembersAct方法,该方法调用members列表中每个成员的actAlone方法:

public void makeMembersAct() {
    for (T member : members) {
        member.actAlone();
    }
}

注意

在接下来的章节中,我们将学习在 Java 9 中将面向对象编程与函数式编程相结合的其他编码方法,以执行列表中每个成员的操作。

以下行声明了makeMembersDance方法,该方法调用members列表中每个成员的danceAlone方法:

public void makeMembersDance() {
    for (T member : members) {
        member.danceAlone();
    }
}

以下行声明了makeMembersSingALyric方法,该方法接收一个lyricString并调用members列表中每个成员的singALyric方法,参数为接收到的lyric

public void makeMembersSingALyric(String lyric) {
    for (T member : members) {
        member.singALyric(lyric);
    }
}

提示

请注意,方法没有标记为 final,因此,我们将能够在将来的子类中重写这些方法。

最后,以下行声明了declareNewPartyLeader方法,该方法可能会抛出InsufficientMembersException。与removeMember方法一样,方法参数后的throws关键字后跟着InsufficientMembersException表示该方法可能会抛出InsufficientMembersException异常。如果members列表中只有一个成员,代码将抛出InsufficientMembersException异常,并使用从members.size()返回的值创建继承自Exception的类的实例。请记住,此异常类使用此值初始化一个字段,调用此方法的代码将能够检索到不足的成员数量。如果至少有两个成员,代码将生成一个新的伪随机党领袖,与现有的不同。代码使用ThreadLocalRandom.current().nextInt生成一个伪随机的int范围内的数字。代码调用speak方法让现任领袖向其他党员解释他们有了新的党领袖。代码调用danceWith方法,让新领袖与前任党领袖一起跳舞。如果调用newPartyLeader.compareTo方法与前任党领袖作为参数返回小于0,则意味着新的党领袖比前任年轻,代码将调用newPartyLeader.danceAlone方法。最后,代码将新值设置为partyLeader字段。

public void declareNewPartyLeader() throws InsufficientMembersException {
    if (members.size() == 1) {
        throw new InsufficientMembersException(members.size());
    }
    T newPartyLeader = partyLeader;
    while (newPartyLeader.equals(partyLeader)) {
        int pseudoRandomIndex = 
            ThreadLocalRandom.current().nextInt(
                0, 
                members.size());
        newPartyLeader = members.get(pseudoRandomIndex);
    }
    partyLeader.speak(
        String.format("%s is our new party leader.", 
            newPartyLeader.getName()));
    newPartyLeader.danceWith(partyLeader);
    if (newPartyLeader.compareTo(partyLeader) < 0) {
        // The new party leader is younger
        newPartyLeader.danceAlone();
    }
    partyLeader = newPartyLeader;
}

使用通用类处理多个兼容类型

我们可以通过将T通用类型参数替换为符合Party<T>类声明中指定的类型约束的任何类型名称来创建Party<T>类的实例。到目前为止,我们有三个实现了SociableComparable<Sociable>接口的具体类:SocialLionSocialParrotSocialSwan。因此,我们可以使用SocialLion来创建Party<SocialLion>的实例,即SocialLion的派对。我们利用类型推断,并使用先前解释的菱形符号。这样,我们将创建一个狮子派对,而Simba是党领袖。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

SocialLion simba = new SocialLion("Simba", 10);
SocialLion mufasa = new SocialLion("Mufasa", 5);
SocialLion scar = new SocialLion("Scar", 9);
SocialLion nala = new SocialLion("Nala", 7);
Party<SocialLion> lionsParty = new Party<>(simba);

lionsParty实例将仅接受SocialLion实例,其中类定义使用名为T的通用类型参数。以下行通过调用addMember方法为狮子派对添加了先前创建的三个SocialLion实例。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

lionsParty.addMember(mufasa);
lionsParty.addMember(scar);
lionsParty.addMember(nala);

以下行调用makeMembersAct方法使所有狮子行动,调用makeMembersDance方法使所有狮子跳舞,使用removeMember方法删除不是派对领袖的成员,使用declareNewPartyLeader方法声明一个新领袖,最后调用makeMembersSingALyric方法使所有狮子唱歌。我们将在调用removeMemberdeclareNewPartyLeader之前添加try关键字,因为这些方法可能会抛出异常。在这种情况下,我们不检查removeMember返回的结果。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

lionsParty.makeMembersAct();
lionsParty.makeMembersDance();
try {
    lionsParty.removeMember(nala);
} catch (CannotRemovePartyLeaderException e) {
    System.out.println(
        "We cannot remove the party leader.");
}
try {
    lionsParty.declareNewPartyLeader();
} catch (InsufficientMembersException e) {
    System.out.println(
        String.format("We just have %s member",
            e.getNumberOfMembers()));
}
lionsParty.makeMembersSingALyric("Welcome to the jungle");

以下行显示了在 JShell 中运行前面的代码片段后的输出。但是,我们必须考虑到新派对领袖的伪随机选择,因此结果在每次执行时会有所不同:

Simba welcomes Mufasa
Simba welcomes Scar
Simba welcomes Nala
Simba to be or not to be
Mufasa to be or not to be
Scar to be or not to be
Nala to be or not to be
Simba dances alone *-* ^\/^ (-)
Mufasa dances alone *-* ^\/^ (-)
Scar dances alone *-* ^\/^ (-)
Nala dances alone *-* ^\/^ (-)
Nala says goodbye to Simba RoarRrooaarrRrrrrrrroooooaaarrrr
Simba says: Scar is our new party leader. *-* ^\/^ (-)
Scar dances with Simba *-* ^\/^ (-)
Scar dances alone *-* ^\/^ (-)
Simba sings Welcome to the jungle Roar Rrooaarr Rrrrrrrroooooaaarrrr
Mufasa sings Welcome to the jungle Roar Rrooaarr Rrrrrrrroooooaaarrrr
Scar sings Welcome to the jungle Roar Rrooaarr Rrrrrrrroooooaaarrrr

我们可以使用SocialParrot创建Party<SocialParrot>的实例,即SocialParrotParty。我们使用先前解释的菱形符号。这样,我们将创建一个鹦鹉派对,Rio是派对领袖。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

SocialParrot rio = new SocialParrot("Rio", 3);
SocialParrot thor = new SocialParrot("Thor", 6);
SocialParrot rambo = new SocialParrot("Rambo", 4);
SocialParrot woody = new SocialParrot("Woody", 5);
Party<SocialParrot> parrotsParty = new Party<>(rio);

parrotsParty实例将仅接受SocialParrot实例,用于类定义使用名为T的泛型类型参数的所有参数。以下行通过为每个实例调用addMember方法,将先前创建的三个SocialParrot实例添加到鹦鹉派对中。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

parrotsParty.addMember(thor);
parrotsParty.addMember(rambo);
parrotsParty.addMember(woody);

以下行调用makeMembersDance方法使所有鹦鹉跳舞,使用removeMember方法删除不是派对领袖的成员,使用declareNewPartyLeader方法声明一个新领袖,最后调用makeMembersSingALyric方法使所有鹦鹉唱歌。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_01.java文件中。

parrotsParty.makeMembersDance();
try {
    parrotsParty.removeMember(rambo);
} catch (CannotRemovePartyLeaderException e) {
    System.out.println(
        "We cannot remove the party leader.");
}
try {
    parrotsParty.declareNewPartyLeader();
} catch (InsufficientMembersException e) {
    System.out.println(
        String.format("We just have %s member",
            e.getNumberOfMembers()));
}
parrotsParty.makeMembersSingALyric("Fly like a bird");

以下行显示了在 JShell 中运行前面的代码片段后的输出。再次,我们必须考虑到新派对领袖的伪随机选择,因此结果在每次执行时会有所不同:

Rio welcomes Thor
Rio welcomes Rambo
Rio welcomes Woody
Rio dances alone /|\ -=- % % +=+
Thor dances alone /|\ -=- % % +=+
Rambo dances alone /|\ -=- % % +=+
Woody dances alone /|\ -=- % % +=+
Rambo says goodbye to Rio YeahYeeaahYeeeaaaah
Rio says: Woody is our new party leader. /|\ -=- % % +=+
Woody dances with Rio /|\ -=- % % +=+
Rio sings Fly like a bird Yeah Yeeaah Yeeeaaaah
Thor sings Fly like a bird Yeah Yeeaah Yeeeaaaah
Woody sings Fly like a bird Yeah Yeeaah Yeeeaaaah

以下行将无法编译,因为我们使用了不兼容的类型。首先,我们尝试将SocialParrot实例rio添加到Party<SocialLion>lionsParty。然后,我们尝试将SocialLion实例simba添加到Party<SocialParrot>parrotsParty。这两行都将无法编译,并且 JShell 将显示一条消息,指示类型不兼容,它们无法转换为每个派对所需的必要类型。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_02.java文件中。

// The following lines won't compile
// and will generate errors in JShell
lionsParty.addMember(rio);
parrotsParty.addMember(simba);

以下屏幕截图显示了在我们尝试执行前面的行时 JShell 中显示的错误:

使用泛型类处理多个兼容类型

我们可以使用SocialSwan创建Party<SocialSwan>的实例,即SocialSwanParty。这样,我们将创建一个天鹅派对,Kevin是派对领袖。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_03.java文件中。

SocialSwan kevin = new SocialSwan("Kevin", 3);
SocialSwan brandon = new SocialSwan("Brandon", 5);
SocialSwan nicholas = new SocialSwan("Nicholas", 6);
Party<SocialSwan> swansParty = new Party<>(kevin);

swansParty实例将仅接受SocialSwan实例,用于类定义使用名为T的泛型类型参数的所有参数。以下行通过为每个实例调用addMember方法,将先前创建的两个SocialSwan实例添加到天鹅派对中。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_03.java文件中。

swansParty.addMember(brandon);
swansParty.addMember(nicholas);

以下行调用makeMembersDance方法使所有鹦鹉跳舞,使用removeMember方法尝试移除党领袖,使用declareNewPartyLeader方法宣布新领袖,最后调用makeMembersSingALyric方法使所有天鹅唱歌。示例的代码文件包含在java_9_oop_chapter_10_01文件夹中的example10_03.java文件中。

swansParty.makeMembersDance();
try {
    swansParty.removeMember(kevin);
} catch (CannotRemovePartyLeaderException e) {
    System.out.println(
        "We cannot remove the party leader.");
}
try {
    swansParty.declareNewPartyLeader();
} catch (InsufficientMembersException e) {
    System.out.println(
        String.format("We just have %s member",
            e.getNumberOfMembers()));
}
swansParty.makeMembersSingALyric("It will be our swan song");

以下行显示了在 JShell 中运行前面的代码片段后的输出。再次,我们必须考虑到新的党领袖是伪随机选择的,因此,结果在每次执行时都会有所不同:

Kevin welcomes Brandon
Kevin welcomes Nicholas
Kevin dances alone ^- ^- ^- -^ -^ -^
Brandon dances alone ^- ^- ^- -^ -^ -^
Nicholas dances alone ^- ^- ^- -^ -^ -^
We cannot remove the party leader.
Kevin says: Brandon is our new party leader. ^- ^- ^- -^ -^ -^
Brandon dances with Kevin ^- ^- ^- -^ -^ -^
Kevin sings It will be our swan song OO-OO-OO WHO-HO WHO-HO WHO-WHO WHO-WHO
Brandon sings It will be our swan song OO-OO-OO WHO-HO WHO-HO WHO-WHO WHO-WHO
Nicholas sings It will be our swan song OO-OO-OO WHO-HO WHO-HO WHO-WHO WHO-WHO

测试你的知识

  1. public class Party<T extends Sociable & Comparable<Sociable>>行的意思是:

  2. 泛型类型约束指定T必须实现SociableComparable<Sociable>接口之一。

  3. 泛型类型约束指定T必须实现SociableComparable<Sociable>接口。

  4. 该类是SociableComparable<Sociable>类的子类。

  5. 以下哪行与 Java 9 中的List<SocialLion> lionsList = new ArrayList<SocialLion>();等效:

  6. List<SocialLion> lionsList = new ArrayList();

  7. List<SocialLion> lionsList = new ArrayList<>();

  8. var lionsList = new ArrayList<SocialLion>();

  9. 以下哪行使用了钻石符号来利用 Java 9 的类型推断:

  10. List<SocialLion> lionsList = new ArrayList<>();

  11. List<SocialLion> lionsList = new ArrayList();

  12. var lionsList = new ArrayList<SocialLion>();

  13. Java 9 允许我们通过以下方式使用参数多态性:

  14. 鸭子打字。

  15. 兔子打字。

  16. 泛型。

  17. 以下哪个代码片段声明了一个类,其泛型类型约束指定T必须实现SociableConvertible接口:

  18. public class Game<T extends Sociable & Convertible>

  19. public class Game<T: where T is Sociable & Convertible>

  20. public class Game<T extends Sociable> where T: Convertible

总结

在本章中,您学会了通过编写能够与不同类型的对象一起工作的代码来最大化代码重用,也就是说,能够实现特定接口的类的实例或其类层次结构包括特定超类的类的实例。我们使用了接口、泛型和受限泛型类型。

我们创建了能够使用受限泛型类型的类。我们结合了类继承和接口,以最大化代码的可重用性。我们可以使类与许多不同类型一起工作,我们能够编写一个能够被重用来创建狮子、鹦鹉和天鹅的派对行为的类。

现在您已经学会了关于参数多态性和泛型的基础知识,我们准备在 Java 9 中与泛型最大化代码重用的更高级场景一起工作,这是我们将在下一章讨论的主题。

第十一章:高级泛型

在本章中,我们将深入探讨参数多态性以及 Java 9 如何允许我们编写使用两个受限泛型类型的类的通用代码。我们将:

  • 在更高级的场景中使用参数多态性

  • 创建一个新接口,用作第二个类型参数的约束

  • 声明两个实现接口以处理两个类型参数的类

  • 声明一个使用两个受限泛型类型的类

  • 使用具有两个泛型类型参数的泛型类

创建一个新接口,用作第二个类型参数的约束

到目前为止,我们一直在处理派对,其中派对成员是善于社交的动物。然而,没有一些音乐很难享受派对。善于社交的动物需要听到一些东西,以便让它们跳舞并享受他们的派对。我们想要创建一个由善于社交的动物和一些可听到的东西组成的派对。

现在,我们将创建一个新的接口,稍后在定义另一个利用两个受限泛型类型的类时将使用该接口作为约束。以下是Hearable接口的代码。

示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

public interface Hearable {
    void playMusic();
    void playMusicWithLyrics(String lyrics);
}

接口声明了两个方法要求:playMusicplayMusicWithLyrics。正如我们在之前的章节中学到的,接口只包括方法声明,因为实现Hearable接口的类将负责提供这两个方法的实现。

声明两个实现接口以处理两个类型参数

现在,我们将声明一个名为Smartphone的类,该类实现先前定义的Hearable接口。我们可以将类声明解读为“Smartphone类实现Hearable接口”。以下是新类的代码。

public class Smartphone implements Hearable {
    public final String modelName;

    public Smartphone(String modelName) {
        this.modelName = modelName;
    }

    @Override
    public void playMusic() {
        System.out.println(
            String.format("%s starts playing music.",
                modelName));
        System.out.println(
            String.format("cha-cha-cha untz untz untz",
                modelName));
    }

    @Override
    public void playMusicWithLyrics(String lyrics) {
        System.out.println(
            String.format("%s starts playing music with lyrics.",
                modelName));
        System.out.println(
            String.format("untz untz untz %s untz untz",
                lyrics));
    }
}

Smartphone类声明了一个构造函数,将必需的modelName参数的值分配给modelName不可变字段。此外,该类实现了Hearable接口所需的两个方法:playMusicplayMusicWithLyrics

playMusic方法打印一条消息,显示智能手机型号名称,并指示设备开始播放音乐。然后,该方法以文字形式打印多个声音。playMusicWithLyrics方法打印一条消息,显示智能手机型号名称,然后是另一条包含文字声音和作为参数接收的歌词的消息。

现在,我们将声明一个名为AnimalMusicBand的类,该类也实现了先前定义的Hearable接口。我们可以将类声明解读为“AnimalMusicBand类实现Hearable接口”。以下是新类的代码。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

public class AnimalMusicBand implements Hearable {
    public final String bandName;
    public final int numberOfMembers;

    public AnimalMusicBand(String bandName, int numberOfMembers) {
        this.bandName = bandName;
        this.numberOfMembers = numberOfMembers;
    }

    @Override
    public void playMusic() {
        System.out.println(
            String.format("Our name is %s. We are %d.",
                bandName,
                numberOfMembers));
        System.out.println(
            String.format("Meow Meow Woof Woof Meow Meow",
                bandName));
    }

    @Override
    public void playMusicWithLyrics(String lyrics) {
        System.out.println(
            String.format("%s asks you to sing together.",
                bandName));
        System.out.println(
            String.format("Meow Woof %s Woof Meow",
                lyrics));
    }
}

AnimalMusicBand类声明了一个构造函数,将必需的bandNamenumberOfMembers参数的值分配给与这些参数同名的不可变字段。此外,该类实现了Hearable接口所需的两个方法:playMusicplayMusicWithLyrics

playMusic方法打印一条消息,向观众介绍动物音乐乐队,并指出成员数量。然后,该方法以文字形式打印多个声音。playMusicWithLyrics方法打印一条消息,要求观众与动物音乐乐队一起唱歌,然后是另一条带有文字和作为参数接收的歌词的消息。

声明一个与两个受限泛型类型一起工作的类

以下行声明了一个PartyWithHearable子类,该子类是先前创建的Party<T>类的子类,利用泛型来处理两个受限类型。类型约束声明包含在尖括号(<>)中。在这种情况下,我们有两个泛型类型参数:TU。名为T的泛型类型参数必须实现SociableComparable<Sociable>接口,就像在Party<T>超类中一样。名为U的泛型类型参数必须实现Hearable接口。请注意,跟随类型参数的extends关键字允许我们向泛型类型参数添加约束,尖括号之后的相同关键字指定该类继承自Party<T>超类。这样,该类为TU泛型类型参数指定了约束,并且继承自Party<T>。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

public class PartyWithHearable<T extends Sociable & Comparable<Sociable>, U extends Hearable> extends Party<T> {
 protected final U soundGenerator;

 public PartyWithHearable(T partyLeader, U soundGenerator) {
        super(partyLeader);
 this.soundGenerator = soundGenerator;
    }

    @Override
    public void makeMembersDance() {
 soundGenerator.playMusic();
        super.makeMembersDance();
    }

    @Override
    public void makeMembersSingALyric(String lyric) {
 soundGenerator.playMusicWithLyrics(lyric);
        super.makeMembersSingALyric(lyric);
    }
}

提示

当 Java 中的类型参数有约束时,它们也被称为有界类型参数。此外,类型约束也被称为有界类型参数的上界,因为任何实现用作上界的接口或任何指定为上界的类的子类都可以用于类型参数。

现在我们将分析许多代码片段,以了解包含在PartyWithHearable<T, U>类中的代码是如何工作的。以下行开始类体并声明了一个受保护的不可变的soundGenerator字段,其类型由U指定:

protected final U soundGenerator;

以下行声明了一个初始化器,该初始化器接收两个参数,partyLeadersoundGenerator,它们的类型分别为TU。这些参数指定了将成为派对第一领导者并成为派对第一成员的第一领导者,以及将使派对成员跳舞和唱歌的声音发生器。构造函数使用super关键字调用其超类中定义的构造函数,并将partyLeader作为参数。

public PartyWithHearable(T partyLeader, U soundGenerator) {
    super(partyLeader);
    this.soundGenerator = soundGenerator;
}

以下行声明了一个makeMembersDance方法,该方法覆盖了在超类中包含的相同声明的方法。代码调用soundGenetor.playMusic方法,然后使用super关键字调用super.makeMembersDance方法,即在Party<T>超类中定义的makeMembersDance方法:

@Override
public void makeMembersDance() {
    soundGenerator.playMusic();
    super.makeMembersDance();
}

注意

当我们在子类中覆盖一个方法时,我们可以使用super关键字后跟一个点(.)和方法名来调用在超类中定义的方法,并将所需的参数传递给该方法。使用super关键字允许我们调用在超类中被覆盖的实例方法。这样,我们可以向方法添加新特性,同时仍然调用基本方法。

最后,以下行声明了一个makeMembersSingALyric方法,该方法覆盖了在超类中包含的相同声明的方法。代码调用soundGenerator.playMusicWithLyrics方法,并将接收到的lyrics作为参数。然后,代码调用super.makeMembersSingALyric方法,并将接收到的lyrics作为参数,即在Party<T>超类中定义的makeMembersSingALyric方法:

@Override
public void makeMembersSingALyric(String lyric) {
    soundGenerator.playMusicWithLyrics(lyric);
    super.makeMembersSingALyric(lyric);
}

以下 UML 图显示了我们将创建的接口和具体子类,包括所有字段和方法。

声明一个与两个受限泛型类型一起工作的类

使用两个泛型类型参数创建泛型类的实例

我们可以通过用符合PartyWithHearable<T, U>类声明中指定的约束或上界的任何类型名称替换TU泛型类型参数来创建PartyWithHearable<T, U>类的实例。我们有三个具体类实现了T泛型类型参数所需的SociableComparable<Sociable>接口:SocialLionSocialParrotSocialSwan。我们有两个实现了U泛型类型参数所需的Hearable接口的类:SmartphoneAnimalMusicBand

我们可以使用SocialLionSmartphone来创建PartyWithHearable<SocialLion, Smartphone>的实例,即社交狮和智能手机的聚会。然后,我们可以使用SocialParrotAnimalMusicBand来创建PartyWithHearable<SocialParrot, AnimalMusicBand>的实例,即社交鹦鹉和动物音乐乐队的聚会。

以下行创建了一个名为androidSmartphone实例。然后,代码创建了一个名为nalaPartyPartyWithHearable<SocialLion, Smartphone>实例,并将nalaandroid作为参数传递。我们利用了类型推断,并使用了我们在上一章学到的菱形符号表示法,第十章, 泛型的代码重用最大化。这样,我们创建了一个使用智能手机的社交狮聚会,其中Nala是聚会领袖,Super Android Smartphone是可听或音乐生成器。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

Smartphone android = new Smartphone("Super Android Smartphone");
PartyWithHearable<SocialLion, Smartphone> nalaParty = 
    new PartyWithHearable<>(nala, android);

nalaParty实例将只接受SocialLion实例,用于类定义中使用泛型类型参数T的所有参数。nalaParty实例将只接受Smartphone实例,用于类定义中使用泛型类型参数U的所有参数。以下行通过调用addMember方法将之前创建的三个SocialLion实例添加到聚会中。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

nalaParty.addMember(simba);
nalaParty.addMember(mufasa);
nalaParty.addMember(scar);

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

创建具有两个泛型类型参数的泛型类的实例

以下行调用makeMembersDance方法,使智能手机的播放列表邀请所有狮子跳舞并使它们跳舞。然后,代码调用removeMember方法来移除不是聚会领袖的成员,使用declareNewPartyLeader方法来声明一个新的领袖,最后调用makeMembersSingALyric方法来使智能手机的播放列表邀请所有狮子唱特定的歌词并使他们唱这个歌词。请记住,在调用removeMemberdeclareNewPartyLeader之前,我们在这些方法前加上try关键字,因为这些方法可能会抛出异常。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_01.java文件中。

nalaParty.makeMembersDance();
try {
    nalaParty.removeMember(mufasa);
} catch (CannotRemovePartyLeaderException e) {
    System.out.println(
        "We cannot remove the party leader.");
}
try {
    nalaParty.declareNewPartyLeader();
} catch (InsufficientMembersException e) {
    System.out.println(
        String.format("We just have %s member",
            e.getNumberOfMembers()));
}
nalaParty.makeMembersSingALyric("It's the eye of the tiger");

以下屏幕截图显示了在 JShell 中执行上述代码的结果:

创建具有两个泛型类型参数的泛型类的实例

以下行显示了在 JShell 中运行前面代码片段后的输出。但是,我们必须考虑到新聚会领袖的伪随机选择,因此结果在每次执行时会有所不同:

Nala welcomes Simba
Nala welcomes Mufasa
Nala welcomes Scar
Super Android Smartphone starts playing music.
cha-cha-cha untz untz untz
Nala dances alone *-* ^\/^ (-)
Simba dances alone *-* ^\/^ (-)
Mufasa dances alone *-* ^\/^ (-)
Scar dances alone *-* ^\/^ (-)
Mufasa says goodbye to Nala RoarRrooaarrRrrrrrrroooooaaarrrr
Nala says: Simba is our new party leader. *-* ^\/^ (-)
Simba dances with Nala *-* ^\/^ (-)
Super Android Smartphone starts playing music with lyrics.
untz untz untz It's the eye of the tiger untz untz
Nala sings It's the eye of the tiger Roar Rrooaarr Rrrrrrrroooooaaarrrr
Simba sings It's the eye of the tiger Roar Rrooaarr Rrrrrrrroooooaaarrrr
Scar sings It's the eye of the tiger Roar Rrooaarr Rrrrrrrroooooaaarrrr

以下行创建了一个名为bandAnimalMusicBand实例。然后,代码创建了一个名为ramboPartyPartyWithHearable<SocialParrot, AnimalMusicBand>实例,并将ramboband作为参数传递。与之前的示例一样,我们利用了类型推断,并且使用了我们在上一章学习的菱形符号,第十章, 泛型的代码重用最大化。这样,我们创建了一个由四只动物组成的音乐乐队的社交鹦鹉派对,其中Rambo是派对领袖,Black Eyed Paws是可听到的或音乐发生器。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_02.java文件中。

AnimalMusicBand band = new AnimalMusicBand(
    "Black Eyed Paws", 4);
PartyWithHearable<SocialParrot, AnimalMusicBand> ramboParty = 
    new PartyWithHearable<>(rambo, band);

ramboParty实例只接受SocialParrot实例作为类定义中使用泛型类型参数T的所有参数。ramboParty实例只接受AnimalMusicBand实例作为类定义中使用泛型类型参数U的所有参数。以下行通过调用addMember方法将之前创建的三个SocialParrot实例添加到派对中。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_02.java文件中。

ramboParty.addMember(rio);
ramboParty.addMember(woody);
ramboParty.addMember(thor);

以下截图显示了在 JShell 中执行上一个代码的结果。

使用两个泛型类型参数创建泛型类的实例

以下行调用makeMembersDance方法,使动物音乐乐队邀请所有鹦鹉跳舞,告诉它们乐队中有四名成员并让它们跳舞。然后,代码调用removeMember方法来移除不是派对领袖的成员,使用declareNewPartyLeader方法来声明一个新的领袖,最后调用makeMembersSingALyric方法,使动物音乐乐队邀请所有鹦鹉唱特定的歌词并让它们唱这个歌词。请记住,在调用removeMemberdeclareNewPartyLeader之前我们加上了try关键字,因为这些方法可能会抛出异常。示例的代码文件包含在java_9_oop_chapter_11_01文件夹中的example11_02.java文件中。

ramboParty.makeMembersDance();
try {
    ramboParty.removeMember(rio);
} catch (CannotRemovePartyLeaderException e) {
    System.out.println(
        "We cannot remove the party leader.");
}
try {
    ramboParty.declareNewPartyLeader();
} catch (InsufficientMembersException e) {
    System.out.println(
        String.format("We just have %s member",
            e.getNumberOfMembers()));
}
ramboParty.makeMembersSingALyric("Turn up the radio");

以下截图显示了在 JShell 中执行上一个代码的结果:

使用两个泛型类型参数创建泛型类的实例

以下行显示了在 JShell 中运行前面的代码片段后的输出。但是,我们必须考虑到新派对领袖的伪随机选择,因此结果在每次执行时会有所不同:

Rambo welcomes Rio
Rambo welcomes Woody
Rambo welcomes Thor
Our name is Black Eyed Paws. We are 4.
Meow Meow Woof Woof Meow Meow
Rambo dances alone /|\ -=- % % +=+
Rio dances alone /|\ -=- % % +=+
Woody dances alone /|\ -=- % % +=+
Thor dances alone /|\ -=- % % +=+
Rio says goodbye to Rambo YeahYeeaahYeeeaaaah
Rambo says: Thor is our new party leader. /|\ -=- % % +=+
Thor dances with Rambo /|\ -=- % % +=+
Black Eyed Paws asks you to sing together.
Meow Woof Turn up the radio Woof Meow
Rambo sings Turn up the radio Yeah Yeeaah Yeeeaaaah
Woody sings Turn up the radio Yeah Yeeaah Yeeeaaaah
Thor sings Turn up the radio Yeah Yeeaah Yeeeaaaah

测试你的知识

  1. PartyWithHearable<T extends Sociable & Comparable<Sociable>, U extends Hearable>这一行的意思是:

  2. 泛型类型约束指定T必须实现SociableComparable<Sociable>接口,U必须实现Hearable接口。

  3. 该类是SociableComparable<Sociable>Hearable类的子类。

  4. 泛型类型约束指定T必须实现SociableComparable<Sociable>接口,U必须实现Hearable接口。

  5. 以下哪一行等同于 Java 9 中的PartyWithHearable<SocialLion, Smartphone>lionsParty = new PartyWithHearable<SocialLion, Smartphone>(nala, android);

  6. PartyWithHearable<SocialLion, Smartphone> lionsParty = new PartyWithHearable<>(nala, android);

  7. PartyWithHearable<SocialLion, Smartphone> lionsParty = new PartyWithHearable(nala, android);

  8. let lionsParty = new PartyWithHearable(nala, android);

  9. 当我们在使用extends关键字的有界类型参数时:

  10. 实现指定为上界的接口的任何类都可以用于类型参数。如果指定的名称是一个类的名称,则其子类不能用于类型参数。

  11. 实现指定为上界的接口或指定为上界的类的任何子类都可以用于类型参数。

  12. 指定为上界的类的任何子类都可以用于类型参数。如果指定的名称是一个接口的名称,则实现该接口的类不能用于类型参数。

  13. 当 Java 中的类型参数具有约束时,它们也被称为:

  14. 灵活的类型参数。

  15. 无界类型参数。

  16. 有界类型参数。

  17. 以下哪个代码片段声明了一个类,其泛型类型约束指定T必须实现Sociable接口,U必须实现Convertible接口:

  18. public class Game<T: where T is Sociable, U: where U is Convertible>

  19. public class Game<T extends Sociable> where U: Convertible

  20. public class Game<T extends Sociable, U extends Convertible>

摘要

在本章中,您学习了通过编写能够与两个类型参数一起工作的代码来最大化代码重用。我们处理了涉及接口、泛型和具有约束的多个类型参数的更复杂的情况,也称为有界类型参数。

我们创建了一个新接口,然后声明了两个实现了这个新接口的类。然后,我们声明了一个使用了两个受限泛型类型参数的类。我们结合了类继承和接口,以最大化代码的可重用性。我们可以使类与许多不同类型一起工作,并且能够编写具有不同音乐生成器的派对的行为,然后可以重用这些行为来创建带有智能手机的狮子派对和带有动物乐队的鹦鹉派对。

Java 9 允许我们处理更复杂的情况,在这些情况下,我们可以为泛型类型参数指定更多的限制或边界。然而,大多数情况下,我们将处理本章和上一章中学到的示例所涵盖的情况。

现在您已经学会了参数多态性和泛型的高级用法,我们准备在 Java 9 中将面向对象编程和函数式编程相结合,这是我们将在下一章中讨论的主题。

第十二章:面向对象,函数式编程和 Lambda 表达式

在本章中,我们将讨论函数式编程以及 Java 9 如何实现许多函数式编程概念。我们将使用许多示例来演示如何将函数式编程与面向对象编程相结合。我们将:

  • 将函数和方法视为一等公民

  • 使用函数接口和 Lambda 表达式

  • 创建数组过滤的函数版本

  • 使用泛型和接口创建数据存储库

  • 使用复杂条件过滤集合

  • 使用 map 操作转换值

  • 将 map 操作与 reduce 结合

  • 使用 map 和 reduce 链式操作

  • 使用不同的收集器

将函数和方法视为一等公民

自 Java 首次发布以来,Java 一直是一种面向对象的编程语言。从 Java 8 开始,Java 增加了对函数式编程范式的支持,并在 Java 9 中继续这样做。函数式编程偏爱不可变数据,因此,函数式编程避免状态更改。

注意

使用函数式编程风格编写的代码尽可能声明性,并且专注于它所做的事情,而不是它必须如何做。

在大多数支持函数式编程范式的编程语言中,函数是一等公民,也就是说,我们可以将函数用作其他函数或方法的参数。Java 8 引入了许多更改,以减少样板代码,并使方法成为 Java 中的一等公民变得容易,并且使得编写使用函数式编程方法的代码变得容易。我们可以通过一个简单的示例,例如过滤列表,轻松理解这个概念。但是,请注意,我们将首先编写具有方法作为一等公民的命令式代码,然后,我们将为此代码创建一个使用 Java 9 中的过滤操作的完整函数式方法的新版本。我们将创建许多版本的此示例,因为这将使我们能够了解在 Java 9 中如何实现函数式编程。

首先,我们将编写一些代码,考虑到我们仍然不知道 Java 9 中包含的将方法转换为一等公民的功能。然后,我们将在许多示例中使用这些功能。

以下行声明了Testable接口,该接口指定了一个接收int类型的number参数并返回boolean结果的方法要求。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

public interface Testable {
    boolean test(int number);
}

以下行声明了实现先前声明的Testable接口的TestDivisibleBy5具体类。该类使用包含返回boolean值的代码实现test方法,指示接收到的数字是否可以被5整除。如果数字和5之间的模运算结果等于0,则表示该数字可以被5整除。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

public class TestDivisibleBy5 implements Testable {
    @Override
    public boolean test(int number) {
        return ((number % 5) == 0);
    }
}

以下行声明了实现先前声明的Testable接口的TestGreaterThan10具体类。该类使用包含返回boolean值的代码实现test方法,指示接收到的数字是否大于10。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

public class TestGreaterThan10 implements Testable {
    @Override
    public boolean test(int number) {
        return (number > 10);
    }
}

以下几行声明了filterNumbersWithTestable方法,该方法接收numbers参数中的List<Integer>tester参数中的Testable实例。该方法使用外部的for循环,即命令式代码,为numbers中的每个Integer元素调用tester.test方法。如果test方法返回true,则代码将Integer元素添加到filteredNumbersList<Integer>中,具体来说,是一个ArrayList<Integer>。最后,该方法将filteredNumbersList<Integer>作为结果返回,其中包含满足测试条件的所有Integer对象。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

public List<Integer> filterNumbersWithTestable(final List<Integer> numbers,
    Testable tester) {
    List<Integer> filteredNumbers = new ArrayList<>();
    for (Integer number : numbers) {
        if (tester.test(number)) {
            filteredNumbers.add(number);
        }
    }
    return filteredNumbers; 
}

filterNumbersWithTestable方法使用两个List<Integer>对象,即两个ListInteger对象。我们讨论的是Integer而不是int原始类型。Integerint原始类型的包装类。但是,我们在Testable接口中声明的test方法,然后在实现该接口的两个类中实现,接收的是int类型的参数,而不是Integer

Java 会自动将原始值转换为相应包装类的对象。每当我们将对象作为参数传递给期望原始类型值的方法时,Java 编译器将该对象转换为相应的原始类型,这个操作称为拆箱。在下一行中,Java 编译器将Integer对象转换或拆箱为int类型的值。

if (tester.test(number)) {

编译器将执行等效于调用intValue()方法的代码,该方法将Integer拆箱为int

if (tester.test(number.intValue())) {

我们不会编写for循环来填充List中的Integer对象。相反,我们将使用IntStream类,该类专门用于描述int原始类型的流。这些类定义在java.util.stream包中,因此,我们必须添加一个import语句才能在 JShell 中使用它。以下一行调用IntStream.rangeClosed方法,参数为120,以生成一个包含从120(包括)的int值的IntStream。链式调用boxed方法将生成的IntStream转换为Stream<Integer>,即从原始int值装箱成的Integer对象流。链式调用collect方法,参数为Collectors.toList(),将Integer对象流收集到List<Integer>中,具体来说,是一个ArrayList<Integer>Collectors类也定义在java.util.stream包中。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

import java.util.stream.Collectors;
import java.util.stream.IntStream;

List<Integer> range1to20 = 
    IntStream.rangeClosed(1, 20).boxed().collect(Collectors.toList());

提示

装箱和拆箱会增加开销,并且会对性能和内存产生影响。在某些情况下,我们可能需要重写我们的代码,以避免不必要的装箱和拆箱,从而实现最佳性能。

非常重要的是要理解collect操作将开始处理管道以返回所需的结果,即从中间流生成的列表。在调用collect方法之前,中间操作不会被执行。以下屏幕截图显示了在 JShell 中执行前几行的结果。我们可以看到range1to20是一个包含从 1 到 20(包括)的Integer列表,装箱成Integer对象。

理解函数和方法作为一等公民

以下行创建了一个名为testDivisibleBy5TestDivisibleBy5类的实例。然后,代码使用List<Integer> range1to20作为numbers参数,使用名为testDivisibleBy5TestDivisibleBy5实例作为tester参数调用了filterNumbersWithTestable方法。代码运行后,List<Integer> divisibleBy5Numbers将具有以下值:[5, 10, 15, 20]。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

TestDivisibleBy5 testDivisibleBy5 = new TestDivisibleBy5();
List<Integer> divisibleBy5Numbers = 
filterNumbersWithTestable(range1to20, testDivisibleBy5);
System.out.println(divisibleBy5Numbers);

以下行创建了一个名为testGreaterThan10TestGreaterThan10类的实例。然后,代码使用range1to20testGreaterThan10作为参数调用了filterNumbersWithTestable方法。代码运行后,List<Integer> greaterThan10Numbers将具有以下值:[11, 12, 13, 14, 15, 16, 17, 18, 19, 20]。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_01.java文件中。

TestGreaterThan10 testGreaterThan10 = new TestGreaterThan10();
List<Integer> greaterThan10Numbers = 
    filterNumbersWithTestable(range1to20, testGreaterThan10);
System.out.println(greaterThan10Numbers);

以下屏幕截图显示了在 JShell 中执行前面行的结果:

理解函数和方法作为一等公民

使用函数接口和 Lambda 表达式

我们不得不声明一个接口和两个类,以使方法能够接收Testable的实例并执行每个类实现的test方法成为可能。幸运的是,Java 8 引入了函数接口,Java 9 使我们能够在代码需要函数接口时提供兼容的Lambda 表达式。简而言之,我们可以写更少的代码来实现相同的目标。

注意

函数接口是满足以下条件的接口:它具有单个抽象方法或单个方法要求。我们可以使用 Lambda 表达式、方法引用或构造函数引用创建函数接口的实例。我们将使用不同的示例来理解 Lambda 表达式、方法引用和构造函数引用,并看到它们的实际应用。

IntPredicate函数接口表示具有一个int类型参数并返回一个boolean结果的函数。布尔值函数称为谓词。该函数接口在java.util.function中定义,因此在使用之前我们必须包含一个import语句。

以下行声明了filterNumbersWithPredicate方法,该方法接收List<Integer>作为numbers参数,并接收IntPredicate实例作为predicate参数。该方法的代码与为filterNumbersWithTestable方法声明的代码相同,唯一的区别是,新方法接收的不是名为testerTestable类型参数,而是名为predicateIntPredicate类型参数。代码还调用了test方法,将从列表中检索的每个数字作为参数进行评估。IntPredicate函数接口定义了一个名为test的抽象方法,该方法接收一个int并返回一个boolean结果。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_02.java文件中。

import java.util.function.IntPredicate;

public List<Integer> filterNumbersWithPredicate(final List<Integer> numbers,
    IntPredicate predicate) {
    List<Integer> filteredNumbers = new ArrayList<>();
    for (Integer number : numbers) {
        if (predicate.test(number)) {
            filteredNumbers.add(number);
        }
    }
    return filteredNumbers; 
}

以下行声明了一个名为divisibleBy5的变量,类型为IntPredicate,并将一个 Lambda 表达式赋给它。具体来说,代码赋予了一个 Lambda 表达式,该表达式接收一个名为nint参数,并返回一个boolean值,指示n5之间的模运算(%)是否等于0。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_02.java文件中。

IntPredicate divisibleBy5 = n -> n % 5 == 0;

Lambda 表达式由以下三个组件组成:

  • n:参数列表。在这种情况下,只有一个参数,因此不需要用括号括起参数列表。如果有多个参数,需要用括号括起列表。我们不必为参数指定类型。

  • ->:箭头标记。

  • n % 5 == 0:主体。在这种情况下,主体是一个单一表达式,因此不需要用大括号({})括起来。此外,在表达式之前也不需要写return语句,因为它是一个单一表达式。

前面的代码等同于以下代码。前面的代码是最短版本,下一行是最长版本:

IntPredicate divisibleBy5 = (n) ->{ return n % 5 == 0 };

想象一下,使用前面两个版本的任何一个代码,我们正在执行以下任务:

  1. 创建一个实现IntPredicate接口的匿名类。

  2. 在匿名类中声明一个接收int参数并返回boolean的测试方法,指定箭头标记(->)后的主体。

  3. 创建一个匿名类的实例。

每当我们输入 lambda 表达式时,当需要IntPredicate时,所有这些事情都是在幕后发生的。当我们为其他函数接口使用 lambda 表达式时,类似的事情会发生,不同之处在于方法名称、参数和方法的返回类型可能会有所不同。

注意

Java 编译器从函数接口中推断出参数和返回类型的类型。事物保持强类型,如果我们在类型上犯了错误,编译器将生成适当的错误,代码将无法编译。

以下行调用filterNumbersWithPredicate方法,使用List<Integer> range1to20作为numbers参数,名为divisibleBy5IntPredicate实例作为predicate参数。代码运行后,List<Integer> divisibleBy5Numbers2将具有以下值:[5, 10, 15, 20]。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_02.java文件中。

List<Integer> divisibleBy5Numbers2 = 
    filterNumbersWithPredicate(range1to20, divisibleBy5);
System.out.println(divisibleBy5Numbers2);

以下行调用filterNumbersWithPredicate方法,使用List<Integer> range1to20作为numbers参数,使用 lambda 表达式作为predicate参数。lambda 表达式接收一个名为nint参数,并返回一个boolean值,指示n是否大于10。代码运行后,List<Integer> greaterThan10Numbers2将具有以下值:[11, 12, 13, 14, 15, 16, 17, 18, 19, 20]。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_02.java文件中。

List<Integer> greaterThan10Numbers2 = 
    filterNumbersWithPredicate(range1to20, n -> n > 10);
System.out.println(greaterThan10Numbers2);

以下截图显示了在 JShell 中执行前几行的结果。

使用函数接口和 lambda 表达式

Function<T, R>函数接口表示一个函数,其中T是函数的输入类型,R是函数的结果类型。我们不能为T指定原始类型,比如int,因为它不是一个类,但我们可以使用装箱类型,即Integer。我们不能为R使用boolean,但我们可以使用装箱类型,即Boolean。如果我们想要与IntPredicate函数接口类似的行为,我们可以使用Function<Integer, Boolean>,即一个具有Integer类型的参数的函数,返回一个Boolean结果。这个函数接口在java.util.function中定义,因此在使用之前,我们必须包含一个import语句。

以下行声明了filterNumbersWithFunction方法,该方法接收numbers参数中的List<Integer>predicate参数中的Function<Integer, Boolean>实例。该方法的代码与filterNumbersWithCondition方法声明的代码相同,不同之处在于新方法接收了Function<Integer, Boolean>类型的参数function,而不是接收了名为predicateIntPredicate类型的参数。代码调用apply方法,并将从列表中检索到的每个数字作为参数进行评估,而不是调用test方法。

Function<T, R>功能接口定义了一个名为 apply 的抽象方法,该方法接收一个T并返回类型为R的结果。在这种情况下,apply 方法接收一个Integer并返回一个Boolean,Java 编译器将自动拆箱为boolean。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_03.java文件中。

import java.util.function.Function;

public List<Integer> filterNumbersWithFunction(final List<Integer> numbers,
 Function<Integer, Boolean> function) {
    List<Integer> filteredNumbers = new ArrayList<>();
    for (Integer number : numbers) {
 if (function.apply(number)) {
            filteredNumbers.add(number);
        }
    }
    return filteredNumbers; 
}

以下行调用了filterNumbersWithFunction方法,将List<Integer> range1to20作为numbers参数,并将 lambda 表达式作为function参数。lambda 表达式接收一个名为nInteger参数,并返回一个Boolean值,指示n3之间的模运算结果是否等于0。Java 会自动将表达式生成的boolean值装箱为Boolean对象。代码运行后,List<Integer> divisibleBy3Numbers将具有以下值:[3, 6, 9, 12, 15, 18]。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_03.java文件中。

List<Integer> divisibleBy3Numbers = 
    filterNumbersWithFunction(range1to20, n -> n % 3 == 0);

Java 将运行等效于以下行的代码。intValue()函数为n中接收的Integer实例返回一个int值,lambda 表达式返回表达式评估生成的boolean值的新Boolean实例。但是,请记住,装箱和拆箱是在幕后发生的。

List<Integer> divisibleBy3Numbers = 
    filterNumbersWithFunction(range1to20, n -> new Boolean(n.intValue() % 3 == 0));

java.util.function中定义了 40 多个功能接口。我们只使用了其中两个能够处理相同 lambda 表达式的接口。我们可以专门撰写一本书来详细分析所有功能接口。我们将继续专注于将面向对象与函数式编程相结合。然而,非常重要的是要知道,在声明自定义功能接口之前,我们必须检查java.util.function中定义的所有功能接口。

创建数组过滤的功能性版本

先前声明的filterNumbersWithFunction方法代表了使用外部for循环进行数组过滤的命令式版本。我们可以使用Stream<T>对象的filter方法,在这种情况下是Stream<Integer>对象,并以函数式方法实现相同的目标。

接下来的几行使用了一种功能性方法来生成一个List<Integer>,其中包含在List<Integer> range1to20中的能被3整除的数字。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_04.java文件中。

List<Integer> divisibleBy3Numbers2 = range1to20.stream().filter(n -> n % 3 == 0).collect(Collectors.toList());

如果我们希望先前的代码在 JShell 中运行,我们必须将所有代码输入到单行中,这对于 Java 编译器成功编译代码并不是必需的。这是 JShell、流和 lambda 表达式的一个特定问题。这使得代码有点难以理解。因此,接下来的几行展示了另一个使用多行的代码版本,这在 JShell 中不起作用,但会使代码更容易理解。只需注意,在下面的示例中,您必须将代码输入到单行中。代码文件使用单行。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_04.java文件中。

range1to20.stream()
.filter(n -> n % 3 == 0)
.collect(Collectors.toList());

提示

stream方法从List<Integer>生成一个Stream<Integer>是特定类型的元素序列,允许我们执行顺序或并行执行的计算或聚合操作。实际上,我们可以链接许多流操作并组成流管道。这些计算具有延迟执行,也就是说,直到有终端操作(例如请求将最终数据收集到特定类型的List中)之前,它们不会被计算。

filter方法接收一个Predicate<Integer>作为参数,并将其应用于Stream<Integer>filter方法返回输入流的元素流,这些元素与指定的谓词匹配。该方法返回一个流,其中包含所有Predicate<Integer>评估为true的元素。我们将先前解释的 lambda 表达式作为filter方法的参数传递。

collect方法接收filter方法返回的Stream<Integer>。我们将Collectors.toList()作为collect方法的参数传递,以对Stream<Integer>的元素执行可变归约操作,并生成List<Integer>,即可变结果容器。代码运行后,List<Integer> divisibleBy3Numbers2将具有以下值:[3, 6, 9, 12, 15, 18]

现在,我们希望采用功能方法来打印结果List<Integer>中的每个数字。List<T>实现了Iterable<T>接口,允许我们调用forEach方法对Iterable的每个元素执行指定为参数的操作,直到所有元素都被处理或操作引发异常。forEach方法的操作参数必须是Consumer<T>,因此在我们的情况下,它必须是Consumer<Integer>,因为我们将为结果List<Integer>调用forEach方法。

Consumer<T>是一个函数接口,表示访问类型为T的单个输入参数并返回无结果(void)的操作。Consumer<T>函数接口定义了一个名为accept的抽象方法,该方法接收类型为T的参数并返回无结果。以下行将 lambda 表达式作为forEach方法的参数传递。lambda 表达式生成一个Consumer<Integer>,打印接收到的n。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_04.java文件中。

divisibleBy3Numbers2.forEach(n -> System.out.println(n));

由于上一行的结果,我们将在 JShell 中看到以下数字的打印:

3
6
9
12
15
18

生成Consumer<Integer>的 lambda 表达式调用System.out.println方法,并将Integer作为参数。我们可以使用方法引用来调用现有方法,而不是使用 lambda 表达式。在这种情况下,我们可以用System.out::println替换先前显示的 lambda 表达式,即调用System.outprintln方法的方法引用。每当我们使用方法引用时,Java 运行时都会推断方法类型参数;在这种情况下,方法类型参数是单个Integer。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_04.java文件中。

divisibleBy3Numbers2.forEach(System.out::println);

该代码将产生与先前对 lambda 表达式调用forEach相同的结果。以下屏幕截图显示了在 JShell 中执行先前行的结果:

创建数组过滤的功能版本

我们可以捕获在 lambda 表达式中未定义的变量。当 lambda 从外部世界捕获变量时,我们也可以称之为闭包。例如,以下行声明了一个名为byNumberint变量,并将4赋给该变量。然后,下一行使用流、过滤器和收集的新版本来生成一个List<Integer>,其中包含能被byNumber变量指定的数字整除的数字。lambda 表达式包括byNumber,Java 在幕后从外部世界捕获了这个变量。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_04.java文件中。

int byNumber = 4;
List<Integer> divisibleBy4Numbers =
    range1to20.stream().filter(
        n -> n % byNumber == 0).collect(
        Collectors.toList());
divisibleBy4Numbers.forEach(System.out::println);

由于前一行的结果,我们将在 JShell 中看到以下数字的打印:

4
8
12
16
20

如果我们使用一个与函数式接口不匹配的 lambda 表达式,代码将无法编译,Java 编译器将生成适当的错误。例如,以下行尝试将返回int而不是Booleanboolean的 lambda 表达式分配给IntPredicate变量。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_05.java文件中。

// The following code will generate an error
IntPredicate errorPredicate = n -> 8;

JShell 将显示以下错误,向我们指出int无法转换为boolean

|  Error:
|  incompatible types: bad return type in lambda expression
|      int cannot be converted to boolean
|  IntPredicate errorPredicate = n -> 8;
|                                     ^

使用泛型和接口创建数据仓库

现在我们想要创建一个仓库,为我们提供实体,以便我们可以应用 Java 9 中包含的函数式编程特性来检索和处理这些实体的数据。首先,我们将创建一个Identifiable接口,该接口定义了可识别实体的要求。我们希望实现此接口的任何类都提供一个getId方法,该方法返回一个int,其值为实体的唯一标识符。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

public interface Identifiable {
    int getId();
}

接下来的行创建了一个Repository<E>通用接口,该接口指定E必须实现最近创建的Identifiable接口的通用类型约束。该类声明了一个getAll方法,该方法返回一个List<E>。实现该接口的每个类都必须为此方法提供自己的实现。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

public interface Repository<E extends Identifiable> {
    List<E> getAll();
}

接下来的行创建了Entity抽象类,它是所有实体的基类。该类实现了Identifiable接口,并定义了一个int类型的不可变id受保护字段。构造函数接收id不可变字段的期望值,并使用接收到的值初始化字段。抽象类实现了getId方法,该方法返回id不可变字段的值。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

public abstract class Entity implements Identifiable {
    protected final int id;

    public Entity(int id) {
        this.id = id;
    }

    @Override
    public final int getId() {
        return id;
    }
}

接下来的行创建了MobileGame类,具体来说,是先前创建的Entity抽象类的子类。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

public class MobileGame extends Entity {
    protected final String separator = "; ";
    public final String name;
    public int highestScore;
    public int lowestScore;
    public int playersCount;

    public MobileGame(int id, 
        String name, 
        int highestScore, 
        int lowestScore, 
        int playersCount) {
        super(id);
        this.name = name;
        this.highestScore = highestScore;
        this.lowestScore = lowestScore;
        this.playersCount = playersCount;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("Id: ");
        sb.append(getId());
        sb.append(separator);
        sb.append("Name: ");
        sb.append(name);
        sb.append(separator);
        sb.append("Highest score: ");
        sb.append(highestScore);
        sb.append(separator);
        sb.append("Lowest score: ");
        sb.append(lowestScore);
        sb.append(separator);
        sb.append("Players count: ");
        sb.append(playersCount);

        return sb.toString();
    }
}

该类声明了许多公共字段,它们的值在构造函数中初始化:namehighestScorelowestScoreplayersCount。该字段是不可变的,但其他三个是可变的。我们不使用 getter 或 setter 来保持事情更简单。但是,重要的是要考虑到,一些允许我们使用实体的框架要求我们对所有字段使用 getter,并且在字段不是只读时使用 setter。

此外,该类重写了从java.lang.Object类继承的toString方法,必须为实体返回一个String表示。此方法中声明的代码使用java.lang.StringBuilder类的一个实例(sb)以一种高效的方式附加许多字符串,最后返回调用sb.toString方法的结果以返回生成的String。此方法使用受保护的分隔符不可变字符串,该字符串确定我们在字段之间使用的分隔符。每当我们使用MobileGame的实例调用System.out.println时,println方法将调用重写的toString方法来打印该实例的String表示。

提示

我们也可以使用String连接(+)或String.format来编写toString方法的代码,因为我们将只使用MobileGame类的 15 个实例。然而,当我们必须连接许多字符串以生成结果并且希望确保在执行代码时具有最佳性能时,最好使用StringBuilder。在我们的简单示例中,任何实现都不会有任何性能问题。

以下行创建了实现Repository<MobileGame>接口的MemoryMobileGameRepository具体类。请注意,我们不说Repository<E>,而是指出Repository<MobileGame>,因为我们已经知道我们将在我们的类中实现的E类型参数的值。我们不是创建一个MemoryMobileGameRepository<E extends Identifiable>。相反,我们正在创建一个非泛型的具体类,该类实现了一个泛型接口并将参数类型E的值设置为MobileGame。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

import java.util.stream.Collectors;

public class MemoryMobileGameRepository implements Repository<MobileGame> {
    @Override
    public List<MobileGame> getAll() {
        List<MobileGame> mobileGames = new ArrayList<>();
        mobileGames.add(
            new MobileGame(1, "Uncharted 4000", 5000, 10, 3800));
        mobileGames.add(
            new MobileGame(2, "Supergirl 2017", 8500, 5, 75000));
        mobileGames.add(
            new MobileGame(3, "Super Luigi Run", 32000, 300, 90000));
        mobileGames.add(
            new MobileGame(4, "Mario vs Kong III", 152000, 1500, 750000));
        mobileGames.add(
            new MobileGame(5, "Minecraft Reloaded", 6708960, 8000, 3500000));
        mobileGames.add(
            new MobileGame(6, "Pikachu vs Beedrill: The revenge", 780000, 400, 1000000));
        mobileGames.add(
            new MobileGame(7, "Jerry vs Tom vs Spike", 78000, 670, 20000));
        mobileGames.add(
            new MobileGame(8, "NBA 2017", 1500607, 20, 7000005));
        mobileGames.add(
            new MobileGame(9, "NFL 2017", 3205978, 0, 4600700));
        mobileGames.add(
            new MobileGame(10, "Nascar Remix", 785000, 0, 2600000));
        mobileGames.add(
            new MobileGame(11, "Little BIG Universe", 95000, 3, 546000));
        mobileGames.add(
            new MobileGame(12, "Plants vs Zombies Garden Warfare 3", 879059, 0, 789000));
        mobileGames.add(
            new MobileGame(13, "Final Fantasy XVII", 852325, 0, 375029));
        mobileGames.add(
            new MobileGame(14, "Watch Dogs 3", 27000, 2, 78004));
        mobileGames.add(
            new MobileGame(15, "Remember Me", 672345, 5, 252003));

        return mobileGames;
    }
}

该类实现了Repository<E>接口所需的getAll方法。在这种情况下,该方法返回一个MobileGameListList<MobileGame>),具体来说是一个ArrayList<MobileGame>。该方法创建了 15 个MobileGame实例,并将它们附加到一个MobileGameArrayList,该方法作为结果返回。

以下行创建了MemoryMobileGameRepository类的一个实例,并为getAll方法返回的List<MobileGame>调用forEach方法。forEach方法在列表中的每个元素上调用一个体,就像在for循环中一样。作为forEach方法参数指定的闭包调用System.out.println方法,并将MobileGame实例作为参数。这样,Java 使用MobileGame类中重写的toString方法为每个MobileGame实例生成一个String表示。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_06.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
repository.getAll().forEach(mobileGame -> System.out.println(mobileGame));

以下行显示在执行打印每个MobileGame实例的toString()方法返回的String后生成的输出:

Id: 1; Name: Uncharted 4000; Highest score: 5000; Lowest score: 10; Players count: 3800
Id: 2; Name: Supergirl 2017; Highest score: 8500; Lowest score: 5; Players count: 75000
Id: 3; Name: Super Luigi Run; Highest score: 32000; Lowest score: 300; Players count: 90000
Id: 4; Name: Mario vs Kong III; Highest score: 152000; Lowest score: 1500; Players count: 750000
Id: 5; Name: Minecraft Reloaded; Highest score: 6708960; Lowest score: 8000; Players count: 3500000
Id: 6; Name: Pikachu vs Beedrill: The revenge; Highest score: 780000; Lowest score: 400; Players count: 1000000
Id: 7; Name: Jerry vs Tom vs Spike; Highest score: 78000; Lowest score: 670; Players count: 20000
Id: 8; Name: NBA 2017; Highest score: 1500607; Lowest score: 20; Players count: 7000005
Id: 9; Name: NFL 2017; Highest score: 3205978; Lowest score: 0; Players count: 4600700
Id: 10; Name: Nascar Remix; Highest score: 785000; Lowest score: 0; Players count: 2600000
Id: 11; Name: Little BIG Universe; Highest score: 95000; Lowest score: 3; Players count: 546000
Id: 12; Name: Plants vs Zombies Garden Warfare 3; Highest score: 879059; Lowest score: 0; Players count: 789000
Id: 13; Name: Final Fantasy XVII; Highest score: 852325; Lowest score: 0; Players count: 375029
Id: 14; Name: Watch Dogs 3; Highest score: 27000; Lowest score: 2; Players count: 78004
Id: 15; Name: Remember Me; Highest score: 672345; Lowest score: 5; Players count: 252003

 the same result. The code file for the sample is included in the java_9_oop_chapter_12_01 folder, in the example12_06.java file.
repository.getAll().forEach(System.out::println);

使用复杂条件过滤集合

我们可以使用我们的新存储库来限制从复杂数据中检索的结果。我们可以将对getAll方法的调用与流、过滤器和收集结合起来,以生成一个Stream<MobileGame>,应用一个带有 lambda 表达式作为参数的过滤器,并调用collect方法,并将Collectors.toList()作为参数,从过滤后的Stream<MobileGame>生成一个过滤后的List<MobileGame>filter方法接收一个Predicate<MobileGame>作为参数,我们使用 lambda 表达式生成该谓词,并将该过滤器应用于Stream<MobileGame>filter方法返回输入流的元素流,这些元素流与指定的谓词匹配。该方法返回一个流,其中所有元素的Predicate<MobileGame>评估为true

注意

接下来的行显示了使用多行的代码片段,这在 JShell 中无法工作,但将使代码更易于阅读和理解。如果我们希望代码在 JShell 中运行,我们必须将所有代码输入到一行中,这对于 Java 编译器成功编译代码并不是必需的。这是 JShell、流和 lambda 表达式的一个特定问题。代码文件使用单行以与 JShell 兼容。

以下行声明了MemoryMobileGameRepository类的新getWithLowestScoreGreaterThan方法。请注意,为了避免重复,我们没有包含新类的所有代码。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_07.java文件中。

public List<MobileGame> getWithLowestScoreGreaterThan(int minimumLowestScore) {
    return getAll().stream()
        .filter(game -> game.lowestScore > minimumLowestScore)
        .collect(Collectors.toList());
}

以下行使用名为repositoryMemoryMobileGameRepository实例调用先前添加的方法,然后链式调用forEach以打印所有lowestScore值大于1000的游戏:

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
repository.getWithLowestScoreGreaterThan(1000).forEach(System.out::println);

以下行显示了执行前面代码后生成的输出:

Id: 4; Name: Mario vs Kong III; Highest score: 152000; Lowest score: 1500; Players count: 750000
Id: 5; Name: Minecraft Reloaded; Highest score: 6708960; Lowest score: 8000; Players count: 3500000

java_9_oop_chapter_12_01 folder, in the example12_07.java file.
public List<MobileGame> getWithLowestScoreGreaterThanV2(int minimumLowestScore) {
return getAll().stream()
 .filter((MobileGame game) -> game.lowestScore > minimumLowestScore) 
    .collect(Collectors.toList());
}

以下行声明了MemoryMobileGameRepository类的新getStartingWith方法。作为filter方法参数传递的 lambda 表达式返回调用游戏名称的startsWith方法的结果,该方法使用作为参数接收的前缀。在这种情况下,lambda 表达式是一个闭包,它捕获了prefix参数,并在 lambda 表达式体内使用它。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_08.java文件中。

public List<MobileGame> getStartingWith(String prefix) {
    return getAll().stream()
        .filter(game -> game.name.startsWith(prefix))
        .collect(Collectors.toList());
}

以下行使用名为repositoryMemoryMobileGameRepository实例调用先前添加的方法,然后链式调用forEach以打印所有以"Su"开头的游戏的名称。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
repository.getStartingWith("Su").forEach(System.out::println);

以下行显示了执行前面代码后生成的输出:

Id: 2; Name: Supergirl 2017; Highest score: 8500; Lowest score: 5; Players count: 75000
Id: 3; Name: Super Luigi Run; Highest score: 32000; Lowest score: 300; Players count: 90000

以下行声明了MemoryMobileGameRepository类的新getByPlayersCountAndHighestScore方法。该方法返回一个Optional<MobileGame>,即一个可能包含MobileGame实例的容器对象,也可能为空。如果有值,isPresent方法将返回true,我们将能够通过调用get方法检索MobileGame实例。在这种情况下,代码调用了findFirst方法链接到filter方法的调用。findFirst方法返回一个Optional<T>,在这种情况下,是由filter方法生成的Stream<MobileGame>中的第一个元素的Optional<MobileGame>。请注意,我们在任何时候都没有对结果进行排序。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_09.java文件中。

public Optional<MobileGame> getByPlayersCountAndHighestScore(
    int playersCount, 
    int highestScore) {
    return getAll().stream()
        .filter(game -> (game.playersCount == playersCount) && (game.highestScore == highestScore))
        .findFirst();
}

以下行使用名为repositoryMemoryMobileGameRepository实例调用先前添加的方法。在每次调用getByPlayersCountAndHighestScore方法后,代码调用isPresent方法来确定Optional<MobileGame>是否有实例。如果方法返回true,代码将调用get方法从Optional<MobileGame>中检索MobileGame实例。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_09.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
Optional<MobileGame> optionalMobileGame1 = 
    repository.getByPlayersCountAndHighestScore(750000, 152000);
if (optionalMobileGame1.isPresent()) {
    MobileGame mobileGame1 = optionalMobileGame1.get();
    System.out.println(mobileGame1);
} else {
    System.out.println("No mobile game matches the specified criteria.");
}
Optional<MobileGame> optionalMobileGame2 = 
    repository.getByPlayersCountAndHighestScore(670000, 829340);
if (optionalMobileGame2.isPresent()) {
    MobileGame mobileGame2 = optionalMobileGame2.get();
    System.out.println(mobileGame2);
} else {
    System.out.println("No mobile game matches the specified criteria.");
}

以下行显示了执行前面代码后生成的输出。在第一次调用中,有一个符合搜索条件的移动游戏。在第二次调用中,没有符合搜索条件的MobileGame实例:

Id: 4; Name: Mario vs Kong III; Highest score: 152000; Lowest score: 1500; Players count: 750000
No mobile game matches the specified criteria.

以下屏幕截图显示了在 JShell 中执行前面行的结果:

使用复杂条件过滤集合

使用 map 操作来转换值

以下行为我们先前编写的MemoryMobileGameRepository类声明了一个新的getGameNamesTransformedToUpperCase方法。新方法执行了最简单的 map 操作之一。对map方法的调用将Stream<MobileGame>转换为Stream<String>。作为map方法参数传递的 lambda 表达式生成了一个Function<MobileGame, String>,即它接收一个MobileGame参数并返回一个String。对collect方法的调用从map方法返回的Stream<String>生成了一个List<String>

示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_10.java文件中。

public List<String> getGameNamesTransformedToUpperCase() {
    return getAll().stream()
        .map(game -> game.name.toUpperCase())
        .collect(Collectors.toList());
}

getGameNamesTransformedToUpperCase方法返回一个List<String>map方法将Stream<MobileGame>中的每个MobileGame实例转换为一个带有name字段转换为大写的String。这样,map方法将Stream<MobileGame>转换为List<String>

以下行使用名为repositoryMemoryMobileGameRepository实例调用先前添加的方法,并生成一个转换为大写字符串的游戏名称列表。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_10.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
repository.getGameNamesTransformedToUpperCase().forEach(System.out::println);

以下行显示执行先前代码后生成的输出:

UNCHARTED 4000
SUPERGIRL 2017
SUPER LUIGI RUN
MARIO VS KONG III
MINECRAFT RELOADED
PIKACHU VS BEEDRILL: THE REVENGE
JERRY VS TOM VS SPIKE
NBA 2017
NFL 2017
NASCAR REMIX
LITTLE BIG UNIVERSE
PLANTS VS ZOMBIES GARDEN WARFARE 3
FINAL FANTASY XVII
WATCH DOGS 3
REMEMBER ME

以下代码创建了一个新的NamesForMobileGame类,其中包含两个构造函数。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_11.java文件中。

public class NamesForMobileGame {
    public final String upperCaseName;
    public final String lowerCaseName;

    public NamesForMobileGame(String name) {
        this.upperCaseName = name.toUpperCase();
        this.lowerCaseName = name.toLowerCase();
    }

    public NamesForMobileGame(MobileGame game) {
        this(game.name);
    }
}

NamesForMobileGame类声明了两个String类型的不可变字段:upperCaseNamelowerCaseName。其中一个构造函数接收一个nameString,并将其转换为大写保存在upperCaseName字段中,并将其转换为小写保存在lowerCaseName字段中。另一个构造函数接收一个MobileGame实例,并使用接收到的MobileGame实例的name字段作为参数调用先前解释的构造函数。

以下代码为我们先前编写的MemoryMobileGameRepository类添加了一个新的getNamesForMobileGames方法。新方法执行了一个 map 操作。对map方法的调用将Stream<MobileGame>转换为Stream<NamesForMobileGame>。作为map方法参数传递的 lambda 表达式生成了一个Function<MobileGame, NamesForMobileGame>,即它接收一个MobileGame参数,并通过调用接收一个name作为参数的构造函数返回一个NamesForMobileGame实例。对collect方法的调用从map方法返回的Stream<NamesForMobileGame>生成了一个List<NamesForMobileGame>。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_11.java文件中。

public List<NamesForMobileGame> getNamesForMobileGames() {
    return getAll().stream()
        .map(game -> new NamesForMobileGame(game.name))
        .collect(Collectors.toList());
}

以下行使用名为repositoryMemoryMobileGameRepository实例调用先前添加的方法。作为forEach方法参数传递的 lambda 表达式声明了一个用大括号括起来的主体,因为它需要多行。此主体使用java.lang.StringBuilder类的一个实例(sb)来附加许多带有大写名称、分隔符和小写名称的字符串。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_11.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository()
repository.getNamesForMobileGames().forEach(names -> {
    StringBuilder sb = new StringBuilder();
    sb.append(names.upperCaseName);
    sb.append(" - ");
    sb.append(names.lowerCaseName);
    System.out.println(sb.toString());
});

以下行显示执行先前代码后生成的输出:

UNCHARTED 4000 - uncharted 4000
SUPERGIRL 2017 - supergirl 2017
SUPER LUIGI RUN - super luigi run
MARIO VS KONG III - mario vs kong iii
MINECRAFT RELOADED - minecraft reloaded
PIKACHU VS BEEDRILL: THE REVENGE - pikachu vs beedrill: the revenge
JERRY VS TOM VS SPIKE - jerry vs tom vs spike
NBA 2017 - nba 2017
NFL 2017 - nfl 2017
NASCAR REMIX - nascar remix
LITTLE BIG UNIVERSE - little big universe
PLANTS VS ZOMBIES GARDEN WARFARE 3 - plants vs zombies garden warfare 3
FINAL FANTASY XVII - final fantasy xvii
WATCH DOGS 3 - watch dogs 3
REMEMBER ME - remember me

下一行代码显示了getNamesForMobileGames方法的另一个版本,名为getNamesForMobileGamesV2,它是等效的并产生相同的结果。在这种情况下,我们用构造函数引用方法替换了生成Function<MobileGame, NamesForMobileGame>的 lambda 表达式:NamesForMobileGame::new。构造函数引用方法是指定类名后跟::new,将使用接收MobileGame实例作为参数的构造函数创建NamesForMobileGame的新实例。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,example12_12.java文件中。

public List<NamesForMobileGame> getNamesForMobileGamesV2() {
    return getAll().stream()
        .map(NamesForMobileGame::new)
        .collect(Collectors.toList());
}

以下代码使用方法的新版本,并产生了第一个版本显示的相同结果。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,example12_12.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository();
repository.getNamesForMobileGamesV2().forEach(names -> {
    StringBuilder sb = new StringBuilder();
    sb.append(names.upperCaseName);
    sb.append(" - ");
    sb.append(names.lowerCaseName);
    System.out.println(sb.toString());
});

结合地图操作和减少

以下行显示了一个for循环的命令式代码版本,用于计算移动游戏的所有lowestScore值的总和。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,example12_13.java文件中。

int lowestScoreSum = 0;
for (MobileGame mobileGame : repository.getAll()) {
    lowestScoreSum += mobileGame.lowestScore;
}
System.out.println(lowestScoreSum);

代码非常容易理解。lowestScoreSum变量的初始值为0for循环的每次迭代从repository.getAll()方法返回的List<MobileGame>中检索一个MobileGame实例,并增加lowestScoreSum变量的值与mobileGame.lowestScore字段的值。

我们可以将地图和减少操作结合起来,以创建先前命令式代码的功能版本,以计算移动游戏的所有lowestScore值的总和。下一行将map的调用链接到reduce的调用,以实现这个目标。看一下以下代码。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,example12_14.java文件中。

int lowestScoreMapReduceSum = repository.getAll().stream().map(game -> game.lowestScore).reduce(0, (sum, lowestScore) -> sum + lowestScore);
System.out.println(lowestScoreMapReduceSum);

首先,代码使用调用mapStream<MobileGame>转换为Stream<Integer>,其中lowestScore存储属性中的值被装箱为Integer对象。然后,代码调用reduce方法,该方法接收两个参数:累积值的初始值0和一个组合闭包,该闭包将重复调用累积值。该方法返回对组合闭包的重复调用的结果。

reduce方法的第二个参数中指定的闭包接收sumlowestScore,并返回这两个值的总和。因此,闭包返回到目前为止累积的总和加上处理的lowestScore值。我们可以添加一个System.out.println语句,以显示reduce方法的第二个参数中指定的闭包中的sumlowestScore的值。以下行显示了先前代码的新版本,其中添加了包含System.out.println语句的行,这将允许我们深入了解reduce操作的工作原理。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,example12_15.java文件中。

int lowestScoreMapReduceSum2 = 
    repository.getAll().stream()
    .map(game -> game.lowestScore)
    .reduce(0, (sum, lowestScore) -> {
        StringBuilder sb = new StringBuilder();
        sb.append("sum value: ");
        sb.append(sum);
        sb.append(";lowestScore value: ");
        sb.append(lowestScore);
        System.out.println(sb.toString());

        return sum + lowestScore;
    });
System.out.println(lowestScoreMapReduceSum2);

以下行显示了先前行的结果,我们可以看到sum参数的值从reduce方法的第一个参数中指定的初始值(0)开始,并累积到目前为止的总和。最后,lowestScoreSum2变量保存了所有lowestScore值的总和。我们可以看到sumlowestScore的最后一个值分别为109105。对于减少操作执行的最后一段代码计算109105并返回10915,这是保存在lowestScoreSum2变量中的结果。

sum value: 0; lowestScore value: 10
sum value: 10; lowestScore value: 5
sum value: 15; lowestScore value: 300
sum value: 315; lowestScore value: 1500
sum value: 1815; lowestScore value: 8000
sum value: 9815; lowestScore value: 400
sum value: 10215; lowestScore value: 670
sum value: 10885; lowestScore value: 20
sum value: 10905; lowestScore value: 0
sum value: 10905; lowestScore value: 0
sum value: 10905; lowestScore value: 3
sum value: 10908; lowestScore value: 0
sum value: 10908; lowestScore value: 0
sum value: 10908; lowestScore value: 2
sum value: 10910; lowestScore value: 5
lowestScoreMapReduceSum2 ==> 10915
10915

在前面的例子中,我们结合使用 map 和 reduce 来执行求和。我们可以利用 Java 9 提供的简化代码来实现相同的目标。在下面的代码中,我们利用mapToInt生成一个IntStream;sum 使用int值工作,不需要将Integer转换为int。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,名为example12_16.java

int lowestScoreMapReduceSum3 =
    repository.getAll().stream()
    .mapToInt(game -> game.lowestScore).sum();
System.out.println(lowestScoreMapReduceSum3);

接下来的行也使用了不太高效的不同管道产生相同的结果。map方法必须将返回的int装箱为Integer并返回一个Stream<Integer>。然后,对collect方法的调用指定了对Collectors.summingInt的调用作为参数。Collectors.summingInt需要int值来计算总和,因此,我们传递了一个方法引用来调用Stream<Integer>中每个IntegerintValue方法。以下行使用Collectors.summingInt收集器来执行int值的求和。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,名为example12_17.java

int lowestScoreMapReduceSum4 = 
    repository.getAll().stream()
.map(game -> game.lowestScore)
.collect(Collectors.summingInt(Integer::intValue));
System.out.println(lowestScoreMapReduceSum4);

在这种情况下,我们知道Integer.MAX_VALUE将允许我们保存准确的求和结果。然而,在某些情况下,我们必须使用long类型。下面的代码使用mapToLong方法来使用long来累积值。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,名为example12_18.java

long lowestScoreMapReduceSum5 =
    repository.getAll().stream()
    .mapToLong(game -> game.lowestScore).sum();
System.out.println(lowestScoreMapReduceSum6);

提示

Java 9 提供了许多归约方法,也称为聚合操作。在编写自己的代码执行诸如计数、平均值和求和等操作之前,请确保考虑它们。我们可以使用它们在流上执行算术操作并获得数字结果。

使用 map 和 reduce 链接多个操作

我们可以链接filtermapreduce操作。以下代码向MemoryMobileGameRepository类添加了一个新的getHighestScoreSumForMinPlayersCount方法。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,名为example12_19.java

public long getHighestScoreSumForMinPlayersCount(int minPlayersCount) {
    return getAll().stream()
        .filter(game -> (game.playersCount >= minPlayersCount))
        .mapToLong(game -> game.highestScore)
        .reduce(0, (sum, highestScore) -> sum + highestScore);
}

新方法执行了一个filter,链接了一个mapToLong,最后是一个reduce操作。对filter的调用生成了一个Stream<MobileGame>,其中包含playersCount值等于或大于作为参数接收的minPlayersCount值的MobileGame实例。mapToLong方法返回一个LongStream,即描述long原始类型流的专门化Stream<T>。对mapToLong的调用接收了每个经过筛选的MobileGame实例的int类型的highestScore值,并将此值转换为long返回。

reduce方法从处理管道中接收一个LongStreamreduce操作的累积值的初始值被指定为第一个参数0,第二个参数是一个带有组合操作的 lambda 表达式,该操作将重复调用累积值。该方法返回重复调用组合操作的结果。

reduce方法的第二个参数中指定的 lambda 表达式接收sumhighestScore,并返回这两个值的和。因此,lambda 表达式返回到目前为止累积的总和,接收到sum参数,加上正在处理的highestScore值。

接下来的行使用了先前创建的方法。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中,名为example12_19.java

MemoryMobileGameRepository repository = new MemoryMobileGameRepository();
System.out.println(repository.getHighestScoreSumForMinPlayersCount(150000));

JShell 将显示以下值作为结果:

15631274

正如我们从前面的示例中学到的,我们可以使用sum方法而不是编写reduce方法的代码。下一行代码显示了getHighestScoreSumForMinPlayersCount方法的另一个版本,名为getHighestScoreSumForMinPlayersCountV2,它是等效的并产生相同的结果。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_20.java文件中。

public long getHighestScoreSumForMinPlayersCountV2(int minPlayersCount) {
    return getAll().stream()
        .filter(game -> (game.playersCount >= minPlayersCount))
        .mapToLong(game -> game.highestScore)
        .sum();
}

以下代码使用方法的新版本,并产生了与第一个版本显示的相同结果。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_20.java文件中。

MemoryMobileGameRepository repository = new MemoryMobileGameRepository();
System.out.println(repository.getHighestScoreSumForMinPlayersCountV2(150000));

使用不同的收集器

我们可以遵循函数式方法,并使用 Java 9 提供的各种收集器来解决不同类型的算法,即java.util.stream.Collectors类提供的各种静态方法。在接下来的示例中,我们将为collect方法使用不同的参数。

以下行将所有MobileGame实例的名称连接起来,生成一个用分隔符("; ")分隔的单个String。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_21.java文件中。

repository.getAll().stream()
.map(game -> game.name.toUpperCase())
.collect(Collectors.joining("; "));

该代码将Collectors.joining(";" )作为参数传递给collect方法。joining静态方法返回一个Collector,它将输入元素连接成一个由作为参数接收的分隔符分隔的String。以下显示了在 JShell 中执行前面行的结果。

UNCHARTED 4000; SUPERGIRL 2017; SUPER LUIGI RUN; MARIO VS KONG III; MINECRAFT RELOADED; PIKACHU VS BEEDRILL: THE REVENGE; JERRY VS TOM VS SPIKE; NBA 2017; NFL 2017; NASCAR REMIX; LITTLE BIG UNIVERSE; PLANTS VS ZOMBIES GARDEN WARFARE 3; FINAL FANTASY XVII; WATCH DOGS 3; REMEMBER ME

java_9_oop_chapter_12_01 folder, in the example12_22.java file.
repository.getAll().stream().sorted(Comparator.comparing(game -> game.name)).map(game -> game.name.toUpperCase()).collect(Collectors.joining("; "));

该代码将Comparator.comparing(game -> game.name)作为参数传递给sorted方法。comparing静态方法接收一个函数,从MobileGame中提取所需的排序键,并返回一个Comparator<MobileGame>,使用指定的比较器比较此排序键。代码将一个 lambda 表达式作为参数传递给comparing静态方法,以指定名称为MobileGame实例的所需排序键。sorted 方法接收一个Stream<MobileGame>,并返回一个根据提供的Comparator<MobileGame>MobileGame实例进行排序的Stream<MobileGame>。以下显示了在 JShell 中执行前面行的结果:

FINAL FANTASY XVII; JERRY VS TOM VS SPIKE; LITTLE BIG UNIVERSE; MARIO VS KONG III; MINECRAFT RELOADED; NBA 2017; NFL 2017; NASCAR REMIX; PIKACHU VS BEEDRILL: THE REVENGE; PLANTS VS ZOMBIES GARDEN WARFARE 3; REMEMBER ME; SUPER LUIGI RUN; SUPERGIRL 2017; UNCHARTED 4000; WATCH DOGS 3

现在我们想要检查玩家数量等于或高于指定阈值的游戏。我们想要检查通过和未通过的游戏。以下行生成一个Map<Boolean, List<MobileGame>,其键指定移动游戏是否通过,值包括通过或未通过的List<MobileGame>。然后,代码调用forEach方法来显示结果。示例的代码文件包含在java_9_oop_chapter_12_01文件夹中的example12_23.java文件中。

Map<Boolean, List<MobileGame>> map1 = 
repository.getAll().stream()
.collect(Collectors.partitioningBy(g -> g.playersCount >= 100000));
map1.forEach((passed, mobileGames) -> {
    System.out.println(
        String.format("Mobile games that %s:",
            passed ? "passed" : "didn't pass"));
    mobileGames.forEach(System.out::println);
});

该代码将Collectors.partitioningBy(g -> g.playersCount >= 100000)作为参数传递给collect方法。partitioningBy静态方法接收一个Predicate<MobileGame>。代码将一个 lambda 表达式作为参数传递给partitioningBy静态方法,以指定输入元素必须基于playersCount字段是否大于或等于100000进行分区。返回的Collector<MobileGame>Stream<MobileGame>分区并将其组织成Map<Boolean, List<MobileGame>>,执行下游归约。

然后,代码调用forEach方法,其中 lambda 表达式作为参数接收来自Map<Boolean, List<MobileGame>中的passedmobileGames参数的键和值。以下显示了在 JShell 中执行前面行的结果:

Mobile games that didn't pass:
Id: 1; Name: Uncharted 4000; Highest score: 5000; Lowest score: 10; Players count: 3800
Id: 2; Name: Supergirl 2017; Highest score: 8500; Lowest score: 5; Players count: 75000
Id: 3; Name: Super Luigi Run; Highest score: 32000; Lowest score: 300; Players count: 90000
Id: 7; Name: Jerry vs Tom vs Spike; Highest score: 78000; Lowest score: 670; Players count: 20000
Id: 14; Name: Watch Dogs 3; Highest score: 27000; Lowest score: 2; Players count: 78004
Mobile games that passed:
Id: 4; Name: Mario vs Kong III; Highest score: 152000; Lowest score: 1500; Players count: 750000
Id: 5; Name: Minecraft Reloaded; Highest score: 6708960; Lowest score: 8000; Players count: 3500000
Id: 6; Name: Pikachu vs Beedrill: The revenge; Highest score: 780000; Lowest score: 400; Players count: 1000000
Id: 8; Name: NBA 2017; Highest score: 1500607; Lowest score: 20; Players count: 7000005
Id: 9; Name: NFL 2017; Highest score: 3205978; Lowest score: 0; Players count: 4600700
Id: 10; Name: Nascar Remix; Highest score: 785000; Lowest score: 0; Players count: 2600000
Id: 11; Name: Little BIG Universe; Highest score: 95000; Lowest score: 3; Players count: 546000
Id: 12; Name: Plants vs Zombies Garden Warfare 3; Highest score: 879059; Lowest score: 0; Players count: 789000
Id: 13; Name: Final Fantasy XVII; Highest score: 852325; Lowest score: 0; Players count: 375029
Id: 15; Name: Remember Me; Highest score: 672345; Lowest score: 5; Players count: 252003

java_9_oop_chapter_12_01 folder, in the example12_24.java file.
Map<Boolean, List<MobileGame>> map1 =
repository.getAll().stream()
.sorted(Comparator.comparing(game -> game.name))
.collect(Collectors.partitioningBy(g -> g.playersCount >= 100000));
map1.forEach((passed, mobileGames) -> {
    System.out.println(
        String.format("Mobile games that %s:",
            passed ? "passed" : "didn't pass"));
    mobileGames.forEach(System.out::println);
});

以下显示了在 JShell 中执行前面行的结果:

Mobile games that didn't pass:
Id: 7; Name: Jerry vs Tom vs Spike; Highest score: 78000; Lowest score: 670; Players count: 20000
Id: 3; Name: Super Luigi Run; Highest score: 32000; Lowest score: 300; Players count: 90000
Id: 2; Name: Supergirl 2017; Highest score: 8500; Lowest score: 5; Players count: 75000
Id: 1; Name: Uncharted 4000; Highest score: 5000; Lowest score: 10; Players count: 3800
Id: 14; Name: Watch Dogs 3; Highest score: 27000; Lowest score: 2; Players count: 78004
Mobile games that passed:
Id: 13; Name: Final Fantasy XVII; Highest score: 852325; Lowest score: 0; Players count: 375029
Id: 11; Name: Little BIG Universe; Highest score: 95000; Lowest score: 3; Players count: 546000
Id: 4; Name: Mario vs Kong III; Highest score: 152000; Lowest score: 1500; Players count: 750000
Id: 5; Name: Minecraft Reloaded; Highest score: 6708960; Lowest score: 8000; Players count: 3500000
Id: 8; Name: NBA 2017; Highest score: 1500607; Lowest score: 20; Players count: 7000005
Id: 9; Name: NFL 2017; Highest score: 3205978; Lowest score: 0; Players count: 4600700
Id: 10; Name: Nascar Remix; Highest score: 785000; Lowest score: 0; Players count: 2600000
Id: 6; Name: Pikachu vs Beedrill: The revenge; Highest score: 780000; Lowest score: 400; Players count: 1000000
Id: 12; Name: Plants vs Zombies Garden Warfare 3; Highest score: 879059; Lowest score: 0; Players count: 789000
Id: 15; Name: Remember Me; Highest score: 672345; Lowest score: 5; Players count: 252003

测试你的知识

  1. 函数接口是满足以下条件的接口:

  2. 它在其默认方法中使用了一个 lambda 表达式。

  3. 它具有单个抽象方法或单个方法要求。

  4. 它实现了Lambda<T, U>接口。

  5. 您可以使用以下哪个代码片段创建函数式接口的实例:

  6. Lambda 表达式、方法引用或构造函数引用。

  7. 只有 lambda 表达式。方法引用和构造函数引用只能与Predicate<T>一起使用。

  8. 方法引用和构造函数引用。Lambda 表达式只能与Predicate<T>一起使用。

  9. IntPredicate函数式接口表示一个带有:

  10. int类型的一个参数,返回void类型。

  11. int类型的一个参数,返回Integer类型的结果。

  12. int类型的一个参数,返回boolean类型的结果。

  13. 当我们对Stream<T>应用filter方法时,该方法返回:

  14. Stream<T>

  15. List<T>

  16. Map<T, List<T>>

  17. 以下哪个代码片段等同于numbers.forEach(n -> System.out.println(n));

  18. numbers.forEach(n::System.out.println);

  19. numbers.forEach(System.out::println);

  20. numbers.forEach(n ->System.out.println);

总结

在本章中,我们使用了 Java 9 中包含的许多函数式编程特性,并将它们与我们之前讨论的面向对象编程的所有内容结合起来。我们分析了许多算法的命令式代码和函数式编程方法之间的差异。

我们使用了函数式接口和 lambda 表达式。我们理解了方法引用和构造函数引用。我们使用泛型和接口创建了一个数据仓库,并用它来处理过滤、映射操作、归约、聚合函数、排序和分区。我们使用了不同的流处理管道。

现在您已经了解了函数式编程,我们准备利用 Java 9 中的模块化功能,这是我们将在下一章中讨论的主题。

第十三章:Java 9 中的模块化

在本章中,我们将利用 Java 9 添加的新功能之一,使我们能够将源代码模块化并轻松管理依赖关系。我们将:

  • 重构现有代码以利用面向对象编程

  • 在 Java 9 中使用新的模块化组织面向对象的代码

  • 在 Java 9 中创建模块化源代码

  • 使用 Java 9 编译器编译多个模块

  • 使用 Java 9 运行模块化代码

重构现有代码以利用面向对象编程

如果我们从头开始编写面向对象的代码,我们可以利用我们在前几章学到的一切以及 Java 9 中包含的所有功能。随着需求的演变,我们将不得不对接口和类进行更改,进一步泛化或专门化它们,编辑它们,并创建新的接口和类。我们以面向对象的方式开始项目的事实将使我们能够轻松地对代码进行必要的调整。

有时,我们非常幸运,有机会在启动项目时就遵循最佳实践。然而,很多时候我们并不那么幸运,不得不处理未遵循最佳实践的项目。在这些情况下,我们可以利用我们喜爱的 IDE 提供的功能和额外的辅助工具来重构现有代码,生成促进代码重用并允许我们减少维护工作的面向对象代码,而不是遵循生成容易出错、重复且难以维护的代码的相同不良实践。

例如,假设我们需要开发一个 Web 服务,允许我们处理 3D 模型并在具有特定分辨率的 2D 图像上渲染它们。需求指定我们将使用我们的 Web 服务渲染的前两个 3D 模型是一个球体和一个立方体。Web 服务必须允许我们更改透视摄像机的以下参数,以便我们可以在 2D 屏幕上看到渲染的 3D 世界的特定部分:

  • 位置(XYZ值)

  • 方向(XYZ值)

  • 上向量(XYZ值)

  • 透视视野(以度为单位)

  • 近裁剪平面

  • 远裁剪平面

假设其他开发人员开始在项目上工作,并生成了一个包含声明两个静态方法的类包装器的单个 Java 文件。其中一个方法渲染一个立方体,另一个方法渲染一个球体。这些方法接收渲染每个 3D 图形所需的所有参数,包括确定 3D 图形位置和大小以及配置透视摄像机和定向光的所有必要参数。

以下几行展示了一个名为Renderer的类的声明示例,其中包含两个静态方法:renderCuberenderSphere。第一个方法设置并渲染一个立方体,第二个方法设置并渲染一个球体。非常重要的是要理解,示例代码并不遵循最佳实践,我们将对其进行重构。请注意,这两个静态方法有很多共同的代码。示例的代码文件包含在java_9_oop_chapter_13_01文件夹中的example13_01.java文件中。

// The following code doesn't follow best practices
// Please, do not use this code as a baseline
// We will refactor it to generate object-oriented code
public class Renderer {
    public static void renderCube(int x, int y, int z, int edgeLength,
        int cameraX, int cameraY, int cameraZ,
        int cameraDirectionX, int cameraDirectionY, int cameraDirectionZ,
        int cameraVectorX, int cameraVectorY, int cameraVectorZ,
        int cameraPerspectiveFieldOfView,
        int cameraNearClippingPlane,
        int cameraFarClippingPlane,
        int directionalLightX, int directionalLightY, int directionalLightZ,
        String directionalLightColor) {
            System.out.println(
                String.format("Created camera at (x:%d, y:%d, z:%d)",
                    cameraX, cameraY, cameraZ));
            System.out.println(
                String.format("Set camera direction to (x:%d, y:%d, z:%d)",
                    cameraDirectionX, cameraDirectionY, cameraDirectionZ));
            System.out.println(
                String.format("Set camera vector to (x:%d, y:%d, z:%d)",
                    cameraVectorX, cameraVectorY, cameraVectorZ));
            System.out.println(
                String.format("Set camera perspective field of view to: %d",
                    cameraPerspectiveFieldOfView));
            System.out.println(
                String.format("Set camera near clipping plane to: %d", 
                    cameraNearClippingPlane));
            System.out.println(
                String.format("Set camera far clipping plane to: %d",
                    cameraFarClippingPlane));
            System.out.println(
                String.format("Created directional light at (x:%d, y:%d, z:%d)",
                    directionalLightX, directionalLightY, directionalLightZ));
            System.out.println(
                String.format("Set light color to %s",
                    directionalLightColor));
            System.out.println(
                String.format("Drew cube at (x:%d, y:%d, z:%d) with edge length equal to %d" +
                    "considering light at (x:%d, y:%d, z:%d) " +
                    "and light's color equal to %s", 
                    x, y, z, edgeLength,
                    directionalLightX, directionalLightY, directionalLightZ,
                    directionalLightColor));
    }

    public static void renderSphere(int x, int y, int z, int radius,
        int cameraX, int cameraY, int cameraZ,
        int cameraDirectionX, int cameraDirectionY, 
        int cameraDirectionZ,
        int cameraVectorX, int cameraVectorY, int cameraVectorZ,
        int cameraPerspectiveFieldOfView,
        int cameraNearClippingPlane,
        int cameraFarClippingPlane,
        int directionalLightX, int directionalLightY, 
        int directionalLightZ,
        String directionalLightColor) {
            System.out.println(
                String.format("Created camera at (x:%d, y:%d, z:%d)",
                    cameraX, cameraY, cameraZ));
            System.out.println(
                String.format("Set camera direction to (x:%d, y:%d, z:%d)",
                    cameraDirectionX, cameraDirectionY, cameraDirectionZ));
            System.out.println(
                String.format("Set camera vector to (x:%d, y:%d, z:%d)",
                    cameraVectorX, cameraVectorY, cameraVectorZ));
            System.out.println(
                String.format("Set camera perspective field of view to: %d",
                    cameraPerspectiveFieldOfView));
            System.out.println(
                String.format("Set camera near clipping plane to: %d", 
                    cameraNearClippingPlane));
            System.out.println(
                String.format("Set camera far clipping plane to: %d",
                    cameraFarClippingPlane));
            System.out.println(
                String.format("Created directional light at (x:%d, y:%d, z:%d)",
                    directionalLightX, directionalLightY, directionalLightZ));
            System.out.println(
                String.format("Set light color to %s",
                    directionalLightColor));
            // Render the sphere
            System.out.println(
                String.format("Drew sphere at (x:%d, y:%d z:%d) with radius equal to %d",
                    x, y, z, radius));
            System.out.println(
                String.format("considering light at (x:%d, y:%d, z:%d)",
                    directionalLightX, directionalLightY, directionalLightZ));
            System.out.println(
                String.format("and the light's color equal to %s",
                    directionalLightColor));
    }
}

每个静态方法都需要大量的参数。现在,让我们想象一下我们对我们的 Web 服务有新的要求。我们必须添加代码来渲染额外的形状,并添加不同类型的摄像机和灯光。此外,我们必须在一个IoT物联网)项目中工作,在这个项目中,我们必须在计算机视觉应用程序中重用形状,因此,我们希望利用我们为 Web 服务编写的代码,并与这个新项目共享代码库。此外,我们必须在另一个项目上工作,这个项目将在一块强大的 IoT 板上运行,具体来说,是英特尔 Joule 系列的一员,它将运行一个渲染服务,并利用其 4K 视频输出功能来显示生成的图形。我们将使用这块板载的强大四核 CPU 来运行本地渲染服务,在这种情况下,我们不会调用 Web 服务。

许多应用程序必须共享许多代码片段,我们的代码必须为新的形状、摄像机和灯光做好准备。代码很容易变得非常混乱、重复,并且难以维护。当然,先前显示的代码已经很难维护了。因此,我们将重构现有的代码,并创建许多接口和类来创建一个面向对象的版本,我们将能够根据新的要求进行扩展,并在不同的应用程序中重用。

到目前为止,我们一直在使用 JShell 来运行我们的代码示例。这一次,我们将为每个接口或类创建一个 Java 源代码文件。此外,我们将把这些文件组织到 Java 9 中引入的新模块中。最后,我们将编译这些模块并运行一个控制台应用程序。您可以使用您喜欢的编辑器或 IDE 来创建不同的代码文件。请记住,您可以下载指定的代码文件,而不必输入任何代码。

我们将创建以下公共接口、抽象类和具体类:

  • Vector3d:这个具体类表示一个可变的 3D 向量,具有xyzint值。

  • 可渲染:这个接口指定了具有位置并且可以被渲染的元素的要求。

  • 场景元素:这个抽象类实现了可渲染接口,表示任何具有位置并且可以被渲染的元素。所有的场景元素都将继承自这个抽象类。

  • 灯光:这个抽象类继承自场景元素,表示场景中的灯光,必须提供其属性的描述。

  • 定向光:这个具体类继承自灯光,表示具有特定颜色的定向光。

  • 摄像机:这个抽象类继承自场景元素,表示场景中的摄像机。

  • 透视摄像机:这个具体类继承自摄像机,表示具有方向、上向量、视野、近裁剪平面和远裁剪平面的透视摄像机。

  • 形状:这个抽象类继承自场景元素,表示场景中可以使用活动摄像机渲染并接收多个灯光的形状。

  • 球体:这个具体类继承自形状,表示一个球体。

  • 立方体:这个具体类继承自形状,表示一个立方体。

  • 场景:这个具体类表示具有活动摄像机、形状和灯光的场景。我们可以使用这个类的实例来组合一个场景并渲染它。

  • 示例 01:这个具体类将声明一个主静态方法,该方法将使用透视摄像机球体立方体定向光来创建一个场景实例并调用其渲染方法。

我们将在一个扩展名为.java的文件中声明之前列举的每个接口、抽象类和具体类,并且文件名与我们声明的类型相同。例如,我们将在名为Vector3d.java的文件中声明Vector3d类,也就是 Java 源文件。

提示

在 Java 源文件中,声明与类型相同名称的单个公共接口或类是一种良好的实践和常见约定。如果我们在 Java 源文件中声明了多个公共类型,Java 编译器将生成错误。

使用 Java 9 中的新模块化组织面向对象的代码

当我们只有一些接口和类时,数百行面向对象的代码很容易组织和维护。然而,随着类型和代码行数的增加,有必要遵循一些规则来组织代码并使其易于维护。

一个非常好的面向对象的代码如果没有以有效的方式组织,就会产生维护上的头疼。我们不应该忘记,一个良好编写的面向对象的代码促进了代码重用。

在我们的示例中,我们只会有一些接口、抽象类和具体类。然而,我们必须想象我们将有大量额外的类型来支持额外的需求。因此,我们最终将拥有数十个与渲染场景组成元素所需的数学运算相关的类,额外类型的灯光,新类型的摄像机,与这些新灯光和摄像机相关的类,以及数十个额外的形状及其相关的类。

我们将创建许多模块,以便我们可以创建具有名称、需要其他模块并导出其他模块可用和可访问的公共 API 的软件单元。当一个模块需要其他模块时,这意味着该模块依赖于列出的模块。每个模块的名称将遵循我们通常在 Java 中使用的包的相同约定。

提示

其他模块只能访问模块导出的公共类型。如果我们在模块内声明了一个公共类型,但没有将其包含在导出的 API 中,那么我们将无法在模块外部访问它。在创建模块依赖关系时,我们必须避免循环依赖。

我们将创建以下八个模块:

  • com.renderer.math

  • com.renderer.sceneelements

  • com.renderer.lights

  • com.renderer.cameras

  • com.renderer.shapes

  • com.renderer.shapes.curvededges

  • com.renderer.shapes.polyhedrons

  • com.renderer

现在,每当我们需要处理灯光时,我们将探索com.renderer.lights模块中声明的类型。每当我们需要处理具有曲边的 3D 形状时,我们将探索com.renderer.shapes.curvededges模块中声明的类型。

每个模块将在与模块名称相同的包中声明类和接口。例如,com.renderer.cameras模块将在com.renderer.cameras包中声明类。是相关类型的分组。每个包生成一个声明范围的命名空间。因此,我们将与模块结合使用包。

以下表格总结了我们将创建的模块,以及我们将在每个模块中声明的接口、抽象类和具体接口。此外,表格还指定了每个模块所需的模块列表。

模块名称 声明的公共类型 模块要求
com.renderer.math Vector3d -
com.renderer.sceneelements Rendereable``SceneElement com.renderer.math
com.renderer.lights Light``DirectionalLight com.renderer.math``com.renderer.sceneelements
com.renderer.cameras Camera``PerspectiveCamera com.renderer.math``com.renderer.sceneelements
com.renderer.shapes Shape com.renderer.math``com.renderer.sceneelements``com.renderer.lights``com.renderer.cameras
com.renderer.shapes.curvededges Sphere com.renderer.math``com.renderer.lights``co m.renderer.shapes
com.renderer.shapes.polyhedrons Cube com.renderer.math``com.renderer.lights``com.renderer.shapes
com.renderer Scene``Example01 com.renderer.math``com.renderer.cameras``com.renderer.lights``com.renderer.shapes``com.renderer.shapes.curvededges``com.renderer.shapes.polyhedrons

非常重要的是要注意,所有模块还需要java.base模块,该模块导出所有平台的核心包,如java.iojava.langjava.mathjava.netjava.util等。然而,每个模块都隐式依赖于java.base模块,因此,在声明新模块并指定其所需模块时,无需将其包含在依赖列表中。

下一个图表显示了模块图,其中模块是节点,一个模块对另一个模块的依赖是一个有向边。我们不在模块图中包括java.lang

使用 Java 9 中的新模块化组织面向对象的代码

我们不会使用任何特定的 IDE 来创建所有模块。这样,我们将了解目录结构和所有必需的文件。然后,我们可以利用我们喜欢的 IDE 中包含的功能轻松创建新模块及其必需的目录结构。

有一个约定规定,模块的源代码必须位于与模块名称相同的目录中。例如,名为com.renderer.math的模块必须位于名为com.renderer.math的目录中。我们必须为每个所需的模块创建一个模块描述符,即在模块的根文件夹中创建一个名为module-info.java的源代码文件。该文件指定了模块名称、所需的模块和模块导出的包。导出的包将被需要该模块的模块看到。

然后,需要为模块名称中由点(.)分隔的每个名称创建子目录。例如,我们将在com.renderer.math目录中创建com/renderer/math目录(在 Windows 中为com\renderer\math子文件夹)。声明每个模块的接口、抽象类和具体类的 Java 源文件将位于这些子文件夹中。

我们将创建一个名为Renderer的基本目录,其中包含一个名为src的子文件夹,其中包含我们所有模块的源代码。因此,我们将Renderer/src(在 Windows 中为Renderer\src)作为我们的源代码基本目录。然后,我们将为每个模块创建一个文件夹,其中包含module-info.java文件和 Java 源代码文件的子文件夹。以下目录结构显示了我们将在Renderer/src(在 Windows 中为Renderer\src)目录中拥有的最终内容。文件名已突出显示。

├───com.renderer
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│               Example01.java
│               Scene.java
│
├───com.renderer.cameras
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───cameras
│                   Camera.java
│                   PerspectiveCamera.java
│
├───com.renderer.lights
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───lights
│                   DirectionalLight.java
│                   Light.java
│
├───com.renderer.math
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───math
│                   Vector3d.java
│
├───com.renderer.sceneelements
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───sceneelements
│                   Rendereable.java
│                   SceneElement.java
│
├───com.renderer.shapes
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───shapes
│                   Shape.java
│
├───com.renderer.shapes.curvededges
│   │   module-info.java
│   │
│   └───com
│       └───renderer
│           └───shapes
│               └───curvededges
│                       Sphere.java
│
└───com.renderer.shapes.polyhedrons
 │   module-info.java
    │
    └───com
        └───renderer
            └───shapes
                └───polyhedrons
 Cube.java

创建模块化源代码。

现在是时候开始创建必要的目录结构,并为每个模块编写module-info.java文件和源 Java 文件的代码了。我们将创建com.renderer.math模块。

创建一个名为Renderer的目录和一个src子目录。我们将使用Renderer/src(在 Windows 中为Renderer\src)作为我们的源代码基本目录。但是,请注意,如果您下载源代码,则无需创建任何文件夹。

现在在Renderer/src(在 Windows 中为Renderer\src)中创建com.renderer.math目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。下面的行组成了名为com.renderer.math的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.math子文件夹中的module-info.java文件中。

module com.renderer.math {
    exports com.renderer.math;
}

module关键字后跟模块名称com.renderer.math开始模块声明。花括号中包含的行指定了模块主体。exports关键字后跟包名com.renderer.math表示该模块导出com.renderer.math包中声明的所有公共类型。

Renderer/src(在 Windows 中为Renderer\src)中创建com/renderer/math(在 Windows 中为com\renderer\math)文件夹。将以下行添加到名为Vector3d.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Vector3d具体类,作为com.renderer.math包的成员。我们将使用Vector3d类,而不是使用xyz的单独值。package关键字后面跟着包名,表示类将被包含在其中的包。

示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.math/com/renderer/math子文件夹中,名为Vector3d.java

package com.renderer.math;

public class Vector3d {
    public int x;
    public int y;
    public int z;

    public Vector3d(int x, 
        int y, 
        int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public Vector3d(int valueForXYZ) {
        this(valueForXYZ, valueForXYZ, valueForXYZ);
    }

    public Vector3d() {
        this(0);
    }

    public void absolute() {
        x = Math.abs(x);
        y = Math.abs(y);
        z = Math.abs(z);
    }

    public void negate() {
        x = -x;
        y = -y;
        z = -z;
    }

    public void add(Vector3d vector) {
        x += vector.x;
        y += vector.y;
        z += vector.z;
    }

    public void sub(Vector3d vector) {
        x -= vector.x;
        y -= vector.y;
        z -= vector.z;
    }

    public String toString() {
        return String.format(
            "(x: %d, y: %d, z: %d)",
            x,
            y,
            z);
    }
}

现在在Renderer/src(在 Windows 中为Renderer\src)中创建com.renderer.sceneelements目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.sceneelements的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.sceneelements子文件夹中,名为module-info.java

module com.renderer.sceneelements {
    requires com.renderer.math;
    exports com.renderer.sceneelements;
}

module关键字后面跟着模块名com.renderer.sceneelements开始模块声明。花括号内包含的行指定了模块主体。requires关键字后面跟着模块名com.renderer.math,表示该模块需要先前声明的com.renderer.math模块中导出的类型。exports关键字后面跟着包名com.renderer.sceneelements,表示该模块导出com.renderer.sceneelements包中声明的所有公共类型。

Renderer/src(在 Windows 中为Renderer\src)中创建com/renderer/sceneelements(在 Windows 中为com\renderer\sceneelements)文件夹。将以下行添加到名为Rendereable.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Rendereable接口,作为com.renderer.sceneelements包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.sceneeelements/com/renderer/sceneelements子文件夹中,名为Rendereable.java

package com.renderer.sceneelements;

import com.renderer.math.Vector3d;

public interface Rendereable {
    Vector3d getLocation();
    void setLocation(Vector3d newLocation);
    void render();
}

将以下行添加到名为SceneElement.java的文件中。接下来的行声明了公共SceneElement抽象类,作为com.renderer.sceneelements包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.sceneelements/com/renderer/sceneelements子文件夹中,名为SceneElement.java

package com.renderer.sceneelements;

import com.renderer.math.Vector3d;

public abstract class SceneElement implements Rendereable {
    protected Vector3d location;

    public SceneElement(Vector3d location) {
        this.location = location;
    }

    public Vector3d getLocation() {
        return location;
    }

    public void setLocation(Vector3d newLocation) {
        location = newLocation;
    }
}

SceneElement抽象类实现了先前定义的Rendereable接口。该类表示场景中的 3D 元素,并具有使用Vector3d指定的位置。该类是所有需要在 3D 空间中具有位置的场景元素的基类。

现在在Renderer/src(在 Windows 中为Renderer\src)中创建com.renderer.lights目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.lights的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.lights子文件夹中,名为module-info.java

module com.renderer.lights {
    requires com.renderer.math;
    requires com.renderer.sceneelements;
    exports com.renderer.lights;
}

前面的行声明了com.renderer.lights模块,并指定该模块需要两个模块:com.renderer.mathcom.renderer.sceneelementsexports关键字后面跟着包名com.renderer.lights,表示该模块导出com.renderer.lights包中声明的所有公共类型。

Renderer/src中创建com/renderer/lights(在 Windows 中为com\renderer\lights)文件夹。将以下行添加到名为Light.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Light抽象类作为com.renderer.lights包的成员。该类继承自SceneElement类,并声明了一个必须返回String类型的描述所有灯光属性的抽象getPropertiesDescription方法。从Light类继承的具体类将负责为此方法提供实现。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.lights/com/renderer/lights子文件夹中的Light.java文件中。

package com.renderer.lights;

import com.renderer.sceneelements.SceneElement;
import com.renderer.math.Vector3d;

public abstract class Light extends SceneElement {
    public Light(Vector3d location) {
        super(location);
    }

    public abstract String getPropertiesDescription();
}

将以下行添加到名为DirectionalLight.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共DirectionalLight具体类作为com.renderer.lights包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.lights/com/renderer/lights子文件夹中的DirectionalLight.java文件中。

package com.renderer.lights;

import com.renderer.math.Vector3d;

public class DirectionalLight extends Light {
    public final String color;

    public DirectionalLight(Vector3d location, 
        String color) {
        super(location);
        this.color = color;
    }

    @Override
    public void render() {
        System.out.println(
            String.format("Created directional light at %s",
                location));
        System.out.println(
            String.format("Set light color to %s",
                color));
    }

    @Override
    public String getPropertiesDescription() {
        return String.format(
            "light's color equal to %s",
            color);
    }
}

DirectionalLight具体类继承自先前定义的Light抽象类。DirectionalLight类表示定向光,并为rendergetPropertiesDescription方法提供实现。

现在在Renderer/src中创建com.renderer.cameras目录(在 Windows 中为Renderer\src)。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.cameras的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.cameras子文件夹中的module-info.java文件中。

module com.renderer.cameras {
    requires com.renderer.math;
    requires com.renderer.sceneelements;
    exports com.renderer.cameras;
}

前面的行声明了com.renderer.cameras模块,并指定该模块需要两个模块:com.renderer.mathcom.renderer.sceneelementsexports关键字后跟包名com.renderer.cameras,表示该模块导出com.renderer.cameras包中声明的所有公共类型。

Renderer/src中创建com/renderer/cameras(在 Windows 中为com\renderer\cameras)文件夹。将以下行添加到名为Camera.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Camera抽象类作为com.renderer.cameras包的成员。该类继承自SceneElement类。该类表示 3D 相机。这是所有相机的基类。在这种情况下,类声明为空,我们只声明它是因为我们知道将会有许多类型的相机。此外,我们希望能够在将来概括所有类型相机的共同要求,就像我们为灯光做的那样。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.cameras/com/renderer/cameras子文件夹中的Camera.java文件中。

package com.renderer.cameras;

import com.renderer.math.Vector3d;
import com.renderer.sceneelements.SceneElement;

public abstract class Camera extends SceneElement {
    public Camera(Vector3d location) {
        super(location);
    }
}

将以下行添加到名为PerspectiveCamera.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共PerspectiveCamera具体类作为com.renderer.cameras包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.cameras/com/renderer/cameras子文件夹中的PerspectiveCamera.java文件中。

package com.renderer.cameras;

import com.renderer.math.Vector3d;

public class PerspectiveCamera extends Camera {
    protected Vector3d direction;
    protected Vector3d vector;
    protected int fieldOfView;
    protected int nearClippingPlane;
    protected int farClippingPlane;

    public Vector3d getDirection() {
        return direction;
    }

    public void setDirection(Vector3d newDirection) {
        direction = newDirection;
    }

    public Vector3d getVector() {
        return vector;
    }

    public void setVector(Vector3d newVector) {
        vector = newVector;
    }

    public int getFieldOfView() {
        return fieldOfView;
    }

    public void setFieldOfView(int newFieldOfView) {
        fieldOfView = newFieldOfView;
    }

    public int nearClippingPlane() {
        return nearClippingPlane;
    }

    public void setNearClippingPlane(int newNearClippingPlane) {
        this.nearClippingPlane = newNearClippingPlane;
    }

    public int farClippingPlane() {
        return farClippingPlane;
    }

    public void setFarClippingPlane(int newFarClippingPlane) {
        this.farClippingPlane = newFarClippingPlane;
    }

    public PerspectiveCamera(Vector3d location, 
        Vector3d direction, 
        Vector3d vector, 
        int fieldOfView, 
        int nearClippingPlane, 
        int farClippingPlane) {
        super(location);
        this.direction = direction;
        this.vector = vector;
        this.fieldOfView = fieldOfView;
        this.nearClippingPlane = nearClippingPlane;
        this.farClippingPlane = farClippingPlane;
    }

    @Override
    public void render() {
        System.out.println(
            String.format("Created camera at %s",
                location));
        System.out.println(
            String.format("Set camera direction to %s",
                direction));
        System.out.println(
            String.format("Set camera vector to %s",
                vector));
        System.out.println(
            String.format("Set camera perspective field of view to: %d",
                fieldOfView));
        System.out.println(
            String.format("Set camera near clipping plane to: %d", 
                nearClippingPlane));
        System.out.println(
            String.format("Set camera far clipping plane to: %d",
                farClippingPlane));
    }
}

PerspectiveCamera具体类继承自先前定义的Camera抽象类。PerspectiveCamera类表示具有许多获取器和设置器方法的透视相机的实现。该类为render方法提供了一个显示所创建相机的所有细节和其不同属性值的实现。

现在在Renderer/src(Windows 中为Renderer\src)中创建com.renderer.shapes目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.shapes的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.shapes子文件夹中的module-info.java文件中。

module com.renderer.shapes {
    requires com.renderer.math;
    requires com.renderer.sceneelements;
    requires com.renderer.lights;
    requires com.renderer.cameras;
    exports com.renderer.shapes;
}

前面的行声明了com.renderer.shapes模块,并指定该模块需要四个模块:com.renderer.mathcom.renderer.sceneelementscom.renderer.lightscom.renderer.camerasexports关键字后跟包名com.renderer.shapes,表示该模块导出了com.renderer.shapes包中声明的所有公共类型。

Renderer/src(Windows 中为Renderer\src)中创建com/renderer/shapes(Windows 中为com\renderer\shapes)文件夹。将以下行添加到名为Shape.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Shape抽象类作为com.renderer.shapes包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.shapes/com/renderer/shapes子文件夹中的Shape.java文件中。

package com.renderer.shapes;

import com.renderer.math.Vector3d;
import com.renderer.sceneelements.SceneElement;
import com.renderer.lights.Light;
import com.renderer.cameras.Camera;
import java.util.*;
import java.util.stream.Collectors;

public abstract class Shape extends SceneElement {
    protected Camera activeCamera;
    protected List<Light> lights;

    public Shape(Vector3d location) {
        super(location);
        lights = new ArrayList<>();
    }

    public void setActiveCamera(Camera activeCamera) {
        this.activeCamera = activeCamera;
    }

    public void setLights(List<Light> lights) {
        this.lights = lights;
    }

    protected boolean isValidForRender() {
        return !((activeCamera == null) && lights.isEmpty());
    }

    protected String generateConsideringLights() {
        return lights.stream()
            .map(light -> String.format(
                "considering light at %s\nand %s",
                    light.getLocation(), 
                    light.getPropertiesDescription()))
            .collect(Collectors.joining());
    }
}

Shape类继承自SceneElement类。该类表示一个 3D 形状,是所有 3D 形状的基类。该类定义了以下方法:

  • setActiveCamera:这个公共方法接收一个Camera实例并将其保存为活动摄像机。

  • setLights:这个公共方法接收一个List<Light>并将其保存为必须考虑以渲染形状的灯光列表。

  • isValidForRender:这个受保护的方法返回一个boolean值,指示形状是否具有活动摄像机和至少一个灯光。否则,该形状不适合被渲染。

  • generateConsideringLights:这个受保护的方法返回一个带有正在考虑渲染形状的灯光、它们的位置和属性描述的String

Shape类的每个子类,代表特定的 3D 形状,将为render方法提供实现。我们将在另外两个模块中编写这些子类。

现在在Renderer/src(Windows 中为Renderer\src)中创建com.renderer.shapes.curvededges目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.curvededges的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.curvededges子文件夹中的module-info.java文件中。

module com.renderer.shapes.curvededges {
    requires com.renderer.math;
    requires com.renderer.lights;
    requires com.renderer.shapes;
    exports com.renderer.shapes.curvededges;
}

前面的行声明了com.renderer.shapes模块,并指定该模块需要三个模块:com.renderer.mathcom.renderer.lightscom.renderer.shapesexports关键字后跟包名com.renderer.shapes.curvededges,表示该模块导出了com.renderer.shapes.curvededges包中声明的所有公共类型。

Renderer/src(Windows 中为Renderer\src)中创建com/renderer/shapes/curvededges(Windows 中为com\renderer\shapes\curvededges)文件夹。将以下行添加到名为Sphere.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Sphere具体类作为com.renderer.shapes.curvededges包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.shapes.curvededges/com/renderer/shapes/curvededges子文件夹中的Sphere.java文件中。

package com.renderer.shapes.curvededges;

import com.renderer.math.Vector3d;
import com.renderer.shapes.Shape;
import com.renderer.lights.Light;

public class Sphere extends Shape {
    protected int radius;

    public Sphere(Vector3d location, int radius) {
        super(location);
        this.radius = radius;
    }

    public int getRadius() {
        return radius;
    }

    public void setRadius(int newRadius) { 
        radius = newRadius;
    }

    @Override
    public void render() {
        if (!isValidForRender()) {
            System.out.println(
                "Setup wasn't completed to render the sphere.");
            return;
        }
        StringBuilder sb = new StringBuilder();
        sb.append(String.format(
            "Drew sphere at %s with radius equal to %d\n",
            location, 
            radius));
        String consideringLights = 
            generateConsideringLights();
        sb.append(consideringLights);
        System.out.println(sb.toString());
    }
}

Sphere类继承自Shape类,并在构造函数中需要一个半径值,除了指定球体位置的Vector3d实例。该类提供了render方法的实现,该方法检查isValidForRender方法返回的值。如果该方法返回true,则球体可以被渲染,并且代码将使用球体半径、位置以及在渲染球体时考虑的灯光构建消息。代码调用generateConsideringLights方法来构建消息。

现在在Renderer/src(Windows 中为Renderer\src)中创建com.renderer.shapes.polyhedrons目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer.polyhedrons的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.polyhedrons子文件夹中的module-info.java文件中。

module com.renderer.shapes.polyhedrons {
    requires com.renderer.math;
    requires com.renderer.lights;
    requires com.renderer.shapes;
    exports com.renderer.shapes.polyhedrons;
}

前面的行声明了com.renderer.polyhedrons模块,并指定该模块需要三个模块:com.renderer.mathcom.renderer.lightscom.renderer.shapesexports关键字后跟包名com.renderer.shapes.polyhedrons,表示该模块导出com.renderer.shapes.polyhedrons包中声明的所有公共类型。

Renderer/src(Windows 中为Renderer\src)中创建com/renderer/shapes/polyhedrons(Windows 中为com\renderer\shapes\polyhedrons)文件夹。将以下行添加到名为Cube.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Cube具体类作为com.renderer.shapes.polyhedrons包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer.shapes.polyhedrons/com/renderer/shapes/polyhedrons子文件夹中的Cube.java文件中。

package com.renderer.shapes.polyhedrons;

import com.renderer.math.Vector3d;
import com.renderer.shapes.Shape;
import com.renderer.lights.Light;
import java.util.stream.Collectors;

public class Cube extends Shape {
    protected int edgeLength;

    public Cube(Vector3d location, int edgeLength) {
        super(location);
        this.edgeLength = edgeLength;
    }

    public int getEdgeLength() {
        return edgeLength;
    }

    public void setEdgeLength(int newEdgeLength) { 
        edgeLength = newEdgeLength;
    }

    @Override
    public void render() {
        if (!isValidForRender()) {
            System.out.println(
                "Setup wasn't completed to render the cube.");
            return;
        }
        StringBuilder sb = new StringBuilder();
        sb.append(String.format(
            "Drew cube at %s with edge length equal to %d\n",
            location,
            edgeLength));
        String consideringLights = 
            generateConsideringLights();
        sb.append(consideringLights);
        System.out.println(sb.toString());
    }
}

Cube类继承自Shape类,并在构造函数中需要一个edgeLength值,除了指定立方体位置的Vector3d。该类提供了render方法的实现,该方法检查isValidForRender方法返回的值。如果该方法返回true,则立方体可以被渲染,并且代码将使用立方体的边长、位置以及在渲染立方体时考虑的灯光构建消息。代码调用generateConsideringLights方法来构建消息。

现在在Renderer/src(Windows 中为Renderer\src)中创建com.renderer目录。将以下行添加到名为module-info.java的文件中,该文件位于最近创建的子文件夹中。接下来的行组成了名为com.renderer的模块描述符。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer子文件夹中的module-info.java文件中。

module com.renderer {
    exports com.renderer;
    requires com.renderer.math;
    requires com.renderer.cameras;
    requires com.renderer.lights;
    requires com.renderer.shapes;
    requires com.renderer.shapes.curvededges;
    requires com.renderer.shapes.polyhedrons;
}

前面的行声明了com.renderer模块,并指定该模块需要六个模块:com.renderer.mathcom.renderer.camerascom.renderer.lightscom.renderer.shapescom.renderer.shapes.curvededgescom.renderer.shapes.polyhedronsexports关键字后跟包名com.renderer,表示该模块导出com.renderer包中声明的所有公共类型。

Renderer/src(Windows 中为Renderer\src)中创建com/renderer(Windows 中为com\renderer)文件夹。将以下行添加到名为Scene.java的文件中,该文件位于最近创建的子文件夹中。接下来的行声明了公共Scene具体类作为com.renderer包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer/com/renderer子文件夹中的Scene.java文件中。

package com.renderer;

import com.renderer.math.Vector3d;
import com.renderer.cameras.Camera;
import com.renderer.lights.Light;
import com.renderer.shapes.Shape;
import java.util.*;

public class Scene {
    protected List<Light> lights;
    protected List<Shape> shapes;
    protected Camera activeCamera;

    public Scene(Camera activeCamera) {
        this.activeCamera = activeCamera;
        this.lights = new ArrayList<>();
        this.shapes = new ArrayList<>();
    }

    public void addLight(Light light) {
        this.lights.add(light);
    }

    public void addShape(Shape shape) {
        this.shapes.add(shape);
    }

    public void render() {
        activeCamera.render();
        lights.forEach(Light::render);
        shapes.forEach(shape -> {
            shape.setActiveCamera(activeCamera);
            shape.setLights(lights);
            shape.render();
        });
    }
}

Scene类表示要渲染的场景。该类声明了一个activateCamera受保护字段,其中包含一个Camera实例。lights受保护字段是Light实例的Listshapes受保护字段是组成场景的Shape实例的ListaddLight方法将接收到的Light实例添加到List<Light>lights中。addShape方法将接收到的Shape实例添加到List<Shape> shapes中。

render方法调用活动摄像机和所有灯光的渲染方法。然后,代码对每个形状执行以下操作:设置其活动摄像机,设置灯光,并调用render方法。

最后,将以下行添加到名为Example01.java的文件中。接下来的行声明了公共Example01具体类作为com.renderer包的成员。示例的代码文件包含在java_9_oop_chapter_13_01/Renderer/src/com.renderer/com/renderer子文件夹中的Example01.java文件中。

package com.renderer;

import com.renderer.math.Vector3d;
import com.renderer.cameras.PerspectiveCamera;
import com.renderer.lights.DirectionalLight;
import com.renderer.shapes.curvededges.Sphere;
import com.renderer.shapes.polyhedrons.Cube;

public class Example01 {
    public static void main(String[] args){
        PerspectiveCamera camera = new PerspectiveCamera(
            new Vector3d(30),
            new Vector3d(50, 0, 0),
            new Vector3d(4, 5, 2),
            90,
            20,
            40);
        Sphere sphere = new Sphere(new Vector3d(20), 8);
        Cube cube = new Cube(new Vector3d(10), 5);
        DirectionalLight light = new DirectionalLight(
            new Vector3d(2, 2, 5), "Cornflower blue");
        Scene scene = new Scene(camera);
        scene.addShape(sphere);
        scene.addShape(cube);
        scene.addLight(light);
        scene.render();
    }
}

Example01类是我们测试应用程序的主类。该类只声明了一个名为mainstatic方法,该方法接收一个名为argsString数组作为参数。当我们执行应用程序时,Java 将调用此方法,并将参数传递给args参数。在这种情况下,main方法中的代码不考虑任何指定的参数。

主要方法创建一个具有必要参数的PerspectiveCamera实例,然后创建一个名为shapecubeShapeCube。然后,代码创建一个名为lightDirectionalLight实例。

下一行创建一个具有camera作为activeCamera参数值的Scene实例。然后,代码两次调用scene.addShape方法,参数分别为spherecube。最后,代码调用scene.addLight,参数为light,并调用scene.render方法来显示模拟渲染过程生成的消息。

使用 Java 9 编译器编译多个模块

在名为Renderer的基本目录中创建一个名为mods的子文件夹。这个新的子文件夹将复制我们在Renderer/src(Windows 中的Renderer\src)文件夹中创建的目录结构。我们将运行 Java 编译器为每个 Java 源文件生成一个 Java 类文件。Java 类文件将包含可以在Java 虚拟机上执行的 Java 字节码,也称为JVM。对于每个具有.java扩展名的 Java 源文件,包括模块描述符,我们将有一个具有.class扩展名的文件。例如,当我们成功使用 Java 编译器编译Renderer/src/com.renderer.math/com/renderer/math/Vector3d.java源文件时,编译器将生成一个Renderer/mods/com.renderer.math/com/renderer/math/Vector3d.class文件,其中包含 Java 字节码(称为 Java 类文件)。在 Windows 中,我们必须使用反斜杠(\)作为路径分隔符,而不是斜杠(/)。

现在,在 macOS 或 Linux 上打开一个终端窗口,或者在 Windows 上打开命令提示符,并转到Renderer文件夹。确保javac命令包含在路径中,并且它是 Java 9 的 Java 编译器,而不是之前版本的 Java 编译器,这些版本不兼容 Java 9 中引入的模块。

在 macOS 或 Linux 中,运行以下命令来编译我们最近创建的所有模块,并将生成的 Java 类文件放在mods文件夹中的目录结构中。-d选项指定了生成类文件的位置,--module-source-path选项指示了多个模块的输入源文件的位置。

javac -d mods --module-source-path src src/com.renderer.math/module-info.java src/com.renderer.math/com/renderer/math/Vector3d.java src/com.renderer.sceneelements/module-info.java src/com.renderer.sceneelements/com/renderer/sceneelements/Rendereable.java src/com.renderer.sceneelements/com/renderer/sceneelements/SceneElement.java src/com.renderer.cameras/module-info.java src/com.renderer.cameras/com/renderer/cameras/Camera.java src/com.renderer.cameras/com/renderer/cameras/PerspectiveCamera.java src/com.renderer.lights/module-info.java src/com.renderer.lights/com/renderer/lights/DirectionalLight.java src/com.renderer.lights/com/renderer/lights/Light.java src/com.renderer.shapes/module-info.java src/com.renderer.shapes/com/renderer/shapes/Shape.java src/com.renderer.shapes.curvededges/module-info.java src/com.renderer.shapes.curvededges/com/renderer/shapes/curvededges/Sphere.java src/com.renderer.shapes.polyhedrons/module-info.java src/com.renderer.shapes.polyhedrons/com/renderer/shapes/polyhedrons/Cube.java src/com.renderer/module-info.java src/com.renderer/com/renderer/Example01.java src/com.renderer/com/renderer/Scene.java

在 Windows 中,运行以下命令以实现相同的目标:

javac -d mods --module-source-path src src\com.renderer.math\module-info.java src\com.renderer.math\com\renderer\math\Vector3d.java src\com.renderer.sceneelements\module-info.java src\com.renderer.sceneelements\com\renderer\sceneelements\Rendereable.java src\com.renderer.sceneelements\com\renderer\sceneelements\SceneElement.java src\com.renderer.cameras\module-info.java src\com.renderer.cameras\com\renderer\cameras\Camera.java src\com.renderer.cameras\com\renderer\cameras\PerspectiveCamera.java src\com.renderer.lights\module-info.java src\com.renderer.lights\com\renderer\lights\DirectionalLight.java src\com.renderer.lights\com\renderer\lights\Light.java src\com.renderer.shapes\module-info.java src\com.renderer.shapes\com\renderer\shapes\Shape.java src\com.renderer.shapes.curvededges\module-info.java src\com.renderer.shapes.curvededges\com\renderer\shapes\curvededges\Sphere.java src\com.renderer.shapes.polyhedrons\module-info.java src\com.renderer.shapes.polyhedrons\com\renderer\shapes\polyhedrons\Cube.java src\com.renderer\module-info.java src\com.renderer\com\renderer\Example01.java src\com.renderer\com\renderer\Scene.java

以下目录结构显示了我们将在Renderer/mods(Windows 中的Renderer\mods)目录中拥有的最终内容。Java 编译器生成的 Java 类文件已经高亮显示。

├───com.renderer
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│               Example01.class
│               Scene.class
│
├───com.renderer.cameras
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───cameras
│                   Camera.class
│                   PerspectiveCamera.class
│
├───com.renderer.lights
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───lights
│                   DirectionalLight.class
│                   Light.class
│
├───com.renderer.math
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───math
│                   Vector3d.class
│
├───com.renderer.sceneelements
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───sceneelements
│                   Rendereable.class
│                   SceneElement.class
│
├───com.renderer.shapes
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───shapes
│                   Shape.class
│
├───com.renderer.shapes.curvededges
│   │   module-info.class
│   │
│   └───com
│       └───renderer
│           └───shapes
│               └───curvededges
│                       Sphere.class
│
└───com.renderer.shapes.polyhedrons
 │   module-info.class
    │
    └───com
        └───renderer
            └───shapes
                └───polyhedrons
 Cube.class

使用 Java 9 运行模块化代码

最后,我们可以使用 java 命令启动 Java 应用程序。返回 macOS 或 Linux 上的终端窗口,或者 Windows 上的命令提示符,并确保你在 Renderer 文件夹中。确保 java 命令包含在路径中,并且它是 Java 9 的 java 命令,而不是不兼容 Java 9 中引入的模块的先前 Java 版本的 java 命令。

在 macOS、Linux 或 Windows 中,运行以下命令来加载已编译的模块,解析 com.renderer 模块,并运行 com.renderer 包中声明的 Example01 类的 main 静态方法。--module-path 选项指定可以找到模块的目录。在这种情况下,我们只指定 mods 文件夹。但是,我们可以包括许多由分号 (;) 分隔的目录。-m 选项指定要解析的初始模块名称,后面跟着一个斜杠 (/) 和要执行的主类的名称。

java --module-path mods -m com.renderer/com.renderer.Example01

以下行显示了执行先前命令后运行 Example01 类的 main 静态方法后生成的输出。

Created camera at (x: 30, y: 30, z: 30)
Set camera direction to (x: 50, y: 0, z: 0)
Set camera vector to (x: 4, y: 5, z: 2)
Set camera perspective field of view to: 90
Set camera near clipping plane to: 20
Set camera far clipping plane to: 40
Created directional light at (x: 2, y: 2, z: 5)
Set light color to Cornflower blue
Drew sphere at (x: 20, y: 20, z: 20) with radius equal to 8
considering light at (x: 2, y: 2, z: 5)
and light's color equal to Cornflower blue
Drew cube at (x: 10, y: 10, z: 10) with edge length equal to 5
considering light at (x: 2, y: 2, z: 5)
and light's color equal to Cornflower blue

在以前的 Java 版本中,我们可以将许多 Java 类文件及其关联的元数据和资源聚合到一个名为 JARJava 存档)文件的压缩文件中。我们还可以将模块打包为包含 module-info.class 文件的模块化 JAR,该文件在顶层文件夹中的压缩文件中。

此外,我们可以使用 Java 链接工具 (jlink) 创建一个定制的运行时映像,其中只包括我们应用程序所需的模块。这样,我们可以利用整体程序优化,并生成一个在 JVM 之上运行的自定义运行时映像。

测试你的知识

  1. 默认情况下,模块需要:

  2. java.base 模块。

  3. java.lang 模块。

  4. java.util 模块。

  5. 有一个约定规定,Java 9 模块的源代码必须位于一个具有以下内容的目录中:

  6. 与模块导出的主类相同的名称。

  7. 与模块名称相同的名称。

  8. 与模块导出的主类型相同的名称。

  9. 以下哪个源代码文件是模块描述符:

  10. module-def.java

  11. module-info.java

  12. module-data.java

  13. 以下是模块描述符中必须跟随模块名称的关键字:

  14. name

  15. module-name

  16. module

  17. 模块描述符中的 exports 关键字后跟包名表示模块导出:

  18. 包中声明的所有类。

  19. 包中声明的所有类型。

  20. 包中声明的所有公共类型。

总结

在本章中,我们学会了重构现有代码,充分利用 Java 9 的面向对象代码。我们已经为未来的需求准备好了代码,减少了维护成本,并最大程度地重用了代码。

我们学会了组织面向对象的代码。我们创建了许多 Java 源文件。我们在不同的 Java 源文件中声明了接口、抽象类和具体类。我们利用了 Java 9 中包含的新模块化特性,创建了许多具有对不同模块的依赖关系并导出特定类型的模块。我们学会了声明模块,将它们编译成 Java 字节码,并在 JShell 之外启动应用程序。

现在你已经学会在 Java 9 中编写面向对象的代码,你可以在真实的桌面应用程序、移动应用、企业应用程序、Web 服务和 Web 应用程序中使用你学到的一切。这些应用程序将最大程度地重用代码,简化维护,并且始终为未来的需求做好准备。你可以使用 JShell 轻松地原型化新的接口和类,这将提高你作为面向对象的 Java 9 开发人员的生产力。

附录 A:练习答案

第一章,JShell-用于 Java 9 的读取-求值-打印-循环

问题 答案
1 1
2 3
3 2
4 1
5 2

第二章,从现实世界对象到 UML 图和 Java 9 通过 JShell

问题 答案
1 3
2 2
3 1
4 3
5 3
6 1
7 2

第三章,类和实例

问题 答案
1 3
2 1
3 3
4 2
5 2
6 3
7 1

第四章,数据的封装

问题 答案
1 2
2 1
3 2
4 1
5 3

第五章,可变和不可变类

问题 答案
1 2
2 3
3 2
4 1
5 2

第六章,继承,抽象,扩展和特化

问题 答案
1 2
2 2
3 1
4 3
5 3

第七章,成员继承和多态性

问题 答案
1 2
2 1
3 3
4 1
5 2

第八章,接口的合同编程

问题 答案
1 2
2 1
3 3
4 2
5 2

第九章,接口的高级合同编程

问题 答案
1 3
2 3
3 1
4 3
5 3

第十章,使用泛型最大化代码重用

问题 答案
1 2
2 2
3 1
4 3
5 1

第十一章,高级泛型

问题 答案
1 3
2 1
3 2
4 3
5 3

第十二章,面向对象,函数式编程和 Lambda 表达式

问题 答案
1 2
2 1
3 3
4 1
5 2

第十三章,Java 9 中的模块化

问题 答案
1 1
2 2
3 2
4 3
5 3
posted @ 2025-09-10 15:06  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报