Java-基础知识-全-

Java 基础知识(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了作者、本书的覆盖范围、开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

自 Java 诞生以来,它已经席卷了编程世界。其特性和功能为开发人员提供了编写强大的跨平台应用程序所需的工具。《Java 基础》向您介绍了这些工具和功能,使您能够创建 Java 程序。本书从语言的介绍、其哲学和演变开始,直到最新版本。您将了解javac/java工具的工作原理,以及 Java 包的方式,以及 Java 程序通常的组织方式。一旦您对此感到满意,您将被介绍到语言的高级概念,如控制流关键字。您将探索面向对象编程及其在 Java 中的作用。在结束课程中,您将掌握类、类型转换和接口,并了解数据结构、数组和字符串的用途;处理异常;以及创建泛型。

通过本书,您将学会如何编写程序、自动化任务,并阅读高级算法和数据结构书籍,或者探索更高级的 Java 书籍。

关于作者

Gazihan Alankus是伊兹密尔经济大学的助理教授,教授与移动应用程序、游戏和物联网相关的书籍。他在华盛顿大学圣路易斯分校获得博士学位,并在谷歌实习。2019 年,他成为了谷歌开发者专家,专注于 Dart 编程语言。他喜欢参与各种研究和开发项目。

Rogério Theodoro de Brito拥有巴西圣保罗大学的计算机科学学士学位和计算生物学硕士学位。在学术上,他是自由/开源软件(FOSS)的爱好者,并在巴西圣保罗的麦肯齐长老会大学教授计算机科学和 IT 的各种课程。他是 Packt 的edX 电子学习课程营销的技术审阅员。

在完成硕士学位后,他开始担任学术讲师的角色,并一直在使用许多语言,如 C、C++、Java、C、Perl 和 Python。

Basheer Ahamed Fazal在印度一家著名的基于软件即服务的产品公司担任技术架构师。他曾在科技组织如 Cognizant、Symantec、HID Global 和 Ooyala 工作。他通过解决围绕敏捷产品开发的复杂问题,包括微服务、亚马逊云服务、基于谷歌云的架构、应用安全和大数据和人工智能驱动的倡议,磨练了自己的编程和算法能力。

Vinicius Isola拥有圣保罗大学物理学学士学位。当 Macromedia Flash 占据互联网时,他开始学习如何编写 ActionScript 程序。在学习 Visual Basic 的 10 个月课程期间,他使用它来构建细胞自动机与遗传算法相结合的生命模拟,用于大学的科学启蒙计划。

如今,他在 Everbridge 担任全职软件工程师,并利用业余时间学习新的编程语言,如 Go,并构建工具来帮助开发人员实现强大的持续集成和持续部署的自动化流水线。

Miles Obare领导着位于内罗毕的体育博彩公司 Betika 的数据工程团队。他致力于构建实时、可扩展的后端系统。此前,他曾在一家金融科技初创公司担任数据工程师,其工作涉及开发和部署数据管道和机器学习模型到生产环境。他拥有电气和计算机工程学位,并经常撰写有关分布式系统的文章。

目标

  • 创建和运行 Java 程序

  • 在代码中使用数据类型、数据结构和控制流

  • 创建对象时实施最佳实践

  • 与构造函数和继承一起工作

  • 了解高级数据结构以组织和存储数据

  • 使用泛型进行更强的编译时类型检查

  • 学习如何处理代码中的异常

受众

Java 基础是为熟悉一些编程语言并希望快速了解 Java 最重要原则的技术爱好者设计的。

方法

Java 基础采用实用的方法,以最短的时间为初学者提供最基本的数据分析工具。它包含多个使用真实商业场景的活动,供您练习并在高度相关的环境中应用您的新技能。

硬件要求

为了获得最佳的学生体验,我们建议以下硬件配置:

  • 处理器:Intel Core i7 或同等级

  • 内存:8GB RAM

  • 存储空间:35GB 可用空间

软件要求

您还需要提前安装以下软件:

  • 操作系统:Windows 7 或更高版本

  • Java 8 JDK

  • IntelliJ IDEA

安装和设置

IntelliJ IDEA 是一个集成开发环境,试图将您可能需要的所有开发工具集成到一个地方。

安装 IntelliJ IDEA

  1. 要在您的计算机上安装 IntelliJ,请转到 https://www.jetbrains.com/idea/download/#section=windows 并下载适用于您操作系统的社区版。

  2. 打开下载的文件。您将看到以下窗口。单击下一步图 0.1:IntelliJ IDEA 社区设置向导

图 0.1:IntelliJ IDEA 社区设置向导

  1. 选择安装 IntelliJ 的目录,然后选择下一步图 0.2:选择安装位置的向导
图 0.2:选择安装位置的向导
  1. 选择首选安装选项,然后单击下一步图 0.3:选择安装选项的向导
图 0.3:选择安装选项的向导
  1. 选择开始菜单文件夹,然后单击安装图 0.4:选择开始菜单文件夹的向导
图 0.4:选择开始菜单文件夹的向导
  1. 下载完成后单击完成

图 0.5:完成安装的向导

图 0.5:完成安装的向导

安装完 IntelliJ 后重新启动系统。

安装 Java 8 JDK

Java 开发工具包(JDK)是使用 Java 编程语言构建应用程序的开发环境:

  1. 要安装 JDK,请转到 https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html。

  2. 转到Java SE Development Kit 8u201并选择接受许可协议选项。

  3. 下载适用于您操作系统的 JDK。

  4. 下载文件后运行安装程序一次。

约定

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:"正确的指令应该是System.out.println。"

代码块设置如下:

public class Test { //line 1
    public static void main(String[] args) { //line 2
        System.out.println("Test"); //line 3
    } //line 4
} //line 5

新术语和重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中显示为这样:"右键单击src文件夹,然后选择新建 | 。"

安装代码包

从 GitHub 存储库下载该书的代码包,并将其复制到您安装了 IntelliJ 的文件夹中。

其他资源

该书的代码包也托管在 GitHub 上:https://github.com/TrainingByPackt/Java-Fundamentals。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在 https://github.com/PacktPublishing/ 上找到。快去看看吧!

第一章:第一章

介绍 Java

学习目标

在本课结束时,你将能够:

  • 描述 Java 生态系统的工作

  • 编写简单的 Java 程序

  • 从用户那里读取输入

  • 利用 java.util 包中的类

介绍

在这第一课中,我们开始学习 Java。如果你是从其他编程语言的背景下来学习 Java,你可能知道 Java 是一种用于编程计算机的语言。但 Java 不仅仅是如此。它不仅仅是一种无处不在的非常流行和成功的语言,它还是一系列技术。除了语言之外,它还包括一个非常丰富的生态系统,并且有一个充满活力的社区,致力于使生态系统尽可能动态。

Java 生态系统

Java 生态系统的三个最基本部分是Java 虚拟机(JVM)Java 运行时环境(JRE)Java 开发工具包(JDK),它们是 Java 实现提供的标准部分。

图 1.1:Java 生态系统的表示

图 1.1:Java 生态系统的表示

每个 Java 程序都在JVM的控制下运行。每次运行 Java 程序时,都会创建一个 JVM 实例。它为正在运行的 Java 程序提供安全性和隔离。它防止代码运行与系统中的其他程序发生冲突。它的工作原理类似于一个非严格的沙箱,使其可以安全地提供资源,即使在敌对环境(如互联网)中,但允许与其运行的计算机进行互操作。简单来说,JVM 就像一个计算机内的计算机,专门用于运行 Java 程序。

注意

服务器通常同时执行许多 JVM。

在 Java 技术的标准层次结构中是java命令)。它包括所有基本的 Java 类(运行时)以及与主机系统交互的库(如字体管理,与图形系统通信,播放声音的能力以及在浏览器中执行 Java 小程序的插件)和实用程序(如 Nashorn JavaScript 解释器和 keytool 加密操作工具)。如前所述,JRE 包括 JVM。

在 Java 技术的顶层是javac。JDK 还包括许多辅助工具,如 Java 反汇编器(javap),用于创建 Java 应用程序包的实用程序(jar),从源代码生成文档的系统(javadoc)等等。JDK 是 JRE 的超集,这意味着如果你有 JDK,那么你也有 JRE(和 JVM)。

但这三个部分并不是 Java 的全部。Java 的生态系统包括社区的大量参与,这是该平台受欢迎的原因之一。

注意

对 GitHub 上顶级 Java 项目使用的最流行的 Java 库进行的研究(根据 2016 年和 2017 年的重复研究)显示,JUnit,Mockito,Google 的 Guava,日志库(log4j,sl4j)以及所有 Apache Commons(Commons IO,Commons Lang,Commons Math 等)都标志着它们的存在,还有连接到数据库的库,用于数据分析和机器学习的库,分布式计算等几乎你能想象到的任何其他用途。换句话说,几乎任何你想编写程序的用途都有现有的工具库来帮助你完成任务。

除了扩展 Java 标准发行版功能的众多库之外,还有大量工具可以自动化构建(例如 Apache Ant,Apache Maven 和 Gradle),自动化测试,分发和持续集成/交付程序(例如 Jenkins 和 Apache Continuum),以及更多其他工具。

我们的第一个 Java 应用程序

正如我们之前简要提到的,Java 中的程序是用源代码(即普通文本,人类可读文件)编写的,这些源代码由编译器(在 Java 的情况下是javac)处理,以生成包含 Java 字节码的类文件。包含 Java 字节码的类文件,然后被提供给一个名为 java 的程序,其中包含执行我们编写的程序的 Java 解释器/JVM:

图 1.2:Java 编译过程

图 1.2:Java 编译过程

简单 Java 程序的语法

像所有编程语言一样,Java 中的源代码必须遵循特定的语法。只有这样,程序才能编译并提供准确的结果。由于 Java 是一种面向对象的编程语言,Java 中的所有内容都包含在类中。一个简单的 Java 程序看起来类似于这样:

public class Test { //line 1
    public static void main(String[] args) { //line 2
        System.out.println("Test"); //line 3
    } //line 4
} //line 5

每个 java 程序文件的名称应与包含main()的类的名称相同。这是 Java 程序的入口点。

因此,只有当这些指令存储在名为Test.java的文件中时,前面的程序才会编译并运行而不会出现任何错误。

Java 的另一个关键特性是它区分大小写。这意味着System.out.Println会抛出错误,因为它的大小写没有正确。正确的指令应该是System.out.println

main()应该始终声明如示例所示。这是因为,如果main()不是一个public方法,编译器将无法访问它,java 程序将无法运行。main()是静态的原因是因为我们不使用任何对象来调用它,就像你对 Java 中的所有其他常规方法一样。

注意

我们将在本书的后面讨论这些publicstatic关键字。

注释用于提供一些额外的信息。Java 编译器会忽略这些注释。

单行注释用//表示,多行注释用/* */表示。

练习 1:一个简单的 Hello World 程序

  1. 右键单击src文件夹,选择新建 |

  2. 输入HelloWorld作为类名,然后点击确定

  3. 在类中输入以下代码:

public class HelloWorld{    
public static void main(String[] args) {  // line 2
        System.out.println("Hello, world!");  // line 3
    }
}
  1. 通过点击运行 | 运行“Main”来运行程序。

程序的输出应该如下所示:

Hello World!

练习 2:执行简单数学运算的简单程序

  1. 右键单击src文件夹,选择新建 |

  2. 输入ArithmeticOperations作为类名,然后点击确定

  3. 用以下代码替换此文件夹中的代码:

public class ArithmeticOperations {
    public static void main(String[] args) {
            System.out.println(4 + 5);
            System.out.println(4 * 5);
            System.out.println(4 / 5);
            System.out.println(9 / 2);
    }
}
  1. 运行主程序。

输出应该如下所示:

9
20
0
4

在 Java 中,当您将一个整数(例如 4)除以另一个整数(例如 5)时,结果总是一个整数(除非您另有指示)。在前面的情况下,不要惊讶地看到 4/5 的结果是 0,因为这是 4 除以 5 的商(您可以使用%而不是除法线来获得除法的余数)。

要获得 0.8 的结果,您必须指示除法是浮点除法,而不是整数除法。您可以使用以下行来实现:

System.out.println(4.0 / 5);

是的,这意味着,像大多数编程语言一样,Java 中有多种类型的数字。

练习 3:显示非 ASCII 字符

  1. 右键单击src文件夹,选择新建 |

  2. 输入ArithmeticOperations作为类名,然后点击确定

  3. 用以下代码替换此文件夹中的代码:

public class HelloNonASCIIWorld {
    public static void main(String[] args) {
            System.out.println("Non-ASCII characters: ☺");
            System.out.println("∀x ∈ ℝ: ⌈x⌉ = −⌊−x⌋");
            System.out.println("π ≅ " + 3.1415926535); // + is used to concatenate 
    }
}
  1. 运行主程序。

程序的输出应该如下所示:

Non-ASCII characters: ☺
∀x ∈ ℝ: ⌈x⌉ = −⌊−x⌋
π ≅ 3.1415926535

活动 1:打印简单算术运算的结果

要编写一个打印任意两个值的和和乘积的 java 程序,请执行以下步骤:

  1. 创建一个新类。

  2. main()中,打印一句描述您将执行的值的操作以及结果。

  3. 运行主程序。您的输出应该类似于以下内容:

The sum of 3 + 4 is 7
The product of 3 + 4 is 12

注意

此活动的解决方案可以在 304 页找到。

从用户那里获取输入

我们之前学习过一个创建输出的程序。现在,我们要学习一个补充性的程序:一个从用户那里获取输入,以便程序可以根据用户给程序的内容来工作:

import java.io.IOException; // line 1
public class ReadInput { // line 2
    public static void main(String[] args) throws IOException { // line 3
        System.out.println("Enter your first byte");
        int inByte = System.in.read(); // line 4
        System.out.println("The first byte that you typed: " + (char) inByte); // line 5
        System.out.printf("%s: %c.%n", "The first byte that you typed", inByte); // line 6
    } // line 7
} // line 8

现在,我们必须剖析我们的新程序的结构,即具有公共类ReadInput的程序。你可能注意到它有更多的行,而且显然更复杂,但不要担心:在合适的时候,每一个细节都会被揭示出来(以其全部、光辉的深度)。但是,现在,一个更简单的解释就足够了,因为我们不想失去对主要内容的关注,即从用户那里获取输入。

首先,在第 1 行,我们使用了import关键字,这是我们之前没有见过的。所有的 Java 代码都是以分层方式组织的,有许多包(我们稍后会更详细地讨论包,包括如何创建自己的包)。

这里,层次结构意味着“像树一样组织”,类似于家谱。在程序的第 1 行,import这个词简单地意味着我们将使用java.io.Exception包中组织的方法或类。

在第 2 行,我们像以前一样创建了一个名为ReadInput的新公共类,没有任何意外。正如预期的那样,这个程序的源代码必须在一个名为ReadInput.java的源文件中。

在第 3 行,我们开始定义我们的main方法,但是这次在括号后面加了几个词。新词是throws IOException。为什么需要这个呢?

简单的解释是:“否则,程序将无法编译。”更长的解释是:“因为当我们从用户那里读取输入时,可能会出现错误,Java 语言强制我们告诉编译器关于程序在执行过程中可能遇到的一些错误。”

另外,第 3 行是需要第 1 行的import的原因:IOException是一个特殊的类,位于java.io.Exception层次结构之下。

第 5 行是真正行动开始的地方:我们定义了一个名为inByte(缩写为“将要输入的字节”)的变量,它将包含System.in.read方法的结果。

System.in.read方法在执行时,将从标准输入(通常是键盘,正如我们已经讨论过的)中取出第一个字节(仅一个),并将其作为答案返回给执行它的人(在这种情况下,就是我们,在第 5 行)。我们将这个结果存储在inByte变量中,并继续执行程序。

在第 6 行,我们打印(到标准输出)一条消息,说明我们读取了什么字节,使用了调用System.out.println方法的标准方式。

注意,为了打印字节(而不是代表计算机字符的内部数字),我们必须使用以下形式的结构:

  • 一个开括号

  • 单词char

  • 一个闭括号

我们在名为inByte的变量之前使用了这个。这个结构被称为类型转换,将在接下来的课程中更详细地解释。

在第 7 行,我们使用了另一种方式将相同的消息打印到标准输出。这是为了向你展示有多少任务可以以不止一种方式完成,以及“没有单一正确”的方式。在这里,我们使用了System.out.println函数。

其余的行只是关闭了main方法定义和ReadInput类的大括号。

System.out.printf的一些主要格式字符串列在下表中:

表 1.1:格式字符串及其含义

表 1.1:格式字符串及其含义

还有许多其他格式化字符串和许多变量,你可以在 Oracle 的网站上找到完整的规范。

我们将看到一些其他常见(修改过的)格式化字符串,例如%.2f(指示函数打印小数点后恰好两位小数的浮点数,例如 2.57 或-123.45)和%03d(指示函数打印至少三位数的整数,可能左侧填充 0,例如 001 或 123 或 27204)。

练习 4:从用户那里读取值并执行操作

从用户那里读取两个数字并打印它们的乘积,执行以下步骤:

  1. 右键单击src文件夹,然后选择新建 |

  2. 输入ProductOfNos作为类名,然后单击确定

  3. 导入java.io.IOException包:

import java.io.IOException;
  1. main()中输入以下代码以读取整数:
public class ProductOfNos{
public static void main(String[] args){
System.out.println("Enter the first number");
int var1 = Integer.parseInt(System.console().readLine());
System.out.println("Enter the Second number");
int var2 = Integer.parseInt(System.console().readLine());
  1. 输入以下代码以显示两个变量的乘积:
System.out.printf("The product of the two numbers is %d", (var1 * var2));
}
}
  1. 运行程序。您应该看到类似于以下内容的输出:
Enter the first number
10
Enter the Second number
20
The product of the two numbers is 200

干得好,这是你的第一个 Java 程序。

包是 Java 中的命名空间,可用于在具有相同名称的多个类时避免名称冲突。

例如,我们可能有由 Sam 开发的名为Student的多个类,另一个类由 David 开发的同名类。如果我们需要在代码中使用它们,我们需要区分这两个类。我们使用包将这两个类放入两个不同的命名空间。

例如,我们可能有两个类在两个包中:

  • sam.Student

  • david.Student

这两个包在文件资源管理器中如下所示:

图 1.3:文件资源管理器中 sam.Student 和 david.Student 包的屏幕截图

图 1.3:文件资源管理器中 sam.Student 和 david.Student 包的屏幕截图

所有对 Java 语言基本的类都属于java.lang包。Java 中包含实用类的所有类,例如集合类、本地化类和时间实用程序类,都属于java.util包。

作为程序员,您可以创建和使用自己的包。

使用包时需要遵循的规则

在使用包时需要考虑一些规则:

  • 包应该用小写字母编写

  • 为了避免名称冲突,包名应该是公司的反向域。例如,如果公司域是example.com,那么包名应该是com.example。因此,如果我们在该包中有一个Student类,可以使用com.example.Student访问该类。

  • 包名应该对应文件夹名。对于前面的例子,文件夹结构将如下所示:图 1.4:文件资源管理器中的文件夹结构的屏幕截图

图 1.4:文件资源管理器中的文件夹结构的屏幕截图

要在代码中使用包中的类,您需要在 Java 文件的顶部导入该类。例如,要使用 Student 类,您可以按如下方式导入它:

import com.example.Student;
public class MyClass {
}

Scannerjava.util包中的一个有用的类。这是一种输入类型(例如 int 或字符串)的简单方法。正如我们在早期的练习中看到的,包使用nextInt()以以下语法输入整数:

sc = new Scanner(System.in);
int x =  sc.nextIn()

活动 2:从用户那里读取值并使用 Scanner 类执行操作

从用户那里读取两个数字并打印它们的和,执行以下步骤:

  1. 创建一个新类,并将ReadScanner作为类名输入

  2. 导入java.util.Scanner

  3. main()中使用System.out.print要求用户输入两个变量ab的数字。

  4. 使用System.out.println输出两个数字的和。

  5. 运行主程序。

输出应该类似于这样:

Enter a number: 12
Enter 2nd number: 23
The sum is 35\.  

注意

此活动的解决方案可在 304 页找到。

活动 3:计算金融工具的百分比增长或减少

用户期望看到股票和外汇等金融工具的日增长或减少百分比。我们将要求用户输入股票代码,第一天的股票价值,第二天相同股票的价值,计算百分比变化并以格式良好的方式打印出来。为了实现这一点,执行以下步骤:

  1. 创建一个新类,并输入StockChangeCalculator作为类名

  2. 导入java.util.Scanner包:

  3. main()中使用System.out.print询问用户股票的symbol,然后是股票的day1day2值。

  4. 计算percentChange值。

  5. 使用System.out.println输出符号和带有两位小数的百分比变化。

  6. 运行主程序。

输出应类似于:

Enter the stock symbol: AAPL
Enter AAPL's day 1 value: 100
Enter AAPL's day 2 value: 91.5
AAPL has changed -8.50% in one day.

注意

此活动的解决方案可在 305 页找到。

摘要

本课程涵盖了 Java 的基础知识。我们看到了 Java 程序的一些基本特性,以及如何在控制台上显示或打印消息。我们还看到了如何使用输入控制台读取值。我们还研究了可以用来分组类的包,并看到了java.util包中Scanner的一个示例。

在下一课中,我们将更多地了解值是如何存储的,以及我们可以在 Java 程序中使用的不同值。

第二章:第二章

变量、数据类型和运算符

学习目标

通过本课程结束时,您将能够:

  • 在 Java 中使用原始数据类型

  • 在 Java 中使用引用类型

  • 实现简单的算术运算

  • 使用类型转换方法

  • 输入和输出各种数据类型

介绍

在上一课中,我们介绍了 Java 生态系统以及开发 Java 程序所需的工具。在本课中,我们将通过查看语言中的基本概念,如变量、数据类型和操作,开始我们的 Java 语言之旅。

变量和数据类型

计算机编程中的一个基本概念是内存,用于在计算机中存储信息。计算机使用位作为可以存储的最小信息单元。一个位要么是 1,要么是 0。我们可以将 8 位分组,得到所谓的“字节”。因为位非常小,所以在编程时通常使用字节作为最小单位。当我们编写程序时,我们实际上是从某个内存位置获取一些位,对它们进行一些操作,然后将结果写回到内存位置。

我们需要一种方法来在计算机的内存中存储不同类型的数据,并告诉计算机在哪个内存位置存储了什么类型的数据。

数据类型是我们指定需要在给定内存位置存储的数据类型和大小的一种方式。数据类型的一个示例是整数、字符或字符串。广义上讲,Java 中可用的数据类型可以分为以下类型:

  • 原始数据类型

  • 参考数据类型

原始类型是基本类型,即它们不能被修改。它们是不可分割的,并且构成了形成复杂类型的基础。Java 中有八种原始数据类型,我们将在后续章节中深入讨论:

  • byte

  • short

  • int

  • long

  • char

  • float

  • double

  • boolean

引用类型是指引用存储在特定内存位置的数据的类型。它们本身不保存数据,而是保存数据的地址。对象,稍后将介绍,是引用类型的示例:

图 2.1:引用类型的表示

图 2.1:引用类型的表示

所有数据类型都具有以下共同属性:

  • 它们与一个值相关联。

  • 它们支持对它们所持有的值进行某些操作。

  • 它们在内存中占据一定数量的位。

例如,整数可以具有值,如 100,支持加法和减法等操作,并且在计算机内存中使用 32 位表示。

变量

每当我们想要处理特定的数据类型时,我们必须创建该数据类型的变量。例如,要创建一个保存您年龄的整数,您可以使用以下行:

int age;

在这里,我们说变量名为age,是一个整数。整数只能保存范围在-2,147,483,648 到 2,147,483,647 之间的值。尝试保存范围外的值将导致错误。然后,我们可以给age变量赋值,如下所示:

age = 30;

age变量现在保存了值 30。单词age称为标识符,用于引用存储值 30 的内存位置。标识符是一个可读的单词,用于引用值的内存地址。

您可以使用自己选择的单词作为标识符来引用相同的内存地址。例如,我们可以将其写成如下形式:

int myAge ;
myAge = 30;

以下是前面代码片段的图形表示:

图 2.2:内存地址中年龄的表示

尽管我们可以使用任何单词作为标识符,但 Java 对构成有效标识符的规则有一些规定。以下是创建标识符名称时需要遵守的一些规则:

  • 标识符应以字母、_$开头。不能以数字开头。

  • 标识符只能包含有效的 Unicode 字符和数字。

  • 标识符之间不能有空格。

  • 标识符可以是任意长度。

  • 标识符不能是保留关键字。

  • 标识符不能包含算术符号,如+或-。

  • 标识符是区分大小写的,例如,age 和 Age 不是相同的标识符。

保留关键字

Java 还包含内置的保留字,不能用作标识符。这些单词在语言中有特殊的含义。

现在让我们讨论 Java 中的原始数据类型。正如我们之前所说,Java 有 8 种原始数据类型,我们将详细了解。

整数数据类型

整数类型是具有整数值的类型。这些是 int、long、short、byte 和 char。

整数数据类型

int数据类型用于表示整数。整数是-2,147,483,648 到 2,147,483,647 范围内的 32 位数字。整数的示例是 0、1、300、500、389 230、1,345,543、-500、-324,145 等。例如,要创建一个int变量来保存值 5,我们写如下:

int num = 5;

num变量现在是一个值为 5 的int。我们还可以在一行中声明多个相同类型的变量:

int num1, num2, num3, num4, num5;

在这里,我们创建了五个变量,全部为int类型,并初始化为零。我们还可以将所有变量初始化为特定值,如下所示:

int num1 = 1, num2 = 2, num3 = 3, num4 = 4, num5 = 5;

除了以十进制格式表示整数外,我们还可以以八进制、十六进制和二进制格式表示整数:

  • 要以十六进制格式表示,我们从 0x 或 0X 开始int,即零后面跟着 x 或 X。数字的长度必须至少为 2 位。十六进制数使用 16 个数字(0-9 和 A-F)。例如,要以十六进制表示 30,我们将使用以下代码:
int hex_num = 0X1E;

打印出的数字将按预期输出 30。要在十六进制中保存值为 501 的整数,我们将写如下:

int hex_num1 = 0x1F5;
  • 要以八进制格式表示,我们从零开始int,并且必须至少有 2 位数字。八进制数有 8 位数字。例如,要以八进制表示 15,我们将执行以下操作:
int oct_num = 017;

尝试打印前面的变量将输出 15。要表示 501 的八进制,我们将执行以下操作:

int oct_num1 = 0765;
  • 要以二进制格式表示,我们从 0b 或 0B 开始int,即零后面跟着 b 或 B。大小写不重要。例如,要在二进制中保存值 100,我们将执行以下操作:
int bin_num = 0b1100100;
  • 要在二进制中保存数字 999,我们将执行以下操作:
int bin_num1 = 0B1111100111;

作为表示整数的前述四种格式的总结,所有以下变量都保存值为 117:

int num = 117;
int hex_num = 0x75;
int oct_num = 0165;
int bin_num = 0b1110101;

长数据类型

longint的 64 位等价。它们保存在-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 范围内的数字。长类型的数字称为长文字,并以 L 结尾。例如,要声明值为 200 的长,我们将执行以下操作:

long long_num = 200L;

要声明值为 8 的long,我们将执行以下操作:

long long_num = 8L;

由于整数是 32 位的,因此位于 long 范围内,我们可以将int转换为long

类型转换

要将值为 23 的int转换为长文字,我们需要进行所谓的类型转换

int num_int = 23;
long num_long = (long)num_int;

在第二行,我们通过使用表示法(long)num_intint类型的num_int转换为长文字。这被称为强制转换。强制转换是将一种数据类型转换为另一种数据类型的过程。虽然我们可以将 long 转换为int,但请记住,数字可能超出int范围,如果无法适应 int,一些数字将被截断。

int一样,long也可以是八进制、十六进制和二进制的,如下所示:

long num = 117L;
long hex_num = 0x75L;
long oct_num = 0165L;
long bin_num = 0b1110101L;

练习 5:类型转换

重要的是要将一种类型转换为另一种类型。在这个练习中,我们将把一个整数转换为浮点数:

  1. 导入Scanner并创建一个公共类:
import java.util.Scanner;

public class Main

{ 
    static Scanner sc = new Scanner(System.in);
    public static void main(String[] args) 
  1. 输入一个整数作为输入:
{ 
    System.out.println("Enter a Number: ");
    int num1 = sc.nextInt();
  1. 打印出整数:
System.out.println("Entered value is: " + num1);
  1. 将整数转换为浮点数:
float fl1 = num1;
  1. 打印出浮点数:
System.out.print("Entered value as a floating point variable is: " + fl1);

    } 

}

字节数据类型

byte是一个 8 位数字,可以容纳范围在-128 到 127 之间的值。byte是 Java 中最小的原始数据类型,可以用来保存二进制值。要给byte赋值,它必须在-128 到 127 的范围内,否则编译器会报错:

byte num_byte = -32;
byte num_byte1 = 111;

你也可以将int转换为byte,就像我们对long所做的那样:

int num_int = 23;
byte num_byte = (byte)num_int;

除了强制转换,我们还可以将byte赋给int

byte num_byte = -32;
int num_int = num_byte;

然而,我们不能直接将int赋给byte,必须进行强制转换。当你尝试运行以下代码时,会引发错误:

int num_int = 23;
byte num_byte = num_int;

这是因为整数可以超出字节范围(-128 到 127),因此会丢失一些精度。Java 不允许你将超出范围的类型赋给较小范围的类型。你必须进行强制转换,这样溢出的位将被忽略。

short 数据类型

short是一个 16 位的数据类型,可以容纳范围在-32,768 到 32,767 之间的数字。要给short变量赋值,确保它在指定的范围内,否则会抛出错误:

short num = 13000;
short num_short = -18979;

你可以把byte赋给short,因为 byte 的所有值都在 short 的范围内。然而,反过来会报错,就像用byteint解释的那样。要把int转换成short,你必须进行强制转换以避免编译错误。这也适用于将long转换为short

short num = 13000;
byte num_byte = 19;
num = num_byte; //OK
int num1 = 10;
short s = num1; //Error
long num_long = 200L;
s = (short)num_long; //OK

布尔数据类型

boolean是一个真或假的值:

boolean finished = true;
boolean hungry = false;

注意

一些语言,比如 C 和 C++,允许布尔值为 true 时取值为 1,false 时取值为 0。Java 不允许你将 1 或 0 赋给布尔值,这将引发编译时错误。

char 数据类型

char数据类型用于保存单个字符。字符用单引号括起来。字符的例子有'a'、'b'、'z'和'5'。Char 类型是 16 位的,不能为负数。Char 类型本质上是从 0 到 65535 的整数,用来表示 Unicode 字符。声明 char 的示例如下:

char a = 'a';
char b = 'b';
char c = 'c';
char five = '5';

请注意,字符要用单引号括起来,而不是双引号。用双引号括起来的char会变成stringstring是一个或多个字符的集合。一个字符串的例子是"Hello World":

String hello = "Hello World";

用双引号括起来的char会引发错误,因为编译器将双引号解释为string,而不是 char:

char hello = "Hello World"; //ERROR

同样,用单引号括起来的多个字符会引发编译错误,因为字符应该只有一个字符:

String hello = 'Hello World'; //ERROR

除了用来保存单个字符,字符也可以用来保存转义字符。转义字符是具有特殊用途的特殊字符。它们由反斜杠后跟一个字符组成,并用单引号括起来。有 8 个预定义的转义字符,如下表所示,以及它们的用途:

表 2.1:转义字符及其用法的表示

表 2.1:转义字符及其用法的表示

假设你写了下面这样一行:

char nl = '\n';

char保存了一个换行符,如果你尝试将其打印到控制台,它会跳到下一行。

如果你打印'\t',输出中会出现一个制表符。

char tb = '\t';

一个'\'会在输出中打印一个反斜杠。

你可以使用转义字符来根据你想要的输出格式化字符串。例如,让我们看看下面这行:

String hello_world = "Hello \n World";

以下是输出:

Hello 
 World

这是因为转义字符'\n'在HelloWorld之间引入了一个新行。

此外,字符还可以使用 Unicode 转义字符'\u'来表示 Unicode。Unicode 是一种国际编码标准,其中一个字符被分配一个数值,可以在任何平台上使用。Unicode 旨在支持世界上所有可用的语言,这与 ASCII 相反。

浮点数据类型

浮点数据类型是具有小数部分的数字。例如 3.2、5.681 和 0.9734。Java 有两种数据类型来表示带有小数部分的类型:

  • float

  • double

浮点类型使用一种称为 IEEE 754 浮点标准的特殊标准表示。这个标准是由电气和电子工程师学会(IEEE)制定的,旨在使低级计算机中浮点类型的表示统一。请记住,浮点类型通常是近似值。当我们说 5.01 时,这个数字必须以二进制格式表示,表示通常是对实际数字的近似。在处理需要测量到微小数字级别的高性能程序时,了解浮点类型在硬件级别的表示方式以避免精度损失变得至关重要。

浮点类型有两种表示形式:十进制格式和科学计数法。

十进制格式是我们通常使用的正常格式,例如 5.4、0.0004 或 23,423.67。

科学计数法是使用字母 e 或 E 表示 10 的幂。例如,科学计数法中的 0.0004 是 4E-4 或 4e-4,类似于 4 x 10-4。科学计数法中的 23,423.67 将是 2.342367E4 或 2.342367e4,类似于 2.342367 x 104。

浮点数据类型

float用于保存 32 位小数,范围为 1.4 x 10 -45 到 3.4 x 10 38。也就是说,float可以保存的最小数字是 1.4 x 10 -45,最大数字是 3.4 x 10 38。浮点数后面跟着一个字母 f 或 F 表示它们是float类型。浮点数的示例如下:

float a = 1.0f;
float b = 0.0002445f;
float c = 93647.6335567f;

浮点数也可以用科学计数法表示,如下所示:

float a = 1E0f;
float b = 2.445E-4f;
float c = 9.36476335567E+4f;

Java 还有一个名为 Float 的类,可以封装浮点数并提供一些有用的功能。例如,要知道你的环境中可用的最大float数和最小float数,可以调用以下方法:

float max = Float.MAX_VALUE;
float min = Float.MIN_VALUE;

当除以零时,Float 类还有值表示正无穷和负无穷:

float max_inf = Float.POSITIVE_INFINITY;
float min_inf = Float.NEGATIVE_INFINITY;

浮点数支持两种零:-0.0f 和+0.0f。正如我们已经说过的,浮点类型在内存中表示为近似值,因此即使是零也不是绝对零。这就是为什么我们有两个零的原因。当一个数字被正零除时,我们得到Float.POSITIVE_INFINITY,当一个数字被负零除时,我们得到Float.NEGATIVE_INFINITY

Float 类还有一个常量NaN,表示不是float类型的数字:

float nan = Float.NaN;

与我们讨论过的整数类型一样,我们可以将intbyteshortlong和 char 赋值给 float,但不能反过来,除非我们进行转换。

注意

将整数转换为浮点数,然后再转换回int,并不总是会得到原始数字。在进行intfloat之间的转换时要小心。

双精度数据类型

double保存 64 位带小数部分的数字。也就是说,范围为 4.9 x 10e -324 到 1.7 x 10e 308。双精度用于保存比浮点数更大的数字。它们以 d 或 D 结尾表示。但是,在 Java 中,默认情况下,任何带小数部分的数字都是double,因此通常不需要在末尾添加 d 或 D。双精度的示例如下:

double d1  = 4.452345;
double d2 = 3.142;
double d3 = 0.123456;
double d4 = 0.000999;

与浮点数一样,双精度也可以用科学计数法表示:

double d1  = 4.452345E0;
double d2 = 3.142E0;
double d3 = 1.23456E-1;
double d4 = 9.99E-4;

你可能已经猜到了,Java 还提供了一个名为Double的类,其中包含一些有用的常量,如下所示:

double max = Double.MAX_VALUE;
double min = Double.MIN_NORMAL;
double max_inf = Double.POSITIVE_INFINITY;
double min_inf = Double.NEGATIVE_INFINITY;
double nan = Double.NaN;

同样,我们可以将整数类型和float赋值给double,但不能反过来,除非我们进行转换。以下是一些允许和一些禁止的示例操作:

int num = 100;
double d1 = num;
float f1 = 0.34f;
double d2 = f1;
double d3 = 'A'; //Assigns 65.0 to d3
int num  = 200;
double d3 = 3.142;
num = d3; //ERROR: We must cast
num = (int)d3; //OK

活动 4:输入学生信息并输出 ID

在任何开发环境中,存储和输出变量都是基础。在这个活动中,你将创建一个程序,要求学生输入他们的数据,然后输出一个简单的 ID 卡。该程序将使用整数和字符串以及java.util包中的 scanner 类。

以下活动使用字符串变量和整数变量输入关于学生的信息,然后打印出来。

  1. 导入 scanner 包并创建一个新的类。

  2. 导入学生的名字作为字符串。

  3. 导入大学名称作为字符串。

  4. 导入学生的年龄作为整数。

  5. 使用System.out.println打印出学生的详细信息。

  6. 运行程序后,输出应该类似于这样:

Here is your ID 
*********************************
Name: John Winston
University: Liverpool University
Age: 19
*********************************

注意

这个活动的解决方案可以在第 306 页找到。

活动 5:计算满箱水果的数量

约翰是一个桃子种植者。他从树上摘桃子,把它们放进水果箱里然后运输。如果一个水果箱装满了 20 个桃子,他就可以运输。如果他的桃子少于 20 个,他就必须摘更多的桃子,这样他就可以装满一个水果箱,然后运输。

我们想通过计算他能够运输的水果箱的数量以及留下的桃子的数量来帮助约翰,给出他能够摘的桃子的数量。为了实现这一点,执行以下步骤:

  1. 创建一个新的类,并输入PeachCalculator作为类名

  2. 导入java.util.Scanner包:

  3. main()中使用System.out.print询问用户numberOfPeaches

  4. 计算numberOfFullBoxesnumberOfPeachesLeft的值。提示:使用整数除法。

  5. 使用System.out.println输出这两个值。

  6. 运行主程序。

输出应该类似于:

Enter the number of peaches picked: 55
We have 2 full boxes and 15 peaches left.

注意

这个活动的解决方案可以在第 307 页找到。

摘要

在这节课中,我们学习了在 Java 中使用基本数据类型和引用数据类型,以及对数据进行简单的算术运算。我们学会了如何将数据类型从一种类型转换为另一种类型。然后我们看到了如何使用浮点数据类型。

在下一节课中,我们将学习条件语句和循环结构。

第三章:第三章

控制流

学习目标

通过本课程结束时,你将能够:

  • 使用 Java 中的ifelse语句控制执行流程

  • 使用 Java 中的 switch case 语句检查多个条件

  • 利用 Java 中的循环结构编写简洁的代码来执行重复的操作

介绍

到目前为止,我们已经看过由 Java 编译器按顺序执行的一系列语句组成的程序。然而,在某些情况下,我们可能需要根据程序的当前状态执行操作。

考虑一下安装在 ATM 机中的软件的例子-它执行一系列操作,也就是说,当用户输入的 PIN 正确时,它允许交易发生。然而,当输入的 PIN 不正确时,软件执行另一组操作,也就是告知用户 PIN 不匹配,并要求用户重新输入 PIN。你会发现,几乎所有现实世界的程序中都存在依赖于值或阶段的这种逻辑结构。

也有时候,可能需要重复执行特定任务,也就是说,在特定时间段内,特定次数,或者直到满足条件为止。延续我们关于 ATM 机的例子,如果输入错误密码的次数超过三次,那么卡就会被锁定。

这些逻辑结构作为构建复杂 Java 程序的基本构件。本课程将深入探讨这些基本构件,可以分为以下两类:

  • 条件语句

  • 循环语句

条件语句

条件语句用于根据某些条件控制 Java 编译器的执行流程。这意味着我们根据某个值或程序的状态做出选择。Java 中可用的条件语句如下:

  • if语句

  • if-else语句

  • else-if语句

  • switch语句

if 语句

if 语句测试一个条件,当条件为真时,执行 if 块中包含的代码。如果条件不为真,则跳过块中的代码,执行从块后的行继续执行。

if语句的语法如下:

if (condition) {
//actions to be performed when the condition is true
}

考虑以下例子:

int a = 9;
if (a < 10){
System.out.println("a is less than 10");
}

由于条件a<10为真,打印语句被执行。

我们也可以在if条件中检查多个值。考虑以下例子:

if ((age > 50) && (age <= 70) && (age != 60)) {
System.out.println("age is above 50 but at most 70 excluding 60");
}

上述代码片段检查age的值是否超过 50,但最多为 70,不包括 60。

if块中的语句只有一行时,我们不需要包括括号:

if (color == 'Maroon' || color == 'Pink')
System.out.println("It is a shade of Red");

else 语句

对于某些情况,如果if条件失败,我们需要执行不同的代码块。为此,我们可以使用else子句。这是可选的。

if else语句的语法如下:

if (condition) {
//actions to be performed when the condition is true
}
else {
//actions to be performed when the condition is false
}

练习 6:实现简单的 if-else 语句

在这个练习中,我们将创建一个程序,根据空座位的数量来检查是否可以预订公交车票。完成以下步骤来实现:

  1. 右键单击src文件夹,然后选择新建 |

  2. 输入Booking作为类名,然后点击OK

  3. 设置main方法:

public class Booking{
public static void main(String[] args){
}
}
  1. 初始化两个变量,一个用于空座位数量,另一个用于请求的票数:
int seats = 3; // number of empty seats
int req_ticket = 4; // Request for tickets
  1. 使用if条件检查所请求的票数是否小于或等于可用的空座位,并打印适当的消息:
if( (req_ticket == seats) || (req_ticket < seats) ) {
     System.out.print("This booing can be accepted");
     }else
         System.out.print("This booking is rejected");
  1. 运行程序。

你应该得到以下输出:

This booking is rejected

else-if 语句

当我们希望在评估else子句之前比较多个条件时,可以使用else if语句。

else if语句的语法如下:

if (condition 1) {
//actions to be performed when condition 1 is true
}
else if (Condition 2) {
//actions to be performed when condition 2 is true
}
else if (Condition 3) {
//actions to be performed when condition 3 is true
}
…
…
else if (Condition n) {
//actions to be performed when condition n is true
}
else {
//actions to be performed when the condition is false
}

练习 7:实现 else-if 语句

我们正在构建一个电子商务应用程序,根据卖家和买家之间的距离计算交付费用。买家在我们的网站上购买物品并输入交付地址。根据距离,我们计算交付费用并显示给用户。在这个练习中,我们得到了以下表格,并需要编写一个程序来向用户输出交付费用:

表 3.1:显示距离及其对应费用的表

表 3.1:显示距离及其对应费用的表

要做到这一点,请执行以下步骤:

  1. 右键单击src文件夹,然后选择新建 |

  2. 输入DeliveryFee作为类名,然后单击OK

  3. 打开创建的类,然后创建主方法:

public class DeliveryFee{
public static void main(String[] args){
}
}
  1. main方法中,创建两个整数变量,一个称为distance,另一个称为fee。这两个变量将分别保存distance和交付费用。将distance初始化为 10,fee初始化为零:
int distance = 10;
int fee = 0;
  1. 创建一个if块来检查表中的第一个条件:
if (distance > 0 && distance < 5){
   fee = 2;
}

这个if语句检查distance是否大于 0 但小于 5,并将交付fee设置为 2 美元。

  1. 添加一个else if语句来检查表中的第二个条件,并将fee设置为 5 美元:
else if (distance >= 5 && distance < 15){
   fee = 5;
}
  1. 添加两个else if语句来检查表中的第三和第四个条件,如下面的代码所示:
else if (distance >= 15 && distance < 25){
   fee = 10;
}else if (distance >= 25 && distance < 50){
   fee = 15;
}
  1. 最后,添加一个else语句来匹配表中的最后一个条件,并设置适当的交付fee
else {
   fee = 20;
}
  1. 打印出fee的值:
System.out.println("Delivery Fee: " + fee);
  1. 运行程序并观察输出:
Delivery Fee: 5

嵌套的 if 语句

我们可以在其他if语句内部使用if语句。这种结构称为嵌套的if语句。我们首先评估外部条件,如果成功,然后评估第二个内部if语句,依此类推,直到所有if语句都完成:

if (age > 20){

   if (height > 170){

       if (weight > 60){
           System.out.println("Welcome");
       }    
   }
}

我们可以嵌套任意多的语句,并且编译器将从顶部向下评估它们。

switch case 语句

switch case语句是在相同的值进行相等比较时,执行多个if else语句的更简单更简洁的方法。以下是一个快速比较:

传统的else if语句如下所示:

if(age == 10){
   discount = 300;
} else if (age == 20){
   discount = 200;
} else if (age == 30){
   discount = 100;
} else {
   discount = 50;
}

然而,使用switch case语句实现相同逻辑时,将如下所示:

switch (age){
   case 10:
       discount = 300;
   case 20:
       discount = 200;
   case 30:
       discount = 100;
   default:
       discount = 50;
}

请注意,这段代码更易读。

要使用switch语句,首先需要使用关键字switch声明它,后跟括号中的条件。case语句用于检查这些条件。它们按顺序检查。

编译器将检查age的值与所有case进行匹配,如果找到匹配,那么将执行该case中的代码以及其后的所有case。例如,如果我们的age等于 10,将匹配第一个case,然后第二个case,第三个casedefault case。如果所有其他情况都不匹配,则执行default case。例如,如果age不是 10、20 或 30,则折扣将设置为 50。它可以被解释为if-else语句中的else子句。default case是可选的,可以省略。

如果age等于 30,那么第三个case将被匹配并执行。由于default case是可选的,我们可以将其省略,执行将在第三个case之后结束。

大多数情况下,我们真正希望的是执行结束于匹配的case。我们希望如果匹配了第一个case,那么就执行该case中的代码,并忽略其余的情况。为了实现这一点,我们使用break语句告诉编译器继续在switch语句之外执行。以下是带有break语句的相同switch case

switch (age){
   case 10:
       discount = 300;
       break;
   case 20:
       discount = 200;
       break;
   case 30:
       discount = 100;
       break;
   default:
       discount = 50;
}

因为default是最后一个case,所以我们可以安全地忽略break语句,因为执行将在那里结束。

注意:

在未来,另一个程序员添加额外的情况时,始终添加一个 break 语句是一个好的设计。

活动 6:使用条件控制执行流程

工厂每小时支付工人 10 美元。标准工作日是 8 小时,但工厂为额外的工作时间提供额外的补偿。它遵循的政策是计算工资如下:

  • 如果一个人工作少于 8 小时-每小时* $10

  • 如果一个人工作超过 8 小时但少于 12 小时-额外 20%的工资

  • 超过 12 小时-额外的一天工资被记入

创建一个程序,根据工作小时数计算并显示工人赚取的工资。

为了满足这个要求,执行以下步骤:

  1. 初始化两个变量和工作小时和工资的值。

  2. if条件中,检查工人的工作小时是否低于所需小时。如果条件成立,则工资应为(工作小时* 10)。

  3. 使用else if语句检查工作小时是否介于 8 小时和 12 小时之间。如果是这样,那么工资应该按照每小时 10 美元计算前 8 小时,剩下的小时应该按照每小时 12 美元计算。

  4. 使用else块为默认的每天$160(额外的一天工资)。

  5. 执行程序以观察输出。

注意

此活动的解决方案可以在第 308 页找到。

活动 7:开发温度系统

在 Java 中编写一个程序,根据温度显示简单的消息。温度概括为以下三个部分:

  • 高:在这种情况下,建议用户使用防晒霜

  • 低:在这种情况下,建议用户穿外套

  • 潮湿:在这种情况下,建议用户打开窗户

要做到这一点,执行以下步骤:

  1. 声明两个字符串,tempweatherWarning

  2. HighLowHumid初始化temp

  3. 创建一个检查temp不同情况的 switch 语句。

  4. 将变量weatherWarning初始化为每种温度情况的适当消息(HighLowHumid)。

  5. 在默认情况下,将weatherWarning初始化为“天气看起来不错。出去散步”。

  6. 完成 switch 结构后,打印weatherWarning的值。

  7. 运行程序以查看输出,应该类似于:

Its cold outside, do not forget your coat.

注意

此活动的解决方案可以在第 309 页找到。

循环结构

循环结构用于在满足条件的情况下多次执行特定操作。它们通常用于对列表项执行特定操作。例如,当我们想要找到从 1 到 100 所有数字的总和时。Java 支持以下循环结构:

  • for循环

  • for each循环

  • while循环

  • do while循环

for 循环

for循环的语法如下:

for( initialization ; condition ; expression) {
    //statements
}

初始化语句在for循环开始执行时执行。可以有多个表达式,用逗号分隔。所有表达式必须是相同类型的:

for( int i  = 0, j = 0; i <= 9; i++)

for循环的条件部分必须评估为 true 或 false。如果没有表达式,则条件默认为 true。

在语句的每次迭代后执行表达式部分,只要条件为真。可以有多个用逗号分隔的表达式。

注意

表达式必须是有效的 Java 表达式,即可以以分号终止的表达式。

以下是for循环的工作原理:

  1. 首先,初始化被评估。

  2. 然后,检查条件。如果条件为真,则执行for块中包含的语句。

  3. 在执行语句后,执行表达式,然后再次检查条件。

  4. 如果仍然不是 false,则再次执行语句,然后执行表达式,再次评估条件。

  5. 这将重复,直到条件评估为 false。

  6. 当条件求值为 false 时,for循环完成,循环后的代码部分被执行。

练习 8:实现一个简单的 for 循环

为了打印所有递增和递减的个位数,执行以下步骤:

  1. 右键单击src文件夹,选择新建 |

  2. 输入Looping作为类名,然后点击OK

  3. 设置main方法:

public class Looping
{
   public static void main(String[] args) {
   }
}
  1. 实现一个for循环,初始化一个变量i为零,一个条件使得值保持在 10 以下,并且i应该在每次迭代中递增一个:
System.out.println("Increasing order");
for( int i  = 0; i <= 9; i++)
System.out.println(i);
  1. 实现另一个for循环,初始化一个变量k为 9,一个条件使得值保持在 0 以上,并且k应该在每次迭代中减少一个:
System.out.println("Decreasing order");
for( int k  = 9; k >= 0; k--)
System.out.println(k);

输出:

Increasing order 
0
1
2
3
4
5
6
7
8
9
Decreasing order
9
8
7
6
5
4
3
2
1
0

活动 8:实现 for 循环

约翰是一个桃农,他从树上摘桃子,把它们放进水果箱里然后运输。如果一个水果箱里装满了 20 个桃子,他就可以运输。如果他的桃子少于 20 个,他就必须摘更多的桃子,这样他就可以装满一个水果箱,然后运输。

我们想通过编写一个自动化软件来帮助约翰启动填充和运输箱子。我们从约翰那里得到桃子的数量,然后为每组 20 个桃子打印一条消息,并说明到目前为止已经运输了多少桃子。例如,对于第三个箱子,我们打印“到目前为止已经运输了 60 个桃子”。我们想用for循环来实现这一点。我们不需要担心剩下的桃子。为了实现这一点,执行以下步骤:

  1. 创建一个新的类,输入PeachBoxCounter作为类名

  2. 导入java.util.Scanner包:

  3. main()中使用System.out.print询问用户numberOfPeaches

  4. 编写一个for循环,计算到目前为止运输的桃子数量。这从零开始,每次增加 20,直到剩下的桃子少于 20。

  5. for循环中,打印到目前为止运输的桃子数量。

  6. 运行主程序。

输出应该类似于:

Enter the number of peaches picked: 42
shipped 0 peaches so far
shipped 20 peaches so far
shipped 40 peaches so far  

注意

这个活动的解决方案可以在 310 页找到。

for循环的所有三个部分都是可选的。这意味着行for( ; ;) 将提供任何错误。它只提供一个邀请循环。

这个for循环什么也不做,也不会终止。在for循环声明的变量在for循环的语句中是可用的。例如,在我们的第一个例子中,我们从语句部分打印了i的值,因为变量i是在for循环中声明的。然而,这个变量在for循环后不可用,并且可以自由声明。但是不能在for循环内再次声明:

for (int i = 0; i <= 9; i++)
   int i  = 10;            //Error, i is already declared

for循环也可以有括号括住的语句,如果我们有多于一个语句。这就像我们之前讨论的if-else语句一样。如果只有一个语句,那么我们不需要括号。当语句多于一个时,它们需要被括在大括号内。在下面的例子中,我们打印出ij的值:

for (int i = 0, j = 0; i <= 9; i++, j++) {
   System.out.println(i);
   System.out.println(j);
}

注意

表达式必须是有效的 Java 表达式,即可以用分号终止的表达式。

break语句可以用来中断for循环并跳出循环。它将执行超出for循环的范围。

例如,如果i等于 5,我们可能希望终止我们之前创建的for循环:

for (int i = 0; i <= 9; i++){

   if (i == 5)
       break;
   System.out.println(i);
}

输出:

0
1
2
3
4

前面的for循环从 0、1、2 和 3 迭代,终止于 4。这是因为在满足条件i即 5 之后,执行了break语句,这结束了for循环,循环后的语句不会被执行。执行继续在循环外部。

continue语句用于告诉循环跳过它后面的所有其他语句,并继续执行下一次迭代:

for (int i = 0; i <= 9; i++){
   if (i == 5)
       continue;
   System.out.println(i);
}

输出:

0
1
2
3
4
6
7
8
9

数字 5 没有被打印出来,因为一旦遇到continue语句,它后面的语句都会被忽略,并且开始下一次迭代。当处理多个项目时,continue语句可能会很有用,因为它可以跳过一些异常。

嵌套 for 循环

循环内的一组语句可以是另一个循环。这样的结构称为嵌套循环:

public class Nested{
     public static void main(String []args){
        for(int i = 1; i <= 3; i++) {
   //Nested loop
   for(int j = 1; j <= 3; j++) {
       System.out.print(i + "" + j);
       System.out.print("\t");
   }
   System.out.println();
}
     }
}

输出:

11    12    13
21    22    23
31    32    33

对于每个i的单个循环,我们循环j三次。您可以将这些for循环理解为如下:

重复i三次,对于每次重复,重复j三次。这样,我们总共有 9 次j的迭代。对于每次j的迭代,我们打印出ij的值。

练习 9:实现嵌套 for 循环

我们在这个练习中的目标是打印一个有七行的星号金字塔,如下所示:

图 3.1:有七行的星号金字塔

为了实现这个目标,请执行以下步骤:

  1. 右键单击src文件夹,然后选择New | Class

  2. 输入NestedPattern作为类名,然后点击OK

  3. 在主方法中,创建一个for循环,初始化变量i为 1,引入条件,使得i的值最多为 15,并将i的值增加 2:

public class NestedPattern{ 
public static void main(String[] args) {
for (int i = 1; i <= 15; i += 2) {
}
}
}
}
  1. 在这个循环内,创建另外两个for循环,一个用于打印空格,另一个用于打印*:
for (int k = 0; k < (7 - i / 2); k++) {
   System.out.print(" ");
   }
for (int j = 1; j <= i; j++) {
   System.out.print("*");
   }
  1. 在外部for循环中,添加以下代码以添加下一行:
System.out.println();

运行程序。您将看到结果金字塔。

for-each 循环

for each循环是 Java 5 中引入的for循环的高级版本。它们用于对数组或项目列表中的每个项目执行给定操作。

让我们来看看这个for循环:

int[] arr = { 1, 2, 3, 4, 5 , 6, 7, 8, 9,10};
for (int i  = 0; i < 10; i++){
   System.out.println(arr[i]);
}

第一行声明了一个整数数组。数组是相同类型项目的集合。在这种情况下,变量 arr 持有 10 个整数的集合。然后我们使用for循环从010,打印出这个数组的元素。我们使用i < 10是因为最后一个项目在索引9处,而不是10。这是因为数组的元素从索引 0 开始。第一个元素在索引0处,第二个在索引1处,第三个在2处,依此类推。arr[0]将返回第一个元素,arr[1]第二个,arr[2]第三个,依此类推。

这个for循环可以用更短的for each循环来替代。for each循环的语法如下:

for( type item : array_or_collection){
    //Code to executed for each item in the array or collection
}

对于我们之前的例子,for each循环将如下所示:

for(int item : arr){
   System.out.println(item);
}

int item是我们当前所在数组中的元素。for each循环将遍历数组中的所有元素。在大括号内,我们打印出这个元素。请注意,我们不必像之前的for循环中那样使用arr[i]。这是因为for each循环会自动为我们提取值。此外,我们不必使用额外的int i来保持当前索引并检查我们是否在10以下(i < 10),就像我们之前使用的for循环那样。for each循环更短,会自动为我们检查范围。

例如,我们可以使用for each循环来打印数组arr中所有元素的平方:

for(int item : arr){
   int square = item * item;
   System.out.println(square);
}

输出:

1
4
9
16
25
36
49
64
81
10

while 和 do while 循环

有时,我们希望重复执行某些语句,也就是说,只要某个布尔条件为真。这种情况需要我们使用while循环或do while循环。while循环首先检查一个布尔语句,如果布尔为真,则执行一段代码块,否则跳过while块。do while循环首先在检查布尔条件之前执行一段代码块。当您希望代码至少执行一次时,请使用do while循环,当您希望在第一次执行之前首先检查布尔条件时,请使用while循环。以下是whiledo while循环的格式:

while循环的语法:

while(condition) {
//Do something
}

do while循环的语法:

do {
//Do something
}
while(condition);

例如,要使用while循环打印从 0 到 10 的所有数字,我们将使用以下代码:

public class Loops {
   public static void main(String[] args){
       int number = 0;
       while (number <= 10){
           System.out.println(number);
           number++;
       }
   }
}

输出:

0
1
2
3
4
5
6
7
8
9
10

我们也可以使用do while循环编写上述代码:

public class Loops {
   public static void main(String[] args){
       int number = 0;
       do {
           System.out.println(number);
           number++;
       }while (number <= 10);
   }
}

使用do while循环,条件最后被评估,所以我们确信语句至少会被执行一次。

练习 10:实现 while 循环

要使用while循环打印斐波那契数列的前 10 个数字,执行以下步骤:

  1. 右键单击src文件夹,然后选择新建 |

  2. 输入FibonacciSeries作为类名,然后单击确定

  3. 声明main方法中所需的变量:

public class FibonacciSeries {
    public static void main(String[] args) {
        int i = 1, x = 0, y = 1, sum=0;
    }
}

这里,i是计数器,xy存储斐波那契数列的前两个数字,sum是一个用于计算变量xy的和的变量。

  1. 实现一个while循环,条件是计数器i不超过 10:
while (i <= 10)
{
}
  1. while循环内,实现打印x的值的逻辑,然后分配适当的值给xysum,这样我们总是打印最后一个和倒数第二个数字的sum
System.out.print(x + " ");
sum = x + y;
x = y;
y = sum;
i++;

活动 9:实现 while 循环

记得 John,他是一个桃子种植者。他从树上摘桃子,把它们放进水果箱里然后运输。如果一个水果箱装满了 20 个桃子,他就可以运输一个水果箱。如果他的桃子少于 20 个,他就必须摘更多的桃子,这样他就可以装满一个装有 20 个桃子的水果箱并运输它。

我们想通过编写一个自动化软件来帮助 John 启动箱子的填充和运输。我们从 John 那里得到桃子的数量,并为每组 20 个桃子打印一条消息,说明我们已经运输了多少箱子,还剩下多少桃子,例如,“已运输 2 箱,剩余 54 个桃子”。我们想用while循环来实现这一点。只要我们有足够的桃子可以装满至少一个箱子,循环就会继续。与之前的for活动相反,我们还将跟踪剩余的桃子。为了实现这一点,执行以下步骤:

  1. 创建一个新类,输入PeachBoxCounter作为类名

  2. 导入java.util.Scanner包:

  3. main()中使用System.out.print询问用户numberOfPeaches

  4. 创建一个numberOfBoxesShipped变量。

  5. 编写一个 while 循环,只要我们至少有 20 个桃子就继续。

  6. 在循环中,从numberOfPeaches中移除 20 个桃子,并将numberOfBoxesShipped增加 1。打印这些值。

  7. 运行主程序。

输出应该类似于:

Enter the number of peaches picked: 42
1 boxes shipped, 22 peaches remaining
2 boxes shipped, 2 peaches remaining

注意

此活动的解决方案可在第 311 页找到。

活动 10:实现循环结构

我们的目标是创建一个订票系统,这样当用户提出票务请求时,票务会根据餐厅剩余座位的数量来批准。

要创建这样一个程序,执行以下步骤:

  1. 导入从用户读取数据所需的包。

  2. 声明变量以存储总座位数、剩余座位和请求的票数。

  3. while循环内,实现if else循环,检查请求是否有效,这意味着请求的票数少于剩余座位数。

  4. 如果前一步的逻辑为真,则打印一条消息表示票已处理,将剩余座位设置为适当的值,并要求下一组票。

  5. 如果第 3 步的逻辑为假,则打印适当的消息并跳出循环。

注意

此活动的解决方案可在第 312 页找到。

活动 11:嵌套循环连续桃子运输。

记得 John,他是一个桃子种植者。他从树上摘桃子,把它们放进水果箱里然后运输。如果一个水果箱装满了 20 个桃子,他就可以运输一个水果箱。如果他的桃子少于 20 个,他就必须摘更多的桃子,这样他就可以装满一个装有 20 个桃子的水果箱并运输它。

我们希望通过编写一个自动化软件来帮助约翰启动装箱和运输。在我们的自动化软件的这个新版本中,我们将允许约翰自行选择批量带来桃子,并将上一批剩下的桃子与新批次一起使用。

我们从约翰那里得到了桃子的进货数量,并将其加到当前的桃子数量中。然后,我们为每组 20 个桃子打印一条消息,说明我们已经运送了多少箱子,还剩下多少桃子,例如,“已运送 2 箱,剩余 54 个桃子”。我们希望用while循环来实现这一点。只要我们有足够多的桃子可以装至少一箱,循环就会继续。我们将有另一个while循环来获取下一批桃子,如果没有,则退出。为了实现这一点,执行以下步骤:

  1. 创建一个新的类,并输入PeachBoxCount作为类名

  2. 导入java.util.Scanner包:

  3. 创建一个numberOfBoxesShipped变量和一个numberOfPeaches变量。

  4. main()中,编写一个无限的while循环。

  5. 使用System.out.print询问用户incomingNumberOfPeaches。如果这是零,则跳出这个无限循环。

  6. 将进货的桃子加到现有的桃子中。

  7. 编写一个while循环,只要我们至少有 20 个桃子就继续。

  8. 在 for 循环中,从numberOfPeaches中减去 20 个桃子,并将numberOfBoxesShipped增加 1。打印这些值。

  9. 运行主程序。

输出应类似于:

Enter the number of peaches picked: 23
1 boxes shipped, 3 peaches remaining
Enter the number of peaches picked: 59
2 boxes shipped, 42 peaches remaining
3 boxes shipped, 22 peaches remaining
4 boxes shipped, 2 peaches remaining
Enter the number of peaches picked: 0

注意

此活动的解决方案可在第 313 页找到。

总结

在本课程中,我们通过查看一些简单的例子,涵盖了 Java 和编程中一些基本和重要的概念。条件语句和循环语句通常是实现逻辑的基本要素。

在下一课中,我们将专注于另外一些基本概念,如函数、数组和字符串。这些概念将帮助我们编写简洁和可重用的代码。

第四章:第四章

面向对象编程

学习目标

通过本课程结束时,您将能够:

  • 解释 Java 中的类和对象的概念

  • 解释面向对象编程的四个基本原则

  • 在 Java 中创建简单的类并使用对象访问它们

  • 在 Java 中实现继承

  • 在 Java 中尝试方法重载和重写

  • 在 Java 中创建和使用注释

介绍

到目前为止,我们已经了解了 Java 的基础知识以及如何使用简单的构造,如条件语句和循环语句,以及如何在 Java 中实现方法。理解这些基本概念非常重要,并且在构建简单程序时非常有用。然而,要构建和维护大型和复杂的程序,基本类型和构造是不够的。使 Java 真正强大的是它是一种面向对象的编程语言。它允许您有效地构建和集成复杂的程序,同时保持一致的结构,使其易于扩展、维护和重用。

在本课中,我们将介绍一种称为面向对象编程(OOP)的编程范式,它是 Java 的核心。我们将看看在 Java 中如何进行 OOP 以及如何实现它来设计更好的程序。

我们将从 OOP 的定义和其基本原则开始,然后看看称为对象的 OOP 构造,并最后通过查看称为继承的概念来结束本课。

我们将在 Java 中编写两个简单的 OOP 应用程序:一个用于表示通常在大学中找到的人,如学生、讲师和工作人员,另一个用于表示农场中的家畜。让我们开始吧!

面向对象原则

OOP 受四个主要原则的约束,如下所示。在本课的其余部分,我们将深入研究这些原则中的每一个:

  • 继承:我们将学习如何通过使用类的层次结构和从派生类继承行为来重用代码

  • 封装:我们还将看看如何可以隐藏外部世界的实现细节,同时通过方法提供一致的接口与我们的对象进行通信

  • 抽象:我们将看看如何可以专注于对象的重要细节并忽略其他细节

  • 多态:我们还将看看如何定义抽象行为并让其他类为这些行为提供实现

类和对象

编程中的范式是编写程序的风格。不同的语言支持不同的范式。一种语言可以支持多种范式。

面向对象编程

面向对象编程,通常称为 OOP,是一种处理对象的编程风格。对象是具有属性来保存其数据和方法来操作数据的实体。

让我们用更简单的术语来解释这一点。

在 OOP 中,我们主要处理对象和类。对象是现实世界项目的表示。对象的一个例子是您的汽车或您自己。对象具有与之关联的属性和可以执行的操作。例如,您的汽车具有轮子、门、发动机和齿轮,这些都是属性,它可以执行诸如加速、刹车和停止等操作,这些都称为方法。以下图表是您作为一个人所拥有的属性和方法的插图。属性有时可以称为字段

图 4.1:与人类相关的对象表示

图 4.1:与人类相关的对象表示

在 OOP 中,我们将类定义为项目的蓝图,将对象定义为类的实例。

类的一个例子是PersonPerson的一个对象/实例的例子是学生或讲师。这些是属于Person类的具体示例对象:

图 4.2 类实例的表示

图 4.2 类实例的表示

在上图中,Person类用于表示所有人,而不考虑他们的性别、年龄或身高。从这个类中,我们可以创建人的具体示例,如Person类内部的方框所示。

在 Java 中,我们主要处理类和对象,因此非常重要的是您理解两者之间的区别。

注意

在 Java 中,除了原始数据类型之外,一切都是对象。

以下是 Java 中类定义的格式:

modifier class ClassName {
    //Body
}

Java 中的类定义由以下部分组成:

  • publicprivateprotected,或者没有修饰符。一个public类可以从其他包中的其他类访问。一个private类只能从声明它的类中访问。一个protected类成员可以在同一个包中的所有类中访问。

  • 类名:名称应以初始字母开头。

  • 主体:类主体由大括号{ }括起来。这是我们定义类的属性和方法的地方。

类名的命名约定

Java 中类的命名约定如下:

  • 类名应该使用驼峰命名法。也就是说,第一个单词应以大写字母开头,所有内部单词的第一个字母都应大写,例如CatCatOwnerHouse

  • 类名应该是名词。

  • 类名应该是描述性的,不应该是缩写,除非它们是广为人知的。

以下是Person类的定义示例:

public class Person {

}

修饰符是 public,意味着该类可以从其他 Java 包中访问。类名是Person

以下是Person类的更健壮的示例,具有一些属性和方法:

public class Person {

   //Properties
   int age;
   int height;
   String name;
   //Methods
   public void walk(){
       //Do walking operations here
   }
   public void sleep(){
       //Do sleeping operations here
   }
   private void takeShower(){
       //Do take shower operations here
   }
}

这些属性用于保存对象的状态。也就是说,age保存当前人的年龄,这可能与下一个人的年龄不同。name用于保存当前人的名字,这也将与下一个人不同。它们回答了这个问题:这个人是谁?

方法用于保存类的逻辑。也就是说,它们回答了这个问题:这个人能做什么?方法可以是私有的、公共的或受保护的。

方法中的操作可以根据应用程序的需要变得复杂。您甚至可以从其他方法调用方法,以及向这些方法添加参数。

练习 11:使用类和对象

执行以下步骤:

  1. 打开 IntelliJ IDEA 并创建一个名为Person.java的文件。

  2. 创建一个名为Person的公共类,具有三个属性,即ageheightnameageheight属性将保存整数值,而name属性将保存字符串值:

public class Person {

   //Properties
   int age;
   int height;
   String name;
  1. 定义三个方法,即walk()sleep()takeShower()。为每个方法编写打印语句,以便在调用它们时将文本打印到控制台上:
  //Methods
   public void walk(){
       //Do walking operations here
       System.out.println("Walking...");
   }
   public void sleep(){
       //Do sleeping operations here
       System.out.println("Sleeping...");
   }
   private void takeShower(){
       //Do take shower operations here
       System.out.println("Taking a shower...");
   }
  1. 现在,将speed参数传递给walk()方法。如果speed超过 10,我们将输出打印到控制台,否则我们不会:
public void walk(int speed){
   //Do walking operations here
   if (speed > 10)
{
       System.out.println("Walking...");
}
  1. 现在我们有了Person类,我们可以使用new关键字为其创建对象。在以下代码中,我们创建了三个对象:
Person me = new Person();
Person myNeighbour = new Person();
Person lecturer = new Person();

me变量现在是Person类的对象。它代表了一种特定类型的人,即我。

有了这个对象,我们可以做任何我们想做的事情,比如调用walk()方法,调用sleep()方法,以及更多。只要类中有方法,我们就可以这样做。稍后,我们将看看如何将所有这些行为添加到一个类中。由于我们没有main方法,这段代码不会有任何输出。

练习 12:使用 Person 类

要调用类的成员函数,请执行以下步骤:

  1. 在 IntelliJ 中创建一个名为PersonTest的新类。

  2. PersonTest类中,创建main方法。

  3. main方法中,创建Person类的三个对象

public static void main(String[] args){
Person me = new Person();
Person myNeighbour = new Person();
Person lecturer = new Person();
  1. 调用第一个对象的walk()方法:
me.walk(20);
me.walk(5);
me.sleep();
  1. 运行类并观察输出:
Walking...
Sleeping…
  1. 使用myNeighbourlecturer对象来做同样的事情,而不是使用me
myNeighbour.walk(20);
myNeighbour.walk(5);
myNeighbour.sleep();
lecturer.walk(20);
lecturer.walk(5);
lecturer.sleep();
}
  1. 再次运行程序并观察输出:
Walking...
Sleeping...
Walking...
Sleeping...
Walking...
Sleeping...

在这个例子中,我们创建了一个名为PersonTest的新类,并在其中创建了Person类的三个对象。然后我们调用了me对象的方法。从这个程序中,可以明显看出Person类是一个蓝图,我们可以根据需要创建尽可能多的对象。我们可以分别操作这些对象,因为它们是完全不同和独立的。我们可以像处理其他变量一样传递这些对象,甚至可以将它们作为参数传递给其他对象。这就是面向对象编程的灵活性。

注意

我们没有调用me.takeShower(),因为这个方法在Person类中声明为私有。私有方法不能在其类外部调用。

构造函数

要能够创建一个类的对象,我们需要一个构造函数。当你想要创建一个类的对象时,就会调用构造函数。当我们创建一个没有构造函数的类时,Java 会为我们创建一个空的默认构造函数,不带参数。如果一个类创建时没有构造函数,我们仍然可以用默认构造函数来实例化它。我们之前使用的Person类就是一个很好的例子。当我们想要一个Person类的新对象时,我们写下了以下内容:

Person me = new Person();

默认构造函数是Person(),它返回Person类的一个新实例。然后我们将这个返回的实例赋给我们的变量me

构造函数和其他方法一样,只是有一些不同:

  • 构造函数的名称与类名相同

  • 构造函数可以是publicprivate

  • 构造函数不返回任何东西,甚至不返回void

让我们看一个例子。让我们为我们的Person类创建一个简单的构造函数:

public class Person {
   //Properties
   int age;
   int height;
   String name;
   //Constructor
   public Person(int myAge){
       age = myAge;
   }

   //Methods
   public void walk(int speed){
       //Do walking operations here
       if (speed > 10)
           System.out.println("Walking...");
   }
   public void sleep(){
       //Do sleeping operations here
       System.out.println("Sleeping...");
   }
   private void takeShower(){
       //Do take shower operations here
       System.out.println("Taking a shower...");
   }
}

这个构造函数接受一个参数,一个名为myAge的整数,并将其值赋给类中的age属性。记住构造函数隐式返回类的实例。

我们可以使用构造函数再次创建me对象,这次传递age

Person me = new Person(30);

this 关键字

在我们的Person类中,我们在构造函数中看到了以下行:

age = myAge;

在这一行中,正如我们之前看到的,我们正在将当前对象的age变量设置为传入的新值myAge。有时,我们希望明确指出我们所指的对象。当我们想引用当前正在处理的对象中的属性时,我们使用this关键字。例如,我们可以将前面的行重写为以下形式:

this.age = myAge;

在这一新行中,this.age用于引用当前正在处理的对象中的 age 属性。this用于访问当前对象的实例变量。

例如,在前面的行中,我们正在将当前对象的age设置为传递给构造函数的值。

除了引用当前对象,如果你有多个构造函数,this还可以用来调用类的其他构造函数。

在我们的Person类中,我们将创建一个不带参数的第二个构造函数。如果调用此构造函数,它将调用我们创建的另一个构造函数,并使用默认值 28:

//Constructor
public Person(int myAge){
   this.age = myAge;
}
public Person(){
   this(28);
}

现在,当调用Person me = new Person()时,第二个构造函数将调用第一个构造函数,并将myAge设置为 28。第一个构造函数将当前对象的age设置为 28。

活动 12:在 Java 中创建一个简单的类

场景:假设我们想为一个动物农场创建一个程序。在这个程序中,我们需要跟踪农场上的所有动物。首先,我们需要一种方法来表示动物。我们将创建一个动物类来表示单个动物,然后创建这个类的实例来表示具体的动物本身。

目标:我们将创建一个 Java 类来表示动物,并创建该类的实例。到本次活动结束时,我们应该有一个简单的Animal类和该类的几个实例。

目标:了解如何在 Java 中创建类和对象。

按照以下步骤完成活动

  1. 在 IDE 中创建一个新项目,命名为Animals

  2. 在项目中,在src/文件夹下创建一个名为Animal.java的新文件。

  3. 创建一个名为Animal的类,并添加实例变量legsearseyesfamilyname

  4. 定义一个没有参数的构造函数,并将legs初始化为 4,ears初始化为 2,eyes初始化为 2。

  5. 定义另一个带有legsearseyes作为参数的带参数构造函数。

  6. namefamily添加 getter 和 setter。

  7. 创建另一个名为Animals.java的文件,定义main方法,并创建Animal类的两个对象。

  8. 创建另一个具有两条legs、两只ears和两只eyes的动物。

  9. 为了设置动物的namefamily,我们将使用在类中创建的 getter 和 setter,并打印动物的名字。

输出应该类似于以下内容:

图 4.4:Animal 类的输出

图 4.3:Animal 类的输出

注意

这项活动的解决方案可以在 314 页找到。

活动 13:编写一个 Calculator 类

对于这个活动,你将创建一个 Calculator 类,给定两个操作数和一个运算符,可以执行操作并返回结果。这个类将有一个 operate 方法,它将使用两个操作数执行操作。操作数和运算符将是类中的字段,通过构造函数设置。

有了 Calculator 类准备好后,编写一个应用程序,执行一些示例操作,并将结果打印到控制台。

要完成这项活动,你需要:

  1. 创建一个名为Calculator的类,有三个字段:double operand1double operand2String operator。添加一个设置所有三个字段的构造函数。

  2. 在这个类中,添加一个operate方法,它将检查运算符是什么("+"、"-"、"x"或"/"),并执行正确的操作,返回结果。

  3. 在这个类中添加一个main方法,这样你就可以写几个示例案例并打印结果。

注意

这项活动的解决方案可以在 318 页找到。

继承

在这一部分,我们将看一下面向对象编程的另一个重要原则,称为继承。面向对象编程中的继承与英语中的继承意思相同。让我们通过使用我们的家谱来看一个例子。我们的父母继承自我们的祖父母。然后我们从我们的父母那里继承,最后,我们的孩子继承,或者将从我们那里继承。同样,一个类可以继承另一个类的属性。这些属性包括方法和字段。然后,另一个类仍然可以从它那里继承,依此类推。这形成了我们所说的继承层次结构

被继承的类称为超类基类,继承的类称为子类派生类。在 Java 中,一个类只能从一个超类继承。

继承的类型

继承的一个例子是公司或政府中的管理层次结构:

  • 单级继承:在单级继承中,一个类只从另一个类继承:

图 4.5:单级继承的表示

图 4.4:单级继承的表示
  • 多级继承:在多级继承中,一个类可以继承另一个类,而另一个类也可以继承另一个类:

图 4.6:多级继承的表示

图 4.5:多级继承的表示
  • 多重继承:在这里,一个类可以从多个类继承:

图 4.7:多重继承的表示

图 4.6:多重继承的表示

在 Java 中不直接支持多重继承,但可以通过使用接口来实现,这将在下一课程中介绍。

面向对象编程中继承的重要性

让我们回到我们的Person类。

很明显,所有人都支持一些共同的属性和行为,尽管他们的性别或种族不同。例如,在属性方面,每个人都有一个名字,每个人都有年龄、身高和体重。在行为方面,所有人都睡觉,所有人都吃饭,所有人都呼吸,等等。

我们可以在所有的Person类中定义所有这些属性和方法的代码,也可以在一个类中定义所有这些常见属性和操作,让其他Person类从这个类继承。这样,我们就不必在这些子类中重写属性和方法。因此,继承允许我们通过重用代码来编写更简洁的代码。

一个类从另一个类继承的语法如下:

class SubClassName extends SuperClassName {
}

我们使用extends关键字来表示继承。

例如,如果我们希望我们的Student类扩展Person类,我们会这样声明:

public class Student extends Person {
}

在这个Student类中,我们可以访问我们在Person类中之前定义的公共属性和方法。当我们创建这个Student类的实例时,我们自动可以访问我们之前在Person类中定义的方法,比如walk()sleep()。我们不需要再重新创建这些方法,因为我们的Student类现在是Person类的子类。但是,我们无法访问私有方法,比如takeShower()

注意

请注意,子类只能访问其超类中的公共属性和方法。如果在超类中将属性或方法声明为私有,则无法从子类访问它。默认情况下,我们声明的属性只能从同一包中的类中访问,除非我们在它们之前明确放置public修饰符。

在我们的Person类中,让我们定义一些所有人都具有的常见属性和方法。然后,我们将从这个类继承这些属性,以创建其他类,比如StudentLecturer

public class Person {
   //Properties
   int age;
   int height;
   int weight;
   String name;
   //Constructors
   public Person(int myAge, int myHeight, int myWeight){
       this.age = myAge;
       this.height = myHeight;
       this.weight = myWeight;
   }
   public Person(){
       this(28, 10, 60);
   }
   //Methods
   public void walk(int speed){
       if (speed > 10)
           System.out.println("Walking...");
   }
   public void sleep(){
       System.out.println("Sleeping...");
   }
   public  void setName(String name){
       this.name = name;
   }
   public String getName(){
       return name;
   }
   public int getAge(){
       return age;
   }
   public int getHeight(){
       return height;
   }
   public int getWeight(){
       return weight;
   }
}

在这里,我们定义了四个属性,两个构造函数和七个方法。您能解释每个方法的作用吗?目前这些方法都相当简单,这样我们就可以专注于继承的核心概念。我们还修改了构造函数以接受三个参数。

让我们创建一个从Person类继承的Student类,创建一个类的对象,并设置学生的名字:

public class Student extends Person {
   public static void main(String[] args){
       Student student = new Student();
       student.setName("James Gosling");
   }
}

我们创建了一个新的Student类,它继承自Person类。我们还创建了Student类的一个新实例,并设置了它的名字。请注意,我们没有在Student类中重新定义setName()方法,因为它已经在Person类中定义了。我们还可以在我们的student对象上调用其他方法:

public class Student extends Person {
   public static void main(String[] args){
       Student student = new Student();
       student.setName("James Gosling");
       student.walk(20);
       student.sleep();
       System.out.println(student.getName());
       System.out.println(student.getAge());
   }
} 

请注意,我们没有在Student类中创建这些方法,因为它们已经在Student类继承的Person类中定义。

在 Java 中实现继承

写下上述程序的预期输出。通过查看程序来解释输出。

解决方案是:

Walking...
Sleeping...
James Gosling
28

让我们定义一个从相同的Person类继承的Lecturer类:

public class Lecturer extends Person {
   public static void main(String[] args){
       Lecturer lecturer = new Lecturer();
       lecturer.setName("Prof. James Gosling");
       lecturer.walk(20);
       lecturer.sleep();
       System.out.println(lecturer.getName());
       System.out.println(lecturer.getAge());
   }
}

注意

请注意继承如何帮助我们通过重用相同的Person类来减少我们编写的代码量。如果没有继承,我们将不得不在所有的类中重复相同的方法和属性。

活动 14:使用继承创建计算器

在之前的活动中,您创建了一个Calculator类,其中包含了同一类中所有已知的操作。当您考虑添加新操作时,这使得这个类更难扩展。操作方法将无限增长。

为了使这个更好,你将使用面向对象的实践将操作逻辑从这个类中拆分出来,放到它自己的类中。在这个活动中,你将创建一个名为 Operator 的类,默认为求和操作,然后创建另外三个类来实现其他三种操作:减法、乘法和除法。这个 Operator 类有一个matches方法,给定一个字符串,如果该字符串表示该操作符,则返回 true,否则返回 false。

将操作逻辑放在它们自己的类中,编写一个名为CalculatorWithFixedOperators的新类,其中有三个字段:double operand1double operand2和类型为Operatoroperator。这个类将具有与之前计算器相同的构造函数,但不再将操作符存储为字符串,而是使用matches方法来确定正确的操作符。

与之前的计算器一样,这个计算器也有一个返回 double 的operate方法,但不再有任何逻辑,而是委托给在构造函数中确定的当前操作符。

要完成这个活动,你需要:

  1. 创建一个名为Operator的类,它有一个在构造函数中初始化的 String 字段,表示操作符。这个类应该有一个默认构造函数,表示默认操作符,即sum。操作符类还应该有一个名为operate的方法,接收两个 double 并将操作符的结果作为 double 返回。默认操作是求和。

  2. 创建另外三个类:SubtractionMultiplicationDivision。它们继承自 Operator,并重写了代表它们的每种操作的operate方法。它们还需要一个不带参数的构造函数,调用 super 传递它们代表的操作符。

  3. 创建一个名为CalculatorWithFixedOperators的新类。这个类将包含四个常量(finals)字段,表示四种可能的操作。它还应该有另外三个字段:类型为 double 的operand1operator2,以及类型为Operatoroperator。这另外三个字段将在构造函数中初始化,该构造函数将接收操作数和操作符作为字符串。使用可能操作符的匹配方法,确定哪一个将被设置为操作符字段。

  4. 与之前的Calculator类一样,这个类也将有一个operate方法,但它只会委托给operator实例。

  5. 最后,编写一个main方法,多次调用新的计算器,打印每次操作的结果。

注意

重写计算器以使用更多的类似乎比最初的代码更复杂。但它抽象了一些重要的行为,打开了一些将在未来活动中探索的可能性。

注意

这个活动的解决方案可以在第 319 页找到。

重载

我们将讨论的下一个面向对象的原则叫做重载。重载是面向对象编程中的一个强大概念,它允许我们重用方法名,只要它们具有不同的签名。方法签名是方法名、它的参数和参数的顺序:

图 4.8:方法签名的表示

图 4.7:方法签名的表示

上述是一个从给定银行名称中提取资金的方法的示例。该方法返回一个 double 并接受一个 String 参数。这里的方法签名是getMyFundsFromBank()方法的名称和 String 参数bankName。签名不包括方法的返回类型,只包括名称和参数。

通过重载,我们能够定义多个方法,这些方法具有相同的方法名,但参数不同。这在定义执行相同操作但接受不同参数的方法时非常有用。

让我们看一个例子。

让我们定义一个名为Sum的类,其中有三个重载的方法,用来对传递的参数进行相加并返回结果:

public class Sum {
    //This sum takes two int parameters
    public int sum(int x, int y) {
        return (x + y);
    }
    //This sum takes three int parameters
    public int sum(int x, int y, int z) {
        return (x + y + z);
    }
    //This sum takes two double parameters
    public double sum(double x, double y) {
        return (x + y);
    }
    public static void main(String args[]) {
        Sum s = new Sum();
        System.out.println(s.sum(10, 20));
        System.out.println(s.sum(10, 20, 30));
        System.out.println(s.sum(10.5, 20.5));
    }
}

输出如下:

30
60
31.0

在这个例子中,sum()方法被重载以接受不同的参数并返回总和。方法名相同,但每个方法都接受不同的参数集。方法签名的差异允许我们使用相同的名称多次。

你可能会想知道重载对面向对象编程带来了什么好处。想象一种情况,我们不能多次重用某个方法名称,就像在某些语言中,比如 C 语言。为了能够接受不同的参数集,我们需要想出六个不同的方法名称。为了那些本质上做同样事情的方法想出六个不同的名称是繁琐和痛苦的,尤其是在处理大型程序时。重载可以避免我们遇到这样的情况。

让我们回到我们的Student类,并创建两个重载的方法。在第一个方法中,我们将打印一个字符串来打印“去上课...”,无论这一周的哪一天。在第二个方法中,我们将传递一周的哪一天,并检查它是否是周末。如果是周末,我们将打印出一个与其他工作日不同的字符串。这是我们将如何实现它:

public class Student extends Person {
   //Add this
   public void goToClass(){
       System.out.println("Going to class...");
   }
   public void goToClass(int dayOfWeek){
       if (dayOfWeek == 6 || dayOfWeek == 7){
           System.out.println("It's the weekend! Not to going to class!");
       }else {
           System.out.println("Going to class...");
       }
   }
   public static void main(String[] args){
       Student student = new Student();
       student.setName("James Gosling");
       student.walk(20);
       student.sleep();
       System.out.println(student.getName());
       System.out.println(student.getAge());
       //Add this
       student.goToClass();
       student.goToClass(6);
   }
}

输出如下:

Walking...
Sleeping...
James Gosling
28
Going to class...
It's the weekend! Not to going to class!

打开我们创建的Lecturer类,并添加两个重载的方法,如下所示:

  • teachClass()打印出"Teaching a random class"

  • teachClass(String className)打印出"Teaching " + className

以下是代码:

public void teachClass(){
   System.out.println("Teaching a random class.");
}
public void teachClass(String className){
   System.out.println("Teaching " + className);
}

我们可以在一个类中重载主方法,但一旦程序启动,JVM 只会调用main(String[] args)。我们可以从这个main方法中调用我们重载的main方法。以下是一个例子:

public class Student {
    public static void main(String[] args){
        // Will be called by the JVM
    }
    public static void main(String[] args, String str1, int num){
        //Do some operations
    }
    public static void main(int num, int num1, String str){

    }
}

在这个例子中,main方法被重载了三次。然而,当我们运行程序时,只会调用签名为main(String[] args)的主方法。从我们的代码的任何地方,我们都可以自由地调用其他主方法。

构造函数重载

就像方法一样,构造函数也可以被重载。当在同一个类中使用不同参数声明相同的构造函数时,这被称为构造函数重载。编译器根据参数的数量和数据类型来区分应该调用哪个构造函数。

在我们讨论构造函数时,我们为我们的Person类创建了第二个构造函数,它接受ageheightweight作为参数。我们可以在同一个类中拥有不接受参数的构造函数和这个构造函数。这是因为这两个构造函数具有不同的签名,因此可以并存。让我们看看我们如何做到这一点:

//Constructors
public Person(){
   this(28, 10, 60);
}
//Overloaded constructor
public Person(int myAge, int myHeight, int myWeight){
   this.age = myAge;
   this.height = myHeight;
   this.weight = myWeight;
}

这两个构造函数具有相同的名称(类名),但接受不同的参数。

添加一个接受ageheightweightname的第三个构造函数。在构造函数内,将所有类变量设置为传递的参数。

代码如下:

public Person(int myAge, int myHeight, int myWeight, String name){
   this.age = myAge;
   this.height = myHeight;
   this.weight = myWeight;
   this.name = name;
}

多态和重写

我们将要讨论的下一个面向对象编程原则是多态。术语“多态”源自生物学,即一个生物体可以呈现多种形式和阶段。这个术语也用在面向对象编程中,子类可以定义它们独特的行为,但仍然与父类共享一些功能。

让我们用一个例子来说明这一点。

在我们的Person示例中,我们有一个名为walk的方法。在我们的Student类中,它继承自Person类,我们将重新定义相同的walk方法,但现在是走去上课而不仅仅是走路。在我们的Lecturer类中,我们也将重新定义相同的walk方法,这次是走到教职工室而不是走到教室。这个方法必须与超类中的walk方法具有相同的签名和返回类型,才能被认为是多态的。以下是我们Student类中实现的样子:

public class Student extends Person {
       ….
   public void walk(int speed){
       //Walk to class
       System.out.println("Walking to class ..");
   }
…...
}

当我们调用student.walk(20)时,我们的Student类中的这个方法将被调用,而不是Person类中的相同方法。也就是说,我们为我们的Student类提供了一种独特的行走方式,这与LecturerPerson类不同。

在 Java 中,我们将这样的方法称为重写方法,这个过程称为方法重写。Java 虚拟机(JVM)调用适当的方法来引用对象。

重写和重载之间的区别

让我们看一下方法重载和重写之间的区别:

  • 方法重载涉及在同一个类中有两个或更多个具有相同名称但不同参数的方法:
void foo(int a)
void foo(int a, float b)
  • 方法重写意味着有两个具有相同参数但不同实现的方法。其中一个存在于父类中,而另一个存在于子类中:
class Parent {
    void foo(double d) {
        // do something
    }
}
class Child extends Parent {

    void foo(double d){
        // this method is overridden.  
    }
}

注解

现在我们将介绍另一个将帮助我们编写更好的 Java 程序的重要主题。

注解是我们可以向程序添加元数据的一种方式。这些元数据可以包括我们正在开发的类的版本信息。这在类被弃用或者我们正在重写某个方法的情况下非常有用。这样的元数据不是程序本身的一部分,但可以帮助我们捕捉错误或提供指导。注解对其注释的代码的操作没有直接影响。

让我们看一个场景。我们如何确保我们正在重写某个方法而不是创建另一个完全不同的方法?当重写方法时,一个错误,比如使用不同的返回类型,将导致该方法不再被重写。这样的错误很容易犯,但如果在软件开发阶段没有及时处理,后来可能会导致软件错误。那么,我们如何强制重写?答案,你可能已经猜到了,就是使用注解。

@字符告诉编译器接下来是一个注解。

让我们在我们的Student类中使用注解来强制重写:

@Override
public void walk(int speed){
   //Walk to class
   System.out.println("Walking to class ..");
}

请注意,我们在方法名称上方添加了@Override行,以指示该方法是从超类中重写的。当编译程序时,编译器将检查此注解,并立即知道我们正在尝试重写此方法。它将检查此方法是否存在于超类中,以及重写是否已正确完成。如果没有,它将报告错误以指示该方法不正确。这在某种程度上将防止我们犯错。

Java 包含内置注解,您也可以创建自己的注解。注解可以应用于类、属性、方法和其他程序元素的声明。在声明上使用时,每个注解按照惯例出现在自己的一行上。让我们看一些 Java 中内置注解的例子:

表 4.1:不同注解及其用途的表格

表 4.1:不同注解及其用途的表格

创建您自己的注解类型

注解是使用interface关键字创建的。让我们声明一个注解,以便我们可以添加类的作者信息:

public @interface Author {
    String name();
    String date();
}

此注释接受作者的姓名和日期。然后我们可以在我们的Student类中使用这个注释:

@Author(name = "James Gosling", date = "1/1/1970")
public class Student extends Person {
}

您可以在上面的示例中用您的值替换名称和日期。

引用

在您使用对象时,重要的是您了解引用。引用是一个地址,指示对象的变量和方法存储在哪里。

当我们将对象分配给变量或将它们作为参数传递给方法时,我们实际上并没有传递对象本身或其副本 - 我们传递的是对象本身在内存中的引用。

为了更好地理解引用的工作原理,让我们举个例子。

以下是一个例子:

创建一个名为Rectangle的新类,如下所示:

public class Rectangle {
    int width;
    int height;
    public Rectangle(int width, int height){
        this.width = width;
        this.height = height;
    }
    public static void main(String[] args){
        Rectangle r1, r2;
        r1 = new Rectangle(100, 200);
        r2 = r1;
        r1.height = 300;
        r1.width = 400;
        System.out.println("r1: width= " + r1.width + ", height= " + r1.height);
        System.out.println("r2: width= " + r2.width + ", height= " + r2.height);
    }
}

以下是输出结果:

r1: width= 400, height= 300
r2: width= 400, height= 300

以下是前面程序中发生的事情的总结:

  1. 我们创建了两个类型为Rectangle的变量r1r2

  2. 一个新的Rectangle对象被赋给r1

  3. r1的值被赋给r2

  4. r2的宽度和高度被改变。

  5. 最终打印了这两个对象的值。

你可能期望r1r2的值不同。然而,输出结果却不是这样。这是因为当我们使用r2 = r1时,我们创建了一个从r2r1的引用,而不是创建一个从r1复制的新对象r2。也就是说,r2指向了r1所指向的相同对象。任何一个变量都可以用来引用对象并改变它的变量:

图 4.9:对象 r1,r2 的表示

图 4.8:对象 r1,r2 的表示

如果你想让r2引用一个新对象,使用以下代码:

r1 = new Rectangle(100, 200);
r2 = new Rectangle(300, 400);

在 Java 中,引用在参数传递给方法时变得特别重要。

注意

在 Java 中没有显式指针或指针算术,就像 C 和 C++中一样。然而,通过使用引用,大多数指针功能被复制,而不带有许多它们的缺点。

活动 15:理解 Java 中的继承和多态

场景:想象我们希望我们在活动一中创建的Animals类更加面向对象。这样,以后如果我们的农场需要,它将更容易维护和扩展。

目标:我们将创建类来继承我们的Animals类,实现重载和重写的方法,并创建一个注解来对我们的类进行版本控制。

目标:理解如何从一个类继承,重载和重写方法,并在 Java 中创建注解。

步骤:

  1. 打开我们之前创建的Animals项目。

  2. 在项目中,在src/文件夹中创建一个名为Cat.java的新文件。

  3. 打开Cat.java并从Animals类继承。

  4. 在其中,创建Cat类的一个新实例,并将家庭设置为"Cat",名称设置为"Puppy",ears设置为两个,eyes设置为两个,legs设置为四个。不要重新定义这些方法和字段 - 而是使用从Animals类继承的方法。

  5. 打印familynameearslegseyes。输出是什么?

注意

这个活动的解决方案可以在第 322 页找到。

总结

在这节课中,我们学到了类是可以创建对象的蓝图,而对象是类的实例,并提供了该类的具体实现。类可以是公共的、私有的或受保护的。类有一个不带参数的默认构造函数。我们可以在 Java 中有用户定义的构造函数。this关键字用于引用类的当前实例。

我们接着学习了继承是一个子类继承了父类的属性的特性。

我们继续学习了 Java 中的重载、多态、注解和引用。

在下一节课中,我们将看一下在 Java 中使用接口和Object类。

第五章:第五章

深入了解面向对象编程

学习目标

在本课结束时,您将能够:

  • 在 Java 中实现接口

  • 执行类型转换

  • 利用Object

  • 使用抽象类和方法

介绍

在上一课中,我们看了面向对象编程的基础知识,如类和对象、继承、多态和重载。

我们看到类如何作为一个蓝图,我们可以从中创建对象,并看到方法如何定义类的行为,而字段保存状态。

我们看了一个类如何通过继承从另一个类获得属性,以便我们可以重用代码。然后,我们学习了如何通过重载重用方法名称 - 也就是说,只要它们具有不同的签名。最后,我们看了子类如何通过覆盖超类的方法重新定义自己独特的行为。

在本课中,我们将深入探讨面向对象编程的原则,以及如何更好地构建我们的 Java 程序。

我们将从接口开始,这些构造允许我们定义任何类都可以实现的通用行为。然后,我们将学习一个称为类型转换的概念,通过它我们可以将一个变量从一种类型转换为另一种类型,然后再转回来。同样,我们将使用 Java 提供的包装类将原始数据类型作为对象处理。最后,我们将详细了解抽象类和方法,这是一种让继承您的类的用户运行其自己独特实现的方法。

在这节课中,我们将通过使用我们在上一课创建的“动物”类来进行三个活动。我们还将使用我们的“人”类来演示一些概念。

让我们开始吧!

接口

在 Java 中,您可以使用接口提供一组类必须实现的方法。

让我们以我们的“人”类为例。我们想定义一组行为,定义任何人的行为,而不管他们的年龄或性别。

这些操作的一些示例包括睡觉、呼吸和移动/行走。我们可以将所有这些常见操作放在一个接口中,让任何声称是人的类来实现它们。实现此接口的类通常被称为“人”类型。

在 Java 中,我们使用关键字 interface 来表示接下来的代码块将是一个接口。接口中的所有方法都是空的,没有实现。这是因为任何实现此接口的类都将提供其独特的实现细节。因此,接口本质上是一组没有主体的方法。

让我们创建一个接口来定义一个人的行为:

public interface PersonBehavior {
   void breathe();
   void sleep();
   void walk(int speed);
}

这个接口称为PersonBehavior,它包含三个方法:一个用于呼吸,另一个用于睡觉,还有一个用于以给定速度行走。实现此接口的每个类都必须实现这三个方法。

当我们想要实现一个给定的接口时,我们在类名后面使用implements关键字,然后是接口名。

让我们举个例子。我们将创建一个新的类Doctor来代表医生。这个类将实现PersonBehavior接口:

public class Doctor implements PersonBehavior {
}

因为我们已经声明要符合PersonBehavior接口,如果我们不实现接口中的三个方法,编译器将给出错误。

public class Doctor implements PersonBehavior {
   @Override
   public void breathe() {

   }
   @Override
   public void sleep() {
   }
   @Override
   public void walk(int speed) {
   }

我们使用@Override注解来指示这个方法来自接口。在这些方法中,我们可以自由地执行与我们的“医生”类相关的任何操作。

在相同的精神下,我们也可以创建一个实现相同接口的“工程师”类:

public class Engineer implements PersonBehavior {
   @Override
   public void breathe() {

   }
   @Override
   public void sleep() {
   }
   @Override
   public void walk(int speed) {
   }
}

第 1 课Java 简介中,我们提到抽象是面向对象编程的基本原则之一。抽象是我们为类提供一致的接口的一种方式。

让我们以手机为例。使用手机,您可以给朋友打电话和发短信。打电话时,您按下通话按钮,立即与朋友连接。该通话按钮形成了您和朋友之间的接口。我们并不真正知道按下按钮时会发生什么,因为所有这些细节都对我们进行了抽象(隐藏)。

您经常会听到API这个术语,它代表应用程序编程接口。这是不同软件和谐交流的一种方式。例如,当您想要使用 Facebook 或 Google 登录应用程序时。应用程序将调用 Facebook 或 Google API。然后 Facebook API 将定义要遵循的登录规则。

Java 中的类可以实现多个接口。这些额外的接口用逗号分隔。类必须为接口中它承诺实现的所有方法提供实现:

public class ClassName implements  InterfaceA, InterfaceB, InterfaceC {

}

用例:监听器

接口最重要的用途之一是为程序中的条件或事件创建监听器。基本上,监听器在发生动作时通知您任何状态更改。监听器也称为回调 - 这个术语源自过程式语言。

例如,当单击或悬停在按钮上时,可以调用事件监听器。

这种事件驱动的编程在使用 Java 制作 Android 应用程序时很受欢迎。

想象一下,我们想要知道一个人行走或睡觉时,以便我们可以执行一些其他操作。我们可以通过使用一个监听此类事件的接口来实现这一点。我们将在以下练习中看到这一点。

练习 13:实现接口

我们将创建一个名为PersonListener的接口,用于监听两个事件:onPersonWalkingonPersonSleeping。当调用walk(int speed)方法时,我们将分派onPersonWalking事件,当调用sleep()时,将调用onPersonSleeping

  1. 创建一个名为PersonListener的接口,并将以下代码粘贴到其中:
public interface PersonListener {
   void onPersonWalking();
   void onPersonSleeping();
}
  1. 打开我们的Doctor类,并在PersonBehavior接口之后添加PersonListener接口,用逗号分隔:
public class Doctor implements PersonBehavior, PersonListener {
  1. 实现我们的PersonListener接口中的两个方法。当医生行走时,我们将执行一些操作并触发onPersonWalking事件,以让其他监听器知道医生正在行走。当医生睡觉时,我们将触发onPersonSleeping事件。修改walk()sleep()方法如下:
@Override
public void breathe() {
}
@Override
public void sleep() {
    //TODO: Do other operations here
    // then raise event
    this.onPersonSleeping();
}
@Override
public void walk(int speed) {
    //TODO: Do other operations here
    // then raise event
    this.onPersonWalking();
}
@Override
public void onPersonWalking() {
    System.out.println("Event: onPersonWalking");
}
@Override
public void onPersonSleeping() {
    System.out.println("Event: onPersonSleeping");
} 
  1. 通过调用walk()sleep()来添加主方法以测试我们的代码:
public static void main(String[] args){
   Doctor myDoctor = new Doctor();
   myDoctor.walk(20);
   myDoctor.sleep();
}
  1. 运行Doctor类并在控制台中查看输出。您应该看到类似于这样的内容:

图 5.1:Doctor 类的输出

图 5.1:Doctor 类的输出

完整的Doctor类如下:

public class Doctor implements PersonBehavior, PersonListener {

   public static void main(String[] args){
       Doctor myDoctor = new Doctor();
       myDoctor.walk(20);
       myDoctor.sleep();
   }
   @Override
   public void breathe() {
   }
   @Override
   public void sleep() {
       //TODO: Do other operations here
       // then raise event
       this.onPersonSleeping();
   }
   @Override
   public void walk(int speed) {
       //TODO: Do other operations here
       // then raise event
       this.onPersonWalking();
   }
   @Override
   public void onPersonWalking() {
       System.out.println("Event: onPersonWalking");
   }
   @Override
   public void onPersonSleeping() {
       System.out.println("Event: onPersonSleeping");
   }
}

注意

由于一个类可以实现多个接口,我们可以在 Java 中使用接口来模拟多重继承。

活动 16:在 Java 中创建和实现接口

场景:在我们之前的动物农场中,我们希望所有动物都具备的共同动作,而不管它们的类型如何。我们还想知道动物何时移动或发出任何声音。移动可以帮助我们跟踪每个动物的位置,声音可以表明动物是否处于困境。

目标:我们将实现两个接口:一个包含所有动物必须具备的两个动作move()makeSound(),另一个用于监听动物的移动和声音。

目标:了解如何在 Java 中创建接口并实现它们。

这些步骤将帮助您完成此活动:

  1. 打开上一课的Animals项目。

  2. 创建一个名为AnimalBehavior的新接口。

  3. 在其中创建两个方法:void move()void makeSound()

  4. 创建另一个名为AnimalListener的接口,其中包含onAnimalMoved()onAnimalSound()方法。

  5. 创建一个名为Cow的新公共类,并实现AnimalBehaviorAnimalListener接口。

  6. Cow类中创建实例变量soundmovementType

  7. 重写move(),使movementType为"Walking",并调用onAnimalMoved()方法。

  8. 重写makeSound(),使movementType为"Moo",并调用onAnimalMoved()方法。

  9. 重写onAnimalMoved()inAnimalMadeSound()方法。

  10. 创建一个main()来测试代码。

输出应该类似于以下内容:

Animal moved: Walking
Sound made: Move

注意

此活动的解决方案可在第 323 页找到。

类型转换

我们已经看到,当我们写int a = 10时,a是整数数据类型,通常大小为 32 位。当我们写char c = 'a'时,c的数据类型是字符。这些数据类型被称为原始类型,因为它们可以用来保存简单的信息。

对象也有类型。对象的类型通常是该对象的类。例如,当我们创建一个对象,比如Doctor myDoctor = new Doctor()myDoctor对象的类型是DoctormyDoctor变量通常被称为引用类型。正如我们之前讨论的那样,这是因为myDoctor变量并不持有对象本身。相反,它持有对象在内存中的引用。

类型转换是我们将一个类型转换为另一个类型的一种方式。重要的是要注意,只有属于同一个超类或实现相同接口(统称为类型)的类或接口,即它们具有父子关系,才能被转换或转换为彼此。

让我们回到我们的Person例子。我们创建了Student类,它继承自这个类。这基本上意味着Student类是Person家族中的一员,任何从Person类继承的其他类也是如此:

图 5.3:从基类继承子类

图 5.2:从基类继承子类

我们在 Java 中使用对象前使用括号进行类型转换:

Student student = new Student();
Person person = (Person)student;

在这个例子中,我们创建了一个名为studentStudent类型的对象。然后,我们通过使用(Person)student语句将其转换为Person类型。这个语句将student标记为Person类型,而不是Student类型。这种类型的类型转换,即我们将子类标记为超类,称为向上转换。这个操作不会改变原始对象;它只是将其标记为不同的类型。

向上转换减少了我们可以访问的方法的数量。例如,student变量不能再访问Student类中的方法和字段。

我们通过执行向下转换将student转换回Student类型:

Student student = new Student();
Person person = (Person)student;
Student newStudent = (Student)person;

向下转换是将超类类型转换为子类类型。此操作使我们可以访问子类中的方法和字段。例如,newStudent现在可以访问Student类中的所有方法。

为了使向下转换起作用,对象必须最初是子类类型。例如,以下操作是不可能的:

Student student = new Student();
Person person = (Person)student;
Lecturer lecturer = (Lecturer) person;

如果您尝试运行此程序,您将收到以下异常:

图 5.4:向下转换时的异常消息

图 5.3:向下转换时的异常消息

这是因为person最初不是Lecturer类型,而是Student类型。我们将在接下来的课程中更多地讨论异常。

为了避免这种类型的异常,您可以使用instanceof运算符首先检查对象是否是给定类型:

if (person instanceof  Lecturer) {
  Lecturer lecturer() = (Lecturer) person;
}

如果person最初是Lecturer类型,则instanceof运算符返回true,否则返回 false。

活动 17:使用 instanceof 和类型转换

在以前的活动中,您使用接口声明了有关员工接口的工资和税收的常见方法。随着 JavaWorks 有限公司的扩张,销售人员开始获得佣金。这意味着现在,您需要编写一个新的类:SalesWithCommission。这个类将扩展自Sales,这意味着它具有员工的所有行为,但还将具有一个额外的方法:getCommission。这个新方法返回这个员工的总销售额(将在构造函数中传递)乘以销售佣金,即 15%。

作为这个活动的一部分,您还将编写一个具有生成员工方法的类。这将作为此活动和其他活动的数据源。这个EmployeeLoader类将有一个方法:getEmployee(),它返回一个 Employee。在这个方法中,您可以使用任何方法返回一个新生成的员工。使用java.util.Random类可能会帮助您完成这个任务,并且如果需要的话,仍然可以获得一致性。

使用您的数据源和新的SalesWithCommission,您将编写一个应用程序,使用for循环多次调用EmployeeLoader.getEmployee方法。对于每个生成的员工,它将打印他们的净工资和所支付的税款。它还将检查员工是否是SalesWithCommission的实例,对其进行转换并打印他的佣金。

完成此活动,您需要:

  1. 创建一个SalesWithCommission类,它扩展自Sales。添加一个接收 double 类型的总销售额并将其存储为字段的构造函数。还添加一个名为getCommission的方法,它返回总销售额乘以 15%(0.15)的 double 类型。

  2. 创建另一个类,作为数据源,生成员工。这个类有一个名为getEmployee()的方法,将创建一个 Employee 实现的实例并返回它。方法的返回类型应该是 Employee。

  3. 编写一个应用程序,在for循环中重复调用getEmployee()并打印有关员工工资和税收的信息。如果员工是SalesWithCommission的实例,还要打印他的佣金。

注意

此活动的解决方案可以在第 325 页找到。

对象类

Java 提供了一个特殊的类称为Object,所有类都隐式继承自它。您不必手动从这个类继承,因为编译器会为您执行。Object是所有类的超类:

图 5.4:超类 Object

这意味着 Java 中的任何类都可以向上转型为Object

Object object = (Object)person;
Object object1 = (Object)student;

同样,您可以向原始类进行向下转换:

Person newPerson = (Person)object;
Student newStudent  = (Student)object1;

当您想要传递您不知道类型的对象时,可以使用这个Object类。当 JVM 想要执行垃圾回收时,也会使用它。

自动装箱和拆箱

有时,我们需要处理只接受对象的方法中的原始类型。一个很好的例子是当我们想要在 ArrayList 中存储整数时(稍后我们将讨论)。这个类ArrayList只接受对象,而不是原始类型。幸运的是,Java 提供了所有原始类型作为类。包装类可以保存原始值,我们可以像操作普通类一样操作它们。

Integer类的一个示例,它可以保存一个int如下:

Integer a = new Integer(1);

我们还可以省略new关键字,编译器会自动为我们进行包装:

Integer a = 1;

然后,我们可以像处理其他对象一样使用这个对象。我们可以将其向上转型为Object,然后将其向下转型为Integer

将原始类型转换为对象(引用类型)的操作称为自动装箱。

我们还可以将对象转换回原始类型:

Integer a = 1;
int b = a;

这里,将原始类型b赋值为a的值,即 1。将引用类型转换回原始类型的操作称为拆箱。编译器会自动为我们执行自动装箱和拆箱。

除了Integer,Java 还为以下基本类型提供了以下包装类:

表 5.1:表示基本类型的包装类的表格

活动 18:理解 Java 中的类型转换

场景:让我们使用我们一直在使用的Animal类来理解类型转换的概念。

目标:我们将为我们的Animal类创建一个测试类,并对CowCat类进行向上转型和向下转型。

目标:内化类型转换的概念。

这些步骤将帮助您完成此活动:

执行以下步骤:

  1. 打开Animals项目。

  2. 创建一个名为AnimalTest的新类,并在其中创建main方法

  3. main()方法中创建CatCow类的对象。

  4. 打印 Cat 对象的所有者。

  5. Cat类的对象向上转型为Animal,并尝试再次打印所有者。注意错误。

  6. 打印 Cow 类的对象的声音。

  7. Cow类的对象向上转型为Animal,并尝试再次打印所有者。注意错误。

  8. 将 Animal 类的对象向下转型为 Cat 类的新对象,并再次打印所有者。

输出应该类似于这样:

图 5.8:AnimalTest 类的输出

图 5.5:AnimalTest 类的输出

注意

此活动的解决方案可以在第 327 页找到。

抽象类和方法

早些时候,我们讨论了接口以及当我们希望与我们的类在它们必须实现的方法上有一个合同时,它们可以是有用的。然后我们看到了我们只能转换共享相同层次树的类。

Java 还允许我们拥有具有抽象方法的类,所有从它继承的类必须实现这些方法。这样的类在访问修饰符之后被称为abstract关键字。

当我们将一个类声明为abstract时,从它继承的任何类必须在其中实现abstract方法。我们不能实例化抽象类:

public abstract class AbstractPerson {
     //this class is abstract and cannot be instantiated
}

因为abstract类首先仍然是类,它们可以有自己的逻辑和状态。这使它们比方法为空的接口具有更多的优势。此外,一旦我们从abstract类继承,我们可以沿着该类层次结构执行类型转换。

Java 还允许我们拥有abstract方法,必须声明为abstract

我们在访问修饰符之后使用abstract关键字来声明一个方法为abstract

当我们从一个abstract类继承时,我们必须在其中实现所有的abstract方法:

public class SubClass extends  AbstractPerson {
       //TODO: implement all methods in AbstractPerson
}

活动 19:在 Java 中实现抽象类和方法

场景:想象一下,当地医院委托您构建一款软件来管理使用该设施的不同类型的人。您必须找到一种方式来代表医生、护士和患者。

目标:我们将创建三个类:一个是抽象类,代表任何人,另一个代表医生,最后一个代表患者。所有的类都将继承自抽象人类。

目标:了解 Java 中abstract类和方法的概念。

这些步骤将帮助您完成此活动:

  1. 创建一个名为Hospital的新项目并打开它。

  2. src文件夹中,创建一个名为Person的抽象类:

public abstract class Patient {
}
  1. 创建一个返回医院中人员类型的abstract方法。将此方法命名为 String getPersonType(),返回一个字符串:
public abstract String getPersonType();

我们已经完成了我们的abstract类和方法。现在,我们将继续从中继承并实现这个abstract方法。

  1. 创建一个名为Doctor的新类,它继承自Person类:
public class Doctor extends Patient {
}
  1. 在我们的Doctor类中重写getPersonType抽象方法。返回"Arzt"字符串。这是医生的德语名称:
@Override
public String getPersonType() {
   return "Arzt";
}
  1. 创建另一个名为Patient的类来代表医院里的病人。同样,确保该类继承自Person并重写getPersonType方法。返回"Kranke"。这是德语中的病人:
public class People extends Patient{
   @Override
   public String getPersonType() {
       return "Kranke";
   }
}

现在我们有了两个类,我们将使用第三个测试类来测试我们的代码。

  1. 创建一个名为HospitalTest的第三个类。我们将使用这个类来测试之前创建的两个类。

  2. HospitalTest类中,创建main方法:

public class HospitalTest {
   public static void main(String[] args){

   }
}
  1. main方法中,创建一个Doctor的实例和一个Patient的实例:
Doctor doctor = new Doctor();
People people = new People();
  1. 尝试为每个对象调用getPersonType方法并将其打印到控制台上。输出是什么?
String str = doctor.getPersonType();
String str1 = patient.getPersonType();
System.out.println(str);
System.out.println(str1);

输出如下:

图 5.6:调用 getPersonType()的输出

注意

此活动的解决方案可在第 329 页找到。

活动 20:使用抽象类封装公共逻辑

JavaWorks 不断发展。现在他们有了许多员工,他们注意到之前构建的应用程序不支持工资变化。到目前为止,每个工程师的工资都必须与其他人相同。经理、销售和带佣金的销售人员也是如此。为了解决这个问题,您将使用一个封装根据税收计算净工资的逻辑的抽象类。为了使其工作,抽象类将有一个接收总工资的构造函数。它不会实现getTax()方法,而是将其委托给子类。使用接收总工资作为构造函数参数的新通用员工的子类。

您还将在EmployeeLoader中添加一个新方法getEmployeeWithSalary(),它将生成一个新的通用员工,并随机生成总工资。

最后,在您的应用程序中,您将像以前一样,打印工资信息和税,如果员工是GenericSalesWithCommission的实例,还要打印他的佣金。

要完成此活动,您需要:

  1. 创建一个抽象类GenericEmployee,它有一个接收总工资并将其存储在字段中的构造函数。它应该实现 Employee 接口并有两个方法:getGrossSalary()getNetSalary()。第一个方法只会返回传入构造函数的值。后者将返回总工资减去调用getTax()方法的结果。

  2. 为每种类型的员工创建一个新的通用版本:GenericEngineerGenericManagerGenericSalesGenericSalesWithCommission。它们都需要一个接收总工资并将其传递给超级构造函数的构造函数。它们还需要实现getTax()方法,返回每个类的正确税值。记得在GenericSalesWithCommission类中也接收总销售额,并添加计算佣金的方法。

  3. EmployeeLoader类中添加一个新方法getEmployeeWithSalary。这个方法将在返回之前为新创建的员工生成一个介于 70,000 和 120,000 之间的随机工资。在创建GenericSalesWithCommission员工时,也记得提供一个总销售额。

  4. 编写一个应用程序,从for循环内多次调用getEmployeeWithSalary方法。这个方法将像前一个活动中一样工作:打印所有员工的净工资和税。如果员工是GenericSalesWithCommission的实例,还要打印他的佣金。

注意

此活动的解决方案可在第 331 页找到。

总结

在这节课中,我们学到了接口是一种定义一组方法的方式,所有实现它们的类必须提供特定的实现。接口可以用于在代码中实现事件和监听器,当特定动作发生时。

然后我们了解到,类型转换是一种让我们将一个类型的变量改变为另一个类型的方法,只要它们在同一层次树上或实现了一个共同的接口。

我们还研究了在 Java 中使用instanceof运算符和Object类,并学习了自动装箱、拆箱、抽象类和抽象方法的概念。

在下一课中,我们将研究一些 Java 中附带的常见类和数据结构。

第六章:第六章

数据结构、数组和字符串

学习目标

通过本课程结束时,您将能够:

  • 创建和操作各种数据结构,如数组

  • 描述编程算法的基本原理

  • 为数组编写简单的排序程序

  • 输入并对字符串执行操作

介绍

这是我们关于 OOP 讨论的最后一个主题。到目前为止,我们已经看过类和对象,以及如何使用类作为蓝图来创建多个对象。我们看到了如何使用方法来保存我们类的逻辑和字段来保存状态。我们讨论了类如何从其他类继承一些属性,以便轻松地重用代码。

我们还看过多态性,或者一个类如何重新定义从超类继承的方法的实现;以及重载,或者我们如何可以有多个使用相同名称的方法,只要它们具有不同的签名。我们还讨论了函数或方法。

我们在上一课中已经讨论了类型转换和接口,以及类型转换是我们将对象从一种类型更改为另一种类型的方法,只要它们在同一层次结构树上。我们谈到了向上转型和向下转型。另一方面,接口是我们定义通用行为的一种方式,我们的类可以提供自己的特定实现。

在本节中,我们将看一些 Java 自带的常见类。这些是您每天都会使用的类,因此了解它们非常重要。我们还将讨论数据结构,并讨论 Java 自带的常见数据结构。请记住,Java 是一种广泛的语言,这个列表并不是详尽无遗的。请抽出时间查看官方 Java 规范,以了解更多关于您可以使用的其他类的信息。在本课程中,我们将介绍一个主题,提供示例程序来说明概念,然后完成一个练习。

数据结构和算法

算法是一组指令,应该遵循以实现最终目标。它们是特定于计算的,但我们经常谈论算法来完成计算机程序中的某个任务。当我们编写计算机程序时,通常实现算法。例如,当我们希望对一组数字进行排序时,通常会想出一个算法来实现。这是计算机科学的核心概念,对于任何优秀的程序员来说都很重要。我们有用于排序、搜索、图问题、字符串处理等的算法。Java 已经为您实现了许多算法。但是,我们仍然有机会定义自己的算法。

数据结构是一种存储和组织数据以便于访问和修改的方式。数据结构的一个示例是用于保存相同类型的多个项目的数组或用于保存键值对的映射。没有单一的数据结构适用于所有目的,因此了解它们的优势和局限性非常重要。Java 有许多预定义的数据结构,用于存储和修改不同类型的数据。我们也将在接下来的部分中涵盖其中一些。

在计算机程序中对不同类型的数据进行排序是一项常见任务。

数组

我们在第 3 课 控制 中提到了数组,当时我们正在讨论循环,但是值得更仔细地看一下,因为它们是强大的工具。数组是有序项目的集合。它用于保存相同类型的多个项目。Java 中数组的一个示例可能是{1, 2, 3, 4, 5, 6, 7},其中保存了整数 1 到 7。这个数组中的项目数是 7。数组也可以保存字符串或其他对象,如下所示:

{"John","Paul","George", "Ringo"}

我们可以通过使用其索引来访问数组中的项。索引是数组中项的位置。数组中的元素从0开始索引。也就是说,第一个数字在索引0处,第二个数字在索引1处,第三个数字在索引2处,依此类推。在我们的第一个示例数组中,最后一个数字在索引6处。

为了能够访问数组中的元素,我们使用myArray[0]来访问myArray中的第一个项目,myArray[1]来访问第二个项目,依此类推,myArray[6]来访问第七个项目。

Java 允许我们定义原始类型和引用类型等对象的数组。

数组也有一个大小,即数组中的项数。在 Java 中,当我们创建一个数组时,必须指定其大小。一旦数组被创建,大小就不能改变。

图 6.1:一个空数组

图 6.1:一个空数组

创建和初始化数组

要创建一个数组,您需要声明数组的名称、它将包含的元素的类型和其大小,如下所示:

int[] myArray = new int[10];

我们使用方括号[]来表示数组。在这个例子中,我们正在创建一个包含 10 个项目的整数数组,索引从 0 到 9。我们指定项目的数量,以便 Java 可以为元素保留足够的内存。我们还使用new关键字来指示一个新数组。

例如,要声明包含 10 个双精度数的数组,请使用以下方法:

double[] myArray = new double[10];

要声明包含 10 个布尔值的数组,请使用以下方法:

boolean[] myArray = new boolean[10];

要声明包含 10 个Person对象的数组,请使用以下方法:

Person[] people = new Person[10];

您还可以创建一个数组,并在同一时间声明数组中的项(初始化):

int[] myArray = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

访问元素

要访问数组元素,我们使用方括号括起的索引。例如,要访问第四个元素,我们使用myArray[3],要访问第十个元素,我们使用myArray[9]

这是一个例子:

int first_element = myArray[0];
int last_element = myArray[9];

要获取数组的长度,我们使用length属性。它返回一个整数,即数组中的项数:

int length = myArray. length;

如果数组没有任何项,length将为 0。我们可以使用length和循环将项插入数组中。

练习 14:使用循环创建数组

使用控制流命令创建长数组可能很有用。在这里,我们将使用for循环创建一个从 0 到 9 的数字数组。

  1. 创建一个名为DataStr的新类,并设置main方法如下:
public class DataStr {
public static void main(String[] args){
}
  1. 创建一个长度为 10 的整数数组如下:
int[] myArray = new int[10];
  1. 初始化一个for循环,变量从零开始,每次迭代增加一个,条件是小于数组长度:
for (int i = 0; i < myArray.length; i++)
  1. 将项i插入数组中:
{
myArray[i] = i;
}
  1. 使用类似的循环结构来打印循环:
for (int i = 0; i < myArray.length; i++){
System.out.println(myArray[i]);
}

完整的代码应该如下所示:

public class DataStr {
    public static void main(String[] args){
        int[] myArray = new int[10];
        for (int i = 0; i < myArray.length; i++){
            myArray[i] = i;
        }
        for (int i = 0; i < myArray.length; i++){
            System.out.println(myArray[i]);
        }
    }
}

您的输出应该如下所示:

图 6.2:DataStr 类的输出

图 6.2:DataStr 类的输出

在这个练习中,我们使用第一个for循环将项目插入myArray中,使用第二个循环将项目打印出来。

正如我们之前讨论的,我们可以用for-each循环替换第二个for循环,这样代码会更简洁,更易读:

for (int i : myArray) {
System.out.println(i);
}

Java 会自动为我们进行边界检查-如果您创建了一个大小为 N 的数组,并使用值小于 0 或大于 N-1 的索引,您的程序将以ArrayOutOfBoundsException异常终止。

练习 15:在数组中搜索一个数字

在这个练习中,您将检查用户输入的数字是否存在于数组中。为此,请执行以下步骤:

  1. 定义一个名为NumberSearch的新类,并在其中包含main方法:
public class NumberSearch {
public static void main(String[] args){
}
}
  1. 确保在顶部导入此包,用于从输入设备读取值:
import java.util.Scanner;
  1. 声明一个名为 sample 的数组,其中存储整数 2、4、7、98、32、77、81、62、45、71:
int [] sample = { 2, 4, 7, 98, 32, 77, 81, 62, 45, 71 }; 
  1. 从用户那里读取一个数字:
Scanner sc = new Scanner(System.in);
System.out.print("Enter the number you want to find: ");
int ele = sc.nextInt();
  1. 检查ele变量是否与数组样本中的任何项目匹配。为此,我们遍历循环,并检查数组的每个元素是否与用户输入的元素匹配:
for (int i = 0; i < 10; i++) {
  if (sample[i] == ele) {
    System.out.println("Match found at element " + i);
    break;
}
else
  {
    System.out.println("Match not found");
    break;
  }
}

您的输出应类似于此:

图 6.3:NumberSearch 类的输出

图 6.3:NumberSearch 类的输出

活动 21:在数组中找到最小的数字

在这个活动中,我们将取一个包含 20 个未排序数字的数组,并循环遍历数组以找到最小的数字。

步骤如下:

  1. 创建一个名为ExampleArray的类,并创建main方法。

  2. 创建一个由 20 个浮点数组成的数组,如下所示:

14, 28, 15, 89, 46, 25, 94, 33, 82, 11, 37, 59, 68, 27, 16, 45, 24, 33, 72, 51
  1. 通过数组创建一个for-each循环,并找到数组中的最小元素。

  2. 打印出最小的浮点数。

注意

此活动的解决方案可在 335 页找到。

活动 22:具有操作符数组的计算器

在这个活动中,您将改变您的计算器,使其更加动态,并且更容易添加新的操作符。为此,您将不是将所有可能的操作符作为不同的字段,而是将它们添加到一个数组中,并使用 for 循环来确定要使用的操作符。

要完成此活动,您需要:

  1. 创建一个名为Operators的类,其中包含根据字符串确定要使用的操作符的逻辑。在这个类中创建一个名为default_operator的公共常量字段,它将是Operators类的一个实例。然后创建另一个名为operators的常量字段,类型为Operators数组,并用每个操作符的实例进行初始化。

  2. Operators类中,添加一个名为findOperator的公共静态方法,它接收操作符作为字符串,并返回Operators的一个实例。在其中,遍历可能的操作符数组,并对每个操作符使用 matches 方法,返回所选操作符,如果没有匹配任何操作符,则返回默认操作符。

  3. 创建一个新的CalculatorWithDynamicOperator类,有三个字段:operand1operator2为 double 类型,operatorOperators类型。

  4. 添加一个构造函数,接收三个参数:类型为 double 的 operand1 和 operand2,以及类型为 String 的 operator。在构造函数中,不要使用 if-else 来选择操作符,而是使用Operators.findOperator方法来设置操作符字段。

  5. 添加一个main方法,在其中多次调用Calculator类并打印结果。

注意

此活动的解决方案可在 336 页找到。

二维数组

到目前为止我们看到的数组都被称为一维数组,因为所有元素都可以被认为在一行上。我们也可以声明既有列又有行的数组,就像矩阵或网格一样。多维数组是我们之前看到的一维数组的数组。也就是说,您可以将其中一行视为一维数组,然后列是多个一维数组。

描述多维数组时,我们说数组是一个 M 乘 N 的多维数组,表示数组有 M 行,每行长度为 N,例如,一个 6 乘 7 的数组:

图 6.4:多维数组的图形表示

图 6.4:多维数组的图形表示

在 java 中,要创建一个二维数组,我们使用双方括号[M][N]。这种表示法创建了一个 M 行 N 列的数组。然后,我们可以使用[i][j]的表示法来访问数组中的单个项目,以访问第 i 行和第 j 列的元素。

要创建一个 8x10 的双精度多维数组,我们需要执行以下操作:

double[][] a = new double[8][10];

Java 将所有数值类型初始化为零,布尔类型初始化为 false。我们也可以循环遍历数组,并手动将每个项目初始化为我们选择的值:

double[][] a = new double[8][10];
for (int i = 0; i < 8; i++)
for (int j = 0; j < 10; j++)
a[i][j] = 0.0;

练习 16:打印简单的二维数组

要打印一个简单的二维数组,请执行以下步骤:

  1. 在名为Twoarray的新类文件中设置main方法:
public class Twoarray {
    public static void main(String args[]) {
    }
}
  1. 通过向数组添加元素来定义arr数组:
int arr[][] = {{1,2,3}, {4,5,6}, {7,8,9}};
  1. 创建一个嵌套的for循环。外部的for循环是按行打印元素,内部的for循环是按列打印元素:
        System.out.print("The Array is :\n");
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                System.out.print(arr[i][j] + "  ");
            }
            System.out.println();
        }
  1. 运行程序。您的输出应该类似于这样:

图 6.5:Twoarray 类的输出

图 6.5:Twoarray 类的输出

大多数与数组相关的操作与一维数组基本相同。要记住的一个重要细节是,在多维数组中,使用a[i]返回一个一维数组的行。您必须使用第二个索引来访问您希望的确切位置,a[i][j]

注意

Java 还允许您创建高阶维度的数组,但处理它们变得复杂。这是因为我们的大脑可以轻松理解三维数组,但更高阶的数组变得难以可视化。

练习 17:创建一个三维数组

在这里,我们将创建一个三维(x,y,z)整数数组,并将每个元素初始化为其行、列和深度(x * y * z)索引的乘积。

  1. 创建一个名为Threearray的新类,并设置main方法:
public class Threearray
{
    public static void main(String args[])
    {
    }
}
  1. 声明一个维度为[2][2][2]arr数组:
int arr[][][] = new int[2][2][2];
  1. 声明迭代的变量:
int i, j, k, num=1;
  1. 创建三个嵌套在彼此内部的for循环,以便将值写入三维数组:
for(i=0; i<2; i++)
  {
    for(j=0; j<2; j++)
      {
        for(k=0; k<2; k++)
         {
         arr[i][j][k] = no;
         no++;
     }
  }
}
  1. 使用嵌套在彼此内部的三个for循环打印数组的元素:
for(i=0; i<2; i++)
  {
  for(j=0; j<2; j++)
    {
      for(k=0; k<2; k++)
      {
      System.out.print(arr[i][j][k]+ "\t");
      }
    System.out.println();
    }
  System.out.println();
  }
}
}
}
}
}

完整的代码应该是这样的:

public class Threearray
{
    public static void main(String args[])
    {
        int arr[][][] = new int[2][2][2];
        int i, j, k, num=1;
        for(i=0; i<2; i++)
        {
            for(j=0; j<2; j++)
            {
                for(k=0; k<2; k++)
                {
                    arr[i][j][k] = num;
                    num++;
                }
            }
        }
        for(i=0; i<2; i++)
        {
            for(j=0; j<2; j++)
            {
                for(k=0; k<2; k++)
                {
                    System.out.print(arr[i][j][k]+ "\t");
                }
                System.out.println();
            }
            System.out.println();
        }
    }
}

输出如下:

图 6.6:Threearray 类的输出

图 6.6:Threearray 类的输出

Java 中的 Arrays 类

Java 提供了Arrays类,它提供了我们可以与数组一起使用的静态方法。通常更容易使用这个类,因为我们可以访问排序、搜索等方法。这个类在java.util.Arrays包中可用,所以在使用它之前,将这一行放在任何要使用它的文件的顶部:

import java.util.Arrays;

在下面的代码中,我们可以看到如何使用Arrays类和一些我们可以使用的方法。所有的方法都在代码片段后面解释:

import java.util.Arrays;
class ArraysExample {
public static void main(String[] args) {
double[] myArray = {0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0};
System.out.println(Arrays.toString (myArray)); 
Arrays.sort(myArray);
System.out.println(Arrays.toString (myArray));
Arrays.sort(myArray);
int index = Arrays.binarySearch(myArray,7.0);
System.out.println("Position of 7.0 is: " + index);
}
}

这是输出:

图 6.7:ArraysExample 类的输出

图 6.7:ArraysExample 类的输出

在这个程序中,我们有Arrays类的三个示例用法。在第一个示例中,我们看到如何使用Arrays.toString()轻松打印数组的元素,而不需要我们之前使用的for循环。在第二个示例中,我们看到如何使用Arrays.sort()快速对数组进行排序。如果我们要自己实现这样一个方法,我们将使用更多的行,并且在过程中容易出现很多错误。

在最后一个示例中,我们对数组进行排序,然后使用Arrays.binarySearch()搜索 7.0,它使用一种称为二分查找的搜索算法。

注意

Arrays.sort()使用一种称为双轴快速排序的算法来对大数组进行排序。对于较小的数组,它使用插入排序和归并排序的组合。最好相信Arrays.sort()针对每种用例进行了优化,而不是实现自己的排序算法。Arrays.binarySearch()使用一种称为二分查找的算法来查找数组中的项。它首先要求数组已排序,这就是为什么我们首先调用Arrays.sort()。二分查找递归地将排序后的数组分成两个相等的部分,直到无法再分割数组为止,此时该值就是答案。

插入排序

排序是计算机科学中算法的基本应用之一。插入排序是排序算法的一个经典示例,尽管它效率低下,但在查看数组和排序问题时是一个很好的起点。算法的步骤如下:

  1. 取数组中的第一个元素,并假设它已经排序,因为它只有一个。

  2. 选择数组中的第二个元素。将其与第一个元素进行比较。如果它大于第一个元素,则两个项目已经排序。如果它小于第一个元素,则交换两个元素,使它们排序。

  3. 取第三个元素。将其与已排序子数组中的第二个元素进行比较。如果较小,则交换两者。然后再次将其与第一个元素进行比较。如果较小,则再次交换两者,使其成为第一个。这三个元素现在将被排序。

  4. 取第四个元素并重复此过程,如果它小于其左邻居,则交换,否则保持在原位。

  5. 对数组中的其余项目重复此过程。

  6. 结果数组将被排序。

例子

取数组[3, 5, 8, 1, 9]

  1. 让我们取第一个元素并假设它已排序:[3]

  2. 取第二个元素,5。由于它大于 3,我们保持数组不变:[3, 5]

  3. 取第三个元素,8。它大于 5,所以这里也没有交换:[3, 5, 8]

  4. 取第四个元素,1。由于它小于 8,我们交换 8 和 1 得到:[3, 5, 1, 8]

  5. 由于 1 仍然小于 5,我们再次交换两者:[3, 1, 5, 8]

  6. 1 仍然小于 3。我们再次交换:[1, 3, 5, 8]

  7. 现在它是最小的。

  8. 取最后一个元素,9。它大于 8,所以没有交换。

  9. 整个数组现在已排序:[1, 3, 5, 8, 9]

练习 18:实现插入排序

在这个练习中,我们将实现插入排序。

  1. 创建一个名为InsertionSort的新类,并在这个类中创建main方法:
public class InsertionSort {
public static void main(String[] args){
}
}
  1. 在我们的main方法中,创建一个随机整数样本数组,并将其传递给我们的sort方法。使用以下数组,[1, 3, 354, 64, 364, 64, 3, 4, 74, 2, 46]:
int[] arr = {1, 3,354,64,364,64, 3,4 ,74,2 , 46};
System.out.println("Array before sorting is as follows: ");
System.out.println(Arrays.toString(arr));
  1. 在使用我们的数组调用sort()后,使用foreach循环在单行中打印排序后数组中的每个项目并用空格分隔:
sort(arr);
        System.out.print("Array after sort looks as follows: ");
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }
}
  1. 创建一个名为sort()的公共静态方法,该方法接受一个整数数组并返回void。这是我们排序算法的方法:
public static void sort(int[] arr){
}

sort方法中,实现前面说明的算法。

  1. sort()方法中将整数num定义为数组的长度:
int num = arr.length;
  1. 创建一个for循环,直到i达到数组的长度为止。在循环内,创建比较数字的算法:k将是由索引i定义的整数,j将是索引i-1。在for循环内添加一个while循环,根据以下条件交换ii-1处的整数:j大于或等于0,并且索引j处的整数大于k
for (int i = 1; i < num; i++) {
        int k = arr[i];
        int j = i - 1;
    while (j>= 0 && arr[j] > k) {
        arr[j + 1] = arr[j];
        j = j - 1;
    }
    arr[j + 1] = k;
    }
}

完成的代码如下所示:

import java.util.Arrays;
public class InsertionSort {
    public static void sort(int[] arr) {
        int num = arr.length;
        for (int i = 1; i < num; i++) {
            int k = arr[i];
            int j = i - 1;
        while (j>= 0 && arr[j] > k) {
            arr[j + 1] = arr[j];
            j = j - 1;
        }
        arr[j + 1] = k;
        }
    }
    public static void main(String[] args) {
        int[] arr = {1, 3, 354, 64, 364, 64, 3, 4, 74, 2, 46};
        System.out.println("Array before sorting is as follows: ");
        System.out.println(Arrays.toString(arr));
        sort(arr);
        System.out.print("Array after sort looks as follows: ");
        for (int i : arr) {
            System.out.print(i + " ");
        }
    }
}

输出如下:

图 6.8:InsertionSort 类的输出

图 6.8:InsertionSort 类的输出

Java 使我们能够处理常用的数据结构,如列表、堆栈、队列和映射变得容易。它配备了 Java 集合框架,提供了易于使用的 API,用于处理这些数据结构。一个很好的例子是当我们想要对数组中的元素进行排序或者想要搜索数组中的特定元素时。我们可以应用于我们的集合的方法,只要它们符合集合框架的要求,而不是自己从头开始重写这些方法。集合框架的类可以保存任何类型的对象。

现在我们将看一下集合框架中的一个常见类,称为ArrayList。有时我们希望存储元素,但不确定我们期望的项目数量。我们需要一个数据结构,可以向其中添加任意数量的项目,并在需要时删除一些。到目前为止,我们看到的数组在创建时需要指定项目的数量。之后,除非创建一个全新的数组,否则无法更改该数组的大小。ArrayList 是一个动态列表,可以根据需要增长和缩小;它们是以初始大小创建的,当我们添加或删除一个项目时,大小会根据需要自动扩大或缩小。

创建 ArrayList 并添加元素

创建ArrayList时,您需要指定要存储的对象类型。数组列表仅支持引用类型(即对象)的存储,不支持原始类型。但是,由于 Java 提供了带有要添加的对象作为参数的add()方法。ArrayList 还有一个方法来获取列表中的项目数,称为size()。该方法返回一个整数,即列表中的项目数:

import java.util.ArrayList;
public class Person {
public static void main(String[] args){
Person john=new Person();
//Initial size of 0
ArrayList<Integer> myArrayList = new ArrayList<>();
System.out.println("Size of myArrayList: "+myArrayList.size());

//Initial size of 5
ArrayList<Integer> myArrayList1 = new ArrayList<>(5);
myArrayList1.add(5);System.out.println("Size of myArrayList1: "+myArrayList1.size());
//List of Person objectsArrayList<Person> people = new ArrayList<>();
people.add(john);System.out.println("Size of people: "+people.size());
 }
}

输出如下:

图 6.9:Person 类的输出

图 6.9:Person 类的输出

在第一个示例中,我们创建了一个大小为 0 的myArrayList,其中包含Integer类型的ArrayList。在第二个示例中,我们创建了一个大小为 5 的Integer类型的ArrayList。尽管初始大小为 5,但当我们添加更多项目时,列表将自动增加大小。在最后一个示例中,我们创建了一个Person对象的ArrayList。从这三个示例中,创建数组列表时应遵循以下规则:

  1. java.util包中导入ArrayList类。

  2. <>之间指定对象的数据类型。

  3. 指定列表的名称。

  4. 使用new关键字创建ArrayList的新实例。

以下是向 ArrayList 添加元素的一些方法:

myArrayList.add( new Integer(1));
myArrayList1.add(1);
people.add(new Person());

在第一个示例中,我们创建一个新的Integer对象并将其添加到列表中。新对象将附加到列表的末尾。在第二行中,我们插入了 1,但由于ArrayList仅接受对象,JVM 将Person类并将其附加到列表中。我们可能还希望在同一类中将元素插入到特定索引而不是在列表末尾附加。在这里,我们指定要插入对象的索引和要插入的对象:

myArrayList1.add(1, 8);
System.out.println("Elements of myArrayList1: " +myArrayList1.toString());

输出如下:

图 6.10:添加元素到列表后的输出

图 6.10:添加元素到列表后的输出

注意

在索引小于 0 或大于数组列表大小的位置插入对象将导致IndexOutOfBoundsException,并且您的程序将崩溃。在指定要插入的索引之前,始终检查列表的大小。

替换和删除元素

ArrayList还允许我们用新元素替换指定位置的元素。在上一个代码中添加以下内容并观察输出:

myArrayList1.set(1, 3);
System.out.println("Elements of myArrayList1 after replacing the element: " +myArrayList1.toString());

这是输出:

图 6.11:替换元素后的列表

图 6.11:替换元素后的列表

在这里,我们将在索引 2 处的元素替换为值为 3 的新Integer对象。如果我们尝试替换列表大小大于的索引或小于零的索引,此方法还会抛出IndexOutOfBoundsException

如果您还希望删除单个元素或所有元素,ArrayList 也支持:

//Remove at element at index 1
myArrayList1.remove(1);
System.out.println("Elements of myArrayList1 after removing the element: " +myArrayList1.toString());
//Remove all the elements in the list
myArrayList1.clear();
System.out.println("Elements of myArrayList1 after clearing the list: " +myArrayList1.toString());

这是输出:

图 6.12:清除所有元素后的列表

图 6.12:清除所有元素后的列表

要获取特定索引处的元素,请使用get()方法,传入索引。该方法返回一个对象:

myArrayList1.add(10);
Integer one = myArrayList1.get(0);
System.out.println("Element at given index: "+one);

输出如下:

图 6.13:给定索引处元素的输出

图 6.13:给定索引处元素的输出

如果传递的索引无效,此方法还会抛出IndexOutOfBoundsException。为了避免异常,始终先检查列表的大小。考虑以下示例:

Integer two = myArrayList1.get(1);

图 6.14:IndexOutOfBounds 异常消息

图 6.14:IndexOutOfBounds 异常消息

练习 19:在数组中添加、删除和替换元素

数组是存储信息的基本但有用的方式。在这个练习中,我们将看看如何在学生名单中添加和删除元素:

  1. 导入java.utilArrayListList类:
import java.util.ArrayList;
import java.util.List;
  1. 创建一个public类和main方法:
public class StudentList {
    public static void main(String[] args) {
  1. 将学生List定义为包含字符串的新 ArrayList:
List<String> students = new ArrayList<>();
  1. 添加四个学生的名字:
students.add("Diana");
students.add("Florence");
students.add("Mary");
students.add("Betty");
  1. 打印数组并删除最后一个学生:
System.out.println(students);
students.remove("Betty");
  1. 打印数组:
System.out.println(students);
  1. 替换第一个学生(在索引 0 处):
students.set(0, "Jean");
  1. 打印数组:
System.out.println(students);  
}
}

输出如下:

图 6.15:StudentList 类的输出

图 6.15:StudentList 类的输出

迭代器

集合框架还提供了迭代器,我们可以使用它们来循环遍历ArrayList的元素。迭代器就像是列表中项目的指针。我们可以使用迭代器来查看列表中是否有下一个元素,然后检索它。将迭代器视为集合框架的循环。我们可以使用array.iterator()对象和hasNext()来循环遍历数组。

练习 20:遍历 ArrayList

在这个练习中,我们将创建一个世界上城市的ArrayList,并使用迭代器逐个打印整个ArrayList中的城市:

  1. 导入 ArrayList 和 Iterator 包:
import java.util.ArrayList;
import java.util.Iterator;
  1. 创建一个public类和main方法:
public class Cities {
public static void main(String[] args){
  1. 创建一个新数组并添加城市名称:
ArrayList<String> cities = new ArrayList<>();
cities.add( "London");
cities.add( "New York");
cities.add( "Tokyo");
cities.add( "Nairobi");
cities.add( "Sydney");
  1. 定义一个包含字符串的迭代器:
Iterator<String> citiesIterator = cities.iterator(); 
  1. 使用hasNext()循环迭代器,使用next()打印每个城市:
while (citiesIterator.hasNext()){
String city = citiesIterator.next();
System.out.println(city);
}
}
}

输出如下:

图 6.16:Cities 类的输出

图 6.16:Cities 类的输出

在这个类中,我们创建了一个包含字符串的新 ArrayList。然后我们插入了一些名字,并创建了一个名为citiesIterator的迭代器。集合框架中的类支持iterator()方法,该方法返回一个用于集合的迭代器。迭代器有hasNext()方法,如果在我们当前位置之后列表中还有另一个元素,则返回 true,并且next()方法返回下一个对象。next()返回一个对象实例,然后将其隐式向下转换为字符串,因为我们声明citiesIterator来保存字符串类型:Iterator<String> citiesIterator

图 6.17:next()和 hasNext()的工作方式

图 6.17:next()和 hasNext()的工作方式

除了使用迭代器进行循环,我们还可以使用普通的for循环来实现相同的目标:

for (int i = 0; i < cities.size(); i++){
String name = cities.get(i);
System.out .println(name);
}

输出如下:

图 6.18:使用 for 循环输出 Cities 类的输出

图 6.18:使用 for 循环输出 Cities 类的输出

在这里,我们使用size()方法来检查列表的大小,并使用get()来检索给定索引处的元素。无需将对象转换为字符串,因为 Java 已经知道我们正在处理一个字符串列表。

同样,我们可以使用更简洁的for-each循环,但实现相同的目标:

for (String city : cities) {
System.out.println(city);
}

输出如下:

图 6.19:使用 for-each 循环输出 Cities 类的输出

图 6.19:使用 for-each 循环输出 Cities 类的输出

活动 23:使用 ArrayList

我们有几个学生希望在我们的程序中跟踪。但是,我们目前不确定确切的数量,但预计随着越来越多的学生使用我们的程序,数量会发生变化。我们还希望能够循环遍历我们的学生并打印他们的名字。我们将创建一个对象的 ArrayList,并使用迭代器来循环遍历 ArrayList:

这些步骤将帮助您完成该活动:

  1. java.util导入ArrayListIterator

  2. 创建一个名为StudentsArray的新类。

  3. main方法中,定义一个Student对象的ArrayList。插入四个学生实例,用我们之前创建的不同类型的构造函数实例化。

  4. 为您的列表创建一个迭代器,并打印每个学生的姓名。

  5. 最后,从ArrayList中清除所有对象。

输出如下:

图 6.20:StudentsArray 类的输出

图 6.20:StudentsArray 类的输出

注意

ArrayList 是一个重要的类,你会发现自己在日常生活中经常使用它。这个类有更多的功能,这里没有涵盖,比如交换两个元素,对项目进行排序等。

注意

此活动的解决方案可以在第 338 页找到。

字符串

Java 有字符串数据类型,用于表示一系列字符。字符串是 Java 中的基本数据类型之一,你几乎在所有程序中都会遇到它。

字符串只是一系列字符。"Hello World","London"和"Toyota"都是 Java 中字符串的例子。字符串在 Java 中是对象而不是原始类型。它们是不可变的,也就是说,一旦它们被创建,就不能被修改。因此,我们将在接下来的部分中考虑的方法只会创建包含操作结果的新字符串对象,而不会修改原始字符串对象。

创建一个字符串

我们使用双引号表示字符串,而单引号表示字符:

public class StringsDemo {
    public static void main(String[] args) {
        String hello="Hello World";
        System.out.println(hello);
    }
}

输出如下:

图 6.21:StringsDemo 类的输出

图 6.21:StringsDemo 类的输出

hello对象现在是一个字符串,是不可变的。我们可以在字符串中使用分隔符,比如\n表示换行,\t表示制表符,或者\r表示回车:

String data = '\t'+ "Hello"+ '\n'+" World";
System.out.println(data);

输出如下:

图 6.22:使用分隔符的输出

图 6.22:使用分隔符的输出

我们在Hello之前有一个制表符,然后在World之前有一个换行符,这会在下一行打印World

连接

我们可以将多个字符串文字组合在一起,这个过程通常被称为连接。我们使用+符号来连接两个字符串,如下所示:

String str = "Hello " + "World";
System.out.println(str);

输出如下:

Hello World

当我们想要替换在运行时计算的值时,通常使用连接。代码如下所示:

String userName = getUserName(); // get the username from an external location like database or input field
System.out.println( " Welcome " + userName);

在第一行,我们从一个我们在这里没有定义的方法中得到了userName。然后我们打印出一个欢迎消息,用userName替换了我们之前得到的userName

当我们想要表示跨越多行的字符串时,连接也很重要:

String quote = "I have a dream that " +
"all Java programmers will " +
"one day be free from " +
"all computer bugs!";
System.out.println(quote);

这是输出:

图 6.23:连接的字符串

图 6.23:连接的字符串

除了+符号,Java 还提供了concat()方法来连接两个字符串文字:

String wiseSaying = "Java programmers are " . concat("wise and knowledgeable").concat("." );
System.out.println(wiseSaying);

这是输出:

图 6.24:使用 concat()连接的字符串

图 6.24:使用 concat()连接的字符串

字符串长度和字符

字符串提供了length()方法来获取字符串中的字符数。字符数是所有有效的 java 字符的计数,包括换行符、空格和制表符:

String saying = "To be or not to be, that is the question."
int num = saying.length();
System.out.println(num);

这是输出:

4

要访问给定索引处的字符,请使用charAt(i)。这个方法接受你想要的字符的索引并返回一个 char:

char c = quote.charAt(7);
System.out.println(c);

这是输出:

r

使用大于字符串中字符数或负数的索引调用charAt(i)将导致您的程序崩溃,并出现StringIndexOutOfBoundsException异常:

char d = wiseSaying.charAt(-3);

图 6.25:StringIndexOutOfBoundsException message

图 6.25:StringIndexOutOfBoundsException message

我们还可以使用getChars()方法将字符串转换为字符数组。此方法返回一个我们可以使用的字符数组。我们可以转换整个字符串或字符串的一部分:

char[] chars = new char [quote.length()]; 
quote.getChars(0, quote.length(), chars, 0); 
System.out.println(Arrays.toString (chars));

输出如下:

图 6.26:字符数组

图 6.26:字符数组

活动 24:输入一个字符串并输出其长度和作为数组

为了检查输入到系统中的名称是否过长,我们可以使用之前提到的一些功能来计算名称的长度。在这个活动中,您将编写一个程序,将输入一个名称,然后导出名称的长度和第一个字母。

步骤如下:

  1. 导入java.util.Scanner包。

  2. 创建一个名为nameTell的公共类和一个main方法。

  3. 使用ScannernextLine在提示"输入您的姓名:"处输入一个字符串。

  4. 计算字符串的长度并找到第一个字符。

  5. 打印输出如下:

Your name has 10 letters including spaces.
The first letter is: J

输出将如下所示:

图 6.27:NameTell 类的输出

图 6.27:NameTell 类的输出

注意

此活动的解决方案可以在第 340 页找到。

活动 25:计算器从输入中读取

将所有计算器逻辑封装起来,我们将编写一个命令行计算器,您可以在其中给出运算符、两个操作数,它将显示结果。这样的命令行应用程序以一个永不结束的 while 循环开始。然后从用户那里读取输入,并根据输入做出决定。

对于这个活动,你将编写一个应用程序,只有两个选择:退出或执行操作。如果用户输入Q(或q),应用程序将退出循环并结束。其他任何内容都将被视为操作。您将使用Operators.findOperator方法来查找运算符,然后从用户那里请求更多输入。每个输入都将被转换为双精度(使用Double.parseScanner.nextDouble)。使用找到的运算符对它们进行操作,并将结果打印到控制台上。

由于无限循环,应用程序将重新开始,要求另一个用户操作。

要完成这个活动,您需要:

  1. 创建一个名为CommandLineCalculator的新类,其中包含一个main方法。

  2. 使用无限循环使应用程序保持运行,直到用户要求退出。

  3. 收集用户输入以决定要执行的操作。如果操作是Qq,退出循环。

  4. 如果操作是其他任何内容,请找到一个运算符,并请求另外两个输入,它们将是操作数,将它们转换为双精度。

  5. 在找到的运算符上调用operate方法,并将结果打印到控制台上。

注意

此活动的解决方案可以在第 341 页找到。

转换

有时我们可能希望将给定类型转换为字符串,以便我们可以打印它出来,或者我们可能希望将字符串转换为给定类型。例如,当我们希望将字符串"100"转换为整数100,或者将整数100转换为字符串"100"时。

使用+运算符将原始数据类型连接到字符串将返回该项的字符串表示。

例如,这是如何在整数和字符串之间转换的:

String str1 = "100";
Integer number = Integer.parseInt(str1);
String str2 = number.toString();
System.out.println(str2);

输出如下:

100

这里我们使用parseInt()方法获取字符串的整数值,然后使用toString()方法将整数转换回字符串。

要将整数转换为字符串,我们将其与空字符串""连接:

int a = 100;
String str = "" + a;

输出如下:

100

注意

Java 中的每个对象都有一个字符串表示。Java 提供了Object超类中的toString()方法,我们可以在我们的类中重写它,以提供我们类的字符串表示。当我们想以字符串格式打印我们的类时,字符串表示很重要。

比较字符串和字符串的部分

String类支持许多用于比较字符串和字符串部分的方法。

比较两个字符串是否相等:

String data= "Hello";
String data1 = "Hello";
if (data == data1){
System. out .println("Equal");
}else{
System. out .println("Not Equal");
}

输出如下:

Equal

如果这个字符串以给定的子字符串结尾或开始,则返回true

boolean value= data.endsWith( "ne");
System.out.println(value);
boolean value1 = data.startsWith("He");
System.out.println(value);

输出如下:

False
True

StringBuilder

我们已经说明了字符串是不可变的,也就是说,一旦它们被声明,就不能被修改。然而,有时我们希望修改一个字符串。在这种情况下,我们使用StringBuilder类。StringBuilder就像普通字符串一样,只是它是可修改的。StringBuilder还提供了额外的方法,比如capacity(),它返回为其分配的容量,以及reverse(),它颠倒其中的字符。StringBuilder还支持String类中的相同方法,比如length()toString()

练习 21:使用 StringBuilder

这个练习将追加三个字符串以创建一个字符串,然后打印出它的长度、容量和反转:

  1. 创建一个名为StringBuilderExample的公共类,然后创建一个main方法:
import java.lang.StringBuilder;
public class StringBuilder {
public static void main(String[] args) { 
  1. 创建一个新的StringBuilder()对象,命名为stringbuilder
StringBuilder stringBuilder = new StringBuilder(); 
  1. 追加三个短语:
stringBuilder.append( "Java programmers "); 
stringBuilder.append( "are wise " ); 
stringBuilder.append( "and knowledgeable");
  1. 使用\n作为换行打印出字符串:
System.out.println("The string is \n" + stringBuilder.toString()); 
  1. 找到字符串的长度并打印出来:
int len = stringBuilder.length();
System.out.println("The length of the string is: " + len);
  1. 找到字符串的容量并打印出来:
int capacity = stringBuilder.capacity(); 
System.out.println("The capacity of the string is: " + capacity);
  1. 颠倒字符串并使用换行打印出来:
stringBuilder.reverse(); 
      System.out.println("The string reversed is: \n" + stringBuilder);
}
}

以下是输出:

图 6.28:StringBuilder 类的输出

图 6.28:StringBuilder 类的输出

在这个练习中,我们使用默认容量为 16 创建了一个StringBuilder的新实例。然后我们插入了一些字符串,然后打印出整个字符串。我们还通过length()获取了构建器中的字符数。然后我们得到了StringBuilder的容量。容量是为StringBuilder分配的字符数。它通常高于或等于构建器的长度。最后,我们颠倒了构建器中的所有字符,然后打印出来。在最后的打印输出中,我们没有使用stringBuilder.toString(),因为 Java 会隐式地为我们执行这个操作。

活动 26:从字符串中删除重复字符

为了创建安全的密码,我们决定需要创建不包含重复字符的字符串行。在这个活动中,您将创建一个程序,它接受一个字符串,删除任何重复的字符,然后打印出结果。

一种方法是遍历字符串的所有字符,对于每个字符,再次遍历字符串,检查字符是否已经存在。如果找到重复的字符,立即将其删除。这种算法是一种蛮力方法,不是在运行时间方面最好的方法。事实上,它的运行时间是指数级的。

这些步骤将帮助您完成这个活动:

  1. 创建一个名为Unique的新类,并在其中创建一个main方法。现在先留空。

  2. 创建一个名为removeDups的新方法,它接受并返回一个字符串。这就是我们的算法所在的地方。这个方法应该是publicstatic的。

  3. 在方法内部,检查字符串是否为 null,空或长度为 1。如果这些情况中有任何一个为真,则只需返回原始字符串,因为不需要进行检查。

  4. 创建一个名为result的空字符串。这将是要返回的唯一字符串。

  5. 创建一个for循环,从 0 到传入方法的字符串的长度。

  6. for循环内,获取字符串当前索引处的字符。将变量命名为c

  7. 还要创建一个名为isDuplicate的布尔变量,并将其初始化为false。当我们遇到重复时,我们将把它改为true

  8. 创建另一个嵌套的for循环,从 0 到结果的length()

  9. for循环内,还要获取结果当前索引处的字符。将其命名为d

  10. 比较cd。如果它们相等,则将isDuplicate设置为 true 并break

  11. 关闭内部的for循环并进入第一个for循环。

  12. 检查isDuplicate是否为false。如果是,则将c追加到结果中。

  13. 退出第一个for循环并返回结果。这就完成了我们的算法。

  14. 返回到我们空的main方法。创建以下几个测试字符串:

aaaaaaa 
aaabbbbb
abcdefgh
Ju780iu6G768
  1. 将字符串传递给我们的方法,并打印出方法返回的结果。

  2. 检查结果。返回的字符串中应该删除重复的字符。

输出应该是这样的:

图 6.29:Unique 类的预期输出

图 6.29:Unique 类的预期输出

注意

此活动的解决方案可在第 342 页找到。

总结

这节课将我们带到面向对象编程核心原则讨论的尽头。在这节课中,我们已经看过了数据类型、算法和字符串。

我们已经看到了数组是相同类型项目的有序集合。数组用方括号[ ]声明,它们的大小不能被修改。Java 提供了集合框架中的Arrays类,它有额外的方法可以用在数组上。

我们还看到了StringBuilder类的概念,它基本上是一个可修改的字符串。stringbuilderlengthcapacity函数。

第七章:第七章

Java 集合框架和泛型

学习目标

通过本课程结束时,您将能够:

  • 使用集合处理数据

  • 以不同的方式比较对象

  • 对对象集合进行排序

  • 使用集合构建高效的算法

  • 为每种用例使用最合适的集合

介绍

在之前的课程中,您学习了如何将对象组合在一起形成数组,以帮助您批量处理数据。数组非常有用,但它们具有静态长度的事实使得在加载未知数量的数据时很难处理。此外,访问数组中的对象需要您知道数组的索引,否则需要遍历整个数组才能找到对象。您还简要了解了 ArrayList,它的行为类似于可以动态改变大小以支持更高级用例的数组。

在本课程中,您将学习 ArrayList 的实际工作原理。您还将了解 Java 集合框架,其中包括一些更高级的数据结构,用于一些更高级的用例。作为这个旅程的一部分,您还将学习如何在许多数据结构上进行迭代,以许多不同的方式比较对象,并以高效的方式对集合进行排序。

您还将了解泛型,这是一种强大的方式,可以让编译器帮助您使用集合和其他特殊类。

从文件中读取数据

在我们开始之前,让我们先了解一些我们将在本课程后面部分使用的基础知识。

二进制与文本文件

您的计算机中有许多类型的文件:可执行文件、配置文件、数据文件等。文件可以分为两个基本组:二进制和文本。

当人类与文件的交互只会间接发生时,例如执行应用程序(可执行文件)或在 Excel 中加载的电子表格文件时,使用二进制文件。如果您尝试查看这些文件的内部,您将看到一堆无法阅读的字符。这种类型的文件非常有用,因为它们可以被压缩以占用更少的空间,并且可以被结构化,以便计算机可以快速读取它们。

另一方面,文本文件包含可读字符。如果用文本编辑器打开它们,你可以看到里面的内容。并非所有文本文件都是供人类阅读的,有些格式几乎不可能理解。但大多数文本文件都可以被人类读取和轻松编辑。

CSV 文件

逗号分隔值(CSV)文件是一种非常常见的文本文件类型,用于在系统之间传输数据。CSV 非常有用,因为它们易于生成和阅读。这种文件的结构非常简单:

  • 每行一个记录。

  • 第一行是标题。

  • 每个记录都是一个长字符串,其中的值使用逗号分隔(值也可以用其他分隔符分隔)。

以下是从我们将要使用的示例数据中提取出的文件的一部分。

id,name,email
10,Bill Gates,william.gates@microsoft.com
30,Jeff Bezos,jeff.bezos@amazon.com
20,Marc Benioff,marc.benioff@salesforce.com

在 Java 中读取文件

Java 有两个基本的类集,用于读取文件:Stream,用于读取二进制文件,和Reader,用于读取文本文件。io包设计中最有趣的部分是StreamReader可以组合在一起逐步添加功能。这种能力被称为管道,因为它类似于将多个管道连接在一起的过程。

我们将使用一个简单的例子来解释这些,还有FileReaderBufferedReader的帮助。

FileReader逐个读取字符。BufferedReader可以缓冲这些字符以一次读取一行。这对我们在读取 CSV 时很简单,因为我们可以创建一个FileReader实例,然后用BufferedReader包装它,然后从 CSV 文件中逐行读取:

图 7.1:从 CSV 文件中读取的过程的示意图

图 7.1:从 CSV 文件中读取的过程的示意图

练习 22:读取 CSV 文件

在这个练习中,您将使用FileReaderBufferedReader从 CSV 文件中读取行,拆分它们,并像记录一样处理它们:

  1. 创建一个名为ReadCSVFile.java的文件,并添加一个同名的类,并向其中添加一个main方法:
public class ReadCSVFile {
  public static void main(String [] args) throws IOException {
  1. 首先,您需要添加一个字符串变量,该变量将从命令行参数中获取要加载的文件的名称:
String fileName = args[0];  
  1. 然后,您创建一个新的FileReader并将其放入BufferedReader中,使用 try-with-resource,如下面的代码所示:
FileReader fileReader = new FileReader(fileName);
try (BufferedReader reader = new BufferedReader(fileReader)) {
  1. 现在您已经打开了一个文件进行读取,您可以逐行读取它。BufferedReader将一直给您新的行,直到文件结束。当文件结束时,它将返回null。因此,我们可以声明一个变量行,并在while条件中设置它。然后,我们需要立即检查它是否为 null。我们还需要一个变量来计算我们从文件中读取的行数:
String line;
int lineCounter = -1;
while ( (line = reader.readLine()) != null ) {
  1. 在循环内,您增加了行计数并忽略了第零行,即标题。这就是为什么我们将lineCounter初始化为-1而不是零的原因:
lineCounter++;
// Ignore the header
if (lineCounter == 0) {
  continue;
}
  1. 最后,您使用String类的split方法拆分行。该方法接收一个分隔符,在我们的情况下是逗号:
String [] split = line.split(",");
System.out.printf("%d - %s\n", lineCounter, split[1]);

注意

您可以看到FileReader是如何传递到BufferedReader中,然后再也没有访问的。这是因为我们只想要行,而不关心将字符转换为行的中间过程。

恭喜!您编写了一个可以读取和解析 CSV 的应用程序。随意深入研究这段代码,并了解当您更改初始行计数值时会发生什么。

输出如下:

1 - Bill Gates
2 - Jeff Bezos
3 - Marc Benioff
4 - Bill Gates
5 - Jeff Bezos
6 - Sundar Pichai
7 - Jeff Bezos
8 - Larry Ellison
9 - Marc Benioff
10 - Larry Ellison
11 - Jeff Bezos
12 - Bill Gates
13 - Sundar Pichai
14 - Jeff Bezos
15 - Sundar Pichai
16 - Marc Benioff
17 - Larry Ellison
18 - Marc Benioff
19 - Jeff Bezos
20 - Marc Benioff
21 - Bill Gates
22 - Sundar Pichai
23 - Larry Ellison
24 - Bill Gates
25 - Larry Ellison
26 - Jeff Bezos
27 - Sundar Pichai

构建 CSV 读取器

现在您知道如何从 CSV 中读取数据,我们可以开始考虑将该逻辑抽象成自己的管道。就像BufferedReader允许您逐行读取文本文件一样,CSV 读取器允许您逐条记录读取 CSV 文件。它建立在BufferedReader功能之上,并添加了使用逗号作为分隔符拆分行的逻辑。以下图表显示了我们的新管道将如何使用 CSV 读取器:

图 7.2:CSVReader 可以添加到链中以逐条读取记录

图 7.2:CSVReader 可以添加到链中以逐条读取记录

练习 23:构建 CSV 读取器

在这个练习中,我们将遵循管道模式,并构建一个简单的CSVReader,我们将在本课程的其余部分中使用它:

  1. 创建一个名为CSVReader.java的新文件,并在编辑器中打开它。

  2. 在此文件中,创建一个名为CSVReader的公共类,并实现Closeable接口:

public class CSVReader implements Closeable {
  1. 添加两个字段,一个字段用于将BufferedReader存储为final,我们将从中读取,另一个字段用于存储行计数:
private final BufferedReader reader;
private int lineCount = 0;
  1. 创建一个构造函数,接收BufferedReader并将其设置为字段。此构造函数还将读取并丢弃传入读取器的第一行,因为那是标题,我们在本课程中不关心它们:
public CSVReader(BufferedReader reader) throws IOException {
  this.reader = reader;
  // Ignores the header
  reader.readLine();
}
  1. 通过调用基础读取器的close方法来实现close方法:
public void close() throws IOException {
  this.reader.close();
}
  1. 就像BufferedReader有一个readLine方法一样,我们的CSVReader类将有一个readRecord方法,该方法将从BufferedReader读取行,然后返回由逗号分隔的字符串。在这种方法中,我们将跟踪到目前为止已读取多少行。我们还需要检查读取器是否返回了一行,因为它可能返回 null,这意味着它已经完成了对文件的读取,并且没有更多的行可以给我们。如果是这种情况,我们将遵循相同的模式并返回 null:
public String[] readRow() throws IOException {
  String line = reader.readLine();
  if (line == null) {
    return null;
  }
  lineCount++;
  return line.split(",");
}

注意

在更复杂的实现中,我们可以存储标题以公开类的用户提供额外的功能,例如按标题名称获取值。我们还可以对行进行整理和验证,以确保没有额外的空格包裹值,并且它们包含预期数量的值(与标题计数相同)。

  1. 使用 getter 公开linecount
public int getLineCount() {
  return lineCount;
}
  1. 现在你的新CSVReader已经准备好使用了!创建一个名为UseCSVReaderSample.java的新文件,其中包含同名的类和一个main方法:
public class UseCSVReaderSample {
  public static void main (String [] args) throws IOException {
  1. 按照之前使用的模式来读取 CSV 中的行,现在你可以使用你的CSVReader类来从 CSV 文件中读取,将以下内容添加到你的main方法中:
String fileName = args[0];
FileReader fileReader = new FileReader(fileName);
BufferedReader reader = new BufferedReader(fileReader);
try (CSVReader csvReader = new CSVReader(reader)) {
  String[] row;
  while ( (row = csvReader.readRow()) != null ) {
    System.out.printf("%d - %s\n", csvReader.getLineCount(), row[1]);
  }
}

注意

从前面的片段中,你可以看到你的代码现在简单得多。它专注于提供业务逻辑(打印带有行数的第二个值),并不关心读取 CSV。这是一个很好的实际例子,说明了如何创建你的读取器来抽象出关于处理来自文件的数据的逻辑。

  1. 为了使代码编译通过,你需要从java.io包中添加导入:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

输出如下:

1 - Bill Gates
2 - Jeff Bezos
3 - Marc Benioff
4 - Bill Gates
5 - Jeff Bezos
6 - Sundar Pichai
7 - Jeff Bezos
8 - Larry Ellison
9 - Marc Benioff
10 - Larry Ellison
11 - Jeff Bezos
12 - Bill Gates
13 - Sundar Pichai
14 - Jeff Bezos
15 - Sundar Pichai
16 - Marc Benioff
17 - Larry Ellison
18 - Marc Benioff
19 - Jeff Bezos
20 - Marc Benioff
21 - Bill Gates
22 - Sundar Pichai
23 - Larry Ellison
24 - Bill Gates
25 - Larry Ellison
26 - Jeff Bezos
27 - Sundar Pichai

数组

正如你已经从之前的课程中学到的,数组非常强大,但它们的静态特性使事情变得困难。假设你有一段代码,从某个数据库或 CSV 文件中加载用户。直到完成加载所有数据之前,从数据库或文件中获取的数据量是未知的。如果你使用的是数组,你将不得不在每次读取记录时调整数组的大小。这将是非常昂贵的,因为数组无法调整大小;它们需要一遍又一遍地复制。

以下是一些代码,用于说明如何调整数组的大小:

// Increase array size by one
// Create new array
User[] newUsers = new User[users.length + 1];
// Copy data over
System.arraycopy(users, 0, newUsers, 0, users.length);
// Switch
users = newUsers;

为了更有效,你可以初始化数组的容量,并在完成读取所有记录后修剪数组,以确保它不包含任何额外的空行。你还需要确保数组在添加新记录时有足够的容量。如果没有,你将不得不创建一个具有足够空间的新数组,并复制数据。

练习 24:从 CSV 文件中读取用户到数组中

在这个练习中,你将学习如何使用数组来存储来自数据源的无限数量的数据。在我们的例子中,我们将使用在前几节中一直使用的相同的用户 CSV:

  1. 创建一个名为User.java的文件,并添加一个同名的类。这个类将有三个字段:idnameemail。它还将有一个可以用所有三个值初始化的构造函数。我们将使用这个类来表示一个User
public class User {
  public int id;
  public String name;
  public String email;
  public User(int id, String name, String email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }
}
  1. User类的开头,添加一个static方法,该方法将从作为字符串数组传递的值创建一个用户。当从 CSV 中读取的值创建一个User时,这将非常有用:
public static User fromValues(String [] values) {
  int id = Integer.parseInt(values[0]);
  String name = values[1];
  String email = values[2];
  return new User(id, name, email);
}
  1. 创建另一个名为IncreaseOnEachRead.java的文件,并添加一个同名的类和一个main方法,该方法将把命令行的第一个参数传递给另一个名为loadUsers的方法。然后,打印加载的用户数量,如下所示:
public class IncreaseOnEachRead {
  public static final void main (String [] args) throws Exception {
    User[] users = loadUsers(args[0]);
    System.out.println(users.length);
  }
}
  1. 在同一个文件中,添加另一个名为loadUsers的方法,它将返回一个用户数组,并接收一个名为fileToRead的字符串,它将是要读取的 CSV 文件的路径:
public static User[] loadUsers(String fileToReadFrom) throws Exception {
  1. 在这个方法中,首先创建一个空的用户数组,并在最后返回它:
User[] users = new User[0];
return users;
  1. 在这两行之间,添加逻辑来使用你的CSVReader逐条读取 CSV 记录。对于每条记录,增加数组的大小,并将新创建的User添加到数组的最后位置:
BufferedReader lineReader = new BufferedReader(new FileReader(fileToReadFrom));
try (CSVReader reader = new CSVReader(lineReader)) {
  String [] row = null;
  while ( (row = reader.readRow()) != null) {
    // Increase array size by one
    // Create new array
    User[] newUsers = new User[users.length + 1];
    // Copy data over
    System.arraycopy(users, 0, newUsers, 0, users.length);
    // Swap
    users = newUsers;
    users[users.length - 1] = User.userFromRow(row);
  }
}

输出如下:

27

现在你可以从 CSV 文件中读取,并拥有了从中加载的所有用户的引用。这实现了在每次读取记录时增加数组的方法。你将如何实现更有效的方法,即初始化数组的容量,并在需要时增加它,并在最后修剪它?

活动 27:使用具有初始容量的数组从 CSV 中读取用户

在这个活动中,你将从 CSV 中读取用户,类似于你在上一个练习中所做的,但不是在每次读取时增加数组,而是使用初始容量创建数组,并在需要时增加它。最后,你需要检查数组是否还有空余空间,并将其缩小,以返回一个确切大小与加载的用户数量相同的数组。

要完成此活动,您需要:

  1. 用初始容量初始化数组。

  2. 在循环中从命令行传入的路径读取 CSV,创建用户并将它们添加到数组中。

  3. 跟踪加载的用户数量。

  4. 在向数组添加用户之前,您需要检查数组的大小,并在必要时进行扩展。

  5. 最后,根据需要缩小数组,以返回加载的确切用户数量。

注意

此活动的解决方案可在第 345 页找到。

Java 集合框架

在构建复杂的应用程序时,您需要以不同的方式操作对象的集合。最初,核心 Java 库仅限于三种选项:数组、向量和哈希表。它们都以自己的方式强大,但随着时间的推移,变得清楚这是不够的。人们开始构建自己的框架来处理更复杂的用例,如分组、排序和比较。

Java 集合框架被添加到 Java 标准版中,以减少编程工作量,并通过提供高效且易于使用的数据结构和算法来改进 Java 应用程序的性能和互操作性。这组接口和实现类旨在为 Java 开发人员提供一种简单的方式来构建可以共享和重用的 API。

向量

向量解决了数组是静态的问题。它们提供了一种动态和可扩展的存储许多对象的方式。它们随着添加新元素而增长,可以准备接收大量元素,并且很容易迭代元素。

为了处理内部数组而不必要地调整大小,向量使用一些容量进行初始化,并使用指针值跟踪最后一个元素添加的位置,这个指针值只是一个标记该位置的整数。默认情况下,初始容量为 10。当您添加的元素超过数组的容量时,内部数组将被复制到一个更大的数组中,留下更多的空间,以便您可以添加额外的元素。复制过程就像您在练习 24中手动处理数组时所做的那样:从 CSV 文件中读取用户到数组。以下是它的工作原理的插图:

图 7.3:向量的插图

图 7.3:向量的插图

在 Java 集合框架之前,使用向量是在 Java 中获得动态数组的方法。然而,存在两个主要问题:

  • 缺乏易于理解和扩展的定义接口

  • 完全同步,这意味着它受到多线程代码的保护

在 Java 集合框架之后,向量被改装以符合新的接口,解决了第一个问题。

练习 25:从 CSV 文件中读取用户到向量

由于向量解决了根据需要增长和缩小的问题,在这个练习中,我们将重写以前的练习,但是不再处理数组的大小,而是委托给一个向量。我们还将开始构建一个UsersLoader类,在所有未来的练习中都会使用:

  1. 创建一个名为UsersLoader.java的文件,并在其中添加一个同名的类:
public class UsersLoader {
}
  1. 您将使用这个类来添加共享方法,以便在未来的课程中从 CSV 文件中加载用户。您将首先编写的方法将从 CSV 中加载用户到向量中。添加一个公共静态方法,返回一个向量。在这个方法中,实例化Vector并在最后返回它:
private static Vector loadUsersInVector(String pathToFile)
    throws IOException {
  Vector users = new Vector();
  return users;
}
  1. 在创建Vector并返回它之间,从 CSV 中加载数据并将其添加到Vector中:
BufferedReader lineReader = new BufferedReader(new FileReader(pathToFile));
try (CSVReader reader = new CSVReader(lineReader)) {
  String [] row = null;
  while ( (row = reader.readRow()) != null) {
    users.add(User.fromValues(row));
  }
}
  1. 添加编译此文件所需的导入项:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Vector;
  1. 创建一个名为ReadUsersIntoVector.java的文件,并在其中添加一个同名的类和一个main方法:
public class ReadUsersIntoVector {
  public static void main (String [] args) throws IOException {
  }
}
  1. main方法中,类似于我们在数组情况下所做的,调用从 CSV 加载用户到Vector的方法,然后打印Vector的大小。在这种情况下,使用我们在上一步中创建的loadUsersInVector()方法:
Vector users = UserLoader.loadUsersInVector(args[0]);
System.out.println(users.size());
  1. 将此文件的导入添加到编译:
import java.io.IOException;
import java.util.Vector;

输出如下:

27

恭喜您完成了又一个练习!这一次,您可以看到您的代码要简单得多,因为大部分加载 CSV、将其拆分为值、创建用户和调整数组大小的逻辑现在都被抽象化了。

活动 28:使用 Vector 读取真实数据集

在此活动中,您将下载一个包含来自美国人口普查的收入信息的 CSV,并对文件中的值进行一些计算。

要开始,请转到此页面:github.com/TrainingByPackt/Java-Fundamentals/tree/master/Lesson07/data。要下载 CSV,您可以单击Adult_Data。它将在浏览器中打开数据文件。下载文件并将其保存到计算机中的某个位置。扩展名无关紧要,但您需要记住文件名和路径。

您可以在网站上阅读有关数据格式的更多信息,或者只需将其作为文本文件打开。在处理此文件时要记住两件事:

  • 文件末尾有一个额外的空行

  • 此文件没有标题行

创建一个应用程序,将计算此文件中的最低工资、最高工资和平均工资。在读取所有行之后,您的应用程序应打印这些结果。为了实现这一点,您需要:

  1. 使用您的CSVReader将文件中的所有工资加载到整数向量中。您可以修改您的CSVReader以支持没有标题的文件。

  2. 迭代向量中的值,并跟踪三个值:最小值、最大值和总和。

  3. 在最后打印结果。请记住,平均值只是向量的总和除以大小。

注意

此活动的解决方案可以在第 347 页找到。

遍历集合

在处理数组时,您有两种迭代的方式:您可以使用带有索引的for循环:

for (int i = 0; i < values.length; i++) {
  System.out.printf("%d - %s\n", i, values[i]);
}

您还可以使用for-each循环进行迭代,其中您无法访问元素的索引:

for (String value : values) {
  System.out.println(value);
}

当您需要迭代向量时,您可以使用带有索引的循环,就像数组一样:

for (int i = 0; i < values.size(); i++) {
  String value = (String) values.get(i);
  System.out.printf("%d - %s\n", i, value);
}

您还可以在for-each循环中使用Vector,就像数组一样:

for (Object value : values) {
  System.out.println(value);
}

这是因为Vector实现了Iterable。 Iterable 是一个简单的接口,告诉编译器该实例可以在for-each循环中使用。实际上,您可以将您的CSVReader更改为实现 Iterable,然后在for-each循环中使用它,就像以下代码中一样:

try (IterableCSVReader csvReader = new IterableCSVReader(reader)) {
  for (Object rowAsObject : csvReader) {
    User user = User.fromValues((String[]) rowAsObject);
    System.out.println(user.name);
  }
}

Iterable 是一个非常简单的接口;它只有一个方法需要实现:iterator()。该方法返回一个迭代器。迭代器是另一个简单的接口,只有两个方法需要实现:

  • hasNext(): 如果迭代器仍有要返回的元素,则返回true

  • next(): 获取下一个记录并返回它。如果在调用此方法之前hasNext()返回false,它将抛出异常。

迭代器表示从集合中获取事物的一种简单方法。但它还有另一个在一些更高级的上下文中很重要的方法,remove(),它会删除刚刚从next()调用中获取的当前元素。

这个remove方法很重要,因为当您在集合上进行迭代时,您不能修改它。这意味着如果您编写一个for-each循环来从向量中读取元素,然后在此循环中调用remove(Object)来从中删除一个元素,将会抛出ConcurrentModificationException。因此,如果您想使用循环迭代集合,并且在此循环中需要从向量中删除一个元素,您将需要使用迭代器。

你一定在想,“为什么它要设计成这样?”因为 Java 是一种多线程语言。你不会在这本书中学习如何创建线程或使用它们,因为这是一个高级主题。但多线程的背后思想是,内存中的一块数据可以被两段代码同时访问。这是可能的,因为现代计算机具有多核能力。在处理多线程应用程序时,使用集合和数组时必须非常小心。以下是说明它发生的过程:

图 7.4:ConcurrentModificationException 发生的说明

图 7.4:ConcurrentModificationException 发生的说明

ConcurrentModificationException比我们预期的更常见。以下是使用迭代器的示例 for 循环,避免了这个问题:

for (Iterator it = values.iterator(); it.hasNext();) {
  String value = (String) it.next();
  if (value.equals("Value B")) {
    it.remove();
  }
}

活动 29:在用户向量上进行迭代

现在你有了一个从 CSV 文件中加载所有用户的方法,并且知道如何在向量上进行迭代,编写一个应用程序,打印文件中所有用户的姓名和电子邮件。要完成这个活动,你需要按照以下步骤进行:

  1. 创建一个新的 Java 应用程序,从一个向量中加载来自 CSV 文件的数据。文件将从命令行指定。

  2. 遍历向量中的用户,并打印一个字符串,其中包含他们的姓名和电子邮件的连接。

注意

这个活动的解决方案可以在第 349 页找到。

哈希表

当处理需要按顺序处理的许多对象时,数组和向量非常有用。但是当你有一组需要通过键(例如某种标识)进行索引的对象时,它们就变得笨重了。

引入了哈希表。它们是一个非常古老的数据结构,是为了解决这个问题而创建的:快速识别给定值并在数组中找到它。为了解决这个问题,哈希表使用哈希函数来唯一标识对象。从哈希中,它们可以使用另一个函数(通常是除法的余数)将值存储在数组中。这使得将元素添加到表中的过程是确定性的,并且获取它非常快。以下是说明值如何存储在哈希表中的过程:

图 7.5:哈希表存储和提取值的过程

图 7.5:哈希表存储和提取值的过程

哈希表使用数组来内部存储一个条目,代表一个键值对。当你将一对放入哈希表时,你提供键和值。键用于找到条目将被存储在数组中的位置。然后,创建并存储一个持有键和值的条目在指定的位置。

要获取值,你传入从中计算哈希的键,然后可以快速在数组中找到条目。

从这个过程中,你免费获得的一个有趣的特性是去重。因为使用相同的键添加值将生成相同的哈希,当你这样做时,它将覆盖之前存储在那里的任何内容。

就像向量一样,Hashtable类是在 Java 的集合框架之前添加的。它遭受了向量遭受的两个问题:缺乏定义的接口和完全同步。它还违反了 Java 的命名约定,没有遵循驼峰命名法来分隔单词。

与向量一样,在引入集合框架后,哈希表也经过了改造,以符合新的接口,使它们成为框架的无缝部分。

练习 26:编写一个通过电子邮件查找用户的应用程序

在这个练习中,你将编写一个应用程序,从指定的 CSV 文件中读取用户到哈希表中,使用他们的电子邮件作为键。然后从命令行接收一个电子邮件地址,并在哈希表中搜索它,打印它的信息或者友好的消息,如果找不到的话:

  1. 在您的UsersLoader.java文件中,添加一个新方法,该方法将使用电子邮件将用户加载到 Hashtable 中。在开始时创建一个Hashtable,并在结束时返回它:
public static Hashtable loadUsersInHashtableByEmail(String pathToFile) 
    throws IOException {
  Hashtable users = new Hashtable();
  return users;
}
  1. 在创建Hashtable并返回它之间,使用email作为键从 CSV 中加载用户并将它们放入Hashtable中:
BufferedReader lineReader = new BufferedReader(new FileReader(pathToFile));
try (CSVReader reader = new CSVReader(lineReader)) {
  String [] row = null;
  while ( (row = reader.readRow()) != null) {
    User user = User.fromValues(row);
    users.put(user.email, user);
  }
}
  1. 导入Hashtable以便文件正确编译:
import java.util.Hashtable;
  1. 创建一个名为FindUserHashtable.java的文件,并添加一个同名的类,并添加一个main方法:
public class FindUserHashtable {
  public static void main(String [] args) throws IOException {
  }
}
  1. 在您的main方法中,使用我们在之前步骤中创建的方法将用户加载到Hashtable中,并打印找到的用户数量:
Hashtable users = UsersLoader.loadUsersInHashtableByEmail(args[0]);
System.out.printf("Loaded %d unique users.\n", users.size());
  1. 打印一些文本,通知用户您正在等待他们输入电子邮件地址:
System.out.print("Type a user email: ");
  1. 通过使用Scanner从用户那里读取输入:
try (Scanner userInput = new Scanner(System.in)) {
  String email = userInput.nextLine();
  1. 检查Hashtable中是否存在电子邮件地址。如果没有,打印友好的消息并退出应用程序:
if (!users.containsKey(email)) {
  // User email not in file
  System.out.printf("Sorry, user with email %s not found.\n", email);
  return;
}
  1. 如果找到,打印有关找到的用户的一些信息:
User user = (User) users.get(email);
System.out.printf("User with email '%s' found!", email);
System.out.printf(" ID: %d, Name: %s", user.id, user.name);
  1. 添加必要的导入:
import java.io.IOException;
import java.util.Hashtable;
import java.util.Scanner;

这是第一种情况的输出:

Loaded 5 unique users.
Type a user email: william.gates@microsoft.com
User with email 'william.gates@microsoft.com' found! ID: 10, Name: Bill Gates

这是第二种情况的输出:

Loaded 5 unique users.
Type a user email: randomstring
Sorry, user with email randomstring not found.

恭喜!在这个练习中,您使用了Hashtable来快速通过电子邮件地址找到用户。

活动 30:使用 Hashtable 对数据进行分组

Hashtable 的一个非常常见的用法是根据某个键对记录进行分组。在这个活动中,您将使用它来计算上一个活动中下载的文件的最低、最高和平均工资。

如果还没有,请转到此页面:github.com/TrainingByPackt/Java-Fundamentals/tree/master/Lesson07/data。要下载 CSV,可以单击Adult_Data。如前所述,此文件包含来自美国人口普查的收入数据。

有许多属性与每个工资相关联。在这个练习中,您将根据教育属性对记录进行分组。然后,像之前一样,打印最低、最高和平均工资,但现在是对每组工资进行的。

要完成此活动,您需要:

  1. 使用CSVReader加载adult.data CSV 文件。这次,您将数据加载到一个 Hashtable 中,其中键是字符串,值是整数的向量。键将是教育属性,并且在向量中,您将存储与该教育相关的所有工资。

  2. 现在,将所有工资分组在 Hashtable 中,现在可以遍历条目、键值对,并执行与上一个活动中相同的计算。

  3. 对于每个条目,打印文件中找到的每个教育水平的最低、最高和平均工资。

注意

此活动的解决方案可以在第 351 页找到。

泛型

与 Vector 等以通用方式与其他类一起工作的类一样,没有明确告诉编译器只接受一种类型的方法。因此,它在任何地方都使用 Object,并且需要在任何地方进行instanceof和转换等运行时检查。

为了解决这个问题,Java 5 中引入了泛型。在本节中,您将更好地了解问题、解决方案以及如何使用它。

问题是什么?

在声明数组时,您告诉编译器数组中包含的数据类型。如果尝试在其中添加其他内容,它将无法编译。看看以下代码:

// This compiles and work
User[] usersArray = new User[1];
usersArray[0] = user;
// This wouldn't compile
// usersArray[0] = "Not a user";
/* If you uncomment the last line and try to compile, you would get the following error: */
File.java:15: error: incompatible types: String cannot be converted to User
        usersArray[0] = "Not a user";
                        ^

假设您尝试使用Vector做类似的事情,如下所示:

Vector usersVector = new Vector();
usersVector.add(user); // This compiles
usersVector.add("Not a user"); // This also compiles

编译器将一点帮助也没有。Hashtable也是如此:

Hashtable usersTable = new Hashtable();
usersTable.put(user.id, user); // This compiles
usersTable.put("Not a number", "Not a user"); // This also compiles

这也发生在获取数据时。当从数组中获取数据时,编译器知道其中包含的数据类型,因此您不需要对其进行转换:

User userFromArray = usersArray[0];

要从集合中获取数据,您需要对数据进行转换。一个简单的例子是在向先前的usersVector添加两个元素后添加以下代码:

User userFromVector = (User) usersVector.get(1);

它将编译,但会在运行时抛出ClassCastException

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to User

这在 Java 世界中很长一段时间是一个很大的错误源。然后泛型出现了,改变了一切。

泛型是一种告诉编译器泛型类只能与指定类型一起使用的方法。让我们看看这意味着什么:

  • 泛型类:泛型类是一个具有泛型功能的类,可以与不同类型一起使用,比如 Vector,可以存储任何类型的对象。

  • 指定类型:使用泛型时,当你实例化一个泛型类时,你要指定该泛型类将与何种类型一起使用。例如,你可以指定你只想在你的 Vector 中存储用户。

  • 编译器:需要强调的是,泛型是一个仅在编译时存在的特性。在运行时,关于泛型类型定义的信息是不存在的。在运行时,一切都像在泛型之前一样运行。

泛型类有一个特殊的声明,公开了它需要多少种类型。一些泛型类需要多种类型,但大多数只需要一种。在泛型类的 Javadoc 中,有一个特殊的尖括号参数列表,指定了它需要多少个类型参数,比如<T, R>。以下是java.util.Map的 Javadoc 截图,它是集合框架中的一个接口之一:

图 7.6:java.util.Map 的 Javadoc 截图,显示了泛型类型声明

图 7.6:java.util.Map 的 Javadoc 截图,显示了泛型类型声明

如何使用泛型

使用泛型时,在声明泛型类的实例时,你要使用尖括号指定该实例将使用的类型。以下是如何声明一个只处理用户的向量:

Vector<User> usersVector = new Vector<>();

对于哈希表,你需要指定键和值的类型。对于一个将用户及其 ID 存储为键的哈希表,声明将如下所示:

Hashtable<Integer, User> usersTable = new Hashtable<>();

只需使用正确的参数声明泛型类型,就可以解决我们之前描述的问题。例如,假设你正在声明一个只处理用户的向量。你会尝试将一个字符串添加到其中,如下面的代码所示:

usersVector.add("Not a user");

然而,这将导致编译错误:

File.java:23: error: no suitable method found for add(String)
        usersVector.add("Not a user");
                   ^

现在编译器确保只有用户会被添加到向量中,你可以从中获取数据而无需进行类型转换。编译器会自动为你转换类型:

// No casting needed anymore
User userFromVector = usersVector.get(0);

练习 27:通过姓名或电子邮件中的文本查找用户

在这个练习中,你将编写一个应用程序,从 CSV 文件中读取用户到一个向量中,就像之前一样。然后,你将被要求输入一个字符串,该字符串将用于过滤用户。应用程序将打印出所有包含传入字符串的姓名或电子邮件的用户的一些信息:

  1. 打开你的UsersLoader.java文件,并将所有的方法设置为使用集合的泛型版本。你的loadUsersInHashtableByEmail应该如下所示(只显示了已更改的行):
public static Hashtable<String, User> loadUsersInHashtableByEmail(String pathToFile)   
    throws IOException {
  Hashtable<String, User> users = new Hashtable<>();
  // Unchanged lines
}

你的loadUsersInVector应该如下所示(只显示了已更改的行):

public static Vector<User> loadUsersInVector(String pathToFile) throws IOException{
  Vector<User> users = new Vector<>();
  // Unchanged lines
}

注意:

你不必更改其他调用这些方法的地方,因为使用它们作为非泛型版本仍然有效。

  1. 创建一个名为FindByStringWithGenerics.java的文件,并添加一个同名的类和一个main方法,如下所示:
public class FindByStringWithGenerics {
  public static void main (String [] args) throws IOException {
  }
}
  1. 在你的main方法中添加一个对loadUsersInVector方法的调用,使用指定泛型类型的向量存储值。打印加载的用户数量:
Vector<User> users = UsersLoader.loadUsersInVector(args[0]);
System.out.printf("Loaded %d users.\n", users.size());
  1. 之后,要求用户输入一个字符串,并将其存储在一个变量中,转换为小写后存储:
System.out.print("Type a string to search for: ");
// Read user input from command line
try (Scanner userInput = new Scanner(System.in)) {
  String toFind = userInput.nextLine().toLowerCase();
}
  1. 在 try-with-resource 块内,创建一个变量来计算找到的用户数量。然后,遍历我们之前加载的向量中的用户,并为每个用户在电子邮件和姓名中搜索字符串,确保将所有字符串转换为小写:
int totalFound = 0;
for (User user : users) {
  if (user.email.toLowerCase().contains(toFind)
        ||user.name.toLowerCase().contains(toFind)) {
    System.out.printf("Found user: %s",user.name);
    System.out.printf(" Email: %s\n", user.email);
    totalFound++;
  }
}
  1. 最后,如果totalFound为零,表示没有找到用户,则打印友好的消息。否则,打印你找到的用户数量:
if (totalFound == 0) {
  System.out.printf("No user found with string '%s'\n", toFind);
} else {
  System.out.printf("Found %d users with '%s'\n", totalFound, toFind);
}

以下是第一个案例的输出:

Loaded 27 users.
Type a string to search for: will
Found user: Bill Gates Email: william.gates@microsoft.com
Found user: Bill Gates Email: william.gates@microsoft.com
Found user: Bill Gates Email: william.gates@microsoft.com
Found user: Bill Gates Email: william.gates@microsoft.com
Found user: Bill Gates Email: william.gates@microsoft.com
Found 5 users with 'will'

以下是第二个案例的输出:

Loaded 27 users.
Type a string to search for: randomstring
No user found with string 'randomstring'

恭喜!现在你明白了泛型如何帮助你编写安全且易于使用的代码来处理你的集合。

排序和比较

在日常生活中,我们经常比较事物:冷/热,短/高,薄/厚,大/小。对象可以使用不同的标准进行比较。你可以按颜色、大小、重量、体积、高度、宽度等进行比较。在比较两个对象时,通常你想找出哪一个在某个标准上更多(或更少)或者它们在你使用的任何度量上是否相等。

有两种基本情况下比较对象很重要:找到最大值(或最小值)和排序。

在找到最大值或最小值时,你将所有对象相互比较,然后根据你所关注的标准选择获胜者。其他一切都可以忽略。你不需要跟踪其他对象,只要确保你不会无限次地重复比较同样的两个对象。

另一方面,排序更加复杂。你需要跟踪到目前为止已经比较过的所有元素,并确保在比较过程中保持它们排序。

集合框架包括一些接口、类和算法,可以帮助你处理所有这些。

可比较和比较器

在 Java 中,有一个描述对象如何相互比较的接口。java.lang.Comparable接口是一个泛型接口,只有一个需要实现的方法:compareTo(T)。根据 Javadocs,compareTo应该返回"负整数、零或正整数,表示此对象小于、等于或大于指定对象"。

为了理解它是如何工作的,让我们以一个字符串为例。字符串实现了java.lang.Comparable<String>,这意味着你可以比较两个字符串,如下所示:

"A".compareTo("B") < 0 // -> true
"B".compareTo("A") > 0 // -> true

如果比较中第一个对象"小于"第二个,则它将返回一个负数(可以是任何数字,大小无关紧要)。如果两者相同,则返回零。如果第一个大于第二个,则返回一个正数(同样,大小无关紧要)。

这一切都很好,直到你遇到以下情况:

"a".compareTo("B") < 0 // -> false

当你查看 String 的 Javadoc 时,它的compareTo方法说它"按字典顺序比较两个字符串"。这意味着它使用字符代码来检查哪个字符串排在前面。不同之处在于字符代码首先包括所有大写字母,然后是所有小写字母。因此,"A"在"B"之后,因为 B 的字符代码在 A 之前。

但是,如果我们想按字母顺序而不是按词典顺序比较字符串怎么办?如前所述,对象可以在许多不同的标准下进行比较。因此,Java 提供了另一个接口,可以用于比较两个对象:java.util.Comparator。类可以实现一个比较器,使用最常见的用例,比如数字可以使用它们的自然顺序进行比较。然后,我们可以创建另一个实现Comparator的类,使用一些其他自定义算法来比较对象。

练习 28:创建一个按字母顺序比较字符串的比较器

在这个练习中,你将创建一个实现java.util.Comparator<String>的类,用于按字母顺序比较字符串,而不是按词典顺序:

  1. 创建一个名为AlphabeticComparator.java的文件,并添加一个同名的类,该类实现java.util.Comparator<String>(不要忘记导入):
import java.util.Comparator;
public class AlphabeticComparator implements Comparator<String> {
  public int compare(String first, String second) {
  }
}
  1. compareTo方法中,你只需将两个字符串转换为小写,然后进行比较:
return first.toLowerCase().compareTo(second.toLowerCase());
  1. 创建一个名为UseAlphabeticComparator.java的新文件,并添加一个同名的类,其中包含一个main方法,以便你可以测试你的新比较器:
public class UseAlphabeticComparator {
  public static void main (String [] args) {
  }
}
  1. 现在实例化你的类,并编写一些测试用例,以确保你的类按预期工作:
AlphabeticComparator comparator = new AlphabeticComparator();
System.out.println(comparator.compare("A", "B") < 0); // -> true
System.out.println(comparator.compare("B", "A") > 0); // -> true
System.out.println(comparator.compare("a", "B") < 0); // -> true
System.out.println(comparator.compare("b", "A") > 0); // -> true
System.out.println(comparator.compare("a", "b") < 0); // -> true
System.out.println(comparator.compare("b", "a") > 0); // -> true

输出如下:

true
true
true
true
true
true

恭喜!你写了你的第一个比较器。现在,让我们继续看看你可以用 Comparables 和 Comparators 做些什么。

排序

当你有对象的集合时,很常见希望以某种方式对它们进行排序。能够比较两个对象是所有排序算法的基础。现在你知道如何比较对象了,是时候利用它来为你的应用程序添加排序逻辑了。

有许多排序算法,每种算法都有其自身的优势和劣势。为简单起见,我们只讨论两种:冒泡排序,因为它简单;归并排序,因为它的稳定性表现良好,这也是 Java 核心实现者选择它的原因。

冒泡排序

最天真的排序算法是冒泡排序,但它也是最简单的,易于理解和实现。它通过迭代每个元素并将其与下一个元素进行比较来工作。如果找到两个未排序的元素,它会交换它们并继续下一个。当它到达数组的末尾时,它会检查有多少元素被交换。它会继续这个循环,直到一个循环中交换的元素数为零,这意味着整个数组或集合已经排序完成。

以下是使用冒泡排序对包含七个元素的数组进行排序的示例:

图 7.7:展示冒泡排序工作原理的示例

图 7.7:展示冒泡排序工作原理的示例

冒泡排序非常节省空间,因为它不需要任何额外的数组或存储变量的地方。然而,它使用了大量的迭代和比较。在示例中,总共有 30 次比较和 12 次交换。

归并排序

冒泡排序虽然有效,但你可能已经注意到,它真的很天真,感觉浪费了很多循环。另一方面,归并排序更有效,基于分而治之的策略。它通过递归地将数组/集合一分为二,直到最终得到多个一元素对。然后,在排序的同时将它们合并在一起。你可以在下面的示例中看到它是如何工作的:

图 7.8:归并排序算法的示例

图 7.8:归并排序算法的示例

与冒泡排序相比,归并排序的比较次数要小得多-仅为示例中的 13 次。它使用更多的内存空间,因为每个合并步骤都需要额外的数组来存储正在合并的数据。

在前面的示例中没有明确表达的一点是,归并排序具有稳定的性能,因为它总是执行相同数量的步骤;无论数据是多么混乱或排序。与冒泡排序相比,如果遇到数组/集合是反向排序的情况,交换的次数可能会非常高。

稳定性对于诸如 Collections Framework 之类的核心库非常重要,这就是为什么归并排序被选为java.util.Collections实用类中排序的实现算法的原因。

活动 31:对用户进行排序

编写三个用户比较器:一个按 ID 比较,一个按名称比较,一个按电子邮件比较。然后,编写一个应用程序,加载唯一用户并按从命令行输入中选择的字段对用户进行排序。要完成此活动,你需要按照以下步骤进行:

  1. 编写三个实现java.util.Comparator<User>的类。一个按 ID 比较,一个按名称比较,一个按电子邮件比较。

  2. 使用返回Hashtable实例的方法从 CSV 中加载用户,这样你就有了一个包含唯一用户的集合。

  3. Hashtable中的值加载到向量中,以便按指定顺序保留它们。

  4. 从命令行读取输入以决定使用哪个字段进行排序。

  5. 使用正确的比较器来使用java.util.Collections的 sort 方法对向量进行排序。

  6. 打印用户。

注意

这个活动的解决方案可以在第 354 页找到。

数据结构

构建应用程序最基本的部分是处理数据。存储数据的方式受到读取和处理数据的影响。数据结构定义了存储数据的方式。不同的数据结构针对不同的用例进行了优化。到目前为止,我们已经提到了两种访问数据的方式:

  • 顺序地,就像数组或向量一样

  • 键值对,就像哈希表一样

注意

在接下来的几节中,我们将讨论已添加到集合框架中的基本数据结构接口,以及它们与其他接口的区别。我们还将深入研究每个实现以及它们解决的用例。

集合

这是最通用的接口,是除 Map 之外所有集合的基础。文档描述它表示一个称为元素的对象的集合。它声明了所有集合的基本接口,具有以下最重要的方法:

  • add(Element): 将元素添加到集合中

  • clear(): 从集合中删除所有元素

  • contains(Object): 检查对象是否在集合中

  • remove(Object): 从集合中删除指定的元素(如果存在)

  • size(): 返回集合中存储的元素数量

列表

列表接口表示一个可以无限增长的元素的顺序集合。列表中的元素可以通过它们的索引访问,这是它们被放置的位置,但如果在其他元素之间添加元素,索引可能会改变。

当遍历列表时,元素将以确定性的顺序获取,并且始终基于它们的索引顺序,就像数组一样。

正如我们之前提到的,Vector 被改装以支持集合框架,并实现了列表接口。让我们看看其他可用的实现。

List扩展了Collection,因此它继承了我们之前提到的所有方法,并添加了一些其他重要的方法,主要与基于位置的访问相关:

  • add(int, Element): 在指定位置添加一个元素

  • get(int): 返回指定位置的元素

  • indexOf(Object): 返回对象的索引,如果不在集合中则返回-1

  • set(int, Element): 替换指定位置的元素

  • subList(int, int): 从原始列表创建一个子列表

ArrayList

就像 Vector 一样,ArrayList 包装了一个数组,并在需要时对其进行扩展,表现得就像一个动态数组。两者之间的主要区别在于向量是完全同步的。这意味着它们保护您免受并发访问(多线程应用程序)的影响。这也意味着在非并发应用程序中,这在大多数情况下发生,向量由于添加到其中的锁定机制而变慢。因此,建议您使用 ArrayList,除非您真的需要一个同步列表。

正如我们之前提到的,就所有目的而言,ArrayList 和 Vector 可以互换使用。它们的功能是相同的,都实现了相同的接口。

LinkedList

LinkedList 是 List 的一种实现,它不像 ArrayList 或 Vector 那样在底层数组中存储元素。它将每个值包装在另一个称为节点的对象中。节点是一个包含对其他节点的两个引用(下一个节点和上一个节点)以及存储该元素的值的内部类。这种类型的列表被称为双向链表,因为每个节点都链接两次,一次在每个方向上:从前一个到下一个,从下一个到前一个。

在内部,LinkedList 存储对第一个和最后一个节点的引用,因此它只能从开始或结束处遍历列表。与数组、ArrayList 和向量一样,它不适用于随机或基于位置的访问,但在非常快速地添加不确定数量的元素时非常适用。

LinkedList 还存储一个变量,用于跟踪列表的大小。这样,它就不必每次都遍历列表来检查大小。

以下插图显示了 LinkedList 的实现方式:

图 7.9:LinkedList 在内部是如何工作的。

图 7.9:LinkedList 在内部是如何工作的

地图

当您需要存储与键关联的元素时,可以使用地图。正如我们之前所看到的,Hashtable 是一种通过某个键对对象进行索引的强大机制,并且在添加了集合框架之后,Hashtable 被改装为实现 Map。

地图的最基本属性是它们不能包含重复的键。

地图之所以强大,是因为它们允许您从三个不同的角度查看数据集:键、值和键值对。将元素添加到地图后,您可以从这三个角度中的任何一个迭代它们,从而在从中提取数据时提供额外的灵活性。

Map接口中最重要的方法如下:

  • clear(): 从地图中删除所有键和值

  • containsKey(Object): 检查地图中是否存在该键

  • containsValue(Object): 检查地图中是否存在该值

  • entrySet(): 返回地图中所有键值对的集合

  • get(Object): 如果存在,返回与指定键关联的值

  • getOrDefault(Object, Value): 如果存在,返回与指定键关联的值,否则返回指定的值

  • keySet(): 包含地图中所有键的集合

  • put(Key, Value): 添加或替换键值对

  • putIfAbsent(Key, Value): 与上一个方法相同,但如果键已经存在,则不会替换

  • size(): 此地图中键值对的数量

  • values(): 返回此地图中所有值的集合

HashMap

就像Hashtable一样,HashMap实现了哈希表来存储键值对的条目,并且工作方式完全相同。正如 Vector 是 ArraySet 一样,Hashtable 是HashMap一样。Hashtable存在于 Map 接口之前,因此 HashMap 被创建为哈希表的非同步实现。

正如我们之前提到的,哈希表,因此 HashMap,非常快速地通过键找到元素。它们非常适合用作内存缓存,您可以在其中加载已由某个字段键入的数据,就像在练习 26中所做的那样:编写一个按电子邮件查找用户的应用程序

TreeMap

TreeMap是可以按键或指定比较器对键值对进行排序的 Map 的实现。

正如其名称所示,TreeMap 使用树作为底层存储机制。树是非常特殊的数据结构,用于在插入发生时保持数据排序,并且同时使用非常少的迭代获取数据。以下插图显示了树的外观以及如何快速找到元素的获取操作,即使在非常大的树中也是如此:

图 7.10:正在遍历树数据结构以获取元素

图 7.10:正在遍历树数据结构以获取元素

树具有代表分支的节点。一切都始于根节点,并扩展为多个分支。在叶节点的末端,有没有子节点的节点。TreeMap 实现了一种称为红黑树的特定类型的树,这是一种二叉树,因此每个节点只能有两个子节点。

LinkedHashMap

LinkedHashMap类的名称有点神秘,因为它在内部使用了两种数据结构来支持一些 HashMap 不支持的用例:哈希表和链表。哈希表用于快速向地图中添加和获取元素。链表用于通过任何方式迭代条目:键、值或键值对。这使得它能够以确定的顺序迭代条目,这取决于它们被插入的顺序。

Set

集合的主要特征是它们不包含重复元素。当您想要收集元素并同时消除重复值时,集合非常有用。

关于集合的另一个重要特征是,根据实现的不同,从集合中获取元素的顺序也会有所不同。这意味着如果您想要消除重复项,您必须考虑之后如何读取它们。

集合框架中的所有集合实现都基于它们对应的 Map 实现。唯一的区别是它们将集合中的值处理为映射中的键。

HashSet

迄今为止,所有集合中最常见的 HashSet 使用 HashMap 作为底层存储机制。它根据 HashMap 中使用的哈希函数存储其元素的随机顺序。

TreeSet

由 TreeMap 支持,TreeSet在想要按其自然顺序(可比较的)或使用比较器对其进行排序的唯一元素时非常有用。

LinkedHashSet

LinkedHashMap支持,LinkedHashSet将保持插入顺序并在添加到集合时删除重复项。它具有与 LinkedHashSet 相同的优点:像 HashSet 一样快速插入和获取,像 LinkedList 一样快速迭代。

练习 29:使用 TreeSet 打印排序后的用户

Activity 31Sorting Users中,您编写了三个可用于对用户进行排序的比较器。让我们使用它们和 TreeSet 来制作一个以更高效的方式打印排序后用户的应用程序:

  1. 向您的UsersLoader类添加一个可以将用户加载到Set中的方法:
public static void loadUsersIntoSet(String pathToFile, Set<User> usersSet)
    throws IOException {
  FileReader fileReader = new FileReader(pathToFile);
  BufferedReader lineReader = new BufferedReader(fileReader);
  try(CSVReader reader = new CSVReader(lineReader)) {
    String [] row = null;
    while ( (row = reader.readRow()) != null) {
      usersSet.add(User.fromValues(row));
    }
  }
}
  1. 导入Set如下:
java.util.Set;
  1. 创建一个名为SortUsersTreeSet.java的新文件,并添加一个同名的类并添加一个main方法:
public class SortUsersTreeSet {
  public static void main (String [] args) throws IOException {
  }
}
  1. 从命令行读取我们将按哪个字段进行排序:
Scanner reader = new Scanner(System.in);
System.out.print("Type a field to sort by: ");
String input = reader.nextLine();
Comparator<User> comparator;
switch(input) {
  case "id":
    comparator = new ByIdComparator();
    break;
  case "name":
    comparator = new ByNameComparator();
    break;
  case "email":
    comparator = new ByEmailComparator();
    break;
  default:
    System.out.printf("Sorry, invalid option: %s\n", input);
    return;
}
System.out.printf("Sorting by %s\n", input);
  1. 使用指定的比较器创建一个用户的TreeSet,使用您的新方法将用户加载到其中,然后将加载的用户打印到命令行:
TreeSet<User> users = new TreeSet<>(comparator);
UsersLoader.loadUsersIntoSet(args[0], users);
for (User user : users) {
  System.out.printf("%d - %s, %s\n", user.id, user.name, user.email);
}

以下是第一种情况的输出:

Type a field to sort by: address
Sorry, invalid option: address

以下是第二种情况的输出

Type a field to sort by: email
Sorting by email
30 - Jeff Bezos, jeff.bezos@amazon.com
50 - Larry Ellison, lawrence.ellison@oracle.com
20 - Marc Benioff, marc.benioff@salesforce.com
40 - Sundar Pichai, sundar.pichai@google.com
10 - Bill Gates, william.gates@microsoft.com

以下是第三种情况的输出

Type a field to sort by: id
Sorting by id
10 - Bill Gates, william.gates@microsoft.com
20 - Marc Benioff, marc.benioff@salesforce.com
30 - Jeff Bezos, jeff.bezos@amazon.com
40 - Sundar Pichai, sundar.pichai@google.com
50 - Larry Ellison, lawrence.ellison@oracle.com

以下是第四种情况的输出

Type a field to sort by: name
Sorting by name
10 - Bill Gates, william.gates@microsoft.com
30 - Jeff Bezos, jeff.bezos@amazon.com
50 - Larry Ellison, lawrence.ellison@oracle.com
20 - Marc Benioff, marc.benioff@salesforce.com
40 - Sundar Pichai, sundar.pichai@google.com

恭喜!在这个练习中,您使用 TreeSet 对从 CSV 文件加载的元素进行排序和去重,同时完成了这些操作。

Queue

队列是一种特殊的数据结构,遵循先进先出(FIFO)模式。这意味着它按插入顺序保留元素,并且可以从第一个插入的元素开始返回元素,同时将元素添加到末尾。这样,新的工作可以排队在队列的末尾,而要处理的工作可以从前面出列。以下是此过程的示例:

图 7.11:存储要处理的工作的队列

图 7.11:存储要处理的工作的队列

在集合框架中,队列由java.util.Queue接口表示。要将元素入队,可以使用add(E)offer(E)。第一个如果队列已满将抛出异常,而第二个则只会返回truefalse,告诉您操作是否成功。它还有出队元素或只检查队列前面的元素的方法。remove()将返回并移除队列前面的元素,如果队列为空则抛出异常。poll()将返回并移除元素,如果队列为空则返回 null。element()peek()的工作方式相同,但只返回元素而不从队列中移除,第一个抛出异常,后者如果队列为空则返回 null。

java.util.Deque是一个接口,它扩展了java.util.Queue,具有额外的方法,允许在队列的两侧添加、移除或查看元素。

java.util.LinkedListjava.util.Queuejava.util.Deque的实现,也实现了java.util.List

java.util.ArrayDeque

队列和双端队列的实现使用数组作为底层数据存储。数组会自动增长以支持添加到其中的数据。

java.util.PriorityQueue

队列的实现使用堆来保持元素的排序顺序。如果元素实现了java.lang.Comparable,则可以由元素来确定顺序,或者可以通过传入的比较器来确定顺序。堆是一种特殊类型的树,它可以保持元素排序,类似于TreeMap。这种队列的实现非常适合需要按一定优先级处理的元素。

练习 30:虚假电子邮件发送器

在这个练习中,您将模拟使用一个处理器向用户发送电子邮件的过程。为此,您将编写两个应用程序:一个模拟发送电子邮件,另一个从 CSV 中读取并为每个用户调用第一个。强制您使用队列的约束是一次只能运行一个进程。这意味着当用户从 CSV 中加载时,您将对其进行排队,并在可能的情况下发送电子邮件:

  1. 创建一个名为EmailSender.java的文件,其中包含一个类和一个main方法。为了模拟发送电子邮件,该类将休眠随机的一段时间,最多一秒:
System.out.printf("Sending email to %s...\n", args[0]);
Thread.sleep(new Random().nextInt(1000));
System.out.printf("Email sent to %s!\n", args[0]);
  1. 创建另一个名为SendAllEmails.java的文件,其中包含一个类和一个main方法。
public class SendAllEmails {
  1. 添加一个名为runningProcessstatic字段。这将代表正在运行的发送电子邮件过程:
private static Process runningProcess = null;
  1. 创建一个static方法,该方法将尝试通过从队列中出队一个元素来启动发送电子邮件的过程,如果该过程可用:
private static void sendEmailWhenReady(ArrayDeque<String> queue)
    throws Exception {
  // If running, return
  if (runningProcess != null && runningProcess.isAlive()) {
    System.out.print(".");
    return;
  }
  System.out.print("\nSending email");
  String email = queue.poll();
  String classpath = System.getProperty("java.class.path");
  String[] command = new String[]{
    "java", "-cp", classpath, "EmailSender", email
  };
  runningProcess = Runtime.getRuntime().exec(command);
}
  1. main方法中,创建一个字符串的ArrayDeque,表示要发送的电子邮件队列:
ArrayDeque<String> queue = new ArrayDeque<>();
  1. 打开 CSV 文件以从中读取每一行。您可以使用CSVReader来实现这一点:
FileReader fileReader = new FileReader(args[0]);
BufferedReader bufferedReader = new BufferedReader(fileReader);
try (CSVReader reader = new CSVReader(bufferedReader)) {
  String[] row;
  while ( (row = reader.readRow()) != null) {
    User user = User.fromValues(row);
  }
}
  1. 用户加载后,我们可以将其电子邮件添加到队列中,并立即尝试发送电子邮件:
queue.offer(user.email);
sendEmailWhenReady(queue);
  1. 由于从文件中读取通常非常快,我们将通过添加一些睡眠时间来模拟缓慢读取:
Thread.sleep(100);
  1. 在 try-with-resources 块之外,也就是在我们完成从文件中读取所有用户之后,我们需要确保排空队列。为此,我们可以使用一个while循环,只要队列不为空就运行:
while (!queue.isEmpty()) {
  sendEmailWhenReady(queue);

  // Wait before checking again
  Thread.sleep(100);
}

注意

在这种情况下,很重要的一点是在你睡觉的时候不要使用 100%的 CPU。这在处理队列中的元素时非常常见,就像在这种情况下一样。

  1. 现在您可以等待最后一个发送电子邮件过程完成,遵循类似的模式:检查并在睡眠时等待:
while (runningProcess.isAlive()) {
  System.out.print(".");
  Thread.sleep(100);
}
System.out.println("\nDone sending emails!");

恭喜!您编写了一个应用程序,使用受限资源(仅一个进程)模拟发送电子邮件。该应用程序忽略了文件中用户的重复情况。它还忽略了发送电子邮件过程的输出。您将如何实现重复发送检测器并避免该问题?您认为发送过程的输出如何影响重复避免的决定?

集合的属性

在选择数据结构解决问题时,您将不得不考虑以下事项:

  • 排序 - 如果在访问数据时顺序很重要,数据将以什么顺序被访问?

  • 独特性 - 如果在集合内部多次具有相同的元素,这是否重要?你如何定义独特性?

  • 可空性 - 值是否可以为空?如果将键映射到值,空键是否有效?在任何情况下使用空是否有意义?

使用以下表格确定哪种集合更适合您的用例:

表 7.1:表示集合属性的表格

表 7.1:表示集合属性的表格

注意

“自然排序”意味着它将根据元素(或键)进行排序,如果元素实现了Comparable,或者使用传入的比较器进行排序。

摘要

在开发应用程序时,处理数据是最基本的任务之一。在本课程中,您学会了如何从文件中读取和解析数据,以便能够将其作为应用程序的一部分进行处理。您还学会了如何比较对象,以便以不同的方式对其进行排序。

作为处理数据的一部分,您学会了如何使用基本和高级数据结构存储数据。了解如何高效地处理数据非常重要,以便避免资源争用场景,例如内存耗尽,或者需要太多的处理或时间来执行手头的任务。高效处理数据的一个重要部分是选择适合特定问题的正确数据结构和算法。您添加到工具库中的所有新工具将帮助您在构建 Java 应用程序时做出正确的决策。

在下一课中,我们将看一些高级数据结构。

第八章:第八章

Java 中的高级数据结构

学习目标

在本课结束时,您将能够:

  • 实现一个链表

  • 实现二叉搜索树

  • 使用枚举更好地处理常量

  • 解释 HashSet 中唯一性背后的逻辑

介绍

在之前的课程中,您学习了 Java 中各种数据结构,如列表、集合和映射。您还学习了如何在许多不同的方式上迭代这些数据结构,比较对象;以及如何以高效的方式对这些集合进行排序。

在本课中,您将学习高级数据结构的实现细节,如链表和二叉搜索树。随着我们的进展,您还将了解一个称为枚举的强大概念,并探索如何有效地使用它们而不是常量。在课程结束时,您将了解equals()hashCode()背后的魔力和神秘。

实现自定义链表

列表有两种实现方式:

  • ArrayList:这是使用数组作为底层数据结构实现的。它具有与数组相同的限制。

  • 链表:链表中的元素分布在内存中,与数组不同,数组中的元素是连续的。

ArrayList 的缺点

ArrayList 的缺点如下:

  • 虽然 ArrayList 是动态的,创建时不需要指定大小。但是由于数组的大小是固定的,因此当向列表添加更多元素时,ArrayList 通常需要隐式调整大小。调整大小遵循创建新数组并将先前数组的所有元素添加到新数组的过程。

  • 在 ArrayList 的末尾插入新元素通常比在中间添加要快,但是当在列表中间添加元素时,代价很高,因为必须为新元素创建空间,并且为了创建空间,现有元素必须移动。

  • 删除 ArrayList 的最后一个元素通常更快,但是当在中间删除元素时,代价很高,因为元素必须进行调整,将元素向左移动。

链表优于数组的优点

以下是链表优于数组的优点:

  • 动态大小,大小不固定,没有调整大小的问题。每个节点都持有对下一个节点的引用。

  • 在链表中随机位置添加和删除元素,与向量和数组相比要简单得多。

在本主题中,您将学习如何为特定目的构建自定义链表。通过这样做,我们将欣赏链表的强大之处,并了解实现细节。

这是链表的图示表示:

图 8.1:链表的表示

图 8.1:链表的表示

动态内存分配是链表的一个常见应用。链表的其他应用包括实现数据结构,如栈、各种队列的实现、图、树等。

练习 31:向链表添加元素

让我们创建一个简单的链表,允许我们添加整数,并打印列表中的元素:

  1. 创建一个名为SimpleIntLinkedList的类如下:
public class SimpleIntLinkedList 
{
  1. 创建另一个代表链表中每个元素的Node类。每个节点都有数据(一个整数值)需要保存;它将有一个对下一个Node的引用。实现数据和next变量的 getter 和 setter:
static class Node {
Integer data;
Node next;
Node(Integer d) {
data = d;
next = null;
}
Node getNext() {
return next;
}
void setNext(Node node) {
next = node;
}
Object getData() {
return data;
}
}
  1. 实现add(Object item)方法,以便可以将任何项目/对象添加到此列表中。通过传递newItem = new Node(item)项目构造一个新的Node对象。从head节点开始,向列表的末尾移动,访问每个节点。在最后一个节点中,将下一个节点设置为我们新创建的节点(newItem)。通过调用incrementIndex()来增加索引以跟踪索引:
// appends the specified element to the end of this list.
    public void add(Integer element) {
        // create a new node
        Node newNode = new Node(element);
        //if head node is empty, create a new node and assign it to Head
        //increment index and return
        if (head == null) {
            head = newNode;
            return;
        }
        Node currentNode = head;

        while (currentNode.getNext() != null) {
                currentNode = currentNode.getNext();
        }
        // set the new node as next node of current
        currentNode.setNext(newNode);
    }
  1. 实现一个 toString()方法来表示这个对象。从头节点开始,迭代所有节点直到找到最后一个节点。在每次迭代中,构造存储在每个节点中的整数的字符串表示。表示将类似于这样:[Input1,Input2,Input3]
  public String toString() {
    String delim = ",";
    StringBuffer stringBuf = new StringBuffer();
    if (head == null)
      return "LINKED LIST is empty";
    Node currentNode = head;
    while (currentNode != null) {
      stringBuf.append(currentNode.getData());
      currentNode = currentNode.getNext();
      if (currentNode != null)
        stringBuf.append(delim);
      }
    return stringBuf.toString();
  }
  1. 为 SimpleIntLinkedList 创建一个类型为 Node 的成员属性(指向头节点)。在 main 方法中,创建一个 SimpleIntLinkedList 对象,并依次添加五个整数(13, 39, 41, 93, 98)到其中。打印 SimpleIntLinkedList 对象。
Node head;
public static void main(String[] args) {
  SimpleLinkedList list = new SimpleLinkedList();
  list.add(13);
  list.add(39);
  list.add(41);
  list.add(93);
  list.add(98);
  System.out.println(list);
  }
}

输出将如下所示:

[13, 39, 41, 93, 98]

活动 32:在 Java 中创建自定义链表

在我们的练习中,我们创建了一个可以接受整数值的链表。作为一个活动,让我们创建一个自定义链表,可以将任何对象放入其中,并显示添加到列表中的所有元素。此外,让我们添加另外两种方法来从链表中获取和删除值。

这些步骤将帮助您完成此活动:

  1. 创建一个名为 SimpleObjLinkedList 的类,并创建一个类型为 Node 的成员属性(指向头节点)。添加一个类型为 int 的成员属性(指向节点中的当前索引或位置)

  2. 创建一个表示链表中每个元素的 Node 类。每个节点将有一个需要保存的对象,并且它将有对下一个节点的引用。LinkedList 类将有一个对头节点的引用,并且可以使用 Node.getNext()来遍历到下一个节点。因为头是第一个元素,我们可以通过在当前节点中移动 next 来遍历到下一个元素。这样,我们可以遍历到列表的最后一个元素。

  3. 实现 add(Object item)方法,以便可以向该列表添加任何项目/对象。通过传递 newItem = new Node(item)项目来构造一个新的 Node 对象。从头节点开始,爬行到列表的末尾。在最后一个节点中,将 next 节点设置为我们新创建的节点(newItem)。增加索引。

  4. 实现 get(Integer index)方法,根据索引从列表中检索项目。索引不能小于 0。编写逻辑来爬行到指定的索引并识别节点并从节点返回值。

  5. 实现 remove(Integer index)方法,根据索引从列表中删除项目。编写逻辑来爬行到指定索引的前一个节点并识别节点。在此节点中,将下一个设置为 getNext()。如果找到并删除元素,则返回 true。如果未找到元素,则返回 false。

  6. 实现一个 toString()方法来表示这个对象。从头节点开始,迭代所有节点直到找到最后一个节点。在每次迭代中,构造存储在每个节点中的对象的字符串表示。

  7. 编写一个 main 方法,创建一个 SimpleObjLinkedList 对象,并依次添加五个字符串("INPUT-1","INPUT-2","INPUT-3","INPUT-4","INPUT-5")到其中。打印 SimpleObjLinkedList 对象。在 main 方法中,使用 get(2)从列表中获取项目并打印检索到的项目的值,还从列表中删除项目 remove(2)并打印列表的值。列表中应该已经删除了一个元素。

输出将如下所示:

[INPUT-1 ,INPUT-2 ,INPUT-3 ,INPUT-4 ,INPUT-5 ]
INPUT-3
[INPUT-1 ,INPUT-2 ,INPUT-3 ,INPUT-5 ]

注意

此活动的解决方案可以在第 356 页找到。

链表的缺点

链表的缺点如下:

  • 访问元素的唯一方法是从第一个元素开始,然后顺序移动;无法随机访问元素。

  • 搜索速度慢。

  • 链表需要额外的内存空间。

实现二叉搜索树

我们在第 7 课中已经简要介绍了树,Java 集合框架和泛型,让我们看看树的一种特殊实现,称为二叉搜索树(BSTs)。

要理解 BSTs,让我们看看什么是二叉树。树中每个节点最多有两个子节点的树是二叉树

BST 是二叉树的一种特殊实现,其中左子节点始终小于或等于父节点,右子节点始终大于或等于父节点。二叉搜索树的这种独特结构使得更容易添加、删除和搜索树的元素。以下图表表示了 BST:

图 8.2:二叉搜索树的表示

图 8.2:二叉搜索树的表示

二叉搜索树的应用如下:

  • 实现字典。

  • 在数据库中实现多级索引。

  • 实现搜索算法。

练习 32:在 Java 中创建二叉搜索树

在这个练习中,我们将创建一个二叉搜索树并实现左右遍历。

  1. 在其中创建一个BinarySearchTree类,其中包含一个Node类。Node类应该有两个指向其左节点和右节点的元素。
//Public class holding the functions of Entire Binary Tree structure
public class BinarySearchTree
{
    private Node parent;
    private int  data;
    private int  size = 0;
    public BinarySearchTree() {
        parent = new Node(data);
    }
private class Node {
        Node left; //points to left node
        Node right; //points to right node
        int  data;
        //constructor of Node
        public Node(int data) {
            this.data = data;
        }
}
  1. 我们将创建一个add(int data)函数,它将检查父节点是否为空。如果为空,它将将值添加到父节点。如果父节点有数据,我们需要创建一个新的Node(data)并找到正确的节点(根据 BST 规则)将此新节点附加到。

为了帮助找到正确的节点,已经实现了一个方法add(Node root, Node newNode),使用递归逻辑深入查找实际应该属于这个新节点的节点。

根据 BST 规则,如果根数据大于newNode数据,则newNode必须添加到左节点。再次递归检查是否有子节点,并且 BST 的相同逻辑适用,直到达到叶节点以添加值。如果根数据小于newNode数据,则newNode必须添加到右节点。再次递归检查是否有子节点,并且 BST 的相同逻辑适用,直到达到叶节点以添加值:

/**
* This is the method exposed as public for adding elements into the Tree.
     * it checks if the size == 0 and then adds the element into parent node. if
     * parent is already filled, creates a New Node with data and calls the
     * add(parent, newNode) to find the right root and add it to it.
     * @param data
     */
  public void add(int data) {
    if (size == 0) {
      parent.data = data;
      size++;
    } else {
      add(parent, new Node(data));
    }
  }
/**
 * Takes two params, root node and newNode. As per BST, check if the root
 * data is > newNode data if true: newNode has to be added in left Node
 * (again recursively check if it has child nodes and the same logic of BST
 * until it reaches the leaf node to add value) else: newNode has to be
 * added in right (again recursively check if it has child nodes and the
 * same logic of BST until it reaches the leaf node to add value)
* 
 * @param root
 * @param newNode
 */
  private void add(Node root, Node newNode) {
    if (root == null) {
      return;
    }
  if (newNode.data < root.data) {
      if (root.left == null) {
        root.left = newNode;
        size++;
      } else {
        add(root.left, newNode);
      }
    }
    if ((newNode.data > root.data)) {
      if (root.right == null) {
        root.right = newNode;
        size++;
      } else {
        add(root.right, newNode);
      }
    }
  }
  1. 创建一个traverseLeft()函数来遍历并打印 BST 根节点左侧的所有值:
  public void traverseLeft() {
  Node current = parent;
  System.out.print("Traverse the BST From Left : ");
        while (current.left != null && current.right != null) {
            System.out.print(current.data + "->[" + current.left.data + " " + current.right.data + "] ");
            current = current.left;
        }
        System.out.println("Done");
    }
  1. 创建一个traverseRight()函数来遍历并打印 BST 根节点右侧的所有值:
    public void traverseRight() {
        Node current = parent;
        System.out.print("Traverse the BST From Right");
        while (current.left != null && current.right != null) {
            System.out.print(current.data + "->[" + current.left.data + " " + current.right.data + "] ");
            current = current.right;
        }
        System.out.println("Done");
    }
  1. 让我们创建一个示例程序来测试 BST 的功能:
    /**
     * Main program to demonstrate the BST functionality.
     * - Adding nodes
     * - finding High and low 
     * - Traversing left and right
     * @param args
     */
    public static void main(String args[]) {
        BinarySearchTree bst = new BinarySearchTree();
        // adding nodes into the BST
        bst.add(32);
        bst.add(50);
        bst.add(93);
        bst.add(3);
        bst.add(40);
        bst.add(17);
        bst.add(30);
        bst.add(38);
        bst.add(25);
        bst.add(78);
        bst.add(10);
        bst.traverseLeft();
        bst.traverseRight();
}
    }

输出如下:

Traverse the BST From Left : 32->[3 50] Done
Traverse the BST From Right32->[3 50] 50->[40 93] Done

活动 33:在 BinarySearchTree 类中实现查找 BST 中最高和最低值的方法

  1. 创建一个实现while循环的getLow()方法,以迭代检查父节点是否有左子节点,并将左侧 BST 中没有左子节点的节点作为最低值返回。

  2. 创建一个实现while循环的getHigh()方法,以迭代检查父节点是否有右子节点,并将右侧 BST 中没有右子节点的节点作为最高值返回。

  3. main方法中,使用之前实现的add方法向二叉搜索树添加元素,并调用getLow()getHigh()方法来识别最高和最低值。

输出将如下所示:

Lowest value in BST :3
Highest value in BST :93

注意

此活动的解决方案可以在第 360 页找到。

枚举

Java 中的枚举(或枚举)是 Java 中的一种特殊类型,其字段由常量组成。它用于强制编译时安全性。

例如,考虑一周的天数,它们是一组固定的常量,因此我们可以定义一个枚举:

public enum DayofWeek { 
 SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY  
} 

现在我们可以简单地检查存储一天的变量是否是声明的枚举的一部分。我们还可以为非通用常量声明枚举,例如:

public enum Jobs { 
  DEVELOPER, TESTER, TEAM LEAD, PROJECT MANAGER 
}

这将强制将作业类型设置为Jobs枚举中声明的常量。这是一个持有货币的示例枚举:

public enum Currency {
    USD, INR, DIRHAM, DINAR, RIYAL, ASD 
}

练习 33:使用枚举存储方向

我们将创建一个枚举并找到值并比较枚举。

  1. 创建一个类EnumExample,并在main方法中。使用值作为枚举获取并打印枚举。使用值作为字符串获取并打印枚举:
public class EnumExample
{
    public static void main(String[] args)
    {
        Direction north = Direction.NORTH;
        System.out.println(north + " : " + north.no);
        Direction south = Direction.valueOf("SOUTH");
        System.out.println(south + " : " + south.no);
    }
}
  1. 让我们创建一个枚举,其中包含具有表示方向的整数值:
public enum Direction
    {
                  EAST(45), WEST(90), NORTH(180), SOUTH(360);
            int no;

Direction(int i){
                no =i;
            }
    }

输出如下:

NORTH : 180
SOUTH : 360

活动 34:使用枚举保存学院部门详情

让我们构建一个完整的枚举来保存学院部门及其编号(BE(“工程学士”,100))。

执行以下步骤:

  1. 使用enum关键字创建DeptEnum枚举。添加两个私有属性(String deptName和 int deptNo)来保存枚举中的值。

  2. 。重写一个构造函数以接受缩写和deptNo并将其放入成员变量中。添加符合构造函数的枚举常量。

  3. 添加deptNamedeptNo的 getter 方法。

  4. 让我们编写一个main方法和示例程序来演示枚举的使用:

输出如下:

BACHELOR OF ENGINEERING : 1
BACHELOR OF ENGINEERING : 1
BACHELOR OF COMMERCE : 2
BACHELOR OF SCIENCE : 3
BACHELOR OF ARCHITECTURE : 4
BACHELOR : 0
true

注意

这项活动的解决方案可以在第 362 页找到。

活动 35:实现反向查找

编写一个应用程序,接受一个值

  1. 创建一个枚举App,声明常量 BE、BCOM、BSC 和 BARC,以及它们的全称和部门编号。

  2. 还声明两个私有变量accronymdeptNo

  3. 创建一个带有缩写和deptNo的参数化构造函数,并将它们分配给作为参数传递的变量。

  4. 声明一个公共方法getAccronym(),返回变量accronym,以及一个公共方法getDeptNo(),返回变量deptNo

  5. 实现反向查找,接受课程名称,并在App枚举中搜索相应的缩写。

  6. 实现main方法,并运行程序。

你的输出应该类似于:

BACHELOR OF SCIENCE : 3
BSC

注意

这项活动的解决方案可以在第 363 页找到。

集合和集合中的唯一性

在这个主题中,我们将学习集合背后找到正在添加的对象的唯一性的逻辑,并理解两个对象级方法的重要性。

魔术在于Object类的两个方法

  • hashCode()

  • equals()

equals()和 hashCode()方法的基本规则

  • 只有当使用hashcode()方法返回的值相同并且equal()方法返回 true 时,两个对象才能相同。

  • 如果两个对象返回相同的hashCode()值,并不一定意味着两个对象相同(因为哈希值也可能与其他对象发生冲突)。在这种情况下,需要调用equals()并验证身份来找到相等性。

  • 我们不能仅仅使用hashCode()来找到相等性;我们需要同时使用equals()来做到这一点。然而,仅仅使用hashCode()就足以找到不相等性。如果hashCode()返回不同的值,可以肯定这些对象是不同的。

向集合添加对象

尽管当我们将一个对象添加到集合中时会发生许多事情,但我们只会关注与我们的研究主题相关的细节:

  • 该方法首先调用该对象的hashCode()方法并获取hashCode,然后Set将其与其他对象的hashCode进行比较,并检查是否有任何对象匹配该hashCode

  • 如果集合中没有任何对象与添加对象的hashCode匹配,那么我们可以百分之百地确定没有其他对象具有相同的身份。新添加的对象将安全地添加到集合中(无需检查equals())。

  • 如果任何对象与添加的对象的hashCode匹配,这意味着可能添加了相同的对象(因为hashCode可能对于两个不同的对象是相同的)。在这种情况下,为了确认怀疑,它将使用equals()方法来查看对象是否真的相等。如果相等,则新添加的对象将不被拒绝,否则新添加的对象将被拒绝。

练习 34:了解 equals()和 hashCode()的行为

让我们创建一个新的类,并在实现equals()hashCode()之前了解Set的行为:

  1. 创建一个带有三个属性的 Student 类:NameString),Ageint)和Year of passingint)。还为这些私有成员创建 getter 和 setter:
/**
 * Sample Class student containing attributes name, age and yearOfPassing
 *
 */
import java.util.HashSet;
class Student {
    private String name;
    private Integer age;
    private Integer yearOfPassing;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public int getYearOfPassing() {
        return yearOfPassing;
    }
    public void setYearOfPassing(int releaseYr) {
        this.yearOfPassing = releaseYr;
    }
}
  1. 编写一个示例类HashCodeExample,以演示集合的行为。在主方法中,创建三个具有不同名称和其他详细信息的Students对象(Raymonds,Allen 和 Maggy):
/**
 * Example class demonstrating the set behavior
 * We will create 3 objects and add into the Set
 * Later will create a new object resembling same as one of the 3 objects created and added into the set
*/
public class HashCodeExample {
    public static void main(String[] args) {
        Student m = new Student();
        m.setName("RAYMONDS");
        m.setAge(20);
        m.setYearOfPassing(2011);
        Student m1 = new Student();
        m1.setName("ALLEN");
        m1.setAge(19);
        m1.setYearOfPassing(2010);
        Student m2 = new Student();
        m2.setName("MAGGY");
        m2.setAge(18);
        m2.setYearOfPassing(2012);
}
}
  1. 创建一个HashSet来保存这些学生对象(set)。一个接一个地将三个对象添加到HashSet中。然后,打印HashSet中的值:
    HashSet<Student> set = new HashSet<Student>();
        set.add(m);
        set.add(m1);
        set.add(m2);
        //printing all the elements of Set
System.out.println("Before Adding ALLEN for second time : ");
        for (Student mm : set) {
            System.out.println(mm.getName() + " " + mm.getAge());
        }
  1. main方法中,创建另一个类似于已创建的三个对象的Student对象(例如:让我们创建一个类似于 Allen 的学生)。将这个新创建的Student对象添加到已经添加(set)了三个学生的HashSet中。然后,打印HashSet中的值。您会注意到 Allen 已经被添加到集合中两次(这意味着集合中未处理重复项):
    //creating a student similar to m1 (name:ALLEN, age:19, yearOfPassing:2010)
        Student m3 = new Student();
        m3.setName("ALLEN");
        m3.setAge(19);
        m3.setYearOfPassing(2010);
//this Student will be added as hashCode() and equals() are not implemented
        set.add(m3);
        // 2 students with same details (ALLEN 19 will be noticed twice)
System.out.println("After Adding ALLEN for second time: ");
        for (Student mm : set) {
            System.out.println(mm.getName() + " " + mm.getAge());
        }

输出如下:

Before Adding ALLEN for second time : 
RAYMONDS 20
MAGGY 18
ALLEN 19
After Adding ALLEN for second time: 
RAYMONDS 20
ALLEN 19
MAGGY 18
ALLEN 19

Allen确实已经被添加到集合中两次(这意味着集合中尚未处理重复项)。这需要在Student类中处理。

练习 35:重写 equals()和 hashCode()

让我们重写Studentequals()hashCode(),看看这之后Set的行为如何改变:

  1. Students类中,让我们通过检查Student对象的每个属性(nameageyearOfPassing同等重要)来重写equals()方法。Object级别的equals()方法以Object作为参数。要重写该方法,我们需要提供逻辑,用于比较自身属性(this)和object o参数。这里的相等逻辑是,只有当他们的nameageyearOfPassing相同时,两个学生才被认为是相同的:
    @Override
    public boolean equals(Object o) {
        Student m = (Student) o;
        return m.name.equals(this.name) && 
                m.age.equals(this.age) && 
                m.yearOfPassing.equals(this.yearOfPassing);
    }
  1. Student类中,让我们重写hashCode()方法。基本要求是对于相同的对象应该返回相同的整数。实现hashCode的一种简单方法是获取对象中每个属性的hashCode并将其相加。其背后的原理是,如果nameageyearOfPassing不同,那么hashCode将返回不同的值,这将表明没有两个对象是相同的:
@Override
    public int hashCode() {
        return this.name.hashCode() + 
                this.age.hashCode() + 
                this.yearOfPassing.hashCode();
    }
  1. 让我们运行HashCodeExample的主方法,以演示在Student对象中重写equals()hashCode()之后集合的行为。
public class HashCodeExample {
    public static void main(String[] args) {
        Student m = new Student();
        m.setName("RAYMONDS");
        m.setAge(20);
        m.setYearOfPassing(2011);
        Student m1 = new Student();
        m1.setName("ALLEN");
        m1.setAge(19);
        m1.setYearOfPassing(2010);
        Student m2 = new Student();
        m2.setName("MAGGY");
        m2.setAge(18);
        m2.setYearOfPassing(2012);

        Set<Student> set = new HashSet<Student>();
        set.add(m);
        set.add(m1);
        set.add(m2);

        //printing all the elements of Set
System.out.println("Before Adding ALLEN for second time : ");
        for (Student mm : set) {
            System.out.println(mm.getName() + " " + mm.getAge());
        }
    //creating a student similar to m1 (name:ALLEN, age:19, yearOfPassing:2010)
        Student m3 = new Student();
        m3.setName("ALLEN");
        m3.setAge(19);
        m3.setYearOfPassing(2010);
//this element will not be added if hashCode and equals methods are implemented
        set.add(m3);
System.out.println("After Adding ALLEN for second time: ");
        for (Student mm : set) {
            System.out.println(mm.getName() + " " + mm.getAge());
        }

    }
}

输出如下:

Before Adding ALLEN for second time: 
ALLEN 19
RAYMONDS 20
MAGGY 18
After Adding ALLEN for second time: 
ALLEN 19
RAYMONDS 20
MAGGY 18

在添加hashCode()equals()之后,我们的HashSet有智能识别和删除重复项的能力。

如果我们不重写equals()hashCode(),JVM 在内存中创建对象时为每个对象分配一个唯一的哈希码值,如果开发人员不重写hashcode方法,那么就无法保证两个对象返回相同的哈希码值。

总结

在这节课中,我们学习了 BST 是什么,以及在 Java 中实现 BST 的基本功能的步骤。我们还学习了一种遍历 BST 向右和向左的技巧。我们看了枚举在常量上的用法,并了解了它们解决的问题类型。我们还建立了自己的枚举,并编写了代码来获取和比较枚举的值。

我们还学习了HashSet如何识别重复项,并看了重写equals()hashCode()的重要性。此外,我们学会了如何正确实现equals()hashCode()

第九章:第九章

异常处理

学习目标

到本课程结束时,您将能够:

  • 使用抛出异常的库

  • 有效使用异常处理

  • 以一种尊重异常的方式获取和释放资源,而不会造成泄漏

  • 实施最佳实践以在 Java 中引入异常

介绍

异常处理是一种处理代码运行时发生错误情况的强大机制。它使我们能够专注于程序的主要执行,并将错误处理代码与预期执行路径分开。Java 语言强制程序员为库方法编写异常处理代码,而诸如 IntelliJ、Eclipse 等的 IDE 则帮助我们生成必要的样板代码。然而,如果没有适当的指导和理解,标准的异常代码可能会带来更多的害处。本课程是异常的实际介绍,将促使您思考异常处理的各个方面,并提供一些在处理编程生活中的异常时可能有帮助的经验法则。

异常背后的动机

当我们创建程序时,通常会关注预期的情况。例如,我们将从某处获取数据,我们将从数据中提取我们假定存在的某些信息,然后将其发送到其他地方,依此类推。我们希望我们的代码能够清晰可读,这样我们团队的成员可以清楚地理解业务逻辑,并且可以发现我们可能犯的错误。然而,在实践中,我们的假设可能不成立,预期情况可能会出现偏差。例如,由于网络或磁盘出现问题,我们可能无法获取数据。我们可能会收到不符合我们假设的数据。或者,由于类似的问题,我们可能无法发送数据。我们必须创建能够在意外情况下优雅地运行的程序。例如:我们应该让用户在网络连接中断时能够重试。异常是我们在 Java 中处理这种情况的方式,而不会使我们的代码过于复杂。

作为程序员,我们必须编写能够在各种意外情况下正常运行的代码。然而,我们也希望我们的代码干净且易于理解。这两个目标经常会相互竞争。

我们希望编写的代码能够清晰地阅读,如下所示:

Do step 1
Do step 2
Do step 3
Done

这反映了一个乐观的情景,即没有发生意外情况。然而,通常情况下会发生意外情况。用户的互联网连接可能中断,网络资源可能中断,客户端可能耗尽内存,可能发生磁盘错误,等等。除非我们编写能够预见这些问题的代码,否则当出现这些问题时,我们的程序可能会崩溃。预见每种可能发生的问题可能会非常困难。即使我们简化事情并以相同的方式处理大多数错误,我们仍然可能需要对我们的代码进行许多检查。例如:我们可能不得不编写更像这样的代码:

Do step 1
If there was a problem with step 1, 
     Handle the error, stop
Else 
    Do step 2
    If there was a problem with step 2, 
           Handle the error, stop
    Else 
           Do step 3
           If there was a problem with step 3
                 Handle the error, stop
           Else
Done

您可以提出替代的代码结构,但一旦您在每个步骤中加入额外的错误处理代码,您的代码就会变得不那么可读,不那么易于理解,也不那么易于维护。如果您不包括这样的错误处理代码,您的程序可能会导致意外情况,例如崩溃。

以下是一个在 C 中处理错误类似于我们之前的伪代码的函数。

int other_idea()
{
    int err = minor_func1();
    if (!err)
        err = minor_func2();
    if (!err)
        err = minor_func3();
    return err;
}

当您使用诸如 C 之类的原始语言编写代码时,您不可避免地会感到可读性和完整性之间的紧张关系。幸运的是,在大多数现代编程语言中,我们有异常处理能力,可以减少这种紧张关系。您的代码既可以清晰可读,又可以同时处理错误。

异常处理背后的主要语言构造是 try-catch 块。在 try 之后放置的代码逐行执行。如果任何一行导致错误,try 块中的其余行将不会执行,执行将转到 catch 块,让您有机会优雅地处理错误。在这里,您会收到一个包含有关问题详细信息的异常对象。但是,如果 try 块中没有发生错误,catch 块将不会执行。

在这里,我们修改了我们最初的示例,使用 try-catch 块来处理错误,而不是使用许多 if 语句:

Try
Do step 1
Do step 2
Do step 3
Catch error
    Handle error appropriately
Done

在这个版本中,我们的代码被放置在 try 和 catch 关键字之间。我们的代码没有错误处理代码,否则会影响可读性。代码的默认预期路径非常清晰:步骤 1,步骤 2 和步骤 3。然而,如果发生错误,执行立即转移到 catch 块。在那里,我们会收到关于问题的信息,以异常对象的形式,并有机会优雅地处理错误。

大多数情况下,您的代码片段会相互依赖。因此,如果一个步骤发生错误,通常不希望执行其余的步骤,因为它们依赖于较早步骤的成功。您可以创造性地使用 try-catch 块来表示代码依赖关系。例如:在以下伪代码中,步骤 2 和步骤 5 中存在错误。成功执行的步骤是步骤 1 和步骤 4。由于步骤 4 和后续步骤与前三个步骤的成功无关,我们能够使用两个单独的 try-catch 块来表示它们的依赖关系。步骤 2 中的错误阻止了步骤 3 的执行,但没有阻止步骤 4 的执行:

Try
Do step 1
Do step 2 - ERROR
Do step 3
Catch error
    Handle error appropriately
Done
Try
Do step 4
Do step 5 - ERROR
Do step 6
Catch error
    Handle error appropriately
Done

如果发生异常而您没有捕获它,错误将传播到调用者。如果这是您的应用程序,您不应该让错误传播出您的代码,以防止应用程序崩溃。但是,如果您正在开发一个被其他代码调用的库,有时让错误传播到调用者是一个好主意。我们将在稍后更详细地讨论这个问题。

练习 36:引入异常

现在让我们实际看看异常的作用。其中一个经典的异常是尝试用零除以一个数字。在这里,我们将使用它来创建异常并验证我们之前的伪代码:

  1. 创建一个新的 Main 类,并添加如下的主方法:
public class Main {
   public static void main(String[] args) { 
  1. 编写代码来打印两个数字的除法结果。添加 try-catch 块来处理异常:
try {
System.out.println("result 1: " + (2 / 2));
System.out.println("result 2: " + (4 / 0));
System.out.println("result 3: " + (6 / 2));
    } catch (ArithmeticException e) {
System.out.println("---- An exception in first block");
}
try {
System.out.println("result 4: " + (8 / 2));
System.out.println("result 5: " + (10 / 0));
System.out.println("result 6: " + (12 / 2));
} catch (ArithmeticException e) {
System.out.println("---- An exception in second block");
}
}
}

运行代码并验证输出是否如下所示:

result 1: 1
---- An exception in block 1
result 4: 4
---- An exception in block 2

请注意,结果 2 和 5 包含除以零的除法运算,这将导致异常。这样,我们有意在这两行中创建异常,以查看在异常情况下执行的进展。以下是预期执行的详细情况:

  • 结果 1 应该正常打印。

  • 在结果 2 的执行过程中,我们应该得到一个异常,这应该阻止结果 2 的打印。

  • 由于异常,执行应该跳转到 catch 块,这应该阻止结果 3 的打印。

  • 结果 4 应该正常打印。

  • 就像结果 2 一样,在结果 5 的执行过程中,我们应该得到一个异常,这应该阻止结果 5 的打印。

  • 同样,由于异常,执行应该跳转到 catch 块,这应该阻止结果 6 的打印。

借助两个 try-catch 块的帮助,由于结果 2 和 5 的异常,我们应该跳过结果 3 和 6。这应该只留下结果 1 和 4,它们将成功执行。

这表明我们之前的讨论是正确的。另外,为了验证执行顺序,请在结果 1 行中设置断点,然后单击“逐步执行”以观察执行如何逐步进行,使用 try-catch 块。

通过异常和try-catch块的帮助,我们能够编写更专注于预期的默认执行路径的代码,同时确保我们处理意外的错误情况,并根据错误的严重程度进行恢复或优雅失败。

异常的不可避免介绍

实际上,大多数新手 Java 开发者在调用库中抛出异常的方法时会遇到异常。这样的方法可以使用 throws 语句指定它会抛出异常。当你调用这种方法时,除非你编写处理该方法可能抛出的异常的代码,否则你的代码将无法编译。

因此,作为一个新手 Java 开发者,你所想要的只是调用一个方法,现在你被迫处理它可能抛出的异常。你的 IDE 可以生成处理异常的代码。然而,默认生成的代码通常不是最好的。一个没有指导的新手和 IDE 代码生成的能力可能会创建相当糟糕的代码。在本节中,你将得到如何最好地使用 IDE 生成的异常处理代码的指导。

假设你写了以下代码来打开和读取一个文件:

import java.io.File;
import java.io.FileInputStream;
public class Main {
   public static void main(String[] args) {
       File file = new File("./tmp.txt");
       FileInputStream inputStream = new FileInputStream(file);
   }
}

目前,你的代码将无法编译,你的 IDE 用红色下划线标出了FileInputStream构造函数。这是因为它可能会抛出异常,就像在它的源代码中指定的那样:

public FileInputStream(File file) throws FileNotFoundException {

在这一点上,你的 IDE 通常会试图提供帮助。例如,当你将光标移动到FileInputStream上并在 IntelliJ 中按下Alt + Enter时,你会看到两个快速修复选项:在方法签名中添加异常用 try/catch 包围。这对应于处理指定异常时你所拥有的两个选项,我们稍后会更深入地学习。第一个选项将你的代码转换为以下内容:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
public class Main {
   public static void main(String[] args) throws FileNotFoundException {
       File file = new File("input.txt");
       FileInputStream inputStream = new FileInputStream(file);
   }
}

现在你的主函数也指定了它可能会抛出异常。这样的异常会导致程序立即退出,这可能是你想要的,也可能不是。如果这是一个你作为库提供给其他人的函数,这个改变将阻止他们的代码编译,除非他们反过来处理指定的异常,就像你一样。同样,这可能是你想要做的,也可能不是。

如果你选择了“用 try/catch 包围”,这是 IntelliJ 提供的第二个选项,你的代码将变成这样:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       try {
           FileInputStream inputStream = new FileInputStream(file);
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       }
   }
}

在这个例子中,我们正在编写代码来自己处理异常。这感觉更合适;我们正在承担责任并编写代码来处理异常。然而,当前形式的代码实际上更有害无益。首先,它实际上并没有对异常做任何有用的事情;它只是捕获它,将有关它的信息打印到stdout,然后继续执行,就好像什么都没有发生一样。特别是在一个不是控制台应用程序的项目中(像大多数 Java 程序一样),打印到日志几乎没有用处。

如果我们找不到这个文件来打开,我们应该聪明地考虑我们可以做什么。我们应该要求用户查找文件吗?我们应该从互联网上下载吗?无论我们做什么,把问题记录在一个晦涩的日志文件中,然后把问题搁置起来可能是处理问题的最糟糕的方式之一。如果我们无法做任何有用的事情,也许不处理异常,让我们的调用者处理它,可能是更诚实地处理问题的方式。

请注意,这里没有银弹,也没有一刀切的建议。每个特殊情况,每个应用程序,每个上下文和每个用户群体都是不同的,我们应该提出一个最适合当前情况的异常处理策略。然而,如果你所做的只是e.printStackTrace(),那你可能做错了什么。

练习 37:使用 IDE 生成异常处理代码

在这个练习中,我们将看看如何使用 IDE 生成异常处理代码:

  1. 在 IntelliJ 中创建一个新的 Java 控制台项目。导入FileFileInputStream类:
import java.io.File;
import java.io.FileInputStream;
  1. 创建一个名为Main的类并添加main()方法:
public class Main {
   public static void main(String[] args) {
  1. 按以下方式打开文件:
File file = new File("input.txt");
FileInputStream fileInputStream = new FileInputStream(file);
  1. 按照以下方式读取文件:
int data = 0;
while(data != -1) {
data = fileInputStream.read();
System.out.println(data);
     }
     fileInputStream.close();
   }
}

请注意,在四个地方,IntelliJ 用红色下划线标出了我们的代码。这些是指定抛出异常的函数。这会阻止您的代码执行。

  1. 转到第一个问题(FileInputStream),按Alt + Enter,选择"main函数可以抛出FileNotFoundException,但这还不够,因为这不是其他函数抛出的异常类型。现在转到剩下的第一个问题(read),按Alt + Enter,选择"input.txt与此同时,这是您应该看到的输出:
Exception in thread "main" java.io.FileNotFoundException: input.txt (The system cannot find the file specified)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.<init>(FileInputStream.java:138)
at Main.main(Main.java:9)

异常从我们的主函数传播出来,JVM 捕获并记录到控制台中。

两件事情发生了。首先,修复read()的问题足以消除代码中的所有问题,因为readclose都会抛出相同的异常:IOException,它在主函数声明的 throws 语句中列出。然而,我们在那里列出的FileNotFoundException异常消失了。为什么呢?

这是因为异常类是一个层次结构,IOExceptionFileNotFoundException的祖先类。由于每个FileNotFoundException也是IOException,指定IOException就足够了。如果这两个类不是以这种方式相关的,IntelliJ 将列出可能抛出的异常作为逗号分隔的列表。

  1. 现在让我们将input.txt提供给我们的程序。您可以在硬盘的任何位置创建input.txt并在代码中提供完整的路径;但是,我们将使用一个简单的方法:IntelliJ 在主项目文件夹中运行您的程序。在这里右键单击您项目的input.txt文件,并在其中写入文本"abc"。如果您再次运行程序,您应该会看到类似于这样的输出:
97
98
99
-1
  1. 指定异常是使我们的程序工作的一种方法。另一种方法是捕获它们。现在让我们尝试一下。返回到您文件的以下版本;您可以重复使用撤消来做到这一点:
import java.io.File;
import java.io.FileInputStream;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       FileInputStream fileInputStream = new FileInputStream(file);
       int data = 0;
       while(data != -1) {
          data = fileInputStream.read();
          System.out.println(data);
       }
       fileInputStream.close();
   }
}
  1. 现在将光标移动到FileInputStream,按Alt + Enter,选择"try/catch块,它实际上将引用变量的创建与引发异常的构造函数调用分开。这主要是因为fileInputStream稍后在代码中使用,并且将其移动到try/catch块内将阻止它对这些用法可见。这实际上是一个常见的模式;您在try/catch块之前声明变量,处理其创建的任何问题,并在以后如果需要的话使其可用。

  2. 当前代码存在一个问题:如果try/catch块内的FileInputStream失败,fileInputStream将继续为空。在try/catch块之后,它将被取消引用,您将获得一个空引用异常。您有两个选择:要么将对象的所有用法放在try/catch块中,要么检查引用是否为空。以下是两种选择中的第一种:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       FileInputStream fileInputStream = null;
       try {
           fileInputStream = new FileInputStream(file);

           int data = 0;
           while(data != -1) {
               data = fileInputStream.read();
               System.out.println(data);
           }
           fileInputStream.close();
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       }
   }
}
  1. 我们将代码移到try/catch块内,以确保我们不会在fileInputStream为空时取消引用。然而,read()close()仍然有红色下划线。在read()上按Alt + Enter会给你一些选项,其中第一个选项是添加一个catch子句:
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       FileInputStream fileInputStream = null;
       try {
           fileInputStream = new FileInputStream(file);
           int data = 0;
           while(data != -1) {
               data = fileInputStream.read();
               System.out.println(data);
           }
           fileInputStream.close();
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       } catch (IOException e) {
           e.printStackTrace();
       }
   }
}

现在我们已经解决了代码中的所有问题,我们实际上可以运行它。请注意,第二个 catch 子句放在第一个之后,因为IOExceptionFileNotFoundException的父类。如果它们的顺序相反,类型为FileNotFoundException的异常实际上将被IOException捕获块捕获。

  1. 这是两种选择中的第二种选择,不将所有代码放在第一个 try 中:
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       FileInputStream fileInputStream = null;
       try {
           fileInputStream = new FileInputStream(file);
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       }
       if (fileInputStream != null) {
           int data = 0;
           while(data != -1) {
               data = fileInputStream.read();
               System.out.println(data);
           }
           fileInputStream.close();
       }
   }
}

如果fileInputStream不为空,我们就运行代码的第二部分。这样,如果创建FileInputStream不成功,我们就可以阻止第二部分运行。单独这样写可能没有太多意义,但如果中间有其他不相关的代码,那么这样写就有意义。你不能把所有东西都放在同一个try块中,在以后的代码中,你可能会依赖于那个try块的成功。这种简单的空值检查在这方面是有用的。

  1. 尽管我们的代码仍然存在问题。让我们在read()close()上使用Alt + Enter,并选择try/catch块。

  2. 更好的方法是将整个代码块放在一个try/catch中。在这种情况下,我们在第一个错误后放弃,这是一个更简单且通常更正确的方法:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
public class Main {
   public static void main(String[] args) {
       File file = new File("input.txt");
       FileInputStream fileInputStream = null;
       try {
           fileInputStream = new FileInputStream(file);
       } catch (FileNotFoundException e) {
           e.printStackTrace();
       }
       if (fileInputStream != null) {
           try {
               int data = 0;
               while(data != -1) {
                   data = fileInputStream.read();
                   System.out.println(data);
               }
               fileInputStream.close();
           } catch (IOException e) {
               e.printStackTrace();
           }
       }
   }
}

为了创建这段代码,我们没有依赖 IntelliJ 的快速修复功能Alt + Enter。虽然通常它很好,你可能会认为它创建的代码是正确的。然而,你必须运用自己的判断力,有时要纠正它创建的代码,就像这个例子一样。

现在你已经体验了使用 IDE 快速而简单地处理异常的方法。在这一节中获得的技能应该在你面临截止日期时指导你,并帮助你避免在使用 IDE 生成的异常代码时出现问题。

异常与错误代码

回想一下我们之前给出的 C 代码示例:

int other_idea()
{
    int err = minor_func1();
    if (!err)
        err = minor_func2();
    if (!err)
        err = minor_func3();
    return err;            
}

这里使用的错误处理方法存在一些缺点。在这段代码中,我们只是尝试调用三个函数。然而,对于每个函数调用,我们都在传递值来跟踪错误状态,并且对于每个函数调用,如果出现错误,都要使用if语句。此外,函数的返回值是错误状态——你不能返回自己选择的值。所有这些额外的工作都使原始代码变得模糊,并且难以理解和维护。

这种方法的另一个局限性是,单个整数值可能无法充分表示错误。相反,我们可能希望有关于错误的更多细节,比如发生时间、关于哪个资源等等。

在异常处理之前,程序员们必须编写代码来确保程序的完整性。异常处理带来了许多好处。考虑一下这个替代的 Java 代码:

int otherIdea() {
   try {
       minorFunc1();
       minorFunc2();
       minorFunc3();
   } catch (IOException e) {
       // handle IOException
   } catch (NullPointerException e) {
       // handle NullPointerException
   }
}

在这里,我们有三个函数调用,没有任何与错误相关的代码污染它们。这些放在了一个try/catch块中,错误处理是在catch块中单独完成的。出于以下原因,这更加可取:

  • 我们不必为每个函数调用都有一个if语句。我们可以将异常处理集中在一个地方。不管是哪个函数引发了异常,我们都在一个地方捕获所有异常。

  • 一个函数中可能发生的问题不止一种。每个函数可能引发多种异常。这些可以在单独的 catch 块中处理,而不是像没有异常处理那样,这将需要每个函数多个 if 语句。

  • 异常由对象表示,而不是单个整数值。虽然整数可以告诉我们出了什么问题,但对象可以告诉我们更多:异常发生时的调用堆栈、相关资源、关于问题的用户可读解释等等,都可以与异常对象一起提供。与单个整数值相比,这使得更容易对异常做出适当的反应。

练习 38:异常与错误代码

为了完成关于异常与错误代码的讨论,让我们体验一下两者,看看哪一个更容易处理。在这个练习中,我们有一个类,其中包含两种不同类型的函数,每种函数有两个函数。thFunction1()thFunction2()是在发生错误时可以抛出异常的函数。ecFunction1()ecFunction2()是返回指示是否发生错误的值的函数。我们使用随机数来模拟有时会发生错误:

  1. 导入IOExceptionRandom类如下:
import java.io.IOException;
import java.util.Random;
  1. 创建一个名为Main的类,其中包含Random类的一个实例:
public class Main {
   Random rand = new Random();
  1. 创建thFunction1()thFunction2()函数,它们抛出IOException如下:
void thFunction1() throws IOException {
       System.out.println("thFunction1 start");
       if (rand.nextInt(10) < 2) {
           throw new IOException("An I/O exception occurred in thFunction1");
       }
       System.out.println("thFunction1 done");
   }
   void thFunction2() throws IOException, InterruptedException {
       System.out.println("thFunction2 start");
       int r = rand.nextInt(10);
       if (r < 2) {
           throw new IOException("An I/O exception occurred in thFunction2");
       }
       if (r > 8) {
           throw new InterruptedException("An interruption occurred in thFunction2");
       }
       System.out.println("thFunction2 done");
   }
  1. 声明三个具有最终值的变量如下:
private static final int EC_NONE = 0;
private static final int EC_IO = 1;
private static final int EC_INTERRUPTION = 2;
  1. 创建两个函数ecFunction1()ecFunction2()如下:
int ecFunction1() {
System.out.println("ecFunction1 start");
if (rand.nextInt(10) < 2) {
return EC_IO;
}
System.out.println("thFunction1 done");
return EC_NONE;
}
int ecFunction2() {
System.out.println("ecFunction2 start");
int r = rand.nextInt(10);
if (r < 2) {
return EC_IO;
}
if (r > 8) {
return EC_INTERRUPTION;
}
System.out.println("ecFunction2 done");
       return EC_NONE;
}
  1. 创建callThrowingFunctions()如下:
private void callThrowingFunctions() {
try {
thFunction1();
thFunction2();
} catch (IOException e) {
System.out.println(e.getLocalizedMessage());
e.printStackTrace();
} catch (InterruptedException e) {
System.out.println(e.getLocalizedMessage());
e.printStackTrace();
}
}
  1. 创建一个名为callErrorCodeFunctions()的方法如下:
private void callErrorCodeFunctions() {
int err = ecFunction1();
if (err != EC_NONE) {
if (err == EC_IO) {
System.out.println("An I/O exception occurred in ecFunction1.");
}
}
err = ecFunction2();
switch (err) {
case EC_IO:
System.out.println("An I/O exception occurred in ecFunction2.");
break;
case EC_INTERRUPTION:
System.out.println("An interruption occurred in ecFunction2.");
break;
}
}
  1. 添加main方法如下:
   public static void main(String[] args) {
       Main main = new Main();
       main.callThrowingFunctions();
       main.callErrorCodeFunctions();
   }
}

在我们的main函数中,我们首先调用抛出函数,然后是错误代码函数。

多次运行此程序,观察每种情况下如何处理错误。以下是使用异常处理捕获错误的示例:

thFunction1 start
thFunction1 done
thFunction2 start
An interruption occurred in thFunction2
java.lang.InterruptedException: An interruption occurred in thFunction2
    at Main.thFunction2(Main.java:24)
    at Main.callThrowingFunctions(Main.java:58)
    at Main.main(Main.java:88)
ecFunction1 start
thFunction1 done
ecFunction2 start
thFunction2 done

请注意,thFunction2已经启动,但尚未完成。它抛出的异常包含有关thFunction2的信息。共享的catch块不必知道此异常来自何处;它只是捕获异常。这样,单个异常捕获块就能够处理多个函数调用。thFunction2抛出并被catch块捕获的异常对象能够传递有关问题的详细信息(例如堆栈跟踪)。这样,默认的预期执行路径保持干净,异常捕获块可以以细致的方式处理问题。

另一方面,看一下这个示例执行输出:

thFunction1 start
thFunction1 done
thFunction2 start
thFunction2 done
ecFunction1 start
An I/O exception occurred in ecFunction1.
ecFunction2 start
ecFunction2 done

ecFunction1中,发生了意外错误。这只是通过从该函数返回的错误代码值来表示的。请注意,此函数无法返回任何其他值;员工编号、某物是否活动等都是函数可能返回的一些示例。以这种方式从函数返回的错误代码禁止在返回值中传递此类信息。

此外,由于错误仅由一个数字表示,我们无法在错误处理代码中获得详细信息。我们还必须为每个函数调用编写错误处理代码,否则我们将无法区分错误位置。这会导致代码变得比应该更复杂和冗长。

进一步使用代码,多次运行它,并观察其行为。这应该让您更好地理解异常与错误代码,以及异常为什么更优越。

活动 36:处理数字用户输入中的错误

现在我们将在一个真实场景中使用异常处理。我们将创建一个控制台应用程序,在其中我们要求用户输入三个整数,将它们相加,并打印结果。如果用户没有输入非数字文本或分数,我们将要求用户提供一个整数。我们将为每个数字分别执行此操作——第三个数字的错误只需要我们重新输入第三个数字,我们的程序将很好地记住前两个数字。

以下步骤将帮助您完成此活动:

  1. 从一个空的 Java 控制台项目开始。将以下代码放入其中,该代码从键盘读取输入,并在用户按下Enter键后将其打印出来。

  2. 将此作为起点,并使用Integer.parseInt()函数将输入转换为数字。

  3. 请注意,与我们之前的例子不同,IDE 没有警告我们可能出现异常。这是因为有两种类型的异常,我们将在接下来的主题中学习。现在,要知道Integer.parseInt()可能会引发java.lang.NumberFormatException。使用我们之前学到的知识,将这行代码放入一个期望NumberFormatExceptiontry/catch块中。

  4. 现在将其放入一个while循环中。只要用户没有输入有效的整数(整数),它就应该循环。一旦我们有这样的值,while循环就不应再循环。如果用户没有输入有效的整数,向用户打印出适当的消息。不要打印原始异常消息或堆栈跟踪。这样,我们坚持要求用户输入一个整数,并且不会放弃,直到我们得到一个整数。

  5. 使用这种策略,输入三个整数并将它们相加。如果您没有为任何输入提供有效的整数,程序应该一遍又一遍地询问。将结果打印到控制台。

注意

此活动的解决方案可在 365 页找到。

异常来源

当代码中出现异常情况时,问题源会抛出一个异常对象,然后被调用堆栈中的一个调用者捕获。异常对象是异常类的一个实例。有许多这样的类,代表各种类型的问题。在本主题中,我们将看看不同类型的异常,了解一些来自 Java 库的异常类,学习如何创建自己的异常,以及如何抛出它们。

在上一个主题中,我们首先使用了IOException。然后,在活动中,我们使用了NumberFormatException。这两个异常之间有所不同。IDE 会强制我们处理IOException,否则代码将无法编译。然而,它并不在乎我们是否捕获了NumberFormatException,它仍然会编译和运行我们的代码。区别在于类层次结构。虽然它们都是Exception类的后代,但NumberFormatExceptionRuntimeException的后代,是Exception的子类:

图 9.1:RuntimeException 类的层次结构

图 9.1:RuntimeException 类的层次结构

上图显示了一个简单的类层次结构。任何是Throwable的后代类都可以作为异常抛出和捕获。然而,Java 对ErrorRuntimeException类的后代类提供了特殊处理。我们将在接下来的部分中进一步探讨这些。

已检查异常

Throwable的任何后代,如果不是ErrorRuntimeException的后代,都属于已检查异常的范畴。例如:IOException,我们在上一个主题中使用过的,就是一个已检查异常。IDE 强制我们要么捕获它,要么在我们的函数中指定抛出它。

要能够抛出已捕获的异常,您的函数必须指定它抛出异常。

抛出已检查异常

创建一个新项目,并粘贴以下代码:

import java.io.IOException;
public class Main {
   private static void myFunction() {
       throw new IOException("hello");
   }
   public static void main(String[] args) {
       myFunction();
   }
}

在这里,我们创建了一个函数,并希望它抛出IOException。然而,我们的 IDE 不会让我们这样做,因为这是一个已检查的异常。以下是它的类型层次结构:

图 9.2:IOException 类的层次结构

图 9.2:IOException 类的层次结构

由于IOExceptionException的后代,它是一个已检查的异常,每个抛出已检查异常的函数都必须指定它。将光标移动到错误行,按下Alt + Enter,然后选择“将异常添加到方法签名”。代码将如下所示:

import java.io.IOException;
public class Main {
   private static void myFunction() throws IOException {
       throw new IOException("hello");
   }
   public static void main(String[] args) {
       myFunction();
   }
}

请注意,我们的代码仍然存在问题。我们将在下一个练习中继续处理它。

已检查异常的另一个要求是,如果你调用指定了已检查异常的方法,你必须要么捕获异常,要么指定你也抛出该异常。这也被称为“捕获或指定规则”。

练习 39:使用 catch 或指定

让我们来看看抛出已检查异常和调用抛出它们的方法。你应该已经打开了这个项目:

  1. 如果你的 IDE 中没有前面的示例,请创建一个项目并添加以下代码:
import java.io.IOException;
public class Main {
   private static void myFunction() throws IOException {
       throw new IOException("hello");
   }
   public static void main(String[] args) {
       myFunction();
   }
}

请注意,带有myFunction()的那一行被标记为红色下划线,因为这一行调用了一个已检查的异常,而我们没有对潜在的异常做任何处理。我们需要指定我们也抛出它,或者我们需要捕获和处理它。IntelliJ 可以帮助我们做这两件事中的任何一件。将光标移动到myFunction1()行上,然后按Alt + Enter

  1. 选择将异常添加到方法签名,以成功指定我们抛出异常。这是它生成的代码:
import java.io.IOException;
public class Main {
   private static void myFunction() throws IOException {
       throw new IOException("hello");
   }
   public static void main(String[] args) throws IOException {
       myFunction();
   }
}

正如你所看到的,这个编译和运行都很顺利。现在撤销(Ctrl + Z)然后再次按Alt+ Enter来获取选项。

  1. 或者,如果我们选择用 try/catch 包围,我们将成功捕获异常。这是它生成的代码:
import java.io.IOException;
public class Main {
   private static void myFunction() throws IOException {
       throw new IOException("hello");
   }
   public static void main(String[] args) {
       try {
           myFunction();
       } catch (IOException e) {
           e.printStackTrace();
       }
   }
}

虽然这个编译和运行,但记住,简单地打印有关它的信息并不是处理异常的最佳方式。

在这些练习中,我们看到了如何抛出已检查的异常以及如何调用抛出它们的方法。

未检查异常

回顾异常类层次结构的顶部:

图 9.3:RuntimeException 类的层次结构

图 9.3:RuntimeException 类的层次结构

在这里,RuntimeException的后代被称为运行时异常。Error的后代被称为错误。这两者都被称为未检查异常。它们不需要被指定,如果被指定了,也不需要被捕获。

未检查异常代表可能发生的事情,与已检查异常相比更加意外。假设你有选择确保它们不会被抛出;因此,它们不必被期望。但是,如果你怀疑它们可能被抛出,你应该尽力处理它们。

NumberFormatException 的层次结构如下:

图 9.4:NormalFormatException 类的层次结构

图 9.4:NormalFormatException 类的层次结构

由于它是RuntimeException的后代,因此它是运行时异常,因此是未检查异常。

练习 40:使用抛出未检查异常的方法

在这个练习中,我们将编写一些会抛出运行时异常的代码:

  1. 在 IntelliJ 中创建一个项目,并粘贴以下代码:
public class Main {
public static void main(String[] args) {
int i = Integer.parseInt("this is not a number");
}
}

请注意,这段代码试图将一个字符串解析为整数,但显然该字符串不包含整数。因此,将抛出NumberFormatException。但是,由于这是一个未检查的异常,我们不必捕获或指定它。当我们运行代码时,就会看到这种情况:

Exception in thread "main" java.lang.NumberFormatException: For input string: "this is not a number"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Integer.parseInt(Integer.java:580)
    at java.lang.Integer.parseInt(Integer.java:615)
    at Main.main(Main.java:6)
  1. 由于我们没有捕获它,NumberFormatExceptionmain函数中抛出并使应用程序崩溃。相反,我们可以捕获它并打印关于它的消息,如下所示:
public class Main {
public static void main(String[] args) {
try {
int i = Integer.parseInt("this is not a number");
} catch (NumberFormatException e) {
System.out.println("Sorry, the string does not contain an integer.");
}
}
}

现在,当我们运行代码时,我们会得到一个输出,显示我们意识到了这种情况:

Sorry, the string does not contain an integer.

尽管捕获未检查异常是可选的,但你应该确保捕获它们,以便创建完整的代码。

对于错误来说,情况几乎是一样的,它们是Error类的后代。在接下来的部分中,我们将讨论运行时异常和错误之间的语义差异。

异常类层次结构

任何可以作为异常抛出的对象都是从ErrorRuntimeException派生的类的实例,被视为未经检查的异常,而从Throwable派生的任何其他类都是经过检查的异常。因此,您使用哪个异常类决定了异常处理的机制(经过检查与未经检查)。

除了异常处理的机制之外,异常类的选择还携带语义信息。例如:如果库方法遇到一个应该在硬盘上的文件丢失的情况,它会抛出一个FileNotFoundException的实例。如果一个字符串中应该包含一个数值,但出现了问题,您给出该字符串的方法会抛出一个NumberFormatException。Java 类库包含了许多适合大多数意外情况的异常类。以下是此层次结构中的类的一个子集:

图 9.5:层次结构中的类子集

图 9.5:层次结构中的类子集

阅读此列表,您会注意到各种场合有很多异常类型。

浏览异常层次结构

在 IntelliJ 中,打开任何 Java 项目或创建一个新项目。在您的代码中的任何地方,创建一个Throwable引用变量如下:

Throwable t;

现在将光标移动到Throwable上,然后按Ctrl + H。层次结构窗口应该打开,并将Throwable类放在焦点位置。它应该看起来像这样:

图 9.6:Throwable 类的层次结构

图 9.6:Throwable 类的层次结构

现在展开ErrorException,并浏览类列表。这些是各种可抛出的类,定义在您的代码可以访问的各种库中。正如您所看到的,有相当广泛的异常列表可供选择。在每个异常类旁边,用括号括起来的是它所属的包。作为一个经验法则,如果您要自己抛出异常,应该尽量使用您也在使用的库中的异常。例如:仅仅为了使用其中定义的ParseException而导入com.sun.jmx.snmp.IPAcl是不好的做法。

现在您对 Java 类库中存在的异常类有了更好的了解,以及您选择的异常类对代码用户传达的信息。

抛出异常和自定义异常

作为程序员,您将编写您或其他人将调用的方法。不可避免地,在您的代码中会出现不希望的情况。在这些情况下,您应该抛出适当异常类的实例。

要抛出异常,首先需要创建一个是Throwable祖先类的实例。然后,填充该实例并使用throw关键字将其抛出。然后,可抛出实例将沿着调用堆栈向上移动并弹出条目,直到遇到一个带有匹配此Throwable类型或其子类的 catch 语句的try/catch块。可抛出实例将作为捕获的异常给该 catch 块,并从那里继续执行。

练习 41:抛出异常

在这个练习中,我们将使用现有的异常类来抛出异常:

  1. 创建一个新的 Java 项目,并添加以下代码,其中有一个函数期望一个包含单个数字的长度为一的字符串并打印它。如果字符串为空,它将抛出一个IllegalArgumentException。如果字符串包含除了单个数字以外的任何内容,它将抛出一个NumberFormatException。由于这些是未经检查的异常,我们不必指定它们:
public class Main {
public static void useDigitString(String digitString) {
if (digitString.isEmpty()) {
throw new IllegalArgumentException("An empty string was given instead of a digit");
}
if (digitString.length() > 1) {
throw new NumberFormatException("Please supply a string with a single digit");
}
}
}
  1. 现在我们将调用此函数并处理它抛出的异常。我们故意调用另一个调用此函数的函数,并在两个不同的地方有 catch 块,以演示异常传播。完整的代码如下所示:
public class Main {
   public static void useDigitString(String digitString) {
       if (digitString.isEmpty()) {
           throw new IllegalArgumentException("An empty string was given instead of a digit");
       }
       if (digitString.length() > 1) {
           throw new NumberFormatException("Please supply a string with a single digit");
       }
       System.out.println(digitString);
   }
   private static void runDigits() {
       try {
           useDigitString("1");
           useDigitString("23");
           useDigitString("4");
       } catch (NumberFormatException e) {
           System.out.println("A number format problem occurred: " + e.getMessage());
       }
       try {
           useDigitString("5");
           useDigitString("");
           useDigitString("7");
       } catch (NumberFormatException e) {
           System.out.println("A number format problem occured: " + e.getMessage());
       }
   }
  1. 添加main()方法如下:
   public static void main(String[] args) {
       try {
           runDigits();
       } catch (IllegalArgumentException e) {
           System.out.println("An illegal argument was provided: " + e.getMessage());
       }
   }
}

注意,从main中调用runDigits,然后调用useDigitString。主函数捕获IllegalArgumentExceptionrunDigits捕获NumberFormatException。尽管我们在useDigitString中抛出了所有异常,但它们被不同的地方捕获。

练习 42:创建自定义异常类

在以前的练习中,我们为我们的异常使用了现有的异常类。NumberFormatException听起来合适,但IllegalArgumentException有点奇怪。而且,它们都是未经检查的异常;也许我们想要有检查的异常。因此,现有的异常类不适合我们的需求。在这种情况下,我们可以创建自己的异常类。让我们继续沿着上一个练习的路线:

  1. 假设我们对NumberFormatException感到满意,但我们想要一个是检查的EmptyInputException。我们可以扩展Exception来实现这一点:
class EmptyInputException extends Exception {
}
  1. 如果我们有额外的信息要放入此异常中,我们可以为此添加字段和构造函数。但是,在我们的情况下,我们只想表明输入为空;对于调用者来说,不需要其他信息。现在让我们修复我们的代码,使我们的函数抛出EmptyInputException而不是IllegalArgumentException
class EmptyInputException extends Exception {
}
public class Main {
   public static void useDigitString(String digitString) throws EmptyInputException {
       if (digitString.isEmpty()) {
           throw new EmptyInputException();
       }
       if (digitString.length() > 1) {
           throw new NumberFormatException("Please supply a string with a single digit");
       }
       System.out.println(digitString);
   }
   private static void runDigits() throws EmptyInputException {
       try {
           useDigitString("1");
           useDigitString("23");
           useDigitString("4");
       } catch (NumberFormatException e) {
           System.out.println("A number format problem occured: " + e.getMessage());
       }
       try {
           useDigitString("5");
           useDigitString("");
           useDigitString("7");
       } catch (NumberFormatException e) {
           System.out.println("A number format problem occured: " + e.getMessage());
       }
   }
  1. 按照以下方式添加main()方法:
   public static void main(String[] args) {
       try {
           runDigits();
       } catch (EmptyInputException e) {
           System.out.println("An empty string was provided");
       }
   }
}

请注意,这使我们的代码变得简单得多——我们甚至不必写消息,因为异常的名称清楚地传达了问题。以下是输出:

1
A number format problem occured: Please supply a string with a single digit
5
An empty string was provided

现在您知道如何抛出异常并创建自己的异常类(如果现有的异常类不够用)。

活动 37:在 Java 中编写自定义异常。

我们将为过山车乘坐的入场系统编写一个程序。对于每位游客,我们将从键盘获取他们的姓名和年龄。然后,我们将打印出游客的姓名以及他们正在乘坐过山车。

由于过山车只适合成年人,我们将拒绝年龄小于 15 岁的游客。我们将使用自定义异常TooYoungException来处理拒绝。此异常对象将包含游客的姓名和年龄。当我们捕获异常时,我们将打印一个适当的消息,解释为什么他们被拒绝。

我们将继续接受游客,直到姓名为空为止。

要实现这一点,请执行以下步骤:

  1. 创建一个新类,并输入RollerCoasterWithAge作为类名。

  2. 还要创建一个异常类TooYoungException

  3. 导入java.util.Scanner包。

  4. main()中,创建一个无限循环。

  5. 获取用户的姓名。如果是空字符串,则跳出循环。

  6. 获取用户的年龄。如果低于 15 岁,则抛出一个TooYoungException,包含姓名和年龄。

  7. 将姓名打印为"John 正在乘坐过山车"。

  8. 捕获异常并为其打印适当的消息。

  9. 运行主程序。

输出应类似于以下内容:

Enter name of visitor: John
Enter John's age: 20
John is riding the roller coaster.
Enter name of visitor: Jack
Enter Jack's age: 13
Jack is 13 years old, which is too young to ride.
Enter name of visitor: 

注意

此活动的解决方案可在第 366 页找到。

异常机制

在以前的主题中,我们抛出并捕获了异常,并对异常的工作原理有了一定的了解。现在让我们重新访问机制,以确保我们做对了一切。

try/catch的工作原理

try/catch语句有两个块:try块和catch块,如下所示:

try {
   // the try block
} catch (Exception e) {
   // the catch block, can be multiple 
}

try块是您的主要执行路径代码所在的地方。您可以在这里乐观地编写程序。如果try块中的任何一行发生异常,执行将在该行停止并跳转到catch块:

try {
   // line1, fine
   // line2, fine
   // line3, EXCEPTION!
   // line4, skipped
   // line5, skipped
} catch (Exception e) {
   // comes here after line3
}

catch块捕获可分配给其包含的异常引用(在本例中为Exception e)的可抛出对象。因此,如果在此处有一个在异常层次结构中较高的异常类(如Exception),它将捕获所有异常。这不会捕获错误,这通常是您想要的。

如果您想更具体地捕获异常的类型,可以提供一个在层次结构中较低的异常类。

练习 43:异常未被捕获,因为它不能分配给 catch 块中的参数

  1. 创建一个新项目并添加以下代码:
public class Main {
   public static void main(String[] args) {
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       }
   }
}

请注意,这段代码甚至无法编译。代码抛出异常,但 catch 子句期望一个InstantiationException,它是Exception的一个后代,不能分配给异常实例。因此,异常既不被捕获,也不被抛出。

  1. 指定一个异常,以便代码可以编译如下:
public class Main {
   public static void main(String[] args) throws Exception {
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       }
   }
}

当我们运行代码时,我们发现我们无法捕获我们抛出的异常:

line 0
line 1
line 2
line 3
Exception in thread "main" java.lang.Exception: EXCEPTION!
    at Main.main(Main.java:8)

有时,您捕获特定异常的一种类型,但您的代码也可能抛出其他类型的异常。在这种情况下,您可以提供多个 catch 块。被捕获的异常类型可以在类层次结构的不同位置。被抛出的异常可以分配给其参数的第一个 catch 块被执行。因此,如果两个异常类具有祖先关系,那么后代的 catch 子句必须在祖先的 catch 子句之前;否则,祖先也会捕获后代的异常。

练习 44:多个 catch 块及其顺序

在这个练习中,我们将看一下程序中的多个 catch 块及其执行顺序。让我们继续上一个练习:

  1. 返回代码的初始形式:
public class Main {
   public static void main(String[] args) {
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       }
   }
}
  1. 当我们在Exception上按Alt + Enter添加一个 catch 子句时,它会在现有的 catch 子句之后添加,这是正确的:
public class Main {
   public static void main(String[] args) {
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       } catch (Exception e) {
           e.printStackTrace();
       }
   }
}
  1. 如果抛出的异常是InstantiationException,它将被第一个 catch 捕获。否则,如果是其他任何异常,它将被第二个 catch 捕获。让我们尝试重新排列 catch 块:
public class Main {
   public static void main(String[] args) {
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (Exception e) {
           e.printStackTrace();
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       }
   }
}

现在我们的代码甚至无法编译,因为InstantiationException的实例可以分配给Exception e,并且它们将被第一个 catch 块捕获。第二个块永远不会被调用。IDE 很聪明地为我们解决了这个问题。

异常的另一个属性是它们沿着调用堆栈传播。每个被调用的函数本质上都会将执行返回给它的调用者,直到其中一个能够捕获异常。

练习 45:异常传播

在这个练习中,我们将通过一个例子来看一下多个函数相互调用的情况:

  1. 我们从最深的方法中抛出异常,这个异常被调用堆栈中更高的一个方法捕获:
public class Main {
   private static void method3() throws Exception {
       System.out.println("Begin method 3");
       try {
           for (int i = 0; i < 5; i++) {
               System.out.println("line " + i);
               if (i == 3) throw new Exception("EXCEPTION!");
           }
       } catch (InstantiationException e) {
           System.out.println("Caught an InstantiationException");
       }
       System.out.println("End method 3");
   }
   private static void method2() throws Exception {
       System.out.println("Begin method 2");
       method3();
       System.out.println("End method 2");
   }
   private static void method1() {
       System.out.println("Begin method 1");
       try {
           method2();
       } catch (Exception e) {
           System.out.println("method1 caught an Exception!: " + e.getMessage());
           System.out.println("Also, below is the stack trace:");
           e.printStackTrace();
       }
       System.out.println("End method 1");
   }
  1. 添加main()方法如下:
   public static void main(String[] args) {
       System.out.println("Begin main");
       method1();
       System.out.println("End main");
   }
}

当我们运行代码时,我们得到以下输出:

Begin main
Begin method 1
Begin method 2
Begin method 3
line 0
line 1
line 2
line 3
method1 caught an Exception!: EXCEPTION!
Also, below is the stack trace:
java.lang.Exception: EXCEPTION!
    at Main.method3(Main.java:8)
    at Main.method2(Main.java:18)
    at Main.method1(Main.java:25)
    at Main.main(Main.java:36)
End method 1
End main

注意,方法 2 和方法 3 没有运行到完成,而方法 1 和main运行到完成。方法 2 抛出异常;方法 3 没有捕获它,而是让它传播上去。最后,方法 1 捕获它。方法 2 和方法 3 突然返回到调用堆栈中更高的方法。由于方法 1 和 main 不让异常传播上去,它们能够运行到完成。

catch 块的另一个特性是我们应该谈论的。假设我们想要捕获两个特定的异常,但不捕获其他异常,但我们将在它们的 catch 块中做完全相同的事情。在这种情况下,我们可以使用管道字符组合这些异常的 catch 块。这个特性是在 Java 7 中引入的,在 Java 6 及以下版本中不起作用。

一个块中的多个异常类型

我们已经在一段代码中处理了单一类型的异常。现在我们将看一下一段代码中的多个异常类型。

考虑以下代码:

import java.io.IOException;
public class Main {
public static void method1() throws IOException {
System.out.println(4/0);
}
public static void main(String[] args) {
try {
System.out.println("line 1");
method1();
System.out.println("line 2");
} catch (IOException|ArithmeticException e) {
System.out.println("An IOException or a ArithmeticException was thrown. Details below.");
e.printStackTrace();
}
}
}

在这里,我们有一个 catch 块,可以使用多个异常类型的 catch 块捕获IOExceptionArithmeticException。当我们运行代码时,我们看到我们引起的ArithmeticException被成功捕获:

line 1
An IOException or a ArithmeticException was thrown. Details below.
java.lang.ArithmeticException: / by zero
    at Main.method1(Main.java:6)
    at Main.main(Main.java:12)

如果异常是IOException,它将以相同的方式被捕获。

现在你更了解try/catch块的机制、异常传播、多个 catch 块和块中的多个异常。

活动 38:处理块中的多个异常

记住我们之前为过山车乘坐的入场系统编写了一个程序吗?这一次,我们还将考虑访客的身高。对于每位访客,我们将从键盘获取他们的姓名、年龄和身高。然后,我们将打印出访客的姓名和他们正在乘坐过山车。

由于过山车只适合特定身高的成年人,我们将拒绝 15 岁以下或低于 130 厘米的访客。我们将使用自定义异常TooYoungExceptionTooShortException来处理拒绝。这些异常对象将包含人的姓名和相关属性(年龄或身高)。当我们捕获异常时,我们将打印一个适当的消息,解释为什么他们被拒绝。

我们将继续接受访客,直到姓名为空为止。

为了实现这一点,执行以下步骤:

  1. 创建一个新类,并输入RollerCoasterWithAgeAndHeight作为类名。

  2. 还要创建两个异常类,TooYoungExceptionTooShortException

  3. 导入java.util.Scanner包。

  4. main()中,创建一个无限循环。

  5. 获取用户的姓名。如果是空字符串,跳出循环。

  6. 获取用户的年龄。如果低于 15,抛出一个带有这个名字和年龄的TooYoungException

  7. 获取用户的身高。如果低于 130,抛出一个带有这个名字和年龄的TooShortException

  8. 将姓名打印为"John 正在乘坐过山车"。

  9. 分别捕获两种类型的异常。为每种情况打印适当的消息。

  10. 运行主程序。

输出应该类似于以下内容:

Enter name of visitor: John
Enter John's age: 20
Enter John's height: 180
John is riding the roller coaster.
Enter name of visitor: Jack
Enter Jack's age: 13
Jack is 13 years old, which is too young to ride.
Enter name of visitor: Jill
Enter Jill's age: 16
Enter Jill's height: 120
Jill is 120 cm tall, which is too short to ride.
Enter name of visitor: 

注意

这个活动的解决方案可以在第 368 页找到。

在 catch 块中我们应该做什么?

当你捕获异常时,你应该对它做些什么。理想情况下,你可以找到一种从错误中恢复并恢复执行的策略。然而,有时你无法做到这一点,可能会选择在你的函数中指定让这个异常使用 throws 语句传播。我们在上一个主题中看到了这些。

然而,在某些情况下,你可能有能力向你的调用者添加更多信息到异常中。例如:假设你调用一个方法来解析用户的年龄,它抛出了一个NumberFormatException。如果你简单地让它传播给你的调用者,你的调用者将不知道这与用户的年龄有关。也许在将异常传播给你的调用者之前,添加这些信息会有益处。你可以通过捕获异常,将其包装在另一个异常中作为原因,并将该异常抛出给你的调用者来实现这一点。这也被称为“链接异常”。

练习 46:链接异常

在这个练习中,我们将看一下链接异常的工作原理:

  1. 创建一个新项目并添加这段代码:
public class Main {
public static int parseUsersAge(String ageString) {
return Integer.parseInt(ageString);
}
public static void readUserInfo()  {
int age = parseUsersAge("fifty five");
}
public static void main(String[] args) {
readUserInfo();
}
}

请注意,尝试将"fifty five"解析为整数将导致NumberFormatException。我们没有捕获它,而是让它传播。以下是我们得到的输出结果:

Exception in thread "main" java.lang.NumberFormatException: For input string: "fifty five"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Integer.parseInt(Integer.java:580)
    at java.lang.Integer.parseInt(Integer.java:615)
    at Main.parseUsersAge(Main.java:4)
    at Main.readUserInfo(Main.java:8)
    at Main.main(Main.java:12)

请注意,异常的输出没有任何迹象表明这个问题与用户的年龄有关。

  1. 捕获异常并链接它以添加关于年龄的信息:
public class Main {
public static int parseUsersAge(String ageString) {
return Integer.parseInt(ageString);
}
public static void readUserInfo() throws Exception {
try {
int age = parseUsersAge("fifty five");
} catch (NumberFormatException e) {
throw new Exception("Problem while parsing user's age", e);
}
}
  1. 按照以下步骤添加main()方法:
public static void main(String[] args) throws Exception {
readUserInfo();
}
}

在这种情况下,这是我们得到的输出:

Exception in thread "main" java.lang.Exception: Problem while parsing user's age
    at Main.readUserInfo(Main.java:11)
    at Main.main(Main.java:16)
Caused by: java.lang.NumberFormatException: For input string: "fifty five"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Integer.parseInt(Integer.java:580)
    at java.lang.Integer.parseInt(Integer.java:615)
    at Main.parseUsersAge(Main.java:4)
    at Main.readUserInfo(Main.java:9)
    ... 1 more

请注意,这包含有关年龄的信息。这是一个异常,它有另一个异常作为原因。如果你愿意,你可以使用e.getCause()方法获取它,并相应地采取行动。当简单记录时,它按顺序打印异常详细信息。

最后的块及其机制

try/catch块在捕获异常时非常有用。但是,在这里有一个常见的情况,它可能有一些缺点。在我们的代码中,我们想获取一些资源。我们负责在完成后释放资源。但是,一个天真的实现可能会导致在发生异常时文件被保持打开状态。

练习 47:由于异常而保持文件打开

在这个练习中,我们将处理finally块:

  1. 假设我们将读取文件的第一行并将其打印出来。我们可以将其编码如下:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class Main {
private static void useTheFile(String s) {
System.out.println(s);
throw new RuntimeException("oops");
}
  1. 添加main()方法如下:
public static void main(String[] args) throws Exception {
try {
BufferedReader br = new BufferedReader(new FileReader("input.txt"));
System.out.println("opened the file");
useTheFile(br.readLine());
br.close();
System.out.println("closed the file");
} catch (Exception e) {
System.out.println("caught an exception while reading the file");
}
}
}

请注意,useTheFile函数在我们关闭文件之前引发了异常。当我们运行它时,我们会得到这个结果:

opened the file
line 1 from the file
caught an exception while reading the file

请注意,我们没有看到“关闭文件”输出,因为执行永远无法通过useTheFile()调用。捕获异常后,即使我们无法访问BufferedReader引用,操作系统仍然持有文件资源。我们刚刚泄漏了一个资源。如果我们在循环中多次执行此操作,我们的应用程序可能会崩溃。

  1. 您可以尝试设计各种解决此资源泄漏问题的解决方案。例如:您可以复制文件关闭代码并将其粘贴到 catch 块中。现在您在try块和catch块中都有它。如果有多个catch块,所有这些都应该如下所示:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class Main {
private static void useTheFile(String s) {
System.out.println(s);
throw new RuntimeException("oops");
}
public static void main(String[] args) throws Exception {
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("input.txt"));
System.out.println("opened the file");
useTheFile(br.readLine());
br.close();
System.out.println("closed the file");
} catch (IOException e) {
System.out.println("caught an I/O exception while reading the file");
br.close();
System.out.println("closed the file");
} catch (Exception e) {
System.out.println("caught an exception while reading the file");
br.close();
System.out.println("closed the file");
}
}
}
  1. 前面的代码是正确的,但它存在代码重复,这使得难以维护。相反,您可能认为可以在一个地方的catch块之后关闭文件:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class Main {
private static void useTheFile(String s) {
System.out.println(s);
throw new RuntimeException("oops");
}
public static void main(String[] args) throws Exception {
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("input.txt"));
System.out.println("opened the file");
useTheFile(br.readLine());
} catch (IOException e) {
System.out.println("caught an I/O exception while reading the file");
throw new Exception("something is wrong with I/O", e);
} catch (Exception e) {
System.out.println("caught an exception while reading the file");
}
br.close();
System.out.println("closed the file");
}
}

虽然这几乎是正确的,但它缺少一个可能性。请注意,我们现在在第一个catch块中抛出异常。这将绕过 catch 块后面的代码,文件仍将保持打开状态。

  1. 因此,我们需要确保无论发生什么,文件关闭代码都将运行。try/catch/finally块是这个问题的解决方案。它就像try/catch块,有一个额外的 finally 块,在我们完成块后执行,无论发生什么。以下是带有finally块的解决方案:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class Main {
private static void useTheFile(String s) {
System.out.println(s);
throw new RuntimeException("oops");
}
public static void main(String[] args) throws Exception {
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("input.txt"));
System.out.println("opened the file");
useTheFile(br.readLine());
} catch (IOException e) {
System.out.println("caught an I/O exception while reading the file");
throw new Exception("something is wrong with I/O", e);
} catch (Exception e) {
System.out.println("caught an exception while reading the file");
} finally {
br.close();
System.out.println("closed the file");
}
}
}

这个新版本关闭文件,无论是否引发异常,或者在最初捕获异常后引发另一个异常。在每种情况下,finally 块中的文件关闭代码都会被执行,并且文件资源会被操作系统适当释放。

这段代码还有一个问题。问题是,在BufferedReader构造函数中打开文件时可能会引发异常,br变量可能仍然为空。然后,当我们尝试关闭文件时,我们将取消引用一个空变量,这将创建一个异常。

  1. 为了避免这个问题,我们需要忽略br如果它是空的。以下是完整的代码:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class Main {
   private static void useTheFile(String s) {
       System.out.println(s);
       throw new RuntimeException("oops");
   }
   public static void main(String[] args) throws Exception {
       BufferedReader br = null;
       try {
           br = new BufferedReader(new FileReader("input.txt"));
           System.out.println("opened the file");
           useTheFile(br.readLine());
       } catch (IOException e) {
           System.out.println("caught an I/O exception while reading the file");
           throw new Exception("something is wrong with I/O", e);
       } catch (Exception e) {
           System.out.println("caught an exception while reading the file");
       } finally {
           if (br != null) {
               br.close();
               System.out.println("closed the file");
           }
       }
   }
}

活动 39:使用多个自定义异常处理

请记住,我们为过山车乘坐的入场系统编写了一个程序,该程序验证了访问者的年龄和身高。这一次,我们将假设我们必须在过山车区域之外护送每个申请人,无论他们是否乘坐过山车。

我们将逐个接纳访客。对于每个访客,我们将从键盘获取他们的姓名,年龄和身高。然后,我们将打印出访客的姓名以及他们正在乘坐过山车。

由于过山车只适合特定身高的成年人,我们将拒绝年龄小于 15 岁或身高低于 130 厘米的访客。我们将使用自定义异常TooYoungExceptionTooShortException来处理拒绝。这些异常对象将包含人的姓名和相关属性(年龄或身高)。当我们捕获异常时,我们将打印出一个适当的消息,解释为什么他们被拒绝。

一旦我们完成了与游客的互动,无论他们是否乘坐过山车,我们都会打印出我们正在护送游客离开过山车区域。

我们将继续接受游客,直到姓名为空。

为了实现这一点,执行以下步骤:

  1. 创建一个新的类,并输入RollerCoasterWithEscorting作为类名。

  2. 还要创建两个异常类,TooYoungExceptionTooShortException

  3. 导入java.util.Scanner包。

  4. main()中,创建一个无限循环。

  5. 获取用户的姓名。如果是空字符串,跳出循环。

  6. 获取用户的年龄。如果低于 15,抛出一个名为TooYoungException的异常。

  7. 获取用户的身高。如果低于 130,抛出一个名为TooShortException的异常。

  8. 将姓名打印为"约翰正在乘坐过山车"。

  9. 分别捕获两种类型的异常。为每个打印适当的消息。

  10. 打印出你正在护送用户离开场地。您必须小心姓名变量的范围。

  11. 运行主程序。

输出应该类似于以下内容:

Enter name of visitor: John
Enter John's age: 20
Enter John's height: 180
John is riding the roller coaster.
Escorting John outside the premises. 
Enter name of visitor: Jack
Enter Jack's age: 13
Jack is 13 years old, which is too young to ride.
Escorting Jack outside the premises. 
Enter name of visitor: Jill
Enter Jill's age: 16
Enter Jill's height: 120
Jill is 120 cm tall, which is too short to ride.
Escorting Jill outside the premises. 
Enter name of visitor: 

注意

这个活动的解决方案可以在第 370 页找到。

带资源的 try 块

try/catch/finally块是处理已分配资源的一种很好的方式。然而,您可能会同意,它感觉有点像样板文件。在 finally 块中分配资源并释放它们是一种非常常见的模式。Java 7 引入了一个新的块,简化了这种常见模式——try with resource块。在这个新的块中,我们将资源分配放在 try 块后面的括号中,然后忘记它们。系统将自动调用它们的.close()方法:

try(Resource r1 = Resource(); OtherResource r2 = OtherResource()) {
    r1.useResource();
    r2.useOtherResource();
} // don't worry about closing the resources

为了使这个工作,所有这些资源都必须实现AutoCloseable接口。

练习 48:带资源的 try 块

在这个练习中,我们将看一下带资源的 try 块:

  1. 按照以下方式导入所需的类:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
  1. 创建一个Main类,其中包含useTheFile()方法,该方法接受一个字符串参数,如下所示:
public class Main {
   private static void useTheFile(String s) {
       System.out.println(s);
       throw new RuntimeException("oops");
   }
  1. 将我们之前的例子转换为使用带资源的 try 块,如下所示:
public static void main(String[] args) throws Exception {
       try (BufferedReader br = new BufferedReader(new FileReader("input.txt"))) {
           System.out.println("opened the file, which will be closed automatically");
           useTheFile(br.readLine());
       } catch (IOException e) {
           System.out.println("caught an I/O exception while reading the file");
           throw new Exception("something is wrong with I/O", e);
       } catch (Exception e) {
           System.out.println("caught an exception while reading the file");
       }
   }
}

最佳实践

虽然学习异常处理及其语句、机制和类是使用它所必需的,但对于大多数程序员来说,这可能还不够。通常,这套理论信息需要各种情况的实际经验,以更好地了解异常。在这方面,关于异常的实际使用的一些经验法则值得一提:

  • 除非您真正处理了异常,否则不要压制异常。

  • 通知用户并让他们承担责任,除非您可以悄悄地解决问题。

  • 注意调用者的行为,不要泄漏异常,除非它是预期的。

  • 尽可能包装和链接更具体的异常。

压制异常

在您的函数中,当您捕获异常并不抛出任何东西时,您正在表明您已经处理了异常情况,并且您已经修复了这种情况,使得好像这种异常情况从未发生过一样。如果您不能做出这样的声明,那么您就不应该压制那个异常。

练习 49:压制异常

例如:假设我们有一个字符串列表,我们期望其中包含整数数字:

  1. 我们将解析它们并将它们添加到相应的整数列表中:
import java.util.ArrayList;
import java.util.List;
public class Main {
   private static List<Integer> parseIntegers(List<String> inputList) {
       List<Integer> integers = new ArrayList<>();
       for(String s: inputList) {
           integers.add(Integer.parseInt(s));
       }
       return integers;
   }
  1. 添加一个如下所示的main()方法:
   public static void main(String[] args) {
       List<String> inputList = new ArrayList<>();
       inputList.add("1");
       inputList.add("two");
       inputList.add("3");

       List<Integer> outputList = parseIntegers(inputList);

       int sum = 0;
       for(Integer i: outputList) {
           sum += i;
       }
       System.out.println("Sum is " + sum);
   }
}

当我们运行这个时,我们得到这个输出:

Exception in thread "main" java.lang.NumberFormatException: For input string: "two"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Integer.parseInt(Integer.java:580)
    at java.lang.Integer.parseInt(Integer.java:615)
    at Main.parseIntegers(Main.java:9)
    at Main.main(Main.java:20)
  1. 我们应该对此做些什么;至少,我们不应该让我们的代码崩溃。正确的行动是什么?我们应该在parseIntegers函数内捕获错误,还是应该在主函数中捕获错误?让我们在parseIntegers中捕获它,看看会发生什么:
import java.util.ArrayList;
import java.util.List;
public class Main {
   private static List<Integer> parseIntegers(List<String> inputList) {
       List<Integer> integers = new ArrayList<>();
       for(String s: inputList) {
           try {
               integers.add(Integer.parseInt(s));
           } catch (NumberFormatException e) {
               System.out.println("could not parse an element: " + s);
           }
       }
       return integers;
   }
  1. 添加一个如下所示的main()方法:
   public static void main(String[] args) {
       List<String> inputList = new ArrayList<>();
       inputList.add("1");
       inputList.add("two");
       inputList.add("3");
       List<Integer> outputList = parseIntegers(inputList);
       int sum = 0;
       for(Integer i: outputList) {
           sum += i;
       }
       System.out.println("Sum is " + sum);
   }
}

现在这是我们的输出:

could not parse an element: two
Sum is 4

它将 1 和 3 相加,忽略了"two"。这是我们想要的吗?我们假设"two"是正确的数字,并期望它包含在总和中。然而,目前我们将它排除在总和之外,并在日志中添加了一个注释。如果这是一个真实的场景,可能没有人会查看日志,我们提供的结果将是不准确的。这是因为我们捕捉了错误,但没有对其进行有意义的处理。

什么才是更好的方法?我们有两种可能性:要么我们可以假设列表中的每个元素实际上都应该是一个数字,要么我们可以假设会有错误,我们应该对其进行处理。

后者是一个更棘手的方法。也许我们可以将有问题的条目收集到另一个列表中,并将其返回给调用者,然后调用者会将其发送回原始位置进行重新评估。例如,它可以将它们显示给用户,并要求他们进行更正。

前者是一个更简单的方法:我们假设初始列表包含数字字符串。然而,如果这个假设不成立,我们必须让调用者知道。因此,我们应该抛出异常,而不是提供一半正确的总和。

我们不应该采取第三种方法:希望列表包含数字,但忽略那些不是数字的元素。请注意,这是我们做出的选择,但这并不是我们在上面列举两个选项时考虑的。这样编程很方便,但它创建了一个原始业务逻辑中不存在的假设。在这样的情况下要非常小心。确保你写下你的假设,并严格执行它们。不要让编程的便利性迫使你接受奇怪的假设。

如果我们假设初始列表包含数字字符串,我们应该这样编码:

import java.util.ArrayList;
import java.util.List;
public class Main {
   private static List<Integer> parseIntegers(List<String> inputList) {
       List<Integer> integers = new ArrayList<>();
       for(String s: inputList) {
           integers.add(Integer.parseInt(s));
       }
       return integers;
   }
   public static void main(String[] args) {
       List<String> inputList = new ArrayList<>();
       inputList.add("1");
       inputList.add("two");
       inputList.add("3");
       try {
           List<Integer> outputList = parseIntegers(inputList);
           int sum = 0;
           for(Integer i: outputList) {
               sum += i;
           }
           System.out.println("Sum is " + sum);
       } catch (NumberFormatException e) {
           System.out.println("There was a non-number element in the list. Rejecting.");
       }
   }
}

输出将简单地如下所示:

There was a non-number element in the list. Rejecting.

让用户参与

以前的经验法则建议我们不要把问题搁置一边,提供一半正确的结果。现在我们将其扩展到程序是交互式的情况。除非你的程序是批处理过程,通常它与用户有一些交互。在这种情况下,让用户成为问题情况的仲裁者通常是正确的方法。

在我们的例子中,一个字符串无法解析为数字,程序无法做太多事情。然而,如果用户看到了"two",他们可以用"2"替换它来解决问题。因此,我们不应该试图悄悄地修复问题,而是应该找到方法让用户参与决策过程,并寻求他们的帮助来解决问题。

练习 50:向用户寻求帮助

我们可以扩展我们之前的例子,以便我们识别列表中的有问题的条目,并要求用户进行更正:

  1. 这是一个处理这种情况的方法:
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
class NonNumberInListException extends Exception {
   public int index;
   NonNumberInListException(int index, Throwable cause) {
       super(cause);
       this.index = index;
   }
}
public class Main {
   private static List<Integer> parseIntegers(List<String> inputList) throws NonNumberInListException {
       List<Integer> integers = new ArrayList<>();
       int index = 0;
       for(String s: inputList) {
           try {
               integers.add(Integer.parseInt(s));
           } catch (NumberFormatException e) {
               throw new NonNumberInListException(index, e);
           }
           index++;
       }
       return integers;
   }
  1. 添加一个main()方法如下:
   public static void main(String[] args) {
       List<String> inputList = new ArrayList<>();
       inputList.add("1");
       inputList.add("two");
       inputList.add("3");
       boolean done = false;
       while (!done) {
           try {
               List<Integer> outputList = parseIntegers(inputList);
               int sum = 0;
               for(Integer i: outputList) {
                   sum += i;
               }
               System.out.println("Sum is " + sum);
               done = true;
           } catch (NonNumberInListException e) {
               System.out.println("This element does not seem to be a number: " + inputList.get(e.index));
               System.out.print("Please provide a number instead: ");
               Scanner scanner = new Scanner(System.in);
               String newValue = scanner.nextLine();
               inputList.set(e.index, newValue);
           }
       }
   }
}

这是一个示例输出:

This element does not seem to be a number: two
Please provide a number instead: 2
Sum is 6

请注意,我们确定了有问题的元素,并要求用户对其进行修正。这是让用户参与并给他们一个机会来解决问题的好方法。

除非预期会抛出异常

到目前为止,我们一直在建议抛出异常是一件好事,我们不应该压制它们。然而,在某些情况下,这可能并非如此。这提醒我们,关于异常的一切都取决于上下文,我们应该考虑每种情况,而不是盲目地遵循模式。

偶尔,您可能会使用第三方库,并且您可能会向它们提供您的类,以便它们调用您的方法。例如:游戏引擎可能会获取您的对象并调用其update()方法,每秒 60 次。在这种情况下,您应该仔细了解如果抛出异常会意味着什么。如果您抛出的异常导致游戏退出,或者显示一个错误发生的弹窗,也许您不应该为不是 showstoppers 的事情抛出异常。假设您在这一帧无法计算所需的值,但也许在下一帧会成功。这值得为此停止游戏吗?也许不值得。

特别是当您重写类/实现接口并将您的对象交给另一个实体来管理时,您应该注意传播异常出您的方法意味着什么。如果调用者鼓励异常,那很好。否则,您可能需要将所有方法包装在广泛的try/catch中,以确保您不会因为不是 showstoppers 的事情而泄漏异常。

考虑链式和更具体的异常传播

当您将异常传播给调用者时,通常有机会向该异常添加更多信息,以使其对调用者更有用。例如:您可能正在从用户提供的字符串中解析用户的年龄、电话号码、身高等。简单地引发NumberFormatException,而不告知调用者是哪个值,这并不是一个很有帮助的策略。相反,为每个解析操作单独捕获NumberFormatException给了我们识别有问题的值的机会。然后,我们可以创建一个新的异常对象,在其中提供更多信息,将NumberFormatException作为初始原因,并抛出该异常。然后,调用者可以捕获它,并了解哪个实体是有问题的。

之前的练习中,我们使用我们自定义的NonNumberInListException来识别列表中有问题的条目的索引,这是这个经验法则的一个很好的例子。在可能的情况下,最好抛出一个我们自己创建的更具信息性的异常,而不是让内部异常在没有太多上下文的情况下传播。

总结

在这节课中,我们从实际角度讨论了 Java 中的异常。首先,我们讨论了异常处理背后的动机,以及它如何比其他处理错误情况的方式更有优势。然后,我们以一个新手 Java 程序员的角度,结合强大的 IDE,提供了如何最好地处理和指定异常的指导。之后,我们深入探讨了异常的原因和各种异常类型,以及使用 try/catch、try/catch/finally 和 try with resource 块处理异常的机制。我们最后讨论了一系列最佳实践,以指导您在涉及异常的各种情况下的决策过程。

第十章:附录

关于

本节旨在帮助学生完成书中的活动。

其中包括学生执行活动目标所需执行的详细步骤。

第 1 课:Java 简介

活动 1:打印简单算术运算的结果

解决方案:

  1. 创建一个名为Operations的类,如下所示:
public class Operations
{
  1. main()中,打印一句话描述您将执行的值操作以及结果:
    public static void main(String[] args) {
        System.out.println("The sum of 3 + 4 is " + (3 + 4));
        System.out.println("The product of 3 + 4 is " + (3 * 4));
    }
}

输出将如下所示:

The sum of 3 + 4 is 7
The product of 3 + 4 is 12

活动 2:从用户那里读取值并使用 Scanner 类执行操作。

解决方案:

  1. 右键单击src文件夹,然后选择新建|

  2. 输入ReadScanner作为类名,然后点击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class ReadScanner
{
    static Scanner sc = new Scanner(System.in);
  public static void main(String[] args) {
    System.out.print("Enter a number: ");
    int a = sc.nextInt();
    System.out.print("Enter 2nd number: ");
    int b = sc.nextInt();
    System.out.println("The sum is " + (a + b) + ".");
    }
}
  1. 运行主程序。

输出将如下所示:

Enter a number: 12                                                                                                             
Enter 2nd number: 23
The sum is 35\.  

活动 3:计算金融工具的百分比增加或减少

解决方案:

  1. 右键单击src文件夹,然后选择新建|

  2. 输入StockChangeCalculator作为类名,然后点击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class StockChangeCalculator{
static Scanner sc = new Scanner(System.in);
public static void main(String[] args) {
    System.out.print("Enter the stock symbol: ");
    String symbol = sc.nextLine();
    System.out.printf("Enter %s's day 1 value: ", symbol);
    double day1 = sc.nextDouble();
    System.out.printf("Enter %s's day 2 value: ", symbol);
    double day2 = sc.nextDouble();
    double percentChange = 100 * (day2 - day1) / day1;
    System.out.printf("%s has changed %.2f%% in one day.", symbol, percentChange);
}
}
  1. 运行主程序。

输出应该类似于:

Enter the stock symbol: AAPL                                                                                                             
Enter AAPL's day 1 value: 100                                                                                                           
Enter AAPL's day 2 value: 91.5                                                                                                           
AAPL has changed -8.50% in one day.

第 2 课:变量、数据类型和运算符

活动 4:输入学生信息并输出 ID

解决方案:

  1. 导入Scanner包并创建一个新类
import java.util.Scanner;
{
public class Input{
static Scanner sc = new Scanner(System.in);
    public static void main(String[] args) 
{
  1. 将学生姓名作为字符串。
System.out.print("Enter student name: ");
String name = sc.nextLine();
  1. 将大学名称作为字符串。
System.out.print("Enter Name of the University: ");
String uni = sc.nextLine();
  1. 将学生的年龄作为整数。
System.out.print("Enter Age: ");
int age = sc.nextInt();
  1. 打印学生详细信息。
System.out.println("Here is your ID");
System.out.println("*********************************");
System.out.println("Name: " + name);
System.out.println("University: " + uni);
System.out.println("Age: " + age);
System.out.println("*********************************");
    }
} 
}

活动 5:计算满箱水果的数量

解决方案:

  1. 右键单击src文件夹,然后选择新建|

  2. 输入PeachCalculator作为类名,然后点击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class PeachCalculator{
static Scanner sc = new Scanner(System.in);
public static void main(String[] args) {
    System.out.print("Enter the number of peaches picked: ");
    int numberOfPeaches = sc.nextInt();
    int numberOfFullBoxes = numberOfPeaches / 20;
    int numberOfPeachesLeft = numberOfPeaches - numberOfFullBoxes * 20;
    System.out.printf("We have %d full boxes and %d peaches left.", numberOfFullBoxes, numberOfPeachesLeft);
}
}
  1. 运行主程序。

输出应该类似于:

Enter the number of peaches picked: 55
We have 2 full boxes and 15 peaches left.

第 3 课:控制流

活动 6:使用条件控制执行流程

解决方案:

  1. 创建一个名为Salary的类并添加main()方法:
public class Salary {
   public static void main(String args[]) { 
  1. 初始化两个变量workerhourssalary
int workerhours = 10; 
double salary = 0;
  1. if条件中,检查工人的工作时间是否低于所需的工作时间。如果条件成立,则工资应为(工作时间* 10)。
if (workerhours <= 8 ) 
salary = workerhours*10;
  1. 使用else if语句检查工作时间是否在 8 小时和 12 小时之间。如果是真的,则工资应为前 8 小时每小时$10,剩下的小时应按每小时$12 计算。
else if((workerhours > 8) && (workerhours < 12)) 
salary = 8*10 + (workerhours - 8) * 12;
  1. 使用else块来处理每天额外的$160(额外一天的工资)的默认情况。
else
    salary = 160;
System.out.println("The worker's salary is " + salary);
}
}

活动 7:开发温度系统

解决方案:

  1. 声明两个字符串,tempweatherWarning,然后用HighLowHumid初始化temp
public class TempSystem
{
    public static void main(String[] args) {
        String temp = "Low";
        String weatherWarning;
  1. 创建一个 switch 语句,检查temp的不同情况,然后根据每种情况的temp初始化变量weatherWarning为适当的消息(HighLowHumid)。
switch (temp) { 
        case "High": 
            weatherWarning = "It's hot outside, do not forget sunblock."; 
            break; 
        case "Low": 
            weatherWarning = "It's cold outside, do not forget your coat."; 
            break; 
        case "Humid": 
            weatherWarning = "The weather is humid, open your windows."; 
            break;
  1. 在默认情况下,将weatherWarning初始化为“天气看起来不错。出去散步吧”。
default: 
  weatherWarning = "The weather looks good. Take a walk outside"; 
  break;
  1. 完成 switch 结构后,打印weatherWarning的值。
} 
        System.out.println(weatherWarning); 
    }
}
  1. 运行程序以查看输出,应该类似于:
It's cold outside, do not forget your coat.

完整代码如下:

public class TempSystem
{
    public static void main(String[] args) {
        String temp = "Low";
        String weatherWarning;
            switch (temp) { 
        case "High": 
            weatherWarning = "It's hot outside, do not forget sunblock."; 
            break; 
        case "Low": 
            weatherWarning = "It's cold outside, do not forget your coat."; 
            break; 
        case "Humid": 
            weatherWarning = "The weather is humid, open your windows."; 
            break; 

        default: 
            weatherWarning = "The weather looks good. Take a walk outside"; 
            break; 
        } 
        System.out.println(weatherWarning); 
    }
}

活动 8:实现 for 循环

解决方案:

  1. 右键单击src文件夹,然后选择新建|

  2. 输入PeachBoxCounter作为类名,然后点击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class PeachBoxCounter
{
static Scanner sc = new Scanner(System.in);
public static void main(String[] args) {
System.out.print("Enter the number of peaches picked: ");
int numberOfPeaches = sc.nextInt();
for (int numShipped = 0; numShipped < numberOfPeaches; numShipped += 20)      {
System.out.printf("shipped %d peaches so far\n", numShipped);
}
}
}

活动 9:实现 while 循环

解决方案:

  1. 右键单击src文件夹,然后选择新建|

  2. 输入PeachBoxCounters作为类名,然后点击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class PeachBoxCounters{
static Scanner sc = new Scanner(System.in);
public static void main(String[] args) {
    System.out.print("Enter the number of peaches picked: ");
    int numberOfPeaches = sc.nextInt();
    int numberOfBoxesShipped = 0;
    while (numberOfPeaches >= 20) {
        numberOfPeaches -= 20;
        numberOfBoxesShipped += 1;
        System.out.printf("%d boxes shipped, %d peaches remaining\n", 
                numberOfBoxesShipped, numberOfPeaches);
    }
}
}

活动 10:实现循环结构

解决方案:

  1. 导入从用户读取数据所需的包。
import java.util.Scanner;
public class Theater {
public static void main(String[] args)
  1. 声明变量以存储可用座位总数、剩余座位和请求的票数。
{
int total = 10, request = 0, remaining = 10;
  1. while循环内,实现if else循环,检查请求是否有效,这意味着请求的票数少于剩余座位数。
while (remaining>=0)
{
System.out.println("Enter the number of tickets");
Scanner in = new Scanner(System.in);
request = in.nextInt();
  1. 如果前一步中的逻辑为真,则打印一条消息以表示票已处理,将剩余座位设置为适当的值,并要求获取下一组票。
if(request <= remaining)
{
System.out.println("Your " + request +" tickets have been procced. Please pay and enjoy the show.");
remaining = remaining - request;
request = 0;
}
  1. 如果步骤 3 中的逻辑为假,则打印适当的消息并跳出循环:
else
{
System.out.println("Sorry your request could not be processed");
break;
}
}
}
}

活动 11:使用嵌套循环进行连续桃子装运

解决方案:

  1. 右键单击src文件夹,然后选择新建 |

  2. 输入PeachBoxCounter作为类名,然后单击确定

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. main()中输入以下内容:
public class PeachBoxCount{    
static Scanner sc = new Scanner(System.in);
public static void main(String[] args) {
    int numberOfBoxesShipped = 0;
    int numberOfPeaches = 0;
    while (true) {
        System.out.print("Enter the number of peaches picked: ");
        int incomingNumberOfPeaches = sc.nextInt();
        if (incomingNumberOfPeaches == 0) {
            break;
        }
        numberOfPeaches += incomingNumberOfPeaches;
        while (numberOfPeaches >= 20) {
            numberOfPeaches -= 20;
            numberOfBoxesShipped += 1;
            System.out.printf("%d boxes shipped, %d peaches remaining\n",
                    numberOfBoxesShipped, numberOfPeaches);
        }
    }
}
}

第 4 课:面向对象编程

活动 12:在 Java 中创建一个简单的类

解决方案:

  1. 在 IDE 中创建一个名为Animals的新项目。

  2. 在项目中,在src/文件夹下创建一个名为Animal.java的新文件。

  3. 打开Animal.java并粘贴以下代码:

public class Animal {

}
  1. 在大括号内,创建以下实例变量来保存我们的数据,如下所示:
public class Animal {
        int legs;
        int ears;
        int eyes;
        String family;
        String name;

    }
  1. 在实例变量下面,定义两个构造函数。一个将不带参数并将腿初始化为 4,耳朵初始化为 2,眼睛初始化为 2。第二个构造函数将以腿、耳朵和眼睛的值作为参数,并设置这些值:
public class Animal {
        int legs;
        int ears;
        int eyes;
        String family;
        String name;
        public Animal(){
            this(4, 2,2);
        }
        public Animal(int legs, int ears, int eyes){
            this.legs = legs;
            this.ears = ears;
            this.eyes = ears;
        }
}
  1. 定义四个方法,两个用于设置和获取家庭,两个用于设置和获取名称:

注意

public class Animal {
    int legs;
    int ears;
    int eyes;
    String family;
    String name;
    public Animal(){
        this(4, 2,2);
    }
    public Animal(int legs, int ears, int eyes){
        this.legs = legs;
        this.ears = ears;
        this.eyes = ears;
    }
    public String getFamily() {
        return family;
    }
    public void setFamily(String family) {
        this.family = family;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}

我们已经完成了构建我们的 Animal 类。让我们继续创建这个类的几个实例。

  1. 创建一个名为Animals.java的新文件,并将以下代码复制到其中,如下所示:
public class Animals {

       public static void main(String[] args){

       }
}
  1. 创建两个Animal类的对象:
public class Animals {
        public static void main(String[] args){
            Animal cow = new Animal();
            Animal goat = new Animal();
        }
}
  1. 让我们再创建一个有 2 条腿、2 只耳朵和 2 只眼睛的动物:
Animal duck = new Animal(2, 2, 2);
  1. 为了设置动物的名称和家庭,我们将使用在类中创建的 getter 和 setter。将以下行复制/写入Animals类中:
public class Animals {
    public static void main(String[] args){
        Animal cow = new Animal();
        Animal goat = new Animal();
        Animal duck = new Animal(2, 2, 2);
        cow.setName("Cow");
        cow.setFamily("Bovidae");
        goat.setName("Goat");
        goat.setFamily("Bovidae");
        duck.setName("Duck");
        duck.setFamily("Anatidae");

        System.out.println(cow.getName());
        System.out.println(goat.getName());
        System.out.println(duck.getFamily());
    }
}

前面代码的输出如下:

Cow
Goat
Anatide

图 4.9:Animal 类的输出

活动 13:编写一个计算器类

解决方案:

  1. 创建一个名为 Calculator 的类:
public class Calculator {
  1. 创建三个字段double operand1double operand2String operator。添加一个设置所有三个字段的构造函数。
private final double operand1;
private final double operand2;
private final String operator;
public Calculator(double operand1, double operand2, String operator){
this.operand1 = operand1;
this.operand2 = operand2;
this.operator = operator;
}
  1. 在这个类中,添加一个operate方法,它将检查运算符是什么("+","-","x"或"/")并执行正确的操作,返回结果:
public double operate() {
if (this.operator.equals("-")) {
return operand1 - operand2;
} else if (this.operator.equals("x")) {
return operand1 * operand2;
} else if (this.operator.equals("/")) {
return operand1 / operand2;
} else {
// If operator is sum or unknown, return sum
return operand1 + operand2;
}
}
  1. 编写一个main()方法如下:
public static void main (String [] args) {
        System.out.println("1 + 1 = " + new Calculator(1, 1, "+").operate());
        System.out.println("4 - 2 = " + new Calculator(4, 2, "-").operate());
        System.out.println("1 x 2 = " + new Calculator(1, 2, "x").operate());
        System.out.println("10 / 2 = " + new Calculator(10, 2, "/").operate());
    }
}

活动 14:使用 Java 创建计算器

解决方案:

  1. 创建一个名为Operator的类,它具有一个在构造函数中初始化的 String 字段,表示运算符。这个类应该有一个代表默认运算符的默认构造函数,即 sum。Operator类还应该有一个名为operate的方法,它接收两个 double 并将运算符的结果作为 double 返回。默认操作是sum
public class Operator {
    public final String operator;
    public Operator() {
        this("+");
    }
    public Operator(String operator) {
        this.operator = operator;
    }
    public boolean matches(String toCheckFor) {
        return this.operator.equals(toCheckFor);
    }
    public double operate(double operand1, double operand2) {
        return operand1 + operand2;
    }
}
  1. 创建另一个名为Subtraction的类。它继承自Operator并覆盖

operate方法与它所代表的每个操作一起使用。它还需要一个不带参数的构造函数,调用 super 传递它所代表的运算符

代表:

public class Subtraction extends Operator {
    public Subtraction() {
        super("-");
    }
    @Override
    public double operate(double operand1, double operand2) {
        return operand1 - operand2;
    }
}
  1. 创建另一个名为Multiplication的类。它继承自 Operator 并覆盖operate方法,其中包含它所代表的每个操作。它还需要一个不带参数的构造函数,调用 super 传递它所代表的运算符:
public class Multiplication extends Operator {
    public Multiplication() {
        super("x");
    }
    @Override
    public double operate(double operand1, double operand2) {
        return operand1 * operand2;
    }
}
  1. 创建另一个名为Division的类。它继承自 Operator 并覆盖operate方法,其中包含它所代表的每个操作。它还需要一个不带参数的构造函数,调用 super 传递它所代表的运算符:
public class Division extends Operator {
    public Division() {
        super("/");
    }
    @Override
    public double operate(double operand1, double operand2) {
        return operand1 / operand2;
    }
}
  1. 与前一个Calculator类一样,这个类也将有一个operate方法,但它只会委托给运算符实例。最后,编写一个main方法,调用新的计算器几次,打印每次操作的结果:
public class CalculatorWithFixedOperators {
    public static void main (String [] args) {
        System.out.println("1 + 1 = " + new CalculatorWithFixedOperators(1, 1, "+").operate());
        System.out.println("4 - 2 = " + new CalculatorWithFixedOperators(4, 2, "-").operate());
        System.out.println("1 x 2 = " + new CalculatorWithFixedOperators(1, 2, "x").operate());
        System.out.println("10 / 2 = " + new CalculatorWithFixedOperators(10, 2, "/").operate());
    }
    private final double operand1;
    private final double operand2;
    // The current operator
    private final Operator operator;
    // All possible operations
    private final Division division = new Division();
    private final Multiplication multiplication = new Multiplication();
    private final Operator sum = new Operator();
    private final Subtraction subtraction = new Subtraction();
    public CalculatorWithFixedOperators(double operand1, double operand2, String operator) {
        this.operand1 = operand1;
        this.operand2 = operand2;
        if (subtraction.matches(operator)) {
            this.operator = subtraction;
        } else if (multiplication.matches(operator)) {
            this.operator = multiplication;
        } else if (division.matches(operator)) {
            this.operator = division;
        } else {
            this.operator = sum;
        }
    }
    public double operate() {
        return operator.operate(operand1, operand2);
    }
}

活动 15:理解 Java 中的继承和多态

解决方案:

  1. 创建一个继承自AnimalCat类:
 public class Cat extends Animal {
  1. 创建实例变量ownernumberOfTeethage如下:
//Fields specific to the Cat family
String owner;
int numberOfTeeth;
int age;
  1. 创建main()方法如下:
public static void main(String[] args){
Cat myCat = new Cat();
//Since Cat inherits from Animal, we have access to it's methods and fields
//We don't need to redefine these methods and fields
myCat.setFamily("Cat");
myCat.setName("Puppy");
myCat.ears = 2;
myCat.legs = 4;
myCat.eyes = 2;
System.out.println(myCat.getFamily());
System.out.println(myCat.getName());
System.out.println(myCat.ears);
System.out.println(myCat.legs);
System.out.println(myCat.eyes);
}
}

输出如下

Cat
Puppy
2
4
2

第 5 课:深入面向对象编程

活动 16:在 Java 中创建和实现接口

解决方案:

  1. 从我们之前的课程中打开Animals项目。

  2. 创建一个名为AnimalBehavior的新接口。

  3. 在此创建两个方法void move()void makeSound()。

  4. 创建一个名为Cow的新的public类,并实现AnimalBehavior接口。重写这两个方法,但现在先留空。

  5. Cow类中,创建两个字段,如下所示:

public class Cow implements AnimalBehavior, AnimalListener {
String sound;
String movementType;

编辑重写的方法,使其如下所示:

@Override
public void move() {
    this.movementType = "Walking";
    this.onAnimalMoved();
}
@Override
public void makeSound() {
    this.sound = "Moo";
    this.onAnimalMadeSound();
}
  1. 创建另一个名为AnimalListener的接口,其中包含以下方法:
public interface AnimalListener {
   void onAnimalMoved();
   void onAnimalMadeSound();
}
  1. Cow类也实现这个接口。确保你重写接口中的两个方法。

  2. 编辑两个方法,使其如下所示:

@Override
   public void onAnimalMoved() {
       System.out.println("Animal moved: " + this.movementType);
   }
@Override
public void onAnimalMadeSound() {
    System.out.println("Sound made: " + this.sound);
}
  1. 最后,创建一个main方法来测试你的代码:
public static void main(String[] args){
   Cow myCow = new Cow();
   myCow.move();
   myCow.makeSound();
}
}
  1. 运行Cow类并查看输出。它应该看起来像这样:
Animal moved: Walking
Sound made: Moo

活动 17:使用 instanceof 和类型转换

解决方案:

  1. 导入Random包以生成随机员工:
import java.util.Random;
  1. 创建一个EmployeeLoader类,作为数据源,如下所示:
public class EmployeeLoader {
  1. 声明一个静态伪随机生成器如下:
private static Random random = new Random(15);
  1. 生成一个新的随机选择的员工如下:
public static Employee getEmployee() {
        int nextNumber = random.nextInt(4);
        switch(nextNumber) {
            case 0:
                // A sales person with total sales between 5000 and 1550000
                double grossSales = random.nextDouble() * 150000 + 5000;
                return new SalesWithCommission(grossSales);
            case 1:
                return new Manager();
            case 2:
                return new Engineer();
            case 3:
                return new Sales();
            default:
                return new Manager();
        }
    }
  1. 创建另一个名为SalesWithCommission的文件,该文件扩展Sales。添加一个接收毛销售额作为 double 的构造函数,并将其存储为字段。还添加一个名为getCommission的方法,该方法返回毛销售额乘以 15%(0.15)的 double:
public class SalesWithCommission extends Sales implements Employee {
    private final double grossSales;
    public SalesWithCommission(double grossSales) {
        this.grossSales = grossSales;
    }
    public double getCommission() {
        return grossSales * 0.15;
    }
}
  1. 编写一个名为ShowSalaryAndCommission的类,其中包含main()方法,该方法在for循环中重复调用getEmployee()并打印有关员工工资和税收的信息。如果员工是SalesWithCommission的实例,还要打印他的佣金:
public class ShowSalaryAndCommission {
    public static void main (String [] args) {
        for (int i = 0; i < 10; i++) {
            Employee employee = EmployeeLoader.getEmployee();
            System.out.println("--- " + employee.getClass().getName());
            System.out.println("Net Salary: " + employee.getNetSalary());
            System.out.println("Tax: " + employee.getTax());
            if (employee instanceof SalesWithCommission) {
                // Cast to sales with commission
                SalesWithCommission sales = (SalesWithCommission) employee;
                System.out.println("Commission: " + sales.getCommission());
            }
        }
    }
}

活动 18:理解 Java 中的类型转换

解决方案:

  1. 打开我们的Animals项目。

  2. 创建一个名为AnimalTest的新类,并在其中创建main方法:

public class AnimalTest {
   public static void  main(String[] args){
   }
}
  1. main方法中,创建两个变量:
Cat cat = new Cat();
Cow cow = new Cow();
  1. 打印cat的所有者:
System.out.println(cat.owner);
  1. cat向上转型为Animal,再次尝试打印所有者。你得到了什么错误?为什么?
Animal animal = (Animal)cat;
System.out.println(animal.owner);

错误消息如下:

图 5.7:在向上转型时访问子类变量时出现异常

原因:由于我们进行了向上转型,所以我们不能再访问子类的变量。

  1. 打印cow的声音:
System.out.println(cow.sound);
  1. 尝试将cow向上转型为Animal。为什么会出错?为什么?
Animal animal1 = (Animal)cow;

错误消息如下:

图 5.8:将 cow 向上转型为 Animal 时出现异常

原因:牛没有继承自 Animal 类,所以它们不共享相同的层次树。

  1. animal向下转型为cat1并再次打印所有者:
Cat cat1 = (Cat)animal;
System.out.println(cat1.owner);
  1. 完整的AnimalTest类应该如下所示:
public class AnimalTest {
   public static void  main(String[] args){
       Cat cat = new Cat();
       Cow cow = new Cow();
       System.out.println(cat.owner);

       Animal animal = (Animal)cat;
       //System.out.println(animal.owner);
       System.out.println(cow.sound);
       //Animal animal1 = (Animal)cow;
       Cat cat1 = (Cat)animal;
       System.out.println(cat1.owner);
   }
}

输出如下:

图 5.9:AnimalTest 类的输出

活动 19:在 Java 中实现抽象类和方法

解决方案:

  1. 创建一个名为Hospital的新项目并打开它。

  2. src文件夹中,创建一个名为Person的抽象类:

public abstract class Patient {
}
  1. 创建一个返回医院中人员类型的抽象方法。命名此方法为getPersonType(),返回一个字符串:
public abstract String getPersonType();

我们已经完成了抽象类和方法。现在,我们将继承它并实现这个抽象方法。

  1. 创建一个名为 Doctor 的继承自 Person 类的新类:
public class Doctor extends Patient {
}
  1. 在我们的Doctor类中重写getPersonType抽象方法。返回字符串"Arzt"。这是德语中的医生:
@Override
public String getPersonType() {
   return "Arzt";
}
  1. 创建另一个名为Patient的类来代表医院中的患者。同样,确保该类继承自Person并重写getPersonType方法。返回"Kranke"。这是德语中的患者:
public class People extends Patient{
   @Override
   public String getPersonType() {
       return "Kranke";
   }
}

现在,我们有了两个类。我们将使用第三个测试类来测试我们的代码。

  1. 创建第三个名为HospitalTest的类。我们将使用这个类来测试我们之前创建的两个类。

  2. HospitalTest类中,创建main方法:

public class HospitalTest {
   public static void main(String[] args){

   }
}
  1. main方法中,创建一个Doctor的实例和另一个Patient的实例:
Doctor doctor = new Doctor();
People people = new People();
  1. 尝试为每个对象调用getPersonType方法并将其打印到控制台。输出是什么?
String str = doctor.getPersonType();
String str1 = patient.getPersonType();
System.out.println(str);
System.out.println(str1);

输出如下:

图 5.10:调用 getPersonType()的输出

活动 20:使用抽象类封装通用逻辑

解决方案:

  1. 创建一个抽象类GenericEmployee,它具有一个接收总工资并将其存储在字段中的构造函数。它应该实现 Employee 接口,并具有两个方法:getGrossSalary()getNetSalary()。第一个将只返回传递给构造函数的值。后者将返回总工资减去调用getTax()方法的结果:
public abstract class GenericEmployee implements Employee {
    private final double grossSalary;
    public GenericEmployee(double grossSalary) {
        this.grossSalary = grossSalary;
    }
    public double getGrossSalary() {
        return grossSalary;
    }
    @Override
    public double getNetSalary() {
        return grossSalary - getTax();
    }
}
  1. 创建每种类型员工的新通用版本:GenericEngineer。它将需要一个接收总工资并将其传递给超级构造函数的构造函数。它还需要实现getTax()方法,返回每个类的正确税值:
public class GenericEngineer extends GenericEmployee {
    public GenericEngineer(double grossSalary) {
        super(grossSalary);
    }
    @Override
    public double getTax() {
        return (22.0/100) * getGrossSalary();
    }
}
  1. 创建每种类型员工的新通用版本:GenericManager。它将需要一个接收总工资并将其传递给超级构造函数的构造函数。它还需要实现getTax()方法,返回每个类的正确税值:
public class GenericManager extends GenericEmployee {
    public GenericManager(double grossSalary) {
        super(grossSalary);
    }
    @Override
    public double getTax() {
        return (28.0/100) * getGrossSalary();
    }
}
  1. 创建每种类型员工的新通用版本:GenericSales。它将需要一个接收总工资并将其传递给超级构造函数的构造函数。它还需要实现getTax()方法,返回每个类的正确税值:
public class GenericSales extends GenericEmployee {
    public GenericSales(double grossSalary) {
        super(grossSalary);
    }
    @Override
    public double getTax() {
        return (19.0/100) * getGrossSalary();
    }
}
  1. 创建每种类型员工的新通用版本:GenericSalesWithCommission。它将需要一个接收总工资并将其传递给超级构造函数的构造函数。它还需要实现getTax()方法,返回每个类的正确税值。记得在GenericSalesWithCommission类中也接收总销售额,并添加计算佣金的方法:
public class GenericSalesWithCommission extends GenericEmployee {
    private final double grossSales;
    public GenericSalesWithCommission(double grossSalary, double grossSales) {
        super(grossSalary);
        this.grossSales = grossSales;
    }
    public double getCommission() {
        return grossSales * 0.15;
    }
    @Override
    public double getTax() {
        return (19.0/100) * getGrossSalary();
    }
}
  1. EmployeeLoader类添加一个新方法getEmployeeWithSalary。此方法将在返回之前为新创建的员工生成一个介于 70,000 和 120,000 之间的随机工资。记得在创建GenericSalesWithCommission员工时也提供总销售额:
public static Employee getEmployeeWithSalary() {
        int nextNumber = random.nextInt(4);
        // Random salary between 70,000 and 70,000 + 50,000
        double grossSalary = random.nextDouble() * 50000 + 70000;
        switch(nextNumber) {
            case 0:
                // A sales person with total sales between 5000 and 1550000
                double grossSales = random.nextDouble() * 150000 + 5000;
                return new GenericSalesWithCommission(grossSalary, grossSales);
            case 1:
                return new GenericManager(grossSalary);
            case 2:
                return new GenericEngineer(grossSalary);
            case 3:
                return new GenericSales(grossSalary);
            default:
                return new GenericManager(grossSalary);
        }
    }
}
  1. 编写一个应用程序,从for循环内多次调用getEmployeeWithSalary方法。此方法将像上一个活动中的方法一样工作:打印所有员工的净工资和税收。如果员工是GenericSalesWithCommission的实例,还要打印他的佣金。
public class UseAbstractClass {
    public static void main (String [] args) {
        for (int i = 0; i < 10; i++) {
            Employee employee = EmployeeLoader.getEmployeeWithSalary();
            System.out.println("--- " + employee.getClass().getName());
            System.out.println("Net Salary: " + employee.getNetSalary());
            System.out.println("Tax: " + employee.getTax());
            if (employee instanceof GenericSalesWithCommission) {
                // Cast to sales with commission
                GenericSalesWithCommission sales = (GenericSalesWithCommission) employee;
                System.out.println("Commission: " + sales.getCommission());
            }
        }
    }
}

第 6 课:数据结构、数组和字符串

活动 21:在数组中找到最小的数字

解决方案:

  1. 在名为ExampleArray的新类文件中设置main方法:
public class ExampleArray {
  public static void main(String[] args) {
  }
}
  1. 创建一个包含 20 个数字的数组:
double[] array = {14.5, 28.3, 15.4, 89.0, 46.7, 25.1, 9.4, 33.12, 82, 11.3, 3.7, 59.99, 68.65, 27.78, 16.3, 45.45, 24.76, 33.23, 72.88, 51.23};
  1. 将最小的浮点数设为第一个数字
double min = array[0];
  1. 创建一个 for 循环来检查数组中的所有数字
for (doublefloat f : array) {
}
  1. 使用 if 来测试每个数字是否小于最小值。如果小于最小值,则将该数字设为新的最小值:
if (f < min)
min = f;
}
  1. 循环完成后,打印出最小的数字:
System.out.println("The lowest number in the array is " + min);
}
}

完整的代码应该如下所示。

public class ExampleArray {
        public static void main(String[] args) {
            double[] array = {14.5, 28.3, 15.4, 89.0, 46.7, 25.1, 9.4, 33.12, 82, 11.3, 3.7, 59.99, 68.65, 27.78, 16.3, 45.45, 24.76, 33.23, 72.88, 51.23};
            double min = array[0];
            for (double f : array) {
                if (f < min)
                    min = f;
            }
            System.out.println("The lowest number in the array is " + min);
        }
}

活动 22:带有操作符数组的计算器

解决方案:

  1. 创建一个名为Operators的类,它将包含基于字符串确定要使用的操作符的逻辑。在这个类中创建一个public常量字段default_operator,它将是Operator类的一个实例。然后创建另一个名为operators的常量字段,类型为Operator数组,并使用每个操作符的实例进行初始化:
public class Operators {
    public static final Operator DEFAULT_OPERATOR = new Operator();
    public static final Operator [] OPERATORS = {
        new Division(),
        new Multiplication(),
        DEFAULT_OPERATOR,
        new Subtraction(),
    };
  1. Operators类中,添加一个名为findOperatorpublic static方法,该方法接收操作符作为字符串并返回Operator的实例。在其中迭代可能的操作符数组,并对每个操作符使用matches方法,返回所选操作符,如果没有匹配任何操作符,则返回默认操作符:
public static Operator findOperator(String operator) {
        for (Operator possible : OPERATORS) {
            if (possible.matches(operator)) {
                return possible;
            }
        }
        return DEFAULT_OPERATOR;
    }
}
  1. 创建一个新的CalculatorWithDynamicOperator类,其中包含三个字段:operand1operator2double类型,operatorOperator类型:
public class CalculatorWithDynamicOperator {
    private final double operand1;
    private final double operand2;
    // The current operator
    private final Operator operator;
  1. 添加一个接收三个参数的构造函数:operand1operand2的类型为doubleoperator为 String 类型。在构造函数中,不要使用 if-else 来选择操作符,而是使用Operators.findOperator方法来设置操作符字段:
public CalculatorWithDynamicOperator(double operand1, double operand2, String operator) {
        this.operand1 = operand1;
        this.operand2 = operand2;
        this.operator = Operators.findOperator(operator);
    }
    public double operate() {
        return operator.operate(operand1, operand2);
    }
  1. 添加一个main方法,在其中多次调用Calculator类并打印结果:
public static void main (String [] args) {
        System.out.println("1 + 1 = " + new CalculatorWithDynamicOperator(1, 1, "+").operate());
        System.out.println("4 - 2 = " + new CalculatorWithDynamicOperator(4, 2, "-").operate());
        System.out.println("1 x 2 = " + new CalculatorWithDynamicOperator(1, 2, "x").operate());
        System.out.println("10 / 2 = " + new CalculatorWithDynamicOperator(10, 2, "/").operate());
    }
}

活动 23:使用 ArrayList

解决方案:

  1. java.util导入ArrayListIterator
import java.util.ArrayList;
import java.util.Iterator;
  1. 创建一个名为StudentsArray的新类:
public class StudentsArray extends Student{
  1. main方法中定义一个Student对象的ArrayList。插入 4 个学生实例,用我们之前创建的不同类型的构造函数实例化:
public static void main(String[] args){
       ArrayList<Student> students = new ArrayList<>();
       Student james = new Student();
       james.setName("James");
       Student mary = new Student();
       mary.setName("Mary");
       Student jane = new Student();
       jane.setName("Jane");
       Student pete = new Student();
       pete.setName("Pete");
       students.add(james);
       students.add(mary);
       students.add(jane);
       students.add(pete);
  1. 为您的列表创建一个迭代器并打印每个学生的姓名:
       Iterator studentsIterator = students.iterator();
       while (studentsIterator.hasNext()){
           Student student = (Student) studentsIterator.next();
           String name = student.getName();
           System.out.println(name);
       }    
  1. 清除所有的“学生”:
       students.clear();
   }
}

最终的代码应该如下所示:

import java.util.ArrayList;
import java.util.Iterator;
public class StudentsArray extends Student{
   public static void main(String[] args){
       ArrayList<Student> students = new ArrayList<>();
       Student james = new Student();
       james.setName("James");
       Student mary = new Student();
       mary.setName("Mary");
       Student jane = new Student();
       jane.setName("Jane");
       students.add(james);
       students.add(mary);
       students.add(jane);
       Iterator studentsIterator = students.iterator();
       while (studentsIterator.hasNext()){
           Student student = (Student) studentsIterator.next();
           String name = student.getName();
           System.out.println(name);
       }

       students.clear();
   }
}

输出如下:

图 6.30:StudentsArray 类的输出

活动 24:输入一个字符串并将其长度输出为数组

解决方案:

  1. 导入java.util.Scanner包:
import java.util.Scanner;
  1. 创建一个名为NameTell的公共类和一个main方法:
public class NameTell
{
  public static void main(String[] args)
  {
  1. 使用ScannernextLine在提示“输入您的姓名:”处输入一个字符串
System.out.print("Enter your name:");
Scanner sc = new Scanner(System.in);
String name = sc.nextLine();
  1. 计算字符串的长度并找到第一个字符:
int num = name.length();
char c = name.charAt(0);
  1. 打印一个输出:
System.out.println("\n Your name has " + num + " letters including spaces.");
System.out.println("\n The first letter is: " + c);
  }
}

输出如下:

图 6.31:NameTell 类的输出

活动 25:计算器从输入中读取

解决方案:

  1. 创建一个名为CommandLineCalculator的新类,其中包含一个main()方法:
import java.util.Scanner;
public class CommandLineCalculator {
    public static void main (String [] args) throws Exception {
        Scanner scanner = new Scanner(System.in);
  1. 使用无限循环使应用程序保持运行,直到用户要求退出。
while (true) {
            printOptions();
            String option = scanner.next();
            if (option.equalsIgnoreCase("Q")) {
                break;
            }
  1. 收集用户输入以决定要执行的操作。如果操作是Qq,则退出循环:
System.out.print("Type first operand: ");
            double operand1 = scanner.nextDouble();
            System.out.print("Type second operand: ");
            double operand2 = scanner.nextDouble();
            Operator operator = Operators.findOperator(option);
            double result = operator.operate(operand1, operand2);
            System.out.printf("%f %s %f = %f\n", operand1, operator.operator, operand2, result);
            System.out.println();
        }
    }
  1. 如果操作是其他任何操作,请查找操作符并请求另外两个输入,这些输入将是覆盖它们为 double 的操作数:
  private static void printOptions() {
        System.out.println("Q (or q) - To quit");
        System.out.println("An operator. If not supported, will use sum.");
        System.out.print("Type your option: ");
    }
}

在找到的操作符上调用operate方法并将结果打印到控制台。

活动 26:从字符串中删除重复字符

解决方案:

  1. 创建一个名为 Unique 的类,如下所示:
public class Unique {
  1. 创建一个名为removeDups的新方法,该方法接受并返回一个字符串。这就是我们的算法所在的地方。此方法应该是publicstatic的:
public static String removeDups(String string){
  1. 在方法内部,检查字符串是否为 null、空或长度为 1。如果这些情况中的任何一个为真,则只返回原始字符串,因为不需要检查:
if (string == null)
           return string;
       if (string == "")
           return string;
       if (string.length() == 1)
           return string;
  1. 创建一个名为result的空字符串。这将是要返回的唯一字符串:
String result = "";
  1. 创建一个从0到传递到方法中的字符串长度的 for 循环。在for循环内,获取字符串当前索引处的字符。将变量命名为c。还创建一个名为isDuplicateboolean并将其初始化为false。当我们遇到重复时,我们将其更改为true
for (int i = 0; i < string.length() ; i++){
           char c = string.charAt(i);
           boolean isDuplicate = false;
  1. 创建另一个嵌套的for循环,从0resultlength()。在内部的for循环中,还要获取结果当前索引处的字符。将其命名为d。比较cd。如果它们相等,则将isDuplicate设置为truebreak。关闭内部的for循环并进入第一个for循环。检查isDuplicate是否为 false。如果是,则将c附加到结果。退出第一个for循环并返回结果。这就结束了我们的算法:
for (int j = 0; j < result.length(); j++){
               char d = result.charAt(j);
               if (c  == d){ //duplicate found
                   isDuplicate = true;
                   break;
               }
           }
           if (!isDuplicate)
               result += ""+c;
       }
       return result;
   }
  1. 创建一个如下所示的main()方法:
public static void main(String[] args){
       String a = "aaaaaaa";
       String b = "aaabbbbb";
       String c = "abcdefgh";
       String d = "Ju780iu6G768";
       System.out.println(removeDups(a));
       System.out.println(removeDups(b));
       System.out.println(removeDups(c));
       System.out.println(removeDups(d));
   }
}

输出如下:

图 6.32:Unique 类的输出

完整的代码如下:

public class Unique {
   public static String removeDups(String string){
       if (string == null)
           return string;
       if (string == "")
           return string;
       if (string.length() == 1)
           return string;
      String result = "";
       for (int i = 0; i < string.length() ; i++){
           char c = string.charAt(i);
           boolean isDuplicate = false;
           for (int j = 0; j < result.length(); j++){
               char d = result.charAt(j);
               if (c  == d){ //duplicate found
                   isDuplicate = true;
                   break;
               }
           }
           if (!isDuplicate)
               result += ""+c;
       }
       return result;
   }
public static void main(String[] args){
       String a = "aaaaaaa";
       String b = "aaabbbbb";
       String c = "abcdefgh";
       String d = "Ju780iu6G768";
       System.out.println(removeDups(a));
       System.out.println(removeDups(b));
       System.out.println(removeDups(c));
       System.out.println(removeDups(d));
   }
}

输出如下:

图 6.30:Unique 类的输出

图 6.33:Unique 类的输出

第 7 课:Java 集合框架和泛型

活动 27:使用具有初始容量的数组从 CSV 中读取用户

解决方案:

  1. 创建一个名为UseInitialCapacity的类,其中包含一个main()方法
public class UseInitialCapacity {
  public static final void main (String [] args) throws Exception {
  }
}
  1. 添加一个常量字段,它将是数组的初始容量。当数组需要增长时,也将使用它:
private static final int INITIAL_CAPACITY = 5;
  1. 添加一个static方法,用于调整数组大小。它接收两个参数:一个用户数组和一个表示数组新大小的int。它还应返回一个用户数组。使用System.arraycopy实现调整大小算法,就像在上一个练习中所做的那样。请注意,新大小可能小于传入数组的当前大小:
private static User[] resizeArray(User[] users, int newCapacity) {
  User[] newUsers = new User[newCapacity];
  int lengthToCopy = newCapacity > users.length ? users.length : newCapacity;
  System.arraycopy(users, 0, newUsers, 0, lengthToCopy);
  return newUsers;
}
  1. 编写另一个static方法,将用户从 CSV 文件加载到数组中。它需要确保数组有能力接收从文件加载的用户。您还需要确保在加载用户后,数组不包含额外的插槽:
public static User[] loadUsers(String pathToFile) throws Exception {
  User[] users = new User[INITIAL_CAPACITY];
  BufferedReader lineReader = new BufferedReader(new FileReader(pathToFile));
  try (CSVReader reader = new CSVReader(lineReader)) {
    String [] row = null;
    while ( (row = reader.readRow()) != null) {
      // Reached end of the array
      if (users.length == reader.getLineCount()) {
        // Increase the array by INITIAL_CAPACITY
        users = resizeArray(users, users.length + INITIAL_CAPACITY);
      }
      users[users.length - 1] = User.fromValues(row);
    } // end of while

    // If read less rows than array capacity, trim it
    if (reader.getLineCount() < users.length - 1) {
      users = resizeArray(users, reader.getLineCount());
    }
  } // end of try

  return users;
}
  1. main方法中,调用加载用户的方法并打印加载的用户总数:
User[] users = loadUsers(args[0]);
System.out.println(users.length);
  1. 添加导入:
import java.io.BufferedReader;
import java.io.FileReader;

输出如下:

27

活动 28:使用 Vector 读取真实数据集

解决方案:

  1. 在开始之前,将您的CSVLoader更改为支持没有标题的文件。为此,添加一个新的构造函数,接收一个boolean,告诉它是否应该忽略第一行:
public CSVReader(BufferedReader reader, boolean ignoreFirstLine) throws IOException {
  this.reader = reader;
  if (ignoreFirstLine) {
    reader.readLine();
  }
}
  1. 将旧构造函数更改为调用此新构造函数,传递 true 以忽略第一行。这将避免您返回并更改任何现有代码:
public CSVReader(BufferedReader reader) throws IOException {
  this(reader, true);
}
  1. 创建一个名为CalculateAverageSalary的类,其中包含main方法:
public class CalculateAverageSalary {
  public static void main (String [] args) throws Exception {
  }
}
  1. 创建另一个方法,从 CSV 中读取数据并将工资加载到 Vector 中。该方法应在最后返回 Vector:
private static Vector loadWages(String pathToFile) throws Exception {
  Vector result = new Vector();
  FileReader fileReader = new FileReader(pathToFile);
  BufferedReader bufferedReader = new BufferedReader(fileReader);
  try (CSVReader csvReader = new CSVReader(bufferedReader, false)) {
    String [] row = null;
    while ( (row = csvReader.readRow()) != null) {
      if (row.length == 15) { // ignores empty lines
        result.add(Integer.parseInt(row[2].trim()));
      }
    }
  }
  return result;
}
  1. main方法中,调用loadWages方法并将加载的工资存储在 Vector 中。还要存储应用程序启动时的初始时间:
Vector wages = loadWages(args[0]);
long start = System.currentTimeMillis();
  1. 初始化三个变量来存储所有工资的最小值、最大值和总和:
int totalWage = 0;
int maxWage = 0;
int minWage = Integer.MAX_VALUE;
  1. for-each循环中,处理所有工资,存储最小值、最大值并将其添加到总和中:
for (Object wageAsObject : wages) {
  int wage = (int) wageAsObject;
  totalWage += wage;
  if (wage > maxWage) {
    maxWage = wage;
  }
  if (wage < minWage) {
    minWage = wage;
  }
}
  1. 最后打印加载的工资数量和加载和处理它们所花费的总时间。还打印平均工资、最低工资和最高工资:
System.out.printf("Read %d rows in %dms\n", wages.size(), System.currentTimeMillis() - start);
System.out.printf("Average, Min, Max: %d, %d, %d\n", totalWage / wages.size(), minWage, maxWage);
  1. 添加导入:
import java.io.BufferedReader;
import java.io.FileReader;
import java.util.Vector;

输出如下:

Read 32561 rows in 198ms
Average, Min, Max: 57873, 12285, 1484705

活动 29:对用户的 Vector 进行迭代

解决方案:

  1. 创建一个名为IterateOnUsersVector的新类,其中包含main方法:
public class IterateOnUsersVector {
  public static void main(String [] args) throws IOException {
  }
}
  1. 在主方法中,调用UsersLoader.loadUsersInVector,传递从命令行传递的第一个参数作为要加载的文件,并将数据存储在 Vector 中:
Vector users = UsersLoader.loadUsersInVector(args[0]);
  1. 使用for-each循环迭代用户 Vector,并将有关用户的信息打印到控制台:
for (Object userAsObject : users) {
  User user = (User) userAsObject;
  System.out.printf("%s - %s\n", user.name, user.email);
}
  1. 添加导入:
import java.io.IOException;
import java.util.Vector;

输出如下:

Bill Gates - william.gates@microsoft.com
Jeff Bezos - jeff.bezos@amazon.com
Marc Benioff - marc.benioff@salesforce.com
Bill Gates - william.gates@microsoft.com
Jeff Bezos - jeff.bezos@amazon.com
Sundar Pichai - sundar.pichai@google.com
Jeff Bezos - jeff.bezos@amazon.com
Larry Ellison - lawrence.ellison@oracle.com
Marc Benioff - marc.benioff@salesforce.com
Larry Ellison - lawrence.ellison@oracle.com
Jeff Bezos - jeff.bezos@amazon.com
Bill Gates - william.gates@microsoft.com
Sundar Pichai - sundar.pichai@google.com
Jeff Bezos - jeff.bezos@amazon.com
Sundar Pichai - sundar.pichai@google.com
Marc Benioff - marc.benioff@salesforce.com
Larry Ellison - lawrence.ellison@oracle.com
Marc Benioff - marc.benioff@salesforce.com
Jeff Bezos - jeff.bezos@amazon.com
Marc Benioff - marc.benioff@salesforce.com
Bill Gates - william.gates@microsoft.com
Sundar Pichai - sundar.pichai@google.com
Larry Ellison - lawrence.ellison@oracle.com
Bill Gates - william.gates@microsoft.com
Larry Ellison - lawrence.ellison@oracle.com
Jeff Bezos - jeff.bezos@amazon.com
Sundar Pichai - sundar.pichai@google.com

活动 30:使用 Hashtable 对数据进行分组

解决方案:

  1. 创建一个名为GroupWageByEducation的类,其中包含一个main方法:
public class GroupWageByEducation {
  public static void main (String [] args) throws Exception {
  }
}
  1. 创建一个static方法,创建并返回一个键类型为 String,值类型为整数向量的Hashtable
private static Hashtable<String, Vector<Integer>> loadWages(String pathToFile) throws Exception {
  Hashtable<String, Vector<Integer>> result = new Hashtable<>();
  return result;
}
  1. 在创建Hashtable和返回它之间,加载来自 CSV 的行,确保它们具有正确的格式:
FileReader fileReader = new FileReader(pathToFile);
BufferedReader bufferedReader = new BufferedReader(fileReader);
try (CSVReader csvReader = new CSVReader(bufferedReader, false)) {
  String [] row = null;
  while ( (row = csvReader.readRow()) != null) {
    if (row.length == 15) {
    }
  }
}
  1. while循环内的if中,获取记录的教育水平和工资:
String education = row[3].trim();
int wage = Integer.parseInt(row[2].trim());
  1. Hashtable中找到与当前教育水平相对应的 Vector,并将新工资添加到其中:
// Get or create the vector with the wages for the specified education
Vector<Integer> wages = result.getOrDefault(education, new Vector<>());
wages.add(wage);
// Ensure the vector will be in the hashtable next time
result.put(education, wages);
  1. 在主方法中,调用您的loadWages方法,传递命令行的第一个参数作为要加载数据的文件:
Hashtable<String,Vector<Integer>> wagesByEducation = loadWages(args[0]);
  1. 使用for-each循环迭代Hashtable条目,并为每个条目获取相应工资的 Vector,并初始化最小值、最大值和总和变量:
for (Entry<String, Vector<Integer>> entry : wagesByEducation.entrySet()) {
  Vector<Integer> wages = entry.getValue();
  int totalWage = 0;
  int maxWage = 0;
  int minWage = Integer.MAX_VALUE;
}
  1. 初始化变量后,遍历所有工资并存储最小值、最大值和总和:
for (Integer wage : wages) {
  totalWage += wage;
  if (wage > maxWage) {
    maxWage = wage;
  }
  if (wage < minWage) {
    minWage = wage;
  }
}
  1. 然后,打印找到的指定条目的信息,该条目表示教育水平:
System.out.printf("%d records found for education %s\n", wages.size(), entry.getKey());
System.out.printf("\tAverage, Min, Max: %d, %d, %d\n", totalWage / wages.size(), minWage, maxWage);
  1. 添加导入:
import java.io.BufferedReader;
import java.io.FileReader;
import java.util.Hashtable;
import java.util.Map.Entry;
import java.util.Vector;

输出如下:

1067 records found for education Assoc-acdm
        Average, Min, Max: 193424, 19302, 1455435
433 records found for education 12th
        Average, Min, Max: 199097, 23037, 917220
1382 records found for education Assoc-voc
        Average, Min, Max: 181936, 20098, 1366120
5355 records found for education Bachelors
        Average, Min, Max: 188055, 19302, 1226583
51 records found for education Preschool
        Average, Min, Max: 235889, 69911, 572751
10501 records found for education HS-grad
        Average, Min, Max: 189538, 19214, 1268339
168 records found for education 1st-4th
        Average, Min, Max: 239303, 34378, 795830
333 records found for education 5th-6th
        Average, Min, Max: 232448, 32896, 684015
576 records found for education Prof-school
        Average, Min, Max: 185663, 14878, 747719
514 records found for education 9th
        Average, Min, Max: 202485, 22418, 758700
1723 records found for education Masters
        Average, Min, Max: 179852, 20179, 704108
933 records found for education 10th
        Average, Min, Max: 196832, 21698, 766115
413 records found for education Doctorate
        Average, Min, Max: 186698, 19520, 606111
7291 records found for education Some-college
        Average, Min, Max: 188742, 12285, 1484705
646 records found for education 7th-8th
        Average, Min, Max: 188079, 20057, 750972
1175 records found for education 11th
        Average, Min, Max: 194928, 19752, 806316

活动 31:对用户进行排序

解决方案:

  1. 编写一个比较器类来比较用户的 ID:
import java.util.Comparator;
public class ByIdComparator implements Comparator<User> {
  public int compare(User first, User second) {
    if (first.id < second.id) {
      return -1;
    }
    if (first.id > second.id) {
      return 1;
    }
    return 0;
  }
}
  1. 编写一个比较器类,按电子邮件比较用户:
import java.util.Comparator;
public class ByEmailComparator implements Comparator<User> {
  public int compare(User first, User second) {
    return first.email.toLowerCase().compareTo(second.email.toLowerCase());
  }
}
  1. 编写一个比较器类,按用户名比较用户:
import java.util.Comparator;
public class ByNameComparator implements Comparator<User> {
  public int compare(User first, User second) {
    return first.name.toLowerCase().compareTo(second.name.toLowerCase());
  }
}
  1. 创建一个名为SortUsers的新类,其中包含一个main方法,该方法按电子邮件加载唯一的用户:
public class SortUsers {
  public static void main (String [] args) throws IOException {
    Hashtable<String, User> uniqueUsers = UsersLoader.loadUsersInHashtableByEmail(args[0]);
  }
}
  1. 加载用户后,将用户转移到用户的 Vector 中,以便保留顺序,因为Hashtable不会这样做:
Vector<User> users = new Vector<>(uniqueUsers.values());
  1. 要求用户选择要按其对用户进行排序的字段,并从标准输入收集输入:
Scanner reader = new Scanner(System.in);
System.out.print("What field you want to sort by: ");
String input = reader.nextLine();
  1. 使用switch语句中的输入来选择要使用的比较器。如果输入无效,则打印友好的消息并退出:
Comparator<User> comparator;
switch(input) {
  case "id":
    comparator = newByIdComparator();
    break;
  case "name":
    comparator = new ByNameComparator();
    break;
  case "email":
    comparator = new ByEmailComparator();
    break;
  default:
    System.out.printf("Sorry, invalid option: %s\n", input);
    return;
}
  1. 告诉用户你要按什么字段排序,并对用户的向量进行排序:
System.out.printf("Sorting by %s\n", input);
Collections.sort(users, comparator);
  1. 使用for-each循环打印用户:
for (User user : users) {
  System.out.printf("%d - %s, %s\n", user.id, user.name, user.email);
}
  1. 添加导入:
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.Hashtable;
import java.util.Scanner;
import java.util.Vector;

输出如下:

5 unique users found.
What field you want to sort by: email
Sorting by email
30 - Jeff Bezos, jeff.bezos@amazon.com
50 - Larry Ellison, lawrence.ellison@oracle.com
20 - Marc Benioff, marc.benioff@salesforce.com
40 - Sundar Pichai, sundar.pichai@google.com
10 - Bill Gates, william.gates@microsoft.com

第 8 课:Java 中的高级数据结构

活动 32:在 Java 中创建自定义链表

解决方案:

  1. 创建一个名为SimpleObjLinkedList的类。
public class SimpleObjLinkedList {
  1. 创建一个名为 Node 的类,表示链表中的每个元素。每个节点将有一个它需要保存的对象,并且将引用下一个节点。LinkedList类将引用头节点,并且可以通过使用Node.getNext()遍历到下一个节点。头部是第一个元素,我们可以通过移动当前节点中的next来遍历到下一个元素。这样,我们可以遍历到列表的最后一个元素:
static class Node {
Object data;
Node next;
Node(Object d) {
data = d;
next = null;
}
Node getNext() {
return next;
}
void setNext(Node node) {
next = node;
}
Object getData() {
return data;
}
}
  1. 实现toString()方法来表示这个对象。从头节点开始,迭代所有节点,直到找到最后一个节点。在每次迭代中,构造存储在每个节点中的对象的字符串表示:
public String toString() {
String delim = ",";
StringBuffer stringBuf = new StringBuffer();
if (head == null)
return "LINKED LIST is empty";    
Node currentNode = head;
while (currentNode != null) {
stringBuf.append(currentNode.getData());
currentNode = currentNode.getNext();
if (currentNode != null)
stringBuf.append(delim);
}
return stringBuf.toString();
}
  1. 实现add(Object item)方法,以便将任何项目/对象添加到此列表中。通过传递newItem = new Node(item) Item 来构造一个新的 Node 对象。从头节点开始,爬到列表的末尾。在最后一个节点中,将下一个节点设置为我们新创建的节点(newItem)。增加索引:
// appends the specified element to the end of this list.    
public void add(Object element) {
// create a new node
Node newNode = new Node(element);
//if head node is empty, create a new node and assign it to Head
//increment index and return
if (head == null) {
head = newNode;
return;
}
Node currentNode = head;
// starting at the head node
// move to last node
while (currentNode.getNext() != null) {
currentNode = currentNode.getNext();
}
// set the new node as next node of current
currentNode.setNext(newNode);
}
  1. 实现get(Integer index)方法,根据索引从列表中检索项目。索引不能小于 0。编写一个逻辑来爬到指定的索引,识别节点,并从节点返回值。
public Object get(int index) {
// Implement the logic returns the element
// at the specified position in this list.
if (head == null || index < 0)
return null;        
if (index == 0){
return head.getData();
}    
Node currentNode = head.getNext();
for (int pos = 0; pos < index; pos++) {
currentNode = currentNode.getNext();
if (currentNode == null)
return null;
}
return currentNode.getData();
}
  1. 实现remove(Integer index)方法,根据索引从列表中删除项目。编写逻辑来爬到指定索引之前的节点并识别节点。在这个节点中,将next设置为getNext()。如果找到并删除了元素,则返回 true。如果未找到元素,则返回 false:
public boolean remove(int index) {
if (index < 0)
return false;
if (index == 0)
{
head = null;
return true;
}
Node currentNode = head;
for (int pos = 0; pos < index-1; pos++) {
if (currentNode.getNext() == null)
return false;
currentNode = currentNode.getNext();
}    
currentNode.setNext(currentNode.getNext().getNext());
return true;
}
  1. 创建一个指向头节点的 Node 类型的成员属性。编写一个main方法,创建一个SimpleObjLinkedList对象,并依次向其中添加五个字符串("INPUT-1","INPUT-2","INPUT-3","INPUT-4","INPUT-5")。打印SimpleObjLinkedList对象。在main方法中,使用get(2)从列表中获取项目并打印检索到的项目的值。还要从列表中删除项目remove(2)并打印列表的值。列表中应该已经删除了一个元素:
Node head;    
    public static void main(String[] args) {
        SimpleObjLinkedList list = new SimpleObjLinkedList();
        list.add("INPUT-1");
        list.add("INPUT-2");
        list.add("INPUT-3");
        list.add("INPUT-4");
        list.add("INPUT-5");
        System.out.println(list);
        System.out.println(list.get(2));
        list.remove(3);
        System.out.println(list);
}
}

输出如下:

[INPUT-1 ,INPUT-2 ,INPUT-3 ,INPUT-4 ,INPUT-5 ]
INPUT-3
[INPUT-1 ,INPUT-2 ,INPUT-3 ,INPUT-5 ]

活动 33:实现 BinarySearchTree 类中的方法,以找到 BST 中的最高和最低值

解决方案:

  1. 使用我们在上一个练习中使用的相同类:BinarySearchTree。添加一个新方法int getLow(),以找到 BST 中的最低值并返回它。正如我们所了解的 BST,最左边的节点将是所有值中最低的。迭代所有左节点,直到达到一个空的左节点,并获取其根的值:
    /**
     * As per BST, the left most node will be lowest of the all. iterate all the
     * left nodes until we reach empty left and get the value of it root.
     * @return int lowestValue
     */
    public int getLow() {
        Node current = parent;
        while (current.left != null) {
            current = current.left;
        }
        return current.data;
    }
  1. 添加一个新方法int getHigh(),以找到 BST 中的最高值并返回它。正如我们所了解的 BST,最右边的节点将是所有值中最高的。迭代所有右节点,直到达到一个空的右节点,并获取其根的值:
    /**
     * As per BST, the right most node will be highest of the all. iterate all
     * the right nodes until we reach empty right and get the value of it root.
     * @return int highestValue
     */
    public int getHigh() {
        Node current = parent;
        while (current.right != null) {
            current = current.right;
        }
        return current.data;
    }
  1. main方法中,构造一个 BST,向其中添加值,然后通过调用getLow()getHigh()来打印最高和最低的值:
/**
     * Main program to demonstrate the BST functionality.
     * - Adding nodes
     * - finding High and low 
     * - Traversing left and right
     * @param args
     */
    public static void main(String args[]) {
        BinarySearchTree bst = new BinarySearchTree();
        // adding nodes into the BST
        bst.add(32);
        bst.add(50);
        bst.add(93);
        bst.add(3);
        bst.add(40);
        bst.add(17);
        bst.add(30);
        bst.add(38);
        bst.add(25);
        bst.add(78);
        bst.add(10);
        //printing lowest and highest value in BST
        System.out.println("Lowest value in BST :" + bst.getLow());
        System.out.println("Highest value in BST :" + bst.getHigh());
    }

输出如下:

Lowest value in BST :3
Highest value in BST :93

活动 34:使用枚举来保存大学部门的详细信息

解决方案:

  1. 使用enum关键字创建一个DeptEnum枚举。添加两个私有属性(String deptNameint deptNo)来保存枚举中的值。重写一个构造函数,以取一个缩写和deptNo并将其放入成员变量中。添加符合构造函数的枚举常量:
    public enum DeptEnum {
    BE("BACHELOR OF ENGINEERING", 1), BCOM("BACHELOR OF COMMERCE", 2), BSC("BACHELOR OF SCIENCE",
            3), BARCH("BACHELOR OF ARCHITECTURE", 4), DEFAULT("BACHELOR", 0);
    private String acronym;
    private int deptNo;
    DeptEnum(String accr, int deptNo) {
        this.accronym = acr;
        this.deptNo = deptNo;
    }
  1. deptNamedeptNo添加 getter 方法:
    public String getAcronym() {
        return acronym;
    }
    public int getDeptNo() {
        return deptNo;
    }
  1. 让我们编写一个main方法和一个示例程序来演示枚举的用法:
public static void main(String[] args) {
// Fetching the Enum using Enum name as string
DeptEnum env = DeptEnum.valueOf("BE");
System.out.println(env.getAcronym() + " : " + env.getDeptNo());
// Printing all the values of Enum
for (DeptEnum e : DeptEnum.values()) {
System.out.println(e.getAcronym() + " : " + e.getDeptNo());    }
// Compare the two enums using the the equals() method or using //the == operator.                
System.out.println(DeptEnum.BE == DeptEnum.valueOf("BE"));
}
}
  1. 输出:
BACHELOR OF ENGINEERING : 1
BACHELOR OF ENGINEERING : 1
BACHELOR OF COMMERCE : 2
BACHELOR OF SCIENCE : 3
BACHELOR OF ARCHITECTURE : 4
BACHELOR : 0
True

活动 35:实现反向查找

解决方案:

  1. 创建一个枚举App,声明常量 BE、BCOM、BSC 和 BARC,以及它们的全称和部门编号。
public enum App {
    BE("BACHELOR OF ENGINEERING", 1), BCOM("BACHELOR OF COMMERCE", 2), BSC("BACHELOR OF SCIENCE", 3), BARCH("BACHELOR OF ARCHITECTURE", 4), DEFAULT("BACHELOR", 0);
  1. 还声明两个私有变量accronymdeptNo
    private String accronym;
    private int deptNo;
  1. 创建一个带参数的构造函数,并将变量accronymdeptNo分配为传递的值。
    App(String accr, int deptNo) {
        this.accronym = accr;
        this.deptNo = deptNo;
    }
  1. 声明一个公共方法getAccronym(),返回变量accronym,以及一个公共方法getDeptNo(),返回变量deptNo
    public String getAccronym() {
        return accronym;
    }
    public int getDeptNo() {
        return deptNo;
    }
  1. 实现反向查找,接受课程名称,并在App枚举中搜索相应的缩写。
    //reverse lookup 
    public static App get(String accr) {
        for (App e : App.values()) {
            if (e.getAccronym().equals(accr))
                return e;
        }
        return App.DEFAULT;
    }
  1. 实现主方法,并运行程序。
    public static void main(String[] args) {

        // Fetching Enum with value of Enum (reverse lookup)
        App noEnum = App.get("BACHELOR OF SCIENCE");
        System.out.println(noEnum.accronym + " : " + noEnum.deptNo);
        // Fetching Enum with value of Enum (reverse lookup)

        System.out.println(App.get("BACHELOR OF SCIENCE").name());
    }
}

您的输出应类似于:

BACHELOR OF SCIENCE : 3
BSC

第 9 课:异常处理

活动 36:处理数字用户输入中的错误

解决方案:

  1. 右键单击src文件夹,然后选择New | Class

  2. 创建一个名为Adder的类,然后单击OK

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. 创建一个名为Adder的类:
import java.util.Scanner;
public class Adder {
  1. main()方法中,使用for循环从用户那里读取值:
   public static void main(String[] args) {
       Scanner input = new Scanner(System.in);
       int total = 0;
       for (int i = 0; i < 3; i++) {
           System.out.print("Enter a whole number: ");
  1. 在同一个循环中,检查是否输入了有效值。如果值有效,则添加一个 try 块来计算三个数字的总和。
           boolean isValid = false;
           while (!isValid) {
               if (input.hasNext()) {
                   String line = input.nextLine();
                   try {
                       int newVal = Integer.parseInt(line);
                       isValid = true;
                       total += newVal;
  1. catch 块应提示用户输入有效数字。
} catch (NumberFormatException e) {
                       System.out.println("Please provide a valid whole number");
                   }
               }
           }
       }
  1. 打印总和:
System.out.println("Total is " + total);
   }
}

将结果打印到控制台。以下是一个没有错误的案例的示例输出:

Enter a whole number: 10
Enter a whole number: 11
Enter a whole number: 12
Total is 33

以下是带有错误的运行的示例输出:

Enter a whole number: 10
Enter a whole number: hello
Please provide a valid whole number
11.1
Please provide a valid whole number
11
Enter a whole number: 12
Total is 33

活动 37:在 Java 中编写自定义异常

解决方案:

  1. 右键单击src文件夹,然后选择New | Class

  2. 输入RollerCoasterWithAge作为类名,然后单击OK

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. 创建一个异常类,TooYoungException
class TooYoungException extends Exception {
   int age;
   String name;
   TooYoungException(int age, String name) {
       this.age = age;
       this.name = name;
   }
}
  1. main()中,创建一个循环,读取访客的姓名:
public class RollerCoasterWithAge {
   public static void main(String[] args) {
       Scanner input = new Scanner(System.in);
       while (true) {
           System.out.print("Enter name of visitor: ");
           String name = input.nextLine().trim();
           if (name.length() == 0) {
               break;
           }
  1. try块,读取访客的年龄,如果年龄低于 15 岁,则抛出TooYoungException,打印乘坐过山车的访客的姓名:
           try {
               System.out.printf("Enter %s's age: ", name);
               int age = input.nextInt();
               input.nextLine();
               if (age < 15) {
                   throw new TooYoungException(age, name);
               }
               System.out.printf("%s is riding the roller coaster.\n", name);
  1. catch 块将显示 15 岁以下访客的消息:
           } catch (TooYoungException e) {
               System.out.printf("%s is %d years old, which is too young to ride.\n", e.name, e.age);
           }
       }
   }
}

活动 38:在一个块中处理多个异常

解决方案:

  1. 右键单击src文件夹,然后选择New | Class

  2. 输入RollerCoasterWithAgeAndHeight作为类名,然后单击OK

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. 创建一个异常类,TooYoungException
class TooYoungException extends Exception {
   int age;
   String name;
   TooYoungException(int age, String name) {
       this.age = age;
       this.name = name;
   }
}
  1. 创建一个异常类,TooShortException
class TooShortException extends Exception {
   int height;
   String name;
   TooShortException(int height, String name) {
       this.height = height;
       this.name = name;
   }
}
  1. main()中,创建一个循环,读取访客的姓名:
public class RollerCoasterWithAgeAndHeight {
   public static void main(String[] args) {
       Scanner input = new Scanner(System.in);
       while (true) {
           System.out.print("Enter name of visitor: ");
           String name = input.nextLine().trim();
           if (name.length() == 0) {
               break;
           }
  1. try块,读取访客的年龄,如果年龄低于 15 岁,则抛出TooYoungException,如果身高低于 130,则抛出TooShortException,并打印乘坐过山车的访客的姓名:
           try {
               System.out.printf("Enter %s's age: ", name);
               int age = input.nextInt();
               input.nextLine();
               if (age < 15) {
                   throw new TooYoungException(age, name);
               }
               System.out.printf("Enter %s's height: ", name);
               int height = input.nextInt();
               input.nextLine();
               if (height < 130) {
                   throw new TooShortException(height, name);
               }
               System.out.printf("%s is riding the roller coaster.\n", name);
           } 
  1. catch 块将显示 15 岁以下或身高低于 130 的访客的消息:
catch (TooYoungException e) {
               System.out.printf("%s is %d years old, which is too young to ride.\n", e.name, e.age);
           } catch (TooShortException e) {
               System.out.printf("%s is %d cm tall, which is too short to ride.\n", e.name, e.height);
           }
       }
   }
}

活动 39:使用多个自定义异常处理

解决方案:

  1. 右键单击src文件夹,然后选择New | Class

  2. 输入RollerCoasterWithAgeAndHeight作为类名,然后单击OK

  3. 导入java.util.Scanner包:

import java.util.Scanner;
  1. 创建一个异常类,TooYoungException
class TooYoungException extends Exception {
   int age;
   String name;
   TooYoungException(int age, String name) {
       this.age = age;
       this.name = name;
   }
}
  1. 创建一个异常类,TooShortException
class TooShortException extends Exception {
   int height;
   String name;
   TooShortException(int height, String name) {
       this.height = height;
       this.name = name;
   }
}
  1. main()中,创建一个循环,读取访客的姓名:
public class Main {
   public static void main(String[] args) {
       Scanner input = new Scanner(System.in);
       while (true) {
           System.out.print("Enter name of visitor: ");
           String name = input.nextLine().trim();
           if (name.length() == 0) {
               break;
           }
  1. try块,读取访客的年龄,如果年龄低于 15 岁,则抛出TooYoungException,如果身高低于 130,则抛出TooShortException,并打印乘坐过山车的访客的姓名:
           try {
               System.out.printf("Enter %s's age: ", name);
               int age = input.nextInt();
               input.nextLine();
               if (age < 15) {
                   throw new TooYoungException(age, name);
               }
               System.out.printf("Enter %s's height: ", name);
               int height = input.nextInt();
               input.nextLine();
               if (height < 130) {
                   throw new TooShortException(height, name);
               }
               System.out.printf("%s is riding the roller coaster.\n", name);
           } 
  1. TooYoungException创建一个 catch 块:
catch (TooYoungException e) {
               System.out.printf("%s is %d years old, which is too young to ride.\n", e.name, e.age);
           } 
  1. TooShortException创建一个 catch 块:
catch (TooShortException e) {
               System.out.printf("%s is %d cm tall, which is too short to ride.\n", e.name, e.height);
           } 
  1. 创建一个最终块,打印一条消息,将访客护送离开场地:
finally {
               System.out.printf("Escorting %s outside the premises.\n", name);
           }
       }
   }
}
posted @ 2025-09-11 09:43  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报