Java17-入门基础知识-全-

Java17 入门基础知识(全)

原文:Beginning Java 17 Fundamentals

协议:CC BY-NC-SA 4.0

一、编程概念

在本章中,您将学习:

  • 编程的一般概念

  • 编程的不同组成部分

  • 主要编程范例

  • 什么是面向对象(OO)范式,它是如何在 Java 中使用的

什么是编程?

术语“编程”在许多上下文中使用。我们讨论它在人机交互环境中的意义。简而言之,编程就是编写一系列指令来告诉计算机执行特定任务的方式。计算机的指令序列被称为程序。一组定义明确的符号用于编写程序。用来编写程序的一套符号被称为编程语言。写程序的人被称为程序员。程序员使用编程语言编写程序。

一个人如何告诉计算机执行一项任务?人能告诉计算机执行任何任务吗,或者计算机有一套预定义的它能执行的任务吗?在我们看人机交流之前,我们先来看看人与人之间的交流。一个人如何与另一个人交流?你会说人与人之间的交流是通过口语来完成的,例如,英语、德语、印地语等。然而,口语并不是人类之间唯一的交流方式。我们也用书面语言或手势交流,而不用说任何话。有些人甚至可以坐在几英里以外的地方交流,而不用任何语言或手势;他们可以在思想层面交流。

要进行成功的交流,仅仅使用像口语或书面语这样的交流媒介是不够的。双方之间成功沟通的主要要求是双方能够理解对方传达的内容。例如,假设有两个人。一个人知道如何说英语,另一个人知道如何说德语。他们能互相交流吗?答案是否定的,因为他们听不懂对方的语言。如果我们在它们之间加一个英德翻译器会怎么样?我们同意,他们将能够在翻译的帮助下交流,即使他们不能直接相互理解。

计算机只能理解二进制格式的指令,二进制格式是 0 和 1 的序列。所有计算机都理解的 0 和 1 的序列被称为机器语言或机器代码。计算机有一套它能理解的固定的基本指令。每台计算机都有自己的一套指令。例如,一台计算机可能使用 0010 作为将两个数字相加的指令,而另一台计算机可能出于相同的目的使用 0101。因此,用机器语言编写的程序是机器相关的。有时机器代码被称为本机代码,因为它是为其编写的机器的本机代码。用机器语言编写的程序很难写、读、理解和修改。假设你想写一个程序,把 15 和 12 这两个数字相加。用机器语言将两个数字相加的程序看起来类似于这里显示的程序。您不需要理解本节中编写的示例代码。这仅仅是为了讨论和说明的目的:

0010010010  10010100000100110
0001000100  01010010001001010

这些指令是把两个数相加。用机器语言写一个程序来执行一个复杂的任务会有多难?基于这段代码,你现在可能意识到用机器语言编写、阅读和理解一个程序是非常困难的。但是计算机不是应该使我们的工作更容易,而不是更困难吗?我们需要用一些更容易书写、阅读和理解的符号来表示计算机的指令,所以计算机科学家们想出了另一种语言,叫做汇编语言。汇编语言提供了不同的符号来编写指令。它比它的前身机器语言更容易写、读和理解。汇编语言使用助记符来表示指令,与机器语言中使用的二进制(0 和 1)相反。用汇编语言编写的将两个数相加的程序如下所示:

li $t1, 15
add $t0, $??, 12

如果比较用两种不同语言编写的执行相同任务的两个程序,你会发现汇编语言比机器代码更容易编写、阅读和理解。对于给定的计算机体系结构,机器语言的指令和汇编语言的指令之间是一一对应的。回想一下,计算机只能理解机器语言的指令。用汇编语言编写的指令必须先翻译成机器语言,计算机才能执行。把汇编语言编写的指令翻译成机器语言的程序叫做汇编程序。图 1-1 显示了汇编代码、汇编程序和机器码之间的关系。

img/323069_3_En_1_Fig1_HTML.png

图 1-1

汇编代码、汇编程序和机器代码之间的关系

机器语言和汇编语言也被称为低级语言,因为程序员必须理解计算机的低级细节才能用这些语言编写程序。例如,如果你在用这些语言编写程序,你需要知道你在哪个内存位置写或者读,哪个寄存器用来存储一个特定的值,等等。很快,程序员们意识到需要一种更高级的编程语言,可以对他们隐藏计算机的底层细节。这种需求导致了诸如 COBOL、Pascal、FORTRAN、C、C++、Java、C#等高级编程语言的发展。高级编程语言使用类似英语的单词、数学符号和标点符号来编写程序。用高级编程语言编写的程序也叫源代码 *。*它们更接近人类熟悉的书面语言。用高级编程语言(例如 Java)编写的将两个数字相加的指令类似于以下内容:

int x = 15 + 12;

你可能会注意到,用高级语言编写的程序比用机器和汇编语言编写的程序更容易、更直观地编写、阅读、理解和修改。你可能已经意识到计算机不能理解用高级语言编写的程序,因为它们只能理解 0 和 1 的序列。因此,需要一种方法将高级语言编写的程序翻译成机器语言。翻译由编译器、解释器或两者的组合来完成。编译器是把用高级编程语言编写的程序翻译成机器语言的程序。编译程序是一个超载的短语。通常,这意味着将高级语言编写的程序翻译成机器语言。有时它被用来指把用高级编程语言编写的程序翻译成低级编程语言,而低级编程语言不一定是机器语言。由编译器生成的代码称为编译代码 *。*编译后的程序由计算机执行。

执行用高级编程语言编写的程序的另一种方法是使用解释器。解释器不会立刻把整个程序翻译成机器语言。相反,它一次读取一条用高级编程语言编写的指令,将其翻译成机器语言,并执行它。你可以把一个解释器看作一个模拟器。有时,编译器和解释器的组合可以用来编译和运行用高级语言编写的程序。例如,用 Java 编写的程序被编译成一种叫做字节码的中间语言。一个解释器,具体称为 Java 平台的 Java 虚拟机(JVM ),用于解释字节码并执行它。解释程序比编译程序运行得慢。今天,大多数 JVM 使用实时(JIT)编译器,根据需要将整个 Java 程序编译成机器语言。有时,另一种称为超前(AOT)编译器的编译器被用来将中间语言(例如 Java 字节码)的程序编译成机器语言。图 1-2 显示了源代码、编译器和机器码之间的关系。

img/323069_3_En_1_Fig2_HTML.png

图 1-2

源代码、编译器和机器码之间的关系

编程语言也分为第一代、第二代、第三代和第四代语言。这种语言的版本越高,用这种语言编写程序就越接近普通的人类语言。机器语言也被称为第一代编程语言或 1GL。汇编语言也被称为第二代编程语言或 2GL。高级过程编程语言,如 C、C++、Java 和 C#,其中您必须使用语言语法编写算法来解决问题,也称为第三代编程语言或 3GLs。高级非过程化编程语言被称为第四代编程语言或 4gl,在这种语言中,您不需要编写算法来解决问题。结构化查询语言(SQL)是使用最广泛的 4GL,用于与数据库通信。

编程语言的组成部分

编程语言是用来为计算机编写指令的符号系统。它可以用三个部分来描述:

  • 句法

  • 语义学

  • 语用学

语法部分处理使用可用的符号形成有效的编程结构。语义部分处理编程结构的含义。语用学部分处理编程语言在实践中的使用。

像书面语言(例如,英语)一样,编程语言具有词汇和语法。编程语言的词汇表由一组单词、符号和标点符号组成。编程语言的语法定义了如何使用该语言的词汇来形成有效的编程结构的规则。您可以将编程语言中的有效编程构造想象成书面语言中的句子,它是使用该语言的词汇和语法形成的。类似地,使用编程语言的词汇和语法来形成编程构造。词汇表和使用该词汇表形成有效编程结构的规则被称为编程语言的语法

在书面语言中,你可能会形成一个语法正确的句子,但这个句子可能没有任何有效的含义。例如,“石头在笑”是一个语法正确的句子。然而,这没有任何意义。在书面语言中,这种歧义是允许的。编程语言意味着向计算机传达指令,没有任何含糊不清的余地。我们不能用模糊的指令与计算机交流。编程语言还有另一个组成部分,叫做语义,它解释了语法上有效的编程结构的含义。编程语言的语义回答了这样一个问题,“这个程序在计算机上运行时做什么?”请注意,语法上有效的编程结构可能在语义上也无效。一个程序在被计算机执行之前必须在语法和语义上是正确的。

编程语言的语用学描述了它的用途和它对用户的影响。用编程语言编写的程序可能在语法和语义上是正确的。但是,它可能不容易被其他程序员理解。这一方面与编程语言的语用学有关。语用学关注的是编程语言的实用方面。它回答了关于编程语言的一些问题,如实现的容易程度、对特定应用的适用性、效率、可移植性、对编程方法的支持等。

编程范例

在线的《韦氏词典学习词典》对“范式”一词的定义如下:

范式是关于应该如何做、做或思考某事的一种理论或一组想法。

一开始,在编程环境中理解“范式”这个词有点困难。编程就是使用编程语言支持的计算模型为现实世界的问题提供解决方案。这个解决方案叫做程序。在我们以程序的形式提供问题的解决方案之前,我们总是对问题及其解决方案有一个心理上的看法。在我讨论如何使用计算模型解决现实世界的问题之前,让我们举一个现实世界的社会问题的例子,一个与计算机无关的问题。

假设地球上有一个地方食物短缺。那个地方的人们没有足够的食物吃。问题是“食物短缺”让我们请三个人提供解决这个问题的方法。这三个人分别是政治家、慈善家和僧侣。政治家会对问题及其解决方案有政治观点。他们可能认为这是一个为他们的同胞服务的机会,通过制定一些法律为饥饿的人提供食物。一个慈善家会提供一些钱/食物来帮助那些饥饿的人,因为他们同情所有的人类,因此也同情那些饥饿的人。一个僧侣会试图用他们的精神观点来解决这个问题。他们可能会对他们说教,让他们自己去工作,去谋生;他们可能会呼吁富人向饥饿的人捐赠食物;或者他们可能会教他们瑜伽来征服他们的饥饿!你看到三个人对同一个现实,也就是“食物短缺”有不同的看法了吗?他们看待现实的方式就是他们的范式。你可以把范式想象成一种在特定背景下看待现实的思维模式。通常有多种范例,让一个人以不同的观点看待同一现实。例如,一个既是慈善家又是政治家的人将有能力以不同的方式看待“食物短缺”问题及其解决方案,一个人有他们的政治思维,一个人有他们的慈善家思维。三个人遇到了同样的问题。他们都提供了解决问题的方法。然而,他们对问题及其解决方案的看法并不相同。我们可以将术语“范式”定义为一组概念和想法,它们构成了一种看待现实的方式。

无论如何,我们为什么需要为一个范例而烦恼呢?如果一个人使用他们的政治、慈善或精神范式来找到解决方案,这有关系吗?最终我们找到了解决问题的方法。不是吗?

仅仅有解决问题的方法是不够的。解决方案必须切实有效。因为问题的解决方案总是与思考问题和解决方案的方式相关,所以范式变得至关重要。你可以看到,和尚提供的解决方案可能会在饥饿的人们得到任何帮助之前杀死他们。慈善家的解决方案可能是一个不错的短期解决方案。这位政治家的解决方案似乎是一个长期的解决方案,也是最好的方案。使用正确的范例来解决一个问题,以得到一个实用的和最有效的解决方案,这总是很重要的。请注意,一种范式不可能是解决所有问题的正确范式。例如,如果一个人在寻求永恒的幸福,他们需要咨询僧侣,而不是政治家或慈善家。

这是著名的计算机科学家 Robert W. Floyd 对术语“编程范式”的定义。他在 1978 年 ACM 图灵奖题为“编程范例”的演讲中给出了这个定义:

编程范例是一种概念化的方式,它意味着执行计算,以及在计算机上执行的任务应该如何构造和组织。

您可以观察到,在编程环境中,单词“paradigm”的含义与日常生活中使用的含义相似。编程用于使用计算机提供的计算模型来解决现实世界的问题。编程范式是您思考和概念化真实世界问题及其在底层计算模型中的解决方案的方式。在您开始使用编程语言编写程序之前,编程范式就已经出现了。这是在分析阶段,当你使用一个特定的范式,以一种特定的方式分析一个问题及其解决方案。编程语言提供了一种适当实现特定编程范例的方法。一种编程语言可以提供使其适合使用一种编程范例而不适合另一种编程范例的特性。

一个程序有两个组成部分——数据和算法。数据用来表示信息片段。算法是对数据进行操作以得出问题解决方案的一组步骤。不同的编程范例涉及通过以不同的方式组合数据和算法来查看问题的解决方案。编程中使用了许多范例。以下是一些常用的编程范例:

  • 命令范式

  • 程序范式

  • 语句范式

  • 功能范式

  • 逻辑范式

  • 面向对象的范例

命令范式

命令式范式也称为算法范式。在命令式范例中,程序由数据和操纵数据的算法(命令序列)组成。特定时间点的数据定义了程序的状态。当命令按特定顺序执行时,程序的状态会发生变化。数据存储在内存中。命令式编程语言提供了引用内存位置的变量、改变变量值的赋值操作以及控制程序流程的其他结构。在命令式编程中,您需要指定解决问题的步骤。

假设你有一个整数,比如说15,你想给它加 10。你的方法是将 1 到 15 相加 10 次,你得到结果,25。你可以用命令式语言写一个程序,把 10 加到 15,如下。请注意,您不需要理解以下代码的语法。试着感受一下:

int num = 15;              // num holds 15 at this point
int counter = 0;           // counter holds 0 at this point
while (counter < 10) {
    num = num + 1;         // Modifying data in num
    counter = counter + 1; // Modifying data in counter
}
// num holds 25 at this point

前两行是变量声明,表示程序的数据部分。while循环代表程序中对数据进行操作的算法部分。循环中的代码被执行十次。在每次迭代中,循环将存储在num变量中的数据递增 1。当循环结束时,它将num的值增加了 10。请注意,命令式编程中的数据是暂时的,而算法是永久的。FORTRAN、COBOL 和 C 是支持命令式范例的编程语言的几个例子。

程序范式

过程范式类似于命令范式,但有一点不同:它将多个命令组合在一个称为过程的单元中。过程作为一个单元执行。执行包含在过程中的命令被称为调用或调用过程。过程语言中的程序由数据和一系列操作数据的过程调用组成。下面这段代码是一个名为addTen的程序的典型代码:

void addTen(int num) {
    int counter = 0;
    while (counter < 10) {
        num = num + 1;          // Modifying data in num
        counter = counter + 1;  // Modifying data in counter
    }
    // num has been incremented by 10
}

addTen过程使用一个占位符(也称为参数)num,它是在执行时提供的。该代码忽略了num的实际值。它只是在num的当前值上加 10。让我们用下面这段代码把 10 加到 15。请注意,addTen程序的代码和以下代码不是使用任何特定的编程语言编写的。这里提供它们只是为了说明的目的:

int x = 15; // x holds 15 at this point
addTen(x);  // Call addTen procedure that will increment x by 10
            // x holds 25 at this point

您可能会注意到命令式范例中的代码和过程式范例中的代码在结构上是相似的。使用过程产生模块化代码并增加算法的可重用性。有些人忽略了这种差异,将命令式和程序式这两种范式视为相同。请注意,即使它们不同,过程范式也总是包含命令范式。在过程范式中,编程的单位不是一系列命令。相反,您将一系列命令抽象成一个过程,而您的程序由一系列过程组成。手术有副作用。它在执行逻辑时修改程序的数据部分。C、C++、Java 和 COBOL 是支持过程范式的编程语言的几个例子。

语句范式

在声明式范例中,程序由问题的描述组成,计算机找到解决方案。这个程序没有具体说明如何解决这个问题。当一个问题被描述给计算机时,它的工作就是得出一个解决方案。对比声明性范式和命令性范式。在命令式范式中,我们关心的是问题的“如何”部分。在声明性范例中,我们关心问题的“是什么”部分。我们关心的是问题是什么,而不是如何解决它。接下来描述的功能范式和逻辑范式是声明性范式的子类型。

使用结构化查询语言(SQL)编写数据库查询属于基于声明性范例的编程,在声明性范例中,您指定想要的数据,数据库引擎计算出如何为您检索数据。与命令式范式不同,在声明式范式中,数据是永久的,而算法是瞬时的。在命令式范例中,数据随着算法的执行而被修改。在声明性范例中,数据作为输入提供给算法,并且输入数据在算法执行时保持不变。该算法产生新数据,而不是修改输入数据。换句话说,在声明性范例中,算法的执行不会产生副作用。

功能范式

函数范式是基于数学函数的概念。您可以将函数想象成一种算法,它从一些给定的输入中计算出一个值。与过程式编程中的过程不同,函数没有副作用。在函数式编程中,值是不可变的。

通过对输入值应用函数来导出新值。输入值不会改变。函数式编程语言不使用用于修改数据的变量和赋值。在命令式编程中,使用循环结构执行重复的任务,例如,while循环。在函数式编程中,使用递归来执行重复的任务,这是一种根据函数本身来定义函数的方法。换句话说,递归函数做一些工作,然后调用自己。

当一个函数应用于相同的输入时,它总是产生相同的输出。可以应用于整数x以将整数n加到其上的函数,比如说add,可以定义如下:

int add(x, n) {
    if (n == 0) {
        return x;
    } else {
        return 1 + add(x, n-1); // Apply the add function recursively
    }
}

注意,add函数不使用任何变量,也不修改任何数据。它使用递归。您可以调用add函数将 10 加到 15,如下所示:

add(15, 10); // Results in 25

Haskell、Erlang 和 Scala 是支持函数范式的编程语言的几个例子。

Tip

Java SE 8 增加了一个新的语言结构,叫做 lambda expressions ,可以用来用 Java 编写函数式编程风格的代码。

逻辑范式

与命令式范式不同,逻辑范式关注的是问题的“是什么”部分,而不是如何解决它。你需要指定的只是需要解决的问题。程序会找出算法来解决它。算法对程序员来说不太重要。程序员的主要任务是尽可能地描述问题。在逻辑范式中,程序由一组公理和一个目标语句组成。公理集是构成理论的事实和推理规则的集合。目标语句是一个定理。该程序使用演绎来证明理论中的定理。逻辑编程使用集合论中一个叫做关系的数学概念。集合论中的关系被定义为两个或更多集合的笛卡尔积的子集。假设有两个集合,PersonsNationality,定义如下:

Person = {John, Li, Ravi}
Nationality = {American, Chinese, Indian}

两个集合的笛卡儿积表示为Person x Nationality,是另一个集合,如下所示:

Person x Nationality = {{John, American}, {John, Chinese}, {John, Indian},
                        {Li, American}, {Li, Chinese}, {Li, Indian},
                        {Ravi, American}, {Ravi, Chinese}, {Ravi, Indian}}

Person x Nationality的每个子集都是另一个定义数学关系的集合。一个关系的每个元素被称为一个元组。设PersonNationality为如下定义的关系:

PersonNationality = {{John, American}, {Li, Chinese}, {Ravi, Indian}}

在逻辑编程中,您可以使用PersonNationality关系作为已知为真的事实的集合。你可以这样语句目标语句(或问题)

PersonNationality(?, Chinese)

意思是“给我所有中国人的名字。”该程序将搜索PersonNationality关系,并提取匹配的元组,这些元组将是您的问题的答案(或解决方案)。在这种情况下,答案将是Li

Prolog 是支持逻辑范例的编程语言的一个例子。

面向对象的范例

在面向对象(OO)范例中,程序由相互作用的对象组成。对象封装了数据和算法。数据定义了对象的状态。算法定义了对象的行为。一个对象通过向其他对象发送消息来与它们通信。当一个对象收到一个消息时,它通过执行它的一个算法来响应,这可能会修改它的状态。将面向对象的范例与命令式和函数式范例进行对比。在命令式和函数式范例中,数据和算法是分离的,而在面向对象的范例中,数据和算法是不分离的;它们被组合在一个实体中,这个实体被称为对象。

类是面向对象范例中编程的基本单位。相似的对象被分组到一个定义中,称为类。类的定义用于创建对象。对象也称为类的实例。一个类由实例变量和方法组成。对象的实例变量的值定义了对象的状态。一个类的不同对象分别维护它们的状态。也就是说,类的每个对象都有自己的实例变量副本。对象的状态对该对象是私有的。也就是说,不能从对象外部直接访问或修改对象的状态。类中的方法定义了它的对象的行为。方法就像过程范式中的过程(或子例程)。方法可以访问/修改对象的状态。通过调用一个对象的方法将消息发送给该对象。

假设你想在你的程序中表现真实世界的人。您将创建一个Person类,它的实例将代表您程序中的人。可以如清单 1-1 所示定义Person类。这个例子使用了 Java 编程语言的语法。此时,您不需要理解您正在编写的程序中使用的语法;我将在后续章节中讨论定义类和创建对象的语法。

package com.jdojo.concepts;
public class Person {
    private String name;
    private String gender;
    public Person(String initialName, String initialGender) {
        name = initialName;
        gender = initialGender;
    }
    public String getName() {
        return name;
    }
    public void setName(String newName) {
        name = newName;
    }
    public String getGender() {
        return gender;
    }
}

Listing 1-1The Definition of a Person Class Whose Instances Represent Real-World Persons in a Program

Person类包括三样东西:

  • 两个实例变量 : namegender

  • 一名建造师 : Person(String initialName, String initialGender)

  • 三种方法 : getName()setName(String newName)getGender()

实例变量存储对象的内部数据。每个实例变量的值表示对象的相应属性的值。每个Person类的实例都有一个namegender数据的副本。对象在某一时间点的所有属性值(存储在实例变量中)共同定义了该对象在该时间点的状态。在现实世界中,一个人拥有许多属性,例如,姓名、性别、身高、体重、头发颜色、地址、电话号码等。然而,当您将现实世界中的人建模为一个类时,您只需要包括那些与被建模的系统相关的人的属性。在当前的演示中,让我们只对现实世界中一个人的两个属性——?? 和 ??——建模,作为Person类中的两个实例变量。

一个类包含对象的定义(或蓝图)。需要有一种方法来构造(创建或实例化)一个类的对象。对象还需要有其属性的初始值,这些初始值将决定其创建时的初始状态。类的构造器用于创建该类的对象。一个类可以有许多构造器,以便于创建具有不同初始状态的对象。Person类提供了一个构造器,允许您通过指定namegender的初始值来创建它的对象。下面的代码片段创建了两个Person类的对象:

Person john = new Person("John Jacobs", "Male");
Person donna = new Person("Donna Duncan", "Female");

第一个对象被称为john,其"John Jacobs""Male"分别作为其namegender属性的初始值。第二个对象被称为donna,分别用"Donna Duncan""Female"作为其namegender属性的初始值。

类的方法代表了它的对象的行为。比如在现实世界中,一个人是有名字的,当被问到名字时他们的反应能力就是他们的行为之一。Person类的对象能够响应三种不同的消息:getNamesetNamegetGender。对象响应消息的能力是使用方法实现的。你可以给一个Person对象发送一条消息,比如说getName,它会通过返回它的名字来响应。这就像问“你叫什么名字?”让对方告诉你他们的名字:

String johnName = john.getName();   // Send getName message to john
String donnaName = donna.getName(); // Send getName message to donna

发送给Person对象的setName消息要求将当前名称更改为新名称。以下代码片段将donna对象的名称从"Donna Duncan"更改为"Donna Jacobs":

donna.setName("Donna Jacobs");

如果此时将getName消息发送给donna对象,它将返回"Donna Jacobs",而不是“唐娜·邓肯”。

您可能会注意到您的Person对象没有能力响应像setGender这样的消息。人对象的性别是在对象创建时设置的,以后不能更改。但是,您可以通过向一个Person对象发送getGender消息来查询它的性别。对象可以(或不可以)响应什么消息是在设计时根据被建模系统的需要决定的。在Person对象的例子中,我们认为它们没有能力通过在Person类中不包含setGender(String newGender)方法来响应setGender消息。图 1-3 显示了名为johnPerson对象的状态和接口。

img/323069_3_En_1_Fig3_HTML.png

图 1-3

人对象的状态和接口

面向对象的范式是一种非常强大的范式,用于在计算模型中对现实世界的现象进行建模。在日常生活中,我们习惯于和周围的物体打交道。面向对象的范例是自然而直观的,因为它让您从对象的角度来思考。然而,它并没有给你正确思考事物的能力。有时,问题的解决方案不属于面向对象范例的范畴。在这种情况下,您需要使用最适合问题领域的范例。面向对象的范例有一个学习曲线。它不仅仅是在你的程序中创建和使用对象。抽象、封装、多态和继承是面向对象范例的一些重要特征。为了充分利用面向对象的范例,您必须理解并能够使用本书涵盖的这些特性。在后续章节中,我们将详细讨论这些特性以及如何在程序中实现它们。

仅举几个例子,C++、Java 和 C#(发音为“C sharp”)都是支持面向对象范例的编程语言。注意,编程语言本身并不是面向对象的。它是面向对象的范例。编程语言可能有也可能没有支持面向对象范例的特性。

Java 是什么?

Java 是一种通用编程语言。它具有支持基于面向对象、过程和函数范例的编程的特性。你经常会读到“Java 是一种面向对象的编程语言”这样的语句。这意味着 Java 语言具有支持面向对象范例的特性。编程语言不是面向对象的。它是面向对象的范例,而编程语言可能具有使实现面向对象范例变得容易的特性。有时候,程序员会有这样的误解,认为所有用 Java 编写的程序都是面向对象的。Java 还具有支持过程和函数范例的特性。你可以用 Java 写一个 100%过程化的程序,其中没有一点面向对象的成分。

Java 平台的最初版本是由 Sun Microsystems(自 2010 年 1 月起成为甲骨文公司的一部分)在 1995 年发布的。Java 编程语言的开发始于 1991 年。最初,这种语言被称为 Oak,意在用于电视机顶盒。

发布后不久,Java 成为一种非常流行的编程语言。它受欢迎的最重要的特征之一是它的“一次编写,随处运行”(WORA)特征。这个特性让您只需编写一次 Java 程序,就可以在任何平台上运行。例如,您可以在 UNIX 上编写和编译 Java 程序,并在 Microsoft Windows、Macintosh 或 UNIX 机器上运行它,而无需对源代码进行任何修改。WORA 是通过将 Java 程序编译成称为字节码的中间语言来实现的。字节码的格式是独立于平台的。一个称为 Java 虚拟机(JVM)的虚拟机用于在每个平台上运行字节码。注意,JVM 是一个用软件实现的程序。它不是物理机器,这就是它被称为“虚拟”机器的原因。JVM 的工作是根据它运行的平台将字节码转换成可执行代码。这个特性使得 Java 程序与平台无关。也就是说,同一个 Java 程序无需任何修改就可以在多个平台上运行。

以下是 Java 在软件行业中受欢迎和被接受背后的一些特征:

  • 简单

  • 各种各样的使用环境

  • 稳健性

在这种情况下,简单可能是一个主观的词。在 Java 发布的时候,C是软件行业广泛使用的流行而强大的编程语言。如果你是一名 C程序员,Java 将为你提供比 C更简单的学习和使用体验。Java 保留了 C/C的大部分语法,这对试图学习这种新语言的 C/C程序员很有帮助。更好的是,它排除了 C中一些最令人困惑和难以正确使用的特性(尽管功能强大)。例如,Java 没有指针和多重继承,而这些在 C++中都有。

如果你正在学习 Java 作为你的第一编程语言,它是否是一门简单的语言对你来说可能不是真的。这就是为什么我们说 Java 或者任何编程语言的简单性都是非常主观的原因。自从第一次发布以来,Java 语言及其库(一组包含 Java 类的包)一直在增长。为了成为一名真正的 Java 开发者,你需要付出一些认真的努力。

Java 可以用来开发可以在不同环境下使用的程序。你可以用 Java 编写能在客户机-服务器环境中使用的程序。Java 程序早期最流行的用途是开发小程序,这在 Java SE 9 中已被弃用。applet 是嵌入在网页中的 Java 程序,它使用超文本标记语言(HTML ),并在诸如 Firefox、Google Chrome 等网络浏览器中显示。applet 的代码存储在 web 服务器上,当浏览器加载包含 applet 引用的 HTML 页面时,下载到客户机上,并在客户机上运行。

Java 包含了一些使开发分布式应用程序变得容易的特性。分布式应用程序由运行在通过网络连接的不同机器上的程序组成。Java 的一些特性使得开发并发应用程序变得很容易。一个并发应用有多个并行运行的交互线程(一个线程就像一个程序中的独立进程,有自己的值和处理,独立于其他线程)。

程序的健壮性是指它合理处理意外情况的能力。程序中的意外情况也称为错误。Java 通过在程序生命周期的不同阶段提供许多错误检查特性来提供健壮性。以下是 Java 程序中可能出现的三种不同类型的错误:

  • 编译时错误

  • 运行时错误

  • 逻辑错误

编译时错误也称为语法错误。它们是由 Java 语言语法的不正确使用引起的。它们被 Java 编译器检测到。有编译时错误的程序在错误被纠正之前不会编译成字节码。语句末尾缺少分号,将十进制值(如 10.23)赋给整数类型的变量,等等。都是编译时错误的例子。

Java 程序运行时会出现运行时错误。这种错误不会被编译器检测到,因为编译器没有所有可用的运行时信息。Java 是一种强类型语言,它在编译时和运行时都有强大的类型检查功能。Java 提供了一种简洁的异常处理机制来处理运行时错误。当 Java 程序中出现运行时错误时,JVM 会抛出一个异常,程序可以捕捉并处理这个异常。例如,将整数除以零(如17/0)会产生运行时错误。Java 通过提供自动内存分配和释放的内置机制,避免了严重的运行时错误,如内存溢出和内存泄漏。自动内存释放的特性被称为垃圾收集。

逻辑错误是程序中最关键的错误,而且很难发现。它们是由程序员通过不正确地实现功能需求而引入的。Java 编译器或 Java 运行时无法检测到这种错误。当应用程序测试人员或用户将程序的实际行为与其预期行为进行比较时,他们会发现这些错误。有时,一些逻辑错误会潜入生产环境中,甚至在应用程序退役后也不会被注意到。

程序中的错误被称为 bug 。在程序中发现并修复错误的过程被称为调试。所有现代集成开发环境(ide),如 NetBeans、Eclipse、JDeveloper 和 IntelliJ IDEA,都为程序员提供了一种叫做调试器的工具,让他们一步一步地运行程序,并在每一步检查程序的状态以检测错误。调试是程序员日常活动的现实。如果你想成为一名优秀的程序员,你必须学习并善于使用你用来开发 Java 程序的开发工具中的调试器。

面向对象的范例和 Java

面向对象范式支持四大原则:抽象封装继承多态。它们也被称为面向对象范例的四大支柱。抽象是暴露一个实体的基本细节,同时忽略不相关的细节,以减少用户的复杂性的过程。封装是将数据和对数据的操作捆绑在一个实体中的过程。继承用于从现有类型派生新类型,从而建立父子关系。多态让一个实体在不同的上下文中有不同的含义。这四项原则将在接下来的章节中详细讨论。

抽象

程序为现实世界的问题提供了解决方案。程序的大小可能从几行到几百万行不等。它可以写成一个从第一行到第一百万行的整体结构。如果一个完整的程序超过 25-50 行,那么它将变得难以编写、理解和维护。为了更容易维护,一个大的整体程序必须分解成更小的子程序。子程序然后被组合在一起解决原来的问题。分解程序时必须小心。所有的子程序都必须足够简单和小,能够被它们自己理解,当它们被汇编时,它们必须解决原始的问题。让我们考虑对设备的以下要求:

设计并开发一种设备,让用户使用所有英文字母、数字和符号来键入文本。

设计这种设备的一种方法是提供一种键盘,该键盘具有用于所有字母、数字和符号的所有可能组合的键。这种解决方案是不合理的,因为设备的尺寸将是巨大的。你可能意识到我们正在谈论设计一个键盘。看看你的键盘,看看它是如何设计的。它将输入文本的问题分解为一次输入一个字母、一个数字或一个符号,这代表了原始问题的一小部分。如果您可以一次键入所有字母、所有数字和所有符号,则可以键入任意长度的文本。

原始问题的另一个分解可以包括两个键:一个键用于键入水平线,另一个键用于键入垂直线,用户可以使用这两个键来键入ETIFHL,因为这些字母仅由水平线和垂直线组成。使用这种解决方案,用户只需两个键的组合就可以键入六个字母。但是,根据您使用键盘的经验,您可能会意识到,分解按键以使一个按键仅用于输入字母的一部分并不是一个合理的解决方案,尽管这是一个解决方案。

为什么提供两个键来键入六个字母不是一个合理的解决方案?我们不是在节省空间和键盘上的键数吗?在这种情况下,“合理”一词的使用是相对的。从纯粹主义者的角度来看,这可能是一个合理的解决方案。我称之为“不合理”的理由是它不容易被用户理解。它向用户暴露了比需要的更多的细节。用户必须记住水平线位于T的顶部和L的底部。当用户为每个字母获得单独的密钥时,他们不必处理这些细节。重要的是,为部分原始问题提供解决方案的子程序必须被简化,以具有相同的细节水平,从而无缝地协同工作。同时,子程序不应该公开不需要知道的细节以便使用。

最后,所有的键都安装在一个键盘上,它们可以单独更换。如果一把钥匙坏了,它可以被替换,而不用担心其他钥匙。类似地,当程序被分解成子程序时,子程序中的修改不应该影响其他子程序。子程序还可以通过关注不同层次的细节而忽略其他细节来进一步分解。一个好的程序分解旨在提供以下特征:

  • 简单

  • 隔离

  • 可维护性

每个子程序都应该足够简单,便于自己理解。简单是通过关注相关的信息,忽略不相关的信息来实现的。哪些信息是相关的,哪些是不相关的,这取决于上下文。

每个子程序都应该与其他子程序隔离开来,以便子程序中的任何更改都应该具有局部影响。一个子程序中的更改不应影响任何其他子程序。子程序定义了与其他子程序交互的接口。子程序的内部细节对外界是隐藏的。只要子程序的接口保持不变,其内部细节的变化就不会影响与其交互的其他子程序。

每个子程序都应该足够小,以便于编写、理解和维护。

所有这些特征都是在一个问题(或解决一个问题的程序)的分解过程中通过一个叫做抽象的过程实现的。抽象是一种对问题进行分解的方法,它关注相关的细节,忽略特定上下文中与问题无关的细节。请注意,没有一个问题的细节是不相关的。换句话说,问题的每个细节都是相关的。然而,一些细节可能在一个上下文中相关,而一些在另一个上下文中相关。需要注意的是,是“上下文”决定了哪些细节是相关的,哪些是不相关的。例如,考虑设计和开发键盘的问题。从用户的角度来看,键盘由可以按下和释放以键入文本的键组成。键的数量、类型、大小和位置是与键盘用户相关的唯一细节。然而,按键并不是键盘的唯一细节。键盘有一个电子电路,它与电脑相连。当用户按键时,键盘和计算机内部会发生很多事情。键盘的内部工作与键盘设计者和制造商有关。然而,它们与键盘用户无关。你可以说不同的用户在不同的语境下对同一件事有不同的看法。关于事物的哪些细节是相关的,哪些是不相关的,这取决于用户和上下文。

抽象是指考虑在特定环境中以适当的方式看待问题所必需的细节,并忽略(隐藏、抑制或忘记)不必要的细节。抽象上下文中的“隐藏”和“抑制”等术语可能会产生误导。这些术语可能意味着隐藏问题的一些细节。抽象是关于一个事物的哪些细节应该被考虑,哪些不应该为了一个特定的目的而被考虑。这确实意味着隐藏细节。东西是如何隐藏的是另一个叫做信息隐藏的概念,这将在下一节讨论。

术语“抽象”用于表示两个事物之一:过程或实体。作为一个过程,它是一种技术,提取关于一个问题的相关细节,忽略不相关的细节。作为一个实体,它是对一个问题的特定观点,考虑一些相关的细节,忽略不相关的细节。

隐藏复杂性的抽象

我们来讨论一下抽象在现实编程中的应用。假设你想写一个程序来计算两个整数之间所有整数的和。假设您想计算 10 到 20 之间所有整数的和。你可以这样写程序。如果您不理解本节程序中使用的语法,请不要担心。试着理解抽象是如何被用来分解程序的:

int sum = 0;
int counter = 10;
while (counter <= 20) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这段代码将添加10 + 11 + 12 + ... + 20并打印165。假设您想计算4060之间所有整数的和。以下是实现这一目标的计划:

int sum = 0;
int counter = 40;
while (counter <= 60) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这段代码将对4060之间的所有整数求和,并打印出1050。请注意这两段代码的相似之处和不同之处。两者的逻辑是一样的。但是,范围的下限和上限是不同的。如果您可以忽略两个代码片段之间存在的差异,您将能够避免两个地方的逻辑重复。让我们考虑下面的代码片段:

int sum = 0;
int counter = lowerLimit;
while (counter <= upperLimit) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这一次,您没有使用任何范围的下限和上限的任何实际值。相反,您使用了在编写代码时未知的lowerLimitupperLimit占位符。通过在代码中使用两个占位符,您隐藏了范围下限和上限的标识。换句话说,在编写这段代码时,您忽略了它们的实际值。您在代码中应用了抽象过程,忽略了范围的下限和上限的实际值。

当这段代码被执行时,实际值必须被替换为lowerLimitupperLimit占位符。在编程语言中,这是通过将代码片段打包在一个称为过程的模块(子例程或子程序)中实现的。占位符被定义为该过程的形式参数。清单 1-2 有这样一个程序的代码。

int getRangeSum(int lowerLimit, int upperLimit) {
    int sum = 0;
    int counter = lowerLimit;
    while (counter <= upperLimit) {
        sum = sum + counter;
        counter = counter + 1;
    }
    return sum;
}

Listing 1-2A Procedure Named getRangeSum to Compute the Sum of All Integers Between Two Integers

一个过程有一个名字,在这个例子中是getRangeSum。过程有一个返回类型,在它的名字前面指定。返回类型指示它将返回给调用者的值的类型。

在这种情况下,返回类型是int,这表明计算的结果将是一个整数。

一个过程有形参(可能是零),这些形参在名字后面的括号中指定。形参由数据类型和名称组成。在这种情况下,形参被命名为lowerLimitupperLimit,两者的数据类型都是int。它有一个主体,放在大括号内。过程的主体包含逻辑。

当您想要执行某个过程的代码时,您必须传递其形参的实际值。您可以计算并打印出1020之间所有整数的和,如下所示:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

这段代码将打印165。要计算4060之间所有整数的总和,您可以执行以下代码片段:

int s2 = getRangeSum(40, 60);
System.out.println(s2);

这段代码将打印出1050,这与您之前获得的结果完全相同。

你在定义getRangeSum过程中使用的抽象方法被称为参数化抽象。过程中的形参用于隐藏过程主体操作的实际数据的身份。getRangeSum过程中的两个参数隐藏了整数范围的下限和上限。现在你已经看到了抽象的第一个具体例子。抽象是一个庞大的话题。在这一节中,我将讲述更多关于抽象的基础知识。

假设一个程序员编写了getRangeSum过程的代码,如清单 1-2 所示,另一个程序员想要使用它。第一个程序员是程序的设计者和编写者;第二个是过程的用户。使用getRangeSum程序的用户需要知道哪些信息?

在回答这个问题之前,让我们考虑一个设计和使用 DVD(数字多功能光盘)播放器的真实例子。DVD 播放器是由电子工程师设计开发的。你如何使用 DVD 播放器?在你使用 DVD 播放器之前,你不需要打开它来研究它的基于电子工程理论的所有细节。当你买它的时候,它有一本如何使用它的手册。一个 DVD 播放器被包装在一个盒子里。盒子里面藏着玩家的详细资料。同时,盒子以接口的形式向外界暴露了关于播放器的一些细节。DVD 播放器的界面由以下项目组成:

  • 输入和输出连接端口,用于连接电源插座、电视机等。

  • 插入 DVD 的面板

  • 执行弹出、播放、暂停、快进等操作的一组按钮。

DVD 播放机附带的手册描述了为用户提供的播放机界面的用法。DVD 用户不需要担心其内部工作的细节。手册还描述了操作它的一些条件。例如,在使用之前,您必须将电源线插入电源插座并打开电源。

程序的设计、开发和使用方式与 DVD 播放器相同。清单 1-2 中所示程序的用户无需担心用于实现程序的内部逻辑。程序的用户只需要知道它的用法,包括使用它的界面,以及在使用它之前和之后必须满足的条件。换句话说,你需要提供一份描述其用法的getRangeSum程序手册。使用getRangeSum程序的用户需要阅读其手册。一个程序的“手册”就是它的规格说明书。有时它也被称为文档或注释。它提供了另一种抽象方法,称为规范抽象。它描述(或公开或关注)程序的“是什么”部分,并对用户隐藏(或忽略或隐藏)程序的“如何”部分。

清单 1-3 显示了与其规格相同的getRangeSum程序代码。

/**
 * Computes and returns the sum of all integers between two
 * integers specified by lowerLimit and upperLimit parameters.
 *
 * The lowerLimit parameter must be less than or equal to the
 * upperLimit parameter. If the sum of all integers between the
 * lowerLimit and the upperLimit exceeds the range of the int data
 * type then result is not defined.
 *
 * @param lowerLimit The lower limit of the integer range
 * @param upperLimit The upper limit of the integer range
 * @return The sum of all integers between lowerLimit (inclusive)
 *         and upperLimit (inclusive)
 */
public static int getRangeSum(int lowerLimit, int upperLimit) {
    int sum = 0;
    int counter = lowerLimit;
    while (counter <= upperLimit) {
        sum = sum + counter;
        counter = counter + 1;
    }
    return sum;
}

Listing 1-3The getRangeSum Procedure with Its Specification for the Javadoc Tool

Javadoc 标准用于编写 Java 程序的规范,Javadoc 工具可以处理该规范以生成 HTML 页面。在 Java 中,程序元素的规范被放在元素前的/***/之间。该规范是为getRangeSum程序的用户准备的。Javadoc 工具将为getRangeSum过程生成规范,如图 1-4 所示。

img/323069_3_En_1_Fig4_HTML.png

图 1-4

getRangeSum 过程的规范

该规范提供了 getRangeSum 过程的描述(“什么”部分)。它还指定了两个条件,称为前置条件,在调用过程时这两个条件必须为真。第一个前提条件是下限必须小于或等于上限。第二个先决条件是,下限和上限的值必须足够小,以便它们之间的所有整数之和符合int数据类型的大小。它指定了另一个条件,称为后置条件,在“Returns”子句中指定。只要前提条件成立,后置条件就成立。前置条件和后置条件就像程序和用户之间的契约(或协议)。它声明,只要程序的用户确保前置条件成立,程序就保证后置条件成立。请注意,规范从未告诉用户程序如何实现(实现细节)后置条件。它只告诉“什么”它要做,而不是“如何”它要做。拥有规范的getRangeSum程序的用户不需要查看getRangeSum过程的主体来找出它使用的逻辑。换句话说,您向用户提供了这个规范,从而隐藏了getRangeSum过程的实现细节。也就是说,getRangeSum过程的用户可以为了使用它而忽略它的实现细节。这是抽象的另一个具体例子。通过使用规范来隐藏子程序的实现细节(“如何”部分)并公开其用法(“做什么”部分)的方法被称为通过规范的抽象

参数化抽象和规范抽象让程序的用户把程序看作一个黑盒,他们只关心程序产生的效果,而不关心程序如何产生这些效果。图 1-5 描述了getRangeSum程序的用户视图。注意,用户看不到(也不需要看到)包含细节的过程主体。细节只与程序的作者有关,与用户无关。

img/323069_3_En_1_Fig5_HTML.png

图 1-5

用户将 getRangeSum 过程视为使用抽象的黑盒

通过应用抽象来定义getRangeSum过程,您获得了哪些优势?最重要的优势之一就是隔离。它与其他程序相隔离。如果你修改了它主体内部的逻辑,其他程序,包括正在使用它的程序,都不需要修改。要打印1020之间的整数之和,可以使用以下程序:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

该过程的主体使用一个while循环,循环的执行次数与上限和下限之间的整数次数一样多。getRangeSum程序内的while循环执行n次,其中n等于(upperLimit – lowerLimit + 1)。需要执行的指令数量取决于输入值。有一种更好的方法来计算两个整数lowerLimitupperLimit之间所有整数的和,使用下面的公式:

n = upperLimit - lowerLimit + 1;
sum = n * (2 * lowerLimit + (n-1))/2;

如果使用这个公式,计算两个整数之间所有整数之和所执行的指令数总是相同的。你可以重写getRangeSum过程的主体,如清单 1-4 所示。此处未显示getRangeSum程序的规格。

public int getRangeSum(int lowerLimit, int upperLimit) {
    int n = upperLimit - lowerLimit + 1;
    int sum = n * (2 * lowerLimit + (n-1))/2;
    return sum;
}

Listing 1-4Another Version of the getRangeSum Procedure with the Logic Changed Inside Its Body

注意清单 1-3 和清单 1-4 之间的getRangeSum过程的主体(实现或“如何”部分)已经改变。然而,getRangeSum过程的用户不会受到这一变化的影响,因为通过使用抽象,该过程的实现细节对其用户是隐藏的。如果您想使用清单 1-4 中所示的getRangeSum过程版本计算 10 到 20 之间所有整数的和,您的旧代码仍然有效:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

您已经看到了抽象的最大好处之一,其中程序的实现细节(在本例中是一个过程)可以被改变,而无需保证使用该程序的代码的任何改变。这个好处也给你一个机会来重写你的程序逻辑,以提高将来的性能,而不影响应用程序的其他部分。

在本节中,我考虑了两种类型的抽象:

  • 过程抽象

  • 数据抽象

过程抽象允许您定义一个过程,例如getRangeSum,您可以将它用作一个动作或一个任务。到目前为止,我一直在讨论过程抽象。参数化抽象和规范抽象是实现过程抽象和数据抽象的两种方法。下一节将详细讨论数据抽象。

数据抽象

面向对象编程基于数据抽象。然而,在讨论数据抽象之前,我需要简单地讨论一下数据类型。数据类型(或简称为类型)由三个部分定义:

  • 一组值(或数据对象)

  • 可以应用于集合中所有值的一组运算

  • 一种数据表示形式,它决定了值的存储方式

编程语言提供了一些预定义的数据类型,称为内置数据类型。他们还让程序员定义他们自己的数据类型,这就是所谓的用户定义的数据类型。由一个不可分割的原子值组成的数据类型(在没有任何其他数据类型帮助的情况下定义)被称为原始数据类型。比如 Java 内置了intfloatbooleanchar等原语数据类型。在 Java 中定义int原始数据类型的三个组件如下:

  • int 数据类型由–2147483648 和 2147483647 之间的所有整数组成。

  • int数据类型定义了加、减、乘、除、比较等操作。

  • int数据类型的值在 32 位存储器中以 2 的补码形式表示。

数据类型的所有三个组成部分都是由 Java 语言预定义的。您不能扩展或重新定义int数据类型的定义。您可以将int数据类型的值命名为

int n1;

该语句说明n1是一个名称(技术上称为标识符),它可以与定义int数据类型的值的值集中的一个值相关联。例如,您可以使用如下赋值语句将整数26与名称n1相关联:

n1 = 26;

在这个阶段,您可能会问,“与名称n1相关联的值26存储在内存中的什么地方?”从int数据类型的定义中可以知道n1将占用 32 位内存。但是,你不知道,不能知道,也不需要知道 32 位在内存中的什么位置分配给了n1。你在这里看到抽象的例子了吗?如果你在这种情况下看到一个抽象的例子,你就对了。这是一个抽象的例子,它内置于 Java 语言中。在这种情况下,关于int数据类型的数据值的数据表示的信息对该数据类型的用户(程序员)是隐藏的。换句话说,程序员忽略了n1的内存位置,而专注于它的值和可以在其上执行的操作。程序员不关心n1的内存是分配在寄存器、RAM 还是硬盘中。

面向对象的编程语言(如 Java)允许您使用称为数据抽象的抽象机制来创建新的数据类型。新的数据类型被称为抽象数据类型(ADT)。ADT 中的数据对象可能由原始数据类型和其他 ADT 的组合组成。ADT 定义了一组可应用于其所有数据对象的操作。数据表示总是隐藏在 ADT 中。对于 ADT 的用户来说,它只包含操作。它的数据元素只能使用它的操作来访问和操作。使用数据抽象的优点是,它的数据表示形式可以更改,而不会影响任何使用 ADT 的代码。

Tip

数据抽象允许程序员创建一种新的数据类型,称为抽象数据类型,其中数据对象的存储表示对数据类型的用户是隐藏的。换句话说,ADT 仅根据可以应用于其类型的数据对象的操作来定义,而无需知道数据的内部表示。这种数据类型被称为抽象的原因是,ADT 的用户永远看不到数据值的表示。用户以抽象的方式查看 ADT 的数据对象,在不知道数据对象表示细节的情况下对它们应用操作。请注意,ADT 并不意味着没有数据表示。数据表示始终存在于 ADT 中。这只意味着对用户隐藏数据表示。

Java 有一些构造,例如类、接口、注释和enum,允许您定义新的 ADT。当您使用一个类来定义一个新的 ADT 时,您需要小心隐藏数据表示,因此您的新数据类型确实是抽象的。如果 Java 类中的数据表示没有被隐藏,那么该类会创建一个新的数据类型,但不会创建 ADT。Java 中的类为您提供了一些特性,您可以使用这些特性来公开或隐藏数据表示。在 Java 中,一个类数据类型的值的集合被称为对象。对对象的操作被称为方法。对象的实例变量(也称为字段)是类类型的数据表示。

Java 中的类允许你实现对数据表示的操作。Java 中的接口允许您创建纯 ADT。接口让您只提供可以应用于其类型的数据对象的操作规范。接口中不能提及操作或数据表示的实现。清单 1-1 显示了使用 Java 语言语法的Person类的定义。通过定义名为Person的类,您已经创建了一个新的 ADT。它对namegender的内部数据表示使用了String数据类型(String是 Java 类库提供的内置 ADT)。请注意,Person类的定义在namegender声明中使用了private关键字来对外界隐藏它们。Person类的用户不能访问namegender数据元素。它提供了四个操作:一个构造器和三个方法(getNamesetNamegetGender)。

构造器操作用于初始化新构造的Person类型的数据对象。getNamesetName操作分别用于访问和修改name数据元素。getGender操作用于访问gender数据元素的值。

Person类的用户必须只使用这四个操作来处理Person类型的数据对象。Person类型的用户不知道用于存储namegender数据元素的数据存储类型。我交替使用三个术语,“类型”、“类”和“接口”,因为它们在数据类型的上下文中表示相同的意思。它给了Person类型的开发人员改变namegender数据元素的数据表示的自由,而不会影响任何Person类型的用户。假设一个Person类型的用户有下面的代码片段:

Person john = new Person("John Jacobs", "Male");
String intialName = john.getName();
john.setName("Wally Jacobs");
String changedName = john.getName();

这段代码只是根据Person类型提供的操作编写的。它没有(也不可能)直接引用namegender实例变量。让我们看看如何在不影响代码片段的情况下改变Person类型的数据表示。清单 1-5 显示了Person类新版本的代码。

package com.jdojo.concepts;
public class Person {
    private String[] data = new String[2];
    public Person(String initialName, String initialGender) {
        data[0] = initialName;
        data[1] = initialGender;
    }
    public String getName() {
        return data[0];
    }
    public void setName(String newName) {
        data[0] = newName;
    }
    public String getGender() {
        return data[1];
    }
}

Listing 1-5Another Version of the Person Class That Uses a String Array of Two Elements to Store Name and Gender Values as Opposed to Two String Variables

比较清单 1-1 和清单 1-5 中的代码。这一次,您用一个包含两个元素的String数组替换了两个实例变量(namegender),它们是清单 1-1 中Person类型的数据表示。因为类中的操作(或方法)是对数据表示进行操作的,所以您必须改变Person类型中所有四个操作的实现。清单 1-5 中的客户端代码是根据四个操作的规范而不是它们的实现编写的。因为您没有更改任何操作的规范,所以您不需要更改使用Person类的代码片段;对于Person类型的新定义,它仍然有效,如清单 1-5 所示。Person类中的一些方法通过参数化使用抽象,所有的方法都通过规范使用抽象。我没有在这里展示方法的规范,这将是 Javadoc 注释。

在本节中,您已经看到了数据抽象的两个主要好处:

  • 它允许您通过定义新的数据类型来扩展编程语言。您创建的新数据类型取决于应用程序域。例如,对于银行系统,PersonCurrencyAccount可能是新数据类型的好选择,而对于汽车保险应用程序,PersonVehicleClaim可能是好选择。新数据类型中包含的操作取决于应用程序的需要。

  • 使用数据抽象创建的数据类型可以改变数据的表示,而不影响使用该数据类型的客户端代码。

封装和信息隐藏

术语封装用来表示两种不同的东西:一个过程或一个实体。作为一个流程,它是将一个或多个项目捆绑到一个容器中的行为。容器可以是物理的,也可以是逻辑的。作为一个实体,它是一个容纳一个或多个项目的容器。

编程语言以多种方式支持封装。过程是执行任务的步骤的封装;数组是几个相同类型元素的封装;等等。在面向对象编程中,封装是将数据和对数据的操作捆绑到一个称为类的实体中。Java 以多种方式支持封装:

  • 它允许您将数据和对数据进行操作的方法捆绑在一个名为的实体中。

  • 它允许您将一个或多个逻辑上相关的类捆绑在一个名为的实体中。Java 中的包是一个或多个相关类的逻辑集合。包创建了一个新的命名范围,其中所有的类都必须有唯一的名称。两个类在 Java 中可以有相同的名字,只要它们被捆绑(或封装)在两个不同的包中。

  • 它允许您将包捆绑到一个在 Java SE 9 中引入的模块中。模块可以导出它的包。其他模块可以访问导出包中定义的类型,而其他模块无法访问非导出包中定义的类型。

  • 它允许你将一个或多个相关的类捆绑在一个名为编译单元 *的实体中。*一个编译单元中的所有类都可以独立于其他编译单元进行编译。

在讨论面向对象编程的概念时,两个术语——封装信息隐藏——经常互换使用。然而,它们在面向对象编程中是不同的概念,因此不应该互换使用。封装就是将项目捆绑在一起成为一个实体。信息隐藏是隐藏可能改变的实现细节的过程。封装不关心捆绑在实体中的项目是否对应用程序中的其他模块隐藏。什么应该被隐藏(或忽略)和什么不应该被隐藏是抽象的关注点。抽象只关心应该隐藏哪一项。抽象不关心项目应该如何隐藏。信息隐藏关注的是如何隐藏一个项目。

封装、抽象和信息隐藏是三个独立的概念。不过,它们的关系非常密切。一个概念促进了其他概念的工作。理解它们在面向对象编程中所扮演角色的细微差别是很重要的。

Tip

在 Java SE 中,您会经常遇到类似“一个模块提供了强大的封装”这样的语句。这里,术语封装用于信息隐藏的意义上。这意味着模块中非导出包中的类型对其他模块是隐藏的(或不可访问的)。

可以使用隐藏或不隐藏任何信息的封装。例如,清单 1-1 中的Person类展示了一个封装和信息隐藏的例子。数据元素(namegender)和方法(getName()setName()getGender())被捆绑在一个名为Person的类中。这就是封装。换句话说,Person类是数据元素namegender加上方法getName()setName()getGender()的封装。同一个Person类通过对外界隐藏数据元素来使用信息隐藏。注意namegender数据元素使用 Java 关键字private,这实质上是对外界隐藏了它们。清单 1-6 显示了一个Person2类的代码。

package com.jdojo.concepts;
public class Person2 {
    public String name;   // Not hidden from its users
    public String gender; // Not hidden from its users
    public Person2(String initialName, String initialGender) {
        name = initialName;
        gender = initialGender;
    }
    public String getName() {
        return name;
    }
    public void setName(String newName) {
        name = newName;
    }
    public String getGender() {
        return gender;
    }
}

Listing 1-6The Definition of the Person2 Class in Which Data Elements Are Not Hidden by Declaring Them Public

清单 1-1 中的代码和清单 1-6 中的代码除了两个小的区别之外基本相同。Person2类使用关键字public来声明namegender数据元素。Person2类使用封装的方式与Person类相同。然而,namegender数据元素并未隐藏。也就是说,Person2类不使用数据隐藏(数据隐藏就是信息隐藏的一个例子)。如果你看看PersonPerson2类的构造器和方法,它们的主体使用了信息隐藏,因为写在它们主体内部的逻辑对它们的用户是隐藏的。

Tip

封装和信息隐藏是面向对象编程的两个不同概念。一个的存在并不意味着另一个的存在。

继承

继承是面向对象编程中的另一个重要概念。它让你以一种新的方式使用抽象。在前面的章节中,您已经看到了类是如何表示抽象的。清单 1-1 中显示的Person类代表了现实世界中一个人的抽象。继承机制允许您通过扩展现有的抽象来定义新的抽象。现有的抽象称为超类型、超类、父类或基类。新的抽象被称为子类型、子类、子类或派生类。据说子类型是从超类型派生(或继承)来的,超类型是子类型的泛化,子类型是超类型的特化。继承可以用来在多个层次上定义新的抽象。子类型可以用作超类型来定义另一个子类型,依此类推。继承产生了以层次形式排列的一族类型。

继承允许你在不同层次使用不同程度的抽象。在图 1-6 中,Person类位于继承层次的顶端(最高级别)。CustomerEmployee类位于继承层次的第二层。当您沿着继承层次向下移动时,您会关注更重要的信息。换句话说,在继承的更高层次上,你关注的是更大的图景;在较低层次的继承中,你关心越来越多的细节。从抽象的角度来看,还有另一种方式来看待继承层次。在图 1-6 中的Person关卡,你专注于CustomerEmployee的共同特点,忽略了它们之间的差异。在Employee级别,你关注ClerkProgrammerCashier的共同特征,忽略它们之间的差异。

img/323069_3_En_1_Fig6_HTML.jpg

图 1-6

人员类的继承层次结构

在继承层次结构中,超类型及其子类型代表一种“是-是”关系。即一个Employee是一个Person;一个Programmer是一个Employee;等等。因为较低层次的继承意味着更多的信息,所以子类型总是包含其父类型所拥有的,甚至更多。继承的这一特点导致了面向对象编程中的另一个特点,即所谓的替代性原则。这意味着父类型总是可以被它的子类型替换。例如,在你的Person抽象中,你只考虑了一个人的namegender信息。如果从Person继承EmployeeEmployee包含从Person继承的namegender信息。Employee可能包括更多信息,如员工 ID、雇佣日期、工资等。如果上下文中需要一个Person,这意味着只有namegender信息与该上下文相关。您总是可以用EmployeeCustomerClerkProgrammer替换该上下文中的Person,因为作为Person的子类型(直接或间接),这些抽象保证了它们至少有能力处理namegender信息。

在编程级别,继承提供了代码重用机制。超类型中编写的代码可以被其子类型重用。子类型可以通过添加更多的功能或重新定义其超类型的现有功能来扩展其超类型的功能。

Tip

继承也被用作实现多态的技术,这将在下一节讨论。继承让你编写多态代码。代码是根据超类型编写的,同样的代码也适用于子类型。

继承是一个很大的话题。这本书用了整整一章来讲述如何在 Java 中使用继承。

多态

“多态”一词源于两个希腊词:“poly”(表示许多)和“morphos”(表示形式)。在编程中,多态是一个实体(例如,变量、类、方法、对象、代码、参数等)的能力。)在不同的语境中呈现不同的含义。具有不同含义的实体称为多态实体。存在各种类型的多态。每种类型的多态都有一个名称,通常表明这种类型的多态在实践中是如何实现的。多态的正确使用会产生通用的和可重用的代码。多态的目的是通过编写适用于许多类型(或者理想情况下适用于所有类型)的通用类型来编写可重用和可维护的代码。多态可以分为以下两类:

  • 特定多态

  • 通用多态

如果一段代码适用于有限数量的类型,并且在编写代码时必须知道所有这些类型,这就是所谓的特定多态。特殊多态也被称为表观多态,因为它不是真正意义上的多态。一些计算机科学纯粹主义者根本不认为特别多态是多态。

临时多态进一步分为两类:

  • 重载多态

  • 强制多态

如果一段代码以这样一种方式编写,它适用于无限多种类型(也适用于编写代码时未知的新类型),它被称为通用多态。在通用多态中,相同的代码适用于许多类型,而在专用多态中,为不同的类型提供不同的代码实现,给人一种明显的多态印象。

通用多态进一步分为两类:

  • 包含多态

  • 参数多态

在随后的部分中,我将通过例子详细描述这些类型的多态。

重载多态

重载是一种特殊的多态。当一个方法(在 Java 中称为方法,在其他语言中称为函数)或一个操作符至少有两个作用于不同类型的定义时,就会导致重载。在这种情况下,相同的名称(对于方法或操作符)用于其不同的定义。也就是说,同一个名字表现出许多行为,因此具有多态。这样的方法和运算符称为重载方法和重载运算符。Java 让你定义重载的方法。Java 有一些重载的操作符,但是它不允许为 ADT 重载操作符。也就是说,不能在 Java 中为操作符提供新的定义。清单 1-7 显示了一个名为MathUtil的类的代码。

// MathUtil.java
package com.jdojo.concepts;
public class MathUtil {
    public static int max(int n1, int n2) {
        /* Code to determine the maximum of two integers goes here */
    }
    public static double max(double n1, double n2) {
        /* Code to determine the maximum of two floating-point numbers goes here */
    }
    public static int max(int[] num) {
        /* Code to determine the maximum in an array of int goes here */
    }
}

Listing 1-7An Example of an Overloaded Method in Java

MathUtil类的max()方法被重载。它有三个定义,每个定义都执行计算最大值的相同任务,但是是在不同的类型上。第一个定义最多计算两个int数据类型的数字,第二个定义最多计算两个double数据类型的浮点数,第三个定义最多计算一个int数据类型的数字数组。下面的代码片段使用了重载的max()方法的所有三个定义:

int max1 = MathUtil.max(10, 23);                 // Uses max(int, int)
double max2 = MathUtil.max(10.34, 2.89);         // Uses max(double, double)
int max3 = MathUtil.max(new int[]{1, 89, 8, 3}); // Uses max(int[])

请注意,方法重载只提供了方法名的共享。它不会导致方法定义的共享。在清单 1-7 中,方法名max被所有三个方法共享,但是它们都有自己的计算不同类型的最大值的定义。在方法重载中,方法的定义不必相关。他们可能做完全不同的事情,并分享相同的名字。

下面的代码片段展示了 Java 中运算符重载的一个例子。操作员是+。在下面的三条语句中,它执行三种不同的操作:

int n1 = 10 + 20;              // Adds two integers
double n2 = 10.20 + 2.18;      // Adds two floating-point numbers
String str = "Hi " + "there";  // Concatenates two strings

在第一条语句中,+运算符对两个整数1020执行加法运算,并返回30。在第二条语句中,它对两个浮点数10.202.18进行加法运算,并返回12.38。在第三个语句中,它执行两个字符串“Hi”和“there”的连接,并返回"Hi there"

在重载中,方法的实际参数的类型(在操作符的情况下是操作数的类型)用于确定使用哪个代码定义。方法重载只提供方法名的重用。只需为重载方法的所有版本提供一个唯一的名称,就可以移除方法重载。例如,您可以将max()方法的三个版本重命名为max2Int()max2Double()maxNInt()。请注意,重载方法或运算符的所有版本都不必执行相关或相似的任务。在 Java 中,重载方法名的唯一要求是,该方法的所有版本在形参的数量和/或类型上必须不同。

强制多态

强制是一种特殊的多态。当一种类型自动隐式转换(强制)为另一种类型时,即使不是显式的,也会发生强制。考虑以下 Java 语句:

int num = 707;
double d1 = (double)num; // Explicit conversion of int to double
double d2 = num;         // Implicit conversion of int to double (coercion)

在第一条语句中,变量num被声明为int数据类型,并被赋值为707。第二个语句使用了一个造型(double),将存储在num中的int值转换为double,并将转换后的值赋给一个名为d1的变量。这是从intdouble的显式转换的情况。在这种情况下,程序员通过使用强制转换来明确他们的意图。第三个语句的效果和第二个完全一样;然而,它依赖于 Java 语言提供的隐式转换(在 Java 中称为扩大转换),该语言在需要时自动将int转换为double。第三个语句是一个强迫的例子。编程语言(包括 Java)在不同的上下文中执行不同类型的强制:赋值(如前所示)、方法参数等。

考虑下面的代码片段,它显示了一个square()方法的定义,该方法接受一个double数据类型的参数:

double square(double num) {
    return num * num;
}

可以使用数据类型为double的实际参数调用square()方法,如下所示:

double d1 = 20.23;
double result = square(d1);

同样的square()方法也可以用int数据类型的实参调用,如下所示:

int k = 20;
double result = square(k);

您已经看到了square()方法对doubleint数据类型的参数都有效,尽管您只根据double数据类型的形参定义了它一次。这正是多态的含义。在这种情况下,square()方法被称为关于doubleint数据类型的多态方法。因此,square()方法表现出多态行为,即使编写代码的程序员并不打算这样做。因为 Java 语言提供的隐式类型转换(从intdouble的强制),所以square()方法是多态的。这里有一个多态方法的更正式的定义:

假设 m 是一个声明 T 类型形参的方法,如果 S 是一个可以隐式转换为 T 的类型,则称方法 m 相对于 S 和 T 是多态的

包含多态

包含是一种普遍的多态。它也被称为子类型(或子类)多态,因为它是使用子类型或子类实现的。这是面向对象编程语言支持的最常见的多态类型。Java 支持。

当使用某个类型编写的一段代码适用于它的所有子类型时,就会出现包含多态。根据子类型规则,属于子类型的值也属于父类型,这种类型的多态是可能的。假设T是一个类型,S1S2S3...T的子类型。属于S1S2S3...的一个值也属于T。这种子类型规则使得编写如下代码成为可能:

T t;
S1 s1;
S2 s2;
...
t = s1; // A value of type s1 can be assigned to a variable of type T
t = s2; // A value of type s2 can be assigned to a variable of type T

Java 使用继承支持包含多态,继承是一种子类化机制。你可以在 Java 中使用一个类型的形参来定义一个方法,比如Person,这个方法可以在它的所有子类型上被调用,比如EmployeeStudentCustomer等等。假设你有一个方法processDetails()如下:

void processDetails(Person p) {
    /*
Write code using the formal parameter p, which is of type Person. The same code will work if an object of any of the subclass of Person is passed to this method.
    */
}

processDetails()方法声明了一个Person类型的形参。您可以定义任意数量的类,这些类是Person类的子类。这种方法适用于这样的子类。假设EmployeeCustomerPerson类的子类。您可以编写这样的代码:

Person p1 = create a Person object;
Employee e1 = create an Employee object;
Customer c1 = create a Customer object;
processDetails(p1); // Use the Person type
processDetails(e1); // Use the Employee type, which is a subclass of Person
processDetails(c1); // Use the Customer type, which is a subclass of Person

子类型规则的作用是超类型包含(因此得名 inclusion)属于其子类型的所有值。只有当一段代码可以处理无限多种类型时,它才被称为通用多态。在包含多态的情况下,代码工作的类型数量是有限的,但却是无限的。约束条件是所有类型都必须是编写代码的那个类型的子类型。如果对一个类型可以有多少个子类型没有限制,那么子类型的数量是无限的(至少理论上是这样)。请注意,包含多态不仅让您编写可重用的代码,还让您编写可扩展和灵活的代码。processDetails()方法作用于Person类的所有子类。它将继续为Person类的所有子类工作,这将在未来定义,没有任何修改。Java 使用其他机制,如方法覆盖和动态调度(也称为后期绑定),以及子类化规则,使包含多态更加有效和有用。

参数多态

参数化是一种普遍的多态。它也被称为“真正的”多态,因为它允许您编写适用于任何类型(相关或不相关)的真正的通用代码。有时,它也被称为泛型。在参数多态中,一段代码以一种可以在任何类型上工作的方式编写。对比参数多态和包含多态。在包含多态中,代码是为一种类型编写的,它适用于它的所有子类型。这意味着代码在包含多态中工作的所有类型都通过超类型-子类型关系相关联。然而,在参数多态中,相同的代码适用于所有类型,这些类型不一定相关。参数多态是通过在编写代码时使用类型变量来实现的,而不是使用任何特定的类型。type 变量假定代码需要为特定类型执行。从 Java 5 到泛型,Java 都支持参数多态。Java 支持多态实体(例如,参数化类)以及使用参数多态的多态方法(参数化方法)。

在 Java 中,参数多态是通过使用泛型来实现的。Java 中的所有集合类型都使用泛型。您可以使用泛型编写代码,如下所示:

/* Example #1 */
// Create a List of String
List<String> sList = new ArrayList<String>();
// Add two Strings to the List
sList.add("string 1");
sList.add("string 2");
// Get the first String from the List
String s1 = sList.get(0);
/* Example #2 */
// Create a List of Integer
List<Integer> iList = new ArrayList<Integer>();
// Add two Integers to the list
iList.add(10);
iList.add(20);
// Get the first Integer from the List
int k1 = iList.get(0);

这段代码使用一个List对象作为一个String类型的列表,使用一个List对象作为一个Integer类型的列表。使用泛型,您可以将一个List对象视为 Java 中任何类型的列表。注意在这些例子中使用了<Xxx>来指定您想要实例化的List对象的类型。

摘要

为计算机编写一组指令来完成一项任务被称为编程。这组指令被称为程序。存在不同类型的编程语言。它们在接近硬件可以理解的指令或范例方面有所不同。机器语言让你用 0 和 1 写程序,是最低级的编程语言。用机器语言编写的程序被称为机器码。汇编语言让你用助记符编写程序。用汇编语言编写的程序被称为汇编代码。后来,更高级的编程语言被开发出来,使用一种类似英语的语言。

实践中有几种类型的编程范例。编程范式是以特定方式观察和分析现实世界问题的思维帽。命令式、过程式、函数式和面向对象是软件开发中一些广泛使用的范例。Java 是一种支持过程式、函数式和面向对象编程范例的编程语言。

抽象、封装、继承和多态是面向对象范例的四大支柱。抽象是隐藏与程序用户无关的程序细节的过程。封装是将多个项目捆绑成一个实体的过程。继承是以分层方式排列类以建立父类型-子类型关系的过程。继承通过允许程序员根据一个同样适用于所有子类型的超类型来编写代码,从而提高了代码的可重用性。多态是指一次编写一段可以在多种类型上操作的代码的方式。方法重载、方法覆盖、子类型和泛型是实现多态的一些方法。

EXERCISES

以下所有问题的答案都可以在本章的不同部分找到。

  1. 什么是编程,什么是程序?

  2. 汇编程序和编译器有什么区别?

  3. 什么是机器语言,用机器语言编写的程序由什么组成?

  4. 什么是汇编语言,用汇编语言写的程序由什么组成?

  5. 说出三种更高级的编程语言。

  6. 基于编程语言(1GL、2GL 等)的产生。),Java 和 SQL 属于什么类别?

  7. 什么是编程范例?用例子描述过程的、功能的和面向对象的范例。

  8. 说出面向对象编程的四个支柱,并用例子描述每个支柱。

  9. 什么是“真正的”多态,Java 是如何支持它的?

  10. 什么是抽象数据类型?Java 如何支持抽象数据类型?

二、设置环境

在本章中,您将学习:

  • 编写、编译和运行 Java 程序需要什么软件

  • 从哪里下载所需的软件

  • 如何验证 Java 开发工具包 17 (JDK 17)的安装

  • 如何启动让您运行 Java 代码片段的jshell命令行工具

  • 从哪里下载、安装和配置用于编写、编译、打包和运行 Java 程序的 NetBeans IDE(集成开发环境)

系统需求

您需要在您的计算机上安装以下软件,以遵循本书中的示例:

  • JDK 17

  • Java 编辑器,最好是 NetBeans 12.5 或更高版本

安装 JDK 17

你需要一个 JDK 来编译和运行 Java 程序。你可以从 https://jdk.java.net/17/ *下载适合你操作系统的 JDK 17。*按照此网页上的说明在您的操作系统上安装 JDK。位于 https://docs.oracle.com/en/java/javase/17/ 的网页包含 JDK 安装的详细说明。

在本书中,我们假设您已经在 Windows 的C:\java17目录中安装了 JDK。如果您已经将它安装在不同的目录中,或者您正在使用不同的操作系统,则需要在您的系统上使用 JDK 安装的路径。例如,如果你已经将它安装在 UNIX 类操作系统的/home/ksharan/jdk17目录中,那么只要我们在本书中使用C:\java17,就使用/home/ksharan/jdk17

当您使用 Java 时,您会经常听到三个术语:

  • JDK_HOME

  • JRE_HOME

  • JAVA_HOME

JDK_HOME指电脑上安装 JDK 的目录。如果您在C:\java17中安装了 JDK,JDK_HOME指的是C:\java17目录。

JDK 有一个子集,称为 JRE (Java 运行时环境)。如果您已经编译了 Java 代码,并且只想运行它,那么您只需要安装 JRE。JDK 包含带有几个工具的 JRE,比如 Java 编译器。JRE_HOME指计算机上安装 JRE 的目录。您总是可以使用 JDK 安装目录作为JRE_HOME的值,因为 JDK 包含 JRE。

通常,JAVA_HOME指的是JRE_HOME。根据上下文,它也可以指JDK_HOME

我在本书中使用术语JDK_HOME来指代 JDK 17 的安装目录。在接下来的两节中,我将解释 JDK 的目录结构以及如何验证 JDK 安装。

JDK 目录结构

在本节中,我们将解释 JDK 安装的目录结构。在 JDK 9 及更高版本中,JDK 目录及其内容的组织方式有一些重要的变化。我们还比较了 JDK 8 和 JDK 9 的目录结构。如果您想将 JDK 8 应用程序迁移到 JDK 9 或更高版本,新的 JDK 结构可能会中断您的应用程序,您需要密切关注本节中描述的变化。

在 JDK 9 之前,JDK 构建系统曾经产生两种类型的运行时映像——Java 运行时环境(JRE)和 Java 开发工具包(JDK)。JRE 是 Java SE 平台的完整实现,JDK 拥有嵌入式 JRE、开发工具和库。你可以选择只安装 JRE 或者安装一个内嵌了 JRE 的 JDK。图 2-1 显示了 Java SE 9 之前的 JDK 安装中的主要目录。JDK_HOME是安装 JDK 的目录。如果您只安装了 JRE,那么您将只有在jre目录下的目录。

img/323069_3_En_2_Fig1_HTML.png

图 2-1

Java SE 9 之前的 JDK 和 JRE 安装目录安排

JDK 8 中的安装目录排列如下:

  • bin目录包含了javacjarjavadoc等命令行开发调试工具。它还包含启动 Java 应用程序的java命令。

  • include目录包含编译本机代码时使用的 C/C++头文件。

  • 目录包含几个 jar 和其他类型的 JDK 工具文件。

    它有一个tools.jar文件,其中包含用于javac编译器的 Java 类。

  • jre\bin目录包含基本命令,如java命令。在 Windows 平台上,它包含系统运行时动态链接库(dll)。

  • jre\lib目录包含用户可编辑的配置文件,如.properties.policy文件。

  • jre\lib\endorsed目录包含允许认可的标准覆盖机制的 jar,该机制允许实现认可的标准或独立技术的类和接口的更高版本(在 Java 社区过程之外创建)被合并到 Java 平台中。这些 jar 被添加到 JVM 的引导类路径中,因此覆盖了 Java 运行时中存在的这些类和接口的任何定义。

  • jre\lib\ext目录包含允许扩展机制的 jar。这种机制通过一个扩展类装入器装入这个目录中的所有 jar,它是引导类装入器的子类,也是系统类装入器的父类,它装入所有应用程序类。通过将 jar 放在这个目录中,可以扩展 Java SE 平台。这些 jar 的内容对所有用这个运行时映像编译或运行的应用程序都是可见的。

  • jre\lib目录包含几个 jar。rt.jar文件包含运行时的 Java 类和资源文件。许多工具依赖于rt.jar文件的位置。

  • jre\lib目录包含用于非 Windows 平台的动态链接的本地库。

  • jre\lib目录包含其他几个子目录,这些子目录包含字体和图像等运行时文件。

没有嵌入 JDK 的 JDK 和 JRE 的根目录过去包含几个文件,如COPYRIGHTLICENSEREADME。根目录中的release文件包含一个描述运行时映像的键值对,比如 Java 版本、OS 版本和架构。以下是来自 JDK 8 的release文件示例,显示了部分内容:

JAVA_VERSION="1.8.0_66"
OS_NAME="Windows"
OS_VERSION="5.2"
OS_ARCH="amd64"
BUILD_TYPE="commercial"

Java SE 9 简化了 JDK 的目录层次结构,消除了 JDK 和 JRE 之间的区别。图 2-2 显示了 JDK 9 及以上版本中 JDK 安装的目录。JRE 8 安装不包含includejmods目录。

img/323069_3_En_2_Fig2_HTML.png

图 2-2

Java SE 9 及更高版本中的 JDK 目录排列

JDK 9+中的安装目录排列如下:

  • 没有名为jre的子目录。

  • bin目录包含所有命令。在 Windows 平台上,它继续包含系统的运行时动态链接库。

  • conf目录包含用户可编辑的配置文件,例如曾经在jre\lib目录中的.properties.policy文件。

  • include目录包含编译本机代码时使用的 C/C++头文件。它只存在于 JDK。

  • jmods目录包含 JMOD 格式的平台模块。创建自定义运行时映像时需要它。它只存在于 JDK,不存在于 JRE。

  • legal目录包含法律声明。

  • lib目录包含非 Windows 平台上的动态链接的本地库。它的子目录和文件不应该被开发者直接编辑或使用。它包含一个名为modules的文件,该文件包含内部格式为 JIMAGE 的 Java SE 平台模块。

    提示 JDK 9 或更高版本比 JDK 8 大得多,因为它包含两个平台模块副本——一个在 JMOD 格式的jmods目录中,另一个在lib\modules文件中,格式为JIMAGE

JDK 的根目录下继续有COPYRIGHTLICENSEREADME等文件。JDK 中的release文件包含一个带有MODULES键的新条目,其值是映像中包含的模块列表。JDK 17 图像中release文件的部分内容如下:

IMPLEMENTOR="Oracle Corporation"
JAVA_VERSION="17"
JAVA_VERSION_DATE="2021-09-14"
OS_ARCH="amd64"
OS_NAME="Windows"
MODULES="java.base java.compiler java.datatransfer”

我们在列表中只显示了三个模块。在完全 JDK 安装中,该列表将包括所有平台模块。在自定义运行时映像中,该列表将只包含您在映像中包含的模块。

Tip

JDK 中的lib\tools.jar和 JRE 中的lib\rt.jar在版本 9 中被从 Java SE 中移除。这些 jar 中可用的类和资源现在以内部格式存储在lib目录中。一种叫做jrt的新方案可以用来从运行时映像中检索那些类和资源。依赖于这些 jar 位置的应用程序将停止工作。

验证 JDK 安装

JDK_HOME\bin目录包含一个名为java的命令,用于启动 Java 应用程序。当使用以下选项之一运行java命令时,它会打印 JDK 版本信息:

  • -version

  • --version

  • -showversion

  • --show-version

所有四个选项打印相同的 JDK 版本信息。以一个连字符开头的选项是 UNIX 风格的选项,而以两个连字符开头的选项是 GNU 风格的选项。JDK 9 引入了 GNU 风格的选项。UNIX 风格的选项在标准错误流上打印 JDK 版本,而 GNU 风格的选项在标准输出流上打印它。-version--version选项在打印完信息后退出,而-showversion--show-version选项在打印完信息后继续执行其他选项。以下命令显示了如何打印 JDK 版本:

C:\>java -version
openjdk version "17-ea" 2021-09-14
OpenJDK Runtime Environment (build 17-ea+10-723)
OpenJDK 64-Bit Server VM (build 17-ea+10-723, mixed mode, sharing)

如果输出的第一行打印出"version 17",那么您的 JDK 安装是好的。您可能会得到如下所示的输出:

'java' is not recognized as an internal or external command, operable program or batch file.

该输出表明JDK_HOME\bin目录不包含在您计算机上的PATH环境变量中。在这种情况下,你可以使用java命令的完整路径来打印它的版本以及你需要它的任何地方。我的JDK_HOME是 Windows 上的C:\java17。以下命令向您展示了如何使用完整路径以及如何在命令提示符下设置PATH环境变量:

C:\>SET PATH=C:\java17\bin;%PATH%
C:\>java --version

您还可以使用以下命令在 Windows 上永久设置PATH环境变量:

 Control Panel > System and Security > System > Advanced system settings > Environment Variables

如果您的计算机上安装了多个 JDK,那么创建一个批处理(或 shell)脚本来打开命令提示符并在脚本中设置PATH环境变量会更容易。这样,您可以使用多个 JDK,而无需在系统级设置PATH环境变量。

启动 JShell 工具

JDK 9 及以上版本在JDK_HOME\bin目录中包含一个jshell工具。该工具允许您执行一段 Java 代码,而不是编写一个完整的 Java 程序。这对初学者很有帮助。第二十三章详细介绍了jshell工具。以下命令向您展示了如何启动jshell工具,执行一些 Java 代码片段,然后退出jshell工具:

C:\>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> System.out.println("Hello JDK 17!")
Hello JDK 17!
jshell> 2 + 2
$2 ==> 4
jshell> /exit
|  Goodbye
C:\>

在阅读后续章节时,您可以在命令提示符下启动jshell工具,并输入一段代码来查看结果。

安装 NetBeans 12

您需要一个 Java 编辑器来编写、打包、编译和运行您的 Java 应用程序,NetBeans 就是这样一个 Java 编辑器。本书的源代码包含 NetBeans 项目。但是,不一定要使用 NetBeans。您可以使用另一个 Java 编辑器,如 Eclipse、IntelliJ IDEA 或 JDeveloper。为了遵循本书中的示例,您需要将源代码(.java文件)复制到您使用另一个 Java 编辑器创建的项目中。

可以从 https://netbeans.org/ 下载 NetBeans 12.5 或以上版本。NetBeans 12 在 JDK 版本 8 和 11 以及当前的 JDK 版本上运行。当您安装 NetBeans 时,它会要求您选择 JDK 主目录。如果您在 JDK 11 上安装 NetBeans,您可以选择 JDK 17 作为 Java 平台,以便在 NetBeans 中使用 JDK 17。如果你把它安装在 JDK 17 上,JDK 17 将是 NetBeans 内部默认的 Java 平台。在下一节中,我们将向您展示如何在 NetBeans IDE 中选择 Java 平台。

配置 NetBeans

启动 NetBeans IDE。如果第一次打开 IDE,它会显示一个标题为“起始页”的窗格,如图 2-3 所示。如果不希望再次显示,可以取消选中面板右上角标有“启动时显示”的复选框。您可以通过单击窗格标题中的 X 来关闭起始页窗格。如果您想随时显示此页面,可以使用“帮助➤起始页”菜单项。

img/323069_3_En_2_Fig3_HTML.png

图 2-3

初始 NetBeans IDE 屏幕

选择工具➤“Java 平台”,显示 Java 平台管理器对话框,如图 2-4 所示。在此图中,NetBeans IDE 正在 JDK 17 上运行,它显示在平台列表中。如果您在 JDK 17 上运行它,JDK 17 将显示在平台列表中,您不需要任何进一步的配置。

img/323069_3_En_2_Fig4_HTML.png

图 2-4

“Java 平台管理器”对话框

如果您在平台列表中看到 JDK 17,您的 IDE 已经配置为使用 JDK 17,您可以通过单击关闭按钮来关闭对话框。如果在平台列表中没有看到 JDK 17,点击添加平台…按钮,打开添加 Java 平台对话框,如图 2-5 所示。确保选中了 Java Standard Edition 单选按钮。点击下一个➤按钮,显示添加 Java 平台对话框,如图 2-6 所示。

img/323069_3_En_2_Fig6_HTML.png

图 2-6

“添加 Java 平台”对话框

img/323069_3_En_2_Fig5_HTML.png

图 2-5

“选择平台类型”框

在“添加 Java 平台”对话框中,选择 JDK 17 的安装目录。在此示例中,我们在 Windows 上的 C:\Users\Adam\jdk-17 中安装了 JDK 17,因此我们在此对话框中选择了 C:\Users\Adam\jdk-17 目录。点击下一个➤按钮。显示如图 2-7 所示的添加 Java 平台对话框。“平台名称”和“平台源”字段是预填充的。

img/323069_3_En_2_Fig7_HTML.png

图 2-7

“添加 Java 平台,平台名”对话框

单击 Finish 按钮,这将使您返回到 Java Platform Manager 对话框,该对话框将 JDK 17 显示为平台列表中的一个项目,如图 2-8 所示。单击关闭按钮关闭对话框。您已经完成了将 NetBeans IDE 配置为使用 JDK 17 的操作。

img/323069_3_En_2_Fig8_HTML.png

图 2-8

加上 JDK 17 后

摘要

要使用 Java 程序,您需要安装一个 JDK,如 OpenJDK 17,以及一个 Java 编辑器,如 NetBeans。安装 JDK 的目录通常称为JDK_HOME。JDK 安装在JDK_HOME\bin目录中复制了许多 Java 工具/命令,比如编译 Java 程序的javac命令,运行 Java 程序的java命令,以及运行 Java 代码片段的jshell工具。

NetBeans IDE 可以安装在 JDK 8、11 或 17 之上。当您将它安装在非 17 版本的 JDK 之上时,如果您想将其用作 Java 平台,则需要将 IDE 配置为使用 JDK 17 版本。

三、编写 Java 程序

在本章中,您将学习:

  • Java 程序的结构

  • 如何组织 Java 程序的源代码

  • 如何使用 Java Shell、命令提示符和 NetBeans 集成开发环境(IDE)编写、编译和运行 Java 程序

  • 什么是模块图

  • 什么是模块路径和类路径,以及如何使用它们

  • 简要介绍 Java 平台和 Java 虚拟机(JVM)

本章和本书的其余章节假设您已经安装了 JDK 17 和 NetBeans IDE 12.5 或更高版本,如第二章所述。

目标语句

本章的主要目标很简单—编写一个 Java 程序在控制台上打印以下消息:

Welcome to Java 17!

你可能会想,“用 Java 打印一条消息会有多难?”事实上,用 Java 打印一条消息并不难。下面一行代码将打印这条消息:

System.out.println("Welcome to Java 17!");

然而,要在一个成熟的 Java 程序中打印这条消息,您必须做大量的准备工作。我们将向您展示如何使用三种方法在 Java 中打印消息:

  • 使用 Java Shell,也称为 JShell 工具

  • 使用命令提示符或终端

  • 使用 NetBeans IDE

使用 JShell 工具不需要做任何准备工作,这是最简单的。它将让您在不了解 Java 编程语言的任何其他知识的情况下打印一条消息。

使用命令提示符需要做大量的准备工作,并使您在按照本节所述的目标打印消息之前学习 Java 程序结构的基础知识。

使用 NetBeans IDE 需要做一些准备工作,它为开发人员提供了最大的帮助。在本章之后,您将只使用 NetBeans 来编写所有程序,除非需要其他两种方法来显示它们的特殊功能。以下部分向您展示了如何使用这三种方法来实现既定目标。

使用 JShell 工具

第二章快速介绍了 JShell 工具。您需要输入以下代码行来打印消息:

System.out.println("Welcome to Java 17!");

我们将在下一节中解释这段代码的每一部分。以下 JShell 会话显示了 Windows 命令提示符下的所有步骤:

c:\>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> System.out.println("Welcome to Java 17!");
Welcome to Java 17!
jshell> /exit
|  Goodbye
c:\>

您已经看到了如何使用 JShell 执行 Java 语句。您还没有看到完整的 Java 程序。JShell 是一个非常强大的工具,你可以用它来快速学习 Java 语言。在接下来的几章中,它将是试验示例中使用的代码片段的便利工具。我们将在本书后面的一章中介绍它的所有细节。

什么是 Java 程序?

使用 Java 编程语言的规则和语法编写的 Java 程序是由计算机执行以完成任务的一组指令。在接下来的几节中,我们只解释编写 Java 程序所涉及的基础知识。我们将在后续章节中详细解释 Java 程序的所有方面。开发 Java 应用程序包括四个步骤:

  • 编写源代码

  • 编译源代码

  • 打包编译后的代码

  • 运行编译后的代码

您可以使用自己选择的文本编辑器编写 Java 程序,例如 Windows 上的记事本和 UNIX 上的 vi 编辑器。使用 Java 编译器将源代码编译成目标代码,也称为字节码。编译好的代码打包成 JAR(JavaArchive)文件。打包的编译代码由 JVM 运行。

当您使用 NetBeans 之类的 IDE 时,IDE 为您提供了一个内置编辑器来编写源代码。IDE 还为您提供了编译、打包和运行应用程序的简单方法。

编写源代码

本节涵盖了编写源代码的细节。我们通过在 Windows 上使用记事本来演示这一点。您可以使用操作系统上提供的文本编辑器。

Note

我将在本章后面介绍如何使用 NetBeans IDE。我首先想介绍使用文本编辑器,因为这个过程揭示了许多您需要了解的 Java 程序。

当你写完源代码后,你必须用扩展名.java保存文件。您将把您的源代码文件命名为Welcome.java。请注意,除了.java之外的任何文件扩展名都是不可接受的。例如,Welcome.txtWelcome.doc这样的名字就不是有效的源代码文件名。

每当你使用一种语言来写东西(在你的例子中,Java 源代码),你需要遵循该语言的语法,并根据你所写的东西使用特定的语法。让我们举一个给你朋友写信的例子。这封信有几个部分:标题、问候语、正文、结束语和你的签名。在一封信中,不仅仅是把五个部分放在一起很重要;相反,它们也应该按照特定的顺序排列。例如,结尾需要跟随身体。一封信中的一些部分是可选的,而其他部分是必须的。例如,在给朋友的信中不写回信地址是可以的,但在商务信函中却是必须的。一开始,你可以把写 Java 程序想象成类似于写信。

Java 程序由一个或多个模块组成。一个模块包含零个或多个包。一个包包含一种或多种类型。术语 type 是一个通用术语,指的是用户定义的数据类型。您可以有四种用户定义的数据类型—类、接口、枚举和注释。广义地说,枚举和注释分别是类和接口的特殊类型。在接下来的几章中,您将只使用类。图 3-1 显示了一个模块的布置。

img/323069_3_En_3_Fig1_HTML.png

图 3-1

Java 程序的结构

Note

在 JDK 9 中引入了模块。直到 JDK 8,你有包和类型,但没有模块。除了包之外,模块是组织代码的可选方式。

纯 Java 程序是操作系统不可知的。你在所有操作系统上写同样的 Java 代码。操作系统使用不同的语法来引用文件和分隔文件路径。使用 Java 程序时,您必须引用文件和目录,并且需要使用适用于您的操作系统的语法。Windows 在文件路径中使用反斜杠(\)作为目录分隔符,例如C:\javafun\src,而类 UNIX 操作系统使用正斜杠(/),例如/home/ksharan/javafun。Windows 使用分号(;)作为路径分隔符,例如C:\java9\bin;C:\bj9r,而类 UNIX 操作系统使用冒号(:),例如/home/ksharan/java9/bin:/home/ksharan/javafun。我使用 Windows 来处理本书中的示例。我还解释了使用不同操作系统时存在的差异。

我们使用以下目录结构来处理本节中的示例:

  • javafun

  • javafun\src

  • javafun\mod

  • javafun\lib

我们将顶级目录命名为javafun,这是开始Java17Fundamentals 的简称。您可以在计算机上的任何其他目录中创建此目录。例如,您可以在 Windows 上将其命名为C:\javafun,在 UNIX 上命名为/home/ksharan/javafun。您将在javafun\src目录中存储源代码,在javafun\mod目录中存储编译后的代码,在javafun\lib目录中存储打包后的代码。继续在您的计算机上创建这些目录。在接下来的章节中,您需要用到它们。

写评论

注释是不可执行的代码,用于记录代码。Java 编译器会忽略它们。它们包含在源代码中,以记录程序的功能和逻辑。Java 支持三种类型的注释:

  • 单行注释

  • 多行 comment

  • 文档注释或 Javadoc 注释

单行注释以两个正斜杠(//)开头,后跟文本,例如:

// This is a single-line comment
package com.jdojo.intro; // This is also a single-line comment

单行注释可以从一行的任何位置开始。从两个正斜杠开始到行尾的部分被认为是注释。如前所述,您还可以混合 Java 源代码,例如,在一行中混合一个包声明和一个注释。注意,这种类型的注释不能插入到 Java 代码中间。下面详细讨论的包声明是不正确的,因为包名和分号也被认为是注释的一部分:

package // An incorrect single-line comment com.jdojo.intro;

下面一行是单行注释。它有一个有效的包声明作为注释文本。

它将被视为注释,而不是包声明:

// package com.jdojo.intro;

第二种类型的注释称为多行注释。多行注释可以跨越多行。它以紧跟其后的星号(/*)的正斜杠开始,以紧跟其后的星号(*/)结束。Java 源代码中多行注释的示例如下:

/*
    This is a multi-line comment.
    It can span more than one line.
*/

该注释也可以使用两个单行注释来编写,如下所示:

// This is a multi-line comment.
// It can span more than one line

您在源代码中使用的注释风格是您个人的选择。可以在 Java 代码中间插入一个多行注释,如下所示。编译器忽略从/**/的所有文本:

package /* A correct comment */ com.jdojo.intro;

第三种类型的注释称为文档(或 Javadoc)注释,也是多行注释。它用于为 Java 程序生成文档。这种注释以紧跟其后的两个星号(/**)的正斜杠开始,以紧跟其后的一个星号(*/)结束。以下是文档注释的简单示例:

/**
   This is a documentation comment. javadoc generates documentation from such comments.
*/

编写 Javadoc 注释是一个庞大的主题。附录 B 详细介绍了这一点。本书中的所有源代码都以单行注释开始,注释中包含包含源代码的文件的名称,例如:

// Welcome.java

声明模块

模块充当包的容器。模块可以包含可以在模块内部使用或由其他模块使用的包。模块控制其包的可访问性。一个模块导出它的包供其他模块使用。如果一个模块需要使用另一个模块的包,第一个模块需要声明对第二个模块的依赖,第二个模块需要导出第一个模块使用的包。下面是声明模块的简化语法:

module <module-name> {
    <module-statement-1>
    <module-statement-2>
}

模块的声明以关键字module开始,后面是模块名。在大括号内,放置模块声明的主体,它包含零个或多个模块语句。清单 3-1 包含了一个名为jdojo.intro的模块的完整代码。

// module-info.java
module jdojo.intro {
    // An empty module body
}

Listing 3-1The Declaration of a Module Named jdojo.intro

jdojo.intro模块不包含模块语句。也就是说,它不导出任何包供其他模块使用,也不依赖于任何其他模块。JDK 由几个模块组成;其中一个模块被命名为java.basejava.base模块被称为原始模块。它不依赖于其他模块,所有其他模块——内置的和用户定义的——都隐式依赖于它。

Tip

在 Java 中,三个术语“依赖于”、“读取”和“需要”可以互换使用,以表示一个模块对另一个模块的依赖性。如果模块P依赖于模块Q,也可以表述为“模块P读取模块Q或者“模块P需要模块”)

模块的依赖关系是在模块体中用一个requires语句声明的。其最简单的语法如下:

requires <module-name>;

您没有为jdojo.intro模块声明任何依赖关系。然而,由于 Java 中的每个模块都隐式地依赖于java.base模块,编译器会在你的jdojo.intro模块中添加对java.base模块的依赖。编译器修改的模块声明如清单 3-2 所示。

// module-info.java
module jdojo.intro {
    requires java.base;
}

Listing 3-2The Compiler-Modified Declaration of the jdojo.intro Module

如果你愿意,你可以在你的模块声明中包含一个"requires java.base"语句。

如果您不这样做,编译器总是会为您添加它。在本书中,我没有将它包含在模块声明中。

为什么每个模块都依赖于java.base模块?java.base模块包含几个 Java 包,它们是在所有 Java 程序中提供基本功能所必需的。例如,您想在控制台上打印一条消息,打印功能包含在名为java.lang的包中的java.base模块中。

通常,模块声明保存在模块源代码根目录下的一个module-info.java文件中。在javafun\src目录中创建一个名为jdojo.intro的子目录,在其中放置jdojo.intro模块的所有源代码。将清单 3-1 中显示的代码保存在一个名为javafun\src\jdojo.intro\module-info.java的文件中。这就完成了您的模块声明。

是否必须将模块声明保存在与模块名同名的根目录下?不,这不是强制性的。您可以将module-info.java文件保存在javafun\src目录中,一切都会正常工作。将模块的所有源代码保存在以模块名称命名的目录中,可以使编译模块代码更加容易。JDK 还支持将模块的代码保存在不同的根目录下。

声明类型

一个包被分成几个编译单元。编译单元包含这些类型的源代码。在很大程度上,您可以将编译单元视为一个包含类和接口等类型的源代码的.java文件。当你编译一个 Java 程序时,你编译的是该程序包含的编译单元。通常,一个编译单元包含一种类型的声明。例如,您要声明一个名为Welcome的类,您将把Welcome类的源代码放在一个名为Welcome.java的编译单元(或文件)中。编译单元由三部分组成:

  • 一包申报

  • 零个或多个import声明

  • 零个或多个类型声明:类、接口、枚举或批注声明

所有三个部分,如果存在,必须按上述顺序指定。图 3-2 显示了一个编译单元的三个部分,其中包含一个类型声明。类型是一个名为Welcome的类。后续部分详细描述了编译单元的每个部分。

img/323069_3_En_3_Fig2_HTML.png

图 3-2

编译单元的组成部分

包装声明

包声明的一般语法如下:

package <your-package-name>;

包声明以关键字package开始,后跟用户提供的包名。空白(空格、制表符、换行符、回车符和换页符)分隔关键字package和包名。分号(;)结束包声明。例如,下面是名为com.jdojo.intro的包的包声明:

package com.jdojo.intro;

图 3-3 显示了包装声明的各个部分。

img/323069_3_En_3_Fig3_HTML.png

图 3-3

编译单元中的部分包声明

您提供包名。包名可以由一个或多个部分组成,用点(.).在这个例子中,包名由三部分组成:comjdojointro。包名中的部分数量没有限制。在一个编译单元中,最多可以有一个包声明。编译单元中声明的所有类型都成为该包的成员。以下是有效包声明的一些示例:

package intro;
package com.jdojo.intro.common;
package com.ksharan;
package com.jdojo.intro;

如何选择一个好的包名?保持包名的唯一性很重要,这样它们就不会与同一应用程序中使用的其他包名冲突。建议对包的开头部分使用反向域名符号,例如:com.yahoo代表 Yahoo, com.google代表 Google,等等。使用公司的反向域名作为包名的主要部分保证了包名不会与其他公司使用的包名冲突,只要它们遵循相同的准则。如果你没有域名,请创建一个唯一的域名。这只是一个指导方针。实际上,没有任何东西可以保证世界上所有 Java 程序都有一个唯一的包名。例如,我拥有一个名为jdojo.com的域名,我以com.jdojo开始我所有的包名,以保持它们的唯一性。在本书中,我以com.jdojo开始一个包名,后面跟着主题名。

为什么我们要使用包声明?包是类型的逻辑存储库。换句话说,它为相关类型提供了一个逻辑分组。包可以存储在特定于主机的文件系统或网络位置。在文件系统中,包名的每个部分表示主机系统上的一个目录。例如,包名com.jdojo.intro表示存在一个名为com的目录,该目录包含一个名为jdojo的子目录,该子目录包含一个名为intro的子目录。也就是说,包名com.jdojo.intro表明在 Windows 上存在一个com\jdojo\intro目录,在类 UNIX 操作系统上存在一个com/jdojo/intro目录。intro目录将包含为com.jdojo.intro包中的所有类型编译的 Java 代码。在主机系统上,用于分隔软件包名称中各部分的点被视为文件分隔符。注意,反斜杠(\)是 Windows 上的文件分隔符,正斜杠(/)用于类似 UNIX 的操作系统。

包名只指定编译后的 Java 程序(类文件)必须存在的部分目录结构。它没有指定类文件的完整路径。在这个例子中,包声明com.jdojo.intro没有指定com目录放在哪里。可以放在C:\目录或者C:\myprograms目录下,也可以放在文件系统中的任何其他目录下。仅仅知道包名不足以定位类文件,因为它只指定了类文件的部分路径。文件系统中类文件路径的前导部分是从 modulepath 中获取的,需要在编译运行 Java 程序时指定。在 JDK 9 之前,包中的类文件是使用类路径定位的,为了向后兼容,在 JDK 9–17 中仍然支持类路径。我们将在本章后面讨论这两种方法。

Java 源代码是区分大小写的。关键字package必须按原样书写——全部小写。单词PackagepackAge不能代替关键词package。包名也区分大小写。在某些操作系统上,文件和目录的名称区分大小写。在这些系统上,包名区分大小写,正如您所看到的:包名被视为主机系统上的目录名。包名com.jdojo.introCom.jdojo.intro可能不一样,这取决于您正在使用的主机系统。建议使用全部小写的包名。

在 JDK 9 之前,编译单元中的包声明是可选的。如果一个编译单元不包含包声明,那么在该编译单元中声明的类型属于一个名为未命名包的包。JDK 9 不允许模块中有未命名的包。如果将类型放在模块中,编译单元必须包含一个包声明。

进口申报

编译单元中的导入声明是可选的。您甚至可以不使用一个导入声明就开发一个 Java 应用程序。为什么需要进口申报单?使用进口报关让您的生活更轻松。它节省了您的一些输入,并使您的代码更干净,更容易阅读。在导入声明中,您告诉 Java 编译器您可以使用特定包中的一个或多个类型。每当在编译单元中使用某个类型时,必须用它的完全限定名来引用它。使用类型的导入声明可以让您使用类型的简单名称来引用该类型。我将很快讨论简单的和完全限定的类型名。

与包声明不同,源代码中对导入声明的数量没有限制。以下是两项进口申报:

import com.jdojo.intro.Account;
import com.jdojo.util.*;

我们将在本书的后面详细讨论导入声明。在本节中,我们只讨论进口申报单所有部分的含义。

进口申报以关键字import开始。进口申报的第二部分由两部分组成:

  • 要在当前编译单元中使用类型的包名

  • 类型名称或星号(*)表示您可以使用包中存储的一个或多个类型

最后,导入声明以分号结束。前两份进口申报说明如下:

  • 我们可以使用来自com.jdojo.intro包的简单名称命名为Account的类型。

  • 我们可以使用com.jdojo.util包中任何类型的简单名称。

如果您想使用来自com.jdojo.common包的名为Person的类,您需要在您的编译单元中包含以下两个导入声明之一:

import com.jdojo.common.Person;

或者

import com.jdojo.common.*;

以下导入声明不包括包comcom.jdojo中的类:

import com.jdojo.intro.Account;
import com.jdojo.intro.*;

你可能认为像这样的进口申报单

import com.*.*;

将允许您使用所有类型的简单名称,这些类型的包声明的第一部分是com。Java 不支持在导入声明中使用这种类型的通配符。您只能命名一个包中的一种类型(com.jdojo.intro.Account)或一个包中的所有类型(com.jdojo.intro)。*);导入类型的任何其他语法都是无效的。

编译单元中的第三部分包含类型声明,它可能包含零个或多个类型声明:类、接口、枚举和注释。根据 Java 语言规范,类型声明也是可选的。但是,如果您省略了这一部分,您的 Java 程序不会做任何事情。

要使 Java 程序有意义,必须在编译单元中至少包含一个类型声明。

我将把对接口、枚举和注释的讨论推迟到本书后面的章节。

让我们讨论如何在编译单元中声明一个类。

类别声明

最简单的形式是,类声明如下所示:

class Welcome {
    // Code for the class body goes here
};

图 3-4 显示了这个类声明的一部分。

img/323069_3_En_3_Fig4_HTML.png

图 3-4

编译单元中类声明的一部分

使用关键字class声明一个类,关键字后面跟有类的名称。在这个例子中,类的名称是Welcome

类的主体放在左大括号和右大括号之间。身体可能是空的。但是,您必须包括两个大括号来标记主体的开始和结束。

可选地,类声明可以以分号结束。本书不会使用可选的分号来结束类声明。

Java 程序中最简单的类声明可能如下所示:

class Welcome { }

这一次,我将整个类声明放在一行中。您可以将关键字class、类名Welcome和两个大括号放在任何您想要的位置,除了您必须包括至少一个空白字符(空格、换行符、制表符等等。)在关键字class和类名Welcome之间。Java 允许你以自由格式的文本格式编写源代码。以下三个类声明都是相同的:

// Class Declaration #1
class
Welcome { }
// Class Declaration #2
class
         Welcome {
}
// Class Declaration #3
class Welcome {
}

本书使用了如下的类声明格式:左大括号放在类名后面的同一行,右大括号放在单独的一行,与类声明第一行的第一个字符对齐,像这样:

class Welcome {
}

类的主体由四部分组成。所有部分都是可选的,可以按任何顺序出现,并且可以分成多个部分:

  • 字段声明

  • 初始化器:静态初始化器和实例初始化器

  • 构造器

  • 方法声明

Java 对类体的四个部分的出现顺序没有任何限制。我从方法声明开始,在本章中只讨论简单的方法声明。我们将在后面的章节中讨论方法声明的高级方面和类体声明的其他部分。

让我们讨论如何在类中声明一个方法。您可能会猜测方法声明将以关键字method开始,因为包和类声明分别以关键字packageclass开始。然而,方法声明不是以关键字method开始的。事实上,method在 Java 语言中并不是一个关键字。你用关键字class开始一个类声明,表明你将要声明一个类。但是,在方法声明的情况下,首先要指定的是方法将返回给调用者的值的类型。如果一个方法没有返回任何东西给它的调用者,你必须在方法声明的开始使用关键字void提到这个事实。方法的名称遵循方法的返回类型。方法名后跟一个左括号和一个右括号。像类一样,方法也有一个主体,用大括号括起来。Java 中最简单的方法声明如下所示:

<method-return-type> <method-name> (<arguments-list>) {
    // Body of the method goes here
}

下面是方法声明的一个示例:

void main() {
    // Empty body of the main method
}

这个方法声明包含四点:

  • 该方法不返回任何东西,如关键字void所示。

  • 方法的名字是main

  • 该方法不需要参数。

  • 该方法不做任何事情,因为它的主体是空的。

方法的返回值是该方法返回给调用者的东西。该方法的调用方可能还希望向该方法传递一些值。如果一个方法需要它的调用者传递值给它,这个事实必须在方法的声明中指出。您希望将值传递给方法的事实是在方法名后面的括号中指定的。关于要传递给方法的值,您需要指定两件事:

  • 要传递的值的类型。假设您想将一个整数(比如 10)传递给方法。您需要使用关键字int来表示这一点,该关键字用于表示一个整数值,如 10。

  • 标识符,它将保存您传递给方法的值。标识符是用户定义的名称。它被称为参数名。

如果您想让main方法从它的调用者那里接受一个整数值,那么它的声明将变成如下:

void main(int num) {
}

这里,num是一个标识符,它将保存传递给这个方法的值。除了num,您可以选择使用另一个标识符,例如num1num2num3myNumber等。main方法的声明如下:

main 方法接受一个 int 类型的参数,它不向其调用者返回任何值。

如果您想将两个整数传递给main方法,它的声明将更改如下:

void main(int num1, int num2) {
}

从这个声明中可以清楚地看出,您需要用逗号(,)来分隔传递给方法的参数。如果你想给这个方法传递 50 个整数,你会怎么做?您将得到一个类似这样的方法声明:

void main(int num1, int num2, ..., int num50) {
}

我只展示了三个参数声明。然而,当你写一个 Java 程序时,你将不得不键入所有的 50 个参数声明。让我们寻找一个更好的方法来给这个方法传递 50 个参数。在所有 50 个参数中有一个相似之处——它们都是同一类型,一个整数。任何值都不会包含像 20.11 或 45.09 这样的分数。所有参数之间的这种相似性允许您在 Java 语言中使用一种称为数组的神奇生物。使用数组向该方法传递 50 个整数参数需要什么?当你写作时

int num

这意味着num是类型int的标识符,它可以保存一个整数值。如果你在 int 后面放两个魔法括号([]),比如

int[] num

这意味着num是一个int的数组,它可以保存任意多的整数值。num所能容纳的整数数量是有限制的。但是,这个限制非常高,我们在详细讨论数组时会讨论这个限制。存储在num中的值可以使用下标访问:num[0]num[1]num[2]等。请注意,在声明一个类型为int的数组时,您没有提到希望num表示 50 个整数的事实。您修改后的main方法声明可以接受 50 个整数,如下所示:

void main(int[] num) {
}

你将如何声明让你传递 50 个人名字的main方法?由于int只能用于整数,所以您必须在 Java 语言中寻找其他表示文本的类型,因为人名将是文本,而不是整数。有一种类型String(注意String中的大写S)表示 Java 语言中的文本。因此,要向方法main传递 50 个名称,您可以如下更改其声明:

void main(String[] name) {
}

在该声明中,您不必将参数名从num更改为name。您更改它只是为了使参数的含义清晰直观。现在让我们向main方法的主体添加一些 Java 代码,这将在控制台上打印一条消息:

System.out.println("The message you want to print");

这不是讨论Systemoutprintln的合适场合。现在,只需输入System(注意System中的大写S)、一个点、out、一个点和println,后跟两个括号,括号中包含您想要打印的信息,并加上双引号。您想要打印一条消息"Welcome to Java 17!",那么您的main方法声明将如下所示:

void main(String[] name) {
    System.out.println("Welcome to Java 17!");
}

这是一个有效的方法声明,它将在控制台上打印一条消息。下一步是编译包含Welcome类声明的源代码,并运行编译后的代码。当您运行一个类时,Java 运行时会在该类中寻找一个名为main的方法,该方法的声明必须如下所示,尽管name可以是任何标识符。

public static void main(String[] name) {
}

除了publicstatic两个关键词,你应该能理解这个方法声明,声明:“main是一个方法,接受一个String的数组作为参数,不返回任何东西。”

现在,你可以把publicstatic看作是两个关键字,它们必须存在才能声明main方法。注意,Java 运行时还要求方法的名称是main。这就是我从一开始就选择main作为方法名称的原因。源代码的最终版本如清单 3-3 所示。我做了两处改动:

  • 我将Welcome类声明为 public。

  • 我将主方法的参数命名为args

将源代码保存在javafun\src\jdojo.intro\com\jdojo\intro目录下名为Welcome.java的文件中。

// Welcome.java
package com.jdojo.intro;
public class Welcome {
    public static void main(String[] args) {
        System.out.println("Welcome to Java 17!");
    }
}

Listing 3-3The Source Code for the Welcome Class

Java 编译器对源代码的文件名施加了限制。如果已经在编译单元中声明了公共类型(例如,类或接口),则编译单元的文件名必须与公共类型的名称相同。在这个例子中,您已经声明了Welcome类 public,这要求您将文件命名为Welcome.java。这也意味着不能在一个编译单元中声明多个公共类型。在一次编译中,最多可以有一个公共类型和任意数量的非公共类型。

此时,本示例的源目录和文件如下所示:

  • javafun\src\jdojo.intro\module-info.java

  • javafun\src\jdojo.intro\com\jdojo\intro\Welcome.java

类型有两个名称

Java 中的每个类(实际上是每个类型)都有两个名字:

  • 简单的名字

  • 完全限定的名称

类的简单名称是在类声明中出现在关键字class之后的名称。在这个例子中,Welcome是类的简单名称。一个类的完全限定名是它的包名后跟一个点和它的简单名。在本例中,com.jdojo.intro.Welcome是该类的完全限定名:

Simple-Name = "Name appearing in the type declaration"
Fully-Qualified-Name = "package name" + "." + "Simple-Name"

您脑海中可能出现的下一个问题是,“没有包声明的类的完全限定名是什么?”答案很简单。在这种情况下,类的简单名称和完全限定名称是相同的。如果您从源代码中删除包声明,Welcome将是您的类的两个名称。

编译源代码

编译是将源代码翻译成一种叫做字节码的特殊二进制格式的过程。这是使用 JDK 附带的一个名为javac的程序(通常称为编译器)来完成的。编译 Java 源代码的过程如图 3-5 所示。

img/323069_3_En_3_Fig5_HTML.png

图 3-5

将 Java 源代码编译成字节码的过程

您提供源代码(在您的例子中是Welcome.javamodule-info.java)作为 Java 编译器的输入,它生成两个扩展名为.class的文件。扩展名为.class的文件称为类文件。类文件是一种叫做字节码的特殊格式。字节码是 Java 虚拟机(JVM)的一种机器语言。我们将在本章的后面讨论 JVM 和字节码。

现在,我将介绍在 Windows 上编译源代码所需的步骤。对于其他平台,例如 UNIX 和 Mac OS X,您需要使用特定于这些平台的文件路径语法。我假设您已经在 Windows 上保存了两个源文件,如下所示:

  • C:\javafun\src\jdojo.intro\module-info.java

  • C:\javafun\src\jdojo.intro\com\jdojo\intro\Welcome.java

打开命令提示符,将当前目录更改为C:\javafun。提示符应该如下所示:

C:\javafun>

使用javac命令的语法如下:

javac -d <output-directory> <source-file1> <source-file2>...<source-fileN>

-d选项指定编译后的类文件将被放置的输出目录。您可以指定一个或多个源代码文件。如果没有指定-d选项,编译后的类文件将被放在与源文件相同的位置。

您的输出目录将是javafun\mod\jdojo.intro,因为您希望将所有的类文件放在这个目录中。您将指定两个源文件,分别是module-info.javaWelcome.java。以下命令将编译您的源代码。该命令是在一行中输入的,而不是如图所示的两行:

C:\javafun>javac -d mod\jdojo.intro src\jdojo.intro\module-info.java src\jdojo.intro\com\jdojo\intro\Welcome.java

注意,该命令使用相对路径,如modsrc,它们相对于当前目录C:\javafun。如果愿意,可以使用绝对路径,比如C:\javafun\mod\jdojo.intro

如果您没有收到错误消息,这意味着您的源文件编译成功,编译器生成了两个名为module-info.classWelcome.class的文件,如下所示:

  • C:\javafun\mod\jdojo.intro\module-info.class

  • C:\javafun\mod\jdojo.intro\com\jdojo\intro\Welcome.class

注意,编译器通过创建一个目录层次结构来放置Welcome.class文件,该目录层次结构反映了Welcome.java文件中的包声明。回想一下,包名反映了目录层次结构。例如,名为com.jdojo.intro的包对应于名为com\jdojo\intro的目录。您已经通过创建镜像包名的目录层次结构放置了Welcome.class文件。Java 编译器足够聪明,可以读取包名,并在输出目录中创建一个目录层次结构来放置生成的类文件。

如果在编译源代码时出现任何错误,可能有以下三种原因之一:

  • 您没有将module-info.javaWelcome.java文件保存在本节开头指定的目录中。

  • 您的计算机上可能没有安装 JDK 17。

  • 如果你已经安装了 JDK 17,你还没有将JDK_HOME\bin目录添加到PATH环境变量中,这里JDK_HOME指的是你在机器上安装 JDK 17 的目录。如果您在目录C:\java17中安装了 JDK 17,您需要将C:\java17\bin添加到您机器上的PATH环境变量中。

如果关于设置PATH环境变量的讨论没有帮助,您可以使用下面的命令。这个命令假设您已经在目录C:\java17中安装了 JDK:

C:\javafun> C:\java17\bin\javac -d mod\jdojo.intro src\jdojo.intro\module-info.java src\jdojo.intro\com\jdojo\intro\Welcome.java

如果您在编译源代码时收到以下错误消息,这意味着您正在使用旧版本的 JDK,例如 JDK 8:

src\jdojo.intro\module-info.java:1: error: class, interface, or enum expected
module jdojo.intro {

从 JDK 9 开始支持模块。在旧的 JDK 上编译module-info.java源文件会导致这个错误。修复方法是使用 JDK 17 的javac命令来编译你的源文件。

字节码文件(.class文件)的名称是Welcome.class。为什么编译器选择将类文件命名为Welcome.class?当您编写源代码并编译它时,您在三个地方使用了“欢迎”一词:

  • 首先,您声明了一个名为Welcome的类。

  • 其次,您将源代码保存在一个名为Welcome.java的文件中。

  • 第三,将Welcome.java文件名作为输入传递给编译器。

你的三个步骤中的哪一个促使编译器将生成的字节码文件命名为Welcome.class?首先,这似乎是第三步,即将Welcome.java作为输入文件名传递给 Java 编译器。然而,猜测是错误的。这是第一步,在文件Welcome.java中声明一个名为Welcome的类,这促使编译器将输出的字节码文件命名为Welcome.class。您可以在一个编译单元中声明任意多个类。假设您在一个名为Welcome.java的编译单元中声明了两个类WelcomeBye。编译器将选择什么文件名来命名输出类文件?编译器扫描整个编译单元。它为编译单元中声明的每个类(实际上是每个类型)创建一个类文件。如果Welcome.java文件有三个类——WelcomeThanksBye——编译器将生成三个类文件——Welcome.classThanks.classBye.class

要运行 Java 程序,您可以安排类文件:

  • 在展开的目录中,正如您现在所看到的

  • 在一个或多个 JAR 文件中

  • 或者两者的组合——展开的目录和 JAR 文件

您现在可以使用javafun\mod\jdojo.intro目录中的类文件运行您的程序。我将暂时推迟运行它。首先,我们将在下一节向您展示如何将编译后的代码打包到一个 JAR 文件中。

打包编译后的代码

JDK 附带了一个名为jar的工具,用于将 Java 编译的代码打包到 JAR 文件中。JAR 文件格式使用 ZIP 格式。JAR 文件只是一个扩展名为.jar的 ZIP 文件,在它的META-INF目录中有一个MANIFEST.MF文件。MANIFEST.MF文件是一个文本文件,包含不同 Java 工具使用的 JAR 文件及其内容的信息。JDK 还包含以编程方式处理 JAR 文件的 API。在这一节中,我们简要说明如何使用jar工具创建一个 JAR 文件。使用jar命令的语法如下:

jar [options] [-C <dir-to-change>] <file-list>

--create选项创建一个新的 JAR 文件。--file选项用于指定要创建的 JAR 文件的名称。-C选项用于指定一个目录,该目录将作为当前目录,该选项后指定的所有文件都将包含在 JAR 文件中。如果您想包含几个目录中的文件,您可以多次指定-C选项。

下面的命令在C:\javafun\lib目录中创建一个名为com.jdojo.intro.jar的 JAR 文件。在运行命令之前,确保C:\javafun\lib目录存在:

C:\javafun>jar --create --file lib/com.jdojo.intro.jar -C mod/jdojo.intro .

这里

  • --create选项指定您想要创建一个新的 JAR 文件。

  • --file lib/com.jdojo.intro.jar选项指定新文件的路径和名称。注意,文件路径以lib开始,这是相对于C:\javafun目录的。您可以自由使用绝对路径,如C:\javafun\lib\com.jdjo.intro.jar

  • -C mod/jdojo.intro选项指定jar命令应该切换到mod/jdojo.intro目录。

  • 注意,-C选项后面是一个空格,然后是一个点,或者命令以一个点结束。点表示当前目录,这是用-C选项指定的目录。它要求切换到mod/jdojo.intro目录,并递归地包含该目录中的所有文件。

该命令创建以下文件:

C:\javafun\lib\com.jdojo.intro.jar

您可以在jar命令中使用--list选项来列出 JAR 文件的内容。使用以下命令列出由前面的命令创建的com.jdojo.intro.jar文件的内容:

C:\javafun>jar --list --file lib/com.jdojo.intro.jar
META-INF/
META-INF/MANIFEST.MF
module-info.class
com/
com/jdojo/
com/jdojo/intro/
com/jdojo/intro/Welcome.class

输出显示了 JAR 文件中的所有目录和文件。输入目录中没有名为MANIFEST.MF的文件。jar命令为您创建一个MANIFEST.MF文件。您还可以看到,JAR 文件的根目录包含了module-info.class文件,而Welcome.class文件被放在了一个目录中,该目录镜像了它被放在mod\jdojo.intro目录中的目录,而后者又镜像了在其包名中指定的目录层次结构。

如果一个 JAR 文件包含一个module-info.class文件,它也被称为模块描述符,这个文件被称为一个模块化 JAR。否则,该文件就被简单地称为 JAR。在这个例子中,com.jdojo.intro.jar文件是一个模块化的 JAR。如果你把module-info.class文件移除,它就会变成一个罐子。

Tip

根目录中包含模块描述符(module-info.class)的 JAR 文件称为模块化 JAR。在 JDK 9 之前没有模块,所以没有模块化的罐子。

您可以使用jar工具来描述使用--describe-module选项的模块,并通过使用--file选项指定模块化 JAR。以下命令描述了打包在com.jdojo.intro.jar文件中的模块:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

考虑以下命令的输出:

  • 第一行以模块名开始,是jdojo.intro。名称后面是模块描述的路径。该路径使用一个jar方案,并指向文件系统。

  • 第二行提到一个requires语句,表明jdojo.intro模块需要java.base模块。回想一下,每个模块都隐式依赖于java.base模块。编译器为您添加了这个。最后一个词,mandated,表示对java.base模块的依赖是由 Java 模块系统规定的。

  • 第三行表示jdojo.intro模块包含一个名为com.jdojo.intro的包。contains这个短语用来表示这个包在模块中,但是它没有被模块导出,所以其他模块不能使用这个包。对于每个导出的包,该命令将打印以下内容:

exports <package-name>.

输出中的最后一行需要解释一下。图 3-1 显示一个模块包含一个或多个包。输出显示jdojo.intro模块包含一个com.jdojo.intro包。然而,您从未指定模块和包之间的链接——无论是在编写源代码时还是在编译或打包期间。那么模块如何知道它们包含的包呢?答案很简单。将module-info.class文件放在根目录下会使模块包含其下的所有包。在您的例子中,镜像Welcome类的com.jdojo.intro包的com/jdojo/intro目录位于模块 JAR 的根目录下。这就是它成为模块一部分的原因。

Tip

模块化 JAR 只包含一个模块的代码。根目录下的所有包都是该模块的一部分。

运行 Java 程序

Java 程序是由 JVM 运行的。使用名为java的命令调用 JVM,该命令位于JDK_HOME\bin目录中。java命令也被称为 Java 启动器。运行它的语法如下:

java [options] --module <module-name>[/<main-class-name>] [arguments]

这里

  • [options]表示传递给java命令的零个或多个选项。

  • --module选项指定模块名和模块内的主类名。<module-name>是模块名,例如jdojo.intro,<main-class-name>是主类的全限定名,例如com.jdojo.intro.Welcome。当您在模块化 JAR 中打包一个模块时,您可以为该模块指定主类名,它存储在模块描述符——module-info.class文件中。在上一节创建com.jdojo.intro.jar模块化 JAR 时,您还没有指定主类名。通过<main-class-name>是可选的。如果没有指定,那么java命令将使用模块描述符中的主类名。该命令调用<main-class-name>main()方法。

  • [arguments]是传递给主类的main()方法的以空格分隔的参数列表。注意,[options]被传递给java命令(或 JVM),而[arguments]被传递给正在运行的主类的main()方法。[arguments]必须在--module选项后指定。

让我们尝试使用以下命令运行Welcome类:

C:\javafun>java --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Module jdojo.intro not found

哎呀!你弄错了。我们有意使用这个命令,以便您可以理解使用模块运行 Java 程序时发生的幕后过程。输出中有两条消息:

  • 第一条消息指出,当 JVM 试图初始化引导层时发生了一个错误。

  • 第二条消息声明 JVM 无法找到jdojo.intro模块。

在启动时,JVM 解析模块的依赖关系。如果启动时没有解析所有必需的模块,程序将无法启动。这是在 Java 9+中使用模块的一个重大改进,在 Java 9+中,所有依赖项都在启动时进行验证。否则,运行时会试图在程序需要依赖项(类型)时解析它们,而不是在启动时解析,这会导致许多运行时意外。

模块系统在一个阶段(编译时或运行时)可访问的所有模块称为可观察模块。模块解析从一组被称为根模块的初始模块开始,并遵循依赖链,直到到达java.base模块。分解模块的集合被称为模块图。在模块图中,每个模块都表示为一个节点。如果第一个模块依赖于第二个模块,则存在从一个模块到另一个模块的有向边。

图 3-6 显示了带有两个名为AB的根模块的模块图。模块A依赖于模块P,模块P又依赖于java.base模块。模块B依赖于模块Q,模块Q又依赖于java.base模块。Java 运行时将只使用已解析的模块。也就是说,Java 运行时只知道模块图中的模块。

img/323069_3_En_3_Fig6_HTML.png

图 3-6

模块图

通常,您只有一个根模块。根模块的集合是如何确定的?当您从一个模块运行 Java 程序时,包含主类的模块是唯一的默认根模块。

Tip

如果您需要解析额外的模块,否则默认情况下不会被解析,您可以使用--add-modules命令行选项将它们添加到默认的根模块集中。我们将把添加根模块的讨论推迟到后面的章节,因为这是一个高级主题。

让我们回到解决我们的错误。前面的命令试图运行jdojo.intro模块中的Welcome类。因此,jdojo.intro模块是唯一的根模块。如果一切正常,JVM 应该已经创建了如图 3-7 所示的模块图。

img/323069_3_En_3_Fig7_HTML.jpg

图 3-7

运行 Welcome 类时在启动时创建的模块图

为了构建这个模块图,JVM 需要定位根模块jdojo.intro。JVM 将只在可观察模块集中寻找一个模块。错误中的第二行表示 JVM 找不到根模块jdojo.intro。要修复这个错误,你需要将jdojo.intro模块包含在可观察模块集中。您知道模块的代码存在于两个位置:

  • javafun\mod\jdojo.intro directory

  • javafun\lib\com.jdojo.intro.jar file

有两种类型的模块:JDK 附带的内置模块和您创建的用户定义模块。JVM 知道所有的内置模块,并将它们包含在可观察模块集中。您需要使用--module-path选项指定用户定义模块的位置。在 modulepath 上找到的模块将包含在可观察模块集中,它们将在模块解析过程中被解析。使用此选项的语法如下:

--module-path <your-module-path>

Module-path是路径名的序列,其中路径名可以是目录、模块化 JAR 或 JMOD 文件的路径。路径可以是绝对的,也可以是相对的。我们将在本书的后面讨论 JMOD 文件。路径名由特定于平台的路径分隔符分隔,在类似 UNIX 的平台上是冒号(:),在 Windows 上是分号(;)。以下是 Windows 上的有效模块路径:

  • C:\javafun\lib

  • C:\javafun\lib;C:\javafun\mod\jdojo.contact\com.jdojo.contact.jar

  • C:\javafun\lib;C:\javafun\extlib

第一个 modulepath 包含一个名为C:\javafun\lib的目录的路径。第二个包含到一个C:\javafun\lib目录和一个位于C:\javafun\mod\jdojo.contact\com.jdojo.contact.jar的模块化 JAR 的路径。第三个包含两个目录的路径— C:\javafun\libC:\javafun\extlib。在类似 UNIX 的平台上,这些模块路径的等效路径看起来类似于:

  • /home/ksharan/javafun/lib

  • /home/ksharan/javafun/lib:/home/ksharan/javafun/mod/jdojo.contact/com.jdojo.contact.jar

  • /home/ksharan/javafun/lib:/home/ksharan/javafun/extlib

JVM 如何使用 modulepath 找到模块?JVM 使用以下规则扫描 modulepaths 上存在的所有模块:

  • 如果路径名是一个目录,那么将在三个地方扫描包含模块的module-info.class文件:目录本身、所有直接子目录和目录中所有模块化 jar 的根目录。如果在这些地方找到了一个module-info.class文件,那么这个模块就包含在可观察模块集中。请注意,子目录不会被递归扫描。

  • 如果路径名是模块化 JAR 或 JMOD 文件,则模块化 JAR 或 JMOD 文件被认为包含模块,该模块被包括在可观察模块的集合中。

使用第一个规则,如果您将N模块化 jar 放在一个C:\javafun\lib目录中,那么在 modulepath 上指定这个目录将包含可观察模块集中的所有N模块。如果您在一个目录中有多个模块,但是您想在一组可观察的模块中只包含其中的几个,那么您可以使用第二种形式,即模块化 JAR 或 JMOD 文件的路径。

您已经将本例中名为com.jdojo.intro.jar的模块化 JAR 放到了C:\javafun\lib目录中。因此,指定C:\javafun\lib作为 modulepath 将使 JVM 能够找到jdojo.intro模块。让我们使用下面的命令来运行Welcome类:

C:\javafun>java --module-path C:\javafun\lib --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

该命令假设C:\javafun是当前目录。您可以在 modulepath 上使用相对路径,即lib而不是C:\javafun\lib,如下所示:

C:\javafun>java --module-path lib --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

这一次,JVM 能够找到jdojo.intro模块。它在包含jdojo.intro模块的C:\javafun\lib目录中找到了一个模块化 JAR 文件com.jdojo.intro.jar

您将模块的编译类保存在C:\javafun\mod\jdojo.intro目录中。模块代码存在于该目录中展开的目录中。根目录包含了module-info.class文件。你也可以在一个模块中运行一个类,它的代码保存在一个展开的目录结构中,就像在C:\javafun\mod\jdojo.intro目录中的那个。下面的命令运行jdojo.intro模块中的Welcome类,该模块的代码在C:\javafun\mod\jdojo.intro目录中:

C:\javafun>java --module-path C:\javafun\mod\jdojo.intro --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

这一次,JVM 扫描了C:\javafun\mod\jdojo.intro目录,发现了一个包含jdojo.intro模块描述符的module-info.class文件。

您可以使用C:\javafun\mod目录作为 modulepath 的一部分来运行相同的命令,如下所示:

C:\javafun>java --module-path C:\javafun\mod --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

JVM 这次是怎么找到jdojo.intro模块的?回想一下在目录中查找模块描述符的规则。JVM 在C:\javafun\mod目录中寻找一个不存在的module-info.class文件。它在目录中寻找任何模块化 jar,但没有找到。现在它寻找C:\javafun\mod目录的直接子目录。它找到了一个名为jdojo.intro的子目录。它扫描了jdojo.intro子目录中的module-info.class文件,并找到了一个包含jdojo.intro模块的模块描述符的文件。这就是如何找到jdojo.intro模块的。

许多 GNU 风格的选项也有较短的名称。例如,您可以分别为--module-path–-module选项使用较短的名称-p-m。前面的命令也可以写成如下形式:

C:\javafun>java -p C:\javafun\mod -m jdojo.intro/com.jdojo.intro.Welcome

解析模块不会加载该模块中的所有类。一次加载所有模块中的所有类是低效的。当程序中第一次引用类时,类被加载。JVM 只定位模块,并做一些内务处理以获得关于模块的更多信息。例如,它跟踪模块包含的所有包。JVM 是如何加载Welcome类的?JVM 使用了类的三条信息:模块路径、模块名和类的完全限定名。在运行Welcome类时,您指定了两条信息:

  • 主模块名,即jdojo.intro。这使得 JVM 定位了这个模块,并且知道这个模块包含了com.jdojo.intro包。回想一下,一个包对应一个目录结构。在这种情况下,JVM 知道在模块内容、模块化 JAR 或包含模块描述符的目录中,存在一个包com/jdojo/intro,它保存了com.jdojo.intro包中的内容。

  • 除了主模块,您还指定了主类的完全限定名,即com.jdojo.intro.Welcome。为了定位Welcome类,JVM 首先找到包含com.jdojo.intro包的模块。它找到jdojo.intro模块来包含这个包。它将包名转换成一个目录层次结构,在类名后面附加一个.class扩展名,并尝试在com/jdojo/intro/Welcome.class定位该类。

根据这两条规则,我们来定位Welcome类文件。如果你指定了javafun\lib目录作为模块路径,com.jdojo.intro.jar文件包含了jdojo.intro模块的内容,这个文件也包含了com/jdojo/intro/Welcome.class文件。这就是Welcome类文件的定位和加载方式。如果您将javafun\mod目录指定为 modulepath,则javafun\mod\jdojo.intro目录包含jdojo.intro模块的内容,并且该目录还包含com/jdojo/intro/Welcome.class文件。图 3-8 描述了需要加载Welcome类时寻找Welcome.class文件的过程。图中使用了C:\javafun\mod\jdojo.intro作为模块和 Windows 路径分隔符的位置,这是一个反斜杠。在类 UNIX 操作系统上,路径分隔符将是一个正斜杠。JAR 文件也使用正斜杠作为路径分隔符。

img/323069_3_En_3_Fig8_HTML.png

图 3-8

使用模块路径在模块中查找类文件的过程

这个例子很简单。它只涉及两个模块— java.basejdojo.intro。如果您关注了讨论,您就会知道当您运行Welcome类时这些模块是如何被解析的。有几个命令行选项可以帮助您理解使用模块时幕后发生的事情。下一节将探讨这样的命令行选项。

使用模块选项

有一些命令行选项可以让您获得关于使用了哪些模块以及如何解析这些模块的更多信息。这些选项对于调试或减少已解析模块的数量非常有用。在这一节中,我将向您展示一些使用这些选项的例子。

列出可观察模块

使用带有java命令的--list-modules选项,您可以打印可观察模块的列表。该选项不接受任何参数。以下命令将打印可观察模块集中包含的所有平台模块的列表。该命令打印大约 100 个模块。显示了部分输出:

C:\javafun>java --list-modules
java.activation@17
java.base@17
java.desktop@17
java.se@17
java.se.ee@17
...

在输出中,模块名后跟一个字符串"@17"。如果模块描述符包含模块版本,则版本显示在@符号之后。如果你使用的是 JDK 17 的最终版本,版本号将会是"17"

要将您的模块包含在可观察模块集中,您需要指定放置模块的 modulepath。以下命令将把jdojo.intro模块包含在可观察模块集中。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --list-modules
java.activation@17
java.base@17
java.desktop@17
java.se@17
java.se.ee@17
...
jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar

注意输出中的最后一项:

  • 它没有打印jdojo.intro模块的模块版本。这是因为您在创建模块化 JARcom.jdojo.intro.jar时没有指定模块版本。我将在下一节向您展示如何指定模块的版本。

  • 它打印出发现了jdojo.intro模块的模块化 JAR 的路径。当您的模块没有被正确解析时,这对于调试非常有帮助。

限制可观察模块

您可以使用--limit-modules减少可观察模块的数量。它接受逗号分隔的模块名称列表:

--limit-modules <module-name>[,<module-name>...]

可观察的模块限于指定模块的列表以及它们递归依赖的模块,加上使用--module选项指定的主模块,加上使用--add-modules选项指定的任何模块。当您通过将 jar 放在类路径上以遗留模式运行 Java 程序时,此选项非常有用,在这种情况下,所有平台模块都包含在根模块集中。

让我们通过在运行Welcome类时使用它来看看这个选项的效果。Welcome类只使用了java.base模块。为了将可观察的模块限制在java.basejdojo.intro模块,您可以将java.base指定为--limit-modules选项的值,如下所示:

C:\javafun>java --module-path C:\javafun\lib --limit-modules java.base --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

注意,尽管您只为--limit-modulus选项指定了java.base模块,但是jdojo.intro模块也包含在可观察模块中,因为它是您正在运行的主模块。

您可以使用-verbose:module选项打印加载的模块。下面的命令运行带有--limit-module选项的Welcome类,并且只加载两个模块:

C:\javafun>java --module-path C:\javafun\lib --limit-modules java.base -verbose:module --module jdojo.intro/com.jdojo.intro.Welcome
[0.079s][info][module,load] java.base location: jrt:/java.base
[0.135s][info][module,load] jdojo.intro location: file:///C:/javafun/lib/com.jdojo.intro.jar
Welcome to Java 17!

以下命令运行不带--limit-module选项的Welcome类,并加载大约 40 个模块。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib -verbose:module --module jdojo.intro/com.jdojo.intro.Welcome
[0.082s][info][module,load] java.base location: jrt:/java.base
[0.142s][info][module,load] jdk.naming.rmi location: jrt:/jdk.naming.rmi
[0.144s][info][module,load] jdk.scripting location: jrt:/jdk.scripting
[0.144s][info][module,load] java.logging location: jrt:/java.logging
[0.144s][info][module,load] jdojo.intro location: file:///C:/javafun/lib/com.jdojo.intro.jar
[0.156s][info][module,load] java.management location: jrt:/java.management
...
Welcome to Java 17!

描述模块

您可以使用带有java命令的--describe-module选项来描述一个模块。回想一下,您也可以使用这个选项和jar命令(参见“打包编译后的代码”一节中的例子)来描述模块化 JAR 中的模块。请确保在描述模块时指定模块路径。要描述平台模块,您不需要指定 modulepath。以下命令显示了几个示例:

C:\javafun>java --module-path C:\javafun\lib --describe-module jdojo.intro
jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
requires java.base mandated
contains com.jdojo.intro
C:\javafun>java --describe-module java.sql
java.sql@17
exports java.sql
exports javax.sql
requires java.logging transitive
requires java.xml transitive
requires java.base mandated
requires java.transaction.xa transitive
uses java.sql.Driver

打印模块分辨率详细信息

使用带有java命令的--show-module-resolution选项,您可以打印启动时发生的模块解析过程的细节。当运行Welcome类时,下面的命令使用这个选项。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome

root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
java.base binds jdk.zipfs jrt:/jdk.zipfs
java.base binds jdk.jdeps jrt:/jdk.jdeps
java.base binds java.desktop jrt:/java.desktop
java.desktop requires java.xml jrt:/java.xml
java.desktop requires java.datatransfer jrt:/java.datatransfer
java.desktop requires java.prefs jrt:/java.prefs
...
Welcome to Java 17!

输出中的第一行显示了被解析的根模块以及根模块的位置。java.base模块不需要任何其他模块。然而,它使用许多服务提供商,如果他们存在的话。输出中的"java.base binds ..."文本表明java.base模块使用的服务提供者存在于可观察模块集中,并且它们被解析。服务提供者模块可能需要其他模块,这也将被解决。java.desktop模块的分辨率就是这样一种情况。java.desktop模块被解析是因为它提供了java.base模块使用的服务,该服务触发了java.xmljava.datatransferjava.prefs模块的解析,因为java.desktop模块需要这三个模块。

Tip

即使您的程序只使用了不需要任何其他模块的java.base模块,其他平台模块也会被解析,因为它们提供了由java.base模块使用的服务。将平台模块限制为java.base模块的最佳方式是使用--limit-modules选项,并将java.base作为其值。

模拟运行您的程序

您可以使用--dry-run选项来模拟运行一个类。它创建 JVM 并加载主类,但不执行主类的main()方法。此选项对于验证模块配置和调试非常有用。以下命令显示了它的用法。输出不包含欢迎消息,因为没有执行Welcome类的main()方法。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --dry-run --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
java.base binds jdk.zipfs jrt:/jdk.zipfs
java.base binds java.logging jrt:/java.logging
java.base binds jdk.localedata jrt:/jdk.localedata
...

增强模块描述符

你在一个module-info.java文件中声明一个模块。模块声明被编译成一个名为module-info.class的类文件。模块的设计者可以使用 XML 或 JSON 格式来声明模块。他们为什么选择类文件格式来存储模块声明?这有几个原因:

  • Java 社区已经熟知了类文件格式。

  • 类文件格式是可扩展的。也就是说,工具可以在编译后扩充module-info.class文件。

  • JDK 已经支持一个名为package-info.java的类似文件,它被编译成一个package-info。用于存储包信息的类文件。

jar工具包含几个选项来扩充模块描述符,其中两个是模块版本和主类名。不能在其声明中指定模块的版本。JDK 9 的设计者在其声明中避免处理模块的版本,声明管理模块的版本是诸如 Maven 或 Gradle 等构建工具的工作,而不是模块系统提供者的工作。鉴于模块描述符的可扩展特性,您可以将模块的版本作为类文件的属性存储在module-info.class文件中。作为开发人员,添加类文件属性并不容易。您可以使用jar工具的--module-version选项将模块版本添加到module-info.class文件中。您已经创建了一个com.jdojo.intro.jar文件,它包含了jdojo.intro模块的模块描述符。让我们重新运行描述现有com.jdojo.intro.jar文件中的jdojo.intro模块的命令,如下所示:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

输出中没有模块版本。以下命令通过将模块版本指定为 1.0 来重新创建com.jdojo.intro.jar文件:

C:\javafun>jar --create --module-version 1.0 --file lib/com.jdojo.intro.jar -C mod/jdojo.intro.

Tip

通常,您应该将模块版本附加到模块 JAR 名称后面。在前面的例子中,您应该将文件命名为com.jdojo.intro-1.0.jar,这样它的所有者就会知道这个模块化 JAR 中存储的是什么版本的模块。我选择了相同的名称(com.jdojo.intro.jar)来简化这个例子。

以下命令重新描述模块,输出显示模块名称及其版本。如果有版本,模块名以<module-name>@<module-version>的形式打印出来:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro@1.0 jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

在一个典型的应用程序中,您将有一个主模块,它是一个包含主类的模块。您可以将主类的名称存储在模块描述符中。当您创建或更新模块化 JAR 时,您所需要做的就是使用带有jar工具的--main-class选项。主类名是包含您想用作应用程序入口点的main()方法的类的完全限定名。以下命令更新现有的模块化 JAR 以添加主类名:

C:\javafun>jar --update --main-class com.jdojo.intro.Welcome --file lib\com.jdojo.intro.jar

以下命令使用模块版本和主类名重新创建模块化 JAR:

C:\javafun>jar --create --module-version 1.0 --main-class com.jdojo.intro.Welcome --file lib/com.jdojo.intro.jar -C mod/jdojo.intro .

模块描述符中的模块版本和主类怎么处理?模块版本旨在供 Maven 和 Gradle 等构建工具使用。当模块存在多个版本时,您需要在应用程序中包含模块的正确版本。如果您的模块描述符包含一个主类属性,您可以使用模块的名称来运行应用程序。JVM 将从模块描述符中读取主类名。现在,jdojo.intro模块的模块描述符包含了主类名。以下命令将运行Welcome类:

C:\javafun>java --module-path C:\javafun\lib --module jdojo.intro
Welcome to Java 17!

在传统模式下运行 Java 程序

模块系统是在 JDK 9。以前 Java 程序是如何编写、编译、打包、运行的?把模块系统拿出来,你会发现以前 Java 程序几乎都是这么写的。然而,运行它们的机制是不同的。你在这一章中编写的Welcome类也将在 JDK 8 中编译和运行。除了少数例外,Java 一直是向后兼容的。你在 JDK 8 中编写的程序也可以在 JDK 17 中运行。

在 JDK 9 之前,类总是使用类路径定位。类路径是一系列目录、JAR 文件和 ZIP 文件。类路径中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。如果比较类路径和模块路径的定义,它们看起来是一样的。它们之间的区别在于类路径用于定位类(更具体地说是类型),而模块路径用于定位模块。

Tip

您将会遇到两个术语,“加载类”和“加载模块”当加载一个类时,从模块路径或类路径中读取它的类文件,该类在运行时表示为一个对象。当一个模块被加载时,模块描述符(module-info.class文件)和其他一些内务处理一起被读取;该模块在运行时表示为一个对象。加载一个模块并不意味着加载该模块中的所有类,这将是非常低效的。模块中的类在运行时第一次被程序引用时被加载。

JDK 17 允许你只使用模块路径,只使用类路径,或者两者结合使用。仅使用 modulepath 意味着您的程序仅由模块组成。只使用类路径意味着你的程序不包含模块。使用两者的组合意味着你的程序的一部分由一些模块组成,而另一部分没有。JDK 9 模块化的 JDK 代码。例如,无论您是否从一个模块运行程序,总是使用java.base模块。Java 支持三种模式:

  • 模块模式

  • 传统模式

  • 混合模式

在你的程序中只使用模块被称为模块模式,并且只使用模块路径。仅使用类路径被称为遗留模式,并且仅使用类路径。使用两者的组合被称为混合模式。JDK 9+支持这些向后兼容的模式。例如,您应该能够在 JDK 17 中使用遗留模式“按原样”运行您的 JDK 8 程序,在该模式下,您将把所有现有的 jar 放在类路径中。如果您正在使用模块开发一个新的 Java 应用程序,但是仍然有一些来自 JDK 8 的 jar,那么您可以使用混合模式,将您的模块化 jar 放在 modulepath 上,将现有的 jar 放在类路径上。

可以使用三个同义选项来指定一个类:--class-path-classpath-cp。第一个选项是在 JDK 9 中添加的,另外两个选项以前就有了。在传统模式下运行 Java 程序的一般语法如下:

java [options] <main-class-name> [arguments]

这里,[options][arguments]与上一节“运行 Java 程序”中讨论的含义相同由于在遗留模式中没有用户定义的模块,您只需简单地指定您想要运行的主类的完全限定名为<main-class-name>。因为必须在模块模式下指定 modulepath,所以必须在遗留模式下指定 class-path。

以下命令在传统模式下运行Welcome类。您不需要重新编译Welcome类。您可以保留或删除module-info.class文件,因为它不会在传统模式下使用:

C:\javafun>java --class-path C:\javafun\mod\jdojo.intro com.jdojo.intro.Welcome
Welcome to Java 17!

JVM 使用以下步骤来运行Welcome类:

  • 它检测到您正试图运行com.jdojo.intro.Welcome类。

  • 它将主类名转换成文件路径com\jdojo\intro\Welcome.class

  • 它获取类路径中的第一个条目,并查找在上一步中计算的Welcome.class文件的路径是否存在。类路径中只有一个条目,它使用那个条目找到了Welcome.class文件。JVM 尝试使用类路径中的所有条目来查找类文件,直到找到该类文件。如果没有找到使用所有条目的类文件,它抛出一个ClassNotFoundException

类路径和模块路径的工作方式有一些不同。类路径中的条目“按原样”使用也就是说,如果在类路径上指定一个目录路径,该目录路径将被附加到类文件路径的前面,以便查找类文件。与之相比,modulepath 包含一个目录路径,在该路径中搜索目录本身、目录中的所有模块化 jar 以及所有直接子目录,以查找模块描述符。使用这个规则,如果您想从 JAR 文件中以遗留模式运行Welcome类,您需要在类路径上指定 JAR 的完整路径。

以下命令无法找到Welcome类,因为在C:\javafun\modC:\javafun\lib目录中没有找到com\jdojo\intro\Welcome.class文件:

C:\javafun>java --class-path C:\javafun\mod com.jdojo.intro.Welcome
Error: Could not find or load main class com.jdojo.intro.Welcome
Caused by: java.lang.ClassNotFoundException: com.jdojo.intro.Welcome
C:\javafun>java --class-path C:\javafun\lib com.jdojo.intro.Welcome
Error: Could not find or load main class com.jdojo.intro.Welcome
Caused by: java.lang.ClassNotFoundException: com.jdojo.intro.Welcome

以下命令找到了Welcome类,因为您在类路径中指定了 JAR 路径:

C:\javafun>java --class-path C:\javafun\lib\com.jdojo.intro.jar com.jdojo.intro.Welcome
Welcome to Java 17!

拥有多个 jar 是典型的非平凡 Java 应用程序。将所有 jar 的完整路径添加到类路径中非常不方便。为了支持这个用例,class-path 语法支持在条目中使用星号(*)作为最后一个字符,这扩展到该条目所代表的目录中的所有 JAR 和 ZIP 文件。假设您有一个名为cdir的目录,其中包含两个 jar—x.jary.jar。要在类路径中包含这两个 jar,您可以在 Windows 中使用以下路径序列之一:

  • cdir\x.jar;cdir\y.jar

  • cdir\*

第二种情况下的星号将被扩展为cdir目录中每个 JAR/ZIP 文件一个条目。这种扩展发生在 JVM 启动之前。以下命令显示了如何在类路径中使用星号:

C:\javafun>java -cp C:\javafun\lib\* com.jdojo.intro.Welcome
Welcome to Java 17!

您必须在类路径条目的末尾使用星号或单独使用星号。如果单独使用星号,星号将被扩展为包括当前目录中的所有 JAR/ZIP 文件。以下命令使用C:\javafun\lib目录作为当前目录,并使用星号作为运行Welcome类的类路径:

C:\javafun\lib>java -cp * com.jdojo.intro.Welcome
Welcome to Java 17!

在混合模式下,可以像这样同时使用 modulepath 和 class-path:

java --module-path <module-path> --class-path <class-path> <other-arguments>

可能会有这样的情况,您可能有重复的类——一个副本在模块路径上,另一个副本在类路径上。在这种情况下,使用 modulepath 上的版本,实际上忽略了类路径副本。如果类路径中存在重复的类,则使用在类路径中最先找到的类。模块之间不允许有重复的包和重复的类。也就是说,如果您有一个名为com.jdojo.intro的包,那么这个包中的所有类都必须通过一个模块可用。否则,您的应用程序将无法编译/运行。

如果 Java 只处理模块,那么从类路径加载的非模块类型是如何使用的?类型是由类装入器装入的。每个类装入器都有一个名为的未命名的模块。从类路径加载的所有类型都成为其类加载器的未命名模块的成员。从 modulepath 加载的所有模块都是声明它们的模块的成员。我们将在后面的章节中重新讨论未命名的模块。

模块路径上的重复模块

有时,在 modulepath 上可能有相同模块的多个版本。模块系统如何从模块路径中选择使用哪个模块副本?在 modulepath 中有两个同名的模块总是错误的。模块系统以有限的方式防止你犯这样的错误。

让我们从一个例子开始,来理解解决重复模块的规则。您有两个版本的jdojo.intro模块——一个在C:\javafun\lib目录下的com.jdojo.intro.jar文件中,另一个在C:\javafun\mod\jdojo.intro目录下。运行Welcome类并在 modulepath 中包含这两个目录:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\mod\jdojo.intro --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

您可能已经预料到这个命令会失败,因为运行一个程序时,运行时系统可以访问同一个模块的两个版本是没有意义的。此命令使用了模块的哪个副本?很难通过查看输出来判断,因为模块的两个副本包含相同的代码。您可以使用--show-module-resolution选项查看模块加载的位置。下面的命令可以做到这一点。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\mod\jdojo.intro --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
...
Welcome to Java 17!

输出表明jdojo.intro模块,在本例中是根模块,是从C:\javafun\lib目录中的模块化 JAR com.jdojo.intro.jar中加载的。让我们交换 modulepath 中条目的顺序,然后重新运行命令:

C:\javafun>java --module-path C:\javafun\mod\jdojo.intro;C:\javafun\lib --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/mod/jdojo.intro/
...
Welcome to Java 17!

这一次,输出表明从C:\javafun\mod\jdojo.intro目录加载了jdojo.intro模块。规则如下:

如果通过 modulepath 中的不同条目可以访问一个模块的多个同名副本,则使用 modulepath 中最先找到的模块副本。

使用这个规则,当在 modulepath 中首先列出了lib目录时,从lib目录中使用jdojo.intro模块,并忽略mod\jdojo.intro目录中的模块副本。当您颠倒了 modulepath 中这些条目的顺序时,就使用了mod\jdojo.intro目录中的模块。

注意规则中 modulepath" 短语中的不同条目可以访问的*。只要一个模块的多个副本存在于不同的 modulepath 条目中,这个规则就适用。但是,如果通过 modulepath 中的同一条目可以访问一个模块的多个副本,则会发生错误。你怎么会陷入这种境地?以下是一些可能性:*

  • 多个具有不同文件名的模块化 jar,但是具有相同名称的模块代码,可能存在于同一个目录中。如果这样的目录是 modulepath 中的一个条目,则可以通过这个单一的 modulepath 条目来访问一个模块的多个副本。

  • 当一个目录被用作 modulepath 条目时,该目录中的所有模块化 jar 和所有包含模块描述符的直接子目录都通过该 modulepath 条目来定位模块。这为通过单个 modulepath 条目访问多个同名模块提供了可能性。

在我们的例子中,jdojo.intro模块的两个副本不能通过单个 modulepath 条目访问。让我们使用以下步骤来模拟错误:

  • 创建一个名为C:\javafun\temp的目录。

  • C:\lib\com.jdojo.intro.jar文件复制到C:\javafun\temp目录。

  • C:\mod\jdojo.intro目录复制到C:\javafun\temp目录。

此时,您拥有以下文件:

  • C:\javafun\temp\com.jdojo.intro.jar

  • C:\javafun\temp\jdojo.intro\module-info.class

  • C:\javafun\temp\jdojo.intro\com\jdojo\intro\Welcome.class

如果在 modulepath 中包含了C:\javafun\temp目录,那么就可以访问jdojo.intro模块的两个副本——一个在模块 JAR 中,一个在子目录中。以下命令失败,并显示一条指明问题的明确消息:

C:\javafun>java --module-path C:\lib;C:\javafun\temp --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Error reading module: C:\lib\com.jdojo.intro-1.0.jar
Caused by: java.lang.module.InvalidModuleDescriptorException: this_class should be module-info

下面的命令将C:\javafun\lib目录作为 modulepath 中的第一个条目,在这里只能找到模块的一个副本。它将C:\javafun\temp目录作为 modulepath 中的第二个条目。您仍然会得到相同的错误:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\temp --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Two versions of module jdojo.intro found in C:\javafun\temp (jdojo.intro and com.jdojo.intro.jar)

命令行选项的语法

JDK 17 支持两种指定命令行选项的方式:

  • UNIX 风格

  • GNU 风格

UNIX 样式的选项以连字符(-)开头,后跟作为一个单词的选项名,例如-p-m-cp。GNU 风格的选项以两个连字符(--)开头,后跟选项名,其中选项名中的每个单词都用连字符连接,例如--module-path--module--class-path

JDK 的设计者已经没有对开发者有意义的选项的简称了。因此,JDK(版本 9)开始使用 GNU 风格的选项。大多数选项在两种风格中都可用。如果可能的话,我们鼓励您使用 GNU 风格的选项,因为它们更容易记忆,对读者来说更直观。

Tip

要打印 JDK 工具支持的所有标准选项的列表,使用--help-h选项运行工具,对于所有非标准选项,使用--help-extra-X选项运行工具。例如,java --helpjava --help-extra命令分别打印java命令的标准和非标准选项列表。

选项可以将一个值作为其参数。选项的值跟在选项名称后面。选项名称和值必须由一个或多个空格分隔。以下示例显示了如何使用两个选项通过java命令指定 modulepath:

// Using the UNIX-style option
C:\javafun>java -p C:\applib;C:\extlib <other-args-go-here>
// Using the GNU-style option
C:\javafun>java --module-path C:\applib;C:\lib <other-args-go-here>

当您使用 GNU 风格的选项时,您可以用以下两种形式之一指定该选项的值:

  • --<name> <value>

  • --<name>=<value>

前面的命令也可以写成如下形式:

// Using the GNU-style option
C:\>java -–module-path=C:\applib;C:\lib <other-args-go-here>

当使用空格作为name-value分隔符时,至少需要使用一个空格。当使用=作为name-value分隔符时,不得在其周围包含任何空格。这个选项

 --module-path=C:\applib

是有效的,而此选项

 --module-path =C:\applib

无效,因为" =C:\applib"将被解释为无效路径 modulepath。

使用 NetBeans IDE 编写 Java 程序

您可以使用 NetBeans IDE 来编写、编译和运行 Java 程序。在本节中,我们将引导您完成使用 NetBeans 的步骤。首先,您将学习如何创建一个新的 Java 项目,编写一个简单的 Java 程序,编译并运行它。最后,您将了解如何打开本书的 NetBeans 项目并使用本书提供的源代码。关于如何下载、安装和配置 NetBeans IDE,请参考第二章。

Note

在撰写本文时,NetBeans IDE 12.5 尚未发布。它将与 JDK 17 一起发布。当您阅读本章时,最终发布版本 12.5 应该已经发布。在本节中,我们使用 NetBeans 12.5 测试版的夜间版本。

创建 Java 项目

当您启动 NetBeans IDE 时,会显示启动页面,如图 3-9 所示。启动页面包含对开发人员有用的链接,如 Java、JavaFX、C++等教程的链接。如果不希望每次启动 IDE 时都显示启动页面,则需要取消选中启动页面右上角的“启动时显示”复选框。您可以通过点击起始页选项卡中显示的X图标来关闭起始页。使用帮助➤起始页可以随时打开起始页。

img/323069_3_En_3_Fig9_HTML.png

图 3-9

带有启动页面的 NetBeans IDE

要创建新的 Java 项目,请遵循以下步骤:

img/323069_3_En_3_Fig10_HTML.png

图 3-10

新项目对话框

  1. 选择文件➤新建项目或按 Ctrl+Shift+N,弹出新建项目对话框,如图 3-10 所示。

img/323069_3_En_3_Fig11_HTML.png

图 3-11

“新建 Java 模块化应用程序”对话框

  1. 在“新建项目”对话框的“类别”列表中,选择“Java with Ant”。在“项目”列表中,可以选择“Java 应用程序”、“Java 类库”或“Java 模块化项目”。当您选择一个类别时,其描述会显示在底部。在前两个类别中,您只能拥有一个 Java 模块,而第三个类别允许您拥有多个 Java 模块。选择 Java 模块化项目选项,然后单击下一个➤按钮。显示如图 3-11 所示的新建 Java 模块化应用程序对话框。

img/323069_3_En_3_Fig12_HTML.png

图 3-12

带有 Java17Fundamentals Java 项目的 NetBeans IDE

  1. 在新建 Java 模块化应用程序对话框中,输入Java17Fundamentals作为项目名称。在“项目位置”字段中,输入或浏览到要保存项目文件的位置。我输入C:\作为项目地点。NetBeans 将创建一个C:\Java17Fundamentals目录,用于存储Java17Fundamentals项目的所有文件。从平台下拉列表中选择 JDK 17 作为 Java 平台。如果没有 JDK 17 版可供选择,请单击管理平台...按钮并创建一个新的 Java 平台。创建一个新的 Java 平台只是在文件系统中添加一个存储 JDK 的位置,并给这个位置命名。完成后,单击“完成”按钮。新的Java17Fundamentals项目显示在 IDE 中,如图 3-12 所示。

在左上角,您可以看到三个选项卡:项目、文件和服务。“项目”选项卡显示所有与项目相关的文件。“文件”选项卡允许您查看计算机上的所有系统文件。“服务”选项卡允许您使用数据库和 web 服务器等服务。如果关闭这些选项卡,可以使用“窗口”菜单下与这些选项卡同名的子菜单重新打开它们。

至此,您已经创建了一个不包含任何模块的模块化 Java 应用程序项目。您需要向项目中添加模块。要新建模块,在项目页签中选择项目名称Java17Fundamentals,右键选择新建➤模块,如图 3-13 所示。显示新模块对话框,如图 3-14 所示。输入jdojo.intro作为模块名,并点击 Finish 按钮。

img/323069_3_En_3_Fig14_HTML.png

图 3-14

“新建模块”对话框

img/323069_3_En_3_Fig13_HTML.png

图 3-13

选择模块菜单项以创建菜单模块

图 3-15 显示了打开module-info.java文件的编辑器。我已经删除了 NetBeans IDE 添加的注释,并在顶部添加了一个注释。您可能需要在“项目”选项卡中展开文件树才能看到所有文件。创建一个jdojo.intro模块创建了一个module-info.java文件,其中包含了对jdojo.intro模块的模块声明。当在编辑器中打开一个module-info.java文件时,NetBeans IDE 会显示三个选项卡——源代码、历史记录和图表。选择图形选项卡显示模块图形,如图 3-16 所示。右键单击模块图中的空白区域,查看用于定制图形的选项。使用布局选项,您可以用不同的方式排列图表中的节点。我更喜欢通过分层排列节点来查看图表。使用“导出为图像”右键选项将图像导出为 PNG 图像。选择一个节点会突出显示进出所选节点的所有边,这使您可以轻松地在图形中可视化模块的角色。选择module-info.java选项卡下的源代码选项卡,查看模块的源代码。

img/323069_3_En_3_Fig16_HTML.png

图 3-16

由 NetBeans IDE 创建的模块图

img/323069_3_En_3_Fig15_HTML.png

图 3-15

jdojo.intro 模块及其 module-info.java 文件已在编辑器中打开

现在您已经准备好将Welcome类添加到jdojo.intro模块中。在项目选项卡中选择jdojo.intro模块节点,然后右键单击。然后选择新➤ Java 类...,显示如图 3-17 所示的新建 Java 类对话框。输入Welcome作为类名,输入com.jdojo.intro作为包名。然后单击“完成”按钮。

img/323069_3_En_3_Fig17_HTML.png

图 3-17

在“新建 Java 类”对话框中输入类的详细信息

图 3-18 显示了为Welcome类创建的源代码。我已经清理了创建新类时 NetBeans 添加的注释。您需要向Welcome类添加一个main()方法,如清单 3-3 所示。图 3-19 显示了使用main()方法的Welcome类。您可以通过按 Ctrl+Shift+S 保存所有更改,也可以使用 Ctrl+S 保存活动文件中的更改。或者,您可以使用文件➤全部保存和文件➤保存菜单或工具栏按钮。

img/323069_3_En_3_Fig19_HTML.png

图 3-19

带有 main()方法的 Welcome 类代码

img/323069_3_En_3_Fig18_HTML.png

图 3-18

NetBeans 创建的欢迎类

使用 NetBeans 时,不需要编译代码。默认情况下,NetBeans 会在您保存代码时对其进行编译。现在您已经准备好运行Welcome类了。NetBeans 允许您运行一个项目或单个 Java 类。如果 Java 文件包含主类,您可以运行它。要运行Welcome类,您需要在 NetBeans 中运行Welcome.java文件。您可以通过以下方式之一运行Welcome类:

  • 在编辑器中打开Welcome.java文件,按下 Shift+F6。或者,您可以在编辑器中右键单击Welcome.java文件并选择 Run File。

  • 在“项目”标签中选择Welcome.java文件,然后按 Shift+F6。或者,在项目选项卡中选择Welcome.java文件,然后选择运行文件。

  • 在“项目”选项卡中选择Welcome.java文件,然后选择“运行➤”“运行文件”。

当您运行 Welcome 类时,输出会出现在 output 选项卡中,如图 3-20 所示。

img/323069_3_En_3_Fig20_HTML.png

图 3-20

欢迎类运行时的输出

在 NetBeans 中创建模块化 jar

您可以从 NetBeans IDE 内部构建模块化 JAR。按 F11 构建项目,这将为您添加到 NetBeans 项目中的每个模块创建一个模块化 JAR。您可以按 Shift+F11 进行清理和构建,这将删除所有现有的已编译类文件和模块化 jar,并在创建新的模块化 jar 之前重新编译所有类。或者,您可以选择运行➤构建项目()菜单项来构建您的项目。

当您构建一个项目时,在哪里创建模块化 jar?NetBeans 在项目目录下创建一个dist目录。回想一下,您已经在C:\Java17Fundamentals中保存了 NetBeans 项目,所以当您在 IDE 中构建项目时,NetBeans 将创建一个C:\Java17Fundamentals\dist目录。假设您的项目中有两个模块— jdojo.introjdojo.test。构建项目将创建以下两个模块化 jar:

  • C:\Java17Fundamentals\dist\jdojo.intro.jar

  • C:\Java17Fundamentals\dist\jdojo.test.jar

NetBeans 项目目录结构

NetBeans 使用默认的目录结构来存储源代码、编译代码和打包代码。以下目录是在 NetBeans 项目目录下创建的:

  • src\<module-name>\classes

  • build\modules\<module-name>

  • dist

这里,<module-name>是你的模块名比如jdojo.introsrc\<module-name>\classes目录存储了特定模块的源代码。模块的module-info.java文件存储在classes子目录中。classes子目录可能有几个子目录,这些子目录反映了模块中存储的类型包所需的目录结构。

build\modules\<module-name>目录存储模块的编译代码。例如,jdojo.intro模块的module-info.class文件将存储在build\modules\jdojo.intro\module-info.classbuild\modules\<module-name>目录镜像了存储在模块中的类型包。例如,我们示例中的Welcome.class文件将存储在build\modules\jdojo.intro\com\jdojo\intro\Welcome.class。当您清理一个项目(右键单击并选择 clean)或清理并构建一个项目时,整个build目录将被删除并重新创建。

dist目录为项目中的每个模块存储了一个模块化 JAR。项目上的CleanClean+Build动作删除所有模块化 jar 并重新创建它们。

本书将在后续章节中引用 NetBeans 目录结构,向您展示在命令行中使用相同模块的示例。您可以使用 NetBeans 编写模块的代码,并为该模块构建一个模块化的 JAR。您可以将 NetBeans 项目的dist目录添加到 modulepath 中,以便在命令行上使用模块化 jar。

向模块添加类

通常,一个模块中有几个类。要向模块中添加新类,请在“项目”选项卡中右键单击该模块,然后选择“新建➤ Java 类”....在“新建 Java 类”对话框中填写类名和包名。

自定义 NetBeans 项目属性

NetBeans 允许您使用“项目属性”对话框为 Java 项目自定义几个属性。要打开“项目属性”对话框,请在“项目”选项卡中右键单击项目名称,然后选择“属性”。Java17Fundamentals项目的项目属性对话框如图 3-21 所示。

img/323069_3_En_3_Fig21_HTML.png

图 3-21

Java17Fundamentals 项目的项目属性对话框

对话框的左侧是属性类别列表。当您选择一个属性类别时,详细信息会显示在右侧。以下是每个属性类别的简要描述:

  • 来源:用于设置与源代码相关的属性,如源文件夹、格式、JDK、编码等。当您从“源/二进制格式”下拉列表中选择 JDK 时,NetBeans IDE 将限制您使用该 JDK 版本之外的 API。“包括/排除”按钮允许您在项目中包括和排除字段。当您想在项目中保留一些文件,但不想编译它们时,使用此按钮,例如,文件可能因为不完整而无法编译。

  • :在几个属性中,它允许您设置三个重要的属性:Java 平台、模块路径和类路径。单击管理平台...按钮打开“Java 平台管理器”对话框,您可以在其中选择现有平台或添加新平台。使用ModulepathClasspath右侧的+号,使用添加项目、添加库和添加 JAR/folder 按钮将项目、预定义的 JAR 文件集和 JAR/Folder 添加到模块路径和类路径中。这里设置的 modulepath 和 classpath 用于编译和运行 Java 项目。请注意,添加到项目中的所有模块都会自动添加到 modulepath 中。如果在当前 NetBeans 项目之外有模块化 jar,可以使用此对话框将它们添加到 modulepath 中。

  • Build :它可以让你设置几个子类别的属性。在“编译”子类别下,可以设置与编译器相关的选项。您可以选择在保存源代码时编译它,也可以选择使用 IDE 中的菜单选项自己编译源代码。在打包子类别下,您可以设置打包模块的选项。“文档”子类别允许您为项目设置生成 Java 文档的选项。

  • Run :这个类别允许您设置用于运行项目的属性。您可以设置 Java 平台和 JVM 参数。使用类别,您可以为项目设置一个主类。通常,当您学习时,您会像在前面章节中一样运行一个 Java 文件,而不是一个模块化的 Java 项目。

打开现有的 NetBeans 项目

假设您已经下载了这本书的源代码。源代码包含一个 NetBeans 项目。要打开项目,请按照下列步骤操作:

img/323069_3_En_3_Fig22_HTML.png

图 3-22

打开 NetBeans Java 项目获取本书的源代码

  1. 按 Ctrl+Shift+O 或选择文件➤打开项目。将显示“打开项目”对话框。

  2. 导航到包含解压缩的下载源代码的文件夹。显示项目Java17Fundamentals,如图 3-22 所示。

  3. 选择项目,然后单击“打开项目”按钮。NetBeans 在 IDE 中打开该项目。使用左侧的“项目”或“文件”选项卡浏览本书所有章节的源代码。参考前面关于如何在源代码中编译、构建和运行类的章节。

在幕后

本节回答了一些与编译和运行 Java 程序相关的一般问题。比如我们为什么要把 Java 源代码编译成字节码格式再运行?什么是 Java 平台?什么是 JVM,它是如何工作的?对这些主题的详细讨论超出了本书的范围。请参考 JVM 规范,了解有关 JVM 功能的任何主题的详细讨论。JVM 规范可以在 http://docs.oracle.com/javase/specs 在线获得。

让我们看一个简单的日常生活例子。假设有一个法国人只会说法语,他必须与另外三个人交流——一个美国人、一个德国人和一个俄罗斯人——而这三个人只懂一种语言(分别是英语、德语和俄语)。法国人将如何与其他三人沟通?有许多方法可以解决这个问题:

  • 这个法国人可能会学习所有三种语言。

  • 法国人可能会雇一个懂四种语言的翻译。

  • 法国人可以雇佣三名懂法英、法德、法俄的翻译。

这个问题还有许多其他可能的解决方案。让我们在运行 Java 程序的上下文中考虑类似的问题。Java 源代码被编译成字节码。相同的字节码需要在所有操作系统上运行,无需任何修改。Java 语言的设计者选择了第三种选择,为每个操作系统配备一个翻译器。翻译器的工作是将字节码翻译成机器码,机器码是运行翻译后的代码的操作系统所固有的。这个翻译器叫做 Java 虚拟机(JVM)。每个操作系统都需要一个 JVM。图 3-23 是 JVM 如何在字节码(类文件)和不同操作系统之间充当翻译器的示意图。

img/323069_3_En_3_Fig23_HTML.png

图 3-23

JVM 作为字节码和操作系统之间的翻译器

编译成字节码格式的 Java 程序有两个优点:

  • 如果想在另一台装有不同操作系统的机器上运行源代码,不需要重新编译。在 Java 中也叫平台独立性。对于 Java 代码,它也被称为“一次编写,随处运行”。

  • 如果您在网络上运行 Java 程序,由于字节码格式的紧凑大小,程序运行得更快,从而减少了网络上的加载时间。

为了在网络上运行 Java 程序,Java 代码的大小必须足够紧凑,以便在网络上更快地传输。由 Java 编译器以字节码格式生成的类文件非常紧凑。这是以字节码格式编译 Java 源代码的优点之一。

使用字节码格式的第二个重要优点是它是架构中立的。字节码格式是与体系结构无关的,这意味着如果你在一个特定的主机系统上编译 Java 源代码,比如说,在 Windows 上,生成的类文件没有提到或影响它是在 Windows 上生成的。如果在两个不同的主机系统(例如 Windows 和 UNIX)上编译相同的 Java 源代码,两个类文件将是相同的。

字节码格式的类文件不能在主机系统上直接执行,因为它没有任何特定于主机系统的直接指令。换句话说,我们可以说字节码不是任何特定主机系统的机器语言。现在的问题是,谁理解字节码,谁把它翻译成底层的特定于主机系统的机器码?JVM 执行这项工作。字节码是 JVM 的机器语言。如果您在 Windows 上编译 Java 源代码来生成一个类文件,那么如果您在运行 UNIX 的机器上有 Java 平台(JVM 和 Java API 统称为 Java 平台),那么您也可以在 UNIX 上运行相同的类文件。您不需要重新编译源代码来为 UNIX 生成新的类文件,因为运行在 UNIX 上的 JVM 可以理解您在 Windows 上生成的字节码。这就是 Java 程序如何实现“编写一次,在任何地方运行”的概念。

Java 平台,也称为 Java 运行时系统,由两部分组成:

  • Java 虚拟机(JVM)

  • Java 应用程序编程接口(Java API)

术语“JVM”在三种上下文中使用:

  • JVM 规范:它是一个抽象机器的规范或标准,Java 编译器可以为其生成字节码。

  • JVM 规范的具体实现:如果你想运行你的 Java 程序,你需要有一个真正的 JVM,它是使用 JVM 的抽象规范开发的。为了运行上一节中的 Java 程序,您使用了java命令,这是抽象 JVM 规范的具体实现。命令(或者 JVM)已经完全用软件实现了。然而,JVM 可以用软件或硬件或两者的组合来实现。

  • 一个正在运行的 JVM 实例:当您调用java命令时,您有一个正在运行的 JVM 实例。

这本书对这三种情况都使用了术语 JVM。它的实际含义应该根据其使用的上下文来理解。

JVM 执行的工作之一是执行字节码并为主机系统生成特定于机器的指令集。JVM 有类装入器和执行引擎。类加载器在需要时读取类文件的内容,并将其加载到内存中。执行引擎的工作是执行字节码。

JVM 也被称为 Java 解释器。“Java 解释器”这个术语经常会引起误解,尤其是对于那些刚刚开始学习 Java 语言的人。对于术语“Java 解释器”,他们的结论是 JVM 的执行引擎一次解释一个字节码,所以 Java 一定非常慢。JVM 的名称“Java 解释器”与执行引擎用来执行字节码的技术没有任何关系。执行引擎执行字节码可能选择的实际技术取决于 JVM 的具体实现。一些执行引擎类型有解释器、实时编译器和自适应优化器。在最简单的解释器中,执行引擎一次解释一个字节码,因此速度较慢。在第二种类型中,即实时编译器,它在方法第一次被调用时,用底层主机语言编译该方法的全部代码。然后在下一次调用相同的方法时重用编译后的代码。与第一种相比,这种执行引擎速度更快,但需要更多内存来缓存编译后的代码。在自适应优化器技术中,它不编译和缓存整个字节码;相反,它只对字节码中使用最频繁的部分这样做。

什么是 API(应用编程接口)?API 是一组特定的方法,由操作系统或应用程序提供给程序员直接使用。在前面的小节中,您在com.jdojo.intro包中创建了Welcome类,它声明了一个方法main,该方法接受一个数组String作为参数,并且不返回任何内容(由关键字void指示)。如果您公开所有这些关于所创建的包、类和方法的信息,并使它们可供其他程序员使用,那么您在Welcome类中的方法main就是一个典型的 API 示例,尽管这并不重要。通常,当我们使用术语“API”时,我们指的是可供程序员使用的一组方法。现在很容易理解 Java API 的意思了。Java API 是程序员在编写 Java 源代码时可以使用的所有类和其他组件的集合。在您的Welcome类示例中,您已经使用了一个 Java API。您在main方法体中使用它来在控制台上打印消息。使用 Java API 的代码是

System.out.println("Welcome to Java 17!");

您没有在代码中声明任何名为println的方法。这个方法通过 Java API 在运行时对 JVM 可用,Java API 是 Java 平台的一部分。概括地说,Java API 可以分为两类:核心 API 和扩展 API。每个 JDK 都必须支持核心 API。核心 Java APIs 的例子是 Java 运行时(例如,小应用程序、AWT、I/O 等。),JFC,JDBC 等。Java 扩展 API 有 JavaMail、JNDI (Java 命名和目录接口)等。Java 包含 JavaFX API 作为扩展 API。编译和运行 Java 程序的过程如图 3-24 所示。

img/323069_3_En_3_Fig24_HTML.png

图 3-24

编译和运行 Java 程序所涉及的组件

摘要

Java 程序是使用文本编辑器或 IDE 以纯文本格式编写的。Java 源代码也称为编译单元,它存储在扩展名为.java的文件中。市场上有一些免费的 Java 集成开发环境(ide ),比如 NetBeans。使用 IDE 开发 Java 应用程序减少了开发 Java 应用程序所需的时间和精力。

JDK 9 向 Java 平台引入了模块系统。模块包含包,包又由类型组成。类型可以是类、接口、枚举或注释。一个模块在一个名为module-info.java的源文件中声明,它被编译成一个名为module-info.class的类文件。一个编译单元包含一个或多个类型的源代码。编译编译单元时,会为编译单元中声明的每个类型生成一个类文件。

使用 Java 编译器将 Java 源代码编译成类文件。类文件包含字节码。JDK 附带的 Java 编译器叫做javac。使用名为jar的工具将编译后的代码打包成 JAR 文件。当一个 JAR 文件在其根目录下包含一个module-info.class文件,它是一个模块描述符,这个 JAR 文件被称为模块化 JAR。编译后的代码由 JVM 运行。JDK 安装一个可以作为java命令运行的 JVM。javacjava命令都位于JDK_HOME\bin目录下,其中JDK_HOME是 JDK 的安装目录。

一个模块可以包含内部和外部使用的包。如果一个模块导出一个包,则该包中包含的公共类型可能会被其他模块使用。如果一个模块想要使用另一个模块导出的包,第一个模块必须声明对第二个模块的依赖。JDK 9+由几个模块组成,称为平台模块。java.base模块是一个原始模块,所有其他模块都隐式依赖于它。

modulepath 是路径名的序列,其中路径名可以是目录、模块化 JAR 或 JMOD 文件的路径。modulepath 中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。用户定义的模块由模块系统使用 modulepath 定位。modulepath 是使用--module-path(或者更简短的版本-p)命令行选项设置的。

可以使用类路径来定位类。类路径是一系列目录、JAR 文件和 ZIP 文件。类路径中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。您可以使用--class-path(或者-cp或者-classpath)命令行选项来指定类路径。

classpath 和 modulepath 的值可能看起来相同,但它们用于不同的目的。classpath 用于定位类(更具体地说是类型),而 modulepath 用于定位模块。您可以通过jarjava命令使用--describe-module(或更短版本的-d)选项打印模块的描述。如果你有一个模块化的 JAR,使用jar命令。如果在模块路径上的模块 JAR 或展开目录中有一个模块,使用java命令。

模块系统在任何阶段(编译时或运行时)可访问的所有模块都称为可观察模块。您可以使用--list-modules命令行选项打印可观察模块的列表。模块系统通过隐晦地解析一组被称为根模块的模块相对于该组可观察模块的依赖性来创建模块图。在编译时,所有被编译的模块组成了根模块集。运行主类的主模块在运行时构成了根模块集。如果主类在类路径上,那么所有系统模块都是根模块。您可以使用--add-modules命令行选项将模块添加到根模块集中。您可以使用--limit-modules命令行选项来限制可观察模块的数量。

JDK 9+只适用于你的代码是否在模块内的模块。每个类装入器都有一个未命名的模块。如果类装入器从 modulepath 装入一个类型,该类型就是一个命名模块的成员。如果类装入器从类路径中装入一个类型,该类型将成为该类装入器的未命名模块的成员。

Java 代码被编译成字节码,由 JVM (Java 虚拟机)运行。这允许相同的代码在许多不同的操作系统上运行。

EXERCISES

  1. 包含 Java 程序源代码的文件的扩展名是什么?

  2. 什么是编译单元?

  3. 在一个编译单元中可以声明多少个类型?

  4. 在一个编译单元中可以声明多少个公共类型?

  5. 如果编译单元包含公共类型,那么对它的命名有什么限制?如果编译单元包含一个名为HelloWorld的公共类的声明,它的名字会是什么?

  6. 在一个编译单元中,以下结构是按什么顺序指定的:类型声明、包和导入语句?

  7. 一个编译单元中可以有多少个 package 语句?

  8. 包含 Java 编译代码的文件的扩展名是什么?

  9. 包含 Java 模块的源代码和编译代码的文件名是什么?

  10. 你用什么关键字来声明一个模块?

  11. 在一个module-info.java文件中可以声明多少个模块?

  12. 什么是未命名模块?一个类装入器可以有多少个未命名的模块?一个类型(例如,一个类)什么时候成为一个未命名模块的成员?

  13. 什么是罐子?JAR 文件和 ZIP 文件有什么区别?

  14. 什么是模块化 JAR,它与 JAR 有什么不同?你能把一个模块化的 JAR 作为一个 JAR 使用吗,反之亦然?

*提示*:模块化 JAR 也是一个 JAR,也可以这样使用。放置在模块路径上的罐子充当模块罐子;在这种情况下,模块定义由模块系统自动导出。这种模块被称为*自动*模块。
  1. 您使用什么命令来启动 JShell 工具,该命令位于哪里?

  2. 你用什么命令编译 Java 源代码?

  3. 你用什么命令把 Java 编译的代码打包成一个 JAR 或者一个模块化的 JAR?

  4. 模块描述符(module-info.class文件)放在模块化 JAR 的什么地方?

  5. 您在C:\lib\com.jdojo.test.jar保存了一个模块化 JAR。它包含一个名为jdojo.test的模块和一个名为com.jdojo.test.Test的主类。编写在模块模式和传统模式下运行该类的命令。

  6. 您在C:\lib\com.jdojo.test.jar保存了一个模块化 JAR。使用jar命令编写命令来描述这个模块化 JAR 中打包的模块。

  7. 什么是模块描述符?在声明模块时,可以指定模块的版本吗?如何指定模块版本?

  8. 什么是可观测模块?什么是根模块,在构建模块图时如何使用它们?

  9. 写出用于将模块添加到根模块集中的命令行选项的名称。

  10. 您使用什么命令行选项来打印可观察模块的列表?

  11. 您使用什么命令行选项来限制可观察模块的集合?

  12. 用于指定 modulepath 的 GNU 风格的选项名是--module-path。它的等价 UNIX 风格选项是什么?

  13. 有哪些选项可以打印命令的帮助?如何为命令的非标准选项打印额外的帮助?

四、数据类型

在本章中,您将学习:

  • 什么是标识符以及声明它们的详细规则

  • 什么是数据类型

  • 原始数据类型和引用数据类型之间的区别

  • 使用“var”的局部变量类型推理

  • 如何声明数据类型的变量

  • 如何给变量赋值

  • Java 中所有原始数据类型的详细描述

  • 什么是数据类型的文字

  • 什么是铸造,什么时候需要铸造

  • 整数和浮点数的二进制表示

  • 浮点数的不同舍入模式

  • Java 如何实现 IEEE 浮点标准

我们在这一章中使用了很多代码片段。评估这些代码片段并查看结果的最快方法是使用 JShell 工具。关于如何在命令提示符下启动 JShell 工具,请参考第二章。

什么是数据类型

数据类型(或简称为类型)由三个部分定义:

  • 一组值(或数据对象)

  • 可以应用于集合中所有值的一组运算

  • 一种数据表示形式,它决定了值的存储方式

编程语言提供了一些预定义的数据类型,这些数据类型被称为内置数据类型。编程语言也可以让程序员定义他们自己的数据类型,这就是所谓的用户定义的数据类型。

由不可分割的原子值组成的数据类型——在没有任何其他数据类型帮助的情况下定义——被称为原语数据类型。用户定义的数据类型是根据原始数据类型和其他用户定义的数据类型定义的。通常,编程语言不允许程序员扩展或重新定义原始数据类型。

Java 提供了很多内置的原始数据类型,比如intfloatbooleanchar等。例如,在 Java 中定义int原始数据类型的三个组件如下:

  • 一个int数据类型由一组介于–2147483648 和 2147483647 之间的整数组成。

  • int数据类型定义了加、减、乘、除、比较等操作。

  • int数据类型的值在 32 位存储器中以 2 的补码形式表示。

数据类型的所有三个组成部分都是由 Java 语言预定义的。开发人员不能扩展或重新定义int数据类型。您可以为int数据类型的值命名,如下所示:

int employeeId;

该语句声明,employeeId是一个名称(技术上称为标识符),它可以与定义int数据类型的值的值集中的一个值相关联。例如,您可以使用如下赋值语句将整数1969与名称employeeId相关联:

employeeId = 1969;

什么是标识符?

Java 中的一个标识符是一个无限长的字符序列。字符序列包括所有 Java 字母和 Java 数字,其中第一个必须是 Java 字母。Java 使用 Unicode 字符集。

Java 字母是由 Unicode 字符集表示的任何语言的字母。例如,A–Z、A–Z、_(下划线)和$被视为 Unicode 的 ASCII 字符集范围内的 Java 字母。Java 数字包括 0–9 个 ASCII 数字和任何表示语言中数字的 Unicode 字符。标识符中不允许有空格。

Tip

Java 中的标识符是一个或多个 Unicode 字母和数字的序列,并且必须以字母开头。

“标识符”是“名称”的技术术语因此,标识符只是 Java 程序中一个实体的名字,比如模块、包、类、方法、变量等等。在前一章中,您声明了一个名为jdojo.intro的模块,一个名为com.jdojo.intro的包,一个名为Welcome的类,一个名为main的方法,以及一个名为argsmain方法的参数。所有这些名字都是标识符。

您见过两种形式的名称:一种是仅由一部分组成的名称,如Welcome,另一种是由多个部分组成的名称,由点分隔,如jdojo.introcom.jdojo.intro。仅由一部分组成而没有使用任何点的名字被称为简单名字;可以由点分隔的部分组成的名称称为限定的 名称。关于 Java 中哪种实体可以有简单名称和限定名称,有一些规则。例如,模块和包可以有限定名,而类、方法和变量只能有简单名。一个实体可以有一个限定名,并不意味着这种实体的名称必须至少由x.y等两部分组成;它只是意味着这样一个实体的名字可能由点分隔的部分组成。例如,模块名ComJdojoIntro与模块名com.jdojo.introjdojo.intro一样有效。

为什么我们有两种类型的名字——简单的和限定的?要理解其背后的原因,请考虑以下两个问题:

  • 约翰和他的朋友安娜住在英国。约翰告诉安娜他明天将去伯明翰。

  • 托马斯和他的朋友旺达住在美国。托马斯告诉旺达,他明天将去伯明翰。

约翰和托马斯谈论的是同一个伯明翰城市吗?答案是否定的。英国和美国都有一个城市叫伯明翰。约翰正在谈论英国的城市,而托马斯正在谈论美国的城市。当您在日常对话或 Java 程序中使用名称时,该名称有一个空格(一个区域、一个地区或一个作用域),在该空格内它是有效的并且必须是唯一的。这样的空间称为命名空间。在我们的示例中,UK 和 USA 作为名称空间,其中伯明翰城市的名称是唯一的。限定名允许您使用名称空间作为名称。例如,John 和 Thomas 可能使用了UK.BirminghamUSA.Birmingham作为城市名,这在 Java 术语中是限定名。

当 Java 实体可以作为独立的实体出现时,例如模块和包,允许使用这些实体的限定名来防止名称冲突。假设我们有一个名为jdojo.intro的模块,其他人也创建了一个名为jdojo.intro的模块。由于名称冲突,这些模块不能在同一个 Java 应用程序中使用(或引用)。然而,如果这些模块将被独立使用,这些名称是可以的。类总是出现在(或被声明在)包内;因此,只要简单名称在包中是唯一的,类名就必须是简单名称。

命名可重用和已发布的模块和包的一个简单的经验法则是使用 Internet 反向域命名约定。如果你拥有jdojo.com,使用com.jdojo作为你所有模块和包的名字的前缀。我们在本书中只使用jdojo作为所有模块名称的前缀,因为这些模块并不公开使用;它们只是用在本书的例子中,而且只是为了学习;较短的模块名称也便于我和你输入和阅读!

Java 编程语言是区分大小写的。标识符中使用的所有字符都很重要,就像它们的大小写一样。名字welcomeWelcomeWELCOME是三个不同的标识符。如果您在 Java 程序中使用直观的名称来表达实体的目的,会对代码的读者有所帮助。假设您需要在一个变量中存储一个雇员的 ID。你可以将变量命名为n1id,Java 不会抱怨。然而,如果您将其命名为employeeIdempId,您的代码将变得更具可读性。任何阅读你的代码的人都会得到正确的上下文和那个变量的目的。表 4-1 包含了一些 Java 中有效和无效标识符的例子。

表 4-1

Java 中有效和无效标识符的示例

|

标识符

|

有效的

|

描述

Welcome 由所有字母组成。
num1 由三个字母和一个数字组成。
_myId 可以以下划线开头。
sum_of_two_numbers 可以有字母和下划线。
Outer$Inner 可以有字母和一个美元。
$var 可以从一个美元开始。
$ 只能是一个亿。
_ 从 JDK 9 开始,下划线不能单独用作标识符。但是,下划线可以是多字符标识符名称的一部分。
2num 标识符不能以数字开头。
my name 标识符不能包含空格。
num1+num2 标识符不能包含+-*/等符号。

关键词

关键字是在 Java 编程语言中具有预定义含义的单词。它们只能在 Java 编程语言定义的上下文中使用。关键字不能用作标识符。表 4-2 包含了 Java 中关键字的完整列表。

表 4-2

Java 中的关键字和保留字列表

|

abstract

|

continue

|

for

|

New

|

switch

assert default If Package synchronized
boolean do goto Private this
break double implements Protected throw
byte else import Public throws
case enum instanceof Return transient
catch extends int short try
char final interface static void
class finally long strictfp volatile
const float native super while
_ (underscore)

Java 中目前不使用两个关键字constgoto。它们是保留关键字,不能用作标识符。Java SE 10 引入了“var”作为保留类型,所以虽然它看起来是一个关键字,但它不是。

随着模块系统的引入,Java SE 9 引入了十个新的受限关键字,这些关键字不能用作标识符。那些受限的关键字是openmodulerequirestransitiveexportsopenstousesprovideswith。它们是受限制的关键字,因为它们仅在模块声明的上下文中被视为关键字;在程序中的任何地方,它们都可以用作标识符。在 Java SE 8 中它们不是关键字。如果它们在 Java SE 9 中被声明为关键字,那么用 Java SE 编写的、使用它们作为标识符的许多程序将会崩溃。

Tip

在 Java 程序中,你会遇到三个词——truefalsenull。它们看似是关键词,其实不是。相反,truefalse是布尔文字,而null是空文字。在 Java 中不能使用truefalsenull作为标识符,即使它们不是关键字。

Java 中的数据类型

在我们开始讨论 Java 中所有可用的数据类型之前,让我们看一个简单的将两个数字相加的例子。假设你的朋友让你把两个数相加。将两个数相加的过程如下:

  1. 你的朋友告诉你第一个数字,你听他们说话,你的大脑把这个数字记录在你记忆中的特定位置。当然,你不知道这个数字在你大脑记忆中的确切位置。

  2. 你的朋友告诉你第二个数字,你听他们,同样,你的大脑把它记录在你记忆中的特定位置。

  3. 现在,你的朋友让你把这两个数相加。你的大脑又开始行动了。它回忆(或读取)两个数并相加,你告诉你的朋友这两个数的和。

现在,如果你的朋友想让你告诉他们这两个数字的区别,他们不需要再告诉你这两个数字了。这是因为这两个数字储存在你的记忆中,你的大脑可以再次回忆和使用它们。然而,你的大脑是否能够执行那两个数字的加法取决于许多因素,例如,那两个数字有多大,你的大脑是否能够记住(或存储)那些大数字,你的大脑是否受过做加法的训练,等等。两个数相加的过程也取决于这两个数的类型。根据这两个数字是整数(例如 10 和 20)、实数(例如 12.4 和 19.1),还是整数和实数的混合(例如 10 和 69.9),你的大脑会使用不同的逻辑将它们相加。整个过程发生在你的大脑中,而你却没有注意到(可能是因为你太习惯于做这些加法)。但是,当您想要在 Java 程序中对数字或任何其他类型的值进行任何类型的操作时,您需要指定关于您想要操作的值的细节以及操作这些值的过程。

让我们讨论 Java 程序中两个数相加的同一个例子。你需要告诉 Java 的第一件事是你要相加的两个数的类型。假设你想把两个整数 50 和 70 相加。当你自己把两个数字相加时,你的大脑给每个数字起了一个名字(可能作为第一个数字和第二个数字)。你没有注意到大脑对这些数字的命名。然而,在 Java 程序中,您必须显式地给这两个数字命名(也称为标识符)。我们把这两个数字分别命名为num1num2。Java 程序中的下面两行表示有两个整数,num1num2:

int num1;
int num2;

int关键字用于表示后面的名称代表一个整数值,例如 10、15、70、1000 等。当这两行代码被执行时,Java 分配了两个内存位置,并将名字num1与第一个内存位置相关联,将名字num2与第二个内存位置相关联。此时的存储状态如图 4-1 所示。

img/323069_3_En_4_Fig1_HTML.png

图 4-1

声明两个 int 类型变量时的内存状态

这些内存位置被称为变量,被命名为num1num2。严格来说,num1num2是与两个内存位置相关联的两个名字。然而,粗略地说,你说

  • num1num2是两个变量

  • num1num2int数据类型的两个变量

  • num1num2是两个int变量

因为您已经声明了int数据类型的num1num2变量,所以您不能在这些内存位置存储一个实数,比如 10.51。下面这段代码在num1中存储 50,在num2中存储 70:

num1 = 50;
num2 = 70;

这两行代码执行后的内存状态如图 4-2 所示。

img/323069_3_En_4_Fig2_HTML.png

图 4-2

两个 int 类型变量赋值后的内存状态

现在,你想把这两个数相加。在添加它们之前,必须分配另一个内存位置来保存结果。您将这个内存位置命名为num3,下面这段代码执行这些任务:

int num3;            // Allocates the memory location num3
num3 = num1 + num2;  // Computes sum and store the result in num3

这两行代码执行后的内存状态如图 4-3 所示。

img/323069_3_En_4_Fig3_HTML.png

图 4-3

两个数相加过程中的记忆状态

前面两行可以合并成一行:

int num3 = num1 + num2;

变量有三个属性:

  • 保存该值的内存位置

  • 存储在内存位置的数据的类型

  • 一个名称(也称为标识符)来引用存储位置

变量的数据类型也决定了内存位置可以容纳的值的范围。因此,为变量分配的内存量取决于它的数据类型。例如,为int数据类型的变量分配 32 位内存。在这个例子中,每个变量(num1num2num3)使用 32 位内存。

Java 支持两种数据类型:

  • 原始数据类型

  • 参考数据类型

原始数据类型的变量保存一个值,而引用数据类型的变量保存对内存中对象的引用。在本节中,我们将讨论 Java 中可用的参考数据类型之一StringString是 Java 库中定义的一个类,你可以用它来操作文本(字符序列)。您如下声明一个名为strString类型的引用变量:

String str;

在将对象的引用赋给引用变量之前,需要创建一个对象。使用new操作符创建一个对象。您可以创建一个以"Hello"为内容的String类的对象,如下所示:

// Creates a String object and assigns the reference of the object to str
str = new String("Hello");

执行这段代码时会发生什么?首先,分配内存,变量str的名称与该内存位置相关联,如图 4-4 所示。这个过程与声明一个原始数据类型变量是一样的。第二段代码用文本"Hello"在内存中创建一个String对象,并将String对象的引用(或内存地址)存储到变量str中。这一事实在图 4-4 的后半部分通过使用一个从变量str指向内存中对象的箭头来显示。

img/323069_3_En_4_Fig4_HTML.png

图 4-4

使用参考变量存储状态

还可以将存储在一个引用变量中的对象的引用分配给另一个引用变量。在这种情况下,两个引用变量都指向内存中的同一个对象。这可以通过以下方式实现:

// Declares String reference variable str1 and str2
String str1;
String str2;
// Assigns the reference of a String object "Hello" to str1
str1 = new String("Hello");
// Assigns the reference stored in str1 to str2
str2 = str1;

有一个引用常量(也叫引用文字)null,可以赋给任何引用变量。如果null被赋值给一个引用变量,就意味着这个引用变量没有引用内存中的任何对象。null参考文字可以分配给str2:

str2 = null;

图 4-5 描述了所有这些语句执行后的存储器状态。

img/323069_3_En_4_Fig5_HTML.png

图 4-5

在引用变量赋值中使用 null 的内存状态

使用new操作符创建一个String对象。然而,字符串的使用如此频繁,以至于有一个创建 String 对象的快捷方式。所有字符串文字,即双引号中的字符序列,都被视为String对象。因此,不使用new操作符来创建一个String对象,您可以像这样使用字符串文字:

// Assigns the reference of a String object with text "Hello" to str1
String str1 = "Hello";
// Assigns the reference of a String object with text "Hello" to str1
String str1 = new String ("Hello");

这两条语句之间有一个微妙的区别,它们用相同的文本"Hello"将一个String对象分配给str1。这本书在单独的一章 15 中讨论了String类的区别。

局部变量类型推理

Java 10 引入了局部变量类型推断特性,如果编译器能够确定它应该使用的类型,该特性允许您使用“var”来代替局部变量的类型(在方法内部)。它与严格定义类型的意思相同,但它是一种更简洁的语法。在很多情况下可以避免类型的重复。

例如,以前面的代码为例,使用“var”会产生如下代码,但含义没有任何变化:

// Assigns the reference of a String object with text "Hello" to str1
var str1 = "Hello";
// Assigns the reference of a String object with text "Hello" to str1
var str1 = new String ("Hello");

它不是一个关键字,仍然可以用作变量名,尽管不建议这样做。我们将在后面的章节中更深入地讨论“var ”,比如可以使用和不可以使用它的每一个场景。

Java 中的原始数据类型

Java 有八种原始数据类型。表 4-3 列出了它们的名称、大小、有符号还是无符号、范围以及一些例子。以下部分将对它们进行详细描述。

表 4-3

原始数据类型、大小、范围和示例的列表

|

数据类型

|

以位为单位的大小

|

签名/未签名

|

范围

|

例子

byte 8 签名 -2 7+2 7 - 1 -2, 8, 10
short 16 签名 -2 15+2 15 - 1 -2, 8, 10
int 32 签名 -2 31+2 31 - 1 1990, -90, 23
long 64 签名 -2 63+2 63 - 1 1990L, -90L, 23L
char 16 无符号的 065535 'A', '8', '\u0000'
float 32 签名 -3.4 x 10 38+3.4 x 10 38 12.89F, -89.78F
double 64 签名 -1.7 x 10 308 到+1.7 x 10 308 12.78, -78.89
boolean 未指明的 不适用的 truefalse true, false

八种原始数据类型分为两类:

  • boolean数据类型

  • 数字数据类型

数字数据类型可以进一步细分为整型和浮点型。所有原始数据类型及其类别如图 4-6 所示。随后的部分详细描述了所有的原始数据类型。

img/323069_3_En_4_Fig6_HTML.png

图 4-6

Java 中按类别划分的基本数据类型列表

Alert

我们已经收到了几封来自本书第一版读者的电子邮件,称将char数据类型列在数值数据类型类别下是一个错误。然而,事实并非如此。char数据类型绝对是数值数据类型。可以给char变量赋一个整数,也可以对char变量进行加减等算术运算。Java 语言规范也将char归类为数字数据类型。许多 Java 书籍要么将char列为单独的数据类型,要么将其描述为非数值数据类型;两个都不对。

在阅读本书和使用 Java 时,你会多次碰到“文字”这个术语。类型为X的文字表示类型为X的值,可以在源代码中直接表示,而不需要任何计算。例如,10 是一个int字面值,这意味着每当你在 Java 程序中需要一个int类型的值 10 时,你可以简单地输入 10。Java 为所有基本类型和两种引用类型定义了文字,即String类型和空类型。

整数数据类型

整数数据类型是数值数据类型,其值是整数(即整数)。Java 提供了五种整型数据类型:byteshortintlongchar。所有整型数据类型将在接下来的章节中详细描述。

int 数据类型

int数据类型是 32 位有符号 Java 原语数据类型。int数据类型的变量占用 32 位内存。其有效范围是–2,147,483,648 到 2,147,483,647(–231 到 231–1)。这个范围内的所有整数都称为整数文字(或整数常量)。例如,10、–200、0、30、19 等。是int类型的整数。一个整数可以被赋给一个int变量,比如说num1,就像这样:

int num1 = 21;

整数文字可以用以下格式表示:

  • 十进制数字格式

  • 八进制数字格式

  • 十六进制数字格式

  • 二进制数字格式

当整数文字以零开始并且至少有两位数时,它被认为是八进制数字格式。以下代码行将十进制值 17(八进制为 021)赋给num1:

// 021 is in octal number format, not in decimal
int num1 = 021;

下面两行代码具有相同的效果,将值 17 赋给变量num1:

// No leading zero - decimal number format
int num1 = 17;
// Leading zero - octal number format. 021 in octal is the same as 17 in decimal
int num1 = 021;

使用带有前导零的int文字时要小心,因为 Java 会将这些文字视为八进制数字格式。请注意,八进制格式的int文字必须至少有两位数,并且必须以零开头,才能被视为八进制数。在十进制数字格式中,数字 0 被视为零,而在八进制数字格式中,00 被视为零:

// Assigns zero to num1, 0 is in the decimal number format
int num1 = 0;
// Assigns zero to num1, 00 is in the octal number format
int num1 = 00;

注意 0 和 00 代表同一个值,零。两行代码具有相同的效果,将值 0 赋给变量num1

所有十六进制数字格式的int文字都以0x0X开头,也就是说,零后面紧跟一个大写或小写的X,并且它们必须包含至少一个十六进制数字。十六进制数字格式使用 16 位数字,0–9 和 A–F(或 A–F)。字母 A-F 的大小写无关紧要。以下是使用十六进制格式的int文字的示例:

int num1 = 0x123;
int num2 = 0xdecafe;
int num3 = 0x1A2B;
int num4 = 0X0123;

一个int字面值也可以用二进制数字格式来表示。二进制数字格式的所有int文字都以0b0B开头,也就是说,零后面紧跟一个大写或小写的B。以下是在二进制数字格式中使用int文字的例子:

int num1 = 0b10101;
int num2 = 0b00011;
int num3 = 0b10;
int num4 = 0b00000010;

以下赋值将相同的十进制数 51966 赋给四种不同格式的名为num1int变量:

num1 = 51966;                // Decimal format
num1 = 0145376;              // Octal format, starts with a zero
num1 = 0xCAFE;               // Hexadecimal format, starts with 0x
num1 = 0b1100101011111110;   // Binary format starts with 0b

Java 有一个名为Integer(注意Integer中的大写I)的类,它定义了两个常量来表示int数据类型的最大值和最小值,Integer.MAX_VALUEInteger.MIN_VALUE

例如

int max = Integer.MAX_VALUE; // Assigns maximum int value to max
int min = Integer.MIN_VALUE; // Assigns minimum int value to min

长数据类型

long数据类型是 64 位有符号 Java 原语数据类型。当整数的计算结果可能超出int数据类型的范围时使用。其范围为–9223372036854775808 到 9223372036854775807(–263到 263–1)。在long范围内的所有整数称为long类型的整数文字。

51 是一个整数。它的数据类型是什么:int还是long?类型为long的整数文字总是以L(或小写l)结尾。本书使用L来标记long类型的整数文字的结束,因为l(小写L)在印刷中经常与1(数字一)混淆。以下是使用long类型的整数文字的示例:

long num1 = 0L;
long num2 = 401L;
long mum3 = -3556L;
long num4 = 89898L;
long num5 = -105L;

Tip

25L是一个long类型的整数文字,而25是一个int类型的整数文字。

long类型的整数也可以用八进制、十六进制和二进制格式表示,例如:

long num1;
num1 = 25L;      // Decimal format
num1 = 031L;     // Octal format
num1 = 0X19L;    // Hexadecimal format
num1 = 0b11001L; // Binary format

当一个long字面值被赋给一个long类型的变量时,Java 编译器检查被赋的值,并确保它在long数据类型的范围内;否则,它会生成一个编译时错误,例如:

// One more than maximum positive value for long. This will generate a compile-time error
long num1 = 9223372036854775808L;

因为int数据类型的范围比long数据类型的范围小,所以存储在int变量中的值总是可以分配给long变量。

int num1 = 10;
long num2 = 20;  // OK to assign int literal 20 to a long variable num2
num2 = num1;     // OK to assign an int to a long

Tip

当您将一个较小类型的值赋给一个较大类型的变量时(例如intlong),Java 会执行一个自动扩大转换,用零填充目标中的高位,保留源的符号位。例如,当给一个long变量赋值一个int文字时,Java 执行扩大转换。

intlong的赋值是有效的,因为所有可以存储在int变量中的值也可以存储在long变量中。然而,反之则不然。你不能简单地将存储在long变量中的值赋给int变量。存在价值溢出(或价值损失)的可能。考虑以下两个变量:

int num1 = 10;
long num2 = 2147483655L;

如果将num2的值分配给num1,则为

num1 = num2;

存储在num2中的值不能存储在num1中,因为num1的数据类型是intnum2的值超出了int数据类型可以处理的范围。为了防止无意中犯这样的错误,Java 不允许您编写这样的代码:

// A compile-time error. long to int assignment is not allowed in Java
num1 = num2;

即使存储在long变量中的值在int数据类型的范围内,也不允许从longint的赋值,如下例所示:

int num1 = 5;
long num2 = 25L;
// A compile-time error. Even if num2's value 25 which is within the range of int
num1 = num2;

如果你想把一个long变量的值赋给一个int变量,你必须在你的代码中明确提到这个事实,这样 Java 就能确保你意识到可能会有值的损失。在 Java 中使用“cast”可以做到这一点,方法是将目标类型放在值前面的括号中,如下所示:

num1 = (int)num2; // Now it is fine because of the "(int)" cast

通过编写(int)num2,您正在指示 Java 将存储在num2中的值视为一个int。在运行时,Java 将只使用num2的 32 个最低有效位(LSB ),并将存储在这 32 位中的值赋给num1。如果num2的值超出了int数据类型的范围,那么在num1中就不会得到相同的值。

Java 有一个类Long(注意Long中的大写L)定义了两个常量来表示long数据类型的最大值和最小值,Long.MAX_VALUELong.MIN_VALUE:

long max = Long.MAX_VALUE;
long min = Long.MIN_VALUE;

字节数据类型

byte数据类型是 8 位有符号 Java 原始整数数据类型。其范围是–128 到 127(–27到 27–1)。这是 Java 中最小的整数数据类型。一般来说,当程序使用大量的变量值在–128 到 127 范围内,或者在文件中或网络上处理二进制数据时,使用byte变量。与intlong文字不同,这里没有byte文字。但是,您可以将byte范围内的任何int文字赋值给byte变量,例如:

byte b1 = 125;
byte b2 = -11;

如果将–128 到 127 范围之外的值赋给byte变量,Java 会生成一个编译器错误。以下赋值产生编译时错误:

// An error. 150 is an int literal outside -128 to 127
byte b3 = 150;

请注意,您只能将–128 到 127 之间的int文字赋给byte变量。然而,这并不意味着您也可以将存储在一个int变量中的值(在–128 到 127 的范围内)赋给一个byte变量。下面这段代码将生成一个编译时错误,因为它将一个int变量num1的值赋给了一个byte变量b1:

int num1 = 15;
// OK. Assignment of int literal (-128 to 127) to byte.
byte b1 = 15;
// A compile-time error. Even though num1 has a value of 15, which is in the range -128 and 127.
b1 = num1;

为什么num1赋给b1时编译器会抱怨?编译器不会试图读取存储在num1中的值,因为num1是一个变量,它的值只有在运行时才知道。它将num1视为int类型,32 位大,而将b1视为byte类型,8 位大。根据它们的大小,编译器会发现您将一个较大的变量赋给了一个较小的变量,这可能会丢失数据。当你把 15 赋给b1时,15 是一个int字面量,它的值在编译时是已知的;编译器可以确保 15 在一个byte的范围内(–128 到 127)。如果您在前面的代码片段中将num1声明为编译时常量,编译器不会生成错误。编译时常量是使用 final 关键字声明的变量,其值在编译时是已知的。以下代码片段实现了这一点:

// Using final makes the num1 variable a compile-time constant
final int num1 = 15;
// OK. Assignment of int literal (-128 to 127) to byte.
byte b1 = 15;
// Now, the compiler knows the value of num1 as 15 and it is fine.
b1 = num1;

您也可以通过强制转换来修复这个错误,就像您在使用long–to-int赋值时所做的那样。num1b1的赋值可以改写如下:

int num1 = 15;
byte b1 = 15;
b1 = (byte)num1; // Ok. Using a cast

在从intbyte的转换之后,Java 编译器不会抱怨intbyte的赋值。如果num1保存了一个不能在 8 位byte变量b1中正确表示的值,则num1的高位(第 9 位到第 32 位)被忽略,低位 8 位中表示的值被分配给b1。在这种int -to- byte赋值的情况下,如果源变量的值超出了byte数据类型的范围,则分配给目标byte变量的值可能与源int变量的值不同。

然而,不管源变量int中的值如何,目标变量byte的值总是介于–128 和 127 之间。像int一样,由于long也是比byte更大的数据类型,如果你想把一个long变量的值赋给一个byte变量,你需要使用显式强制转换。例如,

byte b4 = 10;
long num3 = 19L;
b4 = (byte)num3;  // OK because of cast
b4 = 19L;         // Error. Cannot assign long literal to byte
b4 = (byte)19L;   // OK because of cast

的确,19 和 19L 代表同一个数字。然而,对于 Java 编译器来说,它们是不同的。19 是一个int字面值,即它的数据类型是int,而 19L 是一个long字面值,即它的数据类型是long

Java 有一个名为Byte(注意Byte中的大写B)的类,它定义了两个常量来表示byte数据类型的最大值和最小值Byte.MAX_VALUEByte.MIN_VALUE:

byte max = Byte.MAX_VALUE; // Same as byte max = 127;
byte min = Byte.MIN_VALUE; // Same as byte min = -128;

短数据类型

short数据类型是 16 位有符号 Java 原始整数数据类型。其范围为–32768 至 32767(或–215至 215–1)。通常,short变量用于程序使用大量值在short数据类型范围内的变量时,或者处理文件中的数据时,使用short数据类型可以轻松处理。与intlong文字不同,这里没有short文字。但是,您可以将任何在short(–32768 到 32767)范围内的int文字赋值给short变量,例如:

short s1 = 12905;   // ok
short s2 = -11890;  // ok

因为byte数据类型的范围在short数据类型的范围内,所以byte变量的值总是可以赋给short变量。从intlong变量向short变量赋值的所有其他规则与byte变量相同。以下代码片段说明了将byteintlong值分配给short变量:

short s1 = 15;    // OK
byte b1 = 10;     // OK
s1 = b1;          // OK
int num1 = 10;    // OK
s1 = num1;        // A compile-time error
s1 = (short)num1; // OK because of cast from int to short
s1 = 35000;       // A compile-time error of an int literal outside the short range
long num2 = 555L; // OK
s1 = num2;        // A compile-time error
s1 = (short)num2; // OK because of the cast from long to short
s1 = 555L;        // A compile-time error
s = (short)555L;  // OK because of the cast from long to short

Java 有一个名为Short(注意Short中的大写S)的类,它定义了两个常量来表示short数据类型的最大值和最小值,Short.MAX_VALUEShort.MIN_VALUE:

short max = Short.MAX_VALUE;
short min = Short.MIN_VALUE;

char 数据类型

char数据类型是 16 位无符号 Java 原始数据类型。它的值表示一个 Unicode 字符。注意char是一个无符号数据类型。因此,char变量不能有负值。char数据类型的范围是 0–65535,与 Unicode 字符集的范围相同。一个字符文字表示一个char数据类型的值。字符文字可以用以下格式表示:

  • 作为用单引号括起来的字符

  • 作为字符转义序列

  • 作为 Unicode 转义序列

  • 作为八进制转义序列

单引号中的字符文字

字符文字可以用单引号括起来表示。以下是几个例子:

char c1 = 'A';
char c2 = 'L';
char c3 = '5';
char c4 = '/';

回想一下,双引号中的字符序列是一个String文字。一个String字面量不能赋给一个char变量,即使String字面量只包含一个字符。这个限制是因为 Java 不允许混合原始数据类型和引用数据类型的值。String是一种引用数据类型,而char是一种原始数据类型。以下是几个例子:

char c1 = 'A';     // OK
String s1 = 'A';   // An error. Cannot assign a char 'A' to a String s1
String s2 = "A";   // OK. "A" is a String literal assigned to a String variable
String s3 = "ABC"; // OK. "ABC" is a String literal
char c2 = "A";     // An error. Cannot assign a String "A" to char c2
char c4 = 'AB';    // An error. A character literal must contain only one character

字符转义序列

字符文字也可以表示为字符转义序列。字符转义序列以反斜杠开头,后面紧跟着一个字符,两者都用单引号括起来。有八个预定义的字符转义序列,如表 4-4 中所列。你不能在 Java 中定义你自己的字符转义序列。

表 4-4

字符转义序列列表

|

字符转义序列

|

描述

'\n' 换行
'\r' 回车
'\f' 换页
'\b' 退格键
'\t' 一个标签
'\\' 一个反斜杠
'\"' 双引号
'\'' 单引号

以字符转义序列形式表示的字符文字由两个字符组成——一个反斜杠和一个跟在反斜杠后面的字符。然而,它们只代表一个字符。以下是使用字符序列的几个示例:

char c1 = '\n'; // Assigns a linefeed to c1
char c2 = '\"'; // Assigns double quote to c2
char c3 = '\a'; // A compile-time error. Invalid character escape sequence

Unicode 字符转义序列

一个字符文字也可以表示为一个形式为'\uxxxx'的 Unicode 转义序列。这里,\u(一个反斜杠紧接着一个小写的u)表示 Unicode 转义序列的开始,而xxxx正好表示四个十六进制数字。由xxxx表示的值是字符的 Unicode 值。字符'A'的 Unicode 值为 65。十进制的值 65 在十六进制中可以表示为 41。所以字符'A'可以用 Unicode 转义序列表示为'\u0041'。以下代码片段将相同的字符'A'分配给char变量c1c2:

char c1 = 'A';
char c2 = '\u0041';  // Same as c2 = 'A';

八进制字符转义序列

一个字符文字也可以表示为一个八进制转义序列,形式为'\nnn'。这里,n是一个八进制数字(0–7)。八进制转义序列的范围是从'\000''\377'。八进制数 377 与十进制数 255 相同。因此,使用八进制转义序列,可以表示 Unicode 代码范围从 0 到 255 个十进制整数的字符。

Unicode 字符集(代码范围 0–65535)可以表示为 Unicode 转义序列('\uxxxx')。为什么 Java 有另一个八进制转义序列,它是 Unicode 转义序列的子集?八进制转义序列用于表示字符,以便与使用 8 位无符号字符表示字符的其他语言兼容。与 Unicode 转义序列不同,在 Unicode 转义序列中,您总是需要使用四个十六进制数字,而在八进制转义序列中,您可以使用一个、两个或三个八进制数字。因此,八进制转义序列可以采用'\n''\nn''\nnn'的形式,其中n是八进制数字 0、1、2、3、4、5、6 和 7 中的一个。八进制转义序列的一些例子如下:

char c1 = '\52';
char c2 = '\141';
char c3 = '\400'; // A compile-time error. Octal 400 is out of range
char c4 = '\42';
char c5 = '\10';  // Same as '\n'

如果int字面量在 0–65535 的范围内,您也可以将int字面量赋给char变量。当您将一个int字面值赋给一个char变量时,char变量代表其 Unicode 代码等于该int字面值所代表的值的字符。字符'a'(小写 A)的 Unicode 编码是 97。十进制值 97 在八进制中表示为 141,在十六进制中表示为 61。在 Java 中可以用不同的形式表示 Unicode 字符'a':'a','\141''\u0061'。您还可以使用int文字 97 来表示 Unicode 字符'a'。以下四种赋值在 Java 中具有相同的含义:

char c1 = 97;          // Assign 'a' to c1
char c2 = 'a';         // Assign 'a' to c2
char c3 = '\141';      // Assign 'a' to c3
char c4 = '\u0061';    // Assign 'a' to c4

一个byte变量占用 8 位,一个char变量占用 16 位。即使byte数据类型的范围小于char数据类型的范围,也不能将存储在byte变量中的值赋给char变量。原因是byte是有符号数据类型,而char是无符号数据类型。如果byte变量有一个负值,比如说–15,它就不能存储在char变量中而不损失精度。为了使这样的赋值成功,您需要使用显式强制转换。以下代码片段说明了从char到其他整型数据类型的可能赋值情况,反之亦然:

byte b1 = 10;
short s1 = 15;
int num1 = 150;
long num2 = 20L;
char c1 = 'A';
// byte and char
b1 = c1;          // An error
b1 = (byte)c1;    // OK
c1 = b1;          // An error
c1 = (char)b1;    // OK
// short and char
s1 = c1;          // An error
s1 = (short)c1;   // OK
c1 = s1;          // An error
c1 = (char)s1;    // OK
// int and char
num1 = c1;        // OK
num1 = (int)c1;   // OK. But cast is not required. Use num1 = c1
c1 = num1;        // An error
c1 = (char)num1;  // OK
c1 = 255;         // OK. 255 is in the range of 0-65535
c1 = 70000;       // An error. 70000 is out of range 0-65535
c1 = (char)70000; // OK. But will lose the original value
// long and char
num2 = c1;        // OK
num2 = (long)c1;  // OK. But cast is not required. Use num2 = c1
c1 = num2;        // An Error
c1 = (char)num2;  // OK
c1 = 255L;        // An error. 255L is a long literal
c1 = (char)255L;  // OK. But use c1 = 255 instead

布尔数据类型

boolean数据类型只有两个有效值:truefalse。这两个值被称为boolean文字。您可以按如下方式使用boolean:

// Declares a boolean variable named done
boolean done;
// Assigns true to done
done = true;

Tip

在 Java 中,1 和 0 不会分别被视为booleantruefalse。如果你有 C/C++背景,这是一个改变。Java 只定义了两个boolean值,称为boolean,它们是truefalse。除了truefalse之外,不能给boolean变量赋值。

需要注意的重要一点是,boolean变量不能转换成任何其他数据类型,反之亦然。Java 没有指定boolean数据类型的大小。它的大小由 JVM 实现决定。通常,boolean数据类型的值被编译器映射到int,而boolean数组被编码为byte数组。

浮点数据类型

包含小数部分的数字称为实数,例如 3.25、0.49、–9.19、19.0 等。计算机以二进制格式存储每一个数字,不管是实数还是整数,都只由 0 和 1 组成。因此,有必要将实数转换为二进制表示,然后才能存储。在读取其二进制表示后,必须将其转换回实数。当一个实数被转换成二进制表示时,计算机还必须存储该数中小数点的位置。在计算机内存中存储实数有两种策略:

  • 只存储数字的二进制表示,假设在点的前后总是有固定的位数。一个点在数的十进制表示中称为小数点,在二进制表示中称为二进制点。点的位置总是固定在一个数中的表示类型被称为定点数格式

  • 存储实数的二进制表示和该点在实数中的位置。因为在这种实数表示中,点前后的位数可以变化,所以我们说点可以浮动。这种表示法被称为浮点格式

与定点表示相比,浮点表示速度较慢且不太准确。然而,与定点表示法相比,浮点表示法可以在相同内存量的情况下处理更大范围的数字。

Java 支持浮点数格式。值得注意的是,并非所有的实数都有精确的二进制表示,因此,它们被表示为浮点近似值。Java 使用 IEEE 754 浮点标准来存储实数。IEEE 是电气和电子工程师协会的缩写。Java 有两种浮点数字数据类型:

  • float

  • double

浮点数据类型

float数据类型使用 32 位来存储 IEEE 754 标准格式的浮点数。根据 IEEE 754 标准,用 32 位表示的浮点数也称为单精度浮点数。它可以表示小到1.4 x 10 -45 大到3.4 x 10 38 的实数(近似值)。)在数量上。该范围仅包括大小。它可能是积极的,也可能是消极的。这里,1.4 x 10 -45 是可以存储在float变量中的大于零的最小正数。

所有以fF结尾的实数被称为float文字。float文字可以用以下两种格式表示:

  • 十进制数字格式

  • 科学符号

十进制数字格式的float文字的几个例子如下:

float f1 = 8F;
float f2 = 8.F;
float f3 = 8.0F;
float f4 = 3.51F;
float f5 = 0.0F;
float f6 = 16.78f;

实数3.25也用32.5 x 10-10.325 x 101等指数形式书写。

在 Java 中,这样的实数可以用科学记数法表示为float文字。在科学记数法中,数字32.5 x 10 -1 写成32.5E-1。作为float的字面意思,可以写成32.5E-1F或者32.5E-1f。所有下面的float文字表示同一个实数32.5:

  • 3.25F

  • 32.5E-1F

  • 0.325E+1F

  • 0.325E1F

  • 0.0325E2F

  • 0.0325e2F

  • 3.25E0F

float数据类型定义了两个零:+0.0F(或0.0F)和-0.0F。然而,出于比较的目的,+0.0F-0.0F被认为是相等的。

float数据类型定义了两个无穷大:正无穷大和负无穷大。例如,2.5F除以0.0F的结果是float正无穷大,而2.5F除以-0.0F的结果是float负无穷大。

float的某些操作的结果未定义。例如,0.0F除以0.0F是不确定的。不确定的结果由称为NaN(非数字)的float数据类型的特殊值表示。Java 有一个Float类(注意Float中的大写F),它定义了三个常量,分别代表正无穷大、负无穷大和float数据类型的NaN。表 4-5 列出了这些float常量及其含义。该表还列出了两个常量,代表可存储在float变量中的最大值和最小值(大于零)float值。

表 4-5

在 Float 类中定义的常量

|

浮点常量

|

意义

Float.POSITIVE_INFINITY float 类型的正无穷大。
Float.NEGATIVE_INFINITY float 类型的负无穷大。
Float.NaN 非浮点数类型。
Float.MAX_VALUE 浮点变量中可以表示的最大正值。这等于 3.4 x 10 38 (大约。).
Float.MIN_VALUE 可以用浮点变量表示的大于零的最小正值。这等于 1.4 x 10 -45

所有整型(intlongbyteshortchar)的值都可以赋给float数据类型的变量,而无需使用显式强制转换。以下是几个例子:

int num1 = 15000;
float salary = num1;            // OK. int variable to float
salary = 12455;                 // OK. int literal to float
float bigNum = Float.MAX_VALUE; // Assigns maximum float value
bigNum = 1226L;                 // OK, a long literal to float
float justAChar = 'A';          // OK. Assigns 65.0F to justAChar
// OK. Assigns positive infinity to the fInf variable
float fInf = Float.POSITIVE_INFINITY;
// OK. Assigns Not-a-Number to fNan variable
float fNan = Float.NaN;
// A compile-time error. Cannot assign a float literal to a float variable
// greater than the maximum value of float(3.4E38F approx)
float fTooBig = 3.5E38F;
// A compile-time error. Cannot assign a float literal to a float variable less
// than the minimum value (greater than zero) of float 1.4E-45F
float fTooSmall = 1.4E-46F;

在将一个float值赋给任何整数数据类型的变量intlongbyteshortchar之前,必须对其进行强制转换。这条规则背后的原因是整数数据类型不能存储存储在float值中的小数部分,所以当您将float值转换为整数时,Java 会警告您精度损失。这里有几个例子:

int num1 = 10;
float salary = 10.6F;
num1 = salary;       // A a compile-time error. Cannot assign float to int
num1 = (int)salary;  // OK. num1 will store 10

大多数浮点数都是它们对应的实数的近似值。将intlong分配给float可能会导致精度损失。考虑下面这段代码:

int num1 = 1029989998; // Stores an integer in num1
float num2 = num1;     // Assigns the value stored in num1 to num2
int num3 = (int)num2;  // Assigns the value stored in num2 to num3

您期望存储在num1num3中的值应该相同。然而,它们不是,因为存储在num1中的值不能精确地以浮点格式存储在float变量num2中。并非所有浮点数都有精确的二进制表示形式。这就是num1num3不相等的原因。有关详细信息,请参阅本章后面的“浮点数的二进制表示”一节。下面的 JShell 会话向您展示了num3num1多 18。

jshell> int num1 = 1029989998;
num1 ==> 1029989998
jshell> float num2 = num1;
num2 ==> 1.02999002E9
jshell> int num3 = (int)num2;
num3 ==> 1029990016
jshell> num1 - num3
$4 ==> -18

Tip

int分配给float可能会导致精度损失。然而,这样的赋值不会导致 Java 中的错误。

双精度数据类型

double数据类型使用 64 位来存储 IEEE 754 标准格式的浮点数。根据 IEEE 754 标准,用 64 位表示的浮点数也称为双精度浮点数。它可以表示小到 4.9 x 10 -324 ,大到 1.7 x 10 308 (大约。)在数量上。该范围仅包括震级。它可能是积极的,也可能是消极的。这里,4.9 x 10 -324 是可以存储在double变量中的大于零的最小正数。

所有实数都称为double文字。一个double字面值可以选择以dD结尾,例如 19.27d。也就是说,19.27 和 19.27d 表示同一个double字面值。本书使用不带后缀dDdouble文字。一个double文字可以用以下两种格式表示:

  • 十进制数字格式

  • 科学符号

十进制数字格式的double文字的几个例子如下:

double d1 = 8D
double d2 = 8.;
double d3 = 8.0;
double d4 = 8.D;
double d5 = 78.9867;
double d6 = 45.0;

Tip

8是一个int字面值,而8D8.8.0double字面值。

float文字一样,您也可以使用科学符号来表达double文字,如下所示:

double d1 = 32.5E-1;
double d2 = 0.325E+1;
double d3 = 0.325E1;
double d4 = 0.0325E2;
double d5 = 0.0325e2;
double d6 = 32.5E-1D;
double d7 = 0.325E+1d;
double d8 = 0.325E1d;
double d9 = 0.0325E2d;

float数据类型一样,double数据类型定义了两个零、两个无穷大和一个NaN。它们由Double类中的常量表示。表 4-6 列出了这些常量及其含义。表 4-6 还列出了两个常量,它们代表可以在double变量中表示的最大和最小(大于零)double值。

表 4-6

Double 类中的常量

|

双常量

|

意义

Double.POSITIVE_INFINITY double 类型的正无穷大。
Double.NEGATIVE_INFINITY double 类型的负无穷大。
Double.NaN double 类型的非数字。
Double.MAX_VALUE 双精度变量中可以表示的最大正值。这等于 1.7 x 10 308 (大约。).
Double.MIN_VALUE 可以用双精度变量表示的大于零的最小正值。这等于 4.9 x 10 -324

所有整型(intlongbyteshortcharfloat)的值都可以被赋给double数据类型的变量,而无需使用强制转换:

int num1 = 15000;
double salary = num1;             // OK. An int to double assignment
salary = 12455;                   // OK. An int literal to double
double bigNum = Double.MAX_VALUE; // Assigns the maximum double value to bigNum
bigNum = 1226L;                   // OK. A long literal to double
double justAChar = 'A';           // OK. Assigns 65.0 to justAChar
// Assigns positive infinity to dInf variable
double dInf = Double.POSITIVE_INFINITY;
// Assigns Not-a-Number to dNan variable
double dNan = Double.NaN;
// A compile-time error. Cannot assign a double literal to a double variable
// greater than the maximum value of double (1.7E308 approx)
double dTooBig = 1.8E308;
// A compile-time error. Cannot assign a double literal to a double variable
// less than the minimum value (greater than zero) of double 4.9E-324
double dTooSmall = 4.9E-325;

在将一个double值赋给任何整型数据类型的变量(intlongbyteshortchar)之前,必须将其转换为整型:

int num1 = 10;
double salary = 10.0;
num1 = salary;       // A compile-time Error. Cannot assign double to int
num1 = (int) salary; // Now Ok.

数字文本中的下划线

从 Java 7 开始,您可以在数字文本中的两位数字之间使用任意数量的下划线。例如,一个int文字1969可以写成1_96919_69196_91___969,或者任何其他在两位数字之间使用下划线的形式。在八进制、十六进制和二进制格式中也允许使用下划线。没有标点符号的大数字更难阅读(例如,逗号作为千位分隔符)。在大数字中使用下划线使它们更容易阅读。以下示例显示了数字文本中下划线的有效用法:

int x1 = 1_969;            // Underscore in decimal format
int x2 = 1__969;           // Multiple consecutive underscores
int x3 = 03_661;           // Underscore in octal literal
int x4 = 0b0111_1011_0001; // Underscores in binary literal
int x5 = 0x7_B_1;          // Underscores in hexadecimal literal
byte b1 = 1_2_7;           // Underscores in decimal format
double d1  = 1_969.09_19;  // Underscores in double literal

下划线只能出现在数字之间。这意味着您不能在数字文本的开头或结尾使用下划线。对于十六进制格式,不能使用带前缀的下划线,例如带前缀的0x,带前缀的二进制格式不能使用带后缀的下划线,例如带后缀的L表示long文字,带后缀的F表示float文字。以下示例显示了数字文本中下划线的无效用法:

int y1 = _1969;         // An error. Underscore in the beginning
int y2 = 1969_;         // An error. Underscore in the end
int y3 = 0x_7B1;        // An error. Underscore after prefix 0x
int y4 = 0_x7B1;        // An error. Underscore inside prefix 0x
long z1 = 1969_L;       // An error. Underscore with suffix L
double d1 = 1969_.0919; // An error. Underscore before decimal
double d1 = 1969._0919; // An error. Underscore after decimal

Tip

可以把八进制格式的int文字1969写成03661。八进制格式的int文字开头的零被视为数字,而不是前缀。允许在八进制格式的int文字的第一个零之后使用下划线。可以把03661写成0_3661

Java 编译器和 Unicode 转义序列

回想一下,Java 程序中的任何 Unicode 字符都可以用 Unicode 转义序列的形式表示。比如字符'A'可以换成'\u0041'。Java 编译器首先将每次出现的 Unicode 转义序列转换为 Unicode 字符。Unicode 转义序列以\u开头,后跟四个十六进制数字。'\\u0041'不是 Unicode 转义序列。为了使uxxxx成为 Unicode 转义序列的一个有效部分,它的前面必须有奇数个反斜杠,因为两个相邻的反斜杠(\\)代表一个反斜杠字符。所以,"\\u0041"代表由'\''u''0''0''4','1'组成的六字串。然而,"\\\u0041"代表的是一个双字符的字符串"\A"

有时,在 Java 源代码中不恰当地使用 Unicode 转义序列可能会导致编译时错误。考虑下面一个char变量的声明:

char c = '\u000A'; // Incorrect

程序员打算用一个换行符初始化变量c,换行符的 Unicode 转义序列是\u000A。当这段代码被编译时,编译器会将\u000A转换成一个实际的 Unicode 字符,这段代码会被拆分成如下两行:

// After the actual linefeed is inserted
char c = '
         ';

由于字符文字不能在两行中继续,这段代码会生成一个编译时错误。初始化变量c的正确方法是使用字符转义序列\n,如下所示:

char c = '\n'; // Correct

在字符文字和字符串文字中,换行符和回车符应该总是分别写成\n\r,而不是\u000A\u000D。如果没有正确使用换行符和回车符,即使一行注释也可能产生编译时错误。假设你注释了char变量的错误声明,如下所示:

// char c = '\u000A';

即使这一行是注释行,也会产生编译时错误。在编译之前,注释将被分成两行,如下所示:

// char c = '
';

包含';的第二行导致了错误。在这种情况下,多行注释语法不会生成编译器错误。

/* char c = '\u000A'; */

会被转换成

/* char c = '
'; */

这仍然是一个有效的多行注释。

短暂的休息

我们已经讨论完了 Java 中的所有原始数据类型。在下一节中,我们将讨论二进制数的一般概念以及它们在 Java 中表示不同类型值的用法。如果你有计算机科学背景,你可以跳过以下部分。

本章中的类是清单 4-1 中声明的jdojo.datatype模块的成员。清单 4-2 中的程序展示了如何声明不同数据类型的变量和使用不同类型的文字。它还打印了Double类中一些常量的值。Java 8 增加了Double.BYTES常量,它包含了一个double变量使用的字节数。

// NumberTest.java
package com.jdojo.datatype;
public class NumberTest {
    public static void main(String[] args) {
        int anInt = 100;
        long aLong = 200L;
        byte aByte = 65;
        short aShort = -902;
        char aChar = 'A';
        float aFloat = 10.98F;
        double aDouble = 899.89;
        // Print values of the variables
        System.out.println("anInt = " + anInt);
        System.out.println("aLong = " + aLong);
        System.out.println("aByte = " + aByte);
        System.out.println("aShort = " + aShort);
        System.out.println("aChar = " + aChar);
        System.out.println("aFloat = " + aFloat);
        System.out.println("aDouble = " + aDouble);
        // Print some double constants
        System.out.println("Max double = " + Double.MAX_VALUE);
        System.out.println("Min double = " + Double.MIN_VALUE);
        System.out.println("Double.POSITIVE_INFINITY = " + Double.POSITIVE_INFINITY);
        System.out.println("Double.NEGATIVE_INFINITY = " + Double.NEGATIVE_INFINITY);
        System.out.println("Not-a-Number for double = " + Double.NaN);
        System.out.println("Double takes " + Double.BYTES + " bytes");
    }
}
anInt = 100
aLong = 200
aByte = 65
aShort = -902
aChar = A
aFloat = 10.98
aDouble = 899.89
Max double = 1.7976931348623157E308
Min double = 4.9E-324
Double.POSITIVE_INFINITY = Infinity
Double.NEGATIVE_INFINITY = -Infinity
Not-a-Number for double = NaN
Double takes 8 bytes

Listing 4-2Using Primitive Data Types

// module-info.java
module jdojo.datatype {
    // No module statement needed at this time
}

Listing 4-1The Declaration of a Module Named jdojo.datatype

整数的二进制表示

计算机使用二进制数字系统处理数据。二进制系统中的所有数据都是用 1 和 0 存储的。字符 1 和 0 称为位(二进制数字的简称)。它们是计算机可以处理的最小信息单位。一组 8 位称为一个字节或八位字节。半个字节(即一组 4 位)称为半字节。计算机使用数据总线(一种路径)将数据从计算机系统的一部分发送到另一部分。一次能从一个部分向另一个部分传送多少信息取决于数据总线的位宽。特定计算机上数据总线的位宽也称为字长,一个字长中包含的信息简称为一个字。因此,一个字可以指 16 位或 32 位或其他位宽,这取决于计算机的体系结构。Java 中的longdouble数据类型取 64 位。在字长为 32 位的计算机上,这两种数据类型不会被自动处理。例如,要在一个long变量中写入一个值,需要执行两个写操作——每个 32 位一半一个。

使用以下步骤可以将十进制数转换为二进制格式:

  1. 将十进制数连续除以 2。

  2. 每次除法运算后,记录余数。这将是 1 或 0。

  3. 继续步骤 1 和 2,直到除法结果为 0。

  4. 二进制数是通过从下到上在余数列中写入数字而形成的。

例如,十进制数 13 的二进制表示可以如表 4-7 所示进行计算。

表 4-7

十进制到二进制转换

|

数字

|

除以 2

|

结果

|

剩余物

Thirteen 13/2 six one
six 6/2 three Zero
three 3/2 one one
one 1/2 Zero one

十进制数 13 的二进制表示是 1101。Java 中的一个字节变量占用 1 个字节。字节变量中的值 13 存储为 00001101。注意,在二进制表示 1101 前面添加了四个零,因为一个字节变量总是占据 8 位,而不管它包含的值如何。一个字节或一个字中最右边的位称为最低有效位(LSB ),最左边的位称为最高有效位(MSB)。13 的二进制表示的 MSB 和 LSB 如图 4-7 所示。

img/323069_3_En_4_Fig7_HTML.png

图 4-7

二进制数中的 MSB 和 LSB

二进制数中的每一位都有一个权重,它是 2 的幂。通过将二进制数中的每一位乘以其权重并相加,可以将二进制数转换为十进制数。例如,二进制的 1101 可以转换为十进制的等效值,如下所示:

(1101)2   = 1 x 20 + 0 x 21 + 1 x 22 + 1 x 23
          = 1 + 0 + 4 + 8
          = (13)10

Java 以 2 的补码形式存储负整数。让我们讨论给定数系中一个数的补数。每个数字系统都有一个基数,也称为基数。例如,10 是十进制的基数,2 是二进制的基数,8 是八进制的基数。我们将用符号 R 表示基数。每个数系都定义了两种补码:

  • 递减基数补码,也称为(R-1)补码。

  • 基数补码,也称为 R 的补码。

因此,对于十进制数系统,我们有 9 的补码和 10 的补码;对于八进制数系统,我们有 7 和 8 的补码,对于二进制数系统,我们有 1 和 2 的补码。

补码

设 N 是基数为 R 的数字系统中的一个数,N 是 N 中的总位数。数 N 的减基数补码或(R-1)补码定义为

(Rn-1) - N

在十进制数系统中,数 N 的 9 的补数是

 (10n -1) - N

由于 10 n 由一个 1 后跟 n 个 0 组成,(10n–1)由 n 个 9 组成。因此,一个数的补数可以简单地通过从 9 中减去该数中的每个数字来计算。比如 5678 的 9 的补码是 4321,894542 的 9 的补码是 105457。

在二进制数系统中,二进制数的 1 的补码是

 (2n-1) - N

由于二进制数系统中的 2 n 由 1 后跟 n 个 0 组成,(2n–1)由 n 个 1 组成。比如 10110(这里 n 为 5)的 1 的补码可以计算为(25–1)-10110,也就是 11111—10110(25–1)是 31,也就是二进制的 11111。

一个二进制数的 1 的补码可以简单地通过从 1 中减去该数中的每个数字来计算。二进制数由 0 和 1 组成。1 减 1 得 0,1 减 0 得 1。因此,二进制数的 1 的补码可以通过反转该数的位来计算,即通过将 1 变为 0 和 0 变为 1 来计算。例如,10110 的 1 的补码是 01001,0110001 的 1 的补码是 1001110。

在一个数字系统中,一个数的(R-1)补码是通过从该数字系统的最大数字值中减去该数的每个数字来计算的。例如,八进制数字系统中的最大数字值是 7,因此,八进制数的 7 的补数是通过从 7 中减去该数的每个数字来计算的。对于十六进制数字系统,最大数字值是 15,用 f 表示。例如,八进制数 56072 的 7 的补码是 21705,十六进制数 6A910F 的 15 的补码是 956EF0。

补码

设 N 是基数为 R 的数系中的一个数,N 是数 N 的总位数。数 N 的基数补码或 R 的补码定义如下:

Rn - N

对于 N = 0,R 的补码定义为零。从 R 和(R-1)补数的定义中可以明显看出,一个数的 R 补数是通过将该数的(R-1)补数加 1 来计算的。因此,十进制数的十进制补码是通过在它的九进制补码上加 1 得到的,二进制数的二进制补码是通过在它的一进制补码上加 1 得到的。比如 10110 的 2 的补码是 01001 + 1,也就是 01010。通过仔细观察计算二进制数的二进制补码的过程,您会发现只需查看二进制数就可以计算出二进制数。计算二进制数的二进制补码的简单过程可以描述如下:

  1. 从二进制数的右端开始。

  2. 写下所有数字,直到第一个 1 位保持不变。

  3. 随后反转这些位以获得二进制数的二进制补码。

例如,让我们计算 10011000 的二进制补码。从右端开始,写下所有不变的数字,直到第一个 1 位。由于右数第四位是 1,你就把前四位不变的写出来,就是 1000。现在,将从右数第五位开始的位反转,得到 01101000。该程序如图 4-8 所示。

img/323069_3_En_4_Fig8_HTML.png

图 4-8

计算二进制数的二进制补码

所有负整数(byteshortintlong)都作为它们的二进制补码存储在内存中。让我们考虑 Java 中的两个byte变量:

byte bPos = 13;
byte bNeg = -13;

bPos在内存中存储为 00001101。00001101 的二进制补码计算为 11110011。因此,bNeg为–13,存储为 11110011,如图 4-9 所示。

img/323069_3_En_4_Fig9_HTML.png

图 4-9

以二进制补码形式存储数字 13

浮点数的二进制表示

二进制浮点系统只能以精确的形式表示有限数量的浮点值。所有其他值必须用最接近的可表示值来近似。IEEE 754-1985 是计算机行业最广泛接受的浮点标准,它规定了表示二进制浮点数的格式和方法。为工程计算而设计的 IEEE 标准的目标是最大化精度(尽可能接近实际数字)。精度是指您可以表示的位数。IEEE 标准试图平衡专用于指数的位数和用于小数部分的位数,以将准确度和精度保持在可接受的范围内。本节描述了通用的二进制浮点格式的 IEEE 754-1985 标准,并指出 Java 如何支持该标准。浮点数有四个部分:

  • 符号

  • 有效数字(也称为尾数)

  • 基数(也称为基数)

  • 指数

浮点数 19.25 可以用它的四个部分表示为

+19.25 x 100

这里,符号是+(正),有效数是 19.25,基数是 10,指数是 0。

数字 19.25 也可以用许多其他形式表示,如下所示。我们通过将+19.25 写成 19.25 来省略数字的正号:

  • 19.25 x 100

  • 1.925 x 101

  • 0.1925 x 102

  • 192.5 x 10-1

  • 1925 x 10-2

浮点数可以用无数种方式表示。如果以 10 为基数表示的浮点数的有效位满足以下规则,则称其为规格化形式:

0.1 <= significand < 1

根据这个规则,0.1925 x 10 2 的表示就是 19.25 的规格化形式。浮点数 19.25(基数 10)可以写成二进制形式的 10011.01(基数 2)。浮点数 19.25 可以用许多不同的二进制形式重写。十进制 19.25 的一些替代二进制表示如下:

  • 10011.01 x 20

  • 1001.101 x 21

  • 100.1101 x 22

  • 1.001101 x 24

  • 100110.1 x 2-1

  • 1001101 x 2-2

注意,在二进制形式中,基数是 2。当二进制小数点向左移动一位时,指数就增加一。当二进制小数点向右移动一位时,指数就减一。如果二进制形式的浮点数的有效位满足以下条件,则该浮点数被规格化:

1 <= significand < 2

如果二进制浮点数的有效位的形式是1.bbbbbbb...,其中b是一个位(0 或 1),则该二进制浮点数被称为是规格化形式。因此,1.001101 x 2 4 是二进制浮点数 10011.01 的规格化形式。换句话说,一个规范化的二进制浮点数从位 1 开始,紧接着是一个二进制点。

没有规格化的浮点数称为反规格化浮点数。反规格化的浮点数也称为反规格化或次规格化。所有的浮点数都不能用规范化的形式表示。这可能有两个原因:

  • 该数字不包含任何 1 位。0.0 就是一个例子。由于 0.0 没有任何设置为 1 的位,因此它不能以规范化形式表示。

  • 计算机使用固定位数来存储二进制浮点数的符号、有效数字和指数。如果二进制浮点数的指数是计算机存储格式允许的最小指数,并且有效位小于 1,则这样的二进制浮点数不能被规格化。例如,假设-126是一个二进制浮点数能够以给定存储格式存储的最小指数值。如果二进制浮点数是0.01101 x 2 -126 ,则此数不能规格化。这个数的规范化形式将是1.101 x 2 -128 。然而,给定的存储格式允许最小指数为-126(在这个例子中,我假设数字为––126)。因此,指数-128 ( -128 < -126)不能以给定的存储格式存储,这就是为什么0.01101 x 2 -126 不能以规范化形式存储的原因。

为什么我们需要在将二进制浮点数存储到内存中之前对其进行规范化?以下是这样做的优点:

  • 规范化表示是唯一的。

  • 因为二进制浮点数中的二进制点可以放在数字中的任何位置,所以必须将二进制点的位置与数字一起存储。通过规范化数字,您总是将二进制小数点放在第一个 1 位之后,因此,您不需要存储二进制小数点的位置。这样可以节省存储额外信息的内存和时间。

  • 通过比较两个规范化二进制浮点数的符号、有效数字和指数,可以很容易地对它们进行比较。

  • 在规范化形式中,有效数位可以使用其所有存储位来存储有效数字(位)。例如,如果您只分配 5 位来存储有效位,对于数字 0.0010110 × 2 10 ,将只存储有效位的 0.00101 部分。但是,如果将这个数归一化为 1.0110 x 2 7 ,有效位可以完全存储在 5 位中。

  • 在规格化形式中,有效位总是从 1 位开始,存储有效位时可以省略。回读时,可以加前导 1 位。这个省略的位被称为“隐藏位”,它提供了一个额外的精度位。

IEEE 754-1985 标准将四种浮点格式定义如下:

  • 32 位单精度浮点格式

  • 64 位双精度浮点格式

  • 单扩展浮点格式

  • 双扩展浮点格式

Java 使用 IEEE 32 位单精度浮点格式来存储float数据类型的值。它使用 64 位双精度浮点格式来存储 double 数据类型的值。

我只讨论 IEEE 32 位单精度浮点格式。单精度浮点格式与其他格式的区别在于用于存储二进制浮点数的总位数,以及位数在符号、指数和有效位之间的分布。不同 IEEE 格式之间的差异显示在讨论的结尾。

32 位单精度浮点格式

32 位单精度浮点格式使用 32 位来存储二进制浮点数。二进制浮点数的形式如下:

Sign * Significand * 2Exponent

因为基数总是 2,所以这种格式不存储基数的值。32 位的分布如下:

  • 1 位用于存储标志

  • 8 位存储指数

  • 23 位存储有效位

单精度浮点格式的布局如表 4-8 所示。

表 4-8

IEEE 单精度格式布局

|

1 位符号

|

8 位指数

|

23 位含义

s Eeeeeeee fffffffffffffffffffffff

符号

IEEE 单精度浮点格式使用 1 位来存储数字的符号。0 符号位表示正数,1 符号位表示负数。

指数

指数取 8 位。指数可以是正数,也可以是负数。可以存储在 8 位中的指数值的范围是–127 到 128。必须有一种机制来表示指数的符号。请注意,表 4-8 所示布局中的 1 位符号字段存储的是浮点数的符号,而不是指数的符号。要存储指数的符号,可以使用符号-幅度方法,其中 1 位用于存储符号,其余 7 位存储指数的幅度。您还可以使用 2 的补码方法来存储负指数,就像存储整数一样。但是,IEEE 不使用这两种方法来存储指数。IEEE 使用指数的有偏表示来存储指数值。

什么是偏差和偏差指数?bias 是一个常量值,对于 IEEE 32 位单精度格式为 127。将偏差值存储到内存之前,会将其添加到指数中。这个新的指数,加上一个偏差,被称为有偏差的指数。有偏指数计算如下:

Biased Exponent = Exponent + Bias

比如 19.25 可以用规格化二进制浮点格式写成 1.001101 x 2 4 。这里,指数值是 4。然而,存储在存储器中的指数值将是有偏指数,其计算如下:

Biased Exponent = Exponent + Bias
                = 4 + 127 (Single-precision format)
                = 131

对于 1.001101 x 2 4 ,131 将被存储为指数。读回二进制浮点数的指数时,必须减去该格式的偏差值,才能得到实际的指数值。

为什么 IEEE 使用有偏指数?使用有偏指数的优点是,出于比较的目的,可以将正浮点数视为整数。

假设 E 是用于以给定浮点格式存储指数值的位数。该格式的偏差值可以计算如下:

Bias = 2(E - 1) - 1

对于单精度格式,指数范围从–127 到 128。因此,偏移指数的范围是从 0 到 255。两个极端指数值(–127 和 128 表示无偏,0 和 255 表示有偏)用于表示特殊的浮点数,如零、无穷大、NaN s 和非规格化数。指数范围–126 到 127(偏向 1 到 254)用于表示规范化的二进制浮点数。

重要的

IEEE 单精度浮点格式使用 23 位来存储有效位。用于存储有效位的位数称为浮点格式的精度。因此,您可能已经猜到以单精度格式存储的浮点数的精度是 23。然而,事实并非如此。但首先我们需要讨论有效位的存储格式,然后才能对这种格式的精度下结论。

浮点数的有效位在存储到内存中之前被规范化。规格化的有效数字总是采用1.fffffffffffffffffffffff的形式。这里,f表示有效数的小数部分的位 0 或 1。因为前导 1 位总是以有效数的规范化形式出现,所以不需要存储前导 1 位。因此,在存储规格化有效位时,可以使用所有 23 位来存储有效位的小数部分。事实上,不存储规格化有效位的前 1 位会增加一位精度。这样,您只用 23 位就可以表示 24 位数字(1 个前导位+ 23 个小数位)。因此,对于规格化的有效位,IEEE 单精度格式的浮点数的精度是 24:

Actual Significand: 1.fffffffffffffffffffffff (24 digits)
Stored Significand:   fffffffffffffffffffffff (23 digits)

如果您总是以规范化形式表示二进制浮点数的有效位,则在数字行上,零周围会有一个缺口。可以用 IEEE 单精度格式表示的最小幅度数可以计算如下:

  • 符号:可以是 0 或 1,表示正数或负数。对于这个例子,让我们假设符号位是 0 来表示一个正数。

  • 指数:最小指数值为–126。回想一下,指数值–127 和 128 保留用于表示特殊的浮点数。最小偏差指数将为–126+127 = 1。8 位有偏指数 1 的二进制表示是 00000001。

  • 有效位:规格化形式的有效位的最小值将由前导 1 位和所有设置为 0 的 23 个小数位组成,如 1.000000000000000000。

如果将规格化浮点数的二进制表示与指数和有效位的最小可能值相结合,计算机中存储的实际数字将如表 4-9 所示。

表 4-9

最小可能归一化数

|

符号

|

指数

|

重要的

0 00000001 00000000000000000000000
1-bit 8-bit 23-bit

十进制中最小浮点数的值为 1.0 x 2 -126 。所以 1.0 x 2 -126 是零之后第一个可表示的规格化数,在数轴上留下一个零左右的空隙。

如果使用 IEEE 单精度格式仅存储规范化的浮点数,则所有小于 1.0 x 2 -126 的数值必须四舍五入为零。当处理程序中的小数字时,这会导致严重的问题。为了存储小于 1.0 x 2 -126 的数字,必须对这些数字进行反规格化处理。

特殊浮点数

本节描述特殊的浮点数及其在 IEEE 单精度格式中的表示。

有符号零

IEEE 浮点格式允许两个零,+0.0(或 0.0)和–0.0。对于单精度格式,零由最小指数值–127 表示。零的有效数字是 0.0。由于符号位可以是 0 或 1,所以有两个零:+0.0 和–0.0。单精度格式的零的二进制表示如表 4-10 所示。

表 4-10

单精度格式的正负零的二进制表示形式

|

数字

|

符号

|

指数

|

重要的

0.0 0 00000000 00000000000000000000000
-0.0 1 00000000 00000000000000000000000

为了便于比较,+0.0 和–0.0 被认为是相等的。因此,表达式0.0 == -0.0总是返回true

如果认为两个零相等,为什么 IEEE 要定义两个零?零的符号用于确定包含乘法和除法的算术表达式的结果。3.0 * 0.0 的结果是正零(0.0),而 3.0 *(0.0)的结果是负零(–0.0)。对于一个值为无穷大的浮点数num,关系1/(1/num) = num只因为有两个带符号的零而成立。

符号不定式

IEEE 浮点格式允许两种无穷大:正无穷大和负无穷大。符号位代表无穷大的符号。单精度格式的最大指数值 128(有偏指数 255)和零有效位表示无穷大。最大偏置值 255 可以用 8 位表示,所有位都设置为 1,如 11111111。单精度格式的无限的二进制表示如表 4-11 所示。

表 4-11

单精度格式的正负无穷大的二进制表示

|

数字

|

符号

|

指数

|

重要的

+Infinity 0 11111111 00000000000000000000000
-Infinity 1 11111111 00000000000000000000000

圆盘烤饼

NaN代表“非数字”NaN是用于没有意义结果的算术运算的数值,如零除以零、负数的平方根、将-无穷大加到+无穷大等。

NaN由最大指数值(单精度格式为 128)和非零有效位表示。NaN的符号位不被解释。当NaN是算术表达式中的一个操作数时会发生什么?比如NaN + 100的结果是什么?涉及NaN s 的算术表达式的执行应该停止还是继续?有两种类型的NaN:

  • 安静NaN

  • 信号NaN

当作为算术表达式中的操作数遇到一个安静的NaN时,安静地(即,不引发任何陷阱或异常)产生另一个安静的NaN作为结果。在安静的NaN的情况下,表情NaN + 100会导致另一个安静的NaN。有效位中的最高有效位被设置为 1,用于静默NaN。表 4-12 显示了一个安静NaN的二进制表示。在表中,sb表示 0 或 1 位。

表 4-12

安静 NaN 的二进制表示

|

数字

|

符号

|

指数

|

重要的

Quiet NaN s 11111111 1bbbbbbbbbbbbbbbbbbbbbb

当在算术表达式中遇到作为操作数的信令NaN时,会发出无效操作异常的信号,并作为结果传递一个安静的NaN。信号NaN通常用于初始化程序中未初始化的变量,因此当变量在使用前未初始化时,可以发出错误信号。对于信令NaN,有效位的最高有效位被设置为 0。表 4-13 显示了信令NaN的二进制表示。在表中,sb表示 0 或 1 位。

表 4-13

信令 NaN 的二进制表示

|

数字

|

符号

|

指数

|

重要的

Signaling NaN s 11111111 0bbbbbbbbbbbbbbbbbbbbbb

Tip

IEEE 为单精度格式定义了 2 个24–2 个不同的NaN,为双精度格式定义了 2 个53–2 个不同的NaN。然而,Java 对于float数据类型只有一个NaN,对于double数据类型只有一个NaN。Java 总是使用安静的NaN

非正常

当有偏指数为 0 且有效数位不为零时,它表示一个非规格化的数。表 4-14 显示了以单精度格式表示非规格化数的位模式。

表 4-14

非规范化单精度浮点数的位模式

|

符号

|

指数

|

重要的

S 000000000 fffffffffffffffffffffff

在表 4-14 中,s表示一个符号位,正数可以是 0,负数可以是 1。指数位都是零。有效数位中的至少一个位表示为 1。反规格化数的十进制值计算如下:

(-1)s * 0.fffffffffffffffffffffff * 2-126

假设您想以单精度格式存储一个数字 0.25 x 2 -128 。如果把 0.25 转换成二进制后用规格化形式写出这个数,就是 1.0×2-130。但是,单精度格式允许的最小指数是–126。因此,这个数字不能以单精度格式的规范化形式存储。指数保持为–126,二进制小数点左移,导致非规格化形式为 0.0001 x 2 -126 。数字被存储,如表 4-15 所示。

表 4-15

非规格化数 1.0 * 2 -130 的位模式

|

数字

|

符号

|

指数

|

重要的

0.0001 x 2-126 0 00000000 00010000000000000000000

似乎对于数字 0.0001 x 2 -126 ,有偏指数应计算为–126+127 = 1,指数位应为 00000001。然而,事实并非如此。对于反规格化的数,指数存储为全 0 位;读回来的时候解释为–126。这是因为回读浮点数时需要区分规格化和反规格化的数,对于所有反规格化的数,它们的有效位都没有前导 1 位。非规格化的数字填补了数字行上零周围的空白,如果只存储规格化的数字,这个空白就会出现。

舍入模式

不是所有的实数都可以用有限位数的二进制浮点格式精确表示。因此,不能用二进制浮点格式精确表示的实数必须四舍五入。有四种舍入模式:

  • 向零舍入

  • 向正无穷大舍入

  • 向负无穷大舍入

  • 向最近的方向舍入

向零舍入

这种舍入模式也称为截断或斩波模式。在这种舍入模式下,从原始数字中保留的总位数(或数字)与以给定格式存储浮点数的可用位数相同。其余位被忽略。这种舍入模式称为“向零舍入”,因为它具有使舍入结果更接近零的效果。表 4-16 中显示了一些向零舍入的示例。

表 4-16

向零舍入的示例

|

原始号码

|

可用的二进制点数

|

四舍五入的数字

1.1101 2 1.11
-0.1011 2 -0.10
0.1010 2 0.10
0.0011 2 0.00

向正无穷大舍入

在这种舍入模式下,数字被舍入到更接近正无穷大的值。表 4-17 中显示了向正无穷大舍入的一些示例。

表 4-17

向正无穷大舍入的示例

|

原始号码

|

可用的二进制点数

|

四舍五入的数字

1.1101 2 10.00
-0.1011 2 -0.10
0.1010 2 0.11
0.0011 2 0.01

向负无穷大舍入

在这种舍入模式下,数字被舍入到更接近负无穷大的值。表 4-18 中显示了一些向负无穷大舍入的例子。

表 4-18

向负无穷大舍入的示例

|

原始号码

|

可用的二进制点数

|

四舍五入的数字

1.1101 2 1.11
-0.1011 2 -0.11
0.1010 2 0.10
0.0011 2 0.00

向最近的方向舍入

在这种舍入模式下,舍入的结果是最接近的可表示的浮点数。如果出现平局,也就是说,如果有两个可表示的浮点数同样接近原始数,则结果是最低有效位为零的那个。换句话说,如果出现平局,四舍五入后的结果是偶数。实现 IEEE 浮点标准的系统将此模式作为默认舍入模式。IEEE 标准规定,系统还应允许用户选择其他三种舍入模式之一。Java 使用这种模式作为浮点数的默认舍入模式。Java 不允许用户(即程序员)选择任何其他舍入模式。表 4-19 中显示了向最近值舍入的一些示例。

表 4-19

向最近值舍入的示例

|

原始号码

|

可用的二进制点数

|

四舍五入的数字

1.1101 2 1.11
-0.1011 2 -0.11
0.1010 2 0.10
0.0011 2 0.01

IEEE 浮点异常

IEEE 浮点标准定义了当浮点运算的结果不可接受时发生的几种异常。可以忽略异常,在这种情况下,会采取一些默认操作,比如返回一个特殊值。当为异常启用捕获时,每当该异常发生时都会发出错误信号。浮点运算会导致以下五种浮点异常:

  • 被零除异常

  • 无效操作异常

  • 溢出异常

  • 下溢异常

  • 不准确的例外

被零除异常

当一个非零数字被一个浮点零除时,就会发生被零除异常。如果没有安装陷阱处理程序,则结果为相应符号的无穷大。

无效操作异常

当操作数对于正在执行的操作无效时,会出现无效操作异常。如果没有安装陷阱处理器,则结果是发送一个安静的NaN。以下是引发无效异常的一些操作:

  • 负数的平方根

  • 零除以零或无穷大除以无穷大

  • 零和无穷大的乘法

  • 对信号NaN的任何操作

  • 从无穷大中减去无穷大

  • 当一个安静的NaN><关系运算符比较时

溢出异常

当浮点运算的结果在数量上太大而不适合预期的目标格式时,会发生溢出异常。例如,当您将Float.MAX_VALUE乘以 2 并尝试将结果存储在float变量中时,就会出现这种情况。如果没有安装陷阱处理器,要传递的结果取决于舍入模式和中间结果的符号:

  • 如果舍入模式是向零舍入,则溢出的结果是可以用该格式表示的最大有限数。结果的符号与中间结果的符号相同。

  • 如果舍入模式是向正无穷大舍入,则负溢出导致该格式的最负有限数,正溢出导致该格式的最正有限数。

  • 如果舍入模式是向负无穷大舍入,则负溢出导致负无穷大,正溢出导致该格式的最大正有限数。

  • 如果舍入模式是向最接近的方向舍入,溢出将导致无穷大。结果的符号与中间结果的符号相同。

但是,如果安装了陷阱处理程序,则在溢出情况下传递给陷阱处理程序的结果将按如下方式确定:无限精确的结果除以 2 t 并在传递给陷阱处理程序之前四舍五入。t 的值对于单精度格式是 192,对于双精度格式是 1536,对于扩展格式是 3 x 2 n-1 ,其中 n 是用于表示指数的位数。

下溢异常

当操作的结果太小而不能以其格式表示为规范化的float时,发生下溢异常。如果启用了捕获,则会发出浮点下溢异常信号。否则,该操作导致反规格化的float或零。下溢可以是突然的,也可以是逐渐的。如果运算的结果小于可以用格式中的规范化形式表示的最小值,则结果可以作为零或非规范化数传递。在突然下溢的情况下,结果为零。在逐渐下溢的情况下,结果是一个反规格化的数。IEEE 默认为逐渐下溢(非规格化数字)。Java 支持逐渐下溢。

不准确的例外

如果运算的舍入结果与无限精确的结果不同,则会发出不精确异常。不精确的异常很常见。1.0/3.0 是一个不精确的运算。当操作在没有溢出陷阱的情况下溢出时,也会发生不精确的异常。

Java 和 IEEE 浮点标准

Java 遵循 IEEE 754 标准的一个子集。以下是 IEEE 浮点标准及其 Java 实现之间的一些差异:

  • Java 不会发出 IEEE 异常信号。

  • Java 没有信令NaN

  • Java 使用向最近模式舍入来舍入不精确的结果。但是,Java 在将浮点值转换为整数时会四舍五入为零。Java 没有为浮点计算提供用户可选的舍入模式:向上、向下或向零舍入。

  • IEEE 为单精度格式定义了(224–2】)NaNs,为双精度格式定义了(253–2)NaNs。然而,Java 只为这两种格式定义了一个NaN

表 4-20 列出了不同 IEEE 格式的参数。

表 4-20

IEEE 格式的参数

|   |

宽度英寸

|

指数

以位为单位的宽度

|

精确

|

最高的

指数

|

最低限度

指数

|

指数偏差

单精度 32 8 24 127 -126 127
双精度 64 11 53 1023 -1022 1023
单扩展 >= 43 >= 11 >= 32 >= 1023 <= -1022 Unspecified
双扩展的 >= 79 >= 15 >= 64 >= 16383 <= -16382 Unspecified

小端和大端

这两个术语与 CPU 架构中一个字的字节方向有关。计算机内存由正整数地址引用。在计算机内存中,以最低有效字节在最高有效字节之前的方式存储数字是“自然”的。有时计算机设计者更喜欢使用这种表示的逆序版本。在内存中,较低有效字节在较高有效字节之前的“自然”顺序称为小端顺序。许多供应商,如 IBM、Cray 和 Sun,更喜欢相反的顺序,当然,这被称为大端顺序。例如,32 位十六进制值 0x45679812 将存储在内存中,如下所示:

Address         00  01  02  03
-------------------------------
Little-endian   12  98  67  45
Big-endian      45  67  98  12

在两台机器之间传输数据时,字节顺序的差异可能是一个问题。表 4-21 列出了一些供应商,他们的float类型,以及他们机器上的字节序。

表 4-21

供应商、浮点类型和字节序

|

小贩

|

浮动型

|

字节次序

希腊字母的第一个字母 DEC/IEEE 小端的
国际商用机器公司 国际商用机器公司 大端的
苹果个人计算机 电气电子工程师学会 大端的
太阳 电气电子工程师学会 大端的
虚拟地址扩展器(Virtual Address Extender) 数位计算设备公司(Digital Equipment Corporation) 小端的
个人电脑 电气电子工程师学会 小端的

Java 二进制格式文件中的所有内容都是以大端顺序存储的。这有时被称为网络秩序。这意味着如果你只使用 Java,所有的文件在所有的平台上都是一样的:Mac,PC,UNIX 等等。您可以自由地以电子方式交换二进制数据,而不用担心字节顺序。当您必须与一些不是用 Java 编写的程序交换数据文件时,问题就来了,这些程序使用小端顺序,最常见的是用 c 编写的程序。一些平台在内部使用大端顺序(Mac,IBM 390);有些使用小端顺序(英特尔)。Java 对你隐藏了内部字节序。

摘要

程序中所有需要引用的东西,比如值和实体,都有一个名字。Java 中的名字叫做标识符。Java 中的标识符是一个无限长的字符序列。字符序列包括所有 Java 字母和 Java 数字,其中第一个必须是 Java 字母。Java 使用 Unicode 字符集。Java 字母是由 Unicode 字符集表示的任何语言的字母。例如,A–Z、A–Z、_(下划线)和$被视为 Unicode 的 ASCII 字符集范围内的 Java 字母。Java 数字包括 0–9 个 ASCII 数字和任何表示语言中数字的 Unicode 字符。标识符中不允许有空格。

关键字是在 Java 编程语言中具有预定义含义的单词。Java 编程语言定义了几个关键字。从 Java SE 9 开始,下划线(_)是一个关键字。Java 中关键字的几个例子是classifdowhileintlongfor。关键字不能用作标识符。保留关键字是已经保留以备将来使用的关键字,例如gotoconst。受限关键字是在特定地方使用时具有特殊含义的关键字,在其他地方不被视为关键字。受限关键字的示例有moduleexportsopenopensrequires等。从 Java 10 开始,“var”不是一个关键字,但可以用作局部变量的推断类型。

Java 中的每个值都有一个数据类型。Java 支持两种数据类型:原始数据类型和引用数据类型。原始数据类型代表原子的、不可分割的值。Java 有八种原始数据类型:byteshortintlongfloatdoublecharboolean。原始数据类型的文字是可以在源代码中直接表示的常量。引用数据类型表示内存中对象的引用。Java 是一种静态类型的编程语言。也就是说,它在编译时检查所有值的数据类型。

byte数据类型是 8 位有符号 Java 原始整数数据类型。其范围是–128 到 127(–27到 27–1)。这是 Java 中最小的整数数据类型。

short数据类型是 16 位有符号 Java 原始整数数据类型。其范围是–32768 到 32767(或–215到 215–1)。

int数据类型是 32 位有符号 Java 原语数据类型。int数据类型的变量占用 32 位内存。其有效范围为–2147483648 至 2147483647(–231至 231–1)。这个范围内的所有整数都称为整数文字(或整数常量)。例如,10、–200、0、30、19 等。是int类型的整数。

long数据类型是 64 位有符号 Java 原语数据类型。当整数的计算结果可能超出int数据类型的范围时使用。其范围为–9223372036854775808 到 9223372036854775807(–263到 263–1)。在long范围内的所有整数称为long类型的整数文字。long类型的整数文字必须以Ll结尾,例如 10L 和 897L。

char数据类型是 16 位无符号 Java 原始数据类型。它的值表示一个 Unicode 字符。char数据类型的范围是 0–65535,与 Unicode 字符集的范围相同。字符文字表示char数据类型的值,它可以用四种格式表示:用单引号括起来的字符、用单引号括起来的转义序列、Unicode 转义序列和八进制转义序列。'A''X''8'char文字的例子。

float数据类型使用 32 位来存储 IEEE 754 标准格式的浮点数。

根据 IEEE 754 标准,用 32 位表示的浮点数也称为单精度浮点数。它可以表示小到 1.4 x 10 -45 大到 3.4 x 1038(近似值)的实数。)在数量上。该范围仅包括大小。它可能是积极的,也可能是消极的。这里,1.4 x 10 -45 是可以存储在float变量中的大于零的最小正数。

float文字必须以 F 或 F 结尾。2.0f、56F 和 0.78F 是float文字的几个例子。

double数据类型使用 64 位来存储 IEEE 754 标准格式的浮点数。根据 IEEE 754 标准,用 64 位表示的浮点数也称为双精度浮点数。它可以表示小到 4.9 x 10 -324 大到 1.7 x 10 308 的数字(大约。)在数量上。该范围仅包括震级。它可能是积极的,也可能是消极的。所有实数称为double文字。一个double字面值可以选择以dD结尾,例如 19.27d。也就是说,19.27 和 19.27d 表示同一个double字面值。

boolean数据类型只有两个有效值:truefalse。这两个值被称为boolean文字。

为了使表示为数字文字的大数值更具可读性,Java 允许在数字文字的两位数字之间使用任意数量的下划线。例如,一个int文字1969可以写成1_96919_69196_91___969,或者任何其他在两位数字之间使用下划线的形式。在八进制、十六进制和二进制格式中也允许使用下划线。

EXERCISES

  1. Java 中的标识符是什么?标识符可以由什么组成?列出 Java 中五个有效和五个无效的标识符。

  2. Java 中的关键字、保留关键字、限制关键字是什么?下划线在 Java 里是关键字吗?

  3. 什么是数据类型?原始数据类型和引用数据类型有什么区别?

  4. 列出 Java 编程语言支持的所有八种基本数据类型的名称。以字节为单位列出它们的大小。

  5. 什么是文字?列出 Java 中每个基本类型的两个字面值。

  6. Java 中最短的数值原语类型是什么?它的取值范围是多少?

  7. Consider the following two variable declarations:

    byte small = 10;
    int big = 99;
    
    

    如何将big变量中的值赋给small变量?

  8. 当你把一个更大的变量赋给一个更小的变量时,比如把一个int变量赋给一个byte变量,为什么需要使用强制转换?

  9. 说出 Java 中两种值可以是浮点数的基本数据类型。

  10. 如果你声明一个boolean类型的变量,它可能有哪两个值?

  11. Can you cast a boolean value to an int type, as shown in the following statement?

```java
boolean done = true;
int x = (int) done;

```

当你编译这段代码时会发生什么?
  1. boolean字面值truefalse与整数 1 和 0 一样吗?

  2. 用 Java 命名一个无符号数字数据类型。

  3. 说出编写char数据类型文字的四种不同格式。各举两个例子。

  4. 如何在 Java 中将一个反斜杠(\)和一个双引号(")表示为char文字?编写代码来声明两个名为c1c2char变量。给c1分配一个反斜杠字符,给c2分配一个双引号字符。

  5. 二进制数的 1 和 2 的补码是什么?计算二进制数 10111011 的 1 和 2 的补码。

  6. 为什么 Java 程序中的下面一行注释不能编译?\ u000A是换行符的 Unicode 代码值:

  7. floatdouble数据类型支持多少个零?

  8. 什么是NaN?Java 中的floatdouble类型支持多少个NaN?区分安静的NaN和发信号的NaN。Java 支持哪些类型的NaN——安静的NaN、信令的NaN,还是两者都支持?

  9. 什么是反规格化或反规格化浮点数?

  10. 浮点数有哪些不同的舍入方式?Java 支持哪些舍入方式?

  11. 什么是小端序和大端序?Java 在类文件中编码多字节二进制数据时使用什么样的端序?

char c = '\u000A';

五、运算符

在本章中,您将学习:

  • 什么是运算符

  • Java 中可用的不同类型的运算符

  • 运算符优先级,用于确定在同一个表达式中使用多个运算符时,运算符的计算顺序

我们在这一章中使用了很多代码片段。评估这些代码片段并查看结果的最快方法是使用 JShell 工具。关于如何在命令提示符下启动 JShell 工具,请参考第二章。

本章中的所有类都是名为jdojo.operator的模块的成员,其声明如清单 5-1 所示。

// module-info.java
module jdojo.operator {
    // No module statements
}

Listing 5-1Declaration of a Module Named jdojo.operator

什么是运营商?

一个操作符是一个符号,它对一个、两个或三个操作数执行特定种类的操作,并产生一个结果。运算符及其操作数的类型决定了所执行的运算的种类以及所产生的结果的类型。Java 中的运算符可以根据两个标准进行分类:

  • 它们所操作的操作数的数量

  • 它们对操作数执行的运算类型

根据操作数的数量,有三种类型的运算符:

  • 一元运算符

  • 二元运算符

  • 三元算子

如果一个操作符有一个操作数,这叫做一元操作符;如果它有两个操作数,就叫做二元运算符;如果它有三个操作数,就叫做三元运算符。

一元运算符可以使用后缀或前缀符号。在后缀表示法中,运算符出现在其操作数之后:

<operand> <postfix-unary-operator>

以下是使用后缀一元运算符的示例:

// num is an operand and ++ is a Java unary operator
num++

在前缀表示法中,一元运算符出现在其操作数之前:

<prefix-unary-operator> <operand>

以下是使用前缀一元运算符的示例。请注意,Java 中的++运算符既可以用作前缀,也可以用作后缀运算符:

// ++ is a Java unary operator and num is an operand
++num

二元运算符使用中缀符号。运算符出现在两个操作数之间。使用中缀二元运算符的语法如下:

<first-operand> <infix-binary-operator> <second-operand>

下面是一个使用+作为中缀二元运算符的例子:

// 10 is the first operand, + is a binary operator, and 15 is the second operand
10 + 15

像二元运算符一样,三元运算符也使用中缀符号。使用中缀三元运算符的语法如下:

<first-operand> <operator1> <second-operand> <operator2> <third-operand>

这里,<operator1><operator2>都是三元运算符。

以下是在 Java 中使用三元运算符的示例:

// isSunday is the first operand, ? is the first part of ternary operator,
// holiday is the second operand,: is the second part of ternary operator, noHoliday is the
// third operand
isSunday ? holiday : noHoliday

根据操作员执行的操作类型,您可以将他们分为以下几类:

  • 算术运算符

  • 关系运算符

  • 逻辑运算符

  • 按位运算符

Java 有一个很大的操作符列表。本章讨论了大多数 Java 操作符。在后面的章节中,当上下文合适时,我们会讨论其中的一些。在随后的章节中享受学习 Java 操作符的漫长旅程吧!

赋值运算符

赋值运算符(=)用于给变量赋值。它是一个二元运算符。它需要两个操作数。右操作数的值赋给左操作数。左侧操作数必须是变量,例如:

int num;
num = 25;

这里,num = 25使用赋值运算符=。在这个例子中,25是右边的操作数,num是左边的操作数,是一个变量。

Java 确保赋值运算符右侧操作数的值与左侧操作数的数据类型赋值兼容;否则,会发生编译时错误。在引用变量的情况下,如果右边操作数表示的对象与指定为左边操作数的引用变量的赋值不兼容,您可能能够编译源代码并得到运行时错误。例如,byteshortchar类型的值与int数据类型的赋值是兼容的,因此下面的代码片段是有效的:

byte b = 5;
char c = 'a';
short s = -200;
int i = 10;
i = b; // OK. byte b is assignment compatible to int i
i = c; // OK. char c is assignment compatible to int i
i = s; // OK. short s is assignment compatible to int i

然而,long -to- intfloat -to- int赋值是不兼容的,因此下面的代码片段会生成编译时错误:

long big = 524L;
float f = 1.19F;
int i = 15;
i = big; // A compile-time error. long to int, assignment incompatible
i = f;   // A compile-time error. float to int, assignment incompatible

如果右侧操作数的值与左侧变量的数据类型不兼容,则右侧操作数的值必须转换为适当的类型。前面这段使用赋值运算符的代码可以通过如下强制转换来重写:

i = (int)big; // OK
i = (int)f;   // OK

一个表达式是一系列的变量、操作符和方法调用,根据 Java 编程语言的语法构造,计算为单个值。比如,num = 25就是一个表达式。使用赋值运算符的表达式也有一个值。表达式的值等于右边操作数的值。考虑下面这段代码,假设num是一个int变量:

num = 25;

这里,num = 25称为表达式,num = 25;称为语句。num = 25这个表达有两个意思:

  • 将值 25 赋给变量num

  • 产生值 25,该值等于赋值运算符右侧操作数的值

在表达式中使用赋值操作符的第二个效果(产生一个值)此时可能看起来很奇怪。您可能想知道表达式num = 25产生的值 25 会发生什么变化。我们曾经使用过表达式返回的值吗?答案是肯定的。我们使用表达式返回的值。考虑下面的表达式,它使用链式赋值操作符,假设num1num2int变量。

num1 = num2 = 25;

执行这段代码时会发生什么?首先,执行表达式num2 = 25的一部分。

如前所述,该执行将有两个效果:

  • 它将赋值 25 给num2

  • 它将产生 25 的值。换句话说,你可以说在将值 25 赋给num2后,表达式num2 = 25被值 25 代替,这就把主表达式num1 = num2 = 25变成了num1 = 25

现在,表达式num1 = 25被执行,值 25 被赋给num1,产生的值25被忽略。这样,您可以在一个表达式中为多个变量分配相同的值。在这种链式赋值表达式中可以使用任意数量的变量。

例如:

num1 = num2 = num3 = num4 = num5 = num6 = 219;

假设有两个int变量num1num2。下面的赋值num1 = num2将存储在num2中的值200赋值给num1:

int num1 = 100; // num1 is 100
int num2 = 200; // num2 is 200
num1 = num2;    // num1 is 200\. num2 is 200
num2 = 500;     // num2 is 500\. num1 is still 200

当你说num1 = num2的时候,存储在num2中的值被复制到num1,而num1num2都维护着自己的同一个值200的副本。稍后,当执行num2 = 500时,只有num2的值变为500。但是num1的值保持不变,200。现在,假设有两个引用变量,ref1ref2,它们引用同一个类的两个不同的对象。假设我们写

ref1 = ref2;

表达式ref1 = ref2的作用是,引用变量ref1ref2现在引用内存中的同一个对象——被ref2引用的对象。在这个赋值之后,两个参考变量ref1ref2同样能够操纵对象。参考变量ref1对内存中对象的更改也将被ref2观察到,反之亦然。第七章详细介绍了参考变量赋值。

声明、初始化和赋值

在 Java 程序中使用任何类型的变量之前,必须对其进行声明并赋值。假设您想要使用一个名为num1int变量。首先,您必须声明如下:

// Declaration of a variable num1
int num1;

可以在声明变量之后或者在声明变量的时候给变量赋值。当一个值在声明后被赋值给一个变量时,这就是所谓的赋值。下面这段代码声明了一个int变量num2,并将50赋给它:

int num2;   // Declaration of a variable num2
num2 = 50;  // Assignment

当一个值在声明的时候被赋给一个变量,这就是所谓的初始化。

下面的代码声明了一个int变量num3,并将其初始化为一个值100:

// Declaration of variable num3 and its initialization
int num3 = 100;

通过用逗号分隔每个变量的名称,可以在一个声明中声明多个相同类型的变量:

// Declaration of three variables num1, num2 and num3 of type int
int num1, num2, num3;

您也可以在一个声明中声明多个变量,并初始化部分或全部变量:

// Declaration of variables num1, num2 and num3\. Initialization of only num1 and num3
int num1 = 10, num2, num3 = 200;
// Declaration and initialization of variables num1, num2 and num3
int num1 = 10, num2 = 20, num3 = 30;

Java 不会让你使用一个变量,除非它已经通过初始化或者赋值的过程被赋值了。Java 隐式初始化一些上下文中声明的变量。在其他上下文中声明的变量,如果 Java 没有隐式地初始化它们,那么在使用它们之前,必须进行初始化或者赋值。我们将在第七章中讨论 Java 对变量的隐式初始化。在声明变量时初始化变量是一种好的做法。

算术运算符

算术运算符是将数值作为其操作数并执行算术运算(例如,加法和减法)来计算另一个数值的运算符。表 5-1 列出了 Java 中所有的算术运算符。表 5-1 中列出的所有运算符只能用于数值型操作数。也就是说,算术运算符的两个操作数必须是类型byteshortcharintlongfloatdouble中的一种。这些运算符不能有boolean基元类型和引用类型的操作数。以下部分详细描述了算术运算符。

表 5-1

Java 中的算术运算符列表

|

运算符

|

描述

|

类型

|

使用

|

结果

+ 添加 二进制的 2 + 5 seven
- 减法 二进制的 5 - 2 three
+ 一元加号 一元的 +5 正五。同 5。
- 一元减操作 一元的 -5 负五。
* 增加 二进制的 5 * 3 Fifteen
/ 分开 二进制的 5 / 2 Two
6 / 2 three
5.0 / 2.0 Two point five
6.0 / 2.0 Three
% 系数 二进制的 5 % 3 Two
++ 增量 一元的 num++ 计算 num 的值。将 num 增加 1。
-- 减量 一元的 num-- 评估为num的值。将num减 1。
+= 算术复合赋值 二进制的 num += 5 num的值加 5,并将结果赋给num。如果num是 10,num的新值将是 15。
-= 算术复合赋值 二进制的 num -= 3 num的值中减去 3,并将结果分配给num。如果num是 10,num的新值将是 7。
*= 算术复合赋值 二进制的 num *= 15 将 15 乘以num的值,并将结果赋给num。如果num是 10,num的新值将是 150。
/= 算术复合赋值 二进制的 num /= 5 num的值除以 5,并将结果分配给num。如果num是 10,num的新值将是 2。
%= 算术复合赋值 二进制的 num %= 5 计算num除以 5 的余数,并将结果分配给num。如果num是 12,那么num的新值将是 2。

加法运算符(+)

加法运算符(+)是一种算术运算符,其使用形式如下:

operand1 + operand2

加法运算符用于将两个操作数表示的两个数值相加,例如,5 + 3 等于 8。操作数可以是任何数值、数值变量、数值表达式或方法调用。每个包含加法运算符(+)的表达式都有一个数据类型。表达式的数据类型根据四个规则之一确定:

  • 如果其中一个操作数是double,另一个操作数被转换为double,整个表达式的类型为double

  • 如果其中一个操作数是float,另一个操作数被转换为float,整个表达式的类型为 float。

  • 如果其中一个操作数为long,另一个操作数被转换为long,整个表达式的类型为long

  • 如果前面三个规则都不适用,所有操作数都被转换为int,前提是它们还不属于int类型,并且整个表达式属于int类型。

这些规则有一些重要的含义。考虑一个被赋值为5byte变量b1,如下面这段代码所示:

byte b1;
b1 = 5;

当您试图将相同的值5赋给byte变量b1时,您会得到一个编译时错误,如下面的代码片段所示:

byte b1;
byte b2 = 2;
byte b3 = 3;
b1 = b2 + b3; // A compile-time error. Trying to assign 5 to b1

为什么这段代码会导致编译时错误?表达式b1 = 5b1 = b2 + b3对变量b1赋值5的效果不一样吗?是的,效果是一样的。然而,控制赋值操作的规则在两种情况下是不同的。在表达式b1 = 5中,赋值由一条规则控制,即–128 和 127 之间的任何int文字都可以被赋给一个byte变量。因为 5 介于–128 和 127 之间,所以赋值b1 = 5有效。第二个赋值b1 = b2 + b3,由第四个规则决定算术表达式的数据类型,它使用加法运算符(+)。因为表达式b2 + b3中的两个操作数都是byte类型,所以操作数b2b3首先被转换为int,然后表达式b2 + b3变成了int类型。由于b1的数据类型为byte,小于表达式b2 + b3int数据类型,赋值b1 = b2 + b3,即intbyte不兼容,这就是它产生错误的原因。在这种情况下,您需要将右边表达式的结果转换为左边操作数的数据类型:

b1 = (byte)(b2 + b3); // OK now

初学者可以尝试编写如下代码语句:

b1 = (byte) b2 + b3; // A compile-time error again

两个表达式(byte)(b2 + b3)(byte)b2 + b3是不一样的。在表达式(byte) (b2 + b3)中,首先将b2b3提升为int,然后进行加法运算,得到int类型的值 5。然后,将int5转换为byte并分配给b1

在表达式(byte)b2 + b3中,首先将b2强制转换为byte。注意,这种转换是多余的,因为b2已经是byte的类型;b2b3都晋升为int;整个表达式(byte)b2 + b3属于类型int。由于不允许使用int -to- byte赋值,表达式将不会编译。

第二个表达式(byte)b2 + b3产生的错误提出了一个有趣的问题。为什么 Java 没有先在(byte)b2 + b3中计算出b2 + b3,然后对结果应用(byte)。因为有两个操作要做,一个是对byte的强制转换,另一个是对b2b3的加法,Java 首先对b2进行强制转换,然后对加法进行强制转换。决定先进行造型,再进行添加并不是随意的。Java 中的每个操作符都有一个优先顺序。具有较高优先级的运算符首先被计算,然后才是具有较低优先级的运算符。转换运算符的优先级高于加法运算符。这就是为什么在(byte)b2 + b3.(byte)b2首先被求值的原因。你总是可以使用括号来覆盖操作符的优先级。我们通过在表达式(byte)(b2 + b3).中使用括号来覆盖 cast 操作符的优先级。考虑另一个例子:

byte b1;
b1 = 3 + 2; // Will this line of code compile?

表达式b1 = 3 + 2会编译吗?如果我们应用第四条规则来确定这个表达式的数据类型,它应该不会编译,因为32int文字。表达式3 + 2属于类型int。因为intbyte的赋值不兼容,所以表达式b1 = 3 + 2应该会给出一个错误。但是,我们的假设是错误的,表达式b1 = 3 + 2会编译好。在这种情况下,分配过程如下。

操作数32是常量,所以它们的值在编译时是已知的。因此,编译器在编译时计算表达式3 + 2的结果,并用其结果5替换3 + 2。表达式b1 = 3 + 2被编译器替换为b1 = 5。现在,你可以看到为什么 Java 没有为这个表达式给出任何错误。因为int字面量5-128127, b1 = 5的范围内,所以根据将int字面量赋值给byte变量的规则,它是一个有效的赋值。然而,如果你试图写一个表达式为b1 = 127 + 10,它肯定不会被编译,因为127 + 10的结果,也就是137,超出了byte数据类型的范围。以下是关于操作数的数据类型转换和涉及加法运算符的表达式类型确定的总结:

var = operand1 + operand2;

如果operand1operand2是编译时常量,operand1 + operand2的结果决定了这种赋值是否有效。如果operand1 + operand2的结果在变量var的数据类型范围内,表达式将被编译。否则,将生成编译器错误。如果operand1operand2是一个变量(即operand1operand2的值在编译时无法确定),表达式的数据类型根据本节开始时讨论的四个规则之一确定。以下是加法运算符正确和不正确用法的示例。注释和代码一起表明使用是否正确:

byte b1 = 2;
byte b2 = 3;
short s1 = 100;
int i = 10;
int j = 12;
float f1 = 2.5F;
double d1 = 20.0;
// OK. 125 is in the range -128 and 127
b1 = 15 + 110;
// An error. Data type of i + 5 is int and int to byte assignment is not permitted
b1 = i + 5;
b1 = (byte)(i + 5); // OK
// An error. s1 is promoted to int and s1 + 2 is of the data type int.
// int to byte assignment is not permitted
b1 = s1 + 2;
// An error. b2 is promoted to float and f1 + b2 is of the data type float.
// float to byte assignment is not permitted
b1 = f1 + b2;
// An error. f1 is promoted to double and f1 + d1 is of the data type double
b1 = f1 + d1;
// OK. i is promoted to float and i + f1 is of the data type float
f1 = i + f1;
// An error. i is promoted to double and i + d1 is of data type double.
// double to float assignment is not permitted
f1 = i + d1;
f1 = (float)(i + d1); // OK
// An error. 2.0 and 3.0 are of the type double. The result of 2.0 + 3.2 is 5.2,
// which is also of the type double. double to float assignment is not permitted.
f1 = 2.0 + 3.2;
// OK. 2.0F and 3.2F are of the type float. The result of 2.0F + 3.2F,
// which is 5.2F is of the type float.
f1 = 2.0F + 3.2F;
// OK. j is promoted to float and f1 + j is of the data type float.
// float to double assignment is permitted.
d1 = f1 + j;

减法运算符(-)

减法运算符(-)是一种算术运算符,其使用形式如下:

operand1 - operand2

减法运算符用于计算两个数的差,例如,5 - 3产生2。我们讨论的关于操作数的数字数据转换和涉及加法运算符的表达式的数据类型的确定的所有规则也适用于涉及减法运算符的表达式。以下是使用减法运算符的几个示例:

byte b1 = 5;
int i = 100;
float f1 = 2.5F;
double d1 = 15.45;
// OK. 200 - 173 will be replaced by 27.
// b1 = 27 is OK because 27 is in the range -128 and 127
b1 = 200 - 173;
// An error.  i - 27 is of the type int. int to byte assignment is not allowed
b1 = i - 27;
b1 = (byte)(i - 27);  // OK. Assigns 73 to b1
d1 = f1 - i;          // OK. Assigns -97.5 to d1

乘法运算符(*)

乘法运算符(*)是一种算术运算符,其使用形式如下:

operand1 * operand2

乘法运算符(*)用于计算两个数的乘积,例如,7 * 3产生21。我们讨论的关于操作数的数字数据转换和涉及加法运算符的表达式的数据类型的确定的所有规则也适用于涉及乘法运算符的表达式。以下是使用乘法运算符的一些示例:

byte b1 = 5;
int i = 10;
float f1 = 2.5F;
double d1 = 15.45;
// OK. 20 * 6 will be replaced by 120
// b1 = 120 is OK because 120 is in the range -128 and 127
b1 = 20 * 6;
// An error. i * 12 is of the type int. int to byte assignment is not allowed
b1 = i * 12;
b1 = (byte)(i * 12); // OK. Assigns 120 to b1
// OK. i * b1 is of the type int. int to float assignment is allowed
f1 = i * b1;
// An error. d1 * i is of type double. double to float assignment is not allowed
f1 = d1 * i;
f1 = (float)(d1 * i); // OK. Assigns 154.5 to f1

除法运算符(/)

除法运算符(/)是一种算术运算符,其使用形式如下:

operand1 / operand2

除法运算符(/)用于计算两个数的商,例如,5.0/2.0产生2.5。我们讨论的关于操作数的数字数据转换和涉及加法运算符(+)的表达式的数据类型确定的所有规则也适用于涉及除法运算符的表达式。有两种类型的划分:

  • 整数除法

  • 浮点除法

如果除法运算符的两个操作数都是整数,即byteshortcharintlong,则执行通常的除法运算,结果向零截断以表示一个整数。比如你写一个表达式5/2,除法得出2.5;小数部分0.5被忽略;结果是2。以下示例说明了整数除法:

int num;
num = 5/2; // Assigns 2 to num
num = 5/3; // Assigns 1 to num
num = 5/4; // Assigns 1 to num
num = 5/5; // Assigns 1 to num
num = 5/6; // Assigns 0 to num
num = 5/7; // Assigns 0 to num

在所有这些例子中,赋给变量num的值是一个整数。结果在所有情况下都是整数,不是因为变量num的数据类型是int。结果是一个整数,因为除法运算符的两个操作数都是整数。因为两个操作数的数据类型都是int,所以整个表达式5/3的类型都是int。因为小数部分(如 0.5,0.034)不能存储在int中,所以小数部分被忽略,结果总是整数。使用 JShell 工具是试验这些操作符的好方法。下面的jshell会话向您展示了这些表达式的结果:

jshell> int num;
num ==> 0
jshell> num = 5/2;
num ==> 2
jshell> num = 5/3;
num ==> 1
jshell> num = 5/4;
num ==> 1
jshell> num = 5/5;
num ==> 1
jshell> num = 5/6;
num ==> 0
jshell> num = 5/7;
num ==> 0

如果除法运算符的一个或两个操作数都是floatdouble,则执行浮点除法,并且结果不会被截断。这里有几个例子:

float f1;
// 15.0F and 4.0F are of float type. So, the expression 15.0F/4.0F is of the type float.
// The result 3.75F is assigned to f1.
f1 = 15.0F/4.0F;
// 15 is of type int and 4.0F is of type float. The expression 15/4.0F is of type float.
// The result 3.75F is assigned to f1.
f1 = 15/4.0F;
// An error. 15.0 is of the type double and 4.0F is of the type float. The expression
// 15.0/4.0F is of type double. The result 3.75 is of the type double and cannot be
// assigned to f1 because double to float assignment is not allowed.
f1 = 15.0/4.0F;
// OK. 3.75F is assigned to f1
f1 = (float)(15.0/4.0F);
// 15 and 4 are of type int. The expression 15/4 is of type int. An integer division
// is performed. The result 3 is assigned to f1 because int to float assignment is allowed.
f1 = 15/4;

当你试图将一个数(整数或浮点数)除以零时会发生什么?将一个数除以零的结果取决于除法的类型。如果对数字执行整数除法,除以零会导致运行时错误。如果在 Java 程序中编写一个表达式3/0,它可以很好地编译,但是当这个表达式在运行时执行时,它会给出一个错误,例如:

int i = 2;
int j = 5;
int k = 0;
i = j/k;  // A runtime error. Divide by zero
i = 0/0;  // A runtime error. Divide by zero

如果除法运算符的任一操作数是浮点数,则执行浮点除法,并且该数除以零的结果不是错误。如果被除数(在 7/2 中,7 是被除数,2 是除数)在浮点除零运算中是一个非零数,则结果是正无穷大或负无穷大。如果被除数是浮点零(例如 0.0 或 0.0F),则结果是一个NaN,例如:

float f1 = 2.5F;
double d1 = 5.6;
f1 = 5.0F/0.0F;    // Float.POSITIVE_INFINITY is assigned to f1
f1 = -5.0F/0.0F;   // Float.NEGATIVE_INFINITY is assigned to f1
f1 = -5.0F/-0.0F;  // Float.POSITIVE_INFINITY is assigned to f1
f1 = 5.0F/-0.0F;   // Float.NEGATIVE_INFINITY is assigned to f1
d1 = 5.0/0.0;      // Double.POSITIVE_INFINITY is assigned to d1
d1 = -5.0/0.0;     // Double.NEGATIVE_INFINITY is assigned to d1
d1 = -5.0/-0.0;    // Double.POSITIVE_INFINITY is assigned to d1
d1 = 5.0/-0.0;     // Double.NEGATIVE_INFINITY is assigned to d1
// 5.0F is of the type float and 0 is of the type int. 5.0F/0 is of type float.
// Float.POSITIVE_INFINITY is assigned to f1
f1 = 5.0F/0;
// A compile-time error. 5.0F is of the type float and 0.0 is of the type double
// 5.0F/0.0 is of the type double. double to float assignment is not allowed.
f1 = 5.0F/0.0;
f1 = (float)(5.0F/0.0); // f1 is assigned Float.POSITIVE_INFINITY
f1 = 0.0F/0.0F;         // Assigns Float.NaN to f1
d1 = 0.0/0.0;           // Assigns Double.NaN to d1
d1 = -0.0/0.0;          // Assigns Double.NaN to d1

模数运算符(%)

模数运算符(%)是一个算术运算符,其使用形式如下:

operand1 % operand2

模数运算符(%)也称为余数运算符。模数运算符将左边的操作数除以右边的操作数,并返回除法的余数,例如,7%5的计算结果为2。所有关于操作数的数值数据转换和涉及加法运算符(+)的表达式的数据类型确定的规则也适用于涉及模数运算符的表达式。因为模数运算符的使用涉及除法运算,所以有一些特殊的规则来确定模数运算的结果。

如果模数运算符的两个操作数都是整数,则应用以下规则来计算结果。

规则 1

如果右边的操作数为零,则出现运行时错误,例如:

int num;
num = 15 % 0; // A runtime error

规则 2

如果右侧操作数不为零,结果的符号与左侧操作数的符号相同,例如:

int num;
num = 15 % 6;   // Assigns 3 to num
num = -15 % 6;  // Assigns -3 to num
num = 15 % -6;  // Assigns 3 to num
num = -15 % -6; // Assigns -3 to num because left-hand operand is -15, which is negative
num = 5 % 7;    // Assigns 5 to num
num = 0 % 7;    // Assigns 0 to num

如果模数运算符的任一操作数是浮点数,则应用以下规则来计算结果。

规则 1

即使右边的操作数是浮点零,该操作也不会导致错误。

规则二

如果任一操作数为NaN,则结果为NaN,例如:

float f1;
double d1;
f1 = Float.NaN % 10.5F;     // Assigns Float.NaN to f1
f1 = 20.0F % Float.NaN;     // Assigns Float.NaN to f1
f1 = Float.NaN % Float.NaN; // Assigns Float.NaN to f1
// A compile-time error. The expression is of the type double.
// double to float assignment is not allowed.
f1 = Float.NaN % Double.NaN;
d1 = Float.NaN % Double.NaN; // Assigns Double.NaN to d1

规则三

如果右边的操作数为零,则结果为NaN,例如:

float f1;
f1 = 15.0F % 0.0F; // Assigns Float.NaN to f1

规则四

如果左边的操作数是无穷大,结果是NaN,例如:

float f1;
f1 = Float.POSITIVE_INFINITY % 2.1F; // Assigns Float.NaN to f1

规则五

如果前面的规则都不适用,模数运算符将返回左操作数和右操作数相除的余数。结果的符号与左侧操作数的符号相同,例如:

float f1;
double d1;
f1 = 15.5F % 6.5F;                     // Assigns 2.5F to f1
d1 = 5.5 % 15.65;                      // Assigns 5.5 to d1
d1 = 0.0 % 3.78;                       // Assigns 0.0 to d1
d1 = 85.0 % Double.POSITIVE_INFINITY;  // Assigns 85.0 to d1
d1 = -85.0 % Double.POSITIVE_INFINITY; // Assigns -85.0 to d1
d1 = 85.0 % Double.NEGATIVE_INFINITY;  // Assigns 85.0 to d1
d1 = -85.0 % Double.NEGATIVE_INFINITY; // Assigns -85.0 to d1

一元加号运算符(+)

一元加号运算符(+)是一个算术运算符,其使用形式如下:

+operand

operand必须是原始数字类型。如果operand是一个byteshortchar,一元加运算符将其提升为一个int。否则,使用该运算符没有任何效果。例如,如果有一个值为5int变量num,那么+num仍然具有相同的值5。以下示例说明了该运算符的用法:

byte b1 = 10;
byte b2 = 5;
b1 = b2;  // OK. byte to byte assignment
// A compile-time error. b2 is of the type byte. But, use of the unary plus operator on
// b2 promoted its type to int. Therefore, +b2 is of the type int.
// int (+b2) to byte (b1) assignment is not allowed.
b1 = +b2;
b1 = (byte) +b2; // OK

一元减运算符(-)

一元减号运算符(-)是一种算术运算符,其使用形式如下:

-operand

一元减运算符对其操作数的值进行算术求反。operand必须是原始数字类型。如果operand是一个byteshortchar,则它被提升为一个int。下面的例子说明了它的用法:

byte b1 = 10;
byte b2 = 5;
b1 = b2;  // OK. byte to byte assignment
// A compile-time error. b2 is of the type byte. But, use of unary minus operator (-) on
// b2 promoted its type to int. Therefore, -b2 is of type int.
// int (-b2) to byte (b1) assignment is not allowed.
b1 = -b2;
b1 = (byte) -b2; // OK

复合算术赋值运算符

五个基本算术运算符(+-*/%)中的每一个都有相应的复合算术赋值运算符。用一个例子可以更好地解释这些操作符。假设你有两个变量,num1num2:

int num1 = 100;
byte num2 = 15;

如果您想将num1的值加到num2上,您应该编写如下代码:

num2 = (byte)(num2 + num1);

您需要将num2 + num1的结果转换为byte,因为表达式的数据类型是int。使用复合算术运算符(+=)可以重写相同的效果,如下所示:

num2 += num1; // Adds the value of num1 to num2

复合算术赋值运算符以下列形式使用:

operand1 op= operand2

这里,op是算术运算符+-*/中的一种,%. operand1operand2是原始的数值数据类型,其中operand1必须是变量。前面的表达式等效于下面的表达式:

operand1 = (Type of operand1) (operand1 op operand2)

例如,

int i = 100;
i += 5.5;     // Assigns 105 to i

相当于

i = (int)(i + 5.5); // Assigns 105 to i

使用复合算术赋值运算符有两个优点:

  • operand1只被评估一次。例如,在i += 5.5中,变量i只被评估一次,而在i = (int) (i + 5.5)中,变量i被评估两次。

  • 结果在赋值前自动转换为operand1的类型。强制转换可能会导致缩小转换或标识转换。在前面的示例中,强制转换是收缩转换。表达式i + 5.5的类型为double,该表达式的结果被转换为int。所以结果double 105.5被转换成int 105。如果你写一个类似于i += 5的表达式,等价的表达式将是i = (int)(i + 5)。因为表达式i + 5的类型已经是int,所以将结果再次转换为int是一个身份转换。

复合赋值运算符+=也可以应用于String变量。在这种情况下,operand1必须是String类型,operand2可以是包括boolean在内的任何类型。例如

String str1 = "Hello";
str1 = str1 + 100;    // Assigns "Hello100" to str1

可以改写为

str1 += 100; // Assigns "Hello100" to str1

Tip

只有+=运算符可以与String左操作数一起使用。

以下是使用复合赋值运算符的示例。在示例中,复合赋值运算符的每次使用都独立于其先前使用的效果。在所有情况下,都假定变量的值保持不变,即在声明变量时赋予它们的值:

int i = 110;
float f = 120.2F;
byte b = 5;
String str = "Hello";
boolean b1 = true;
i += 10;  // Assigns 120 to i
          // A compile-time error. boolean type cannot be used with +=
          // unless left-hand operand (here i) is a String variable
i += b1;
i -= 15;  // Assigns 95 to i. Assuming i was 110
i *= 2;   // Assigns 220 to i. Assuming i was 110
i /= 2;   // Assigns 55 to i. Assuming i was 110
i /= 0;   // Run-time error. Divide by zero error
f /= 0.0; // Assigns Float.POSITIVE_INFINITY to f
i %= 3;   // Assigns 2 to i. Assuming i is 110
str += " How are you"; // Assigns "Hello How are you" to str
str += f;  // Assigns "Hello120.2" to str. Assuming  str was "Hello"
b += f;    // Assigns 125 to b. Assuming b was 5, f was 120.2
str += b1; // Assigns "Hellotrue" to str. Assuming str was "Hello"

递增(++)和递减(-)运算符

增量运算符(++)用于数值数据类型的变量,以1递增变量值,而减量运算符(--用于以1递减变量值。在这一节中,我们只讨论增量运算符。同样的讨论也适用于递减运算符,唯一的区别是它将值递减1而不是递增1。假设有一个int变量i声明如下:

int i = 100;

要将i的值增加1,您可以使用以下三个表达式之一:

i = i + 1; // Assigns 101 to i
i += 1;    // Assigns 101 to i
i++;       // Assigns 101 to i

增量运算符++也可用于更复杂的表达式,如

int i = 100;
int j = 50;
j = i++ + 15;  // Assigns 115 to j and i becomes 101

表达式i++ + 15计算如下:

  • i的值进行求值,右边的表达式变成100 + 15

  • 存储器中i的值增加1。所以在这个阶段,变量i在内存中的值是101

  • 表达式100 + 15被评估,并且结果 115 被分配给j

有两种增量运算符:

  • 后缀增量运算符,例如i++

  • 前缀递增运算符,例如++i

++出现在其操作数之后时,称为后缀增量运算符。当++出现在其操作数之前时,称为前缀递增运算符。后缀和前缀增量运算符之间的唯一区别是它们使用操作数当前值和操作数值增量的顺序。后缀增量首先使用操作数的当前值,然后增加操作数的值,正如您在表达式j = i++ + 15中看到的。因为i++使用了一个后缀增量运算符,所以首先用i的当前值来计算表达式i++ + 15(例如100 + 15))的值。分配给j的值是115。然后,i的值增加了1.。如果使用前缀增量运算符重写该表达式,结果将会不同:

int i = 100;
int j = 50;
j = ++i + 15;   // Assigns 116 to j and i becomes 101

在这种情况下,表达式++i + 15的计算如下:

  • 因为++i使用了前缀递增运算符,首先,i的值在内存中由1递增。因此,i的值就是101

  • 表达式中使用了i的当前值,即101;这个表情变成了101 + 15

  • 表达式101 + 15被求值,结果116被赋值给j

注意,在对两个表达式i++ + 15++i + 15求值后,i的值是相同的,为101。然而,分配给j的值不同。如果你在一个简单的表达式中使用增量操作符++,如i++++i,你看不出使用后缀或前缀操作符有什么不同。

对于 Java 初学者来说,有一个难题。该难题包括增量运算符的使用,如下所示:

int i = 15;
i = i++; // What will be the value of i after this assignment?

猜猜i = i++执行后i的值会是多少?如果你的猜测是 16,那你就错了。以下是如何计算表达式的解释:

  • i++被评估。因为i++使用了后缀增量运算符,所以在表达式中使用了i的当前值。i的当前值为 15。这个表情变成了i = 15

  • 作为i++的第二个效果,i的值在内存中以1递增。此时i的值在内存中是 16。

  • 对表达式i = 15求值,并将值15赋给i。内存中变量i的值是 15,那就是最终值。事实上,变量i在前一步中观察到一个值 16,但是这一步用 15 覆盖了那个值。所以i = i++执行后变量i的最终值会是15,而不是 16。

在这个例子中,操作的顺序很重要。值得注意的是,在i++的情况下,一旦在表达式中使用了i的当前值,变量i的值就会增加。为了使这一点更清楚,考虑下面的例子:

int i = 10;
i = i++ + i;   // Assigns 21 to i
i = 10;
i = ++i + i++; // Assigns 22 to i

还有后缀和前缀递减运算符,例如i--, --i。以下是使用后缀和前缀减量运算符的几个示例:

int i = 15;
int j = 16;
i--;
--i;
i = 10;
i = i--;       // Assigns 10 to i
i = 10;
j = i-- + 10;  // Assigns 20 to j and 9 to i
i = 10;
j = --i + 10;  // Assigns 19 to j and 9 to i

关于递增和递减运算符的使用,有两点需要记住:

  • 递增和递减运算符的操作数必须是变量。例如,表达式5++是不正确的,因为++与常量一起使用。

  • 使用++--运算符的表达式的结果是一个值,而不是一个变量。例如,i++评估为一个值,所以我们不能使用i++作为赋值操作符的左边或者需要变量的地方。

字符串串联运算符(+)

+运算符在 Java 中被重载。如果一个操作符被用来执行一个以上的函数,就说它是重载的。到目前为止,您已经看到了它作为算术加法运算符将两个数相加的用法。它也可以用来连接两个字符串。两个字符串,例如,"abc""xyz",可以使用+操作符作为"abc" + "xyz"连接起来,产生一个新的字符串"abcxyz"。字符串串联的另一个示例如下:

String str1 = "Hello";
String str2 = " Alekhya";
String str3 = str1 + str2; // Assigns "Hello Alekhya" to str3

字符串串联运算符也可用于将一个基元和一个引用数据类型值串联成一个字符串。在这一节中,我们只讨论字符串和原始数据类型的连接。当+操作符的任一操作数是字符串时,它执行字符串连接。当+的两个操作数都是数字时,它执行数字加法。考虑以下代码片段:

int num = 26;
String str1 = "Alphabets";
String str2 = num + str1; // Assigns "26Alphabets" to str2

当执行表达式num + str1时,+操作符充当字符串连接操作符,因为它右边的操作数str1是一个String。在numstr1串接之前,num被它的字符串表示代替,也就是"26"。现在,表达式变成了"26" + str1,产生了"26Alphabets"。表 5-2 列出了原始数据类型值的字符串表示。

表 5-2

原始数据类型值的字符串表示形式

|

数据类型

|

价值

|

字符串表示

int, short, byte, long 1678 "1678"
0 "0"
char 'A' "A"
'\u0041'``(Unicode escape sequence) "A"
boolean True "true"
False "false"
float 2.5 "2.5"
0.0F "0.0"
-0.0F "-0.0"
Float.POSITIVE_INFINITY "Infinity"
Float.NEGATIVE_INFINITY "-Infinity"
Float.NaN "NaN"
double 89.12 "89.12"
0.0 "0.0"
-0.0 "-0.0"
Double.POSITIVE_INFINITY "Infinity"
Double.NEGATIVE_INFINITY "-Infinity"
Double.NaN "NaN"

如果一个String变量包含了null引用,那么连接操作符使用一个字符串"null"。以下示例说明了在字符串串联中使用基本数据类型值的字符串表示形式:

boolean b1 = true;
boolean b2 = false;
int num = 365;
double d = -0.0;
char c = 'A';
String str1;
String str2 = null;
str1 = b1 + " friends";    // Assigns "true friends" to str1
str1 = b2 + " identity";   // Assigns "false identity" to str1
// Assigns "null and void" to str1\. Because str2 is null, it is replaced
// by a string "null" by the string concatenation operator
str1 = str2 + " and void";
str1 = num + " days";      // Assigns "365 days" to str1
str1 = d + " zero";        // Assigns "-0.0 zero" to str1
str1 = Double.NaN + " is absurd"; // Assigns "NaN is absurd" to str1
str1 = c + " is a letter"; // Assigns "A is a letter" to str1
str1 = "This is " + b1;    // Assigns "This is true" to str1
// Assigns "Beyond Infinity" to str1
str1 = "Beyond " + Float.POSITIVE_INFINITY

确定使用多个+操作符和字符串的表达式的结果有时可能会令人困惑。下面这个表达式的结果会是什么?

 12 + 15 + " men"

结果会是"1215 men还是27 men"?找到正确答案的关键是找到哪个+是算术运算符,哪个+是字符串串联运算符。

如果两个操作数都是数字,+运算符执行加法。如果任一操作数是字符串,+操作符执行字符串连接。除非使用括号覆盖,否则表达式的执行从左到右进行。在表达式12 + 15 + " men"中,左起第一个+1215进行加法运算,得到27。之后,表达式减少到27 + " men"。现在,+操作符执行一个字符串连接,因为右边的操作数" men"是一个字符串,它产生了"27 men"。考虑下面这段代码:

int num1 = 12;
int num2 = 15;
String str1 = " men";
String str2;

我们想使用三个变量num1num2str1以及+操作符创建一个字符串"1215 men"。我们想把结果赋给str2。这是第一次尝试:

str2 = num1 + num2 + str1;

这条语句将把"27 men"赋值给str2。另一个解决方案是将num2 + str1放在括号中:

str2 = num1 + (num2 + str1); // Assigns "1215 men" to str2

首先计算括号中的表达式。首先对表达式(num2 + str1)求值,将表达式简化为num1 + "15 men",然后表达式求值为"1215 men"。另一种方法是在表达式的开头放置一个空字符串:

str2 = "" + num1 + num2 + str1; // Assigns "1215 men" to str1

在这种情况下,首先对"" + num1求值,得到"12",从而将表达式简化为"12" + num2 + str1。现在,"12" + num2被求值,结果是"1215"。现在,表达式被简化为"1215" + " men",结果是一个字符串"1215 men"。您也可以在表达式中的num1num2之间放置一个空字符串,以获得相同的结果:

str2 = num1 + "" + num2 + str1; // Assigns "1215 men" to str2

有时候,字符串连接比你想象的要复杂。考虑下面这段代码:

boolean b = false;
int num = 15;
String str1 = "faces";
String str2 = b + num + str1; // A compile-time error

最后一条语句生成一个编译时错误。这种说法有什么问题?您期望将字符串“false15faces"分配给str2。不是吗?让我们来分析一下b + num + str1这个表达。左起第一个+运算符是算术运算符还是字符串串联运算符?对于一个要成为字符串连接操作符的+操作符来说,它必须至少有一个操作数是字符串。因为bnum都不是字符串,所以b + num + str1中左起的第一个+运算符不是字符串连接运算符。

是算术加法运算符吗?它的操作数类型为boolean ( b)和int ( num)。你已经知道算术加法运算符(+)不能有boolean操作数。表达式b + num中出现的boolean操作数导致了编译时错误。一个boolean不能加到一个数字上。然而,如果另一个操作数是一个字符串,那么+操作符作为一个字符串连接操作符在boolean上工作。若要更正此错误,可以按如下方式重写表达式:

str2 = b + (num + str1);     // OK. Assigns "false15faces" to str2
str2 = "" + b + num + str1;  // OK. Assigns "false15faces" to str2
str2 = b + "" + num + str1;  // OK. Assigns "false15faces" to str2

您使用println()print()方法在标准输出上打印一条消息,如下所示:

System.out.println("Prints a new line at the end of text");
System.out.print("Does not print a new line at the end of text");

如果您使用System.out.println()方法在控制台上打印文本,在打印完文本后,它还会在文本末尾打印一个新的行字符。使用println()print()的唯一区别是前者在文本末尾打印一个新行,而后者不打印。println()print()方法被重载。到目前为止,您只看到了它们在字符串参数中的使用。您可以将任何 Java 数据类型参数传递给这两个方法。以下代码片段说明了如何将 Java 基元类型作为参数传递给这些方法:

int num = 156;
// Prints 156 on the console
System.out.println(num);
// Prints, Value of num = 156, on the console
System.out.println("Value of num = " + num);
// Prints a new line character on the console
System.out.println();

清单 5-2 包含一个完整的程序来演示算术运算符和字符串连接运算符的使用。

// ArithOperator.java
package com.jdojo.operator;
class ArithOperator {
    public static void main(String[] args) {
        int num = 120;
        double realNum = 25.5F;
        double veryBigNum = 25.8 / 0.0;
        double garbage = 0.0 / 0.0;
        boolean test = true;
        // Print the value of num
        System.out.println("num = " + num);
        // Print the value of realNum
        System.out.println("realNum = " + realNum);
        // Print the value of veryBigNum
        System.out.println("veryBigNum = " + veryBigNum);
        // Print the value of garbage
        System.out.println("garbage = " + garbage);
        // Print the value of test
        System.out.println("test = " + test);
        // Print the maximum value of int type
        System.out.println("Maximum int = " + Integer.MAX_VALUE);
        // Print the maximum value of double type
        System.out.println("Maximum double = " + Double.MAX_VALUE);
        // Print the sum of two numbers
        System.out.println("12.5 + 100 = " + (12.5 + 100));
        // Print the difference of two numbers
        System.out.println("12.5 - 100 = " + (12.5 - 100));
        // Print the multiplication of two numbers
        System.out.println("12.5 * 100 = " + (12.5 * 100));
        // Print the result of division
        System.out.println("12.5 / 100 = " + (12.5 / 100));
        // Print the result of modulus
        System.out.println("12.5 % 100 = " + (12.5 % 100));
        // Print the result of string concatenation
        System.out.println("\"abc\" + \"xyz\" = " + "\"" + ("abc" + "xyz") + "\"");
    }
}

num = 120
realNum = 25.5
veryBigNum = Infinity
garbage = NaN
test = true
Maximum int = 2147483647
Maximum double = 1.7976931348623157E308
12.5 + 100 = 112.5
12.5 - 100 = -87.5
12.5 * 100 = 1250.0
12.5 / 100 = 0.125
12.5 % 100 = 12.5
"abc" + "xyz" = "abcxyz"

Listing 5-2An Example of Using Java Operators

关系运算符

关系运算符比较其操作数的值。这种比较的几个例子是相等、不相等、大于、小于等的比较。Java 支持七种关系运算符,其中六种在表 5-3 中列出。第七个是instanceof操作符,我们将在本书后面讨论。

表 5-3

Java 中的关系运算符列表

|

运算符

|

意义

|

类型

|

使用

|

结果

== 等于 二进制的 3 == 2 false
!= 不等于 二进制的 3 != 2 true
> 大于 二进制的 3 > 2 true
>= 大于或等于 二进制的 3 >= 2 true
< 不到 二进制的 3 < 2 false
<= 小于或等于 二进制的 3 <= 2 false

所有关系运算符都是二元运算符。也就是说,它们有两个操作数。关系运算符产生的结果总是一个boolean值:truefalse

相等运算符(==)

等式运算符(==)用于以下形式:

operand1 == operand2

相等运算符用于测试两个操作数是否相等。它使用以下规则:

  • 两个操作数都必须是基元类型或引用类型。不允许混合操作数类型。

  • 对于原语操作数,如果两个操作数表示相同的值,则返回true;否则返回false。操作数必须都是数字或者都是boolean。不允许混合使用数字和boolean类型。

  • 对于引用操作数,如果两个操作数都引用内存中的同一个对象,则返回true;否则返回false

假设有一个名为iint变量,声明如下:

int i = 10;

现在,i == 10将测试i是否等于 10。因为i等于10,所以表达式i == 10将计算为true。考虑另一个例子:

int i;
int j;
int k;
boolean b;
i = j = k = 15;    // Assign 15 to i, j and k
b = (i == j == k); // A compile-time error

在这个例子中,您试图测试三个变量ijk是否具有相同的值,而表达式(i == j == k)导致了一个错误。为什么会出现错误?表达式(i == j == k)的计算如下:

  • 首先,在表达式i == j == k中对i == j求值。因为ij的值相同,都是 15,所以表达式i == j返回true

  • 第一步将表达式i == j == k简化为true == k。这是一个错误,因为==操作符的操作数属于booleanint类型。不能用等号运算符混合boolean和数字类型的操作数。

当相等运算符的操作数是浮点类型时,以下规则适用。

规则 1

负零(–0.0)和正零(0.0)被认为是相等的。回想一下–0.0 和 0.0 在内存中的存储方式是不同的:

double d1 = 0.0;
double d2 = -0.0;
boolean b = (d1 == d2); // Assigns true to b

规则二

正负不定式被认为是不相等的:

double d1 = Double.POSITIVE_INFINITY;
double d2 = Double.NEGATIVE_INFINITY;
boolean b = (d1 == d2); // Assigns false to b

规则 3

如果任一操作数为NaN,相等测试返回false:

double d1 = Double.NaN;
double d2 = 5.5;
boolean b = (d1 == d2); // Assigns false to b

注意,即使两个操作数都是NaN,相等运算符也会返回false:

d1 = Double.NaN;
d2 = Double.NaN;
b = (d1 == d2); // Assigns false to b

如何测试存储在floatdouble变量中的值是否是NaN?如果你写下面这段代码来测试一个double变量d1的值是否为NaN,它将总是返回false:

double d1 = Double.NaN;
boolean b = (d1 == Double.NaN); // Assigns false to b. Incorrect way of testing

FloatDouble类有一个isNaN()方法,分别接受一个float和一个double参数。如果参数为NaN,则返回true;否则返回false。例如,为了测试d1是否为NaN,前面的表达式可以重写如下:

double d1 = Double.NaN;
// Assigns true to b. Correct way to test for a NaN value
b = Double.isNaN(d1);

您不应该使用==操作符来测试两个字符串是否相等,例如:

String str1 = new String("Hello");
String str2 = new String("Hello");
boolean b;
b = (str1 == str2); // Assigns false to b

Java 中的new操作符总是在内存中创建一个新对象。所以,str1str2指的是内存中两个不同的对象,这也是str1 == str2求值为false的原因。诚然,内存中的两个String对象具有相同的内容。每当==操作符与引用变量一起使用时,它总是比较其操作数所引用的对象的引用。要比较由两个String变量str1str2表示的文本,应该使用String类的equals()方法,如下所示:

// Assigns true to b because str1 and str2 have the same text "Hello"
b = str1.equals(str2);
// Assigns true to b because str1 and str2 have the same text "Hello"
b = str2.equals(str1);

我们将在第十五章讨论更多关于字符串比较的内容,包括字符串。

不等式运算符(!=)

不等式运算符(!=)以如下形式使用:

operand1 != operand2

如果operand1operand2不相等,不等式运算符返回true。否则返回false。不等式运算符(!=)的操作数的数据类型规则与等式运算符(==)相同。以下是使用不等式运算符的几个示例:

int i = 15;
int j = 10;
int k = 15;
boolean b;
b = (i != j);        // Assigns true to b
b = (i != k);        // Assigns false to b
b = (true != true);  // Assigns false to b
b = (true != false); // Assigns true to b

如果任一操作数为NaN ( floatdouble),则不等式运算符返回true。如果d1是浮点变量(doublefloat),当且仅当d1NaN时,d1 == d1返回false,而d1 != d1返回true

大于运算符(>)

“大于”运算符(>)以下列形式使用:

operand1 > operand2

如果operand1的值大于operand2的值,则“大于”运算符返回true。否则返回false。大于运算符只能用于原始数字数据类型。如果任一操作数为NaN ( floatdouble),则返回false。以下是使用此运算符的几个示例:

int i = 10;
int j = 15;
double d1 = Double.NaN;
boolean b;
b = (i > j); // Assigns false to b
b = (j > i); // Assigns true to b
// A compile-time error. > cannot be used with boolean operands
b = (true > false);
b = (d1 > Double.NaN); // Assigns false to b
String str1 = "Hello";
String str2 = "How is Java?";
// A compile-time error. > cannot be used with reference type operands str1 and str2
b = (str1 > str2);

如果要测试String str1中的字符数是否大于str2中的字符数,应该使用String类的length()方法。String类的length()方法返回字符串中的字符数,例如:

i = str1.length(); // Assigns 5 to i. "Hello" has 5 characters
b = (str1.length() > str2.length()); // Assigns false to b
b = (str2.length() > str1.length()); // Assigns true to b

大于或等于运算符(> =)

“大于或等于”运算符(>=)用于以下形式:

operand1 >= operand2

如果operand1的值大于或等于operand2的值,则“大于或等于”运算符返回true。否则返回false。大于或等于运算符只能用于原始数字数据类型。如果任一操作数是NaN ( floatdouble),大于或等于运算符返回false。以下是使用此运算符的几个示例:

int i = 10;
int j = 10;
boolean b;
b = (i >= j);  // Assigns true to b
b = (j >= i);  // Assigns true to b

小于运算符(

“小于运算符”(<)用于以下形式:

operand1 < operand2

如果operand1小于operand2,则“小于运算符”返回true。否则返回false。运算符只能用于原始数值数据类型。如果任一操作数是NaN ( floatdouble),小于运算符返回false。以下是使用此运算符的几个示例:

int i = 10;
int j = 15;
double d1 = Double.NaN;
boolean b;
b = (i < j);           // Assigns true to b
b = (j < i);           // Assigns false to b
// A compile-time error. < cannot be used with boolean operands
b = (true < false);
b = (d1 < Double.NaN); // Assigns false to b

小于或等于运算符(< =)

“小于或等于运算符”(<=)在此表格中使用:

operand1 <= operand2

如果operand1的值小于或等于operand2的值,则“小于或等于运算符”返回true。否则返回false。运算符只能用于原始数值数据类型。如果任一操作数是NaN ( floatdouble),小于或等于运算符返回false。以下是使用此运算符的几个示例:

int i = 10;
int j = 10;
int k = 15;
boolean b;
b = (i <= j);  // Assigns true to b
b = (j <= i);  // Assigns true to b
b = (j <= k);  // Assigns true to b
b = (k <= j);  // Assigns false to b

布尔逻辑运算符

布尔逻辑运算符获取boolean操作数,对其应用布尔逻辑,并产生一个boolean值。表 5-4 列出了 Java 中可用的boolean逻辑运算符。所有的boolean逻辑运算符只能与boolean操作数一起使用。后续部分将详细解释这些运算符的用法。

表 5-4

布尔逻辑运算符列表

|

运算符

|

意义

|

类型

|

使用

|

结果

! 逻辑非 一元的 !true false
&& 短路和 二进制的 true && true true
& 逻辑“与” 二进制的 true & true true
&#124;&#124; 短路或 二进制的 true &#124;&#124; false true
&#124; 逻辑或 二进制的 true &#124; false true
^ 逻辑异或(异或) 二进制的 true ^ true false
&= 和分配 二进制的 test &= true
&#124;= 或分配 二进制的 test &#124;= true
^= 异或赋值 二进制的 test ^= true

逻辑非运算符(!)

逻辑NOT运算符(!)用于以下形式:

!operand

如果operandfalse,操作员返回true,如果operandtrue,操作员返回false:

boolean b;
b = !true;    // Assigns false to b
b = !false;   // Assigns true to b
int i = 10;
int j = 15;
boolean b1 = true;
b = !b1;      // Assigns false to b
b = !(i > j); // Assigns true to b, because i > j returns false

假设您想要将一个boolean变量b的值更改为true,如果其当前值为false,如果其当前值为true,则更改为false。这可以通过以下方式实现:

b = !b; // Assigns true to b if it was false and false if it was true

逻辑短路和运算符(&&)

逻辑短路AND运算符(&&)用于以下形式:

operand1 && operand2

如果两个操作数都是true,则&&运算符返回true。如果任一操作数为false,则返回false。它被称为短路操作符AND,因为如果operand1(左边的操作数)计算结果为false,它将返回false而不计算operand2(右边的操作数):

int i = 10;
int j = 15;
boolean b = (i > 5 && j > 10);  // Assigns true to b

在这个表达式中,首先计算i > 5,然后返回true。因为左边的操作数被求值为true,所以右边的操作数也被求值。右边的操作数j > 10被求值,它也返回true。现在,表情简化为true && true。因为两个操作数都是true,所以最后的结果是true。考虑另一个例子:

int i = 10;
int j = 15;
boolean b = (i > 20 && j > 10);  // Assigns false to b

表达式i > 20返回false。这个表达简化为false && j > 10。因为左边的操作数是false,右边的操作数j > 10不求值,&&返回false。然而,在这个例子中没有办法证明右边的操作数j > 10没有被求值。考虑另一个例子来证明这一点。我们已经讨论过赋值操作符(=)。如果numint类型的变量,num = 10返回值10:

int num = 10;
boolean b = ((num = 50) > 5); // Assigns true to b

注意这个例子中圆括号的使用。在表达式((num = 50) > 5)中,首先计算(num = 50)表达式。它将50赋给num并返回50,将表达式简化为(50 > 5),?? 又返回true。如果在执行num = 50表达式后使用num的值,其值将为50。记住这一点,考虑下面的代码片段:

int i = 10;
int j = 10;
boolean b = (i > 5 && ((j = 20) > 15));
System.out.println("b = " + b);
System.out.println("i = " + i);
System.out.println("j = " + j);
b = true
i = 10
j = 20

由于左边的操作数i > 5被赋值为true,右边的操作数((j = 20) > 15)被赋值,变量j被赋值为 20。如果我们改变这个代码,那么左边的操作数计算为false,右边的操作数将不被计算,并且j的值将保持为10。更改后的代码如下:

int i = 10;
int j = 10;
// ((j = 20) > 5) is not evaluated because i > 25 returns false
boolean b = (i > 25 && ((j = 20) > 15));
System.out.println ("b = " + b);
System.out.println ("i = " + i);
System.out.println ("j = " + j); // Will print j = 10
b = false
i = 10
j = 10

逻辑与运算符(&)

逻辑AND运算符(&)用于以下形式:

operand1 & operand2

如果两个操作数都是true,则逻辑AND运算符返回true。如果任一操作数为false,则返回false。逻辑AND操作符(&)的工作方式与逻辑短路AND操作符(&&)相同,除了一点不同——逻辑AND操作符(&)计算其右边的操作数,即使其左边的操作数计算结果为false:

int i = 10;
int j = 15;
boolean b;
b = (i > 5 & j > 10);            // Assigns true to b
b = (i > 25 & ((j = 20) > 15));  // ((j = 20) > 5) is evaluated even if i > 25 returns false
System.out.println ("b = " + b);
System.out.println ("i = " + i);
System.out.println ("j = " + j); // Will print j = 20
b = false
i = 10
j = 20

逻辑短路或运算符(||)

逻辑短路OR运算符(||)用于以下形式:

operand1 || operand2

如果任一操作数为true,逻辑短路OR运算符返回true。如果两个操作数都是false,则返回false。它被称为短路OR操作符,因为如果operand1计算结果为true,它将返回true而不计算operand2:

int i = 10;
int j = 15;
boolean b = (i > 5 || j > 10); // Assigns true to b

在这个表达式中,首先计算i > 5,然后返回true。因为左边的操作数求值为true,右边的操作数不求值,表达式(i > 5 || j > 10)返回true。考虑另一个例子:

int i = 10;
int j = 15;
boolean b = (i > 20 || j > 10); // Assigns true to b

表达式i > 20返回false。这个表达简化为false || j > 10。因为||的左边操作数是false,右边操作数j > 10被求值,返回true,整个表达式返回true

逻辑或运算符(|)

逻辑OR运算符(|)用于以下形式:

operand1 | operand2

如果任一操作数为true,逻辑OR运算符返回true。如果两个操作数都是false,则返回false。逻辑OR操作符的工作方式与逻辑短路OR操作符相同,除了一点不同——逻辑OR操作符计算其右边的操作数,即使其左边的操作数计算结果为true:

int i = 10;
int j = 15;
boolean b = (i > 5 | j > 10); // Assigns true to b

在这个表达式中,首先计算i > 5,然后返回true。即使左边的操作数i > 5求值为true,右边的操作数j > 15仍然求值,整个表达式(i > 5 | j > 10)返回true

逻辑异或运算符(^)

逻辑XOR运算符(^)用于以下形式:

operand1 ^ operand2

如果operand1operand2不同,逻辑XOR运算符返回true。也就是说,如果其中一个操作数是true,它返回true,但不是两个都是。如果两个操作数相同,则返回false:

int i = 10;
boolean b;
b = true ^ true;      // Assigns false to b
b = true ^ false;     // Assigns true to b
b = false ^ true;     // Assigns true to b
b = false ^ false;    // Assigns false to b
b = (i > 5 ^ i < 15); // Assigns false to b

复合布尔逻辑赋值运算符

有三个复合boolean逻辑赋值操作符。注意 Java 没有任何像&&=||=这样的操作符。这些运算符以如下形式使用:

operand1 op= operand2

operand1必须是一个boolean变量,op可以是&|^。这种形式相当于写作

operand1 = operand1 op operand2

表 5-5 显示了复合逻辑赋值运算符及其等价物。

表 5-5

复合逻辑赋值运算符及其等价形式

|

表示

|

相当于

operand1 &= operand2 operand1 = operand1 & operand2
operand1 &#124;= operand2 operand1 = operand1 &#124; operand2
operand1 ^= operand2 operand1 = operand1 ^ operand2

如果两个操作数的值都是true&=返回true。否则,返回false:

boolean b = true;
b &= true;  // Assigns true to b
b &= false; // Assigns false to b

如果任一操作数的计算结果为true,则|=返回true。否则,返回false:

boolean b = false;
b |= true;  // Assigns true to b
b |= false; // Assigns false to b

如果两个操作数的值不同,也就是说,其中一个操作数是true而不是两个都是,^=返回true。否则,返回false:

boolean b = true;
b ^= true;  // Assigns false to b
b ^= false; // Assigns true to b

三元运算符(?:)

Java 有一个条件运算符。它被称为三元运算符。它需要三个操作数。它以这种形式使用:

boolean-expression ? true-expression : false-expression

两个符号?:组成三元运算符。如果boolean-expression求值为true,则求值为true-expression;否则,它评估false-expression

假设你有三个整数变量num1num2minNum。您希望将num1num2中的最小值分配给minNum。您可以使用三元运算符来实现这一点:

int num1 = 50;
int num2 = 25;
// Assigns num2 to minNum, because num2 is less than num1
int minNum = (num1 < num2 ? num1 : num2);

按位运算符

按位运算符是一种使用整数操作数的位模式对其执行操作的运算符。表 5-6 中列出了 Java 中的位运算符。

表 5-6

按位运算符列表

|

运算符

|

意义

|

类型

|

使用

|

结果

& 按位“与” 二进制的 25 & 24 24
&#124; 按位或 二进制的 25 &#124; 2 27
^ 按位异或 二进制的 25 ^ 2 27
~ 按位非(1 的补码) 一元的 ~25 -26
<< 左移 二进制的 25 << 2 100
>> 有符号右移位 二进制的 25 >> 2 6
>>> 无符号右移 二进制的 25 >>> 2 6
&=, !=, ^=, <<=, >>=, >>>= 复合赋值位运算符 二进制的

所有按位运算符都只处理整数。按位AND ( &)运算符对其两个操作数的相应位进行运算,如果两位都为 1,则返回1,否则返回0。注意,按位AND ( &)对各个操作数的每一位进行运算,而不是对整个操作数进行运算。以下是使用按位AND ( &)运算符的所有位组合的结果:

1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0

考虑下面这段 Java 代码:

int i = 13 & 3;

13 & 3的值计算如下。为了清楚起见,32 位以 8 位块的形式示出。在内存中,所有 32 位都是连续的:

13         00000000 00000000 00000000 00001101
 3         00000000 00000000 00000000 00000011
----------------------------------------------
13 & 3 -   00000000 00000000 00000000 00000001 (Equal to decimal 1)

因此,13 & 3为 1,赋给前面一段代码中的i

按位OR ( |)对其操作数的相应位进行运算,如果任一位为 1,则返回1,否则返回0。以下是使用按位OR ( |)运算符的所有位组合的结果:

1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0

13 | 3的值可以计算如下。13 | 3的结果是15:

13       00000000 00000000 00000000 00001101
 3       00000000 00000000 00000000 00000011
--------------------------------------------
13 | 3   00000000 00000000 00000000 00001111 (Equal to decimal 15)

按位XOR ( ^)对其操作数的相应位进行运算,如果只有一位为 1,则返回1。否则返回0。以下是使用按位XOR ( ^)运算符的所有位组合的结果:

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

13 ^ 3的值可以计算如下。13 ^ 3的结果是14:

13      00000000 00000000 00000000 00001101
 3      00000000 00000000 00000000 00000011
-------------------------------------------
13 ^ 3  00000000 00000000 00000000 00001110 (Equal to decimal 14)

按位NOT ( ~)对其操作数的每一位进行运算。它将位反转,即 1 变为 0,0 变为 1。它也被称为按位 补码运算符。它计算操作数的 1 的补码。以下是使用按位NOT ( ~)运算符的所有位组合的结果:

~1 = 0
~0 = 1

~13的值可以计算如下。~13的结果是-14:

13     00000000 00000000 00000000 00001101
------------------------------------------
~13    11111111 11111111 11111111 11110010  (Equal to decimal -14)

按位左移运算符(<<)将所有位向左移动指定为其右侧操作数的位数。它在低位插入零。向右移动 1 位的效果等同于将数字乘以 2。因此,9 << 1会产生18,而9 << 2会产生36。计算13 << 4的程序如图 5-1 所示。

img/323069_3_En_5_Fig1_HTML.png

图 5-1

计算 13 << 4

13 << 35的结果是什么?你可能已经猜到了零。然而,事实并非如此。实际上只用 32 位来表示13,因为13被认为是int的字面,而int占据了 32 位。在int中,您只能将所有位向左移动31位。如果按位左移运算符(< int)的左操作数,则只有右操作数的 5 个低位值被用作要移位的位数。例如,在13 << 35中,右操作数(35)可以用二进制表示如下:

00000000000000000000000000100011

35 中的 5 个低位是 00011,等于 3。所以你写13 << 35就相当于写13 << 3。对于按位左移运算符的所有正右操作数,您可以用 32 取右操作数的模,这将是要移位的最终位数。因此,13 << 35可以认为是13 << (35 % 32),与13 << 3相同。如果左边的操作数是long,右边操作数的前 6 个低位的值被用作要移位的位数:

long  val = 13;
long result;
result = val << 35;

由于val是一个long35的 6 个低位比特,即 100011,将被用作要移位的数字。图 5-2 显示了用于计算13 >> 4-13 >> 4的步骤。

img/323069_3_En_5_Fig2_HTML.png

图 5-2

计算 13 >> 4 和-13 >> 4

按位有符号右移位运算符(>>)将所有位向右移位指定为其右侧操作数的数字。如果左边操作数的最高有效位是 1(对于负数),则在移位操作后,所有高位都填充 1。如果最高有效位是 0(对于正数),所有高位都用 0 填充。因为右移位运算(>>)后的符号位保持不变,所以称为有符号右移位运算符。例如,13 >> 4的结果为零,如图 5-2 所示。还要注意的是,在-13 >> 4的情况下,所有 4 个高位都是 1,因为在-13中,最高有效位是 1。-13 >> 4的结果是-1

无符号右移位运算符(>>>)的工作方式与有符号右移位运算符(>>)相同,除了一点不同——它总是用零填充高位。如图所示,13 >>> 4的结果是零,而-13 >>> 4的结果是268435455。没有无符号左移运算符:

13         00000000 00000000 00000000  00001101
13 >>> 4   00000000 00000000 00000000  00000000 1101
-13        11111111 11111111 11111111  11110011
-13 >>> 4  00001111 11111111 11111111  11111111 0011

复合按位赋值运算符以下列形式使用:

operand1 op= operand2

这里,op是位运算符&|^<<>>之一,>>>. operand1operand2是原始整数数据类型,其中operand1必须是变量。前面的表达式等效于下面的表达式:

operand1 = (Type of operand1) (operand1 op operand2)

假设有两个int变量,ij,表 5-7 列出了复合按位赋值运算符的等价表达式。

表 5-7

复合按位赋值运算符列表

|

表示

|

相当于

i &= j i = i & j
i &#124;= j i = i &#124; j
i ^= j i = i ^ j
i <<= j i = i << j
i >>= j i = i >> j
i >>>= j i = i >>> j

运算符优先级

考虑下面这段代码:

int result;
// What will be the value assigned to result?
result = 10 + 8 / 2;

最后一条语句执行后,赋给变量result的值会是什么?会是9还是14?要看先做的操作。如果先执行加法10 + 8,则为9。如果先执行除法8/2,则为14。Java 中的所有表达式都是根据运算符优先级层次结构进行计算的,该层次结构建立了控制表达式计算顺序的规则。优先级较高的运算符在优先级较低的运算符之前进行计算。如果运算符具有相同的优先级,则表达式从左向右计算。乘法、除法和余数运算符的优先级高于加法和减法运算符。因此,在前面的表达式中,首先对8/2求值,这将表达式简化为10 + 4,进而产生14

考虑另一个表达式:

result = 10 * 5 / 2;

表达式10 * 5 / 2使用两个运算符:一个乘法运算符和一个除法运算符。两个运算符具有相同的优先级。表达式从左到右计算。首先对表达式10 * 5求值,然后对表达式50 / 2求值。整个表达式求值为25。如果要先执行除法,必须使用括号。括号具有最高优先级,因此,括号内的表达式首先被求值。您可以使用括号重写前面的代码:

result = 10 * (5 / 2); // Assigns 20 to result. Why?

也可以使用嵌套括号。在嵌套括号中,首先计算最内层括号的表达式。表 5-8 按优先级顺序列出了 Java 运算符。同一级别的运算符具有相同的优先级。表 5-8 列出了一些我们还没有讨论的操作符。我们将在后面的其他章节中讨论它们。在表中,“级别”列中的值越低,表示优先级越高。

表 5-8

Java 运算符及其优先级

|

水平

|

运算符符号

|

执行的操作

one ++ 前增量或后增量
-- 前减量或后减量
+- 一元加号,一元减号
~ 逐位补码
! 逻辑非
(type) 演员阵容
Two */% 乘法,除法,剩余物
three +- 加法、减法
+ 串并置
four << 左移
>> 有符号右移位
>>> 无符号右移
five < 不到
<= 小于或等于
> 大于
>= 大于或等于
instanceof 类型比较
six == 价值相等
!= 不等于
seven & 按位 AND
& 逻辑与
eight ^ 按位异或
^ 逻辑异或
nine &#124; 按位或
&#124; 逻辑或
Ten && 逻辑短路和
Eleven &#124;&#124; 逻辑短路或
Twelve ? : 三元算子
Thirteen = 分配
+=-=*=/=%=<<=>>=>>>=&=&#124;=^= 复合赋值

摘要

运算符是一种符号,用于对其操作数执行某种类型的计算。Java 包含一组丰富的操作符。根据操作数的数量,运算符分为一元、二元和三元。根据操作数类型和它们对操作数执行的操作,它们被分类为算术、关系、逻辑或按位。

算术运算符是将数值作为其操作数并执行算术运算(例如,加法和减法)来计算另一个数值的运算符。Java 中算术运算符的几个例子是+-*/

在 Java 中,+运算符是重载的。当它的一个操作数是一个String时,它执行一个字符串连接。例如,一个表达式50 + " States"产生另一个字符串"50 States"

关系运算符比较其操作数的值,返回一个boolean值— truefalse。关系运算符的几个例子是==!=>>=<<=

布尔逻辑运算符获取boolean操作数,对其应用布尔逻辑,并产生一个boolean值。Java 中布尔逻辑运算符的几个例子是&&||&

Java 支持三元运算符(? :),它接受三个操作数。它也被称为条件运算符。如果第一个操作数计算结果为true,则计算第二个操作数并返回;否则,计算并返回第三个操作数。

按位运算符是一种使用整数操作数的位模式对其执行操作的运算符。Java 中按位运算符的两个例子是&|

如果可以在多个上下文中使用一个运算符来执行不同类型的计算,则该运算符称为重载。Java 包含一个重载的+操作符。它被用作算术加法运算符和字符串连接运算符。与 C++不同,Java 不允许开发人员重载运算符。

Java 中的每个操作符相对于其他操作符都有优先顺序。如果一个表达式中出现多个运算符,则优先级较高的运算符的操作数优先于优先级较低的运算符的操作数。

EXERCISES

  1. 什么是运营商?什么是一元、二元和三元运算符?举例说明 Java 中每种类型的运算符。

  2. 前缀、后缀和中缀运算符之间有什么区别?举一个 Java 中这种运算符的例子。

  3. 什么是算术运算符,它们接受什么类型的操作数,产生什么类型的结果?

  4. 举出两个 Java 中的运算符,它们只接受Boolean操作数并产生一个布尔值。

  5. 两个运算符有什么区别:===

  6. Consider the following snippet of code:

    boolean done;
    /* Some code goes here */
    your-code-goes-here;
    
    

    使用Boolean逻辑运算符,反转存储在done变量中的当前值。也就是说,写一条语句,如果变量done的当前值是false,则将true赋值给变量done,如果变量done的当前值是true,则将false赋值给变量done

  7. Consider the following snippet of code:

    int x = 23;
    int y = ++x % 3;
    
    

    这段代码执行后,y的值会是什么?

  8. Consider the following snippet of code:

    int x = 23;
    x = x++ % x;
    
    

    这段代码执行后,x的值会是什么?用执行的步骤解释你的答案,解释在第二条语句的执行过程中x的值是如何变化的。

  9. 解释为什么下面的代码片段不能编译:

    int x = 10;
    boolean yes = (x = 20);
    
    
  10. 当执行下面的代码片段时,赋给名为yes的变量的值是什么?

```java
int x = 10;
boolean yes = (x == 20);

```
  1. 当执行下面的代码片段时,y的值会是什么?
```java
int x = 19;
int y = x > 10 ? 69 : 68;

```
  1. 您有一个名为x的短变量,其声明和初始化如下:
short x = -19;

您希望使用以下语句将19赋值给x,这两条语句都不会编译:

x = -x;
x = -1 * x;

你将如何重写这两条语句来编译它们?下面的语句试图修复这些语句中的编译时错误,但是没有将19赋值给x,这是怎么回事?

  1. 当执行下面的代码片段时,输出会是什么?

    boolean b = true;
    String str = !b +" is not " + b;
    System.out.println(str);
    
    
  2. 当执行下面的代码片段时,输出会是什么?

    boolean b = true;
    String str = (b ^= b) + " is " + b;
    System.out.println(str);
    
    
  3. 当您执行下面的代码片段时,输出会是什么?

    int x = 10;
    int y = x++;
    int z = ++x;
    System.out.println("x = " + x + ", y = " + y + ", z = " + z);
    
    
  4. 使用三元运算符(? :)和按位 and 运算符(&)完成第二条语句,这将生成一条消息"x is odd"。您的代码必须以任意顺序包含以下标记:x&==?:"odd""even"。您可以根据需要使用额外的令牌:

    int x = 19;
    String msg = your-code-goes-here ;
    System.out.println("x is " + msg);
    
    
  5. 下列哪一个作业将无法编译,为什么?

    int i1 = 100;
    int i2 = 10.6;
    byte b1 = 90;
    byte b2 = 3L;
    short s1 = -90;
    float f1 = 12.67;
    float f2 = 0.00f;
    double d1 = 12.56;
    double d2 = 12.78d;
    boolean bn1 = true;
    boolean bn2 = 0;
    char c1 = 'A';
    char c2 = "A";
    char c3 = 0;
    char c4 = '\u0000';
    
    
  6. 写下分配给下列每个语句中已声明变量的值。如果一个语句产生了编译时错误,解释错误背后的原因,如果可能的话,提供一个修复错误的解决方案:

    int i1 = 10/4;
    int i2 = 10.0/4.0;
    int i3 = 0/0;
    long l1 = 10/4;
    long l2 = 10.0/4.0;
    float f1 = 10/4;
    float f2 = 10.0/4.0;
    double d1 = 10/4;
    double d2 = 10.0/4.0;
    double d3 = 0/0;
    double d4 = 0/0.0;
    double d5 = 2.9/0.0;
    
    
  7. 完成下面的代码片段,它将把x的二进制补码分配给y。您必须使用按位运算符:

    int x = 19;
    int y = your-code-goes-here;
    
    
  8. 以下代码片段的输出会是什么?

    int x = 19;
    int y = (~x + 1) + x;
    System.out.println(y);
    
    
x -= x;

六、语句

在本章中,您将学习:

  • 什么是语句

  • Java 中的表达式是什么,如何转换成表达式语句

  • 什么是块语句,块中声明的变量的范围是什么

  • 什么是控制流语句,如何使用if-elsefor-循环、while-循环和do-while循环语句

  • 如何使用break语句退出循环或程序块

  • 如何使用continue语句忽略循环语句体的其余部分,并继续下一次迭代

  • 什么是空语句以及在哪里使用它

  • 什么是开关表达式以及如何使用它

本章中的所有例子都在jdojo.statement模块中,其声明如清单 6-1 所示。

// module-info.java
module jdojo.statememnt {
    // No module statements
}

Listing 6-1The Declaration of a Module Named jdojo.statement

什么是语句?

一条语句指定了 Java 程序中的一个动作,比如将xy的和赋给z,将一条消息打印到标准输出,将数据写入文件,遍历一个值列表,有条件地执行一段代码,等等。语句是使用关键字、运算符和表达式编写的。

语句的类型

根据语句执行的动作,Java 中的语句可以大致分为三类:

  • 声明语句

  • 表达式语句

  • 控制流语句

后续部分详细描述了所有语句类型。

声明语句

声明语句用于声明变量。您已经使用了这种类型的语句。以下是 Java 中声明语句的几个例子:

int num;
int num2 = 100;
String str;

表达式语句

Java 中的表达式由文字、变量、运算符和方法调用组成;它们是 Java 程序的组成部分。对表达式求值;并且该评估可以产生变量、值或者什么都不产生。一个表达式总是有一个类型,如果是对返回类型为void的方法的方法调用,那么这个类型可能是void。以下是 Java 中表达式的几个例子:

  • 19 + 69

  • num + 2

  • num++

  • System.out.println("Hello")

  • new String("Hello")

末尾带分号的表达式称为表达式语句。然而,并不是所有的 Java 表达式都可以通过添加分号来转换成表达式语句。假设xy为两个int变量,下面是一个算术表达式,其计算结果为int值:

x + y

但是,以下不是 Java 中的有效表达式语句:

x + y;

允许这样的声明是没有意义的。它将xy的值相加,并且不对该值做任何处理。只有以下四种表达式可以通过在它们后面附加分号来转换为表达式语句:

  • 增量和减量表达式

  • 赋值表达式

  • 对象创建表达式

  • 方法调用表达式

增量和减量表达式语句的几个示例如下:

num++;
++num;
num--;
--num;

赋值表达式语句的几个例子如下:

num = 100;
num *= 10;

对象创建表达式语句的示例如下:

new String("This is a text");

注意,这条语句创建了一个新的String类对象。但是,新对象的引用不存储在任何引用变量中。所以这种说法用处不大。但是,在某些情况下,您可以以一种有用的方式使用这样的对象创建语句,例如,JDBC 驱动程序在加载驱动程序类时向驱动程序管理器注册自己,加载驱动程序类的一种方法是创建它的对象并丢弃已创建的对象。

您调用方法println()在控制台上打印一条消息。当你使用的println()方法末尾没有分号时,它就是一个表达式。当您在方法调用的末尾添加分号时,它就变成了一个语句。下面是一个方法调用表达式语句的示例:

System.out.println("This is a statement");

控制流语句

默认情况下,Java 程序中的所有语句都按照它们在程序中出现的顺序执行。但是,您可以使用控制流语句来更改执行顺序。有时,您可能希望仅在特定条件为真时才执行一条或一组语句。有时,您可能希望多次重复执行一组语句,或者只要特定条件为真。所有这些在 Java 中都可以使用控制流语句;iffor语句是控制流语句的例子。我们将很快讨论控制流语句。

块语句

block 语句是用大括号括起来的零个或多个语句的序列。block 语句通常用于将几个语句组合在一起,因此可以在需要使用单个语句的情况下使用它们。在某些情况下,您只能使用一条语句。如果您想在这些情况下使用多条语句,可以通过将所有语句放在大括号内来创建一条 block 语句,这将被视为一条语句。您可以将块语句视为一个复合语句,该语句被视为一个语句。以下是块语句的示例:

{ /* Start of a block statement. A block statement starts with { */
    int num1 = 20;
    num1++;
} /* End of the block statement. A block statement ends with } */
{
  // Another valid block statement with no statements inside
}

block 语句中声明的所有变量只能在该块中使用。换句话说,你可以说在一个块中声明的所有变量都有局部范围。考虑以下代码片段:

// Declare a variable num1
int num1;
{   // Start of a block statement
    // Declares a variable num2, which is a local variable for this block
    int num2;
    // num2 is local to this block, so it can be used here
    num2 = 200;
    // We can use num1 here because it is declared outside and before this block
    num1 = 100;
}   // End of the block statement
    // A compile-time error. num2 has been declared inside a block and
    // so it cannot be used outside that block
num2 = 50;

您也可以将一个 block 语句嵌套在另一个 block 语句中。封闭块(外部块)中声明的所有变量对于封闭块(内部块)都是可用的。但是,在封闭的内部块中声明的变量在封闭的外部块中不可用,例如:

// Start of the outer block
{
    int num1 = 10;
    // Start of the inner block
    {
        // num1 is available here because we are in an inner block
        num1 = 100;
        int num2 = 200; // Declared inside the inner block
        num2 = 678;     // OK. num2 is local to inner block
    }
    // End of the inner block
    // A compile-time error. num2 is local to the inner block.
    // So, it cannot be used outside the inner block.
    num2 = 200;
}
// End of the outer block

关于嵌套块语句,需要记住的重要一点是,如果在外部块中已经定义了同名的变量,则不能在内部块中定义该变量。这是因为在外部块中声明的变量总是可以在内部块中使用,如果在内部块中声明一个同名的变量,Java 就没有办法在内部块中区分这两个变量。以下代码片段无法编译:

int num1 = 10;
{
    // A Compile-time error. num1 is already in scope. Cannot redeclare num1
    float num1 = 10.5F;
    float num2 = 12.98F; // OK
    {
        // A compile-time error. num2 is already in scope.
        // You can use num2 already defined in the outer
        // block, but cannot redeclare it.
        float num2;
    }
}

if-else 语句

if-else语句的格式如下:

if (condition)
    statement1
else
    statement2

condition必须是一个boolean表达式。也就是说,它必须评估为truefalse。如果condition评估为true,则执行statement1。否则,执行statement2。一条if-else语句的流程图如图 6-1 所示。

img/323069_3_En_6_Fig1_HTML.png

图 6-1

if-else 语句的流程图

if-else语句中的else部分是可选的。如果缺少了else部分,该语句有时被简单地称为if语句。你可以写一个if声明如下:

if (condition)
    statement

一条if语句的流程图如图 6-2 所示。

img/323069_3_En_6_Fig2_HTML.png

图 6-2

if 语句的流程图

假设有两个名为num1num2int变量。假设您想在num1大于50的情况下将10加到num2上。否则你要从num2中减去10。您可以使用if-else语句来编写这个逻辑:

if (num1 > 50)
    num2 = num2 + 10;
else
    num2 = num2 - 10;

假设您有三个名为num1num2num3int变量。如果num1大于50,你想把10加到num2num3上。否则你要从num2num3中减去10。您可以尝试以下不正确的代码片段:

if (num1 > 50)
    num2 = num2 + 10;
    num3 = num3 + 10;
else
    num2 = num2 - 10;
    num3 = num3 - 10;

这段代码会生成一个编译时错误。这段代码有什么问题?在if-else语句中,只能在ifelse之间放置一条语句。这就是语句num3 = num3 + 10;导致编译时错误的原因。事实上,在一个if-else语句或一个简单的if语句中,您总是只能将一个语句与if部分相关联。对于else部分也是如此。在本例中,只有num2 = num2 - 10;else零件相关联;最后一条语句num3 = num3 - 10;else部分没有关联。无论num1是否大于50,你都要执行两条语句。在这种情况下,您需要将两条语句捆绑成一条块语句,如下所示:

if (num1 > 50) {
    num2 = num2 + 10;
    num3 = num3 + 10;
} else {
    num2 = num2 - 10;
    num3 = num3 - 10;
}

if-else语句可以嵌套,如下图所示:

if (num1 > 50) {
    if (num2 < 30) {
        num3 = num3 + 130;
    } else {
        num3 = num3 - 130;
    }
} else {
    num3 = num3 = 200;
}

有时,在嵌套的if-else语句中,很难确定哪个else与哪个if在一起。考虑下面这段代码:

int i = 10;
int j = 15;
if (i > 15)
if (j == 15)
    System.out.println("Thanks");
else
    System.out.println("Sorry");

这段代码的输出会是什么?它会打印"Thanks""Sorry",还是根本不打印任何东西?如果你猜到它不会打印任何东西,你已经了解了if-else协会。

您可以应用一个简单的规则来计算出在一个if-else语句中,哪个else对应哪个if。从else开始向上移动。如果您找不到任何其他的else语句,您找到的第一个if将与您开始的else一起使用。如果你在找到任何if之前找到一个else,那么第二个if将与你开始的else一起移动,以此类推。在本例中,从else开始,您找到的第一个ifif (j == 15),因此else与这个if一起出现。可以使用缩进和块语句重写前面的代码,如下所示:

int i = 10;
int j = 15;
if (i > 15) {
    if (j == 15) {
        System.out.println("Thanks");
    } else {
        System.out.println("Sorry");
    }
}

因为i等于 10,表达式i > 15将返回false,因此控件根本不会进入if语句。因此,不会有任何输出。

注意,if语句中的condition表达式必须是boolean类型。因此,如果您想比较两个int变量ij是否相等,您的if语句必须如下所示:

if (i == j)
    statement

你不能像这样写一个if语句:

if (i = 5) /* A compile-time error */
    statement

这个if语句不会被编译,因为i = 5是一个赋值表达式,它的值为int值 5。条件表达式必须返回一个boolean值:truefalse。因此,赋值表达式不能用作if语句中的条件表达式,除非您将boolean值赋给boolean变量,如下所示:

boolean b;
if (b = true) /* Always returns true */
    statement

这里,赋值表达式b = true总是在将true赋值给b后返回true。在这种情况下,允许在if语句中使用赋值表达式,因为表达式b = true的数据类型是boolean

您可以使用三元运算符来代替简单的if-else语句。假设,如果一个人是男性,你想将头衔设置为Mr.,如果不是,则设置为Ms.,你可以使用一个if-else语句和一个三元运算符来实现,如下所示:

String title;
boolean isMale = true;
// Using an if-else statement
if (isMale)
    title = "Mr.";
else
    title = "Ms.";
// Using a ternary operator
title = (isMale ? "Mr." : "Ms.");

您可以看到使用if-else语句和三元运算符的区别。使用三元运算符代码很紧凑。但是,您不能使用三元运算符来替换所有的if-else语句。只有当if-else语句中的ifelse部分只包含一个语句并且两个语句返回相同类型的值时,才可以使用三元运算符代替if-else语句。因为三元运算符是一个运算符,所以可以在表达式中使用。假设你想把ij中的最小值赋给k。您可以在变量k的以下声明语句中实现这一点:

int i = 10;
int j = 20;
int k = (i < j ? i : j); // Using a ternary operator in initialization

使用if-else语句也可以达到同样的效果,如下所示:

int i = 10;
int j = 20;
int k;
if (i < j)
    k = i;
else
    k = j;

使用三元运算符和if-else语句的另一个区别是,您可以使用将三元运算符作为方法参数的表达式。但是,您不能使用if-else语句作为方法的参数。假设您有一个接受一个int作为参数的calc()方法。你有两个整数,num1num2。如果您想将两个整数中的最小值传递给calc()方法,您应该编写如下所示的代码:

// Use an if-else statement
if (num1 < num2)
    calc(num1);
else
    calc(num2);
// Use a ternary operator
calc(num1 < num2 ? num1 : num2);

假设您想要打印消息"k is 15",如果变量int的值k等于15。否则,你要打印消息"k is not 15"。您可以使用三元运算符并编写一行代码来打印消息,如下所示:

System.out.println(k == 15 ? "k is 15" : "k is not 15");

switch 语句

switch语句的一般形式如下:

switch (switch-value) {
    case label1:
        statements
    case label2:
        statements
    case label3:
        statements
    default:
        statements
}

switch-value必须评估为一种类型:byteshortcharintenumString。有关如何在switch语句中使用enum类型的详细信息,请参考关于枚举的第二十二章。关于如何在switch语句中使用字符串的详细信息,参见第十五章。label1label2等。是编译时常量表达式,其值必须在switch-value的类型范围内。一条switch语句被评估如下:

  • The switch-value被评估。

  • 如果switch-value的值匹配一个case标签,则从匹配的case标签开始执行,并执行所有语句,直到switch语句结束。

  • 如果switch-value的值与case标签不匹配,则从可选的default标签后面的语句开始执行,直到switch语句结束。

以下代码片段是使用switch语句的示例:

int i = 10;
switch (i) {
    case 10: // Found the match
        System.out.println("Ten");       // Execution starts here
    case 20:
        System.out.println("Twenty");    // Also executes this statement
    default:
        System.out.println ("No-match"); // Also executes this statement
}
Ten
Twenty
No-match

i的值是 10。执行从case 10:之后的第一条语句开始,经过case 20:default标签,执行这些标签下的语句。如果您将i的值更改为 50,那么case标签中将没有任何匹配,执行将从default标签后的第一条语句开始,这将打印"No-match"。以下示例说明了这一逻辑:

int i = 50;
switch (i) {
    case 10:
        System.out.println("Ten");
    case 20:
        System.out.println("Twenty");
    default:
        System.out.println("No-match"); // Execution starts here
}
No-match

default标签不必是出现在switch语句中的最后一个标签,它是可选的。下面是一个不是最后一个标签的default标签的例子:

int i = 50;
switch (i) {
    case 10:
        System.out.println("Ten");
    default:
        System.out.println("No-match"); // Execution starts here
    case 20:
        System.out.println("Twenty");
}
No-match
Twenty

因为i的值是 50,与任何一个case标签都不匹配,所以执行从default标签后的第一条语句开始。控制通过随后的标签case 20:并执行该 case 标签后的语句,打印Twenty。一般来说,如果i的值是 10,你要打印Ten,如果i的值是20,你要打印Twenty。如果i的值既不是10也不是20,你想打印No-match。使用break关键字可以做到这一点。

当在switch语句中执行break语句时,控制权被转移到switch语句之外。下面是一个在switch语句中使用break语句的例子:

int i = 10;
switch (i) {
    case 10:
        System.out.println("Ten");
        break; // Transfers control outside the switch statement
    case 20:
        System.out.println("Twenty");
        break; // Transfers control outside the switch statement
    default:
        System.out.println("No-match");
        break; // Transfers control outside the switch statement. It is not necessary.
}
Ten

请注意前面代码片段中对break语句的使用。事实上,switch语句中的break语句的执行会停止switch语句的执行,并将控制权转移给switch语句之后的第一条语句(如果有的话)。在前面的代码片段中,在default标签中使用break语句是不必要的,因为default标签是switch语句中的最后一个标签,并且switch语句的执行将在此之后停止。然而,我建议即使在最后一个标签中也使用一个break语句,以避免以后添加额外标签时出现错误。

用作case标签的常量表达式的值必须在switch-value的数据类型范围内。记住 Java 中的byte数据类型的范围是–128 到 127,下面的代码不会编译,因为第二个case标签是150,它在byte数据类型的范围之外:

byte b = 10;
switch (b) {
    case 5:
        b++;
    case 150: // A compile-time error. 150 is outside the range -128 to 127
        b--;
    default:
        b = 0;
}

switch语句中的两个 case 标签不能相同。下面这段代码无法编译,因为case标签10重复了:

int num = 10;
switch (num) {
    case 10:
        num++;
    case 10: // A compile-time error. Duplicate label 10
        num--;
    default:
        num = 100;
}

需要注意的是,switch语句中每个case的标签必须是编译时常量。也就是说,标签的值必须在编译时已知。否则,会发生编译时错误。例如,下面的代码不会编译:

int num1 = 10;
int num2 = 10;
switch (num1) {
    case 20:
        System.out.println("num1 is 20");
    case num2: // A Compile-time error. num2 is a variable and cannot be used as a label
        System.out.println("num1 is 10");
}

你可能会说,当执行switch语句时,你知道num2的值是 10。但是,所有变量都是在运行时计算的。变量的值在编译时是未知的。因此,case num2:导致了编译器错误。这是必要的,因为 Java 在编译时确保所有的case标签都在switch-value的数据类型范围内。否则,那些 case 标签后面的语句将永远不会在运行时执行。

Tip

default标签是可选的。一条switch语句中最多只能有一个default标签。

if-else语句中的条件表达式比较相同变量的值是否相等时,switch语句是编写if-else语句的一种更清晰的方式。例如,下面的if-elseswitch语句完成了同样的事情:

// Using an if-else statement
if (i == 10)
    System.out.println("i is 10");
else if (i == 20)
    System.out.println("i is 20");
else
    System.out.println("i is neither 10 nor 20");
// Using a switch statement
switch (i) {
    case 10:
        System.out.println(“i is 10");
        break;
    case 20:
        System.out.println("i is 20");
        break;
    default:
        System.out.println("i is neither 10 nor 20");
}

开关表达式

Switch 表达式是作为 Java 12 中的预览特性和 Java 14 中的核心特性引入的。switch 表达式产生单个值,并使用单个表达式、throw 语句或代码块,而不是依赖于 break 关键字。这就产生了一个更清晰、更不容易出错的语法。

例如,将前面的 switch 语句转换为 switch 表达式,如下所示:

switch (i) {
    case 10 -> System.out.println("i is 10");
    case 20 -> System.out.println("i is 20");
    default -> System.out.println("i is neither 10 nor 20");
}

与 switch 语句不同,switch 表达式只产生一个值,因此前面的示例可以重写如下:

String message = switch (i) {
    case 10 -> "i is 10";
    case 20 -> "i is 20";
    default -> "i is neither 10 nor 20";
}
System.out.println(message);

开关表达式使用 case 标签,后跟->和下列之一:

  • 表达式,包括但不限于常量值

  • throw 语句

  • 使用左右花括号的代码块

此外,每个 case 标签可以支持多个用逗号分隔的值。例如,以下开关表达式使用每种类型中的一种:

String message = switch (i) {
    case 10, 15 -> "i is ten or fifteen";
    case 20 -> {
        String str = "i is";
        yield str + " twenty";
    }
    default -> throw new RuntimeException("i is not 10, 15, or 20");
}

第一个 case 语句将匹配 10 或 15。

yield 语句在开关表达式中用于指定开关表达式返回的值。

由于异常脱离了正在执行的当前方法或执行上下文,因此可能会引发异常。我们将在第十三章中全面介绍异常情况。

使用 yield 语句,switch 表达式也支持旧式的 case 标签(case L:),但是不能在同一个 switch 表达式中混合 case 标签类型。换句话说,您必须使用所有旧式的案例标签,或者一个都不使用。

for 语句

for语句是一个迭代语句,用于根据某些条件多次循环一个语句。它也被称为for循环语句或简称为for循环。for循环语句的一般形式是

for (initialization; condition-expression; expression-list)
    statement

initializationcondition-expressionexpression-list用分号隔开。一条for -loop 语句由四部分组成:

  • 初始化

  • 条件表达式

  • 声明

  • 表达式列表

首先,执行初始化部分;然后,对条件表达式求值。如果条件表达式的计算结果为true,则执行与for -loop 语句相关的语句。之后,计算表达式列表中的所有表达式。再次评估条件表达式,如果评估结果为true,则执行与for -loop 语句相关的语句,然后执行表达式列表,依此类推。这个执行循环一直重复,直到条件表达式的值为false。图 6-3 显示了for循环语句的流程图。

img/323069_3_En_6_Fig3_HTML.png

图 6-3

for 循环语句的流程图

例如,下面的for -loop 语句将打印 1 到 10 之间的所有整数,包括 1 和 10:

for(int num = 1; num <= 10; num++)
    System.out.println(num);

首先,int num = 1被执行,它声明了一个名为numint变量,并将其初始化为 1。需要注意的是,在for -loop 语句的初始化部分声明的变量只能在那个for -loop 语句中使用。然后对条件表达式num <= 10求值,为1 <= 10;它第一次评估为true。现在,执行与for -loop 语句相关的语句,打印num的当前值。最后,对表达式列表中的表达式num++进行求值,这将使num的值增加 1。此时,num的值变为 2。对条件表达式2 <= 10求值,返回true,并打印num的当前值。此过程持续到num的值变为 10 并被打印。之后,num++num的值设置为 11,条件表达式11 <= 10返回false,停止执行for -loop 语句。

一个for -loop 语句中的三个部分(初始化、条件表达式和表达式列表)都是可选的。请注意,第四部分(语句)不是可选的。因此,如果在for -loop 语句中没有要执行的语句,则必须使用空块语句或分号来代替语句。被当作语句的分号被称为空语句空语句。使用for -loop 语句的无限循环可以写成如下:

for( ; ; ) {
    // An infinite loop
}

前面的for -loop 语句可以用一个空语句重写,该语句是一个分号,如下所示:

// An infinite loop. Note a semicolon as a statement
for( ; ; );

下面是对for -loop 语句各部分的详细讨论。

初始化

for -loop 语句的初始化部分可以有一个变量声明语句,它可以声明一个或多个相同类型的变量,或者它可以有一个由逗号分隔的表达式语句列表。请注意,初始化部分使用的语句不以分号结尾。以下代码片段显示了for -loop 语句中的初始化部分:

// Declares two variables i and j of the same type int
for(int i = 10, j = 20; ; );
// Declares one double variable salary
for(double salary = 3455.78F; ; );
// Attempts to declare two variables of different types
for(int i = 10, double d1 = 20.5; ; ); /* A compile-time error */
// Uses an expression i++
int i = 100;
for(i++; ; ); // OK
// Uses an expression to print a message on the console
for(System.out.println("Hello"); ; );  // OK
// Uses two expressions: to print a message and to increment num
int num = 100;
for(System.out.println("Hello"), num++; ; );

Tip

当执行for循环时,for循环的初始化部分只执行一次。

您可以在for -loop 语句的初始化部分声明一个新变量。但是,您不能重新声明已经在范围内的变量:

int i = 10;
for (int i = 0; ; ); // An error. Cannot re-declare i

可以在for -loop 语句中重新初始化变量i,如下图:

int i = 10;      // Initialize i to 10
i = 500;         // Value of i changes here to 500
/* Other statements go here... */
for (i = 0; ; ); // Reinitialize i to zero inside the for-loop loop

条件表达式

条件表达式必须计算出truefalseboolean值。否则,会发生编译时错误。条件表达式是可选的。如果它被省略,trueboolean值被假定为条件表达式,这将导致无限循环,除非使用break语句来停止循环。以下两个for -loop 语句导致无限循环,它们是相同的:

// An infinite loop - Implicitly condition-expression is true
for( ; ; );
// An infinite loop - An explicit true is used as the condition-expression
for( ; true; );

break语句用于停止执行for循环语句。当一条break语句被执行时,控制被转移到for循环语句之后的下一条语句,如果有的话。您可以重写for -loop 语句,使用break语句打印 1 到 10 之间的所有整数:

// A for-loop with no condition-expression
for(int num = 1;  ; num++) {
    System.out.println(num); // Print the number
    if (num == 10) {
        break;               // Break out of loop when i is 10
    }
}

这个for -loop 语句打印与前面的for -loop 语句相同的整数。但是,不推荐使用后者,因为您正在使用一个break语句,而不是使用条件表达式来跳出循环。尽可能使用条件表达式来中断for循环是一个很好的编程实践。

表达式列表

表达式列表部分是可选的。它可能包含一个或多个由逗号分隔的表达式。您只能使用可以通过在末尾附加分号来转换为语句的表达式。有关更多详细信息,请参考本章开头对表达式语句的讨论。您可以重写打印 1 到 10 之间所有整数的相同示例,如下所示:

for(int num = 1; num <= 10; System.out.println(num), num++);

注意这个for -loop 语句在表达式列表中使用了两个表达式,用逗号分隔。一个for -loop 语句给了你更多的能力来编写紧凑的代码。

您可以如下重写前面的for -loop 语句,使其更加简洁并完成相同的任务:

for(int num = 1; num <= 10; System.out.println(num++));

请注意,您将表达式列表中的两个表达式合并为一个。您使用了num++作为println()方法的参数,所以它首先打印num的值,然后将其值递增 1。如果用++num代替num++,能否预测前面for循环语句的输出?

也可以使用嵌套的for -loop 语句,即for -loop 语句在另一个for -loop 语句内部。假设您想要打印一个 3 × 3(读作三乘三)矩阵,如下所示:

11      12      13
21      22      23
31      32      33

打印 3 × 3 矩阵的代码可以写成如下:

// Outer for-loop statement
for(int i = 1; i <= 3; i++) {
    // Inner for-loop statement
    for(int j = 1; j <= 3; j++) {
        System.out.print(i + "" + j);
        // Prints a tab after each column value
        System.out.print("\t");
    }
    System.out.println(); // Prints a new line
}

可以使用以下步骤来解释前面的代码:

  1. 执行从外层for-循环语句的初始化部分(int i = 1)开始,其中i被初始化为 1。

  2. 外部for-循环语句(i <= 3)的条件表达式被评估为i等于 1,这是真的。

  3. 外部for循环的语句部分以内部for循环语句开始。

  4. 现在j被初始化为 1。

  5. 内部for-循环语句(j <= 3)的条件表达式被评估为j等于 1,这是真的。

  6. 执行与内部for -loop 语句相关的 block 语句,打印 11 和一个制表符。

  7. 执行内部for-循环语句(j++)的表达式列表,将j的值增加到 2。

  8. 内部for-循环语句(j <= 3)的条件表达式被评估为j等于 2,这是真的。

  9. 执行与内部for -loop 语句相关的 block 语句,打印 12 和一个制表符。在此阶段,打印文本如下所示:

  10. 执行内部for-循环语句(j++)的表达式列表,将j的值增加到 3。

  11. 内部for-循环语句(j <= 3)的条件表达式被评估为j等于 3,这是真的。

  12. 执行与内部for -loop 语句相关的 block 语句,打印 13 和一个制表符。在这一阶段,打印文本如下所示:

11  12

  1. 执行内部for-循环语句(j++)的表达式列表,将j的值增加到 4。

  2. 内部for-循环语句(j <= 3)的条件表达式被评估为j等于 4,这是假的。至此,内for循环完成。

  3. 执行外部for -loop 语句的 block 语句的最后一条语句,即System.out.println()。它打印系统相关的行分隔符。

  4. 执行外部for-循环语句(i++)的表达式列表,将i的值增加到 2。

  5. 现在,内部的for -loop 语句重新开始,其中i的值等于 2。对于等于 3 的i,也执行这一系列步骤。当i变为 4 时,外部的for-循环语句退出,此时,打印出来的矩阵会是这样:

    11   12   13
    21   22   23
    31   32   33
    
    
11  12  13

请注意,这段代码还会在每一行的末尾打印一个制表符,并在最后一行之后打印一个新行,这不是必需的。需要注意的重要一点是,变量j是在每次内部for循环语句启动时创建的,当内部for循环语句退出时被销毁。因此,变量j被创建和销毁三次。您不能在内部for -loop 语句之外使用变量j,因为它已经在内部for -loop 语句中声明,并且它的作用域是内部for -loop 语句的局部。清单 6-2 包含本节讨论的完整代码。该程序确保不打印额外的制表符和新的行字符。

// PrintMatrix.java
package com.jdojo.statement;
public class PrintMatrix {
    public static void main(String[] args) {
        for (int i = 1; i <= 3; i++) {
            for (int j = 1; j <= 3; j++) {
                System.out.print(i + "" + j);
                // Print a tab, except for the last number in a row
                if (j < 3) {
                    System.out.print("\t");
                }
            }
            // Print a new line, except after the last line
            if (i < 3) {
                System.out.println();
            }
        }
    }
}
11    12      13
21    22      23
31    32      33

Listing 6-2Using a for Loop to Print a 3 × 3 Matrix

for-each 语句

Java 5 引入了一个增强的for循环,称为for-each循环。它用于迭代数组和集合的元素。参考关于数组和集合的章节第十九章获得关于for-each循环的详细解释,以及通过集合元素循环的其他方法。for - each循环的一般语法如下:

for(Type element : a_collection_or_an_array) {
    // This code will be executed once for each element in
    // the collection/array.
    // Each time this code is executed, the element
    // variable holds the reference
    // of the current element in the collection/array
}

以下代码片段打印了一个int数组numList的所有元素:

// Create an array with 4 elements
int[] numList = {10, 20, 30, 40};
// Print each element of the array in a separate line
for(int num : numList) {
    System.out.println(num);
}
10
20
30
40

while 语句

while语句是另一个迭代(或循环)语句,用于在条件为真时重复执行一个语句。一条while语句也被称为while循环语句。while循环语句的一般形式是

while (condition-expression)
    statement

条件表达式必须是boolean表达式,语句可以是简单语句,也可以是 block 语句。首先计算条件表达式。如果它返回true,则执行该语句。再次计算条件表达式。如果返回true,则执行该语句。该循环继续,直到条件表达式返回false。图 6-4 显示了一条while语句的流程图。

img/323069_3_En_6_Fig4_HTML.png

图 6-4

while 语句的流程图

for -loop 语句不同,while -loop 语句中的条件表达式不是可选的。例如,要使while语句成为无限循环,需要使用boolean字面量true作为条件表达式:

while (true)
    System.out.println ("This is an infinite loop");

一般来说,for -loop 语句可以转换成while -loop 语句。然而,并不是所有的for循环语句都可以转换成while循环语句。这里显示了一个for循环和一个while循环语句之间的转换:

// A for-loop statement
for (initialization; condition-expression; expression-list)
    statement
// Equivalent while-loop Statements
initialization
while (condition-expression) {
    statement
    expression-list
}

您可以使用如下所示的while循环打印 1 到 10 之间的所有整数:

int i = 1;
while (i <= 10) {
    System.out.println(i);
    i++;
}

这个while loop可以用如下三种不同的方式重写:

// #1
int i = 0;
while (++i <= 10) {
    System.out.println(i);
}
// #2
int i = 1;
while (i <= 10) {
    System.out.println(i++);
}
// #3
int i = 1;
while (i <= 10) {
    System.out.println(i);
    i++;
}

break语句用于退出while循环语句中的循环。您可以使用break语句重写前面的示例,如下所示。请注意,下面这段代码只是为了说明在while循环中如何使用break语句;这不是使用break语句的好例子:

int i = 1;
while (true) { /* Cannot exit the loop from here because it is true */
    if (i <= 10) {
        System.out.println(i);
        i++;
    } else {
        break; // Exit the loop
    }
}

do-while 语句

do-while语句是另一个循环语句。它类似于while -loop 语句,但有一点不同。即使条件表达式第一次评估为false,与while循环语句相关的语句也不能执行一次。然而,与do-while语句相关的语句至少执行一次。do-while语句的一般形式是

do
    statement
while (condition-expression);

注意,do-while语句以分号结束。条件表达式必须是一个boolean表达式。该语句可以是简单语句,也可以是块语句。首先执行语句。然后对条件表达式求值。如果计算结果为true,则再次执行该语句。该循环继续,直到条件表达式评估为false。图 6-5 显示了一条do-while语句的流程图。

img/323069_3_En_6_Fig5_HTML.png

图 6-5

do-while 语句的流程图

像在for循环和while循环中一样,break语句可以用来退出do-while循环。一个do-while循环可以计算 1 到 10 之间的整数之和,如下所示:

int i = 1;
int sum = 0;
do {
    sum = sum + i; // Better to use sum += i
    i++;
}
while (i <= 10);
// Print the result
System.out.println("Sum = " + sum);
Sum = 55

什么时候用do-while语句代替while语句?您可以将每个do-while语句重写为while语句,反之亦然。然而,在某些用例中使用do-while语句会让你的代码更具可读性。考虑以下代码片段:

String filePath = "C:\\kishori\\poem.txt";
BufferedReader reader = new BufferedReader(new FileReader(filePath));
String line;
while((line = reader.readLine()) != null) {
    System.out.println(line);
}

该代码一次读取一行文件的内容,并将其打印在标准输出上。对于这段代码,我省略了错误检查和导入语句的细节。它使用一个while循环。下面的代码片段使用了一个do-while语句来做同样的事情:

String filePath = "C:\\kishori\\poem.txt";
BufferedReader reader = new BufferedReader(new FileReader(filePath));
String line;
do {
    line = reader.readLine();
    if (line != null) {
        System.out.println(line);
    }
 } while (line != null);

您可以看到,当您使用do-while语句时,逻辑并不流畅。在打印之前,您必须使用一个额外的if语句来检查一行之前是否被读取过。在这种情况下,使用while语句是更好的选择。

当循环的条件表达式依赖于循环内部计算的值时,您需要使用do-while语句。假设您需要要求用户输入一个介于 1 和 12 之间的月份值。程序会一直询问用户,直到输入一个有效值。在这种情况下,do-while语句更合适。清单 6-3 包含了完整的程序。我省略了错误检查,比如当用户输入文本而不是整数时。

// UserInput.java
package com.jdojo.statement;
import java.util.Scanner;
public class UserInput {
    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);
        int month;
        do {
            System.out.print("Enter a month[1-12]: ");

            // Read an input from the user
            month = input.nextInt();
        } while (month < 1 || month > 12);
        System.out.println("You entered " + month);
    }
}
Enter a month[1-12]: 20
Enter a month[1-12]: -1
Enter a month[1-12]: 0
Enter a month[1-12]: 9
You entered 9

Listing 6-3Using a do-while Statement to Accept a Valid User Input

Scanner类用于从标准输入中读取输入。在这种情况下,键盘是标准输入。Scanner类的nextInt()方法从键盘中读取下一个整数。程序循环运行,直到用户输入 1 到 12 之间的整数。如果用户输入一个非整数值,程序将因出错而中止。

中断语句

break语句用于退出程序块。break语句有两种形式:

  • 未标记的break语句

  • 标记为break的语句

未标记的break语句的一个例子是

break;

带标签的break语句的一个例子是

break label;

您已经看到了未标记的break语句在switchfor-循环、while-循环和do-while语句中的使用。它将控制转移出它所在的switchfor-循环、while-循环或do-while语句。在这四种嵌套语句的情况下,如果在内部语句中使用了未标记的break语句,它只会将控制权转移到内部语句之外,而不会转移到外部语句之外。假设您想要打印 3 × 3 矩阵的下半部分,如下所示:

11
21      22
31      32      33

要仅打印 3 × 3 矩阵的下半部分,您可以编写以下代码片段:

for(int i = 1; i <= 3; i++) {
    for(int j = 1; j <= 3; j++) {
        System.out.print ( i + "" + j);
        if (i == j) {
            break; // Exit the inner for loop
        }
        System.out.print("\t");
    }
    System.out.println();
}
11
21    22
31    32      33

在内部for -loop 语句中使用了break语句。当外循环计数器(i)的值等于内循环计数器(j)的值时,执行break语句,内循环退出。如果你想从内for循环语句中退出外for循环语句,你必须使用带标签的break语句。Java 中的标签是后跟冒号的任何有效的 Java 标识符。以下是 Java 中的一些有效标签:

  • label1:

  • alabel:

  • Outer:

  • Hello:

  • IamALabel:

现在使用前一个例子中带标签的break语句,看看结果:

outer:  // Defines a label named outer
for(int i = 1; i <= 3; i++ ) {
    for(int j = 1; j <= 3; j++ ) {
        System.out.print(i + "" + j);
        if (i == j) {
            break outer;  // Exit the outer for loop
        }
        System.out.print("\t");
    }
    System.out.println();
}  // The outer label ends here

前面代码片段的输出如下:

11

为什么它只打印了 3 × 3 矩阵的一个元素?这次,您在内部的for循环语句中使用了一个带标签的break语句。当i == j第一次评估为true时,执行带标签的break语句。它将控制权从标记为outer的块中转移出来。请注意,outer标签正好出现在外部for循环语句之前。因此,与标签outer相关联的块是外部的for -loop 语句。带标签的语句不仅可以用在switchfor -loop、while -loop 和do-while语句中;相反,它可以用于任何类型的 block 语句。以下是一个带标签的break语句的简单示例:

blockLabel:
{
    int i = 10;
    if (i == 5) {
        break blockLabel; // Exits the block
    }

    if (i == 10) {
        System.out.println("i is not five");
    }
}

关于带标签的break语句,需要记住的重要一点是,与break语句一起使用的标签必须是使用带标签的break语句的块的标签。以下代码片段说明了带标签的break语句的不正确用法:

lab1:
{
    int i = 10;
    if (i == 10)
        break lab1; // Ok. lab1 can be used here
}
lab2:
{
    int i = 10;
    if (i == 10)
        // A compile-time error. lab1 cannot be used here
        // because this block is not associated with
        // lab1 label. We can use only lab2 in this block.
        break lab1;
}

continue 语句

continue语句只能在for循环、while循环和do-while语句中使用。continue报表有两种形式:

  • 未标记的continue语句

  • 标记为continue的语句

未标记的continue语句的一个例子是

continue;

带标签的continue语句的一个例子是

continue label;

当在for循环中执行continue语句时,循环体中的其余语句被跳过,表达式列表中的表达式被执行。您可以使用for -loop 语句打印 1 到 10 之间的所有奇数,如下所示:

for (int i = 1; i < 10; i += 2) {
    System.out.println(i);
}

在这个for -loop 语句中,您在表达式列表中将i的值增加 2。你可以用一条continue语句重写之前的for -loop 语句,如图 6-6 所示。

img/323069_3_En_6_Fig6_HTML.png

图 6-6

在 for-loop 语句中使用 continue 语句

对于是 2 的倍数的i的值,表达式i % 2返回 0,表达式i % 2 == 0返回true。在这种情况下,执行continue语句,跳过最后一条语句System.out.println(i)。增量语句i++continue语句执行后执行。前面的代码片段肯定不是使用continue语句的最佳例子;然而,它的目的是说明其用途。

当在while循环或do-while循环中执行未标记的continue语句时,循环中剩余的语句将被跳过,条件表达式将在下一次迭代中进行计算。例如,图 6-7 中的代码片段将使用while循环中的continue语句打印 1 到 10 之间的所有奇数。

img/323069_3_En_6_Fig7_HTML.png

图 6-7

在 while-loop 语句中使用 continue 语句

for循环和while循环中使用continue语句的主要区别在于控制转移的位置。在一个for循环中,控制被转移到表达式列表,而在一个while循环中,控制被转移到条件表达式。这就是为什么在不修改一些逻辑的情况下,for -loop 语句不能总是被转换成while -loop 语句。

未标记的continue语句总是继续最内层的for循环、while循环和do-while循环。如果你正在使用嵌套循环语句,你需要使用一个带标签的continue语句来继续外层循环。例如,您可以使用如下所示的continue语句重写打印 3 × 3 矩阵下半部分的代码片段:

outer: // The label "outer" starts here
for(int i = 1; i <= 3; i++) {
    for(int j = 1; j <= 3; j++) {
        System.out.print(i + "" + j);
        System.out.print("\t");
        if (i == j) {
            System.out.println(); // Print a new line
            continue outer;       // Continue the outer loop
        }
    }
}  // The label "outer" ends here

空洞的声明

空语句本身就是一个分号。一个空的语句没有任何作用。如果一个空的声明没有任何作用,我们为什么要有它?有时,语句是结构语法的一部分。然而,你可能不需要做任何有意义的事情。在这种情况下,使用空语句。一个for循环必须有一个与之相关联的语句。然而,要打印 1 到 10 之间的所有整数,您只能使用一个for -loop 语句的初始化、条件表达式和表达式列表部分。在这种情况下,您没有与for循环语句相关联的语句。因此,在这种情况下使用空语句,如下所示:

for(int i = 1; i <= 10; System.out.println(i++))
;  // This semicolon is an empty statement for the for loop

有时,空语句用于避免代码中的双重否定逻辑。假设noDataFound是一个boolean变量。您可以编写如下所示的代码片段:

if (noDataFound)
    ; // An empty statement
else {
      // Do some processing
}

前面的if-else语句可以不使用空语句来编写,如下所示:

if (!noDataFound) {
    // Do some processing
}

使用哪种代码是个人的选择。最后,请注意,如果您在只需要一个分号的地方键入两个或更多分号,这不会导致任何错误,因为每个多余的分号都被视为一个空语句,例如:

i++;  // Ok. Here, semicolon is part of statement
i++;; // Still Ok. The second semicolon is considered an empty statement.

在不允许使用语句的地方,不能使用空语句。例如,当只允许一个语句时,添加额外的空语句将导致错误,如下面的代码片段所示。它将两个语句i++;和一个空语句(;)关联到一个if语句,其中只允许一个语句:

if (i == 10)
    i++;; // A compile-time error. Cannot use two statements before an else statement
else
    i--;

摘要

Java 程序中的语句指定一个动作。Java 中的语句可以大致分为三类:声明语句、表达式语句和控制流语句。声明语句用于声明变量。表达式语句用于计算表达式的值。控制流语句控制其他语句的执行顺序。控制流语句包括ifif-else和循环语句。循环语句重复执行语句块,直到某个条件变为假。Java 提供了四个循环语句:for循环、for-each循环、while循环和do-while循环。break语句用于将控制转移到 block 语句或循环之外。continue语句用于忽略执行循环的剩余代码,并继续下一次迭代。Java 也有一个空语句,它本身就是一个分号。

EXERCISES

  1. 什么是语句?

  2. 什么是表达式?Java 中如何把一个表达式转换成表达式语句?可以把 Java 中所有类型的表达式都转换成表达式语句吗?

  3. 什么是控制语句,你为什么使用它们?

  4. 什么是 block 语句,如何创建 block 语句?

  5. 什么是空言?

  6. while -loop 和do-while语句有什么区别?

  7. 一个switch语句包含一个switch-value。列出一个switch-value必须评估的所有类型。

  8. 什么时候可以用switch语句代替if-else语句?

  9. 考虑下面的代码片段。count变量的有效值必须在 11(含)到 20(含)的范围内。编写if-else语句的条件,以便打印出正确的消息:

    int count = 20;
    if(<your-code-goes-here>)
        System.out.println("Count is valid.");
    else
        System.out.println("Count is invalid");
    
    
  10. 修复以下代码片段中的编译时错误。确保固定代码打印出y :

```java
int x = 10;
int y = 20;
if (x = 10)
    y++;
    System.out.println("y = " + y);
else
    y--;
    System.out.println("y = " + y);

```

的值
  1. 使用if-else语句重写以下代码片段。当您将变量x初始化为另一个值时,确保switchif-else语句具有相同的输出。(提示:这是一个棘手的问题,因为在任何case标签中都没有break语句。)
```java
int x = 50;
switch (x) {
    case 10:
        System.out.println("Ten");
    default:
        System.out.println("No-match");
    case 20:
        System.out.println("Twenty");
}

```
  1. 下面的代码片段是上一个代码片段的修改版本。使用 if-else 语句重写它。当您将变量 x 初始化为另一个值:
```java
int x = 50;
switch (x) {
    case 10:
        System.out.println("Ten");
        break;
    default:
        System.out.println("No-match");
        break;
    case 20:
        System.out.println("Twenty");
        break;
}

```

时,确保 switch 和 if-else 语句具有相同的输出
  1. 一名程序员正在学习switch语句,他们试图在任何可能的地方使用它。下面的代码片段是在不需要的地方强制使用的一个例子。不使用控制流语句重写以下代码片段。也就是说,你需要去掉switch语句,让程序逻辑保持不变:
```java
int x = 10;
// Some logic goes here...
switch(x) {
    default:
    x++;
}

```
  1. 如何使用forwhiledo-while语句编写一个无限循环?举一个例子。

  2. 下面的for语句的目的是以相反的顺序打印从 1 到 10 的整数。代码没有按预期打印数字。识别逻辑错误并修复代码,这样它会输出 10,9,8,…1:

```java
for(byte b = 10; b >= 1; b++)
    System.out.println(b);

```
  1. 写一个for语句,以逆序打印从 13 到 1 的所有奇数。for语句的主体必须是空语句。也就是说,您可以只使用for语句的初始化、条件表达式和表达式列表来编写您的所有逻辑。您的for声明模板如下:

  2. 使用for语句编写一段代码,计算从 1 到 10 的所有整数之和,并在标准输出中打印出来。您的代码模板如下:

    int sum = 0;
    for(<your-code>; <your-code>; <your-code>);
    System.out.println("Sum = " + sum);
    
    
  3. 使用嵌套的for语句打印下面的金字塔。

        *
       ***
      *****
     *******
    
    
  4. 编写一个嵌套的for语句,它将打印以下内容:

         1
        22
       333
      4444
     55555
    666666
    
    
  5. 完成以下代码片段。它应该打印从lowerupper的所有整数的逗号分隔列表。比如lower是 1,upper是 4,就应该打印1, 2, 3, 4。(提示:使用System.out.print()打印不带新行的消息。)

    int lower = 1;
    int upper = 4;
    for(<your-code-goes-here>) {
        <your-code-goes-here>
    
    
for(<your-code>; <your-code>; <your-code>);

七、类

在本章中,您将学习:

  • Java 中有哪些类

  • 如何在 Java 中定义类

  • 如何声明类成员,如字段

  • 如何创建一个类的对象

  • 如何在编译单元中声明 import 语句

  • 如何在 Java 中定义记录

什么是课?

类是面向对象范例中编程的基本单位。在第三章中,你看到了 Java 中一个类的一些基本方面,例如,使用class关键字声明一个类,声明main()方法运行一个类,等等。本章详细解释了如何声明和使用一个类。

让我们从现实世界中一个简单的类的例子开始,来构建 Java 中一个类的技术概念。当你环顾四周,你会看到许多物体,比如书、电脑、键盘、桌子、椅子、人等等。您看到的每个对象都属于一个类。问自己一个简单的问题,“我是谁?”你显然会回答:我是人。你说你是人类是什么意思?你的意思是世界上存在一个人类阶级,而你是那个阶级的一个实例(“存在”)。你也明白其他人类(人类类的其他实例)也存在,他们与你相似,但不相同。你和你的朋友都是同一人类类的实例,具有相同的属性,如姓名、性别、身高、体重和行为,如思考、说话、行走的能力等。然而,对你和你的朋友来说,属性和行为在价值、质量或两者方面都不同。例如,两者都有名字和说话的能力。然而,你的名字可能是理查德,你的朋友的名字可能是格雷格。你可能说得慢,而你的朋友可能说得快。如果你想为你和你的朋友准备一个模型来检查你的行为,有两个选择:

  • 你可以分别列出你和你的朋友的所有属性和行为,并分别检查它们,就好像你和你的朋友之间没有联系一样。

  • 您可以列出您和您的朋友共有的属性和行为,然后在不指明您和您的朋友的情况下,将它们作为实体的属性和行为进行检查。该模型假设所有列出的属性和行为都将出现在一个实体中(没有命名),尽管它们可能因实体而异。您可能希望将您和您朋友的所有属性和行为作为一个类的属性和行为列出,比如说 human,并将您和您的朋友视为该 human 类的两个不同实例。本质上,您已经将具有相似属性和行为的实体(例如,您和您的朋友)分组在一起,并将该组称为类。然后,您将把所有对象(同样,您和您的朋友)视为该类的实例。

第一种方法将每个对象视为一个独立的实体。在第二种方法中,基于属性和行为的相似性对对象进行分类,其中对象总是属于一个类;类成为编程的基本部分。要确定对象的任何属性或行为,您需要查找它的类定义。例如,你是人类类的一个对象。你会飞吗?这个问题可以通过一系列步骤来回答。首先,你需要回答这个问题,“你属于哪个阶层?”答案是你属于人类阶级。人类类是否定义了一种飞行行为?答案是否定的。因为你是没有定义飞行行为的人类类的实例,所以你不能飞行。如果你仔细观察你得出答案的方式,你会发现这个问题是对一个物体(你)提出的,但答案是由这个物体所属的类(人)提供的。

类是必不可少的,它们是面向对象编程中程序的基本部分。它们被用作创建对象的模板。如何在 Java 中定义一个类?Java 中的一个类可能由五个部分组成:

  • 菲尔茨

  • 方法

  • 构造器

  • 静态初始化器

  • 实例初始化器

字段和方法也称为类的成员。类和接口也可以是类的成员。本章只关注字段。一个类可以有零个或多个类成员。一个类的类成员也称为嵌套类

类似于在婴儿出生时给出人的初始特征,如名字、性别、身高和体重,新创建的对象的属性在对象被创建时被初始化。在 Java 中,给对象的属性赋予初始值叫做初始化一个对象。构造器用于初始化一个类的对象。一个类必须至少有一个构造器。

初始化器用于初始化类的字段。您可以有零个或多个静态或实例类型的初始值设定项。初始化器和构造器执行相同的任务。初始化器也可以用来初始化类级别的字段,而构造器只能初始化对象级别的字段。

本章的其余部分将讨论如何声明和使用一个类的字段。

声明类

用 Java 声明类的一般语法如下:

[modifiers] class <class-name> {
    // Body of the class goes here
}

这里

  • modifiers是可选的;它们是将特殊含义与类声明相关联的关键字。一个类声明可以有零个或多个修饰符。

  • 关键字class用于声明一个类。

  • class-name是用户定义的类名,应该是有效的 Java 标识符。

  • 每个类都有一个主体,在一对大括号({})中指定。一个类的主体包含它的不同组成部分,例如,字段、方法等。

下面的代码片段定义了一个名为Human的类,其主体为空。注意Human类不使用任何修饰符:

// Human.java
class Human {
    // An empty body for now
}

下面的代码片段定义了一个名为Human的公共类,其主体为空。注意,这个声明使用了一个public修饰符:

// Human.java
public class Human {
    // An empty body for now
}

我将在本章后面详细解释公共类和其他类型类的区别。

在类中声明字段

类的字段表示该类对象的属性(也称为特性)。假设一个Human类的每个对象都有两个属性:一个名字和一个性别。Human类应该包含两个字段的声明:一个表示姓名,一个表示性别。

这些字段是在类体中声明的。在类中声明字段的一般语法是

[modifiers] class <class-name> {
    // A field declaration
    [modifiers] <data-type> <field-name> [= <initial-value>];
}

一个字段声明可以使用零个或多个modifiers。字段的数据类型位于其名称之前。或者,您也可以用一个值初始化每个字段。如果你不想初始化一个字段,它的声明应该在它的名字后面用分号结束。

有了两个字段namegender,Human类的声明如下所示:

// Human.java
class Human {
    String name;
    String gender;
}

Tip

Java 中有一个约定(不是规则或要求),以大写字母开始类名,后面的单词大写,例如,HumanTableColorMonitor等。字段和方法的名称要以小写字母开头,后面的单词要大写,例如namefirstNamemaxDebitAmount等。

Human类声明了两个字段:namegender。两个字段都是String类型。Human类的每个实例(或对象)都有这两个字段的副本。

有时属性属于类本身,而不属于该类的任何特定实例。例如,所有人类的计数不是任何特定人类的属性。相反,它属于人类阶级本身。人类计数的存在不依赖于人类类的任何特定实例,即使人类类的每个实例都对 count 属性的值有贡献。不管该类有多少个实例,都只存在一个类属性副本。但是,类的每个实例都有一个单独的实例属性副本。例如,namegender属性的单独副本存在于Human类的每个实例中。你总是指定一个人的名字和性别。然而,即使没有Human类的实例,也可以说Human类实例的数量为零。

Java 允许您为一个类声明两种类型的字段:

  • 类别字段

  • 实例字段

类字段也被称为类变量。实例字段也被称为实例变量。在前面的代码片段中,namegenderHuman类的两个实例变量。Java 有一种不同的方法来声明类变量。所有的类变量都必须使用关键字static作为修饰符来声明。清单 7-1 中Human类的声明增加了一个count类变量。

// Human.java
package com.jdojo.cls;
class Human {
    String name;        // An instance variable
    String gender;      // An instance variable
    static long count;  // A class variable because of the static modifier
}

Listing 7-1Declaration of a Human Class with One Class Variable and Two Instance Variables

Tip

一个类变量也被称为静态变量。实例变量也被称为非静态变量

创建类的实例

下面是创建类实例的一般语法:

new <Call-to-Class-Constructor>;

new操作符之后是对正在创建实例的类的构造器的调用。new操作符通过在堆上分配内存来创建一个类的实例。下面的语句创建了一个Human类的实例:

new Human();

这里,Human()是对Human类的构造器的调用。你给你的Human类添加了构造器了吗?不,你没有添加任何构造器到你的Human类中。您只添加了三个字段。你怎么能为一个没有添加的类使用构造器呢?当您没有向类中添加构造器时,Java 编译器会为您添加一个。Java 编译器添加的构造器称为默认构造器。默认构造器不接受任何参数。类的构造器的名称与类名相同。我们将在第九章中详细讨论构造器。

当一个类的实例被创建时会发生什么?new操作符为类的每个实例字段分配内存。回想一下,在创建类的实例时,没有给类变量分配内存。图 7-1 描述了内存中Human类的一个实例。

img/323069_3_En_7_Fig1_HTML.png

图 7-1

由 new Human()实例创建表达式在内存中创建的 Human 类的实例

图 7-1 显示内存是为实例变量namegender分配的。您可以创建任意多的Human类的实例。每次创建Human类的实例时,Java 运行时都会为namegender实例变量分配内存。为一个Human类的实例分配了多少内存?简单的答案是,您不知道一个类的实例使用了多少内存,事实上,您也不需要知道这一点。Java 运行时会自动为您处理内存分配和释放。

现在,您想要向前移动一步,并为新创建的Human类实例的namegender实例变量赋值。您能给新创建的Human类实例的namegender实例变量赋值吗?答案是否定的。您不能访问namegender实例变量,即使它们存在于内存中。要访问一个类实例的实例变量,你必须有它的引用(或句柄)。表达式new Human()在内存中创建了一个Human类的新实例。新创建的实例就像一个充满氦气的气球留在空中。当你在空中释放一个充满氦气的气球时,你就失去了对气球的控制。如果在气球释放到空中之前给它系上一根绳子,你可以用这根绳子控制气球。类似地,如果您想要控制(或访问)一个类的实例,您必须将该实例的引用存储在一个引用变量中。你用一根绳子控制一个气球;你用遥控器控制电视。控制设备的类型取决于您想要控制的对象的类型。类似地,您需要使用不同类型的引用变量来引用(或处理或使用)不同类的实例。

在 Java 中,类名定义了一个新的引用类型。特定引用类型的变量可以在内存中存储相同引用类型的实例的引用。假设您想要声明一个引用变量,它将存储一个对Human类实例的引用。您将如下所示声明变量:

Human jack;

这里,Human是类名,也是引用类型,jack是该类型的变量。换句话说,jack是一个Human类型的参考变量。jack变量可以用来存储Human类实例的引用。

操作符为一个类的新实例分配内存,并返回对该实例的引用(或间接指针)。您需要将由new操作符返回的引用存储在一个引用变量中:

jack = new Human();

注意jack本身是一个变量,它会被单独分配内存。jack变量的内存位置将存储新创建的Human类实例的内存位置的引用。图 7-2 描述了当引用变量jack被声明时,以及当Human类的实例被创建且其引用被分配给jack变量时的内存状态。

img/323069_3_En_7_Fig2_HTML.png

图 7-2

当引用变量被声明时,以及当引用变量被赋予一个类的实例的引用时,内存状态

您可以将jack变量视为内存中Human实例的远程控制器。您可以使用jack变量引用内存中的Human实例。我们将在下一节讨论如何使用引用变量。您也可以将两个语句合并成一个:

Human jack = new Human();

空引用类型

Java 中的每个类都定义了一个新的引用类型。Java 有一种特殊的引用类型,称为空类型。它没有名字。因此,不能定义空引用类型的变量。空引用类型只有一个由 Java 定义的值,即null文字。简直就是null。null 引用类型与所有其他引用类型都是赋值兼容的。也就是说,你可以将null赋给任何引用类型的变量。实际上,null存储在引用变量中意味着引用变量不引用任何对象。你可以把null存储在一个引用变量中,作为一个不带气球的字符串,其中气球是一个有效对象,字符串是一个引用变量。例如,您可以编写如下代码:

// Assign null to john
Human john = null;  // john is not referring to any object
john = new Human(); // Now, john is referring to a valid Human object

您可以使用带有比较运算符的null来检查相等和不相等:

if (john == null) {
    // john is referring to null. Cannot use john for anything
} else {
    // Do something with john
}

如果你对一个null引用执行一个操作,一个NullPointerException被抛出:

Human john = null;
// The following statement throws a NullPointerException because john is null and you
// cannot use any operation on a null reference variable
String name = john.name;

注意null是 null 类型的文字。Java 不允许混合引用类型和原始类型。不能将null赋给原始类型的变量。以下赋值语句将生成编译时错误:

// A compile-time error. A reference type value, null, cannot be assigned to
// a primitive type variable num
int num = null;

因为null(或任何引用类型的值)不能赋给一个原始类型的变量,所以 Java 编译器不允许您将原始值与null值进行比较。下面的比较将产生一个编译时错误。换句话说,您可以将引用类型与其他引用类型进行比较,并将基元类型与其他基元类型进行比较:

int num = 0;
// A compile-time error. Cannot compare a primitive type to a reference type
if (num == null) {
}

Tip

Java 有一个特殊的引用类型,叫做 null 类型。空类型没有名称。null 类型有一个文字值,用null表示。null 类型与所有其他引用类型都是赋值兼容的。您可以为任何引用类型变量分配一个null值。您可以将null值转换为任何引用类型。需要强调的是,null是“空引用类型”的文字值,而不是关键字。

使用点符号访问类的字段

点符号用于引用实例变量。点符号语法的一般形式如下:

<reference-variable-name>.<instance-variable-name>

例如,使用jack.name来引用jack引用变量所引用的实例的name实例变量。如果您想给name实例变量赋值,您可以使用下面的方法:

jack.name = "Jack Parker";

以下语句将name实例变量的值赋给String变量aName:

String aName = jack.name;

如何引用类变量?使用点符号有两种方法引用类变量:

  • 使用类的名称

  • 使用类实例的引用

您可以使用类的名称来引用类变量:

<class-name>.<class-variable-name>

例如,你可以使用Human.count来引用Human类的count类变量。要给count类变量赋一个新值,比如 101,可以这样写:

Human.count = 101;

要将count类变量的值读入一个名为population的变量,您可以使用:

long population = Human.count;

还可以使用引用变量来引用类的类变量。例如,你可以使用jack.count来引用Human类的count类变量。您可以使用下面的语句给count类变量赋值,比如 101:

jack.count = 101;

以下语句将count类变量的值读入名为population的变量:

long population = jack.count;

这两个语句都假设jack是一个Human类型的引用变量,并且它引用一个有效的Human实例。

Tip

您可以使用类名或类类型的引用变量来引用类变量。因为类变量属于类,并且由类的所有实例共享,所以使用类名引用它是合乎逻辑的。但是,您必须始终使用类类型的引用变量来引用实例变量。

是时候看看Human类中的字段了。本章中的大多数类都是jdojo.cls模块的一部分,如清单 7-2 中所声明的。模块名中的cls是 class 的简称。你不能使用jdojo.class作为模块名,因为class是一个关键字。该模块输出一个com.jdojo.cls包。您还没有学习模块声明中的exports语句。我在这一章解释它。

// module-info.class
module jdojo.cls {
    exports com.jdojo.cls;
}

Listing 7-2Declaration of the jdojo.cls Module

清单 7-3 有一个完整的程序,演示了如何访问一个类的类变量和实例变量。

// FieldAccessTest.java
package com.jdojo.cls;
class FieldAccessTest {
    public static void main(String[] args) {
        // Create an instance of the Human class
        Human jack = new Human();
        // Increase count by one
        Human.count++;
        // Assign values to name and gender
        jack.name = "Jack Parker";
        jack.gender = "Male";
        // Read and print the values of name, gender and count
        String jackName = jack.name;
        String jackGender = jack.gender;
        long population = Human.count;
        System.out.println("Name: " + jackName);
        System.out.println("Gender: " + jackGender);
        System.out.println("Population: " + population);
        // Change the name
        jack.name = "Jackie Parker";
        // Read and print the changed name
        String changedName = jack.name;
        System.out.println("Changed Name: " + changedName);
    }
}
Name: Jack Parker
Gender: Male
Population: 1
Changed Name: Jackie Parker

Listing 7-3Using Fields in a Class Declaration

该程序中的以下语句需要一些解释:

// Increase count by one
Human.count++;

它在count类变量上使用增量运算符(++)。在count类变量增加 1 后,您读取并打印它的值。输出显示其值增加 1 后,其值变为1。这意味着在执行Human.count++语句之前,它的值为零。但是,您从未将其值设置为零。其声明如下:

static long count;

当如前所示声明count类变量时,默认情况下它被初始化为零。如果没有给一个类的所有字段(类变量和实例变量)赋一个初始值,那么它们都被初始化为默认值。下一节描述用于初始化类的字段的规则。

字段的默认初始化

一个类的所有字段,静态的和非静态的,都被初始化为默认值。字段的默认值取决于其数据类型:

  • 一个数值字段(byteshortcharintlongfloatdouble)被初始化为零。

  • 一个boolean字段被初始化为false

  • 引用类型字段被初始化为null

根据这些规则,Human类的字段将被初始化如下:

  • count类变量被初始化为零,因为它是数字类型。这就是Human.count++评估为1 ( 0 + 1 = 1)的原因,如清单 7-3 的输出所示。

  • namegender实例变量属于String类型。String是引用类型。它们被初始化为null。回想一下,namegender字段的副本存在于Human类的每个对象中,并且namegender的每个副本被初始化为null

如果您考虑默认初始化Human类的字段,它的行为就好像您已经声明了Human类,如下所示。这个Human类的声明和清单 7-1 中显示的声明是相同的:

class Human {
    String name = null;
    String gender = null;
    static long count = 0;
}

清单 7-4 展示了字段的默认初始化。DefaultInit类只包含实例变量。类字段使用与实例字段相同的默认值进行初始化。如果将DefaultInit类的所有字段声明为static,输出将是相同的。该类包括两个引用类型的实例变量,strjack,它们是StringHuman类型。注意StringHuman都是引用类型,默认情况下null被分配给它们的引用。

// DefaultInit.java
package com.jdojo.cls;
class DefaultInit {
    byte b;
    short s;
    int i;
    long l;
    float f;
    double d;
    boolean bool;
    String str;
    Human jack;

    public static void main(String[] args) {
        // Create an object of DefaultInit class
        DefaultInit obj = new DefaultInit();
        // Print the default values for all instance variables
        System.out.println("byte is initialized to " + obj.b);
        System.out.println("short is initialized to " + obj.s);
        System.out.println("int is initialized to " + obj.i);
        System.out.println("long is initialized to " + obj.l);
        System.out.println("float is initialized to " + obj.f);
        System.out.println("double is initialized to " + obj.d);
        System.out.println("boolean is initialized to " + obj.bool);
        System.out.println("String is initialized to " + obj.str);
        System.out.println("Human is initialized to " + obj.jack);
    }
}
byte is initialized to 0
short is initialized to 0
int is initialized to 0
long is initialized to 0
float is initialized to 0.0
double is initialized to 0.0
boolean is initialized to false
String is initialized to null
Human is initialized to null

Listing 7-4Default Initialization of Class Fields

类的访问级别修饰符

在清单 7-1 中,您在com.jdojo.cls包中创建了Human类。您使用了清单 7-3 中的Human类来创建它在FieldAccessTest类中的对象,该类与Human类在同一个模块和同一个包中。编译和运行清单 7-3 中的以下语句没有问题:

Human jack = new Human();

让我们在jdojo.cls模块的com.jdojo.common包中创建一个名为ClassAccessTest的类。注意ClassAccessTestHuman类在不同的包中。ClassAccessTest类声明如下:

// ClassAccessTest.java
package com.jdojo.common;
public class ClassAccessTest {
    public static void main(String[] args) {
        Human jack;
    }
}

ClassAccessTest类的代码非常简单。它只做一件事——在其main()方法中声明一个Human类型的引用变量。编译ClassAccessTest类。哎呀!您得到了一个编译时错误:

ClassAccessTest.java:6: error: cannot find symbol
     Human jack;
        ^
  symbol:   class Human
  location: class ClassAccessTest
1 error

如果您仔细阅读错误,编译器会抱怨以下变量声明中的类型Human:

Human jack;

编译器声明它找不到术语Human的定义。用jack变量声明的ClassAccessTest类有什么问题?当您通过类的简单名称来引用一个类时,编译器会在引用类所在的同一个包中查找该类声明。在您的例子中,引用的类ClassAccessTestcom.jdojo.common包中;它使用简单的名字Human来引用Human类。因此,编译器在com.jdojo.common包中寻找Human类。编译器正在寻找一个不存在的com.jdojo.common.Human类。这是您收到错误的原因。

通过在ClassAccessTest中使用简单的名称Human,您的意思是指com.jdojo.cls包中的Human类,而不是com.jdojo.common包中的类。如果在com.jdojo.common包中有Human类,那么ClassAccessTest的代码就会被编译。让我们假设您没有一个com.jdojo.common.Human类,并且您想要修复这个错误。您可以通过使用Human类的完全限定名来修复它,就像这样:

// ClassAccessTest.java
package com.jdojo.common;
public class ClassAccessTest {
    public static void main(String[] args) {
        com.jdojo.cls.Human jack;
    }
}

现在编译ClassAccessTest类。哎呀!您又遇到了编译时错误。然而,这一次,错误不同了:

ClassAccessTest.java:6: error: Human is not public in com.jdojo.cls; cannot be accessed from outside package
        com.jdojo.cls.Human jack;
                     ^
1 error

这一次,编译器并不是说它不理解Human类型。是说它知道什么是com.jdojo.cls.Human型;然而,它只能在声明它的com.jdojo.cls包中访问。换句话说,Human类型在com.jdojo.common包中是不可访问的。这里出现了类的访问级别的概念。

当声明一个类时,还可以指定该类是可以从任何包中访问(或使用或引用),还是只能从声明该类的包中访问。例如,您可以在Human类的声明中指定是只能从com.jdojo.cls包中访问它,还是可以从任何包中访问它,包括com.jdojo.common包。指定类的访问级别的一般语法如下:

[access-level-modifier] class <class-name> {
    // Body of the class goes here
}

类声明中的访问级别修饰符只有两个有效值:无值和public:

  • 无值:与没有访问级别修饰符相同。它也被称为包级访问。如果一个类具有包级访问权限,那么它只能在声明它的包中被访问。清单 7-1 中的Human类拥有包级访问权限。这就是您能够使用(或访问)清单 7-3 中的FieldAccessTest类中的Human类的原因。注意,Human类和FieldAccessTest类在同一个包中,并且都有包级访问。因此,它们可以相互参照。Human类在com.jdojo.cls包中,它有包级访问。因此,不能从任何其他包访问它,例如com.jdojo.common。这就是你试图编译ClassAccessTest类时收到错误的原因。

  • 公共:带有public访问级别修饰符的类可以从同一个模块中的任何包中访问。如果您想让Human类可以从任何包(例如com.jdojo.common)中访问,您需要将它声明为public

模块M中声明的类C可以在模块N中访问吗?这个问题的答案取决于类C的访问级别修饰符和模块MN的声明。要使模块N中的包中的C类可访问,必须满足以下标准:

  • 模块M中的类C必须声明为public

  • 模块M必须将类C的包导出到所有其他模块,或者至少导出到模块N。通过导出包,模块声明包中的公共类(或任何类型)可以被所有或一些其他模块使用。

  • 模块N的声明必须需要模块M

模块依赖是一个很大的话题。我们在第十章中详细讨论。在这一章中,我们限制在同一个模块中讨论一个类型的可访问性,除非必须提到模块依赖。

让我们重新定义Human类,如清单 7-5 所示。这一次,您已经将它的访问级别指定为public,因此可以从任何包中访问它。

// Human.java
package com.jdojo.cls;
public class Human {
    String name;        // Instance variable
    String gender;      // Instance variable
    static long count;  // Class variable
}

Listing 7-5Redefined Human Class with the Public Access Level Modifier

重新编译Human类,然后编译ClassAccessTest类。这一次,ClassAccessTest类编译没有任何错误。

Tip

当我说一个类可以从一个包中访问时,这意味着什么?类定义了一个新的引用类型。引用类型可用于声明变量。当类在包中可访问时,类名可以用作引用类型,例如,在驻留在该包中的代码中声明变量。

进口申报

在上一节中,您学习了两条规则:

  • 您必须声明一个类public才能在声明它的包之外的包中使用它。如果另一个包在另一个模块中,那么在两个模块声明中都需要额外的工作来使public类可被访问。

  • 您需要使用类的完全限定名,以便在声明它的包之外的包中使用它。可以在声明类的包中使用它的简单名称来引用它。

第一条规则无可替代。也就是说,如果一个类需要从它的包外部访问,它必须被声明为public

还有另一种方法来处理第二条规则。通过使用导入声明,可以在包外通过简单名称引用类。导入声明用于将类从编译单元的包外部导入到编译单元中。从技术上讲,导入声明用于将任何类型导入编译单元,而不仅仅是类。导入声明出现在包声明之后,第一个类型声明之前。图 7-3 显示了进口申报出现的地方。在一个编译单元中可以有零个或多个导入声明。

img/323069_3_En_7_Fig3_HTML.png

图 7-3

Java 中编译单元的结构

本节只提到导入类。但是,同样的规则适用于导入任何其他类型,例如,接口、注释或枚举。因为到目前为止我只讨论了类类型,所以在这个讨论中我没有提到任何其他类型。

有两种类型的进口申报:

  • 单一类型进口报关单

  • 按需进口申报

单一类型进口报关单

单一类型导入声明用于从包中导入单一类型(例如,一个类)。它采取以下形式:

import <fully-qualified-name-of-a-type>;

下面的导入声明从com.jdojo.cls包中导入了Human类:

import com.jdojo.cls.Human;

单一类型导入声明仅从包中导入一种类型。如果您想要从一个包中导入多个类型(例如,三个类),您需要为每个类型使用一个单独的导入声明。以下进口报关单从pkg1包进口Class11,从pkg2包进口Class21Class22,从pkg3包进口Class33:

import pkg1.Class11;
import pkg2.Class21;
import pkg2.Class22;
import pkg3.Class33;

让我们重新看看com.jdojo.common.ClassAccessTest类,它有一个编译时错误:

// ClassAccessTest.java
package com.jdojo.common;
public class ClassAccessTest {
    public static void main(String[] args) {
        Human jack;
    }
}

当您使用简单名称的Human类时,您会收到一个编译时错误,因为编译器在com.jdojo.common包中找不到Human类。您通过使用Human类的完全限定名解决了这个错误,如下所示:

// ClassAccessTest.java
package com.jdojo.common;
public class ClassAccessTest {
    public static void main(String[] args) {
        com.jdojo.cls.Human jack; // Uses full qualified name for the Human class
    }
}

您还有另一种方法来解决这个错误,那就是使用单一类型的导入声明。您可以导入com.jdojo.cls.Human类来使用它的简单名称。修改后的ClassAccessTest类声明如下:

// ClassAccessTest.java – Modified version
package com.jdojo.common;
import com.jdojo.cls.Human; // Import the Human class
public class ClassAccessTest {
    public static void main(String[] args) {
        Human jack;         // Use simple name of the Human class
    }
}

修改后的ClassAccessTest类编译良好。当编译器在语句中遇到简单的类名Human时,比如

Human jack;

它遍历所有的导入声明,将简单名称解析为完全限定的名称。当它试图解析简单名称Human时,它会找到导入声明import com.jdojo.cls.Human,该声明导入了Human类。它假设您在前面的语句中使用简单名称Human时打算使用com.jdojo.cls.Human类。编译器用下面的语句替换前面的语句:

com.jdojo.cls.Human jack;

Tip

导入声明允许您在代码中使用简单的类型名称,从而使代码更具可读性。编译代码时,编译器用完全限定名替换类型的简单名称。它使用导入声明将类型的简单名称转换为它们的完全限定名称。需要强调的是,在 Java 程序中使用导入声明不会影响编译代码的大小或运行时性能。使用导入声明只是在源代码中使用简单类名的一种方式。

使用导入声明时,有许多微妙的地方需要记住。我们将很快讨论它们。

按需进口申报

有时您可能需要从同一个包中导入多种类型。您需要使用与需要从包中导入的类型数量一样多的单类型导入声明。按需导入声明用于使用一个import声明从包中导入多种类型。按需导入声明的语法是

import <package-name>.*;

这里,包名后面是一个点和一个星号(*)。例如,下面的按需导入声明从com.jdojo.cls包中导入所有类型:

import com.jdojo.cls.*;

有时,在按需导入声明中使用星号会导致对导入类型的错误假设。假设有两个类,C1C2。他们分别在p1p1.p2包里。也就是他们的全限定名是p1.C1p1.p2.C2。您可以将按需进口声明编写为

import p1.*;

认为它将导入两个类,p1.C1p1.p2.C2。这个假设是错误的。宣言

import p1.*;

仅从p1包中导入所有类型。它不会导入p1.p2.C2类,因为C2类不在p1包中;而是在p2包里,是p1的子包。按需导入声明末尾的星号表示仅来自指定包的所有类型。星号并不意味着子包和这些子包中的类型。有时,开发人员试图在按需导入声明中使用多个星号,认为它也会从所有的子包中导入类型:

import p1.*.*; // A compile-time error

这个按需导入声明会导致编译时错误,因为它使用了多个星号。它不遵循按需导入声明的语法。在按需进口申报单中,申报单必须以点号结尾,后跟一个且只能有一个星号。

如果您想要导入类C1C2,您需要使用两个按需导入声明:

import p1.*;
import p1.p2.*;

您可以使用按需导入声明重写ClassAccessTest类的代码:

// ClassAccessTest.java – Modified version uses import-on-demand
package com.jdojo.common;
// Import all types from the com.jdojo.cls package including the Human class
import com.jdojo.cls.*;
public class ClassAccessTest {
    public static void main(String[] args) {
        Human jack; // Use simple name of the Human class
    }
}

当编译器试图解析前面代码中的简单名称Human时,它将使用按需导入声明来查看Human类是否存在于com.jdojo.cls包中。实际上,import声明中的星号会被替换为Human,然后编译器检查com.jdojo.cls.Human类是否存在。假设在com.jdojo.cls包中有两个名为HumanTable的类。以下代码将通过一个按需导入声明进行编译:

// ClassAccessTest.java – Modified version uses import-on-demand
package com.jdojo.common;
// Import all types from com.jdojo.cls package including Human and Table classes
import com.jdojo.cls.*;
public class ClassAccessTest {
    public static void main(String[] args) {
        Human jack; // Use simple name of the Human class
        Table t1;   // Use simple name of the Table class
    }
}

前面代码中的一个按需导入声明与下面两个单一类型导入声明具有相同的效果:

import com.jdojo.cls.Human; // Import Human class
import com.jdojo.cls.Table; // Import Table class

在 Java 程序中使用哪种类型的导入声明更好:单一类型导入还是按需导入?使用按需进口申报很简单。但是,它不可读。让我们看看下面的代码,它编译得很好。假设类AB不在com.jdojo.cls包中:

// ImportOnDemandTest.java
package com.jdojo.cls;
import p1.*;
import p2.*;
public class ImportOnDemandTest {
    public static void main(String[] args) {
        A a; // Declare a variable of class A type
        B b; // Declare a variable of class B type
    }
}

通过查看这段代码,你能说出类AB的完全限定名吗?包里的Ap1还是p2?仅仅通过查看代码来判断包属于哪个类AB是不可能的,因为您已经使用了按需导入声明。让我们使用两个单一类型导入声明重写前面的代码:

// ImportOnDemandTest.java
package com.jdojo.cls;
import p1.A;
import p2.B;
public class ImportOnDemandTest {
    public static void main(String[] args) {
        A a; // Declare a variable of class A type
        B b; // Declare a variable of class B type
    }
}

通过查看导入声明,现在可以看出类A在包p1中,类B在包p2中。单一类型的导入声明使读者很容易知道哪个类是从哪个包中导入的。这也使得知道程序中其他包使用的类的数量和名称变得容易。本书在所有的例子中都使用单一类型的导入声明,除了我们讨论按需导入声明的例子。

尽管建议您在程序中使用单一类型导入声明,但您需要了解在同一程序中同时使用单一类型导入和按需导入声明的一些技巧性用法和含义。后续部分将详细讨论它们。

导入声明和类型搜索顺序

导入声明用于在编译期间将类型的简单名称解析为它们的完全限定名称。编译器使用预定义的规则来解析简单名称。假设以下语句出现在使用简单名称A的 Java 程序中:

A var;

在编译过程中,Java 编译器必须将简单名称A解析为完全限定名称。它按以下顺序搜索程序中引用的类型:

  • 当前编译单元

  • 单一类型进口申报

  • 在同一包中声明的类型

  • 按需进口申报

此类型搜索列表不完整。如果某个类型有嵌套类型,则在查找当前编译单元之前会先搜索嵌套类型。我们将推迟对嵌套类型的讨论,直到在本系列的第二本书中讨论内部类。

让我们用几个例子来讨论类型搜索的规则。假设您有一个名为B.java的 Java 源文件(编译单元),其内容如下。注意文件B.java包含两个类AB的声明:

// B.java
package p1;
class B {
    A var;
}
class A {
    // Code goes here
}

当类B声明类型A的实例变量var时,它使用简单名称引用类A。编译B.java文件时,编译器会在当前编译单元(B.java文件)中寻找简单名称为A的类型。它会在当前编译单元中找到一个简单名称为A的类声明。简单名称A将被替换为完全限定名称p1.A。注意,两个类AB是在同一个编译单元中声明的,因此它们在同一个包p1中。编译器将对类B的定义进行如下更改:

package p1;
class B {
    p1.A var; // A has been replaced by p1.A by the compiler
}

假设您想要使用前面示例中包p2中的类A。也就是有一个类p2.A,你想在类B中声明p2.A类型的实例变量var,而不是p1.A。让我们通过使用单一类型导入声明导入类p2.A来解决这个问题,如下所示:

// B.java – Includes a new import declaration
package p1;
import p2.A;
class B {
    A var; // You want to use p2.A when you use A here
}
class A {
    // Code goes here
}

当你编译修改后的B.java文件时,你会得到如下编译错误:

"B.java": p1.A is already defined in this compilation unit at line 2, column 1

修改后的源代码有什么问题?当您从其中移除单一类型导入声明时,它可以正常编译。这意味着是单一类型导入声明导致了错误。在解决这个错误之前,您需要了解一个关于单一类型导入声明的新规则。规则是

使用多个单一类型导入声明导入多个具有相同简单名称的类型是一个编译时错误。

假设你有两个类,p1.Ap2.A。请注意,这两个类有相同的简单名称A,放在两个不同的包中。根据这个规则,如果您想在同一个编译单元中使用两个类p1.Ap2.A,您不能使用两个单一类型的导入声明:

// Test.java
package pkg;
import p1.A;
import p2.A; // A compile-time error
class Test {
    A var1; // Which A to use p1.A or p2.A?
    A var2; // Which A to use p1.A or p2.A?
}

这条规则背后的原因是,当你在代码中使用简单的名字A时,编译器无法知道使用哪个类(p1.Ap2.A)。Java 可能已经通过使用第一个导入的类或最后一个导入的类解决了这个问题,这很容易出错。Java 决定将问题扼杀在萌芽状态,当您导入两个具有相同简单名称的类时,它会给出一个编译时错误,这样您就不会犯这样愚蠢的错误,并最终花费数小时来解决它们。

让我们回到在一个编译单元中导入p2.A类的问题,这个编译单元已经声明了一个类A。以下代码会产生编译时错误:

// B.java – Includes a new import declaration
package p1;
import p2.A;
class B {
    A var1; // You want to use p2.A when you use A
}
class A {
    // Code goes here
}

这一次,您只使用了一个单一类型的导入声明,而不是两个。为什么会出现错误?当您在同一个编译单元中声明多个类时,它们很可能是紧密相关的,并且会相互引用。您需要认为 Java 使用单一类型的导入声明导入了在同一个编译单元中声明的每个类。您可以将前面的代码看作是由 Java 转换的,如下所示:

// B.java – Includes a new import declaration
package p1;
import p1.A; // Think of it being added by Java
import p1.B; // Think of it being added by Java
import p2.A;
class B {
    A var; // We want to use p2.A when you use A
}
class A {
           // Code goes here
}

你现在能看出问题了吗?类A被导入了两次,一次由 Java 导入,一次由您导入,这就是错误的原因。你如何在你的代码中引用p2.A?很简单。每当你想在你的编译单元中使用p2.A时,使用完全限定名p2.A;

// B.java – Uses fully qualified name p2.A in class B
package p1;
class B {
    p2.A var; // Use fully qualified name of A
}
class A {
              // Code goes here
}

Tip

如果在同一个编译单元中存在具有相同简单名称的类型,则使用单类型导入声明将类型导入到编译单元是一个编译时错误。

让我们用代码解决编译时错误,该代码需要使用来自不同包的具有相同简单名称的类。代码如下:

// Test.java
package pkg;
import p1.A;
import p2.A; // A compile-time error
class Test {
    A var1;  // Which A to use p1.A or p2.A?
    A var2;  // Which A to use p1.A or p2.A?
}

您可以使用以下两种方法之一来解决该错误。第一种方法是删除两个导入声明,并使用类A的完全限定名,如下所示:

// Test.java
package pkg;
class Test {
    p1.A var1; // Use p1.A
    p2.A var2; // Use p2.A
}

第二种方法是只使用一个导入声明从一个包中导入类A,比如说p1,并从p2包中使用类A的完全限定名,如下所示:

// Test.java
package pkg;
import p1.A;
class Test {
    A var1;    // Refers to p1.A
    p2.A var2; // Uses the fully qualified name p2.A
}

Tip

如果您想要在一个编译单元中使用多个具有相同简单名称的类,但是来自不同的包,那么您最多可以导入一个类。对于其余的类,您必须使用完全限定名。您可以选择对所有类使用完全限定名。

让我们讨论一些关于使用按需导入声明的规则。在使用所有其他方法解析简单名称之后,编译器使用按需导入声明来解析类型的简单名称。使用单一类型导入声明和按需导入声明导入具有相同简单名称的类是有效的。在这种情况下,使用单一类型导入声明。假设您有三个类:p1.Ap2.Ap2.B。假设您有一个编译单元,如下所示:

// C.java
package p3;
import p1.A;
import p2.*;
class C {
    A var; // Will always use p1.A (not p2.A)
}

在这个例子中,类A被导入了两次:一次使用包p1中的简单类型导入声明,另一次使用包p2中的按需导入声明。简单名称A被解析为p1.A,因为单一类型的导入声明总是优先于按需导入声明。一旦编译器找到使用单一类型导入声明的类,它就停止搜索,而不使用任何按需导入声明来查找该类。

让我们将前面示例中的导入声明更改为使用按需导入声明,如下所示:

// C.java
package p3;
import p1.*;
import p2.*;
class  C {
    A var; // A compile-time error. Which A to use p1.A or p2.A?
}

编译类C产生以下错误:

"C.java": reference to A is ambiguous, both class p2.A in p2 and class p1.A in p1 match at line 8, column 5

错误信息清晰明了。当编译器使用按需导入声明找到一个类时,它会继续在所有按需导入声明中搜索该类。如果它使用多个按需导入声明找到具有相同简单名称的类,它将生成一个错误。您可以用几种方法解决此错误:

  • 使用两个单一类型的导入声明。

  • 使用一个单一类型导入和一个按需导入声明。

  • 对两个类都使用完全限定名。

下面的列表包含了更多关于导入声明的规则:

  • 忽略重复的单一类型导入和按需导入声明。以下代码是有效的:

  • 使用单一类型导入声明或按需导入声明从同一个包中导入类是合法的,尽管不是必需的。下面的代码从同一个包p5中导入类F。注意,在同一个包中声明的所有类都是自动导入的。在这种情况下,将忽略导入声明:

// D.java
package p4;
import p1.A;
import p1.A; // Ignored. A duplicate import declaration.
import p2.*;
import p2.*; // Ignored. A duplicate import declaration.
class D {
             // Code goes here
}

// E.java
package p5;
import p5.F; // Will be ignored
class E {
             // Code goes here
}
// F.java
package p5;
import p5.*; // Will be ignored
class F {
             // Code goes here
}

自动进口申报

您一直用简单的名字使用String类和System类,并且您从来没有想过在您的任何程序中导入它们。这些类的全限定名是java.lang.Stringjava.lang.System。Java 总是自动导入在java.lang包中声明的所有类型。想象以下按需导入声明在编译前被添加到源代码中:

import java.lang.*;

这就是为什么您能够在代码中使用简单的名称StringSystem而不用导入它们。你可以在你的程序中使用java.lang包中的任何类型,只要有简单的名字。

使用导入声明从java.lang包中导入类型不是错误。编译器会简单地忽略它们。以下代码将编译无误:

package p1;
import java.lang.*;      // Will be ignored because it is automatically done for you
public class G {
    String anythingGoes; // Refers to java.lang.String
}

使用类型的简单名称时需要小心,它与在java.lang包中定义的类型相同。假设您声明了一个p1.String类:

// String.java
package p1;
public class String {
    // Code goes here
}

假设在同一个包中有一个Test类,p1:

// Test.java
package p1;
public class Test {
    // Which String class will be used: p1.String or java.lang.String
    String myStr;
}

Test级中所指的String级是:p1.String还是java.lang.String?它将引用p1.String,而不是java.lang.String,因为编译单元的包(在本例中是p1)在任何导入声明之前被搜索,以解析类型的简单名称。编译器在包p1中找到String类。它不会在java.lang包中搜索String类。如果您想在这个例子中使用java.lang.String类,您必须使用它的完全限定名,如下所示:

// Test.java
package p1;
public class Test {
    java.lang.String s1; // Use java.lang.String
    p1.String s2;        // Use p1.String
    String s3;           // Will use p1.String
}

静态进口申报

静态导入声明顾名思义。它将某个类型的静态成员(静态变量/方法)导入到编译单元中。您在前面的章节中学习了静态变量(或类变量)。我们将在下一节讨论静态方法。静态导入声明有两种形式:

  • 单一静态导入

  • 静态按需导入

单静态导入声明导入一个类型的静态成员。静态按需导入声明导入一个类型的所有静态成员。静态导入声明的一般语法如下:

// Single-static-import declaration:
import static <package-name>.<type-name>.<static-member-name>;
//Static-import-on-demand declaration:
import static <package-name>.<type-name>.*;

您已经使用System.out.println()方法在标准输出上打印了消息。Systemjava.lang包中的一个类,它有一个名为out的静态变量。当你使用System.out时,你指的是System类中名为out的静态变量。您可以使用静态导入声明从System类导入out static变量,如下所示:

import static java.lang.System.out;

你的程序现在不需要用类名System作为System.out来限定out变量。相反,它可以在你的程序中用名字out来表示System.out。编译器将使用静态导入声明将名称out解析为System.out

清单 7-6 展示了如何使用静态导入声明。它导入了System类的out静态变量。注意,main()方法使用的是out.println()方法,而不是System.out.println()。编译器会用System.out.println()调用替换out.println()调用。

// StaticImportTest.java
package com.jdojo.cls;
import static java.lang.System.out;
public class StaticImportTest {
    public static void main(String[] args) {
        out.println("Hello static import!");
    }
}
Hello static import!

Listing 7-6Using Static Import Declarations

Tip

导入声明导入类型名,并允许您在程序中使用该类型的简单名称。导入声明对类型做什么,静态导入声明对类型的静态成员做什么。静态导入声明允许您使用某个类型的静态成员(静态变量/方法)的名称,而不用类型名称来限定它。

让我们看另一个使用静态导入声明的例子。java.lang包中的Math类包含许多实用常量和静态方法。例如,它包含一个名为PI的类变量,其值等于22/7(数学中的圆周率)。如果您想使用Math类的任何静态变量或方法,您需要用类名Math来限定它们。例如,您可以将PI静态变量称为Math.PI,将sqrt()方法称为Math.sqrt()。您可以使用下面的 static-import-on-demand 声明来导入Math类的所有静态成员:

import static java.lang.Math.*;

现在,您可以使用静态成员的名称,而不用类名Math来限定它。清单 7-7 演示了通过导入Math类的static成员来使用它。

// StaticImportTest2.java
package com.jdojo.cls;
import static java.lang.System.out;
import static java.lang.Math.*;
public class StaticImportTest2 {
    public static void main(String[] args) {
        double radius = 2.9;
        double area = PI * radius * radius;
        out.println("Value of PI is: " + PI);
        out.println("Radius of circle: " + radius);
        out.println("Area of circle: " + area);
        out.println("Square root of 2.0: " + sqrt(2.0));
    }
}
Value of PI is: 3.141592653589793
Radius of circle: 2.9
Area of circle: 26.420794216690158
Square root of 2.0: 1.4142135623730951

Listing 7-7Using Static Imports to Import Multiple Static Members of a Type

以下是一些关于静态导入声明的重要规则。

静态导入规则#1

如果导入两个具有相同简单名称的静态成员,一个使用单静态导入声明,另一个使用静态按需导入声明,则使用单静态导入声明导入的成员优先。假设有两个类,p1.C1p2.C2。这两个类都有一个名为m1的静态方法。下面的代码将使用p1.C1.m1()方法,因为它是使用单静态导入声明导入的:

// Test.java
package com.jdojo.cls;
import static p1.C1.m1; // Imports C1.m1() method
import static p2.C2.*;  // Imports C2.m1() method too
public class Test {
    public static void main(String[] args) {
        m1();           // C1.m1() will be called
    }
}

静态导入规则#2

不允许使用单一静态导入声明来导入具有相同简单名称的两个静态成员。以下静态导入声明生成了一个编译时错误,因为它们都导入了一个具有相同简单名称m1的静态成员:

import static p1.C1.m1;
import static p1.C2.m1; // A compile-time error

静态导入规则#3

如果静态成员是使用单静态导入声明导入的,并且在同一个类中存在同名的静态成员,则使用该类中的静态成员。下面是两个类的代码,p1.Ap2.Test:

// A.java package p1;
public class A {
    public static void test() {
        System.out.println("p1.A.test()");
    }
}
// Test.java
package p2;
import static p1.A.test;
public class Test {
    public static void main(String[] args) {
        test(); // Will use p2.Test.test() method, not p1.A.test() method
    }
    public static void test() {
        System.out.println("p2.Test.test()");
    }
}
p2.Test.test()

Test类使用单一静态导入声明从p1.A类导入静态方法test()Test类还定义了一个静态方法test()。当你用简单名test调用main()方法中的test()方法时,指的是p2.Test.test()方法,而不是静态导入导入的方法。

在这种情况下使用静态导入声明有一个隐藏的危险。假设在p2.Test类中没有test()静态方法。一开始,test()方法调用将调用p1.A.test()方法。稍后,您将在Test类中添加一个test()方法。现在test()方法调用将开始调用p2.Test.test(),这将在您的程序中引入一个难以发现的 bug。

Tip

似乎静态导入帮助您使用静态成员的简单名称来简化程序的编写和阅读。有时,静态导入可能会在您的程序中引入微妙的错误,这可能很难调试。建议您仅在极少数情况下使用静态导入。

申报记录

Java 中的记录是一种特殊类型的类,它具有不可变的字段(意味着它们不能被更改),具有由编译器自动为其生成的多个方法,并扩展了 java.lang.Record。它在 Java 14 中作为预览功能引入,在 Java 16 中最终确定。

记录类型允许 Java 编译器和运行时进行大量的性能改进,这是其他方法无法做到的。Java 记录的主要特征是它们是不可变的——一旦实例被创建,它的字段值就不能被更改——并且它们具有与记录定义中定义的字段名称相匹配的访问器方法。

用 Java 声明记录的一般语法如下:

[modifiers] record <record-name>( <field-definitions> ) {
    // Body of the record class goes here
}

这里

  • modifiers是可选的;它们是将特殊含义与记录声明相关联的关键字。一个记录声明可能有零个或多个修饰符,就像类声明一样。

  • 单词 record 用于声明一个记录。它不是关键字,仍然可以用作变量名。

  • record-name是用户定义的记录名,它应该是一个有效的 Java 标识符。

  • 每个记录都有一个主体,在一对大括号({})中指定。记录的主体可以包含不同的组件,例如,字段、方法等。,也可以是空的。

  • 字段定义是一个逗号分隔的<data-type> <field-name>声明列表,它定义了记录的immutable fields

例如,让我们重新创建人类类作为记录(清单 7-8 )。

// Human.java
package com.jdojo.cls;
public record Human (String name, String gender) {
    static long count;  // Class variable
}

Listing 7-8Redefined Human Class as a Record

现在,当创建一个人的实例时,必须提供“姓名”和“性别”字段,并且不能对该实例进行更改。与类不同,没有默认的字段初始化。如示例所示,记录中仍然可以有静态变量,它们不是不可变的。类变量(也称为静态变量)与类(在本例中是人类)相关联,而不是该类的特定实例。

要创建记录,可以使用如下所示的构造器,例如:

Human bob = new Human("Bob", "male")

您可以像这样使用方法调用来访问这些值(下一章将解释所有关于方法的内容):

String name = bob.name() //Bob

记录还有几个自动生成的方法(equals、hashCode 和 toString),我们将在后续章节中了解更多。

摘要

类是面向对象编程的基本构件。在 Java 中,类代表一个引用类型。类充当创建对象的模板。一个类由四部分组成:字段、初始化器、构造器和方法。字段表示该类对象的状态。初始值设定项和构造器用于初始化类的字段。new操作符用于创建一个类的对象。方法表示该类的对象的行为。

字段和方法被称为类的成员。构造器不是类的成员。顶级类有一个访问级别,它决定了从程序的哪个部分可以访问它。顶级类可以具有公共级或包级访问权限。可以从同一个模块中的任何地方访问公共类。如果该类的模块导出该类的包,如果其他模块声明依赖于该类的模块,则也可以从这些模块内部访问该公共类。顶级类上缺少访问级别修饰符,这使得该类具有包级别的访问权限,这使得该类可以在其包内进行访问。

Java 中的每个类都定义了一个新的引用类型。Java 有一种特殊的引用类型,称为空类型。它没有名字。因此,不能定义空引用类型的变量。空引用类型只有一个由 Java 定义的值,即null文字。简直就是null。空引用类型与所有其他引用类型都是赋值兼容的。

一个类可以有两种类型的字段。它们被称为实例变量和类变量,也分别称为非静态变量和静态变量。实例变量代表类的对象的状态。该类的每个对象都有一个所有实例变量的副本。类变量代表类本身的状态。一个类只存在一个类变量副本。可以使用点符号访问类的字段,其形式如下:

<qualifier>.<field-name>

对于实例变量,限定符是对该类实例的引用。对于类变量,限定符可以是类实例的引用或类名。

一个类的所有字段,静态的和非静态的,都被初始化为默认值。字段的默认值取决于其数据类型。一个数值字段(byteshortcharintlongfloatdouble)被初始化为零。一个boolean字段被初始化为false。引用类型字段被初始化为null

编译单元中的 Import 语句用于从其他包中导入类型。它们允许使用其他包中的简单类型名。编译器使用导入语句将简单名称解析为完全限定的名称。静态导入语句用于从其他包中导入类型的静态成员。

Java 16 引入了记录,记录是具有自动生成方法的不可变类,使用单词“record”和括号内的字段定义列表来定义。这些字段没有默认值,必须在创建记录实例时提供。

EXERCISES

  1. 什么是类的实例变量?实例变量的另一个名字是什么?

  2. 什么是类的类变量?类变量的另一个名字是什么?

  3. 一个类的不同类型字段的默认值是什么?

  4. 用两个名为xyint实例变量创建一个名为Point的类。两个实例变量都应该声明为公共的。不要初始化这两个实例变量。

  5. 将一个main()方法添加到您在前一个练习中创建的Point类中。创建一个Point类的对象,并打印xy实例变量的默认值。将xy的值分别设置为 5 和 10,并通过在程序中读回它们来打印它们的值。

  6. 假设Point是您在前面的练习中创建的类名,那么当下面的代码片段运行时会发生什么呢?

    Point p = null;
    int x = p.x;
    
    
  7. 以下代码的输出是什么?

    public class Employee {
        String name;
        boolean retired;
        double salary;
        public static void main(String[] args) {
            Employee emp = new Employee();
            System.out.println(emp.name);
            System.out.println(emp.retired);
            System.out.println(emp.salary);
        }
    }
    
    
  8. java.time包包含一个LocalDate类。该类包含一个返回当前本地日期的now()方法。CurrentDate类在它的 main 方法中使用了类的简单名称LocalDate。当前形式的代码无法编译。通过添加导入语句(首先是单一类型导入语句,然后是按需导入语句)来导入LocalDate类,完成并运行下面的代码。运行CurrentDate类时,它会以 ISO 格式打印当前的本地日期,比如 2017-08-27:

    // CurrentDate.java
    package com.jdojo.cls.excercise;
    /* Add an import statement here. */
    public class CurrentDate {
        public static void main(String[] args) {
            LocalDate today = LocalDate.now();
            System.out.println(today);
        }
    }
    
    
  9. 考虑下面这个名为StaticImport的类的代码。代码不能编译,因为它在它的main()方法中使用了out.println()而不是System.out.println()方法。通过添加静态导入语句来完成代码。System类在java.lang包中,outSystem类中的静态变量:

    // StaticImport.java
    package com.jdojo.cls.excercise;
    /* Add a static import statement here. */
    public class StaticImport {
        public static void main(String[] args) {
            out.println("Hello static import");
        }
    }
    
    
  10. 以下名为MathStaticImport的类的代码无法编译。添加一个 static-import-on-demand 语句来完成代码,这样它就可以编译了。java.lang.Math类包含名为PI的静态变量和名为sqrt() :

```java
// MathStaticImport.java
package com.jdojo.cls.excercise;
/* Add a static-import-on-demand statement here. */
public class MathStaticImport {
    public static void main(String[] args) {
        double radius = 2.0;
        double perimeter = 2 * PI * radius;
        System.out.println("Value of PI is " + PI);
        System.out.println("Square Root of 2 is " + sqrt(2));
        System.out.println("Perimeter of a circle of radius 2.0 is "
                           + perimeter);
    }

```

的静态方法
  1. 定义一个名为 Computer 的公共记录类,具有以下字段和类型:String name、int numberOfProcessors、int memory、int diskSpace 和 String brand。

八、方法

在本章中,您将学习:

  • 什么是方法以及如何在类中声明方法

  • Java 程序中代词this的含义

  • 什么是局部变量以及使用它们的规则

  • 如何调用类的方法

  • 不同的一般参数传递机制和 Java 中的参数传递机制

  • 如何声明和使用 var-args 参数

本章中的示例类在com.jdojo.cls包中,它是jdojo.cls模块的成员。你在第七章中创建了jdojo.cls模块。

什么是方法?

类中的方法定义了该类对象的行为或该类本身的行为。方法是一个命名的代码块。可以调用该方法来执行其代码。调用该方法的代码称为该方法的调用方。可选地,方法可以接受来自调用者的输入值,并且它可以向调用者返回值。输入值的列表称为参数。一个方法可以没有参数。如果一个方法有零个参数,你说这个方法没有任何参数或者这个方法不接受任何参数。方法总是在类或接口的主体中定义。也就是说,你不能单独拥有一个方法。为了保持示例代码的简单,在本章中,它将一个方法显示为一个独立的代码块。当我们讨论一个完整的例子时,我们展示了一个类体内的方法。

声明类的方法

方法声明的一般语法形式如下

[modifiers] <return-type> <method-name> (<parameters-list>) [throws-clause] {
    // Body of the method goes here
}

这里

  • modifiers是该方法的可选修饰符列表。

  • return-type是方法返回值的数据类型。

  • method-name是方法的名称。

  • parameters-list是该方法接受的参数列表。这是可选的。多个参数由逗号分隔。参数总是用左括号和右括号括起来。如果一个方法不带参数,就使用一对空括号。

  • 参数列表后面可以有一个throws子句,声明方法可能抛出的异常类型。

  • 最后,在左大括号和右大括号内指定方法代码,也称为方法体。

请注意,方法声明中的四个部分是必需的:返回类型、方法名、一对左右括号和一对左右大括号。让我们详细讨论方法声明中的每个部分。我们将在本章和本书后续章节的不同章节中讨论修饰语。我们将在第十三章中讨论throws条款。下面是一个方法的示例:

int add(int n1, int n2) {
    int sum = n1 + n2;
    return sum;
}

该方法的名称是add。它需要两个参数。两个参数都是类型int。这些参数命名为n1n2。该方法返回一个int值,该值在其返回类型中指明。该方法的主体计算两个参数的总和并返回总和。图 8-1 显示了add方法的不同部分。

img/323069_3_En_8_Fig1_HTML.png

图 8-1

add 方法的一部分

方法的返回类型是该方法被调用时将返回的值的数据类型。它可以是原始数据类型(例如,intdoubleboolean等)。)或引用类型(如Human, String等)。).有时,方法不会向其调用方返回值。如果一个方法不向调用者返回任何值,那么关键字void被用作返回类型。在前面的例子中,add方法返回两个整数的和,这将是一个整数。这就是它的返回类型被指定为int的原因。

方法名必须是有效的 Java 标识符。按照惯例,Java 方法以小写字母开始,随后使用大写字母。例如,getNamesetNamegetHumanCountcreateHuman是有效的方法名。AbCDeFg也是一个有效的方法名;然而,它并不遵循标准的方法命名约定。

一个方法可以从它的调用者那里获取输入值。参数用于从调用者处获取输入值。参数由两部分组成:数据类型和变量名。事实上,方法参数声明就是变量声明。变量用于保存从方法调用方传递来的输入值。逗号用于分隔方法的两个参数。在前面的例子中,add方法声明了两个参数,n1n2。两个参数都属于int数据类型。当调用add方法时,调用者必须传递两个int值。从调用者传递的第一个值存储在n1中,第二个值存储在n2中。参数n1n2也被称为形式参数

一个方法有一个签名,它在特定的上下文中唯一地标识该方法。方法的签名是以下四个部分的组合:

  • 方法的名称

  • 参数数量

  • 参数类型

  • 参数的顺序

修饰符、返回类型、参数名和throws子句不是方法签名的一部分。表 8-1 列出了一些方法声明及其签名的例子。

表 8-1

方法声明及其签名的示例

|

方法声明

|

方法签名

int add(int n1, int n2) add(int, int)
int add(int n3, int n4) add(int, int)
public int add(int n1, int n2) add(int, int)
public int add(int n1, int n2) throws OutofRangeException add(int, int)
void process(int n) process(int)
double add(int n1, double d1) add(int, double)
double add(double d1, int n1) add(double, int)

最常见的情况是,您需要了解两个方法是否具有相同的签名。理解方法参数的类型和顺序是其签名的一部分是非常重要的。例如,double add(int n1, double d1)double add(double d1, int n1)具有不同的签名,因为它们的参数顺序不同,即使参数的数量和类型相同。

Tip

方法的签名唯一地标识了类中的方法。不允许一个类中有多个方法具有相同的签名。

最后,方法的代码在方法体中指定,方法体用大括号括起来。执行方法的代码也称为“调用方法”或“调用方法”一个方法被调用时使用它的名字和它的参数值,如果有的话,放在括号里。要调用add方法,需要使用下面的语句:

add(10, 12);

这个对add方法的调用分别传递 10 和 12 作为参数n1n2的值。用于调用add方法的两个值 10 和 12 被称为实际参数。Java 在执行方法体内的代码之前,会将实际参数复制到形式参数中。在前面对add方法的调用中,10 将在n1中被复制,12 将在n2中被复制。您可以将形参名称称为在方法体中具有实际参数值的变量。在add方法的以下语句中,您可以看到n1n2被视为变量:

int sum = n1 + n2;

return语句用于从方法中返回值。它以关键字return开始。如果一个方法返回一个值,关键字return后面必须跟一个表达式,该表达式计算返回的值。如果该方法不返回值,其返回类型被指定为void。如果该方法的返回类型是void,则该方法不必包含return语句。如果一个带有void返回类型的方法想要包含一个return语句,那么return关键字后面不能跟任何表达式;return关键字后面紧跟着一个分号来标记语句的结束。下面是两种风格的return语句:

// If a method returns a value, <<an expression>> must evaluate to a data type,
// which is assignment compatible with the specified return type of the method
return <an expression>;

或者

// If method's return type is void
return;

一个return语句是做什么的?顾名思义,它将控制返回给方法的调用方。如果它有一个表达式,它将计算表达式并将表达式的值返回给调用者。如果一个return语句没有表达式,它只是将控制权返回给它的调用者。return语句是在方法体中执行的最后一条语句。在一个方法体中可以有多个return语句。然而,对于一个特定的方法调用,最多只能执行一个return语句。

add方法返回它的两个参数之和。如何捕获方法的返回值?方法调用本身是一个表达式,其数据类型是方法的返回类型,并且它计算方法的返回值。例如,如果你写一个这样的语句

add(10, 12);

add(10, 12)为表达式,其数据类型为int。在运行时,它将被评估为一个int值 22,这是从add方法返回的值。要获取方法调用的值,可以在任何可以使用值的地方使用方法调用表达式。例如,下面的代码片段将从add方法返回的值赋给一个名为sum的变量:

int sum = add(10, 12); // sum variable will be assigned 22

现在把注意力转向一个不返回值的方法。您指定void作为这种方法的返回类型。考虑下面名为printPoem的方法的方法声明:

void printPoem() {
    System.out.println("Strange fits of passion have I known:");
    System.out.println("And I will dare to tell,");
    System.out.println("But in the lover's ear alone,");
    System.out.println("What once to me befell.");
}

printPoem方法指定void作为它的返回类型,这意味着它不向它的调用者返回值。它不指定任何参数,这意味着它不接受来自其调用者的任何输入值。如果需要调用printPoem方法,需要编写以下语句:

printPoem();

Note

当我们在本书中提到一个方法时,我们使用方法名后面跟着一对左括号和右括号。例如,我们将add方法称为add(),将printPoem方法称为printPoem()。有时候,我们需要参考方法的形参来明确方法的含义。在那些情况下,我们可以只使用形参的数据类型,例如add(int, int),来引用add(int n1, int n2)方法。无论我们在讨论中用什么惯例来指代一个方法,它的使用环境都会使意思变得清晰。

因为printPoem()方法不返回任何值,所以您不能在任何需要值的表达式中使用对该方法的调用。例如,以下语句会导致编译时错误:

int x = printPoem(); // A compile-time error

当方法的返回类型是void时,没有必要在方法体中使用return语句,因为您没有从该方法返回的值。回想一下,return语句做两件事:计算它的表达式(如果有的话),并通过结束方法体中的执行将控制权返回给调用者。即使您没有从方法返回值,您仍然可以简单地使用一个return语句来结束方法的执行。让我们给printPoem方法添加一个参数,允许调用者传递想要打印的小节号。修改后的方法声明如下:

void printPoem(int stanzaNumber) {
    if (stanzaNumber < 1 || stanzaNumber > 2) {
        System.out.println("Cannot print stanza #" + stanzaNumber);
        return; // End the method call
    }
    if (stanzaNumber == 1) {
        System.out.println("Strange fits of passion have I known:");
        System.out.println("And I will dare to tell,");
        System.out.println("But in the lover's ear alone,");
        System.out.println("What once to me befell.");
    } else if (stanzaNumber == 2) {
        System.out.println("When she I loved looked every day");
        System.out.println("Fresh as a rose in June,");
        System.out.println("I to her cottage bent my way,");
        System.out.println("Beneath an evening-moon.");
    }
}

修改后的printPoem()方法知道如何打印第一部分和第二部分。如果它的调用者传递了一个超出这个范围的节数,它将打印一条消息并结束方法调用。这是通过在第一个if语句中使用一个return语句来实现的。您可以编写前面的printPoem()方法,而不用编写任何return语句,如下所示:

void printPoem(int stanzaNumber) {
    if (stanzaNumber == 1) {
        /* Print stanza #1 */
    } else if (stanzaNumber == 2) {
        /* Print stanza #2 */
    } else {
        System.out.println("Cannot print stanza #" + stanzaNumber);
    }
}

编译器会强迫你在方法体中包含一个return语句,在声明中指定返回类型。然而,如果编译器确定一个方法已经指定了返回类型,但是它总是异常地结束它的执行,例如,通过抛出一个异常,你不需要在方法体中包含一个return语句。例如,下面的方法声明是有效的。此时不要担心throwthrows关键词;我们将在本书的后面介绍它们:

int aMethod() throws Exception {
    throw new Exception("Do not call me...");
}

局部变量

在方法、构造器或块中声明的变量称为局部变量。我们在第九章讨论构造器。方法中声明的局部变量只在方法执行期间存在。因为局部变量只存在一段临时时间,所以它不能在方法、构造器或声明它的块之外使用。方法的形参被视为局部变量。当调用方法时,在执行方法体之前,它们用实际的参数值初始化。您需要遵守以下关于局部变量使用的规则。

规则 1

默认情况下,局部变量不会初始化。注意,这个规则与实例/类变量初始化的规则相反。当声明一个实例/类变量时,它用一个缺省值初始化。考虑下面一个add()方法的部分定义:

int add(int n1, int n2) {
    int sum;
    /* What is the value of sum? We do not know because it has not been initialized yet. */
    /* More code goes here... */
}

规则 2

这条规则是第一条规则的派生。在给局部变量赋值之前,不能在程序中访问它。下面的代码片段将生成一个编译时错误,因为它试图在局部变量sum被赋值之前打印它的值。注意,Java 运行时必须读取(或访问)变量sum的值来打印它:

int add(int n1, int n2) {
    int sum;
    // A compile-time error. Cannot read sum because it is not assigned a value yet.
    System.out.println(sum);
}

以下代码片段编译良好,因为局部变量sum在被读取之前已被初始化:

int add(int n1, int n2) {
    int sum = 0;
    System.out.println(sum); // Ok. Will print 0
}

规则三

局部变量可以在方法体的任何地方声明。但是,必须在使用之前声明它。这条规则的含义是,您不需要在方法体的开头声明所有的局部变量。将变量声明为更接近其用途是一种好的做法。

规则 4

局部变量隐藏同名的实例变量和类变量的名称。我们来详细讨论一下这个规则。每个变量,无论其类型如何,都有一个范围。有时变量的范围也称为它的可见性。变量的作用域是程序的一部分,在这里变量可以用它的简单名字来引用。方法中声明的局部变量的范围是方法体中变量声明之后的部分。在块中声明的局部变量的作用域是该变量声明之后的块的其余部分。方法的形参范围是整个方法体。这意味着方法的形参名称可以在整个方法体中使用,例如:

int sum(int n1, int n2) {
    // n1 and n2 can be used here
}

实例变量和类变量的范围是整个类。例如,实例变量n1和类变量n2可以在类NameHidingTest1的任何地方用它们的简单名称引用,如下所示:

class NameHidingTest1 {
    int n1 = 10;         // An instance variable
    static int n2 = 20;  // A class variable
                         // m1 is a method
    void m1() {
                         // n1 and n2 can be used here
    }
    int n3 = n1;         // n1 can be used here
}

当两个同名的变量,比如一个实例变量和一个局部变量,在程序的同一个部分的作用域内,会发生什么?考虑下面的NameHidingTest2类代码:

class NameHidingTest2 {
        // Declare an instance variable named n1
    int n1 = 10;
        // m1 is a method
    void m1() {
        // Declare a local variable named n1
        int n1 = 20;
        /* Both, instance variable n1 and local variable n1, are in scope here */
        // What value will be assigned to n2: 10 or 20?
        int n2 = n1;
    }
    /* Only instance variable n1 is in scope here */
        // n3 will be assigned 10 from the instance variable n1
    int n3 = n1;
}

在前面的代码中执行m1()方法时,会给变量n2赋什么值?注意,该类声明了一个名为n1的实例变量,方法m1()也声明了一个同名的局部变量n1。实例变量n1的范围是包括m1()方法主体的整个类主体。局部变量n1的范围是m1()方法的整个主体。当这个声明

int n2 = n1;

m1()方法内执行,两个同名的变量n1在作用域内:一个值为10,另一个值为20。前面的语句指的是哪个n1:实例变量n1还是局部变量n1?当局部变量与类字段(实例/类变量)同名时,局部变量名称会隐藏类字段的名称。这就是所谓的姓名隐藏。在这种情况下,局部变量名称n1隐藏了m1()方法中实例变量n1的名称。前面的语句将引用局部变量n1,而不是实例变量n1。因此,n2将被赋值为20

Tip

与类字段同名的局部变量隐藏了类字段的名称。换句话说,当一个局部变量和一个同名的类字段在范围内时,局部变量优先。

NameHidingTest3的以下代码阐明了局部变量进入范围时的场景:

public class NameHidingTest3 {
    // Declare an instance variable named n1
    int n1 = 10;
    public void m1() {
        /* Only the instance variable n1 is in scope here */
        // Assigns 10 to n2
        int n2 = n1;
        /* Only the instance variable n1 is in scope here. The local variable n2
           is also in scope here, which you are ignoring for our discussion for now.
        */
        // Declare a local variable named n1
        int n1 = 20;
        /* Both, instance variable n1 and local variable n1 are in scope here.
           We are ignoring n2 for now.
        */
        // Assigns 20 to n3
        int n3 = n1;
    }
}

前面的代码将变量n1的值赋给了m1()方法中的n2。当你把n1的值赋给n2时,你还没有声明局部变量n1。此时,只有实例变量n1在范围内。当你将n1赋值给n3时,此时实例变量n1和局部变量n1都在作用域内。分配给n2n3的值取决于名称隐藏规则。当范围内有两个同名的变量时,使用局部变量。

这是否意味着不能用相同的名称声明一个实例/类变量和一个局部变量,并同时使用这两个变量?答案是否定的。你可以用相同的名字声明一个实例/类变量和一个局部变量。您唯一需要知道的是,如果实例/类变量的名称被局部变量隐藏,如何引用它。在下一节中,您将学习如何引用隐藏的实例/类变量。

实例方法和类方法

在第七章中,您学习了两种类型的类字段:实例变量和类变量。类可以有两种类型的方法:实例方法和类方法。实例方法和类方法也分别称为非静态方法和静态方法。

实例方法用于实现类的实例(也称为对象)的行为。实例方法只能在类实例的上下文中调用。

类方法用于实现类本身的行为。类方法总是在类的上下文中执行。

static修饰符用于定义一个类方法。方法声明中缺少static修饰符使得该方法成为实例方法。下面是声明静态和非静态方法的示例:

// A static or class method
static void aClassMethod() {
    // Method's body goes here
}
// A non-static or instance method
void anInstanceMethod() {
    // Method's body goes here
}

回想一下,一个类的每个实例都有一个单独的实例变量副本,而一个类变量只有一个副本,与该类的实例数量(可能是零)无关。

当调用类的静态方法时,该类的实例可能不存在。因此,不允许从静态方法内部引用实例变量。一旦类定义加载到内存中,类变量就存在了。在创建类的第一个实例之前,类定义总是被加载到内存中。请注意,不必创建一个类的实例来将其定义加载到内存中。JVM 保证一个类的所有类变量在该类的任何实例存在之前就存在。因此,您总是可以从实例方法内部引用类变量。

Tip

类方法(或静态方法)只能引用该类的类变量(或静态变量)。实例方法(非静态方法)可以引用类变量以及类的实例变量。

清单 8-1 展示了在实例和类方法中可以访问的类字段的类型。

// MethodType.java
package com.jdojo.cls;
public class MethodType {
    static int m = 100; // A static variable
    int n = 200;        // An instance variable
    // Declare a static method
    static void printM() {
        /* You can refer to only static variable m in this method
           because you are inside a static method.
        */
        System.out.println("printM() - m = " + m);
        // Uncommenting the following statement results in a compile-time error.
        //System.out.println("printM() - n = " + n);
    }
    // Declare an instance method
    void printMN() {
        // You can refer to both static and instance variables m and n in this method.
        System.out.println("printMN() - m = " + m);
        System.out.println("printMN() - n = " + n);
    }
}

Listing 8-1Accessing Class Fields from Static and Non-static Methods

MethodType类将m声明为静态变量,将n声明为非静态变量。它将printM()声明为静态方法,将printMN()声明为实例方法。在printM()方法中,你只能引用静态变量m,因为静态方法只能引用静态变量。如果在printM()方法中取消注释语句,代码将无法编译,因为静态方法将试图访问非静态变量nprintMN()方法是非静态方法,它可以访问静态变量m和非静态变量n。现在您想调用MethodType类的printM()printMN()方法。下一节解释如何调用方法。

调用方法

在方法体中执行代码被称为调用(或调用)方法。实例方法和类方法的调用方式不同。使用点标记法在类的实例上调用实例方法。调用实例方法的语法如下:

<instance-reference>.<instance-method-name>(<actual-parameters>)

请注意,在调用一个类的实例方法之前,必须有一个对该类实例的引用。例如,您可以编写以下代码片段来调用清单 8-1 中列出的MethodType类的printMN()实例方法:

// Create an instance of MethodType class and store its reference in mt reference variable
MethodType mt = new MethodType();
// Invoke the printMN() instance method using the mt reference variable
mt.printMN();

Tip

您可能会在 Java 中看到使用 :: 的方法引用。这是一个方法引用,而不是方法调用(然而,这超出了本章的范围)。

要调用一个类方法,可以在类名和方法名中使用点符号。例如,下面的代码片段调用了MethodType类的printM()类方法:

// Invoke the printM() class method
MethodType.printM();
MethodType mt = new MethodType();
mt.printM(); // Call the class method using an instance mt

使用类名和使用实例引用,哪一个是调用类方法的更好方法?这两种方法做的工作是一样的。然而,使用类名调用类方法比使用实例引用更直观。这本书使用一个类名来调用一个类方法,除了为了演示你也可以使用一个实例引用来调用一个类方法。清单 8-2 展示了如何调用一个类的实例方法和类方法。注意,当您使用类名或实例引用调用类方法printM()时,输出显示相同的结果。

// MethodTypeTest.java
package com.jdojo.cls;
public class MethodTypeTest {
    public static void main(String[] args) {
        // Create an instance of the MethodType class
        MethodType mt = new MethodType();
        // Invoke the instance method
        System.out.println("Invoking instance method...");
        mt.printMN();
        // Invoke the class method using the class name
        System.out.println("Invoking class method using class name...");
        MethodType.printM();
        // Invoke the class method using the instance reference
        System.out.println("Invoking class method using an instance...");
        mt.printM();
    }
}
Invoking instance method...
printMN() - m = 100
printMN() - n = 200
Invoking class method using class name...
printM() - m = 100
Invoking class method using an instance...
printM() - m = 100

Listing 8-2Examples of Invoking Instance Methods and Class Methods of a Class

特殊的 main()方法

既然您已经在前一节中学习了在类中声明方法,那么让我们讨论一下您一直用来运行类的main()方法。main()方法声明如下:

public static void main(String[] args) {
    // Method body goes here
}

main()方法的声明中使用了两个修饰符publicstaticpublic修饰符使得它可以从应用程序的任何地方访问,只要声明它的类是可访问的。static修饰符使它成为一个类方法,所以可以使用类名来调用它。它的返回类型是void,这意味着它不向它的调用者返回值。它的名字是main,它接受一个类型为String数组的参数(String[])。请注意,您一直使用args作为其参数的名称。但是,您可以使用您希望的任何参数名称。例如,您可以将main方法声明为public static void main(String[] myParameters),这与前面所示的声明main方法相同。无论选择什么参数名,如果需要引用传递给该方法的参数,都需要在方法体中使用相同的名称。

在一个类中声明一个main()方法有什么特殊之处?通过向java命令传递一个类名来运行 Java 应用程序。例如,您可以使用下面的命令来运行MethodTypeTest类:

java <other-options> --module jdojo.cls/com.jdojo.cls.MethodTypeTest

当执行前面的命令时,JVM(java命令实际上启动了一个 JVM)在内存中找到并加载MethodTypeTest类定义。然后,它寻找一个方法声明,声明为publicstatic,返回void,有一个方法参数为String数组。如果找到了main()方法声明,JVM 就会调用该方法。如果没有找到main()方法,它不知道从哪里开始应用程序,并且抛出一个错误,指出没有找到main()方法。

为什么需要将main()方法声明为 static?main()方法充当 Java 应用程序的入口点。当您运行一个类时,JVM 会调用它。JVM 不知道如何创建一个类的实例。它需要一种标准的方式来启动 Java 应用程序。指定关于main()方法的所有细节并使其成为静态的,为 JVM 提供了一种启动 Java 应用程序的标准方式。通过使main()方法成为静态的,JVM 可以使用类名来调用它,类名在命令行上传递。

如果不将main()方法声明为 static,会发生什么?如果没有将main()方法声明为 static,它将被视为一个实例方法。代码可以很好地编译。但是,您将无法运行该类,因为它的main()方法被声明为实例方法。

一个类中可以有多个main()方法吗?答案是肯定的。一个类中可以有多个方法,只要它们没有相同的签名,就可以命名为main。下面对MultipleMainMethod类的声明是有效的,它声明了三个main()方法。第一个main()方法,声明为public static void main(String[] args),可以用作运行应用程序的入口点。就 JVM 而言,另外两个main()方法没有特别的意义:

// MultipleMainMethod.java
package com.jdojo.cls;
public class MultipleMainMethods {
    public static void main(String[] args) {
        /* May be used as the application entry point */
    }
    public static void main(String[] args, int a) {
        /* Another main() method */
    }
    public int main() {
        /* Another main() method */
        return 0;
    }
}

Java 中每个类都需要有一个main()方法吗?答案是否定的。如果你想运行一个类,你需要在这个类中声明一个public static void main(String[] args)方法。如果你有一个 Java 应用程序,你需要在至少一个类中有一个main()方法,这样你就可以通过运行那个类来启动你的应用程序。所有其他在应用程序中使用但不用于启动应用程序的类不需要有一个main()方法。

您能在代码中调用main()方法吗?还是只能由 JVM 调用?当您运行一个类时,JVM 会调用main()方法。除此之外,您可以将main()方法视为任何其他类方法。程序员有一个普遍的(错误的)印象,认为main()方法只能由 JVM 调用。然而,事实并非如此。诚然,JVM 通常(但不是必须)调用main()方法来启动 Java 应用程序。然而,它不一定只能由 JVM 调用(至少理论上是这样)。下面的例子展示了如何像调用任何其他类方法一样调用main()方法。清单 8-3 有一个MainTest1类的定义,它声明了一个main()方法。清单 8-4 有一个MainTest2类的定义,它声明了一个main()方法。

// MainTest2.java
package com.jdojo.cls;
public class MainTest2 {
    public static void main(String[] args) {
        System.out.println("Inside the MainTest2.main() method.");
        MainTest1.main(args);
    }
}
Inside the MainTest2.main() method.
Inside the MainTest1.main() method.

Listing 8-4A MainTest2 Class, Which Declares a main() Method, Which in Turn Calls the main() Method of the MainTest1 Class

// MainTest1.java
package com.jdojo.cls;
public class MainTest1 {
    public static void main(String[] args) {
        System.out.println("Inside the MainTest1.main() method.");
    }
}

Listing 8-3A MainTest1 Class, Which Declares a main() Method

MainTest2类的main()方法打印一条消息,并使用下面的代码调用MainTest1类的main()方法:

MainTest1.main(args);

注意,MainTest1类的main()方法接受一个String数组作为参数,前面的语句将args作为该参数的实际值传递。

JVM 将调用MainTest2类的main()方法,这又调用MainTest1类的main()方法。清单 8-4 中的输出证实了这一点。您还可以通过运行MainTest1类让 JVM 调用MainTest1类的main()方法,这将产生以下输出:

Inside the MainTest1.main() method.

Tip

类中的main()方法,声明为public static void main(String[] args),只有当类由 JVM 运行时才有特殊的意义;它充当 Java 应用程序的入口点。否则,main()方法将被视为与任何其他类方法相同。

这是什么?

Java 有一个关键词叫做this。它是对类的当前实例的引用。它只能在实例的上下文中使用。它永远不能在类的上下文中使用,因为它意味着当前实例,而在类的上下文中不存在任何实例。关键字this在很多上下文中都有使用。我们将在本章和第九章中讲述它的大部分用途。考虑以下声明类ThisTest1的代码片段:

public class ThisTest1 {
    int varA = 555;
    int varB = varA;      // Assign value of varA to varB
    int varC = this.varA; // Assign value of varA to varC
}

ThisTest1类声明了三个实例变量:varAvarBvarC。实例变量varA被初始化为 555。实例变量varB被初始化为varA的值,即 555。实例变量varC被初始化为varA的值,即 555。注意varBvarC的初始化表达式的不同。我们在初始化varB的时候用了不合格的varA。我们在初始化varC时使用了this.varA。但是,效果是一样的。varBvarC都用varA的值初始化。当我们使用this.varA时,它表示当前实例的varA的值,为 555。在这个简单的例子中,没有必要使用关键字this。在上例中,不合格的varA指的是当前实例的varA。但是,有些情况下你必须使用关键字this。我们将很快讨论这种情况。

因为在类的上下文中使用关键字this是非法的,所以在初始化类变量时不能使用它,如下所示:

// Would not compile
public class ThisTest2 {
    static int varU = 555;
    static int varV = varU;
    static int varW = this.varU; // A compile-time error
}

当您编译类ThisTest2的代码时,您会收到以下错误:

"ThisTest2.java": non-static variable this cannot be referenced from a static context at line 4, column 21

错误非常明显,那就是不能在静态上下文中使用关键字this。注意,静态和非静态单词在 Java 中与“类”和“实例”术语同义。静态上下文与类上下文相同,非静态上下文与实例上下文相同。可以通过从varW的初始化表达式中删除限定符this来纠正前面的代码,如下所示:

public class CorrectThisTest2 {
    static int varU = 555;
    static int varV = varU;
    static int varW = varU; // Now it is fine
}

您还可以用类名限定类变量,如CorrectThisTest3类所示:

public class CorrectThisTest3 {
    static int varU = 555;
    static int varV = varU;
    static int varW = CorrectThisTest3.varU;
}

Tip

大多数情况下,您可以在声明实例和类变量的类中使用它们的简单名称。只有当实例变量或类变量被另一个同名变量隐藏时,才需要用关键字this限定实例变量,用类名限定类变量。

考虑下面的ThisTest3类代码片段:

public class ThisTest3 {
    int varU = 555;
    static int varV = varU; // A compile-time error
    static int varW = varU; // A compile-time error
}

当您编译ThisTest3类时,您会收到以下错误:

"ThisTest3.java": non-static variable varU cannot be referenced from a static context at line 3, column 21
"ThisTest3.java": non-static variable varU cannot be referenced from a static context at line 4, column 21

与您收到的针对ThisTest2类的错误相比,虽然措辞不同,但错误的类型是相同的。上次编译器抱怨使用了关键字this。这次,它抱怨使用了实例变量varU。关键字thisvarU都存在于实例的上下文中。它们不存在于类的上下文中。存在于实例上下文中的任何东西都不能在类的上下文中使用。然而,在类的上下文中存在的任何东西都可以在实例的上下文中使用。实例变量的声明和初始化发生在实例的上下文中。在ThisTest3类中,varU是一个实例变量,它只存在于实例的上下文中。ThisTest3类中的varVvarW是类变量,它们只存在于类的上下文中。这就是编译器产生错误的原因。

考虑一下ThisTest4类的代码,如清单 8-5 所示。它声明了一个实例变量num和一个实例方法printNum()。在printNum()实例方法中,它打印实例变量num的值。在它的main()方法中,它创建了一个ThisTest4类的实例,并在其上调用printNum()方法。ThisTest4类的输出显示了预期的结果。

// ThisTest4.java
package com.jdojo.cls;
public class ThisTest4 {
    int num = 1982; // An instance variable
    public static void main(String[] args) {
        ThisTest4 tt4 = new ThisTest4();
        tt4.printNum();
    }
    void printNum() {
        System.out.println("Instance variable num: " + num);
    }
}
Instance variable num: 1982

Listing 8-5An Example of Using the Simple Name of an Instance Variable in an Instance Method

现在修改ThisTest4类的printNum()方法,使其接受一个int参数,并将该参数命名为num。清单 8-6 中有作为ThisTest5类一部分的printNum()方法的修改代码。

// ThisTest5.java
package com.jdojo.cls;
public class ThisTest5 {
    int num = 1982; // An instance variable
    public static void main(String[] args) {
        ThisTest5 tt5 = new ThisTest5();
        tt5.printNum(1969);
    }
    void printNum(int num) {
        System.out.println("Parameter num: " + num);
        System.out.println("Instance variable num: " + num);
    }
}
Parameter num: 1969
Instance variable num: 1969

Listing 8-6Variable Name Hiding

ThisTest5类的输出表明,当您在方法体中使用简单名称num时,printNum()方法正在使用它的参数num。这是一个名字隐藏的例子,其中本地变量(方法参数被认为是一个本地变量)numprintNum()方法体内隐藏了实例变量num的名字。在printNum()方法中,简单名num指的是它的参数num,而不是实例变量num。在这种情况下,如果您想在printNum()方法中引用num实例变量,您必须使用关键字this来限定num变量。

使用this.num是从printNum()方法内部引用实例变量的唯一方式,只要将方法的参数名保持为num。另一种方法是将参数重命名为num以外的名称,例如numParamnewNum。清单 8-7 展示了如何使用关键字this来引用printNum()方法中的num实例变量。

// ThisTest6.java
package com.jdojo.cls;
public class ThisTest6 {
    int num = 1982; // An instance variable
    public static void main(String[] args) {
        ThisTest6 tt6 = new ThisTest6();
        tt6.printNum(1969);
    }
    void printNum(int num) {
        System.out.println("Parameter num: " + num);
        System.out.println("Instance variable num: " + this.num);
    }
}
Parameter num: 1969
Instance variable num: 1982

Listing 8-7Using the this Keyword to Refer to an Instance Variable Whose Name Is Hidden by a Local Variable

ThisTest6的输出显示了预期的结果。如果不想使用关键字this,可以重命名printNum()方法的参数,如下所示:

void printNum(int numParam) {
    System.out.println("Parameter num: " + numParam);
    System.out.println("Instance variable num: " + num);
}

一旦您将参数重命名为除了num之外的名称,num实例变量就不再隐藏在printNum()方法的主体中,因此您可以使用它的简单名称来引用它。

您可以使用关键字this来引用printNum()方法中的实例变量num,即使它没有被隐藏,如下所示。但是,在以下情况下使用关键字this是一种选择,而不是一种要求:

void printNum(int numParam) {
    System.out.println("Parameter num: " + numParam);
    System.out.println("Instance variable num: " + this.num);
}

在前面的例子中,您看到当实例变量名称被隐藏时,需要使用关键字 this 来访问实例变量。在这种情况下,您可以通过重命名隐藏实例变量名称的变量或重命名实例变量本身来避免使用关键字this。有时保持变量名相同更容易,因为它们代表相同的东西。如果实例变量和局部变量在类中表示相同的东西,本书使用相同名称的惯例。例如,下面的代码非常常见:

public class Student {
    private int id; // An instance variable
    public void setId(int id) {
        this.id = id;
    }
    public int getId() {
        return this.id;
    }
}

Student类声明了一个名为id的实例变量。在其setId()方法中,它还将参数命名为id,并使用this.id来引用实例变量。它还使用this.id来引用其getId()方法中的实例变量id。注意在getId()方法中没有隐藏名称,您可以使用简单的名称id,这意味着实例变量id

表 8-2 列出了类的各个部分,它们出现的上下文,以及关键字this,实例变量和类变量的允许使用。虽然我们还没有涵盖这个表中列出的一个类的所有部分,但我们将在本章和第九章中涵盖它们。

表 8-2

上下文类型和允许使用的关键字 this、实例变量和类变量

|

类的一部分

|

语境

|

可以用这个关键词吗?

|

可以使用实例变量吗?

|

可以使用类变量吗?

实例变量初始化 情况
类变量初始化
实例初始化器 情况
类初始化(也称为静态初始值设定项)
构造器 情况
实例方法(也称为非静态方法) 情况
分类方法(也称为静态方法)

关键字this是一个final(一个常量在 Java 中被称为final,因为 Java 使用final关键字来声明一个常量)对它所在的类的当前实例的引用。因为它是final,你不能改变它的值。因为this是关键字,所以不能声明名为this的变量。以下代码将生成编译时错误:

public class ThisError {
    void m1() {
        // An error. Cannot name a variable this
        int this = 10;
        // An error. Cannot assign a value to this because it is a constant.
        this = null;
    }
}

您还可以使用关键字this来限定实例方法名,尽管这并不是必需的。下面的代码片段显示了使用关键字this调用m2()方法的m1()方法。请注意,这两种方法都是实例方法:

public class ThisTestMethod {
    void m1() {
        // Invoke the m2() method
        this.m2(); // same as "m2();"
    }
    void m2() {
        // do something
    }
}

类成员的访问级别

在第七章中,我们讨论了(非内部)类的访问级别,可以是公共的或者默认的(包级别)。本节讨论类成员的访问级别:字段和方法。类成员的访问级别决定了程序的哪个区域可以访问(使用或引用)它。以下四个访问级别修饰符之一可用于类成员:

  • public

  • private

  • protected

  • 默认或包级访问

类成员的四种访问级别中有三种是使用三个关键字之一指定的:publicprivateprotected。第四种类型称为默认访问级别(或包级访问),它是通过不使用访问修饰符来指定的。也就是说,缺少三个访问级别修饰符publicprivateprotected中的任何一个都指定了包级别的访问。

如果使用关键字public将类成员声明为 public,只要类本身是可访问的,就可以从任何地方访问它。

如果使用关键字private将一个类成员声明为私有,那么它只能在声明类的主体中被访问,而不能在其他地方被访问。

如果一个类成员使用protected关键字声明为 protected,那么它可以从同一个包或该类的后代中访问,即使后代在不同的包中。我们将在第二十章中详细讨论受保护的访问级别。

如果您没有为类成员使用任何访问级别修饰符,那么它拥有包级别的访问权限。可以从同一个包中访问具有包级访问权限的类成员。

类成员属于定义该成员的类。只有当类本身可访问时,类成员才可以基于这些规则进行访问。如果该类不可访问,则无论成员的访问级别如何,其成员都是不可访问的。让我们打个比方。厨房属于房子。只有你能进入房子,你才能进入厨房。如果房子的前门是锁着的(房子是无法进入的),那么即使厨房被贴上了公共的标签,厨房也是无法进入的。把类成员想象成房子里的厨房,把类想象成房子本身。在 Java 8 中,程序的所有部分总是可以访问公共类,这在 Java 9 中随着模块系统的引入而改变。一个公开课不一定对所有人都是真正的公开课。模块中定义的公共类可能属于以下三类之一:

  • 仅在定义模块中是公共的

  • 仅对特定模块公开

  • 对所有人公开

如果某个类在模块中被定义为公共的,但该模块不导出包含该类的包,则该类仅在该模块中是公共的。没有其他模块可以访问该类。在这种情况下,公共类的公共成员在包含该类的模块中的任何地方都是可访问的,而在其他任何地方都是不可访问的。

如果一个类在一个模块中被定义为公共的,但是该模块使用一个限定的导出来导出包含该类的包,那么该类将只能被限定的导出中指定的模块访问。在这种情况下,公共类的公共成员可由包含该类的模块和该类的包导出到的指定模块访问。

如果一个类在一个模块中被定义为公共的,但是该模块使用非限定的导出来导出该类的包,那么所有读取定义该类的模块的模块都可以访问该类。在这种情况下,所有模块都可以访问公共类的公共成员。

类成员的访问级别可以从最严格到最不严格排列为private、包级别、protectedpublic。表 8-3 总结了类成员的四个访问级别,假设类本身是可访问的。

表 8-3

类成员的访问级别列表

|

类成员的访问级别

|

易接近

private 仅在同一个类内
package-level 在同一个包装中
protected 任何包中的相同包或后代
public 到处

在本章中,我们将讨论限制在访问同一个模块中的类成员。我们将在第十章中讨论模块间的可访问性。下面是一个示例类,它声明了许多具有不同访问级别的类成员:

// AccessLevelSample.java
package com.jdojo.cls;
// Class AccessLevelSample has public access level
public class AccessLevelSample {
    private int num1;            // private access level
    int num2;                    // package-level access
    protected int num3;          // protected access level
    public int num4;             // public access level
    public static int count = 1; // public access level
                                 // The m1() method has private access level
    private void m1() {
                                 // Code goes here
    }
    // The m2() method has package-level access
    void m2() {
        // Code goes here
    }
    // The m3() method has protected access level
    protected void m3() {
        // Code goes here
    }
    // The m4() method has public access level
    public void m4() {
        // Code goes here
    }
    // The doSomething() method has private access level
    private static void doSomething() {
        // Code goes here
    }
}

请注意,可以为类的实例和静态成员指定访问级别。在声明中将访问级别修饰符指定为第一个修饰符是一种约定。如果你为一个公共类声明一个静态字段,按照惯例,你应该首先使用public修饰符,然后使用static修饰符。例如,实例变量num的以下两个声明都是有效的:

// Declaration #1
public static int num; // Conventionally used
// Declaration #2
static public int num; // Technically correct, but conventionally not used.

让我们讨论几个为类成员使用访问级别修饰符的例子及其效果。考虑清单 8-8 中显示的AccessLevel类的代码。

// AccessLevel.java
package com.jdojo.cls;
public class AccessLevel {
    private int v1 = 100;
    int v2 = 200;
    protected int v3 = 300;
    public int v4 = 400;
    private void m1() {
        System.out.println("Inside m1():");
        System.out.println("v1 = " + v1 + ", v2 = " + v2
                + ", v3 = " + v3 + ", v4 = " + v4);
    }
    void m2() {
        System.out.println("Inside m2():");
        System.out.println("v1 = " + v1 + ", v2 = " + v2
                + ", v3 = " + v3 + ", v4 = " + v4);
    }

    protected void m3() {
        System.out.println("Inside m3():");
        System.out.println("v1 = " + v1 + ", v2 = " + v2
                + ", v3 = " + v3 + ", v4 = " + v4);
    }
    public void m4() {
        System.out.println("Inside m4():");
        System.out.println("v1 = " + v1 + ", v2 = " + v2
                + ", v3 = " + v3 + ", v4 = " + v4);
    }
}

Listing 8-8An AccessLevel Class with Class Members Having Different Access Levels

该类有四个名为v1v2v3v4的实例变量和四个名为m1()m2()m3()m4()的实例方法。实例变量和实例方法使用了四种不同的访问级别修饰符。在这个例子中,我们选择使用实例变量和方法;相同的访问级别规则适用于类变量和类方法。AccessLevel类的代码编译时没有任何错误。请注意,无论类成员的访问级别是什么,它总是可以在声明它的类中访问。这可以通过以下事实来验证:您已经在所有四个方法中访问了(读取它们的值)所有具有不同访问级别的实例变量。考虑清单 8-9 中显示的AccessLevelTest1类。

// AccessLevelTest1.java
package com.jdojo.cls;
public class AccessLevelTest1 {
    public static void main(String[] args) {
        AccessLevel al = new AccessLevel();
        // int a = al.v1; /* A compile-time error */
        int b = al.v2;
        int c = al.v3;
        int d = al.v4;
        System.out.println("b = " + b + ", c = " + c + ", d = " + d);
        //al.m1(); /* A compile-time error */
        al.m2();
        al.m3();
        al.m4();
        // Modify the values of instance variables
        al.v2 = 20;
        al.v3 = 30;
        al.v4 = 40;

        System.out.println("\nAfter modifying v2, v3 and v4");
        al.m2();
        al.m3();
        al.m4();
    }
}
b = 200, c = 300, d = 400
Inside m2():
v1 = 100, v2 = 200, v3 = 300, v4 = 400
Inside m3():
v1 = 100, v2 = 200, v3 = 300, v4 = 400
Inside m4():
v1 = 100, v2 = 200, v3 = 300, v4 = 400
After modifying v2, v3 and v4
Inside m2():
v1 = 100, v2 = 20, v3 = 30, v4 = 40
Inside m3():
v1 = 100, v2 = 20, v3 = 30, v4 = 40
Inside m4():
v1 = 100, v2 = 20, v3 = 30, v4 = 40

Listing 8-9A Test Class Located in the Same Package as the AccessLevel Class

AccessLevelAccessLevelTest1类在同一个包中。AccessLevelTest1类可以访问AccessLevel类的所有成员,除了声明为private的成员。不能从AccessLevelTest1类访问AccessLevel类的实例变量v1和实例方法m1(),因为它们的访问级别是private。如果取消注释AccessLevelTest1类中的两条语句,这两条语句试图访问private实例变量v1AccessLevel类的private实例方法m1(),您将收到以下编译时错误:

AccessLevelTest1.java:8: error: v1 has private access in AccessLevel
        int a = al.v1; /* A compile-time error */
                  ^
AccessLevelTest1.java:15: error: m1() has private access in AccessLevel
        al.m1(); /* A compile-time error */
          ^
2 errors

AccessLevelTest1类读取AccessLevel类的实例变量的值,并修改它们。你必须注意一件事:即使你不能从AccessLevelTest1类访问AccessLevel类的private实例变量v1private方法m1(),你也可以打印private实例变量v1的值,如输出所示。

类成员的访问级别修饰符指定谁可以直接访问它。如果一个类成员不能直接访问,它可能可以间接访问。在这个例子中,实例变量v1和实例方法m1()不能从AccessLevel类外部直接访问;但是,它们可以从外部间接访问。对不可访问的类成员的间接访问通常是通过提供另一个方法来实现的,该方法可从外部访问。

假设您希望外界读取并修改原本不可访问的private实例变量v1的值。您需要在AccessLevel类中添加两个public方法getV1()setV1();这两个方法将读取并修改v1实例变量的值。修改后的AccessLevel类将如下所示:

public class AccessLevel {
    private int v1;
    /* Other code goes here */
    public int getV1() {
        return this.v1;
    }
    public void setV1(int v1) {
        this.v1 = v1;
    }
}

现在,即使私有实例变量v1不能从外部直接访问,也可以通过公共方法getV1()setV1()间接访问。我们说 v1 是封装在 AccessLevel 类中的。封装是将变量包装在一个类中的概念,这样我们就可以限制如何从外部操纵这些变量。

考虑另一个测试类,如清单 8-10 所示。

// AccessLevelTest2.java
package com.jdojo.cls.p1;
import com.jdojo.cls.AccessLevel;
public class AccessLevelTest2 {
    public static void main(String[] args) {
        AccessLevel al = new AccessLevel();
        //int a = al.v1; /* A compile-time error */
        //int b = al.v2; /* A compile-time error */
        //int c = al.v3; /* A compile-time error */
        int d = al.v4;
        System.out.println("d = " + d);
        //al.m1(); /* A compile-time error */
        //al.m2(); /* A compile-time error */
        //al.m3(); /* A compile-time error */
        al.m4();
        /* Modify the values of instance variables */
        //al.v2 = 20;  /* A compile-time error */
        //al.v3 = 30;  /* A compile-time error */
        al.v4 = 40;
        System.out.println("After modifying v4...");
        //al.m2();  /* A compile-time error */
        //al.m3();  /* A compile-time error */
        al.m4();
    }
}
d = 400
Inside m4():
v1 = 100, v2 = 200, v3 = 300, v4 = 400
After modifying v4...
Inside m4():
v1 = 100, v2 = 200, v3 = 300, v4 = 40

Listing 8-10A Test Class Located in a Different Package from the AccessLevel Class

注意com.jdojo.cls.p1包中的AccessLevelTest2类,它不同于AccessLevel类所在的com.jdojo.cls包。AccessLevelTest2类的代码类似于AccessLevelTest1类的代码,只是大部分语句都被注释掉了。注意使用 import 语句从com.jdojo.cls包中导入AccessLevel类,这样就可以在main()方法中使用它的简单名称。在AccessLevelTest1类中,没有必要导入AccessLevel类,因为它们在同一个包中。AccessLevelTest2类只能访问AccessLevel类的公共成员,因为它与AccessLevel类在不同的包中。这就是未注释语句只访问公共实例变量v4public方法m4()的原因。注意,即使只有v4实例变量是可访问的,你也可以打印v1v2v3的值,通过公共方法m4()间接访问它们。

现在考虑一个更棘手的情况。参见清单 8-11 。

// AccessLevel2.java
package com.jdojo.cls;
class AccessLevel2 {
    public static int v1 = 600;
}

Listing 8-11A Class with Package-Level Access Having a Public Instance Variable

注意,AccessLevel2类没有使用访问级别修饰符,默认情况下它拥有包级别的访问权限。也就是说,AccessLevel2类只能在com.jdojo.cls包中访问。AccessLevel2类很简单。它只声明了一个成员,即public static变量v1

考虑清单 8-12 中显示的类AccessLevelTest3,它与类AccessLevel2在不同的包中。

// AccessLevelTest3.java
package com.jdojo.cls.p1;
import com.jdojo.cls.AccessLevel2; // A compile-time error
public class AccessLevelTest3 {
    public static void main(String[] args) {
        int a = AccessLevel2.v1;   // A compile-time error
    }
}

Listing 8-12A Test Class That Attempts to Access a Public Member of a Class with a Package-Level Access

AccessLevelTest3类试图访问AccessLevel2类的公共静态变量v1,这将产生一个编译时错误。这里有一个例外,具有公共访问级别的类成员可以从任何地方访问,当且仅当类本身可访问时,公共类成员才可以从任何地方访问。在本节的开始,我们使用了一个房子和房子里的厨房的类比。如果你错过了那个类比,让我们再来一个。

假设你口袋里有一些钱,你声明你的钱是公共的。所以,谁都可以要你的钱。然而,你把自己藏起来,这样就没有人能接近你。除非你能接触到别人,否则别人怎么能接触到你的钱呢?这就是AccessLevel2类及其公共静态变量v1的情况。把AccessLevel2类和你自己比较,把它的公共静态变量v1和你的钱比较。AccessLevel2类拥有包级别的访问权限。因此,只有其包(com.jdojo.cls)内的代码才能访问其名称。它的静态变量v1具有 public 的访问级别,这意味着任何代码都可以从任何包中访问它。静态变量v1属于AccessLevel2类。除非AccessLevel2类本身是可访问的,否则它的静态变量v1是不可访问的,即使它已经被声明为公共的。

清单 8-12 中的import语句也会产生一个编译时错误,因为AccessLevel2类在它的包com.jdojo.cls之外是不可访问的。

Tip

您必须考虑类及其成员的访问级别,以确定类成员是否可访问。类成员的访问级别可以使它可以被程序的一部分访问。然而,只有当成员所属的类本身也可访问时,程序的这一部分才能访问该类成员。

访问级别:案例研究

一个类成员可以有四个访问级别之一:privateprotectedpublic或包级别。类成员应该使用哪个访问级别?答案取决于成员类型及其用途。让我们讨论一个银行账户的例子。假设您创建了一个名为Account的类来表示一个银行账户,如下所示:

// Account.java
package com.jdojo.cls;
public class Account {
    public double balance;
}

银行账户持有账户中的余额。这个Account类就是这样做的。在现实世界中,一个银行账户可以保存更多的信息,例如,账号、账户持有人姓名、地址等。让我们保持Account类的简单,这样您就可以专注于访问级别的讨论。它允许每个实例在其balance实例变量中保存一个数值。如果您想要创建一个Account类的实例并操作它的平衡,它将看起来像这样:

// Create an account object
Account ac = new Account();
// Change the balance to 1000.00
ac.balance = 1000.00;
// Change the balance to 550.29
ac.balance = 550.29;

这段代码可以在 Java 应用程序中任何可以访问Account类的地方执行,因为Account类和它的balance实例变量都是公共的。然而,在现实世界中,没有人会让自己的银行账户被这样操纵。例如,银行可能会要求您的账户最低余额为零。通过这种实现,没有什么可以阻止您执行下面的语句,该语句将帐户中的余额减少到负数:

// Set a negative balance
ac.balance = -440.67;

在面向对象编程中,根据经验,定义对象状态的信息片段应该声明为私有的。一个类的所有实例变量构成了该类对象的状态。因此,应该将它们声明为private。如果需要类外的代码访问私有实例变量,则应该通过提供方法来间接进行访问。该方法应该具有适当的访问级别,这将只允许预期的客户端代码访问它。如前所述,这就是所谓的封装。

让我们将balance实例变量声明为私有变量。Account类的修改代码如下:

// Account.java
package com.jdojo.cls;
public class Account {
    private double balance;
}

使用修改后的Account类,您可以在 Java 应用程序中的任何地方创建一个Account类的对象,如果可以像下面这样访问Account类的话:

// Create an account object
Account ac = new Account();

然而,您不能访问Account对象的balance实例变量,除非您在Account类本身内部编写代码。因为私有实例变量balance不能从Account类外部访问,所以下面的代码只有在Account类内部编写时才有效:

// Change the balance
ac.balance = 188.37;

在这种形式下,修改后的Account类是不可接受的,因为您可以创建一个帐户,但不能读取或操作其余额。Account类必须为外部世界提供一些接口,以可控的方式访问和操作它的平衡。比如你有钱,想和外界分享,你不把钱给大家看,直接让他们拿。而是任何想要你钱的人都需要问你(给你发信息),然后你根据一定的情况把你的钱给他们。换句话说,钱是你的私人财产,你让别人以一种可控的方式获得它,让他们向你要钱,而不是让他们直接从你的口袋里拿钱。同样,您希望其他人查看帐户余额,将钱存入帐户,并从帐户中借记钱。然而,所有这些操作都应该通过一个Account对象发生,而不是直接操作一个帐户对象的余额。

Java 允许你使用实例方法向对象发送消息。一个对象可以接收来自外部世界的消息,并且它可以根据其内部状态对同一消息做出不同的响应。例如,当你所有的钱都花光了,有人问你要钱时,你可以回答说你没有钱。然而,当你有钱时,你对同样的信息(给我钱)的反应是不同的(通过给钱)。

让我们在Account类中声明三个公共方法,它们将作为那些需要访问和操作账户余额的人与外界的接口:

  • 一个getBalance()方法将返回一个账户的余额。

  • 一个credit()方法将指定的金额存入一个账户。

  • 一个debit()方法将从账户中提取指定的金额。

如果交易成功,credit()debit()方法都将返回 1,如果交易失败,则返回–1。

清单 8-13 有修改后的Account类的代码。

// Account.java
package com.jdojo.cls;
public class Account {
    private double balance;
    public double getBalance() {
        // Return the balance of this account
        return this.balance;
    }
    public int credit(double amount) {
        // Make sure credit amount is not negative, NaN or infinity
        if (amount < 0.0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
            System.out.println("Invalid credit amount: " + amount);
            return -1;
        }
        // Credit the amount
        System.out.println("Crediting amount: " + amount);
        this.balance = this.balance + amount;
        return 1;
    }
    public int debit(double amount) {
        // Make sure the debit amount is not negative, NaN or infinity */
        if (amount < 0.0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
             System.out.println("Invalid debit amount: " + amount);
            return -1;
        }
        // Make sure a minimum balance of zero is maintained
        if (this.balance < amount) {
            System.out.println("Insufficient funds. Debit attempted: " + amount);
            return -1;
        }
        // Debit the amount
        System.out.println("Debiting amount: " + amount);
        this.balance = this.balance - amount;
        return 1;
    }
}

Listing 8-13A Modified Version of the Account Class with a Private Instance Variable and Public Methods

该类包含一个私有实例变量。它包含允许外界访问和修改私有实例变量的公共方法。公共方法就像私有实例变量的保护罩。它们让外界以可控的方式读取或修改私有实例变量。例如,您不能贷记负金额,并且必须保持最低零余额。我们来测试一下Account类。测试代码如清单 8-14 所示。

// AccountTest.java
package com.jdojo.cls;
public class AccountTest {
    public static void main(String[] args) {
        Account ac = new Account();
        double balance = ac.getBalance();
        System.out.println("Balance = " + balance);
        // Credit and debit some amount
        ac.credit(234.78);
        ac.debit(100.12);
        balance = ac.getBalance();
        System.out.println("Balance = " + balance);
        // Attempt to credit and debit invalid amounts
        ac.credit(-234.90);
        ac.debit(Double.POSITIVE_INFINITY);
        balance = ac.getBalance();
        System.out.println("Balance = " + balance);
        // Attempt to debit more than the balance
        ac.debit(2000.00);
        balance = ac.getBalance();
        System.out.println("Balance = " + balance);
    }
}
Balance = 0.0
Crediting amount: 234.78
Debiting amount: 100.12
Balance = 134.66
Invalid credit amount: -234.9
Invalid debit amount: Infinity
Balance = 134.66
Insufficient funds. Debit attempted: 2000.0
Balance = 134.66

Listing 8-14A Test Class to Test the Account Class Behavior

AccountTest类创建了一个Account类的对象,并尝试使用它的公共方法对其进行各种操作。结果显示在输出中,这表明这是一个改进的Account类,保护帐户对象不被错误操作。您还可以注意到,将实例变量设为私有并允许通过公共方法访问它们可以让您实施您的业务规则。如果您公开实例变量,您就不能实施任何控制其有效值的业务规则,因为任何人都可以不受任何限制地修改它们。

当你设计一个类时,要记住的一个要点是它的可维护性。将所有实例变量保持为私有,并允许通过公共方法访问它们,这使得您的代码为将来的更改做好了准备。假设你的账户最低余额为零。您已经在生产环境中部署了Account类,并且它正在应用程序的许多地方使用。现在,您想要实现一个新的业务规则,该规则规定每个帐户的最小余额必须为 100。做出这种改变很容易。只需修改debit()方法的代码,就完成了。您不需要对调用Account类的debit()方法的客户端代码进行任何修改。请注意,您需要对Account类做一些工作,以完全执行最小余额为 100 的规则。创建帐户时,默认情况下余额为零。为了在创建帐户时执行这个新的最小余额规则,您需要了解一个类的构造器。这本书将在下一章介绍构造器。

Account类中的balance实例变量的访问级别的另一个选项是给它一个包级别的访问。回想一下,包级别的访问是通过在类成员的声明中不使用访问修饰符而给予类成员的。如果balance实例变量有包级别的访问权,这比给它public访问权要好一点,因为它不是从任何地方都可以访问的。然而,它可以被声明了Account类的同一个包中的代码直接访问和操作。到目前为止,您已经理解了让任何代码直接从Account类外部访问balance实例变量是不可接受的。此外,如果您声明Account类的方法具有包级访问权限,那么它只能在声明了Account类的同一个包中使用。您希望从应用程序的任何地方使用其方法来操作Account类的对象。因此,您不能将Account类的方法或实例变量声明为具有包级访问。什么时候声明一个类和/或类成员拥有包级的访问权限?通常,当一个类必须充当包中其他类的助手类或内部实现时,包级访问用于该类及其成员。

什么时候对类成员使用私有访问级别?您已经看到了为Account类使用private实例变量的好处。实例变量的私有访问级别提供了数据隐藏,其中对象的内部状态受到保护,不会被外部访问。一个类的实例方法定义了它的对象的行为。如果一个方法只在类内部使用,而外部代码不知道它,那么这个方法应该有一个私有访问级别。让我们回到你的Account课堂。您已经使用了相同的逻辑来验证传递给credit()debit()方法的数量。您可以将验证金额的代码移动到私有方法isValidAmount(),该方法由Account类在内部使用。它检查用于贷记或借记的金额是否不是负数,不是NaN,也不是无穷大。一个数字成为有效数字的这三个标准只适用于Account类,其他类不需要使用它们。这就是为什么您需要将此方法声明为 private。声明它是私有的还有一个好处。将来,你可以制定一个规则,规定你必须从任何账户中最少存入或借记 10 英镑。这时候,你只需改变私有的isValidAmount()方法,就大功告成了。如果您公开了这个方法,它将影响所有使用它来验证金额的客户端代码。您可能不想全局更改有效金额的标准。当业务规则发生变化时,为了保持变化的效果局限在一个类中,您必须将一个方法实现为私有方法。您可以在您的Account类中实现这个逻辑,如下所示(只显示修改后的代码):

// Account.java
package com.jdojo.cls;
public class Account {
    /* Other code goes here */
    public int credit(double amount) {
        // Make sure credit amount is valid
        if (!this.isValidAmount(amount, "credit")) {
          return -1;
        }
        /* Other code goes here */
    }
    public int debit(double amount) {
        // Make sure debit amount is valid
        if (!this.isValidAmount(amount, "debit")) {
          return -1;
        }
        /* Other code goes here */
    }
    // Use a private method to validate credit/debit amount
    private boolean isValidAmount(double amount, String operation) {
        // Make sure amount is not negative, NaN or infinity
        if (amount < 0.0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
          System.out.println("Invalid " + operation + " amount: " + amount);
          return false;
        }
        return true;
    }
}

注意,您可能已经使用下面的逻辑以一种更简单的方式实现了credit()方法(也是debit()方法):

if (amount >= 0)  {
    this.balance = this.balance + amount;
    return 1;
} else {
    /* Print error here */
    return –1;
}

您可以使用更简单的逻辑来实现credit()方法,该方法检查金额是否有效,而不是检查金额是否无效。我们没有使用这个逻辑,因为我们想在同一个例子中演示如何使用私有方法。有时,人们会编写更多的代码来阐述讨论中的观点。

现在,您只剩下受保护的访问级别修饰符。什么时候声明一个类成员受保护?具有受保护访问级别的类成员可以在同一个包和子类中被访问,即使子类不在同一个包中。我们将在第二十章中讨论如何创建一个子类以及受保护访问级别的使用。

什么是 Var-Args 方法?

术语“var-args”是“可变长度参数”的简写它允许您声明一个方法或构造器,该方法或构造器接受可变数量的参数。在这个讨论中,我们只使用“方法”这个术语。然而,讨论也适用于构造器。

一个方法接受的参数数量被称为它的 arity 。接受可变长度参数的方法称为变量实参法或变量实参法。var-args 方法看起来像什么?在查看 var-args 方法之前,让我们先讨论一下非 var-args 方法是如何工作的。

考虑下面声明了一个max()方法的MathUtil类的代码。该方法有两个参数。它计算并返回两个参数中的最大值:

public class MathUtil {
    public static int max(int x, int y) {
        int max = x;
        if (y > max) {
            max = y;
        }
        return max;
    }
}

MathUtil类或它的max()方法中没有什么特别的事情发生。假设你想计算两个整数的最大值,比如 12 和 18。您将如下调用max()方法:

int max = MathUtil.max(12, 18);

执行该语句时,18 将被赋给变量max。假设你想计算三个整数的最大值。您可能会得出以下逻辑:

int max = MathUtil.max(MathUtil.max(70, 9), 30);

这个逻辑很好。它计算两个整数的最大值,然后计算两个整数和第三个整数的结果的最大值。假设你想计算十个整数的最大值。您可以重复这个逻辑,这样就可以了,尽管代码可能不可读。你需要一个更好的方法来做这件事。

现在尝试重载max()方法,使其接受三个整数参数。这里是MathUtil类的新版本,叫做MathUtil2:

public class MathUtil2 {
    public static int max(int x, int y) {
        int max = x;
        if (y > max) {
            max = y;
        }
        return max;
    }
    public static int max(int x, int y, int z) {
        int max = x;
        if (y > max) {
            max = y;
        }
        if (z > max) {
            max = z;
        }
        return max;
    }
}

您可以计算两个和三个整数的最大值,如下所示:

int max1 = MathUtil2.max(12, 18);
int max2 = MathUtil2.max(10, 8, 18);

添加一个带有三个int参数的max()方法确实暂时解决了问题。真正的问题仍然存在。您必须添加一个带有所有可能整数参数的max()方法。你会同意没有一个程序员想写一个max()方法,在那里他们必须不断添加新的版本。

在 Java 中引入 var-args 方法之前,当方法的参数数量在设计时未知时,您可以将方法参数声明为一个数组int,如下所示。我们将在后面的章节中详细讨论数组:

public class MathUtil3 {
    public static int max(int[] num) {
        /* Must check for zero element in num here */
        int max = Integer.MIN_VALUE;
        for(int i = 0; i < num.length; i++) {
            if (num[i] > max) {
                max = num[i];
            }
        }
        return max;
    }
}

您可以编写以下代码片段,使用MathUtil3.max()方法计算两个和三个整数的最大值:

int[] num1 = new int[] {10, 1};
int max1 = MathUtil3.max(num1);
int[] num2 = new int[] {10, 8, 18} ;
int max2 = MathUtil3.max(num2);

您可以向MathUtil3.max()方法传递任意数量的整数。从某种意义上说,您有办法向方法传递任意数量的参数。困扰程序员的是当方法的参数类型是数组时需要调用的方式。当需要用数组参数调用方法时,必须创建一个数组对象并打包其所有元素的值。这里的问题不是max(int[] num)方法中的代码;相反,是客户端代码调用这个方法。

Var-args 前来救援。让我们声明一个max()方法,它可以接受任意数量的整数参数,包括零参数。var-args 方法的优点在于调用该方法的客户端代码更简单。那么如何声明一个 var-args 方法呢?你所需要做的就是在方法参数的数据类型后添加一个省略号(或类似于...)的三点)。下面的代码片段展示了一个带有一个可变长度参数nummax()方法声明,该参数属于int数据类型。注意数据类型int后省略号的位置:

public static int max(int... num) {
    // Code goes here
}

在省略号前后添加空格是可选的。以下所有 var-args 方法声明都是有效的。他们在省略号前后使用不同类型的空格:

public static int max(int ... num)   // A space after
public static int max(int ... num)   // A space before and after
public static int max(int ... num)   // No space before and after
public static int max(int ... num)
                                     // A space before and a newline after

var-args 方法可以有多个参数。下面的代码片段显示aMethod()接受三个参数,其中一个是可变长度参数:

public static int aMethod(String str, double d1, int...num) {
    // Code goes here
}

var-args 方法有两个限制:

  • var-args 方法最多可以有一个变长参数。下面对m1()方法的声明是无效的,因为它声明了两个变长参数n1n2:

  • var-args 方法的可变长度参数必须是参数列表中的最后一个参数。以下对m2()方法的声明无效,因为变长参数n1未被声明为最后一个参数:

// An invalid declaration
void m1(String str, int...n1, int...n2) {
    // Code goes here
}

// An invalid declaration
void m2(int...n1, String str) {
    // Code goes here
}

您可以通过将参数n1移动到最后来修改前面的声明,如下所示:

// A valid declaration
void m2(String str, int...n1) {
    // Code goes here
}

让我们重写max()方法,使其成为 var-args 方法,如下所示:

public class MathUtil4 {
    public static int max(int...num) {
        int max = Integer.MIN_VALUE;
        for(int i = 0; i < num.length; i++) {
            if (num[i] > max) {
                max = num[i];
            }
        }
        return max;
    }
}

在 var-args 方法中几乎总是有一个循环来处理可变长度参数的参数列表。length属性给出了为可变长度参数传递的值的数量。例如,max() var-args 方法中的num.length将给出传递给该方法的整数个数。要获得变长参数中的第 n 个值,需要使用varArgsName[n-1]。例如,num[0]num[1]num[n-1]将包含为num可变长度参数传递的第一个、第二个和第 n 个值。如果您只想处理可变长度参数传入的所有值,您可以使用一个更简单的循环,一个foreach循环。您可以使用一个foreach循环重写max()方法的代码,如下所示:

public static int max2(int...num) {
    int max = Integer.MIN_VALUE;
    for(int currentNumber : num) {
        if (currentNumber > max) {
            max = currentNumber;
        }
    }
    return max;
}

MathUtil4.max()方法的主体与将num参数声明为int数组是一样的。你这样想是对的。Java 编译器使用数组实现变长参数。MathUtil4.max()方法之前的声明被编译器改变了。编译代码时,声明部分max(int...num)被改为max(int[] num)。使用变长参数有什么好处?在方法中使用变长参数的好处来自于调用方法的优雅方式。您可以如下调用MathUtil4.max()方法:

int max1 = MathUtil4.max(12, 8);
int max2 = MathUtil4.max(10, 1, 30);

对于方法中的变长参数,可以使用零个或多个参数。以下代码是对max()方法的有效调用:

int max = MathUtil4.max(); // Passing no argument is ok

不带参数调用MathUtil4.max()方法会返回什么?如果你看看方法的主体,它会返回Integer.MIN_VALUE,也就是-2147483648。实际上,没有至少两个参数的对max()方法的调用不是有效的调用。当方法是 var-args 方法时,必须检查无效的参数数量。对于非 var-args 方法,您不会遇到参数数量无效的问题,因为编译器会强制您使用确切数量的参数。以下对max()方法的声明将强制其调用者传递至少两个整数:

// Arguments n1 and n2 are mandatory
public static int max(int n1, int n2, int... num) {
    // Code goes here
}

编译器会将前两个参数n1n2视为强制参数,将第三个参数num视为可选参数。现在,您可以向max()方法传递两个或更多的整数。清单 8-15 展示了max()方法的最终完整代码。

// MathUtil5.java
package com.jdojo.cls;
public class MathUtil5 {
    public static int max(int n1, int n2, int... num) {
        // Initialize max to the maximum of n1 and n2
        int max = (n1 > n2 ? n1 : n2);
        for(int i = 0; i < num.length; i++) {
            if (num[i] > max) {
                max = num[i];
            }
        }
        return max;
    }
    public static void main(String[] args) {
        System.out.println("max(7, 9) = " + MathUtil5.max(7, 9));
        System.out.println("max(70, 19, 30) = " + MathUtil5.max(70, 19, 30));
        System.out.println("max(-7, -1, 3) = " + MathUtil5.max(-70, -1, 3));
    }
}
max(7, 9) = 9
max(70, 19, 30) = 70
max(-7, -1, 3) = 3

Listing 8-15A Utility Class to Compute the Maximum of Some Specified Integers Using a Var-Args Method

当调用MathUtil5.max()方法时,可以传递任意数量的整数。以下所有语句都是有效的:

int max1 = MathUtil5.max(12, 8);        // Assigns 12 to max1
int max2 = MathUtil5.max(10, 1, 30);    // Assigns 30 to max2
int max3 = MathUtil5.max(11, 3, 7, 37); // Assigns 37 to max3

如果在没有参数或只有一个参数的情况下调用MathUtil5.max()方法,编译器将生成一个错误:

int max1 = MathUtil5.max();   // A compile-time error
int max2 = MathUtil5.max(10); // A compile-time error

重载 Var-Args 方法

同样的方法重载规则也适用于 var-args 方法。只要方法的参数在类型、顺序或数量上不同,就可以用变长参数重载方法。例如,下面是一个重载的max()方法的有效示例:

public class MathUtil6 {
    public static int max(int x, int y) {
        // Code goes here
    }
    public static int max(int...num) {
        // Code goes here
    }
}

考虑下面的代码片段,它用两个参数调用重载方法MathUtil6.max():

int max = MathUtil6.max(12, 13); // which max() will be called?

MathUtil6类有两个max()方法。一个方法接受两个int参数,另一个接受一个可变长度的int参数。在这种情况下,Java 将调用max(int x, int y)。Java 首先尝试使用参数数量的精确匹配来查找方法声明。如果没有找到精确匹配,它将使用可变长度参数查找匹配。

Tip

如果 var-args 方法重载,Java 会使用该方法更具体的版本,而不是使用 var-args 方法。Java 使用 var-args 方法作为解决方法调用的最后手段。

有时调用重载的 var-args 方法可能会给 Java 编译器带来混乱。方法本身的重载可能是有效的。但是,对它的调用可能会导致问题。考虑下面的MathUtil7类代码片段,它是方法重载的一个有效例子:

public class MathUtil7 {
    public static int max(int...num) {
        // Code goes here
    }
    public static int max(double...num) {
        // Code goes here
    }
}

当执行下面的语句时,会调用哪个版本的max()方法?

int max = MathUtil7.max(); // Which max() to call?

前面的语句将生成一个编译时错误,指出对MathUtil7.max()的调用不明确。Java 允许您为变长参数传递零个或多个值。在前面的语句中,方法max(int...num)max(double...num)都符合调用MathUtil7.max()的条件。编译器无法决定调用哪一个。您可能会发现许多其他实例,在这些实例中,对重载的 var-args 方法的调用会导致不明确的方法调用,并且编译器会生成错误。错误消息将引导您找到有问题的代码。

Var-Args 方法和 main()方法

回想一下,如果您想要运行一个类,您需要在其中声明一个main()方法,并使用一个String数组作为它的参数。对于main()方法的签名必须是main(String[] args)。编译器使用数组实现 var-args 方法。如果你的方法签名是m1(Xxx...args),编译器会把它改成m1(Xxx[] args)。现在,您可以使用使用String数组的旧符号或者使用使用 var-args 的新符号来声明您的类的main()方法。下面对Test类的main()方法的声明是有效的。您将能够使用java命令运行Test类:

public class Test {
    public static void main(String...args)  {
        System.out.println("Hello from varargs main()...");
    }
}

参数传递机制

本节讨论向不同编程语言中使用的方法传递参数的不同方式。这一节并不是专门针对 Java 的。Java 可能不支持本节中使用的语法或符号。这一节对于程序员理解方法调用过程中的内存状态很重要。如果你是一个有经验的程序员,你可以跳过这一节。下一节讨论 Java 中的参数传递机制。

下面是一些常用的向方法传递参数的机制:

  • 按值传送

  • 按常量值传递

  • 通过引用传递

  • 按参考值传递

  • 按结果传递

  • 按结果值传递

  • 按名字传递

  • 因需要而错过

变量有三个组成部分:名称、内存地址(或位置)和数据。变量名是一个逻辑名,在程序中用来引用它的内存地址。数据存储在与变量名相关联的内存地址中。存储在内存地址的数据也称为变量值。假设你有一个名为idint变量,它的值是785,存储在内存地址131072。你可以如下声明并初始化id变量:

int id = 785;

如图 8-2 所示,您可以可视化变量名、其内存地址和存储在内存地址的数据之间的关系。

img/323069_3_En_8_Fig2_HTML.png

图 8-2

变量名、它的内存地址和数据之间的关系

在图 8-2 中,你看到变量id的实际数据存储在内存地址。您也可以将数据存储在不是变量实际值的内存地址中;相反,它是存储实际值的位置的内存地址。在这种情况下,存储在第一存储器地址的值是对存储在某个其他存储器地址的实际数据的引用,这样的值被称为引用或指针。如果一个变量存储了对某些数据的引用,它就被称为引用变量

对比短语“变量”和“参考变量”变量在其内存位置存储实际数据本身。引用变量存储实际数据的引用(或内存地址)。图 8-3 描述了变量和参考变量之间的差异。

img/323069_3_En_8_Fig3_HTML.png

图 8-3

变量和参考变量之间的差异

在图 8-3 中,idRef为参考变量,id为变量。这两个变量分别分配内存。785的实际值存储在 id 变量的存储位置,即131072。然而,idRef的内存位置(262144)存储了785所在的id变量(或内存位置)的地址。您可以使用这两个变量中的任何一个来获取内存中的值785。获取引用变量所引用的实际数据的操作称为解引用

方法(在某些编程语言中也称为函数或过程)可以选择性地接受来自其调用者的参数。方法的参数允许在调用方上下文和方法上下文之间共享数据。实践中有许多机制将参数传递给方法。下面几节讨论一些常用的参数传递机制。

按值传送

按值传递是最容易理解的参数传递机制。然而,它不一定在所有情况下都是最有效和最容易实现的。当调用一个方法时,实际参数的值被复制到形参中。当方法执行开始时,内存中存在该值的两个副本:一个副本用于实参,一个副本用于形参。在方法内部,形参操作它自己的值的副本。对形式参数值的任何更改都不会影响实际参数的值。

图 8-4 描述了使用传值机制调用方法时的内存状态。需要强调的是,一旦形式参数获得了它的值,也就是实际参数的副本,这两个参数就彼此无关了。形参在方法调用结束时被丢弃。但是,在方法调用结束后,实际参数会保留在内存中。实际参数在内存中保留多长时间取决于实际参数的上下文。

img/323069_3_En_8_Fig4_HTML.png

图 8-4

调用方法时实际参数和形参的内存状态

考虑下面一个increment()方法的代码,它接受一个int参数并按2递增:

// Assume that num is passed by value
void increment(int num) {
    /* #2 */
    num = num + 2;
    /* #3 */
}

假设您用下面的代码片段调用了increment()方法:

int id = 57;
/* #1 */
increment(id);
/* #4 */

代码中的四个执行点被标记为#1、#2、#3 和#4。表 8-4 描述了在increment()方法被调用之前、之后以及调用时,实参和形参的存储状态。注意,形式参数num不再存在于#4 的内存中。

表 8-4

当 increment()方法被调用并且参数通过值传递时,实参和形参的内存状态描述

|

执行点

|

实际参数 id 的存储状态

|

形参 num 的存储状态

#1 id变量存在于内存中,其值为 57。 此时不存在num变量。
#2 id变量存在于内存中,其值为 57。 形式参数num已在内存中创建。实际参数id的值已被复制到与num变量相关的地址。此时,num保存的值为 57。
#3 id变量存在于内存中,其值为 57。 此时,num的值为 59。
#4 id变量存在于内存中,其值为 57。 形式参数num此时不存在于内存中,因为方法调用已经结束。

当方法调用结束时,包括形参在内的所有局部变量都将被丢弃。您可以观察到在increment()方法中增加形参的值实际上是没有用的,因为它永远不会被传递回调用者环境。如果您想将一个值发送回调用者环境,您可以在方法体中使用一个return语句来完成。下面是smartIncrement()方法的代码,该方法将递增的值返回给调用者:

// Assume that num is passed by value
int smartIncrement(int num) {
    num = num + 2;
    return num;
}

您将需要使用下面的代码片段来存储从id变量中的方法返回的增量值:

int id = 57;
id = smartIncrement(id);  // Store the returned value in id
/* At this point id has a value of 59 */

请注意,通过值传递允许您使用多个参数将多个值从调用方环境传递给方法。但是,它只允许您从方法发回一个值。如果只考虑方法调用中的参数,那么按值传递是一种单向通信。它允许您使用参数将信息从调用方传递给方法。但是,它不允许您通过参数将信息传回给调用者。有时,您可能希望通过参数将多个值从一个方法发送到调用者的环境中。在这些情况下,您需要考虑向方法传递参数的不同方式。在这种情况下,按值传递机制没有任何帮助。

当参数通过值传递时,用于交换两个值的方法不起作用。考虑以下经典swap()方法的代码:

// Assume that x and y are passed by value
void swap(int x, int y) {
    int temp = x;
    x = y;
    y = temp;
}

您可以使用下面的代码片段调用前面的swap()方法:

int u = 75;
int v = 53;
swap(u, v);
/* At this point, u and v will be still 75 and 53, respectively */

至此,您应该能够理解为什么uv的值在传递给swap()方法时没有被交换。当调用swap()方法时,uv的值被分别复制到xy形参的位置。在swap()方法内部,形式参数xy的值被交换了,实际参数uv的值根本没有被触及。当方法调用结束时,形参xy被丢弃。

使用按值传递的优点如下:

  • 很容易实现。

  • 如果被复制的数据是一个简单的值,速度会更快。

  • 实际参数在传递给方法时不会受到任何副作用的影响。

使用按值传递的缺点如下:

  • 如果实际的参数是复杂的数据,比如一个大的对象,那么将数据复制到另一个内存位置即使不是不可能,也是很困难的。

  • 复制大量数据会占用内存空间和时间,这可能会降低方法调用的速度。

按常量值传递

通过常量值传递通过值传递本质上是相同的机制,唯一的区别是形式参数被视为常量,因此,它们不能在方法体内改变。实际参数的值被复制到形式参数中,如中通过值所做的。如果通过常量值传递形参,则只能读取方法体中形参的值。

通过引用传递

重要的是,不要混淆短语“引用”和“通过引用传递”“引用”是一条信息(通常是一个内存地址),用于获取存储在其他位置的实际数据。“通过引用传递”是一种将信息从调用者的环境传递给使用形式参数的方法的机制。

在按引用传递中,传递实参的内存地址,形参与实参的内存地址映射(或关联)。这种技术也称为别名,即多个变量与同一个内存位置相关联。形式参数名是实际参数名的别名。当一个人有两个名字时,无论你用两个名字中的哪一个,你指的都是同一个人。类似地,当参数通过引用传递时,无论您在代码中使用哪个名称(实际的参数名称或形式参数名称),您都是在引用相同的内存位置,因此引用相同的数据。

在引用传递中,如果在方法内部修改了形参,实参会立即看到修改。图 8-5 描述了当一个方法的参数通过引用传递时,实参和形参的存储状态。

img/323069_3_En_8_Fig5_HTML.png

图 8-5

当参数通过引用传递时,实参和形参的记忆状态

许多书使用“通过引用”这个短语。然而,它们并不是指我们在本节中讨论的那个。它们实际上意味着“通过引用值传递”,我们将在下一节讨论这一点。请注意,在按引用传递中,您没有为形参分配单独的内存。相反,您只需将形式参数名与实际参数的相同内存位置相关联。

我们再做一遍increment()方法调用练习。这一次,假设num参数是通过引用传递的:

// Assume that num is passed by reference
void increment(int num) {
    /* #2 */
    num = num + 2;
    /* #3 */
}

您将使用以下代码片段调用increment()方法:

int id = 57;
/* #1 */
increment(id);
/* #4 */

表 8-5 描述了在increment()方法调用之前、之后和期间,实参和形参的内存状态。注意,在#4,形式参数num不再存在于内存中,在方法调用结束后,实际参数id仍然具有值59

表 8-5

调用 increment()方法并通过引用传递参数时,实参和形参的内存状态描述

|

执行点

|

实际参数 id 的存储状态

|

形参 num 的存储状态

#1 id变量存在于内存中,其值为 57。 此时不存在num变量。
#2 id变量存在于内存中,其值为 57。 形参名num已经和实参id的内存地址关联。在这一点上,num指的是57的值,和id指的完全一样。
#3 id变量存在于内存中,其值为 59。在方法内部,您使用了名为num的形参将值增加 2。然而,idnum是同一个内存位置的两个名字,因此,id的值也是 59。 此时,num保存一个值59
#4 id变量存在于内存中,其值为59 名为num的形参此时不存在于内存中,因为方法调用已经结束。

通过引用传递允许您在调用方环境和被调用的方法之间进行双向通信。您可以通过引用一个方法来传递多个参数,该方法可以修改所有参数。对形参的所有修改都会立即反映到调用者的环境中。这让您可以在两个环境之间共享多条数据。

经典的swap()方法示例在其参数通过引用传递时有效。考虑下面的swap()方法的定义:

// Assume that x and y are passed by reference
void swap(int x, int y) {
    int temp = x;
    x = y;
    y = temp;
}

您可以使用下面的代码片段调用前面的swap()方法:

int u = 75;
int v = 53;
swap(u, v);
/* At this point, u and v will be 53 and 75, respectively. */

考虑以下名为getNumber()的方法的代码片段:

// Assume that x and y are passed by reference
int getNumber(int x, int y) {
    int x = 3;
    int y = 5;
    int sum = x + y;
    return sum;
}

假设您如下调用getNumber()方法:

int w = 100;
int s = getNumber(w, w);
/* What is the value of s at this point: 200, 8, 10 or something else? */

getNumber()方法返回时,变量s中会存储什么值?注意,getNumber()方法的两个参数都是通过引用传递的,并且在调用中为两个参数传递了同一个变量w。当getNumber()方法开始执行时,形参xy是同一个实参w.的别名。当你使用wx或 y 时,你指的是内存中相同的数据。在将xy相加并将结果存储在sum局部变量中之前,该方法将y的值设置为5,使得wxy的值都为5。当xy被添加到方法内部时,xy都引用值5getNumber()方法返回10

考虑将对getNumber()方法的另一个调用作为表达式的一部分,如下所示:

int a = 10;
int b = 19;
int c = getNumber(a, b) + a;
/* What is the value of c at this point? */

在前面的代码片段中猜测c的值有点棘手。您需要考虑getNumber()方法调用对实际参数的副作用。getNumber()方法将返回8,它还将把ab的值分别修改为35。值11 ( 8 + 3)将被分配给c。考虑下面的语句,其中您更改了加法运算符的操作数顺序:

int a = 10;
int b = 19;
int d =  a + getNumber(a, b);
/* What is the value of d at this point? */

d的值将是18 ( 10 + 8)。本地值 10 将用于a。如果参数是通过引用传递的,您需要考虑方法调用对实际参数的副作用。你可能会认为表达式getNumber(a, b) + aa + getNumber(a, b)会给出相同的结果。然而,正如我们已经解释过的,当参数通过引用传递时,结果可能不一样。

使用按引用传递的优点如下:

  • 与按值传递相比,它更有效,因为不复制实际的参数值。

  • 它允许您在调用者和被调用的方法环境之间共享多个值。

使用按引用传递的缺点如下:

  • 如果调用者没有考虑到对被调用方法内部的实际参数所做的修改,这可能是危险的。

  • 由于形式参数对实际参数的副作用,程序逻辑不容易理解。

按参考值传递

使用按引用值传递向方法传递参数的机制不同于按引用传递。然而,这两种机制具有相同的效果。在通过引用值传递中,实参的引用被复制到形参。形式参数使用解引用机制来访问实际参数的值。方法内部的形参所做的修改对于实际的形参来说是直接可见的,就像通过引用传递一样。图 8-6 描述了使用引用值传递机制时,实际参数和形式参数的存储状态。

img/323069_3_En_8_Fig6_HTML.png

图 8-6

当使用引用值传递机制进行方法调用时,实际参数和形参的内存状态

按引用传递和按引用值传递有一个重要的区别。在通过引用值传递中,实参的引用作为方法调用的一部分被复制到形参。但是,您可以更改形参以引用方法内部的不同内存位置,这不会使实际形参引用内存中的新位置。一旦更改了存储在形参中的引用,对存储在新位置的值所做的任何更改都不会更改实际参数的值。

涉及通过引用传递的副作用和存储器状态的讨论和示例也适用于通过引用值传递机制。大多数编程语言使用按引用值传递来模拟按引用传递机制。

通过常量参考值

按恒定参考值传递本质上与按参考值传递相同,只是有一点不同。形参被视为方法体内的常量。也就是说,在方法的整个执行过程中,形参持有实参持有的引用的副本。不能在方法体内部修改形参来保存除实际形参所引用的数据之外的数据引用。

按结果传递

你可以把按结果传递看作是按值传递的反义词。在按值传递中,实际参数的值被复制到形参中。在按结果传递中,实参的值不会复制到形参中。当方法执行开始时,形参被视为未初始化的局部变量。在方法执行期间,形参被赋值。在方法执行结束时,形参的值被复制回实参。

图 8-7 描述了使用参数传递的结果传递机制时,实参和形参的存储状态。

img/323069_3_En_8_Fig7_HTML.png

图 8-7

使用按结果传递参数机制时,实际参数和形式参数的内存状态

当使用按结果传递机制时,有时形式参数也被称为OUT参数。它们被称为OUT参数,因为它们被用于从方法中复制一个值到调用者的环境中。同样,如果使用传值机制,形式参数有时也称为IN参数,因为它们用于复制实际参数的值。

按值传递结果

也称为按复制-还原传递,这是按值传递和按结果传递的组合(因此得名“按值传递结果”)。它也被称为传递参数的IN-OUT方式。当调用一个方法时,实际参数的值被复制到形参中。在方法的执行过程中,形参对它自己的数据本地副本进行操作。当方法调用结束时,形参的值被复制回实参。这就是它也被称为通过拷贝还原传递的原因。它在方法调用开始时复制实参的值,在方法调用结束时在实参中恢复形参的值。图 8-8 描绘了使用按值传递结果机制传递参数时,实参和形参的存储状态。

img/323069_3_En_8_Fig8_HTML.png

图 8-8

使用按结果传递参数机制时,实际参数和形式参数的内存状态

它以不同的方式实现了按引用传递的效果。在按引用传递中,对形参的任何修改对实参都是可见的。在按值传递结果中,只有当方法调用返回时,对形参的任何修改对实参才是可见的。如果使用按值传递结果的形参在一个方法中被多次修改,那么实际的形参只能看到最后修改的值。

按值传递结果用于模拟分布式应用程序中的引用传递。假设您进行了一个远程方法调用,它在不同的机器上执行。存在于一台机器上的实际参数的引用(内存地址)在执行远程方法的机器上没有任何意义。在这种情况下,客户端应用程序向远程机器发送实际参数的副本。复制到形参的值在远程机器上。形式参数对副本进行操作。当远程方法调用返回时,远程机器上的形参值被复制回客户机上的实参。这为客户端代码提供了通过引用运行在另一台机器上的远程方法来传递参数的功能。

按名字传递

通常,在将实际参数表达式的值/引用传递给方法之前,会对其进行计算。在按名称传递中,调用方法时不计算实际参数的表达式。方法体中的形参名称在文本上被替换为相应的实参表达式。在方法的执行过程中,每次遇到实际参数时都会对其进行计算,并且这些参数是在调用方的上下文中计算的,而不是在方法的上下文中。如果在替换过程中,方法中的局部变量与实际的参数表达式之间存在名称冲突,则局部变量将被重命名,以便为每个变量提供唯一的名称。

按名称传递是使用 thunks 实现的。一个 thunk 是一段代码,它在特定的上下文中计算并返回表达式的值。为每个实际参数生成一个 thunk,它的引用被传递给方法。每次使用形参时,都会调用 thunk,它在调用者上下文中计算实际的形参。

按名称传递的优点是,除非在方法中使用实际参数,否则永远不会计算它们。这也被称为懒惰评估。与传值机制形成对比,在传值机制中,实际参数总是在被复制到形参之前进行计算。这叫热切评价。按名称传递的缺点是,每次在方法体中使用相应的形式参数时,都要计算实际参数。如果一个方法使用了按名称传递的形参,也很难理解它的逻辑,这也有副作用。

考虑方法squareDivide()的如下声明:

int squareDivide(int x, int y) {
    int z =  x * x/y * y;
    return z;
}

考虑下面调用squareDivide()方法的代码片段:

squareDivide((4+4), (2+2));

您可以想象这个调用的执行,就像您编写了如下的squareDivide()方法一样。注意,(2+2)(4+4)的实际参数表达式在方法体内被多次求值:

int squareDivide() {
    int z = (4+4)*(4+4)/(2+2)*(2+2);
    return z;
}

因需要而错过

按需要传递类似于按名称传递,只有一点不同。在按名称传递中,每次在方法中使用实际参数时,都会对其进行计算。在按需传递中,实际参数仅在第一次使用时评估一次。当第一次调用实际参数的 thunk 时,它计算实际参数表达式,缓存该值并返回它。当再次调用同一个 thunk 时,它只是返回缓存的值,而不是再次重新计算实际的参数表达式。

Java 中的参数传递机制

Java 支持两种数据类型:原始数据类型和引用数据类型。原始数据类型是一种简单的数据结构,它只有一个相关的值。引用数据类型是一种复杂的数据结构,它代表一个对象。原始数据类型的变量将值直接存储在其内存地址中。假设你有一个int变量id。此外,假设它已经被赋值为754,并且它的存储器地址是131072:

int id = 754;

图 8-9 显示了id变量的记忆状态。

img/323069_3_En_8_Fig9_HTML.png

图 8-9

id 变量的值为 754 时的内存状态

754直接存储在与id变量名相关联的内存地址131072中。如果执行下面的语句,将一个新值351赋给id变量,会发生什么?

id = 351;

当一个新值351被分配给id变量时,在如图 8-10 所示的存储地址旧值754被新值替换。

img/323069_3_En_8_Fig10_HTML.png

图 8-10

当新值 351 赋给 id 变量时,该变量的内存状态

当你使用对象和引用变量时,情况就不同了。考虑清单 8-16 中所示的Car类的声明。它有三个实例变量——modelyearprice——分别被赋予了初始值"Unknown"20000.0

// Car.java
package com.jdojo.cls;
public class Car {
    public String model = "Unknown";
    public int year     = 2000;
    public double price = 0.0;
}

Listing 8-16Car Class with Three Public Instance Variables

当创建引用类型的对象时,该对象是在堆上创建的,并存储在特定的内存地址。让我们创建一个Car类的对象,如下所示:

new Car();

图 8-11 显示了执行前一条语句创建一个Car对象时的内存状态。您可能假设存储对象的内存地址是262144。请注意,当创建一个对象时,会为它的所有实例变量分配内存,并对它们进行初始化。在这种情况下,新Car对象的modelyearprice已经被正确初始化,如图所示。

img/323069_3_En_8_Fig11_HTML.png

图 8-11

使用 new Car()语句创建 Car 对象时的内存状态

此时,没有办法从 Java 程序中引用新创建的Car对象,即使它存在于内存中。new操作符(在new Car()中使用)返回它创建的对象的内存地址。在你的例子中,它将返回262144。回想一下,数据的内存地址(在您的例子中是Car对象)也被称为该数据的引用。从现在开始,你会说 Java 中的new操作符返回对它创建的对象的引用,而不是说它返回对象的内存地址。两者的意思是一样的。然而,Java 使用术语“引用”,它比“内存地址”有更一般的含义为了访问新创建的Car对象,您必须将它的引用存储在一个引用变量中。回想一下,引用变量存储对某些数据的引用,这些数据存储在其他地方。引用类型的所有变量在 Java 中都是引用变量。Java 中的引用变量可以存储一个null引用,也就是说不引用任何东西。考虑下面的代码片段,它对引用变量执行不同的操作:

Car myCar = null;   /* #1 */
myCar = new Car();  /* #2 */
Car xyCar = null;   /* #3 */
xyCar = myCar;      /* $4 */

当执行标记为#1 的语句时,为引用变量myCar分配内存,比如在内存地址8192null值是一个特殊值,通常是零的内存地址,存储在myCar变量的内存地址。图 8-12 描述了当myCar变量被分配了一个null参考值时,该变量的存储状态。

img/323069_3_En_8_Fig12_HTML.png

图 8-12

执行“Car myCar = null”语句时,myCar 变量的内存状态

标记为#2 的语句的执行是一个两步过程。首先,它执行语句的new Car()部分来创建一个新的Car对象。假设新的Car对象被分配在内存地址 9216。new Car()表达式返回新对象的引用,即9216。第二步,新对象的引用存储在myCar引用变量中。图 8-13 显示了执行标记为#2 的语句后myCar参考变量和新Car对象的存储状态。注意,新的Car对象(9216)的内存地址和myCar引用变量的值在这一点上是匹配的。您不需要担心本例中用于内存地址的数字;我只是编了一些数字来说明内部是如何使用内存地址的。Java 不允许你访问对象或变量的内存地址。Java 允许你通过引用变量来访问/修改对象的状态。

img/323069_3_En_8_Fig13_HTML.png

图 8-13

当执行 myCar = new Car()语句时,myCar 引用变量和 new Car 对象的内存状态

标记为#3 的语句类似于标记为#1 的语句。参考变量xyCar的存储状态如图 8-14 所示,假设10240是参考变量xyCar的存储地址。

img/323069_3_En_8_Fig14_HTML.png

图 8-14

xyCar 参考变量的存储状态

有趣的是要注意执行标记为#4 的语句时的内存状态。声明内容如下:

xyCar = myCar;  /* #4 */

回想一下,变量名有两个关联:内存地址和存储在该内存地址的值。存储器地址(或位置)也称为其lvalue,而存储在其存储器地址的值也称为rvalue。当一个变量被用在赋值操作符的左边时(在标签为#4 的语句中的xyCar,它指的是它的内存地址。当一个变量用在赋值操作符的右边时(在标签为#4 的语句中为myCar,它指的是存储在其内存地址中的值(rvalue)。标记为#4 的语句可解读如下:

xyCar = myCar; /* #4 */
At lvalue of xyCar store rvalue of myCar;       /* #4 – another way */
At memory address of xyCar store value of myCar /* #4 – another way */

因此,当你执行语句xyCar = myCar时,它读取myCar的值,也就是9216,并将其存储在xyCar的内存地址。引用变量myCar存储一个对Car对象的引用。像xyCar = myCar这样的赋值不会复制myCar引用的对象。相反,它将存储在myCar(对Car对象的引用)中的值复制到xyCar。当赋值xyCar = myCar完成时,myCarxyCar的引用变量引用了内存中的同一个Car对象。此时,内存中只存在一个Car对象。图 8-15 显示了执行#4 语句时的内存状态。

img/323069_3_En_8_Fig15_HTML.png

图 8-15

显示 myCar 和 xyCar 引用内存中相同汽车对象的内存状态

这时,你可以使用引用变量myCarxyCar来访问内存中的Car对象。以下代码片段将访问内存中的同一个对象:

myCar.model = "Civic LX"; /* Use myCar to change model */
myCar.year  = 1999;       /* Use myCar to change year */
xyCar.price = 16000.00;   /* Use xyCar to change the price */

执行前三条语句后,Car对象的modelyearprice将被改变;记忆状态将如图 8-16 所示。

img/323069_3_En_8_Fig16_HTML.png

图 8-16

显示在使用 myCar 和 xyCar 更改汽车对象的状态后,myCar 和 xyCar 在内存中引用同一个汽车对象的内存状态

此时,内存中存在两个参考变量myCarxyCar以及一个Car对象。两个引用变量引用同一个Car对象。让我们执行下面的语句,并将其标记为#5:

myCar = new Car(); /* #5 */

前面的语句将在内存中创建一个新的Car对象,其实例变量具有初始值,并将新的Car对象的引用分配给myCar引用变量。xyCar引用变量仍然引用它之前引用的Car对象。假设新的Car对象已经被分配在存储器地址5120。两个参考变量myCarxyCar以及两个Car对象的存储状态如图 8-17 所示。

img/323069_3_En_8_Fig17_HTML.png

图 8-17

参考变量 myCar 和 xyCar 以及两个 Car 对象的内存状态

让我们再做一次修改,将参考变量xyCar设置为null,如下所示:

xyCar = null; /* #6 */

图 8-18 显示了语句#6 执行后的内存状态。

img/323069_3_En_8_Fig18_HTML.png

图 8-18

引用变量 myCar 和 xyCar 以及两个 Car 对象在 xyCar 被赋予空引用后的内存状态

现在xyCar引用变量存储了一个null引用,它不再引用任何Car对象。带有Civic LX模型的Car对象没有被任何引用变量引用。您根本无法在您的程序中访问这个Car对象,因为您没有对它的引用。在 Java 术语中,带有Civic LX模型的Car对象是不可达的。当内存中的对象不可访问时,它就有资格进行垃圾收集。注意,在xyCar被设置为null后,具有Civic LX模型的Car对象不会立即被销毁(或释放)。它会一直留在内存中,直到垃圾收集器运行并确保它不可到达。有关如何释放对象内存的更多细节,请参考一本关于垃圾收集的书。

我们已经介绍了足够多的关于变量类型以及它们如何在 Java 中工作的背景知识。是时候讨论 Java 中的参数传递机制了。简而言之,我们可以声明

Java 中的所有参数都是通过值传递的。

这个简短的声明引起了很多混乱。这是否意味着当一个参数是引用类型时,实际参数所引用的对象会被复制并赋给形参?用例子详细说明“Java 中的所有参数都是通过值传递的”这句话很重要。即使是资深的 Java 程序员也很难理解 Java 中的参数传递机制。更详细地说,Java 支持以下四种类型的参数传递机制:

  • 按值传送

  • 按常量值传递

  • 按参考值传递

  • 通过常量参考值

注意,在 Java 中传递参数的所有四种方式都包括单词“value”这就是为什么许多关于 Java 的书籍将它们总结为“Java 通过值传递所有参数”有关前面提到的四种类型的参数传递机制的更多细节,请参考上一节。

前两种类型,按值传递和按常量值传递,适用于原始数据类型的参数。最后两种类型,按引用值传递和按常量引用值传递,适用于引用类型的参数。

当形参属于原始数据类型时,实际形参的值被复制到形参中。在方法体中对形参值的任何改变只会改变形参的副本,而不会改变实际形参的值。现在您可以知道交换两个原始值的swap()方法在 Java 中不起作用。

清单 8-17 演示了swap()方法不能用 Java 编写,因为原始类型的参数是通过值传递的。输出显示swap()方法的xy形参接收了ab的值。xy的值在方法内部交换,这完全不影响实际参数ab的值。

// BadSwapTest.java
package com.jdojo.cls;
public class BadSwapTest {
    public static void swap(int x, int y) {
        System.out.println("#2: x = " + x + ", y = " + y);
        int temp = x;
        x = y;
        y = temp;
        System.out.println("#3: x = " + x + ", y = " + y);
    }
    public static void main(String[] args) {
        int a = 19;
        int b = 37;
        System.out.println("#1: a = " + a + ", b = " + b);
        // Call the swap() method to swap values of a and b
        BadSwapTest.swap(a, b);
        System.out.println("#4: a = " + a + ", b = " + b);
    }
}
#1: a = 19, b = 37
#2: x = 19, y = 37
#3: x = 37, y = 19
#4: a = 19, b = 37

Listing 8-17An Incorrect Attempt to Write a swap() Method to Swap Two Values of Primitive Types in Java

原始类型参数通过值传递。但是,您可以在方法内部修改形参的值,而不会影响实际的形参值。Java 也允许你传递常量值。在这种情况下,不能在方法内部修改形参。通过制作实参的副本,用实参的值初始化形参,然后就是常量值,只能读取。您需要在正式的参数声明中使用final关键字来表明您打算通过常量值传递参数。任何改变参数值的尝试都会导致编译时错误。清单 8-18 演示了如何使用传递常量值机制将参数x传递给test()方法。任何试图改变test()方法中形参x值的行为都会导致编译时错误。如果在test()方法中取消对"x = 10;"语句的注释,将会出现以下编译器错误:

Error(10):  final parameter x may not be assigned

您已经向test()方法传递了两个参数xy。参数y是通过值传递的,因此可以在方法内部进行更改。这可以通过查看输出来确认。

// PassByConstantValueTest.java
package com.jdojo.cls;
public class PassByConstantValueTest {
    // x is passed by constant value and y is passed by value
    public static void test(final int x, int y) {
        System.out.println("#2: x = " + x + ", y = " + y);
        /* Uncommenting the following statement will generate a compile-time error */
        // x = 79; /* Cannot change x. It is passed by constant value */
        y = 223; // Ok to change y
        System.out.println("#3: x = " + x + ", y = " + y);
    }

    public static void main(String[] args) {
        int a = 19;
        int b = 37;
        System.out.println("#1: a = " + a + ", b = " + b);
        PassByConstantValueTest.test(a, b);
        System.out.println("#4: a = " + a + ", b = " + b);
    }
}
#1: a = 19, b = 37
#2: x = 19, y = 37
#3: x = 19, y = 223
#4: a = 19, b = 37

Listing 8-18An Example of Pass by Constant Value

让我们讨论一下引用类型参数的参数传递机制。Java 允许您使用按引用值传递和按常量引用值传递机制将引用类型参数传递给方法。当参数通过引用值传递时,存储在实参中的引用被复制到形参中。当方法开始执行时,实参和形参都引用内存中的同一个对象。如果实参有一个null引用,形参将包含null引用。您可以将对另一个对象的引用分配给方法体中的形参。在这种情况下,形参开始引用内存中的新对象,而实参仍然引用它在方法调用之前引用的对象。清单 8-19 展示了 Java 中的引用传递机制。它在main()方法中创建一个Car对象,并将Car对象的引用存储在myCar引用变量中:

// Create a Car object and assign its reference to myCar
Car myCar = new Car();

它使用myCar引用变量修改新创建的Car对象的型号、年份和价格:

// Change model, year and price of Car object using myCar
myCar.model = "Civic LX";
myCar.year  = 1999;
myCar.price = 16000.0;

输出中标记为#1 的消息显示了Car对象的状态。使用以下调用将引用变量myCar传递给test()方法:

PassByReferenceValueTest.test(myCar);

由于test()方法中形参xyCar的类型是Car,这是一个引用类型,Java 使用通过引用值传递机制将myCar实参的值传递给xyCar形参。当调用test(myCar)方法时,Java 将存储在myCar引用变量中的Car对象的引用复制到xyCar引用变量中。当执行进入test()方法体时,myCarxyCar引用内存中的同一个对象。此时,内存中只有一个Car对象,而不是两个。理解test(myCar)方法调用没有复制由myCar引用变量引用的Car对象是非常重要的。相反,它复制了由作为实际参数的myCar引用变量引用的Car对象的引用(内存地址),并将该引用复制到作为形式参数的xyCar引用变量。事实上,myCarxyCar都引用了内存中的同一个对象,这由输出中标记为#2 的消息表示,该消息是使用test()方法中的xyCar形参打印的。

现在您创建一个新的Car对象,并将其引用分配给test()方法中的xyCar形参:

// Let's make xyCar refer to a new Car object
xyCar = new Car();

此时,内存中有两个Car对象。xyCar形参引用了新的Car对象,而不是其引用被传递给方法的那个对象。注意,实际的参数myCar仍然引用您在main()方法中创建的Car对象。输出中标记为#3 的消息表明了xyCar形参引用了新的Car对象。当test()方法调用返回时,main()方法打印被myCar引用变量引用的Car对象的详细信息。参见清单 8-19 。

Tip

当引用类型的参数传递给 Java 中的方法时,形参可以像实参一样访问对象。形式参数可以通过直接更改实例变量的值或调用对象上的方法来修改对象。通过形参对对象进行的任何修改都可以通过实参立即看到,因为两者都在内存中保存对同一个对象的引用。可以修改形参本身来引用方法中的另一个对象(或null引用)。

// PassByReferenceValueTest.java
package com.jdojo.cls;
public class PassByReferenceValueTest {
    public static void main(String[] args) {
        // Create a Car object and assign its reference to myCar
        Car myCar = new Car();
        // Change model, year and price of Car object using myCar
        myCar.model = "Civic LX";
        myCar.year = 1999;
        myCar.price = 16000.0;
        System.out.println("#1: model = " + myCar.model
                + ", year = " + myCar.year
                + ", price = " + myCar.price);
        PassByReferenceValueTest.test(myCar);
        System.out.println("#4: model = " + myCar.model
                + ", year = " + myCar.year
                + ", price = " + myCar.price);
    }

    public static void test(Car xyCar) {
        System.out.println("#2: model = " + xyCar.model
                + ", year = " + xyCar.year
                + ", price = " + xyCar.price);
        // Let's make xyCar refer to a new Car object
        xyCar = new Car();
        System.out.println("#3: model = " + xyCar.model
                + ", year = " + xyCar.year
                + ", price = " + xyCar.price);
    }
}
#1: model = Civic LX, year = 1999, price = 16000.0
#2: model = Civic LX, year = 1999, price = 16000.0
#3: model = Unknown, year = 2000, price = 0.0
#4: model = Civic LX, year = 1999, price = 16000.0

Listing 8-19An Example of Pass by Reference Value

如果不希望该方法更改引用类型形参来引用不同于实际形参所引用的对象,可以使用按常量引用值传递机制来传递该形参。如果在引用类型形参声明中使用关键字final,则形参通过常量引用值传递,形参不能在方法内部修改。下面的test()方法声明将xyzCar形参声明为final,参数通过常量引用值传递。该方法试图通过给xyzCar形参分配一个null引用,然后给一个新的Car对象分配一个引用来改变它。这两个赋值语句都会生成编译器错误:

// xyzCar is passed by constant reference value because it is declared final
void test(final Car xyzCar) {
    // Can read the object referenced by xyzCar
    String model = xyzCar.model;
    // Can modify object referenced by xyzCar
    xyzCar.year = 2001;
    /* Cannot modify xyzCar. That is, xyzCar must reference the object what the actual
       parameter is referencing at the time this method is called. You cannot even set it to
       null reference.
    */
    xyzCar = null;      // A compile-time error. Cannot modify xyzCar
    xyzCar = new Car(); // A compile-time error. Cannot modify xyzCar
}

让我们再讨论一个关于 Java 中参数传递机制的例子。考虑下面的changeString()方法代码:

public static void changeString(String s2) {
    /* #2 */
    s2 = s2 + " there";
    /* #3 */
}

考虑下面调用changeString()方法的代码片段:

String s1 = "hi";
/* #1 */
changeString(s1);
/* #4 */

#4 的s1会是什么内容?String是 Java 中的引用类型。在#1,s1正在引用一个内容为"hi"String对象。当调用changeString(s1)方法时,s1通过引用值传递给s2。在#2,s1s2引用内存中相同的String对象,其内容为"hi"。当

s2 = s2 + " there";

语句执行后,会发生两件事。首先,计算s2 + " there"表达式,在内存中创建一个新的String对象,内容为"hi there",并返回其引用。由s2 + " there"表达式返回的引用被分配给s2形参。此时内存中有两个String对象:一个是"hi"的内容,另一个是"hi there"的内容。在#3,实参s1引用内容为"hi"String对象,形参s2引用内容为"hi there"String对象。当changeString()方法调用结束时,形参s2被丢弃。请注意,在changeString()方法调用结束后,内容为"hi there"String对象仍然存在于内存中。当方法调用结束时,只有形参被丢弃,而不是形参所引用的对象。在#4,引用变量s1仍然引用内容为"hi"String对象。清单 8-20 有完整的代码试图修改String类型的形参。

Tip

一个String对象是不可变的,这意味着它的内容在创建后不能被改变。如果你需要改变一个String对象的内容,你必须用新的内容创建一个新的String对象。

// PassByReferenceValueTest2.java
package com.jdojo.cls;
public class PassByReferenceValueTest2 {
    public static void changeString(String s2) {
        System.out.println("#2: s2 = " + s2);
        s2 = s2 + " there";
        System.out.println("#3: s2 = " + s2);
    }
    public static void main(String[] args) {
        String s1 = "hi";
        System.out.println("#1: s1 = " + s1);
        PassByReferenceValueTest2.changeString(s1);
        System.out.println("#4: s1 = " + s1);
    }
}
#1: s1 = hi
#2: s2 = hi
#3: s2 = hi there
#4: s1 = hi

Listing 8-20Another Example of Pass by Reference Value Parameter Passing in Java

摘要

类中的方法定义了该类对象的行为或该类本身的行为。方法是一个命名的代码块。可以调用该方法来执行其代码。调用该方法的代码称为该方法的调用方。可选地,方法可以接受来自调用者的输入值,并且它可以向调用者返回值。输入值的列表称为方法的参数。Var-args 参数用于定义方法和构造器的参数,它们可以接受可变数量的参数。方法总是在类或接口的主体中定义。

类的方法可以有以下四种访问级别之一:公共、私有、受保护或包级别。在定义它们时,关键字publicprivateprotected分别赋予它们公共、私有或受保护的访问级别。缺少这些关键字中的任何一个都会指定包级访问。

您可以在方法体中声明变量,这样的变量称为局部变量。与类的字段不同,默认情况下,局部变量不会初始化。局部变量必须先初始化,然后才能读取它们的值。试图在局部变量初始化之前读取局部变量的值会导致编译时错误。

一个类可以有两种方法:实例方法和类方法。实例方法和类方法也分别称为非静态方法和静态方法。实例方法用于实现类的实例(也称为对象)的行为。实例方法只能在类实例的上下文中调用。类方法用于实现类本身的行为。类方法总是在类的上下文中执行。static修饰符用于定义一个类方法。方法声明中缺少static修饰符使得该方法成为实例方法。

可以使用点标记法访问类的方法,其形式为

<qualifier>.<method-name>(<method-actual-parameters>)

对于实例方法,限定符是对类实例的引用。对于类方法,限定符可以是类的实例的引用或类名。

您可以从类的非静态方法调用该类的静态方法;但是,不允许从静态方法调用非静态方法。类的静态方法可以访问该类的所有静态字段,而非静态方法可以访问该类的静态和非静态字段。

Java 有一个关键词叫做this。它是对类的当前实例的引用。它只能在实例的上下文中使用。它永远不能在类的上下文中使用,因为它意味着当前实例,而在类的上下文中不存在任何实例。关键字this用在许多上下文中,比如非静态方法、构造器、实例初始化器和初始化实例变量的表达式。

向方法和构造器传递参数有不同的机制。Java 使用按值传递和按常量值传递机制来传递原始数据类型的参数。通过引用值传递和通过常量引用值传递用于在 Java 中传递引用类型的参数。

EXERCISES

  1. Java 中的方法是什么?

  2. 描述类的静态方法和非静态方法的区别。

  3. 静态方法可以访问类的实例变量吗?如果你的答案是否定的,请解释原因。

  4. void作为方法的返回类型是什么意思?

  5. 用两个名为xyint实例变量创建一个名为Point2D的类。

    两个实例变量都应该声明为私有的。不要初始化这两个实例变量。为两个实例变量添加 setters 和 getters,这将允许Point类的用户更改和访问它们的值。声明 setters 为setX(int x)setY(int y),getters 为getX()getY()

  6. Implement a method named distance in the Point2D class that you created in the previous exercise. The method accepts an instance of the Point2D class and returns the distance between the current point and the point represented by the parameter. The method should be declared as follows:

    public class Point2D {
        /* Code from the previous exercise goes here. */
        public double distance(Point2D p) {
            /* Your code for this exercise goes here. */
        }
    }
    
    

    提示两点(x1, y1)(x2, y2)之间的距离计算为\sqrt{{\left(x1-x2\right)}²+{\left(y1-y2\right)}²}。您可以使用Math.sqrt(n)方法来计算一个数字n的平方根。

  7. Enhance the Point2D class by adding a static factory method named create(). A factory method in a class is used to create objects of the class. The create() method should be declared as follows:

    public class Point2D {
        /* Code from the previous exercise goes here. */
        public Point2D create(int x, int y) {
            /* Your code for this exercise goes here. */
        }
    }
    
    

    create()方法返回的Point2D对象的xy实例变量应该分别初始化为该方法的xy参数。

  8. 用方法名avg()创建一个名为MathUtil的类。它计算并返回一系列数字的平均值。该方法必须接受最少带有两个double值的double类型的可变长度参数。运行MathUtil类并验证输出打印出正确的结果:

    // MathUtil.java
    package com.jdojo.cls.excercise;
    public class MathUtil {
        public static void main(String[] args) {
            System.out.println("avg(10, 15) = " + avg(10, 15));
            System.out.println("avg(2, 3, 4) = " + avg(2, 3, 4));
            System.out.println("avg(20.5, 30.5, 40.5) = "
                               + avg(20.5, 30.5, 40.5));
            System.out.println("avg(-2.0, 0.0, 2.0) = "
                               + avg(-2.0, 0.0, 2.0));
        }
        public static double avg(/* Your parameters go here. */) {
            /* Your code goes here. */
        }
    }
    
    
  9. The main() method of a class serves as an entry point of a Java application. It is declared as follows:

    public static void main(String[] args) {
        // Your code goes here
    }
    
    

    使用 var-args 更改main()方法的声明。

  10. 当下面的PassByValueTest类运行时,输出会是什么?

```java
// PassByValueTest.java
package com.jdojo.cls.excercise;
public class PassByValueTest {
    public static void main(String[] args) {
        int x = 100;
        System.out.println("x = " + x);
        change(x);
        System.out.println("x = " + x);
        Point2D p = new Point2D();
        p.setX(40);
        p.setY(60);
        System.out.println("p.x = " + p.getX()
                         + ", p.y = " + p.getY());
        changePointReference(p);
        System.out.println("p.x = " + p.getX()
                         + ", p.y = " + p.getY());
        changePoint(p);
        System.out.println("p.x = " + p.getX()
                         + ", p.y = " + p.getY());
    }
    public static void change(int x) {
        x = 200;
    }
    public static void changePointReference(Point2D p) {
        p = new Point2D();
    }
    public static void changePoint(Point2D p) {
        int newX = p.getX() / 2;
        int newY = p.getY() / 2;
        p.setX(newX);
        p.setY(newY);
    }
}

```

九、构造器

在本章中,您将学习:

  • 什么是构造器以及如何使用它们

  • 一个类的不同类型的初始化器

  • 声明final变量、字段、类和方法

  • 什么是泛型类以及如何使用它们

什么是构造器?

构造器是一个命名的代码块,用于在对象创建后立即初始化类的对象。构造器的结构看起来类似于方法。然而,两者之间的相似之处就止于此。它们是两种不同的结构,用于不同的目的。

声明构造器

构造器声明的一般语法如下:

[modifiers] <constructor-name>(<parameters-list>) [throws-clause] {
    // Body of the constructor goes here
}

构造器的声明以修饰符开始。构造器的访问修饰符可以是publicprivateprotected或包级别(没有修饰符)。构造器名与类的简单名相同。构造器名后面是一对左括号和右括号,其中可能包含参数。或者,右括号后面可以跟一个throws子句,然后是一个逗号分隔的异常列表。我将在第十三章和中讨论关键词throws的用法。放置代码的构造器体用大括号括起来。

如果你比较一下声明方法的语法和声明构造器的语法,你会发现它们几乎是一样的。建议在学习构造器声明时记住方法声明,因为大多数特征都是相似的。

下面的代码展示了一个为类Test声明构造器的例子。图 9-1 显示了构造器的解剖结构:

img/323069_3_En_9_Fig1_HTML.png

图 9-1

测试类构造器的剖析

// Test.java
package com.jdojo.cls;
public class Test {
    public Test() {
        // Code goes here
    }
}

Tip

构造器的名称必须匹配类的简单名称,而不是完全限定名称。

与方法不同,构造器没有返回类型。您甚至不能将void指定为构造器的返回类型。考虑下面一个类Test2的声明:

public class Test2 {
    // Below is a method, not a constructor.
    public void Test2() {
        // Code goes here
    }
}

Test2是否声明了一个构造器?答案是否定的。类Test2没有声明构造器。相反,您可能看到的是一个方法声明,它与类的简单名称同名。它是一个方法声明,因为它指定了一个返回类型void。请注意,方法名也可以与类名相同,如本例所示。

仅仅名字本身并不能构成一个方法或构造器。如果构造的名称与类的简单名称相同,那么它可能是一个方法或构造器。如果它指定了返回类型,则它是一个方法。如果它没有指定返回类型,它就是一个构造器。

什么时候使用构造器?在新实例创建之后,使用带有new操作符的构造器来初始化一个类的实例(或对象)。有时短语“创建”和“初始化”在构造器的上下文中可以互换使用。但是,您需要清楚创建和初始化对象的区别。new操作符创建一个对象,构造器初始化该对象。

以下语句使用Test类的构造器来初始化Test类的对象:

Test t = new Test();

图 9-2 显示了这种说法的剖析。new操作符后面是对构造器的调用。new操作符,连同构造器调用,例如"new Test()",被称为实例(或对象)创建表达式。实例创建表达式在内存中创建一个对象,执行指定构造器体中的代码,最后返回新对象的引用。

img/323069_3_En_9_Fig2_HTML.png

图 9-2

用 new 运算符解析构造器调用

我已经介绍了足够多的关于声明构造器的理论。是时候看看一个构造器了。清单 9-1 有一个Cat类的代码。

// Cat.java
package com.jdojo.cls;
public class Cat {
    public Cat() {
        System.out.println("Meow...");
    }
}

Listing 9-1A Cat Class with a Constructor

Cat类声明了一个构造器。在构造器的主体内部,它打印一条消息"Meow..."。清单 9-2 包含了一个CatTest类的代码,该类在其main()方法中创建了两个Cat对象。请注意,您总是使用对象创建表达式来创建一个新的Cat类对象。由您决定将新对象的引用存储在引用变量中。第一个Cat对象被创建,其引用未被保存。创建第二个Cat对象,其引用存储在引用变量c中。

// CatTest.java
package com.jdojo.cls;
public class CatTest {
    public static void main(String[] args) {
        // Create a Cat object and ignore its reference
        new Cat();
        // Create another Cat object and store its reference in c
        Cat c = new Cat();
    }
}
Meow...
Meow...

Listing 9-2A Test Class That Creates Two Cat Objects

重载构造器

一个类可以有多个构造器。如果一个类有多个构造器,它们被称为重载构造器。由于构造器的名称必须与类的简单名称相同,因此有必要区分不同的构造器。重载构造器的规则与重载方法的规则相同。如果一个类有多个构造器,所有的构造器在数量、顺序或参数类型上都必须不同。清单 9-3 包含了一个Dog类的代码,它声明了两个构造器。一个构造器不接受任何参数,另一个接受一个String参数。

// Dog.java
package com.jdojo.cls;
public class Dog {
    // Constructor #1
    public Dog() {
        System.out.println("A dog is created.");
    }
    // Constructor #2
    public Dog(String name) {
        System.out.println("A dog named " + name + " is created.");
    }
}

Listing 9-3A Dog Class with Two Constructors, One with No Parameters and One with a String Parameter

如果一个类声明了多个构造器,您可以使用其中的任何一个来创建该类的对象。例如,下面两条语句创建了两个Dog类的对象:

Dog dog1 = new Dog();
Dog dog2 = new Dog("Cupid");

第一条语句使用不带参数的构造器,第二条语句使用带String参数的构造器。如果使用带参数的构造器创建对象,实际参数的顺序、类型和数量必须与形参的顺序、类型和数量相匹配。清单 9-4 有使用不同构造器创建两个Dog对象的完整代码。

// DogTest.java
package com.jdojo.cls;
public class DogTest {
    public static void main(String[] args) {
        Dog d1 = new Dog();         // Uses Constructor #1
        Dog d2 = new Dog ("Canis"); // Uses Constructor #2
    }
}
A dog is created.
A dog named Canis is created.

Listing 9-4Testing the Constructors of the Dog Class

运行DogTest类的输出表明,当在main()方法中创建两个Dog对象时,会调用不同的构造器。

每个对象创建表达式调用一次构造器。在对象创建过程中,一个构造器的代码只能执行一次。如果一个构造器的代码被执行了N次,这意味着该类的N个对象将被创建,你必须使用N个对象创建表达式来完成。但是,当一个对象创建表达式调用一个构造器时,被调用的构造器可能会从它的主体调用另一个构造器。本书将在本节的后面介绍一个构造器调用另一个构造器的场景。

为构造器编写代码

到目前为止,您一直在用构造器编写琐碎的代码。在构造器中应该写什么样的代码?构造器的目的是初始化新创建的对象的实例变量。在构造器中,您应该限制自己只编写初始化对象实例变量的代码。调用构造器时,对象没有完全创建。该对象仍在创建过程中。如果假设内存中存在一个完整的对象,在构造器中编写一些处理逻辑,有时可能会得到意想不到的结果。让我们创建另一个类来表示一个狗对象。您将调用这个类SmartDog,如清单 9-5 所示。

// SmartDog.java
package com.jdojo.cls;
public class SmartDog {
    private String name;
    private double price;
    public SmartDog() {
        // Initialize the name to “Unknown” and the price to 0.0
        this.name = "Unknown";
        this.price = 0.0;
        System.out.println("Using SmartDog() constructor");
    }
    public SmartDog(String name, double price) {
        // Initialize name and price instance variables with the
        // values of the name and price parameters
        this.name = name;
        this.price = price;
        System.out.println("Using SmartDog(String, double) constructor");
    }
    public void bark() {
        System.out.println(name + " is barking...");
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
    public void setPrice(double price) {
        this.price = price;
    }
    public double getPrice() {
        return this.price;
    }
    public void printDetails() {
        System.out.print("Name: " + this.name);
        if (price > 0.0) {
            System.out.println(", price: " + this.price);
        } else {
            System.out.println(", price: Free");
        }
    }
}

Listing 9-5A SmartDog Class That Declares Two Constructors to Initialize Instance Variables Differently

SmartDog级看起来大一点。但是,它的逻辑很简单。以下是您需要了解的SmartDog类中的要点:

  • 它声明了两个实例变量;他们是namepricename实例变量存储一只聪明狗的名字。price实例变量存储它的销售价格。

  • 它声明了两个构造器。第一个构造器没有参数。它将 name 和 price 实例变量分别初始化为"Unknown"0.0。第二个构造器接受两个名为nameprice的参数。它将nameprice实例变量初始化为传递给这两个参数的任何值。注意构造器中关键字this的使用。关键字this指的是构造器的代码正在执行的对象。在第一个构造器中没有必要使用关键字this。但是,您必须使用关键字this来引用第二个构造器中的实例变量,因为形参的名称隐藏了实例变量的名称。

  • 这两个构造器在它们的主体中初始化实例变量(或者对象的状态)。它们不包括任何其他处理逻辑。

  • 实例方法bark()在标准输出中打印一条消息,带有正在吠叫的智能狗的名字。

  • setName()getName()方法用于设置和获取智能狗的名称。setPrice()getPrice()方法用于设置和获取智能狗的价格。

  • printDetails()方法打印智能狗的nameprice。如果智能狗的价格没有设置为正值,它会将价格打印为"Free"

清单 9-6 有一个SmartDogTest类的代码,演示了两个构造器如何初始化实例变量。

// SmartDogTest.java
package com.jdojo.cls;
public class SmartDogTest {
    public static void main(String[] args) {
        // Create two SmartDog objects
        SmartDog sd1 = new SmartDog();
        SmartDog sd2 = new SmartDog("Nova", 219.2);
        // Print details about the two dogs
        sd1.printDetails();
        sd2.printDetails();
        // Make them bark
        sd1.bark();
        sd2.bark();
        // Change the name and price of Unknown dog
        sd1.setName("Opal");
        sd1.setPrice(321.80);
        // Print details again
        sd1.printDetails();
        sd2.printDetails();
        // Make them bark one more time
        sd1.bark();
        sd2.bark();
    }
}
Using SmartDog() constructor
Using SmartDog(String, double) constructor
Name: Unknown, price: Free
Name: Nova, price: 219.2
Unknown is barking...
Nova is barking...
Name: Opal, price: 321.8
Name: Nova, price: 219.2
Opal is barking...
Nova is barking...

Listing 9-6A Test Class to Demonstrate the Use of the SmartDog Class

从另一个构造器调用一个构造器

一个构造器可以调用同一个类的另一个构造器。我们来考虑下面这个Test类。它声明了两个构造器;一个不接受任何参数,一个接受一个int参数:

public class Test {
    Test() {
    }
    Test(int x) {
    }
}

假设您想从不带参数的构造器中调用带int参数的构造器。你的第一次尝试是错误的,如下所示:

public class Test {
    Test() {
        // Call another constructor
        Test(103); // A compile-time error
    }
    Test(int x) {
    }
}

前面的代码无法编译。Java 有一种特殊的方式从一个构造器调用另一个构造器。你必须使用关键字this,就像它是构造器的名字一样,从另一个构造器调用一个构造器。下面的代码使用语句"this(103);"从不带参数的构造器调用带int参数的构造器。这是关键字this的另一种用法:

public class Test {
    Test() {
        // Call another constructor
        this(103); // OK. Note the use of the keyword this.
    }
    Test(int x) {
    }
}

从一个构造器调用另一个构造器有两条规则。这些规则确保一个构造器在一个类的对象创建过程中只执行一次。这些规则如下:

  • 对另一个构造器的调用必须是该构造器中的第一条语句。

  • 构造器不能调用自身。

如果一个构造器调用另一个构造器,它必须是构造器体中的第一个可执行语句。这使得编译器很容易检查一个构造器是否被调用过,并且只被调用过一次。例如,下面的代码将生成一个编译时错误,因为用int参数this(k)调用构造器是构造器体内的第二条语句,而不是第一条语句:

public class Test {
    Test() {
        int k = 10; // First statement
        this(k);    // Second statement. A compile-time error
    }
    Test(int x) {
    }
}

尝试编译此Test类的代码将生成以下错误消息:

Error(4):  call to this must be first statement in constructor

构造器不能调用自身,因为这会导致递归调用。在下面的Test类代码中,两个构造器都试图调用自己:

public class Test {
    Test() {
        this();
    }
    Test(int x ) {
        this(10);
    }
}

尝试编译此代码将导致以下错误。每次尝试调用构造器本身都会生成一条错误信息:

Error(2):  recursive constructor invocation
Error(6):  recursive constructor invocation

通常,当有多种方法来初始化类的对象时,可以为该类创建重载构造器。让我们考虑清单 9-5 中显示的SmartDog类。两个构造器给了你两种方法来初始化一个新的SmartDog对象。第一个用默认值初始化nameprice。第二个构造器让您用调用者提供的值初始化nameprice。有时,您可能会执行一些逻辑来初始化构造器中的对象。让您从一个构造器调用另一个构造器只允许您编写一次这样的逻辑。您可以为您的SmartDog类使用这个特性,如下所示:

// SmartDog.java
package com.jdojo.cls;
public class SmartDog {
    private String name;
    private double price;
    public SmartDog() {
        // Call another constructor with "Unknown" and 0.0 as parameters
        this("Unknown", 0.0);
        System.out.println("Using SmartDog() constructor");
    }
    public SmartDog(String name, double price) {
        // Initialize name and price to specified name and price
        this.name = name;
        this.price = price;
        System.out.println("Using SmartDog(String, double) constructor");
    }
    /* Rest of code remains the same */
}

请注意,您只在不接受任何参数的构造器中更改了代码。您没有在第一个构造器中为nameprice设置默认值,而是调用了第二个构造器,并将默认值作为第一个构造器的参数。

在构造器中使用 return 语句

构造器的声明中不能有返回类型。这意味着构造器不能返回任何值。回想一下,return语句有两种类型:一种有返回表达式,另一种没有返回表达式。没有返回表达式的return语句只是将控制权返回给调用者,而不返回任何值。您可以在构造器体中使用没有返回表达式的return语句,尽管这被认为是一个应该避免的坏习惯。当执行构造器中的return语句时,控制返回到调用方,忽略构造器的其余代码。

下面的代码展示了一个在构造器中使用return语句的例子。如果参数x是负数,构造器只需执行一个return语句来结束对构造器的调用。否则,它会执行一些逻辑:

public class Test {
    public Test(int x) {
        if (x < 0) {
            return;
        }
        /* Perform some logic here */
    }
}

构造器的访问级别修饰符

构造器的访问级别决定了可以在对象创建表达式中使用该构造器来创建该类对象的程序部分。与字段和方法类似,您可以为构造器指定四个访问级别之一:

  • public

  • private

  • protected

下面的代码为Test类声明了四个构造器。每个构造器的注释解释了它的访问级别:

// Class Test has public access level
public class Test {
    // Constructor #1 - package-level access
    Test() {
    }
    // Constructor #2 - public access level
    public Test(int x) {
    }
    // Constructor #3 - private access level
    private Test(int x, int y) {
    }
    // Constructor #4 - protected access level
    protected Test(int x, int y, int z){
    }
}

这些访问级别的效果与它们对方法的效果相同。如果类本身是可访问的,具有public访问级别的构造器可以在应用程序的任何部分使用。具有private访问级别的构造器只能在声明它的同一个类中使用。具有protected访问级别的构造器可以在声明其类的同一个包中的程序的任何部分使用,也可以在任何包的任何子类中使用。具有包级访问权限的构造器可以在声明其类的同一个包中使用。

您可以为一个类指定一个public访问级别或包级别的访问。一个类定义了一个新的引用类型,您可以用它来声明一个引用变量。类的访问级别决定了类名可以在程序的哪个部分使用。通常,在强制转换或引用变量声明中使用类名,如下所示:

// Test class name is used to declare the reference variable t
Test t;
// Test class name is used to cast the reference variable xyz
Test t2 = (Test)xyz;

让我们讨论一个类及其构造器的不同访问级别组合,以及它们在程序中的作用。考虑下面的代码,它声明了一个访问级别为public的类T1。它还有一个构造器,也有一个public访问级别:

// T1.java
package com.jdojo.cls.p1;
public class T1 {
    public T1() {
    }
}

因为类T1有一个public访问级别,所以你可以在同一个模块的任何地方声明一个T1类型的引用变量。如果此代码在不同的模块中,则假设包含该类的模块导出该类的包,并且具有此代码的模块读取第一个模块:

// Code inside any package
T1 t;

因为类T1的构造器有一个public访问级别,所以您可以在任何包的对象创建表达式中使用它:

// Code inside any package
new T1();

您可以在任何包的代码中将前面两个语句合并为一个:

// Code inside any package
T1 t = new T1();

让我们考虑下面这个类T2的代码,它有一个public访问级别和一个带有private访问级别的构造器:

// T2.java
package com.jdojo.cls.p1;
public class T2 {
    private T2() {
    }
}

因为类T2有一个public访问级别,所以可以用它的名字在同一个模块的任何包中声明一个引用变量。如果这个包在不同的模块中,假设这个模块可以读取包含T2类的包。类T2的构造器有一个private访问级别。拥有一个private构造器意味着你不能在T2类之外创建一个T2类的对象。回想一下,private方法、字段或构造器不能在声明它的类之外使用。因此,除非出现在T2类中,否则下面的代码不会被编译:

// Code outside the T2 class
new T2(); // A compile-time error

如果不能在T2类之外创建它的对象,那么T2类有什么用?让我们考虑一些可能的情况,你可以声明一个构造器private,并且仍然创建和使用这个类的对象。

构造器用于创建一个类的对象。您可能想要限制一个类的对象数量。限制一个类的对象数量的唯一方法是完全控制它的构造器。如果您声明一个类的所有构造器都具有private访问级别,那么您可以完全控制该类的对象将如何被创建。通常,您在该类中包含一个或多个公共静态方法,这些方法创建和/或返回该类的对象。如果你设计一个类,使得该类只有一个对象存在,这被称为单例模式。下面的代码是基于单例模式的T2类的一个版本:

// T2.java
package com.jdojo.cls.p1;
public class T2 {
    private static T2 instance = new T2();
    private T2() {
    }
    public static T2 getInstance() {
        return T2.instance;
    }
    /* Other code goes here */
}

T2类声明了一个名为instance的私有静态引用变量,它保存了对T2类的对象的引用。注意,T2类使用它自己的private构造器来创建一个对象。它的公共静态getInstance()方法返回该类的唯一对象。不能存在多个T2类的对象。

您可以使用T2.getInstance()方法来获取对T2类的对象的引用。在内部,T2类不会在每次调用T2.getInstance()方法时创建一个新对象。相反,它为对此方法的所有调用返回相同的对象引用:

T2 t1 = T2.getInstance();
T2 t2 = T2.getInstance();

有时你希望一个类只有静态成员。创建这样一个类的对象可能没有意义。例如,java.lang.Math类声明其构造器是私有的。Math类包含静态变量和静态方法来执行数字运算。创建Math类的对象是没有意义的。

也可以将类的所有构造器声明为私有,以防止继承。继承允许您通过扩展另一个类的定义来定义一个类。如果您不希望任何其他人扩展您的类,实现这一点的一种方法是将您的类的所有构造器声明为私有。另一种防止类被扩展的方法是将其声明为 final。我们将在第二十章详细讨论继承。

让我们考虑一下类T3,它的构造器有一个受保护的访问级别,如下所示:

// T3.java
package com.jdojo.cls.p1;
public class T3 {
    protected T3() {
    }
}

具有受保护访问级别的构造器可以在同一个包中的任何地方使用,也可以在任何包的子类中使用。类T3com.jdojo.cls.p1包中。您可以在com.jdojo.cls.p1包中的任何地方编写下面的语句,这将创建一个T3类的对象:

// Valid anywhere in the com.jdojo.cls.p1 package
new T3();

稍后您将详细了解更多关于继承的内容。但是,为了完成对受保护构造器的讨论,您将在下面的示例中使用继承。当我们在第二十章讨论时,关于继承的事情会更清楚。使用关键字extends继承(或扩展)一个类。下面的代码通过从T3类继承来创建一个T3Child类:

// T3Child.java
package com.jdojo.cls.p2;
import com.jdojo.cls.p1.T3;
public class T3Child extends T3 {
    public T3Child() {
        super(); // Ok. Calls T3() constructor, which is declared protected.
    }
}

T3类被称为T3Child类的父类。在创建父类的对象之前,不能创建子类的对象。注意在T3Child()构造器体内super()语句的使用。语句super()调用了T3类的受保护构造器。super关键字用于调用父类的构造器,就像您使用关键字this调用同一类的另一个构造器一样。您不能直接调用T3的受保护构造器,因为它在com.jdojo.cls.p1包之外:

new T3();

考虑一个T4类,它的构造器具有包级访问权限。回想一下,不使用访问级别修饰符会给出包级别的访问:

// T4.java
package com.jdojo.cls.p1;
public class T4 {
    // T4() has package-level access
    T4() {
    }
}

您可以使用T4的构造器在com.jdojo.cls.p1包中的任何地方创建它的对象。有时你需要一个类作为包中其他类的助手类。这些类的对象只需要在包中创建。您可以为此类帮助器类的构造器指定包级访问。

默认构造器

声明类的主要目的是创建其类型的对象。你需要一个构造器来创建一个类的对象。一个类有一个构造器的必要性是显而易见的,如果你没有声明一个构造器,Java 编译器会给你的类添加一个构造器。编译器添加的构造器称为默认构造器。默认构造器没有任何参数。有时默认构造器也被称为无参数构造器。默认构造器的访问级别与类的访问级别相同。

您一直在使用的类称为顶级类。你也可以在另一个类中声明一个类,这叫做内部类(或者嵌套类)。顶级类可以拥有公共或包级别的访问权限。但是,内部类可以具有公共、私有、受保护或包级别的访问权限。Java 编译器为一个顶级类以及一个嵌套类添加了一个默认构造器。根据类的访问级别,顶级类的默认构造器可以具有公共或包级别的访问权限。但是,内部类的默认构造器可以具有 public、private、protected 或 package 级别的访问级别,这取决于它的类访问级别。

表 9-1 展示了几个类的例子,以及编译器给它们添加了一个默认的构造器。当编译器添加默认构造器时,它还会添加一个名为super()的语句来调用父类的无参数构造器。有时,在默认构造器中调用父类的无参数构造器可能会导致类无法编译。参见第二十章对这个话题的完整讨论。

表 9-1

Java 编译器为其添加默认构造器的类的示例

|

您的类的源代码

|

您的类的编译版本

|

评论

public class Test {``} public class Test {``public Test() {``}``} 编译器添加了一个默认的具有public级访问权限的构造器。
class Test {``} class Test {``Test() {``}``} 编译器添加一个具有包级访问权限的默认构造器。
public class Test {``Test() {``}``} public class Test {``Test() {``}``} Test类已经有一个构造器。编译器不添加任何构造器。
public class Test {``public Test(int x) {``}``} public class Test {``public Test(int x) {``}``} Test类已经有一个构造器。编译器不添加任何构造器。
public class Test {``private class Inner {``}``} public class Test {``public Test() {``}``private class Inner {``private Inner(){``}``}``} Test是公共顶级类,Inner是私有内部类。编译器为Test类添加了一个public默认构造器,为Inner类添加了一个private默认构造器。

Tip

将构造器显式添加到所有的类中,而不是让编译器为您的类添加默认的构造器,这是一个很好的编程实践。构造器的故事还没有结束。你将在第二十章中重温构造器。

静态构造器

构造器用于创建新对象的上下文中;因此,它们被认为是对象上下文的一部分,而不是类上下文。不能声明构造器static。关键字this是对当前对象的引用,它在构造器体中可用,因为它在所有实例方法体中都可用。

实例初始化块

您已经看到了构造器用于初始化一个类的实例。实例初始化块,也称为实例初始化器,也用于初始化类的对象。为什么 Java 提供两个构造来执行同一件事?

不是所有的 Java 类都有构造器。得知不是所有的类都可以有构造器,你感到惊讶吗?简单来说,这本书提到了内部类,它不同于顶级类。还有另一种类型的类叫做匿名类。顾名思义,匿名类没有名字。回想一下,构造器是一个命名的代码块,其名称与类的简单名称相同。因为匿名类不能有名字,所以它也不能有构造器。你将如何初始化一个匿名类的对象?您可以使用实例初始化器来初始化匿名类的对象。使用实例初始化器初始化对象不仅限于匿名类;任何类型的类都可以用它来初始化它的对象。

实例初始化器只是一个类体内的代码块,但是在任何方法或构造器之外。回想一下,代码块是用大括号括起来的合法 Java 语句序列。实例初始值设定项没有名称。它的代码简单地放在左大括号和右大括号中。下面的代码片段展示了如何为一个Test类声明一个实例初始化器。注意,实例初始化器是在实例上下文中执行的,关键字this在实例初始化器中是可用的:

public class Test {
    private int num;
    // An instance initializer
    {
        this.num = 101;
        /* Other code for the instance initializer goes here */
    }
    /* Other code for Test class goes here */
}

一个类可以有多个实例初始化器。对于您创建的每个对象,它们都是按照文本顺序自动执行的。所有实例初始化器的代码都在任何构造器之前执行。清单 9-7 展示了构造器和实例初始化器的执行顺序。

// InstanceInitializer.java
package com.jdojo.cls;
public class InstanceInitializer {
    {
        System.out.println("Inside instance initializer 1.");
    }
    {
        System.out.println("Inside instance initializer 2.");
    }
    public InstanceInitializer() {
        System.out.println("Inside no-args constructor.");
    }
    public static void main(String[] args) {
        InstanceInitializer ii = new InstanceInitializer();
    }
}
Inside instance initializer 1.
Inside instance initializer 2.
Inside no-args constructor.

Listing 9-7Example of Using an Instance Initializer

Tip

实例初始值设定项不能有return语句。除非所有声明的构造器在它们的throws子句中列出这些检查的异常,否则它不能抛出检查的异常;这个规则的一个例外是匿名类,因为它没有构造器;匿名类的实例初始化器可能抛出检查异常。

静态初始化块

静态初始化块也称为静态初始化器。它类似于实例初始化块。它用于初始化一个类。换句话说,您可以在静态初始化器块中初始化类变量。实例初始化器对每个对象执行一次,而静态初始化器对一个类只执行一次,当类定义加载到 JVM 中时。为了将其与实例初始化器区分开来,需要在声明的开头使用static关键字。一个类中可以有多个静态初始化器。所有静态初始值设定项都是按照它们出现的文本顺序执行的,并且在任何实例初始值设定项之前执行。清单 9-8 展示了静态初始化器何时被执行。

// StaticInitializer.java
package com.jdojo.cls;
public class StaticInitializer {
    private static int num;
    // An instance initializer
    {
        System.out.println("Inside instance initializer.");
    }
    // A static initializer. Note the use of the keyword static below.
    static {
        num = 1245;
        System.out.println("Inside static initializer.");
    }
    // Constructor
    public StaticInitializer() {
        System.out.println("Inside constructor.");
    }
    public static void main(String[] args) {
        System.out.println("Inside main() #1\. num: " + num);
        // Declare a reference variable of the class
        StaticInitializer si;
        System.out.println("Inside main() #2\. num: " + num);
        // Create an object
        new StaticInitializer();
        System.out.println("Inside main() #3\. num: " + num);
        // Create another object
        new StaticInitializer();
    }
}
Inside static initializer.
Inside main() #1\. num: 1245
Inside main() #2\. num: 1245
Inside instance initializer.
Inside constructor.
Inside main() #3\. num: 1245
Inside instance initializer.
Inside constructor.

Listing 9-8An Example of Using a static Initializer in a Class

最初,输出可能会令人困惑。它表明在第一条消息显示在main()方法中之前,static初始化器已经执行。当您使用下面的命令运行StaticInitializer类时,您会得到输出:

C:\Java17Fundamentals>java --module-path dist --module jdojo.cls/com.jdojo.cls.StaticInitializer

在执行其main()方法之前,java命令必须加载StaticInitializer类的定义。当StaticInitializer类的定义被加载到内存中时,该类被初始化,并且它的静态初始化器被执行。这就是你在看到来自main()方法的消息之前看到来自静态初始化器的消息的原因。注意,实例初始化器被调用了两次,因为您创建了两个StaticInitializer类的对象。

Tip

一个static初始化器不能抛出被检查的异常,也不能有一个return语句。

最后一个关键字

在 Java 中,final关键字被用在许多上下文中。它在不同的上下文中有不同的含义。然而,顾名思义,它的主要含义在所有上下文中都是相同的。其主要含义如下:

final 关键字关联的构造不允许修改或替换构造的原始值或定义。

如果你记住了final关键字的主要含义,它将帮助你理解它在特定上下文中的专门含义。final关键字可用于以下三种情况:

  • 变量声明

  • 类别声明

  • 方法声明

在本节中,我们只讨论在变量声明的上下文中使用final关键字。第二十章详细讨论了它在类和方法声明中的使用。本节将简要描述它在所有三个上下文中的含义。

如果一个变量被声明为final,它只能被赋值一次。也就是说,final变量的值一旦被设置就不能修改。如果一个类被声明为 final,它就不能被扩展(或子类化)。如果一个方法被声明为final,它不能在包含该方法的类的子类中被重新定义(覆盖或隐藏)。

让我们讨论一下final关键字在变量声明中的用法。在这个讨论中,变量声明意味着局部变量、方法/构造器的形参、实例变量和类变量的声明。要将一个变量声明为final,需要在变量声明中使用final关键字。下面的代码片段声明了四个final变量— YESNOMSGact:

final int YES = 1;
final int NO = 2;
final String MSG = "Good-bye";
final Account act = new Account();

您只能设置一次final变量的值。第二次尝试设置final变量的值将会产生编译时错误:

final int x = 10;
int y = 101 + x; // Reading x is ok
// A compile-time error. Cannot change value of the final variable x once it is set
x = 17;

有两种方法可以初始化final变量:

  • 您可以在声明时初始化它。

  • 您可以将其初始化推迟到以后。

一个final变量的初始化可以推迟多长时间取决于变量类型。然而,您必须在第一次读取变量之前初始化该变量。

如果你没有在声明的时候初始化一个final变量,这样的变量被称为空白最终变量。以下是声明空白最终变量的示例:

// A blank final variable
final int multiplier;
/* Do something here... */
// Set the value of multiplier first time
multiplier = 3;
// Ok to read the multiplier variable
int value = 100 * multiplier;

让我们看一下每种类型变量的例子,看看如何声明它们final

最终局部变量

你可以声明一个局部变量final。如果将局部变量声明为空的最终变量,则必须在使用。如果您第二次尝试更改最终局部变量的值,将会收到一个编译时错误。下面的代码片段在一个test()方法中使用了 final 和空白 final 局部变量。代码中的注释解释了如何使用代码中的final变量:

public static void test() {
    int x = 4;        // A variable
    final int y = 10; // A final variable. Cannot change y here onward
    final int z;      // A blank final variable
    // We can read x and y, and modify x
    x = x + y;
    /* We cannot read z here because it is not initialized yet */
    /* Initialize the blank final variable z */
    z = 87;
    /* Can read z now. Cannot change z here onwards */
    x = x + y + z;
    /* Perform other logic here... */
}

最终参数

也可以声明一个形参final。当调用方法或构造器时,形参会自动用实参的值进行初始化。因此,您不能在方法或构造器体中更改最终形参的值。下面的代码片段显示了一个test2()方法的最终形参x:

public void test2(final int x) {
    // Can read x, but cannot change it
    int y = x + 11;
    /* Perform other logic here... */
}

最终实例变量

可以将实例变量声明为 final 和 blank final。实例变量(也称为字段)是对象状态的一部分。最终实例变量指定对象状态的一部分,该部分在对象创建后不会改变。创建对象时,必须初始化一个空的 final 实例变量。以下规则适用于初始化空白最终实例变量:

  • 它必须在一个实例初始化器或所有构造器中初始化。以下规则是对该规则的扩展。

  • 如果它是在实例初始化器中初始化的,就不应该在任何其他实例初始化器或构造器中再次初始化。

  • 如果它没有在任何实例初始化器中初始化,编译器会确保它只在调用任何构造器时初始化一次。这条规则可以分为两个子规则。根据经验,空白的 final 实例变量必须在所有构造器中初始化。如果遵循这条规则,当一个构造器调用另一个构造器时,一个空的 final 实例变量将被初始化多次。为了避免多次初始化空的最终实例变量,如果构造器中的第一个调用是对另一个构造器的调用,该构造器初始化空的最终实例变量,则不应在构造器中初始化该变量。

这些初始化空白 final 实例变量的规则可能看起来很复杂。但是,如果您只记住一条规则,就很容易理解了,即当调用该类的任何构造器时,空白的 final 实例变量必须初始化一次,且只能初始化一次。前面描述的所有规则都是为了确保遵守该规则。

让我们考虑初始化 final 和空白 final 实例变量的不同场景。对于最终实例变量,我们没有什么可讨论的,其中xTest类的最终实例变量:

public class Test {
    private final int x = 10;
}

final实例变量x在声明时已经被初始化,并且它的值以后不能被改变。下面的代码显示了一个带有名为y的空白最终实例变量的Test2类:

public class Test2 {
    private final int y; // A blank final instance variable
}

试图编译Test2类会产生一个错误,因为空白的最终实例变量y从未初始化。注意,编译器将为Test2类添加一个默认的构造器,但是它不会在构造器内部初始化y。下面的Test2类代码将会编译,因为它在实例初始化器中初始化了y:

public class Test2 {
    private final int y;
    {
        y = 10; // Initialized in an instance initializer
    }
}

以下代码将无法编译,因为它在两个实例初始化器中多次初始化y:

public class Test2 {
    private final int y;
    {
        y = 10; // Initialized y for the first time
    }
    {
        y = 10; // An error. Initializing y again
    }
}

这个代码对你来说可能是合法的。然而,这是不合法的,因为两个实例初始化器正在初始化y,即使它们都将y设置为相同的值,10。该规则是关于一个空的 final 实例变量应该被初始化的次数,而不考虑用于其初始化的值。由于所有的实例初始化器都是在创建Test2类的对象时执行的,y将被初始化两次,这是不合法的。

具有两个构造器的类Test2的以下代码将被编译:

public class Test2 {
    private final int y;
    public Test() {
        y = 10; // Initialize y
    }
    public Test(int z) {
        y = z; // Initialize y
    }
}

这段代码在两个构造器中初始化空白的最终实例变量y。看起来似乎y被初始化了两次——在每个构造器中一次。注意,y是一个实例变量,对于Test2类的每个对象都有一个y的副本。当一个Test2类的对象被创建时,它将使用两个构造器中的一个,而不是两个。因此,对于Test2类的每个对象,y只初始化一次。

下面是修改后的Test2类的代码,它呈现了一个棘手的情况。两个构造器都初始化空白的最终实例变量y。棘手的部分是无参数构造器调用另一个构造器:

public class Test2 {
    private final int y;
    public Test() {
        this(20); // Call another constructor
        y = 10;   // Initialize y
    }
    public Test(int z) {
        y = z;   // Initialize y
    }
}

Test2类的这段代码无法编译。编译器生成一条错误消息,内容为“变量 y 可能已经被赋值" 让我们考虑创建一个Test2类的对象,如下所示:

Test2 t = new Test2(30);

通过调用单参数构造器创建一个Test2类的对象没有问题。空白的最终实例变量y只初始化一次。让我们创建一个Test2类的对象:

Test2 t2 = new Test2();

当使用无参数构造器时,它调用单参数构造器,该构造器将y初始化为 20。无参数构造器再次将y初始化为 10,这是对y的第二次初始化。由于这个原因,前面的Test2类的代码无法编译。您需要从无参数构造器中移除y的初始化,然后代码就可以编译了。下面是将编译的Test2类的修改代码:

public class Test2 {
    private final int y;
    public Test() {
        this(20); // Another constructor will initialize y
    }
    public Test(int z) {
        y = z;    // Initialize y
    }
}

最终类别变量

您可以将类变量声明为 final 和 blank final。您必须在一个静态初始化器中初始化一个空的 final 类变量。如果一个类有多个静态初始化器,那么必须在其中一个静态初始化器中只初始化一次所有的空 final 类变量。

下面的Test3类代码展示了如何处理一个 final 类变量。习惯上使用全部大写字母来命名最终类变量。这也是在 Java 程序中定义常量的一种方式。Java 类库有许多定义public static final变量作为常量的例子:

public class Test3 {
    public static final int YES = 1;
    public static final int NO = 2;
    public static final String MSG;
    static {
        MSG = "I am a blank final static variable";
    }
}

最终参考变量

任何类型的变量(原语和引用)都可以声明为 final。在这两种情况下,final关键字的主要含义是相同的。也就是说,存储在final变量中的值一旦被设置就不能更改。我们将在本节中更详细地介绍最后一个参考变量。引用变量存储对象的引用。最终引用变量意味着一旦它引用了一个对象(或null,它就不能被修改来引用另一个对象。考虑以下语句:

final Account act = new Account();

这里,act是一个Account类型的final参考变量。它在声明时被初始化。此时,act正在引用内存中的一个对象。

现在,您不能让act变量引用内存中的另一个对象。以下语句会生成编译时错误:

act = new Account(); // A compile-time error. Cannot change act

在这种情况下,会产生一种常见的误解。程序员错误地认为由act引用变量引用的Account对象不能被改变。将act引用变量作为 final 的声明语句有两点:

  • 一个act作为参考变量,即final

  • 内存中的一个Account对象,其引用存储在act变量中

不能改变的是act引用变量,而不是它正在引用的Account对象。如果Account类允许您改变其对象的状态,您可以使用act变量来改变状态。以下是修改Account对象的balance实例变量的有效语句:

act.deposit(2001.00); // Modifies state of the Account object
act.debit(2.00);      // Modifies state of the Account object

如果您不希望类的对象在创建后被修改,您需要在类设计中包含该逻辑。在创建对象后,该类不应该让它的任何实例变量被修改。这样的对象被称为不可变对象

编译时与运行时最终变量

您使用final变量来定义常量。这就是final变量也被称为常量的原因。如果一个final变量的值可以由编译器在编译时计算出来,那么这个变量就是一个编译时常量。如果一个final变量的值不能被编译器计算出来,它就是一个运行时最终变量。所有空白最终变量的值直到运行时才知道。直到运行时才计算引用。因此,所有空白的最终变量和最终引用变量都是运行时常量

当您在表达式中使用编译时常量时,Java 会执行优化。它用常量的实际值代替了编译时常量的使用。假设您有一个如下所示的Constants类,它声明了一个名为MULTIPLIER的静态最终变量:

public class Constants {
    public static final int MULTIPLIER = 12;
}

考虑以下语句:

int x = 100 * Constants.MULTIPLIER;

当您编译这条语句时,编译器会用值 12 替换Constants.MULTIPLIER,您的语句编译如下:

int x = 100 * 12;

现在,100 * 12 也是一个编译时常量表达式。编译器会用它的值 1200 来替换它,您的原始语句将被编译如下:

int x = 1200;

这种编译器优化有一个缺点。如果你改变了Constants类中MULTIPLIER final变量的值,你必须重新编译所有引用Constants.MULTIPLIER变量的类。否则,它们将继续使用上次编译时存在的MULTIPLIER常量的旧值。

通用类

抽象和多态是面向对象编程的核心。定义变量是一个抽象的例子,变量隐藏了实际的值和存储值的位置。定义一个方法隐藏了其实现逻辑的细节,这是另一种形式的抽象。为方法定义参数是多态的一部分,多态允许方法处理不同类型的值或对象。

Java 有一个特性叫做 generics ,它允许用 Java 编写真正的多态代码。使用泛型,您可以在不知道代码所操作的Object类型的情况下编写代码。它允许您创建泛型类、构造器和方法。

泛型类是使用形式类型参数定义的。形式类型参数是一列逗号分隔的变量名,放在类声明中类名后面的尖括号(<>)中。下面的代码片段声明了一个接受一个形式类型参数的泛型类Wrapper:

public class Wrapper<T>  {
    // Code for the Wrapper class goes here
}

该参数被命名为T。此时的T是什么?答案是你不知道。此时你只知道T是一个类型变量,它可以是 Java 中的任何引用类型,比如StringIntegerDoubleHumanAccount等。当使用Wrapper类时,指定正式类型参数值。采用形式类型参数的类也被称为参数化类

您可以通过将String类型指定为其形式类型参数的值来声明Wrapper<T>类的变量,如下所示。这里,String是实际的类型参数:

Wrapper<String> stringWrapper;

Java 允许您使用泛型类,而无需指定正式的类型参数。这是为了向后兼容。您也可以声明一个Wrapper<T>类的变量,如下所示:

Wrapper aRawWrapper;

当一个泛型类在没有指定实际类型参数的情况下被使用时,它被称为原始类型。前面的声明使用了Wrapper<T>类作为原始类型,因为它没有指定T的值。

Tip

泛型类的实际类型参数(如果指定)必须是引用类型,例如,StringHuman等。不允许将基元类型作为泛型类的实际类型参数。

一个类可以接受多个形参。下面的代码片段声明了一个Mapper类,它接受两个名为TR的形参:

public class Mapper<T,R>  {
    // Code for the Mapper class goes here
}

您可以声明一个Mapper<T, R>类的变量,如下所示:

Mapper<String,Integer> mapper;

这里,实际的类型参数是StringInteger

习惯上,而不是要求,给形式类型参数取一个字符的名字,例如,TRUV等。通常,T代表“类型”,R 代表“返回”,等等。单字符名称使代码更具可读性。但是,没有什么可以阻止您声明一个泛型类,如下所示,它有四个形式类型参数,分别名为MyTypeYourTypeHelloWhoCares:

public class Fun<MyType, YourType, Hello, WhoCares> {
    // Code for the Fun class goes here
}

Java 将编译Fun类,但是你代码的读者肯定会抱怨!正式的类型参数可以在类体内作为类型使用。还有另一个选项(更清楚),使用全部大写字母,例如

public class Fun2<TYPE1, TYPE2, RETURN_TYPE> {
    // Code for the Fun2 class goes here
}

清单 9-9 声明了一个泛型类Wrapper<T>,这是在 Java 中使用泛型的一个例子。

// Wrapper.java
package com.jdojo.cls;
public class Wrapper<T> {
    private T obj;
    public Wrapper(T obj) {
        this.obj = obj;
    }
    public T get() {
        return obj;
    }
    public void set(T obj) {
        this.obj = obj;
    }
}

Listing 9-9Declaring a Generic Class Wrapper<T>

Wrapper<T>类使用形式类型参数声明实例变量obj,为其构造器和set()方法声明一个形式参数,并作为get()方法的返回类型。

您可以通过为构造器指定实际类型参数来创建泛型类型的对象,如下所示:

Wrapper<String> w1 = new Wrapper<String>("Hello");

大多数情况下,编译器可以推断出构造器的实际类型参数。在这些情况下,可以省略实际的类型参数。在下面的赋值语句中,编译器会将构造器的实际类型参数推断为String (this <> is called the diamond operator ):

Wrapper<String> w1 = new Wrapper<>("Hello");

一旦你声明了一个泛型类的变量,你就可以把形式类型参数看作是为所有实际目的指定的实际类型参数。现在,你可以认为,对于w1,Wrapper<T>类的get()方法返回一个String:

String s1 = w1.get();

清单 9-10 中的程序展示了如何使用泛型Wrapper<T>类。

// WrapperTest.java
package com.jdojo.cls;
public class WrapperTest {
    public static void main(String[] args) {
        Wrapper<String> w1 = new Wrapper<>("Hello");
        String s1 = w1.get();
        System.out.println("s1=" + s1);
        w1.set("Testing generics");
        String s2 = w1.get();
        System.out.println("s2=" + s2);
        w1.set(null);
        String s3 = w1.get();
        System.out.println("s3=" + s3);
    }
}
s1=Hello
s2=Testing generics
s3=null

Listing 9-10Using a Generic Class in Your Code

谈到泛型在 Java 中提供了什么,这只是冰山一角。要完全理解泛型,您必须先了解其他主题,比如继承。

摘要

构造器是一个命名的代码块,用于在对象创建后立即初始化类的对象。构造器的结构看起来类似于方法。然而,它们是两种不同的构造,并且用于不同的目的。构造器的名称与类的简单名称相同。像方法一样,构造器可以接受参数。与方法不同,构造器不能指定返回类型。构造器与new操作符一起使用,为新对象分配内存,构造器初始化新对象。构造器不向其调用方返回值。您可以在构造器中使用不带表达式的return语句。return语句结束构造器调用,并将控制权返回给调用者。

构造器不被视为类的成员。像字段和方法一样,构造器也有访问级别:公共、私有、受保护或包级别。在定义它们时,关键字publicprivateprotected的存在分别赋予它们公共、私有或受保护的访问级别。缺少这些关键字中的任何一个都会指定包级访问。

一个类可以有多个构造器。如果一个类有多个构造器,它们被称为重载构造器。由于构造器的名称必须与类的简单名称相同,因此有必要区分不同的构造器。一个类中的所有收缩函数在数量、顺序或参数类型上都必须不同于其他收缩函数。

一个构造器可以使用关键字this调用同一个类的另一个构造器,就好像它是一个方法名一样。如果一个类的构造器调用同一类的另一个构造器,则必须满足以下规则:

  • 对另一个构造器的调用必须是该构造器中的第一条语句。

  • 构造器不能调用自身。

如果没有在类中添加构造器,Java 编译器会添加一个。这样的构造器被称为默认构造器。默认的构造器和它的类具有相同的访问级别,并且没有参数。

一个类也可以有一个或多个实例初始化器来初始化该类的对象。实例初始化器只是一个类体内的代码块,但是在任何方法或构造器之外。回想一下,代码块是用大括号括起来的合法 Java 语句序列。实例初始值设定项没有名称。它的代码简单地放在左大括号和右大括号中。当一个类的对象被创建时,该类的所有实例初始化器都是按文本顺序执行的。通常,实例初始化器用于初始化匿名类的对象。

一个类可以有一个或多个静态初始化器,用来初始化一个类,通常是类变量。实例初始化器对每个对象执行一次,而静态初始化器对一个类只执行一次,当类定义加载到 JVM 中时。为了将其与实例初始化器区分开来,需要在声明的开头使用static关键字。一个类的所有静态初始化器都按照它们出现的文本顺序执行,并且在任何实例初始化器之前执行。

您可以最终定义一个类及其成员。如果某件事是最终的,那就意味着它的定义或价值,无论它代表什么,都不能被修改。在 Java 中,Final 变量用于定义常量。编译时常量是在程序编译时已知其值的常量。运行时常量是在程序运行之前不知道其值的常量。

变量可以声明为 blank final,在这种情况下,变量被声明为 final,但在声明时不赋值。在读取空的 final 变量的值之前,必须为其赋值。空白的 final 实例变量必须在其实例初始值设定项或构造器中初始化一次。您可以将类变量声明为空的 final。您必须在一个静态初始化器中初始化一个空的 final 类变量。如果一个类有多个静态初始化器,那么必须在其中一个静态初始化器中只初始化一次所有的空 final 类变量。

Java 允许您使用泛型编写真正的多态代码,在泛型中,代码是根据形式类型参数编写的。泛型类是使用形式类型参数定义的。形式类型参数是一列逗号分隔的变量名,放在类声明中类名后面的尖括号(<>)中。采用形式类型参数的类也被称为参数化类。实际的类型参数是在使用参数化类时指定的。

EXERCISES

  1. 什么是构造器?创建一个类的对象时,必须和构造器一起使用的操作符的名字是什么?

  2. 什么是默认构造器?默认构造器的访问级别是什么?

  3. 如何从同一个类的另一个构造器调用一个类的构造器?描述在代码中进行这种调用的任何限制。

  4. 什么是静态和实例初始化器?

  5. 什么是final变量和空白最终变量?

  6. 将方法的参数或构造器的参数声明为 final 有什么影响?

  7. Consider the following code for a Cat class:

    // Cat.java
    package com.jdojo.cls.excercise;
    public class Cat {
    }
    
    

    当编译Cat类时,编译器会给它添加一个默认的构造器。重写Cat类,就像添加默认构造器而不是编译器一样。

  8. Consider the following code for a Mouse class:

    // Mouse.java
    package com.jdojo.cls.excercise;
    class Mouse {
    }
    
    

    当编译Mouse类时,编译器会给它添加一个默认的构造器。重写Mouse类,就像添加默认构造器而不是编译器一样。

  9. 用两个名为xyint实例变量创建一个SmartPoint2D类。实例变量应该声明为 private 和 final。SmartPoint2D类的一个实例代表了 2D 平面中的一个不变点。也就是说,一旦SmartPoint2D类的对象被创建,该对象的 x 和 y 值就不能被改变。向该类添加一个公共构造器,它应该接受两个实例变量xy的值,并用传入的值初始化它们。

  10. 为您在前一个练习中创建的SmartPoint2D类中的xy实例变量添加 getters。

  11. SmartPoint2D类添加一个名为ORIGINpublic static final变量。ORIGIN变量属于SmartPoint2D类,是一个 x = 0,y = 0 的SmartPoint2D

  12. Implement a method named distance in the SmartPoint2D class that you created in the previous exercise. The method accepts an instance of the SmartPoint2D class and returns the distance between the current point and the point represented by the parameter. The method should be declared as follows:

```java
public class SmartPoint2D {
    /* Code from the previous exercise goes here. */
    public double distance(SmartPoint2D p) {
        /* Your code for this exercise goes here. */
    }
}

```

提示两点`(x1, y1)`和`(x2, y2)`之间的距离计算为`√((x2-x1)`<sup>`2`</sup>`+ (y2-y1)`<sup>`2`</sup>`)`。您可以使用`Math.sqrt(n)`方法来计算数字`n`的平方根。
  1. 创建一个Circle类,它有三个名为xyradius的私有最终实例变量。xy实例变量代表圆心的xy坐标;它们属于int数据类型。radius实例变量代表圆的半径;它属于double数据类型。向Circle类添加一个构造器,该构造器接受其实例变量xyradius的值。为三个实例变量添加 getters。

  2. 通过添加四个名为centerDistancedistanceoverlapstouches的实例方法来增强Circle类。所有这些方法都接受一个Circle作为参数。centerDistance方法返回圆心和另一个作为参数传入的圆之间的距离(作为一个double))。distance方法返回两个圆之间的最小距离(作为double)。如果两个圆重叠,distance方法返回一个负数。如果两个圆重叠,overlaps方法返回true,否则返回false。如果两个圆相互接触,则touches方法返回true,否则返回falsedistance方法必须使用centerDistance方法。overlapstouches方法的主体必须只包含一个使用distance方法的语句。

提示:两个圆之间的距离是它们的圆心距离减去它们的半径。如果两个圆之间的距离为负,则这两个圆重叠。如果两个圆之间的距离为零,它们就相交。
  1. 通过添加两个名为perimeterarea的方法来增强Circle类,这两个方法分别计算并返回圆的周长和面积。

  2. Circle类添加第二个构造器,该构造器接受一个double参数,即圆的半径。这个构造器应该调用另一个现有的Circle类的构造器,用三个参数传递零作为xy的值。

  3. 双精度值可以是NaN,正无穷大,负无穷大。用三个参数xyradius增强Circle类的构造器,所以当radius参数的值不是有限数或负数时,它抛出一个RuntimeException

Hint The `java.lang.Double` class contains a static `isFinite(double n)` method, which returns `true` if the specified parameter `n` is a finite number and `false` otherwise. Use the following statement to throw a `RuntimeException`:

```java
throw new RuntimeException(
           "Radius must be a finite non-negative number.");

```
  1. 考虑下面的InitializerTest类。这个类中有多少静态和实例初始化器?运行该类时将打印什么?
```java
// InitializerTest.java
package com.jdojo.cls.excercise;
public class InitializerTest {
    private static int count;
    {
        System.out.println(count++);
    }
    {
        System.out.println(count++);
    }
    static {
        System.out.println(count);
    }
    public static void main(String[] args) {
        new InitializerTest();
        new InitializerTest();
    }
}

```
  1. 描述为什么下面的FinalTest类不能编译:
```java
// FinalTest.java
package com.jdojo.cls.excercise;
public class FinalTest {
    public static int square(final int x) {
        x = x * x;
        return x;
    }
}

```
  1. 描述为什么下面的BlankFinalTest类不能编译:
```java
// BlankFinalTest.java
package com.jdojo.cls.excercise;
public class BlankFinalTest {
    private final int x;
    private final int y;
    {
        y = 100;
    }
    public BlankFinalTest() {
        y = 100;
    }
    /* More code goes here */
}

```

十、模块

在本章中,您将学习:

  • 什么是模块

  • 如何声明模块

  • 模块的隐式可读性意味着什么以及如何声明它

  • 不合格和合格出口的区别

  • 声明模块的运行时可选依赖项

  • 如何打开整个模块或其选定的包进行深层思考

  • 关于跨模块拆分包的规则

  • 模块声明的限制

  • 不同类型的模块:命名模块、未命名模块、显式模块、自动模块、普通模块和开放模块

  • 了解运行时的模块

  • 如何使用javap工具反汇编模块的定义

本章中一些例子的代码经历了几个步骤。本书的源代码包含了那些例子的最后一步中使用的代码。如果你想在阅读本章的每一步都看到这些例子,你需要稍微修改一下源代码,使其与你正在进行的步骤保持同步。

什么是模块?

简单来说,一个模块就是一组包。一个模块可以有选择地包含诸如图像、属性文件等资源。现在,让我们只关注作为一组包的模块。一个模块指定了它的包对其他模块的可访问性以及它对其他模块的依赖性。模块中的包的可访问性决定了其他模块是否可以访问该包。一个模块的依赖关系决定了这个模块读取的其他模块的列表。“依赖于”、“读取”和“需要”这三个术语可以互换使用,以表示一个模块对另一个模块的依赖性。如果模块M依赖于模块N,下面三个短语意思相同:“模块M依赖于模块N”;“模块M需要模块N”;或者“模块M读取模块N

默认情况下,模块中的包只能在同一个模块中访问。如果一个模块中的包需要在它的模块之外被访问,包含这个包的模块需要导出这个包。一个模块可以将其包导出到所有其他模块,或者只导出到其他模块的选定列表。

如果一个模块想要从另一个模块访问包,第一个模块必须声明对第二个模块的依赖,第二个模块必须导出包,以便第一个模块可以访问它们。

声明模块

模块是在编译单元中声明的。本书在第三章中介绍了编译单元的概念,其中编译单元包含类型声明(类和接口声明)。包含模块声明的编译单元不同于包含类型声明的编译单元。从 Java 9 开始,有两种类型的编译单元:

  • 普通编译单元

  • 模块化编译单元

一个普通的编译单元由三部分组成:包声明、导入声明和顶级类型声明。普通编译单元中的所有部分都是可选的。参见第三章了解更多关于普通编译单元的详细信息。

模块化编译单元包含一个模块声明。模块声明之前可以有可选的导入声明。模块化编译单元不能有包声明。模块化编译单元中的导入声明允许您在模块声明中使用简单的类型名称和静态类型成员。

Tip

模块化编译单元被命名为module-info,扩展名为.java.jav。本书示例中的所有模块化编译单元都被命名为module-info.java

使用模块化编译单元的语法如下:

[import-declarations]
<module-declaration>

导入声明中使用的类型可能来自同一模块或其他模块中的包。有关如何使用进口申报的更多详细信息,请参考第七章。模块声明的语法如下:

[open] module <module-name> {
    <module-statement-1>;
    <module-statement-2>;
    ...
}

module 关键字用于声明一个模块。模块声明可以选择以关键字open开始,以声明一个开放的模块(将在后面描述)。module关键字后面是一个模块名。一个模块名是一个合格的 Java 标识符,它是一个或多个 Java 标识符的序列,由一个点分隔,类似于包名。

模块声明的主体放在花括号内,花括号中可以有零个或多个模块语句。模块语句也被称为模块 指令。这本书使用了语句这个术语,而不是指令。模块语句有五种类型:

  • exports声明

  • opens声明

  • requires声明

  • uses声明

  • provides声明

对于一个模块访问另一个模块中的类型,第二个模块使包含这些类型的包可被访问,第一个模块读取第二个模块。所有五种类型的模块语句都用于这两个目的:

  • 使类型可访问

  • 访问这些类型

exportsopensprovides语句表达了一个模块中的类型对其他模块的可用性。模块中的requiresuses语句用于表达模块对其他模块使用exportsopensprovides语句读取可用类型的依赖性。这些类型的语句的区别在于模块提供的类型和其他模块使用的类型的上下文。以下是包含所有五种模块语句的模块声明示例:

module jdojo.policy {
    exports com.jdojo.policy;
    requires java.sql;
    opens com.jdojo.policy.model;
    uses com.jdojo.common.Job;
    provides com.jdojo.common.Job with com.jdojo.policy.JobImpl;
}

以下术语是 Java 中的受限关键字:openmodulerequirestransitiveexportsopenstousesprovideswith。只有当它们出现在模块化编译单元的特定位置时,才会被视为关键字。它们在其他任何地方都是正常的术语。例如,下面的模块声明是有效的,尽管模块名“module”不是很直观:

module module {
    exports com.jdojo.policy;
}

这里,第一个“模块”术语是一个受限制的关键字,第二个是一个用作模块名称的普通术语。

后续章节详细描述了exportsrequires语句。我们在本章中简要解释一下opens语句。

声明模块依赖

在 Java SE 8 之前,一个包中的公共类型可以不受任何限制地被其他包访问。换句话说,包并不控制它们所包含的类型的可访问性。Java SE 9 和更高版本中的模块系统提供了对模块包中包含的类型的可访问性的细粒度控制。

跨模块的可访问性是被使用模块和使用模块之间的双向协议。一个模块显式地将其公共类型提供给其他模块使用,使用这些公共类型的模块显式地声明对第一个模块的依赖。模块的所有非导出包都是模块私有的,不能从模块外部访问它们。

让一个包中的公共类型对其他模块可用被称为导出那个包,这是通过在模块声明中使用exports语句来完成的。模块可以将其包导出到所有其他模块或选定的模块列表中。当一个模块将其包导出到所有其他模块时,称为不合格导出。以下是将包导出到所有其他模块的语法:

exports <package>;

这里,<package>是当前模块中的包。读取当前模块的所有其他模块都可以使用这个包中的公共类型。考虑以下声明:

module jdojo.address {
    exports com.jdojo.address;
}

jdojo.address模块将名为com.jdojo.address的包导出到所有其他模块。只有在jdojo.address模块中才能访问jdojo.address模块中的所有其他包。

一个模块也可以有选择地只将包导出到一个或多个命名的模块。这种导出被称为合格导出模块友好导出。限定导出中的包中的公共类型只能由指定的命名模块访问。以下是使用限定导出的语法:

exports <package> to <friend-module> [, <friend-module>...] ;

这里,<package>是当前模块中的一个包,只导出到"to"子句中列出的友元模块。下面是一个使用限定导出的jdojo.policy模块的模块声明:

module jdojo.policy {
    exports com.jdojo.policy to jdojo.claim, jdojo.payment;
}

jdojo.policy模块包含一个名为com.jdojo.policy的包。该模块使用一个合格的导出将这个包只导出到两个模块,jdojo.claimjdojo.payment

Tip

合格输出的to条款中指定的模块不需要是可观察的。

不合格出口和合格出口哪个更好用?当您向公众共享包中的公共类型时,例如,当您开发供公众使用的模块时,应该使用非限定导出。一旦您发布了您的模块,您就不应该改变导出包中的公共 API。有时,坏的 API 会永远留在一个模块中,因为该模块是公共使用的,更改/删除 API 会影响很多用户。有时,您可能需要在模块之间共享公共类型,这些模块是库或框架的一部分;但是,这些模块中的公共类型不供公共使用。在这种情况下,您应该使用限定的导出,这将在您更改涉及那些共享公共类型的 API 时将影响降到最低。java.base模块使用几个合格的导出将它的包导出到其他 JDK 模块。您可以使用以下命令来描述java.base模块,以列出合格的导出:

C:\> java --describe-module java.base
java.base@17
exports java.io
exports java.lang
...
qualified exports jdk.internal.org.xml.sax to jdk.jfr
qualified exports sun.security.tools to jdk.jartool
...
contains sun.invoke
contains sun.invoke.util
contains sun.io
...

一个requires语句用于指定一个模块对另一个模块的依赖性。如果一个模块读取另一个模块,第一个模块需要在其声明中有一个requires语句。requires语句的一般语法如下:

requires [transitive] [static] <module>;

这里,<module>是当前模块读取的模块名称。transitivestatic修改器都是可选的。如果static修饰符存在,对<module>的依赖在编译时是强制的,但在运行时是可选的。如果没有static修饰符,read 模块在编译时和运行时都是必需的。transitive修饰符的存在意味着一个模块隐式地读取当前模块也读取<module>。我们将很快介绍一个在requires语句中使用transitive修饰符的例子。下面是一个使用requires语句的例子:

module jdojo.claim {
    requires jdojo.policy;
}

这里,jdojo.claim模块使用一个requires语句来表示它读取了jdojo.policy模块。在jdojo.claim模块中可以访问jdojo.policy模块中所有导出包的所有公共类型。

每个模块都隐式读取java.base模块。如果模块声明没有显式读取java.base模块,编译器会在模块声明中添加一条requires语句来读取java.base模块。一个jdojo.common模块的以下两个模块声明是相同的:

// Declaration #1
module jdojo.common {
    // The compiler will add a dependence to the java.base module
}
// Declaration #2
module jdojo.common {
    // Add a dependence to the java.base module explicitly
    requires java.base;
}

你可以可视化两个模块之间的依赖关系,如图 10-1 所示,它描述了两个名为jdojo.policyjdojo.claim的示例模块之间的依赖关系。

img/323069_3_En_10_Fig1_HTML.png

图 10-1

声明模块之间的依赖关系

jdojo.policy模块包含两个名为com.jdojo.policycom.jdojo.policy.impl的包;它导出了com.jdojo.policy包,该包以虚线显示,以区别于未导出的com.jdojo.policy.impl包。jdojo.claim模块包含两个包— com.jdojo.claimcom.jdojo.claim.impl;它不导出任何包,并声明依赖于jdojo.policy模块。以下两个模块声明在 Java 代码中表达了这种依赖性:

module jdojo.policy {
    exports com.jdojo.policy;
}
module jdojo.claim {
    requires jdojo.policy;
}

Tip

两个模块(被使用的模块和正在使用的模块)中的依赖声明是不对称的——被使用的模块导出一个,而正在使用的模块需要一个模块

模块依赖的一个例子

在这一节中,我们将带您看一个使用模块依赖的完整例子。假设您有两个名为jdojo.addressjdojo.person的模块。jdojo.address模块包含一个名为com.jdojo.address的包,其中包含一个名为Address的类。jdojo.person模块想要使用来自jdojo.address模块的Address类。图 10-2 显示了jdojo.person module的模块图。

img/323069_3_En_10_Fig2_HTML.jpg

图 10-2

jdojo.person 模块的模块图

在 NetBeans 中,您可以创建两个名为jdojo.addressjdojo.person的模块。清单 10-1 和 10-2 包含模块声明和Address类的代码。

// Address.java
package com.jdojo.address;
public class Address {
    private String line1 = "1111 Main Blvd.";
    private String city = "Jacksonville";
    private String state = "FL";
    private String zip = "32256";
    public Address() {
    }
    public Address(String line1, String city, String state, String zip) {
        this.line1 = line1;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }
    public String getLine1() {
        return line1;
    }
    public void setLine1(String line1) {
        this.line1 = line1;
    }

    public String getCity() {
        return city;
    }
    public void setCity(String city) {
        this.city = city;
    }

    public String getState() {
        return state;
    }
    public void setState(String state) {
        this.state = state;
    }
    public String getZip() {
        return zip;
    }
    public void setZip(String zip) {
        this.zip = zip;
    }
    @Override
    public String toString() {
        return "[Line1:" + line1 + ", State:" + state +
               ", City:" + city + ", ZIP:" + zip + "]";
    }

}

Listing 10-2The Address Class

// module-info.java
module jdojo.address {
    // Export the com.jdojo.address package
    exports com.jdojo.address;
}

Listing 10-1The Module Declaration for the jdojo.address Module

Address类是一个简单的类,有四个字段以及它们的 getters 和 setters。默认值是为这些字段设置的,因此您不必在示例中键入它们。Address类中的toString()方法返回地址对象的字符串表示(本书在第十一章和 20 章中详细介绍了toString()方法的使用)。

jdojo.address模块导出com.jdojo.address包,所以Address类是公共的,在导出的com.jdojo.address包中,可以被其他模块使用。在这个例子中,您将在jdojo.person模块中使用Address类。清单 10-3 和 10-4 包含了jdojo.person模块的模块声明和Person类的代码。

// Person.java
package com.jdojo.person;
import com.jdojo.address.Address;
public class Person {
    private long personId;
    private String firstName;
    private String lastName;
    private Address address = new Address();
    public Person(long personId, String firstName, String lastName) {
        this.personId = personId;
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public long getPersonId() {
        return personId;
    }
    public void setPersonId(long personId) {
        this.personId = personId;
    }
    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    public Address getAddress() {
        return address;
    }
    public void setAddress(Address address) {
        this.address = address;
    }
    @Override
    public String toString() {
        return "[Person Id:" + personId + ", First Name:" + firstName +
               ", Last Name:" + lastName + ", Address:" + address + "]";
    }
}

Listing 10-4A Person Class

// module-info.java
module jdojo.person {
    // Read the jdojo.address module
    requires jdojo.address;
    // Export the com.jdojo.person package
    exports com.jdojo.person;
}

Listing 10-3The Module Declaration for the jdojo.person Module

Person类在jdojo.person模块中,它使用了一个Address类型的字段,该字段在jdojo.address模块中。这意味着jdojo.person模块读取jdojo.address模块。这由jdojo.person模块声明中的requires语句表示:

// Read the jdojo.address module
requires jdojo.address;

jdojo.person模块的声明包括一个没有static修饰符的requires语句,这意味着jdojo.address模块在编译时和运行时都是必需的。当你编译jdojo.person模块时,你必须在模块路径中包含jdojo.address模块。在提供的源代码中,这两个模块是单个 NetBeans 模块化项目的一部分,您不需要执行额外的步骤来修改模块路径。

如果您使用两个单独的 NetBeans 项目创建这两个模块,那么您需要在jdojo.person模块的模块路径中包含jdojo.address模块的项目。在 NetBeans 中右键单击jdojo.person项目并选择 Properties。在“类别”列表中,选择“库”。选择 Compile 选项卡并单击 Modulepath 行上的+号。从菜单中选择添加项目…,如图 10-3 所示,从文件系统中选择jdojo.address NetBeans 项目。如果您在模块化 JAR 或目录中有一个已编译的jdojo.address模块,您可以使用 Add JAR/Folder 菜单选项。

img/323069_3_En_10_Fig3_HTML.png

图 10-3

在 NetBeans 中设置项目的模块路径

jdojo.person模块还导出了com.jdojo.person包,所以这个包中的公共类型,例如Person类,可能会被其他模块使用。清单 10-5 包含了一个Main类的代码,它在jdojo.person模块中。

// Main.java
package com.jdojo.person;
import com.jdojo.address.Address;
public class Main {
    public static void main(String[] args) {
        Person john = new Person(1001, "John", "Jacobs");
        String fName = john.getFirstName();
        String lName = john.getLastName();
        Address addr = john.getAddress();
        System.out.printf("%s %s%n", fName, lName);
        System.out.printf("%s%n", addr.getLine1());
        System.out.printf("%s, %s %s%n", addr.getCity(),
                          addr.getState(), addr.getZip());
    }
}

John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

Listing 10-5A Main Class to Test the jdojo.person Module

当您运行这个类时,输出显示您能够从jdojo.address模块中使用Address类。我们已经完成了这个展示如何使用exportsrequires模块语句的例子。如果您在运行这个示例时遇到任何问题,请参考下一节,其中列出了一些可能的错误及其解决方案。

此时,您也可以使用命令提示符运行这个示例。您需要在模块路径中包含为jdojo.personjdojo.address模块编译的展开目录或模块化 jar。以下命令使用了来自dist目录的模块化 jar:

C:\JavaFun>java --module-path dist\jdojo.person.jar;dist\jdojo.address.jar --module jdojo.person/com.jdojo.person.Main
John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

本书提供的源代码包含了JavaFun\dist目录中的所有模块化 jar。在前面的命令中,我们有选择地包含了用于jdojo.personjdojo.address模块的模块化 jar,以向您展示当您运行com.jdojo.person.Main类时,所有其他模块都没有被使用。您可以简化这个命令,只将dist目录添加到模块路径中,如下所示,Java 运行时将像以前一样使用所需的两个模块:

C:\JavaFun>java --module-path dist --module jdojo.person/com.jdojo.person.Main
John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

解决纷争

如果您是第一次使用 JDK,那么在使用这个示例时,可能会出现一些问题。下面是几个出现错误消息的场景和相应的解决方案。

空包错误

错误是

error: package is empty or does not exist: com.jdojo.address
    exports com.jdojo.address;
                     ^
1 error

当您为jdojo.address模块编译模块声明而没有包含Address类的源代码时,您会得到这个错误。该模块导出com.jdojo.address包。您必须在导出的包中定义至少一个类型。

找不到模块错误

错误是

error: module not found: jdojo.address
    requires jdojo.address;
                      ^
1 error

当您在模块路径中没有包含jdojo.address模块的情况下为jdojo.person模块编译模块声明时,您会得到这个错误。jdojo.person模块读取jdojo.address模块,因此前者必须能够在编译时和运行时在模块路径上找到后者。如果使用命令提示符,使用--module-path选项指定jdojo.address模块的模块路径。如果您使用的是 NetBeans,请参考上一节关于如何为jdojo.person模块配置模块路径的内容。

包不存在错误

错误是

error: package com.jdojo.address does not exist
import com.jdojo.address.Address;
                        ^
error: cannot find symbol
    private Address address = new Address();
            ^
  symbol:   class Address
  location: class Person

当您在jdojo.person模块中编译PersonMain类而没有在模块声明中添加适当的requires语句时,您会得到这个错误。错误消息指出编译器找不到com.jdojo.address.Address类。解决方案是在编译和运行jdojo.person模块时,在jdojo.person模块的模块声明中添加一个requires jdojo.address"语句,并在模块路径中添加jdojo.address模块。

模块解析异常

部分误差是

Error occurred during initialization of VM
java.lang.module.ResolutionException: Module jdojo.person not found
...

当您尝试使用命令提示符运行该示例时,可能会由于以下原因而出现此错误:

  • 未正确指定模块路径。

  • 模块路径是正确的,但是在模块路径上找不到指定目录或模块化 jar 中的编译代码。

假设您使用以下命令运行该示例:

C:\JavaFun>java --module-path dist --module jdojo.person/com.jdojo.person.Main

确保以下模块化 jar 存在:

  • C:\JavaFun\dist\jdojo.person.jar

  • C:\JavaFun\dist\jdojo.address.jar

如果这些模块化 jar 不存在,在 NetBeans 中构建JavaFun项目。如果您使用展开目录中的模块代码通过以下命令运行示例,请确保在 NetBeans 中编译项目:

C:\JavaFun>java --module-path build\modules\jdojo.person;build\modules\jdojo.address
--module jdojo.person/com.jdojo.person.Main

隐性依赖

如果一个模块可以读取另一个模块,而第一个模块在其声明中没有包含读取第二个模块的requires语句,那么就说第一个模块隐式地读取了第二个模块。每个模块都隐式读取java.base模块。隐式读取不限于java.base模块。一个模块也可以隐式地读取另一个模块,而不是java.base模块。在我们向您展示如何向模块添加隐式可读性之前,我们将构建一个示例来展示我们为什么需要这个特性。

在上一节中,您创建了两个名为jdojo.addressjdojo.person的模块,其中第二个模块使用以下声明读取第一个模块:

module jdojo.person {
    requires com.jdojo.address;
    ...
}

jdojo.person模块中的Person类是指jdojo.address模块中的Address类。让我们创建另一个名为jdojo.person.test的模块,它读取jdojo.person模块。模块声明如清单 10-6 所示。

// module-info.java
module jdojo.person.test {
    requires jdojo.person;
}

Listing 10-6The Module Declaration for the jdojo.person.test Module

jdojo.person.test模块的模块图如图 10-4 所示。注意,jdojo.person.test模块不读取jdojo.address模块,所以由jdojo.address模块导出的com.jdojo.address包中的公共类型在jdojo.person.test模块中是不可访问的。

img/323069_3_En_10_Fig4_HTML.png

图 10-4

jdojo.person.test 模块的模块图

清单 10-7 包含了jdojo.person.test模块中Main类的代码。

// Main.java
package com.jdojo.person.test;
import com.jdojo.person.Person;
public class Main {
    public static void main(String[] args) {
        Person john = new Person(1001, "John", "Jacobs");
        // Get John's city and print it
        String city = john.getAddress().getCity();
        System.out.printf("John lives in %s%n", city);
    }
}

Listing 10-7A Main Class to Test the jdojo.person.test Module

main()方法中的代码非常简单——它创建一个Person对象并读取一个人地址中的城市值:

Person john = new Person(1001, "John", "Jacobs");
String city = john.getAddress().getCity();

编译jdojo.person.test模块的代码会产生以下错误:

C:\JavaFun\src\jdojo.person.test\classes\com\jdojo\person\test\Main.java:11: error: Address.getCity() in package com.jdojo.address is not accessible
        String city = john.getAddress().getCity();
  (package com.jdojo.address is declared in module jdojo.address, but module jdojo.person.test does not read it)
1 error

编译器消息不是很清楚。它表明jdojo.person.test模块无法访问Address类。回想一下,Address类在jdojo.address模块中,jdojo.person.test module不读取这个模块。查看代码,很明显代码应该可以编译。您可以访问使用了Address类的Person类;所以你应该可以使用Address类。这里,对john.getAddress()方法的调用返回一个Address类型的对象,您无权访问它。模块系统只是在执行由jdojo.address模块定义的封装。如果一个模块想显式或隐式地使用Address类,它必须读取jdojo.address模块。你如何修理它?简单的答案是通过将声明改为清单 10-8 中所示的声明,让jdojo.person.test模块读取jdojo.address模块。

// module-info.java
module jdojo.person.test {
    requires jdojo.person;
    requires jdojo.address;
}

Listing 10-8The Modified Module Declaration for the jdojo.person.test Module

图 10-5 显示了jdojo.person.test模块修改后的模块图。

img/323069_3_En_10_Fig5_HTML.png

图 10-5

jdojo.person.test 模块的修改后的模块图

编译并运行jdojo.person.test模块中的Main类,它将打印以下内容:

John lives in Jacksonville

您通过在jdojo.person.test模块的声明中添加一个requires语句解决了这个问题。然而,很有可能读取jdojo.person模块的其他模块将需要处理地址,它们将需要添加相同的requires语句。如果jdojo.person模块在其公共 API 中公开了来自多个其他模块的类型,那么读取jdojo.person模块的模块将需要为每个这样的模块添加一个requires语句。对于所有这些模块来说,增加一个额外的requires语句是非常麻烦的。

还有另一个用例可以创建这种场景。假设只有两个模块——jdojo.person.testjdojo.person——其中前者读取后者,后者导出其公共类型被前者使用的所有包。com.jdojo.address包在jdojo.person模块里,jdojo.person.test模块编译好了。后来,jdojo.person模块被重构为两个模块——jdojo.personjdojo.address。现在,jdojo.person.test模块停止工作,因为jdojo.person模块中的一些公共类型现在被移动到了jdojo.address模块中,而jdojo.person.test模块不读取这些公共类型。

JDK 9 的设计师意识到了这个问题,并提供了一个简单的方法来解决这个问题。在这种情况下,您需要做的就是修改jdojo.person模块的声明,在requires语句中添加一个transitive修饰符来读取jdojo.address模块。清单 10-9 包含了对jdojo.person模块的修改声明。

// module-info.java
module jdojo.person {
    // Read the jdojo.address module
    requires transitive jdojo.address;
    // Export the com.jdojo.person package
    exports com.jdojo.person;
}

Listing 10-9The Modified Module Declaration for the jdojo.person Module That Uses a Transitive Export

现在,你可以删除这个声明

requires jdojo.address;

来自jdojo.person.test模块的声明。您需要将jdojo.address项目保留在模块路径上来编译和运行jdojo.person.test模块项目,因为jdojo.address模块仍然需要使用该模块中的Address类型。重新编译jdojo.person模块。重新编译并运行jdojo.person.test模块中的主类,以获得想要的输出。

Tip

当模块M使用来自模块N的公共类型,并且这些公共类型是模块M的公共 API 的一部分时,考虑在模块M中使用一个requires transitive N。假设您有一个导出包的模块P和另一个读取模块P的模块Q。如果您重构模块P以将其拆分为多个模块,比如说ST,请考虑将requires transitive Srequires transitive T语句添加到P的模块声明中,以确保所有读取P的模块(在本例中为模块Q)继续工作而不做任何更改。

requires语句包含transitive修饰符时,依赖于当前模块的模块隐式读取在requires语句中指定的模块。参考清单 10-9 ,任何读取jdojo.person模块的模块都会隐式读取jdojo.address模块。本质上,隐式读取使模块声明更容易阅读,将一个模块重构为多个模块更容易,但更难推理,因为仅通过查看模块声明,您无法了解它的所有依赖项。图 10-6 显示了jdojo.person.test模块的最终模块图。

img/323069_3_En_10_Fig6_HTML.png

图 10-6

jdojo.person.test 模块的模块图

当模块被解析时,模块图通过为每个传递依赖添加一个读边来扩充。在本例中,如图 10-7 中的虚线箭头所示,读取边沿将从jdojo.person.test模块添加到jdojo.address模块。图中用虚线显示了将jdojo.person.test模块连接到jdojo.address模块的边,以表示它是在模块图被解析后添加的。

img/323069_3_En_10_Fig7_HTML.png

图 10-7

jdojo.person.test 模块的模块图,在用隐式读边扩充后

选择性依赖

模块系统在编译时和运行时验证模块依赖性。有时候你想让模块依赖在编译时是强制性的,但在运行时是可选的。

如果特定的模块在运行时可用,您可以开发一个性能更好的库。否则,它会退回到另一个模块,使其性能达不到最佳状态。但是,库是针对可选模块编译的,它确保如果可选模块不可用,依赖于可选模块的代码不会被执行。

另一个例子是导出注释包的模块。Java 运行时已经忽略了不存在的注释类型。然而,模块依赖在启动时被验证;如果模块在运行时丢失,应用程序将不会启动。因此,有必要将包含注释包的模块的模块依赖声明为可选的。

您可以通过在requires语句中使用static关键字来声明可选的依赖项:

requires static <optional-module>;

以下模块声明包含对jdojo.annotation模块的可选依赖:

module jdojo.claim {
    requires static jdojo.anotation;
}

在一条requires语句中,允许同时有transitivestatic修饰语;

module jdojo.claim {
    requires transitive static jdojo.anotation;
}

打开模块和包

反思是一个浩如烟海的话题。如果这是您第一次接触 Java,您可能很难理解这一部分。当您对 Java 有了更多的经验时,或者只是阅读它而不必担心会跟不上所解释的一切时,您可以重温这一节。

反射是一种在编译时不知道 Java 类型的情况下使用它们的方法。你已经在本章中使用了诸如Person类的类型。要创建一个Person并调用它的getFirstName()方法,您需要编写如下代码:

import com.jdojo.person.Person;
...
Person john = new Person(1001, "John", "Jacobs");
String firstName = john.getFirstName();

在这种情况下,Java 编译器确保在com.jdojo.person包中有一个名为Person的类。编译器还确保这段代码可以访问Person类、它的构造器和它的getFirstName()方法。如果Person类不存在,你就不能编译这段代码。当您运行这段代码时,Java 运行时再次验证Person的存在以及这段代码使用它所需的访问权限。使用反射,您可以在不知道Person类存在的情况下重写这段代码。您的代码和编译器不知道Person类,但是您将能够实现同样的功能。为此,这段代码只需要运行时访问Person类。Java 中有两种类型的访问:

  • 编译时访问

  • 运行时访问

编译器在编译期间验证编译时访问。编译时访问必须遵循 Java 语言访问规则,例如,类之外的代码不能访问该类的私有成员。

Java 运行时验证对类型及其成员的运行时访问。在运行时,代码可以通过两种方式访问类型及其成员:

  • 第一种方法是运行根据被访问的类型编写的编译代码。在这种情况下,运行时会像在编译期间一样加强 Java 语言的可访问性规则。

  • 第二种方法是在运行时使用反射来访问类型及其成员。在这种情况下,编译器不知道您的代码将在运行时访问的类型及其成员。使用反射访问类型及其成员被称为使用反射访问。与普通访问不同,反射访问允许访问所有类型(不仅仅是公共类型)和这些类型的所有成员(甚至是私有成员)。

反射访问有好有坏。它之所以好,是因为它让您开发可以在所有未知类型上工作的库。有几个很好的框架,比如 Spring 和 Hibernate,非常依赖于对应用程序库中定义的类型成员的深度反射访问。反射访问之所以不好,是因为它破坏了封装——它可以访问类型和这些类型的成员,而使用正常的访问规则是无法访问这些类型和成员的。使用反射访问不可访问的类型及其成员有时被称为深度反射

20 多年来,Java 允许反射访问。Java 9 中模块系统的设计者在设计对模块代码的深度反射访问时面临着一个巨大的挑战。允许对导出包的类型进行深度反射违反了模块系统的强封装主题。它使得外部代码可以访问任何东西,即使模块开发者不想公开模块的某个部分。另一方面,不允许深度反射将使 Java 社区缺乏一些广泛使用的伟大框架,并且它还将破坏许多依赖于深度反射的现有应用程序。由于这一限制,许多现有的应用程序根本无法迁移到 JDK 9。

经过几次反复的设计和实验之后,模块系统的设计者们提出了一个折中的办法——鱼和熊掌不可兼得!该设计允许您拥有一个具有强封装、深度反射访问以及两者兼而有之的模块。规则如下:

  • 导出的包将只允许在编译时和运行时访问公共类型及其公共/受保护成员。如果不导出包,其他模块将无法访问该包中的所有类型。这提供了强大的封装。

  • 您可以打开一个模块,以允许在运行时对该模块中所有包中的所有类型进行深度反射。这样的模块被称为开放模块

  • 您可以拥有一个普通模块——一个没有为深度反射而打开的模块——以及在运行时为深度反射而打开的特定包。所有其他未打开的包装都被严密封装。模块中允许深度反射的包被称为开放包

  • 有时,您可能希望在编译时访问包中的类型,以便根据该包中的类型编写代码;同时,您希望在运行时对这些类型进行深度反射访问。您可以导出并打开同一个包来实现这一点。

开放模块

module关键字前使用open修饰符声明一个打开的模块:

open module jdojo.model {
    // Module statements go here
}

这里的jdojo.model模块是一个开放模块。其他模块可以在这个模块的所有包中的所有类型上使用深度反射。在一个开放模块的声明中可以有exportsrequiresusesprovides语句。在打开的模块中不能有opens语句。一个opens语句用于打开一个特定的包进行深度反射。因为开放模块打开所有包进行深度反射,所以在开放模块内部不允许使用opens语句。

打开包装

打开一个包意味着向该包中的公共类型授予对其他模块的正常运行时访问,并允许其他模块对该包中的类型使用深度反射。您可以向所有其他模块或特定模块列表打开一个包。向所有其他模块打开包的opens语句的语法如下:

opens <package>;

在这里,<package>可用于所有其他模块的深度反射。您还可以使用限定的opens语句打开特定模块的包:

opens <package> to <module1>, module2>...;

在这里,<package>只对<module1><module2>等开放深刻反思。下面是一个在模块声明中使用opens语句的例子:

module jdojo.model {
    // Export the com.jdojo.util package to all modules
    exports com.jdojo.util;
    // Open the com.jdojo.util package to all modules
    opens com.jdojo.util;
    // Open the com.jdojo.model.policy package only to the hibernate.core module
    opens com.jdojo.model.policy to hibernate.core;
}

jdojo.model模块导出com.jdojo.util包,这意味着所有公共类型及其公共成员在编译时都是可访问的,并且在运行时可以正常反射。第二条语句在运行时打开同一个包进行深度反射。总之,com.jdojo.util包的所有公共类型及其公共成员在编译时都是可访问的,并且该包允许在运行时进行深度反射。第三条语句将com.jdojo.model.policy包只对hibernate.core模块开放以进行深度反射,这意味着在编译时没有其他模块可以访问这个包的任何类型,而hibernate.core模块可以在运行时使用深度反射访问所有类型及其成员。

Tip

对另一个模块的开放包执行深度反射的模块不需要读取包含开放包的模块。但是,如果您知道模块名,那么允许并强烈建议您添加对带有开放包的模块的依赖,这样模块系统就可以在编译时和运行时验证这种依赖。

当一个模块M打开它的包P用于到另一个模块N的深度反射时,有可能模块N将它在包P上的深度反射访问授权给另一个模块Q。模块N将需要使用模块 API 以编程方式来完成。将反射访问委托给另一个模块可以避免将整个模块向所有其他模块开放;同时,它在被授予反射访问的模块部分创建了额外的工作。

跨模块拆分包

不允许将包拆分成多个模块*。也就是说,不能在多个模块中定义同一个包。如果同一个包中的类型在多个模块中,那么这些模块应该合并到一个模块中,或者您需要重命名包。有时,您可以成功编译这些模块,但会收到一个运行时错误;其他时候,您会收到编译时错误。正如我在开始提到的,拆分包并不是无条件禁止的。你需要知道这种错误背后的简单规则。*

*如果两个名为MN的模块定义了同一个名为P的包,那么MN模块中的包P就不能被Q访问。换句话说,多个模块中的同一个包不能同时被一个模块读取。否则,会发生错误。如果一个模块正在使用包P中的类型T,而该类型在两个模块中都存在,则模块系统无法决定是否使用这两个模块之一中的P.T。它会生成一个错误,并希望您修复该问题。考虑以下代码片段:

// Test.java
package java.util;
public class Test {
}

JDK 中的java.base模块包含一个java.util包,该包可供所有模块使用。如果你将 JDK 17 中的Test类作为一个模块的一部分或者单独编译,你会收到下面的错误:

error: package exists in another module: java.base
package java.util;
^
1 error

如果你在一个名为M的模块中有这个类,编译时错误会指出这个模块和java.base模块中的java.util包可以被模块M读取。您必须将这个Test类的包从java.util更改为其他东西,比如说com.jdojo.util,它不存在于任何可观察的模块中。

模块声明中的限制

声明模块有几个限制。如果违反了这些规则,您将在编译时或启动时得到错误:

  • 模块图不能包含循环依赖关系。也就是说,两个模块不能相互读取。如果有,它们应该是一个模块,而不是两个。请注意,通过以编程方式添加可读性边缘或使用命令行选项,可以在运行时拥有循环依赖关系。

  • 模块声明不支持模块版本。您需要使用jar工具或其他一些工具,比如javac,将模块的版本添加为类文件属性。

  • 模块系统没有子模块的概念。即jdojo.personjdojo.person.client是两个独立的模块;第二个不是第一个的子模块。

模块类型

Java 已经存在 20 多年了;新老应用程序都将继续使用没有模块化或永远不会模块化的库。如果 JDK 9 强迫每个人模块化他们的应用程序,JDK 9 可能不会被大多数人采用。JDK 9 的设计者考虑到了向后兼容性。您可以采用 JDK 9 及更高版本,方法是按照您自己的节奏模块化您的应用程序,或者决定根本不模块化—只运行您现有的应用程序。在大多数情况下,在 JDK 8 或更早版本中工作的应用程序将继续在 JDK 9 或更高版本中工作,而不做任何更改。为了简化迁移,JDK 定义了四种类型的模块:

  • 正规模

  • 开放模块

  • 自动模块

  • 未命名模块

事实上,你会遇到六个术语来描述六种不同类型的模块,对于一个 JDK 9 的初学者来说,这些术语很容易混淆。其他两种类型的模块用于传达这四种类型模块的更广泛的类别。图 10-8 显示了所有类型模块的示意图。

img/323069_3_En_10_Fig8_HTML.png

图 10-8

模块类型

在我描述模块的主要类型之前,我给你图 10-8 所示模块类型的简要定义。

  • 模块是代码和数据的集合。

  • 根据模块是否有名字,模块可以是命名模块未命名模块。没有未命名模块的进一步分类。

  • 当一个模块有一个名字时,这个名字可以在模块声明中显式给出,也可以自动(或隐式)生成。如果在模块声明中显式地给出了这个名字,它就被称为显式模块。如果这个名字是由模块系统通过读取模块路径上的 JAR 文件名生成的,它就被称为一个自动模块

  • 如果你声明一个没有使用open修饰符的模块,它被称为普通模块

  • 如果你使用open修饰符声明一个模块,它被称为开放模块

基于这些定义,开放模块也是显式模块和命名模块。自动模块是命名模块,因为它有一个自动生成的名称,但它不是显式模块,因为它是由模块系统在编译时和运行时隐式声明的。以下小节描述了这些模块类型。

Tip

如果 Java 平台最初是用模块系统设计的,那么您将只有一种模块类型——普通模块!所有其他模块类型的存在是为了向后兼容以及模块的平滑迁移和采用。

正规模

使用模块声明而不使用open修饰符显式声明的模块总是有一个名字,它被称为普通模块或简称为模块。到目前为止,您主要使用的是普通模块。我一直把普通模块称为模块,并且我继续在这个意义上使用这个术语,除非我需要区分这四种类型的模块。默认情况下,普通模块中的所有类型都被封装。普通模块的示例如下:

module a.normal.module {
    // Module statements go here
}

开放模块

如果一个模块声明包含了open修饰符,那么这个模块就是开放模块。开放模块的示例如下:

open module an.open.module {
    // Module statements go here
}

自动模块

为了向后兼容,用于查找类型的类路径机制在 JDK 9 中仍然有效。您可以选择将 jar 放在类路径、模块路径以及两者的组合上。请注意,您可以将模块化 jar 以及 jar 放在模块路径和类路径上。

当您将一个 JAR 放在模块路径上时,这个 JAR 被视为一个模块,它被称为一个自动模块。名称自动模块来源于这样一个事实,即该模块是从一个 JAR 中自动定义的——您不需要通过添加一个module-info.class文件来显式声明该模块。自动模块有一个名称。自动模块的名称是什么?它读取什么模块,导出什么包?我将很快回答这些问题。

自动模块的存在仅仅是为了将现有的 Java 应用程序移植到模块系统。通过将现有的 jar 放在模块路径上,它们允许您将它们作为模块使用。然而,它们是不可靠的,因为当 jar 的作者将它们转换成模块化 jar 时,他们可能会选择给它们不同于自动派生的模块名。当作者将 jar 转换为模块化 jar 时,自动模块中导出的包也可能改变。当您在应用程序中使用自动模块时,请记住这些风险。

为了防止自动模块的模块名改变,作者可以在他们将 JAR 转换成模块化 JAR 之前建议一个模块名。您可以在 JAR 的MANIFEST.MF文件中使用建议的模块名,将其指定为自动模块名。您可以在 JAR 中的MANIFEST.MF文件的主要部分指定一个自动模块名作为属性“Automatic-Module-Name”的值。

自动模块也是命名模块。假设您想使用一个 JAR com.jdojo.intro-1.0作为自动模块。它的名称和版本是使用以下规则从 JAR 文件的名称中派生出来的:

  • 如果 JAR 文件在其MANIFEST.MF文件的主要部分中有属性“Automatic-Module-Name ”,那么该属性的值就是模块名。模块名也可以通过下面的步骤从 JAR 文件的名称中获得。

  • JAR 文件的扩展名.jar被删除。这一步删除了.jar扩展名,接下来的步骤使用com.jdojo.intro-1.0来导出模块的名称及其版本。

  • 如果名称以连字符结尾,后跟至少一个数字(可以选择后跟一个点),则模块名称源自最后一个连字符之前的名称部分。如果连字符后面的部分可以被解析为有效版本,则该部分被指定为模块的版本;否则,这部分被忽略。在我们的例子中,模块名将来自于com.jdojo.intro。版本将衍生为 1.0。

  • 对于模块名称,所有尾随的数字和点都被删除。在我们的例子中,模块名的剩余部分com.jdojo.intro不包含任何尾随数字和点。所以这一步不会改变任何事情。

  • 名称部分中的每个非字母数字字符都被替换为一个点;并且,在得到的字符串中,两个连续的点被一个点代替;并且所有的前导点和尾随点都被移除。在我们的例子中,名称部分没有任何非字母数字字符,所以模块名是com.jdojo.intro

按顺序应用这些规则会给你一个模块名和一个模块版本。在本节的最后,我们将向您展示如何通过 JAR 文件来知道自动模块的名称。表 10-1 列出了几个 JAR 名和它们派生的自动模块名。注意,该表没有显示 JAR 文件名中的扩展名.jar,并且假设在 JAR 文件的MANIFEST.MF的主部分中没有指定“Automatic-Module-Name”属性。

表 10-1

从 JAR 文件名派生自动模块名称的例子

|

罐子名称

|

模块名

|

模块版本

com.jdojo.intro-1.0 com.jdojo.intro 1.0
junit-4.10.jar Junit 4.10
jdojo-logging1.5.0 N/A
spring-core-4.0.1.RELEASE spring.core 4.0.1.RELEASE
jdojo-trans-api_1.5_spec-1.0.0 N/A N/A
_ N/A N/A

让我们看一下表中的三种奇怪情况,如果您将 jar 放在模块路径上,您将会收到一个错误。第一个 JAR 名是jdojo-logging1.5.0。应用所有的规则,派生的模块名是jdojo.logging1.5.0,这是一个无效的模块名。回想一下,模块名是一个合格的 Java 标识符。也就是说,模块名中的每一部分都必须是有效的 Java 标识符。在这种情况下,名称的两部分“5”和“0”不是有效的 Java 标识符。在模块路径上使用这个 JAR 将会产生一个错误,除非您使用清单文件中的“Automatic-Module-Name”属性指定一个有效的模块名。

第二个给出错误的 JAR 名是jdojo-trans-api_1.5_spec-1.0.0。让我们应用规则来导出自动模块名:

  • 它找到最后一个连字符,在这个连字符后面只有数字和点,并将 JAR 名称分成两部分:jdojo-trans-api_1.5_spec1.0.0。第一部分用于派生模块名。第二部分是模块版本。

  • 名称部分不包含任何尾随数字和点。因此,应用下一个规则,将所有非字母数字字符转换为点。得到的字符串是jdojo.trans.api.1.5.spec。现在,“1”和“5”是模块名中的两个部分,它们不是有效的 Java 标识符。所以派生的模块名是无效的,这就是当您将这个 JAR 文件添加到模块路径时出现错误的原因。

第三个 JAR 名称是表中的最后一个条目,是一个下划线(_)。即 JAR 文件被命名为_.jar。如果您应用这些规则,下划线将被一个点代替,该点将被删除,留下一个空字符串,这不是一个有效的模块名。模块路径上的_.jar文件会导致如下异常:

java.lang.module.ResolutionException: Unable to derive module descriptor for: _.jar

您可以使用带有–-describe-module选项的jar命令来了解将从 JAR 派生的自动模块的名称。一般语法如下:

jar --describe-module --file <your-JAR-file-path>

下面的命令打印名为jdojo.util-2.2.jar的 JAR 的自动模块名,假设 JAR 存在于C:\JavaFun目录中:

c:\JavaFun\jars>jar --describe-module --file jdojo.util-2.2.jar
No module descriptor found. Derived automatic module.
jdojo.util@2.2 automatic
requires java.base mandated
contains com.jdojo.person

输出中的第一行表明jdojo.util-2.2.jar是一个 JAR,而不是一个模块化 JAR。如果它是一个模块化的 JAR,模块名将从module-info.class文件中读取。第一行表示没有找到模块描述符。第二行打印模块名jdojo.util和模块版本2.2。在第二行的末尾,打印出单词automatic,表示这个模块名是作为自动模块名派生出来的。输出中的第三行和第四行打印自动模块的依赖和包信息。

您可以使用jar命令来更新清单条目。我们将向您展示如何向 JAR 添加“Automatic-Module-Name”属性。我们在这个例子中使用了jdojo.util-2.2.jar。您需要创建一个文本文件并添加 manifest 属性。清单 10-10 显示了名为manifest.txt的清单文件的内容。该文件包含两行。第一行指定了一个名为“Automatic-Module-Name”的属性,其值为jdojo.misc。第二行是一个你看不到的空行。确保在这个文件中有一个空行。否则,下一个命令将不起作用。

Automatic-Module-Name: jdojo.misc

Listing 10-10Contents of a manifest.txt File

下面的命令将更新jdojo.util-2.2.jar文件中的清单文件,假设 JAR 文件和manifest.txt文件都放在同一个目录下,C:\JavaFun:

c:\JavaFun\jars>jar --update --manifest manifest.txt --file jdojo.util-2.2.jar

如果您描述jdojo.util-2.2.jar文件来查看派生的自动模块名,那么将从其清单文件的“Automatic-Module-Name”属性中读取模块名。让我们重新运行前面的命令来描述模块:

c:\JavaFun\jars>jar --describe-module --file jdojo.util-2.2.jar
No module descriptor found. Derived automatic module.
jdojo.misc@2.2 automatic
requires java.base mandated
contains com.jdojo.person

一旦你知道了一个自动模块的名字,其他显式模块可以使用requires语句读取它。下面的模块声明读取来自模块路径上的jdojo.util-2.2.jar的名为jdojo.misc的自动模块,假设自动模块名来自 JAR 文件名:

module jdojo.lib {
    requires jdojo.util;
    //...
}

要有效地使用一个自动模块,它必须导出包并读取其他模块。让我们看看这方面的规则:

  • 自动模块读取所有其他模块。重要的是要注意,在模块图被解析之后,从一个自动模块到所有其他模块的可读性被增加了。

  • 自动模块中的所有包装都被导出并打开。

这两个规则基于这样一个事实,即没有实际可行的方法来判断一个自动模块依赖于哪些其他模块,以及其他模块将需要编译该自动模块的哪些包或进行深度反射。

自动模块读取所有其他模块可能会产生循环依赖,这在模块图被解析后是允许的。回想一下,在模块图解析期间,模块之间的循环依赖是不允许的。也就是说,在模块声明中不能有循环依赖。

自动模块没有模块声明,因此它们不能声明对其他模块的依赖。显式模块可以声明对其他自动模块的依赖。考虑一种情况,一个显式模块M读取一个自动模块P,而模块P使用另一个自动模块Q中的类型T。当您使用模块M中的主类启动应用程序时,模块图将只包含MP——为了简洁起见,在此讨论中不包括java.base模块。解析过程将从模块M开始,并将看到它读取另一个模块P。解析过程没有实际可行的方法来判断模块P读取模块Q。通过将模块PQ放在类路径上,您将能够编译它们。然而,当您运行这个应用程序时,您将收到一个ClassNotFoundException。当模块P试图从模块Q中访问一个类型时,异常发生。要解决这个问题,模块Q必须包含在模块图中,方法是使用--add-modules命令行选项将其添加为根模块,并将Q指定为该选项的值。

未命名模块

您可以将 jar 和模块化 jar 放在类路径上。当一个类型正在被加载,而它的包在任何已知的模块中都找不到时,模块系统会尝试从类路径加载该类型。如果在类路径上找到该类型,它将被类加载器加载,并成为该类加载器的一个名为未命名模块的模块的成员。每个类装入器都定义一个未命名的模块,其成员都是它从类路径中装入的类型。未命名模块没有名字,所以显式模块不能使用requires语句声明对它的依赖。如果有一个显式模块需要使用未命名模块中的类型,则必须通过将 JAR 放在模块路径上,将未命名模块的 JAR 用作自动模块。

试图在编译时从显式模块中访问未命名模块中的类型是一个常见的错误。这是不可能的,因为未命名的模块没有名字,而显式模块需要一个模块名才能在编译时读取另一个模块。自动模块作为显式模块和未命名模块之间的桥梁,如图 10-9 所示。显式模块可以使用requires语句访问自动模块,自动模块可以访问未命名模块。

img/323069_3_En_10_Fig9_HTML.png

图 10-9

一种自动模块,充当显式模块和未命名模块之间的桥梁

未命名模块没有名称。这并不意味着未命名模块的名称是一个空字符串,“未命名”或null。下面的模块声明试图声明对未命名模块的依赖,这是无效的:

module some.module {
    requires "";        // A compile-time error
    requires "unnamed"; // A compile-time error
    requires unnamed;   // A compile-time error, unless a named module named unnamed exists
    requires null;      // A compile-time error
}

未命名模块读取其他模块,并使用以下规则向其他模块导出和打开其所有包:

  • 未命名的模块读取所有其他模块。因此,未命名模块可以访问所有模块(包括平台模块)中所有导出包的公共类型。该规则使得使用在 Java SE 8 中编译和运行的类路径的应用程序可以继续在 Java SE 9 中编译和运行,前提是它们只使用标准的、未被弃用的 Java SE APIs。

  • 未命名的模块向所有其他模块开放其所有包。因此,显式模块可以在运行时使用反射来访问未命名模块中的类型。

  • 未命名的模块导出它的所有包。显式模块不能在编译时读取未命名的模块。在模块图被解析后,所有的自动模块都被用来读取未命名的模块。

    提示一个未命名的模块可能包含一个由命名模块导出的包。在这种情况下,未命名模块中的包将被忽略。

聚合器模块

您可以创建一个不包含自己代码的模块。它收集并重新导出其他模块的内容。这样的模块被称为聚合器模块。假设有几个模块依赖于五个模块。您可以为这五个模块创建一个聚合器模块,现在,您的模块只能依赖于一个模块—聚合器模块。聚合器模块并不是与前面章节所解释的不同的模块类型。它是一个命名模块。它有一个特殊的名字,“聚合器”,因为它没有自己的内容。相反,它将其他几个模块的内容以不同的名称组合成一个模块。

一个聚合器模块只包含一个类文件,那就是module-info.class。聚合器模块的模块声明由所有的"requires transitive <module>"语句组成。下面是一个聚合器模块声明的示例。聚合器模块名为jdojo.all,它聚合了三个模块——jdojo.policyjdojo.claimjdojo.payment:

module jdojo.all {
    requires transitive jdojo.policy;
    requires transitive jdojo.claim;
    requires transitive jdojo.payment;
}

聚合器模块的存在是为了方便。从版本 9 开始,Java 包含了几个聚合器模块,比如java.sejava.se.eejava.se模块收集了 Java SE 中不与 Java EE 重叠的部分。java.se.ee模块集合了构成 Java SE 的所有模块,包括与 Java EE 重叠的模块。

了解运行时的模块

Java SE 17 提供了一组类和接口来以编程方式处理模块。它们统称为模块 API 。模块 API 允许您查询和修改模块信息。在本节中,我们将快速预览模块 API。

JVM 中加载的每个类型都由一个java.lang.Class<T>类的实例表示。也就是说,Class<T>类的一个实例在运行时表示类型T。您可以使用该类的对象的getClass()方法来获取类型的引用。假设存在一个Person类,下面的代码片段获得了对Person类的引用:

Person p = new Person();
Class<Person> cls = p.getClass();

还可以使用类文本来获取类的引用。类文字是后面跟有一个“.class”的类的名称。例如,您可以使用类文字Person.class来获取Person类的引用。您可以重写前面的代码片段,如下所示:

Class<Person> cls = Person.class;

在运行时,每种类型都作为模块的成员加载。如果该类型是从类路径加载的,则它是加载该类型的类加载器的未命名模块的成员。如果该类型是从模块路径加载的,则它是命名模块的成员。java.lang.Module类的一个实例表示运行时的一个模块。Class类包含一个getModule()方法,该方法返回一个代表该类型模块的Module。下面的代码片段获取了对Person类所属的Module对象的引用:

Class<Person> cls = Person.class;
Module m = cls.getModule();

Module类包含几个方法,让您查询模块在编译时的声明状态和运行时的实际状态。请注意,模块状态可以从源代码中声明的方式进行更改。模块 API 中的其他类和接口在java.lang.module包中。例如,java.lang.module包中的ModuleDescriptor类的一个实例,代表了在源文件中为显式模块声明的模块描述符,以及为自动模块合成的模块描述符。您可以将Module类的getDescriptor()方法用于ModuleDescriptor类的实例。未命名的模块没有模块描述符,所以getDescriptor()方法为未命名的模块返回null。可以使用Module类的getName()方法来获取模块的名称;该方法为未命名的模块返回null

清单 10-11 包含了一个jdojo.mod模块的声明。清单 10-12 包含了一个ModuleInfo类的代码,它打印了它所属模块的信息。

// ModuleInfo.java
package com.jdojo.mod;
import java.lang.module.ModuleDescriptor;
public class ModuleInfo {
    public static void main(String[] args) {
        // Get the class reference
        Class<ModuleInfo> cls = ModuleInfo.class;
        // Get the module reference
        Module m = cls.getModule();
        if (m.isNamed()) {
            // It is a named module
            // Get the module name
            String name = m.getName();
            // Get the module descriptor
            ModuleDescriptor md = m.getDescriptor();
            // Print the module details
            System.out.println("Module Name: " + name);
            System.out.println("Module is open: " + md.isOpen());
            System.out.println("Module is automatic: " + md.isAutomatic());
        } else {
            // It is an unnamed module
            System.out.println("Unnamed module.");
        }
    }
}

Listing 10-12A ModuleInfo Class

// module-info.java
module jdojo.mod {
    exports com.jdojo.mod;
}

Listing 10-11The Module Declaration for the jdojo.mod Module

下面的命令通过将jdojo.mod模块的模块化 JAR 放在模块路径上来运行ModuleInfo类。输出清楚地显示了正确的模块信息:

C:\JavaFun>java --module-path dist\jdojo.mod.jar --module jdojo.mod/com.jdojo.mod.ModuleInfo
Module Name: jdojo.mod
Module is open: false
Module is automatic: false

下面的命令通过将jdojo.mod模块的模块化 JAR 放在类路径上来运行ModuleInfo类。这一次,类是从类路径加载的,它成为加载它的类加载器的未命名模块的成员:

C:\JavaFun>java --class-path dist\jdojo.mod.jar com.jdojo.mod.ModuleInfo
Unnamed module.

模块的迁移路径

如果您是第一次学习模块,可以跳过这一部分。当您必须迁移现有的 Java 应用程序以使用模块时,您可以重新访问。

当您将应用程序迁移到模块时,您应该记住模块系统提供的两个好处:强大的封装和可靠的配置。你的目标是拥有一个完全由普通模块组成的应用程序,除了一些打开的模块。似乎有人可以给你一个清晰的清单,列出将现有应用程序移植到模块时需要执行的步骤。然而,考虑到应用程序的多样性、它们与其他代码的相互依赖性以及不同的配置需求,这是不可能的。我们所能做的就是列出一些通用的指导方针来帮助您完成迁移,这也是本节所要做的。

一个重要的 Java 应用程序通常由位于三层的几个 jar 组成:

  • 应用程序开发人员开发的应用层中的应用程序 jar

  • 第三方提供的库层中的库 jar

  • JVM 层中的 Java 运行时 jar

Java 已经通过将 Java 运行时 jar 转换成模块,将它们模块化了。也就是说,Java 运行时由模块组成,并且只由模块组成。

库层主要由放置在类路径上的第三方 jar 组成。如果您想迁移您的应用程序以使用模块,您可能得不到第三方 jar 的模块化版本。您也无法控制供应商如何将第三方 jar 转换成模块。您可以将库 jar 放在模块路径上,并将其视为自动模块。

您可以选择完全模块化您的应用程序代码。以下是您可以选择的模块类型,从最不理想到最理想:

  • 未命名模块

  • 自动模块

  • 开放模块

  • 正规模

迁移的第一步是通过将所有的 jar(应用程序 jar 和库 jar)放在类路径上来检查您的应用程序是否在 JDK 17 中运行,而无需对您的代码进行任何修改。类路径上 jar 中的所有类型都将是未命名模块的一部分。您的应用程序在这种状态下使用 JDK 17,没有任何强大的封装和可靠的配置。

一旦您的应用程序在 JDK 17 中运行,您就可以开始将应用程序代码转换成自动模块。自动模块中的所有包都是开放的,用于深度反射访问,并被导出,用于对其公共类型的普通编译时和运行时访问。从这个意义上说,它并不比未命名的模块更好;它没有为您提供强大的封装。但是,自动模块为您提供了可靠的配置,因为其他显式模块可以声明对自动模块的依赖。

您还有另一种选择,将您的应用程序代码转换成开放模块,这提供了适度的更强的封装:在开放模块中,所有的包都是开放的,用于深度反射访问,但是您可以指定导出哪些包(如果有的话),用于普通的编译时和运行时访问。显式模块还可以声明对开放式模块的依赖,从而为您带来可靠配置的好处。

普通模块提供最强的封装,允许您选择打开、导出或同时打开和导出哪些包。显式模块也可以声明对普通模块的依赖。

表 10-2 包含了模块类型的列表,以及它们提供的强大封装和可靠配置的程度。

表 10-2

模块类型和不同程度的强大封装以及它们提供的可靠配置

|

模块类型

|

强封装

|

可靠的配置

未命名的
自动的 适度的
打开 适度的
标准 最强壮的 最强壮的

Java 类依赖分析器

为了帮助确定在转换项目以使用开放或普通模块时需要什么模块,您可以使用 Java 类依赖分析器,简称 jdeps。它是一个命令行工具,用于分析现有 JAR 文件和模块的依赖关系。

例如,假设您有一个名为 aopalliance-1.0.jar 的 JAR 文件,您可以对它运行 jdeps,如下所示:

$ jdeps aopalliance-1.0.jar
aopalliance-1.0.jar -> java.base
   org.aopalliance.aop             -> java.io                       java.base
   org.aopalliance.aop             -> java.lang                     java.base
   org.aopalliance.intercept       -> java.lang                     java.base
   org.aopalliance.intercept       -> java.lang.reflect             java.base
   org.aopalliance.intercept       -> org.aopalliance.aop           aopalliance-1.0.jar

这基本上显示了所有依赖的包以及它们包含在哪个模块中。这个 JAR 只依赖于 java.base 模块。

它还可以用来分析相反的情况—哪些模块依赖于给定的模块,例如:

$ jdeps --inverse --require java.sql
Inverse transitive dependences on [java.sql]
java.sql <- java.se
java.sql <- java.sql.rowset <- java.se

反汇编模块定义

在这一节中,我们将解释 JDK 附带的javap工具,它可以用来反汇编类文件。这个工具对于学习模块系统非常有用,尤其是反编译模块的描述符。

我们将在提供的源代码中使用来自JavaFun目录的代码。我们假设您已经在C:\JavaFun目录中提取了它。如果不同,请在下面的示例中用您的路径替换此路径。

在第三章中,您有两个jdojo.intro模块的module-info.class文件副本:一个在mod\jdojo.intro目录中,另一个在lib\com.jdojo.intro.jar文件的模块化 JAR 中。当您将模块的代码打包到 JAR 中时,您已经为模块指定了一个版本和一个主类。这些信息去了哪里?它们作为类属性被添加到module-info.class文件中。所以两个module-info.class文件的内容是不一样的。你怎么证明?首先在两个module-info.class文件中打印模块声明。您可以使用位于JDK_HOME\bin目录中的javap工具来反汇编任何类文件中的代码。您可以指定要反汇编的文件名、URL 或类名。以下命令打印模块声明:

C:\JavaFun>javap mod\jdojo.intro\module-info.class
Compiled from "module-info.java"
module jdojo.intro {
  requires java.base;
}
C:\JavaFun>javap jar:file:lib/com.jdojo.intro.jar!/module-info.class
Compiled from "module-info.java"
module jdojo.intro {
  requires java.base;
}

第一个命令使用一个文件名,第二个命令使用一个使用jar方案的 URL。这两个命令都使用相对路径。如果您愿意,可以使用绝对路径。

输出表明两个module-info.class文件包含相同的模块声明。您需要使用–verbose选项(或–v选项)打印类别信息,以查看类别属性。下面的命令打印出mod目录中的module-info.class文件信息,显示模块版本和主类名不存在。显示了部分输出:

C:\JavaFun>javap -verbose mod\jdojo.intro\module-info.class
Classfile /C:/JavaFun/mod/jdojo.intro/module-info.class
  Last modified Jul 23, 2021; size 154 bytes
  MD5 checksum 2e4a3e6b8b8b03c92fdede9a5784b1d7
  Compiled from "module-info.java"
module jdojo.intro
...

下面的命令打印来自lib\com.jdojo.intro.jar文件的module-info.class文件信息,并显示模块版本和主类名确实存在。显示了部分输出。输出中的相关行以粗体显示:

C:\JavaFun>javap -verbose jar:file:lib/com.jdojo.intro.jar!/module-info.class
Classfile jar:file:lib/com.jdojo.intro.jar!/module-info.class
  Last modified Jul 24, 2021; size 263 bytes
  MD5 checksum 60f5f169a580f02fa8085fd36e50c0e5
  Compiled from "module-info.java"
module jdojo.intro@1.0
...
   #8 = Utf8               ModuleMainClass
   #9 = Utf8               com/jdojo/intro/Welcome
  #10 = Class              #9             // com/jdojo/intro/Welcome
...
  #14 = Utf8               1.0
  ...
ModulePackages:
  #7                                      // com.jdojo.intro
ModuleMainClass: #10                      // com.jdojo.intro.Welcome
Module:
  #13,0                                   // "jdojo.intro"
  #14                                     // 1.0
 ...

您也可以在模块中反汇编类的代码。您需要指定模块路径、模块名称和类的完全限定名。以下命令从模块化 JAR 中打印出com.jdojo.intro.Welcome类的代码:

C:\JavaFun>javap --module-path lib --module jdojo.intro com.jdojo.intro.Welcome
Compiled from "Welcome.java"
public class com.jdojo.intro.Welcome {
  public com.jdojo.intro.Welcome();
  public static void main(java.lang.String[]);
}

您还可以打印系统分类的分类信息。以下命令打印来自java.base模块的java.lang.Object类的类信息。请注意,在打印系统类信息时,不需要指定模块路径:

C:\JavaFun>javap --module java.base java.lang.Object
Compiled from "Object.java"
public class java.lang.Object {
  public java.lang.Object();
  public final native java.lang.Class<?> getClass();
  public native int hashCode();
  public boolean equals(java.lang.Object);
  ...
}

如何打印系统模块的模块声明,比如java.basejava.sql?回想一下,系统模块是以一种叫做 JIMAGE 的特殊文件格式打包的,而不是模块化的 jar。JDK 9 引入了一个新的 URL 方案,称为jrt ( jrt是 Java 运行时的缩写),用来引用 Java 运行时映像(或系统模块)的内容。使用jrt方案的语法是

jrt:/<module>/<path-to-a-file>

以下命令打印名为java.sql的系统模块的模块声明:

C:\JavaFun>javap jrt:/java.sql/module-info.class
Compiled from "module-info.java"
module java.sql@17 {
  requires transitive java.logging;
  requires transitive java.xml;
  requires java.base;
  exports javax.transaction.xa;
  exports javax.sql;
  exports java.sql;
  uses java.sql.Driver;
}

以下命令打印java.se的模块声明,它是一个聚合器模块:

C:\JavaFun>javap jrt:/java.se/module-info.class
Compiled from "module-info.java"
module java.se@17 {
  requires transitive java.naming;
  requires transitive java.instrument;
  requires transitive java.compiler;
  requires transitive java.sql.rowset;
  requires transitive java.logging;
  requires transitive java.management.rmi;
  requires transitive java.desktop;
  requires transitive java.rmi;
  requires transitive java.datatransfer;
  requires transitive java.prefs;
  requires transitive java.xml.crypto;
  requires transitive java.sql;
  requires transitive java.xml;
  requires transitive java.security.sasl;
  requires transitive java.scripting;
  requires transitive java.management;
  requires java.base;
  requires transitive java.security.jgss;
}

您也可以使用jrt方案来引用一个系统类。以下命令打印java.base模块中java.lang.Object类的类信息:

C:\JavaFun>javap jrt:/java.base/java/lang/Object.class
Compiled from "Object.java"
public class java.lang.Object {
  public java.lang.Object();
  public final native java.lang.Class<?> getClass();
  public native int hashCode();
  public boolean equals(java.lang.Object);
  ...
}

摘要

简单来说,一个模块就是一组包。一个模块可以有选择地包含诸如图像、属性文件等资源。如果一个模块需要使用另一个模块中包含的公共类型,第二个模块需要导出包含这些类型的包,第一个模块需要读取第二个模块。

模块使用exports语句导出它的包。模块只能将其包导出到一组命名模块或所有其他模块。在编译时和运行时,导出包中的公共类型可供其他模块使用。导出的包不允许对公共类型的非公共成员进行深度反射。

如果一个模块希望允许其他模块使用反射访问所有类型的成员——公共的和非公共的——那么该模块必须声明为开放模块,或者该模块可以使用opens语句有选择地打开包。从打开的包中访问类型的模块不需要读取包含那些打开的包的模块。

一个模块使用requires语句声明了对另一个模块的依赖。使用transitive修饰符可以声明这种依赖是可传递的。如果模块M声明了对模块N的传递依赖,那么任何声明了对模块M的依赖的模块都声明了对模块N的隐式依赖。

通过在requires语句中使用static修饰符,可以在编译时将依赖声明为强制的,但在运行时声明为可选的。依赖关系在运行时可以是可选的,同时也是可传递的。

根据模块是如何声明的以及它是否有名字,模块有几种类型。根据模块是否有名字,模块可以是命名的模块未命名的模块。当一个模块有一个名字时,这个名字可以在模块声明中显式给出,也可以自动(或隐式)生成。如果在模块声明中显式地给出了这个名字,它就被称为显式模块。如果在 JAR 清单的“Automatic-Module-Name”属性中指定了该名称,或者该名称是由模块系统通过读取模块路径上的 JAR 文件名生成的,则该名称被称为自动模块。如果你声明一个没有使用open修饰符的模块,它被称为普通模块。如果你使用open修饰符声明一个模块,它被称为开放模块。基于这些定义,开放模块也是显式模块和命名模块。自动模块是命名模块,因为它有一个自动生成的名称,但它不是显式模块,因为它是由模块系统在编译时和运行时隐式声明的。

当您在模块路径上放置一个 JAR(不是模块化 JAR)时,JAR 表示一个自动模块,其名称在 JAR 清单的“Automatic-Module-Name”属性中指定,或者从 JAR 文件名中派生。自动模块读取所有其他模块,并且它的所有包都被导出和打开。

在 JDK 9+中,类装入器可以从模块或类路径装入类。每个类装入器都维护一个名为未命名模块的模块,该模块包含它从类路径装入的所有类型。未命名的模块读取所有其他模块。它向所有其他模块导出并打开它的所有包。未命名模块没有名称,因此显式模块不能声明对未命名模块的编译时依赖。如果显式模块需要访问未命名模块中的类型,前者可以使用自动模块作为桥梁,或者使用反射。

您可以创建一个不包含自己代码的模块。它收集并重新导出其他模块的内容。这样的模块被称为聚合器 模块。一个聚合器模块只包含一个类文件,那就是module-info.class。聚合器模块的模块声明由所有的requires transitive <module>语句组成。

不允许将包分割成多个模块*。也就是说,不能在多个模块中定义同一个包。如果两个名为MN的模块定义了同一个名为P的包,那么MN模块中的包P就不能被Q访问。换句话说,多个模块中的同一个包不能同时被一个模块读取。否则,会发生错误。*

*Java 9 提供了一组在运行时与模块一起工作的类和接口。它们统称为模块 API。模块 API 允许您查询模块信息并在运行时修改它。在运行时,模块被表示为java.lang.Module类的一个实例。你可以使用java.lang.Class<T>类的getModule()方法来获取一个类型的模块的引用。

您可以使用javap工具来打印模块声明或属性。使用工具的-verbose(或-v)选项打印模块描述符的类属性。JDK 以特殊的格式存储运行时映像。JDK 9 引入了一个叫做jrt的新文件模式,你可以用它来访问运行时映像的内容。它的语法是jrt:/<module>/<path-to-a-file>

EXERCISES

  1. 什么是模块?

  2. 你用什么关键字来声明一个模块?

  3. 指定模块名的规则是什么?以下哪些模块名称是有效的?

    jdojo.dashboard
    $jdojo.$dashboard
    jdojo.policy.1.0
    javaFundamentals
    
    
  4. 列出仅在模块声明中的特定位置使用时才被视为关键字的所有受限关键字。

  5. 您使用什么模块语句将包导出到所有其他模块或一组命名模块?

  6. Consider the following declaration for a module named jdojo.core:

    module jdojo.core {
        exports com.jdojo.core to jdojo.ext, jdojo.util;
    }
    
    

    解释这个模块声明中exports语句的作用。在编译jdojo.core模块时,jdojo.extjdojo.util这两个模块必须存在吗?

  7. 你用什么模块语句来表达一个模块对另一个模块的依赖?什么是传递依赖,使用传递依赖有什么好处?

  8. Consider the following declaration for a module named jdojo.ext:

    module jdojo.ext {
        requires jdojo.core;
    }
    
    

    jdojo.ext读取的是哪两个模块?

  9. 如何表达一个模块对另一个模块的依赖,这个模块在编译时是强制的,但在运行时是可选的?

  10. 什么是开放模块?你什么时候使用开放模块?

  11. 一个开放的模块和选择性的打开一个模块的包有什么区别?为什么不能在打开的模块内部使用opens语句?

  12. Consider the following declaration for a module named jdojo.misc:

```java
module jdojo.misc {
    opens com.jdojo.misc;
    exports com.jdojo.misc;
}

```

这个模块声明有效吗?如果有效,解释打开和导出模块的同一个包的效果。
  1. 你能有两个包含相同包的模块吗?描述禁止两个模块拥有相同包的确切规则。

  2. 什么是自动模块?描述两种指定或导出自动模块名称的方法。

  3. 什么是未命名模块?如果你把一个模块化的 JAR 放在类路径上,那么这个模块化 JAR 中的所有类型都是未命名模块的成员吗?

  4. 什么是聚合器模块?举出 JDK 9 中的一个聚合器模块。

  5. 运行时表示模块的类的完全限定类名是什么?

  6. 如何在运行时获取一个类所属模块的引用?

  7. Consider the following snippet of code assuming that a Person class exists:

```java
Person john = new Person();
String moduleName = john./* Complete the code */;
System.out.println("Module name of Person class is " + moduleName);

```

用您的代码替换第二行中的注释,完成这段代码。这个代码片段应该打印出模块的名称,如果`Person`类是未命名模块的成员,则打印出`null`类的成员。
  1. 使用jarjava工具描述一个模块时,你选择了哪个选项?

  2. 如果给你一个包含模块声明的编译代码的module-info.class文件,你将如何获得模块的源代码?换句话说,你用什么工具反汇编一个类文件,也可以是一个module-info.class文件?

  3. JDK 模块以一种叫做 JIMAGE 的内部格式存储。JDK 9 引入的访问 JDK 模块的类文件和资源的新方案的名称是什么?

  4. 使用javap命令打印java.sql模块的声明,它是一个 JDK 模块。**

十一、对象和Object

在本章中,您将学习:

  • Java 中的层次类结构

  • Object类是所有其他类的超类

  • 如何用详细的例子使用Object类的方法

  • 如何在你的类中重新实现Object类的方法

  • 如何检查两个对象是否相等

  • 不可变对象和可变对象的区别

  • 如何使用Objects类的实用方法来优雅地处理null

  • lambda 表达式简介

本章中的所有类都是一个jdojo.object模块的成员,如清单 11-1 中所声明的。

// module-info.java
module jdojo.object {
    exports com.jdojo.object;
}

Listing 11-1The Declaration of a jdojo.object Module

Object

Java 在java.lang包中有一个Object类,它是java.base模块的成员。所有的 Java 类,那些包含在 Java 类库中的和那些你创建的,都直接或间接地扩展了Object类。所有 Java 类都是Object类的子类,而Object类是所有类的超类。注意Object类本身没有超类。

Java 中的类被安排在一个树状的层次结构中,其中Object类位于根(或顶部)。我们将在第二十章中详细讨论类的层次结构,其中包括继承。我们在本章中讨论了Object类的一些细节。

关于Object类有两条重要的规则。这里就不解释这些规则背后的原因了。在你阅读了第二十章之后,你会明白为什么你可以用Object类做这些事情的原因。

规则 1

Object类的引用变量可以保存任何类的对象的引用。正如任何引用变量都可以存储一个null引用一样,Object类型的引用变量也可以。考虑下面对Object类型的引用变量obj的声明:

Object obj;

您可以将 Java 中任何对象的引用分配给obj。以下所有语句都是有效的:

// Can assign the null reference
obj = null;
// Can assign a reference of an object of the Object class
obj = new Object();
// Can assign a reference of an object of the Account class
Account act = new Account();
obj = act;
// Can assign a reference to an object of any class. Assume that the AnyClass class exists

obj = new AnyClass();

这条规则的反面是不正确的。不能将Object类对象的引用赋给任何其他类型的引用变量。以下语句无效:

Account act = new Object(); // A compile-time error

有时,你可能会将一个特定类型的对象的引用存储在一个Object类型的引用变量中,然后你想将相同的引用赋回给一个Account类型的引用变量。您可以使用如下所示的强制转换来实现这一点:

Object obj2 = new Account(); Account act = (Account) obj2; // Must use a cast

有时您可能不确定Object类的引用变量是否包含对特定类型对象的引用。在这些情况下,您需要使用instanceof操作符进行测试。instanceof操作符的左操作数是一个引用变量,它的右操作数是一个类名——具体来说,是一个类型名,包括类和接口。如果其左操作数是其右操作数类型的引用,则返回true。否则,它返回false。关于instanceof操作器的更详细讨论,参见第二十章:

Object obj;
Cat c;
/* Do something here and store a reference in obj... */
if (obj instanceof Cat) {
    // If we get here, obj holds a reference of a Cat for sure
    c = (Cat)obj;
}

当您有一个将Object作为参数的方法时,您需要使用这个规则。您可以为Object类的参数传递任何对象的引用。请考虑下面的代码片段,它显示了一个方法声明:

public void m1(Object obj) {
  // Code goes here
}

您可以通过多种不同方式调用m1():

m1(null);           // Pass null reference
m1(new Object());   // Pass a reference of an object of the Object class
m1(new AnyClass()); // Pass a reference of an object of the AnyClass class

规则二

Object类包含九个方法,可以在 Java 的所有类中使用。我们可以把这些方法分为两类:

  • 第一类方法已经在Object类中实现。你应该在它们被实现的时候使用它们。您不能在您创建的任何类中重新实现(重新实现的技术术语是覆盖)这些方法。他们的实现是final。属于这一类别的方法有getClass()notify()notifyAll()wait()

  • 第二类方法在Object类中有一个默认的实现。您可以通过在您的类中重新实现它们来自定义它们的实现。属于这一类别的方法有toString()equals()hashCode()clone()finalize()

Java 程序员必须理解正确使用Object类中的所有方法。我们将详细讨论它们,除了notify()notifyAll()wait()方法。这些方法用于线程同步(这超出了本书的范围,但将在本系列的第二卷中讨论)。表 11-1 列出了Object类中的所有方法,并附有简要描述。“Implemented”列中的“Yes”表示Object类已经实现了该方法,无需编写任何代码即可使用。此列中的“否”表示您需要在使用该方法之前实现它。“可定制”列中的“是”表示您可以在您的类中重新实现该方法来定制它。该列中的“否”表示Object类已经实现了该方法,其实现是final

表 11-1

Object类中的方法

|

方法

|

执行

|

可定制的

|

描述

public String toString() 返回对象的字符串表示形式。通常,它用于调试目的。
public boolean equals(Object obj) 用于比较两个对象是否相等。
public int hashCode() 返回对象的哈希代码(整数)值。
protected Object clone() throws CloneNotSupportedException 用于制作对象的副本。
protected void finalize() throws Throwable 在对象被销毁之前由垃圾收集器调用。它在 Java SE 9 中已被弃用。
public final Class getClass() 返回对对象的Class对象的引用。
public final void notify() 通知对象的等待队列中的一个线程。
public final void notifyAll() 通知对象的等待队列中的所有线程。
public final void wait()``throws InterruptedException``public final void wait(long timeout)``throws InterruptedException``public final void wait(long timeout, int nanos)``throws InterruptedException 使线程在对象的等待队列中等待,超时或不超时。

要重新实现一个Object类的方法,你需要像在Object类中一样声明这个方法,然后在它的主体中编写你自己的代码。重新实现一个方法有更多的规则。我们将在第二十章介绍所有规则。你可以在你的类中重新实现Object类的toString()方法,比如说Test,如图所示:

public class Test {
    /* Reimplement the toString() method of the Object class */
    public String toString() {
        return "Here is a string";
    }
}

本书将在接下来的章节中详细讨论Object类的六个方法。

一个对象的类是什么?

Java 中的每个对象都属于一个类。您在源代码中定义了一个类,它被编译成二进制格式(扩展名为.class的类文件)。在运行时使用一个类之前,它的二进制表示被加载到 JVM 中。将类的二进制表示加载到 JVM 中是由一个称为类加载器的对象来处理的。通常,在一个 Java 应用程序中使用多个类装入器来装入不同类型的类。类加载器是类java.lang.ClassLoader的一个实例。Java 允许你通过扩展ClassLoader类来创建自己的类装入器。通常,您不需要创建自己的类装入器。Java 运行时将使用其内置的类加载器来加载您的类。

类装入器将类定义的二进制格式读入 JVM。二进制类格式可以从任何可访问的位置加载,例如本地文件系统、网络、数据库等。然后,它创建一个java.lang.Class<T>类的对象,这是 JVM 中类型T的二进制表示。注意类名java.lang.Class中的大写C。不同的类装入器可以在 JVM 中多次装入类定义的二进制格式。JVM 中的类由它的完全限定名和它的类装入器的组合来标识。通常,类的二进制定义在 JVM 中只加载一次。

Tip

您可以将Class<T>类的对象视为类源代码的运行时描述符。运行时,类的源代码由Class类的对象表示。事实上,Java 中的所有类型——类、接口和基本类型——在运行时都由一个Class类的实例来表示。

Object类的getClass()方法返回Class对象的引用。因为getClass()方法是在Object类中声明和实现的,所以可以对任何类型的引用变量使用这个方法。下面的代码片段显示了如何为一个Cat对象获取Class对象的引用:

Cat c = new Cat();
Class catClass = c.getClass();

Class类是泛型的,它的形式类型参数是由它的对象表示的类名。您可以使用泛型重写该语句,如下所示:

Class<Cat> catClass = c.getClass();

默认情况下,类定义只加载一次,每个 Java 类只有一个Class对象。我们不考虑那些你已经编写代码多次加载同一个类的情况。如果你在同一个类的不同对象上使用getClass()方法,你将得到同一个Class对象的引用。考虑以下代码片段:

Cat c2 = new Cat();
Cat c3 = new Cat();
Class catClass2 = c2.getClass();
Class catClass3 = c3.getClass();

这里,c2c3是同一个Cat类的两个对象。因此,c2.getClass()c3.getClass()返回同一个Class对象的引用,该对象代表 JVM 中的Cat类。表达式catClass2 == catClass3将计算为true

Class类有许多有用的方法。您可以使用它的getName()方法来获取类的完全限定名。你可以使用它的getSimpleName()来获得类的简单名称,例如:

String fullName = catClass.getName();
String simpleName = catClass.getSimpleName();

Tip

当应用程序启动时,并不是应用程序中的所有类都被加载到 JVM 中。加载一个类,当应用程序第一次使用该类时,创建一个对应的Class对象。

计算对象的哈希代码

哈希代码是使用算法为一条信息计算的整数值。哈希代码也称为哈希和、哈希值或简称为哈希。从一条信息中计算整数的算法称为哈希函数。散列码的定义涉及三件事:

  • 一条信息

  • 一种算法

  • 整数值

你有一条信息。你对它应用一个算法来产生一个整数值。你得到的整数值是你所拥有的信息的散列码。如果您更改信息片段或算法,计算出的哈希代码可能会更改,也可能不会更改。图 11-1 描绘了计算散列码的过程。

img/323069_3_En_11_Fig1_HTML.png

图 11-1

计算散列码的过程

计算散列码是一个单向过程。从哈希代码中获取原始信息并不容易,这也不是哈希代码计算的目标。

可以用来生成散列码的信息可以是任意的字节、字符、数字或它们的组合序列。例如,您可能想要计算字符串"Hello"的散列码。

哈希函数是什么样子的?哈希函数可能像下面的函数一样简单,它返回所有输入数据的整数零:

int myHashFunction(<your input data>) {
    return 0;  // Always return zero
}

这个散列函数符合散列函数的定义,尽管它不是一个实用的好函数。编写一个好的哈希函数不是一件容易的事情。在编写一个好的散列函数之前,您需要考虑关于输入数据的许多事情。

你为什么需要一个散列码?当数据存储在基于散列的集合(或容器)中时,需要它来有效地检索与之相关的数据。在将数据存储到容器中之前,会计算其哈希代码,然后将其存储在基于其哈希代码的位置(也称为)。当您想要检索数据时,可以使用它的哈希代码来查找它在容器中的位置,这样可以更快地检索信息。值得注意的是,使用散列码的数据的有效检索是基于散列码值在一个范围内的分布。如果生成的哈希代码不是均匀分布的,则数据检索可能效率不高。在最坏的情况下,对数据的检索可能与对存储在容器中的所有元素进行线性搜索一样糟糕。如果使用散列函数,容器中的所有元素都将存储在同一个桶中,这将需要搜索所有元素。使用一个好的散列函数,使它为您提供均匀分布的散列码,这对于实现一个高效的基于散列的容器以实现快速数据检索是至关重要的。

Java 中哈希码有什么用?Java 使用哈希代码的原因和上面描述的一样——从基于哈希的集合中高效地检索数据。如果你的类的对象在基于散列的集合中没有被用作键,例如在HashSetHashMap等中。,您不必担心对象的哈希代码。

你可以用 Java 计算一个对象的散列码。在对象的情况下,将用于计算哈希代码的信息片段是组成对象状态的信息片段。Java 设计者认为对象的哈希代码非常重要,因此他们提供了一个默认实现来计算Object类中对象的哈希代码。

Object类包含一个hashCode()方法,该方法返回一个int,它是对象的哈希代码。此方法的默认实现通过将对象的内存地址转换为整数来计算对象的哈希代码。因为hashCode()方法是在Object类中定义的,所以它在 Java 的所有类中都可用。但是,您可以自由地在类中重写该实现。这里是当你在你的类中重写hashCode()方法时你必须遵循的规则。假设有两个对象引用,xy:

  • 如果x.equals(y)返回truex.hashCode()必须返回一个整数,等于y.hashCode()。也就是说,如果两个对象使用equals()方法相等,它们必须有相同的散列码。

  • 如果x.hashCode()等于y.hashCode(),则x.equals(y)不需要返回true。也就是说,如果两个对象使用hashCode()方法具有相同的散列码,那么使用equals()方法它们不一定相等。

  • 如果在 Java 应用程序的同一次执行中对同一个对象多次调用hashCode()方法,该方法必须返回相同的整数值。hashCode()equals()方法紧密联系在一起。如果您的类重写这两个方法中的任何一个,它必须重写这两个方法,以便您的类的对象能够在基于哈希的集合中正确工作。另一个规则是,您应该只使用那些实例变量来计算对象的哈希代码,这些代码也在equals()方法中用于检查相等性。

如果你的类是可变的,你不应该在基于散列的集合中使用你的类的对象作为键。如果用作键的对象在使用后发生更改,您将无法在集合中定位该对象,因为在基于哈希的集合中定位对象是基于其哈希代码的。在这种情况下,集合中会有滞留的对象。

你应该如何为一个类实现一个hashCode()方法?以下是为您的类编写hashCode()方法的逻辑的一些准则,对于大多数目的来说是合理的:

  1. 从一个质数开始,比如 37:

  2. 使用以下逻辑分别计算原始数据类型的每个实例变量的哈希代码值。注意,您只需要在哈希代码计算中使用那些实例变量,它们也是equals()方法逻辑的一部分。让我们将这一步的结果存储在一个int变量code中。我们假设value是实例变量的名字。

    对于byteshortintchar数据类型,使用它们的整数值作为

int hash = 37;

code = (int)value;

对于long数据类型,使用XOR作为 64 位的两半

code = (int)(value ^ (value >>> 32));

对于float数据类型,使用以下方法将其浮点值转换为等效的整数值

code = Float.floatToIntBits(value);

对于double数据类型,使用Double类的doubleToLongBits()方法将其浮点值转换为long,然后使用前面针对long数据类型描述的过程将长整型值转换为int值:

long longBits = Double.doubleToLongBits(value);
code = (int)(longBits ^ (longBits >>> 32));

对于boolean数据类型,使用1表示true,使用0表示false:

  1. 对于引用实例变量,如果是null,则使用0。否则,调用它的hashCode()方法来获得它的散列码。假设ref是引用变量的名称:
code = (value ? 1 : 0);

  1. 使用以下公式计算哈希代码。在公式中使用59是一个任意的决定。任何其他质数,比如说47,都可以很好地工作:
code = (ref == null ? 0: ref.hashCode());

  1. 对您想要包含在hashCode()计算中的所有实例变量重复前面的三个步骤。

  2. 最后,从您的hashCode()方法返回包含在hash变量中的值。

hash = hash * 59 + code;

这个方法是在 Java 中计算对象散列码的许多方法之一,但不是唯一的方法。如果你需要一个更强的散列函数,请查阅一本关于计算散列码的好教科书。所有原始包装类和String类都覆盖了hashCode()方法,以提供相当好的散列函数实现。

Tip

Java 7 中增加了一个名为java.lang.Objects的实用程序类。它包含一个hash()方法,该方法计算任意类型的任意数量的值的哈希代码。建议你使用Objects.hash()方法来计算一个对象的散列码。有关详细信息,请参阅本章后面的“Object类”一节。

清单 11-2 包含了一个Book类的代码。它展示了hashCode()方法的一个可能的实现。注意在hashCode()方法的声明中使用了@Override注释。当你在你的类中重新实现超类的方法时,你应该使用这个注释。注释用于用附加信息标记方法、字段和类。在这种情况下,@Override 告诉 Java 编译器您打算从超类或接口中覆盖一个方法。这将在第 20 和 21 章中详细介绍。

// Book.java
package com.jdojo.object;
public class Book {
    private String title;
    private String author;
    private int pageCount;
    private boolean hardCover;
    private double price;
    /* Other code goes here */
    /* Must implement the equals() method too. */
    @Override
    public int hashCode() {
        int hash = 37;
        int code = 0;
        // Use title
        code = (title == null ? 0 : title.hashCode());
        hash = hash * 59 + code;
        // Use author
        code = (author == null ? 0 : author.hashCode());
        hash = hash * 59 + code;
        // Use pageCount
        code = pageCount;
        hash = hash * 59 + code;
        // Use hardCover
        code = (hardCover ? 1 : 0);
        hash = hash * 59 + code;
        // Use price
        long priceBits = Double.doubleToLongBits(price);
        code = (int) (priceBits ^ (priceBits >>> 32));
        hash = hash * 59 + code;
        return hash;
    }
}

Listing 11-2A Book Class That Reimplements the hashCode() Method

Book类有五个实例变量:titleauthorpageCounthardcoverprice。该实现使用所有五个实例变量来计算一个Book对象的散列码。您还必须为Book类实现equals()方法,它必须使用所有五个实例变量来检查两个Book对象是否相等。您需要确保equals()方法和hashCode()方法在它们的逻辑中使用相同的实例变量集。假设您向Book类添加了一个实例变量。姑且称之为ISBN。因为ISBN惟一地标识了一本书,所以您可以只使用ISBN实例变量来计算它的散列码,并与另一个Book对象进行相等性比较。在这种情况下,只使用一个实例变量来计算哈希代码并检查相等性就足够了。

关于 Java 中对象的散列码有一些误解。开发人员认为散列码唯一地标识了一个对象,并且它必须是一个正整数。然而,它们不是真的。哈希代码不唯一地标识对象。两个不同的对象可能具有相同的哈希代码。散列码不必仅仅是正数。它可以是任何整数值,正的或负的。关于散列码的用法也存在混乱。它们仅用于从基于哈希的集合中高效检索数据。如果您的对象没有在基于散列的集合中用作键,并且您没有在您的类中覆盖equals()方法,那么您根本不需要担心在您的类中重新实现hashCode()方法。最有可能的是,它将覆盖equals()方法,这将提示您为您的类覆盖hashCode()方法。如果您不同时在您的类中重写并提供正确的hashCode()equals()方法的实现,您的类的对象在基于散列的集合中将不会正常工作。Java 编译器或 Java 运行时永远不会对这两个方法在类中的不正确实现给出任何警告或错误。

比较对象是否相等

宇宙中的每个对象都不同于所有其他对象,Java 程序中的每个对象都不同于所有其他对象。所有对象都有唯一的标识。一个对象被分配的内存地址可以被当作它的标识,这将使它总是唯一的。如果两个对象具有相同的标识(或 Java 术语中的引用),则它们是相同的。考虑以下代码片段:

Object obj1;
Object obj2;
/* Do something... */
if (obj1 == obj2) {
    /* obj1 and obj2 are the same object based on identity */
} else {
    /* obj1 and obj2 are different objects based on identity */
}

这段代码使用身份比较来测试obj1obj2是否相等。它比较两个对象的引用,以测试它们是否相等。

有时,如果两个对象基于它们的一些或所有实例变量具有相同的状态,您希望将它们视为相等。如果你想基于标准而不是引用(身份)来比较你的类的两个对象是否相等,你的类需要重新实现Object类的equals()方法。Object类中的equals()方法的默认实现比较作为参数传递的对象和调用该方法的对象的引用。如果两个引用相等,它返回true。否则返回false。换句话说,Object类中的equals()方法执行基于身份的相等性比较。该方法的实现如下。回想一下,类的实例方法中的关键字this指的是调用该方法的对象的引用:

public boolean equals(Object obj) {
    return (this == obj);
}

考虑下面的代码片段。它使用相等运算符(==)比较一些Point对象,该运算符总是比较其两个操作数的引用。它还使用了Object类的equals()方法来比较相同的两个引用。输出显示结果是相同的。请注意,您的Point类不包含equals()方法。当您在Point对象上调用equals()方法时,将使用Object类中equals()方法的实现:

Point pt1 = new Point(10, 10);
Point pt2 = new Point(10, 10);
Point pt3 = new Point(12, 19);
Point pt4 = pt1;
System.out.println("pt1 == pt1: " + (pt1 == pt1));
System.out.println("pt1.equals(pt1): " + pt1.equals(pt1));
System.out.println("pt1 == pt2: " + (pt1 == pt2));
System.out.println("pt1.equals(pt2): " + pt1.equals(pt2));
System.out.println("pt1 == pt3: " + (pt1 == pt3));
System.out.println("pt1.equals(pt3): " + pt1.equals(pt3));
System.out.println("pt1 == pt4: " + (pt1 == pt4));
System.out.println("pt1.equals(pt4): " + pt1.equals(pt4));
pt1 == pt1: true
pt1.equals(pt1): true
pt1 == pt2: false
pt1.equals(pt2): false
pt1 == pt3: false
pt1.equals(pt3): false
pt1 == pt4: true
pt1.equals(pt4): true

实际上,如果两个点具有相同的(x,y)坐标,则认为它们是相同的。如果你想为你的Point类实现这个相等规则,你必须重新实现equals()方法,如清单 11-3 所示。

// SmartPoint.java
package com.jdojo.object;
public class SmartPoint {
    private int x;
    private int y;
    public SmartPoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
    /* Reimplement the equals() method */
    @Override
    public boolean equals(Object otherObject) {
        // Are the same?
        if (this == otherObject) {
            return true;
        }
        // Is otherObject a null reference?
        if (otherObject == null) {
            return false;
        }
        // Do they belong to the same class?
        if (this.getClass() != otherObject.getClass()) {
            return false;
        }
        // Get the reference of otherObject in a SmartPoint variable
        SmartPoint otherPoint = (SmartPoint)otherObject;
        // Do they have the same x and y co-ordinates
        boolean isSamePoint = (this.x == otherPoint.x && this.y == otherPoint.y);
        return isSamePoint;
    }
    /* Reimplement hashCode() method of the Object class,
       which is a requirement when you reimplement equals() method
    */
    @Override
    public int hashCode() {
        return (this.x + this.y);
    }
}

Listing 11-3A SmartPoint Class That Reimplements equals() and hashCode() Methods

你称你的新类为SmartPoint。Java 建议一起重新实现hashCode()equals()方法,如果它们中的任何一个在你的类中被重新实现。如果你重新实现了equals()方法而不是hashCode()方法,Java 编译器不会抱怨。但是,当您在基于哈希的集合中使用类的对象时,将会得到不可预知的结果。

hashCode()方法的唯一要求是,如果m.equals(n)方法返回truem.hashCode()必须返回与n.hashCode()相同的值。因为您的equals()方法使用(x,y)坐标来测试相等性,所以您从hashCode()方法返回 x 和 y 坐标的和,这满足了技术要求。实际上,您需要使用更好的散列算法来计算散列值。

您已经在SmartPoint类的equals()方法中编写了几行代码。让我们一个一个地过一遍逻辑。首先,您需要检查传递的对象是否与调用该方法的对象相同。如果这两个对象是相同的,那么通过返回true来认为它们是相等的。这是通过以下代码完成的:

// Are they the same?
if (this == otherObject) {
    return true;
}

如果传递的参数是null,两个对象不能相同。注意,调用方法的对象永远不能是null,因为不能在null引用上调用方法。当试图在null引用上调用方法时,Java 运行时将抛出运行时异常。下面的代码确保您正在比较两个非空对象:

// Is otherObject a null reference?
if (otherObject == null) {
    return false;
}

该方法的参数类型为Object。这意味着可以传递任何类型的对象引用。例如,可以使用apple.equals(orange),其中appleorange分别是对一个Apple对象和一个Orange对象的引用。在您的例子中,您只想将一个SmartPoint对象与另一个SmartPoint对象进行比较。为了确保被比较的对象属于同一类,您需要下面的代码。如果有人用一个不是SmartPoint对象的参数调用该方法,它将返回false:

// Do they have the same class?
if (this.getClass() != otherObject.getClass()) {
    return false;
}

此时,您肯定有人试图比较两个具有不同身份(引用)的非空SmartPoint对象。现在你想比较两个物体的(x,y)坐标。要访问otherObject形参的 x 和 y 实例变量,必须将其转换成一个SmartPoint对象。下面的语句可以做到这一点:

// Get the reference of otherObject in a SmartPoint variable
SmartPoint otherPoint = (SmartPoint)otherObject;

此时,只需要比较两个SmartPoint对象的 x 和 y 实例变量的值。如果它们相同,则通过返回true认为两个对象相等。否则,两个对象不相等,你返回false。这是通过以下代码完成的:

// Do they have the same x and y coordinates
boolean isSamePoint = (this.x == otherPoint.x && this.y == otherPoint.y);
return isSamePoint;

是时候测试您在SmartPoint类中对equals()方法的重新实现了。清单 11-4 是你的测试类。您可以在输出中观察到,您有两种方法来比较两个SmartPoint对象是否相等。等式运算符(==)基于相同性对它们进行比较,equals()方法基于(x,y)坐标值对它们进行比较。注意,如果两个SmartPoint对象的(x,y)坐标相同,equals()方法返回true

// SmartPointTest.java
package com.jdojo.object;
public class SmartPointTest {
    public static void main(String[] args)  {
        SmartPoint pt1 = new SmartPoint(10, 10);
        SmartPoint pt2 = new SmartPoint(10, 10);
        SmartPoint pt3 = new SmartPoint(12, 19);
        SmartPoint pt4 = pt1;
        System.out.println("pt1 == pt1: " + (pt1 == pt1));
        System.out.println("pt1.equals(pt1): " + pt1.equals(pt1));
        System.out.println("pt1 == pt2: " + (pt1 == pt2));
        System.out.println("pt1.equals(pt2): " + pt1.equals(pt2));
        System.out.println("pt1 == pt3: " + (pt1 == pt3));
        System.out.println("pt1.equals(pt3): " + pt1.equals(pt3));
        System.out.println("pt1 == pt4: " + (pt1 == pt4));
        System.out.println("pt1.equals(pt4): " + pt1.equals(pt4));
    }
}
pt1 == pt1: true
pt1.equals(pt1): true
pt1 == pt2: false
pt1.equals(pt2): true
pt1 == pt3: false
pt1.equals(pt3): false
pt1 == pt4: true
pt1.equals(pt4): true

Listing 11-4A Test Class to Demonstrate the Difference Between Identity and State Comparisons

在你的类中有一些实现equals()方法的规范,所以当与 Java 的其他领域(例如,基于散列的集合)一起使用时,你的类将正确地工作。实施这些规范是类设计者的责任。如果您的类不符合这些规范,Java 编译器或 Java 运行时将不会生成任何错误。相反,你的类的对象将会行为不正确。例如,您将对象添加到集合中,但您可能无法检索它。下面是equals()方法实现的规范。假设xyz是三个对象的非空引用:

  • 反身性:应该是反身性。表达式x.equals(x)应该返回true。也就是说,一个对象必须等于它自己。

  • 对称:应该是对称的。如果x.equals(y)返回truey.equals(x)必须返回true。也就是说,如果 x 等于yy一定等于 x

  • 传递性:应该是传递性的。如果x.equals(y)返回truey.equals(z)返回truex.equals(z)必须返回true。也就是说,如果x等于yy等于zx一定等于z

  • 一致性:应该是一致的。如果x.equals(y)返回true,它应该一直返回true,直到 x 或 y 的状态被修改。如果x.equals(y)返回false,它应该一直返回false,直到 x 或 y 的状态被修改。

  • 与空引用的比较:任何类的对象都不应该等于null引用。表达式x.equals(null)应该总是返回false

  • 与 hashCode()方法的关系:如果x.equals(y)返回truex.hashCode()必须返回与y.hashCode()相同的值。也就是说,如果根据equals()方法,两个对象相等,那么它们必须具有从它们的hashCode()方法返回的相同散列码值。然而,事实可能恰恰相反。如果两个对象有相同的散列码,这并不意味着根据equals()方法它们必须相等。也就是说,如果x.hashCode()等于y.hashCode(),这并不意味着x.equals(y)将返回true

您的SmartPoint类满足了equals()hashCode()方法的所有六条规则。为SmartPoint类实现equals()方法相当容易。它有两个原始类型的实例变量,您在比较中使用了这两个变量。

对于应该使用多少实例变量来比较一个类的两个对象的相等性,没有规则。这完全取决于类的用途。例如,如果您有一个Account类,帐号本身就足以比较两个Account对象的相等性。但是,确保在equals()方法中使用相同的实例变量来比较相等性,在hashCode()方法中使用相同的实例变量来计算散列代码值。如果你的类有引用实例变量,你可以从你的类的equals()方法中调用它们的equals()方法。清单 11-5 展示了如何在equals()方法中使用引用实例变量比较。

// SmartCat.java
package com.jdojo.object;
public class SmartCat {
    private String name;
    public SmartCat(String name) {
        this.name = name;
    }
    /* Reimplement the equals() method */
    @Override
    public boolean equals(Object otherObject) {
        // Are they the same?
        if (this == otherObject) {
            return true;
        }
        // Is otherObject a null reference?
        if (otherObject == null) {
            return false;
        }
        // Do they belong to the same class?
        if (this.getClass() != otherObject.getClass()) {
            return false;
        }
        // Get the reference of otherObject is a SmartCat variable
        SmartCat otherCat = (SmartCat)otherObject;
        // Do they have the same names
        boolean isSameName = (this.name == null ? otherCat.name == null
                           : this.name.equals(otherCat.name) );
        return isSameName;
    }
    /* Reimplement the hashCode() method, which is a requirement
       when you reimplement equals() method
    */
    @Override
    public int hashCode() {
        return (this.name == null ? 0 : this.name.hashCode());
    }
}

Listing 11-5Overriding the equals() and hashCode() Methods in a Class

SmartCat类有一个name实例变量,它的类型是StringString类有自己版本的equals()方法实现,可以逐个字符地比较两个字符串。SmartCat类的equals()方法调用name实例变量上的equals()方法来检查两个名称是否相等。类似地,它在其hashCode()方法中利用了String类中hashCode()方法的实现。

对象的字符串表示形式

一个对象由它的状态来表示,状态是在某个时间点上它的所有实例变量的值的组合。有时,通常在调试中,以字符串形式表示对象是有帮助的。表示对象的字符串中应该有什么?对象的字符串表示应该包含足够的可读格式的对象状态信息。Object类的toString()方法允许您编写自己的逻辑,将类的对象表示为字符串。Object类提供了toString()方法的默认实现。它返回以下格式的字符串:

<fully-qualified-class-name>@<hash-code-of-object-in-hexadecimal-format>

考虑下面的代码片段及其输出。您可能会得到不同的输出:

// Create two objects
Object obj = new Object();
IntHolder intHolder = new IntHolder(234);
// Get string representation of objects
String objStr = obj.toString();
String intHolderStr = intHolder.toString();
// Print the string representations
System.out.println(objStr);
System.out.println(intHolderStr);
java.lang.Object@360be0
com.jdojo.object.IntHolder@45a877

注意,IntHolder类没有toString()方法。尽管如此,您仍然能够使用intHolder引用变量调用toString()方法,因为Object类中的所有方法在所有类中都自动可用。

您可能会注意到,从toString()方法返回的用于IntHolder对象的字符串表示不是很有用。它不会给你任何关于IntHolder物体状态的线索。让我们在IntHolder类中重新实现toString()方法。您将调用新类SmartIntHolder。你的toString()方法应该返回什么?SmartIntHolder类的一个对象代表一个整数值。将存储的整数值作为字符串返回就可以了。您可以使用String类的valueOf()静态方法将一个整数值(比如说123)转换成一个String对象,如下所示:

String str = String.valueOf(123); // str contains "123" as a string

清单 11-6 包含了SmartIntHolder类的完整代码。

// SmartIntHolder.java
package com.jdojo.object;
public class SmartIntHolder {
    private int value;
    public SmartIntHolder(int value) {
        this.value = value;
    }
    public void setValue(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
    /* Reimplement toString() method of the Object class */
    @Override
    public String toString() {
        // Return the stored value as a string
        String str = String.valueOf(this.value);
        return str;
    }
}

Listing 11-6Reimplementing the toString() Method of the Object Class in the SmartIntHolder Class

以下代码片段向您展示了如何使用SmartIntHolder类的toString()方法:

// Create an object of the SmartIntHolder class
SmartIntHolder intHolder = new SmartIntHolder(234);
String intHolderStr = intHolder.toString();
System.out.println(intHolderStr);
// Change the value in SmartIntHolder object
intHolder.setValue(8967);
intHolderStr = intHolder.toString();
System.out.println(intHolderStr);
234
8967

在类中重新实现toString()方法没有特殊的技术要求。您需要确保它被声明为public,它的返回类型是String,并且不带任何参数。返回的字符串应该是人类可读的文本,以便在调用方法时给出对象状态的概念。建议在您创建的每个类中重新实现Object类的toString()方法。

假设您有一个Point类来表示一个 2D 点,如清单 11-7 所示。一个Point保存一个点的 x 和 y 坐标。Point类中的toString()方法的实现可以返回一个(x, y)形式的字符串,其中 x 和 y 是点的坐标。

// Point.java
package com.jdojo.object;
public class Point {
    private int x;
    private int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    /* Reimplement toString() method of the Object class */
    @Override
    public String toString() {
        String str = "(" + x + ", " + y + ")";
        return str;
    }
}

Listing 11-7A Point Class Whose Object Represents a 2D Point

类的toString()方法非常重要,Java 为你提供了使用它的简单方法。在需要对象的字符串表示的情况下,Java 会自动调用对象的toString()方法。值得一提的两种情况是

  • 涉及对象引用的字符串串联表达式

  • 使用对象引用作为参数调用System.out.print()System.out.println()方法

当你像这样连接一个字符串和一个对象时

String str = "Hello" + new Point(10, 20);

Java 在Point对象上调用toString()方法,并将返回值连接到"Hello"字符串。这条语句将把一个"Hello(10, 20)"字符串赋给str变量。该语句与下面的语句相同:

String str = "Hello" + new Point(10, 20).toString();

您可以使用字符串连接运算符(+)来连接不同类型的数据。首先,Java 在连接数据之前获取所有数据的字符串表示。在串联表达式中自动调用对象的toString()方法可以帮助您节省一些输入。如果串联中使用的对象引用是一个null引用,Java 使用一个"null"字符串作为字符串表示。

下面的代码片段清楚地说明了对对象引用的toString()方法的调用。当您单独使用对象的引用或者在字符串连接表达式中调用它的toString()方法时,您可能会发现结果是相同的。类似地,当您使用System.out.println(pt)时,Java 会自动调用pt引用变量上的toString()方法:

Point pt = new Point(10, 12);
// str1 and str2 will have the same contents
String str1 = "Test " + pt;
String str2 = "Test " + pt.toString();
System.out.println(pt);
System.out.println(pt.toString());
System.out.println(str1);
System.out.println(str2);
(10, 12)
(10, 12)
Test (10, 12)
Test (10, 12)

下面的代码片段展示了在字符串连接表达式和System.out.println()方法调用中使用null引用的效果。注意,当pt持有null引用时,不能使用pt.toString()。对null引用的任何方法的调用都会产生运行时异常:

// Set pt to null
Point pt = null;
String str3 = "Test " + pt;
System.out.println(pt);
System.out.println(str3);
//System.out.println(pt.toString()); /* Will generate a runtime exception */
null
Test null

克隆对象

Java 不提供自动机制来克隆(复制)对象。回想一下,当您将一个引用变量赋给另一个引用变量时,只复制对象的引用,而不是对象的内容。克隆一个对象意味着一点一点地复制对象的内容。如果你想克隆你的类的对象,你必须在你的类中重新实现clone()方法。一旦你重新实现了clone()方法,你应该能够通过调用clone()方法来克隆你的类的对象。Object类中的clone()方法声明如下:

protected Object clone() throws CloneNotSupportedException

您需要注意一些关于clone()方法声明的事情:

  • 声明为protected。因此,您将无法从客户端代码中调用它。以下代码无效:

  • 这意味着如果你想让客户端代码克隆你的类的对象,你需要在你的类中声明方法。

  • 它的返回类型是Object。这意味着您需要转换clone()方法的返回值。假设MyClass是可克隆的。您的克隆代码将如下所示:

Object obj = new Object();
Object clone = obj.clone(); // Error. Cannot access protected clone()
                            // method

MyClass mc = new MyClass();
MyClass clone = (MyClass)mc.clone(); // Need to use a cast

克隆对象不需要知道对象的任何内部细节。Object类中的clone()方法拥有克隆一个对象所需的所有代码。你需要做的就是从你的类的clone()方法中调用它。它将按位复制原始对象,并返回副本的引用。

Object类中的clone()方法抛出一个CloneNotSupportedException。这意味着当您调用Object类的clone()方法时,您需要将调用放在try-catch块中或者重新抛出异常。您将在第十三章中了解更多关于try-catch模块的信息。您可以选择不从您的类的clone()方法中抛出CloneNotSupportedException。以下代码片段放在您的类的clone()方法中,该方法使用super关键字调用Object类的clone()方法:

YourClass obj = null;
try {
    // Call clone() method of the Object class using super.clone()
    obj = (YourClass)super.clone();
} catch (CloneNotSupportedException e) {
    e. printStackTrace();
}
return obj;

您必须做的一件重要事情是在您的类声明中添加一个implements Cloneable子句。Cloneable是在java.lang包中声明的接口。你会在第二十一章中了解到接口。现在,只需在类声明中添加这个子句。否则,当您在您的类的对象上调用clone()方法时,您将得到一个运行时错误。您的类声明必须如下所示:

public class MyClass implements Cloneable {
    // Code for your class goes here
}

清单 11-8 包含了一个DoubleHolder类的完整代码。它覆盖了Object类的clone()方法。clone()方法中的注释解释了代码在做什么。DoubleHolder类的clone()方法没有throws子句,而Object类的clone()方法有。当您覆盖一个方法时,您可以选择删除在超类中声明的throws子句。

// DoubleHolder.java
package com.jdojo.object;
public class DoubleHolder implements Cloneable {
    private double value;
    public DoubleHolder(double value) {
        this.value = value;
    }
    public void setValue(double value) {
        this.value = value;
    }
    public double getValue() {
        return this.value;
    }
    @Override
    public Object clone() {
        DoubleHolder copy = null;
        try {
            // Call the clone() method of the Object class, which will do a
            // bit-by-bit copy and return the reference of the clone
            copy = (DoubleHolder) super.clone();
        } catch (CloneNotSupportedException e) {
            // If anything goes wrong during cloning, print the error details
            e.printStackTrace();
        }
        return copy;
    }
}

Listing 11-8A DoubleHolder Class with Cloning Capability

一旦您的类正确实现了clone()方法,克隆您的类的对象就像调用它的clone()方法一样简单。下面的代码片段展示了如何克隆一个DoubleHolder类的对象。注意,您必须将从dh.clone()方法调用返回的引用转换为DoubleHolder类型:

DoubleHolder dh = new DoubleHolder(100.00);
DoubleHolder dhClone = (DoubleHolder) dh.clone();

此时,DoubleHolder类有两个独立的对象。dh变量引用原始对象,dhClone变量引用原始对象的克隆。原始对象和克隆对象持有相同的值100.00。但是,它们有该值的单独副本。如果更改原始对象中的值,例如dh.setValue(200),克隆对象中的值保持不变。清单 11-9 展示了如何使用clone()方法克隆一个DoubleHolder类的对象。输出证明,一旦克隆了一个对象,内存中就会有两个独立的对象。

// CloningTest.java
package com.jdojo.object;
public class CloningTest {
    public static void main(String[] args)  {
        DoubleHolder dh = new DoubleHolder(100.00);
        // Clone dh
        DoubleHolder dhClone = (DoubleHolder)dh.clone();
        // Print the values in original and clone
        System.out.println("Original:" + dh.getValue());
        System.out.println("Clone:" + dhClone.getValue());
        // Change the value in original and clone
        dh.setValue(200.00);
        dhClone.setValue(400.00);
        // Print the values in original and clone again
        System.out.println("Original:" + dh.getValue());
        System.out.println("Clone :" + dhClone.getValue());
    }
}
Original:100.0
Clone:100.0
Original:200.0
Clone:400.0

Listing 11-9A Test Class to Demonstrate Object Cloning

在 Java 5 中,您不需要在您的类中将clone()方法的返回类型指定为Object类型。您可以在clone()方法声明中将您的类指定为返回类型。这不会强制客户端代码在调用您的类的clone()方法时使用强制转换。下面的代码片段显示了DoubleHolder类的变更代码,它只能在 Java 5 或更高版本中编译。它将DoubleHolder声明为clone()方法的返回类型,并在return语句中使用强制转换:

// DoubleHolder.java
package com.jdojo.object;
public class DoubleHolder implements Cloneable {
    /* The same code goes here as before... */
    public DoubleHolder clone() {
        Object copy = null;
        /* The same code goes here as before... */
        return (DoubleHolder)copy;
    }
}

使用前面对clone()方法的声明,您可以编写代码来克隆一个对象,如下所示。请注意,不再需要强制转换:

DoubleHolder dh = new DoubleHolder(100.00);
DoubleHolder dhClone = dh.clone();// Clone dh. No cast is needed

一个对象可能由另一个对象组成。在这种情况下,内存中分别存在两个对象——一个被包含的对象和一个容器对象。容器对象存储被包含对象的引用。克隆容器对象时,会克隆所包含对象的引用。克隆完成后,容器对象有两个副本;它们都引用了同一个包含的对象。这被称为克隆,因为复制的是引用,而不是对象。Object类的clone()方法只进行浅层克隆,除非你另外编码。图 11-2 显示了一个复合对象的内存状态,其中一个对象包含另一个对象的引用。图 11-3 显示了使用浅层克隆来克隆复合对象时的内存状态。您可能会注意到,在浅层克隆中,包含的对象由原始复合对象和克隆的复合对象共享。

img/323069_3_En_11_Fig3_HTML.png

图 11-3

使用浅层克隆克隆容器对象后的内存状态

img/323069_3_En_11_Fig2_HTML.png

图 11-2

复合对象。容器对象存储另一个对象(被包含的对象)的引用

在克隆复合对象的过程中,如果复制的是包含的对象,而不是它们的引用,则称为深度克隆。您必须克隆一个对象的所有引用变量所引用的所有对象,以获得深度克隆。一个复合对象可以有多个层次的被包含对象链接。例如,容器对象可能具有对另一个被包含对象的引用,而后者又具有对另一个被包含对象的引用,依此类推。您是否能够执行复合对象的深度克隆取决于许多因素。如果你有一个被包含对象的引用,它可能不支持深度克隆;在这种情况下,你必须满足于浅层克隆。你可能有一个被包含对象的引用,它本身就是一个复合对象。然而,包含的对象只支持浅层克隆,在这种情况下,您将不得不满足于浅层克隆。让我们看看浅层克隆和深层克隆的例子。

如果一个对象的引用实例变量存储了对不可变对象的引用,您不需要克隆它们。也就是说,如果一个复合对象包含的对象是不可变的,您不需要克隆包含的对象。在这种情况下,不可变包含对象的浅拷贝是好的。回想一下,不可变对象在创建后就不能修改了。不可变对象的引用可以被多个对象共享,而不会有任何副作用。这是拥有不可变对象的好处之一。如果一个复合对象包含一些对可变对象的引用和一些对不可变对象的引用,那么您必须克隆被引用的可变对象以拥有一个深度副本。清单 11-10 有一个ShallowClone类的代码。

// ShallowClone.java
package com.jdojo.object;
public class ShallowClone implements Cloneable {
    private DoubleHolder holder = new DoubleHolder(0.0);
    public ShallowClone(double value) {
        this.holder.setValue(value);
    }
    public void setValue(double value) {
        this.holder.setValue(value);
    }
    public double getValue() {
        return this.holder.getValue();
    }
    @Override
    public Object clone() {
        ShallowClone copy = null;
        try {
            copy = (ShallowClone) super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return copy;
    }
}

Listing 11-10A ShallowClone Class That Supports Shallow Cloning

ShallowClone类的对象由DoubleHolder类的对象组成。ShallowClone类的clone()方法中的代码与DoubleHolder类的clone()方法中的代码相同。区别在于用于这两个类的实例变量的类型。DoubleHolder类有一个原始类型double的实例变量,而ShallowClone类有一个引用类型DoubleHolder的实例变量。当ShallowClone类调用Object类的clone()方法(使用super.clone())时,它接收自身的浅层副本。也就是说,它与其克隆体共享其实例变量中使用的DoubleHolder对象。

清单 11-11 有测试用例来测试ShallowClone类的一个对象及其克隆。输出显示,克隆后,通过原始对象更改值也会更改克隆对象中的值。这是因为ShallowClone对象将值存储在DoubleHolder类的另一个对象中,该对象由克隆对象和原始对象共享。

// ShallowCloneTest.java
package com.jdojo.object;
public class ShallowCloneTest {
    public static void main(String[] args) {
        ShallowClone sc = new ShallowClone(100.00);
        ShallowClone scClone = (ShallowClone) sc.clone();
        // Print the value in original and clone
        System.out.println("Original: " + sc.getValue());
        System.out.println("Clone: " + scClone.getValue());
        // Change the value in original and it will change the value
        // for clone too because we have done shallow cloning
        sc.setValue(200.00);
        // Print the value in original and clone
        System.out.println("Original: " + sc.getValue());
        System.out.println("Clone: " + scClone.getValue());
    }
}
Original: 100.0
Clone: 100.0
Original: 200.0
Clone: 200.0

Listing 11-11A Test Class to Demonstrate the Shallow Copy Mechanism

在深度克隆中,需要克隆一个对象的所有引用实例变量引用的所有对象。您必须先执行浅层克隆,然后才能执行深层克隆。浅层克隆是通过调用Object类的clone()方法来执行的。然后,您需要编写代码来克隆所有引用实例变量。清单 11-12 展示了一个DeepClone类的代码,它执行深度克隆。

// DeepClone.java
package com.jdojo.object;
public class DeepClone implements Cloneable {
    private DoubleHolder holder = new DoubleHolder(0.0);
    public DeepClone(double value) {
        this.holder.setValue(value);
    }
    public void setValue(double value) {
        this.holder.setValue(value);
    }
    public double getValue() {
        return this.holder.getValue();
    }
    @Override
    public Object clone() {
        DeepClone copy = null;
        try {
            copy = (DeepClone) super.clone();
            // Need to clone the holder reference variable too
            copy.holder = (DoubleHolder) this.holder.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return copy;
    }
}

Listing 11-12A DeepClone Class That Performs Deep Cloning

如果你比较一下ShallowCloneDeepClone类的clone()方法中的代码,你会发现,对于深度克隆,你只需要多写一行代码:

// Need to clone the holder reference variable too
copy.holder = (DoubleHolder)this.holder.clone();

如果DoubleHolder类不可克隆会怎么样?在这种情况下,您将无法编写这个语句来克隆holder实例变量。您可以克隆 holder 实例变量,如下所示:

// Need to clone the holder reference variable too
copy.holder = new DoubleHolder(this.holder.getValue());

目标是克隆holder实例变量,并且不一定要通过调用它的clone()方法来完成。清单 11-13 展示了你的DeepClone类是如何工作的。将它的输出与ShallowCloneTest类的输出进行比较,看看有什么不同。

// DeepCloneTest.java
package com.jdojo.object;
public class DeepCloneTest {
    public static void main(String[] args) {
        DeepClone sc = new DeepClone(100.00);
        DeepClone scClone = (DeepClone) sc.clone();
        // Print the value in original and clone
        System.out.println("Original: " + sc.getValue());
        System.out.println("Clone: " + scClone.getValue());
        // Change the value in original and it will not change the value
        // for clone because we have done deep cloning
        sc.setValue(200.00);
        // Print the value in original and clone
        System.out.println("Original: " + sc.getValue());
        System.out.println("Clone: " + scClone.getValue());
    }
}
Original: 100.0
Clone: 100.0
Original: 200.0
Clone: 100.0

Listing 11-13A Test Class to Test Deep Cloning of Objects

Tip

使用Object类的clone()方法并不是克隆一个对象的唯一方法。您可以使用其他方法来克隆对象。您可以提供一个复制构造器,它接受同一个类的对象并创建该对象的克隆。你可以在你的类中提供一个工厂方法,它可以接受一个对象并返回它的克隆。克隆对象的另一种方法是先序列化它,然后再反序列化它。这里不讨论对象的序列化和反序列化。

完成一个对象

有时,当对象被销毁时,该对象会使用需要释放的资源。当一个对象将要被销毁时,Java 为您提供了一种执行资源释放或其他类型的清理的方法。在 Java 中,你可以创建对象,但不能销毁对象。JVM 运行一个名为垃圾收集器的低优先级特殊任务来销毁所有不再被引用的对象。垃圾收集器让您有机会在对象被销毁之前执行清理代码。Object类有一个finalize()方法,声明如下:

protected void finalize() throws Throwable { }

Object类中的finalize()方法不做任何事情。您需要在类中重写该方法。在你的类的一个对象被销毁之前,你的类的finalize()方法将被垃圾收集器调用。清单 11-14 包含了Finalize类的代码。它覆盖了Object类的finalize()方法,并在标准输出中输出一条消息。您可以在此方法中执行任何清理逻辑。finalize()方法中的代码也被称为终结器

// Finalize.java
package com.jdojo.object;
public class Finalize {
    private int x;
    public Finalize(int x) {
        this.x = x;
    }
    @Override
    public void finalize() {
        System.out.println("Finalizing " + this.x);
        /* Perform any cleanup work here... */
    }
}

Listing 11-14A Finalize Class That Overrides the finalize() Method of the Object Class

垃圾回收器只为每个对象调用一次终结器。为对象运行终结器并不一定意味着该对象将在终结器完成后立即被销毁。当垃圾回收器确定不存在对该对象的引用时,将运行终结器。但是,当一个对象的终结器运行时,它可能会将自己的引用传递给程序的其他部分。这就是垃圾收集器在运行一个对象的终结器后再检查一次以确保该对象不存在引用,然后销毁(释放内存)该对象的原因。没有指定终结器的运行顺序和运行时间。甚至不能保证终结器会运行。这使得程序员在finalize()方法中编写清理逻辑变得不可靠。有更好的方法来执行清理逻辑,例如,使用一个try-finally块。建议不要依赖 Java 程序中的finalize()方法来清理对象使用的资源。

Tip

从 Java 9 开始,Object类中的finalize()方法就被弃用了,因为使用finalize()方法清理资源本身就存在问题。有几种更好的方法来清理资源,例如,使用try-with-resourcestry-finally块。我们将在本书的第十三章讨论这些技术。为了完整起见,我们在本章中已经介绍了finalize()方法。

清单 11-15 包含测试你的Finalize类的终结器的代码。运行这个程序时,您可能会得到不同的输出。

// FinalizeTest.java
package com.jdojo.object;
public class FinalizeTest {
    public static void main(String[] args) {
        // Create many objects, say 2000000 objects.
        for(int i = 0; i < 2000000; i++) {
            new Finalize(i);
        }
    }
}
Finalizing 977620
Finalizing 977625
Finalizing 977627

Listing 11-15A Test Class to Test Finalizers

该程序创建了 2000000 个Finalize类的对象,但没有存储它们的引用。不要存储您创建的对象的引用,这一点很重要。只要持有对象的引用,它就不会被销毁,它的终结器也不会运行。从输出中可以看到,在程序完成之前,只有三个对象有机会运行它们的终结器。您可能根本得不到输出,或者得到不同的输出。如果没有得到任何输出,可以通过增加要创建的对象的数量来尝试。当垃圾收集器感觉内存不足时,它会销毁对象。您可能需要创建更多的对象来触发垃圾回收,这反过来将运行您的对象的终结器。

不可变对象

一个对象的状态在被创建后不能被改变,这个对象被称为不可变对象。对象不可变的类称为不可变类。如果一个对象的状态在它被创建后可以被改变(或变异),它被称为可变对象,它的类被称为可变类。

在我们进入创建和使用不可变对象的细节之前,让我们定义一下“不变性”这个词对象的实例变量定义了对象的状态。对象的状态有两种视图:内部和外部。对象的内部状态由其实例变量在某个时间点的实际值来定义。对象的外部状态由对象的用户(或客户端)在某个时间点看到的值定义。当我们声明一个对象是不可变的时,我们必须明确我们指的是对象的哪种状态是不可变的:内部状态、外部状态,或者两者都是。

通常,当我们在 Java 中使用短语“不可变对象”时,我们指的是外部不变性。在外部不变性中,对象可以在创建后改变其内部状态。但是,外部用户看不到其内部状态的变化。创建后,用户看不到其状态的任何变化。在内部不变性中,对象的状态在创建后不会改变。如果一个对象是内部不可变的,那么它也是外部不可变的。我们讨论两者的例子。

不可变对象比可变对象有几个优点。不可变对象可以被程序的不同区域共享,而不用担心它的状态变化。测试一个不可变的类很容易。不可变对象本质上是线程安全的。您不必从多个线程同步对不可变对象的访问,因为它的状态不会改变。有关线程同步的更多细节,请参考本系列的第二卷。不可变对象不必被复制并传递到同一个 Java 应用程序中的另一个程序区域,因为它的状态不会改变。你只需要传递它的引用,它就可以作为一个副本。它的引用可以用来访问它的内容。避免复制是一个很大的性能优势,因为它节省了时间和空间。

让我们从一个可变类开始,它的对象的状态可以在创建后修改。清单 11-16 包含一个IntHolder类的代码。

// IntHolder.java
package com.jdojo.object;
public class IntHolder {
    private int value;
    public IntHolder(int value) {
        this.value = value;
    }
    public void setValue(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
}

Listing 11-16An Example of a Mutable Class Whose Object’s State Can Be Changed After Creation

value实例变量定义了一个IntHolder对象的状态。您创建了一个IntHolder类的对象,如下所示:

IntHolder holder = new IntHolder(101);
int v = holder.getValue(); // Stores 101 in v

此时,value实例变量持有101,定义其状态。您可以使用 getter 和 setter 来获取和设置实例变量:

// Change the value
holder.setValue(505);
int w = holder.getValue(); // Stores 505 in w

此时,value实例变量已经从101变成了505。也就是说,对象的状态已经改变。通过setValue()方法促进了状态的改变。IntHolder类的对象是可变对象的例子。

让我们让IntHolder类成为不可变的。您所需要做的就是从其中移除setValue()方法,使其成为一个不可变的类。让我们把你的不可变版本的IntHolder类称为IntWrapper,如清单 11-17 所示。

// IntWrapper.java
package com.jdojo.object;
public class IntWrapper {
    private final int value;
    public IntWrapper(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
}

Listing 11-17An Example of an Immutable Class

这就是如何创建一个IntWrapper类的对象:

IntWrapper wrapper = new IntWrapper(101);

此时,wrapper对象持有101,没有办法改变。因此,IntWrapper类是一个不可变的类,它的对象是不可变的对象。您可能已经注意到对IntHolder类做了两处修改,将其转换为IntWrapper类。移除了setValue()方法,并制作了value实例变量final。在这种情况下,没有必要使value实例变量final。使用final关键字可以让类的读者清楚你的意图,并且它可以保护value实例变量不被无意中更改。声明定义对象final的不可变状态的所有实例变量是一个好的实践(作为一个经验法则),这样 Java 编译器将在编译期间实施不变性。IntWrapper类的对象在内部和外部都是不可变的。一旦创建,就无法更改其状态。

让我们创建一个IntWrapper类的变体,它将是外部不可变但内部可变的。姑且称之为IntWrapper2。在清单 11-18 中列出。

// IntWrapper2.java
package com.jdojo.object;
public class IntWrapper2 {
    private final int value;
    private int halfValue = Integer.MAX_VALUE;
    public IntWrapper2(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
    public int getHalfValue() {
        // Compute half value if it is not already computed
        if (this.halfValue == Integer.MAX_VALUE) {
            // Cache the half value for future use
            this.halfValue = this.value / 2;
        }
        return this.halfValue;
    }
}

Listing 11-18An Example of an Externally Immutable and Internally Mutable Class

IntWrapper2添加了另一个名为halfValue的实例变量,它将保存传递给构造器的值的一半。这是一个微不足道的例子。然而,它的目的是解释外部和内部不可变对象的含义。假设(只是为了讨论起见)计算整数的一半是一个非常昂贵的过程,你不想在IntWrapper2类的构造器中计算它,尤其是如果不是每个人都要求这样做的话。halfValue实例变量被初始化为最大的整数值,这是一个标志,表明它还没有被计算。您添加了一个getHalfValue()方法,它检查您是否已经计算了一半的值。第一次,它会计算一半的值并缓存在halfValue实例变量中。从第二次开始,它将简单地返回缓存的值。

问题是,“一个IntWrapper2对象是不可变的吗?”答案是肯定和否定的。它是内部可变的。然而,它是外部不可变的。一旦它被创建,它的客户端将从getValue()getHalfValue()方法中看到相同的返回值。然而,当第一次调用getHalfValue()方法时,它的状态(具体来说是halfValue)在其生命周期中会改变一次。但是,该对象的用户看不到此更改。此方法在所有后续调用中返回相同的值。像IntWrapper2这样的对象叫做不可变对象。回想一下,不可变对象通常意味着外部不可变。

Java 类库中的String类就是不可变类的一个例子。它使用了针对IntWrapper2类讨论的缓存技术。当第一次调用hashCode()方法时,String类为其内容计算散列码,并缓存该值。因此,String对象在内部改变它的状态,但不是为它的客户端。你不会遇到“Java 中的一个String对象是外部不可变和内部可变的”这种说法相反,你会遇到“Java 中的一个String对象是不可变的”这种说法你应该明白这意味着String对象至少是外部不可变的。

清单 11-19 显示了一个棘手的情况,试图创建一个不可变的类。IntHolderWrapper类没有可以直接让您修改存储在其valueHolder实例变量中的值的方法。它似乎是一个不可变的类。

// IntHolderWrapper.java
package com.jdojo.object;
public class IntHolderWrapper {
    private final IntHolder valueHolder;
    public IntHolderWrapper(int value) {
        this.valueHolder = new IntHolder(value);
    }
    public IntHolder getIntHolder() {
        return this.valueHolder;
    }
    public int getValue() {
        return this.valueHolder.getValue();
    }
}

Listing 11-19An Unsuccessful Attempt to Create an Immutable Class

清单 11-20 包含一个测试类来测试IntHolderWrapper类的不变性。

// BadImmutableTest.java
package com.jdojo.object;
public class BadImmutableTest {
    public static void main(String[] args) {
        IntHolderWrapper ihw = new IntHolderWrapper(101);
        int value = ihw.getValue();
        System.out.println("#1 value = " + value);
        IntHolder holder = ihw.getIntHolder();
        holder.setValue(207);
        value = ihw.getValue();
        System.out.println("#2 value = " + value);
    }
}
#1 value = 101
#2 value = 207

Listing 11-20A Test Class to Test Immutability of the IntHolderWrapper Class

输出显示IntHolderWrapper类是可变的。对其getValue()方法的两次调用返回不同的值。罪魁祸首是它的getIntHolder()法。它返回valueHolder实例变量,这是一个引用变量。注意,valueHolder实例变量代表了一个IntHolder类的对象,它构成了一个IntHolderWrapper对象的状态。如果valueHolder引用变量引用的对象发生变化,IntHolderWrapper的状态也会发生变化。由于IntHolder对象是可变的,您不应该从getIntHolder()方法返回它对客户机的引用。以下两条语句从客户端代码更改对象的状态:

IntHolder holder = ihw.getIntHolder(); /* Got hold of instance variable */
holder.setValue(207); /* Change the state by changing the instance variable's state */

请注意,IntHolderWrapper类的设计者在返回valueHolder引用时忽略了一点,即即使没有直接的方法来改变IntHolderWrapper类的状态,也可以间接改变。

你如何改正这个问题?解决方法很简单。在getIntHolder()方法中,复制valueHolder对象并返回副本的引用,而不是实例变量本身。这样,如果客户端改变了这个值,它只会在客户端的副本中被改变,而不会在由IntHolderWrapper对象持有的副本中被改变。清单 11-21 包含了IntHolderWrapper类的正确的不可变版本,您称之为IntHolderWrapper2

// IntHolderWrapper2.java
package com.jdojo.object;
public class IntHolderWrapper2 {
    private final IntHolder valueHolder;
    public IntHolderWrapper2(int value) {
        this.valueHolder = new IntHolder(value);
    }
    public IntHolder getIntHolder() {
        // Make a copy of valueHolder
        int v = this.valueHolder.getValue();
        IntHolder copy = new IntHolder(v);
        // Return the copy instead of the original
        return copy;
    }
    public int getValue() {
        return this.valueHolder.getValue();
    }
}

Listing 11-21A Modified, Immutable Version of the IntHolderWrapper Class

创建一个不可变的类比看起来要稍微复杂一些。我已经在本节中介绍了一些案例。这是另一个你需要小心的例子。假设你设计了一个不可变的类,它有一个引用类型的实例变量。假设它在其一个构造器中接受其引用类型实例变量的初始值。如果实例变量的类是可变类,则必须复制传递给其构造器的参数,并将副本存储在实例变量中。在构造器中传递对象引用的客户端代码可能会在以后通过同一引用更改该对象的状态。清单 11-22 展示了如何正确实现IntHolderWrapper3类的第二个构造器。它包含注释的第二个构造器的实现的错误版本。

// IntHolderWrapper3.java
package com.jdojo.object;
public class IntHolderWrapper3 {
    private final IntHolder valueHolder;
    public IntHolderWrapper3(int value) {
        this.valueHolder = new IntHolder(value);
    }
    public IntHolderWrapper3(IntHolder holder) {
        // Must make a copy of holder parameter
        this.valueHolder = new IntHolder(holder.getValue());
        /* Following implementation is incorrect. Client code will be able to change the
           state of the object using holder reference later */
        //this.valueHolder = holder; /* do not use it */
    }
    /* Rest of the code goes here... */
}

Listing 11-22Using a Copy Constructor to Correctly Implement an Immutable Class

Object

JDK 在java.util包中包含一个名为Objects的实用程序类,用于处理对象。它由所有静态方法组成。Objects类的大多数方法都能优雅地处理null值。Java 9 给这个类增加了一些实用方法。Objects类中的方法根据它们执行的操作类型分为以下几类:

  • 边界检查

  • 比较对象

  • 计算哈希代码

  • 检查是否为空

  • 验证参数

  • 获取对象的字符串表示形式

边界检查

此类别中的方法用于检查索引或子范围是否在范围的界限内。通常,在执行涉及数组边界的操作之前,对数组使用这些方法。Java 中的数组是相同类型元素的集合。数组中的每个元素都有一个用于访问它们的索引。数组索引是从零开始的。第一个元素的索引为 0,第二个为 1,第三个为 2,依此类推。假设你有一个五个元素的数组,有人让你从索引 3 开始给他们数组的四个元素。此请求无效,因为数组索引范围是从 0 到 4,而请求的元素是从索引 3 到 6。Objects类包含以下三种执行边界检查的方法——所有这些方法都是在 Java 9 中添加的:

  • int checkFromIndexSize(int fromIndex, int size, int length)

  • int checkFromToIndex(int fromIndex, int toIndex, int length)

  • int checkIndex(int index, int length)

如果对索引或子范围的检查不在0length的范围内,所有这些方法都会抛出IndexOutOfBoundsException,其中length是这些方法的参数之一。

checkFromIndexSize(int fromIndex, int size, int length)方法检查从fromIndex(含)到fromIndex + size(不含)的子范围是否在从0(含)到length(不含)的范围界限内。

checkFromToIndex(int fromIndex, int toIndex, int length)方法检查从fromIndex(含)到toIndex(不含)的子范围是否在从0(含)到length(不含)的范围界限内。

checkIndex(int index, int length)方法检查index是否在从0(含)到length(不含)的范围内。

比较对象

此类别中的方法用于比较对象以进行排序或相等。这一类别中有三种方法:

  • <T> int compare(T a, T b, Comparator<? super T> c)

  • boolean deepEquals(Object a, Object b)

  • boolean equals(Object a, Object b)

compare()方法用于比较两个对象以进行排序。如果两个参数相同,它将返回0。否则,它返回c.compare(a, b)的值。如果两个参数都是null,则返回0

deepEquals()方法用于检查两个对象是否完全相等。如果两个参数完全相等,则返回true。否则,它返回false。如果两个参数都是null,则返回true

方法比较两个对象是否相等。如果两个参数相等,它返回true。否则,它返回false。如果两个参数都是null,则返回true。如果只有一个参数是null,则返回false

计算哈希代码

此类别中的方法用于计算一个或多个对象的哈希代码。这一类别中有两种方法:

  • int hash(Object... values)

  • int hashCode(Object obj)

hash()方法在其参数中为所有指定的对象生成一个散列码。它可用于计算包含多个实例字段的对象的哈希代码。清单 11-23 包含了另一个版本的Book类。这一次,hashCode()方法使用Objects.hash()方法来计算一个Book对象的散列码。比较清单 11-2 中Book类的代码和清单 11-23 中Book2类的代码。注意使用Objects.hash()方法计算对象的散列码是多么容易。

// Book2.java
package com.jdojo.object;
import java.util.Objects;
public class Book2 {
    private String title;
    private String author;
    private int pageCount;
    private boolean hardCover;
    private double price;
    /* Other code goes here */
    /* Must implement the equals() method too. */
    @Override
    public int hashCode() {
        return Objects.hash(title, author, pageCount, hardCover, price);
    }
}

Listing 11-23Using the Objects.hash() Method to Compute the Hash Code of an Object

如果将单个对象引用传递给Objects.hash()方法,则返回的哈希代码不等于从对象的hashCode()方法返回的哈希代码。换句话说,如果book是一个对象引用,book.hashCode()不等于Objects.hash(book)

Objects.hashCode(Object obj)方法返回指定对象的散列码值。如果参数是null,则返回0

检查是否为空

这类方法用于检查对象是否为空。这一类别中有两种方法:

  • boolean isNull(Object obj)

  • boolean nonNull(Object obj)

如果指定的对象是null,则isNull()方法返回true。否则返回false。还可以使用比较运算符==检查一个对象是否为空,例如objnullobj == null返回true。Java 8 中加入了isNull()方法。它的存在是为了像Objects::isNull一样被用作方法引用。

nonNull()方法执行与isNull()方法相反的检查。它也被添加到 Java 8 中,像Objects::nonNull一样被用作方法引用。

验证参数

这个类别中的方法用于验证构造器和方法的required参数。您可以通过使用if语句编写几行代码来实现的目标,您可以使用这些方法在一行代码中实现。这一类别中有五种方法:

  • <T> T requireNonNull(T obj)

  • <T> T requireNonNull(T obj, String message)

  • <T> T requireNonNull(T obj, Supplier<String> messageSupplier)

  • <T> T requireNonNullElse(T obj, T defaultObj)

  • <T> T requireNonNullElseGet(T obj, Supplier<? extends T> supplier)

requireNonNull(T obj)方法检查参数是否不是null。如果参数是null,它抛出一个NullPointerException。此方法设计用于验证方法和构造器的参数。注意方法声明中的形式类型参数<T>。这使它成为一个泛型方法;任何类型的对象都可以作为参数传递给此方法。它的返回类型与传递的对象的类型相同。该方法被重载。该方法的第二个版本允许您为参数为null时抛出的NullPointerException指定消息。该方法的第三个版本将一个Supplier<String>作为第二个参数。它将消息的创建推迟到执行空值检查之后。如果第一个参数是null,则调用Supplier<String>对象的get()方法来获取在NullPointerException中使用的错误消息。使用供应商延迟了错误消息的构造,并且还为您提供了更多的选项,比如在错误消息中添加时间戳。

Java 9 在Objects类中添加了requireNonNullElse()requireNonNullElseGet()方法。requireNonNullElse()方法返回第一个参数,如果它不是null;否则,如果第二个参数不是null,则返回第二个参数。如果两个参数都为空,它抛出一个NullPointerExceptionrequireNonNullElseGet()方法返回第一个参数,如果它不是null;否则,返回从supplierget()方法返回的 not null值。如果第一个参数是null并且供应商为空或者供应商返回null,则抛出一个NullPointerException

获取对象的字符串表示形式

此类别中的方法用于获取对象的字符串表示形式。这一类别中有两种方法:

  • String toString(Object o)

  • String toString(Object o, String nullDefault)

如果参数是null,toString()方法返回一个“null”字符串。对于非空参数,它返回通过调用参数的toString()方法返回的值。方法的第二个版本允许您在参数为 null 时指定默认返回的字符串。

使用Object

清单 11-24 展示了如何使用Objects类的一些方法。程序使用 lambda 表达式创建一个Supplier<String>。Lambda 表达式在本书第二十一章详细讨论。这里用它来给你一个完整的例子。简而言之,lambda 表达式使用以下语法:参数 -> 表达式。如果 lambda 表达式没有参数,则使用以下语法:() -> 表达式

// ObjectsTest.java
package com.jdojo.object;
import java.time.Instant;
import java.util.Objects;
import java.util.function.Supplier;
public class ObjectsTest {
    public static void main(String[] args) {
        // Compute hash code for two integers, a char, and a string
        int hash = Objects.hash(10, 8900, '\u20b9', "Hello");
        System.out.println("Hash Code is " + hash);
        // Test for equality
        boolean isEqual = Objects.equals(null, null);
        System.out.println("null is equal to null: " + isEqual);
        isEqual = Objects.equals(null, "XYZ");
        System.out.println("null is equal to XYZ: " + isEqual);
        // toString() method test
        System.out.println("toString(null) is " + Objects.toString(null));
        System.out.println("toString(null, \"XXX\") is " + Objects.toString(null, "XXX"));
        // Testing requireNonNull(T obj, String message)
        try {
            printName("Doug Dyer");
            printName(null);
        } catch (NullPointerException e) {
            System.out.println(e.getMessage());
        }
        // requireNonNull(T obj, Supplier<String> messageSupplier)
        try {
            // Using a lambda expression to create a Supplier<String> object.
            // The Supplier returns a time stamped message.
            Supplier<String> messageSupplier =
                    () -> "Name is required. Error generated on " + Instant.now();
            printNameWithSupplier("Babalu", messageSupplier);
            printNameWithSupplier(null, messageSupplier);
        } catch (NullPointerException e) {
            System.out.println(e.getMessage());
        }
         //<T> T requireNonNullElse(T obj, T defaultObj)
         printNameWithDefault("Kishori Sharan");
         // Default name "John Doe" will be used
         printNameWithDefault(null);
    }
    public static void printName(String name) {
        // Test name for not null. Generate a NullPointerException if it is null.
        Objects.requireNonNull(name, "Name is required.");
        // Print the name if the above statement did not throw an exception
        System.out.println("Name is " + name);
    }
    public static void printNameWithSupplier(String name, Supplier<String> messageSupplier) {
        // Test name for not null. Generate a NullPointerException if it is null.
        Objects.requireNonNull(name, messageSupplier);
        // Print the name if the above statement did not throw an exception
        System.out.println("Name is " + name);
    }
    public static void printNameWithDefault(String name) {
        // Test name for not null. Generate a NullPointerException if it is null.
        Objects.requireNonNullElse(name, "John Doe");
        // Print the name if the above statement did not throw an exception
        System.out.println("Name is " + name);
    }
}
Hash Code is 79643668
null is equal to null: true
null is equal to XYZ: false
toString(null) is null
toString(null, "XXX") is XXX
Name is Doug Dyer
Name is required.
Name is Babalu
Name is required. Error generated on 2017-07-29T02:44:25.974523900Z
Name is Kishori Sharan
Name is null

Listing 11-24A Test Class to Demonstrate the Use of the Methods of the Objects Class

摘要

Java 中的类是以树状层次结构排列的。树中的类具有超类-子类关系。Object类是类层次结构的根。它是 Java 中所有类的超类。Object类在java.lang包中,后者又在java.base模块中。Object类包含在所有类中自动可用的方法。有些方法已经实现,有些方法实现为空。类也可以重新实现Object类中的一些方法。Object类的引用变量可以存储 Java 中任何引用类型的引用。

加载到 JVM 中的每个类型都由一个Class<T>类的实例来表示。Object类的getClass()方法返回调用该方法的Object类型的Class<T>对象的引用。

哈希代码是使用算法为一条信息计算的整数值。哈希代码也称为哈希和、哈希值或简称为哈希。从一条信息中计算整数的算法称为哈希函数。Object类包含一个hashCode()方法,该方法返回一个int,它是对象的哈希代码。此方法的默认实现通过将对象的内存地址转换为整数来计算对象的哈希代码。因为hashCode()方法是在Object类中定义的,所以它在 Java 的所有类中都可用。但是,您可以自由地在类中重写该实现。

宇宙中的每个对象都不同于所有其他对象,Java 程序中的每个对象都不同于所有其他对象。所有对象都有唯一的标识。一个对象被分配的内存地址可以被当作它的标识,这将使它总是唯一的。如果两个对象具有相同的标识(或 Java 术语中的引用),则它们是相同的。Java 中的等号运算符(==)比较两个对象的引用,测试它们是否相等。有时,如果两个对象基于它们的一些或所有实例变量具有相同的状态,您希望将它们视为相等。如果你想基于标准而不是引用(身份)来比较你的类中的两个对象是否相等,你的类需要重新实现Object类的equals()方法。Object类中的equals()方法的默认实现比较作为参数传递的对象和调用该方法的对象的引用。

有时,通常在调试时,以字符串形式表示对象是有帮助的,字符串形式应该包含足够的可读格式的对象状态信息。Object类的toString()方法允许您编写自己的逻辑,将类的对象表示为字符串。Object类提供了toString()方法的默认实现。它返回一个字符串,该字符串包含对象的完全限定类名和十六进制格式的对象哈希代码。

克隆一个对象意味着一点一点地复制对象的内容。Java 不提供自动机制来克隆(复制)对象。如果你想要你的类的对象被克隆,你必须在你的类中重新实现Object类的clone()方法。一旦你重新实现了clone()方法,你应该能够通过调用clone()方法来克隆你的类的对象。

有时,当对象被销毁时,该对象会使用需要释放的资源。垃圾收集器通过调用对象的finalize()方法,让您有机会在对象被销毁之前执行清理代码。该方法在Object类中声明,其默认实现不做任何事情。finalize()方法中的代码也被称为终结器。您需要在您的类中重新实现finalize()方法,并编写释放资源的逻辑。finalize()方法是有问题的,在 Java 9 中已经被否决了。还有许多其他技术可以用来释放对象持有的资源。

Java 7 在java.util包中增加了一个实用程序类Objects。Java 8 和 Java 9 给这个类增加了一些方法。Objects类中的方法根据它们执行的操作类型分为以下几类:对一个范围内的索引或子范围进行边界检查、比较对象、计算哈希代码、检查空值、验证构造器和方法参数,以及获取对象的字符串表示。这个类中的大多数方法都是用来优雅地处理null值的。

EXERCISES

  1. Java 中所有类的超类的全限定名是什么?

  2. java.lang.Object类的超类是什么?

  3. 说出三个在Object类中可用的方法,并简要描述它们的用法。

  4. 什么是哈希码?Java 里什么时候用?Object类中的什么方法用于返回对象的哈希代码?

  5. 如何使用==运算符比较两个对象?

  6. 如果你想基于状态而不是引用来比较类中对象的相等性,那么Object类的什么方法必须在你的类中被覆盖?

  7. Object类中equals()方法的默认实现是什么?

  8. 在 Java 中,下列语句是正确的吗?

    如果两个对象根据 equals(Object)方法是相等的,那么在这两个对象上调用 hashCode 方法必须产生相同的整数结果。

  9. 如果你的类覆盖了Object类的equals()方法,那么Object类的哪一个方法也应该被你的类覆盖?

  10. Java 中的对象克隆是什么?什么是浅层克隆和深层克隆?

  11. What method of the Object class do you need to override in your class to allow cloning of objects of your class? Create a Phone class with two fields as shown:

```java
// Phone.java
package com.jdojo.object.excercise;
public class Phone {
    private String areaCode;
    private String number;
}

```

在`Phone`类中实现`clone()`方法,这样就可以正确地克隆`Phone`对象。类中的两个实例变量都是必需的。
  1. 您需要覆盖Object类的什么方法来提供您的类的对象的字符串表示?通过实现toString()方法来增强Phone类。

  2. finalize()方法在一个类中有什么用?你应该使用finalize()方法来清理你的类的对象持有的资源吗?

  3. 什么是不可变对象和不可变类?使用不可变对象有什么好处?说出一个你经常使用的 Java 不可变类。

  4. 使用Objects类的方法实现hashCode()方法和Phone类的其他方法。例如,在Phone类的构造器和方法中使用Objects类的requireNonNull()方法来验证参数值。

  5. 如何定义一个没有参数的 lambda 表达式,并返回字符串“Hello world”?

  6. 编写以下代码片段中缺少的部分,它将打印简单名称和Phone类的完全限定名称:

```java
Phone p = new Phone();
Class cls = /* your code goes here */;
String simpleName = cls./* your code goes here */;
String fullyQualifedName = cls./* your code goes here */;
System.out.println("Simple class name: "  + simpleName);
System.out.println("Fully qualified name: "  + fullyQualifedName);

```

十二、包装类

在本章中,您将学习:

  • Java 中的包装类以及如何使用它们

  • 如何从字符串中获取原始值

  • 基元值如何在需要时自动装入包装对象

  • 包装对象如何在需要时自动解装箱成原始值

本章中的所有类都是一个jdojo.wrapper模块的成员,如清单 12-1 中所声明的。

// module-info.java
module jdojo.wrapper {
    exports com.jdojo.wrapper;
}

Listing 12-1The Declaration of a jdojo.wrapper Module

包装类

在前面的章节中,你已经知道了基本类型和引用类型是不兼容赋值的。您甚至不能将原始值与对象引用进行比较。Java 库的某些部分只能处理对象;例如,Java 中的集合只能处理对象。您不能创建原始值列表,如 1、3、8 和 10。您需要将原始值包装到对象中,然后才能将它们存储在列表或集合中。

自从 Java 第一次发布以来,原始值和引用值之间的赋值不兼容性就一直存在。Java 库在java.lang包中提供了八个类来代表八种基本类型中的每一种。这些类被称为包装类,,因为它们将原始值包装在一个对象中。表 12-1 列出了原语类型及其对应的包装类。注意包装类的名称。遵循 Java 命名类的惯例,它们以大写字母开头。

表 12-1

基本类型及其相应包装类的列表

|

原语类型

|

包装类

Byte Byte
Short Short
Int Integer
Long Long
Float Float
double Double
Char Character
boolean Boolean

所有的包装类都是不可变的。它们提供了三种创建对象的方法:

  • 使用构造器(已弃用)

  • 使用valueOf()工厂静态方法

  • 使用parseXxx()方法,其中Xxx是包装类的名称。它在Character级中不可用。

Tip

从 Java SE 9 开始,所有包装类中的所有构造器都被弃用,因为创建包装对象很少需要它们。被弃用意味着它们将在 Java 的未来版本中被删除。您应该使用其他方法,比如valueOf()parseXxx()方法,来创建它们的对象。

除了Character之外,每个包装器类都至少提供了两个构造器:一个接受相应的原始类型的值,另一个接受一个StringCharacter类只提供了一个接受char的构造器。包装类中的所有构造器都不推荐使用,因此应该避免使用。创建包装类对象的首选方式是使用它们的valueOf()静态方法。下面的代码片段使用一些包装类的valueOf()方法创建了它们的对象:

Integer intObj1 = Integer.valueOf(100);
Integer intObj2 = Integer.valueOf("1969");
Double doubleObj1 = Double.valueOf(10.45);
Double doubleObj2 = Double.valueOf("234.60");
Character charObj1 = Character.valueOf('A');

使用valueOf()方法为整数数值(byteshortintlong)创建对象可以更好地使用内存,因为该方法缓存了一些对象以供重用。这些原始类型的包装类缓存原始值在–128 和 127 之间的包装对象。例如,如果多次调用Integer.valueOf(25),则从缓存中返回同一个Integer对象的引用。然而,当您多次调用new Integer(25)时,每次调用都会创建一个新的Integer对象。清单 12-2 展示了为Integer包装类使用构造器和valueOf()方法的区别。

// CachedWrapperObjects.java
package com.jdojo.wrapper;
public class CachedWrapperObjects {
    public static void main(String[] args) {
        System.out.println("Using the constructor:");
        // Create two Integer objects using constructors
        Integer iv1 = new Integer(25);
        Integer iv2 = new Integer(25);
        System.out.println("iv1 = " + iv1 + ", iv2 = " + iv2);
        // Compare iv1 and iv2 references
        System.out.println("iv1 == iv2: " + (iv1 == iv2));
        // Let's see if they are equal in values
        System.out.println("iv1.equals(iv2): " + iv1.equals(iv2));
        System.out.println("\nUsing the valueOf() method:");
        // Create two Integer objects using the valueOf()
        Integer iv3 = Integer.valueOf(25);
        Integer iv4 = Integer.valueOf(25);
        System.out.println("iv3 = " + iv3 + ", iv4 = " + iv4);
        // Compare iv3 and iv4 references
        System.out.println("iv3 == iv4: " + (iv3 == iv4));
        // Let's see if they are equal in values
        System.out.println("iv3.equals(iv4): " + iv3.equals(iv4));
    }
}
Using the constructor:
iv1 = 25, iv2 = 25
iv1 == iv2: false
iv1.equals(iv2): true
Using the valueOf() method:
iv3 = 25, iv4 = 25
iv3 == iv4: true
iv3.equals(iv4): true

Listing 12-2The Difference Between Using Constructors and the valueOf() Method to Create Integer Objects

注意,iv1iv2是对两个不同对象的引用,因为iv1 == iv2返回false。然而,iv3iv4是对同一个对象的引用,因为iv3 == iv4返回true。当然,iv1iv2iv3iv4表示25的同一个原始值,如equals()方法返回值所示。通常,程序使用较小的整数。如果你正在包装更大的整数,那么valueOf()方法在每次被调用时都会创建一个新的对象。

Tip

new操作符总是创建一个新对象。如果您不需要原始值的新对象,请使用包装类的valueOf()工厂方法,而不是使用构造器。包装器类中的equals()方法已经被重新实现,以比较包装器对象中被包装的原始值,而不是它们的引用。

数字包装类

ByteShortIntegerLongFloatDouble类是数字包装类。都是继承自Number类。Number类被声明为抽象的。您不能创建Number类的对象。但是,您可以声明Number类的引用变量。您可以将六个数字包装类中任何一个的对象引用分配给Number类的引用变量。

Number类包含六个方法。它们被命名为xxxValue(),其中xxx是六种原始数据类型之一的名称(byteshortintlongfloatdouble)。方法的返回类型与xxx相同。也就是说,byteValue()方法返回一个byte,intValue()方法返回一个int等等。以下代码片段显示了如何从数字包装对象中检索不同的基元类型值:

// Creates an Integer object
Integer intObj = Integer.valueOf(100);
// Gets byte from Integer
byte b = intObj.byteValue();
// Gets double from Integer
double dd = intObj.doubleValue();
System.out.println("intObj = " + intObj);
System.out.println("byte from intObj = " + b);
System.out.println("double from intObj = " + dd);
// Creates a Double object
Double doubleObj = Double.valueOf("329.78");
// Gets different types of primitive values from Double
double d = doubleObj.doubleValue();
float f = doubleObj.floatValue();
int i = doubleObj.intValue();
long l = doubleObj.longValue();
System.out.println("doubleObj = " + doubleObj);
System.out.println("double from doubleObj = " + d);
System.out.println("float from doubleObj = " + f);
System.out.println("int from doubleObj = " + i);
System.out.println("long from doubleObj = " + l);
intObj = 100
byte from intObj = 100
double from intObj = 100.0
doubleObj = 329.78
double from doubleObj = 329.78
float from doubleObj = 329.78
int from doubleObj = 329
long from doubleObj = 329

Java 8 在一些数字包装类如IntegerLongFloatDouble中增加了一些方法如sum()max()min()。例如,Integer.sum(10, 20)只是返回 10 + 20 的结果。起初,你可能会想,“包装类的设计者除了添加这些琐碎的方法之外,难道没有任何有用的事情可做吗?我们是不是忘了用加法运算符+来加两个数,所以我们就用Integer.sum(10, 20)?”你的假设是错误的。这些方法是为了更大的目的而添加的。它们不打算用作Integer.sum(10, 20)。它们旨在用作处理集合的方法引用。

您的程序可能接收字符串形式的数字。您可能希望从这些字符串中获取原始值或包装对象。有时,字符串中的整数值可能以不同的基数(也称为基数)编码,例如十进制、二进制、十六进制等。包装类有助于处理包含原始值的字符串:

  • 使用valueOf()方法将字符串转换成包装对象。

  • 使用parseXxx()方法将字符串转换成原始值。

ByteShortIntegerLongFloatDouble类分别包含parseByte()parseShort()parseInt()parseLong()parseFloat()parseDouble()方法,用于将字符串解析为原始值。

以下代码片段将包含二进制格式的整数的字符串转换为一个Integer对象和一个int值:

String str = "01111111";
int radix = 2;
// Creates an Integer object from the string
Integer intObject = Integer.valueOf(str, radix);
// Extracts the int value from the string
int intValue = Integer.parseInt(str, 2);
System.out.println("str = " + str);
System.out.println("intObject = " + intObject);
System.out.println("intValue = " + intValue);
str = 01111111
intObject = 127
intValue = 127

Java 9 在IntegerLong类中添加了一些方法来解析内容不全是整数的字符串。下面是Integer类中这类方法的列表。Long类中的方法名以Long结尾,这些方法返回long。所有这些方法都抛出一个NumberFormatException:

  • int parseInt(CharSequence s, int beginIndex, int endIndex, int radix)

  • int parseUnsignedInt(CharSequence s, int beginIndex, int endIndex, int radix)

  • int parseUnsignedInt(String s)

  • int parseUnsignedInt(String s, int radix)

新版本的parseInt()方法将CharSequence参数(比如一个String)解析为指定radix中的有符号int,从指定的beginIndex开始,延伸到endIndex - 1。以下代码片段向您展示了如何使用新的parseInt()方法从字符串中的日期提取整数形式的年、月和日值,该字符串采用yyyy-mm-dd格式:

String dateStr = "2017-07-29";
int year = Integer.parseInt(dateStr, 0, 4, 10);
int month = Integer.parseInt(dateStr, 5, 7, 10);
int day = Integer.parseInt(dateStr, 8, 10, 10);

System.out.println("Year = " + year);
System.out.println("Month = " + month);
System.out.println("Day = " + day);
Year = 2017
Month = 7
Day = 29

三个版本的parseInt()方法将字符串解析为有符号整数,而三个版本的parseUnsignedInt()方法将字符串中的数字解析为指定基数的无符号整数。

所有数字包装类都包含几个有用的常量。它们的MIN_VALUEMAX_VALUE常量代表了它们对应的原语类型所能代表的最小值和最大值。例如,Byte.MIN_VALUE常量是–128,Byte.MAX_VALUE常量是 127,这是可以存储在byte中的最小值和最大值。它们还有一个SIZE常量,表示相应原语类型的变量所占的位数。比如Byte.SIZE是 8,Integer.SIZE是 32。

通常,您从外部来源(例如,文件)接收字符串。如果字符串不能转换成数字,包装类将抛出一个NumberFormatException。通常将字符串解析逻辑放在try-catch块中并处理异常。

下面的代码片段试图将两个字符串解析成double值。第一个字符串包含一个有效的double,第二个包含一个无效的double。当调用parseDouble()方法解析第二个字符串时,抛出一个NumberFormatException:

String str1 = "123.89";
try {
    double value1 = Double.parseDouble(str1);
    System.out.println("value1 = " + value1);
} catch (NumberFormatException e) {
    System.out.println("Error in parsing " + str1);
}
String str2 = "78H.90"; // An invalid double
try {
    double value2 = Double.parseDouble(str2);
    System.out.println("value2 = " + value2);
} catch (NumberFormatException e) {
    System.out.println("Error in parsing " + str2);
}
value1 = 123.89
Error in parsing 78H.90

Note

java.math包包含了BigDecimalBigInteger类。它们用于保存大的十进制数和整数,它们不适合原始类型doublelong。这些类是可变的,它们通常不被称为包装类。如果您对大数字执行计算,并且不希望丢失超出标准基本类型范围的中间值,请使用它们。

字符包装类

Character类的一个对象包装了一个char值。该类包含几个在处理字符时很有用的常量和方法。例如,它包含isLetter()isDigit()方法来检查字符是字母还是数字。toUpperCase()toLowerCase()方法将一个字符转换成大写和小写。值得研究一下这个类的 API 文档。该类提供了一个构造器和一个工厂valueOf()方法来从char创建对象。使用工厂方法以获得更好的性能。charValue()方法返回对象包装的char。以下代码片段显示了如何创建Character对象以及如何使用它们的一些方法:

// Using the constructor
Character c1 = new Character('A');
// Using the factory method - preferred
Character c2 = Character.valueOf('2');
Character c3 = Character.valueOf('ñ');
// Getting the wrapped char values
char cc1 = c1.charValue();
char cc2 = c2.charValue();
char cc3 = c3.charValue();
System.out.println("c1 = " + c1);
System.out.println("c2 = " + c2);
System.out.println("c3 = " + c3);
// Using some Character class methods on c1
System.out.println("isLowerCase c1  = " + Character.isLowerCase(cc1));
System.out.println("isDigit c1  = " + Character.isDigit(cc1));
System.out.println("isLetter c1  = " + Character.isLetter(cc1));
System.out.println("Lowercase of c1  = " + Character.toLowerCase(cc1));
// Using some Character class methods on c2
System.out.println("isLowerCase c2  = " + Character.isLowerCase(cc2));
System.out.println("isDigit c2  = " + Character.isDigit(cc2));
System.out.println("isLetter c2  = " + Character.isLetter(cc2));
System.out.println("Lowercase of c2  = " + Character.toLowerCase(cc2));
System.out.println("Uppercase of c3  = " + Character.toUpperCase(cc3));
c1 = A
c2 = 2

c3 = ñ
isLowerCase c1  = false
isDigit c1  = false
isLetter c1  = true
Lowercase of c1  = a
isLowerCase c2  = false
isDigit c2  = true
isLetter c2  = false
Lowercase of c2  = 2
Uppercase of c3  = Ñ

布尔包装类

一个Boolean类的对象包装了一个booleanBoolean.TRUEBoolean.FALSE是两个Boolean类型的常量,代表布尔值truefalse。您可以使用构造器或valueOf()工厂方法创建一个Boolean对象。解析字符串时,该类将“true”(忽略所有字符的大小写)视为true,将任何其他字符串视为false。您应该总是使用这个类的valueOf()方法来创建一个Boolean对象,因为它返回的是Boolean.TRUEBoolean.FALSE常量,而不是创建新的对象。下面的代码片段展示了如何使用Boolean类。每个语句中的变量名表示在Boolean对象中表示的布尔值(truefalse)的类型:

// Using constructors
Boolean b11True = new Boolean(true);
Boolean b21True = new Boolean("true");
Boolean b31True = new Boolean("tRuE");
Boolean b41False = new Boolean("false");
Boolean b51False = new Boolean("how is this"); // false
// Using the factory methods
Boolean b12True = Boolean.valueOf(true);
Boolean b22True = Boolean.valueOf("true");
Boolean b32True = Boolean.valueOf("tRuE");
Boolean b42False = Boolean.valueOf("false");
Boolean b52False = Boolean.valueOf("how is this"); // false
// Getting a boolean value from a Boolean object
boolean bbTrue = b12True.booleanValue();
// Parsing strings to boolean values
boolean bTrue = Boolean.parseBoolean("true");
boolean bFalse = Boolean.parseBoolean("This string evaluates to false");
// Using constants
Boolean bcTrue    = Boolean.TRUE;
Boolean bcFalse = Boolean.FALSE;
// Printing some Boolean objects
System.out.println("bcTrue = " + bcTrue);
System.out.println("bcFalse = " + bcFalse);
bcTrue = true
bcFalse = false

无符号数字运算

Java 不支持无符号原始整数数据类型。byteshortintlong是有符号数据类型。对于有符号数据类型,值范围的一半用于存储正数,另一半用于存储负数,因为 1 位用于存储值的符号。比如一个byte需要 8 位;其范围是–128 到 127。如果在一个字节中只存储正数,它的范围应该是 0–255。

Java 8 在包装类中添加了一些静态方法,这些方法支持将有符号值中的位视为无符号整数的操作。Byte类包含两个静态方法:

  • int toUnsignedInt(byte x)

  • long toUnsignedLong(byte x)

这些方法将指定的字节参数转换为一个int和一个long,就好像该字节存储了一个无符号值一样。如果指定的byte参数为零或正数,转换后的intlong值将与参数值相同。如果参数是负数,转换后的数字将是 2 8 + x。例如,对于 10 的输入,返回值将是 10,对于–10 的输入,返回值将是 28+(–10),即 246。负数以 2 的补码形式存储。值–10 将存储为 11110110。最高有效位 1 表示它是一个负数。前 7 位(1110110)的二进制补码是 001010,十进制数是 10。如果将一个字节中的实际位 11110110 视为无符号整数,则其值为 246 (128 + 64 + 32 + 16 + 0 + 4 + 2 + 0)。以下代码片段显示了如何获取以无符号整数形式存储在字节中的值:

byte b = -10;
int x = Byte.toUnsignedInt(b);
System.out.println("Signed value in byte = " + b);
System.out.println("Unsigned value in byte = " + x);
Signed value in byte = -10
Unsigned value in byte = 246

Short类包含与Byte类相同的两个方法,除了它们接受一个short作为参数并将其转换为一个int和一个long。Java 9 在Short类中添加了一个新的静态方法compareUnsigned(short x, short y),该方法比较两个短值,并将这些值视为无符号值。如果x等于y,则返回 0;如果x小于y,则小于 0 的值为无符号值;以及如果x大于y则大于 0 的值作为无符号值。

Integer类包含以下静态方法来支持无符号操作和转换:

  • int compareUnsigned(int x, int y)

  • int divideUnsigned(int dividend, int divisor)

  • int parseUnsignedInt(String s)

  • int parseUnsignedInt(String s, int radix)

  • int remainderUnsigned(int dividend, int divisor)

  • long toUnsignedLong(int x)

  • String toUnsignedString(int i)

  • String toUnsignedString(int i, int radix)

注意,Integer类不包含addUnsigned()subtractUnsigned()multiplyUnsigned()方法,因为这三个操作在两个有符号和两个无符号操作数上是按位相同的。下面的代码片段显示了对两个int变量的除法运算,就好像它们的位代表无符号值一样:

// Two negative ints
int x = -10;
int y = -2;
// Performs signed division
System.out.println("Signed x = " + x);
System.out.println("Signed y = " + y);
System.out.println("Signed x/y = " + (x/y));
// Performs unsigned division by treating x and y holding unsigned values
long ux = Integer.toUnsignedLong(x);
long uy = Integer.toUnsignedLong(y);
int uQuotient = Integer.divideUnsigned(x, y);
System.out.println("Unsigned x = " + ux);
System.out.println("Unsigned y = " + uy);
System.out.println("Unsigned x/y = " + uQuotient);
Signed x = -10
Signed y = -2
Signed x/y = 5
Unsigned x = 4294967286
Unsigned y = 4294967294
Unsigned x/y = 0

Long类包含执行无符号运算的方法。这些方法类似于Integer类中的方法。请注意,您不能将存储在long中的值转换为无符号值,因为您需要比long数据类型提供的更大的存储空间才能这样做,但是long是 Java 提供的最大的整数数据类型。这就是ByteShort类有toUsignedInt()toUnSignedLong()方法的原因,因为intlongbyteshort大。事实上,要将有符号数据类型X的值作为无符号值存储在有符号数据类型Y中,数据类型Y的大小至少需要两倍于X的大小。遵循这个存储要求,在Integer类中有一个toUnsignedLong()方法,但是在Long类中没有这样的方法。

汽车爆炸和脱氧核糖核酸病毒

自动装箱和取消装箱用于在原始数据类型和它们对应的包装类之间自动转换值。它们完全在编译器中实现。在我们定义自动装箱/取消装箱之前,让我们讨论一个例子。这个例子很简单,但是它的目的是演示在 Java 5 中添加自动装箱功能之前,当您在原始类型和它们的包装对象之间进行转换时,您必须经历的痛苦,反之亦然。

假设您有一个方法,它接受两个int值,将它们相加,然后返回一个int值。你可能会说,“这个方法有什么大不了的?”应该像下面这样简单:

// Only method code is shown
public static int add(int a, int b) {
    return a + b;
}

该方法可以如下使用:

int a = 200;
int b = 300;
int result = add(a, b); // result will get a value of 500

你是对的,这种方法没有什么大不了的。让我们给逻辑增加一点扭曲。考虑用同样的方法处理Integer对象而不是int值。以下是相同方法的代码:

public static Integer add(Integer a, Integer b) {
    int aValue = a.intValue();
    int bValue = b.intValue();
    int resultValue = aValue + bValue;
    Integer result = Integer.valueOf(resultValue);
    return result;
}

你注意到当你改变同样的方法来使用Integer对象时所涉及的复杂性了吗?您必须执行三件事情来在Integer对象中添加两个int值:

  • 使用intValue()方法将方法参数abInteger对象展开为int值:

  • 将两个int值相加:

int aValue = a.intValue();
int bValue = b.intValue();

  • 将结果包装到一个新的Integer对象中,并返回结果:
int resultValue = aValue + bValue;

Integer result = Integer.valueOf(resultValue);
return result;

清单 12-3 有完整的代码来演示add()方法的使用。

// MathUtil.java
package com.jdojo.wrapper;
public class MathUtil {
    public static Integer add(Integer a, Integer b) {
        int aValue = a.intValue();
        int bValue = b.intValue();
        int resultValue = aValue + bValue;
        Integer result = Integer.valueOf(resultValue);
        return result;
    }
    public static void main(String[] args) {
        int iValue = 200;
        int jValue = 300;
        int kValue;
        /* will hold result as int */
        // Box iValue and jValue into Integer objects
        Integer i = Integer.valueOf(iValue);
        Integer j = Integer.valueOf(jValue);
        // Store returned value of the add() method in an Integer object k
        Integer k = MathUtil.add(i, j);
        // Unbox Integer object's int value into kValue int variable
        kValue = k.intValue();
        // Display the result using int variables
        System.out.println(iValue + " + " + jValue + " = " + kValue);
    }
}
200 + 300 = 500

Listing 12-3Adding Two int Values Using Integer Objects

请注意将两个int值相加所需的代码量。对于 Java 开发人员来说,将一个int值打包/解包到一个Integer,反之亦然,是一件痛苦的事情。Java 设计者意识到了这一点(尽管为时已晚),他们为您自动化了这个包装和解包过程。

从一个原始数据类型(byteshortintlongfloatdoublecharboolean)到其对应的包装对象(ByteIntegerLongFloatDoubleCharacterBoolean)的自动包装被称为自动装箱。相反,从包装对象到其相应的原始数据类型值的解包装,被称为解装箱。对于自动装箱/取消装箱,以下代码是有效的:

Integer n = 200; // Boxing
int a = n;       // Unboxing

编译器将用下面的语句替换前面的语句:

Integer n = Integer.valueOf(200);
int a = n.intValue();

清单 12-3 中列出的MathUtil类的main()方法中的代码可以重写如下。装箱和取消装箱是自动完成的:

int iValue = 200;
int jValue = 300;
int kValue = MathUtil.add(iValue, jValue);
System.out.println(iValue + " + " + jValue + " = " + kValue);

Tip

自动装箱/取消装箱是在编译代码时执行的。JVM 完全不知道编译器执行的装箱和拆箱操作。

小心空值

自动装箱/取消装箱使您不必编写额外的代码行。这也让你的代码看起来更整洁。然而,它确实带来了一些惊喜。其中一个惊喜是在你意想不到的地方得到一个NullPointerException。基本类型不能被赋予一个null值,而引用类型可以有一个null值。装箱和取消装箱发生在基本类型和引用类型之间。请看下面的代码片段:

Integer n = null; // n can be assigned a null value
int a = n;        // will throw NullPointerException at run time

在这段代码中,假设您不控制nulln的赋值。作为方法调用的结果,您可能会得到一个null Integer对象,例如int a = getSomeValue(),其中getSomeValue()返回一个Integer对象。在这样的地方,你可能会大吃一惊。然而,它会发生,因为在这种情况下int a = n被转换为int a = n.intValue()并且nnull。这个惊喜是你从自动装箱/拆箱中获得的优势的一部分,你需要意识到这一点。

重载方法和自动装箱/取消装箱

当您调用一个重载方法并希望依赖自动装箱/取消装箱特性时,您会有一些惊讶。假设一个类中有两个方法:

public void test(Integer iObject) {
    System.out.println("Integer=" + iObject);
}
public void test(int iValue) {
    System.out.println("int=" + iValue);
}

假设您对test()方法进行了两次调用:

test(101);
test(Integer.valueOf(101));

以下哪一项将是输出?

int=101
Integer=101

或者

Integer=101
int=101

使用自动装箱/取消装箱的方法调用规则遵循两步过程:

  1. 如果传递的实际参数是原始类型(如在test(10)中)

    1. 尝试找到一个具有原始类型参数的方法。如果没有完全匹配,请尝试扩大基本类型以找到匹配。

    2. 如果上一步失败,将原始类型装箱并尝试寻找匹配。

  2. 如果传递的实际参数是引用类型(如在test(Integer.valueOf(101)中)

    1. 尝试找到一个带有引用类型参数的方法。如果匹配,调用该方法。在这种情况下,匹配不一定要精确。它应该遵循子类型和超类型分配规则。

    2. 如果上一步失败,将引用类型取消装箱到相应的基元类型,并尝试查找精确匹配,或者扩大基元类型并查找匹配。

如果您将这些规则应用于前面的代码片段,它将打印如下内容:

int=101
Integer=101

假设你有以下两个test()方法:

public void test(Integer iObject) {
    System.out.println("Integer=" + iObject);
}
public void test(long iValue) {
    System.out.println("long=" + iValue);
}

如果使用下面的代码,会打印出什么?

test(101);
test(Integer.valueOf(101));

它将打印以下内容:

long=101
Integer=101

test(101)的第一次调用将试图为一个int参数找到一个精确匹配。它没有找到方法test(int),所以它扩展了int数据类型,找到一个匹配的test(long),并调用这个方法。假设您有如下两个test()方法:

public void test(Long lObject) {
    System.out.println("Long=" + lObject);
}
public void test(long lValue) {
    System.out.println("long=" + lValue);
}

如果执行下面的代码,会打印出什么?

test(101);
test(Integer.valueOf(101));

它将打印以下内容:

long=101
long=101

看到这个输出,你感到惊讶吗?应用我列出的规则,您会发现这个输出遵循了这些规则。对test(101)的调用是清楚的,因为它将 101 从int扩展到long并执行test(long)方法。为了调用test(Integer.valueOf(101)),它寻找一个方法test(Integer),但是没有找到。也就是说,一个Integer永远不会被加宽到Long。因此,它取消Integerint的装箱,并寻找一个test(int)方法,但没有找到。现在,它加宽了int并找到test(long)并执行它。

考虑以下三种test()方法。我在前面的方法列表中添加了一个test(Number nObject)方法:

public void test(Long lObject) {
    System.out.println("Long=" + lObject);
}
public void test(Number nObject) {
    System.out.println("Number=" + nObject);
}
public void test(long lValue) {
    System.out.println("long=" + lValue);
}

如果执行下面的代码,会打印出什么?

test(101);
test(Integer.valueOf(101));

它将打印以下内容:

long=101
Number=101

看输出是不是又惊了?应用我列出的规则。对test(101)的调用是明确的。为了调用test(Integer.valueOf(101)),它寻找一个方法test(Integer),但是没有找到。它试图根据规则列表中的规则 2(a)将Integer参数扩展为NumberObject。回想一下,所有数字包装类都是从Number类继承的。所以可以将一个Integer赋值给一个Number类型的变量。它在第二个test(Number nObject)方法中找到一个匹配并执行它。

我还有一个惊喜给你。考虑以下两种test()方法:

public void test(Long lObject) {
    System.out.println("Long=" + lObject);
}
public void test(Object obj) {
    System.out.println("Object=" + obj);
}

当你执行下面的代码时会打印出什么?

test(101);
test(Integer.valueOf(101));

这一次,您将获得以下输出:

Object=101
Object=101

有意义吗?不完全是。下面是解释。当它调用test(101)时,它必须将int装箱为Integer,因为test(int)没有匹配项,即使在扩大了int值之后。于是test(101)变成了test(Integer.valueOf(101))。现在它也找不到任何test(Integer)。注意Integer是一个引用类型,它继承了Number类,后者又继承了Object类。因此,一个Integer总是一个Object,Java 允许你将一个子类型(Integer)的对象赋给一个超类型(Object)的变量。这就是在这种情况下调用test(Object)的原因。第二个调用test(Integer.valueOf(101)),工作方式相同。它尝试使用test(Integer)方法。当它没有找到它时,基于引用类型的子类型和超类型分配规则,它的下一个匹配是test(Object)

比较运算符和自动装箱/取消装箱

本节讨论比较操作==>>=<<=。只有==(逻辑等式运算符)可以同时用于引用类型和原始类型。其他运算符只能用于基元类型。

我们先来看看容易的(>, >=, <,和<=))。如果一个数值包装对象与这些比较操作符一起使用,它必须被取消装箱,并且在比较中必须使用相应的基元类型。考虑以下代码片段:

Integer a = 100;
Integer b = 100;
System.out.println("a : " + a);
System.out.println("b : " + b);
System.out.println("a > b: " + (a > b));
System.out.println("a >= b: " + (a >= b));
System.out.println("a < b: " + (a < b));
System.out.println("a <= b: " + (a <= b));
a : 100
b : 100
a > b: false
a >= b: true
a < b: false
a <= b: true

这个输出没有任何惊喜。如果用这些比较运算符混合引用和原语这两种类型,仍然会得到相同的结果。首先,取消对引用类型的装箱,并与两个基元类型进行比较,例如:

if (101 > Integer.valueOf(100)) {
    // Do something
}

被转换为

if(101 <= (Integer.valueOf(100)).intValue()) {
    // Do something
}

现在,让我们讨论一下==操作符和自动装箱规则。如果两个操作数都是基元类型,则使用值比较将它们作为基元类型进行比较。如果两个操作数都是引用类型,则比较它们的引用。在这两种情况下,不会发生自动装箱/取消装箱。当一个操作数是引用类型,而另一个是基元类型时,引用类型被取消装箱为基元类型,并进行值比较。让我们看看每种类型的例子。

考虑下面的代码片段。这是一个对==操作符使用两种基本类型操作数的例子:

int a = 100;
int b = 100;
int c = 505;
System.out.println(a == b); // will print true
System.out.println(a == c); // will print false

考虑以下代码片段:

Integer aa = Integer.valueOf(100);
Integer bb = new Integer(100);
Integer cc = new Integer(505);
System.out.println(aa == bb); // will print false
System.out.println(aa == cc); // will print false

在这段代码中,没有发生自动装箱/取消装箱。这里,aa == bbaa == cc比较的是aabbcc的引用,而不是它们的值。用new操作符创建的每个对象都有一个唯一的参考。现在,这里有一个惊喜:考虑下面的代码片段。这一次您依赖于自动装箱:

Integer aaa = 100;              // Boxing – Integer.valueOf(100)
Integer bbb = 100;              // Boxing – Integer.valueOf(100)
Integer ccc = 505;              // Boxing – Integer.valueOf(505)
Integer ddd = 505;              // Boxing – Integer.valueOf(505)
System.out.println(aaa == bbb); // will print true
System.out.println(aaa == ccc); // will print false
System.out.println(ccc == ddd); // will print false

您使用了aaabbbcccddd作为参考类型。aaa == bbb true怎么样,而ccc == ddd false呢?好吧。这一次,自动装箱功能没有带来任何惊喜。相反,它来自于Integer.valueOf()方法。对于–128 和 127 之间的所有值,Integer类缓存Integer对象引用。当您调用它的valueOf()方法时,就会用到缓存。例如,如果您调用Integer.valueOf(100)两次,那么您将从缓存中获得同一个Integer对象的引用,该引用表示int值为 100。但是,如果您调用Integer.valueOf(n),其中n在范围–128 到 127 之外,那么每次调用都会创建一个新对象。这就是aaabbb具有来自缓存的相同引用,而cccddd具有不同引用的原因。ByteShortCharacterLong类也缓存-128 到 127 范围内的值的对象引用。

收集和自动装箱/拆箱

自动装箱/取消装箱有助于您处理集合。集合仅适用于引用类型。不能在集合中使用基元类型。如果要在集合中存储基元类型,必须在存储基元值之前对其进行包装,并在检索后对其进行解包装。假设你有一个List,你想在其中存储整数。你应该这样做:

List list = new ArrayList();
list.add(Integer.valueOf(101));
Integer a = (Integer) list.get(0);
int aValue = a.intValue();

你又回到了起点。List接口的add()get()方法与Object类型一起工作,并且您必须再次求助于包装和解开原始类型。自动装箱/取消装箱可以帮助您将基元类型包装为引用类型,这段代码可以重写如下:

List list = new ArrayList();
list.add(101);                    // Autoboxing will work here
Integer a = (Integer) list.get(0);
int aValue = a.intValue();
/*int aValue = list.get(0); */    // auto-unboxing won't compile

因为get()方法的返回类型是Object,所以这段代码中的最后一条语句将不起作用。注意,拆箱是从一个原始包装器类型(比如Integer)到其对应的原始类型(比如int)进行的。如果你试图将一个Object引用类型赋给一个int原始类型,自动拆箱不会发生。事实上,您的代码甚至无法编译,因为不允许Objectint的转换。

尝试以下代码:

List<Integer> list = new ArrayList<>();
list.add(101);            // autoboxing will work
int aValue = list.get(0); // auto-unboxing will work, too

所有集合类都是通用的。它们声明了形式类型参数。在创建List对象时,在尖括号(<Integer>)中指定Integer类型,告诉编译器List将只保存一个Integer类型的对象。这使得编译器可以在您处理List对象时自由地包装和展开您的原始int值。

摘要

对于每种原始数据类型,Java 都提供了一个类来将原始数据类型的值表示为对象。Java 不支持无符号原始数字数据类型和无符号数字运算。Java 8 通过在包装类中添加一些方法,增加了对原始数据类型的无符号操作的有限支持。Java 9 在IntegerLong类中添加了一些方法,将字符串解析为无符号整数。Java 9 还在Short类中添加了一个方法,将两个short值作为无符号short值进行比较。

Java 不允许在同一个表达式中混合原始类型和引用类型的值。将原始值转换成它们的包装对象是不方便的,反之亦然。Java 5 增加了对根据上下文自动将原始值转换为包装对象的支持,反之亦然。这个特性叫做自动装箱/拆箱。例如,它允许将整数 25 赋给Integer对象的引用;编译器使用表达式Integer.valueOf(25)自动将整数 25 装入包装对象中。

QUESTIONS AND EXERCISES

  1. Java 中的包装类是什么?命名以下原始类型的包装类:byteintlongchar

  2. 使用包装类Integer,打印int数据类型的最大值和最小值。

  3. 数值包装类的超类的名字是什么?

  4. 假设你有一个字符串"1969"。完成下面的代码片段,将字符串中的整数值存储到一个int变量和一个Integer对象中:

    String str = "1969";
    int value = /* Your code goes here */;
    Integer object = /* Your code goes here */;
    
    
  5. 您有一个字符串"7B1",它包含一个十六进制格式的整数。使用Integer类解析并将其值存储在int变量中。

  6. 下面的代码片段可以编译吗?如果会,请描述规则/原因:

  7. 您有一个 1969 的整数值,您想以十六进制格式打印它的值。完成以下代码片段来实现这一点:

    int x = 1969;
    String str = Integer./* your code goes here */;
    System.out.println("1969 in hex is " + str);
    
    
  8. 为什么下面的语句不能编译

Integer x = 19;

Double x = 1969;

而下面的说法呢?

double y = 1969;

一定要明白这些说法无效和有效背后的原因。描述以下语句是如何编译的:

  1. 以下代码片段的输出会是什么?解释你的答案:

    Number x = 1969;
    System.out.println(x.getClass().getSimpleName());
    
    
  2. 当下面的代码片段运行时,输出会是什么?

    Double x = 128.5;
    System.out.println(x.intValue());
    System.out.println(x.byteValue());
    
    
Number x = 1969;

十三、异常处理

在本章中,您将学习:

  • 使用异常在 Java 中处理错误

  • 如何使用try-catch块处理异常

  • 如何使用finally块清理资源

  • 检查异常和未检查异常的区别

  • 如何创建新的异常类型并使用它

  • 如何使用try-catch-resources块来使用可自动关闭的资源

  • 如何访问线程的堆栈帧

  • 如何获取方法调用方的类名

本章中的所有类都是一个jdojo.exception模块的成员,如清单 13-1 中所声明的。

// module-info.java
module jdojo.exception {
    exports com.jdojo.exception;
}

Listing 13-1The Declaration of a jdojo.exception Module

什么是例外?

异常是在 Java 程序执行期间,当没有定义正常执行路径时可能出现的情况。例如,Java 程序可能会遇到试图将整数除以零的数值表达式。在执行以下代码片段的过程中,可能会出现这种情况:

int x = 10, y = 0, z;
z = x / y; // Divide-by-zero

语句z = x / y试图将x除以y。因为y为零,所以x / y的结果没有在 Java 中定义。注意一个浮点数除以零,比如9.5 / 0.0,是定义好的,是无穷大。一般来说,异常情况,比如一个整数被零除,可以这样表述:

Java 程序试图将一个整数被零除时出错。

Java 编程语言以不同的方式描述了前面的错误条件。在 Java 中有这样的说法:

当 Java 程序试图将一个整数除以零时抛出异常。

实际上,这两种说法的意思是一样的。它们意味着程序中出现了异常情况。程序出现异常情况后会发生什么?你需要处理这样的异常情况。处理它的方法之一是在执行操作之前检查所有可能导致异常情况的可能性。您可以重写前面的代码,如下所示:

int x = 10, y = 0, z;
if (y == 0) {
    // Report the abnormal/error condition here
} else {
    // Perform division here
    z = x / y;
}

您可能会注意到这段代码做了两件事:它处理错误条件并执行预期的操作。它混合了执行错误处理的代码和操作。一行代码(z = x / y)已经膨胀到至少五行代码。这是一个简单的例子。当错误处理代码与执行操作的实际代码混合在一起时,您可能没有完全意识到真正的问题。

为了弄清楚这个问题,考虑另一个例子。假设您想要编写更新员工工资的 Java 代码。雇员的记录储存在数据库里。伪代码可能如下所示:

Connect to the database
Fetch the employee record
Update the employee salary
Commit the changes

实际的代码将执行这四个动作。这四个操作中的任何一个都可能导致错误。例如,您可能无法连接到数据库,因为数据库已关闭;由于某些验证失败,您可能无法提交更改。您需要在执行一个操作之后和后续操作开始之前执行错误检查。带有错误检查的伪代码可能如下所示:

// Connect to the database
if (connected to the database successfully) {
    // Fetch the employee record
    if (employee record fetched) {
        // Update the employee salary
        if (update is successful) {
            // Commit the changes
            if (commit was successful ) {
                // Employee salary was saved successfully
            } else {
                // An error. Save failed
            }
        } else {
            //An error. Salary could not be updated
        }
    } else {
        // An error. Employee record does not exist
    }
} else {
    // An error. Could not connect to the database
}

请注意,当您在四行伪代码中添加错误处理时,代码膨胀到了 20 多行。这段代码最糟糕的地方在于,执行动作的代码被错误处理代码弄得乱七八糟。它还引入了许多嵌套的if-else语句,导致代码杂乱无章。

在上两个例子中,您看到了使用if-else语句处理错误的方式并不优雅且不可维护。Java 有一个更好的处理错误的方法:将执行动作的代码与处理错误的代码分开。在 Java 中,我们用短语“异常”而不是“错误”来表示程序中的异常情况;使用短语“异常处理”代替短语“错误处理”一般来说,我们说一个错误发生了,你处理它。在 Java 中,我们说抛出一个异常,然后你捕获它。这就是为什么异常处理也被称为捕捉异常的原因。处理异常的代码被称为异常处理程序。您可以使用 Java 语法重写前面的伪代码(虽然不是完整的 Java 代码),如下所示:

try {
    // Connect to the database
    // Fetch employee record
    // Update employee salary
    // Commit the changes
} catch(DbConnectionException e1){
    // Handle DB Connection exception here
} catch(EmpNotFoundException e2){
    // Handle employee not found exception here
} catch(UpdateFailedException e3){
    // Handle update failed exception here
} catch(CommitFailedException e4){
    // Handle commit failed exception here
}

你不需要完全理解前面的伪代码。我们将很快讨论细节。您需要观察代码的结构,它允许执行动作的代码与处理异常的代码分离。执行动作的代码放在try块中,处理异常的代码放在catch块中。您将会发现,与之前编写许多if-else语句来达到相同效果的尝试相比,这段代码在优雅性和可维护性方面要好得多。

Tip

在 Java 中,会抛出并捕获一个异常。捕捉异常与处理异常是一样的。执行该操作的代码可能会引发异常,而处理该异常的代码会捕获所引发的异常。这种类型的异常处理允许您将执行操作的代码与处理在执行操作时可能出现的异常的代码分开。

异常是一个对象

代码的异常处理部分如何知道发生在代码另一部分的异常?当异常发生时,Java 创建一个对象,该对象包含关于异常的所有信息(例如,异常的类型、代码中发生异常的行号等)。)并将其传递给适当的异常处理程序。术语“异常”用于表示两种情况之一——异常条件和表示异常条件的 Java 对象。该术语的含义将从上下文中显而易见。当我们谈论抛出异常时,我们谈论的是三件事:

  • 异常情况的发生

  • 创建一个 Java 对象来表示异常情况

  • 将异常对象抛出(或传递)给异常处理程序

异常的抛出与将对象引用传递给方法是一样的。在这里,您可以将异常处理程序想象成一个接受异常对象引用的方法。异常处理程序捕获异常对象并采取适当的措施。您可以将异常处理程序捕获的异常视为没有返回的方法调用,其中异常对象的引用是方法的实际参数。Java 还允许您创建自己的表示异常的对象,然后抛出它。

Tip

Java 中的异常是一个封装了程序中错误细节的对象。

使用 try-catch 块

在我们讨论try-catch块之前,让我们编写一个 Java 程序,试图将一个整数除以零,如清单 13-2 所示。

// DivideByZero.java
package com.jdojo.exception;
public class DivideByZero {
    public static void main(String[] args) {
        int x = 10, y = 0, z;
        z = x / y;
        System.out.println("z = " + z);
    }
}
Exception in thread "main" java.lang.ArithmeticException: / by zero
    at com.jdojo.exception.DivideByZero.main(DivideByZero.java:7)

Listing 13-2A Java Program Attempting to Divide an Integer by Zero

您是否期待清单 13-2 的输出?它表示在运行DivideByZero类时发生了异常。输出包含四条信息:

  • 它包括发生异常的线程的名称。线程的名字是"main"

  • 它包括已发生的异常的类型。异常的类型由异常对象的类名指示。在这种情况下,java.lang.ArithmeticException是异常的类名。Java 运行时创建这个类的对象,并将其引用传递给异常处理程序。

  • 它包括一条消息,描述代码中导致错误的异常情况。在这种情况下,消息是“/除以零”(读作“除以零”)。

  • 它包括异常发生的位置。输出中的第二行表明异常发生在com.jdojo.exception.DivideByZero类的main()方法中。源代码包含在DivideByZero.java文件中。源代码中导致异常的行号是 7。

您可能会注意到,在短短两行输出中,Java 运行时打印出了足够多的信息来帮助您跟踪代码中的错误。

当执行第 7 行的z = x / y时,Java 运行时检测到异常情况,这是试图将一个整数除以零。它用关于异常的所有相关信息创建一个类为ArithmeticException的新对象,然后将这个对象抛出(或传递)给一个异常处理程序。在这种情况下,谁捕捉(或处理)了异常?您没有在代码中指定任何异常处理程序。事实上,此时您甚至不知道如何指定异常处理程序。因为在这种情况下没有指定异常处理程序,所以 Java 运行时为您处理了异常。Java 运行时处理 Java 程序中抛出的所有异常吗?答案是肯定的。Java 运行时处理 Java 程序中的所有异常。但是,只有当您自己不处理异常时,它才会处理异常。

如果出现异常,而 Java 运行时没有找到程序员定义的异常处理程序来处理它,这样的异常被称为未捕获异常。所有未捕获的异常都由 Java 运行时处理。因为一个未被捕获的异常总是由 Java 运行时来处理的,为什么还要担心在程序中提供任何异常处理程序呢?这是一个有趣的观点。为什么您需要担心 Java 运行时会为您做的事情呢?如果你懒得收拾自己的烂摊子(处理自己的错误状况),那么有一个坏消息要告诉你。您不应该对 Java 运行时期望过高。您可能不喜欢运行时为您处理异常的方式。它捕获未捕获的异常,在标准错误上打印错误堆栈,并暂停 Java 应用程序。换句话说,如果您让 Java 运行时处理所有的异常,那么您的程序会在异常发生的地方停止执行。这是你想做的吗?答案是否定的。有时,在处理完异常后,您可能想继续执行程序的其余部分,而不是暂停程序。当您运行DivideByZero类时,语句z = x / y中的表达式x / y导致了一个异常。Java 没有执行完语句z = x / y。有时这种情况被称为“语句z = x / y异常完成”运行时处理了异常,但它停止执行整个程序。这就是您在程序中看不到以下语句输出的原因:

System.out.println("z = " + z);

现在你知道让运行时处理你的异常并不总是一个好主意。如果您想自己处理异常,您需要将代码放在一个try块中。一个try块如下所示:

try {
    // Code for the try block goes here
}

一个try块以关键字try开始,后面跟着一个左括号和一个右括号。try块的代码放在左大括号和右大括号内。

一个try块不能单独使用。其后必须是一个或多个catch模块或一个finally模块或两者的组合。为了处理可能由try块中的代码抛出的异常,您需要使用一个catch块。一个catch模块可用于处理多种类型的异常。现在,我将只关注处理catch块中的一种类型的异常;我将在单独的章节中讲述如何在catch块中处理多个异常。catch块的语法类似于方法的语法:

catch (ExceptionClassName parameterName) {
    // Exception handling code goes here
}

注意,catch块的声明类似于方法声明。它以关键字catch开头,后跟一对括号。在括号中,您声明一个参数,就像在方法中一样。参数类型是它应该捕获的异常类的名称。parameterName是用户给定的名字。括号后面是左大括号和右大括号。异常处理代码放在大括号内。当抛出异常时,异常对象的引用被复制到parameterName。您可以使用parameterName从异常对象中获取信息。它的行为完全像一个方法的形参。

您可以将一个或多个catch模块关联到一个try模块。一个try-catch块的一般语法如下。下面的代码片段显示了一个try块,它有三个关联的catch块。您可以将任意数量的catch模块关联到一个try模块:

try {
    // Your code that may throw an exception goes here
} catch (ExceptionClass1 e1){
    // Handle exception of ExceptionClass1 type
} catch (ExceptionClass2 e2){
    // Handle exception of ExceptionClass2 type
} catch (ExceptionClass3 e3){
    // Handle exception of ExceptionClass3 type
}

让我们使用一个try-catch块来处理代码中可能出现的被零除异常。清单 13-3 显示了完整的代码。

// DivideByZeroWithTryCatch.java
package com.jdojo.exception;
public class DivideByZeroWithTryCatch {
    public static void main(String[] args) {
        int x = 10, y = 0, z;
        try {
            z = x / y;
            System.out.println("z = " + z);
        } catch (ArithmeticException e) {
            // Get the description of the exception
            String msg = e.getMessage();
            // Print a custom error message
            System.out.println("An error has occurred. The error is: " + msg);
        }
        System.out.println("At the end of the program.");
    }
}
An exception has occurred. The error is: / by zero
At the end of the program.

Listing 13-3Handling an Exception Using a try-catch Block

清单 13-3 的产量比清单 13-2 的产量好。它确切地告诉你程序执行时发生了什么。请注意,当异常发生时,程序没有终止,因为您处理了异常。程序执行了打印"At the end of the program"消息的最后一条语句。

传送控制

当在try块中抛出异常时,您需要非常精确地理解控制流。首先,Java 运行时创建一个适当类的对象来表示已经发生的异常。检查跟随try块的第一个catch块。如果异常对象可以分配给catch块的参数,则catch块的参数被分配给异常对象的引用,并且控制转移到catch块的主体。当catch块完成执行其主体时,控制转移到try-catch块之后的点。值得注意的是,在执行catch程序块后,控制不会转移回try程序块。相反,它被转移到跟在try-catch块后面的代码中。如果一个try程序块有许多与之相关的catch程序块,则最多执行一个catch程序块。图 13-1 显示了当try块中出现异常时,典型 Java 程序中的控制转移。

img/323069_3_En_13_Fig1_HTML.png

图 13-1

try 块中发生异常时的控制权转移

在这个例子中,假设当执行try-statement-2时,它抛出一个类型为Exception2的异常。当抛出异常时,控制权转移到第二个catch块,执行catch-statement-21catch-statement-22。在catch-statement-22执行后,控制转移到try-catch块之外,并且statement-1开始执行。理解当try-statement-2抛出异常时try-statement-3永远不会被执行是非常重要的。在三个catch块中,当try块中的语句抛出异常时,最多执行一个。

异常类层次结构

Java 类库包含许多异常类。图 13-2 显示了一些异常类。请注意,Object类不属于异常类家族。它在图中显示为继承层次结构中的Throwable类的祖先。

img/323069_3_En_13_Fig2_HTML.jpg

图 13-2

异常类层次结构中的一些类

异常类层次结构从java.lang.Throwable类开始。回想一下,Object类是 Java 中所有类的超类。它也是Throwable类的超类。这就是图中将Object类显示在类层次结构顶部的原因。需要强调的是,Java 异常类家族始于Throwable类,而不是Object类。

当一个异常被抛出时,它必须是一个Throwable类或者它的任何子类的对象。catch块的参数必须是Throwable类型或其子类之一,如ExceptionArithmeticExceptionIOException等。下列catch块不是有效的catch块,因为它们的参数不是ThrowableThrowable的子类:

// A compile-time error. The Object class is not a throwable class.
catch(Object e1) {
}
// A compile-time error. The String class is not a throwable class.
catch(String e1) {
}

下面的catch块是有效的,因为它们将 throwable 类型指定为参数,这些类型是Throwable类或其子类:

// Throwable is a valid exception class
catch(Throwable t) {
}
// Exception is a valid exception class because it is a subclass of Throwable
catch(Exception e) {
}
// IOException class is a valid exception class because it is a subclass of Throwable
catch(IOException t) {
}
// ArithmeticException is a valid exception class because it is a subclass of Throwable
catch(ArithmeticException t) {
}

您还可以通过从某个异常类继承您的类来创建自己的异常类。图 13-2 只显示了 Java 类库中可用的数百个异常类中的几个。我们在第二十章中讨论了如何从另一个类继承一个类。

布置多个抓块

Object类的引用变量可以引用任何类型的对象。假设AnyClass是一个类,下面是一个有效语句:

Object obj = new AnyClass();

前面赋值背后的规则是,一个类的对象的引用可以赋给它自己类型或者它的超类的引用变量。因为Object类是 Java 中所有类的超类(直接或间接),所以将任何对象的引用赋给Object类的引用变量是有效的。这个赋值规则不仅限于Object类的引用变量。它适用于任何物体。其表述如下:

如果 S 与 T 相同或者 S 是 T 的子类,则 T 类的引用变量可以引用 S 类的对象,以下语句在 Java 中始终有效假设 S 是 T 的子类:

T t1 = new T();

T t2 = new S();

这条规则意味着任何对象的引用都可以存储在Object类型的引用变量中。您可以将此规则应用于例外分类层次结构。因为Throwable类是所有异常类的超类,所以Throwable类的引用变量可以引用任何异常类的对象。以下所有语句都是有效的:

Throwable e1 = new Exception();
Throwable e2 = new IOException();
Throwable e3 = new RuntimeException();
Throwable e4 = new ArithmeticException();

记住这个赋值规则,考虑下面的try-catch块:

try {
    statement1;
    statement2; // Exception of class MyException is thrown here
    statement3;
} catch (Exception1 e1) {
    // Handle Exception1
} catch(Exception2 e2) {
    // Handle Exception2
}

当执行前面的代码片段时,statement2抛出一个MyException类型的异常。假设运行时创建了一个MyException对象,如下所示:

new MyException();

现在运行时选择合适的catch块,它可以捕获异常对象。它从与try模块相关的第一个 catch 模块开始,依次寻找合适的catch时钟。检查catch块是否能处理异常的过程非常简单。获取 catch 块的参数类型和参数名称,将它们放在赋值操作符的左边,并将抛出的异常对象放在右边。如果这样形成的语句是一个有效的 Java 语句,那么catch块将处理这个异常。否则,运行时将对下一个catch块重复该检查。为了检查第一个catch块是否可以处理前面代码片段中的MyException,Java 将形成以下语句:

// Catch parameter declaration = thrown exception object reference
Exception1 e1 = new MyException();

只有当MyException类是Exception1类的子类或者MyExceptionException1是同一个类时,前面的语句才是有效的 Java 语句。如果前面的语句有效,运行时将把MyException对象的引用赋给e1,然后执行第一个catch块内的代码。如果前面的语句不是有效的语句,运行时将使用下面的语句对第二个catch块进行同样的检查:

// Catch parameter declaration = thrown exception object reference
Exception2 e2 = new MyException();

如果前面的语句有效,MyException对象被分配给e2,并且执行catch块的主体。如果前面的语句无效,运行时没有为在try块中抛出的异常找到匹配的catch块,然后选择不同的执行路径,我们稍后将对此进行讨论。

通常,对于可以从try块抛出的每种类型的异常,都要在try块后添加一个catch块。假设有一个try块,它可以抛出三种异常,分别用三个类来表示——Exception1Exception2Exception3。假设Exception1Exception2的超类,Exception2Exception3的超类。三个异常类的类层次结构如图 13-3 所示。

img/323069_3_En_13_Fig3_HTML.jpg

图 13-3

异常 1、异常 2 和异常 3 异常类的类层次结构

考虑下面的try-catch块:

try {
    // Exception1, Exception2 or Exception 3 could be thrown here
} catch (Exception1 e1) {
    // Handle Exception1
} catch (Exception2 e2) {
    // Handle Exception2
} catch (Exception3 e3) {
    // Handle Exception3
}

如果您尝试应用这些步骤来找到一个合适的catch块,前面的代码片段将总是执行第一个catch块,而不管从try块抛出的异常类型(Exception1Exception2Exception3)。这是因为Exception1Exception2Exception3的直接/间接超类。前面的代码片段显示了开发人员犯的一个逻辑错误。Java 编译器被设计用来处理您可能会犯的这种逻辑错误,它会生成一个编译时错误。要修复错误,您需要颠倒这三个catch块的顺序。您必须应用以下规则来为一个try块安排多个catch块:

多个 catch 块对于一个 try 块,必须从最具体的异常类型到最一般的异常类型进行安排。否则,会发生编译时错误。第一个 catch 块应该处理最具体的异常类型,最后一个处理最一般的异常类型。

下面的代码片段使用了多个catch块的有效序列。ArithmeticException类是RuntimeException类的子类。如果这两个异常都在同一个try程序块的catch程序块中处理,那么最具体的类型ArithmeticException必须出现在最一般的类型RuntimeException之前:

try {
    // Do something, which might throw Exception
} catch(ArithmeticException e1) {
    // Handle ArithmeticException first
} catch(RuntimeException e2) {
    // Handle RuntimeException after ArithmeticException
}

多抓块

您可以使用 multi-catch 块在单个catch块中处理多种类型的异常。您可以在一个多catch块中指定多个异常类型。多个异常由竖线分隔(|)。以下是语法:

try {
    // May throw ExceptionA, ExceptionB, or ExceptionC
} catch (ExceptionA | ExceptionB | ExceptionC  e) {
    // Handle ExceptionA, ExceptionB, and ExceptionC
}

在多catch块中,不允许有通过子类化相关的可选异常。也就是说,ExceptionAExceptionBExceptionC不能因为是彼此的子类或超类而相关联。例如,下面的多catch块是不允许的,因为ExceptionAExceptionBThrowable的子类。事实上,所有异常类都是Throwable的直接或间接子类:

try {
    // May throw ExceptionA, ExceptionB, or ExceptionC
} catch (ExceptionA | ExceptionB | Throwable  e) {
    // Handle Exceptions here
}

前面的代码片段将生成以下编译时错误:

error: Alternatives in a multi-catch statement cannot be related by subclassing
        } catch(ExceptionA | ExceptionB | Throwable e) {
                                          ^
  Alternative ExceptionA is a subclass of alternative Throwable
1 error

已检查和未检查的异常

在我们查看检查和未检查的异常之前,让我们看一个从标准输入中读取字符的 Java 程序。您一直在使用System.out.println()方法在标准输出(通常是控制台)上打印消息。您可以使用System.in.read()方法从标准输入(通常是键盘)中读取一个字节。它将字节的值作为 0 到 255 之间的int返回。如果到达输入的结尾,它返回–1。清单 13-4 包含了一个ReadInput类的代码,该类的readChar()方法从标准输入中读取一个字节,并将该字节作为一个字符返回。它假设您使用的语言包含 Unicode 值在 0 到 255 之间的所有字母。readChar()方法包含用于读取的主代码。要从标准输入中读取字符,需要使用ReadInput.readChar()方法。

// ReadInput.java
package com.jdojo.exception;
public class ReadInput {
    public static char readChar() {
        char c = '\u0000';
        int input = System.in.read();
        if (input != -1) {
            c = (char)input;
        }
        return c;
    }
}

Listing 13-4Reading Input from Standard Input

编译ReadInput类。哎呀!编译器生成了以下错误:

"ReadInput.java": unreported exception java.io.IOException; must be caught or declared to be thrown at line 7, column 31

错误指向源代码中的第 7 行:

int input = System.in.read();

这种说法有所遗漏。该错误还告诉您有一个未捕获的异常,必须捕获或声明该异常。您知道如何使用try-catch块捕捉异常。但是,您可能不理解如何声明异常。您将在下一节学习如何声明异常。

System.in.read()方法调用可能抛出一个java.io.IOException。这个错误告诉你将这个方法调用放在一个try-catch块中,这样你就可以处理这个异常。如果您没有捕捉到这个异常,您需要在readChar()方法的声明中包含它可能抛出一个java.io.IOException。在前面几节中,您已经了解到运行时处理所有未捕获的异常。那么为什么在这种情况下 Java 运行时不能处理java.io.IOException?您需要了解已检查和未检查的异常,以便完全理解这个错误。Java 程序中可能会出现三种异常情况:

  • 已检查:第一类是发生几率较高的异常,你可以处理。例如,当您从文件中读取时,更有可能发生 I/O 错误。最好在程序中处理这些类型的异常。异常类层次结构中的类(参见图 13-2 )是Exception类的子类,包括Exception类本身,不包括RuntimeException及其所有子类,都属于这一类。如果某个方法或构造器可能会引发属于此类别的异常,您必须采取适当的操作,在调用该方法或构造器的代码中处理该异常。您需要采取什么“适当的行动”来处理这些类型的异常?您可以采取以下两种措施之一:

    • 您可以将能够抛出异常的代码放在一个try-catch块中。其中一个catch块必须能够处理可能抛出的异常类型。

    • 您可以在调用方法/构造器声明中指定它可能会引发异常。您可以通过在方法/构造器声明中使用一个throws子句来实现这一点。

  • 错误:第二类是在 Java 程序执行过程中可能出现的异常,你对此无能为力。例如,当运行时内存不足时,您会收到一个java.lang.OutOfMemoryError异常。您无法从内存不足错误中恢复。最好让应用程序崩溃,然后在程序中寻找更有效地管理内存的方法。异常类层次结构中的类(参见图 13-2 )是Error类的子类,而Error类本身也属于这种异常类别。如果一段代码可能抛出属于这一类的异常,编译器不会坚持要求您采取行动。如果这种类型的异常在运行时抛出,运行时将通过显示详细的错误消息和暂停应用程序来处理它。

  • 未检查:第三类是运行时可能发生的异常,如果自己处理,或许可以从异常情况中恢复。这一类别中有许多例外。但是,如果您觉得更有可能引发这种异常,您应该在代码中处理它。如果你试图通过使用try-catch块来处理它们,你的代码会变得混乱。异常类层次结构中的类(参见图 13-2 )是RuntimeException类的子类,而RuntimeException类本身也属于这种异常类别。如果一段代码可能抛出属于这一类的异常,编译器不会坚持要求您采取行动。如果这种类型的异常在运行时抛出,运行时将通过显示详细的错误信息和暂停程序来处理它。

第一类异常称为检查异常。Throwable类也属于检查异常。Throwable类、Exception类和Exception类的子类,不包括RuntimeException类及其子类,被称为检查异常。它们被称为检查异常,因为编译器检查它们是否在代码中得到处理。

所有不是检查异常的异常都称为未检查异常。Error类、Error类的所有子类、RuntimeException类及其所有子类都是未检查的异常。它们被称为未检查异常,因为编译器不会检查它们是否在代码中得到处理。然而,你可以自由处理它们。处理已检查或未检查异常的程序结构是相同的。它们之间的区别在于编译器强迫(或不强迫)你在代码中处理它们的方式。

让我们修复ReadInput类的编译时错误。现在你知道了java.io.IOException是一个被检查的异常,编译器会强迫你处理它。你将使用try-catch块来处理它。清单 13-5 显示了ReadInput类的代码。这一次,您已经在readChar()方法中处理了IOException,代码将会很好地编译。

// ReadInput.java
package com.jdojo.exception;
import java.io.IOException;
public class ReadInput {
    public static char readChar() {
        char c = '\u0000';
        int input = 0;
        try {
            input = System.in.read();
            if (input != -1) {
                c = (char)input;
            }
        } catch (IOException e) {
            System.out.print("IOException occurred while reading input.");
        }
        return c;
    }
}

Listing 13-5A ReadInput Class Whose readChar() Method Reads One Character from the Standard Input

如何使用ReadInput类?您可以像在 Java 中使用其他类一样使用它。如果要捕获用户输入的第一个字符,需要调用ReadInput.readChar()静态方法。清单 13-6 包含了展示如何使用ReadInput类的代码。它提示用户输入一些文本。输入文本的第一个字符显示在标准输出上。

// ReadInputTest.java
package com.jdojo.exception;
public class ReadInputTest {
    public static void main(String[] args) {
        System.out.print("Enter some text and press Enter key: ");
        char c = ReadInput.readChar();
        System.out.println("First character you entered is: " + c);
    }
}
Enter some text and press Enter key: Hello
First character you entered is: H

Listing 13-6A Program to Test the ReadInput Class

检查异常:捕捉或声明

如果一段代码可能引发已检查的异常,您必须执行下列操作之一:

  • 通过将这段代码放在try-catch块中来处理检查到的异常。

  • 在方法/构造器声明中指定它将引发检查到的异常。

ReadInput类的readChar()方法中的System.in.read()方法的调用(参见清单 13-5 )抛出了一个IOException类型的检查异常。在这种情况下,您应用了第一个选项,通过调用try-catch块中的System.in.read()方法来处理IOException

让我们假设你正在为一个有三条语句的类编写一个方法m1()。假设三条语句可能分别抛出类型为Exception1Exception2Exception3的检查异常。该方法的代码可能如下所示:

// Will not compile
public void m1() {
    statement-1; // May throw Exception1
    statement-2; // May throw Exception2
    statement-3; // May throw Exception3
}

您不能以这种形式编译m1()方法的代码。您必须使用一个try-catch块来处理异常,或者在其声明中包含它可能抛出三个被检查的异常。如果您想在m1()方法体中处理检查过的异常,您的代码可能如下所示:

public void m1() {
    try {
        statement-1; // May throw Exception1
        statement-2; // May throw Exception2
        statement-3; // May throw Exception3
    } catch(Exception1 e1) {
        // Handle Exception1 here
    } catch(Exception2 e2) {
        // Handle Exception2 here
    } catch(Exception3 e3) {
        // Handle Exception3 here
    }
}

前面的代码假设当抛出三个异常中的一个时,您不想执行其余的语句。

如果您想使用不同的逻辑,您可能需要不止一个try-catch块。例如,如果您的逻辑规定您必须尝试执行所有三条语句,即使前一条语句引发了异常,您的代码也将如下所示:

public void m1() {
    try {
        statement-1; // May throw Exception1
    } catch(Exception1 e1) {
        // Handle Exception1 here
    }

    try {
        statement-2; // May throw Exception2
    } catch(Exception2 e2) {
        // Handle Exception2 here
    }
    try {
        statement-3; // May throw Exception3
    } catch(Exception3 e3) {
        // Handle Exception3 here
    }
}

消除编译时错误的第二种方法是在m1()方法的声明中指定抛出三个检查过的异常。这是通过在m1()方法的声明中使用一个throws子句来实现的。指定throws子句的一般语法如下:

[modifiers] <return-type> <method-name>([parameters]) [throws <list-of-exceptions>] {
    // Method body goes here
}

关键字throws用于指定一个throws子句。throws子句放在方法参数列表的右括号之后。throws关键字后面是逗号分隔的异常类型列表。回想一下,异常类型只不过是 Java 类的名称,它位于异常类层次结构中。您可以在m1()方法的声明中指定一个throws子句,如下所示:

public void m1() throws Exception1, Exception2, Exception3 {
    statement-1; // May throw Exception1
    statement-2; // May throw Exception2
    statement-3; // May throw Exception3
}

当一段代码抛出多个检查异常时,您也可以在同一方法中混合使用这两个选项。您可以使用一个try-catch块来处理其中一些,并在方法声明中使用一个throws子句来声明一些。以下代码使用try-catch块处理Exception2,并使用throws子句声明异常Exception1Exception3:

public void m1() throws Exception1, Exception3 {
     statement-1;    // May throw Exception1
    try {
        statement-2; // May throw Exception2
    } catch(Exception2 e){
        // Handle Exception2 here
    }
    statement-3;     // May throw Exception3
}

让我们回到ReadInput类的例子。清单 13-3 通过添加一个try-catch块修复了编译时错误。现在让我们使用第二个选项:在readChar()方法的声明中包含一个throws子句。清单 13-7 包含了ReadInput类的另一个版本,叫做ReadInput2

// ReadInput2.java
package com.jdojo.exception;
import java.io.IOException;
public class ReadInput2 {
    public static char readChar() throws IOException {
        char c = '\u0000';
        int input = 0;
        input = System.in.read();
        if (input != -1) {
            c = (char) input;
        }
        return c;
    }
}

Listing 13-7Using a throws Clause in a Method's Declaration

清单 13-8 包含了测试ReadInput2类的readChar()方法的ReadInput2Test类的代码。

// ReadInput2Test.java
package com.jdojo.exception;
public class ReadInput2Test {
    public static void main(String[] args) {
        System.out.print("Enter some text and then press Enter key: ");
        char c = ReadInput2.readChar();
        System.out.print("The first character you entered is: " + c);
    }
}

Listing 13-8Testing a throws Clause in a Method’s Declaration

现在,编译ReadInput2Test类。哎呀!编译ReadInput2Test类会产生以下错误:

Error(6,11): unreported exception: class java.io.IOException; must be caught or declared to be thrown

此时,您可能不太清楚编译器错误。ReadInput2类的readChar()方法声明它可能抛出一个IOExceptionIOException是被检查的异常。因此,ReadInput2Testmain()方法中的下面这段代码可能会抛出一个被检查的IOException:

char c = ReadInput2.readChar();

回想一下关于处理被检查的异常的规则,这在本节的开头已经讨论过了。如果一段代码可能抛出检查异常,您必须使用两个选项之一:将这段代码放在一个try-catch块中来处理异常,或者在方法或构造器的声明中使用一个throws子句来指定检查异常。现在,您必须在main()方法中为ReadInput2.readChar()方法的调用应用这两个选项之一。清单 13-9 使用第一个选项,并将对ReadInput2.readChar()方法的调用放在try-catch块中。请注意,您在try块中放置了三条语句,这是不必要的。您需要在try块中只放置可能抛出被检查异常的代码。

// ReadInput2Test2.java
package com.jdojo.exception;
import java.io.IOException;
public class ReadInput2Test2 {
    public static void main(String[] args) {
        char c = '\u0000';
        try {
            System.out.print("Enter some text and then press Enter key:");
            c = ReadInput2.readChar();
            System.out.println("The first character you entered is: " + c);
        } catch (IOException e) {
            System.out.println("Error occurred while reading input.");
        }
    }
}

Listing 13-9A Program to Test the ReadInput2.readChar() Method

您还可以使用第二个选项来修复编译器错误。清单 13-10 包含使用第二个选项的代码(声明抛出的异常)。

// ReadInput2Test3.java
package com.jdojo.exception;
import java.io.IOException;
public class ReadInput2Test3 {
    public static void main(String[] args) throws IOException {
        System.out.print("Enter some text and then press Enter key: ");
        char c = ReadInput2.readChar();
        System.out.println("The first character you entered is: " + c);
    }
}

Listing 13-10A Program to Test the Throws Declaration for ReadInput2.readChar() Method

该程序包括一个带有用于main()方法的IOExceptionthrows子句。你能像使用java命令运行其他类一样运行ReadInput2Test3类吗?是的。您可以像在 Java 中运行其他类一样运行ReadInput2Test3类。运行一个类的要求是它应该包含一个main()方法,该方法被声明为public static void main(String[] args)。该要求没有指定任何关于throws条款的内容。一个main()方法,用来运行一个类作为起点,可能包含也可能不包含一个throws子句。

假设您运行了ReadInput2Test3类,并且对ReadInput2类的readChar()方法中的System.in.read()方法的调用抛出了一个IOExceptionIOException会怎么处理,谁来处理?当在方法体中抛出异常时,运行时检查抛出异常的代码是否在try-catch块中。如果抛出异常的代码在try-catch块中,Java 运行时会寻找能够处理异常的catch块。如果它没有找到可以处理异常的catch块或者方法调用不在try-catch块中,异常将被向上传播到方法调用堆栈。也就是说,异常被传递给方法的调用方。在您的例子中,异常不在ReadInput2类的readChar()方法中处理。它的调用者是ReadInput2Test2类的main()方法中的一段代码。在这种情况下,在ReadInput2Test2.main()方法内部进行ReadInput2.readChar()方法调用时会抛出相同的异常。运行库应用相同的检查来处理异常。如果运行ReadInput2Test2类并抛出IOException,运行时会发现对ReadInput2.readChar()的调用在一个try-catch块中,该块可以处理IOException。因此,它会将控制转移到处理异常的catch块,程序在ReadInput2Test2类的main()方法中继续。理解这一点非常重要,控件在抛出异常后不会返回到ReadInput2.readChar()方法,并且该异常是在ReadInput2Test2.main()方法中处理的。

当运行ReadInput2Test3类时,对ReadInput2.readChar()方法的调用不在try-catch块中。在这种情况下,Java 运行时必须将异常向上传播到方法调用堆栈。main()方法是 Java 应用程序的方法调用栈的开始。这是所有 Java 应用程序启动的方法。如果main()方法抛出异常,运行时会处理它。回想一下,如果运行时为您处理了一个异常,它会在标准错误上打印调用堆栈的详细信息,并退出应用程序。

回想一下,具有异常类型的catch块可以处理相同类型或其任何子类类型的异常。例如,具有Throwable异常类型的catch块能够处理 Java 中所有类型的异常,因为Throwable类是所有异常类的超类。这个概念也适用于throws条款。如果一个方法抛出了一个Exception1类型的检查异常,你可以在它的throws子句或者Exception1的任何超类中提到Exception1类型。这条规则背后的推理是,如果方法的调用者处理的异常是Exception1的超类,那么同一个处理程序也可以处理Exception1

Tip

Java 编译器通过使用try-catch块或在方法或构造器声明中使用throws子句来强制您处理检查异常。如果一个方法抛出一个异常,它应该在调用栈中的某个地方被处理。也就是说,如果一个方法抛出异常,它的调用者可以处理它,或者它的调用者的调用者可以处理它,以此类推。如果异常未被调用堆栈中的任何调用方处理,则称为未捕获异常(或未处理异常)。一个未捕获的异常最终由 Java 运行时处理,它在标准错误上打印异常堆栈跟踪并退出 Java 应用程序。对于不是主线程的线程中未捕获的异常,可以指定不同的行为。

编译器对由程序员处理的检查异常非常挑剔。如果try块中的代码不能抛出已检查的异常,而其关联的catch块捕捉到已检查的异常,编译器将生成一个错误。考虑清单 13-11 中的代码,它使用了一个try-catch块。catch块指定了一个IOException,这是一个被检查的异常。但是,相应的try块不会抛出IOException

// CatchNonExistentException.java
package com.jdojo.exception;
import java.io.IOException;
// Will not compile
public class CatchNonExistentException {
    public static void main(String[] args) {
        int x = 10, y = 0, z = 0;
        try {
            z = x / y;
        } catch(IOException e) {
            // Handle the exception
        }
    }
}

Listing 13-11Catching a Checked Exception That Is Never Thrown in the try Block

当您编译CatchNonExistentException类的代码时,您会得到以下编译器错误:

Error(12):  exception java.io.IOException is never thrown in body of corresponding try statement

错误消息是不言自明的。它声明IOException永远不会在try块中被抛出。因此,catch号一定抓不住它。修复该错误的一种方法是完全移除try-catch块。清单 13-12 展示了提及通用catch块的另一种有趣的方式(但不是一种好方式)。

// CatchNonExistentException2.java
package com.jdojo.exception;
// Will compile fine
public class CatchNonExistentException2 {
    public static void main(String[] args) {
        int x = 10, y = 0, z = 0;
        try {
            z = x / y;
        } catch(Exception e) {
            // Handle the exception
        }
    }
}

Listing 13-12Catching a Exception That Is Never Thrown in the try Block

IOException一样,Exception也是 Java 中的检查异常类型。如果一个catch块不应该捕捉被检查的异常,除非它在相应的try块中被抛出,那么CatchNonExistentException2的代码是如何编译好的?它不应该生成相同的编译时错误吗?乍一想,你是对的。编译失败的原因应该和CatchNonExistentException类失败的原因一样。有两个选中的异常类是此规则的异常。这两个异常类是ExceptionThrowableException类是IOException和其他异常的超类,是检查异常。也是RuntimeException的超类和RuntimeException的所有子类,都是未检查的异常。回想一下超类异常类型也可以处理子类异常类型的规则。因此,您可以使用Exception类来处理已检查的异常和未检查的异常。检查catch程序块中未抛出异常的规则仅适用于已检查的异常。catch块中的ExceptionThrowable类可以处理已检查和未检查的异常,因为它们是这两种类型的超类。这就是为什么编译器会让你在一个catch块中使用这两种检查过的异常类型,即使相关的try块没有抛出任何检查过的异常。

Tip

所有关于编译器检查正在处理或抛出的异常的规则只适用于被检查的异常。Java 不会强迫你在代码中处理未检查的异常。然而,你可以自由处理它们,只要你觉得合适。

检查异常和初始值设定项

你不能从一个static初始化器抛出一个检查过的异常。如果static初始化器中的一段代码抛出了一个检查过的异常,必须使用初始化器内部的try-catch块来处理。static初始化器对于一个类只被调用一次,程序员在代码中没有特定的点来捕捉它。这就是为什么static初始化器必须处理它可能抛出的所有可能的检查异常:

public class Test {
    static {
        // Must use try-catch blocks to handle all checked exceptions
    }
}

对于实例初始化器,规则是不同的。实例初始化器作为类的构造器调用的一部分被调用。它可能会抛出已检查的异常。然而,所有那些被检查的异常必须包含在该类的所有构造器的throws子句中。这样,编译器可以确保在调用任何构造器时,所有被检查的异常都被程序员处理了。下面的Test类代码假设实例初始化器抛出一个CException类型的检查异常。编译器会强制你给Test的所有构造器添加一个带CExceptionthrows子句:

public class Test {
     // Instance initializer
    {
     // Throws a checked exception of type CException
    }
    // All constructors must specify that they throw CException
    // because the instance initializer throws CException
    public Test() throws CException {
        // Code goes here
    }
    public Test(int x) throws CException {
        // Code goes here
    }
    // Rest of the code goes here
}

当您使用Test类的任何构造器创建一个对象时,您必须处理CException,如下所示:

Test t = null;
try {
    t = new Test();
} catch (CException e) {
    // Handle the exception here
}

如果不使用try-catch块来处理CException,那么必须使用throws子句来指定Test类的构造器可以抛出CException

如果实例初始化器抛出一个检查过的异常,你必须为你的类声明一个构造器。如果您没有添加默认构造器,编译器会将它添加到您的类中。但是,编译器不会在默认构造器中添加一个throws子句,这会打破之前的规则。以下代码将不会编译:

public class Test123 {
    {
        // Throws CException, which is a checked exception.
        throw new CException();
    }
}

当编译Test123类时,编译器会添加一个默认的构造器,并且类Test123将如下所示:

public class Test123 {
    {
        // Throws CException, which is a checked exception.
        throw new CException();
    }
    public Test123() {
        // An empty body. The compiler did not add a throws clause.
    }
}

注意,编译器添加的默认构造器不包含包含由实例初始化器抛出的CExceptionthrows子句。这就是Test123类无法编译的原因。要编译Test123类,必须显式添加至少一个构造器,并使用一个throws子句指定它可能抛出CException

引发异常

Java 异常并不总是由运行时抛出。您还可以使用throw语句在代码中抛出异常。throw语句的语法是

throw <a-throwable-object-reference>;

这里,throw是一个关键字,后面跟一个对 throwable 对象的引用。可抛出对象是一个类的实例,它是Throwable类或Throwable类本身的子类。下面是一个抛出IOExceptionthrow语句的例子:

// Create an object of IOException
IOException e1 = new IOException(“File not found”);
// Throw the IOException
throw e1;

回想一下,new操作符返回新对象的引用。您还可以创建一个 throwable 对象,并在一条语句中将其抛出:

// Throw an IOException
throw new IOException("File not found");

当您在代码中引发异常时,处理异常的规则也同样适用。如果你抛出一个被检查的异常,你必须通过把代码放在一个try-catch块中或者通过在包含throw语句的方法或构造器声明中使用一个throws子句来处理它。如果抛出未检查的异常,则这些规则不适用。

创建异常类

您也可以创建自己的异常类。它们必须扩展(或继承)现有的异常类。我们将在关于继承的第二十章中详细介绍如何扩展一个类。本节解释扩展类的必要语法。关键字extends用于扩展一个类,如下所示:

[modifiers] class <class-name> extends <superclass-name> {
    // Body for <class-name> goes here
}

这里,<class-name>是你的异常类名,<superclass-name>>是已经存在的异常类名,由你的类扩展。

假设您想要创建一个MyException类,它扩展了java.lang.Exception类。语法如下所示:

public class MyException extends Exception {
    // Body for MyException class goes here
}

异常类的主体是什么样子的?异常类就像 Java 中的其他类一样。通常,不向异常类添加任何方法。许多可用于查询异常对象状态的有用方法都在Throwable类中声明,您无需重新声明即可使用它们。通常,在异常类中包含四个构造器。所有构造器都将使用super关键字调用其超类的相应构造器。清单 13-13 显示了一个有四个构造器的MyException类的代码。

// MyException.java
package com.jdojo.exception;
public class MyException extends Exception {
    public MyException() {
        super();
    }
    public MyException(String message) {
        super(message);
    }
    public MyException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyException(Throwable cause) {
        super(cause);
    }
}

Listing 13-13A MyException Class That Extends the Exception Class

第一个构造器创建一个异常,用null作为它的详细消息。第二个构造器创建一个带有详细消息的异常。第三个和第四个构造器允许您通过包装另一个带有/不带有详细消息的异常来创建一个异常。

您可以抛出类型为MyException的异常,如下所示:

throw new MyException("Your message goes here");

您可以在方法/构造器声明的throws子句中使用MyException类,或者将其作为catch块中的参数类型。以下代码片段显示:

import com.jdojo.exception.MyException;
...
public void m1() throws MyException {
    // Code for m1() body goes here
}
try {
    // Code for the try block goes here
} catch(MyException e) {
    // Code for the catch block goes here
}

表 13-1 显示了Throwable类的一些常用方法。注意Throwable类是 Java 中所有异常类的超类。此表中显示的所有方法在所有异常类中都可用。

表 13-1

Throwable 类的部分方法列表

|

方法

|

描述

Throwable getCause() 这个方法是在 Java 1.4 中添加的。它返回异常的原因。如果没有设置异常的原因,则返回null
String getMessage() 它返回异常的详细消息。
StackTraceElement[] getStackTrace() 这个方法是在 Java 1.4 中添加的。它返回堆栈跟踪元素的数组。数组中的每个元素代表一个堆栈帧。数组的第一个元素表示堆栈的顶部,数组的最后一个元素表示堆栈的底部。栈顶是创建异常对象的方法/构造器。StackTraceElement类的对象持有类名、方法名、文件名、行号等信息。
Throwable initCause(Throwable cause) 这个方法是在 Java 1.4 中添加的。有两种方法可以将异常设置为异常的原因。一种方法是使用构造器,它接受原因作为参数。另一种方法是使用这种方法。
void printStackTrace() 它在标准错误流中打印堆栈跟踪。输出将异常对象本身的描述打印为第一行,然后是每个堆栈帧的描述。打印异常的堆栈跟踪对于调试非常有用。
void printStackTrace(PrintStream s) 它将堆栈跟踪打印到指定的PrintStream对象。
void printStackTrace(PrintWriter s) 它将堆栈跟踪打印到指定的PrintWriter对象。
String toString() 它返回异常对象的简短描述。异常对象的描述包含异常类的名称和详细消息。

清单 13-14 展示了异常类的printStackTrace()方法的使用。main()方法调用m1()方法,后者又调用m2()方法。这个调用的堆栈框架从位于堆栈底部的main()方法开始。栈顶包含了m2()方法。输出显示printStackTrace()方法从上到下打印堆栈信息。每个堆栈帧包含类名、方法名、源文件名和行号。printStackTrace()方法的第一行打印异常对象的类名和详细消息。

// StackTraceTest.java
package com.jdojo.exception;
public class StackTraceTest {
    public static void main(String[] args) {
        try {
            m1();
        } catch (MyException e) {
            e.printStackTrace(); // Print the stack trace
        }
    }
    public static void m1() throws MyException {
        m2();
    }
    public static void m2() throws MyException {
        throw new MyException("Some error has occurred.");
    }
}
com.jdojo.exception.MyException: Some error has occurred.
        at jdojo.exception/com.jdojo.exception.StackTraceTest.m2(StackTraceTest.java:18)
        at jdojo.exception/com.jdojo.exception.StackTraceTest.m1(StackTraceTest.java:14)
        at jdojo.exception/com.jdojo.exception.StackTraceTest.main(StackTraceTest.java:7)

Listing 13-14Printing the Stack Trace of an Exception

清单 13-14 演示了如何在标准错误上打印异常的堆栈跟踪。有时,您可能需要将堆栈跟踪保存在文件或数据库中。您可能需要以变量中的字符串形式获取堆栈跟踪信息。另一个版本的printStackTrace()方法允许您这样做。清单 13-15 展示了如何使用printStackTrace(PrintWriter s)方法将异常对象的堆栈跟踪打印到String对象。该程序与清单 13-14 相同,只有一处不同。它将堆栈跟踪存储在一个字符串中,然后在标准输出中打印该字符串。方法getStackTrace()将堆栈跟踪写入一个字符串并返回该字符串。

// StackTraceAsStringTest.java
package com.jdojo.exception;
import java.io.StringWriter;
import java.io.PrintWriter;
public class StackTraceAsStringTest {
    public static void main(String[] args) {
        try {
            m1();
        } catch (MyException e) {
            String str = getStackTrace(e);
            // Print the stack trace to the standard output
            System.out.println(str);
        }
    }
    public static void m1() throws MyException {
        m2();
    }
    public static void m2() throws MyException {
        throw new MyException("Some error has occurred.");
    }
    public static String getStackTrace(Throwable e) {
        StringWriter strWriter = new StringWriter();
        PrintWriter printWriter = new PrintWriter(strWriter);
        e.printStackTrace(printWriter);
        // Get the stack trace as a string
        String str = strWriter.toString();
        return str;
    }
}
com.jdojo.exception.MyException: Some error has occurred.
      at jdojo.exception/com.jdojo.exception.StackTraceAsStringTest.m2(StackTraceAsStringTest.java:24)
      at jdojo.exception/com.jdojo.exception.StackTraceAsStringTest.m1(StackTraceAsStringTest.java:20)
      at jdojo.exception/com.jdojo.exception.StackTraceAsStringTest.main(StackTraceAsStringTest.java:10)

Listing 13-15Writing Stack Trace of an Exception to a String

最终块

您已经看到了如何将一个或多个 catch 块关联到一个try块。一个try块也可以有零个或一个finally块。一个finally块从来不自己用。它总是与try块一起使用。使用finally块的语法是

finally {
    // Code for finally block goes here
}

一个finally块以关键字finally开始,后面跟着一个左大括号和一个右大括号。一个finally块的代码放在大括号内。

trycatchfinally块有两种可能的组合:try-catch-finallytry-finally。一个try块后面可以跟随零个或多个catch块。一个try模块最多可以有一个finally模块。try-catch-finally块的语法是

try {
    // Code for try block goes here
} catch(Exception1 e1) {
    // Code for catch block goes here
} finally {
    // Code for finally block goes here
}

try-finally块的语法是

try {
    // Code for try block goes here
} finally {
    // Code for finally block goes here
}

当你使用一个try-catch-finally块时,你的意图是执行下面的逻辑:

尝试执行 尝试 块中的代码。如果 try 块中的代码抛出任何异常,执行匹配的 catch 块。最后,执行 中的代码,最后 块中的代码无论如何都要在 块中完成执行。

**当你使用一个try-finally块时,你的意图是执行下面的逻辑:

尝试执行 尝试 块中的代码。当 try 块中的代码执行完毕后,执行 最后 块中的代码。

Tip

无论相关的try和/或catch块中发生什么,都保证执行一个finally块。这个规则有两个例外:如果正在执行trycatch块的线程死亡,则finally块可能不会被执行,或者 Java 应用程序可能会退出,例如,通过调用trycatch块内的System.exit()方法。

为什么需要用finally块?有时你想执行两组语句,比如说set-1set-2。条件是无论set-1中的语句如何执行完,都要执行set-2。例如,set-1中的语句可能抛出异常,也可能正常完成。您可以编写逻辑,它将在执行set-1之后执行set-2,而不使用finally块。然而,代码可能不那么干净。您可能最终会在多个地方重复相同的代码,并编写意大利面条式的if-else语句。例如,set-1可能使用构造,使控件从程序的一点跳到另一点。它可以使用类似于breakcontinuereturnthrow等的结构。如果set-1有很多出口点,您需要重复调用set-2才能在很多地方出口。编写将执行set-1set-2的逻辑既困难又难看。finally块使得编写这个逻辑变得容易。你需要做的就是将set-1代码放在try块中,将set-2代码放在finally块中。可选地,您也可以使用catch块来处理可能从set-1抛出的异常。您可以编写 Java 代码来执行set-1set-2,如下所示:

try {
    // Execute all statements in set-1
} catch(MyException e1) {
    // Handle any exceptions here that may be thrown by set-1
} finally {
    // Execute statements in set-2
}

如果你以这种方式构建你的代码来执行set-1set-2,你会得到更干净的代码,保证在set-1执行后set-2的执行。

通常,您使用一个finally块来编写清理代码。例如,您可能会在程序中获得一些资源,当您使用完这些资源时,必须释放它们。一个try-finally块让您实现这个逻辑。您的代码结构如下所示:

try {
    // Obtain and use some resources here
} finally {
    // Release the resources that were obtained in the try block
}

当您编写执行数据库事务和文件输入/输出的程序时,您会频繁地编写try-finally块。您在try块中获得并使用一个数据库连接,并在finally块中释放该连接。当使用与数据库相关的程序时,无论事务发生什么情况,都必须释放最初获得的数据库连接。如前所述,这类似于执行set-1set-2中的语句。清单 13-16 展示了finally模块在四种不同情况下的使用。

// FinallyTest.java
package com.jdojo.exception;
public class FinallyTest {
    public static void main(String[] args) {
        int x = 10, y = 0, z;
        try {
            System.out.println("Before dividing x by y.");
            z = x / y;
            System.out.println("After dividing x by y.");
        } catch (ArithmeticException e) {
            System.out.println("Inside catch block - 1.");
        } finally {
            System.out.println("Inside finally block - 1.");
        }
        System.out.println("-------------------------------");
        try {
            System.out.println("Before setting z to 2449.");
            z = 2449;
            System.out.println("After setting z to 2449.");
        } catch (Exception e) {
            System.out.println("Inside catch block - 2.");
        } finally {
            System.out.println("Inside finally block - 2.");
        }
        System.out.println("-------------------------------");
        try {
            System.out.println("Inside try block - 3.");
        } finally {
            System.out.println("Inside finally block - 3.");
        }
        System.out.println("-------------------------------");
        try {
            System.out.println("Before executing System.exit().");
            System.exit(0);
            System.out.println("After executing System.exit().");
        } finally {
            // This finally block will not be executed
            // because application exits in try block
            System.out.println("Inside finally block - 4.");
        }
    }
}
Before dividing x by y.
Inside catch block - 1.
Inside finally block - 1.
-------------------------------
Before setting z to 2449.
After setting z to 2449.
Inside finally block - 2.
-------------------------------
Inside try block - 3.
Inside finally block - 3.
-------------------------------
Before executing System.exit().

Listing 13-16Using a finally Block

第一个try-catch-finally块试图对一个整数执行除零操作。表达式x / y抛出一个ArithmeticException,控制转移到catch块。finally块在catch块结束后执行。请注意,try块中的第二条消息没有打印出来,因为一旦抛出异常,控制跳转到最近的匹配catch块,并且控制不再返回到try块。

第二个try-catch-finally块是一个例子,其中try块正常结束(没有抛出异常)。在try程序段结束后,执行finally程序段。

第三个try-finally块很简单。try程序块正常结束,然后执行finally程序块。

第四个try-finally程序块演示了一个不执行finally程序块的异常情况。try块通过执行System.exit()方法退出应用程序。当调用System.exit()方法而不执行相关的finally块时,应用程序停止执行。

再次引发异常

被捕获的异常可以被重新引发。出于不同的原因,您可能希望重新引发异常。原因之一可能是在捕获它之后,但在将它向上传播到调用堆栈之前采取一个操作。例如,您可能希望记录有关异常的详细信息,然后将其重新抛出给客户端。另一个原因是对客户端隐藏异常类型/位置。您没有向客户端隐藏异常情况本身。相反,您隐藏了异常情况的类型。出于两个原因,您可能希望对客户端隐藏实际的异常类型:

  • 客户端可能没有准备好处理引发的异常。

  • 引发的异常对客户端没有意义。

重新抛出一个异常就像使用一个throw语句一样简单。下面的代码片段捕获异常,打印其堆栈跟踪,并再次引发相同的异常。当重新引发同一个异常对象时,它会保留原始异常的详细信息:

try {
    // Code that might throw MyException
} catch(MyException e) {
    e.printStackTrace(); // Print the stack trace
    // Rethrow the same exception
    throw e;
}

当一个catch块抛出异常时,不会搜索同一组中的另一个catch块来处理该异常。如果您想处理从一个catch块抛出的异常,您需要将抛出异常的代码包含在另一个try-catch块中。另一种处理方法是将整个try-catch块放入另一个try - catch块中。下面的代码片段展示了安排嵌套的try-catch来处理Exception1Exception2的两种方式。嵌套try-catch的实际排列取决于手头的情况。如果您没有将可能引发异常的代码包含在try块中,或者try块没有匹配的关联catch块来捕获异常,则运行时将在调用堆栈中向上传播异常,前提是该方法是用throws子句定义的:

// #1 - Arranging nested try-catch
try {
    // May throw Exception1
} catch(Exception1 e1) {
    // Handle Exception1 here
    try {
        // May throw Exception2
    } catch(Exception2 e2) {
        // Handle Exception2 here
    }
}
/* #2 - Arranging nested try-catch */
try {       try {        // May throw Exception1
    }    catch(Exception1 e1) {
        // Handle Exception1 here
        // May throw Exception2
    }
} catch(Exception2 e2) {
    // Handle Exception2 here
}

以下代码片段显示了如何捕获一种类型的异常并重新引发另一种类型的异常:

try {
    // Code that might throw a MyException
} catch(MyException e) {
    e.printStackTrace(); // Print the stack trace
    // Rethrow a RuntimeException
    throw new RuntimeException(e.getMessage());
}

catch块捕获MyException,打印其堆栈跟踪,并重新抛出一个RuntimeException。在这个过程中,它丢失了最初抛出的异常的详细信息。当RuntimeException被创建时,它从它被创建的点开始打包堆栈帧的信息。客户端从被重新抛出的RuntimeException的创建点获取信息,而不是原始MyException的信息。在前面的代码中,您对客户端隐藏了原始异常的类型和位置。

您也可以重新引发另一种类型的异常,并使用原始异常作为重新引发异常的原因。就好像新异常是原始异常的包装。您可以使用接受原因作为参数的新异常类型的构造器之一来设置异常的原因。您也可以使用initCause()方法来设置异常的原因。以下代码片段再次抛出一个RuntimeException设置MyException作为其原因:

try {
    // Code that might throw a MyException
} catch(MyException e) {
    e.printStackTrace(); // Print the stack trace
    // Rethrow a RuntimeException using the original exception as its cause
    throw new RuntimeException(e.getMessage(), e);
}

当您再次抛出异常时,您还可以选择向客户端隐藏异常的位置。Throwable类的fillInStackTrace()方法从调用该方法的地方开始填充异常对象中的堆栈跟踪信息。您需要对您捕获的异常调用此方法,并希望重新抛出以隐藏原始异常的位置。以下代码片段显示了如何通过隐藏原始异常的位置来重新引发异常:

try {
    // Code that might throw MyException
} catch(MyException e) {
    // Re-package the stack frames in the exception object
    e.fillInStackTrace();
    // Rethrow the same exception
    throw e;
}

清单 13-17 展示了如何通过隐藏原始异常的位置来重新抛出异常。MyExceptionm2()方法中抛出。m1()方法捕捉异常,重新填充堆栈跟踪,然后再抛出。main()方法接收异常,就好像异常是在m1()内部抛出的,而不是在m2()内部抛出的。

// RethrowTest.java
package com.jdojo.exception;
public class RethrowTest {
    public static void main(String[] args) {
        try {
            m1();
        } catch (MyException e) {
            // Print the stack trace
            e.printStackTrace();
        }
    }
    public static void m1() throws MyException {
        try {
            m2();
        } catch (MyException e) {
            e.fillInStackTrace();
            throw e;
        }
    }
    public static void m2() throws MyException {
        throw new MyException("An error has occurred.");
    }
}

om.jdojo.exception.MyException: An error has occurred.
      at jdojo.exception/com.jdojo.exception.RethrowTest.m1(RethrowTest.java:19)
      at jdojo.exception/com.jdojo.exception.RethrowTest.main(RethrowTest.java:8)

Listing 13-17Rethrowing

an Exception to Hide the Location of the Original Exception

重新抛出异常的分析

Java 7 改进了重新抛出异常的机制。考虑下面的方法声明代码片段:

public void test() throws Exception {
    try {
        // May throw Exception1, or Exception2
    } catch (Exception e) {
        // Rethrow the caught exception
        throw e;
    }
}

try块可能会抛出Exception1Exception2catch块指定Exception作为它的参数,并重新抛出它捕获的异常。在 Java 7 之前,编译器看到catch块抛出Exception类型的异常;并且它坚持认为,在throws子句中,test()方法必须指定它抛出了一个Exception类型或Exception类型的超类型的异常。

因为try块只能抛出Exception1Exception2类型的异常,所以catch块将再次抛出一个总是这两种类型的异常。当再次抛出异常时,Java 会执行这种分析。它允许您相应地指定test()方法的throws子句。从 Java 7 开始,您可以在test()方法的throws子句中指定更具体的异常类型Exception1Exception2,如下所示:

public void test() throws Exception1, Exception2 {
    try {
        // May throw Exception1, Exception2 or Exception3
    } catch (Exception e) {
        // Rethrow the caught exception
        throw e;
    }
}

抛出太多异常

方法/构造器可以在其throws子句中列出的异常类型的数量没有限制。但是,最好保持较低的数量。使用一个方法的客户端必须处理该方法可能以某种方式抛出的所有异常。同样重要的是要记住,方法一旦被设计、实现并发布给公众,就不应该抛出新类型的异常。如果一个方法在公开发布后开始抛出一个新类型的异常,所有调用这个方法的客户端代码都必须改变。如果一个方法抛出了太多的异常或者在公开发布后添加了一个新的异常,这表明设计很差。您可以通过捕捉方法中所有较低级别的异常并重新引发较高级别的异常来避免方法中的这些问题。您引发的异常可能包含较低级别的异常作为其原因。考虑下面这个方法m1()的代码片段,它抛出三个异常(Exception1Exception2Exception3):

public void m1() throws Exception1, Exception2, Exception3 {
    // Code for m1() method goes here
}

您可以重新设计m1()方法,只抛出一个异常,比如说MyException,如下所示:

public void m1() throws MyException {
    try {
        // Code for m1() method goes here
    } catch(Exception1 e){
        throw new MyException("Msg1", e);
    } catch(Exception2 e){
        throw new MyException("Msg2", e);
    } catch(Exception3 e){
        throw new MyException("Msg3", e);
    }
}

重新设计的方法只抛出一个类型为MyException的异常。异常的详细消息特定于在方法内部引发和捕获的较低级别的异常。较低级别的异常也作为较高级别的异常的原因传播到客户端。如果m1()方法将来需要抛出新的异常,您仍然可以在旧的设计中加入新的异常。您需要添加一个catch块来捕捉新的异常并重新抛出MyException。这种设计使m1()方法的throws条款保持稳定。它还允许将来在其主体中包含更多的异常类型。

Tip

不要从你的方法中抛出一个普通的异常,比如ThrowableExceptionErrorRuntimeException等等。不要在catch块中指定通用异常类型。异常抛出或处理的目的是准确了解发生的错误情况,并采取适当的措施。它通过向用户提供特定的错误消息来帮助您了解错误的原因。当您使用特定的异常类型处理异常时,生成特定的错误信息会变得更加容易。

访问线程的堆栈

堆栈是用于存储临时数据的内存区域。它使用后进先出(LIFO)方式来添加和删除数据。栈类似于日常生活中的栈,比如一摞书。书架的底部有放在上面的第一本书。书架顶上放着最后一本书。当必须从书库中取出一本书时,放在书库上的最后一本书将首先被取出。这就是堆栈也被称为后进先出内存的原因。图 13-4 显示了一个堆栈的排列。

img/323069_3_En_13_Fig4_HTML.png

图 13-4

堆栈中的存储器排列

该图示出了堆叠放置的三本书。Book-1名列第一,Book-2第二,Book-3第三。最后添加到堆栈上的Book-3代表堆栈的顶部。Book-1,首先添加到堆栈上,代表堆栈的底部。向堆栈中添加元素称为 push 操作,从堆栈中移除元素称为 pop 操作。最初,堆栈是空的,第一个操作是推送操作。当一个堆栈被丢弃时,它必须执行相同次数的 push 和 pop 操作,所以它又是空的。

Java 中的每个线程都被分配了一个堆栈来存储它的临时数据。线程将方法调用的状态存储在其堆栈上。Java 方法的状态包括参数值、局部变量、任何中间计算值和方法的返回值(如果有的话)。Java 堆栈由堆栈框架组成。每个帧存储一个方法调用的状态。一个新的帧被推送到一个线程的堆栈上,用于方法调用。当方法完成时,从线程的堆栈中弹出该帧。

假设一个线程从m1()方法开始。m1()方法调用m2()方法,后者又调用m3()方法。图 13-5 显示了调用m1()m2()m3()方法时线程堆栈上的帧。注意,图中显示了从方法m2()调用方法m3()时的帧,方法m2()又从方法m1()调用。

img/323069_3_En_13_Fig5_HTML.png

图 13-5

调用方法 m1()、m2()和 m3()时线程堆栈的状态

您可以获得某个特定时间点的线程堆栈的一些信息。请注意,当程序执行时,线程堆栈的状态总是在变化。因此,您可以获得一个线程堆栈的快照,就像它在您请求它时存在一样。java.lang.StackTraceElement类的一个对象代表一个堆栈框架。您可以查询关于堆栈框架的四条信息:类名、文件名、方法名和行号。要获得堆栈信息,您需要调用一个Throwable对象的getStackTrace()方法。它返回一个StackTraceElement对象的数组。数组的第一个元素表示顶部堆栈帧。数组的最后一个元素表示底部堆栈帧。当你创建一个Throwable类(或者任何 Java 中的异常类)的对象时,它捕获正在执行的线程的堆栈。

Tip

从 Java 9 开始,Java 引入了堆栈审核 API,这将在本系列的第二卷中详细介绍。使用新的堆栈审核 API,遍历堆栈跟踪并获取方法内部调用方类的引用要容易得多。

清单 13-18 展示了如何获得一个线程的堆栈帧。一个Throwable对象在线程被创建时捕获它的堆栈。如果您有一个Throwable对象,并且想要在与创建Throwable对象不同的位置捕获线程堆栈的快照,您可以调用Throwable类的fillInStackTrace()方法。它在您调用此方法时捕获当前线程的当前堆栈状态。

// StackFrameTest.java
package com.jdojo.exception;
public class StackFrameTest {
    public static void main(String[] args) {
        m1();
    }
    public static void m1() {
        m2();
    }
    public static void m2() {
        m3();
    }
    public static void m3() {
        // Create a Throwable object that will hold the stack state
        // at this point for the thread that executes the following statement
        Throwable t = new Throwable();
        // Get the stack trace elements
        StackTraceElement[] frames = t.getStackTrace();
        // Print details about the stack frames
        printStackDetails(frames);
    }
    public static void printStackDetails(StackTraceElement[] frames) {
        System.out.println("Frame count: " + frames.length);
        for (int i = 0; i < frames.length; i++) {
            // Get frame details
            int frameIndex = i; // i = 0 means top frame
            String fileName = frames[i].getFileName();
            String className = frames[i].getClassName();
            String methodName = frames[i].getMethodName();
            int lineNumber = frames[i].getLineNumber();
            // Print frame details
            System.out.println("Frame Index: " + frameIndex);
            System.out.println("File Name: " + fileName);
            System.out.println("Class Name: " + className);
            System.out.println("Method Name: " + methodName);
            System.out.println("Line Number: " + lineNumber);
            System.out.println("---------------------------");
        }
    }
}
Frame count: 4
Frame Index: 0
File Name: StackFrameTest.java
Class Name: com.jdojo.exception.StackFrameTest
Method Name: m3
Line Number: 21
-----------------------------------------------
Frame Index: 1
File Name: StackFrameTest.java
Class Name: com.jdojo.exception.StackFrameTest
Method Name: m2
Line Number: 15
-----------------------------------------------
Frame Index: 2
File Name: StackFrameTest.java
Class Name: com.jdojo.exception.StackFrameTest
Method Name: m1
Line Number: 11
-----------------------------------------------
Frame Index: 3
File Name: StackFrameTest.java
Class Name: com.jdojo.exception.StackFrameTest
Method Name: main
Line Number: 7
-----------------------------------------------

Listing 13-18A Sample Program That Prints the Details of the Stack Frames of a Thread

现在您已经可以访问线程的堆栈帧了,您可能想知道如何处理这些信息。关于线程堆栈的信息让你知道程序中代码执行的位置。通常,您记录这些信息是为了调试目的。如果您将printStackTrace()方法的output与清单 13-18 的输出进行比较,您会发现它们是相似的,除了它们以不同的格式打印相同的信息。

用资源尝试块

在 try-with-resources 被添加到 Java 之前,当你使用一个资源时,比如一个文件、一个 SQL 语句等等。,您必须使用一个finally块并编写几行样板代码来关闭资源。使用资源的典型代码如下所示:

AnyResource aRes;
try {
    aRes = create the resource...;
    // Work with the resource here
} finally {
    // Let us try to close the resource
    try {
        if (aRes != null) {
            aRes.close(); // Close the resource
        }
    } catch(Exception e) {
        e.printStackTrace();
    }
}

使用try-with-resources块,前面的代码可以写成如下:

try (AnyResource aRes = create the resource...) {
    // Work with the resource here. The resource will be closed automatically.
}

哇哦!您可以使用一个try-with-resources块只用三行代码就编写出相同的逻辑,而以前需要 14 行代码。当程序退出块时,try-with-resources块自动关闭资源。一个try-with-resource区块可能有一个或多个catch区块和/或一个finally区块。

从表面上看,try-with-resources块就像上一个例子中的一样简单。然而,它带来了一些微妙之处,我们需要详细讨论。

您可以在一个try-with-resources块中指定多个资源。两个资源必须用分号隔开。最后一个资源后面不能跟分号。下面的代码片段展示了一个try-with-resources块使用一个或多个资源的一些用法:

try (AnyResource aRes1 = getResource1()) {
    // Use aRes1 here
}
try (AnyResource aRes1 = getResource1(); AnyResource aRes2 = getResource2()) {
    // Use aRes1 and aRes2 here
}

您在try-with-resources中指定的资源是隐式最终的。您可以将资源声明为 final,即使这样做是多余的:

try (final AnyResource aRes1 = getResource1()) {
    // Use aRes1 here
}

try-with-resources中指定的资源必须是类型java.lang.AutoCloseable。这个AutoCloseable接口有一个close()方法。当程序退出try-with-resources块时,自动调用所有资源的close()方法。在多个资源的情况下,以指定资源的相反顺序调用close()方法。

考虑清单 13-19 中所示的MyResource类。它实现了AutoCloseable接口,并为close()方法提供了实现。如果exceptionOnClose实例变量被设置为true,它的close()方法抛出一个RuntimeException。如果level等于或小于零,它的use()方法抛出一个RuntimeException。现在我们可以使用MyResource类来演示使用try-with-resources块的各种规则。

// MyResource.java
package com.jdojo.exception;
public class MyResource implements AutoCloseable {
    private int level;
    private boolean exceptionOnClose;
    public MyResource(int level, boolean exceptionOnClose) {
        this.level = level;
        this.exceptionOnClose = exceptionOnClose;
        System.out.println("Creating MyResource. Level = " + level);
    }
    public void use() {
        if (level <= 0) {
            throw new RuntimeException("Low in level.");
        }
        System.out.println("Using MyResource level " + this.level);
        level--;
    }
    @Override
    public void close() {
        if (exceptionOnClose) {
            throw new RuntimeException("Error in closing");
        }
        System.out.println("Closing MyResource...");
    }
}

Listing 13-19An AutoCloseable Resource Class

清单 13-20 显示了在try-with-resources块中使用MyResource对象的简单情况。输出表明try-with-resources块自动调用了MyResource对象的close()方法。

// SimpleTryWithResource.java
package com.jdojo.exception;
public class SimpleTryWithResource {
    public static void main(String[] args) {
        // Create and use a resource of MyResource type.
        // Its close() method will be called automatically
        try (MyResource mr = new MyResource(2, false)) {
            mr.use();
            mr.use();
        }
    }
}
Creating MyResource. Level = 2
Using MyResource level 2
Using MyResource level 1
Closing MyResource...

Listing 13-20A Simple Use of a MyResource Object in a try-with-resources Block

当自动关闭资源时,可能会引发异常。如果一个try-with-resources块在没有抛出异常的情况下完成,并且对close()方法的调用抛出了异常,那么运行时会报告由close()方法抛出的异常。如果一个try-with-resources块抛出一个异常,并且对close()方法的调用也抛出一个异常,那么运行时会抑制从close()方法抛出的异常,并报告从try-with-resources块抛出的异常。以下代码片段演示了这一规则:

// Create a resource of MyResource type with two levels, which can throw exception on
// closing and use it thrice so that its use() method throws an exception
try (MyResource mr = new MyResource (2, true) ) {
    mr.use();
    mr.use();
    mr.use(); // Will throw a RuntimeException
} catch(Exception e) {
    System.out.println(e.getMessage());
}
Creating MyResource. Level = 2
Using MyResource level 2
Using MyResource level 1
Low in level.

use()方法的第三次调用抛出了一个异常。在前面的代码片段中,自动的close()方法调用将抛出一个RuntimeException,因为您在创建资源时将true作为第二个参数传递。“低电平”的输出表明catch模块收到了从use()方法抛出的RuntimeException,而不是从close()方法抛出的。

您可以通过使用Throwable类的getSuppressed()方法来检索隐藏的异常(这个方法是在 Java 7 中添加的)。它返回一个Throwable对象的数组。数组中的每个对象代表一个被抑制的异常。下面的代码片段演示了如何使用getSuppressed()方法来检索隐藏的异常:

try (MyResource mr = new MyResource (2, true) ) {
    mr.use();
    mr.use();
    mr.use(); // Throws an exception
} catch(Exception e) {
    System.out.println(e.getMessage());
    // Display messages of suppressed exceptions
    System.out.println("Suppressed exception messages are...");
    for(Throwable t : e.getSuppressed()) {
        System.out.println(t.getMessage());
    }
}
Creating MyResource. Level = 2
Using MyResource level 2
Using MyResource level 1
Low in level.
Suppressed exception messages are...
Error in closing

Tip

在 Java 9 之前,引用在try-with-resources块中使用的资源的变量必须在同一个try-with-resources块中声明。这个限制在 Java 9 中被取消了,它允许在一个try-with-resources块中使用一个 final 或者有效的 final 资源变量。

在 Java 9 之前,try-with-resources块有一个限制,即必须在同一个try-with-resources块中声明引用资源的变量。如果您收到一个资源引用作为方法中的一个参数,您将不能像这样编写您的逻辑:

void useIt(MyResource res) {
    try(res) {
        // Work with res here
    }
}

JDK 9 取消了这一限制,即您必须为想要使用try-with-resource块管理的资源声明新的变量。现在,您可以使用一个 finaleffectively final 变量来引用一个由try-with-resources块管理的资源。如果变量是使用final关键字显式声明的,那么它就是最终变量:

// res is explicitly final
final MyResource res = new MyResource(2, false);

如果变量的值在初始化后从未改变,那么它实际上就是最终变量。在下面的代码片段中,res变量实际上是 final 变量,即使它没有被声明为 final。它被初始化并且不再被改变:

void doSomething() {
    // res is effectively final
    MyResource res = new MyResource(2, false);
    res.use();
}

通过 try 使用已经声明的变量,您可以编写如下内容:

MyResource res = new MyResource(2, false);
try (res) {
    // Work with res here
}

如果您想要使用一个try-with-resources块来管理多个资源,您可以这样做:

MyResource res1 = new MyResource(2, false);
MyResource res2 = new MyResource(3, false);
try (res1; res2) {
    // Use res1 and res2 here
}

您可以在同一个try-with-resources块中混合使用这两种方法。以下代码片段在try-with-resources块中使用了两个预声明的有效最终变量和一个新声明的变量:

MyResource res1 = new MyResource(2, false);
MyResource res2 = new MyResource(3, false);
try (res1; res2; MyResource res3 = new MyResource(5, false)) {
    // Use res1, res2, and res3 here
}

try-with-resource块中声明的变量是隐式的final。下面的代码片段显式声明了这样一个变量final,它等同于前面的代码示例:

MyResource res1 = new MyResource(2, false);
MyResource res2 = new MyResource(3, false);
// Declare res3 explicitly final
try (res1; res2; final MyResource res3 = new MyResource(5, false)) {
    // Use res1, res2, and res3 here
}

清单 13-21 包含了一个ResourceTest类的代码,它展示了一个完整的工作示例,展示了如何使用引用那些资源的 final 或有效的 final 变量,使用try-with-resources块来管理资源。

// ResourceTest.java
package com.jdojo.exception;
public class ResourceTest {
    public static void main(String[] args) {
        MyResource r1 = new MyResource(1, false);
        MyResource r2 = new MyResource(2, false);
        try (r1; r2) {
            r1.use();
            r2.use();
            r2.use();
        }
        useResource(new MyResource(3, false));
    }
    public static void useResource(MyResource res) {
        try (res; MyResource res4 = new MyResource(4, false)) {
            res.use();
            res4.use();
        }
    }
}

Creating MyResource. Level = 1
Creating MyResource. Level = 2
Using MyResource level 1
Using MyResource level 2
Using MyResource level 1
Closing MyResource...
Closing MyResource...
Creating MyResource. Level = 3
Creating MyResource. Level = 4
Using MyResource level 3
Using MyResource level 4
Closing MyResource...
Closing MyResource...

Listing 13-21A ResourceTest Class to Demonstrate the Use of try-with-resources Blocks in Java

摘要

一个例外是在 Java 程序中出现异常情况,其中没有定义正常的执行路径。Java 允许您将执行操作的代码与处理操作执行时可能发生的异常的代码分开。

使用try-catch块将您的动作执行代码放在try块中,将异常处理代码放在catch块中。一个try块也可能有一个finally块,通常用于清理try块中使用的资源。您可以组合使用try-catchtry-catch-finallytry-finally模块。

try-with-resources块可以方便地自动关闭资源。你可以在一个try-with-resources块中使用AutoCloseable资源。当块退出时,那些资源的close()方法被自动调用。在 Java 9 之前,引用在try-with-resources块中使用的资源的变量必须在同一个try-with-resources块中声明。Java 允许在try-with-resources块中使用有效的最终资源变量。

有两种类型的异常:检查的异常和未检查的异常。编译器确保所有检查到的异常都在程序中得到处理,或者程序在一个throws子句中声明它们。处理或声明未检查的异常是可选的。

EXERCISES

  1. Java 中的异常是什么?说出 Java 支持的两种异常类型。

  2. Java 中所有异常类的超类是什么?

  3. 如果一段代码可能抛出异常,你会使用什么类型的语句/块来放置你的代码?

  4. 在一个catch块中可以捕捉多少个异常?

  5. 可以从catch块内部抛出异常吗?

  6. 说出两个 Java 中的结构,你可以用它们来清理资源。

  7. Java 中检查和未检查的异常是什么?java.lang.ArithmeticException是被检查的异常吗?java.io.IOException是被检查的异常吗?

  8. 在方法声明中使用什么关键字来声明该方法抛出异常?

  9. 你用什么关键字抛出一个异常?

  10. 下面的语句会编译吗?

throw null;

如果这个语句编译了,执行的时候会怎么样?

  1. 可以在方法声明的throws子句中不指定异常的情况下抛出运行时异常吗?

  2. 下面的方法声明会编译吗?如果不是,请描述原因:

    public void test() {
        throw new RuntimeException("An error has occurred.");
        System.out.println("Everything is cool!");
    }
    
    
  3. 完成下面的代码片段,以便在标准输出中显示与异常相关的错误消息:

    try {
        int x = 100 / 0;
    } catch (ArithmeticException e) {
        String errorMessage = e./* You code goes here */;
        System.out.println(errorMessage);
    }
    
    
  4. Throwable类的什么方法打印异常对象的堆栈跟踪?

  5. 描述下面的try-catch块不编译的原因:

    try {
        // The following statement throws NumberFormatException
        int luckNumber = Integer.parseInt("Hello");
    } catch (Exception e) {
        // Handle the exception here
    } catch (NumberFormatException e) {
        // Handle the exception here
    }
    
    
  6. 考虑下面方法中的代码,假设MyResource是实现AutoCloseable接口的类。代码无法编译。描述代码不编译的原因并修复,所以编译:

    MyResource res = new MyResource(1, false);
    try (res) {
        res.use();
    }
    res = null;
    
    ```**
    
    

十四、断言

在本章中,您将学习:

  • Java 中的断言是什么

  • 如何在 Java 程序中使用断言

  • 如何启用和禁用断言

  • 如何检查断言的状态

本章中的所有类都是一个jdojo.assertion模块的成员,如清单 14-1 中所声明的。

// module-info.java
module jdojo.assertion {
    exports com.jdojo.assertion;
}

Listing 14-1The Declaration of a jdojo.assertion Module

什么是断言?

断言的字面意思是以一种强烈、自信和有力的方式语句某事。当你断言“某事”时,你相信“某事”是真的。请注意,断言“某事”并不意味着“某事”总是正确的。它只是意味着“某事”是真的可能性非常高(或者你非常有信心)。有时你可能是错的,即使你断言它是真的,那“某事”也可能是假的。

Java 中断言的含义与其字面意思相似。它是 Java 程序中的一个语句。它允许程序员在程序的特定点断言一个条件为真。考虑下面的代码片段,其中有两个语句,中间有一个注释:

int x = 10 + 15;
/* We assert that value of x is 25 at this point */
int z = x + 12;

第一条语句使用两个硬编码的整数值,1015,并将它们的总和赋给变量x。在第一条语句执行后,可以断言变量x的值是25。请注意,在这种情况下,使用了注释来进行断言。x 的值不是这个码中的25的概率是多少?你可能认为x的值不是25的概率是零。这意味着你的断言将永远为真。那么,当只看代码就很明显的时候,添加一个断言 x 的值是25的注释有什么意义呢?在编程中,在某个时候看起来显而易见的东西在其他时候可能并不明显。

假设存在一个getPrice()方法,考虑下面的代码片段:

int quantity = 15;
double unitPrice = getPrice();
/* We assert that unitPrice is greater than 0.0 at this point */
double totalPrice = quantity * unitPrice;

在这段代码中,您断言在执行第二条语句后,变量unitPrice的值将大于0.0。第二条语句执行后,unitPrice的值大于0.0的概率有多大?光看代码很难回答这个问题。然而,为了让代码正确工作,您假设断言“?? 的值大于 ??”必须为真。否则,您的代码将指出getPrice()方法中的严重错误。

对顾客来说,一件商品的价格总是大于零可能是显而易见的。然而,这对程序员来说并不明显,因为他们必须依赖于getPrice()方法的正确实现。如果getPrice()方法有 bug,程序员的断言就为假。如果程序员的断言是假的,他们需要知道他们断言的失败,他们需要修复 bug。如果断言是假的,他们就不会想继续进行价格计算。你用了一个评论来语句你的主张。注释不是可执行代码。即使unitPrice的值不大于零,你的注释也不会报告这个错误或者停止程序。在这种情况下,您需要使用断言工具来接收详细的错误消息并暂停程序。

您可以在 Java 中使用assert语句进行断言。assert语句的语法有两种形式:

  • assert booleanAssertionExpression;

  • assert booleanAssertionExpression : errorMessageExpression;

一个assert语句以assert关键字开始,后面是一个布尔断言表达式,它是程序员认为为真的条件。如果断言表达式评估为true,则不采取任何行动。如果断言表达式评估为false,运行时抛出一个java.lang.AsssertionError

第二种形式的assert语句语法允许您在抛出断言错误时指定一个定制的错误消息表达式。断言条件和自定义消息由冒号分隔。errorMessageExpression不一定是字符串。它可以是一个计算任何数据类型的表达式,除了void数据类型。运行时会将错误信息表达式的结果转换为字符串。您可以重写前面显示的代码来利用assert语句,就像这样:

int x = 10 + 15;
assert x == 25; // Uses the first form of the assert statement
int z = x + 12;

这里您用一个assert语句替换了注释。你需要指定的只是你断言为真的条件。您使用了第一种形式的assert语句。当断言失败时,您没有使用任何自定义消息。当断言失败时,Java 运行时为您提供所有细节,如行号、源代码、文件名等。关于错误。

在大多数情况下,assert语句的第一种形式就足够了。如果您认为出错时程序中的一些值可以帮助您更好地诊断问题,那么您应该使用第二种形式的assert语句。假设您想在断言失败时打印x的值。您可以使用以下代码片段:

int x = 10 + 15;
assert x == 25: "x = " + x; // Uses the second form of the assert statement
int z = x + 12;

如果您只想要x的值,而不想要其他的,您可以使用下面的代码片段:

int x = 10 + 15;
assert x == 25: x; // Uses the second form of the assert statement
int z = x + 12;

注意,assert语句的第二种形式中的errorMessageExpression可以是任何数据类型。这段代码提供了x作为errorMessageExpression的值,该值计算为int。当运行时抛出一个AssertionError时,它将使用x值的字符串表示。

此时,您可能想测试一下assert语句。在用assert语句编译和运行 Java 类之前,让我们讨论一些更多的细节。然而,您将使用带有assert语句的 Java 代码,如清单 14-2 所示。

// AssertTest.java
package com.jdojo.assertion;
public class AssertTest {
    public static void main(String[] args) {
        int x = 10 + 15;
        assert x == 100 :  "x = " + x; // should throw an AssertionError
    }
}

Listing 14-2A Simple Test Class to Test the assert Statement

AssertTest类的代码很简单。它给变量x赋值25,并断言x的值应该是100。当您运行AssertTest类时,您期望它总是抛出一个AssertionError,但是它需要以正确的方式运行,我们将在下面讨论。

测试断言

是时候看看assert声明的实际效果了。尝试在 NetBeans 中运行AssertTest类,或者在命令提示符下使用以下命令:

C:\JavaFun>java --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest

该命令结束时没有任何输出。您不希望在标准输出中出现错误消息吗?你的断言x == 100不是假的吗?x的值是25,不是100。在看到运行中的assert语句之前,您需要再执行一个步骤。尝试以下命令来运行AssertTest类:

C:\JavaFun>java -ea --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest
Exception in thread "main" java.lang.AssertionError: x = 25
        at jdojo.assertion/com.jdojo.assertion.AssertTest.main(AssertTest.java:7)

您也可以在 NetBeans 项目中启用断言。在 NetBeans 中右键单击项目名称,指定-ea为运行类别下的 VM 选项,如图 14-1 所示。一旦启用断言,在 NetBeans 中运行AssertTest类将会生成相同的错误。

img/323069_3_En_14_Fig1_HTML.png

图 14-1

在 NetBeans 项目中启用断言

当您运行AssertTest类时,会生成一个AssertionError,错误消息为“x = 25"”。这就是在代码中断言失败时发生的情况。Java 运行时抛出一个AssertionError。因为您在代码中使用了第二种形式的assert语句,所以错误消息还包含您的自定义断言消息,它打印了x的值。请注意,默认情况下,断言错误包含断言失败的行号和源代码文件名。该错误消息指出在AssertFile.java源文件的第 7 行断言失败。

那么将-ea开关与java命令结合使用的神奇之处是什么呢?默认情况下,assert语句不被 Java 运行时执行。换句话说,默认情况下,断言是禁用的。您必须在运行您的类时启用断言,以便执行您的assert语句。–ea开关在运行时启用断言。这就是当您使用–ea开关运行AssertTest类时收到预期错误消息的原因。我们将在下一节详细讨论启用/禁用断言。

启用/禁用断言

使用断言的目的是检测程序中的逻辑错误。通常,应该在开发和测试环境中启用断言。断言帮助程序员快速找到代码中问题的位置和类型。一旦应用程序被测试,断言就不太可能失败。Java 设计者牢记在生产环境中使用断言可能导致的性能损失。这就是默认情况下断言在运行时被禁用的原因。尽管不希望在生产环境中启用断言,但是您可以选择这样做。

Java 提供了命令行选项(或开关),以在运行时启用不同级别的断言。例如,您可以选择在所有用户定义的类、所有系统类、一个包及其子包中的所有类中启用断言,或者只为一个类启用断言,等等。表 14-1 列出了运行时可以在命令行上启用/禁用断言的所有开关。每个开关都有一个长格式和一个短格式。

表 14-1

运行时启用/禁用断言的命令行开关

|

命令行开关

|

描述

-enableassertions, -ea 用于在运行时为系统类和用户定义的类启用断言。您可以向此开关传递一个参数来控制启用断言的级别。
-disableassertions, -da 用于在运行时禁用系统类和用户定义类的断言。您可以向此开关传递一个参数,以控制禁用断言的级别。
-enablesystemassertions, -esa 用于在所有系统类中启用断言。您不能向此开关传递任何参数。
-disablesystemassertions, -dsa 用于禁用所有系统类中的断言。您不能向此开关传递任何参数。

两个开关-ea-da,让您控制不同级别断言的启用和禁用。您可以向这些开关传递一个参数,以控制应该启用或禁用断言的级别。请注意,您不能向-esa-dsa开关传递任何参数。它们在所有系统类中启用和禁用断言。如果您将一个参数传递给-ea-da开关,开关和参数必须用冒号隔开,如下所示。表 14-2 列出了可用于这些开关的可能参数:

表 14-2

可以传递给–ea 和–da 开关的参数列表

|

–ea 和–da 开关的参数

|

描述

(no argument) 启用或禁用所有用户定义的类中的断言。注意,要在所有系统类中启用/禁用断言,需要分别使用不带参数的–esa–dsa开关。
packageName... 注意packageName后面的三个点。它启用/禁用指定的packageName及其任何子包中的断言。它还可以用来启用/禁用系统包中的断言。
... 这个参数值是三个点。它启用/禁用当前工作目录中未命名包中的断言。
className 启用/禁用指定className中的断言。它还可以用来启用/禁用系统类中的断言。
  • -ea:<argument>

  • -da:<argument>

下面是使用带有不同参数的断言开关的示例。所有的例子都假设您在运行com.jdojo.assertion.AssertTest类时启用了断言。这些示例只向您展示了如何启用断言。默认情况下,所有断言都被禁用:

/* Enable assertions in all system classes */
C:\JavaFun>java –esa --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest
/* Enable assertions in all user-defined classes */
C:\JavaFun>java –ea --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest
/* Enable assertions in com.jdojo package and its sub-packages */
C:\JavaFun>java –ea:com.jdojo... --module-path dist --module   jdojo.assertion/com.jdojo.assertion.AssertTest
C:\JavaFun>java –ea:... --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest
/* Enable assertions in com.jdojo.assertion.AssertTest class */
C:\JavaFun>java –ea:com.jdojo.assertion.AssertTest --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest

您可以在一个命令中使用多个–ea–da开关来实现启用/禁用断言的更细粒度。所有开关都按照指定的顺序从左到右进行处理:

/* Enable assertions in the p1 package and all its sub-packages, and disable assertion for
 * the p1.p2.MyClass class
 */
C:\JavaFun>java -ea:p1... -da:p1.p2.MyClass --module-path dist --module jdojo.assertion/com.jdojo.assertion.AssertTest

Tip

当类被加载时,类的断言被启用或禁用。类的断言状态在设置后不能更改。这条规则有一个例外。如果在类初始化之前执行了一个assert语句,Java 运行时会像启用断言一样执行它。当两个类通过调用另一个类的构造器或方法在它们的static初始化器中相互引用时,就会出现这种情况。

使用断言

对于什么时候在程序中使用断言,可能会产生混淆。在 Java 中,通过在现有的异常类层次结构中添加一个新类java.lang.AssertionError来实现断言。有时程序员会将一个断言误认为是另一个异常。当你只看类层次结构时,这可能是真的,你可能会说它只是现有异常类层次结构中的另一个类。然而,异常和断言之间的相似之处仅限于类的层次结构。主要区别在于它们使用背后的原因。异常用于处理用户的错误和业务规则实现。如果有可能从异常情况中恢复,您希望从异常情况中恢复并继续应用程序。断言用于检测程序员所犯的编程错误。您不希望从编程错误中恢复并继续应用程序。断言用于验证程序员在代码的特定点上对程序的假设是正确的。您不应该使用断言来处理用户的错误或验证数据,因为断言不应该在生产环境中启用。

断言不应用于验证公共方法的数据参数。下面的代码片段是一个BankAccount类的credit()方法,它使用断言来验证被贷记的金额:

// An incorrect implementation
public void credit(double amount) {
    assert amount > 0.0 : "Invalid credit amount: " + amount;
    // Other code goes here
}

credit()方法的代码依赖于启用一个断言来验证账户的信用量。最有可能的是,在生产环境中断言将被禁用,这将允许甚至是负数的信用。这种对公共方法参数的验证应该使用异常来执行,如下所示:

// A correct implementation
public void credit(double amount) {
    if (amount <=  0.0) {
        throw new IllegalArgumentException("Invalid credit amount:" + amount);
    }
    // Other code goes here
}

您可以使用断言来验证非公共方法的参数。客户端不能直接调用非公共方法。如果一个非公共方法的参数不正确,这表明程序员的错误,使用断言是合适的。

您不应该使用有副作用的断言,例如修改对象状态的断言。假设reComputeState()改变了类的对象的状态,考虑方法中的以下代码片段:

assert reComputeState();

当这个assert语句被执行时,它将改变对象的状态。随后与对象的交互取决于其改变后的状态。如果断言被禁用,这段代码将不会执行,对象也不会正常工作。

您可以使用断言来实现类不变量。类不变量是关于确定类的对象状态的值总是成立的条件。当一个对象从一种状态转换到另一种状态时,类不变量在短暂的时间内可能不成立。假设您有一个包含四个实例变量的BankAccount类:namedobstartDatebalance。对于BankAccount对象,以下类不变量必须为真:

  • 账户上的name不能是null

  • 账户上的dob不能是null,也不能是未来的日期。

  • 账户上的startDate不能是null

  • 账户上的startDate不能在dob之前。

  • 账户上的balance必须大于零。

您可以将所有这些条件检查打包到一个方法中,比如说validAccount()方法:

private boolean validAccount() {
    boolean valid = false;
    // Check for class invariants here. Return true if it is true. Otherwise, return false.
    return valid;
}

您可以在方法和构造器中使用下面的断言,以确保类不变量是强制的。假设BankAccount类的toString()方法返回了足够多的信息来帮助程序员调试错误:

assert validAccount(); this.tostring();

您可以在每个方法的开头和从该方法返回之前使用这个assert语句。如果方法不修改对象的状态,就不需要检查方法内部的类不变量。您应该只在构造器的末尾使用它,因为当构造器开始执行时,类不变量将不再有效。

检查断言状态

你如何知道在你的程序中断言是否被激活?使用assert语句很容易检查断言状态。考虑以下代码片段:

boolean enabled = false;
assert enabled = true;
/* Check the value of enabled here */

这段代码使用了第一种形式的assert语句。注意,在表达式enabled = true中,它使用了赋值运算符(=),而不是相等比较运算符(==)。该表达式将把true赋给enabled变量,并对true求值。注意enabled变量已经被初始化为false。如果使能断言,在执行assert语句后,enabled变量的值将为true。如果断言被禁用,变量enabled的值将为false。因此,在assert语句之后检查enabled变量的值将会给你一个提示,即你的类是否启用了断言。清单 14-3 显示了检查是否为AssertionStatusTest类启用了断言的完整代码。注意,断言也可以在类的基础上启用或禁用。如果为某个特定的类启用了断言,并不保证所有其他类也启用了断言。

// AssertionStatusTest.java
package com.jdojo.assertion;
public class AssertionStatusTest {
    public static void main(String[] args)  {
        boolean enabled = false;
        assert enabled = true;
        if (enabled) {
            System.out.println("Assertion is enabled.");
        } else {
            System.out.println("Assertion is disabled.");
        }
    }
}

Listing 14-3A Program to Check Whether Assertion Is Enabled

摘要

断言是 Java 编程语言的一个特性,它允许您在程序中断言某些条件成立。关键字assert用于编写断言语句。断言用于检测程序中的逻辑错误,通常在开发和测试环境中启用。可以为包和类启用和禁用断言。它们不应该用于验证用户的输入或业务规则。断言不能代替异常。相反,它们相辅相成。

QUESTIONS AND EXERCISES

  1. Java 中的断言是什么?你用什么语句给你的程序添加断言?

  2. 描述两种形式的assert语句。

  3. 默认情况下是否启用断言?如果您的答案是否定的,您如何启用它?

  4. 您应该在以下哪个环境中启用断言:开发、测试和生产?

  5. 在所有系统类中,您用来启用和禁用断言的命令行选项是什么?

  6. 假设x必须大于10 :

    int x = getValue();
    assert /* Your code goes here */ : 'x must be greater than 10.";
    
    

    ,完成以下代码片段中的assert语句

  7. 您正在为一个公共方法编写代码,并且想要验证该方法的参数。你会用断言还是异常来实现?描述你的反应。

十五、字符串

在本章中,您将学习:

  • 什么是String对象

  • 如何创建String对象

  • 如何使用String文字

  • 如何操纵String s

  • 如何在switch语句或开关表达式中使用String s

  • 如何使用StringBuilderStringBuffer对象构造字符串

  • 如何创建多行字符串

本章中的所有类都是一个jdojo.string模块的成员,如清单 15-1 中所声明的。

// module-info.java
module jdojo.string {
    exports com.jdojo.string;
}

Listing 15-1The Declaration of a jdojo.string Module

什么是字符串?

零个或多个字符的序列称为字符串。在 Java 程序中,字符串由java.lang.String类的对象表示。String类是不可变的。也就是说,String对象的内容在创建后不能修改。String类有两个同伴类,java.lang.StringBuilderjava.lang.StringBuffer。伴随类是可变的。当字符串的内容可以修改时,应该使用它们。

在 Java 9 之前,String类的实现将字符存储在一个char数组中,对字符串中的每个字符使用 2 个字节。大多数String对象只包含拉丁 1 字符,只需要 1 个字节来存储字符串中的一个字符。因此,在大多数情况下,这种String对象的char数组中有一半的空间没有被使用。Java 9 改变了String类的内部实现,使用一个byte数组来存储String对象的内容;它还存储一个编码标志,指示String中的每个字符是 1 字节还是 2 字节。这样做是为了有效利用String对象使用的内存。作为开发人员,在程序中使用字符串不需要了解任何新知识,因为没有为String类更改公共接口。

字符串文字

字符串由一系列用双引号括起来的零个或多个字符组成。所有字符串文字都是String类的对象。字符串文字的示例有

String s1 = "";                       // An empty string
String s2 = "Hello";                  // String literal consisting of 5 characters
String s3 = "Just a string literal";  // String literal consisting of 21 characters

多个字符串文字可用于组成单个字符串文字:

// Composed of two string literals "Hello" and "Hi". It represents one string literal "HelloHi"
String s4 = "Hello" + "Hi";

使用两个双引号的字符串文字不能分成两行(可以使用本章后面介绍的文本块语法来实现这一点):

// Cannot break a string literal in multiple lines. A compile-time error
String wronStr = "Hello";

如果您想将"Hello"分成两行,您可以使用字符串连接运算符(+)将其断开,如下所示:

String s5 = "He" + "llo";

或者

String s6 = "He" + "llo";

这里显示了另一个多行字符串文字的例子。整个文本代表一个字符串文字:

String s7 = "This is a big string literal" +
" and it will continue in several lines." +
" It is also valid to insert multiple new lines as we did here. " +
"Adding more than one line in between two string literals " +
"is a feature of Java Language syntax, " +
" not of string literal.";

字符串中的转义序列字符

字符串由字符组成。使用所有转义序列字符来构成字符串文字是有效的。例如,要在字符串中包含换行符和回车符,可以使用\n\r,如下所示:

"\n"      // String literal with a line feed
"\r"      // String literal with a carriage return
"\n\r"    // String literal with a line feed and a carriage return
"First line.\nSecond line." // An embedded line feed
"Tab\tSeparated\twords"     // An embedded tab escape character
"Double quote \" is here"   // Embedded double quote in string literal

字符串中的 Unicode 转义

一个字符也可以用形式为\uxxxx的 Unicode 转义来表示,其中x是一个十六进制数字(0–9A–F)。在字符串文字中,字符'A',第一个大写的英文字母,也可以写成'\u0041',例如Apple\u0041pple在 Java 中的处理是一样的。换行符和回车转义符也可以用 Unicode 转义符分别表示为'\u000A''\u000D'。不能使用 Unicode 转义在字符串中嵌入换行符和回车符。换句话说,你不能在一个字符串中用'\u000A'替换'\n',用'\u000D'替换'\r'。为什么?原因是 Unicode 转义在编译过程的最开始就被处理,导致'\u000A'和'\u000D'分别被转换成一个真正的换行符和一个回车符。这违反了字符串不能在两行中继续的规则。例如,在编译的早期阶段,“Hel\u000Alo"被翻译成以下内容,这是一个无效的字符串文字,会生成编译时错误:

"Hello"

Tip

在字符串中使用 Unicode 转义符\u000A\u000D分别表示换行符和回车符是一个编译时错误。你必须使用\n\r的转义序列来代替。

什么是 CharSequence?

一个CharSequencejava.lang包中的一个接口。我在第二十一章中讨论接口。现在,您可以将CharSequence视为一个表示可读字符序列的对象。仅举几个例子,StringStringBufferStringBuilder就是CharSequence的例子。它们提供只读方法来读取一些属性和它们所表示的字符序列的内容。在String类的 API 文档中,您会看到许多方法的参数被声明为CharSequence。在需要一个CharSequence的地方,你总是可以通过一个String、一个StringBuilder或者一个StringBuffer

创建字符串对象

String类包含许多可以用来创建String对象的构造器。默认构造器允许您创建一个以空字符串为内容的String对象。例如,以下语句创建一个空的String对象,并将其引用赋给emptyStr变量:

String emptyStr = new String();

String类包含另一个构造器,它将另一个String对象作为参数:

String str1 = new String();
String str2 = new String(str1); // Passing a String as an argument

现在str1表示与str2相同的字符序列。此时,str1str2都代表一个空字符串。您也可以将字符串文字传递给此构造器:

String str3 = new String("");
String str4 = new String("Have fun!");

执行完这两条语句后,str3将引用一个String对象,它的内容是一个空字符串(零字符序列),而str4将引用一个String对象,它的内容是“Have fun!"”。

字符串的长度

String类包含一个length()方法,该方法返回String对象中的字符数。注意,length()方法返回字符串中的字符数,而不是字符串使用的字节数。方法length()的返回类型是int。清单 15-2 演示了如何计算字符串的长度。空字符串的长度为零。

// StringLength.java
package com.jdojo.string;
public class StringLength {
    public static void main (String[] args) {
        // Create two string objects
        String str1 = new String() ;
        String str2 = new String("Hello") ;
        // Get the length of str1 and str2
        int len1 = str1.length();
        int len2 = str2.length();
        // Display the length of str1 and str2
        System.out.println("Length of \"" + str1 + "\" = " + len1);
        System.out.println("Length of \"" + str2 + "\" = " + len2);
    }
}
Length of "" = 0
Length of "Hello" = 5

Listing 15-2Knowing the Length of a String

字符串文字是字符串对象

所有字符串文字都是String类的对象。编译器用对一个String对象的引用替换所有的字符串文字。考虑以下语句:

String str1 = "Hello";

当这个语句被编译时,编译器遇到字符串文字"Hello",它创建一个String对象,以"Hello"作为其内容。实际上,字符串文字和String对象是一样的。只要可以使用String对象的引用,就可以使用String文字。String类的所有方法都可以直接和String文字一起使用。例如,要计算String文字的长度,您可以编写

int len1 =  "".length();      // len1 is equal to 0
int len2 =  "Hello".length(); // len2 is equal to 5

字符串对象是不可变的

对象是不可变的。也就是说,您不能修改String对象的内容。这带来了一个好处,字符串可以被共享,而不用担心它们被修改。例如,如果您需要两个内容相同的String类的对象(相同的字符序列),您可以创建一个String对象,并且您可以在两个地方使用它的引用。有时,Java 中字符串的不变性会被误解,尤其是初学者。考虑下面这段代码:

String str;
str = new String("Just a string");
str = new String("Another string");

这里,str是可以引用任何String对象的引用变量。换句话说,str是可以改变的,是可变的。然而,str所指的String对象总是不可变的。这种情况如图 15-1 和 15-2 所示。

img/323069_3_En_15_Fig2_HTML.png

图 15-2

将不同的字符串对象引用赋给字符串变量

img/323069_3_En_15_Fig1_HTML.png

图 15-1

字符串引用变量和字符串对象

如果你不希望str在初始化后引用任何其他的String对象,你可以声明它为final,就像这样:

final String str = new String("str cannot refer to other object");
str = new String("Let us try"); // A compile-time error. str is final

Tip

不可变的是内存中的String对象,而不是String类型的引用变量。如果你想让一个引用变量一直引用内存中同一个String对象,你必须声明引用变量final

比较字符串

您可能想要比较由两个String对象表示的字符序列。String类覆盖了Object类的equals()方法,并提供了自己的实现,它根据内容比较两个字符串是否相等。例如,您可以比较两个字符串是否相等,如下所示:

String str1 = new String("Hello");
String str2 = new String("Hi");
String str3 = new String("Hello");
boolean b1, b2;
b1 = str1.equals(str2); // false will be assigned to b1
b2 = str1.equals(str3); // true will be assigned to b2

还可以将字符串文字与字符串文字或字符串对象进行比较,如下所示:

b1 = str1.equals("Hello");  // true will be assigned to b1
b2 = "Hello".equals(str1);  // true will be assigned to b2
b1 = "Hello".equals("Hi");  // false will be assigned to b1

回想一下,==操作符总是比较内存中两个对象的引用。比如str1 == str2str1 == str3会返回false,因为str1str2str3是内存中三个不同String对象的引用。注意new操作符总是返回一个新的对象引用。

有时您希望比较字符串以进行排序。您可能希望根据字符的 Unicode 值或它们在字典中出现的顺序对字符串进行排序。String类中的compareTo()方法和java.text.Collator类中的compare()方法允许您比较字符串以进行排序。

如果您想基于字符的 Unicode 值比较两个字符串,请使用String类的compareTo()方法,其声明如下:

public int compareTo(String anotherString)

它返回一个整数,可以是 0(零)、正整数或负整数。它比较两个字符串的相应字符的 Unicode 值。如果任意两个字符的 Unicode 值不同,该方法将返回这两个字符的 Unicode 值之差。例如,"a".compareTo("b")将返回–1.'a'的 Unicode 值为 97,'b'的 Unicode 值为 98。它返回差值97 – 98,也就是–1。以下是字符串比较的示例:

"abc".compareTo("abc") will return 0
"abc".compareTo("xyz") will return -23 (value of 'a' – 'x')
"xyz".compareTo("abc") will return 23 (value of 'x' – 'a')

非常重要的一点是,compareTo()方法根据字符的 Unicode 值来比较两个字符串。该比较可能与字典顺序比较不同。这对于英语和其他一些语言来说没有问题,在这些语言中,字符的 Unicode 值与字符的字典顺序相同。在字符的字典顺序可能与其 Unicode 值不同的语言中,不应使用此方法来比较两个字符串。要执行基于语言的字符串比较,应该使用java.text.Collator类的compare()方法。参考本章中的“区分语言的字符串比较”一节,了解如何使用java.text.Collator类。清单 15-3 演示了字符串比较。

// StringComparison.java
package com.jdojo.string;
public class StringComparison {
    public static void main(String[] args) {
        String apple = new String("Apple");
        String orange = new String("Orange");
        System.out.println(apple.equals(orange));
        System.out.println(apple.equals(apple));
        System.out.println(apple == apple);
        System.out.println(apple == orange);
        System.out.println(apple.compareTo(apple));
        System.out.println(apple.compareTo(orange));
    }
}
false
true
true
false
0
-14

Listing 15-3Comparing Strings

字符串池

Java 维护了一个包含所有字符串的池,以最小化内存使用并获得更好的性能。它在字符串池中为程序中找到的每个字符串创建一个String对象。当它遇到一个字符串文字时,它在字符串池中寻找具有相同内容的字符串对象。如果它在字符串池中没有找到匹配,它将使用该内容创建一个新的String对象,并将其添加到字符串池中。最后,它用池中新创建的String对象的引用替换字符串。如果它在字符串池中找到一个匹配,它就用在池中找到的String对象的引用替换字符串。让我们用一个例子来讨论这个场景。考虑以下语句:

String str1 = new String("Hello");

当 Java 遇到字符串文字"Hello"时,它会尝试在字符串池中寻找匹配。如果字符串池中没有内容为"Hello"String对象,则创建一个内容为"Hello"的新的String对象,并将其添加到字符串池中。字符串文字"Hello"将被字符串池中新的String对象的引用所替换。因为使用了new操作符,Java 将在堆上创建另一个 String 对象。因此,在这种情况下将创建两个String对象。考虑以下代码:

String str1 = new String("Hello");
String str2 = new String("Hello");

这段代码会创建多少个String对象?假设执行第一条语句时,"Hello"不在字符串池中。因此,第一条语句将创建两个String对象。当执行第二条语句时,将在字符串池中找到字符串文字"Hello"。这一次,"Hello"将被替换为引用池中已经存在的对象。然而,Java 将创建一个新的String对象,因为您在第二条语句中使用了new操作符。假设"Hello"不在字符串池中,前面两条语句将创建三个String对象。如果这些语句开始执行时"Hello"已经在字符串池中,那么只会创建两个String对象。考虑以下语句:

String str1 = new String("Hello");
String str2 = new String("Hello");
String str3 = "Hello";
String str4 = "Hello";

str1 == str2返回的值会是什么?它将是false,因为new操作符总是在内存中创建一个新对象,并返回这个新对象的引用。

str2 == str3返回的值会是什么?又会是false了。这个需要稍微解释一下。注意new操作符总是创建一个新的对象。因此,str2在内存中有一个对新对象的引用。因为在执行第一条语句时已经遇到了"Hello",所以它存在于字符串池中,str3引用字符串池中内容为"Hello"String对象。所以str2str3引用两个不同的对象,str2 == str3返回false

str3 == str4返回的值会是什么?会是true。注意,在执行第一条语句时,"Hello"已经被添加到字符串池中。第三条语句将把字符串池中一个String对象的引用分配给str3。第四条语句将把字符串池中相同的对象引用分配给str4。换句话说,str3str4在字符串池中引用同一个String对象。==运算符比较两个参考值;因此,str3 == str4返回true。考虑另一个例子:

String s1 = "Have" + "Fun";
String s2 = "HaveFun";

s1 == s2会回true吗?是的,它会返回true。当在编译时常量表达式中创建一个String对象时,它也被添加到字符串池中。由于"Have" + "Fun"是一个编译时常量表达式,结果字符串"HaveFun"将被添加到字符串池中。因此,s1s2会引用字符串池中的同一个对象。

所有编译时常量字符串都被添加到字符串池中。考虑以下示例来阐明此规则:

final String constStr = "Constant";  // constStr is a constant
String varStr = "Variable";          // varStr is not a constant
// "Constant is pooled" will be added to the string pool
String s1 = constStr + " is pooled";
// Concatenated string will not be added to the string pool
String s2 = varStr + " is not pooled";

执行这段代码后,"Constant is pooled" == s1将返回true,而"Variable is not pooled" == s2将返回false

Tip

编译时常量表达式产生的所有字符串和字符串都被添加到字符串池中。

您可以使用其intern()方法将一个String对象添加到字符串池中。如果找到匹配项,intern()方法将从字符串池中返回对象的引用。否则,它向字符串池添加一个新的String对象,并返回新对象的引用。例如,在前面的代码片段中,s2引用了一个String对象,其内容为"Variable is not pooled"。您可以通过编写以下代码将这个String对象添加到字符串池中

// Will add the content of s2 to the string pool and return the reference
// of the string object from the pool
s2 = s2.intern();

现在"Variable is not pooled" == s2将返回true,因为您已经在s2上调用了intern()方法,并且它的内容已经被缓冲。

Tip

String类在内部维护一个字符串池。所有字符串都会自动添加到池中。您可以通过调用String对象上的intern()方法将自己的字符串添加到池中。您不能直接访问该池。除了退出并重新启动应用程序之外,没有办法从池中删除字符串对象。

字符串操作

本节描述了对String对象的一些常用操作。

获取索引处的字符

您可以使用charAt()方法从String对象中获取特定索引处的字符。索引从零开始。表 15-1 显示了字符串"HELLO"中所有字符的索引。

表 15-1

字符串“HELLO”中所有字符的索引

| 索引-> | `0` | `1` | `2` | `3` | `4` | | 字符-> | `H` | `E` | `L` | `L` | `O` |

注意第一个字符H的索引是 0(零),第二个字符E是 1,依此类推。最后一个字符 O 的索引是 4,等于字符串"Hello"的长度减 1。

下面的代码片段将打印索引值和字符串"HELLO"中每个索引处的字符:

String str = "HELLO";
// Get the length of string
int len = str.length();
// Loop through all characters and print their indexes
for (int i = 0; i < len; i++) {
    System.out.println(str.charAt(i) + " is at index " + i);
}
H is at index 0
E is at index 1
L is at index 2
L is at index 3
O is at index 4

测试字符串是否相等

如果您想比较两个字符串是否相等并忽略它们的大小写,您可以使用equalsIgnoreCase()方法。如果您想执行区分大小写的相等比较,您需要使用equals()方法,如前所述:

String str1 = "Hello";
String str2 = "HELLO";
if (str1.equalsIgnoreCase(str2)) {
    System.out.println ("Ignoring case str1 and str2 are equal");
} else {
    System.out.println("Ignoring case str1 and str2 are not equal");
}
if (str1.equals(str2)) {
    System.out.println("str1 and str2 are equal");
} else {
    System.out.println("str1 and str2 are not equal");
}
Ignoring case str1 and str2 are equal
str1 and str2 are not equal

测试字符串是否为空

有时你需要测试一个String对象是否为空。空字符串的长度为零。有三种方法可以检查空字符串:

  • 使用isEmpty()方法。

  • 使用equals()方法。

  • 获取String的长度,并检查它是否为零。

以下代码片段显示了如何使用所有三种方法:

String str1 = "Hello";
String str2 = "";
// Using the isEmpty() method
boolean empty1 = str1.isEmpty();     // Assigns false to empty1
boolean empty2 = str2.isEmpty();     // Assigns true to empty1
// Using the equals() method
boolean empty3 = "".equals(str1);    // Assigns false to empty3
boolean empty4 = "".equals(str2);    // Assigns true to empty4
// Comparing length of the string with 0
boolean empty5 = str1.length() == 0; // Assigns false to empty5
boolean empty6 = str2.length() == 0; // Assigns true to empty6

这些方法中哪一种最好?第一种方法可能看起来可读性更强,因为方法名称暗示了它的意图。然而,第二种方法是首选,因为它可以优雅地处理与null的比较。如果字符串是null,第一个和第三个方法抛出一个NullPointerException。第二种方法在字符串为null时返回false,例如"".equals(null)返回false

改变案例

要将字符串的内容转换成小写和大写,可以分别使用toLowerCase()方法和toUpperCase()方法。例如,"Hello".toUpperCase()将返回字符串"HELLO",而"Hello".toLowerCase()将返回字符串"hello"

回想一下String对象是不可变的。当您在一个String对象上使用toLowerCase()toUpperCase()方法时,原始对象的内容不会被修改。相反,Java 创建了一个新的String对象,它的内容与原来的String对象相同,只是改变了原来字符的大小写。以下代码片段创建了三个String对象:

String str1 = new String("Hello"); // str1 contains "Hello"
String str2 = str1.toUpperCase();  // str2 contains "HELLO"
String str3 = str1.toLowerCase();  // str3 contains "hello"

搜索字符串

您可以使用indexOf()lastIndexOf()方法获取一个字符或一个字符串在另一个字符串中的索引,例如:

String str = "Apple";
int index = str.indexOf('p');  // index will have a value of 1
index = str.indexOf("pl");     // index will have a value of 2
index = str.lastIndexOf('p');  // index will have a value of 2
index = str.lastIndexOf("pl"); // index will have a value of 2
index = str.indexOf("k");      // index will have a value of -1

indexOf()方法从字符串的开头开始搜索字符或字符串,并返回第一个匹配的索引。lastIndexOf()方法从末尾开始匹配字符或字符串,并返回第一个匹配的索引。如果在字符串中没有找到字符或字符串,这些方法返回–1

将值表示为字符串

String类有一个重载的valueOf()静态方法。它可用于获取任何原始数据类型或任何对象的值的字符串表示形式,例如:

String s1 = String.valueOf('C');  // s1 has "C"
String s2 = String.valueOf("10"); // s2 has "10"
String s3 = String.valueOf(true); // s3 has "true"
String s4 = String.valueOf(1969); // s4 has "1969"

获取子字符串

您可以使用substring()方法来获取字符串的一部分。此方法重载如下:

  • 字符串子字符串(int startIndex)

  • string substr(int begin index,int endIndex)

第一个版本返回一个字符串,该字符串从索引beginIndex处的字符开始,一直延伸到该字符串的末尾。第二个版本返回一个字符串,从索引beginIndex处的字符开始,延伸到索引endIndex - 1处的字符。如果指定的索引超出了字符串的范围,这两种方法都会抛出一个IndexOutOfBoundsException。以下是使用这些方法的示例:

String s1 = "Hello".substring(1);    // s1 has "ello"
String s2 = "Hello".substring(1, 4); // s2 has "ell"

修剪绳子

您可以使用trim()方法删除字符串中所有的前导和尾随空格以及控制字符。事实上,trim()方法从字符串中删除了所有前导和尾随字符,这些字符的 Unicode 值小于\u0020(十进制 32)。例如:

  • " hello ".trim()会回“你好”。

  • "hello ".trim()会回“你好”。

  • "\n \r \t hello\n\n\n\r\r"会回“你好”。

注意,trim()方法只删除了开头和结尾的空白。它不会删除出现在字符串中间的任何空白或控制字符,例如:

  • 因为\n在字符串中,所以" he\nllo ".trim()将返回"he\nllo"

  • "h ello".trim()将返回"h ello",因为空格在字符串内部。

替换字符串的一部分

String类包含以下方法,允许您通过用不同的字符或字符串替换旧字符串的一部分来创建新字符串:

  • String replace(char oldChar, char newChar)

  • String replace(CharSequence target, CharSequence replacement)

  • String replaceAll(String regex, String replacement)

  • String replaceFirst(String regex, String replacement)

replace(char oldChar, char newChar)方法通过用newChar替换所有出现的oldChar来返回一个新的String对象。这里有一个例子:

// Both 'o's in "tooth" will be replaced by two 'e'. str will contain "teeth"
String str = "tooth".replace('o', 'e');

replace(CharSequence target, CharSequence replacement)方法与CharSequence一起工作。它通过用replacement替换所有出现的target来返回一个新的String对象。这里有一个例子:

// "oo" in "tooth" will be replaced by "ee". str will contain "teeth"
String str = "tooth".replace("oo", "ee");

replaceAll(String regex, String replacement)方法使用regex中的正则表达式来查找匹配。它通过用replacement替换每个匹配返回一个新的String对象。匹配一个数字的正则表达式是\d。我在第十八章中讲述了正则表达式。这里有一个例子:

// Replace all digits with an *. str contains "Born on Sept **, ****"
String str = "Born on Sept 19, 1969".replaceAll("\\d", "*");

replaceFirst(String regex, String replacement)方法的工作原理与replaceAll()方法相同,除了它只使用replacement替换第一个匹配。这里有一个例子:

// Replace the first digit with an *. str contains "Born on Sept *9, 1969"
String str = "Born on Sept 19, 1969".replaceFirst("\\d", "*");

匹配字符串的开头和结尾

startsWith()方法检查字符串是否以指定的参数开始,而endsWith()检查字符串是否以指定的字符串参数结束。两种方法都返回一个boolean值。以下是使用这些方法的示例:

String str = "This is a Java program";
// Test str if it starts with "This"
if (str.startsWith("This")){
    System.out.println("String starts with This");
} else {
    System.out.println("String does not start with This");
}
// Test str if it ends with "program"
if (str.endsWith("program")) {
    System.out.println("String ends with program");
} else {
    System.out.println("String does not end with program");
}
String starts with This
String ends with program

拆分和连接字符串

在指定的分隔符周围拆分一个字符串,并使用指定的分隔符将多个字符串连接成一个字符串通常很有用。

使用split()方法将一个字符串拆分成多个字符串。使用分隔符执行拆分。split()方法返回一个String的数组。你将在第十九章中学习数组。但是,在本节中,您将使用它来完成字符串的操作。

Note

split()方法采用一个定义模式的正则表达式作为分隔符。

String str = "AL,FL,NY,CA,GA";
// Split str using a comma as the delimiter
String[] parts = str.split(",");
// Print the the string and its parts
System.out.println(str);
for(String part : parts) {
    System.out.println(part);
}
AL,FL,NY,CA,GA
AL
FL
NY
CA
GA

Java 8 在String类中添加了一个静态的join()方法,将多个字符串连接成一个字符串。它超载了:

  • String join(CharSequence delimiter, CharSequence... elements)

  • String join(CharSequence delimiter, Iterable<? extends CharSequence> elements)

第一个版本采用一个分隔符和一系列要连接的字符串。第二个参数是 var-args,所以您也可以将一个数组传递给这个方法。

第二个版本采用一个分隔符和一个Iterable,例如一个ListSet。以下代码片段使用第一个版本来连接几个字符串:

// Join some strings using a comma as the delimiter
String str = String.join(",", "AL", "FL", "NY", "CA", "GA");
System.out.println(str);
AL,FL,NY,CA,GA

switch 语句中的字符串

我们在第六章讨论了switch语句。您也可以在switch语句中使用字符串。switch表达式使用了一个String类型。如果switch表达式是null,则抛出NullPointerExceptioncase标签必须是String文字或常量。不能在case标签中使用String变量。下面是一个在switch语句中使用String的例子,它将在标准输出中打印"Turn on":

String status = "on";
switch(status) {
    case "on":
        System.out.println("Turn on"); // Will execute this
        break;
    case "off":
        System.out.println("Turn off");
        break;
    default:
        System.out.println("Unknown command");
        break;
}

字符串的switch语句将switch表达式与case标签进行比较,就好像调用了String类的equals()方法一样。在前面的例子中,将调用status.equals("on")来测试是否应该执行第一个case块。注意,String类的equals()方法执行区分大小写的字符串比较。这意味着使用字符串的switch语句是区分大小写的。

下面的switch语句将在标准输出中打印"Unknown command",因为大写的switch表达式"ON"与小写的第一个case标签"on"不匹配:

String status = "ON";
switch(status) {
    case "on":
        System.out.println("Turn on");
        break;
    case "off":
        System.out.println("Turn off");
        break;
    default:
        System.out.println("Unknown command"); // Will execute this
        break;
}

作为一个良好的编程实践,在执行带有字符串的switch语句之前,您需要做以下两件事:

  • 检查switch语句的switch值是否为null。如果是null,不执行switch语句。

  • 如果您想在switch语句中执行不区分大小写的比较,您需要将switch表达式转换为小写或大写,并相应地在case标签中使用小写或大写。

您可以重写前面的switch语句示例,如清单 15-4 所示,它考虑了两个建议。

// StringInSwitch.java
package com.jdojo.string;
public class StringInSwitch {
    public static void main(String[] args) {
        operate("on");
        operate("off");
        operate("ON");
        operate("Nothing");
        operate("OFF");
        operate(null);
    }
    public static void operate(String status) {
        // Check for null
        if (status == null) {
            System.out.println("status cannot be null.");
            return;
        }
        // Convert to lowercase
        switch (status.toLowerCase()) {
            case "on":
                System.out.println("Turn on");
                break;
            case "off":
                System.out.println("Turn off");
                break;
            default:
                System.out.println("Unknown command");
                break;
        }
    }
}
Turn on
Turn off
Turn on
Unknown command
Turn off
status cannot be null.

Listing 15-4Using Strings in a switch Statement

测试字符串的回文

如果你是一个有经验的程序员,你可以跳过这一节。这对初学者来说是一个简单的练习。

回文是一个单词、一句诗、一个句子或一个数字,向前和向后读起来都一样。例如,“在我看到厄尔巴岛之前我是能干的”和 1991 就是回文的例子。让我们写一个方法,它接受一个字符串作为参数,并测试这个字符串是否是一个回文。如果字符串是回文,该方法将返回true。否则将返回false。您将使用在前面章节中学到的String类的一些方法。下面是对该方法中要执行的步骤的描述。

假设输入字符串的字符数为n。您需要比较索引 0 和(n–1)、1 和(n–2)、2 和(n–3)等处的字符。请注意,如果您继续比较,最后,您将比较索引(n–1)处的字符和索引 0 处的字符,这在开始时已经比较过了。你只需要在中途比较一下角色。如果所有相等的比较都返回true,那么这个字符串就是一个回文。

字符串中的字符数可以是奇数也可以是偶数。在这两种情况下,只比较字符的一半是可行的。字符串的中间根据字符串的长度是奇数还是偶数而变化。例如,字符串"FIRST"的中间是字符R.字符串"SECOND"的中间字符是什么?你可以说它没有中间字,因为它的长度是偶数。为此,有趣的是,如果字符串中的字符数是奇数,您不需要将中间的字符与任何其他字符进行比较。

如果字符串中的字符数是偶数,则需要继续进行字符比较,直到字符串长度的一半;如果字符数是奇数,则需要继续进行字符比较,直到字符串长度的一半减去一个。通过将字符串长度除以 2,可以得到两种情况下要进行的比较次数。注意,字符串的长度是整数;如果您将一个整数除以 2,整数除法将丢弃分数部分,如果有的话,这将处理奇数字符的情况。清单 15-5 包含完整的代码。

// Palindrome.java
package com.jdojo.string;
import java.util.Objects;
public class Palindrome {
    public static void main(String[] args) {
        String str1 = "hello";
        boolean b1 = Palindrome.isPalindrome(str1);
        System.out.println(str1 + " is a palindrome: " + b1);
        String str2 = "noon";
        boolean b2 = Palindrome.isPalindrome(str2);
        System.out.println(str2 + " is a palindrome: " + b2);
    }
    public static boolean isPalindrome(String inputString) {
        Objects.requireNonNull(inputString, "String cannot be null.");
        // Get the length of string
        int len = inputString.length();
        // In case of an empty string and one character strings, we do not need to
        // do any comparisons. They are always palindromes.
        if (len <= 1) {
            return true;
        }
        // Convert the string into uppercase, so we can make the comparisons case insensitive
        String newStr = inputString.toUpperCase();
        // Initialize the result variable to true
        boolean result = true;
        // Get the number of comparisons to be done
        int counter = len / 2;
        // Do the comparison
        for (int i = 0; i < counter; i++) {
            if (newStr.charAt(i) != newStr.charAt(len - 1 - i)) {
                // It is not a palindrome
                result = false;
                // Exit the loop
                break;
            }
        }
        return result;
    }
}
hello is a palindrome: false
noon is a palindrome: true

Listing 15-5Testing a String for a Palindrome

StringBuilder 和 StringBuffer

StringBuilderStringBufferString类的伙伴类。与String不同,它们代表一个可变的字符序列。也就是说,您可以更改StringBuilderStringBuffer的内容,而无需创建新对象。您可能想知道为什么存在两个类来表示同一个东西——一个可变的字符序列。StringBuffer类从一开始就是 Java 库的一部分,而StringBuilder类是在 Java 5 中添加的。两者的区别在于线程安全。StringBuffer是线程安全的,StringBuilder不是线程安全的。大多数时候,您不需要线程安全,在这些情况下使用StringBuffer会有性能损失。这就是后来加上StringBuilder的原因。这两个类有相同的方法,除了StringBuffer中的所有方法都是同步的。本节我们将只讨论StringBuilder。在代码中使用StringBuffer只是改变类名的问题。

Tip

当不需要线程安全时,使用StringBuilder,例如,在方法或构造器中操作局部变量中的字符序列。否则,使用StringBuffer。线程安全和同步将在本系列的第二卷中介绍。

在字符串内容经常变化的情况下,可以使用StringBuilder类的对象,而不是String类。回想一下,由于String类的不变性,使用String对象的字符串操作会产生许多新的String对象,从而降低性能。一个StringBuilder对象可以被认为是一个可修改的字符串。它有许多方法来修改它的内容。StringBuilder类包含四个构造器:

  • StringBuilder()

  • StringBuilder(CharSequence seq)

  • StringBuilder(int capacity)

  • StringBuilder(String str)

无参数构造器创建一个默认容量为 16 的空StringBuilder

第二个构造器将一个CharSequence对象作为参数。它创建一个StringBuilder对象,其内容与指定的CharSequence相同。

第三个构造器以一个int作为参数;它创建一个空的StringBuilder对象,其初始容量与指定的参数相同。一个StringBuilder的容量是在不分配更多空间的情况下它能容纳的字符数。当需要额外空间时,容量会自动调整。

第四个构造器获取一个String并创建一个与指定的String具有相同内容的StringBuilder。以下是创建StringBuilder对象的一些例子:

// Create an empty StringBuilder with a default initial capacity of 16 characters
StringBuilder sb1 = new StringBuilder();
// Create a StringBuilder from of a string
StringBuilder sb2 = new StringBuilder("Here is the content");
// Create an empty StringBuilder with 200 characters as the initial capacity
StringBuilder sb3 = new StringBuilder(200);

append()方法允许您将文本添加到StringBuilder的末尾。它超载了。它需要多种类型的论证。有关所有重载的append()方法的完整列表,请参考该类的 API 文档。它还有其他方法,例如insert()delete(),也可以让你修改它的内容。

StringBuilder类有两个属性:lengthcapacity。在给定的时间点,它们的值可能不相同。它的长度是指其内容的长度,而它的容量是指它在不需要分配新内存的情况下可以容纳的最大字符数。它的长度在任何时候都至多等于它的容量。length()capacity()方法分别返回它的长度和容量,例如:

StringBuilder sb = new StringBuilder(200);  // Capacity:200, length:0
sb.append("Hello");                         // Capacity:200, length:5
int len = sb.length();                      // len is assigned 5
int capacity = sb.capacity();               // capacity is assigned 200

一个StringBuilder的容量是由运行时控制的,而它的长度是由你放入其中的内容控制的。当其内容被修改时,运行时会调整容量。

您可以通过使用toString()方法将StringBuilder的内容作为String获取:

// Create a String object
String s1 = new String("Hello");
// Create a StringBuilder from of the String object s1
StringBuilder sb = new StringBuilder(s1);
// Append " Java" to the StringBuilder’s content
sb.append(" Java"); // Now, sb contains "Hello Java"
// Get a String from the StringBuilder
String s2 = sb.toString(); // s2 contains "Hello Java"

String不同,StringBuilder有一个setLength()方法,它把它的新长度作为参数。如果新长度大于现有长度,多余的位置用null字符填充(空字符为\u0000)。如果新长度小于现有长度,其内容将被截断以适应新长度:

// Length is 5
StringBuilder sb = new StringBuilder("Hello");
// Now the length is 7 with last two characters as null character '\u0000'
sb.setLength(7);
// Now the length is 2 and the content is "He"
sb.setLength(2);

StringBuilder类有一个reverse()方法,用相同的字符序列替换它的内容,但是顺序相反。清单 15-6 展示了StringBuilder类的一些方法。

// StringBuilderTest.java
package com.jdojo.string;
public class StringBuilderTest {
    public static void main(String[] args) {
        // Create an empty StringBuilder
        StringBuilder sb = new StringBuilder();
        printDetails(sb);
        // Append "blessings"
        sb.append("blessings");
        printDetails(sb);
        // Insert "Good " in the beginning
        sb.insert(0, "Good ");
        printDetails(sb);
        // Delete the first o
        sb.deleteCharAt(1);
        printDetails(sb);
        // Append " be with you"
        sb.append(" be with you");
        printDetails(sb);
        // Set the length to 3
        sb.setLength(3);
        printDetails(sb);
        // Reverse the content
        sb.reverse();
        printDetails(sb);
    }
    public static void printDetails(StringBuilder sb) {
        System.out.println("Content: \"" + sb + "\"");
        System.out.println("Length: " + sb.length());
        System.out.println("Capacity: " + sb.capacity());
        // Print an empty line to separate results
        System.out.println();
    }
}
Content: ""
Length: 0
Capacity: 16
Content: "blessings"
Length: 9
Capacity: 16
Content: "Good blessings"
Length: 14
Capacity: 16
Content: "God blessings"
Length: 13
Capacity: 16
Content: "God blessings be with you"
Length: 25
Capacity: 34
Content: "God"
Length: 3
Capacity: 34
Content: "doG"
Length: 3
Capacity: 34

Listing 15-6Using a StringBuilder Object

字符串串联运算符(+)

有三种连接字符串的方法:

  • 使用String类的concat(String str)方法

  • 使用+字符串串联运算符

  • 使用StringBuilderStringBuffer

concat()方法将一个String作为参数,这意味着您只能用它来连接字符串。如果要将不同数据类型的值连接成一个字符串,请使用连接运算符,例如:

// Assigns "hi there" to s1
String s1 = "hi ".concat(" there");
// Assign "XY12.56" to s2
String s2 = "X" + "Y" + 12.56;
// Assign "XY12.56" to s3
String s3 = new StringBuilder().append("X").append("Y").append(12.56).toString();

多行字符串

在版本 15 和更高版本中,通过文本块向 Java 添加了多行字符串支持。文本块必须以三个引号和一个新行开始,这是定义跨越多行的字符串的一种更方便的方式。以前在 Java 中,如果你想定义一个包含换行符或多行的字符串,你需要使用“\n”在字符串中显式地提供换行符(新行)。

例如,使用文本块,可以定义如下所示的多行字符串:

String text = """
     First line.
     Second line.
     Third line.
          """;

Java 忽略每行前面的空白(空格或制表符)。这使得在保持所有文本缩进的同时定义多行字符串成为可能。

以前,如果没有文本块,您需要以下列方式定义相同的字符串:

String text = "First line.\n" +
     "Second line.\n" +
     "Third line.\n";

在定义多行字符串时,不要忘记包括三个引号和一个新行。例如,以下语法将不起作用,因为它不包括三个引号后的新行:

String text = """First line.
      Second line.
      Third line.
          """;

您不需要转义文本块中的引号。否则,文本块的字符串定义规则与普通字符串相同。清单 15-7 展示了文本块的作用。

// TextBlocks.java
public class TextBlocks {
    public static void main(String[] args) {
         String text = """
         First line.
         "Second line."
         Third line.
         """;
         System.out.print(text);
    }
}
First line.
"Second line."
Third line.

Listing 15-7Multiline Strings

这个程序演示了如何定义一个包含引号的多行字符串,并打印出结果。

文本块使得在 Java 程序中定义多行 SQL 查询或 JSON 主体更加容易。

区分语言的字符串比较

String类根据字符的 Unicode 值来比较字符串。有时,您可能希望根据字典顺序来比较字符串。

使用java.text.Collator类的compare()方法执行区分语言(字典顺序)的字符串比较。该方法将两个要比较的字符串作为参数。如果两个字符串相同,则返回0,如果第一个字符串在第二个字符串之后,则返回1,如果第一个字符串在第二个字符串之前,则返回-1。清单 15-8 展示了Collator类的用法。

// CollatorStringComparison.java
package com.jdojo.string;
import java.text.Collator;
import java.util.Locale;
public class CollatorStringComparison {
    public static void main(String[] args) {
        // Create a Locale object for US
        Locale USLocale = new Locale("en", "US");
        // Get a Collator instance for US
        Collator c = Collator.getInstance(USLocale);
        String str1 = "cat";
        String str2 = "Dog";
        int diff = c.compare(str1, str2);
        System.out.print("Comparing using Collator class: ");
        print(diff, str1, str2);
        System.out.print("Comparing using String class: ");
        diff = str1.compareTo(str2);
        print(diff, str1, str2);
    }
    public static void print(int diff, String str1, String str2) {
        if (diff > 0) {
            System.out.println(str1 + " comes after " + str2);
        } else if (diff < 0) {
            System.out.println(str1 + " comes before " + str2);
        } else {
            System.out.println(str1 + " and " + str2 + " are the same.");
        }
    }
}
Comparing using Collator class: cat comes before Dog
Comparing using String class: cat comes after Dog

Listing 15-8Language-Sensitive String Comparisons

该程序还使用String类显示了相同的两个字符串的比较。请注意,在字典顺序中,单词"cat"位于单词"Dog"之前。Collator类使用它们的字典顺序来比较它们。然而,String类比较了"cat"的第一个字符的 Unicode 值(99)和"Dog"的第一个字符的 Unicode 值(68)。基于这两个值,String类确定"Dog""cat"之前。输出确认了比较字符串的两种不同方式。

摘要

在这一章中,你学习了StringStringBuilderStringBuffer类。一个String表示不可变的字符序列,而StringBuilderStringBuffer表示可变的字符序列。StringBuilderStringBuffer工作方式相同,只是后者是线程安全的,而前者不是。

String类提供了几个方法来操作它的内容。每当你从一个String获得一部分内容,一个新的String对象就会被创建。String类根据字符的 Unicode 值比较两个字符串。Java 增加了对文本块的支持,使用三个引号加上一个新行来表示开始,并且文本可以缩进。使用java.text.Collator类按照字典顺序比较字符串。从 Java 7 开始,可以在switch语句中使用字符串。

QUESTIONS AND EXERCISES

  1. Java 中的字符串是什么?在创建一个String对象后,你能改变它的内容吗?

  2. 什么是字符串文字?

  3. String级和StringBuilder级有什么区别?

  4. StringBuffer级和StringBuilder级有什么区别?

  5. 当执行以下代码片段时,编写输出:

    String s1 = "Hello";
    String s2 = "\"Hello\"";
    System.out.println("s1 = " + s1);
    System.out.println("s2 = " + s2);
    
    
  6. 当执行以下代码片段时,编写输出:

    String s1 = "Who\nknows";
    System.out.println("s1 = " + s1);
    
    
  7. 当执行以下代码片段时,编写输出:

    String s1 = "Having fun with strings";
    int len = s1.length();
    char c = s1.charAt(4);
    
    
  8. 当执行以下代码片段时,编写输出:

    String s1 = "Fun";
    String s2 = new String("Fun");
    System.out.println(s1 == s2);
    System.out.println(s1.equals(s2));
    System.out.println("Fun" == "Fun");
    
    
  9. 当执行以下代码片段时,编写输出:

    StringBuilder sb = new StringBuilder(200);
    sb.append("Hello").append(false);
    System.out.println("length = " + sb.length());
    System.out.println("capacity = " + sb.capacity());
    System.out.println(sb.toString());
    
    
  10. 当执行以下代码片段时,编写输出:

```java
String s1 = 10 + 20 + " = what";
String s2 = 10 + String.valueOf(20) + " = what";
System.out.println(s1);
System.out.println(s2);

```
  1. 如这里所声明的,完成名为equalsContents()的方法的代码。如果两个参数在删除了开头和结尾的空白并忽略了大小写后具有相同的内容,那么该方法应该返回true。如果两个参数都为空,它应该返回true。否则应该返回false :
```java
public static boolean equalsContents(String s1, String s2) {
    /* your code goes here*/
}

```
  1. 完成以下代码,以便将年、月和日打印为19690919 :
```java
String date = "1969-09-19";
String year = date./*your code goes here*/;
String month = date./*your code goes here*/;
String day = date./*your code goes here*/;
System.out.println("year = " + year);
System.out.println("month = " + month);
System.out.println("day = " + day);

```
  1. 完成下面的代码片段,以便打印预期的输出,显示在该代码片段之后:
```java
String s1 = "noon and spoon";
String s2 = s1./*Your code goes here*/;
System.out.println(s1);
System.out.println(s2);

```

预期的输出如下:

```java
noon and spoon
nun and spun

```
  1. 完成下面的代码片段,以便打印预期的输出,显示在该代码片段之后:
```java
String s1 = "noon and spoon";
String s2 = s1./*Your code goes here*/;
System.out.println(s1);
System.out.println(s2);

```

预期的输出如下:

```java
noon and spoon
nn and spn

```
  1. 完成一个reverse(String str)方法的代码。它接受一个字符串并返回该字符串的反码。不要使用StringBuilderStringBuffer类:
```java
public static String reverse(String str) {
    /* Your code goes here */
}

```
  1. 表达式"abc".compareTo("abc")的值是多少?

十六、日期和时间

在本章中,您将学习:

  • 什么是 Java 日期时间 API

  • 日期-时间 API 背后的设计原则

  • 计时、时区和夏令时(DST)的演变

  • 关于日期、时间和日期时间保持的 ISO-8601 标准

  • 如何使用日期-时间 API 类表示日期、时间和日期时间,以及如何查询、调整、格式化和解析它们

  • 如何使用旧的日期 API

  • 如何在旧的和新的日期时间 API 之间进行互操作

日期-时间 API 是在 Java 8 中引入的,从那以后,它在几个接口和类中得到了增强,增加了许多新方法。本章全面介绍了日期-时间 API。API 由以java.time开头的包组成,它们在java.base模块中。本章中的所有示例程序都是一个jdojo.datetime模块的成员,如清单 16-1 中所声明的。

// module-info.java
module jdojo.datetime {
    exports com.jdojo.datetime;
}

Listing 16-1The Declaration of a jdojo.datetime Module

日期时间 API

Java 8 引入了一个新的日期时间 API 来处理日期和时间。在本章中,我们将 Java 8 之前的日期和时间相关类称为传统日期时间 API。遗留的日期-时间 API 包括像DateCalendarGregorianCalendar等类。它们在java.utiljava.sql包里。Date级从 JDK 成立之初就有了;其他的是在 JDK 1.1 中添加的。

为什么我们需要一个新的日期时间 API?简单的答案是,传统日期-时间 API 的设计者在两次尝试中都没有成功。举几个例子,传统日期-时间 API 的一些问题如下:

  • 日期总是由两部分组成:日期和时间。如果你只需要一个没有任何时间信息的约会,你别无选择。开发人员过去在 date 对象中将时间设置为午夜,以表示纯日期,这是不正确的,原因有几个。同样的论点对于只存储时间也是有效的。

  • datetime 简单地存储为自 1970 年 1 月 1 日午夜 UTC 以来经过的毫秒数。

  • 操纵日期就像你能想到的那样复杂;Date对象中的year字段被存储为从 1900 开始的偏移量;月份从 0 到 11,而不是从 1 到 12,因为人类习惯于将它们概念化。

  • 传统的 datetime 类是可变的,因此不是线程安全的。

第三次是魅力吗?这是第三次尝试提供一个正确的、强大的、可扩展的日期时间 API。然而,Java 日期时间 API 并不是免费的。如果你想充分利用它的潜力,它有一个陡峭的学习曲线。它由大约 80 个类组成。不要担心大量的课程。它们被精心设计和命名。一旦你理解了它的设计背后的思想,你就可以相对容易地理解一个类的名字和你在特定情况下需要使用的方法。作为开发人员,您需要了解大约 15 个类,以便在日常编程中有效地使用 Java 日期时间 API。

设计原则

在开始学习 Java 日期时间 API 的细节之前,您需要理解一些关于日期和时间的基本概念。日期时间 API 基于 ISO-8601 日期时间标准。一个名为 Joda-Time 的 Java datetime 框架启发了这个日期时间 API,它是在 Java 8 中添加的。如果你以前使用过 Joda-Time,你将能够很快学会这个日期-时间 API。您可以在 www.joda.org/joda-time/ 找到 Joda-Time 项目的详细信息。

日期-时间 API 区分了机器和人类使用日期和时间的方式。机器把时间看作是连续的滴答,一个以秒、毫秒等为单位的递增数字。人类使用日历系统按照年、月、日、小时、分钟和秒来处理时间。日期时间 API 有一组独立的类来处理基于机器的时间和基于日历的人类时间。它可以让你将基于机器的时间转换为基于人类的时间,反之亦然。

传统的日期时间 API 已经存在超过 15 年了。在使用现有应用程序时,您可能会遇到旧的 datetime 类。旧的 datetime 类已经过改进,可以与新的类无缝协作。当您编写新代码时,请使用新的日期-时间 API 类。当您接收遗留类的对象作为输入时,将遗留对象转换为新的 datetime 对象,并使用新的日期-时间 API。

Java 日期时间 API 主要由不可变的类组成。因为 API 是可扩展的,所以建议您尽可能创建不可变的类来扩展 API。对 datetime 对象的操作会创建一个新的 datetime 对象。这种模式使得链接方法调用变得容易。

日期-时间 API 中的类不提供公共构造器。它们允许你通过提供名为of()ofXxx()from()的静态工厂方法来创建它们的对象。API 使用定义良好的命名约定来命名方法。API 中的每个类都有几个方法。了解方法命名约定可以让您轻松找到适合您的目的的正确方法。我们将在单独的章节中讨论方法命名约定。

一个简单的例子

让我们看一个使用 Java 日期时间 API 处理日期和时间的例子。LocalDate类的一个实例表示没有时间的本地日期;LocalTime类的一个实例表示没有日期的当地时间;LocalDateTime类的一个实例代表一个本地日期和时间;ZonedDateTime类的一个实例用时区表示日期和时间。

A LocalDate和 a LocalTime也被称为分音 ,因为它们不代表时间线上的瞬间;他们也不知道夏令时的变化。一个ZonedDateTime代表给定时区中的一个时间点,可以转换为时间轴上的一个瞬间;它知道夏令时。例如,将凌晨 1:00 的LocalTime增加 4 个小时,将得到另一个凌晨 5:00 的LocalTime,而不管日期和地点。但是,如果您在代表芝加哥/美国时区 2014 年 3 月 9 日凌晨 1:00 的ZonedDateTime上增加 4 个小时,那么在同一时区,它将为您提供 2014 年 3 月 9 日上午 6:00 的时间,因为夏令时的原因,时钟会在当天的凌晨 2:00 向前移动 1 小时。例如,航空应用程序将使用ZonedDateTime类的实例来存储航班的出发时间和到达时间。

在 Date-Time API 中,表示日期、时间和日期时间的类有一个now()方法,分别返回当前日期、时间或日期时间。下面的代码片段创建 datetime 对象,这些对象表示日期、时间以及它们的组合(有和没有时区):

LocalDate dateOnly = LocalDate.now();
LocalTime timeOnly = LocalTime.now();
LocalDateTime dateTime = LocalDateTime.now();
ZonedDateTime dateTimeWithZone = ZonedDateTime.now();

A LocalDate不区分时区。同一时刻在不同时区会有不同的解读。当时间和时区对日期值的意义不重要时,如出生日期、书籍的出版日期等,使用LocalDate对象存储日期值。

您可以使用静态工厂方法of()指定日期时间对象的组件。下面的代码片段通过指定日期的年、月和日部分来创建一个LocalDate:

// Create a LocalDate representing January 12, 1968
LocalDate myBirthDate = LocalDate.of(1968, JANUARY, 12);

Tip

LocalDate只存储日期值,不存储时间和时区。当您使用静态方法now()获得一个LocalDate时,系统默认时区用于获得日期值。

清单 16-2 展示了如何获取当前日期、时间、日期时间和时区。它还展示了如何从年、月和日构造日期。您可能会得到不同的输出,因为它打印日期和时间的当前值。

// CurrentDateTime.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import static java.time.Month.JANUARY;
public class CurrentDateTime {
    public static void main(String[] args) {
        // Get current date, time, and datetime
        LocalDate dateOnly = LocalDate.now();
        LocalTime timeOnly = LocalTime.now();
        LocalDateTime dateTime = LocalDateTime.now();
        ZonedDateTime dateTimeWithZone = ZonedDateTime.now();
        System.out.println("Current Date: " + dateOnly);
        System.out.println("Current Time: " + timeOnly);
        System.out.println("Current Date and Time: " + dateTime);
        System.out.println("Current Date, Time, and Zone: " + dateTimeWithZone);
        // Construct a birth date and time from datetime components
        LocalDate myBirthDate = LocalDate.of(1968, JANUARY, 12);
        LocalTime myBirthTime = LocalTime.of(7, 30);
        System.out.println("My Birth Date: " + myBirthDate);
        System.out.println("My Birth Time: " + myBirthTime);
    }
}
Current Date: 2021-08-04
Current Time: 08:48:29.402753900
Current Date and Time: 2021-08-04T08:48:29.402753900
Current Date, Time, and Zone: 2021-08-04T08:48:29.403754200-05:00[America/Chicago]
My Birth Date: 1968-01-12
My Birth Time: 07:30

Listing 16-2Obtaining Current Date, Time, and Datetime and Constructing a Date

该程序使用四个类来获取本地日期、时间、日期时间和带时区的日期时间。在传统的日期-时间 API 中,只使用Calendar类就可以得到类似的结果。

日期时间 API 是全面的。它跨越了大约 80 个类和大约 1000 个方法。它允许你使用不同的刻度和不同的日历系统来表示和操作日期和时间。几个地方标准和一个通用标准(ISO-8601)已被用于计时。为了充分利用日期-时间 API,您需要了解计时的历史。接下来的几节将简要介绍使用日历系统和 ISO-8601 日期和时间标准测量时间的不同方法。如果您对这些主题有很好的理解,您可以跳过这些部分,从“探索日期-时间 API”部分继续。

计时的演变

天平是用来测量物理量的,如以米为单位的绳子的长度,以磅为单位的人的体重,以升为单位的水的体积等。在这里,米、磅和升是特定尺度上的计量单位。

我们如何测量时间?时间不是物质的东西。为了测量时间,我们将它与周期性的物理现象联系起来,例如,钟摆的摆动、地球绕轴自转、地球绕太阳公转、与原子中两个能级之间的量子跃迁有关的电磁信号的振荡等。因此,时标是事件的排列,用来定义持续时间。

在古代,由于地球绕轴旋转而产生的日出日落等事件被用作时间刻度;时间刻度的单位是一天。两次连续日出之间的持续时间计为 1 天。

随着人类文明的进步,计时设备被开发出来。其中一些是

  • 基于太阳位置的日晷

  • 基于钟摆周期性运动的机械钟

  • 最后,基于铯-133 原子特性的原子钟

时钟是一种计时装置,由两部分组成:频率标准和计数器。时钟中的频率标准是获得等间隔周期事件的组件,以测量所需时间间隔的长度。计数器(也称为累加器或加法器)计算周期性事件的发生次数。例如,在摆钟中,一个完整的钟摆周期的出现表示 1 秒的时间间隔,齿轮计算秒数,时钟的表面显示时间。即使在古代,也有两部分时钟计时的概念。根据日出和日落的周期性事件,地球的旋转提供了时钟的第一个组成部分;日历提供了时钟的第二个组成部分来计算日、月和年。

基于地球的自转,使用了几种时间尺度,称为世界时(UT)。地球围绕地轴和太阳的运动是不规则的。由于地球运动的不规则性,一天的长度一天比一天长,一年中的天数一年比一年多。太阳日(也称为视太阳日或真太阳日)是通过在当地时间中午连续观察两次太阳经过来测量的时间长度。如果你使用一个完美的时钟每天中午在当地子午线上观察太阳,你会发现,在一年中,太阳在天空中的位置在当地子午线东西方向变化大约 4 度(大约 16 分钟)。这意味着,在一年中的某一天,时钟显示的正午和太阳通过当地子午线的时间之间可能有多达 16 分钟的差异。由于地球自转轴相对于其轨道平面和围绕太阳的椭圆轨道的倾斜,时钟时间和太阳时间之间的差异被称为时间方程。用太阳日测量的时间称为视太阳时。

通过对太阳时进行校正以解释时间方程式而获得的时间被称为世界时零(UT0)或平均太阳时。穿过英国格林威治的本初子午线(零度经度)的午夜,对于 UT0 定义为 00 小时。第二个被定义为平均太阳日的 1/86400。

地球相对于其旋转轴的摆动被称为极移。经极移校正的 UT0 产生另一个时间标度,称为 UT1。地球的旋转速度是不均匀的。UT1 根据地球自转速度的季节变化进行了修正,产生了另一个时间尺度,称为 UT2。

地球不规则的自转速率导致了另一种时间尺度,称为星历表时间(ET)。ET 是基于地球绕太阳一周的周期和其他天体的运动。在 ET 标度上,星历秒被定义为 1900 年 1 月 0 日 12 小时星历时间的热带年的分数 1/31556925.9747。20 世纪 80 年代初,地球动力学时间(TDT)和重心动力学时间(TDB)取代了 ET。

国际原子时(又称为 TAI,法语名称 Temps Atomique International)是一种原子时标度。原子秒,TAI 标度中的时间单位,被定义为对应于铯-133 原子基态的两个超精细能级之间跃迁的 9192631770 个辐射周期的持续时间。1967 年,原子秒的定义变成了国际单位制(SI)秒的定义。国际度量衡局(BIPM)是原子时的官方计时器。有 65 个实验室,230 多个原子钟为 TAI 标度做出贡献。对 TAI 有贡献的每个时钟基于其性能被分配一个加权因子。所有起作用的原子钟的加权平均值给出了 TAI。

为什么我们要用很多原子钟来测量 TAI?一个时钟可能会出现故障并停止计时。甚至原子钟也会受到环境变化的影响。为了避免这样的失败和不准确,几个原子钟被用来跟踪 TAI。

1972 年 1 月 1 日,协调世界时(UTC)被采纳为全世界所有民用的官方时间尺度。UTC 和原子钟的运行速度相同。当 BIPM 在泰表上计算秒时,天文学家继续利用地球自转来测量时间。天文时间与 UTC 进行比较,如果两者相差超过 0.9 秒,就会在 UTC 上加上或减去一个闰秒,以使 UT0 和 UTC 的时间刻度尽可能接近。国际地球自转服务(IERS)决定将闰秒引入 UTC。

在任何时候,UTC 都与 TAI 相差整数秒。UTC 和 TAI 之间的关系如下:

UTC = TAI - (algebraic sum of leap seconds)

截至 2021 年 8 月,UTC 增加了 37 个闰秒。到目前为止,还没有从 UTC 减去闰秒。因此,在 2021 年 8 月,直到引入另一个闰秒,UTC 和 TAI 的关系如下:

UTC = TAI - 37

你可能会想,因为我们一直在给 UTC 加闰秒,所以 UTC 应该在 TAI 之前。这不是真的。将闰秒添加到 UTC 会使该小时在 UTC 刻度上变成 61 秒,而不是 60 秒。TAI 是连续的时间尺度;它一直在滴答作响。当 UTC 完成一小时的第 61 秒时,TAI 已移动到下一小时的第一秒。因此,当添加闰秒时,UTC 落后于 TAI。类似的逻辑,但顺序相反,适用于从 UTC 减去闰秒。如果在未来的任何时候,加上和减去 UTC 的闰秒变得相等,UTC 和 TAI 将读取相同的时间。

UTC 代表地球上本初子午线(经度零度)的时间,它穿过英国的格林威治。UTC 基于 24 小时制,每天从午夜 00 点开始。UTC 也被称为祖鲁时间。ISO-8601 标准使用字母 Z 作为日期指示符的 UTC 例如,15 小时后第 19 分 23 秒的 UTC 被写成 15:19:23Z。

您与 UTC 的合作还没有结束!我们讨论另外两个版本的 UTC:简化的 UTC 和带平滑闰秒的 UTC(UTC-SLS)。

人类习惯于按照 24 小时周期来理解太阳日:每小时由 60 分钟组成,每分钟由 60 秒组成。一个太阳日由 86400 秒组成。在 UTC 标度上,由于闰秒,一个太阳日也可能由 86399 或 86401 秒组成。为了让普通用户更容易理解,大多数计算机系统忽略了 UTC 刻度上的闰秒。忽略闰秒的 UTC 刻度称为简化的 UTC 刻度。

Tip

为了满足大多数用户的期望,新的 Java 日期时间 API 使用简化的 UTC,其中闰秒被忽略,使得所有的日子都有相同的 86400 秒。

当 UTC 加上或减去一个闰秒时,会在一天结束时的时间刻度中产生 1 秒的间隙或重叠。UTC-SLS 是处理 UTC 闰秒的建议标准。UTC-SLS 不是在一天结束时引入闰秒,而是建议通过将时钟的速率改变 0.1%,在一天的最后 1000 秒内进行 1 秒的平滑调整。在 UTC 上添加闰秒的一天,UTC-SLS 将使这一天的最后 1000 秒长 1001 毫秒,从而将 UTC-SLS 时钟的速率从 23:43:21 降低 0.1%到 24:00:00。在 UTC 上添加闰秒的一天,UTC-SLS 会将这一天的最后 1000 秒设为 999 毫秒,从而将 UTC-SLS 时钟的速率从 23:43:19 增加 0.1%到 24:00:00。

最后,有人提议通过取消 UTC 的闰秒来实现统一而单调的民用时间。有些人还提议用闰时来代替 UTC 闰秒!

时区和夏令时

当世界协调时 2021 年 4 月 20 日午夜,印度新德里和美国芝加哥当地时间是几点?印度新德里时间 2021 年 4 月 20 日早上 5 点半,美国芝加哥时间 2021 年 4 月 19 日晚上 7 点。我们如何确定一个地方的当地时间?全世界只有一次不是很好吗?如果是 UTC 午夜,那么世界各地都是午夜。也许这在过去会是一个好主意,因为随着时间的推移,人类的大脑能够通过实践来适应新的想法。某个地区的本地时间设置为一天从午夜 00 点开始。因此,00 小时在新德里和芝加哥都是午夜。

从地理上看,世界可以分为 24 个经度带,每个经度带覆盖从本初子午线开始的 15 度经度范围;每个波段代表一个 1 小时的时区。时区所覆盖的区域将遵守相同的时间。

人类在政治上的分裂多于地理上的分裂。在这个世界上,我们的政治分歧总是压倒地理上的相似性!有时,一条假想的分隔两个国家或州的边界使人们在边界的每一边观察不同的时间。

实际上,时区是根据政治区域划分的:国家和国家内部的区域。每个时区的本地时间都是 UTC 的一个偏移量。时差,即一个时区中 UTC 和本地时间的差异,称为时区时差。本初子午线以东的区域使用正的区域偏移。负区域偏移用于本初子午线以西的区域。时区偏移量以小时和分钟表示,如+5:30 小时、–10:00 小时等。例如,印度使用+5:30 小时的时区偏移量;因此,您可以在 UTC 上加 5 小时 30 分钟,得到印度当地时间。您可能认为时区偏移量的值对于一个时区是固定的。唉,要是我们这些文明先进的人类在守时方面也这么简单就好了!

有些国家有不止一个时区。例如,美国有五个时区:阿拉斯加、太平洋、山地、中部和东部。印度只有一个时区。在美国,当阿拉巴马州莫比尔(中部时区)的当地时间是早上 7:00 时,加利福尼亚州洛杉矶(太平洋时区)的当地时间是早上 5:00。印度的每个地方,因为只有一个时区,所以观察时间相同。

一些时区的时差在一年中会有变化。例如,在美国芝加哥(称为中部时区),夏令时为–5:00,冬令时为–6:00。大多数国家使用固定的时区偏移量。例如,印度使用+5:30 小时固定时区时差。时区的规则由政府决定,这些规则控制着时区偏移量变化的时间以及变化的幅度。这些规则被称为时区规则。

时区偏移量范围在+14:00 小时到–12:00 小时之间。如果一天只有 24 小时,我们如何将+14 作为时区偏移量?+14 到–12 的范围内,一天 26 小时!请注意,一些国家由几个小岛组成,这些小岛位于国际日期变更线的两侧,相隔一天。这给这些国家的岛屿之间的官方交流带来了问题,因为它们只有四个共同的工作日。他们将时区偏移量扩展到 12:00 之后,从而移动了他们国家的国际日期变更线,使整个国家都位于国际日期变更线的一侧。使用+13:00 和+14:00 时区时差的国家有基里巴斯(发音为“kirbas”)、萨摩亚和托克劳。

夏令时用于在春天通过将时钟向前拨(通常是 1 小时)来更好地利用晚上的日光。秋天的时候,时钟会拨回相同的时间。一年中观察夏令时的时间称为夏季;一年的另一部分被称为冬季。并非所有国家都采用夏令时。一个国家的政府决定该国(或一个国家内的某些地方)是否采用夏令时;如果是这样,政府决定日期和时间向前和向后移动时钟。例如,美国采用夏令时的时区在当地时间 2021 年 3 月 11 日凌晨 2:00 将时钟向前拨了 1 小时,因此产生了 1 小时的时差。请注意,在 2021 年 3 月 11 日,当地时间凌晨 2:00 到 3:00 在美国的那些区域不存在。在秋季,当时钟向后移动时,会产生等量的时间重叠。印度和泰国是不采用夏令时的两个国家。DST 每年两次更改 DST 观测位置相对于 UTC 的时区偏移量。

日历系统

人类使用日历来处理时间。日历中使用的时间单位是年、月、日、小时、分钟和秒。从这个意义上说,日历是一个追踪时间的系统,包括过去和未来,对人类社会、政治、法律、宗教和其他目的都有意义。

通常,日历系统不记录一天中的时间;它以日、月、年为单位工作。广义地说,在历法中,一天是基于地球绕轴自转,一个月是基于月球绕地球公转,一年是基于地球绕太阳公转。有时,日历系统是基于周的,而周是基于非天文周期的。

纵观人类历史,已知不同的文明使用不同的日历系统。大多数古代历法系统是基于由太阳运动、月亮运动或者两者产生的天文周期,因此产生了三种类型的历法系统:太阳历、阴历和日月历。

阳历的设计与回归年(也称为太阳年)一致,回归年是春分点之间的平均间隔。当太阳中心与地球赤道在同一平面时,一年出现两次春分。术语“春分”的意思是相等的夜晚;在春分点,白天和黑夜几乎一样长。春分发生在春季的 3 月 21 日左右;秋分发生在 9 月 22 日左右的秋季。阳历是阳历的一个例子,阳历是世界上最常用的民用日历。

阴历是基于月相周期的。它与回归年不一致。在一年中,它会从一个热带年漂移 11-12 天。一个农历大约需要 33 年才能赶上一个回归年,再漂移 33 年。太阴月,也称为合月,是新月之间的时间间隔,等于 29 天,12 小时,44 分钟和 2.8 秒。伊斯兰历是阴历的一个例子。

阴阳历像阴历一样根据月相周期来计算月份。然而,它每 2 年或 3 年插入一个月,以保持自己与回归年一致。佛教、印度教、中国和希伯来历法都是阴阳历的例子。

儒略历

儒略历是一种阳历,由朱利叶斯·凯撒于公元 45 年引入。它被欧洲文明广泛使用,直到+1582 年公历被引入。

普通的一年由 365 天组成。每四年,在 2 月 28 日和 3 月 1 日之间插入一天,被指定为 2 月 29 日,使一年有 366 天,这被称为闰年。公元前 1 年被认为是闰年。儒略历年的平均长度是 365.25 天,接近当时已知的回归年的长度。

一年由 12 个月组成。月份的长度是固定的。表 16-1 列出了儒略历中月份的顺序、名称和天数。

表 16-1

儒略历(和公历)中月份的顺序、名称和天数

|

命令

|

月份名称

|

天数

one 一月 Thirty-one
Two 二月 28(闰年 29)
three 三月 Thirty-one
four 四月 Thirty
five 五月 Thirty-one
six 六月 Thirty
seven 七月 Thirty-one
eight 八月 Thirty-one
nine 九月 Thirty
Ten 十月 Thirty-one
Eleven 十一月 Thirty
Twelve 十二月 Thirty-one

公历

公历是世界上最广泛使用的民用日历。它遵循儒略历一年中的月数和月中的天数的规则。然而,它改变了计算闰年的规则:如果一年能被 4 整除,那么它就是闰年。能被 100 整除的一年不是闰年,除非它也能被 400 整除。

例如,4、8、12、400 和 800 被称为闰年,1、2、3、5、300 和 100 被称为平年。0 年(公元前 1 年)被认为是闰年。有了闰年的新定义,公历一年的平均长度是 365.2425 天,非常接近回归年的长度。公历每 400 年重复一次。如果你把你的纸质日历保存到 2014 年,你的第 n 个曾孙将能够在 2414 年再次使用它!

公历是在 1582 年 10 月 15 日星期五引入的。根据现存的儒略历,公历开始的前一天是 1582 年 10 月 4 日星期四。注意,公历的引入没有影响工作日的循环;但是,它在两个日历之间留下了 10 天的不连续,这被称为转换。转换前的日期是儒略历日期,转换后的日期是公历日期,转换期间的日期不存在。

公历在 1582 年 10 月 15 日之前并不存在。我们如何在公历开始之前给事件指定日期?应用于无效日期的公历被称为预测公历。因此,1582 年 10 月 14 日存在于公历中,与儒略历中的 1582 年 10 月 4 日相同。

为什么公历的第一天是 1582 年 10 月 15 日星期五,而不是 1582 年 10 月 5 日星期五?根据道吉特的说法,在儒略历中,基督教节日复活节的日期是基于 3 月 21 日是春分的假设计算出来的。后来才知道,春分从 3 月 21 日开始一直在漂移;因此,复活节的日期偏离了季节性的春天。为了保持复活节的日期与春天同步,公历的开始日期调整了 10 天,所以 1583 年及以后的春分大约在 3 月 21 日。

Tip

儒略历和公历的主要区别在于确定闰年的规则。阳历中一年的平均长度比儒略历中的更接近于回归年的长度。

日期时间的 ISO-8601 标准

新的日期时间 API 广泛支持 ISO-8601 标准。本节旨在对 ISO-8601 标准中包含的日期时间组件及其文本表示进行简要而有限的概述。ISO-8601 中的日期时间由三部分组成:日期、时间和时区偏移量,它们以下列格式组合:

[date]T[time][zone offset]

日期组件由三个日历字段组成:年、月和日。日期中的两个字段由连字符分隔:

year-month-day

例如,2021-04-30 代表 2021 年 4 月的第 30 天。

有时,人们处理可能不包含完整信息的日期来识别日历中的某一天。例如,12 月 25 日作为圣诞节是有意义的,不需要指定日期中的年份部分。为了在日历中标识特定的圣诞节,我们还必须指定年份。缺少某些部分的日期被称为部分日期。2021,2021-05,- 05-29 等等。都是偏音的例子。ISO-8601 只允许从右端省略日期中的部分。也就是说,它允许省略日或月和日。日期-时间 API 允许三种类型的部分:年、年-月和月-日。

日期和时间部分由“T”字符分隔。时间组件由字段组成:小时、分钟和秒。冒号分隔时间组件中的两个字段。时间以这种格式表示:

hour:minute:second

ISO-8601 使用 24 小时计时系统。小时元素可以在 00 到 24 之间。小时 24 用于表示日历日的结束。minute 元素的值范围从 00 到 59。第二元素的范围可以从 00 到 60。第二元素的值 60 表示正闰秒。例如,15:20:56 表示当地时间午夜后 15 小时 20 分 56 秒。当允许降低精度时,秒或秒和分元素可从时间中省略。例如,15:19 表示 15 小时后的 19 分钟,07 表示从午夜开始的 07 小时。

午夜是一个日历日的开始。用 00:00:00 或 00:00 表示。日历日的开始与前一个日历日的结束相一致。因此,日历日的午夜也可以用 24:00:00 或 24:00 来表示。

如果指定的日期、时间或日期时间不带时区偏移量,则分别被视为本地日期、时间或日期时间。本地日期、时间和日期时间的示例分别是 2021-05-01、13:52:05 和 2021-05-01T13:52:05。

使用时区偏移量,可以表示相对于一天中的 UTC 的时间部分。时区偏移量表示本地时间和 UTC 之间的固定差值。它以加号或减号(+或–)开头,后跟小时和分钟元素,用冒号分隔。区域偏移的一些示例有+05:30、–06:00、+10:00、+5:30 等。字符 Z 用作时区偏移量指示符来表示一天中的 UTC 时间。例如,10:20:40Z 表示一天中上午 10 点 20 分 40 秒的 UTC12:20:40+2:00 表示当地时间下午 12 点 20 分 40 秒,比 UTC 早 2 个小时。10:20:40Z 和 12:20:40+2:00 这两个时间都表示相同的时间点。

Tip

ISO-8601 规定了在时间表示中使用相对于 UTC 分量的固定时区偏移量的标准。回想一下,对于采用夏令时的时区,时区偏移量可能会有所不同。除了 ISO-8601 标准,日期时间 API 还支持可变时区偏移量。

下面是一个完全指定了所有三个部分的日期时间的示例:

2021-05-01T16:30:00-06:00

此日期时间表示 2021 年 5 月 1 日,比 UTC 晚 6 个小时的午夜后 16 小时 30 分钟。

ISO-8601 包括几个其他日期和时间相关概念的标准,如瞬间、持续时间、周期、时间间隔等。日期-时间 API 提供了一些类,这些类的对象直接表示大多数(但不是全部)ISO 日期和时间概念。

日期-时间 API 中所有日期和时间类的toString()方法以 ISO 格式返回日期和时间的文本表示。该 API 包括允许您以非 ISO 格式格式化日期和时间的类。

ISO 标准包括用于指定称为持续时间的时间量的格式。ISO-8601 将持续时间定义为非负量。但是,日期-时间 API 也允许将负数量视为持续时间。表示持续时间的 ISO 格式如下:

PnnYnnMnnDTnnHnnMnnS

在这种格式中,P是持续时间指示符;nn表示一个数字;YMDHMS分别表示年、月、日、时、月、秒;并且T是时间指示器,仅当持续时间涉及小时、分钟和秒时才出现。下面是一些持续时间的文本表示的例子。行内注释描述了持续时间:

P12Y      // A duration of 12 years
PT15:30   // A duration of 15 hours and 30 minutes
PT20S     // A duration of 20 seconds
P4Y2MT30M // A duration of 4 years 2 months and 30 minutes

Tip

日期-时间 API 提供了DurationPeriod类来处理大量的时间。一个Duration代表机器尺度时间线上的时间量。一个Period代表人类尺度时间线上的时间量。

探索日期-时间 API

起初,探索日期-时间 API 是令人生畏的,因为它包含许多具有许多方法的类。学习方法的命名约定将极大地帮助理解 API。日期-时间 API 经过精心设计,以保持类名及其方法的一致性和直观性。以相同前缀开头的方法做类似的工作。例如,类中的of()方法被用作静态工厂方法来创建该类的对象。

日期-时间 API 的所有类、接口和枚举都在java.time包及其四个子包中,如表 16-2 中所列。

表 16-2

日期时间 API 的包和子包

|

包裹

|

描述

java.time 包含常用的类。LocalDateLocalTimeLocalDateTimeZonedDateTimePeriodDurationInstant类都在这个包里。这个包中的类是基于 ISO 标准的。
java.time.chrono 包含支持非 ISO 日历系统的类,例如,回历、泰国佛教日历等。
java.time.format 包含用于格式化和解析日期和时间的类。
java.time.temporal 包含用于访问日期和时间组件的类。它还包含类似日期时间调整器的类。
java.time.zone 包含支持时区和区域规则的类。

下面几节解释了日期-时间 API 中方法名使用的前缀及其含义和示例。

ofXxx()方法

日期-时间 API 中的类不提供公共构造器来创建它们的对象。它们允许您通过名为“of”或“ofXxx” (where Xxx is replaced by a description of the parameters)”的静态工厂方法来创建对象。下面的代码片段显示了如何创建LocalDate类的对象:

LocalDate ld1 = LocalDate.of(2021, 5, 2);          // 2021-05-02
LocalDate ld2 = LocalDate.of(2021, Month.JULY, 4); // 2021-07-04
LocalDate ld3 = LocalDate.ofEpochDay(2002);        // 1975-06-26
LocalDate ld4 = LocalDate.ofYearDay(2014, 40);     // 2014-02-09

from()方法

from()方法是一个静态工厂方法,类似于of()方法,用于从指定的参数中派生出一个日期时间对象。与of()方法不同,from()方法需要对指定的参数进行数据转换。

为了理解一个from()方法做什么,可以把它想象成一个deriveFrom()方法。使用from()方法,从指定的参数中派生出一个新的 datetime 对象。下面的代码片段展示了如何从一个LocalDateTime派生出一个LocalDate:

LocalDateTime ldt = LocalDateTime.of(2021, 5, 2, 15, 30); // 2021-05-02T15:30
LocalDate ld = LocalDate.from(ldt);                       // 2021-05-02

withXxx()方法

日期-时间 API 中的大多数类都是不可变的。他们没有setXxx()方法。如果您想要更改 datetime 对象的一个字段,例如,日期中的年份值,您需要寻找一个带有前缀“with”的方法一个withXxx()方法返回指定字段被改变的对象的副本。

假设您有一个LocalDate对象,并且您想要更改它的年份。你需要使用LocalDate类的withYear(int newYear)方法。下面的代码片段显示了如何从另一个LocalDate获得一个LocalDate,其中的年份发生了变化:

LocalDate ld1 = LocalDate.of(2021, Month.MAY, 2); // 2021-05-02
LocalDate ld2 = ld1.withYear(2014);               // 2014-05-02

您可以通过链接withXxx()方法调用来更改多个字段,从而从现有的LocalDate中获得新的LocalDate。下面的代码片段通过更改年份和月份,从现有的LocalDate创建一个新的LocalDate:

LocalDate ld3 = LocalDate.of(2021, 5, 2); // 2021-05-02
LocalDate ld4 = ld3.withYear(2024)
                   .withMonth(7);         // 2024-07-02

getXxx()方法

一个getXxx()方法返回对象的指定元素。例如,LocalDate类中的getYear()方法返回日期中的年份部分。以下代码片段显示了如何从LocalDate对象中获取年、月和日:

LocalDate ld = LocalDate.of(2021, 5, 2);
int year = ld.getYear();       // 2021
Month month = ld.getMonth();   // Month.MAY
int day = ld.getDayOfMonth();  // 2

toXxx()方法

一个toXxx()方法将一个对象转换成一个相关的Xxx类型。例如,LocalDateTime类中的toLocalDate()方法返回一个LocalDate对象,其日期在原始的LocalDateTime对象中。下面是一些使用toXxx()方法的例子:

LocalDate ld = LocalDate.of(2021, 8, 29); // 2021-08-29
// Convert the date to epoch days. The epoch days is the number of days from
// 1970-01-01 to a date. A date before 1970-01-01 returns a negative integer.
long epochDays = ld.toEpochDay();         // 18868
// Convert a LocalDateTime to a LocalTime using the toLocalTime() method
LocalDateTime ldt = LocalDateTime.of(2021, 8, 29, 16, 30);
LocalTime lt = ldt.toLocalTime();         // 16:30

atXxx()方法

atXxx()方法允许您通过提供一些额外的信息,从现有的日期时间对象构建一个新的日期时间对象。对比使用atXxx()方法和withXxx()方法;前者允许您通过提供附加信息来创建新类型的对象,而后者允许您通过更改对象的字段来创建对象的副本。

假设您有日期 2021 年 5 月 2 日。如果您想创建一个新的日期 2021-07-02(月份改为 7),您可以使用一个withXxx()方法。如果您想要创建一个日期时间 2021-05-02T15:30(通过添加时间 15:30),您将使用一个atXxx()方法。下面是一些使用atXxx()方法的例子:

LocalDate ld = LocalDate.of(2021, 5, 2);  // 2021-05-02
LocalDateTime ldt1 = ld.atStartOfDay();   // 2021-05-02T00:00
LocalDateTime ldt2 = ld.atTime(15, 30);   // 2021-05-02T15:30

atXxx()方法支持构建器模式。以下代码片段显示了如何使用生成器模式来构建本地日期:

// Use a builder pattern to build a date 2021-05-22
LocalDate d1 = Year.of(2021).atMonth(5).atDay(22);
// Use an of() factory method to build a date 2021-05-22
LocalDate d2 = LocalDate.of(2021, 5, 22);

plusXxx()和 minusXxx()方法

一个plusXxx()方法通过添加一个指定的值来返回一个对象的副本。例如,LocalDate类中的plusDays(long days)方法通过添加指定的天数来返回LocalDate对象的副本。

一个minusXxx()方法通过减去一个指定的值返回一个对象的副本。例如,LocalDate类中的minusDays(long days)方法通过减去指定的天数来返回LocalDate对象的副本:

LocalDate ld = LocalDate.of(2021, 5, 2); // 2021-05-02
LocalDate ld1 = ld.plusDays(5);          // 2021-05-07
LocalDate ld2 = ld.plusMonths(3);        // 2021-08-02
LocalDate ld3 = ld.plusWeeks(3);         // 2021-05-23
LocalDate ld4 = ld.minusMonths(7);       // 2011-10-02
LocalDate ld5 = ld.minusWeeks(3);        // 2021-04-11

multipliedBy()、dividedBy()和 negated()方法

乘法、除法和求反在日期和时间上没有意义。它们适用于表示时间量的日期时间类型,如DurationPeriod。持续时间和周期可以加减。日期-时间 API 支持负的持续时间和周期:

Duration d = Duration.ofSeconds(200); // PT3M20S (3 minutes and 20 seconds)
Duration d1 = d.multipliedBy(2);      // PT6M40S (6 minutes and 40 seconds)
Duration d2 = d.negated();            // PT-3M-20S (-3 minutes and -20 seconds)

瞬间和持续时间

时间线(或时间轴)是时间流逝的数学表示,即沿着唯一轴的瞬时事件。一个机器尺度的时间线用一个递增的数字来表示时间的流逝,如图 16-1 所示。

img/323069_3_En_16_Fig1_HTML.png

图 16-1

代表机器尺度时间流逝的时间线

瞬间是时间线上代表唯一时刻的点。一个历元是时间线上的一个瞬间,用作参考点(或原点)来测量其他瞬间。

Instant类的一个对象代表时间轴上的一个瞬间。它使用时间轴以纳秒的精度表示简化的 UTC。也就是说,时间线上两个连续瞬间之间的时间间隔(或持续时间)是一纳秒。时间轴使用 1970-01-01T00:00:00Z 作为纪元。历元之后的瞬间具有正值;纪元前的瞬间具有负值。该时期的瞬间被赋予零值。

有不同的方法可以创建一个Instant类的实例。使用它的now()方法,您可以使用系统默认时钟获得当前时刻:

// Get the current instant
Instant i1 = Instant.now();

您可以使用来自 epoch 的不同单位的时间量来获得Instant类的实例。下面的代码片段创建了一个Instant对象来表示纪元后的 19 秒,即 1970-01-01T00:00:19Z:

// An instant: 19 seconds from the epoch
Instant i2 = Instant.ofEpochSecond(19);

Duration类的对象表示时间线上两个瞬间之间的时间量。Duration类支持定向持续时间。也就是说,它允许正持续时间和负持续时间。图 16-1 用箭头显示持续时间,表示它们是定向持续时间。

您可以使用ofXxx()静态工厂方法之一创建Duration类的实例:

// A duration of 2 days
Duration d1 = Duration.ofDays(2);
// A duration of 25 minutes
Duration d2 = Duration.ofMinutes(25);

Tip

Instant 类的toString()方法以 ISO-8601 格式yyyy-MM-ddTHH:mm:ss.SSSSSSSSSZ返回Instant的文本表示。Duration类的toString()方法以PTnHnMnS格式返回持续时间的文本表示,其中n是小时数、分钟数或秒数。

你能用瞬间和持续时间做什么?通常,它们用于记录两个事件之间的时间戳和经过时间。可以比较两个瞬间,从而知道一个瞬间发生在另一个瞬间之前还是之后。您可以在一个瞬间上加上(或减去)一个持续时间来获得另一个瞬间。将两个持续时间相加会产生另一个持续时间。日期-时间 API 中的类是Serializable。您可以使用Instant在数据库中存储时间戳。

InstantDuration类分别存储它们值的秒和纳秒部分。Duration类有getSeconds()getNano()方法,而Instant类有getEpochSecond()getNano()方法来获取这两个值。下面是一个获取Instant的秒和纳秒的例子:

// Get the current instant
Instant i1 = Instant.now();
// Get seconds and nanoseconds
long seconds = i1.getEpochSecond();
int nanoSeconds = i1.getNano();
System.out.println("Current Instant: " + i1);
System.out.println("Seconds: " + seconds);
System.out.println("Nanoseconds: " + nanoSeconds);
(You may get a different output.)
Current Instant: 2021-08-22T00:12:42.337685118Z
Seconds: 1629591162
Nanoseconds: 337685118

清单 16-3 展示了一些可以在瞬间和持续时间执行的操作的使用。

// InstantDurationTest.java
package com.jdojo.datetime;
import java.time.Duration;
import java.time.Instant;
public class InstantDurationTest {
    public static void main(String[] args) {
        Instant i1 = Instant.ofEpochSecond(20);
        Instant i2 = Instant.ofEpochSecond(55);
        System.out.println("i1:" + i1);
        System.out.println("i2:" + i2);
        Duration d1 = Duration.ofSeconds(55);
        Duration d2 = Duration.ofSeconds(-17);
        System.out.println("d1:" + d1);
        System.out.println("d2:" + d2);
        // Compare instants
        System.out.println("i1.isBefore(i2):" + i1.isBefore(i2));
        System.out.println("i1.isAfter(i2):" + i1.isAfter(i2));
        // Add and subtract durations to instants
        Instant i3 = i1.plus(d1);
        Instant i4 = i2.minus(d2);
        System.out.println("i1.plus(d1):" + i3);
        System.out.println("i2.minus(d2):" + i4);
        // Add two durations
        Duration d3 = d1.plus(d2);
        System.out.println("d1.plus(d2):" + d3);
    }
}
i1:1970-01-01T00:00:20Z
i2:1970-01-01T00:00:55Z
d1:PT55S
d2:PT-17S
i1.isBefore(i2):true
i1.isAfter(i2):false
i1.plus(d1):1970-01-01T00:01:15Z
i2.minus(d2):1970-01-01T00:01:12Z
d1.plus(d2):PT38S

Listing 16-3Using Instant and Duration Classes

Java 9 中向Duration类添加了几个有用的方法,这些方法可以分为以下三类:

  • 方法将一个持续时间除以另一个持续时间

  • 获取特定时间单位的持续时间的方法和获取持续时间的特定部分(如天、小时、秒等)的方法。

  • 将持续时间截断为特定时间单位的方法

在接下来的章节中,我们将展示使用这些方法的例子。在示例中,我们使用了 23 天 3 小时 45 分 30 秒的持续时间。下面的代码片段将它创建为一个Duration对象,并将它的引用存储在一个名为compTime的变量中:

// Create a duration of 23 days, 3 hours, 45 minutes, and 30 seconds
Duration compTime = Duration.ofDays(23)
                            .plusHours(3)
                            .plusMinutes(45)
                            .plusSeconds(30);
System.out.println("Duration: " + compTime);
Duration: PT555H45M30S

将天数乘以 24 转换为小时数后,如输出所示,该持续时间表示 555 小时 45 分 30 秒。

将一段时间除以另一段时间

此类别中只有一种方法:

long dividedBy(Duration divisor)

dividedBy()方法允许您将一个持续时间除以另一个持续时间。它返回特定的divisor在方法被调用的持续时间内出现的次数。要知道这个持续时间有多少个整周,您可以调用使用 7 天作为持续时间的dividedBy()方法。以下代码片段向您展示了如何计算持续时间中的整天数、周数和小时数:

long wholeDays = compTime.dividedBy(Duration.ofDays(1));
long wholeWeeks = compTime.dividedBy(Duration.ofDays(7));
long wholeHours = compTime.dividedBy(Duration.ofHours(7));
System.out.println("Number of whole days: " + wholeDays);
System.out.println("Number of whole weeks: " + wholeWeeks);
System.out.println("Number of whole hours: " + wholeHours);
Number of whole days: 23
Number of whole weeks: 3
Number of whole hours: 79

转换和检索持续时间部分

在这个类别的Duration类中有几个方法:

  • long toDaysPart()

  • long toDays()

  • int toHoursPart()

  • long toHours()

  • int toMillisPart()

  • long toMillis()

  • int toMinutesPart()

  • long toMinutes()

  • int toNanosPart()

  • long toNanos()

  • int toSecondsPart()

  • long toSeconds()

Duration类包含两组方法。它们被命名为toXxx()toXxxPart(),其中Xxx可能是DaysHoursMinutesSecondsMillisNanos

名为toXxx()的方法将持续时间转换为Xxx时间单位,并返回整个部分。名为toXxxPart()的方法将持续时间分解为days:hours:minutes:seconds:millis:nanos部分,并从中返回Xxx部分。在本例中,toDays()会将持续时间转换为天数,并返回整个部分,即 23。toDaysPart()将持续时间分解为23Days:3Hours:45Minutes:30Seconds:0Millis:0Nanos并返回第一部分,即 23。让我们将相同的规则应用于toHours()toHoursPart()方法。toHours()方法将持续时间转换为小时,并返回整数小时数,即 555。toHoursPart()方法将持续时间分成几部分,并返回小时部分,即 3。以下代码片段向您展示了几个示例:

System.out.println("toDays(): " + compTime.toDays());
System.out.println("toDaysPart(): " + compTime.toDaysPart());
System.out.println("toHours(): " + compTime.toHours());
System.out.println("toHoursPart(): " + compTime.toHoursPart());
System.out.println("toMinutes(): " + compTime.toMinutes());
System.out.println("toMinutesPart(): " + compTime.toMinutesPart());
Duration: PT555H45M30S
toDays(): 23
toDaysPart(): 23
toHours(): 555
toHoursPart(): 3
toMinutes(): 33345
toMinutesPart(): 45

截断持续时间

这个类别的Duration类中只有一个方法:

Duration truncatedTo(TemporalUnit unit)

truncatedTo()方法返回持续时间的副本,其概念性时间单位小于指定的被截断的unit。指定的时间单位必须小于或等于DAYS。指定大于DAYS的时间单位,如WEEKSYEARS,会引发运行时异常。

Tip

一个truncatedTo(TemporalUnit unit)方法也存在于LocalTimeInstant类中。

以下代码片段向您展示了如何使用此方法:

System.out.println("Truncated to DAYS: " + compTime.truncatedTo(ChronoUnit.DAYS));
System.out.println("Truncated to HOURS: " + compTime.truncatedTo(ChronoUnit.HOURS));
System.out.println("Truncated to MINUTES: " + compTime.truncatedTo(ChronoUnit.MINUTES));
Truncated to DAYS: PT552H
Truncated to HOURS: PT555H
Truncated to MINUTES: PT555H45M

持续时间为23Days:3Hours:45Minutes:30Seconds:0Millis:0Nanos。当您将其截断为DAYS时,所有小于天的部分都被丢弃,它返回 23 天,与输出中显示的 552 小时相同。当您截断到HOURS时,它会丢弃所有小于小时的部分,并返回 555 小时。将其截断为MINUTES会保留最多分钟的部分,并丢弃所有更小的部分,如秒和毫秒。

人类尺度时间

在上一节中,我们讨论了使用InstantDuration类,它们的实例更适合处理机器时间。人类用年、月、日、时、分、秒等字段来处理时间。回想一下以下用于指定日期和时间的 ISO-8601 格式:

[date]T[time][zone offset]

日期-时间 API 提供了几个类,如表 16-3 中所列,用于表示所有字段及其人类时间的组合。类的“组件”列中的“是”或“否”指示该类的实例是否存储该组件。我们将很快详细讨论所有这些类。

表 16-3

人类尺度的日期和时间类及其组成部分

|

类别名

|

日期

|

时间

|

区域偏移

|

区域规则

LocalDate
LocalTime
LocalDateTime
OffsetTime
OffsetDateTime
ZonedDateTime
ZoneOffset
ZoneId

ZoneOffset 类

ZoneOffset类的一个实例表示相对于 UTC 时区的固定时区偏移量,例如+05:30、–06:00 等。时区不同于 UTC 是一段时间。由于观察到的夏令时,A ZoneOffset不知道时区偏移量的变化。ZoneOffset类声明了三个常量:

  • UTC

  • MAX

  • MIN

UTC是 UTC 的时区偏移常量。MAXMIN是支持的最大和最小区域偏移量。

Tip

Z,而不是+00:00–00:00,被用作 UTC 时区的时区偏移指示符。

ZoneOffset类提供了使用小时、分钟和秒的组合来创建其实例的方法。清单 16-4 展示了如何创建ZoneOffset类的实例。

// ZoneOffsetTest.java
package com.jdojo.datetime;
import java.time.ZoneOffset;
public class ZoneOffsetTest {
    public static void main(String[] args) {
        // Create zone offset using hour, minute, and second
        ZoneOffset zos1 = ZoneOffset.ofHours(-6);
        ZoneOffset zos2 = ZoneOffset.ofHoursMinutes(5, 30);
        ZoneOffset zos3 = ZoneOffset.ofHoursMinutesSeconds(8, 30, 45);
        System.out.println(zos1);
        System.out.println(zos2);
        System.out.println(zos3);
        // Create zone offset using offset ID as a string
        ZoneOffset zos4 = ZoneOffset.of("+05:00");
        ZoneOffset zos5 = ZoneOffset.of("Z"); // Same as ZoneOffset.UTC
        System.out.println(zos4);
        System.out.println(zos5);
        // Print the values for zone offset constants
        System.out.println("ZoneOffset.UTC: "  + ZoneOffset.UTC);
        System.out.println("ZoneOffset.MIN: "  + ZoneOffset.MIN);
        System.out.println("ZoneOffset.MAX: "  + ZoneOffset.MAX);
    }
}
-06:00
+05:30
+08:30:45
+05:00
Z
ZoneOffset.UTC: Z
ZoneOffset.MIN: -18:00
ZoneOffset.MAX: +18:00

Listing 16-4Creating Instances of the ZoneOffset Class

根据 ISO-8601 标准,时区偏移量可能包括小时和分钟或仅包括小时。新的日期时间 API 还允许时区偏移中的秒。您可以使用ZoneOffset类的compareTo()方法将一个区域偏移量与另一个区域偏移量进行比较。区域偏移按降序进行比较,即它们在一天中的时间内出现的顺序,例如,区域偏移+5:30 出现在区域偏移+5:00 之前。ISO-8601 标准支持–12:00 到+14:00 之间的区域偏移。但是,为了避免将来时区偏移延长时出现任何问题,日期-时间 API 支持–18:00 到+18:00 之间的时区偏移。

ZoneId 类

ZoneId类的一个实例表示时区偏移量和为观察到的夏令时更改时区偏移量的规则的组合。并非所有时区都遵循夏令时。为了简化你对ZoneId的理解,你可以这样想:

ZoneId = ZoneOffset + ZoneRules

Tip

A ZoneOffset表示相对于 UTC 时区的固定时区偏移量,而ZoneId表示可变时区偏移量。偏差、一年中时区偏移量更改的时间以及更改量都由时区规则控制。ZoneOffset类继承自ZoneId类。

时区有一个唯一的文本 ID,可以用三种格式指定:

  • 在这种格式中,时区 ID 是根据时区偏移量来指定的,它可以是以下格式之一:+h+hh+hh:mm-hh:mm+hhmm-hhmm+hh:mm:ss-hh:mm:ss+hhmmss-hhmmss,其中hms分别表示小时、分钟和秒的单个数字。Z用于 UTC。区域偏移的一个例子是+06:00

  • 在这种格式中,区域 ID 以UTCGMTUT为前缀,后跟一个区域偏移量,例如UTC+06:00

  • 在这种格式中,通过使用区域来指定区域 ID,例如America/Chicago

使用前两种形式的区域 id,创建一个带有固定区域偏移的ZoneId。您可以使用of()工厂方法创建一个ZoneId:

ZoneId usChicago = ZoneId.of("America/Chicago");
ZoneId bdDhaka = ZoneId.of("Asia/Dhaka");
ZoneId fixedZoneId = ZoneId.of("+06:00");

ZoneId类提供对所有已知时区 id 的访问。它的getAvailableZoneIds()静态方法返回一个包含所有可用区域 id 的Set<String>。清单 16-5 显示了如何打印所有区域 id。输出中显示了区域 id 的部分列表。

// PrintAllZoneIds.java
package com.jdojo.datetime;
import java.time.ZoneId;
import java.util.Set;
public class PrintAllZoneIds {
    public static void main(String[] args) {
        Set<String> zoneIds = ZoneId.getAvailableZoneIds();
        for (String zoneId: zoneIds) {
             System.out.println(zoneId);
        }
    }
}
Asia/Aden
Africa/Cairo
Pacific/Honolulu
America/Chicago
Europe/Athens
...

Listing 16-5 Printing All Available Zone IDs

通过一个ZoneId对象,您可以访问由ZoneId表示的时区的区域规则。您可以使用ZoneId类的getRules()方法来获取ZoneRules类的一个实例,以处理夏令时的转换、指定日期时间的时区偏移量、夏令时的数量等规则。通常,您不会在代码中直接使用区域规则。作为一名开发人员,您将使用一个ZoneId来创建一个ZonedDateTime,稍后将对此进行讨论。清单 16-6 中的程序显示了如何查询ZoneRules对象来获取关于ZoneId的时间偏移和时间变化的信息。时间转换列表非常大,在输出中只显示了一部分。

// ZoneRulesTest.java
package com.jdojo.datetime;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.zone.ZoneOffsetTransition;
import java.time.zone.ZoneRules;
import java.util.List;
public class ZoneRulesTest {
    public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        System.out.println("Current Date Time: " + now);
        ZoneId fixedZoneId = ZoneId.of("+06:00");
        ZoneId bdDhaka = ZoneId.of("Asia/Dhaka");
        ZoneId usChicago = ZoneId.of("America/Chicago");
        // Print some zone rules for ZoneIds
        printDetails(fixedZoneId, now);
        printDetails(bdDhaka, now);
        printDetails(usChicago, now);
    }
    public static void printDetails(ZoneId zoneId, LocalDateTime now) {
        System.out.println("Zone ID: " + zoneId.getId());
        ZoneRules rules = zoneId.getRules();
        boolean isFixedOffset = rules.isFixedOffset();
        System.out.println("isFixedOffset(): " + isFixedOffset);
        ZoneOffset offset = rules.getOffset(now);
        System.out.println("Zone offset: " + offset);
        List<ZoneOffsetTransition> transitions = rules.getTransitions();
        System.out.println(transitions);
    }
}
Current Date Time: 2021-08-20T20:10:08.836642261
Zone ID: +06:00
isFixedOffset(): true
Zone offset: +06:00
[]
Zone ID: Asia/Dhaka
isFixedOffset(): false
Zone offset: +06:00
[Transition[Overlap at 1890-01-01T00:00+06:01:40 to +05:53:20], ..., Transition[Overlap at 2010-01-01T00:00+07:00 to +06:00]]
Zone ID: America/Chicago
isFixedOffset(): false
Zone offset: -05:00
[Transition[Overlap at 1883-11-18T12:09:24-05:50:36 to -06:00], ..., Transition[Overlap at 2008-11-02T02:00-05:00 to -06:00]]

Listing 16-6Knowing the Time Change Rules (the ZoneRules) for a ZoneId

一些组织和团体提供了一组时区规则作为数据库,其中包含世界上所有时区的代码和数据。每个提供者都有一个唯一的组 ID。标准规则提供者之一是由 TZDB 组 ID 标识的 TZ 数据库。参见 www.twinsun.com/tz/tz-link.htm 了解更多关于 TZ 数据库的详细信息。

由于时区的规则会随着时间的推移而变化,因此一个组会为不同的区域提供多个版本的规则数据。通常,区域代表时区规则相同的时区。每个组都有自己的版本和区域命名方案。

TZDB 以[area]/[city]格式存储地区名称。一些地区名称的例子有非洲/突尼斯、美洲/芝加哥、亚洲/加尔各答、亚洲/东京、欧洲/伊斯坦布尔、欧洲/伦敦和欧洲/莫斯科。

日期时间 API 使用 TZDB 作为默认时区规则提供程序。如果您使用的是 TZDB 中基于区域的分区 ID,请使用区域名称作为分区 ID。如果 TZDB 以外的组用于区域规则,则区域名称应该以提供商的组 ID 为前缀,形式为“groupregion”。例如,如果您使用的是国际航空运输协会(IATA)的提供商,请为芝加哥地区使用“IATACHI”。关于如何注册您自己的区域规则提供者的更多细节,请参考java.time.zone包中的ZoneRulesProvider类。

有用的与日期时间相关的枚举

在我们讨论表示日期和时间的不同组合的类之前,有必要讨论一些表示日期和时间组件的常量的枚举:

  • Month

  • DayOfWeek

  • ChronoField

  • ChronoUnit

大多数情况下,您会将这些枚举中的常量直接用作方法的参数,或者作为方法的返回值接收它们。一些枚举包含使用常量本身作为输入来计算有用的日期时间值的方法。

代表月份

枚举有 12 个常量来代表一年中的 12 个月。常量名有JANUARYFEBRUARYMARCHAPRILMAYJUNEJULYAUGUSTSEPTEMBEROCTOBERNOVEMBERDECEMBER。月份按从 1 到 12 的顺序编号,一月是 1,十二月是 12。Month enum 提供了一些有用的方法,比如用of()从 int value中获取Month的实例,用from()从任意 date 对象中获取Month,用getValue()获取Monthint值,等等。

为了获得更好的可读性,如果在日期-时间 API 中可用,请使用枚举常量,而不是整数值。例如,对于七月,在代码中使用Month.JULY,而不是整数 7。有时 API 提供了两个版本的方法:一个采用Month枚举参数,另一个采用int月份值。这种方法的一个例子是LocalDate类中的静态工厂方法of():

  • static LocalDate of(int year, int month, int dayOfMonth)

  • static LocalDate of(int year, Month month, int dayOfMonth)

清单 16-7 展示了Month枚举的一些用法。

// MonthTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.Month;
public class MonthTest {
    public static void main(String[] args) {
        // Use Month.JULY as a method argument
        LocalDate ld1 = LocalDate.of(2021, Month.JULY, 1);
        // Derive a Month from a local date
        Month m1 = Month.from(ld1);
        // Create a Month from an int value 2
        Month m2 = Month.of(2);
        // Get the next month from m2
        Month m3 = m2.plus(1);
        // Get the Month from a local date
        Month m4 = ld1.getMonth();
        // Convert an enum constant to an int
        int m5 = m2.getValue();
        System.out.format("%s, %s, %s, %s, %d%n", m1, m2, m3, m4, m5);
    }
}
JULY, FEBRUARY, MARCH, JULY, 2

Listing 16-7Using the Month Enum

表示一周中的某一天

枚举有七个常量来代表一周的七天。常量有MONDAYTUESDAYWEDNESDAYTHURSDAYFRIDAYSATURDAYSUNDAY。它的getValue()方法返回一个int值,1 代表星期一,2 代表星期二,依此类推,它遵循 ISO-8601 标准。DayOfWeek枚举在java.time包中。下面是一些使用DayOfWeek枚举及其方法的例子:

LocalDate ld = LocalDate.of(2021, 5, 10);
// Extract the day-of-week from a LocalDate
DayOfWeek dw1 = DayOfWeek.from(ld); // THURSDAY
// Get the int value of the day-of-week
int dw11 = dw1.getValue();          // 4
// Use the method of the LocalDate class to get day-of-week
DayOfWeek dw12 = ld.getDayOfWeek(); // THURSDAY
// Obtain a DayOfWeek instance using an int value
DayOfWeek dw2 = DayOfWeek.of(7);    // SUNDAY
// Add one day to the day-of-week to get the next day
DayOfWeek dw3 = dw2.plus(1);        // MONDAY
// Get the day-of-week two days ago
DayOfWeek dw4 = dw2.minus(2);       // FRIDAY

表示日期时间字段

datetime 中的大多数字段都可以表示为数值,例如,年、月、日、小时等。接口的一个实例表示日期时间的一个字段,例如,年、月、分钟等。ChronoField枚举实现了TemporalField接口,并提供了几个常量来表示日期时间字段。ChronoField枚举包含一长串常量。其中一些如下:AMPM_OF_DAYCLOCK_HOUR_OF_AMPMCLOCK_HOUR_OF_DAYDAY_OF_MONTHDAY_OF_WEEKDAY_OF_YEARERAHOUR_OF_AMPMHOUR_OF_DAYINSTANT_SECONDSMINUTE_OF_HOURMONTH_OF_YEARSECOND_OF_MINUTEYEARYEAR_OF_ERA

通常,使用TemporalField从日期时间中获取字段的值。所有 datetime 类都有一个为指定的TemporalField返回一个int值的get()方法。如果一个字段的值可能太大而无法存储在一个int中,那么使用伴随的getLong()方法来获取一个long中的值。

并非所有日期时间类都支持所有类型的字段。例如,LocalDate不支持MINUTE_OF_HOUR字段。使用 datetime 类的isSupported()方法来检查它们是否支持特定类型的字段。使用ChronoFieldisSupportedBy()方法检查字段是否受日期时间类支持。

Tip

特定于 ISO-8601 日历系统的一些日期时间字段的常量在IsoFields类中声明。例如,IsoFields.DAY_OF_QUARTER表示基于 ISO-8601 的季度日。

以下代码片段演示了如何使用ChronoField从日期时间中提取字段值,以及该日期时间是否支持该字段:

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoField;
...
LocalDateTime now = LocalDateTime.now();
System.out.println("Current Date Time: " + now);
System.out.println("Year: " + now.get(ChronoField.YEAR));
System.out.println("Month: " + now.get(ChronoField.MONTH_OF_YEAR));
System.out.println("Day: " + now.get(ChronoField.DAY_OF_MONTH));
System.out.println("Hour-of-day: " + now.get(ChronoField.HOUR_OF_DAY));
System.out.println("Hour-of-AMPM: " + now.get(ChronoField.HOUR_OF_AMPM));
System.out.println("AMPM-of-day: " + now.get(ChronoField.AMPM_OF_DAY));
LocalDate today = LocalDate.now();
System.out.println("Current Date : " + today);
System.out.println("LocalDate supports year: " + today.isSupported(ChronoField.YEAR));
System.out.println(
"LocalDate supports hour-of-day: " + today.isSupported(ChronoField.HOUR_OF_DAY));
System.out.println("Year is supported by LocalDate: " + ChronoField.YEAR.isSupportedBy(today));
System.out.println(
"Hour-of-day is supported by LocalDate: " + ChronoField.HOUR_OF_DAY.isSupportedBy(today));
Current Date Time: 2021-08-20T20:11:25.739544947
Year: 2021
Month: 8
Day: 20
Hour-of-day: 20
Hour-of-AMPM: 8
AMPM-of-day: 1
Current Date : 2021-08-20
LocalDate supports year: true
LocalDate supports hour-of-day: false
Year is supported by LocalDate: true
Hour-of-day is supported by LocalDate: false

AMPM_OF_DAY字段的值可以是 0 或 1;0 表示上午,1 表示下午。

表示日期时间字段的单位

时间是以年、月、日、小时、分钟、秒、周等单位来计量的。java.time.temporal包中的TemporalUnit接口的一个实例代表一个时间单位。同一个包中的ChronoUnit包含以下常量来表示时间单位:CENTURIESDAYSDECADESERASFOREVERHALF_DAYSHOURSMICROSMILLENNIAMILLISMINUTESMONTHSNANOSSECONDSWEEKSYEARS

ChronoUnit枚举实现了TemporalUnit接口。因此,enum 中的所有常量都是TemporalUnit的一个实例。

Tip

特定于 ISO-8601 日历系统的一些日期时间单位的常量在IsoFields类中声明。例如,IsoFields.QUARTER_YEARSIsoFields.WEEK_BASED_YEARS分别代表基于 ISO-8601 的季度年(3 个月)和基于周的年(52 或 53 周)。ISO-8601 标准将 7 天视为一周;一周从星期一开始;一年的第一个日历周包括一年的第一个星期四;一年的第一周可能开始于前一年,而一年的最后一周可能结束于下一年。这可能导致一年中有 53 周。例如,2009 年的第一周开始于 2008 年 12 月 29 日,最后一周开始于 2009 年 12 月 29 日,因此 2009 年是 53 周的一年。

Datetime 类提供了两种方法,minus()plus()。它们需要一定的时间和时间单位,通过减去和加上指定的时间来返回新的日期时间。便利方法如minusDays()minusHours()plusDays()plusHours()等。也由适用的类提供来加减时间。以下代码片段说明了在这些方法中使用ChronoUnit枚举常量:

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
...
LocalDateTime now = LocalDateTime.now();
// Get the date time 4 days ago
LocalDateTime ldt2 = now.minus(4, ChronoUnit.DAYS);
// Use the minusDays() method to get the same result
LocalDateTime ldt3 = now.minusDays(4);
// Get date and time 4 hours later
LocalDateTime ldt4 = now.plus(4, ChronoUnit.HOURS);
// Use the plusHours() method to get the same result
LocalDateTime ldt5 = now.plusHours(4);
System.out.println("Current Datetime: " + now);
System.out.println("4 days ago: " + ldt2);
System.out.println("4 days ago: " + ldt3);
System.out.println("4 hours after: " + ldt4);
System.out.println("4 hours after: " + ldt5);
Current Datetime: 2021-08-20T20:13:39.453109419
4 days ago: 2021-08-16T20:13:39.453109419
4 days ago: 2021-08-16T20:13:39.453109419
4 hours after: 2021-08-21T00:13:39.453109419
4 hours after: 2021-08-21T00:13:39.453109419

本地日期、时间和日期时间

LocalDate类的一个实例表示没有时间或时区的日期。该类中的几个方法允许您将一个LocalDate转换为其他 datetime 对象,并操纵它的字段(年、月和日)来获得另一个LocalDate。下面的代码片段创建了一些LocalDate对象:

// Get the current local date
LocalDate ldt1 = LocalDate.now();
// Create a local date May 10, 2021
LocalDate ldt2 = LocalDate.of(2021, Month.MAY, 10);
// Create a local date, which is 10 days after the epoch date 1970-01-01
LocalDate ldt3 = LocalDate.ofEpochDay(10); // 1970-01-11

LocalDate类包含两个常量MAXMIN,分别是最大和最小支持的LocalDateLocalDate.MAX的值为+99999999-12-31 和LocalDate.MIN is –999999999-01-01

LocalTime类的一个实例表示没有日期或时区的时间。时间以纳秒的精度表示。它包含MINMAXMIDNIGHTNOON常量,分别代表时间常量 00:00、23:59:59.99999999、00:00 和 12:00。这个类中的几个方法允许你以不同的方式创建、操作和比较时间。下面的代码片段创建了一些LocalTime对象:

// Get the current local time
LocalTime lt1 = LocalTime.now();
// Create a local time 07:30
LocalTime lt2 = LocalTime.of(7, 30);
// Create a local time 07:30:50
LocalTime lt3 = LocalTime.of(7, 30, 50);
// Create a local time 07:30:50.000005678
LocalTime lt4 = LocalTime.of(7, 30, 50, 5678);

LocalDateTime类的一个实例表示没有时区的日期和时间。它提供了几种创建、操作和比较日期时间的方法。你可以把LocalDateTime想象成LocalDateLocalTime的组合:

LocalDateTime = LocalDate + LocalTime

以下代码片段以不同的方式创建了一些LocalDateTime对象:

// Get the current local datetime
LocalDateTime ldt1 = LocalDateTime.now();
// A local datetime 2021-05-10T16:14:32
LocalDateTime ldt2 = LocalDateTime.of(2021, Month.MAY, 10, 16, 14, 32);
// Construct a local datetime from a local date and a local time
LocalDate ld1 = LocalDate.of(2021, 5, 10);
LocalTime lt1 = LocalTime.of(16, 18, 41);
LocalDateTime ldt3 = LocalDateTime.of(ld1, lt1); // 2021-05-10T16:18:41

有关方法的完整列表,请参考这些类的在线 API 文档。在浏览在线 API 文档之前,请务必阅读本章中的“浏览日期-时间 API”一节。仅仅在一个类中,你就会发现超过 60 种方法。如果不知道这些方法名背后的模式,查看这些类的 API 文档将会让人不知所措。请记住,在 API 中使用不同的方法可以获得相同的结果。

清单 16-8 展示了一些在本地日期、时间和日期时间上创建和执行操作的方法。

// LocalDateTimeTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
public class LocalDateTimeTest {
    public static void main(String[] args) {
        // Create a local date and time
        LocalDate ld = LocalDate.of(2021, Month.MAY, 11);
        LocalTime lt = LocalTime.of(8, 52, 23);
        System.out.println("ld: " + ld);
        System.out.println("ld.isLeapYear(): " + ld.isLeapYear());
        System.out.println("lt: " + lt);
        // Create a local datetime from the local date and time
        LocalDateTime ldt = LocalDateTime.of(ld, lt);
        System.out.println("ldt: " + ldt);
        // Add 2 months and 25 minutes to the local datetime
        LocalDateTime ldt2 = ldt.plusMonths(2).plusMinutes(25) ;
        System.out.println("ldt2: " + ldt2);
        // Derive the local date and time from the localdatetime
        LocalDate ld2 = LocalDate.from(ldt2);
        LocalTime lt2 = LocalTime.from(ldt2);
        System.out.println("ld2: " + ld2);
        System.out.println("lt2: " + lt2);
    }
}
ld: 2021-05-11
ld.isLeapYear(): false
lt: 08:52:23
ldt: 2021-05-11T08:52:23
ldt2: 2021-07-11T09:17:23
ld2: 2021-07-11
lt2: 09:17:23

Listing 16-8Using a Local Date, Time, and Datetime

您可以给LocalDate添加年、月和日。如果给 2024-01-31 加一个月会是什么结果?如果日期时间 API 只是将月份添加到月份字段中,结果将是 2024-02-31,这是一个无效的日期。添加月份后,检查结果是否为有效日期。如果不是有效的日期,则该日期将调整为该月的最后一天。在这种情况下,结果将是 2024-02-29:

LocalDate ld1 = LocalDate.of(2024, Month.JANUARY, 31);
LocalDate ld2 = ld1.plusMonths(1);
System.out.println(ld1);
System.out.println(ld2);
2024-01-31
2024-02-29

如果您将日期添加到LocalDatemonthyear字段会被调整以保持结果为有效日期:

LocalDate ld1 = LocalDate.of(2024, Month.JANUARY, 31);
LocalDate ld2 = ld1.plusDays(30);
LocalDate ld3 = ld1.plusDays(555);
System.out.println(ld1);
System.out.println(ld2);
System.out.println(ld3);
2024-01-31
2024-03-01
2025-08-08

如何获取特定年份中所有日期都在周日的数据?如何获取未来 5 年中所有落在本月 13 日和星期五的日期?这些类型的计算在 Java 中是可能的,它使用一个顺序循环来生成所有这样的日期并检查每个日期的特定条件,但是 Java 9 通过在LocalDate类中提供一个datesUntil()方法使这样的计算变得非常容易。该方法重载了如下两种变体:

  • Stream<LocalDate> datesUntil(LocalDate endExclusive)

  • Stream<LocalDate> datesUntil(LocalDate endExclusive, Period step)

这些方法产生一个有序的LocalDate流。流中的第一个元素是调用该方法的LocalDatedatesUntil(LocalDate endExclusive)方法一次递增流中的日期一天。datesUntil(LocalDate endExclusive, Period step)方法按照指定的step递增日期。指定的结束日期是独占的(不包含)。您可以对返回的流进行一些有用的计算。

Tip

流是在 Java 8 中添加的,是 Java 中一个非常有用的概念,在 JDK 的很多地方都使用,包括 LocalDate。流接口以及支持类和接口位于 java.util.stream 包中。蒸汽代表一系列的对象。它支持可链接的方法,如 map、filter、count 和 reduce,并且延迟执行。我们所说的懒惰是指在使用诸如 count()或 forEach()之类的终端操作之前,它不会做任何事情。forEach()方法可以接受 lambda 表达式或方法引用作为输入,并为流中的每个对象执行一个操作。我们将在 More Java 17 中详细介绍流。

以下代码片段计算 2021 年的周日数。请注意,代码使用 2022 年 1 月 1 日作为最后一个日期,这是唯一的,这将使流返回 2021 年的所有日期:

long sundaysIn2021 = LocalDate.of(2021, 1, 1)
                              .datesUntil(LocalDate.of(2022, 1, 1))
                              .filter(ld -> ld.getDayOfWeek() == DayOfWeek.SUNDAY)
                              .count();
System.out.println("Number of Sundays in 2021: " + sundaysIn2021);
Number of Sundays in 2021: 52

以下代码片段打印 2020 年 1 月 1 日(含)到 2025 年 1 月 1 日(含)之间的所有日期,这些日期都是星期五,并且是该月的第 13 天:

System.out.println("Fridays that fall on 13th of the month between 2020 - 2024: ");
LocalDate.of(2020, 1, 1)
         .datesUntil(LocalDate.of(2025, 1, 1))
         .filter(ld -> ld.getDayOfMonth() == 13 && ld.getDayOfWeek() == DayOfWeek.FRIDAY)
         .forEach(System.out::println);
Fridays that fall on 13th of the month between 2020 – 2024 (inclusive):
2020-03-13
2020-11-13
2021-08-13
2022-05-13
2023-01-13
2023-10-13
2024-09-13
2024-12-13

以下代码片段打印 2021 年每个月的最后一天:

System.out.println("Last Day of months in 2021:");
LocalDate.of(2021, 1, 31)
         .datesUntil(LocalDate.of(2022, 1, 1), Period.ofMonths(1))
         .map(ld -> ld.format(DateTimeFormatter.ofPattern("EEE MMM dd, yyyy")))
         .forEach(System.out::println);
Last Day of months in 2021:
Sun Jan 31, 2021
Sun Feb 28, 2021
Wed Mar 31, 2021
Fri Apr 30, 2021
Mon May 31, 2021
Wed Jun 30, 2021
Sat Jul 31, 2021
Tue Aug 31, 2021
Thu Sep 30, 2021
Sun Oct 31, 2021
Tue Nov 30, 2021
Fri Dec 31, 2021

如何将一个Instant转换成一个LocalDateLocalTimeLocalDateTime?在 Java 8 中,LocalDateTime类包含一个名为ofInstant ( Instant instant, ZoneId zone)的静态方法,通过提供一个ZoneId,可以将一个 Instant 转换成一个LocalDateTime。然而,在LocalDateLocalTime类中没有这样的方法。Java 9 通过在这两个类中提供一个ofInstant()方法弥补了这个差距。以下代码片段向您展示了如何使用这两种方法将一个Instant转换为一个LocalDate和一个LocalTime:

/* Without using ofInstant */
// Get an Instant
Instant now = Instant.now();
// Get the system default time zone
ZoneId zone = ZoneId.systemDefault();
// Convert the Instant to a ZonedDateTime
ZonedDateTime zdt = now.atZone(zone);
// Get the LocalDate from the ZonedDateTime
LocalDate ld1 = zdt.toLocalDate();
// Get the LocalTime from the ZonedDateTime
LocalTime lt1 = zdt.toLocalTime();
System.out.println("In Java 8");
System.out.println("Instant: " + now);
System.out.println("Local Date: " + ld1);
System.out.println("Local Time: " + lt1);

/* Using ofInstant */
// Get a LocalDate from the Instant
LocalDate ld2 = LocalDate.ofInstant(now, zone);
// Get the LocalTime from the Instant
LocalTime lt2 = LocalTime.ofInstant(now, zone);
System.out.println("\nIn Java 9");
System.out.println("Instant: " + now);
System.out.println("Local Date: " + ld2);
System.out.println("Local Time: " + lt2);

你如何计算天数、小时数等?在两个日期和时间之间?日期-时间 API 有不同的方法来计算两个日期和时间之间的时间段。我们把对这种计算的讨论推迟到“两个日期和时间之间的周期”一节。

偏移时间和日期时间

OffsetTimeOffsetDateTime类的一个实例分别代表一个时间和一个日期时间,与 UTC 有一个固定的时区偏移量。偏移时间和日期时间不知道时区。ISO-8601 格式的偏移时间和偏移日期时间的示例分别是 10:50:11+5:30 和 2021-05-11T10:50:11+5:30。

Tip

没有OffsetDate类。这是最初设计的一部分。后来不了了之。

本地日期和时间与偏移日期和时间之间的关系可以表示如下:

OffsetTime = LocalTime + ZoneOffset
OffsetDateTime = LocalDateTime + ZoneOffset

使用偏移时间和日期时间类似于使用本地时间和日期时间,只是您必须使用时区偏移。你总是可以从一个OffsetXxx中提取一个LocalXxx。一个OffsetDateTime在时间轴上存储一个瞬间,因此支持OffsetDateTimeInstant之间的转换。

清单 16-9 展示了创建偏移时间和日期时间的例子。当您使用now()方法获取当前偏移时间和日期时间时,系统默认时区用于获取时区偏移值。对于当前时间和日期时间,您将获得不同的输出。

// OffsetDateTimeTest.java
package com.jdojo.datetime;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
public class OffsetDateTimeTest {
    public static void main(String[] args) {
        // Get the current offset time
        OffsetTime ot1 = OffsetTime.now();
        System.out.println("Current offset time: " + ot1);
        // Create a zone offset +05:30
        ZoneOffset offset = ZoneOffset.ofHoursMinutes(5, 30);
        // Create an offset time
        OffsetTime ot2 = OffsetTime.of(16, 40, 28, 0, offset);
        System.out.println("An offset time: " + ot2);
        // Get the current offset datetime
        OffsetDateTime odt1 = OffsetDateTime.now();
        System.out.println("Current offset datetime: " + odt1);
        // Create an offset datetime
        OffsetDateTime odt2 = OffsetDateTime.of(2021, 5, 11, 18, 10, 30, 0, offset);
        System.out.println("An offset datetime: " + odt2);
        // Get the local date and time from the offset datetime
        LocalDate ld1 = odt1.toLocalDate();
        LocalTime lt1 = odt1.toLocalTime();
        System.out.println("Current Local Date: " + ld1);
        System.out.println("Current Local Time: " + lt1);
        // Get the instant from the offset datetime
        Instant i1 = odt1.toInstant();
        System.out.println("Current Instant: " + i1);
        // Create an offset datetime from the instant
        ZoneId usChicago = ZoneId.of("America/Chicago");
        OffsetDateTime odt3 = OffsetDateTime.ofInstant(i1, usChicago);
        System.out.println("Offset datetime from instant: " + odt3);
    }
}
Current offset time: 21:06:12.772227562-04:00
An offset time: 16:40:28+05:30
Current offset datetime: 2021-08-20T21:06:12.982737832-04:00
An offset datetime: 2021-05-11T18:10:30+05:30
Current Local Date: 2021-08-20
Current Local Time: 21:06:12.982737832
Current Instant: 2021-08-21T01:06:12.982737832Z
Offset datetime from instant: 2021-08-20T20:06:12.982737832-05:00

Listing 16-9Using Offset Dates, Times, and Datetimes

分区日期时间

ZonedDateTime类的一个实例表示一个带有时区规则的日期时间。时区规则包括时区偏移量及其因夏令时而变化的规则。没有ZonedDateZonedTime;它们毫无意义。ZonedDateTimeLocalDateTime之间的关系可以表示如下:

ZonedDateTime = LocalDateTime + ZoneId

下面是一个从LocalDateTime创建ZonedDateTime的例子:

ZoneId usCentral = ZoneId.of("America/Chicago");
LocalDateTime ldt = LocalDateTime.of(2021, Month.MAY, 11, 7, 30);
ZonedDateTime zdt = ZonedDateTime.of(ldt, usCentral);
System.out.println(zdt);
2021-05-11T07:30-05:00[America/Chicago]

并不是所有的LocalDateTimeZoneId的组合都会产生有效的ZonedDateTime。由于夏令时的变化,某个时区的本地时间线上可能会有间隙或重叠。例如,在美国/芝加哥时区 2013 年 3 月 10 日 02:00,时钟向前拨了一个小时,因此在当地时间线上留下了 1 个小时的间隙;02:00 到 02:59 之间的时间不存在。2013 年 11 月 3 日,在同一个美国/芝加哥时区的 02:00,时钟向后移动一小时,从而在当地时间线上产生了 1 小时的重叠;01:00 到 01:59 之间的时间存在两次。日期-时间 API 有明确定义的规则来处理这种间隙和重叠:

  • 如果本地日期时间落在间隙的中间,则时间会向前移动与间隙相同的量。例如,如果您想要为美国/芝加哥时区构建一个 2013 年 3 月 10 日 02:30:00 的时区日期时间,您将得到 2013 年 3 月 10 日 3:30:00。时间往前挪一个小时,等于一个小时的间隙。

  • 如果本地日期时间在重叠的中间,则时间有效。在该间隙中,存在两个区域偏移:一个是在向后移动时钟之前存在的较早的偏移,另一个是在向后移动时钟之后存在的较晚的偏移。默认情况下,对于间隙中的时间,使用先前存在的区域偏移。ZonedDateTime类包含withEarlierOffsetAtOverlap()withLaterOffsetAtOverlap(),如果时间在重叠范围内,可以让您选择所需的时区偏移量。

以下代码片段演示了ZonedDateTime的结果,时间落在间隙和重叠中:

ZoneId usChicago = ZoneId.of("America/Chicago");
// 2013-03-10T02:30 did not exist in America/Chicago time zone
LocalDateTime ldt = LocalDateTime.of(2013, Month.MARCH, 10, 2, 30);
ZonedDateTime zdt = ZonedDateTime.of(ldt, usChicago);
System.out.println(zdt);
// 2013-10-03T01:30 existed twice in America/Chicago time zone
LocalDateTime ldt2 = LocalDateTime.of(2013, Month.NOVEMBER, 3, 1, 30);
ZonedDateTime zdt2 = ZonedDateTime.of(ldt2, usChicago);
System.out.println(zdt2);
// Try using the two rules for overlaps: one will use the earlier
// offset -05:00 (the default) and another the later offset -06:00
System.out.println(zdt2.withEarlierOffsetAtOverlap());
System.out.println(zdt2.withLaterOffsetAtOverlap());
2013-03-10T03:30-05:00[America/Chicago]
2013-11-03T01:30-05:00[America/Chicago]
2013-11-03T01:30-05:00[America/Chicago]
2013-11-03T01:30-06:00[America/Chicago]

ZonedDateTime类包含一个静态工厂方法ofLocal(LocalDateTime localDateTime, ZoneId zone, ZoneOffset preferredOffset)。如果在指定的zone中当地时间有两个时区偏移量,您可以使用此方法通过指定首选时区偏移量来创建一个ZonedDateTime。如果指定的首选区域偏移无效,则使用重叠的较早区域偏移。下面的代码片段演示了此方法的用法。当我们提供无效的首选偏移–07:00 时,将使用较早的偏移–05:00:

ZoneId usChicago = ZoneId.of("America/Chicago");
ZoneOffset offset5 = ZoneOffset.of("-05:00");
ZoneOffset offset6 = ZoneOffset.of("-06:00");
ZoneOffset offset7 = ZoneOffset.of("-07:00");
// At 2013-10-03T01:30, -05:00 and -06:00 offsets were valid for
// the time zone America/Chicago
LocalDateTime ldt = LocalDateTime.of(2013, Month.NOVEMBER, 3, 1, 30);
ZonedDateTime zdt5 = ZonedDateTime.ofLocal(ldt, usChicago, offset5);
ZonedDateTime zdt6 = ZonedDateTime.ofLocal(ldt, usChicago, offset6);
ZonedDateTime zdt7 = ZonedDateTime.ofLocal(ldt, usChicago, offset7);
System.out.println("With offset " + offset5 + ": " + zdt5);
System.out.println("With offset " + offset6 + ": " + zdt6);
System.out.println("With offset " + offset7 + ": " + zdt7);
With offset -05:00: 2013-11-03T01:30-05:00[America/Chicago]
With offset -06:00: 2013-11-03T01:30-06:00[America/Chicago]
With offset -07:00: 2013-11-03T01:30-05:00[America/Chicago]

ZonedDateTime类包含几个方法,将它转换为本地和偏移日期、时间和日期时间表示,比较它的实例,并通过更改它的一些字段获得它的新实例。清单 16-10 展示了如何使用分区日期时间。您将获得当前日期和时间的不同输出。

// ZonedDateTimeTest.java
package com.jdojo.datetime;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
public class ZonedDateTimeTest {
    public static void main(String[] args) {
        // Get the current zoned datetime for the system default time zone
        ZonedDateTime zdt1 = ZonedDateTime.now();
        System.out.println("Current zoned datetime:" + zdt1);
        // Create a local datetime
        LocalDateTime ldt = LocalDateTime.of(2021, Month.MARCH, 11, 7, 30);
        // Create some zoned datetimes
        ZoneId usCentralZone = ZoneId.of("America/Chicago");
        ZonedDateTime zdt2 = ZonedDateTime.of(ldt, usCentralZone);
        System.out.println(zdt2);
        // Get zone offset and zone id
        ZoneOffset offset = zdt2.getOffset();
        ZoneId zone = zdt2.getZone();
        System.out.println("Offset:" + offset + ", Zone:" + zone);
        // Subtract 10 hours. Zone-offset changes from -05:00 to -06:00
        ZonedDateTime zdt3 = zdt2.minusHours(10);
        System.out.println(zdt3);
        // Create a datetime in Asia/Kolkata time zone
        ZoneId indiaKolkataZone = ZoneId.of("Asia/Kolkata");
        ZonedDateTime zdt4 = ZonedDateTime.of(ldt, indiaKolkataZone);
        System.out.println(zdt4);
        // Perform some conversions on zoned date time
        LocalDateTime ldt2 = zdt4.toLocalDateTime();
        OffsetDateTime odt = zdt4.toOffsetDateTime();
        Instant i1 = zdt4.toInstant();
        System.out.println("To local datetime: " + ldt2);
        System.out.println("To offset datetime: " + odt);
        System.out.println("To instant: " + i1);
    }
}
Current zoned datetime:2021-08-20T21:14:15.207158017-04:00[America/New_York]
2021-03-11T07:30-06:00[America/Chicago]
Offset:-06:00, Zone:America/Chicago
2021-03-10T21:30-06:00[America/Chicago]
2021-03-11T07:30+05:30[Asia/Kolkata]
To local datetime: 2021-03-11T07:30
To offset datetime: 2021-03-11T07:30+05:30
To instant: 2021-03-11T02:00:00Z

Listing 16-10Using the ZonedDateTime Class

相同的瞬间,不同的时间

有时,您希望将一个时区的日期时间转换为另一个时区的日期时间。这类似于在芝加哥 2021 年 5 月 14 日 16:30 问印度的日期和时间。你可以通过几种方式得到这个。您可以使用ZonedDateTime类的toInstant()方法从第一个分区日期时间中获取瞬间,并使用ofInstant()方法创建第二个分区日期时间。您也可以使用ZonedDateTime类的withZoneSameInstant(ZoneId newZoneId)方法,如清单 16-11 所示,来获得相同的结果。

// DateTimeZoneConversion.java
package com.jdojo.datetime;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class DateTimeZoneConversion {
    public static void main(String[] args) {
        LocalDateTime ldt = LocalDateTime.of(2021, Month.MAY, 14, 16, 30);
        ZoneId usCentral = ZoneId.of("America/Chicago");
        ZonedDateTime zdt = ZonedDateTime.of(ldt, usCentral);
        System.out.println("In US Central Time Zone:" + zdt);
        ZoneId asiaKolkata = ZoneId.of("Asia/Kolkata");
        ZonedDateTime zdt2 = zdt.withZoneSameInstant(asiaKolkata);
        System.out.println("In Asia/Kolkata Time Zone:" + zdt2);
        ZonedDateTime zdt3 = zdt.withZoneSameInstant(ZoneId.of("Z"));
        System.out.println("In UTC Time Zone:" + zdt3);
    }
}
In US Central Time Zone:2021-05-14T16:30-05:00[America/Chicago]
In Asia/Kolkata Time Zone:2021-05-15T03:00+05:30[Asia/Kolkata]
In UTC Time Zone:2021-05-14T21:30Z

Listing 16-11Converting a Datetime in a Time Zone to Another Time Zone

时钟

Clock类是现实世界时钟的抽象。它提供对某个时区的当前时刻、日期和时间的访问。您可以获得系统默认时区的时钟:

Clock clock = Clock.systemDefaultZone();

您还可以获得特定时区的时钟:

// Get a clock for Asia/Kolkata time zone
ZoneId asiaKolkata = ZoneId.of("Asia/Kolkata");
Clock clock2 = Clock.system(asiaKolkata);

要从时钟中获取当前时刻、日期和时间,可以使用与日期时间相关的类的now(Clock c)方法:

// Get the system default clock
Clock clock = Clock.systemDefaultZone();
// Get the current instant of the clock
Instant instant1 = clock.instant();
// Get the current instant using the clock and the Instant class
Instant instant2 = Instant.now(clock);
// Get the local date using the clock
LocalDate ld = LocalDate.now(clock);
// Get the zoned datetime using the clock
ZonedDateTime zdt = ZonedDateTime.now(clock);

在所有日期、时间和日期时间类中没有参数的now()方法使用默认时区的系统默认时钟。以下两条语句使用相同的时钟:

LocalTime lt1 = LocalTime.now();
LocalTime lt2 = LocalTime.now(Clock.systemDefaultZone());

Clock类的systemUTC()方法返回 UTC 时区的时钟。您也可以获得一个固定的时钟,它总是返回相同的时间。当您希望您的测试用例使用相同的当前时间,并且不依赖于系统时钟的当前时间时,固定时钟在测试中非常有用。您可以使用Clock类的fixed(Instant fixedInstant, ZoneId zone)静态方法来获得一个在指定时区具有固定时刻的时钟。Clock类还可以让你获得一个时钟,它给出的时间与另一个时钟有固定的偏差。

时钟总是知道它的时区。您可以使用Clock类获得系统默认时区,如下所示:

ZoneId defaultZone = Clock.systemDefaultZone().getZone();

Tip

Clock类的默认实现忽略闰秒。您还可以扩展Clock类来实现您自己的时钟。

Clock类包含许多静态工厂方法,这些方法允许您创建一个以指定间隔计时的时钟。这些方法如下:

  • static Clock tick(Clock baseClock, Duration tickDuration)

  • static Clock tickMillis(ZoneId zone)

  • static Clock tickMinutes(ZoneId zone)

  • static Clock tickSeconds(ZoneId zone)

tick()方法允许您以Duration的形式指定 tick 的粒度。此方法返回的时钟使用指定为第一个参数的时钟。返回的时钟使指定的时钟在指定的持续时间内滴答,作为第二个参数。以下代码片段获取系统默认时区的时钟,每 1 毫秒滴答一次:

Clock clock = Clock.tick(Clock.systemDefaultZone(), Duration.ofMillis(1));

其他的tickXxx()方法返回指定时区的最佳可用时钟,该时钟以Xxx间隔计时。例如,tickSeconds()方法返回的时钟每秒滴答一次。

Tip

在 Java 9 中,tickMillis()方法被添加到了Clock类中。

周期

周期是根据日历字段yearsmonthsdays定义的时间量。持续时间也是用秒和纳秒来衡量的时间量。支持负句点。

周期和持续时间的区别是什么?持续时间表示精确的纳秒数,而周期表示不精确的时间量。一个时期对于人类就像一个持续时间对于机器一样。

周期的一些例子是 1 天、2 个月、5 天、3 个月和 2 天等。当有人提到两个月的时间时,你不知道这两个月中纳秒的确切数量。2 个月的时间可能意味着不同的天数(因此也就意味着不同的纳秒数),这取决于该时间开始的时间。例如,从 1 月 1 日午夜开始的两个月可能代表 59 天或 60 天,这取决于该年是否是闰年。类似地,一天的时间可能代表 23、24 或 25 小时,这取决于这一天是否遵循夏令时的开始/结束。

Period类的一个实例代表一个句点。使用以下静态工厂方法之一创建一个Period:

  • static Period of(int years, int months, int days)

  • static Period ofDays(int days)

  • static Period ofMonths(int months)

  • static Period ofWeeks(int weeks)

  • static Period ofYears(int years)

下面的代码片段创建了Period类的一些实例:

Period p1 = Period.of(2, 3, 5);  // 2 years, 3 months, and 5 days
Period p2 = Period.ofDays(25);   // 25 days
Period p3 = Period.ofMonths(-3); // -3 months
Period p4 = Period.ofWeeks(3);   // 3 weeks (21 days)
System.out.println(p1);
System.out.println(p2);
System.out.println(p3);
System.out.println(p4);
P2Y3M5D
P25D
P-3M
P21D

您可以对周期执行加、减、乘和求反操作。除法运算执行整数除法,例如 7 除以 3 等于 2。以下代码片段显示了一些操作及其对周期的结果:

Period p1 = Period.ofDays(15);  // P15D
Period p2 = p1.plusDays(12);    // P27D
Period p3 = p1.minusDays(12);   // P3D
Period p4 = p1.negated();       // P-15D
Period p5 = p1.multipliedBy(3); // P45D

使用Period类的plus()minus()方法将一个周期添加到另一个周期,并将一个周期从另一个周期中减去。使用Period类的normalized()方法来规范化年和月。该方法确保month值保持在 0–11 之间。例如,“2 年 15 个月”将被规范化为“3 年 3 个月”:

Period p1 = Period.of(2, 3, 5);
Period p2 = Period.of(1, 15, 28);
System.out.println("p1: " + p1);
System.out.println("p2: " + p2);
System.out.println("p1.plus(p2): " + p1.plus(p2));
System.out.println("p1.plus(p2).normalized(): " + p1.plus(p2).normalized());
System.out.println("p1.minus(p2): " + p1.minus(p2));
p1: P2Y3M5D
p2: P1Y15M28D
p1.plus(p2): P3Y18M33D
p1.plus(p2).normalized(): P4Y6M33D
p1.minus(p2): P1Y-12M-23D

日期-时间 API 处理基于周期和持续时间的计算的方式有很大的不同。包括周期在内的计算行为符合人类的预期。例如,当您将 1 天的时间段添加到ZonedDateTime中时,日期部分会更改为第二天,保持时间不变,而不管一天有多少小时(23、24 或 25 小时)。但是,当您添加一天的持续时间时,它将始终添加 24 小时。让我们通过一个例子来阐明这一点。

2021-03-11T02:00,美国中部时区的时钟向前拨 1 小时,使 2021-03-11 成为 23 小时的一天。假设你给一个人美国中部时区的日期时间 2021-03-10T07:30。如果你问他们一天后的日期时间是什么,他们的答案会是 2021-03-11T07:30。他们的答案很自然,因为对人类来说,在当前日期时间上加一天,第二天也是同样的时间。让我们问一个机器同样的问题。要求机器在 2021-03-10T07:30 上加上 24 小时,认为等于 1 天。该计算机的响应将是 2021-03-11T08:30,因为它将在初始日期时间上增加 24 小时,而已知 02:00 和 03:00 之间的时间不存在。清单 16-12 用一个 Java 程序演示了这个讨论。

// PeriodTest.java
package com.jdojo.datetime;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class PeriodTest {
    public static void main(String[] args) {
        ZoneId usCentral = ZoneId.of("America/Chicago");
        LocalDateTime ldt = LocalDateTime.of(2021, Month.MARCH, 10, 7, 30);
        ZonedDateTime zdt1 = ZonedDateTime.of(ldt, usCentral);
        Period p1 = Period.ofDays(1);
        Duration d1 = Duration.ofHours(24);
        // Add a period of 1 day and a duration of 24 hours
        ZonedDateTime zdt2 = zdt1.plus(p1);
        ZonedDateTime zdt3 = zdt1.plus(d1);
        System.out.println("Start Datetime: " + zdt1);
        System.out.println("After 1 Day period: " + zdt2);
        System.out.println("After 24 Hours duration: " + zdt3);
    }
}
Start Datetime: 2021-03-10T07:30-06:00[America/Chicago]
After 1 Day period: 2021-03-11T07:30-05:00[America/Chicago]
After 24 Hours duration: 2021-03-11T08:30-05:00[America/Chicago]

Listing 16-12Difference in Adding a Period and Duration to a Datetime

两个日期和时间之间的时间段

计算两个日期、时间和日期时间之间经过的时间是一个常见的要求。例如,您可能需要计算两个本地日期之间的天数或两个本地日期时间之间的小时数。日期-时间 API 提供了计算两个日期和时间之间经过时间的方法。有两种方法可以获得两个日期和时间之间的时间量:

  • ChronoUnit枚举中的一个常量使用between()方法。

  • 在一个与日期时间相关的类上使用until()方法,例如LocalDateLocalTimeLocalDateTimeZonedDateTime等。

ChronoUnit枚举有一个between()方法,它接受两个日期时间对象并返回一个long。方法返回从第一个参数到第二个参数所用的时间。如果第二个参数出现在第一个参数之前,它将返回一个负数。返回的数量是两个日期和时间之间的完整单位数。比如你调用HOURS.between(lt1, lt2),其中lt1lt2分别是 07:00 和 09:30,它会返回 2,而不是 2.5。但是如果调用MINUTES.between(lt1, lt2),会返回 150。

until()方法有两个参数。第一个参数是结束日期或时间。第二个参数是计算经过时间的时间单位。清单 16-13 中的程序展示了如何使用这两种方法来计算两个日期和时间之间的时间。

// TimeBetween.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Month;
import static java.time.temporal.ChronoUnit.DAYS;
import static java.time.temporal.ChronoUnit.HOURS;
import static java.time.temporal.ChronoUnit.MINUTES;
public class TimeBetween {
    public static void main(String[] args) {
        LocalDate ld1 = LocalDate.of(2022, Month.JANUARY, 7);
        LocalDate ld2 = LocalDate.of(2022, Month.MAY, 18);
        long days = DAYS.between(ld1, ld2);
        LocalTime lt1 = LocalTime.of(7, 0);
        LocalTime lt2 = LocalTime.of(9, 30);
        long hours = HOURS.between(lt1, lt2);
        long minutes = MINUTES.between(lt1, lt2);
        System.out.println("Using between (days): " + days);
        System.out.println("Using between (hours): " + hours);
        System.out.println("Using between (minutes): " + minutes);
        // Using the until() method
        long days2 = ld1.until(ld2, DAYS);
        long hours2 = lt1.until(lt2, HOURS);
        long minutes2 = lt1.until(lt2, MINUTES);
        System.out.println("Using until (days): " + days2);
        System.out.println("Using until (hours): " + hours2);
        System.out.println("Using until (minutes): " + minutes2);
    }
}
Using between (days): 131
Using between (hours): 2
Using between (minutes): 150
Using until (days): 131
Using until (hours): 2
Using until (minutes): 150

Listing 16-13Computing the Amount of Time Elapsed Between Two Dates and Times

并不总是能够计算出两个日期和时间之间经过的时间。例如,您不能说出LocalDateLocalDateTime之间的小时数,因为LocalDate不存储时间部分。如果将这样的参数传递给这些方法,将引发运行时异常。规则是指定的结束日期/时间应该可以转换为开始日期/时间。

部分的

部分日期是一种日期、时间或日期时间,它不完全指定时间线上的某个时刻,但对人类仍然有意义。如果有更多的信息,部分可能与时间线上的多个瞬间相匹配。例如,12 月 25 日不是一个可以在时间线上唯一确定的完整日期;然而,当我们谈论圣诞节时,它是有意义的。同样,1 月 1 日作为元旦也是有意义的。

您必须有日期、时间和时区,以便在时间线上唯一地标识某个时刻。如果你有这三条信息中的一些,但不是全部,你就有了一部分。如果不提供更多的信息,就无法从分部中获得Instant。我们已经在前面的章节中讨论了一些部分。

LocalDateLocalTimeLocalDateTimeOffsetTime是部分音的例子。OffsetDateTimeZonedDateTime不是偏音;他们有信息来唯一地识别时间线上的一个瞬间。我们将在本节中讨论另外三个部分:

  • Year

  • YearMonth

  • MonthDay

这些部分的名字很容易描述它们。A Year代表一个年份,比如 2021,2013 等等。A YearMonth代表一年和一个月的有效组合,例如 2021-05、2013-09 等。一个MonthDay代表一个月和一个月中某一天的有效组合,例如 12-15。清单 16-14 显示了你可以在这些部分上执行的一些操作。

// Partials.java
package com.jdojo.datetime;
import java.time.Month;
import java.time.MonthDay;
import java.time.Year;
import java.time.YearMonth;
public class Partials {
    public static void main(String[] args) {
        // Use Year
        Year y1 = Year.of(2021);    // 2021
        Year y2 = y1.minusYears(1); // 2020
        Year y3 = y1.plusYears(1);  // 2022
        Year y4 = Year.now();       // current year
        if (y1.isLeap()) {
            System.out.println(y1 + " is a leap year.");
        } else {
            System.out.println(y1 + " is not a leap year.");
        }
        // Use YearMonth
        YearMonth ym1 = YearMonth.of(2021, Month.MAY); // 2021-05
        // Get the number of days in the month
        int monthLen = ym1.lengthOfMonth(); // 31
        System.out.println("Days in month in " + ym1 + ": " + monthLen);
        // Get the number of days in the year
        int yearLen = ym1.lengthOfYear(); // 365
        System.out.println("Days in year in " + ym1 + ": " + yearLen);
        // Use MonthDay
        MonthDay md1 = MonthDay.of(Month.DECEMBER, 25);
        MonthDay md2 = MonthDay.of(Month.FEBRUARY, 29);
        if (md2.isValidYear(2020)) {
            System.out.println(md2 + " occurred in 2020");
        } else {
            System.out.println(md2 + " did not occur in 2020");
        }
    }
}
2021 is not a leap year.
Days in month in 2021-05: 31
Days in year in 2021-05: 365
--02-29 occurred in 2020

Listing 16-14 Using Year, YearMonth, and MonthDay Partials

最后,清单 16-15 包含一个合并两个部分以得到另一个部分的例子。这是一个完整的程序,从程序运行的那一年开始计算 5 年的圣诞节。您可能会得到不同的输出。

// ChristmasDay.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.Month;
import java.time.MonthDay;
import java.time.Year;
import java.time.format.TextStyle;
import java.util.Locale;
public class ChristmasDay {
    public static void main(String[] args) {
        MonthDay dec25 = MonthDay.of(Month.DECEMBER, 25);
        Year year = Year.now();
        // Construct and print Christmas days in next five years
        for (int i = 0; i < 5; i++) {
            LocalDate ld = year.plusYears(i).atMonthDay(dec25);
            int yr = ld.getYear();
            String weekDay = ld.getDayOfWeek()
                               .getDisplayName(TextStyle.FULL, Locale.getDefault());
            System.out.format("Christmas in %d is on %s.%n", yr, weekDay);
        }
    }
}
Christmas in 2021 is on Saturday.
Christmas in 2022 is on Sunday.
Christmas in 2023 is on Monday.
Christmas in 2024 is on Wednesday.
Christmas in 2025 is on Thursday.

Listing 16-15Combining a Year and MonthDay to get a LocalDate

该程序为 12 月 25 日创建了一个MonthDay部分,并一直将一年与它结合起来以得到一个LocalDate。您可以使用如下所示的LocalDate类重写清单 16-15 中的程序。它展示了日期-时间 API 的多功能性,允许您以不同的方式获得相同的结果:

LocalDate ld = LocalDate.of(Year.now().getValue(), Month.DECEMBER, 25);
for (int i = 0; i < 5; i++) {
    LocalDate newDate = ld.withYear(ld.getYear() + i);
    int yr = newDate.getYear();
    String weekDay = newDate.getDayOfWeek()
                            .getDisplayName(TextStyle.FULL, Locale.getDefault());
    System.out.format("Christmas in %d is on %s.%n", yr, weekDay);
}

调整日期

有时您希望调整日期和时间以具有特定的特征,例如,每月的第一个星期一、下一个星期二等。您可以使用TemporalAdjuster接口的实例对日期和时间进行调整。该接口有一个方法adjustInto(),它接受一个Temporal并返回一个Temporal。日期时间 API 提供了几个常用的日期时间调节器。如果他们不适合你的需要,你可以推出自己的调整器。我们讨论两者的例子。

提供了一个TemporalAdjusters类。它由返回不同类型的预定义日期调整器的所有静态方法组成。与日期时间相关的类包含一个with(TemporalAdjuster adjuster)方法。您需要将从TemporalAdjusters类的方法之一返回的对象传递给with()方法。with()方法将通过使用调整器中的逻辑调整其组件来返回原始日期时间对象的副本。以下代码片段计算 2022 年 1 月 1 日之后的第一个星期一:

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.Month;
import java.time.temporal.TemporalAdjusters;
...
LocalDate ld1 = LocalDate.of(2022, Month.JANUARY, 1);
LocalDate ld2 = ld1.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
System.out.println(ld1);
System.out.println(ld2);
2022-01-01
2022-01-03

方法名是不言自明的,如表 16-4 所示。

表 16-4

TemporalAdjusters 类中的有用方法

|

方法

|

描述

next(DayOfWeek dayOfWeek) 返回一个调整器,该调整器将日期调整为被调整日期之后一周中的第一个指定日期。
nextOrSame(DayOfWeek dayOfWeek) 返回一个调整器,该调整器将日期调整为被调整日期之后一周中的第一个指定日期。如果要调整的日期已经是一周中的某一天,则返回相同的日期。
previous(DayOfWeek dayOfWeek) 返回一个调整器,该调整器将日期调整为被调整日期之前一周的第一个指定日期。
previousOrSame(DayOfWeek dayOfWeek) 返回一个调整器,该调整器将日期调整为被调整日期之前一周的第一个指定日期。如果要调整的日期已经是一周中的某一天,则返回相同的日期。
firstInMonth(DayOfWeek dayOfWeek),``lastInMonth(DayOfWeek dayOfWeek) 每个都返回一个调整符,该调整符将日期调整为被调整的日期所代表的月份中指定的第一/最后一天。
dayOfWeekInMonth(int ordinal, DayOfWeek dayOfWeek) 返回一个调整器,该调整器将日期调整为被调整的日期所代表的月份中指定的一周的第ordinal天。它适用于计算日期,如 2022 年 1 月的第三个星期一。
firstDayOfMonth()``lastDayOfMonth() 每个都返回一个调整符,该调整符将日期调整为被调整日期所代表的月份的第一天/最后一天。
firstDayOfYear()``lastDayOfYear() 每个都返回一个调整符,该调整符将日期调整为被调整的日期所代表的一年的第一天/最后一天。
firstDayOfNextMonth() 返回一个调整器,该调整器将日期调整为被调整日期所代表的下个月的第一天。
firstDayOfNextYear() 返回一个调整器,该调整器将日期调整为被调整日期所代表的下一年的第一天。
ofDateAdjuster(UnaryOperator``<LocalDate> dateBasedAdjuster) 开发人员编写自己的LocalDate-based调整器的一种方便方法。

TemporalAdjusters类提供了一个dayOfWeekInMonth()方法。该方法返回一个日期调整器,它将日期调整到一周中指定的ordinal日,例如,一个月的第一个星期天,一个月的第三个星期五,等等。指定的ordinal值可能在 1 和 5 之间。如果ordinal为 5,并且该月没有第五个指定的dayOfWeek,则从下个月开始返回第一个指定的dayOfWeek。以下代码片段请求日期调整器在 2021 年 6 月的第五个星期天。日期调整器返回 2021 年 7 月的第一个星期日,因为 2021 年 6 月没有第五个星期日:

LocalDate ld1 = LocalDate.of(2021, Month.JUNE, 22);
LocalDate ld2 = ld1.with(TemporalAdjusters.dayOfWeekInMonth(6, DayOfWeek.SUNDAY));
System.out.println(ld1);
System.out.println(ld2);
2021-06-22
2021-07-04

您可以使用日期调整器和其他方法来执行复杂的调整。您可以获得从今天起 3 个月 14 天后的第二个星期五的日期,如下所示:

LocalDate date = LocalDate.now()
                          .plusMonths(3)
                          .plusDays(14)
                          .with(DateAdjusters.dayOfWeekInMonth(2, DayOfWeek.FRIDAY));

您可以使用ofDateAdjuster()方法为LocalDate创建自己的日期调整器。下面的代码片段创建并使用了一个日期调整器。调整器在被调整的日期上增加了 3 个月零 2 天。请注意,我们使用了一个 lambda 表达式来创建调整器,我们在第十一章中简要讨论过:

// Create an adjuster that returns a date after 3 months and 2 days
TemporalAdjuster adjuster =
    TemporalAdjusters.ofDateAdjuster((LocalDate date) -> date.plusMonths(3).plusDays(2));
// Use the adjuster
LocalDate today = LocalDate.now();
LocalDate dayAfter3Mon2Day = today.with(adjuster);
System.out.println("Today: " + today);
System.out.println("After 3 months and 2 days: " + dayAfter3Mon2Day);
Today: 2021-08-20
After 3 months and 2 days: 2021-11-22

清单 16-16 演示了如何调整日期。

// AdjustDates.java
package com.jdojo.datetime;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;
public class AdjustDates {
    public static void main(String[] args) {
        LocalDate today = LocalDate.now();
        System.out.println("Today: " + today);
        // Use a DateAdjuster to adjust today’s date to the next Monday
        LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
        System.out.println("Next Monday: " + nextMonday);
        // Use a DateAdjuster to adjust today’s date to the last day of month
        LocalDate lastDayOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
        System.out.println("Last day of month: " + lastDayOfMonth);
        // Create an adjuster that returns a date after 3 months and 2 days
        TemporalAdjuster adjuster = TemporalAdjusters.ofDateAdjuster(
                (LocalDate date) -> date.plusMonths(3).plusDays(2));
        LocalDate dayAfter3Mon2Day = today.with(adjuster);
        System.out.println("Date after adding 3 months and 2 days: " + dayAfter3Mon2Day);
    }
}
Today: 2021-08-20
Next Monday: 2021-08-23
Last day of month: 2021-08-31
Date after adding 3 months and 2 days: 2021-11-22

Listing 16-16Adjusting Dates and Times

让我们创建一个自定义日期调整器。如果被调整的日期是周末或 13 号星期五,则返回下一个星期一。否则,它返回原始日期。也就是说,调整器将只返回工作日,除了星期五 13。清单 16-17 包含调整器的完整代码。调节器已被定义为类中的常量。使用调整器就像将CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13常量传递给 datetime 类的with()方法一样简单,datetime 类可以提供一个LocalDate:

// CustomAdjusters.java
package com.jdojo.datetime;
import java.time.DayOfWeek;
import static java.time.DayOfWeek.FRIDAY;
import static java.time.DayOfWeek.MONDAY;
import static java.time.DayOfWeek.SATURDAY;
import static java.time.DayOfWeek.SUNDAY;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;
public class CustomAdjusters {
    public final static TemporalAdjuster WEEKDAYS_WITH_NO_FRIDAY_13
            = TemporalAdjusters.ofDateAdjuster(CustomAdjusters::getWeekDayNoFriday13);
    // No public constructor as it is a utility class
    private CustomAdjusters() {
    }
    private static LocalDate getWeekDayNoFriday13(LocalDate date) {
        // Initialize the new date with the original one
        LocalDate newDate = date;
        DayOfWeek day = date.getDayOfWeek();
        if (day == SATURDAY || day == SUNDAY || (day == FRIDAY && date.getDayOfMonth() == 13)) {
            // Return next Monday
            newDate = date.with(TemporalAdjusters.next(MONDAY));
        }
        return newDate;
    }
}

Listing 16-17Creating a Custom Date Adjuster

LocalDate ld = LocalDate.of(2013, Month.DECEMBER, 13);                      // Friday
LocalDate ldAdjusted = ld.with(CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13); // Next Monday

清单 16-18 演示了如何使用定制日期调整器。2021 年 8 月 12 日,星期四。您使用调整器调整 2021 年的 8 月 12 日、13 日和 14 日。2021 年 8 月 12 日,返回时没有任何调整。另外两个日期调整到下周一,也就是 2021 年 8 月 16 日。注意,调整器可以用在任何能够提供LocalDate的 datetime 对象上。程序用它来调整一个ZonedDateTime

// CustomAdjusterTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class CustomAdjusterTest {
    public static void main(String[] args) {
        LocalDate ld1 = LocalDate.of(2021, Month.AUGUST, 12); // Thursday
        LocalDate ld2 = LocalDate.of(2021, Month.AUGUST, 13); // Friday
        LocalDate ld3 = LocalDate.of(2021, Month.AUGUST, 14); // Saturday
        LocalDate ld1Adjusted = ld1.with(CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13);
        System.out.println(ld1 + " adjusted to " + ld1Adjusted);
        LocalDate ld2Adjusted = ld2.with(CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13);
        System.out.println(ld2 + " adjusted to " + ld2Adjusted);
        LocalDate ld3Adjusted = ld3.with(CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13);
        System.out.println(ld3 + " adjusted to " + ld3Adjusted);
        // Use it to adjust a ZonedDateTime
        ZonedDateTime zdt
                = ZonedDateTime.of(ld2, LocalTime.of(8, 45), ZoneId.of("America/Chicago"));
        ZonedDateTime zdtAdjusted = zdt.with(CustomAdjusters.WEEKDAYS_WITH_NO_FRIDAY_13);
        System.out.println(zdt + " adjusted to " + zdtAdjusted);
    }
}

2021-08-12 adjusted to 2021-08-12
2021-08-13 adjusted to 2021-08-16
2021-08-14 adjusted to 2021-08-16
2021-08-13T08:45-05:00[America/Chicago] adjusted to 2021-08-16T08:45-05:00[America/Chicago]

Listing 16-18Using the Custom Date Adjuster

查询日期时间对象

所有日期时间类都支持查询。查询是对信息的请求。请注意,您可以使用 datetime 对象的get(TemporalField field)方法获得 datetime 对象的组成部分,例如,来自LocalDate的年份。使用查询来请求不作为组件提供的信息。例如,您可以查询一个LocalDate是否是 13 号星期五。查询的结果可以是任何类型。

TemporalQuery<R>接口的一个实例代表一个查询。所有 datetime 类都包含一个query()方法,该方法将一个TemporalQuery作为参数并返回一个结果。

TemporalQueries是一个包含几个预定义查询作为其静态方法的实用程序类,如表 16-5 所示。如果 datetime 对象没有查询中查找的信息,查询将返回 null。例如,对来自LocalTime对象的LocalDate的查询返回null.年表,这是一个用于在日历系统中识别和操作日期的接口。

表 16-5

TemporalQueries 类中的实用方法

|

方法

|

返回类型

|

描述

chronology() TemporalQuery<Chronology> 获取年表的查询。
localDate() TemporalQuery<LocalDate> 获取LocalDate的查询。
localTime() TemporalQuery<LocalTime> 获取LocalTime的查询。
offset() TemporalQuery<ZoneOffset> 获取ZoneOffset的查询。
precision() TemporalQuery<TemporalUnit> 获取支持的最小单位的查询。
zone() TemporalQuery<ZoneId> 获取ZoneId的查询。如果ZoneId不可用,它将查询ZoneOffset。如果两者都不可用,则返回 null,例如,LocalDate两者都不可用。
zoneId() TemporalQuery<ZoneId> 获取ZoneId的查询。如果ZoneId不可用,则返回 null。

清单 16-19 中的程序展示了如何使用预定义的查询。它使用查询从一个LocalDate、一个LocalTime和一个ZonedDateTime中获得精度和LocalDate。该程序使用当前日期,因此您可能会得到不同的输出。

// QueryTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalQueries;
import java.time.temporal.TemporalQuery;
import java.time.temporal.TemporalUnit;
public class QueryTest {
    public static void main(String[] args) {
        // Get references of the precision and local date queries
        TemporalQuery<TemporalUnit> precisionQuery = TemporalQueries.precision();
        TemporalQuery<LocalDate> localDateQuery = TemporalQueries.localDate();
        // Query a LocalDate
        LocalDate ld = LocalDate.now();
        TemporalUnit precision = ld.query(precisionQuery);
        LocalDate queryDate = ld.query(localDateQuery);
        System.out.println("Precision of LocalDate: " + precision);
        System.out.println("LocalDate of LocalDate: " + queryDate);
        // Query a LocalTime
        LocalTime lt = LocalTime.now();
        precision = lt.query(precisionQuery);
        queryDate = lt.query(localDateQuery);
        System.out.println("Precision of LocalTime: " + precision);
        System.out.println("LocalDate of LocalTime: " + queryDate);
        // Query a ZonedDateTime
        ZonedDateTime zdt = ZonedDateTime.now();
        precision = zdt.query(precisionQuery);
        queryDate = zdt.query(localDateQuery);
        System.out.println("Precision of ZonedDateTime: " + precision);
        System.out.println("LocalDate of ZonedDateTime: " + queryDate);
    }
}
Precision of LocalDate: Days
LocalDate of LocalDate: 2021-08-20
Precision of LocalTime: Nanos
LocalDate of LocalTime: null
Precision of ZonedDateTime: Nanos
LocalDate of ZonedDateTime: 2021-08-20

Listing 16-19Querying Datetime Objects

创建和使用自定义查询很容易。有两种方法可以创建自定义查询。

  • 创建一个实现TemporalQuery接口的类,并使用该类的实例作为查询。

  • 使用任何方法引用作为查询。该方法应该接受一个TemporalAccessor并返回一个对象。方法的返回类型定义了查询的结果类型。

清单 16-20 包含了一个Friday13Query类的代码。该类实现了TemporalQuery接口。queryFrom()方法是接口实现的一部分。如果 datetime 对象包含的日期是星期五 13,则该方法返回 true。否则,它返回 false。如果 datetime 对象不包含一个月中的某一天和一周中的某一天的信息,例如一个LocalTime对象,则查询返回 false。该类定义了一个可以用作查询的常量IS_FRIDAY_13

// Friday13Query.java
package com.jdojo.datetime;
import java.time.DayOfWeek;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalQuery;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoField.DAY_OF_WEEK;
import static java.time.DayOfWeek.FRIDAY;
public class Friday13Query implements TemporalQuery<Boolean> {
    public final static Friday13Query IS_FRIDAY_13 = new Friday13Query();
    // Prevent outside code from creating objects of this class
    private Friday13Query() {
    }
    @Override
    public Boolean queryFrom(TemporalAccessor temporal) {
        if (temporal.isSupported(DAY_OF_MONTH) && temporal.isSupported(DAY_OF_WEEK)) {
            int dayOfMonth = temporal.get(DAY_OF_MONTH);
            int weekDay = temporal.get(DAY_OF_WEEK);
            DayOfWeek dayOfWeek = DayOfWeek.of(weekDay);
            if (dayOfMonth == 13 && dayOfWeek == FRIDAY) {
                return Boolean.TRUE;
            }
        }
        return Boolean.FALSE;
    }
}

Listing 16-20A Class Implementing the TemporalQuery Interface

下面的代码片段将Friday13Query与三个 datetime 对象一起使用。第一个LocalDate发生在星期五 13,正如您在输出中看到的,查询返回 true。

LocalDate ld1 = LocalDate.of(2021, 8, 13);
Boolean isFriday13 = ld1.query(Friday13Query.IS_FRIDAY_13);
System.out.println("Date: " + ld1 + ", isFriday13: " + isFriday13);
LocalDate ld2 = LocalDate.of(2022, 1, 10);
isFriday13 = ld2.query(Friday13Query.IS_FRIDAY_13);
System.out.println("Date: " + ld2 + ", isFriday13: " + isFriday13);
LocalTime lt = LocalTime.of(7, 30, 45);
isFriday13 = lt.query(Friday13Query.IS_FRIDAY_13);
System.out.println("Time: " + lt + ", isFriday13: " + isFriday13);
Date: 2021-08-13, isFriday13: true
Date: 2022-01-10, isFriday13: false
Time: 07:30:45, isFriday13: false

清单 16-21 包含一个CustomQueries类的代码。该类包含一个静态方法isFriday13()isFriday13()方法的方法引用可以用作查询。

// CustomQueries.java
package com.jdojo.datetime;
import java.time.DayOfWeek;
import static java.time.DayOfWeek.FRIDAY;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoField.DAY_OF_WEEK;
import java.time.temporal.TemporalAccessor;
public class CustomQueries {
    public static Boolean isFriday13(TemporalAccessor temporal) {
        if (temporal.isSupported(DAY_OF_MONTH) && temporal.isSupported(DAY_OF_WEEK)) {
            int dayOfMonth = temporal.get(DAY_OF_MONTH);
            int weekDay = temporal.get(DAY_OF_WEEK);
            DayOfWeek dayOfWeek = DayOfWeek.of(weekDay);
            if (dayOfMonth == 13 && dayOfWeek == FRIDAY) {
                return Boolean.TRUE;
            }
        }
        return Boolean.FALSE;
    }
}

Listing 16-21A CustomQueries Class with a IsFriday13 Method That Can Be Used a Query

下面的代码片段使用CustomQueries类中的isFriday13()方法的方法引用作为查询。该代码使用与上一示例中相同的 datetime 对象,您会得到相同的结果:

LocalDate ld1 = LocalDate.of(2021, 8, 13);
Boolean isFriday13 = ld1.query(CustomQueries::isFriday13);
System.out.println("Date: " + ld1 + ", isFriday13: " + isFriday13);
LocalDate ld2 = LocalDate.of(2022, 1, 10);
isFriday13 = ld2.query(CustomQueries::isFriday13);
System.out.println("Date: " + ld2 + ", isFriday13: " + isFriday13);
LocalTime lt = LocalTime.of(7, 30, 45);
isFriday13 = lt.query(CustomQueries::isFriday13);
System.out.println("Time: " + lt + ", isFriday13: " + isFriday13);
Date: 2021-08-13, isFriday13: true
Date: 2022-01-10, isFriday13: false
Time: 07:30:45, isFriday13: false

日期-时间 API 通常会提供多种选择来执行相同的任务。让我们考虑一个从一个ZonedDateTime获取LocalTime的任务。清单 16-22 中的程序显示了实现这一点的五种方法:

// LocalTimeFromZonedDateTime.java
package com.jdojo.datetime;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalQueries;
public class LocalTimeFromZonedDateTime {
    public static void main(String[] args) {
        ZonedDateTime zdt = ZonedDateTime.now();
        // Use the toLocalTime() method of the ZonedDateTime class (preferred)
        LocalTime lt1 = zdt.toLocalTime();
        // Use the from() method of the LocalTime class
        LocalTime lt2 = LocalTime.from(zdt);
        // Use the localTime() query
        LocalTime lt3 = zdt.query(TemporalQueries.localTime());
        // Use the LocalTime::from method as a query
        LocalTime lt4 = zdt.query(LocalTime::from);
        // Get all time components and construct a LocalTime
        int hours = zdt.getHour();
        int minutes = zdt.getMinute();
        int seconds = zdt.getSecond();
        int nanos = zdt.getNano();
        LocalTime lt5 = LocalTime.of(hours, minutes, seconds, nanos);
        // Print all LocalTimes
        System.out.println("zdt: " + zdt);
        System.out.println("lt1: " + lt1);
        System.out.println("lt2: " + lt2);
        System.out.println("lt3: " + lt3);
        System.out.println("lt4: " + lt4);
        System.out.println("lt5: " + lt5);
    }
}
zdt: 2021-08-04T21:11:42.547440400-05:00[America/Chicago]
lt1: 21:11:42.547440400
lt2: 21:11:42.547440400
lt3: 21:11:42.547440400
lt4: 21:11:42.547440400
lt5: 21:11:42.547440400

Listing 16-22Multiple Ways of Getting the LocalTime from a ZonedDateTime

哪种方法才是正确的方法?大多数情况下,所有方法都会执行相同的逻辑。然而,有些方法比其他方法更具可读性。在这种情况下,应该使用调用ZonedDateTime类的toLocalTime()方法的代码,因为它简单明了,可读性最好。至少,您不应该从ZonedDateTime中提取时间成分来构造LocalTime,如示例中的第五种方法所示。

非 ISO 日历系统

日期类如LocalDate使用的是 ISO 日历系统,也就是公历。日期-时间 API 还允许您使用其他日历,如泰国佛教日历、回历、民国日历和日本日历。非 ISO 日历相关的类在java.time.chrono包中。

每个可用的非 ISO 日历系统都有一个XxxChronologyXxxDate类。XxxChronology类表示Xxx日历系统,而XxxDate类表示Xxx日历系统中的日期。每个XxxChronology类包含一个INSTANCE常量,代表该类的一个单独实例。例如,HijrahChronologyHijrahDate是您将用来处理回历系统的类。下面的代码片段显示了获取泰国佛教日历中当前日期的两种方法。您可能会得到不同的输出:

import java.time.chrono.ThaiBuddhistChronology;
import java.time.chrono.ThaiBuddhistDate;
...
ThaiBuddhistChronology thaiBuddhistChrono = ThaiBuddhistChronology.INSTANCE;
ThaiBuddhistDate now = thaiBuddhistChrono.dateNow();
ThaiBuddhistDate now2 = ThaiBuddhistDate.now();
System.out.println("Current Date in Thai Buddhist: " + now);
System.out.println("Current Date in Thai Buddhist: " + now2);
Current Date in Thai Buddhist: ThaiBuddhist BE 2564-08-20
Current Date in Thai Buddhist: ThaiBuddhist BE 2564-08-20

您也可以将一种日历系统中的日期转换为另一种日历系统。也允许从 ISO 日期转换到非 ISO 日期。将日期从一种日历系统转换到另一种日历系统,只需调用目标日期类的from()静态方法,并将源日期对象作为其参数传递。清单 16-23 展示了如何将 ISO 日期转换成泰国佛教日期,反之亦然。您可能会得到不同的输出。

// InterCalendarDates.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.chrono.ThaiBuddhistDate;
public class InterCalendarDates {
    public static void main(String[] args) {
        ThaiBuddhistDate thaiBuddhistNow = ThaiBuddhistDate.now();
        LocalDate isoNow = LocalDate.now();
        System.out.println("Thai Buddhist Current Date: " + thaiBuddhistNow);
        System.out.println("ISO Current Date: " + isoNow);
        // Convert Thai Buddhist date to ISO date and vice versa
        ThaiBuddhistDate thaiBuddhistNow2 = ThaiBuddhistDate.from(isoNow);
        LocalDate isoNow2 = LocalDate.from(thaiBuddhistNow);
        System.out.println("Thai Buddhist Current Date from ISO: " + thaiBuddhistNow2);
        System.out.println("ISO Current Date from Thai Buddhist: " + isoNow2);
    }
}
Thai Buddhist Current Date: ThaiBuddhist BE 2564-08-20
ISO Current Date: 2021-08-20
Thai Buddhist Current Date from ISO: ThaiBuddhist BE 2564-08-20
ISO Current Date from Thai Buddhist: 2021-08-20

Listing 16-23Using the Thai Buddhist and ISO Calendars

格式化日期和时间

DateTimeFormatter类的对象允许您格式化和解析日期时间对象。通过格式化,我的意思是以用户定义的文本形式表示日期时间对象,例如,将 2021 年 5 月 24 日的LocalDate表示为“05/24/2021”有时格式化也被称为打印,因为格式化特性还允许您将日期时间对象的文本表示打印(或输出)到Appendable对象,比如StringBuilder

解析是格式化的逆过程。它允许您从日期时间的文本表示中构造一个日期时间对象。从文本“05/24/2021”创建一个LocalDate对象来表示 2021 年 5 月 24 日,这是解析的一个例子。

存在不同的格式化和解析日期时间的方法。如果学习方法不正确,学习如何格式化日期时间可能会很困难。要记住的最重要的一点是格式化和解析总是由DateTimeFormatter类的对象来执行。区别在于如何创建该对象。DateTimeFormatter类不提供任何公共构造器。您必须间接获取它的对象。一开始,困惑在于如何获得它的对象。使用DateTimeFormatter类的以下两种方法之一来格式化日期、时间或日期时间:

  • String format(TemporalAccessor temporal)

  • void formatTo(TemporalAccessor temporal, Appendable appendable)

format()方法接受一个日期、时间或日期时间对象,并根据格式化程序的规则返回该对象的文本表示。formatTo()方法允许您将对象的文本表示写入一个Appendable,例如,一个文件、一个StringBuilder等。

要格式化 datetime 对象,格式化程序需要两条信息:格式模式和区域设置。有时一条或两条信息都是默认的;有时候,你提供了它们。

您可以用几种方式执行格式化。它们都直接或间接地使用一个DateTimeFormatter对象:

  • 使用预定义的标准日期时间格式器

  • 使用日期时间类的format()方法

  • 使用用户定义的模式

  • 使用DateTimeFormatterBuilder

使用预定义的格式化程序

预定义的格式化程序在DateTimeFormatter类中被定义为常量。它们在表 16-6 中列出。大多数格式化程序使用 ISO 日期时间格式;一些格式化程序使用稍加修改的 ISO 格式。

表 16-6

预定义的日期时间格式器

|

格式程序

|

描述

|

例子

BASIC_ISO_DATE 一个 ISO 日期格式化程序,用于格式化和解析日期,而无需在两个日期部分之间使用分隔符。 20140109, 20140109-0600
ISO_DATEISO_TIMEISO_DATE_TIME 日期、时间和日期时间格式化程序,使用 ISO 分隔符格式化和解析日期、时间和日期时间。 2014-01-09, 2014-01-09-06:00,``15:38:32.927, 15:38:32.943-06:00,``2014-01-09T15:20:07.747-06:00, 2014-01-09T15:20:07.825-06:00[America/Chicago]
ISO_INSTANT 一个 instant formatter,用于格式化和解析 UTC 格式的 instant(或表示 instant 的 datetime 对象,如ZonedDateTime)。 2014-01-09T21:23:56.870Z
ISO_LOCAL_DATEISO_LOCAL_TIMEISO_LOCAL_DATE_TIME 日期、时间和日期时间格式化程序,用于格式化或分析不带偏移量的日期、时间和日期时间。 2014-01-09, 15:30:14.352, 2014-01-09T15:29:11.384
ISO_OFFSET_DATEISO_OFFSET_TIMEISO_OFFSET_DATE_TIME 日期、时间和日期时间格式化程序,使用 ISO 格式格式化和解析带有偏移量的日期、时间和日期时间。 2014-01-09-06:00,``15:34:29.851-06:00,``2014-01-09T15:33:07.07-06:0
ISO_ZONED_DATE_TIME 日期时间格式化程序,用于格式化和分析带有区域 ID 的日期时间(如果有)。 2014-01-09T15:45:49.112-06:00, 2014-01-09T15:45:49.128-06:00[America/Chicago]
ISO_ORDINAL_DATE 一个日期格式化程序,用于格式化和解析带有年份和日期的日期。 2014-009
ISO_WEEK_DATE 一个日期格式化程序,用于格式化和解析基于周的日期。格式为年-周-年-日-周。例如,2014-W02-4 表示 2014 年第二周的第四天。 2014-W02-4,``2014-W02-4-06:00
RFC_1123_DATE_TIME 使用 RFC1123 规范格式化和解析电子邮件日期时间的日期时间格式化程序。 Thu, 9 Jan 2014 15:50:44 -05:00

使用预定义的格式化程序很简单:只需将日期/时间对象传递给format()。下面的代码片段使用ISO_DATE格式化程序来格式化一个LocalDateOffsetDateTimeZonedDateTime。当它格式化并打印当前日期时,您可能会得到不同的输出:

import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import static java.time.format.DateTimeFormatter.ISO_DATE;
...
// Format dates using the ISO_DATE formatter
String ldStr = ISO_DATE.format(LocalDate.now());
String odtStr = ISO_DATE.format(OffsetDateTime.now());
String zdtStr = ISO_DATE.format(ZonedDateTime.now());
System.out.println("Local Date: " + ldStr);
System.out.println("Offset Datetime: " + odtStr);
System.out.println("Zoned Datetime: " + zdtStr);
Local Date: 2021-08-20
Offset Datetime: 2021-08-20-04:00
Zoned Datetime: 2021-08-20-04:00

请注意预定义格式化程序的名称。正在格式化的 datetime 对象必须包含如其名称所示的组件。例如,ISO_DATE格式化程序期望日期组件的存在,因此,它不应该用于格式化仅时间对象,如LocalTime。类似地,ISO_TIME格式化程序也不应该用来格式化LocalDate:

// A runtime error as a LocalTime does not contain date components
String ltStr = ISO_DATE.format(LocalTime.now());

使用 Datetime 类的 format()方法

您可以使用 datetime 对象的format()方法来格式化它。format()方法接受一个DateTimeFormatter类的对象。下面的代码片段使用了这种方法。使用ISO_DATE格式器:

import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import static java.time.format.DateTimeFormatter.ISO_DATE;
...
LocalDate ld = LocalDate.now();
String ldStr = ld.format(ISO_DATE);
System.out.println("Local Date: " + ldStr);
OffsetDateTime odt = OffsetDateTime.now();
String odtStr = odt.format(ISO_DATE);
System.out.println("Offset Datetime: " + odtStr);
ZonedDateTime zdt = ZonedDateTime.now();
String zdtStr = zdt.format(ISO_DATE);
System.out.println("Zoned Datetime: " + zdtStr);
Local Date: 2021-08-20
Offset Datetime: 2021-08-20-04:00
Zoned Datetime: 2021-08-20-04:00

使用用户定义的模式

DateTimeFormatter类中最常用的方法之一是ofPattern()方法,它返回一个具有指定格式模式和区域设置的DateTimeFormatter对象:

  • static DateTimeFormatter ofPattern(String pattern)

  • static DateTimeFormatter ofPattern(String pattern, Locale locale)

下面的代码片段获取了两个格式化程序来将日期格式化为“年、月、日”格式。第一个格式化程序以默认语言环境格式化日期时间,第二个格式化程序以德语语言环境格式化日期时间:

// Get a formatter for the default locale
DateTimeFormatter fmt1 = DateTimeFormatter.ofPattern("MMMM dd, yyyy");
// Get a formatter for the German locale
DateTimeFormatter fmt2 = DateTimeFormatter.ofPattern("MMMM dd, yyyy", Locale.GERMAN);

有时您有一个用于模式和地区的DateTimeFormatter对象。您希望使用相同的模式来格式化另一个地区的日期时间。DateTimeFormatter类有一个withLocale()方法,为使用相同模式的指定地区返回一个DateTimeFormatter对象。在前面的代码片段中,您可以用下面的语句替换第二个语句:

// Get a formatter for the German locale using the same pattern as fmt1
DateTimeFormatter fmt2 = fmt1.withLocale(Locale.GERMAN);

Tip

使用DateTimeFormatter类的getLocale()方法来了解它将用来格式化日期时间的区域设置。

日期时间格式是基于一种模式执行的。格式模式是一系列具有特殊含义的字符。例如,模式中的 MMMM 使用月份的完整拼写名称,如一月、二月等。;MMM 使用月份名称的缩写形式,如一月、二月等。;MM 使用两位数的月份号,如 01、02 等。;m 使用一位数或两位数的月份数,如 1、2、10、11 等。

在一个格式模式中,有些字符有特殊的含义,有些是按字面意思使用的。具有特殊含义的字符将被格式化程序解释,它们将被替换为日期时间成分。格式化程序输出出现在模式中的文字字符。所有字母 A–Z 和 A–Z 都被保留为模式字母,尽管并非所有字母都被使用。如果您想在模式中包含一个文字字符串,您需要用单引号将它括起来。要输出单引号,您需要使用两个连续的单引号。

日期时间格式化程序直接输出除[,]和单引号以外的任何非字母字符。但是,建议您用单引号将它们括起来。假设您的本地日期是 2021 年 5 月 29 日。“1997 MMMM dd,yyyy”和“‘1997’MMMM DD,yyyy”两种模式都将输出 1997 年 5 月 29 日 2021;但是,建议使用后者,它在文字 1997 前后使用单引号。

表 16-7 列出了图案中使用的符号及其含义。表中的所有示例都使用“2021-07-29t 07:30:12.789-05:00[美国/芝加哥]”作为输入日期时间。

表 16-7

日期时间格式符号和示例说明

|

标志

|

描述

|

例子

图案 输出
G 时代 G 广告
俄文 域年份
回答 A
u 年它可以是正数,也可以是负数。在纪元开始日期之后,它是一个正数。在纪元开始日期之前,它是一个负数。例如,公元 2014 年的年值是 2014 年,而公元前 2014 年的年值是–2014 年。 u/uuu/uuuu Two thousand and twenty-one
溃疡性龈炎 Twelve
别发牢骚 02021
y 纪元年它从纪元开始日期向前或向后计算年份。它总是一个正数。例如,公元 2014 年的年值是 2014 年,公元前 2014 年的年值是 2015 年。在公共纪元中,0 年是公元前 1 年。 是/是/是 Two thousand and twenty-one
尤尼克斯 Twelve
是的 02021
D 一年中的第几天(1–366) D One hundred and fifty
男/女 一年中的月份 M five
梅智节拍器 05
七月
七月
d 一月中的某一天 d 5, 29
截止日期(Deadline Date 的缩写) 05, 29
g 改良儒略日(在 Java 9 中添加) g Fifty-seven thousand seven hundred and ninety-six
ggg Fifty-seven thousand seven hundred and ninety-six
个性签名 057796
问/问 一年中的一个季度 Q three
即时通信软件 03
即时通信软件 Q3
QQQQ 第三季度
Y 基于周的年份 Y Two thousand and twenty-one
尤尼克斯 Twelve
YYYY 年年 Two thousand and twenty-one
w 基于周的一年中的周 w Thirty-one
W 每月的第几周 W five
E 星期几 E seven
电子工程师 07
东方马脑脊髓炎 太阳
依依社区防屏蔽 在星期日
F 一个月中的星期几 F one
a 一天的上午/下午 a
h 上午/下午的时钟小时(1–12) h seven
K 上午/下午的时间(0–11) K seven
k 上午/下午的时钟小时(1–24) k seven
H 一天中的小时(0–23) H seven
殿下 07
m 一小时中的分钟 毫米 Thirty
s 分钟的秒 悬浮物 Twelve
S 几分之一秒 SSSSSSSSS 000000789
A 一天中的毫秒 A Twenty-seven million and twelve thousand
n 毫微秒 n Seven hundred and eighty-nine
普通 一天的纳秒 普通 27012000000789
V 时区 ID 美国/芝加哥
v 通用非位置区域名称(在 Java 9 中添加) v 计算机化 X 线体层照相术
vvv 中央标准时间
z 时区名称 z 中央日光时间
Z 区域偏移当区域偏移为零时,它输出+0000 或+00:00,这取决于您是使用 Z、ZZ 还是 ZZZ。 Z –0500
锯齿形 –0500
打鼾声 –05:00
ZZZ GMT-05:00
O 局部区域偏移 O GMT-5
X 区域偏移与符号 Z 不同,它打印区域偏移零的 Z。如果分和秒都是零,例如+09,x 只输出小时;XX 输出小时和分钟,不带冒号,比如+0830;XXX 输出带有冒号的小时和分钟,例如+08:30;XXXX 输出小时、分钟和可选的秒,不带冒号,比如+083045;XXXXX 用冒号输出小时、分钟和可选的秒,例如+08:30:45。 X +0530
xx +0530
XXX +05:30
电影站 +053045
五 x 综合征 +05:30:45
x 与 X 相同,只是它为区域偏移零打印+00,而不是 z。 xx -0500
p 填充下一个它用空格填充后面模式的输出。比如 mm 输出 30,pppmm 输出‘30’,pppmm 输出‘30’。ps 的数量决定输出的宽度。 pppmm ' 30'
(单引号显示了带有空格的填充。)
' 文本转义单引号内的文本直接输出。要输出单引号,请使用两个连续的单引号。 你好 你好
你好,MMMM 你好,七月
'' 单引号 '''你好' ' ' MMMM 你好,七月
[ ] 可选部分参考讨论中的例子。
#, {, } 这些是留作将来使用的。

模式字符串中可以有可选的部分。符号[]分别表示可选部分的开始和结束。只有当所有元素的信息都可用时,才输出包含在可选部分中的模式。否则,跳过可选部分。可选节可以嵌套在另一个可选节中。清单 16-24 展示了如何在模式中使用可选部分。可选部分包含时间信息。设置日期格式时,将跳过可选部分。

// OptionalSectionTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.time.format.DateTimeFormatter;
public class OptionalSectionTest {
    public static void main(String[] args) {
        // A pattern with an optional section
        String pattern = "MM/dd/yyyy[ 'at' HH:mm:ss]";
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern);
        LocalDate ld = LocalDate.of(2021, Month.MAY, 30);
        LocalTime lt = LocalTime.of(17, 30, 12);
        LocalDateTime ldt = LocalDateTime.of(ld,lt);
        // Format a date. Optional section will be skipped because a
        // date does not have time (HH, mm, and ss) information.
        String str1 = fmt.format(ld);
        System.out.println(str1);
        // Format a datetime. Optional section will be output.
        String str2 = fmt.format(ldt);
        System.out.println(str2);
    }
}
05/30/2021
05/30/2021 at 17:30:12

Listing 16-24Using an Optional Section in a Datetime Formatting Pattern

清单 16-25 展示了如何使用不同的模式来格式化日期和时间。

// FormattingDateTime.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.Temporal;
import java.util.Locale;
public class FormattingDateTime {
    public static void main(String[] args) {
        LocalDate ld = LocalDate.of(2021, Month.APRIL, 30);
        System.out.println("Formatting date: " + ld);
        format(ld, "M/d/yyyy");
        format(ld, "MM/dd/yyyy");
        format(ld, "MMM dd, yyyy");
        format(ld, "MMMM dd, yyyy");
        format(ld, "EEEE, MMMM dd, yyyy");
        format(ld, "'Month' q 'in' QQQ");
        format(ld, "[MM-dd-yyyy][' at' HH:mm:ss]");
        LocalTime lt = LocalTime.of(16, 30, 5, 78899);
        System.out.println("\nFormatting time:" + lt);
        format(lt, "HH:mm:ss");
        format(lt, "KK:mm:ss a");
        format(lt, "[MM-dd-yyyy][' at' HH:mm:ss]");
        ZoneId usCentral = ZoneId.of("America/Chicago");
        ZonedDateTime zdt = ZonedDateTime.of(ld, lt, usCentral);
        System.out.println("\nFormatting zoned datetime:" + zdt);
        format(zdt, "MM/dd/yyyy HH:mm:ssXXX");
        format(zdt, "MM/dd/yyyy VV");
        format(zdt, "[MM-dd-yyyy][' at' HH:mm:ss]");
    }
    public static void format(Temporal co, String pattern) {
        DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.US);
        String str = fmt.format(co);
        System.out.println(pattern + ": " + str);
    }
}
Formatting date: 2021-04-30
M/d/yyyy: 4/30/2021
MM/dd/yyyy: 04/30/2021
MMM dd, yyyy: Apr 30, 2021
MMMM dd, yyyy: April 30, 2021
EEEE, MMMM dd, yyyy: Monday, April 30, 2021
'Month' q 'in' QQQ: Month 2 in Q2
[MM-dd-yyyy][' at' HH:mm:ss]: 04-30-2021
Formatting time:16:30:05.000078899
HH:mm:ss: 16:30:05
KK:mm:ss a: 04:30:05 PM
[MM-dd-yyyy][' at' HH:mm:ss]:  at 16:30:05
Formatting zoned datetime:2021-04-30T16:30:05.000078899-05:00[America/Chicago]
MM/dd/yyyy HH:mm:ssXXX: 04/30/2021 16:30:05-05:00
MM/dd/yyyy VV: 04/30/2021 America/Chicago
[MM-dd-yyyy][' at' HH:mm:ss]: 04-30-2021 at 16:30:05

Listing 16-25Using Patterns to Format Dates and Times

使用特定于区域设置的格式

DateTimeFormatter类有几个方法返回一个DateTimeFormatter,它带有适合人类阅读的预定义格式模式。使用以下方法获取对此类格式化程序的引用:

  • DateTimeFormatter ofLocalizedDate(FormatStyle dateStyle)

  • DateTimeFormatter ofLocalizedDateTime(FormatStyle dateTimeStyle)

  • DateTimeFormatter ofLocalizedDateTime(FormatStyle dateStyle, FormatStyle timeStyle)

  • DateTimeFormatter ofLocalizedTime(FormatStyle timeStyle)

这些方法接受一个FormatStyle枚举类型的参数,它有四个常量:SHORTMEDIUMLONGFULL。这些常量用于输出不同详细程度的格式化日期和时间。输出中的细节是特定于语言环境的。这些方法使用系统默认区域设置。对于不同的语言环境,使用withLocal()方法获得一个具有指定语言环境的新的DateTimeFormatter

清单 16-26 展示了如何使用一些预定义的特定于地区的格式。它格式化美国(默认)、德国和印度地区的日期和时间。

// LocalizedFormats.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import static java.time.format.FormatStyle.FULL;
import static java.time.format.FormatStyle.LONG;
import static java.time.format.FormatStyle.MEDIUM;
import static java.time.format.FormatStyle.SHORT;
import java.util.Locale;
public class LocalizedFormats {
    public static void main(String[] args) {
        LocalDate ld = LocalDate.of(2021, Month.APRIL, 19);
        LocalTime lt = LocalTime.of(16, 30, 20);
        LocalDateTime ldt = LocalDateTime.of(ld, lt);
        DateTimeFormatter fmt = DateTimeFormatter.ofLocalizedDate(SHORT);
        System.out.println("Formatter Default Locale: " + fmt.getLocale());
        System.out.println("Short Date: " + fmt.format(ld));
        fmt = DateTimeFormatter.ofLocalizedDate(MEDIUM);
        System.out.println("Medium Date: " + fmt.format(ld));
        fmt = DateTimeFormatter.ofLocalizedDate(LONG);
        System.out.println("Long Date: " + fmt.format(ld));
        fmt = DateTimeFormatter.ofLocalizedDate(FULL);
        System.out.println("Full Date: " + fmt.format(ld));
        fmt = DateTimeFormatter.ofLocalizedTime(SHORT);
        System.out.println("Short Time: " + fmt.format(lt));
        fmt = DateTimeFormatter.ofLocalizedDateTime(SHORT);
        System.out.println("Short Datetime: " + fmt.format(ldt));
        fmt = DateTimeFormatter.ofLocalizedDateTime(MEDIUM);
        System.out.println("Medium Datetime: " + fmt.format(ldt));
        // Use German locale to format the datetime in medius style
        fmt = DateTimeFormatter.ofLocalizedDateTime(MEDIUM)
                               .withLocale(Locale.GERMAN);
        System.out.println("German Medium Datetime: " + fmt.format(ldt));
        // Use Indian(English) locale to format datetime in short style
        fmt = DateTimeFormatter.ofLocalizedDateTime(SHORT)
                               .withLocale(new Locale("en", "IN"));
        System.out.println("Indian(en) Short Datetime: " + fmt.format(ldt));
        // Use Indian(English) locale to format datetime in medium style
        fmt = DateTimeFormatter.ofLocalizedDateTime(MEDIUM)
                               .withLocale(new Locale("en","IN"));
        System.out.println("Indian(en) Medium Datetime: " + fmt.format(ldt));
    }
}
Formatter Default Locale: en_US
Short Date: 4/19/21
Medium Date: Apr 19, 2021
Long Date: April 19, 2021
Full Date: Thursday, April 19, 2021
Short Time: 4:30 PM
Short Datetime: 4/19/21, 4:30 PM
Medium Datetime: Apr 19, 2021, 4:30:20 PM
German Medium Datetime: 19.04.2021, 16:30:20
Indian(en) Short Datetime: 19/04/21, 4:30 PM
Indian(en) Medium Datetime: 19-Apr-2021, 4:30:20 PM

Listing 16-26Using Predefined Format Patterns

使用 DateTimeFormatterBuilder 类

在内部,所有日期时间格式化程序都是使用DateTimeFormatterBuilder获得的。通常,您不需要使用该类。前面讨论的方法在几乎所有的用例中都是足够的。该类有一个无参数的构造器和许多appendXxx()方法。创建该类的一个实例,并调用这些appendXxx()方法来构建所需的格式化程序。最后,调用toFomatter()方法获得一个DateTimeFormatter对象。下面的代码片段构建了一个DateTimeFormatter对象,将日期格式化为“圣诞节在YEARWEEK_DAY”的格式:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import static java.time.format.TextStyle.FULL_STANDALONE;
import static java.time.temporal.ChronoField.DAY_OF_WEEK;
import static java.time.temporal.ChronoField.YEAR;
...
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                              .appendLiteral("Christmas in ")
                              .appendValue(YEAR)
                              .appendLiteral(" is on ")
                              .appendText(DAY_OF_WEEK, FULL_STANDALONE)
                              .toFormatter();
LocalDate ld = LocalDate.of(2020, 12, 25);
String str = ld.format(formatter);
System.out.println(str);
Christmas in 2020 is on Friday

您可以使用一种模式创建相同的格式化程序,这种模式比前面使用DateTimeFormatterBuilder的代码更容易编写和读取:

LocalDate ld = LocalDate.of(2020, 12, 25);
String pattern = "'Christmas in' yyyy 'is on' EEEE";
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
String str = ld.format(formatter);
System.out.println(str);
Christmas in 2020 is on Friday

解析日期和时间

解析是从字符串创建日期、时间或日期时间对象的过程。像格式化一样,解析也由一个DateTimeFormatter处理。有关如何获取DateTimeFormatter类的实例的详细信息,请参考上一节“格式化日期和时间”。用于格式化的相同符号也用作解析符号。有两种方法可以将字符串解析为 datetime 对象:

  • 使用 datetime 类的parse()方法

  • 使用DateTimeFormatter类的parse()方法

Tip

如果文本不能被解析,抛出一个DateTimeParseException。这是一个运行时异常。该类包含两个提供错误详细信息的方法。getErrorIndex()方法返回发生错误的文本中的索引。getParsedString()方法返回被解析的文本。在解析日期时间时处理此异常是一种好的做法。

每个 datetime 类都有两个重载版本的静态方法parse()parse()方法的返回类型与定义的 datetime 类相同。下面是LocalDate类中parse()方法的两个版本:

  • static LocalDate parse(CharSequence text)

  • static LocalDate parse(CharSequence text, DateTimeFormatter formatter)

第一个版本的parse()方法采用 ISO 格式的datetime对象的文本表示。例如,对于一个LocalDate,文本应该是 yyyy-mm-dd 格式。第二个版本让您指定一个DateTimeFormatter。下面的代码片段将两个字符串解析成两个LocalDate对象:

// Parse a LocalDate in ISO format
LocalDate ld1 = LocalDate.parse("2022-01-10");
// Parse a LocalDate in MM/dd/yyyy format
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy");
LocalDate ld2 = LocalDate.parse("01/10/2022", formatter);
System.out.println("ld1: " + ld1);
System.out.println("ld2: " + ld2);
ld1: 2022-01-10
ld2: 2022-01-10

DateTimeFormatter类包含几个parse()方法,以便于将字符串解析成 datetime 对象。DateTimeFormatter类不知道可以由字符串构成的日期时间对象的类型。因此,它们中的大多数都返回一个TemporalAccessor对象,您可以查询该对象以获得日期时间组件。您可以将TemporalAccessor对象传递给 datetime 类的from()方法来获取特定的 datetime 对象。下面的代码片段展示了如何使用DateTimeFormatter对象构建LocalDate来解析 MM/dd/yyyy 格式的字符串:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
...
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy");
TemporalAccessor ta = formatter.parse("01/10/2022");
LocalDate ld = LocalDate.from(ta);
System.out.println(ld);
2022-01-10

另一个版本的parse()方法使用了一个TemporalQuery,它可以用来将字符串直接解析成一个特定的 datetime 对象。下面的代码片段使用了这个版本的parse()方法。第二个参数是LocalDate类的from()方法的方法引用。您可以将下面的代码片段视为前面代码的简写:

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy");
LocalDate ld = formatter.parse("01/10/2022", LocalDate::from);

System.out.println(ld);
2022-01-10

DateTimeFormatter类包含一个parseBest()方法。使用这种方法几乎不需要解释。假设您收到一个字符串作为方法的参数。参数可能包含不同的日期和时间信息。在这种情况下,您希望使用最多的信息来解析字符串。考虑以下模式:

yyyy-MM-dd['T'HH:mm:ss[Z]]

这个模式有两个可选部分。具有这种模式的文本可以被完全解析为一个OffsetDateTime,部分解析为一个LocalDateTime和一个LocalDate。您可以为这个模式创建一个解析器,如下所示:

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd['T'HH:mm:ss[Z]]");

下面的代码片段将OffsetDateTimeLocalDateTimeLocalDate指定为首选的解析结果类型:

String text = ...
TemporalAccessor ta =
    formatter.parseBest(text, OffsetDateTime::from, LocalDateTime::from, LocalDate::from);

该方法将尝试按顺序将文本解析为指定的类型,并返回第一个成功的结果。通常,对parseBest()方法的调用之后是一系列带有instanceof操作符的if-else语句,以检查返回的Object类型。清单 16-27 展示了如何使用parseBest()方法。请注意,第四个文本的格式无效,解析它会引发异常。

// ParseBestTest.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
public class ParseBestTest {
    public static void main(String[] args) {
        DateTimeFormatter parser
                = DateTimeFormatter.ofPattern("yyyy-MM-dd['T'HH:mm:ss[Z]]");
        parseStr(parser, "2021-05-31");
        parseStr(parser, "2021-05-31T16:30:12");
        parseStr(parser, "2021-05-31T16:30:12-0500");
        parseStr(parser, "2021-05-31Hello");
    }
    public static void parseStr(DateTimeFormatter formatter, String text) {
        try {
            TemporalAccessor ta = formatter.parseBest(text,
                    OffsetDateTime::from,
                    LocalDateTime::from,
                    LocalDate::from);
            if (ta instanceof OffsetDateTime) {
                OffsetDateTime odt = OffsetDateTime.from(ta);
                System.out.println("OffsetDateTime: " + odt);
            } else if (ta instanceof LocalDateTime) {
                LocalDateTime ldt = LocalDateTime.from(ta);
                System.out.println("LocalDateTime: " + ldt);
            } else if (ta instanceof LocalDate) {
                LocalDate ld = LocalDate.from(ta);
                System.out.println("LocalDate: " + ld);
            } else {
                System.out.println("Parsing returned: " + ta);
            }
        } catch (DateTimeParseException e) {
            System.out.println(e.getMessage());
        }
    }

}
LocalDate: 2021-05-31
LocalDateTime: 2021-05-31T16:30:12
OffsetDateTime: 2021-05-31T16:30:12-05:00
Text '2021-05-31Hello' could not be parsed, unparsed text found at index 10

Listing 16-27Using the parseBest() Method of the DateTimeFormatter Class

传统日期时间类

我们将 Java 8 之前可用的与日期时间相关的类称为遗留日期时间类。主要的遗留类是DateCalendarGregorianCalendar。他们在java.util包里。关于如何将DateCalendar对象转换为新的日期-时间 API 的日期时间对象,或者反过来,请参考“与遗留日期时间类的互操作性”一节。

日期类

一个Date类的对象代表一个瞬间。一个Date对象存储了从 UTC 时间 1970 年 1 月 1 日午夜开始的毫秒数。

Tip

传统日期时间 API 中的Date类类似于新日期时间 API 中的Instant类。它们分别具有毫秒和纳秒的精度。

从 JDK 1.1 开始,Date类的大多数构造器和方法都被弃用了。Date类的默认构造器用于创建一个带有当前系统日期时间的Date对象。清单 16-28 展示了Date类的用法。您可能会得到不同的输出,因为它打印当前的日期和时间。

// CurrentLegacyDate.java
package com.jdojo.datetime;
import java.util.Date;
public class CurrentLegacyDate {
    public static void main (String[] args) {
        // Create a new Date object
        Date currentDate = new Date();
        System.out.println("Current date: " + currentDate);
        // Get the milliseconds value of the current date
        long millis = currentDate.getTime();
        System.out.println("Current datetime in millis: " + millis);
    }
}
Current date: Sat Aug 21 20:13:38 EDT 2021
Current datetime in millis: 1629591218981

Listing 16-28Using the Date Class

一个对象以 1900 年为基础工作。当你调用这个对象的setYear()方法将年份设置为 2017 年时,你将需要传递 117(2017–1900 = 117)。它的getYear()方法返回 2017 年的 117。此类中的月份范围从 0 到 11,其中一月是 0,二月是 2 …十二月是 11。

日历类

Calendar是一个抽象类。抽象类不能被实例化。我们在关于继承的第二十章中详细讨论抽象类。GregorianCalendar类是一个具体的类,它继承了Calendar类。

Calendar类声明了一些 final 静态字段来表示日期字段。例如,Calendar.JANUARY可以用来指定日期中的一月。GregorianCalendar类有一个默认的构造器,它创建一个对象来表示当前的日期时间。您还可以创建一个GregorianCalendar对象,使用它的其他构造器来表示特定的日期。它还允许您获取特定时区的当前日期:

// Get the current date in the system default time zone
GregorianCalendar currentDate = new GregorianCalendar();
// Get GregorianCalendar object representing March 26, 2003 06:30:45 AM
GregorianCalendar someDate = new GregorianCalendar(2003, Calendar.MARCH, 26, 6, 30, 45);
// Get Indian time zone, which is GMT+05:30
TimeZone indianTZ = TimeZone.getTimeZone("GMT+05:30");
// Get current date in India
GregorianCalendar indianDate = new GregorianCalendar(indianTZ);
// Get Moscow time zone, which is GMT+03:00
TimeZone moscowTZ = TimeZone.getTimeZone("GMT+03:00");
// Get current date in Moscow
GregorianCalendar moscowDate = new GregorianCalendar(moscowTZ);

Tip

一个Date包含日期时间。一个GregorianCalendar包含一个带时区的日期时间。

日期的月份部分的范围是从 0 到 11。即 1 月为 0,2 月为 1,以此类推。在Calendar类中使用为月份和其他日期字段声明的常量比使用它们的整数值更容易。例如,您应该在程序中使用Calendar.JANUARY常量来表示一月,而不是 0。您可以使用get()方法通过将请求的字段作为参数传递来获取日期时间中的字段值:

// Create a GregorianCalendar object
GregorianCalendar gc = new GregorianCalendar();
// year will contain the current year value
int year = gc.get(Calendar.YEAR);
// month will contain the current month value
int month = gc.get(Calendar.MONTH);
// day will contain day of month of the current date
int day = gc.get(Calendar.DAY_OF_MONTH);
// hour will contain hour value
int hour = gc.get(Calendar.HOUR);
// minute will contain minute value
int minute = gc.get(Calendar.MINUTE);
// second will contain second values
int second = gc.get(Calendar.SECOND);

您可以使用GregorianCalendar类的setLenient()方法将日期解释设置为宽松或不宽松。默认是宽大的。如果日期解释宽松,则日期(如 2003 年 3 月 35 日)将被解释为 2003 年 4 月 5 日。如果日期解释不宽松,这样的日期将导致错误。您还可以使用before()after()方法来比较两个日期,无论一个日期是在另一个日期之前还是之后。有两种方法,add()roll(),需要解释一下。下面几节将对它们进行描述。

add()方法

add()方法用于将一个金额添加到日期的特定字段中。添加的量可以是负的或正的。假设您将日期 2003 年 12 月 1 日存储在一个GregorianCalendar对象中。您希望在月字段中添加 5。月字段的值将是 16,这超出了范围(0–11)。在这种情况下,较大的日期字段(这里,year 大于 month)将被调整以适应溢出。在“月份”字段中添加 5 后,日期将是 2004 年 5 月 1 日。以下代码片段阐释了这一概念:

GregorianCalendar gc = new GregorianCalendar(2003, Calendar.DECEMBER, 1);
gc.add(Calendar.MONTH, 5); // Now gc represents May 1, 2004

这种方法也可能导致调整较小的字段。假设您将日期 2003 年 1 月 30 日存储在一个GregorianCalendar对象中。您在月份字段中添加 1。新月份字段不会溢出。但是,产生的日期 2003 年 2 月 30 日不是有效的日期。2003 年 2 月中的日期必须介于 1 和 28 之间。在这种情况下,日期字段会自动调整。它被设置为最接近的有效值,即28。结果日期将是 2003 年 2 月 28 日。

roll()方法

roll()方法的工作原理与add()方法相同,除了当被改变的字段溢出时,它不改变更大的字段。它可能会调整较小的字段以使日期成为有效日期。这是一个重载方法:

  • void roll(int field, int amount)

  • void roll(int field, boolean up)

第二个版本将指定的field向上/向下滚动一个时间单位,而第一个版本将指定的field滚动指定的amount。因此,gc.roll(Calendar.MONTH, 1)gc.roll(Calendar.MONTH, true),相同,gc.roll(Calendar.MONTH, -1)gc.roll(Calendar.MONTH, false)相同。清单 16-29 展示了GregorianCalendar类的一些方法的使用。您可能会得到不同的输出。

// GregorianDate .java
package com.jdojo.datetime;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
public class GregorianDate {
    public static void main(String[] args) {
        GregorianCalendar gc = new GregorianCalendar();
        System.out.println("Current Date: " + getStr(gc));
        // Add 1 year
        gc.add(Calendar.YEAR, 1);
        System.out.println("After adding a year: " + getStr(gc));
        // Add 15 days
        gc.add(Calendar.DATE, 15);
        System.out.println("After adding 15 days: " + getStr(gc));
        long millis = gc.getTimeInMillis();
        Date dt = gc.getTime();
        System.out.println("Time in millis: " + millis);
        System.out.println("Time as Date: " + dt);
    }

    public static String getStr(GregorianCalendar gc) {
        int day = gc.get(Calendar.DAY_OF_MONTH);
        int month = gc.get(Calendar.MONTH);
        int year = gc.get(Calendar.YEAR);
        int hour = gc.get(Calendar.HOUR);
        int minute = gc.get(Calendar.MINUTE);
        int second = gc.get(Calendar.SECOND);
        String str = day + "/" + (month + 1) + "/" + year + " "
                + hour + ":" + minute + ":" + second;
        return str;
    }
}

Current Date: 21/8/2021 8:15:15
After adding a year: 21/8/2022 8:15:15
After adding 15 days: 5/9/2022 8:15:15
Time in millis: 1662423315056
Time as Date: Mon Sep 05 20:15:15 EDT 2022

Listing 16-29Using the GregorianCalendar Class

与传统日期时间类的互操作性

当新的日期时间 API 出现时,传统的 datetime 类已经存在了 18 年。作为一名 Java 开发人员,您可能需要维护使用遗留类的应用程序。出于这个原因,还提供了遗留类和新的日期时间 API 之间的互操作性。遗留类中添加了新的方法,可以将其对象转换为新的 datetime 对象,反之亦然。本节将讨论以下遗留类的互操作性:

  • java.util.Date

  • java.util.Calendar

  • java.util.GregorianCalendar

  • java.util.TimeZone

  • java.sql.Date

  • java.sql.Time

  • java.sql.Timestamp

  • java.nio.file.attribute.FileTime

表 16-8 包含传统日期时间类及其新日期时间对应类的列表。除了Calendar类,所有遗留类都提供双向转换。toXxx()方法是实例方法。它们返回新的 datetime 类的对象。其他方法是静态方法,它们接受新 datetime 类的对象并返回旧类的对象。例如,java.util.Date类中的from()方法是一个静态方法,它接受一个Instant参数并返回一个java.util.DatetoInstant()方法是一个实例方法,将一个java.util.Date转换成一个Instant

表 16-8

新日期时间和旧日期时间类之间的转换

|

遗留类

|

遗留类中的新方法

|

等效的新日期时间类

java.util.Date from(), toInstant() Instant
Calendar toInstant() None
GregorianCalendar from(), toZonedDateTime() ZonedDateTime
TimeZone getTimeZone(), toZoneId() ZoneId
java.sql.Date valueOf(), toLocalDate() LocalDate
Time valueOf(), toLocalTime() LocalTime
Timestamp from(), toInstant() Instant
valueOf(), toLocalDateTime() LocalDateTime
FileTime from(), toInstant() Instant

清单 16-30 展示了如何将Date转换成Instant,反之亦然。您可能会得到不同的输出。

// DateAndInstant.java
package com.jdojo.datetime;
import java.util.Date;
import java.time.Instant;
public class DateAndInstant {
    public static void main(String[] args) {
        // Get the current date
        Date dt = new Date();
        System.out.println("Date: " + dt);
        // Convert the Date to an Instant
        Instant in = dt.toInstant();
        System.out.println("Instant: " + in);
        // Convert the Instant back to a Date
        Date dt2 = Date.from(in);
        System.out.println("Date: " + dt2);
    }
}

Date: Sat Aug 21 20:05:05 EDT 2021
Instant: 2021-08-22T00:05:05.841Z
Date: Sat Aug 21 20:05:05 EDT 2021

Listing 16-30Converting a Date to an Instant and Vice Versa

通常,遗留代码使用GregorianCalendar来存储日期、时间和日期时间。您可以将它转换成一个ZonedDateTime,它可以转换成新的日期时间 API 中的任何其他类。Calendar类提供了一个toInstant()方法来将其实例转换成一个InstantCalendar类是抽象的。通常,您会有一个具体子类的实例,例如GregorianCalendar。因此,将Instant转换为GregorianCalendar是一个两步过程:

  • Instant转换为ZonedDateTime

  • 使用GregorianCalendar类的from()静态方法获得一个GregorianCalendar

清单 16-31 中的程序展示了如何将一个GregorianCalendar转换成一个ZonedDateTime,反之亦然。该程序还显示了如何获得一个LocalDateLocalTime等。来自一个GregorianCalendar。您可能会得到不同的输出,因为输出取决于系统的默认时区。

// GregorianCalendarAndNewDateTime.java
package com.jdojo.datetime;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.GregorianCalendar;
import java.util.TimeZone;
public class GregorianCalendarAndNewDateTime {
    public static void main(String[] args) {
        // Create a GC for the default time zone
        GregorianCalendar gc = new GregorianCalendar(2022, 1, 11, 15, 45, 50);
        System.out.println("Gregorian Calendar: " + gc.getTime());
        // Convert the GC to a LocalDate
        LocalDate ld = gc.toZonedDateTime().toLocalDate();
        System.out.println("Local Date: " + ld);
        // Convert the GC to a LocalTime
        LocalTime lt = gc.toZonedDateTime().toLocalTime();
        System.out.println("Local Time: " + lt);
        // Convert the GC to a LocalDateTime
        LocalDateTime ldt = gc.toZonedDateTime().toLocalDateTime();
        System.out.println("Local DateTime: " + ldt);
        // Convert the GC to an OffsetDate
        OffsetDateTime od = gc.toZonedDateTime().toOffsetDateTime();
        System.out.println("Offset Date: " + od);
        // Convert the GC to an OffsetTime
        OffsetTime ot = gc.toZonedDateTime().toOffsetDateTime().toOffsetTime();
        System.out.println("Offset Time: " + ot);
        // Convert the GC to an ZonedDateTime
        ZonedDateTime zdt = gc.toZonedDateTime();
        System.out.println("Zoned DateTime: " + zdt);
        // Convert the ZonedDateTime to a GC. In GC month starts at 0
        // and in new API at 1
        ZoneId zoneId = zdt.getZone();
        TimeZone timeZone = TimeZone.getTimeZone(zoneId);
        System.out.println("Zone ID: " + zoneId);
        System.out.println("Time Zone ID: " + timeZone.getID());
        GregorianCalendar gc2 = GregorianCalendar.from(zdt);
        System.out.println("Gregorian Calendar: " + gc2.getTime());
    }
}

Gregorian Calendar: Fri Feb 11 15:45:50 EST 2022
Local Date: 2022-02-11
Local Time: 15:45:50
Local DateTime: 2022-02-11T15:45:50
Offset Date: 2022-02-11T15:45:50-05:00
Offset Time: 15:45:50-05:00
Zoned DateTime: 2022-02-11T15:45:50-05:00[America/New_York]
Zone ID: America/New_York
Time Zone ID: America/New_York
Gregorian Calendar: Fri Feb 11 15:45:50 EST 2022

Listing 16-31Converting

a GregorianCalendar to New Datetime Types and Vice Versa

如何将一个Date转换成一个LocalDate?一个Date代表一个瞬间,所以首先你需要用一个ZoneIdDate转换成一个ZoneDateTime,然后从ZonedDateTime得到一个LocalDate。以下代码片段将由Date表示的当前日期转换为 Java 中的LocalDate:

Date dt = new Date();
LocalDate ld = dt.toInstant()
                 .atZone(ZoneId.systemDefault())
                 .toLocalDate();
System.out.println("Date: " + dt);
System.out.println("LocalDate: " + ld);
Date: Sat Aug 21 20:07:35 EDT 2021
LocalDate: 2021-08-21

这种转换是经常需要的。Java 9 在LocalDate类中添加了一个ofInstant()方法,使得这种转换更加容易。该方法声明如下:

static LocalDate ofInstant(Instant instant, ZoneId zone)

以下代码片段使用 ofInstant()执行相同的转换:

Date dt = new Date();
LocalDate ld = LocalDate.ofInstant(dt.toInstant(), ZoneId.systemDefault());
System.out.println("Date: " + dt);
System.out.println("LocalDate: " + ld);
Date: Sat Aug 21 20:09:26 EDT 2021
LocalDate: 2021-08-21

摘要

通过java.time包,Java 提供了一个全面的日期时间 API 来处理日期、时间和日期时间。默认情况下,大多数类都基于 ISO-8601 标准。主要的类别有

  • Instant

  • LocalDate

  • LocalTime

  • LocalDateTime

  • OffsetTime

  • OffsetDateTime

  • ZonedDateTime

Instant类代表时间轴上的一个瞬间;并且它适合于机器,例如,作为事件的时间戳。LocalDateLocalTimeLocalDateTime类表示人类可读的日期、时间和不带时区的日期时间。OffsetTimeOffsetDateTime类表示一个时间和日期时间,与 UTC 有一个时区偏移量。ZoneDateTime类表示具有时区规则的时区的日期时间,它将根据时区中夏令时的变化来调整时间。

日期-时间 API 提供了表示机器和人类所用时间的类。Duration类代表机器的时间量,而Period类代表人类感知的时间量。日期时间 API 通过java.time.format.DateTimeFormatter类为格式化和解析日期时间提供了广泛的支持。日期时间 API 通过java.time.chrono包支持非 ISO 日历系统。提供了对回历、日文、民国和泰国佛教日历的内置支持。该 API 是可扩展的,支持构建您自己的日历系统。

EXERCISES

  1. 您将使用哪个类来存储不包含时间和时区部分的日期?

  2. 你用什么类来存储一个知道夏令时的日期和时间?

  3. a ZoneIdZoneOffset有什么区别?

  4. ZonedDateTimeOffsetDateTime有什么区别?

  5. 编写代码,将代表系统默认时区中当前时间的Instant转换为LocalDate

  6. 编写一个程序,打印从 2001 年到 2099 年的所有年份,其中一年的最后一天(12 月 31 日)是星期一。

  7. 编写将系统默认时区中的java.util.Date转换为LocalDate的代码。

  8. 完成下面的代码片段,以便打印出"Friday January 12, 1968"。应该将日期 1968-01-12 格式化并打印:

    LocalDate bday = LocalDate.of(1968, Month.JANUARY, 12);
    String pattern = /* Your code goes here */;
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern);
           String formattedBDay = fmt.format(bday);
           System.out.println(formattedBDay);
    
    
  9. 完成以下代码片段,打印 1968 年 1 月 12 日到 1969 年 9 月 19 日之间的天数。它应该打印616 :

    LocalDate ld1 = LocalDate.of(1968, Month.JANUARY, 12);
    LocalDate ld2 = LocalDate.of(1969, Month.SEPTEMBER, 19);
    long daysBetween = /* Your code goes here */;
    System.out.println(daysBetween);
    
    
  10. 完成printFirstDayOfMonth()方法中的代码。该方法将一个LocalDate作为参数,并打印日期所在月份的第一天。假设这个方法传入的LocalDate是 2017-08-05;它将打印"First day of AUGUST, 2017 is on SATURDAY" :

```java
public static void printFirstDayOfMonth(LocalDate ld) {
    LocalDate newDate = ld.with(/* Your Code goes here */);
    System.out.printf("First day of %s, %d is on %s%n",
        ld.getMonth(), ld.getYear(), newDate.getDayOfWeek());
}

```

十七、格式化数据

在本章中,您将学习:

  • 如何格式化和解析日期和数字

  • 如何使用printf样式的格式

  • 如何创建使用自定义格式化程序的类

Java 提供了一组丰富的 API 来格式化数据。数据可能包括简单的值(如数字)或对象(如字符串、日期和其他类型的对象)。本章介绍了 Java 中不同类型值的格式化选项。本章中的所有示例程序都是清单 17-1 中声明的jdojo.format模块的成员。

// module-info.java
module jdojo.format {
    exports com.jdojo.format;
}

Listing 17-1The Declaration of a jdojo.format Module

格式化日期

日期时间 API 在第十六章中有所介绍。如果您正在编写与日期和时间相关的新代码,建议您使用日期-时间 API。但是,如果您需要使用使用旧方式格式化日期和时间的遗留代码,则提供本节内容。

在本节中,我们将讨论如何使用传统的日期 API 来格式化日期。我们还将讨论如何解析一个字符串来创建一个日期对象。您可以用预定义的格式或自己选择的格式来格式化日期。Java 库提供了两个类来格式化“java.text”包中的日期:

  • java.text.DateFormat

  • java.text.SimpleDateFormat

接下来的两节将向您展示如何以预定义和自定义的格式来格式化日期。

使用预定义的日期格式

使用DateFormat类使用预定义的格式来格式化日期。是一个abstract类。该类是抽象的,所以不能使用new操作符创建该类的实例。你可以调用它的一个getXxxInstance()方法,其中Xxx可以是DateDateTimeTime,来获取格式化程序对象,或者只是getInstance()。格式化的文本取决于两个因素:样式和区域设置。使用DateFormat类的format()方法来格式化日期和时间。格式化的样式决定了格式化文本中包含多少日期/时间信息,而区域设置决定了如何组合所有信息。DateFormat类将五种样式定义为常量:

  • DateFormat.DEFAULT

  • DateFormat.SHORT

  • DateFormat.MEDIUM

  • DateFormat.LONG

  • DateFormat.FULL

DEFAULT的格式与MEDIUM相同,除非你使用getInstance(),默认为SHORT。表 17-1 显示了美国地区相同日期的不同格式。

表 17-1

为区域设置(如美国)预定义的日期格式样式和格式化文本

|

风格

|

格式化日期示例

DEFAULT Mar 27, 2021
SHORT 3/27/21
MEDIUM Mar 27, 2021
LONG March 27, 2021
FULL Thursday, March 27, 2021

java.util.Locale类包含一些常见地区的常量。例如,对于语言为"fr"(法语)和国家代码为"FR"的地区,可以使用Locale.FRANCE。或者,你可以为法兰西创建一个Locale对象,如下所示:

Locale french FranceLocale = new Locale("fr", "FR") ;

要创建一个Locale,如果Locale类没有为那个国家声明一个常量,您需要使用一个两个字母的小写语言代码和一个两个字母的大写国家代码。语言代码和国家代码已在 ISO-639 代码和 ISO-3166 代码中列出。创建语言环境的更多示例如下:

Locale hindiIndiaLocale = new Locale("hi", "IN");
Locale bengaliIndiaLocale = new Locale("bn", "IN");
Locale thaiThailandLocale = new Locale("th", "TH");

Tip

使用Locale.getDefault()方法为您的系统获取默认的Locale

以下代码片段打印美国地区的长格式的当前日期:

Date today = new Date();
DateFormat formatter = DateFormat.getDateInstance(DateFormat.LONG, Locale.US);
String formattedDate = formatter.format(today);

System.out.println(formattedDate);
August 6, 2021

清单 17-2 中列出的程序默认以短格式和中格式显示地区日期(对于运行本例的 JVM 是 US)、法国和德国。程序打印当前日期。当你运行这个程序时,它将打印同一日期的不同格式。您可能会得到不同的输出,因为程序打印当前日期。

// PredefinedDateFormats.java
package com.jdojo.format;
import java.text.DateFormat;
import java.util.Date;
import java.util.Locale;
public class PredefinedDateFormats {
    public static void main(String[] args) {
        // Get the current date
        Date today = new Date();
        // Print date in the default locale format
        Locale defaultLocale = Locale.getDefault();
        printLocaleDetails(defaultLocale);
        printDate(defaultLocale, today);
        // Print date in French (France) format
        printLocaleDetails(Locale.FRANCE);
        printDate(Locale.FRANCE, today);
        // Print date in German (Germany) format. You could also use Locale.GERMANY
        // instead of new Locale ("de", "DE").
        Locale germanLocale = new Locale("de", "DE");
        printLocaleDetails(germanLocale);
        printDate(germanLocale, today);
    }
    public static void printLocaleDetails(Locale locale) {
        String languageCode = locale.getLanguage();
        String languageName = locale.getDisplayLanguage();
        String countryCode = locale.getCountry();
        String countryName = locale.getDisplayCountry();
        // Print the locale info
        System.out.println("Language: " + languageName + "("
                + languageCode + "); "
                + "Country: " + countryName
                + "(" + countryCode + ")");
    }
    public static void printDate(Locale locale, Date date) {
        // Format and print the date in SHORT style
        DateFormat formatter = DateFormat.getDateInstance(DateFormat.SHORT, locale);
        String formattedDate = formatter.format(date);
        System.out.println("SHORT: " + formattedDate);
        // Format and print the date in MEDIUM style
        formatter = DateFormat.getDateInstance(DateFormat.MEDIUM, locale);
        formattedDate = formatter.format(date);
        System.out.println("MEDIUM: " + formattedDate);
        // Print a blank line at the end
        System.out.println();
    }
}
Language: English(en); Country: United States(US)
SHORT: 1/24/21
MEDIUM: Jan 24, 2021
Language: French(fr); Country: France(FR)
SHORT: 24/01/21
MEDIUM: 24 janv. 2021
Language: German(de); Country: Germany(DE)
SHORT: 24.01.21
MEDIUM: 24.01.2021

Listing 17-2Using the Predefined Date Formats

使用自定义日期格式

如果你想使用定制的日期格式,使用SimpleDateFormat类。使用SimpleDateFormat类的格式是区分地区的。它的默认构造器使用默认区域设置和该区域设置的默认日期格式创建格式化程序。您可以使用其他构造器创建格式化程序,在这些构造器中您可以指定自己的日期格式和区域设置。一旦有了SimpleDateFormat类的对象,就可以调用它的format()方法来格式化日期。如果您想为后续的格式化更改日期格式,您可以使用applyPattern()方法,通过传递新的日期格式(或模式)作为参数。下面的代码片段向您展示了如何使用SimpleDateFormat类格式化日期:

// Create a formatter with a pattern dd/MM/yyyy.
SimpleDateFormat simpleFormatter = new SimpleDateFormat("dd/MM/yyyy");
// Get the current date
Date today = new Date();
// Format the current date
String formattedDate = simpleFormatter.format(today);
// Print the date
System.out.println("Today is (dd/MM/yyyy): " + formattedDate);
// Change the date format. Now month will be spelled fully.
simpleFormatter.applyPattern("MMMM dd, yyyy");
// Format the current date
formattedDate = simpleFormatter.format(today);
// Print the date
System.out.println("Today is (MMMM dd, yyyy): " + formattedDate);
Today is (dd/MM/yyyy): 06/08/2021
Today is (MMMM dd, yyyy): August 06, 2021

请注意,在您的计算机上运行这段代码时,输出会有所不同。它将使用默认的区域设置以这种格式打印当前日期。前面的输出是在美国地区。

表 17-2 中列出了用于创建日期和时间格式的字母及其含义。这些示例显示的日期是 2021 年 7 月 10 日下午 12:30:55。

表 17-2

用于格式化日期和时间的格式化符号列表

|

|

日期或时间组件

|

语句

|

例子

G 时代标志 文本 广告
y 2021; Twenty-one
Y 基于周的年份 2021; Twenty-one
M 一年中的月份 七月;七月;07
w 一年中的周 数字 Twenty-eight
W 月中的周 数字 Two
D 一年中的每一天 数字 One hundred and ninety-one
d 一个月中的第几天 数字 Ten
F 一个月中的星期几 数字 Two
E 一周中的某一天 文本 周六;坐
a AM/PM 标记 文本 首相
H 一天中的小时(0–23) 数字 Twelve
k 一天中的小时数(1–24) 数字 Twelve
K 上午/下午的小时数(0–11) 数字 Zero
h 上午/下午的小时数(1–12) 数字 Twelve
m 小时中的分钟 数字 Thirty
s 分钟秒 数字 Fifty-five
S 毫秒 数字 Nine hundred and seventy-eight
z 时区 通用时区 太平洋标准时间;PSTGMT-08:00
Z 时区 RFC 822 时区 –0800

您可以在格式化的日期中嵌入文字。假设您将自己的出生日期(1969 年 9 月 19 日)存储在一个 date 对象中,现在您想将其打印为“我出生于 1969 年 9 月 19 日”。消息中的一些部分来自出生日期,而其他部分是文字,它们旨在按原样出现在消息中。在日期模式中,不能将字母(如 A–Z 和 A–Z)用作文字。您需要将它们放在单引号内,将其视为文字,而不是格式模式的一部分。首先,您需要一个Date对象来表示 1969 年 9 月 19 日。Date类的构造器采用年、月和日,已被弃用。让我们从GregorianCalendar类开始,使用它的getTime()方法获得一个Date对象。以下代码片段打印了这条消息:

// Create a GregorianCalendar object with September 19, 1969 as date
GregorianCalendar gc = new GregorianCalendar(1969, Calendar.SEPTEMBER,19);
// Get a Date object
Date birthDate = gc.getTime();
// Create the pattern. You must place literals inside single quotes
String pattern = "'I was born on the day' dd 'of the month' MMMM 'in' yyyy";
// Create a SimpleDateFormat with the pattern
SimpleDateFormat simpleFormatter = new SimpleDateFormat(pattern);
// Format and print the date
System.out.println(simpleFormatter.format(birthDate));
I was born on the Day 19 of the month September in 1969

解析日期

在前面几节中,您已经将日期对象转换为格式化文本。让我们看看如何将文本转换成Date对象。这是通过使用SimpleDateFormat类的parse()方法来完成的。parse()方法的签名如下:

Date parse(String text, ParsePosition startPos)

该方法有两个参数。第一个参数是要从中提取日期的文本。第二个是文本中字符的起始位置,从这里开始解析。文本中可以嵌入日期部分。例如,您可以从文本中提取两个日期,如“第一个日期是 1995 年 1 月 1 日,第二个日期是 2001 年 12 月 12 日”。因为解析器不知道日期在文本中的开始位置,所以您需要使用ParsePosition对象告诉它。它只是跟踪解析位置。对于ParsePosition类只有一个构造器,它采用一个int,这是解析开始的位置。在parse()方法成功之后,ParsePosition对象的索引被设置为所用日期文本的最后一个字符的索引加 1。请注意,该方法不使用所有传递的文本作为其第一个参数。它只使用创建日期对象所需的文本。

让我们从一个简单的例子开始。假设您有一个字符串"09/19/1969",它代表日期 1969 年 9 月 19 日。你想从这个字符串中得到一个Date对象。以下代码片段说明了这些步骤:

// Our text to be parsed
String text = "09/19/1969";
// Create a pattern for the date text "09/19/1969"
String pattern = "MM/dd/yyyy";
// Create a SimpleDateFormat object to represent this pattern
SimpleDateFormat simpleFormatter = new SimpleDateFormat(pattern);
// Since the date part in text "09/19/1969" start at index zero,
// we create a ParsePosition object with value zero
ParsePosition startPos = new ParsePosition(0);
// Parse the text
Date parsedDate = simpleFormatter.parse(text, startPos);
// Here, parsedDate will have September 19, 1969 as date and startPos current index
// will be set to 10, which you can get calling startPos.getIndex() method.

让我们解析更复杂的文本。如果上一个例子中的文本是"09/19/1969 Junk",您将得到相同的结果,因为在读取 1969 之后,解析器将不再查看文本中的任何字符。假设你有文本"XX01/01/1999XX12/31/2000XX"。文本中嵌入了两个日期。如何解析这两个日期?第一个日期的文本从索引 2 开始(前两个 x 的索引为 0 和 1)。一旦对第一个日期文本的解析完成,ParsePosition对象将指向文本中的第三个 X。您只需要将它的索引增加 2,指向第二个日期文本的第一个字符。以下代码片段说明了这些步骤:

// Our text to be parsed
String text = "XX01/01/1999XX12/31/2000XX";
// Create a pattern for our date text "09/19/1969"
String pattern = "MM/dd/yyyy";
// Create a SimpleDateFormat object to represent this pattern
SimpleDateFormat simpleFormatter = new SimpleDateFormat(pattern);
// Set the start index at 2
ParsePosition startPos = new ParsePosition(2);
// Parse the text to get the first date (January 1, 1999)
Date firstDate = simpleFormatter.parse(text, startPos);
// Now, startPos has its index set after the last character of the first date parsed.
// To set its index to the next date increment its index by 2.
int currentIndex = startPos.getIndex();
startPos.setIndex(currentIndex + 2);
// Parse the text to get the second date (December 31, 2000)
Date secondDate = simpleFormatter.parse(text, startPos);

留给读者的练习是编写一个程序,从文本“我出生于 1969 年 9 月 19 日”中提取Date对象中的日期。提取的日期应该是 1969 年 9 月 19 日。(提示:在前面的一个示例中,当您处理格式化日期对象时,已经有了该文本的模式。)

这里还有一个解析包含日期和时间的文本的例子。假设您有文本"2003-04-03 09:10:40.325",它以年-月-日hour:minute:second.millisecond的格式表示时间戳。您想要获得时间戳的时间部分。清单 17-3 展示了如何从这个文本中获取时间部分。

// ParseTimeStamp.java
package com.jdojo.format;
import java.util.Date;
import java.util.Calendar;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
public class ParseTimeStamp {
    public static void main(String[] args){
        String input = "2003-04-03 09:10:40.325";
        // Prepare the pattern
        String pattern = "yyyy-MM-dd HH:mm:ss.SSS" ;
        SimpleDateFormat sdf = new SimpleDateFormat(pattern);
        // Parse the text into a Date object
        Date dt = sdf.parse(input, new ParsePosition(0));
        System.out.println(dt);
        // Get the Calendar instance
        Calendar cal = Calendar.getInstance();
        // Set the time
        cal.setTime(dt);
        // Print time parts
        System.out.println("Hour:" + cal.get(Calendar.HOUR));
        System.out.println("Minute:" + cal.get(Calendar.MINUTE));
        System.out.println("Second:" + cal.get(Calendar.SECOND));
        System.out.println("Millisecond:" + cal.get(Calendar.MILLISECOND));

    }
}
Thu Apr 03 09:10:40 CST 2003
Hour:9
Minute:10
Second:40
Millisecond:325

Listing 17-3Parsing a Timestamp to Get Its Time Parts

格式化数字

在本节中,我们将讨论如何格式化数字。我们还将讨论如何解析一个字符串来创建一个Number对象。以下两个类可用于格式化和解析数字:

  • java.text.NumberFormat

  • java.text.DecimalFormat

NumberFormat类用于将数字格式化为特定地区的预定义格式。DecimalFormat类用于在特定的地区将数字格式化为您选择的格式。

使用预定义的数字格式

您可以使用NumberFormat类的getXxxInstance()方法来获取格式化程序对象的实例,其中Xxx可以替换为NumberCurrencyIntegerPercent,或者只替换为getInstance()。这些方法是重载的。如果不带任何参数调用它们,它们将返回默认区域设置的格式化程序对象。调用format()方法,将数字作为参数传递,以获得字符串形式的格式化数字。下面的代码片段向您展示了如何为不同的地区获取不同类型的数字格式化程序。它还向您展示了如何使用针对美国地区的货币格式化程序来格式化薪水。请注意,它只进行格式化,不进行货币转换:

// Get a number formatter for default locale
NumberFormat defaultFormatter = NumberFormat.getNumberInstance();
// Get a number formatter for French (France) locale
NumberFormat frenchFormatter = NumberFormat.getNumberInstance(Locale.FRENCH);
// Get a currency formatter for US
NumberFormat usCurrencyFormatter = NumberFormat.getCurrencyInstance(Locale.US);
double salary = 12590.90;
String str = usCurrencyFormatter.format(salary);
System.out.println("Salary in US currency: " + str);
Salary in US currency: $12,590.90

清单 17-4 展示了如何将数字格式化为当前地区(本例中默认地区为美国)和印度地区的默认格式。

// DefaultNumberFormatters.java
package com.jdojo.format;
import java.util.Locale;
import java.text.NumberFormat;
public class DefaultNumberFormatters {
    public static void main(String[] args){
        double value = 1566789.785 ;
        // Default locale
        printFormatted(Locale.getDefault(), value);
        // Indian locale
        // (Rupee is the Indian currency. Short form is Rs.)
        Locale indianLocale = new Locale("en", "IN");
        printFormatted(indianLocale, value);
    }
    public static void printFormatted(Locale locale, double value) {
        // Get number and currency formatter
        NumberFormat nf = NumberFormat.getInstance(locale);
        NumberFormat cf = NumberFormat.getCurrencyInstance(locale);
        System.out.println("Formatting value: " + value + " for locale: " + locale);
        System.out.println("Number: "   + nf.format(value));
        System.out.println("Currency: " + cf.format(value));
    }
}
Formatting value: 1566789.785 for locale: en_US
Number: 1,566,789.785
Currency: $1,566,789.78
Formatting value: 1566789.785 for locale: en_IN
Number: 1,566,789.785
Currency: Rs. 1,566,789.78

Listing 17-4Formatting Numbers Using Default Formats

使用自定义数字格式

要执行更高级的格式化,可以使用DecimalFormat类。它允许您提供自己的格式模式。一旦创建了一个DecimalFormat类的对象,就可以使用它的applyPattern()方法来改变格式模式。您可以为正数和负数指定不同的模式。这两种模式由分号分隔。

在格式化数字时,DecimalFormat类使用四舍五入模式。例如,如果您在数字格式中只指定了小数点后两位,则 12.745 将被舍入到 12.74,因为 5 在中间,4 是偶数;12.735 也将被舍入到 12.74,因为 5 在中间,第二个位置上最接近的偶数将是 4;12.746 将四舍五入为 12.75。清单 17-5 展示了DecimalFormat类的用法。

// DecimalFormatter.java
package com.jdojo.format;
import java.text.DecimalFormat;
public class DecimalFormatter {
    private static DecimalFormat formatter = new DecimalFormat();
    public static void main(String[] args) {
        formatNumber("##.##", 12.745);
        formatNumber("##.##", 12.746);
        formatNumber("0000.0000", 12.735);
        formatNumber("#.##", -12.735);
        // Positive and negative number format
        formatNumber("#.##;(#.##)", 12.735);
        formatNumber("#.##;(#.##)", -12.735);
    }
    public static void formatNumber(String pattern, double value) {
        // Apply the pattern
        formatter.applyPattern(pattern);
        // Format the number
        String formattedNumber = formatter.format(value);
        System.out.println("Number: " + value + ", Pattern: "
                + pattern + ", Formatted Number: "
                + formattedNumber);
    }
}
Number: 12.745, Pattern: ##.##, Formatted Number: 12.74
Number: 12.746, Pattern: ##.##, Formatted Number: 12.75
Number: 12.735, Pattern: 0000.0000, Formatted Number: 0012.7350
Number: -12.735, Pattern: #.##, Formatted Number: -12.73
Number: 12.735, Pattern: #.##;(#.##), Formatted Number: 12.73
Number: -12.735, Pattern: #.##;(#.##), Formatted Number: (12.73)

Listing 17-5Formatting Numbers

解析数字

您还可以使用DecimalFormat类的parse()方法将字符串解析为数字。parse()方法返回一个java.lang.Number类的对象。您可以使用xyzValue()方法获取原始值,其中xyz可以是bytedoublefloatintlongshort

清单 17-6 展示了使用DecimalFormat类来解析一个数字。注意,您也可以使用java.lang.Double类的parseDouble()方法将字符串解析为double值。但是,该字符串必须采用默认的数字格式。使用DecimalFormat类的parse()方法的优点是字符串可以是任何格式。

// ParseNumber.java
package com.jdojo.format;
import java.text.DecimalFormat;
import java.text.ParsePosition;
public class ParseNumber {
    public static void main(String[] args) {
        // Parse a string to decimal number
        String str = "XY4,123.983";
        String pattern = "#,###.###";
        DecimalFormat formatter = new DecimalFormat(pattern);
        // Create a ParsePosition object to specify the first digit of number
        // in the string. It is 4 in "XY4,123.983" with the index 2.
        ParsePosition pos = new ParsePosition(2);
        Number numberObject = formatter.parse(str, pos);
        double value = numberObject.doubleValue();
        System.out.println("Parsed Value is " + value);
    }
}
Parsed Value is 4123.983

Listing 17-6Parsing Numbers

printf 样式的格式

在这一节中,我们将讨论如何使用类似于 c 语言中的printf()函数所支持的printf样式的格式化来格式化对象和值。首先,我们将介绍 Java 中的printf样式格式化支持的一般思想,然后将介绍格式化所有类型的值的细节。

大局

java.util.Formatter类支持printf风格的格式,类似于 C 编程语言中的printf()函数所支持的格式。如果您熟悉 C、C++和 C#,您应该更容易理解本节中的讨论。在本节中,您将使用格式化字符串,如"%1$s""%1$4d"等。在你的代码中没有完整的解释它们的意思。你可能无法完全理解它们;你现在应该忽略它们。只需关注输出,并尝试了解Formatter类想要完成的更大的画面,而不是试图理解细节。我们将在下一节讨论细节。让我们从清单 17-7 中的一个简单例子开始。您可能会得到稍微不同的输出。

// PrintfTest.java
package com.jdojo.format;
import java.util.Date;
public class PrintfTest {
    public static void main(String[] args) {
        // Formatting strings
        System.out.printf("%1$s, %2$s, and %3$s %n", "Fu", "Hu", "Lo");
        System.out.printf("%3$s, %2$s, and %1$s %n", "Fu", "Hu", "Lo");
        // Formatting numbers
        System.out.printf("%1$4d, %2$4d, %3$4d %n", 1, 10, 100);
        System.out.printf("%1$4d, %2$4d, %3$4d %n", 10, 100, 1000);
        System.out.printf("%1$-4d, %2$-4d, %3$-4d %n", 1, 10, 100);
        System.out.printf("%1$-4d, %2$-4d, %3$-4d %n", 10, 100, 1000);
        // Formatting date and time
        Date dt = new Date();
        System.out.printf("Today is %tD %n", dt);
        System.out.printf("Today is %tF %n", dt);
        System.out.printf("Today is %tc %n", dt);
    }
}
Fu, Hu, and Lo
Lo, Hu, and Fu
1,  10,  100
10, 100, 1000
1,  10,  100
10, 100, 1000
Today is 08/06/21
Today is 2021-08-06
Today is Sun Aug 06 10:29:03 CDT 2021

Listing 17-7Using C’s printf-Style Formatting in Java

您一直在使用System.out.println()System.out.print()方法在标准输出上打印文本。实际上,System.outjava.io.PrintStream类的一个实例,它有println()print()实例方法。PrintStream类包含另外两个方法,format()printf(),它们可以用来将格式化的输出写到PrintStream实例中。这两种方法工作原理相同。清单 17-5 使用System.out.printf()方法将格式化文本打印到标准输出。

String类包含一个format()静态方法,它返回一个格式化的字符串。PrintStream类的format() / printf()方法和String类的format()静态方法的格式化行为是相同的。它们之间唯一的区别是PrintStream类中的format()printf()方法将格式化的输出写入输出流,而String类的format()方法将格式化的输出作为String返回。

PrintStream类的format()printf()方法和String类的format()方法是方便的方法。它们的存在是为了简化文本格式。然而,Formatter类完成了真正的工作。下面详细讨论一下Formatter类。您将在示例中使用这些方便的方法。一个Formatter用于格式化文本。格式化的文本可以写入以下目的地:

  • 可追加的(例如,StringBuffer、StringBuilder、Writer 等。)

  • 一个文件

  • 输出流

  • 打印流

下面的代码片段完成了与清单 17-7 中的代码相同的事情。这一次,您使用一个Formatter对象来格式化数据。当您调用Formatter对象的format()方法时,格式化的文本存储在StringBuilder对象中,您将它传递给Formatter对象的构造器。当您完成所有文本的格式化后,您调用StringBuildertoString()方法来获得整个格式化的文本:

// Create an Appendable data storage for our formatted output
StringBuilder sb = new StringBuilder();
// Create a Formatter that will store its output to the StringBuffer
Formatter fm = new Formatter(sb);
// Formatting strings
fm.format("%1$s, %2$s, and %3$s %n", "Fu", "Hu", "Lo");
fm.format("%3$s, %2$s, and %1$s %n", "Fu", "Hu", "Lo");
// Formatting numbers
fm.format("%1$4d, %2$4d, %3$4d %n", 1, 10, 100);
fm.format("%1$4d, %2$4d, %3$4d %n", 10, 100, 1000);
fm.format("%1$-4d, %2$-4d, %3$-4d %n", 1, 10, 100);
fm.format("%1$-4d, %2$-4d, %3$-4d %n", 10, 100, 1000);
// Formatting date and time
Date dt = new Date();
fm.format("Today is %tD %n", dt);
fm.format("Today is %tF %n", dt);
fm.format("Today is %tc %n", dt);
// Display the entire formatted string
System.out.println(sb.toString());

如果您想将所有格式化的文本写入一个文件,可以使用下面的代码片段。您将需要处理FileNotFoundException,如果指定的文件不存在,它可能会从Formatter类的构造器中抛出。当您使用完Formatter对象后,您将需要调用它的close()方法来关闭输出文件。注意示例代码中使用了一个try-with-resources块,所以格式化程序是自动关闭的:

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Formatter;
...
// Create a Formatter that will write the output to the file C:\kishori\xyz.txt
try (Formatter fm = new Formatter(new File("C:\\kishori\\xyz.txt"))) {
    // Formatting strings
    fm.format("%1$s, %2$s, and %3$s %n", "Fu", "Hu", "Lo");
    fm.format("%3$s, %2$s, and %1$s %n", "Fu", "Hu", "Lo");
} catch (FileNotFoundException e) {
    e.printStackTrace();
}

Formatter类的format()方法被重载。其声明如下:

  • Formatter format(String format, Object... args)

  • Formatter format(Locale l, String format, Object... args)

第一个版本的format()方法使用默认的语言环境进行格式化。第二个版本允许您指定一个地区。PrintStream类的format() / printf()方法和String类的format()方法提供了相同的两个版本的format()方法,它们接受相同类型的参数。对Formatter类的format()方法的讨论同样适用于PrintStreamString类中的这些便利方法。

只要适用,Formatter类就使用特定于地区的格式。例如,如果您想要格式化一个十进制数,比如 12.89,在法国该数被格式化为 12,89(注意 12 和 89 之间的逗号),而在美国它被格式化为 12.89(注意 12 和 89 之间的点)。format()方法的 locale 参数用于将文本格式化为特定于语言环境的格式。下面的代码片段演示了特定于区域设置的格式的效果。请注意,对于相同的输入值,美国和法国的格式化输出有所不同:

System.out.printf(Locale.US, "In US: %1$.2f %n", 12.89);
System.out.printf(Locale.FRANCE, "In France: %1$.2f %n", 12.89);
Date dt = new Date();
System.out.printf(Locale.US, "In US: %tA %n", dt);
System.out.printf(Locale.FRANCE, "In France: %tA %n", dt);
In US: 12.89
In France: 12,89
In US: Friday
In France: vendredi

细节

使用Formatter格式化数据需要两种输入:

  • 格式字符串

  • 值的列表

格式字符串是定义输出外观的模板。它包含零个或多个固定文本和零个或多个嵌入的格式说明符。固定文本不会应用任何格式。格式说明符有两个用途。它在格式字符串中充当格式化数据的占位符,并指定应该如何格式化数据。

让我们考虑下面的例子。假设您想打印一个人的出生日期。以下是此类文本的一个示例:

January 16, 1970 is John's birthday.

Note

除非另有说明,本节中的所有输出均为美国语言环境。

以前的文本包含固定文本和格式化文本。固定文本应该出现在输出中。格式化的文本将取决于输入。您可以将之前的文本转换为模板,如下所示:

<month> <day>, <year> is <name>'s birthday.

您已经用尖括号中的占位符替换了可能不同的文本,例如,<month><day>等。您将需要四个输入值(月、日、年和名称)来使用前面的模板获得格式化的文本。例如,如果您将<month><day><year><name>的值分别提供为"January""16""1970""John",模板将生成

January 16, 1970 is John's birthday.

在本例中,您已经用实际值替换了模板中的占位符。您没有对实际值进行任何格式化。由Formatter类提供的格式以类似的方式工作。我们在这个例子中称之为占位符的叫做格式说明符。我们在这个例子中称之为模板的东西叫做格式字符串

格式说明符总是以百分号(%)开始。您可以将您的模板转换成一个格式字符串,它可以与Formatter类一起使用,如下所示:

%1$tB %1$td, %1$tY is %2$s's birthday.

在这个格式字符串中,“%1$tB""%1$td""%1$tY"%2$s"是四个格式说明符,而" "", ""is "'s birthday."是固定文本。

下面的代码片段使用这个格式字符串打印格式化文本。注意dob"John"是格式字符串的输入值。在这种情况下,输入值dob是包含出生日期的LocalDate类的一个实例:

LocalDate dob = LocalDate.of(1970, Month.JANUARY, 16);
System.out.printf("%1$tB %1$td, %1$tY is %2$s's birthday.", dob, "John");
January 16, 1970 is John's birthday.

格式说明符的一般语法如下:

%<argument-index$><flags><width><.precision><conversion>

除了%<conversion>部分,其他部分都是可选的。请注意,格式说明符的任何两个部分之间都没有空格。%(百分号)表示格式字符串中格式说明符的开始。如果要将%指定为格式字符串中固定文本的一部分,需要使用两个连续的%作为%%

<argument-index$>表示格式说明符引用的参数的索引。它由一个十进制格式的整数后跟一个$(美元符号)组成。第一个参数称为1$,第二个称为2$,依此类推。您可以在同一格式字符串内的不同格式说明符中多次引用同一个参数。

<flags>表示输出的格式。它是一组字符。<flags>的有效值取决于格式说明符引用的参数的数据类型。

<width >表示需要写入输出的最小字符数。

通常,<.precision >表示要写入输出的最大字符数。然而,它的确切含义因<conversion>的值而异。这是一个十进制数。它以一个点开始(.)。

<conversion>表示输出应该如何格式化。它的值取决于格式说明符引用的参数的数据类型。这是强制性的。

有两个特殊的格式说明符:%%%n%%格式说明符输出%(一个百分号),而%n输出一个特定于平台的换行符。下面的代码片段演示了这两个特殊格式说明符的用法:

System.out.printf("Interest rate is 10%%.%nJohn%nDonna");
Interest rate is 10%.
John
Donna

您没有为代码中的printf()方法提供任何参数,因为这两个特殊的格式说明符对任何参数都不起作用。注意输出中的两行新行是由格式字符串中的两个%n格式说明符生成的。

引用格式说明符中的参数

我们还没有讨论格式说明符的转换部分。对于本节的讨论,我们使用s作为格式说明符的转换字符。s转换将其参数格式化为字符串。最简单的形式是,您可以使用%s作为格式说明符。让我们考虑以下代码片段及其输出:

System.out.printf("%s, %s, and %s", "Ken", "Lola", "Matt");
Ken, Lola, and Matt

格式字符串中的格式说明符可以通过三种方式引用参数:

  • 普通索引

  • 显式索引

  • 相对索引

普通索引

当一个格式说明符没有指定一个参数索引值时(如在%s中),它被称为普通索引。在普通索引中,参数索引由格式字符串中格式说明符的索引确定。第一个没有参数索引的格式说明符的索引为 1,第二个格式说明符的索引为 2,依此类推。索引为 1 的格式说明符引用第一个参数;索引为 2 的格式说明符引用第二个参数;等等。图 17-1 显示了格式说明符和参数的索引。

img/323069_3_En_17_Fig1_HTML.png

图 17-1

格式字符串中格式说明符的索引和参数的索引

图 17-1 显示了在前面的例子中索引是如何映射的。指定的第一个%s格式是指第一个参数"Ken"。指定的第二个%s格式是指第二个参数"Lola"。而指定的第三个%s格式指的是第三个自变量"Matt"

如果参数的数量大于格式字符串中格式说明符的数量,多余的参数将被忽略。考虑下面的代码片段及其输出。它有三个格式说明符(三个%s)和四个参数。第四个参数"Lo"是一个额外的参数,被忽略:

System.out.printf("%s, %s, and %s", "Ken", "Lola", "Matt", "Lo");
Ken, Lola, and Matt

如果格式说明符引用了一个不存在的参数,就会抛出java.util.MissingFormatArgumentException。以下代码片段将引发此异常,因为参数的数量比格式说明符的数量少一个。有三个格式说明符,但只有两个参数:

// Compiles fine, but throws a runtime exception
System.out.printf("%s, %s, and %s", "Ken", "Lola");

注意,Formatter类的format()方法的最后一个参数是 var-args 参数。还可以将数组传递给 var-args 参数。下面的代码片段是有效的,尽管它使用了三个格式说明符和一个数组类型的参数。数组类型参数包含三个格式说明符的三个值:

String[] names = {"Ken", "Matt", "Lola"};
System.out.printf("%s, %s, and %s", names);
Ken, Matt, and Lola

以下代码片段也是有效的,因为它在数组类型参数中传递了四个值,但只有三个格式说明符:

String[] names = {"Ken", "Matt", "Lola", "Lo"};
System.out.printf("%s, %s, and %s", names);
Ken, Matt, and Lola

以下代码片段无效,因为它使用了只有两个元素和三个格式说明符的数组类型参数。当运行以下代码片段时,将抛出一个MissingFormatArgumentException:

String[] names = {"Ken", "Matt"};
System.out.printf("%s, %s, and %s", names); // Throws an exception

显式索引

当格式说明符显式指定参数索引时,称为显式索引。请注意,参数索引是在格式说明符中的%符号之后指定的。是十进制格式的整数,以$(美元符号)结尾。考虑下面的代码片段及其输出。它使用三种格式说明符,%1$s%2$s%3$s,这些说明符使用显式索引:

System.out.printf("%1$s, %2$s, and %3$s", "Ken", "Lola", "Matt");
Ken, Lola, and Matt

当格式说明符使用显式索引时,它可以使用参数的索引来引用参数列表中任何索引处的参数。考虑以下代码片段:

System.out.printf("%3$s, %1$s, and %2$s", "Lola", "Matt", "Ken");
Ken, Lola, and Matt

这段代码与之前的代码具有相同的输出。但是,在这种情况下,参数列表中的值的顺序不同。第一个格式说明符%3$s,引用第三个参数"Ken";第二个格式说明符%1$s,引用第一个参数"Lola";第三个格式说明符%2$s引用第二个参数"Matt"

允许使用显式索引多次引用同一个参数。也允许不引用格式字符串中的某些参数。在下面的代码片段中,"Lola"的第一个参数没有被引用,而"Ken"的第三个参数被引用了两次:

System.out.printf("%3$s, %2$s, and %3$s", "Lola", "Matt", "Ken");
Ken, Matt, and Ken

相对索引

还有第三种方法引用格式说明符中的参数,这种方法称为相对索引。在相对索引中,格式说明符使用与前一个格式说明符相同的参数。相对索引不使用参数索引值。相反,它使用<字符作为格式说明符中的标志。因为在相对索引中,格式说明符使用与前一个格式说明符相同的参数,所以它不能与第一个格式说明符一起使用,因为第一个格式说明符没有前一个格式说明符。考虑以下代码片段及其输出,它使用相对索引:

System.out.printf("%1$s, %<s, %<s, %2$s, and %<s", "Ken", "Matt");
Ken, Ken, Ken, Matt, and Matt

这段代码使用了五种格式说明符:%1$s%<s%<s%2$s%<s。它使用了两个参数:"Ken""Matt"。请注意,如果某些格式说明符使用相对索引,参数的数量可能会少于格式说明符的数量。%1$s的第一个格式说明符使用显式索引来引用第一个参数"Ken"%<s的第二个格式说明符使用相对索引(注意<标志);因此,它将使用与前面的格式说明符1$s相同的参数。这样,第一个和第二个格式说明符都使用第一个参数"Ken"。这一点通过将"Ken"显示为前两个名称的输出得到了证实。%<s的第三个格式说明符也使用相对索引。它将使用与前一个格式说明符(第二个格式说明符)相同的参数。因为第二个格式说明符使用了第一个参数"Ken",所以第三个也将使用相同的参数。这在将"Ken"显示为第三个名称的输出中得到确认。第四个%2$s格式说明符使用显式索引来使用"Matt"的第二个参数。%<s的第五个也是最后一个格式说明符使用相对索引,它将使用与其前一个格式说明符(第四个格式说明符)相同的参数。由于第四个格式说明符使用第二个参数"Matt",第五个格式说明符也将使用第二个参数"Matt"。这在将"Matt"显示为第五个名称的输出中得到确认。

以下语句将抛出一个MissingFormatArgumentException,因为它对第一个格式说明符使用了相对索引:

System.out.printf("%<s, %<s, %<s, %2$s, and %<s", "Ken", "Matt");

可以混合所有三种类型的索引来引用同一格式字符串中不同格式说明符内的参数。考虑以下语句及其输出:

System.out.printf("%1$s, %s, %<s, %s, and %<s", "Ken", "Matt");
Ken, Ken, Ken, Matt, and Matt

第一个格式说明符使用显式索引来使用第一个参数"Ken"。第二个和第四个格式说明符(都是%s)使用普通索引。第三和第五个格式说明符(都是%<s)使用相对索引。从相对索引规则中可以清楚地看出,第三和第五个格式说明符将分别使用与第二和第四个格式说明符相同的参数。第二个和第四个格式说明符将使用哪些参数?答案很简单。当您有一些使用普通索引和一些显式索引的格式说明符时,只是为了理解这个规则,忽略使用显式索引的格式说明符,并将使用普通索引的格式说明符编号为 1、2 等等。使用此规则,您可以将前面的语句视为与下面的语句相同:

System.out.printf("%1$s, %1$s, %<s, %2$s, and %<s", "Ken", "Matt");

请注意,您已经用%1$s替换了第一次出现的%s,用%2$s替换了第二次出现的%s,就好像它们使用了显式索引一样。这解释了前面语句生成的输出。

在格式说明符中使用标志

标志充当修饰符。他们修改格式化的输出。表 17-3 列出了可用于格式说明符的所有标志。

表 17-3

有效标志、它们的描述和用法示例的列表

|

|

描述

|

例子

格式字符串 自变量 格式化文本
- 结果是左对齐的。请注意,当您没有在格式说明符中使用-标志时,结果是右对齐的。 "'%6s'" "Ken" '   Ken'
"'%-6s'" "Ken" 'Ken   '
# 根据格式说明符的转换部分,参数被格式化为替代形式。该示例显示了同一个十进制数 6270185 被格式化为十六进制格式。当使用#标志时,十六进制数以 0x 为前缀。 "%x" 6270185 5face9
"%#x" 6270185 0x5face9
+ 结果包含一个代表正值的+符号。它仅适用于数值。 "%d" 105 105
"%+d" 105 +105
' ' 结果包含正值的前导空格。它仅适用于数值。 "'%d'" 105 '105'
"'% d'" 105 ' 105'
0 结果是零填充。它仅适用于数值。 "'%6d'" 105 '  105'
"'%06d'" 105 '000105'
, 结果包含特定于区域设置的分组分隔符。它仅适用于数值。例如,在美国地区,逗号被用作千位分隔符,而在法国地区,则使用空格。 "%,d" 89105 89,105``(US Locale)
"%,d" 89105 89 105``(France locale)
( 负数的结果用括号括起来。它仅适用于数值。 "%d" -1969 -1969
"%(d" -1969 (1969)
< 它导致先前格式说明符的参数被重用。它主要用于格式化日期和时间。 "%s and %<s" "Ken" Ken and Ken

标志的有效使用取决于其使用的上下文。根据被格式化的值,允许在一个格式说明符中使用多个标志。例如,格式说明符%1$,0(12d使用三个标志:,0(。如果-122899被这个格式说明符用作参数,它将输出(000122,899)。当我们在接下来的小节中讨论不同数据类型的格式时,将详细讨论使用每个标志的效果。

转换字符

不同的转换字符用于格式化不同数据类型的值。例如,s用于将值格式化为字符串。格式说明符中其他部分的有效值也由格式说明符引用的转换字符和参数的数据类型决定。基于数据类型的格式化类型可以大致分为四类:

  • 常规格式

  • 字符格式

  • 数字格式

  • 日期/时间格式

许多转换字符都有大写变体。比如S就是s的大写变体。大写变体将格式化输出转换为大写,就像调用了output.toUpperCase()方法一样,其中output是对格式化输出字符串的引用。以下语句及其输出演示了使用大写变体S的效果。注意,对于相同的输入值"Ken"s产生"Ken"S产生"KEN":

System.out.printf("%s and %<S", "Ken");
Ken and KEN

常规格式

常规格式可用于格式化任何数据类型的值。表 17-4 列出了通用格式类别下可用的转换。

表 17-4

常规格式的转换字符列表

|

转换

|

大写字母

不同的

|

描述

b B 它根据参数的值产生truefalse。它为一个null参数和一个值为假的布尔参数产生false。否则,就会产生true
h H 它生成一个字符串,该字符串是参数的十六进制格式的哈希代码值。如果自变量为null,则产生"null"
s S 它产生参数的字符串表示。如果参数是null,它产生一个"null"字符串。如果参数实现了Formattable接口,它就调用参数上的formatTo()方法,返回值就是结果。如果参数没有实现Formattable接口,那么将对参数调用toString()方法来获得结果。

通用格式的格式说明符的通用语法如下:

%<argument_index$><flags><width><.precision><conversion>

宽度表示要写入输出的最小字符数。如果参数的字符串表示形式的长度小于宽度值,结果将用空格填充。空格填充在参数值的左侧执行。如果使用了-标志,则向右执行空格填充。宽度值本身并不能决定结果的内容。宽度和精度的值共同决定了结果的最终内容。

精度表示要写入输出的最大字符数。在应用宽度之前,先将精度应用于参数。您需要理解在宽度之前应用精度的后果。如果精度小于参数的长度,参数将被截断到精度,并执行空格填充以使输出的长度与宽度值匹配。考虑以下代码片段:

System.out.printf("'%4.1s'", "Ken");
'   K'

参数是"Ken",,格式说明符是%4.1s,其中4是宽度,1是精度。首先,应用将值"Ken"截断为K的精度。现在,应用了宽度,这表明至少应该向输出中写入四个字符。但是,应用精度后,您只剩下一个字符。因此,K将用三个空格填充,以匹配宽度值 4。

考虑以下代码片段:

System.out.printf("'%1.4s'", "Ken");
'Ken'

参数值是"Ken",,格式说明符是%1.4s,其中1是宽度,4是精度。因为精度值 4 大于参数长度 3,所以精度没有影响。因为宽度值 1 小于应用精度后结果的宽度,所以宽度值对输出没有影响。

以下是使用布尔、字符串和哈希代码格式转换的几个示例。请注意,哈希代码格式转换(hH)以十六进制格式输出参数的哈希代码值。这些示例还演示了使用大写转换变量的效果:

// Boolean conversion
System.out.printf("'%b', '%5b', '%.3b'%n", true, false, true);
System.out.printf("'%b', '%5b', '%.3b'%n", "Ken", "Matt", "Lola");
System.out.printf("'%B', '%5B', '%.3B'%n", "Ken", "Matt", "Lola");
System.out.printf("%b %n", 1969);
System.out.printf("%b %n", new Object());
'true', 'false', 'tru'
'true', ' true', 'tru'
'TRUE', ' TRUE', 'TRU'
true
true
// String conversion
System.out.printf("'%s', '%5s', '%.3s'%n", "Ken", "Matt", "Lola");
System.out.printf("'%S', '%5S', '%.3S'%n", "Ken", "Matt", "Lola");
// Use '-' flag to left-justify the result. You must use width when you specify the '-' flag
System.out.printf("'%S', '%-5S', '%.3S'%n", "Ken", "Matt", "Lola");
System.out.printf("%s %n", 1969);
System.out.printf("%s %n", true);
System.out.printf("%s %n", new Object());
'Ken', ' Matt', 'Lol'
'KEN', ' MATT', 'LOL'
'KEN', 'MATT ', 'LOL'
1969
true
java.lang.Object@de6f34
// Hash Code conversion
System.out.printf("'%h', '%5h', '%.3h'%n", "Ken", "Matt", "Lola");
System.out.printf("'%H', '%5H', '%.3H'%n", "Ken", "Matt", "Lola");
System.out.printf("%h %n", 1969);
System.out.printf("%h %n", true);
System.out.printf("%h %n", new Object());
'12634', '247b34', '243'
'12634', '247B34', '243'
7b1
4cf
156ee8e

如果您将一个原始类型的值作为参数传递给Formatter类的format()方法(或PrintStream类的printf()方法),原始类型的值将使用自动装箱规则,使用适当类型的包装类转换为引用类型。例如,这种说法

System.out.println("%s", 1969);

被转换为

System.out.println("%s", new Integer(1969));

编写自定义格式化程序

Formatter类通过sS转换支持自定义格式。如果参数实现了java.util.Formattable接口,那么s转换会对参数调用formatTo()方法来获得格式化的结果。向formatTo()方法传递格式说明符中使用的Formatter对象、标志、宽度和精度值的引用。您可以在类的formatTo()方法中应用任何自定义逻辑来格式化您的类的对象。清单 17-8 包含了一个FormattablePerson类的代码,它实现了Formattable接口。

// FormattablePerson.java
package com.jdojo.format;
import java.util.Formattable;
import java.util.Formatter;
import java.util.FormattableFlags;
public class FormattablePerson implements Formattable {
    private String firstName = "Unknown";
    private String lastName = "Unknown";
    public FormattablePerson(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    /* Other code goes here... */
    @Override
    public void formatTo(Formatter formatter, int flags, int width, int precision) {
        String str = this.firstName + " " + this.lastName;
        int alternateFlagValue = FormattableFlags.ALTERNATE & flags;
        if (alternateFlagValue == FormattableFlags.ALTERNATE) {
            str = this.lastName + ", " + this.firstName;
        }
        // Check if uppercase variant of the conversion is being used
        int upperFlagValue = FormattableFlags.UPPERCASE & flags;
        if (upperFlagValue == FormattableFlags.UPPERCASE) {
            str = str.toUpperCase();
        }
        // Call the format() method of formatter argument,
        // so our result is stored in it and the caller will get it
        formatter.format(str);
    }
}

Listing 17-8Implementing a Custom Formatter Using the Formattable Interface

你的Formattable人有名字和姓氏。formatTo()方法内部的逻辑有意保持简单。你检查一下备用旗#。如果在格式说明符中使用了该标志,您可以将人名格式化为LastName, FirstName格式。如果没有使用替代标志,您可以将人名格式化为FirstName LastName格式。你也支持大写变体Ss的转换。如果使用了S转换,您将人名格式化为大写。您的逻辑不使用标志、宽度和精度的其他值。标志作为位掩码的int值传入。要检查是否传递了一个标志,您需要使用按位&操作符。按位&运算符中使用的操作数由java.util.FormattableFlags类中的常量定义。例如,要检查格式说明符是否使用左对齐-标志,您需要使用以下逻辑:

int leftJustifiedFlagValue = FormattableFlags.LEFT_JUSTIFY & flags;
if (leftJustifiedFlagValue == FormattableFlags.LEFT_JUSTIFY) {
    // Left-justified flag '-' is used
} else {
    // Left-justified flag '-' is not used
}

您可以使用字符串转换sS将您的FormattablePerson对象与格式说明符一起使用,如下所示:

FormattablePerson fp = new FormattablePerson("Ken", "Smith");
System.out.printf("%s %n", fp );
System.out.printf("%#s %n", fp );
System.out.printf("%S %n", fp );
System.out.printf("%#S %n", fp );
Ken Smith
Smith, Ken
KEN SMITH
SMITH, KEN

字符格式

字符格式可应用于char原始数据类型或Character对象的值。如果byteByteshortShortintInteger类型的值是有效的 Unicode 码位,也可以应用于这些值。您可以通过使用Character类的isValidCodePoint(int value)静态方法来测试一个整数值是否表示一个有效的 Unicode 码位。

字符格式化的转换字符为c。它的大写变体是C。字符格式不支持标志#和精度。标志-width在通用格式的上下文中具有相同的含义。以下代码片段演示了字符格式的使用:

System.out.printf("%c %n", 'a');
System.out.printf("%C %n", 'a');
System.out.printf("%C %n", 98);
System.out.printf("'%5C' %n", 100);
System.out.printf("'%-5C' %n", 100);
a
A
B
'    D'
'D    '

数字格式

数字格式可以大致分为两类:

  • 整数格式

  • 浮点数格式

格式化数值时,会自动应用许多特定于区域设置的格式。例如,用于数字格式的数字总是特定于区域设置的。如果带格式的数字包含小数分隔符或组分隔符,它们总是分别被替换为特定于区域设置的小数分隔符或组分隔符。以下代码片段显示了相同的数字1234567890在美国、印度和泰国这三个不同地区的不同格式:

Locale englishUS = new Locale ("en", "US");
Locale hindiIndia = new Locale ("hi", "IN");
Locale thaiThailand = new Locale ("th", "TH", "TH");
System.out.printf(englishUS, "%d %n", 1234567890);
System.out.printf(hindiIndia, "%d %n", 1234567890);
System.out.printf(thaiThailand, "%d %n", 1234567890);

img/323069_3_En_17_Figa_HTML.png

整数格式

整数格式化处理格式化整数。可以应用于byteByteshortShortintIntegerlongLongBigInteger的格式值。表 17-5 包含整数格式类别下可用的转换列表。

表 17-5

适用于 byte、Byte、short、Short、int、Integer、long、Long 和 BigInteger 数据类型的转换列表

|

转换

|

大写字母

不同的

|

描述

d 它将参数格式化为特定于区域设置的十进制整数(基数为 10)。此转换不能使用#标志。
o 它将参数格式化为基数为 8 的整数,没有任何本地化。如果此转换使用了#标志,输出总是以0(零)开始。(、+, ' ',,标志不能用于此转换。
x X 它将参数格式化为基数为 16 的整数,没有任何本地化。如果此转换使用了#标志,则输出总是以0x开始。当大写变量 X 与#标志一起使用时,输出总是以0X开始。(, +, ' ',,标志不能用于带有 byte、ByteshortShortintIntegerlongLong数据类型参数的转换。,标志不能与带有数据类型为BigInteger的参数的此转换一起使用。

整数格式的格式说明符的一般语法如下:

%<argument_index$><flags><width><conversion>

请注意,格式说明符中的精度部分不适用于整数格式。以下代码片段演示了使用带有各种标志的d转换来格式化整数:

System.out.printf("'%d' %n", 1969);
System.out.printf("'%6d' %n", 1969);
System.out.printf("'%-6d' %n", 1969);
System.out.printf("'%06d' %n", 1969);
System.out.printf("'%(d' %n", 1969);
System.out.printf("'%(d' %n", -1969);
System.out.printf("'% d' %n", 1969);
System.out.printf("'% d' %n", -1969);
System.out.printf("'%+d' %n", 1969);
System.out.printf("'%+d' %n", -1969);
'1969'
'  1969'
'1969  '
'001969'
'1969'
'(1969)'
' 1969'
'-1969'
'+1969'
'-1969'

当转换ox与数据类型为byteByteshort, ShortintIntegerlongLong的负参数一起使用时,参数值首先通过添加数字2N转换为无符号数,其中N是用于表示参数的数据类型值的位数。例如,如果参数数据类型是byte,它需要 8 位来存储值,则–X的参数值将通过向其添加 256 来转换为正的值–X + 256。结果包含值–X + 256的基数为 8 或基数为 16 的等效值。转换ox不会将负参数值转换为BigInteger参数类型的无符号值。考虑以下代码片段和输出:

byte b1 = 9;
byte b2 = -9;
System.out.printf("%o %n", b1);
System.out.printf("%o %n", b2);
11
367

转换o将 8 进制整数 11 输出为正的十进制整数 9。然而,当负十进制整数–9 用于o转换时,–9 被转换为正数-9 + 256 ( =247)。最终输出包含367,它是十进制247的八进制等效值。

下面的代码片段展示了关于intBigInteger参数类型的ox转换的更多示例:

System.out.printf("%o %n", 1969);
System.out.printf("%o %n", -1969);
System.out.printf("%o %n", new BigInteger("1969"));
System.out.printf("%o %n", new BigInteger("-1969"));
System.out.printf("%x %n", 1969);
System.out.printf("%x %n", -1969);
System.out.printf("%x %n", new BigInteger("1969"));
System.out.printf("%x %n", new BigInteger("-1969"));
System.out.printf("%#o %n", 1969);
System.out.printf("%#x %n", 1969);
System.out.printf("%#o %n", new BigInteger("1969"));
System.out.printf("%#x %n", new BigInteger("1969"));
3661
37777774117
3661
-3661
7b1
fffff84f
7b1
-7b1
03661
0x7b1
03661
0x7b1

浮点数格式

浮点数格式化处理格式化数字,它有一个整数部分和一个小数部分。可以应用于floatFloatdoubleDoubleBigDecimal数据类型的格式值。表 17-6 包含用于浮点数格式化的转换列表。

表 17-6

适用于 float、Float、double、Double 和 BigDecimal 数据类型的转换列表

|

转换

|

大写字母

不同的

|

描述

e E 它将参数格式化为特定于区域设置的计算机化科学记数法,例如 1.969919e+03。输出包含一个数字,后跟一个十进制分隔符,后跟指数部分。例如,如果精度为 6,1969.919 将被格式化为 1.969919e+03。精度是小数点后的位数。组分隔符标志不能用于此转换。
g G 它将参数格式化为特定于语言环境的通用科学符号。根据参数的值,它充当e转换或f转换。它根据精度值对参数值进行舍入。如果舍入后的值大于或等于 10-4 但小于 10 个精度,它会将该值格式化为使用了f转换。如果舍入后的值小于 10-4 或大于或等于 10 精度,它会像使用e转换一样格式化该值。请注意,结果中有效数字的总数等于精度值。默认情况下,使用 6 的精度。
f 它将参数格式化为特定于区域设置的十进制格式。精度是小数点后的位数。该值根据指定的精度值进行舍入。
a A 它将参数格式化为十六进制指数形式。它不适用于BigDecimal类型的参数。

浮点数格式的格式说明符的一般语法如下:

%<argument_index$><flags><width><.precision><conversion>

精度有不同的含义。含义取决于转换字符。默认情况下,精度值为 6。对于ef转换,精度是小数点后的位数。对于g转换,精度是舍入后得到的量值的总位数。精度不适用于转换。

以下代码片段显示了如何使用默认精度(6)格式化浮点数:

System.out.printf("%e %n", 10.2);
System.out.printf("%f %n", 10.2);
System.out.printf("%g %n", 10.2);
System.out.printf("%e %n", 0.000002079);
System.out.printf("%f %n", 0.000002079);
System.out.printf("%g %n", 0.000002079);
System.out.printf("%a %n", 0.000002079);
1.020000e+01
10.200000
10.2000
2.079000e-06
0.000002
2.07900e-06
'1.97e+03'
0x1.1709e564a6d14p-19

以下代码片段显示了在浮点数格式化中使用widthprecision的效果:

System.out.printf("%.2e %n", 1969.27);
System.out.printf("%.2f %n", 1969.27);
System.out.printf("%.2g %n", 1969.27);
System.out.printf("'%8.2e' %n", 1969.27);
System.out.printf("'%8.2f' %n", 1969.27);
System.out.printf("'%8.2g' %n", 1969.27);
System.out.printf("'%10.2e' %n", 1969.27);
System.out.printf("'%10.2f' %n", 1969.27);
System.out.printf("'%10.2g' %n", 1969.27);
System.out.printf("'%-10.2e' %n", 1969.27);
System.out.printf("'%-10.2f' %n", 1969.27);
System.out.printf("'%-10.2g' %n", 1969.27);
System.out.printf("'%010.2e' %n", 1969.27);
System.out.printf("'%010.2f' %n", 1969.27);
System.out.printf("'%010.2g' %n", 1969.27);
1.97e+03
1969.27
2.0e+03
'1.97e+03'
' 1969.27'
' 2.0e+03'
'  1.97e+03'
'   1969.27'
'   2.0e+03'
'1.97e+03  '
'1969.27   '
'2.0e+03   '
'001.97e+03'
'0001969.27'
'0002.0e+03'

如果浮点转换的参数值是NaNInfinity,则输出分别包含字符串"NaN""Infinity"。以下代码片段显示了当浮点数的值为NaN或无穷大时浮点数的格式:

System.out.printf("%.2e %n", Double.NaN);
System.out.printf("%.2f %n", Double.POSITIVE_INFINITY);
System.out.printf("%.2g %n", Double.NEGATIVE_INFINITY);
System.out.printf("%(f %n", Double.POSITIVE_INFINITY);
System.out.printf("%(f %n", Double.NEGATIVE_INFINITY);
NaN
Infinity
-Infinity
Infinity
(Infinity)

格式化日期和时间

日期/时间格式化处理格式化日期、时间和日期/时间。可以应用于longLongjava.util.Calendarjava.util.Datejava.time.temporal.TemporalAccessor类型的格式值。一个long / Long类型的参数中的值被解释为自 1970 年 1 月 1 日午夜 UTC 以来经过的毫秒数。

Note

TemporalAccessor是 Java 8 中增加的一个接口。它是新的日期时间 API 的一部分。API 中所有指定某种日期和/或时间的类都是TemporalAccessor. LocalDateLocalTimeLocalDateTimeZonedDateTime都是TemporalAccessor的例子。参考第十六章了解更多关于使用新日期时间 API 的信息。

t转换字符用于格式化日期/时间值。它有一个大写的变体T。日期/时间格式的格式说明符的一般语法如下:

%<argument_index$><flags><width><conversion>

请注意,格式说明符中的精度部分不适用于日期/时间格式。对于日期/时间格式,转换是两个字符的序列。转换中的第一个字符总是tT。第二个字符称为转换后缀,它决定了日期/时间参数的格式。表格 17-7 至 17-9 列出了所有可与t / T数据/时间转换字符一起使用的转换后缀。

表 17-9

日期/时间格式的后缀字符列表

|

转换后缀

|

描述

R 它以 24 小时制格式将时间格式化为hour : minute。其效果与使用%tH:%tM作为格式说明符是一样的。例子有 1 1:2301:3521:30等。
T 它以 24 小时制格式将时间格式化为hour:minute:second。其效果与使用%tH:%tM:%tS作为格式说明符是一样的。例子有11:23:1001:35:01, 21:30:34等。
r 它以 12 小时制格式将时间格式化为hour:minute:second morning/afternoon marker。其效果与使用%tI:%tM:%tS %T p 作为格式说明符是一样的。上午/下午标记可以是特定于场所的。09:23:45 AM09:30:00 PM等。是美国地区的例子。
D 它将日期格式化为%tm/%td/%ty,比如01/19/11
F 它将日期格式化为%tY-%tm-%td,比如2011-01-19
c 它将日期和时间格式化为%ta %tb %td %tT %tZ %tY,比如 Wed Jan 19 11:52:06 CST 201 1。

表 17-8

日期格式的后缀字符列表

|

转换后缀

|

描述

B 特定于区域设置的月份全名,如“January”、“February”等。对于美国地区。
b 特定于区域设置的缩写月份名称,如“Jan”、“Feb”等。对于美国地区。
h b
A 一周中某一天的特定于语言环境的全名,例如“Sunday""Monday"等。对于美国地区。
a 一周中某一天的特定于语言环境的简称,如"Sun""Mon"等。对于美国地区。
C 它将四位数年份除以 100,并将结果格式化为两位数。如果得到的数字是一位数,它会添加一个前导零。它忽略除以 100 的结果中的小数部分。有效值为 00–99。例如,如果四位数的年份是 2011 年,它将输出 20;如果四位数年份是 12,则输出 00。
Y 至少是四位数的年份。如果年份少于四位数,它会添加前导零。比如年份是 789,就输出 0789;如果年份是 2021,则输出 2021;如果年份是 20189,则输出 20189。
y 年份的最后两位数。如有必要,它会添加一个前导零。比如年份是 9,就输出 09;如果年份是 123,则输出 23;如果年份是 2011 年,它将输出 11。
j 一年中三位数的一天。有效值为 000–366。
m 两位数的月份。有效值为 01–13。需要特殊值 13 来支持农历。
d 一个月中两位数的某一天。有效值为 01–31。
e 一月中的某一天。有效值为 1–31。除了不在输出中添加前导零之外,它的行为与“d”相同。

表 17-7

时间格式的后缀字符列表

|

转换后缀

|

描述

H 24 小时制中一天中的两位数小时。有效值为 00–23。00 用于午夜。
I 12 小时制中一天中的两位数小时。有效值为 01–12。01 值对应于早上或下午的一点钟。
k 除了不在输出中添加前导零之外,它的行为与H后缀相同。有效值为 0–23。
l 除了不添加前导零之外,它的行为与I后缀相同。有效值为 1–12。
M 一小时内的两位数分钟。有效值为 00–59。
S 一分钟内的两位数秒。有效值为 00–60。值 60 是支持闰秒所需的特殊值。
L 一秒钟内的三位数毫秒。有效值为 000–999。
N 一秒内的九位数纳秒。有效值为 000000000–99999999。纳秒值的精度取决于操作系统支持的精度。
p 它以小写形式输出特定于地区的早晨或下午标记。例如,对于美国地区,它将输出"am""pm"。如果您想要大写的输出(例如,"AM""PM"用于美国地区),您需要使用大写的变体T作为转换字符。
z 它从 GMT 输出数字时区偏移量(例如+0530)。
Z 它是时区的字符串缩写(例如,CST、EST、IST 等)。).
s 它输出自 1970 年 1 月 1 日午夜 UTC 开始的纪元开始以来的秒数。
Q 它输出自 1970 年 1 月 1 日午夜 UTC 开始的纪元开始以来的毫秒数。

只要适用,数据/时间格式就会应用本地化。以下代码片段格式化了相同的日期和时间,即美国、印度和泰国地区的 2014 年 1 月 25 日上午 11:48:16。注意在格式说明符中使用了<标志。它允许您使用以多种格式说明符保存日期和时间值的参数:

Locale englishUS = Locale.US;
Locale hindiIndia = new Locale ("hi", "IN");
Locale thaiThailand = new Locale ("th", "TH", "TH");
// Construct a LocalDateTime
LocalDateTime ldt = LocalDateTime.of(2014, Month.JANUARY, 25, 11, 48, 16);
System.out.printf(englishUS, "In US: %tB %<te, %<tY %<tT %<Tp%n", ldt);
System.out.printf(hindiIndia, "In India: %tB %<te, %<tY %<tT %<Tp%n", ldt);
System.out.printf(thaiThailand, "In Thailand: %tB %<te, %<tY %<tT %<Tp%n", ldt);

img/323069_3_En_17_Figb_HTML.png

以下代码片段将当前日期和时间格式化为默认区域设置(在本例中为 US)。运行代码时,您将得到不同的输出。它使用一个ZonedDateTime参数来保存当前日期/时间和时区:

ZonedDateTime currentTime = ZonedDateTime.now();
System.out.printf("%tA %<tB %<te, %<tY %n", currentTime);
System.out.printf("%TA %<TB %<te, %<tY %n", currentTime);
System.out.printf("%tD %n", currentTime);
System.out.printf("%tF %n", currentTime);
System.out.printf("%tc %n", currentTime);
System.out.printf("%Tc %n", currentTime);
Saturday August 21, 2021
SATURDAY AUGUST 21, 2021
08/21/21
2021-08-21
Sat Aug 21 20:52:19 EDT 2021
SAT AUG 21 20:52:19 EDT 2021

注意使用大写变体T作为转换字符的效果。它以大写字母格式化参数。大写字母的定义取决于所使用的语言环境。如果区域设置没有不同的大写和小写字母,当您使用Tt作为转换字符时,输出将是相同的。

摘要

DateFormat类用于使用预定义格式格式化传统日期和时间,而SimpleDateFormat类用于以自定义格式格式化传统日期和时间。

NumberFormat类用于将数字格式化为特定地区的预定义格式。DecimalFormat类用于在特定的地区以您选择的格式格式化一个数字。

您可以使用java.util.Formatter类来格式化字符串、数字和日期/时间,从而使用printf样式的格式。它让您将格式化的输出发送到StringBuilderStringBufferFileOutputStreamPrintStream等。您已经使用了System.out.format()System.out.printf()方法将格式化的输出发送到标准输出。使用静态的String.format()方法获得一个格式化的字符串。使用Formatter将格式化的输出发送到您选择的目的地。您可以实现Formattable接口,将自定义格式应用于该类的对象。

QUESTIONS AND EXERCISES

  1. 使用预定义的特定于语言环境的格式来格式化java.util.Date对象中的日期,您会使用什么类呢?

  2. 你会用什么类来格式化一个使用自定义格式的java.util.Date对象中的日期?

  3. 您将使用什么类来解析String对象中的日期以获得java.util.Date对象?

  4. 您会使用什么类来将一个数字格式化为预定义的特定于地区的格式?

  5. 你用什么类来定制一个数字的格式?

  6. 您将使用什么类来解析字符串中的double以获得一个数字?

  7. 假设下面代码片段中的new Date()表达式返回的当前日期是 1968 年 1 月 12 日,那么下面代码片段的输出会是什么?

    import java.text.SimpleDateFormat;
    import java.util.Date;
    ...
    SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
    String currDate = sdf.format(new Date());
    System.out.println(currDate);
    
    
  8. 您会使用什么方法将格式化输出打印到标准输出— System.out.println()System.out.printf()

  9. 输出布尔值、字符、整数、浮点数和字符串的格式说明符是什么?

  10. 下面的语句抛出一个MissingFormatArgumentException。描述异常背后的原因以及您将如何修复它:

```java
System.out.printf("%d %f", 1969);
System.out.printf("%d %f", 1969, 2017);

```
  1. 编写以下语句的输出:
```java
System.out.printf("%s %s%n", "Ken", "Lu");
System.out.printf("%s %<s%n", "Ken", "Lu");
System.out.printf("%s %<s %2s%n", "Ken", "Lu");

```
  1. 编写以下代码片段的输出:
```java
System.out.println(new DecimalFormat("##.##").format(12.675));
System.out.printf("%.2f%n", 12.675);
System.out.printf("%1.2f%n", 12.675);
System.out.printf("%2.2f%n", 12.675);
System.out.printf("%2.1f%n", 12.675);

```
  1. 完成下面的代码片段,它将输出“我的生日是 1969 年 9 月 19 日星期五”。注意,您必须以大写形式输出星期几和月份的名称:
```java
LocalDate bDay = LocalDate.of(1969, 9, 19);
String format = /* Your code goes here*/;
System.out.printf(format, bDay);

```
  1. 编写以下代码片段的输出:
```java
System.out.printf("%d%n", 16);
System.out.printf("%x%n", 10);
System.out.printf("%c%n", 'a');
System.out.printf("'%5C' %n", 'a');
System.out.printf("'%-5C' %n", 'a');

```
  1. 编写以下代码片段的输出:
```java
System.out.printf("%s %<s %s %1$s %s%n", "Li", "Hu", "Xi");

```

十八、正则表达式

在本章中,您将学习:

  • 如何创建正则表达式

  • 如何在String类中使用方便的方法来执行基于正则表达式的查找和替换

  • 如何使用Pattern类编译正则表达式

  • 如何使用Matcher类来匹配一个正则表达式和一个输入字符串

  • 如何在正则表达式中使用组

  • 如何使用Matcher类执行高级查找和替换

本章中的所有示例程序都是清单 18-1 中声明的jdojo.regex模块的成员。

// module-info.java
module jdojo.regex {
    exports com.jdojo.regex;
}

Listing 18-1The Declaration of a jdojo.regex Module

什么是正则表达式?

正则表达式是描述字符序列中模式的一种方式。该模式可用于验证字符序列、搜索字符序列、用另一个字符序列替换匹配该模式的字符序列等。

先说个例子。假设你有一个字符串,它可能是一个电子邮件地址。如何确保字符串是有效的电子邮件地址格式?此时,您对电子邮件地址的存在不感兴趣。您只想验证它的格式。

您希望根据一些规则来验证字符串。例如,它必须包含一个@符号,该符号前面至少有一个字符,后面是域名。或者,您可以指定@符号之前的文本必须只包含字母、数字、下划线和连字符。域名必须包含一个点。您可能想要添加更多的验证。如果您只想检查字符串中的@字符,您可以通过调用email.indexOf('@')来完成,其中email是保存电子邮件地址的字符串的引用。如果要确保邮件字符串中只有一个@字符,就需要添加更多的逻辑。在这种情况下,您可能会有 20–50 行代码,甚至更多,这取决于您想要执行的验证的数量。

这就是正则表达式派上用场的地方。这将使您的电子邮件地址验证变得容易。只用一行代码就可以完成。听起来是不是好得不像真的?就在不久前,您被告知您可能最终会有 50 行代码。现在,您被告知只需一行代码就可以完成同样的任务。这是真的。它可以在一行代码中完成。在我们详细讨论如何做到这一点之前,让我们列出完成这项任务所需的步骤:

  • 为了验证这些类型的字符串,您需要识别您正在寻找的模式。例如,在最简单的电子邮件地址验证形式中,字符串应该由一些文本(至少一个字符)加上一个@符号,后跟一些域名文本。让我们暂时忽略任何其他细节。

  • 你需要一种方式来表达这种被认可的模式。正则表达式用于描述这种模式。

  • 你需要一个程序来匹配输入字符串的模式。这样的程序也被称为正则表达式引擎。

假设您想测试一个字符串的形式是否为X@X,其中X是任意字符。弦乐"a@a""b@f""3@h"都是这种形式。你可以在这里观察到一种模式。模式是“一个字符后面跟着@,后面跟着另一个字符。”用 Java 怎么表达这种模式?

在这种情况下,字符串".@."将代表您的正则表达式。在".@."中,圆点有着特殊的含义。它们代表任何字符。所有在正则表达式中有特殊含义的字符都被称为元字符。我们将在下一节讨论元字符。String类包含一个matches()方法。它将一个正则表达式作为参数,如果整个字符串匹配正则表达式,则返回true。否则返回false。这个方法的特征是

boolean matches(String regex)

清单 18-2 包含了说明String类的matches()方法用法的完整代码。

// RegexMatch.java
package com.jdojo.regex;
public class RegexMatch {
    public static void main(String[] args) {
        // Prepare a regular expression to represent a pattern
        String regex = ".@.";
        // Try matching many strings against the regular expression
        matchIt("a@k", regex);
        matchIt("webmaster@jdojo.com", regex);
        matchIt("r@j", regex);
        matchIt("a%N", regex);
        matchIt(".@.", regex);
    }
    public static void matchIt(String str, String regex) {
        // Test for pattern match
        boolean matched = str.matches(regex);
        System.out.printf("%s matched %s = %b%n", str, regex, matched);
    }
}

a@k matched .@. = true
webmaster@jdojo.com matched .@. = false
r@j matched .@. = true
a%N matched .@. = false
.@. matched .@. = true

Listing 18-2Matching a String Against a Pattern

需要注意的一些要点如下:

  • 正则表达式".@.""webmaster@jdojo.com"不匹配,因为点意味着只有一个字符,而String.matches()方法匹配正则表达式中的模式和整个字符串。注意,字符串"webmaster@jdojo.com"具有由.@.表示的模式;也就是一个字符后跟@和另一个字符。但是,模式匹配字符串的一部分,而不是整个字符串。"webmaster@jdojo.com""r@j"部分与该模式相匹配。我们给出了一些例子,在这些例子中,你可以在字符串中的任何地方匹配模式,而不是匹配整个字符串。

  • 如果要匹配字符串中的点字符,需要对正则表达式中的点进行转义。正则表达式".\\.."将匹配任何三个字符的字符串,其中中间的字符是点字符。比如方法调用"a.b".matches(".\\..")会返回true;方法调用"...".matches(".\\..")将返回true;方法调用"abc".matches(".\\..")"aa.ca".matches(".\\..")将返回false

您也可以用另一个字符串替换匹配的字符串。String类有两种方法来进行匹配替换:

  • String replaceAll(String regex, String replacementString)

  • String replaceFirst(String regex, String replacementString)

replaceAll()方法用指定的replacementString替换与指定的regex表示的模式匹配的字符串。它返回替换后的新字符串。使用replaceAll()方法的一些例子如下:

String regex = ".@.";
// newStr will contain "webmaste***dojo.com" String newStr = "webmaster@jdojo.com".replaceAll(regex,"***");
// newStr will contain "***"
newStr = "A@B".replaceAll(regex,"***");
// newStr will contain "***and***"
newStr = "A@BandH@G".replaceAll(regex,"***");
// newStr will contain "B%T" (same as the original string)
newStr = "B%T".replaceAll(regex,"***");

replaceFirst()方法用replacementString替换第一次出现的匹配。它返回替换后的新字符串。使用replaceFirst()方法的一些例子如下:

String regex = ".@.";
// newStr will contain "webmaste***dojo.com"
String newStr = "webmaster@jdojo.com".replaceFirst(regex, "***");
// newStr will contain "***"
newStr = "A@B".replaceFirst(regex, "***");
// newStr will contain "***andH@G"
newStr = "A@BandH@G".replaceFirst(regex, "***");
// newStr will contain "B%T" (same as the original string)
newStr = "B%T".replaceFirst(regex, "***");

元字符

元字符是具有特殊含义的字符。它们用在正则表达式中。有时元字符没有任何特殊含义,它们被视为普通字符。根据使用它们的上下文,它们被视为普通字符或元字符。Java 中正则表达式支持的元字符如下:

  • ( (a left parenthesis)

  • ) (a right parenthesis)

  • [ (a left bracket)

  • ] (a right bracket)

  • { (a left brace)

  • } (a right brace)

  • \ (a backslash)

  • ^ (a caret)

  • $ (a dollar sign)

  • | (a vertical bar)

  • ? (a question mark)

  • * (an asterisk)

  • + (an addition sign)

  • . (a dot or period)

  • < (a less-than sign)

  • > (a greater-than sign)

  • - (a hyphen)

  • = (an equal to sign)

  • ! (an exclamation mark)

字符类

元字符[](左右括号)用于指定正则表达式中的字符类。字符类是一组字符。正则表达式引擎将尝试匹配集合中的一个字符。请注意,在 Java 中,字符类与类结构或类文件没有关系。角色类别"[ABC]"将匹配角色ABC。例如,字符串"A@V""B@V""C@V"将匹配正则表达式"[ABC]@."。然而,字符串"H@V"将不匹配正则表达式"[ABC]@.",因为@前面没有ABC。作为另一个例子,字符串"man""men"将匹配正则表达式"m[ae]n"

当我们使用“匹配”这个词时,我们的意思是模式存在于一个字符串中。我们并不是说整个字符串都匹配这个模式。例如,"WEB@JDOJO.COM"匹配模式"[ABC]@.",因为@B.之前,即使字符串包含三个@符号,字符串"A@BAND@YEA@U"也匹配模式"[ABC]@."两次。第二个@不是匹配的一部分,因为它的前面是D,而不是AB,C

您还可以使用字符类指定字符范围。范围用连字符(-)表示。例如,正则表达式中的"[A-Z]"代表任意大写英文字母;"[0-9]"代表09之间的任意数字。如果在一个字符类的开头使用^,表示补语(意为不)。例如,"[^ABC]"表示除了AB,C以外的任何字符。字符类"[^A-Z]"代表除大写英文字母以外的任何字符。如果您在字符类中除了开头以外的任何地方使用^,它将失去其特殊含义(即补码的特殊含义),并且它只匹配一个^字符。例如,"[ABC^]"将匹配ABC^

您也可以在一个字符类中包含两个或多个范围。例如,"[a-zA-Z]"匹配从az的任意字符,AZ. "[a-zA-Z0-9]"匹配从az的任意字符(大写和小写)以及从09的任意数字。表 18-1 中列出了一些字符类别的例子。

表 18-1

字符类的示例

|

字符类

|

意义

|

种类

[abc] 人物ab,c 简单字符类
[^xyz] 除了xyz之外的一个角色 补充还是否定
[a-p] 字符ap 范围
[a-cx-z] 字符acxz,包括abcxyz 联盟
[0-9&&[4-8]] 两个范围的交集(45678) 交集
[a-z&&[^aeiou]] 所有小写字母减去元音。换句话说,一个小写字母,它不是元音。也就是全部小写辅音。 减法

预定义的字符类

表 18-2 中列出了一些常用的预定义字符类。

表 18-2

预定义正则表达式字符类的列表

|

预定义的字符类

|

意义

. (a dot) 任何字符(可能与行终止符匹配,也可能不匹配)。更多细节请参考java.util.regex.Pattern类的 API 文档中的“行终止符”一节。
\d 一个数字。同[0-9]
\D 一个非数字。同[⁰-9]
\s 空白字符。同[ \t\n\x0B\f\r]。该列表包括一个空格、一个制表符、一个新行、一个垂直制表符、一个换页符和一个回车符。
\S 非空白字符。同[^\s]
\w 一个单词字符。同[a-zA-Z_0-9]。该列表包括小写字母、大写字母、下划线和十进制数字。
\W 非单词字符。同[^\w]

如果您允许在电子邮件地址验证中使用所有的大写和小写字母、下划线和数字,那么只验证三个字符的电子邮件地址的正则表达式应该是"\w@\w"。现在,您在电子邮件地址验证过程中领先一步。不再只允许在电子邮件的第一部分使用ABC(用正则表达式[ABC]@.来表示),现在你允许任何单词字符作为第一部分和第二部分。

正则表达式的更多功能

到目前为止,您只看到了使用正则表达式的String类的三个方法。包java.util.regex包含三个类来支持正则表达式的完整版本。这些类别如下:

  • 模式

  • 制榫机

  • PatternSyntaxException

一个Pattern保存正则表达式的编译形式。正则表达式的编译形式是其专用的内存表示形式,以促进更快的字符串匹配。

一个Matcher将待匹配的字符串与一个Pattern,相关联,并执行实际的匹配。

一个PatternSyntaxException代表一个格式错误的正则表达式中的错误。

编译正则表达式

一个Pattern保存正则表达式的编译形式。它是不可改变的。可以分享。它没有public构造器。该类包含一个静态的compile()方法,该方法返回一个Pattern对象。compile()方法被重载:

  • static Pattern compile(String regex)

  • static Pattern compile(String regex, int flags)

以下代码片段将一个正则表达式编译成一个Pattern对象:

// Prepare a regular expression
String regex = "[a-z]@.";
// Compile the regular expression into a Pattern object
Pattern p = Pattern.compile(regex);

第二个版本的compile()方法允许您指定修改模式匹配方式的标志。flags参数是一个位掩码。这些标志被定义为Pattern类中的int常量,如表 18-3 所列。

表 18-3

模式类中定义的标志列表

|

|

描述

Pattern.CANON_EQ 启用规范等效。如果设置了此标志,则只有当两个字符的完全规范分解匹配时,它们才匹配。
Pattern.CASE_INSENSITIVE 启用不区分大小写的匹配。此标志仅为 US-ASCII 字符集设置不区分大小写的匹配。对于 Unicode 字符集的不区分大小写匹配,UNICODE_CASE标志也应该与该标志一起设置。
Pattern.COMMENTS 允许模式中有空白和注释。当设置了这个标志时,空白被忽略,以#开头的嵌入注释被忽略,直到一行结束。
Pattern.DOTALL 启用 dotall 模式。默认情况下,表达式.(一个点)不匹配行终止符。设置此标志时,表达式匹配任何字符,包括行终止符。
Pattern.LITERAL 启用模式的文字解析。当设置了该标志时,正则表达式中的字符被按字面意思处理。也就是说,元字符和转义序列没有特殊的含义。CASE_INSENSTIVEUNICODE_CASE标志在与此标志一起使用时保持其效果。
Pattern.MULTILINE 启用多线模式。默认情况下,表达式^$匹配整个输入序列的开头和结尾。当该标志被设置时,它们分别在一个行结束符或输入序列结束符之后和之前匹配。
Pattern.UNICODE_CASE 启用支持 Unicode 的大小写折叠。当该标志与CASE_INSENSITIVE标志一起设置时,根据 Unicode 标准执行不区分大小写的匹配。
Pattern.UNICODE_CHARACTER_CLASS 启用预定义字符类和POSIX字符类的 Unicode 版本。设置该标志也具有设置UNICODE_CASE标志的效果。设置此标志时,(仅限 US-ASCII)预定义字符类和POSIX字符类符合 Unicode 技术标准#18: Unicode 正则表达式附录 C——兼容性属性。
Pattern.UNIX_LINES 启用UNIX线条模式。设置该标志时,只有\n字符被识别为行结束符。

下面的代码片段编译了一个设置了CASE_INSENSTIVEDOTALL标志的正则表达式,因此匹配 US-ASCII 字符集时不区分大小写,表达式.(一个点)将匹配一个行结束符。例如,"A@\n"将由以下模式匹配:

// Prepare a regular expression
String regex = "[a-z]@.";
// Compile the regular expression into a Pattern object with
// the CASE_INSENSITIVE and DOTALL flags
Pattern p = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

创建匹配器

通过解释保存在Pattern对象中的编译模式,Matcher类的一个实例用于对一系列字符执行匹配。它没有public构造器。Pattern类的matcher()方法用于获取Matcher类的实例。该方法将模式要匹配的字符串作为参数。下面的代码片段显示了如何获得一个Matcher:

// Create a Pattern object and compile it into a Pattern
String regex = "[a-z]@.";
Pattern p = Pattern.compile(regex);
// String to perform the match
String str = "abc@yahoo.com,123@cnn.com,ksharan@jdojo.com";
// Get a matcher object using Pattern object p for str
Matcher m = p.matcher(str);

此时,Matcher对象m已经将Pattern对象p中表示的模式与str中的字符序列相关联。它准备好开始匹配操作。通常,Matcher对象用于在字符序列中寻找匹配。比赛可能成功也可能失败。如果匹配成功,您可能有兴趣知道匹配的开始和结束位置以及匹配的文本。您可以查询一个Matcher对象来获得所有这些信息。

匹配模式

你需要使用Matcher的以下方法来对输入执行匹配:

  • find()

  • start()

  • end()

  • group()

find()方法用于在输入中寻找模式的匹配。如果查找成功,则返回true。否则,它返回false。对该方法的第一次调用从输入的开始处开始搜索模式。如果上一次对此方法的调用成功,则下一次对此方法的调用将在前一次匹配后开始搜索。通常,在一个while循环中调用find()方法来查找所有匹配。这是一个重载的方法。另一个版本的find()方法采用整数参数,这是开始查找匹配的偏移量。

start()方法返回前一个匹配的起始索引。通常,它在成功的find()方法调用之后使用。

end()方法返回匹配字符串中最后一个字符的索引加 1。因此,在成功调用find()方法之后,end()start()方法返回的值之间的差值将给出匹配字符串的长度。使用String类的substring()方法,可以得到如下匹配的字符串:

// Continued from previous fragment of code
if (m.find()) {
    // str is the string we are looking into
    String foundStr = str.substring(m.start(), m.end());
    System.out.println("Found string is:" + foundStr);
}

group()方法返回通过先前成功的find()方法调用找到的字符串。回想一下,您还可以通过使用匹配的开始和结束,使用String类的substring()方法来获取之前匹配的字符串。因此,前面的代码片段可以替换为以下代码:

if (m.find()) {
    String foundStr = m.group();
    System.out.println("Found text is:" + foundStr);
}

清单 18-3 说明了这些方法的使用。为了清楚起见,省略了对方法参数的验证。程序试图在不同的字符串中找到"[abc]@."模式。

// PatternMatcher.java
package com.jdojo.regex;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class PatternMatcher {
    public static void main(String[] args) {
        String regex = "[abc]@.";
        String source = "cric@jdojo.com is a valid email address";
        PatternMatcher.findPattern(regex, source);
        source = "kelly@jdojo.com is invalid";
        PatternMatcher.findPattern(regex, source);
        source = "a@band@yea@u";
        PatternMatcher.findPattern(regex, source);
        source = "There is an @ sign here";
        PatternMatcher.findPattern(regex, source);
    }
    public static void findPattern(String regex, String source) {
        // Compile regex into a Pattern object
        Pattern p = Pattern.compile(regex);
        // Get a Matcher object
        Matcher m = p.matcher(source);
        // Print regex and source text
        System.out.println("\nRegex: " + regex);
        System.out.println("Text: " + source);
        // Perform find
        boolean found = false;
        while (m.find()) {
            System.out.printf("Matched Text: %s, Start: %s, End: %s%n",
                    m.group(), m.start(), m.end());
            // We found at least one match. Set the found flag to true
            found = true;
        }
        if (!found) {
            // We did not find any match
            System.out.println("No match found");
        }
    }
}
Regex: [abc]@.
Text: cric@jdojo.com is a valid email address
Matched Text: c@j, Start: 3, End: 6
Regex: [abc]@.
Text: kelly@jdojo.com is invalid
No match found
Regex: [abc]@.
Text: a@band@yea@u
Matched Text: a@b, Start: 0, End: 3
Matched Text: a@u, Start: 9, End: 12
Regex: [abc]@.
Text: There is an @ sign here
No match found

Listing 18-3Using Pattern and Matcher Classes

查询匹配

在上一节中,我们向您展示了如何查询一个Matcher来获得一个匹配的状态(或细节)。获得这些状态的方法有start()end()group()groupCount()。匹配状态也可以表示为MatchResult的实例,它是一个接口。您可以使用MatchResult的以下方法来获取匹配状态:

  • int end()

  • int end(int group)

  • String group()

  • String group(int group)

  • int groupCount()

  • int start()

  • int start(int group)

如何获得MatchResult的实例?调用MatchertoMatchResult()方法得到匹配状态的副本:

Matcher m = /* get a matcher here */
while (m.find()) {
    MatchResult result = m.toMatchResult();
    // Use result here...
}

为什么要使用MatchResult而不是Matcher中的方法来获取匹配状态?原因有二:

  • MatchertoMatchResult()返回匹配状态的副本,这意味着Matcher的匹配状态的任何后续变化都不会影响MatchResult。在匹配过程中,您可以将所有匹配状态收集到MatchResult的实例中,然后在程序中进行分析。

  • A MatchResult是不可变的。如果您有处理器来处理匹配,您可以安全地将MatchResult实例传递给那些处理器。传递Matcher是不安全的,因为处理器可能会意外修改Matcher,这将在无意中影响您的程序。

Matcher类中有一些方法可以和MatchResult一起工作。我们将在本章后面介绍它们。现在,只要记住一个MatchResult包含一场比赛的细节的拷贝。

当心反斜线

当心在正则表达式中使用反斜杠。字符类\w(即反斜杠后跟一个w)代表一个单词字符。回想一下,反斜杠字符也被用作转义字符的一部分。因此,\w必须写成\\w作为字符串文字。您还可以使用反斜杠来消除元字符的特殊含义。例如,一个[标志着一个角色类的开始。匹配括号中数字的正则表达式是什么,例如,[1][5]等。?注意,正则表达式[0-9]将匹配任何数字。数字可以用括号括起来,也可以不括起来。你可以考虑用[[0-9]]。它不会给你任何错误;然而,它也不能完成这项工作。您也可以将一个字符类嵌入到另一个字符类中。比如可以写[a-z[0-9]],和[a-z0-9]一样。在这种情况下,[[0-9]]中的第一个[应该被视为普通字符,而不是元字符。必须使用反斜杠作为\[[0-9]\]。要将这个正则表达式写成字符串文字,需要在双引号中使用两个反斜杠作为"\\[[0-9]\\]]"

正则表达式中的量词

您还可以指定正则表达式中的字符与字符序列匹配的次数。如果您想匹配所有两位数的整数,那么您的正则表达式应该是\d\d,与[0-9][0-9]相同。匹配任意整数的正则表达式是什么?你无法用你目前所掌握的知识写出匹配任何整数的正则表达式。您需要能够使用正则表达式来表达“一位数或更多位数”的模式。量词的概念就来了。表 18-4 列出了量词及其含义。

表 18-4

量词及其意义

|

数量词

|

意义

* 零次或多次
+ 一次或多次
? 一次或根本没有
{m} 恰好m
{m, } 至少m
{m, n} 至少m次,但不超过n

值得注意的是,量词必须跟在它指定数量的字符或字符类之后。匹配任何整数的正则表达式是\d+,它将匹配一个或多个数字。这种匹配整数的解法正确吗?不,不是的。假设你的文本是“这是包含 10 和 120 的文本 123”。如果您对这个字符串运行您的模式\d+,它将匹配 123、10 和 120。注意123不是作为整数使用的;相反,它是单词text123的一部分。如果你在文本中寻找整数,那么text123中的 123 肯定不是整数。您希望匹配文本中构成一个单词的所有整数。

需要是发明之母。现在,您需要指定只在单词边界上执行匹配,而不是在嵌入了整数的文本中。这对于从先前的结果中排除整数 123 是必要的。下一节讨论使用元字符来匹配边界。

根据您在本节中学到的知识,让我们来改进您的电子邮件地址验证。在一个电子邮件地址中,必须有且只有一个@符号。要指定一个且仅一个字符,您可以在正则表达式中使用该字符一次,尽管您可以使用{1}作为量词。例如,X{1}X在正则表达式中的意思是一样的。你在这方面很好。然而,到目前为止,您的解决方案只支持在@符号前后有一个字符。实际上,电子邮件地址中的@符号前后可以有多个字符。您可以将验证电子邮件地址的模式指定为\w+@\w+,这意味着一个或多个单词字符、一个@符号和一个或多个单词字符。

匹配边界

到目前为止,您并不关心文本中模式匹配的位置。有时,您可能想知道匹配是否发生在行首。您可能对查找和替换特定的匹配感兴趣,只要该匹配是在单词中找到的,而不是作为任何单词的一部分。例如,您可能希望将字符串中的单词apple替换为单词orange。假设你的字符串是“我有一个苹果和五个菠萝”。当然,您不希望在这个字符串中用orange替换所有出现的apple。如果你这样做,你的新字符串将是“我有一个橘子和五个菠萝”。事实上,你希望新的字符串是“我有一个橘子和五个菠萝”。你想匹配单词apple作为一个独立的单词,而不是任何其他单词的一部分。

表 18-5 列出了所有可以在正则表达式中使用的边界匹配器。

表 18-5

正则表达式中的边界观察器列表

|

边界匹配器

|

意义

^ 一行的开始
$ 一行的结尾
\b 单词边界
\B 非单词边界
\A 输入的开始
\G 上一场比赛的结束
\Z 输入的结尾,但最后一个终止符除外,如果有的话
\z 输入的结束

在 Java 中,一个单词字符由[a-zA-Z_0-9]定义。字边界是零宽度匹配,可以匹配以下内容:

  • 在单词字符和非单词字符之间

  • 字符串的开头和一个单词字符

  • 一个单词字符和字符串的结尾

非单词边界也是零宽度匹配,它与单词边界相反。它与以下内容匹配:

  • 空字符串

  • 两个单词字符之间

  • 两个非单词字符之间

  • 在非单词字符和字符串的开头或结尾之间

匹配单词apple的正则表达式是\bapple\b,意思如下:单词边界、单词apple和单词边界。清单 18-4 演示了如何使用正则表达式匹配单词边界。

// MatchBoundary.java
package com.jdojo.regex;
public class MatchBoundary {
    public static void main(String[] args) {
        // Prepare regular expression. Use \\b to get \b inside the string literal.
        String regex = "\\bapple\\b";
        String replacementStr = "orange";
        String inputStr = "I have an apple and five pineapples";
        String newStr = inputStr.replaceAll(regex, replacementStr);
        System.out.printf("Regular Expression: %s%n", regex);
        System.out.printf("Input String: %s%n", inputStr);
        System.out.printf("Replacement String: %s%n", replacementStr);
        System.out.printf("New String: %s%n", newStr);
    }
}
Regular Expression: \bapple\b
Input String: I have an apple and five pineapples
Replacement String: orange
New String: I have an orange and five pineapples

Listing 18-4Matching a Word Boundary

有两个边界匹配器:^(一行的开始)和\A(输入的开始)。一个输入字符串可以由多行组成。在这种情况下,\A将匹配整个输入字符串的开头,而^将匹配输入中每一行的开头。例如,正则表达式"^The"将匹配一个the输入字符串,它位于任何一行的开头。

组和反向引用

您可以将多个字符作为一个组来使用,从而将它们视为一个单元。通过将一个或多个字符括在括号内,可以在正则表达式中创建组。(ab)ab(z)ab(ab)(xyz)(the((is)(is)))是组的例子。正则表达式中的每个组都有一个组号。组号从 1 开始。Matcher类有一个方法groupCount(),该方法返回与Matcher实例相关的模式中的组数。有一个特殊的群体叫 0 组(零)。它是指整个正则表达式。groupCount()方法不报告组 0。

每个组是如何编号的?正则表达式中的每个左括号标记一个新组的开始。表 18-6 列出了正则表达式中组编号的一些例子。注意,我们还列出了所有正则表达式的组 0,尽管它没有被Matcher类的groupCount()方法报告。列表中的最后一个示例显示存在组 0,即使正则表达式中没有显式组。

表 18-6

正则表达式中的组示例

|

正则表达式:AB(XY)

Matcher类的groupCount()方法报告的组数:1
组号 群组文本
0 AB(XY)
1 (XY)
正则表达式:(AB)(XY)
Matcher类的groupCount()方法报告的组数:2
组号 群组文本
0 (AB)(XY)
1 (AB)
2 (XY)
正则表达式:((A)((X)(Y)))
Matcher类的groupCount()方法报告的组数:5
组号 群组文本
0 ((A)((X)(Y)))
1 ((A)((X)(Y)))
2 (A)
3 ((X)(Y))
4 (X)
5 (Y)
正则表达式:ABXY
Matcher类的groupCount()方法报告的组数:0
组号 群组文本
0 ABXY

您还可以在正则表达式中反向引用组号。假设您想要匹配以"ab"开头,然后是"xy",最后是"ab"的文本。你可以写一个正则表达式为"abxyab"。您也可以通过形成一个包含"ab"的组并将其反向引用为"(ab)xy\1"来获得相同的结果。这里,"\1"指的是组 1,在这种情况下是"(ab)"。可以用"\2"指代组 2,"\3"指代组 3,以此类推。正则表达式"(ab)xy\12"会如何解释?您已经使用"\12"作为组反向参考。正则表达式引擎足够聪明,可以检测到它只包含"(ab)xy\12"中的一个组。它使用"\1"作为第 1 组的后向引用,第 1 组是"(ab)",,第 2 组是普通字符。因此,正则表达式"(ab)xy\12""abxyab2"相同。如果正则表达式有 12 个或更多组,正则表达式中的\12 将指第 12 个组。

还可以通过在正则表达式中使用组号来获取匹配文本的一部分。Matcher类中的group()方法被重载。你已经看到了没有参数的group()方法。该方法的另一个版本将组号作为参数,并返回该组匹配的文本。假设您在输入文本中嵌入了电话号码。所有电话号码都是一个单词,长度为十位数。前三个数字是区号。正则表达式\b\d{10}\b将匹配输入文本中的所有电话号码。然而,要获得前三位数字(区号),您必须编写额外的代码。如果使用组构成正则表达式,则可以使用组号获得区号。将电话号码的前三个数字放在一个组中的正则表达式是\b(\d{3})\d{7}\b。如果m是对与该模式相关联的Matcher对象的引用,则在成功匹配后,m.group(1)将返回电话号码的前三位数字。您也可以使用m.group(0)来获取整个匹配的文本。清单 18-5 展示了在正则表达式中使用组来获取电话号码的区号部分。注意2339829与模式不匹配,因为它只有 7 个数字,而使用的模式只查找 10 个数字的电话号码。

// PhoneMatcher.java
package com.jdojo.regex;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class PhoneMatcher {
    public static void main(String[] args) {
        // Prepare a regular expression: A group of 3 digits followed by 7 digits.
        String regex = "\\b(\\d{3})\\d{7}\\b";
        // Compile the regular expression
        Pattern p = Pattern.compile(regex);
        String source = "3342449027, 2339829, and 6152534734";
        // Get the Matcher object
        Matcher m = p.matcher(source);
        // Start matching and display the found area codes
        while (m.find()) {
            String phone = m.group();
            String areaCode = m.group(1);
            System.out.printf("Phone: %s, Area Code: %s%n", phone, areaCode);
        }
    }
}
Phone: 3342449027, Area Code: 334
Phone: 6152534734, Area Code: 615

Listing 18-5Using Groups in Regular Expressions

组也用于格式化或用另一个字符串替换匹配的字符串。假设您想要将所有十位数的电话号码格式化为(xxx) xxx -xxxx,其中 x 表示一个数字。如您所见,电话号码分为三组:前三位、后三位和后四位。您需要使用三个组组成一个正则表达式,这样您就可以通过它们的组号来引用这三个匹配的组。正则表达式应该是\b(\d{3})(\d{3})(\d{4})\b。开头和结尾的\b表示您只对匹配单词边界的十位数感兴趣。以下代码片段说明了如何显示格式化的电话号码:

// Prepare the regular expression
String regex = "\\b(\\d{3})(\\d{3})(\\d{4})\\b";
// Compile the regular expression
Pattern p = Pattern.compile(regex);
String source = "3342449027, 2339829, and 6152534734";
// Get Matcher object
Matcher m = p.matcher(source);
// Start match and display formatted phone numbers
while (m.find()) {
    System.out.printf("Phone: %s, Formatted Phone: (%s) %s-%s%n",
            m.group(), m.group(1), m.group(2), m.group(3));
}
Phone: 3342449027, Formatted Phone: (334) 244-9027
Phone: 6152534734, Formatted Phone: (615) 253-4734

您也可以用格式化的电话号码替换输入文本中的所有十位数电话号码。您已经学习了如何使用String类的replaceAll()方法用另一个文本替换匹配的文本。Matcher类也有一个replaceAll()方法,它完成同样的事情。在用格式化的电话号码替换电话号码时,您面临的问题是获取匹配电话号码的匹配部分。在这种情况下,替换文本也包含匹配的文本。您事先不知道什么文本与模式匹配。团体来拯救你。$n,其中n为组号,内部替换文本为组n的匹配文本。例如,$1指第一个匹配的组。用格式化的电话号码替换电话号码的替换文本将是($1) $2-$3。清单 18-6 展示了在替换文本中引用组的技术。

// MatchAndReplace.java
package com.jdojo.regex;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MatchAndReplace {
    public static void main(String[] args) {
        // Prepare the regular expression
        String regex = "\\b(\\d{3})(\\d{3})(\\d{4})\\b";
        String replacementText = "($1) $2-$3";
        String source = "3342449027, 2339829, and 6152534734";
        // Compile the regular expression
        Pattern p = Pattern.compile(regex);
        // Get Matcher object
        Matcher m = p.matcher(source);
        // Replace the phone numbers by formatted phone numbers
        String formattedSource = m.replaceAll(replacementText);
        System.out.printf("Text: %s%n", source );
        System.out.printf("Formatted Text: %s%n", formattedSource );
    }
}
Text: 3342449027, 2339829, and 6152534734
Formatted Text: (334) 244-9027, 2339829, and (615) 253-4734

Listing 18-6Back Referencing a Group in Replacement Text

您也可以通过使用String类获得相同的结果。你根本不需要使用PatternMatcher类。下面的代码片段说明了相同的概念,但是使用了String类。String类在内部使用PatternMatcher类来获得结果:

// Prepare the regular expression
String regex = "\\b(\\d{3})(\\d{3})(\\d{4})\\b";
String replacementText = "($1) $2-$3";
String source = "3342449027, 2339829, and 6152534734";
// Use replaceAll() method of the String class
String formattedSource = source.replaceAll(regex, replacementText)

Matcher类包含以下replaceAll()replaceFirst()方法:

  • 字符串替换全部(字符串替换)

  • 字符串 replaceAll(函数 replacer)

  • 字符串替换优先(字符串替换)

  • 字符串 replaceFirst(函数 replacer)

    提示replaceAll(Function<MatchResult,String> replacer)replaceFirst(Function<MatchResult,String> replacer)方法被添加到 Java 9 的Matcher类中。

正如我们在本节中解释的那样,replaceAll(String)replaceFirst(String)方法的工作原理与String类中同名的方法相同。其他版本以一个Function<MatchResult,String>作为参数。Function接受一个MatchResult并返回一个替换字符串。这些方法让您有机会使用您在Function中的逻辑来获得替换字符串。在执行查找和替换之前,这四种方法都首先重置匹配器。Function<T,R>java.util.function包中的一个接口。我们将在第二十章中详细讨论Function接口。

假设您想在一个输入字符串中查找十位数的电话号码,并且您想用区号 334 屏蔽所有的电话号码。比如一个电话号码是 3342449027,你想用(***) ***-****代替。您可以在Matcher类中使用新的replaceAll()方法来实现。清单 18-7 包含完整的程序。

// MaskAndFormat.java
package com.jdojo.regex;
import java.util.regex.MatchResult;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MaskAndFormat {
    public static void main(String[] args) {
        // Prepare the regular expression
        String regex = "\\b(\\d{3})(\\d{3})(\\d{4})\\b";
        String source = "3342449027, 2339829, and 6152534734";
        // Compile the regular expression
        Pattern p = Pattern.compile(regex);
        // Get Matcher object
        Matcher m = p.matcher(source);
        // Replace the phone numbers by formatted phone numbers
        String formattedSource = m.replaceAll(MaskAndFormat::mask);
        System.out.printf("Text: %s%n", source );
        System.out.printf("Formatted Text: %s%n", formattedSource );
    }
    private static String mask(MatchResult result) {
        String replacementText = "($1) $2-$3";
        String areaCode = result.group(1);
        if("334".equals(areaCode)) {
            replacementText = "(***) ***-****";
        }
        return replacementText;
    }
}
Text: 3342449027, 2339829, and 6152534734
Formatted Text: (***) ***-****, 2339829, and (615) 253-4734

Listing 18-7Using Logic to Mask or Format Phone Number Depending on the Area Code

注意以下语句在main()方法中的使用:

String formattedSource = m.replaceAll(MaskAndFormat::mask);

replaceAll()方法的参数是MaskAndFormat::mask,它是对MaskAndFormat类的mask()静态方法的方法引用。当找到匹配时,MatchResult被传递给mask()方法,从该方法返回的字符串被用作替换文本。注意您如何在mask()方法中用区号 334 屏蔽了电话号码。所有其他区号都使用与上一示例中相同的替换文字。

使用命名组

在一个大的正则表达式中使用组号很麻烦。Java 也支持命名组。您可以使用组名做任何事情,就像您在上一节中使用组号所做的那样:

  • 您可以命名一个组。

  • 您可以使用名称支持引用组。

  • 您可以在替换文本中引用组名。

  • 您可以使用组名获得匹配的文本。

和前面一样,您需要使用一对括号来创建一个组。开始括号后面是一个?和一个放在尖括号中的组名。定义命名组的格式如下:

(?<groupName>pattern)

群组名称必须仅由字母和数字组成:azAZ09。组名必须以字母开头。以下是使用三个命名组的正则表达式的示例。组名为areaCodeprefixlineNumber。正则表达式匹配一个十位数的电话号码:

\b(?<areaCode>\d{3})(?<prefix>\d{3})(?<lineNumber>\d{4})\b

您可以使用\k<groupName>反向引用名为groupName的组。电话号码中的区号和前缀部分使用相同的模式。您可以将前面反向引用areaCode组的正则表达式重写如下:

\b(?<areaCode>\d{3})\k<areaCode>(?<lineNumber>\d{4})\b

您可以在替换文本中引用一个命名组作为${groupName}。下面的代码片段显示了一个正则表达式,其中包含三个命名组以及使用它们的名称引用这三个组的替换文本:

String regex = "\\b(?<areaCode>\\d{3})(?<prefix>\\d{3})(?<lineNumber>\\d{4})\\b";
String replacementText = "(${areaCode}) ${prefix}-${lineNumber}";

当您命名一个组时,该组仍然会获得一个组号,如前一节所述。即使一个组有名称,您仍然可以通过它的组号来引用它。前面的代码片段重写如下,其中第三个组已被命名为lineNumber,在替换文本中使用其组号$3进行引用:

String regex = "\\b(?<areaCode>\\d{3})(?<prefix>\\d{3})(?<lineNumber>\\d{4})\\b";
String replacementText = "(${areaCode}) ${prefix}-$3";

匹配成功后,您可以使用Matcher类的group(String groupName)方法来获取该组的匹配文本。

清单 18-8 展示了如何在正则表达式中使用组名,以及如何在替换文本中使用组名。

// NamedGroups.java
package com.jdojo.regex;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class NamedGroups {
    public static void main(String[] args) {
        // Prepare the regular expression
        String regex =
            "\\b(?<areaCode>\\d{3})(?<prefix>\\d{3})(?<lineNumber>\\d{4})\\b";
        // Reference first two groups by names and the third one as its number
        String replacementText = "(${areaCode}) ${prefix}-$3";
        String source = "3342449027, 2339829, and 6152534734";
        // Compile the regular expression
        Pattern p = Pattern.compile(regex);
        // Get Matcher object
        Matcher m = p.matcher(source);
        // Replace the phone numbers by formatted phone numbers
        String formattedSource = m.replaceAll(replacementText);
        System.out.printf("Text: %s%n", source);
        System.out.printf("Formatted Text: %s%n", formattedSource);
    }
}
Text: 3342449027, 2339829, and 6152534734
Formatted Text: (334) 244-9027, 2339829, and (615) 253-4734

Listing 18-8Using Named Groups in Regular Expressions

在使用Matcher类的find()方法成功匹配之后,您可以使用它的start()end()方法来知道组的匹配边界。这些方法是重载的:

  • int start()

  • int start(int groupNumber)

  • int start(String groupName)

  • int end()

  • int end(int groupNumber)

  • int end(String groupName)

不带参数的方法返回前一个匹配的起点和终点。其他两组方法返回前一个匹配中一个组的开始和结束。下面的代码片段使用了前面的例子,在一个字符串中匹配一个十位数的电话号码。它打印每个成功匹配的每个组的开始:

// Prepare the regular expression
String regex = "\\b(?<areaCode>\\d{3})(?<prefix>\\d{3})(?<lineNumber>\\d{4})\\b";
String source = "3342449027, 2339829, and 6152534734";
System.out.println("Source Text: " + source);
// Compile the regular expression
Pattern p = Pattern.compile(regex);
// Get Matcher object
Matcher m = p.matcher(source);
while(m.find()) {
    String matchedText = m.group();
    int start1 = m.start("areaCode");
    int start2 = m.start("prefix");
    int start3 = m.start("lineNumber");
    System.out.printf("Matched Text: %s", matchedText);
    System.out.printf(". Area code start: %d", start1);
    System.out.printf(", Prefix start: %d", start2);
    System.out.printf(", Line Number start: %d%n", start3);
}
Source Text: 3342449027, 2339829, and 6152534734
Matched Text: 3342449027\. Area code start: 0, Prefix start: 3, Line Number start: 6
Matched Text: 6152534734\. Area code start: 25, Prefix start: 28, Line Number start: 31

重置匹配器

如果您已经完成了对输入文本的模式匹配,并且想要再次从输入文本的开头重新开始匹配,那么您需要使用Matcher类的reset()方法。在调用了reset()方法之后,下一个匹配模式的调用将从输入文本的开头开始。reset()方法被重载。另一个版本允许您将不同的输入文本与模式相关联。如果模式保持不变,这两个版本的reset()方法允许您重用任何现有的Matcher类实例。通过避免重新创建一个新的Matcher对象来执行相同模式的匹配,这增强了程序的性能。

电子邮件验证的最终结论

您现在已经学习了正则表达式的主要部分。您已经准备好完成您的电子邮件地址验证示例。请注意,我们只验证电子邮件地址的格式,而不验证它是否指向有效的电子邮件收件箱。您的电子邮件地址将根据以下规则进行验证:

  • 所有电子邮件地址都将采用name@domain的形式。

  • 名称部分必须以字母数字字符(a-z, A-Z, 0-9)开头。

  • 名称部分必须至少有一个字符。

  • 名称部分可以包含任何字母数字字符(a-z, A-Z, 0-9)、下划线、连字符或点号。

  • 域部分必须至少包含一个点。

  • 域部分中的点的前后必须至少有一个字母数字字符。

  • 您还应该能够使用组号来引用名称和域部分。这种验证表明,您将名称和域部分作为组放在正则表达式中。

以下正则表达式将根据这些规则匹配一个电子邮件地址。组 1 是名称部分,而组 2 是域部分:

([a-zA-Z0-9]+[\\w\\-.]*)@([a-zA-Z0-9]+\\.[a-zA-Z0-9\\-.]+)

添加的验证越多,正则表达式就越复杂。鼓励读者为电子邮件地址添加更多的验证,并相应地修改前面的正则表达式。这个正则表达式允许域部分有两个连续的点。你会如何阻止?

使用正则表达式查找并替换

查找和替换是正则表达式支持的一种非常强大的技术。有时您可能需要找到一个模式,并根据它匹配的文本替换它;也就是说,基于一些条件来决定替换文本。Java 正则表达式设计者看到了这种需求,他们在Matcher类中包含了两个方法,让您可以完成这项任务:

  • Matcher appendReplacement(StringBuffer sb, String replacement)

  • Matcher appendReplacement(StringBuilder sb, String replacement)

  • StringBuffer appendTail(StringBuffer sb)

  • StringBuffer appendTail(StringBuilder sb)

    提示Java 9 中增加了与StringBuilder一起工作的appendReplacement()appendTail()方法的版本。

考虑以下文本:

一列载有 125 名男女的火车正以每小时 100 英里的速度行驶。火车票价是每人 75 美元。”

您想要查找文本中的所有数字(例如,125、100 和 75)并替换它们,如下所示:

  • 一百乘以一百

  • “> 100”改为“超过 100”

  • “不足一百”改为“不足一百”

替换后,该案文应为:

一辆载有 100 多名男女的火车正以每小时 100 英里的速度行驶。火车票价每人不到 100 美元。”

要完成这项任务,您需要找到文本中嵌入的所有数字,将找到的数字与 100 进行比较,并决定替换文本。使用文本编辑器查找和替换文本时也会出现这种情况。文本编辑器突出显示您正在搜索的单词,您输入一个新单词,文本编辑器会为您进行替换。您也可以使用这两种方法创建一个在文本编辑器中找到的查找/替换程序。通常,这些方法与Matcher类的find()方法一起使用。下面概述了使用这两种方法完成文本查找和替换的步骤:

  1. 创建一个Pattern对象。

  2. 创建一个Matcher对象。

  3. 创建一个StringBuffer/StringBuilder对象来保存结果。

  4. 在循环中使用find()方法来匹配模式。

  5. 根据找到的匹配位置调用appendReplacement()appendTail()方法。

让我们通过编译正则表达式来创建一个Pattern。因为您想要查找所有的数字,所以您的正则表达式应该是\b\d+\b。注意第一个和最后一个\b。他们指出你只对单词边界上的数字感兴趣:

String regex = "\\b\\d+\\b"; Pattern p = Pattern.compile(regex);

通过将图案与文本相关联来创建一个Matcher:

String text = "A train carrying 125 men and women was traveling" +
              " at the speed of 100 miles per hour. The train" +
              " fare was 75 dollars per person.";
Matcher m = p.matcher(text);

创建一个StringBuilder来保存新文本:

StringBuilder sb = new StringBuilder();

开始在Matcher对象上使用find()方法来寻找匹配。当您第一次调用find()方法时,数字 125 将与模式匹配。此时,您希望根据匹配的文本准备替换文本,如下所示

String replacementText = "";
// Get the matched text. Recall that group() method returns the whole matched text
String matchedText = m.group();
// Convert the text into integer for comparison
int num = Integer.parseInt(matchedText);
// Prepare the replacement text
if (num == 100) {
    replacementText = "a hundred";
} else if (num < 100) {
    replacementText = "less than a hundred";
} else {
    replacementText = "more than a hundred";
}

现在,您将在Matcher对象上调用appendReplacement()方法,传递一个空的StringBuilderreplacementText作为参数。在本例中,replacementText有一个字符串"more than hundred",因为find()方法调用匹配数字 125:

m.appendReplacement(sb, replacementText);

知道appendReplacement()方法调用做什么是很有趣的。它检查是否有先前的匹配。因为这是对find()方法的第一次调用,所以没有先前的匹配。对于第一次匹配,它从输入文本的开头开始追加文本,直到匹配文本之前的字符。在您的情况下,以下文本被附加到StringBuilder。此时,StringBuilder中的文字是

"A train carrying "

现在,appendReplacement()方法将replacementText参数中的文本追加到StringBuilder中。这将把StringBuilder的内容改为

"A train carrying more than a hundred"

appendReplacement()方法还做了一件事。它将追加位置(即Matcher对象的内部状态)设置为第一个匹配文本之后的字符位置。在您的情况下,追加位置将被设置为 125 后面的字符,这是 125 后面的空格字符的位置。这就完成了第一个查找和替换步骤。

您将再次调用Matcher对象的find()方法。它会找到模式,也就是另一个数,是 100。您将使用与第一次匹配后相同的过程来计算替换文本的值。这一次,replacementText将包含字符串"a hundred"。您调用appendReplacement()方法如下:

m.appendReplacement(sb, replacementText);

同样,它检查是否有先前的匹配。由于这是对find()方法的第二次调用,它将找到一个先前的匹配,并将使用上一次appendReplacement()调用保存的追加位置作为起始位置。要追加的最后一个字符将是第二次匹配之前的字符。它还会将追加位置设置为数字 100 后面的字符位置。此时,StringBuilder包含以下文字:

"A train carrying more than a hundred men and women was traveling at the speed of a hundred"

第三次调用find()方法时,它会找到数字 75,替换后的StringBuilder内容如下。追加位置将被设置到数字75后面的字符位置:

"A train carrying more than a hundred men and women was traveling at the speed of a hundred miles per hour. The train fare was less than a hundred"

如果再次调用find()方法,它将找不到任何匹配。然而,StringBuilder不包含最后一个匹配之后的文本,也就是“dollars per person."”要追加最后一个匹配之后的文本,需要调用appendTail()方法。它从追加位置开始向StringBuilder追加文本,直到输入字符串结束。对此方法的调用

m.appendTail(sb);

StringBuilder修改成这样:

"A train carrying more than a hundred men and women was traveling at the speed of a hundred miles per hour. The train fare was less than a hundred dollars per person."

如果您在第二次调用appendReplacement()方法之后调用了appendTail()方法,那么StringBuilder的内容会是什么?完整的程序如清单 18-9 所示。

// AdvancedFindReplace.java
package com.jdojo.regex;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class AdvancedFindReplace {
    public static void main(String[] args) {
        String regex = "\\b\\d+\\b";
        StringBuilder sb = new StringBuilder();
        String text = "A train carrying 125 men and women was traveling at"
                + " the speed of 100 miles per hour. "
                + "The train fare was 75 dollars per person.";
        Pattern p = Pattern.compile(regex);
        Matcher m = p.matcher(text);
        while (m.find()) {
            String matchedText = m.group();
            // Convert the text into an integer for comparing
            int num = Integer.parseInt(matchedText);
            // Prepare the replacement text
            String replacementText;
            if (num == 100) {
                replacementText = "a hundred";
            } else if (num < 100) {
                replacementText = "less than a hundred";
            } else {
                replacementText = "more than a hundred";
            }
            m.appendReplacement(sb, replacementText);
        }
        // Append the tail
        m.appendTail(sb);
        // Display the old and new text
        System.out.printf("Old Text: %s%n", text);
        System.out.printf("New Text: %s%n", sb.toString());
    }
}
Old Text: A train carrying 125 men and women was traveling at the speed of 100 miles per hour. The train fare was 75 dollars per person.
New Text: A train carrying more than a hundred men and women was traveling at the speed of a hundred miles per hour. The train fare was less than a hundred dollars per person.

Listing 18-9Find-and-Replace Using Regular Expressions and appendReplacement() and appendTail() Methods

匹配结果流

Matcher类中有一个方法返回一个MatchResult流:

Stream<MatchResult> results()

Streams API 是一个庞大的主题,我们在第十六章中简单提到过。它允许您对数据流应用过滤-映射-归约操作。我们给出了一个使用results()方法来完成对Matcher类的讨论的例子。如果您在理解本节中的示例时有困难,请在阅读完 Streams API 之后重新阅读本节。

results()方法在一个元素属于MatchResult类型的流中返回匹配结果。您可以查询MatchResult获取比赛详情。results()方法不会重置匹配器。如果你想重用匹配器,不要忘记调用它的reset()方法来重置它到一个期望的位置。当您使用results()方法时,诸如计算匹配数、获取匹配列表和查找不同的区号等操作变得很容易。清单 18-10 展示了这种方法的一些有趣的用法。它在输入字符串中搜索十位数或七位数的电话号码。它获取所有格式化的匹配电话号码的列表。在第二个示例中,它在匹配结果中打印一组不同的区号。

// DistinctAreaCode.java
package com.jdojo.regex;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
public class DistinctAreaCode {
    public static void main(String[] args) {
        // A regex to match 7-digit or 10-digit phone numbers
        String regex = "\\b(\\d{3})?(\\d{3})(\\d{4})\\b";
        // An input string
        String source = "1, 3342229999, 2330001, 6159996666, 123, 3340909090";
        System.out.println("Input: " + source);
        // Create a matcher
        Matcher matcher = Pattern.compile(regex)
                .matcher(source);
        // Collect formatted phone numbers into a list
        List<String> phones = matcher.results()
                .map(mr -> (mr.group(1) == null ? "" : "(" + mr.group(1) + ") ")
                      + mr.group(2) + "-" + mr.group(3))
                .collect(toList());
        System.out.println("Phones: " + phones);
        // Reset the matcher, so we can reuse it from start
        matcher.reset();
        // Get distinct area codes
        Set<String> areaCodes = matcher.results()
                .filter(mr -> mr.group(1) != null)
                .map(mr -> mr.group(1))
                .collect(toSet());
        System.out.println("Distinct Area Codes: " + areaCodes);
    }
}
Input: 1, 3342229999, 2330001, 6159996666, 123, 3340909090
Phones: [(334) 222-9999, 233-0001, (615) 999-6666, (334) 090-9090]
Distinct Area Codes: [334, 615]

Listing 18-10Using the results() Method of the Matcher Class

main()方法中,下面的正则表达式将匹配七位数或十位数的电话号码:

// A regex to match 7-digit or 10-digit phone numbers
String regex = "\\b(\\d{3})?(\\d{3})(\\d{4})\\b";

您想将一个十位数的电话号码格式化为(xxx) xxx-xxxx,将一个七位数的电话号码格式化为xxx-xxxx。最后,您希望将所有格式化的电话号码收集到一个List<String>中。“Collect”是一个终端操作,它接受一个收集器作为参数;如示例所示,我们导入了两个方法,这两个方法提供了收集器 toList()和 toSet()。以下语句执行此操作:

 // Collect formatted phone numbers into a list
 List<String> phones = matcher.results()
                        .map(mr -> (mr.group(1) == null ? "" : "(" + mr.group(1) + ") ")
                                    + mr.group(2) + "-" + mr.group(3))
                        .collect(toList());

请注意map()方法的使用,它接受一个MatchResult并返回一个格式化的电话号码作为一个String。当匹配的是一个七位数的电话号码时,组 1 将是null。现在,您希望重用匹配器来查找十位数电话号码中不同的区号。您必须重置匹配器,以便下一个匹配从输入字符串的开头开始:

// Reset the matcher, so we can reuse it from start
matcher.reset();

MatchResult中的第一组包含区号。您需要过滤掉七位数的电话号码,并将 group 1 的值收集到一个Set<String>中,以获得一组不同的区号。下面的语句可以做到这一点:

// Get distinct area codes
Set<String> areaCodes = matcher.results()
                               .filter(mr -> mr.group(1) != null)
                               .map(mr -> mr.group(1))
                               .collect(toSet());

摘要

正则表达式是用作匹配某些文本的模式的字符序列。Java 通过java.util.regex包中的PatternMatcher类提供了对使用正则表达式的全面支持。在String类中有几种使用正则表达式的方便方法。

一个Pattern对象代表一个编译过的正则表达式。一个Matcher对象用于将一个Pattern与一个输入文本相关联,以搜索模式。Matcher类的find()方法用于在输入文本中查找模式的匹配。正则表达式允许您使用组。群组会自动从 1 到 n 编号。从左起的第一个群组编号为 1。存在包含整个正则表达式的特殊组 0。您也可以给组命名。您可以通过编号或名称来引用组。

Java 9 给Matcher类增加了一些有用的方法。replaceAll()replaceFirst()方法被重载;现在他们用一个Function<MatchResult,String>作为匹配结果的替换符,允许您使用任何逻辑来生成匹配的替换文本。results()方法返回一个Stream<MatchResult>,允许您将操作流式传输到匹配的结果。

QUESTIONS AND EXERCISES

  1. 什么是正则表达式?

  2. 什么是元字符?如何在正则表达式中将元字符作为普通字符使用?

  3. 你用什么类来编译一个模式?

  4. 你用什么类来匹配一个编译模式?

  5. 正则表达式"[aieou]"是什么意思?会和字符串"Hello"匹配吗?

  6. 编写一个正则表达式,它将匹配任何以小写辅音开头,后跟一个或多个小写元音,再后跟一个小写辅音的单词。例如,它应该匹配猫,狗,酷,小床,厄运,认为等。,但不是可乐,猫,鱼,冷等。

  7. 以下代码片段的输出会是什么:

    String source = "I saw the rat running.";
    String regex = "r..";
    Pattern p = Pattern.compile(regex);
    Matcher m = p.matcher(source);
    while(m.find()) {
        System.out.println(m.group());
    }
    
    
  8. 完成下面的代码片段,它将匹配输入中的两个单词— catcot。当代码运行时,它应该在两行上打印出catcot:

    String source = "cat camera can pen cow cab cot";
    String regex = /* Your code goes here */;
    Pattern p = Pattern.compile(regex);
    Matcher m = /* Your code goes here */;
    while(m.find()) {
        System.out.println(m.group());
    }
    
    
  9. 完成下面的代码片段,将所有以c开头的三个字母的单词替换为大写字母。代码应该打印"CAT camera CAN pen COW CAB COT"

    String source = "cat camera can pen cow cab cot";
    String regex = "/* You code goes here*/";
    Pattern p = Pattern.compile(regex);
    Matcher m = p.matcher(source);
    String str = m.replaceAll(mr -> mr.group().toUpperCase());
    System.out.println(str);
    
    
  10. 编写以下代码片段的输出:

```java
String source = "ABXXXABB";
String regex = "AB*";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
String str = m.replaceAll("Hello");
System.out.println(str);

```
  1. 编写以下代码片段的输出:
```java
String source = "ABXXXABB";
String regex = "AB?";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
String str = m.replaceAll("Hello");
System.out.println(str);

```
  1. 编写以下代码片段的输出:
```java
String source = "ABXXXABB";
String regex = "AB+";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
String str = m.replaceAll("Hello");
System.out.println(str);

```
  1. 描述以下代码片段的意图并写出输出:
```java
String source = "I have 25 cents and 400 books.";
String regex = "\\b(\\d+)\\b";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
int sum = m.results()
           .mapToInt(mr -> Integer.parseInt(mr.group()))
           .sum();
System.out.println(sum);

```
  1. 以下正则表达式中有多少个组:
```java
String regex = "\\b((\\d{3})(\\d{3})(\\d{4}))|((\\d{3})(\\d{4}))\\b";

```
  1. 完成以下代码片段,以 xxx-xxxx 和(xxx) xxx-xxxx 格式打印七位数和十位数的电话号码。输出应该是"(334) 233-0908, 233-7656, 234, (617) 908-6547, unknown" :
```java
String source = "3342330908, 2337656, 234, 6179086547, unknown";
String regex = "/* Your code goes here*/";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
StringBuilder sb = new StringBuilder();
while(m.find()) {
    String replacement =
        m.group(1) != null ? /* Your code goes here*/;
    m.appendReplacement(sb, replacement);
}
m.appendTail(sb);
System.out.println(sb.toString());

```
  1. 完成下面的代码片段,它将在单独的一行上打印源字符串中的每个单词:
```java
String source = "bug dug jug mug tug";
String regex = "/*your code goes here*/";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
while(m.find()) {
   System.out.println(m.group());
}

```
  1. 下面的代码片段试图计算并打印问号(?)在输入字符串中。完成下面的代码片段,因此输出是3 :
```java
String source = "What? How? I do not know. Why?";
String regex = "/* Your code goes here */";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(source);
long questionMarkCount = m.results().count();
System.out.println(questionMarkCount);

```

十九、数组

在本章中,您将学习:

  • 如何声明数组类型的变量

  • 如何创建数组

  • 如何访问数组的元素

  • 如何使用一个for循环和一个for - each循环来遍历一个数组的元素

  • 如何将一个数组的元素复制到另一个数组

  • 如何复制基元和引用类型的数组

  • 如何使用多维数组

  • 需要变长数组时如何使用ArrayList

  • 如何将一个ArrayList的元素转换成一个数组,反之亦然

  • 如何执行与数组相关的操作,如对数组元素排序、比较两个数组、在数组中执行二分搜索法、用值填充数组、获取数组的字符串表示等。

本章中的所有示例程序都是清单 19-1 中声明的jdojo.array模块的成员。

// module-info.java
module jdojo.array {
    exports com.jdojo.array;
}

Listing 19-1The Declaration of a jdojo.array Module

什么是数组?

数组是一种固定长度的数据结构,用于保存同一数据类型的多个值。让我们考虑一个例子,它将解释为什么我们需要数组。假设您被要求声明变量来保存三个雇员的雇员 id。员工 id 将是整数。保存三个整数值的变量声明如下所示:

int empId1, empId2, empId3;

如果员工人数增加到五人,你会怎么做?您可以修改变量声明,如下所示:

int empId1, empId2, empId3, empId4, empId5;

如果员工人数增加到 1000 人,你会怎么做?你肯定不想声明 1000 个int变量,比如empId1empId2...empId1000。即使你这样做了,产生的代码也是难以管理和笨拙的。在这种情况下,数组可以帮助您。使用数组,您可以声明一个类型的变量,该变量可以包含任意多的该类型的值。事实上,Java 对数组可以容纳的值的数量有限制。一个数组最多可以容纳 2,147,483,647 个值,这是int数据类型的最大值。

什么使变量成为数组?在变量声明中,将[](空括号)放在数据类型之后或变量名之后,使变量成为数组。例如,

int empId;

是一个简单的变量声明。这里,int是数据类型,empId是变量名。这个声明意味着empId变量可以保存一个整数值。将[]放在前面声明中的数据类型之后,如

int[] empId;

使empId成为数组变量。前面的声明读作“empId是一个int的数组。”您也可以通过在变量名称后放置[]来使empId变量成为一个数组,如下所示:

int empId[];

这两个声明都是有效的。本书使用第一个约定来声明数组。我们从一个保存三个雇员 id 的变量声明的例子开始讨论。到目前为止,您已经为在一个变量中保存多个值做好了准备。也就是说,声明为一个int数组的empId变量能够保存多个int值。你的empId数组变量可以容纳多少个值?答案是你还不知道。在声明数组时,不能指定数组可以容纳的值的数量。随后的部分解释了如何指定一个数组可以容纳的值的数量。您可以声明基元类型的数组以及引用类型的数组。以下是数组声明的更多示例:

// salary can hold multiple float values
float[] salary;
// name can hold multiple references to String objects
String[] name;
// emp can hold multiple references to Employee objects
Employee[] emp;

Tip

数组是固定长度的数据结构,用于存储相同类型的数据项。数组的所有元素都连续存储在内存中。

数组是对象

Java 中的数组是一个对象。Java 中的每个对象都属于一个类;每个数组对象也是如此。您可以使用new操作符创建一个数组对象。您已经使用了带有构造器的new操作符来创建一个类的对象。构造器的名称与类名相同。数组对象的类的名字是什么?这个问题的答案不是那么明显。我们将在本章后面回答这个问题。

现在,让我们专注于如何创建一个特定类型的数组对象。数组创建表达式的一般语法如下:

new <array-data-type>[<array-length>];

数组对象创建表达式以new操作符开始,后跟要存储在数组中的值的数据类型,再跟一个用[](括号)括起来的整数,这是数组中元素的数目。例如,您现在可以创建一个数组来存储五个int值,如下所示:

new int[5];

在这个表达式中,5是数组的长度(也称为数组的维数)。“维度”一词也用于另一个上下文中。您可以拥有一维、二维、三维或多维数组。具有一个以上维度的数组称为多维数组。我们将在本章后面讨论多维数组。在本书中,我把前面表达式中的5称为数组的长度,而不是数组的维数。

注意,前面的表达式在内存中创建了一个数组对象,它分配内存来存储五个整数。new操作符返回内存中新对象的引用。如果要在代码中稍后使用该对象,必须将该引用存储在对象引用变量中。引用变量类型必须匹配由new操作符返回的对象引用的类型。在前一种情况下,new操作符将返回一个int数组类型的对象引用。您已经看到了如何声明一个int数组类型的引用变量。现声明如下:

int[] empIds;

要在empId中存储数组对象引用,您可以这样写:

empIds = new int[5];

您也可以将数组的声明及其创建合并到一条语句中,如下所示:

int[] empIds = new int[5];

由于数组的类型可以从初始化中解释,如果这是一个局部变量,您可以使用局部类型推断来避免重复,如下所示:

var empIds = new int[5];

如何创建一个数组来存储 252 个员工 id?您可以这样做:

var empIds = new int[252];

创建数组时,也可以使用表达式来指定数组的长度:

int total = 23;
int[] array1 = new int[total];     // array1 has 23 elements
int[] array2 = new int[total * 3]; // array2 has 69 elements

因为所有数组都是对象,所以它们的引用可以赋给一个Object类型的引用变量,例如:

int[] empId = new int[5]; // Create an array object
Object obj = empId;       // A valid assignment

但是,如果您在一个Object类型的引用变量中引用了一个数组,那么在将它赋给一个数组引用变量或通过索引访问元素之前,您需要将它转换为适当的数组类型。记住每个数组都是一个对象。然而,并不是每个对象都一定是数组:

// Assume that obj is a reference of the Object type that holds a reference of int[]
int[] tempIds = (int[]) obj;

访问数组元素

一旦使用new操作符创建了一个数组对象,就可以使用括号中的元素索引来引用数组中的每个元素。第一个元素的索引是 0,第二个元素是 1,第三个元素是 2,依此类推。这被称为从零开始的索引。数组最后一个元素的索引是数组长度减 1。如果您有一个长度为 5 的数组,数组元素的索引将是 0、1、2、3 和 4。考虑以下语句:

int[] empId = new int[5];

empId数组的长度为5;其要素可分为empId[0]empId[1]empId[2]empId[3]empId[4]

如果引用数组中不存在的元素,将会导致运行时错误。例如,在您的代码中使用empId[5]会抛出异常,因为empId的长度为5,而empId[5]引用的是不存在的第六个元素。可以为数组元素赋值,如下所示:

empId[0] = 10;  // Assign 10 to the first element of empId
empId[1] = 20;  // Assign 20 to the second element of empId
empId[2] = 30;  // Assign 30 to the third element of empId
empId[3] = 40;  // Assign 40 to the fourth element of empId
empId[4] = 50;  // Assign 50 to the fifth element of empId

表 19-1 显示了一个数组的详细信息。它显示执行语句后数组元素的索引、值和引用。

表 19-1

empId 数组在内存中的数组元素

|

元素的索引

|

0

|

1

|

2

|

3

|

4

元素的值 10 20 30 40 50
元素的引用 empId[0] empId[1] empId[2] empId[3] empId[4]

以下语句将empId数组的第三个元素的值赋给一个int变量temp:

int temp = empId[2]; // Assigns 30 to temp

数组长度

一个数组对象有一个名为lengthpublic final实例变量,它包含数组中元素的数量:

int[] empId = new int[5];  // Create an array of length 5
int len = empId.length;    // 5 will be assigned to len

注意length是你创建的数组对象的属性。在创建数组对象之前,不能使用它的length属性。以下代码片段说明了这一点:

// salary is a reference variable, which can refer to an array of int.
// At this point, it contains null. That is, it is not referencing a valid object.
int[] salary = null;
// A runtime error. salary is not referring to any array object yet.
int len = salary.length;
// Create an int array of length 1000 and assign its reference to salary
salary = new int[1000];
// Correct. len2 has a value 1000
int len2 = salary.length;

通常,使用循环来访问数组元素。如果您想对数组的所有元素进行任何处理,您可以执行一个从索引 0(零)到长度减 1 的循环。例如,要将值 10、20、30、40 和 50 赋给长度为5empId数组的元素,可以执行如下所示的for循环:

for (int i = 0 ; i < empId.length; i++) {
    empId[i] = (i + 1) * 10;
}

值得注意的是,在执行循环时,循环条件必须检查数组索引/下标是否小于数组长度,如"i < empId.length"所示,因为数组索引从 0 开始,而不是从 1 开始。程序员在使用for循环处理数组时犯的另一个常见错误是从 1 开始循环计数器,而不是从 0 开始。如果把前面代码中for循环的初始化部分从int i = 0改成int i = 1会怎么样?它不会给你任何错误。但是,第一个元素empId[0]不会被处理,也不会被赋值为 10。

数组创建后,不能更改其长度。您可能想修改length属性:

int[] roll = new int[5]; // Create an array of 5 elements
// A compile-time error. The length property of an array is final. You cannot modify it.
roll.length = 10;

你可以有一个零长度的数组。这样的数组称为空数组:

// Create an array of length zero
int[] emptyArray = new int[0];
// Will assign zero to len
int len = emptyArray.length;

Tip

数组使用从零开始的索引。也就是说,数组的第一个元素的索引为零。数组在运行时动态创建。创建数组后,不能修改数组的长度。如果需要修改数组的长度,必须创建一个新数组,并将旧数组中的元素复制到新数组中。数组的长度可以为零。

初始化数组元素

回想一下第七章中的内容,与类成员变量(实例和静态变量)不同,局部变量在默认情况下是不初始化的。除非给局部变量赋值,否则不能访问局部变量。同样的规则也适用于空白的最终变量。编译器使用明确赋值的规则来确保所有变量在程序中使用它们的值之前已经被初始化。

不管数组创建的范围是什么,数组元素总是被初始化。原始数据类型的数组元素被初始化为其数据类型的默认值。例如,数字数组元素被初始化为零,boolean元素被初始化为false,char 元素被初始化为'\u0000'。引用类型的数组元素初始化为null。以下代码片段说明了数组初始化:

// intArray[0], intArray[1] and intArray[2] are initialized to zero by default.
int[] intArray = new int[3];
// bArray[0] and bArray[1] are initialized to false.
boolean[] bArray = new boolean[2];
// An example of a reference type array. strArray[0] and strArray[1] are
// initialized to null.
String[] strArray = new String[2]
// Another example of a reference type array.
// All 100 elements of the person array are initialized to null.
Person[] person = new Person[100];

清单 19-2 展示了一个实例变量和一些局部变量的数组初始化。

// ArrayInit.java
package com.jdojo.array;
public class ArrayInit {
    private final boolean[] bArray = new boolean[3];  // An instance variable
    public ArrayInit() {
        // Display the initial value for elements of the instance variable bArray
        for (int i = 0; i < bArray.length; i++) {
            System.out.println("bArray[" + i + "]:" + bArray[i]);
        }
    }
    public static void main(String[] args) {
        System.out.println("int array initialization:");
        int[] empId = new int[3];  // A local array variable
        for (int i = 0; i < empId.length; i++) {
            System.out.println("empId[" + i + "]:" + empId[i]);
        }
        System.out.println("\nboolean array initialization:");
        // Initial value for bArray elements are displayed inside the constructor
        new ArrayInit();
        System.out.println("\nReference type array initialization:");
        String[] name = new String[3];  // A local array variable
        for (int i = 0; i < name.length; i++) {
            System.out.println("name[" + i + "]:" + name[i]);
        }
    }
}
int array initialization:
empId[0]:0
empId[1]:0
empId[2]:0
boolean array initialization:
bArray[0]:false
bArray[1]:false
bArray[2]:false
Reference type array initialization:
name[0]:null
name[1]:null
name[2]:null

Listing 19-2Default Initialization of Array Elements

当心引用类型的数组

基元类型的数组元素包含该基元类型的值,而引用类型的数组元素包含对对象的引用。假设您有一个int数组:

int[] empId = new int[5];

这里,empId[0]empId[1]...empId[4]包含一个int值。假设你有一个String数组,像这样:

String[] name = new String[5];

这里,name[0], name[1]...name[4]可以包含对String对象的引用。注意,String对象,即name数组的元素,还没有被创建。正如上一节所讨论的,此时name数组的所有元素都包含null。您需要创建String对象并将它们的引用逐个分配给数组的元素,如下所示:

name[0] = "John";
name[1] = "Donna";
name[2] = "Wally";
name[3] = "Reddy";
name[4] = "Buddy";

一个常见的错误是,在创建数组之后,在为每个元素分配有效的对象引用之前,引用引用类型数组的元素。以下代码说明了这一常见错误:

// Create an array of String
String[] name = new String[5];
// A runtime error as name[0] is null
int len = name[0].length();
// Assign a valid string object to all elements of the array
name[0] = "John";
name[1] = "Donna";
name[2] = "Wally";
name[3] = "Reddy";
name[4] = "Buddy";
// Now you can get the length of the first element
int len2 = name[0].length(); // Correct. len2 has value 4

图 19-1 描述了String参考型数组的初始化概念。这个概念适用于所有引用类型。

img/323069_3_En_19_Fig1_HTML.png

图 19-1

引用类型数组初始化

数组的所有元素都连续存储在内存中。对于引用类型的数组,数组元素存储对象的引用。这些元素中的引用是连续存储的,而不是它们所引用的对象。对象存储在堆上;并且它们的位置通常不相邻。

显式数组初始化

当声明数组或使用new操作符创建数组对象时,可以显式初始化数组元素。元素的初始值由逗号分隔,并用大括号({})括起来:

// Initialize the array at the time of declaration
int[] empIds = {1, 2, 3, 4, 5};

这段代码创建一个长度为5int数组,并将其元素初始化为12345。请注意,在声明数组时指定数组初始化列表,并不指定数组的长度。数组的长度与数组初始化列表中指定的值的数量相同。这里,empId数组的长度将是5,因为您在初始化列表中传递了五个值{ 12345}。初始化列表中的最后一个值后面可以跟一个逗号:

int[] empIds = {1, 2, 3, 4, 5, }; // A comma after the last value 5 is valid.

或者,您可以初始化数组的元素,如下所示:

int[] empIds = new int[]{1, 2, 3, 4, 5};

如果正在定义局部变量,并且类型包含在初始化中,则还可以使用“var ”,如下所示:

var empIds = new int[]{1, 2, 3, 4, 5};

请注意,如果指定了数组初始化列表,则不能指定数组的长度。数组的长度与初始化列表中指定的值的数量相同。使用空初始化列表创建空数组是有效的:

int[] emptyNumList = { };

对于引用类型数组,可以在初始化列表中指定对象列表。下面的代码片段说明了StringAccount类型的数组初始化。假设Account类存在,并且它有一个构造器,该函数将一个账号作为参数:

// Create a String array with two Strings "Sara" and "Truman"
String[] names = {new String("Sara"), new String("Truman")};
// You can also use String literals
String[] names = {"Sara", "Truman"};
// Create an Account array with two Account objects
Account[] ac = new Account[]{new Account(1), new Account(2)};

Tip

当使用初始化列表初始化数组元素时,不能指定数组的长度。数组的长度被设置为初始化列表中值的数量。

使用数组的限制

Java 中的数组在创建后不能扩展或收缩。假设您有一个包含 100 个元素的数组,稍后,您只需要保留 15 个元素。你不能去掉剩下的 85 个元素。如果需要 135 个元素,就不能再追加 35 个元素。如果您的应用程序有足够的可用内存,您就可以处理第一个限制(内存不能释放给未使用的数组元素)。但是,如果需要向现有数组添加更多元素,就没有办法了。唯一的解决方案是创建另一个所需长度的数组,并将数组元素从原始数组复制到新数组中。有两种方法可以将数组元素从一个数组复制到另一个数组:

  • 使用循环

  • 使用java.lang.System类的static arraycopy()方法

  • 使用java.util.Arrays类的copyOf()方法

假设您有一个长度为originalLengthint数组,您想将其长度修改为newLength。您可以应用复制数组的第一种方法,如下面的代码片段所示:

int originalLength = 100;
int newLength = 15;
int[] ids = new int[originalLength];
// Do some processing here...
// Create a temporary array of new length
int[] tempIds = new int[newLength];
// While copying array elements we have to check if the new length
// is less than or greater than original length
int elementsToCopy = originalLength > newLength ? newLength : originalLength;
// Copy the elements from the original array to the new array
for (int i = 0; i < elementsToCopy; i++){
    tempIds[i] = ids[i];
}
// Finally assign the reference of new array to ids
ids = tempIds;

将一个数组的元素复制到另一个数组的另一种方法是使用System类的arraycopy()方法。arraycopy()的签名方法如下:

public static void arraycopy(Object sourceArray, int sourceStartPosition,
                             Object destinationArray,
                             int destinationStartPosition,
                             int lengthToBeCopied)

这里

  • sourceArray是对源数组的引用。

  • sourceStartPosition是源数组中开始复制元素的起始索引。

  • destinationArray是对目标数组的引用。

  • destinationStartPosition是目标数组中的起始索引,将从该处复制源数组中的新元素。

  • lengthToBeCopied是从源数组复制到目标数组的元素个数。

您可以用以下代码替换之前的for循环:

// Now copy array elements using the arraycopy() method
System.arraycopy (ids, 0, tempIds, 0, elementsToCopy);

也可以使用Arrays类的copyOf()静态方法。下面显示了一些copyOf()方法的声明:

  • boolean[] copyOf(boolean[] original, int newLength)

  • byte[] copyOf(byte[] original, int newLength)

  • char[] copyOf(char[] original, int newLength)

  • double[] copyOf(double[] original, int newLength)

  • float[] copyOf(float[] original, int newLength)

  • int[] copyOf(int[] original, int newLength)

  • short[] copyOf(long[] original, int newLength)

  • <T> T[] copyOf(T[] original, int newLength)

copyOf()方法的第一个参数是源数组。第二个参数newLength是新数组中元素的数量。如果newLength小于源数组的长度,返回的数组将是源数组的截断副本。如果newLength大于源数组的长度,那么返回的数组将包含原始数组中的所有元素,多余的元素将根据数组的数据类型设置默认值。如果newLength等于源数组的长度,则返回的数组包含与源数组相同数量的元素。

Tip

Arrays类包含一个copyOfRange()方法,让您将一组元素从一个数组复制到另一个数组。它对 int 数组的声明是int[] copyOfRange(int[] original, int from, int to)。该方法对所有数据类型都是重载的。这里,fromto是要复制的源数组中元素的初始索引(含)和最终索引(不含)。这些索引必须在源数组的范围内,这意味着目标数组的长度最大可以等于源数组的长度。

两个类的对象java.util.ArrayListjava.util.Vector可以用来代替数组,其中数组的长度需要修改。你可以把这两个类的对象想象成变长数组。下一节将详细讨论这两个类。

清单 19-3 展示了如何使用for循环、System.arraycopy()方法、Arrays.copyOf()方法和Arrays.copyOfRange()方法复制一个数组。

// ArrayCopyTest.java
package com.jdojo.array;
import java.util.Arrays;
public class ArrayCopyTest {
    public static void main(String[] args) {
        // Have an array with 5 elements
        int[] data = {1, 2, 3, 4, 5};
        // Expand the data array to 7 elements
        int[] eData = expandArray(data, 7);
        // Truncate the data array to 3 elements
        int[] tData = expandArray(data, 3);
        System.out.println("Using for-loop...");
        printArrays(data, eData, tData);
        /* Using System.arraycopy() method */
        // Copy data array to new arrays
        eData = new int[7];
        tData = new int[3];
        System.arraycopy(data, 0, eData, 0, 5);
        System.arraycopy(data, 0, tData, 0, 3);
        System.out.println("\nUsing System.arraycopy() method...");
        printArrays(data, eData, tData);
        /* Using Arrays.copyOf() method  */
        // Copy data array to new arrays
        eData = Arrays.copyOf(data, 7);
        tData = Arrays.copyOf(data, 3);
        System.out.println("\nUsing Arrays.copyOf() method...");
        printArrays(data, eData, tData);
        /* Using Arrays.copyOfRange() method */
        // Copy data array to new arrays
        int[] copy1 = Arrays.copyOfRange(data, 0, 3);
        int[] copy2 = Arrays.copyOfRange(data, 2, 4);
        System.out.println("\nUsing Arrays.copyOfRange() method...");
        System.out.println("Original Array: " + Arrays.toString(data));
        System.out.println("Copy1 (0, 3): " + Arrays.toString(copy1));
        System.out.println("Copy2 (2, 4): " + Arrays.toString(copy2));
    }
    // Uses a for-loop to copy an array
    public static int[] expandArray(int[] oldArray, int newLength) {
        int originalLength = oldArray.length;
        int[] newArray = new int[newLength];
        int elementsToCopy = originalLength > newLength ? newLength : originalLength;
        for (int i = 0; i < elementsToCopy; i++) {
            newArray[i] = oldArray[i];
        }
        return newArray;
    }
    private static void printArrays(int[] original, int[] expanded, int[] truncated) {
        System.out.println("Original Array: " + Arrays.toString(original));
        System.out.println("Expanded Array: " + Arrays.toString(expanded));
        System.out.println("Truncated Array: " + Arrays.toString(truncated));
    }
}
Using for-loop...
Original Array: [1, 2, 3, 4, 5]
Expanded Array: [1, 2, 3, 4, 5, 0, 0]
Truncated Array: [1, 2, 3]
Using System.arraycopy() method...
Original Array: [1, 2, 3, 4, 5]
Expanded Array: [1, 2, 3, 4, 5, 0, 0]
Truncated Array: [1, 2, 3]
Using Arrays.copyOf() method...
Original Array: [1, 2, 3, 4, 5]
Expanded Array: [1, 2, 3, 4, 5, 0, 0]
Truncated Array: [1, 2, 3]
Using Arrays.copyOfRange() method...
Original Array: [1, 2, 3, 4, 5]
Copy1 (0, 3): [1, 2, 3]
Copy2 (2, 4): [3, 4]

Listing 19-3Copying an Array Using a for Loop and the System.arraycopy() Method

Arrays类在java.util包中。它包含了许多处理数组的方便方法。例如,它包含将数组转换为字符串格式、对数组排序等方法。您使用了Arrays.toString()静态方法来获取字符串格式的数组内容。该方法被重载;您可以使用它来获取字符串格式的任何类型数组的内容。在这个例子中,您使用了一个for循环和 S ystem.arraycopy()方法来复制数组。注意,使用arraycopy()方法比使用for循环要强大得多。例如,arraycopy()方法被设计用来处理将一个数组的元素从一个区域复制到同一个数组中的另一个区域。它会处理数组中源区域和目标区域的任何重叠。对于引用类型数组,您可以使用以下版本的copyOfRange()方法来更改返回数组的类型:

<T,U> T[] copyOfRange(U[] original, int from, int to, Class<? extends T[]> newType)

该方法采用一个U类型的数组,并返回一个T类型的数组。

模拟可变长度数组

你知道 Java 不提供变长数组。然而,Java 库提供了一些类,它们的对象可以用作变长数组。这些类提供了获取其元素的数组表示的方法。ArrayListVectorjava.util包中的两个类,每当需要可变长度数组时都可以使用。LinkedList 是另一种类型的列表,可以存储任意数量的元素,但不利用数组,因此具有不同的性能特征。

ArrayListVector类的工作方式相同,只是Vector类中的方法是同步的,而ArrayList类中的方法不是同步的。Vector 是一个遗留类,应该避免使用。如果你的对象列表被多个线程同时访问和修改,你应该使用CopyOnWriteArrayList类,它会慢一些但是线程安全的。否则,你应该使用ArrayList类。在接下来的讨论中,我们将仅参考ArrayList。然而,这个讨论也适用于other List implementations

数组和ArrayList类的一个很大的区别是,后者只处理对象,不处理原始数据类型。ArrayList类是一个泛型类,它将元素的类型作为类型参数。如果你想使用原始值,你需要声明一个包装类的ArrayList。例如,使用ArrayList<Integer>来处理int元素,你所有的int值都会自动装箱到Integer对象中。下面的代码片段说明了ArrayList类的用法:

import java.util.ArrayList;
...
// Create an ArrayList of Integer
ArrayList<Integer> ids = new ArrayList<>();
// Get the size of array list
int total = ids.size();    // total will be zero at this point
// Print the details of array list
System.out.println("ArrayList size is " + total);
System.out.println("ArrayList elements are " + ids);
// Add three ids 10, 20, 30 to the  array list.
ids.add(new Integer(10)); // Adding an Integer object.
ids.add(20);              // Adding an int. Autoboxing is at play.
ids.add(30);              // Adding an int. Autoboxing is at play.
// Get the size of the array list
total = ids.size(); // total will be 3
// Print the details of array list
System.out.println("ArrayList size is " + total);
System.out.println("ArrayList elements are " + ids);
// Clear all elements from array list
ids.clear();
// Get the size of the array list
total = ids.size(); // total will be 0
// Print the details of array list
System.out.println("ArrayList size is " + total);
System.out.println("ArrayList elements are " + ids);
ArrayList size is 0
ArrayList elements are []
ArrayList size is 3
ArrayList elements are [10, 20, 30]
ArrayList size is 0
ArrayList elements are []

请注意该输出中的一个重要观察结果。您可以打印一个ArrayList中所有元素的列表,只需将它的引用传递给System.out.println()方法。ArrayList类的toString()方法返回一个字符串,该字符串是用括号([ ])括起来的元素的逗号分隔的字符串表示。

像数组一样,ArrayList使用从零开始的索引。即ArrayList的第一个元素的索引为零。您可以通过使用get(int index)方法获得存储在任何索引处的元素:

// Get the element at the index 0 (the first element)
Integer firstId = ids.get(0);
// Get the element at the index 1 (the second element)
int secondId = ids.get(1); // Auto-unboxing is at play

您可以使用其contains()方法检查ArrayList是否包含一个对象:

Integer id20 = 20;
Integer id50 = 50;
// Add three objects to the arraylist
ids.add(10);
ids.add(20);
ids.add(30);
// Check if the array list contains id20 and id50
boolean found20 = ids.contains(id20); // found20 will be true
boolean found50 = ids.contains(id50); // found50 will be false

您可以通过三种方式之一迭代一个ArrayList的元素:使用循环、使用迭代器或使用 forEach 方法。在这一章中,我们将讨论如何使用for循环和 forEach 来遍历ArrayList的元素。下面的代码片段展示了如何使用for循环来遍历ArrayList的元素:

// Get the size of the ArrayList
int total = ids.size();
// Iterate through all elements
for (int i = 0; i < total; i++) {
    int temp = ids.get(i);
    // Do some processing...
}

如果想遍历ArrayList的所有元素而不考虑它们的索引,可以使用如下所示的for - each循环:

// Iterate through all elements
for (int temp : ids) {
    // Do some processing with temp...
}

要使用 forEach 遍历所有元素,需要提供一个方法引用或 lambda 表达式作为参数,例如

ids.forEach(id ->
      //Do some processing with id
);

清单 19-4 展示了使用for循环和for-each循环来遍历ArrayList的元素。它还展示了如何使用remove()方法从ArrayList中删除一个元素。

// NameIterator.java
package com.jdojo.array;
import java.util.ArrayList;
public class NameIterator {
    public static void main(String[] args) {
        // Create an ArrayList of String
        ArrayList<String> nameList = new ArrayList<>();
        // Add some names
        nameList.add("Chris");
        nameList.add("Laynie");
        nameList.add("Jessica");
        // Get the count of names in the list
        int count = nameList.size();
        // Let us print the name list using a for loop
        System.out.println("List of names...");
        for(int i = 0; i < count; i++) {
            String name = nameList.get(i);
            System.out.println(name);
        }
        // Let us remove Jessica from the list
        nameList.remove("Jessica");
        // Get the count of names in the list again
        count = nameList.size();
        // Let us print the name list again using a for-each loop
        System.out.println("\nAfter removing Jessica...");
        for(String name : nameList) {
            System.out.println(name);
        }
    }
}
List of names...
Chris
Laynie
Jessica
After removing Jessica...
Chris
Laynie

Listing 19-4Iterating Through Elements of an ArrayList

将数组作为参数传递

您可以将数组作为参数传递给方法或构造器。传递给方法的数组类型必须与形参类型的赋值兼容。方法的数组类型参数声明的语法与其他数据类型的语法相同。也就是说,参数声明应以数组类型开头,后跟空格和参数名,如下所示:

[modifiers] <return-type> <methodName>([<array-type> argumentName, ...])

以下是带有数组参数的方法声明的一些示例:

// The processSalary() method has two parameters:
// 1\. id is an array of int
// 2\. salary is an array of double
public static void processSalary(int[] id, double[] salary) {
    // Code goes here...
}
// The setAka() method has two parameters:
// 1\. id is int (It is simply int type, not array of int)
// 2\. aka is an array of String
public static void setAka(int id, String[] aka) {
    // Code goes here...
}
// The printStates() method has one parameter:
// 1\. stateNames is an array of String
public static void printStates(String[] stateNames) {
    // Code goes here...
}

下面的代码片段模拟了ArrayListtoString()方法。它接受一个int数组,并返回用括号括起来的逗号分隔的值([]):

public static String arrayToString(int[] source) {
    if (source == null) {
        return null;
    }
    // Use StringBuilder to improve performance
    StringBuilder result = new StringBuilder("[");
    for (int i = 0; i < source.length; i++) {
        if (i == source.length - 1) {
            result.append(source[i]);
        } else {
            result.append(source[i] + ",");
        }
    }
    result.append("]");
    return result.toString();
}

这种方法可以按如下方式调用:

int[] ids = {10, 15, 19};
String str = arrayToString(ids);  // Pass ids int array to arrayToString() method

因为数组是一个对象,所以数组引用被传递给方法。接收数组参数的方法可以修改数组的元素。清单 19-5 展示了一个方法如何改变它的数组参数的元素;这个例子还展示了如何实现swap()方法来使用数组交换两个整数。

// Swap.java
package com.jdojo.array;
public class Swap {
    public static void main(String[] args) {
        int[] num = {17, 80};
        System.out.println("Before swap");
        System.out.println("#1: " + num[0]);
        System.out.println("#2: " + num[1]);
        // Call the swap() method passing the num array
        swap(num);
        System.out.println("After swap");
        System.out.println("#1: " + num[0]);
        System.out.println("#2: " + num[1]);
    }
    // The swap() method accepts an int array as an argument and swaps the values
    // if array contains two values.
    public static void swap (int[] source) {
        if (source != null && source.length == 2) {
            // Swap the first and the second elements
            int temp = source[0];
            source[0] = source[1];
            source[1] = temp;
        }
    }
}
Before swap
#1: 17
#2: 80
After swap
#1: 80
#2: 17

Listing 19-5Passing an Array as a Method Parameter

回想一下,在第八章的中,我们无法实现一个方法来使用基本类型的参数交换两个整数。这是因为,对于基本类型,实际参数被复制到形式参数。这里,您能够在swap()方法中交换两个整数,因为您使用了一个数组作为参数。数组的引用被传递给方法,而不是数组元素的副本。

Tip

将数组传递给方法会有风险。该方法可以修改数组元素,这有时可能不是所期望或想要的。在这种情况下,您应该将数组的副本传递给方法,而不是原始数组;因此,如果该方法修改了数组,您的原始数组不会受到影响。

您可以使用数组的clone()方法快速复制数组。“快速复制”一词值得特别注意。对于基本类型,克隆的数组将拥有原始数组的真实副本。创建一个相同长度的新数组,并将原始数组中每个元素的值复制到克隆数组的相应元素中。但是,对于引用类型,存储在原始数组的每个元素中的对象的引用被复制到克隆数组的相应元素中。这称为浅层复制,而前一种类型(复制对象或值)称为深层复制。在浅层复制的情况下,原始数组和克隆数组的元素都引用内存中的同一个对象。您可以使用存储在原始数组和克隆数组中的引用来修改对象。在这种情况下,即使将原始数组的副本传递给方法,原始数组中引用的对象的状态也可以在方法内部修改。这个问题的解决方案是制作原始数组的深层副本,将其传递给方法。下面的代码片段演示了一个int数组和一个String数组的克隆。注意,clone()方法的返回类型是Object,您需要将返回值转换为适当的数组类型:

// Create an array of 3 integers 1, 2, and 3
int[] ids = {1, 2, 3};
// Declare an array of int named clonedIds.
int[] clonedIds;
// The clonedIds array has the same values as the ids array.
clonedIds = (int[]) ids.clone();
// Create an array of 3 strings.
String[] names  = {"Lisa", "Pat", "Kathy"};
// Declare an array of String named clonedNames.
String[] clonedNames;
// The clonedNames array has the reference of the same three strings as the names array.
String[] clonedNames = (String[]) names.clone();

图 19-2 到 19-5 描述了前面代码片段中的原始数组ids和引用数组names的克隆过程。

img/323069_3_En_19_Fig5_HTML.png

图 19-5

names 数组克隆在 clonedNames 数组中

img/323069_3_En_19_Fig4_HTML.png

图 19-4

填充 names 数组,并声明 clonedNames 数组

img/323069_3_En_19_Fig3_HTML.png

图 19-3

ids 数组克隆在 clonedIds 数组中

img/323069_3_En_19_Fig2_HTML.png

图 19-2

填充 ids 数组,并声明 clonedIds 数组

注意,当克隆names数组时,clonedNames数组元素引用内存中相同的String对象。当您提到修改传递给它的数组参数的方法时,您可能指以下三种情况中的一种或全部:

  • 数组参数引用

  • 数组参数的元素

  • 数组参数元素引用的对象

数组参数引用

因为数组是一个对象,所以它的引用的副本被传递给一个方法。如果方法更改数组参数,实际参数不受影响。清单 19-6 说明了这一点。main()方法将一个数组传递给tryArrayChange()方法,后者又将一个不同的数组引用分配给参数。输出显示,main()方法中的数组不受影响。

// ModifyArrayParam.java
package com.jdojo.array;
import java.util.Arrays;
public class ModifyArrayParam {
    public static void main(String[] args) {
        int[] origNum = {101, 307, 78};
        System.out.println("Before method call: " + Arrays.toString(origNum));
        // Pass the array to the method
        tryArrayChange(origNum);
        System.out.println("After method call: " + Arrays.toString(origNum));
    }
    public static void tryArrayChange(int[] num) {
        System.out.println("Inside method-1: " + Arrays.toString(num));
        // Create and store a new int array in num
        num = new int[]{10, 20};
        System.out.println("Inside method–2: " + Arrays.toString(num));
    }
}
Before method call: [101, 307, 78]
Inside method-1: [101, 307, 78]
Inside method–2: [10, 20]
After method call: [101, 307, 78]

Listing 19-6Modifying an Array Parameter Inside a Method

如果不希望方法改变方法体内的数组引用,必须将方法参数声明为final,如下所示:

public static void tryArrayChange(final int[] num) {
    // An error. num is final and cannot be changed
    num = new int[]{10, 20};
}

数组参数的元素

存储在数组参数元素中的值总是可以在方法内部更改。清单 19-7 说明了这一点。

// ModifyArrayElements.java
package com.jdojo.array;
import java.util.Arrays;
public class ModifyArrayElements {
    public static void main(String[] args) {
        int[] origNum = {10, 89, 7};
        String[] origNames = {"Mike", "John"};
        System.out.println("Before method call, origNum: " + Arrays.toString(origNum));
        System.out.println("Before method call, origNames: " + Arrays.toString(origNames));
        // Call methods passing the arrays
        tryElementChange(origNum);
        tryElementChange(origNames);
        System.out.println("After method call, origNum: " + Arrays.toString(origNum));
        System.out.println("After method call, origNames: " + Arrays.toString(origNames));
    }
    public static void tryElementChange(int[] num) {
        // If the array has at least one element, store 1116 in its first element.
        if (num != null && num.length > 0) {
            num[0] = 1116;
        }
    }
    public static void tryElementChange(String[] names) {
        // If the array has at least one element, store "Twinkle" in its first element
        if (names != null && names.length > 0) {
            names[0] = "Twinkle";
        }
    }
}
Before method call, origNum: [10, 89, 7]
Before method call, origNames: [Mike, John]
After method call, origNum: [1116, 89, 7]
After method call, origNames: [Twinkle, John]

Listing 19-7Modifying Elements of an Array Parameter Inside a Method

请注意,在方法调用后,数组的第一个元素发生了变化。您可以在方法内部更改数组参数的元素,即使数组参数声明为final

数组参数元素引用的对象

本节仅适用于引用类型的数组参数。如果数组的引用类型是可变的,您可以更改存储在数组元素中的对象的状态。在上一节中,我讨论了用新的对象引用替换数组元素中存储的引用。本节讨论如何更改数组元素所引用的对象的状态。考虑一个Item类,如清单 19-8 所示。

// Item.java
package com.jdojo.array;
public class Item {
    private double price;
    private final String name;
    public Item (String name, double price) {
        this.name = name;
        this.price = price;
    }
    public double getPrice() {
        return this.price;
    }
    public void setPrice(double price ) {
        this.price = price;
    }
    @Override
    public String toString() {
        return "[" + this.name + ", " + this.price + "]";
    }
}

Listing 19-8An Item Class

清单 19-9 说明了这一点。main()方法创建一个Item数组。该数组被传递给tryStateChange()方法,该方法将数组中第一个元素的价格改为 10.38。输出显示在main()方法中创建的数组中的原始元素的价格发生了变化。

// ModifyArrayElementState.java
package com.jdojo.array;
public class ModifyArrayElementState {
    public static void main(String[] args) {
        Item[] myItems = {new Item("Pen", 25.11), new Item("Pencil", 0.10)};
        System.out.println("Before method call #1:" + myItems[0]);
        System.out.println("Before method call #2:" + myItems[1]);
        // Call the method passing the array of Item
        tryStateChange(myItems);
        System.out.println("After method call #1:" + myItems[0]);
        System.out.println("After method call #2:" + myItems[1]);
    }
    public static void tryStateChange(Item[] allItems) {
        if (allItems != null && allItems.length > 0) {
            // Change the price of the first item to 10.38
            allItems[0].setPrice(10.38);
        }
    }
}
Before method call #1:[Pen, 25.11]
Before method call #2:[Pencil, 0.1]
After method call #1:[Pen, 10.38]
After method call #2:[Pencil, 0.1]

Listing 19-9Modifying the States of Array Elements of an Array Parameter Inside a Method

Tip

方法可以用来克隆一个数组。对于引用数组,clone()方法执行浅层复制。应该小心地将数组传递给方法并从方法返回。如果一个方法可能会修改它的数组参数,而您不希望实际的数组参数受到该方法调用的影响,则必须将数组的深层副本传递给该方法。

如果将对象的状态存储在数组实例变量中,则在从类的任何方法返回该数组的引用之前,应该仔细考虑。该方法的调用方将获得数组实例变量的句柄,并且能够在类外部更改该类的对象的状态。以下示例说明了这种情况:

public class MagicNumber {
    // Magic numbers are not supposed to be changed. They can be looked up though.
    private int[] magicNumbers = {5, 11, 21, 51, 101};
    // Other code goes here...
    public int[] getMagicNumbers () {
        /* Never do the following. If you do this, callers of this
           method will be able to change the magic numbers.
        */
        // return this.magicNumbers;
        /* Do the following instead. In case of reference arrays, make a deep copy, and
          return that copy. For primitive arrays you can use the clone() method.
        */
        return (int[]) magicNumbers.clone();
    }
}

您也可以创建一个数组并将其传递给一个方法,而不将数组引用存储在变量中。假设有一个名为setNumbers(int[] nums)的方法,它以一个int数组作为参数。您可以调用此方法,如下所示:

setNumbers(new int[]{10, 20, 30});

注意,在这种情况下,您必须使用new操作符。以下方法调用将不起作用:

// A compile-time error. The array initialization list is supported only
// in an array declaration statement
setNumbers({10, 20, 30});

命令行参数

可以从命令提示符(Windows 中的命令提示符和 UNIX 中的 shell 提示符)启动 Java 应用程序。它也可以从 Java 开发环境工具中启动,如 NetBeans、Eclipse、JDeveloper 等。Java 应用程序在命令行运行,如下所示:

java --module-path <module-path> --module <module-name/<class-name>
java --module-path <module-path> --module <module-name/<class-name> <list-of-command-line arguments>

参数列表中的每个参数由空格分隔。例如,以下命令运行com.jdojo.array.Test类并传递三个名称作为命令行参数:

C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.Test Cat Dog Rat

当运行Test类时,这三个命令行参数会发生什么变化?操作系统将参数列表传递给 JVM。有时,操作系统可以通过解释参数的含义来扩展参数列表,并且可以将修改后的参数列表传递给 JVM。JVM 使用空格作为分隔符来解析参数列表。它创建了一个String数组,其长度与列表中参数的数量相同。它按顺序用参数列表中的项目填充String数组。最后,JVM 将这个String数组传递给正在运行的Test类的main()方法。这是您使用传递给main()方法的String数组参数的时候。如果没有命令行参数,JVM 将创建一个零长度的String数组,并将其传递给main()方法。如果要将空格分隔的单词作为一个参数传递,可以用双引号将它们括起来。您还可以通过将特殊字符用双引号括起来来避免操作系统对它们的解释。让我们创建一个名为CommandLine的类,如清单 19-10 所示。

// CommandLine.java
package com.jdojo.array;
public class CommandLine {
    public static void main(String[] args) {
        // args contains all command-line arguments
        System.out.println("Total Arguments: " + args.length);
        // Display all arguments
        for (int i = 0; i < args.length; i++) {
            System.out.println("Argument #" + (i + 1) + ": " + args[i]);
        }
    }
}

Listing 19-10Processing Command-line Arguments Inside the main() Method

以下是向CommandLine类传递命令行参数的几个例子:

C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.CommandLine
Total Arguments: 0
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.CommandLine Cat Dog Rat
Total Arguments: 3
Argument #1: Cat
Argument #2: Dog
Argument #3: Rat
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.CommandLine "Cat Dog Rat"
Total Arguments: 1
Argument #1: Cat Dog Rat
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.CommandLine 29 Dogs
Total Arguments: 2
Argument #1: 29
Argument #2: Dogs

命令行参数有什么用?它们允许你改变程序的行为,而不需要重新编译。例如,您可能希望按升序或降序对文件内容进行排序。您可以传递指定排序顺序的命令行参数。如果命令行上没有指定排序顺序,默认情况下可以采用升序。如果您调用排序类com.jdojo.array.SortFile,您可以通过以下方式运行它:

// To sort employee.txt file in ascending order
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.SortFile names.txt asc
// To sort department.txt file in descending order
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.SortFile names.txt desc
// To sort salary.txt in ascending order
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.SortFile names.txt

根据传递给SortFile类的main()方法的String数组的第二个元素(如果有的话),可以对文件进行不同的排序。

注意,所有命令行参数都作为String传递给main()方法。如果你想传递一个数字参数,你需要把参数转换成一个数字。为了说明这种数值参数转换,让我们开发一个迷你计算器类,它将一个表达式作为命令行参数并打印结果。迷你计算器只支持四种基本运算:加、减、乘、除。见清单 19-11 。

// Calc.java
package com.jdojo.array;
import java.util.Arrays;
public class Calc {
    public static void main(String[] args) {
        // Print the list of commandline argument
        System.out.println(Arrays.toString(args));
        // Make sure we received three arguments and the
        // the second argument has only one character to indicate operation.
        if (!(args.length == 3 && args[1].length() == 1)) {
            printUsage();
            return;    // Stop the program here
        }
        // Parse the two number operands. Place the parsing code inside a try-catch,
        // so we will handle the error in case both operands are not numbers.
        double n1;
        double n2;
        try {
            n1 = Double.parseDouble(args[0]);
            n2 = Double.parseDouble(args[2]);
        } catch (NumberFormatException e) {
            System.out.println("Both operands must be a number");
            printUsage();
            return;    // Stop the program here
        }
        String operation = args[1];
        double result = compute(n1, n2, operation);
        // Print the result
        System.out.println(args[0] + args[1] + args[2] + " = " + result);
    }
    public static double compute(double n1, double n2, String operation) {
        // Initialize the result with not-a-number
        double result = Double.NaN;
        switch (operation) {
            case "+":
                result = n1 + n2;
                break;
            case "-":
                result = n1 - n2;
                break;
            case "*":
                result = n1 * n2;
                break;
            case "/":
                result = n1 / n2;
                break;
            default:
                System.out.println("Invalid operation:" + operation);
        }
        return result;
    }
    public static void printUsage() {
        System.out.println("Usage: java com.jdojo.array.Calc expr");
        System.out.println("Where expr could be:");
        System.out.println("n1 + n1");
        System.out.println("n1 - n2");
        System.out.println("n1 * n2");
        System.out.println("n1 / n2");
        System.out.println("n1 and n2 are two numbers");
    }
}

Listing 19-11A Mini Command-Line Calculator

以下是使用Calc类执行基本算术运算的几种方法:

C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.Calc 3 + 7
[3, +, 7]
3+7 = 10.0
C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.Calc 78.9 * 98.5
[78.9, *, 98.5]
78.9*98.5 = 7771.650000000001

当您尝试使用*(星号)作为两个数字相乘的运算时,可能会出现错误。操作系统可能会将其解释为当前目录中的所有文件名。为了避免这种错误,可以用双引号或操作系统提供的转义符将运算符括起来,如下所示:

C:\JavaFun>java --module-path build\modules\jdojo.array --module jdojo.array/com.jdojo.array.Calc 78.9 "*" 98.5

Tip

如果你的程序使用命令行参数,它就不是一个 100%的 Java 程序。这是因为该程序不符合“编写一次,到处运行”的类别有些操作系统没有命令提示符,因此,您可能无法使用命令行参数功能。此外,操作系统可能会以不同的方式解释命令行参数中的元字符。

多维数组

如果列表中的数据元素使用多个维度来标识,则可以使用多维数组来表示列表。例如,表中的数据元素由行和列两个维度来标识。您可以将表格数据存储在二维数组中。您可以通过在数组声明中为每个维度使用一对括号([])来声明多维数组。例如,您可以声明一个二维数组int,如下所示:

int[][] table;

这里,table是一个引用变量,可以保存对二维数组int的引用。在声明的时候,内存只分配给引用变量table,不分配给任何数组元素。该代码执行后的存储状态如图 19-6 所示。

img/323069_3_En_19_Fig6_HTML.png

图 19-6

二维数组声明后的内存状态

可以创建一个三行两列的二维数组int,如下所示:

table = new int[3][2];

该代码执行后的存储器状态如图 19-7 所示。所有元素的值都显示为零,因为默认情况下,数值数组的所有元素都被初始化为零。正如本章前面所讨论的,多维数组的数组元素的默认初始化规则与一维数组的规则相同。

img/323069_3_En_19_Fig7_HTML.png

图 19-7

创建二维数组后的内存状态

多维数组中每个维度的索引是从零开始的。table数组的每个元素都可以作为table[rowNumber][columnNumber]来访问。行号和列号总是从零开始。例如,您可以为table数组中的第一行和第二列赋值,如下所示:

table[0][1] = 32;

您可以为第三行和第一列赋值 71,如下所示:

table[2][0] = 71;

两次分配后的存储器状态如图 19-8 所示。

img/323069_3_En_19_Fig8_HTML.png

图 19-8

二维数组元素两次赋值后的内存状态

Java 不支持真正意义上的多维数组。相反,它支持数组的数组。使用数组的数组,可以实现与多维数组相同的功能。创建二维数组时,第一个数组的元素属于数组类型,可以引用一维数组。每个一维数组的大小不必相同。考虑到table二维数组的数组概念,你可以描绘数组创建和两个值赋值后的内存状态,如图 19-9 所示。

img/323069_3_En_19_Fig9_HTML.png

图 19-9

数组的数组

二维数组的名字table,指的是三个元素的数组。数组的每个元素都是一维数组inttable[0]table[1]table[2]的数据类型是一个int数组。table[0]table[1]table[2]的长度各为 2。

创建多维数组时,必须至少指定第一级数组的维度。例如,创建二维数组时,必须至少指定第一维,即行数。您可以获得与前面的代码片段相同的结果,如下所示:

table = new int[3][];

该语句仅创建数组的第一级。此时只存在table[0]table[1]table[2]。他们指的是null。此时,table.length的值为3。由于table[0]table[1]table[2]引用的是null,所以不能访问它们的length属性。也就是说,您在一个表中创建了三行,但是您不知道每行将包含多少列。由于table[0]table[1]table[2]int的数组,可以给它们赋值如下:

table[0] = new int[2]; // Create 2 columns for row 1
table[1] = new int[2]; // Create 2 columns for row 2
table[2] = new int[2]; // Create 2 columns for row 3

您已经完成了二维数组的创建,该数组有三行,每行有两列。您可以将这些值分配给一些单元格,如下所示:

table[0][1] = 32;
table[2][0] = 71;

也可以创建一个每行有不同列数的二维数组。这样的数组称为参差数组。清单 19-12 展示了如何使用一个参差不齐的数组。

// RaggedArray.java
package com.jdojo.array;
public class RaggedArray {
    public static void main(String[] args) {
        // Create a two-dimensional array of 3 rows
        int[][] raggedArr = new int[3][];
        // Add 2 columns to the first row
        raggedArr[0] = new int[2];
        // Add 1 column to the second row
        raggedArr[1] = new int[1];
        // Add 3 columns to the third row
        raggedArr[2] = new int[3];
        // Assign values to all elements of raggedArr
        raggedArr[0][0] = 1;
        raggedArr[0][1] = 2;
        raggedArr[1][0] = 3;
        raggedArr[2][0] = 4;
        raggedArr[2][1] = 5;
        raggedArr[2][2] = 6;
        // Print all elements. One row at one line
        System.out.println(raggedArr[0][0] + "\t" + raggedArr[0][1]);
        System.out.println(raggedArr[1][0]);
        System.out.println(raggedArr[2][0] + "\t" + raggedArr[2][1] + "\t" + raggedArr[2][2]);
    }
}

1     2
3
4     5        6

Listing 19-12An Example of a Ragged Array

Tip

Java 支持数组的数组,可以用来实现多维数组提供的功能。多维数组广泛应用于科学和工程领域。如果您在业务应用程序中使用二维以上的数组,您可能需要重新考虑选择多维数组作为您的数据结构。

访问多维数组的元素

通常,使用嵌套的for循环来填充多维数组。用于填充多维数组的for循环的数量等于数组中的维数。例如,两个for循环用于填充一个二维数组。通常,循环用于访问多维数组的元素。清单 19-13 展示了如何填充和访问一个二维数组的元素。

// MDAccess.java
package com.jdojo.array;
public class MDAccess {
    public static void main(String[] args){
        int[][] ra = new int[3][];
        ra[0] = new int[2];
        ra[1] = new int[1];
        ra[2] = new int[3];
        // Populate the ragged array using for loops
        for(int i = 0; i < ra.length; i++) {
            for(int j = 0; j < ra[i].length; j++){
                ra[i][j] = i + j;
            }
        }
        // Print the array using for loops
        for(int i = 0; i < ra.length; i++) {
            for (int j = 0; j < ra[i].length; j++){
                System.out.print(ra[i][j] + "\t");
            }
            // Add a new line after each row is printed
            System.out.println();
        }
    }
}
0     1
1
2     3        4

Listing 19-13Accessing Elements of a Multidimensional Array

初始化多维数组

您可以通过在声明或创建多维数组时提供值列表来初始化多维数组的元素。如果用值列表初始化数组,则不能指定任何维度的长度。每个维度的初始值的数量将决定数组中每个维度的长度。由于多维数组中涉及许多维度,因此某个级别的值列表用大括号括起来。对于二维数组,每行的值列表用一对大括号括起来,如下所示:

int[][] arr = {{10, 20, 30}, {11, 22}, {222, 333, 444, 555}};

该语句创建一个包含三行的二维数组。第一行包含值为 10、20 和 30 的三列。第二行包含值为 11 和 22 的两列。第三行包含值为 222、333、444 和 555 的四列。可以创建一个 0 行 0 列的二维数组,如下所示:

int[][] empty2D = { };

引用类型的多维数组的初始化遵循相同的规则。您可以像这样初始化一个二维的String数组:

String[][] acronymList = {{"JMF", "Java Media Framework"},
                          {"JSP", "Java Server Pages"},
                          {"JMS", "Java Message Service"}};

您可以在创建多维数组时初始化它的元素,如下所示:

int[][] arr = new int[][]{{1, 2}, {3,4,5}};

阵列的增强 for 循环

Java 有一个增强的for循环,可以让你以一种更简洁的方式遍历数组的元素。增强型for环路也被称为for-each环路。语法如下:

for(DataType e : array) {
    // Loop body goes here...
    // e contains one element of the array at a time
}

for - each循环使用与基本for循环相同的for关键字。它的主体被执行的次数与array中的元素数一样多。DataType e是变量声明,其中e是变量名,DataType是其数据类型。变量e的数据类型应该与array的类型赋值兼容。变量声明后面跟一个冒号(:),冒号后面跟一个要循环的数组的引用。for - each循环将数组元素的值赋给变量e,您可以在循环体中使用该变量。下面的代码片段使用了一个for - each循环来打印一个int数组的所有元素:

int[] numList = {1, 2, 3};
for(int num : numList) {
    System.out.println(num);
}
1
2
3

您可以使用基本的for循环完成同样的任务,如下所示:

int[] numList = {1, 2, 3};
for(int i = 0; i < numList.length; i++) {
    int num = numList[i];
    System.out.println(num);
}
1
2
3

注意,for - each循环提供了一种遍历数组元素的方法,这比基本的for循环更简洁。然而,它不能代替基本的for循环,因为您不能在所有情况下使用它。例如,您不能访问数组元素的索引,也不能修改循环内元素的值,因为您没有该元素的索引。

数组声明语法

您可以通过在数组的数据类型之后或数组引用变量的名称之后放置一对括号([])来声明数组。例如,下面的声明

int[] empIds;
int[][] points2D;
int[][][] points3D;
Person[] persons;

相当于

int empIds[];
int points2D[][];
int points3D[][][];
Person persons[];

Java 还允许混合两种语法。在同一个数组声明中,可以在数据类型后放置一些括号,在变量名后放置一些括号。例如,您可以如下声明一个二维数组int:

int[] points2D[];

您可以在一个声明语句中声明一个二维数组和一个三维数组int,如下所示:

int[] points2D[], points3D[][];

或者

int[][] points2D, points3D[];

运行时数组边界检查

在运行时,Java 会检查对数组元素的每次访问的数组边界。如果超出了数组界限,就会抛出一个java.lang.ArrayIndexOutOfBoundsException。编译时对数组索引值的唯一要求是它们必须是整数。Java 编译器不会检查数组索引的值是小于零还是超出其长度。这个检查必须在运行时执行,在每次允许访问数组元素之前。运行时数组边界检查会降低程序的执行速度,原因有两个:

  • 第一个原因是约束支票本身的成本。要检查数组边界,数组的长度必须加载到内存中,并且必须执行两次比较(一次小于零,一次大于或等于其长度)。

  • 第二个原因是,当超出数组界限时,必须抛出异常。Java 必须做一些内务处理,并准备好在超出数组界限时抛出异常。

清单 19-14 展示了超出数组边界时抛出的异常。程序创建一个名为test的数组int,长度为 3。程序无法访问第四个元素(test[3]),因为它不存在。当进行这样的尝试时,抛出一个ArrayIndexOutOfBoundsException

// ArrayBounds.java
package  com.jdojo.array;
public class ArrayBounds {
    public static void main(String[] args) {
        int[] test = new int[3];
        System.out.println("Assigning 12 to the first element");
        test[0] = 12;  // OK. Index 0 is between 0 and 2.
        System.out.println("Assigning 79 to the fourth element");
        // Index 3 is not between 0 and 2\. At runtime, an exception is thrown.
        test[3] = 79;
        System.out.println("We will not get here");
    }
}
Assigning 12 to the first element
Assigning 79 to the fourth element
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
      at com.jdojo.array.ArrayBounds.main(ArrayBounds.java:14)

Listing 19-14Array Bounds Checks

在访问数组元素之前检查数组长度是一个好习惯。数组边界违规引发异常这一事实可能会被误用,如下面的代码片段所示,该代码片段打印存储在数组中的值:

/* Do not use this code, even if it works.*/
// Create an array
int[] arr = new int[10];
// Populate the array here...
// Print the array. Wrong way
try {
    // Start an infinite loop. When we are done with all elements an exception is
    // thrown and we will be in catch block and hence out of the loop.
    int counter = 0;
    while (true) {
        System.out.println(arr[counter++]);
    }
} catch (ArrayIndexOutOfBoundsException e) {
    // We are done with printing array elements
}
// Do some processing here...

前面的代码片段使用一个无限的while循环来打印数组元素的值,并依靠异常抛出机制来检查数组边界。正确的方法是使用一个for循环,并使用数组的length属性检查数组索引值。

数组对象的类是什么?

数组是对象。因为每个对象都有一个类,所以每个数组都必须有一个类。Object类的所有方法都可以在数组上使用。因为Object类的getClass()方法为 Java 中的任何对象提供了该类的引用,所以您将使用该方法获取所有数组的类名。清单 19-15 展示了如何获得一个数组的类名。

// ArrayClass.java
package com.jdojo.array;
public class ArrayClass {
    public static void main (String[] args){
        int[] iArr = new int[2];
        int[][] iiArr = new int[2][2];
        int[][][] iiiArr = new int[2][2][2];
        String[] sArr  = {"A", "B"} ;
        String[][] ssArr = {{"AA"}, {"BB"}} ;
        String[][][] sssArr = {} ; // A 3D empty array of string
        // Print the class name for all arrays
        System.out.println("int[]: " + getClassName(iArr));
        System.out.println("int[][]: " + getClassName(iiArr));
        System.out.println("int[][][]: " + getClassName(iiiArr));
        System.out.println("String[]: " + getClassName(sArr));
        System.out.println("String[][]: " + getClassName(ssArr));
        System.out.println("String[][][]: " + getClassName(sssArr));
    }
    // Any Java object can be passed to getClassName() method.
    // Since every array is an object, we can also pass an array to this method.
    public static String getClassName(Object obj) {
        // Get the reference of its class
        Class<?> c = obj.getClass();
        // Get the name of the class
        String className = c.getName();
        return className;
    }
}
int[]: [I
int[][]: [[I
int[][][]: [[[I
String[]: [Ljava.lang.String;
String[][]: [[Ljava.lang.String;
String[][][]: [[Ljava.lang.String;

Listing 19-15Knowing the Class of an Array

数组的类名以左括号([)开始。左括号的数量等于数组的维数。对于一个int数组,左括号后面是一个字符I。对于引用类型数组,左括号后面是字符L,后面是类名,后面是分号。表 [19-2 中显示了一维原始数组和引用类型的类名。

表 19-2

数组的类名

|

数组类型

|

类别名

byte[] [B
short[] [S
int[] [I
long[] [J
char[] [C
float[] [F
double[] [D
boolean[] [Z
com.jdojo.array.Person[] [Lcom.jdojo.array.Person;

数组的类名在编译时不可用于声明或创建它们。您必须使用本章中描述的语法来创建数组。也就是说,您不能编写以下代码来创建一个int数组:

[I myIntArray;

相反,您必须编写以下代码来创建一个int数组:

int[] myIntArray;

数组赋值兼容性

数组中每个元素的数据类型与数组的数据类型相同。例如,int[]数组的每个元素都是一个int;一个String[]数组的每个元素都是一个String。赋给数组元素的值必须与其数据类型的赋值兼容。例如,允许将一个byte值赋给一个int数组的元素,因为byteint的赋值是兼容的。但是,不允许将float值赋给int数组的元素,因为floatint的赋值不兼容:

int[] sequence = new int[10];
sequence[0] = 10;    // OK. Assigning an int 10 to an int
sequence[1] = 19.4f; // A compile-time error. Assigning a float to an int

在处理引用类型数组时,必须遵循相同的规则。如果有一个类型为T的引用类型数组,当且仅当ST的赋值兼容时,它的元素才能被赋值为类型为S的对象引用。子类对象引用总是与 Java 中所有类的超类赋值兼容。你可以使用一个Object类的数组来存储任何类的对象,例如:

Object[] genericArray = new Object[4];
genericArray[0] = new String("Hello");  // OK
genericArray[1] = new Person("Daniel"); // OK. Assuming Person class exists
genericArray[2] = new Account(189);     // OK. Assuming Account class exist
genericArray[3] = null;                 // Ok. null can be assigned to any reference type

您需要在从数组中读回对象时执行强制转换,如下所示:

/* The compiler will flag an error for the following statement. genericArray is of Object
   type and an Object reference cannot be assigned to a String reference variable. Even
   though genericArray[0] contains a String object reference, we need to cast it to String
   as we do in next statement.
*/
String s = genericArray[0]; // A compile-time error
String str = (String) genericArray[0]; // OK
Person p = (Person) genericArray[1];   // OK
Account a = (Account) genericArray[2]; // OK

如果您试图将数组元素强制转换为一种类型,而这种类型的实际类型与新类型的赋值不兼容,则会抛出java.lang.ClassCastException。例如,下面的语句将在运行时抛出一个ClassCastException:

String str = (String) genericArray[1]; // Person cannot be cast to String

不能将超类的对象引用存储在子类的数组中。以下代码片段说明了这一点:

String[] names = new String[3];
names[0] = new Object(); // A compile-time error. Object is superclass of String
names[1] = new Person(); // A compile-time error. Person is not subclass of String
names[2] = null;         // OK.

最后,如果前一种类型与后一种类型在赋值上兼容,则可以将数组引用赋给另一种类型的数组引用:

Object[] obj = new Object[3];
String[] str = new String[2];
Account[] a = new Account[5];
obj = str;            // OK
str = (String[]) obj; // OK because obj has String array reference
obj = a;
// A ClassCastException will be thrown. obj has the reference of an Account array and
// an Account cannot be converted to a String
str = (String[]) obj;
a = (Account[]) obj; // OK

将列表转换为数组

当列表中元素的数量不精确时,可以使用一个ArrayList。一旦列表中的元素数量固定,您可能想要将一个ArrayList转换成一个数组。您可以出于以下原因之一执行此操作:

  • 程序语义可能要求您使用数组,而不是ArrayList。例如,您可能需要将一个数组传递给一个方法,但是您将数据存储在一个ArrayList中。

  • 您可能希望将用户输入存储在一个数组中。但是,您不知道用户将输入多少个值。在这种情况下,您可以在接受用户输入的同时将值存储在一个ArrayList中。最后,您可以将ArrayList转换成一个数组。

  • 访问数组元素比访问ArrayList元素快。如果您有一个ArrayList并且想要多次访问元素,您可能想要将ArrayList转换为一个数组以获得更好的性能。

ArrayList类有一个名为toArray()的重载方法:

  • Object[] toArray()

  • <T> T[] toArray(T[] a)

第一种方法将ArrayList的元素作为Object的数组返回。第二种方法接受任意类型的数组作为参数。如果有足够的空间,所有的ArrayList元素都被复制到传递的数组中,并返回相同的数组。如果传递的数组中没有足够的空间,则创建一个新的数组。新数组的类型与传递的数组相同。新数组的长度等于ArrayList的大小。清单 19-16 展示了如何将一个ArrayList转换成一个数组。

// ArrayListToArray.java
package com.jdojo.array;
import java.util.ArrayList;
import java.util.Arrays;
public class ArrayListToArray {
    public static void main(String[] args) {
        ArrayList<String> al = new ArrayList<>();
        al.add("cat");
        al.add("dog");
        al.add("rat");
        // Print the content of the ArrayList
        System.out.println("ArrayList: " + al);
        // Create an array of the same length as the ArrayList
        String[] s1 = new String[al.size()];
        // Copy the ArrayList elements to the array
        String[] s2 = al.toArray(s1);
        // s1 has enough space to copy all ArrayList elements.
        // al.toArray(s1) returns s1 itself
        System.out.println("s1 == s2: " + (s1 == s2));
        System.out.println("s1: " + Arrays.toString(s1));
        System.out.println("s2: " + Arrays.toString(s2));
        // Create an array of string with 1 element.
        s1 = new String[1];
        s1[0] = "hello"; // Store hello in first element
        // Copy ArrayList to the array s1
        s2 = al.toArray(s1);
        /* Since s1 doesn't have sufficient space to copy all ArrayList elements,
           al.toArray(s1) creates a new String array with 3 elements in it. All
           elements of arraylist are copied to the new array. Finally, the new array is
           returned. Here, s1 == s2 is false. s1 will be untouched by the method call.
         */
        System.out.println("s1 == s2: " + (s1 == s2));
        System.out.println("s1: " + Arrays.toString(s1));
        System.out.println("s2: " + Arrays.toString(s2));
    }
}
ArrayList: [cat, dog, rat]
s1 == s2: true
s1: [cat, dog, rat]
s2: [cat, dog, rat]
s1 == s2: false
s1: [hello]
s2: [cat, dog, rat]

Listing 19-16An ArrayList to an Array Conversion

执行数组操作

在日常编程中,您需要执行一些例行的数组操作,如排序、搜索、比较和复制。java.util.Arrays类是一个实用类,包含 150 多个静态便利方法来执行这种类型的数组操作。在您推出自己的代码来执行数组操作之前,请参考Arrays类 API 文档,您可能会找到实现相同功能的方法。

不要被这个类中的大量方法吓倒。它不支持超过 150 种类型的数组操作。在Arrays类中有大量方法的原因是为了支持对所有原始类型和引用类型的数组的相同操作。大多数方法至少有九个重载版本——一个用于八个基元类型数组,一个用于引用类型数组。有时,可以在整个数组或一系列元素上执行操作,这使得一个数组操作的最小方法数加倍,至少达到 18 个。不可能详细介绍每一种方法并提供示例。那将会超过这本书的 100 页。我们根据它们执行的操作类型将所有方法分为不同的类别,并提供了几个例子。表 19-3 列出了这些类别以及在这些类别中执行数组操作的方法的名称。

表 19-3

Arrays 类中的方法及其类别和说明

|

种类

|

方法名称

|

描述

转换 asList() 返回一个由指定数组支持的固定大小的列表。这个方法只有一个版本。
stream() 为所有元素或某个范围的元素返回一个数组的序列流。
toString() 返回指定数组内容的字符串表示形式。
deepToString() 返回数组“深层内容”的字符串表示形式。适合用于多维数组。
搜索 binarySearch() 允许您使用二分搜索法算法搜索排序后的数组。在传递给此方法之前,必须对数组进行排序;否则,结果不确定。允许在整个数组或数组中的某个元素范围内进行搜索。
比较 compare() 按字典顺序比较两个数组。如果第一个和第二个数组相等并且包含相同顺序的相同元素,则返回 0;如果第一个数组在字典序上小于第二个数组,则返回小于 0 的值;如果第一个数组在字典序上大于第二个数组,则返回大于 0 的值。该方法是在 Java 9 中添加的。
compareUnsigned() 工作原理与compare()方法相同,在数字上将元素视为无符号元素。该方法是在 Java 9 中添加的。
deepEquals() 如果两个指定数组完全相等,则返回true
equals() 如果两个指定的整数数组彼此相等,则返回true。您可以比较整个数组或数组中某个范围的元素是否相等。该方法是在 Java 9 中添加的。
mismatch() 查找并返回两个数组之间第一个不匹配项的索引;否则,如果没有发现不匹配,则返回–1。可以比较两个数组的全部内容或它们的元素范围是否不匹配。该方法是在 Java 9 中添加的。
复制 copyOf() 将一个数组复制到另一个数组。指定新数组的长度。新数组可能小于或大于源数组。如果它更大,则用数组的数据类型的默认值填充附加元素。
copyOfRange() 将一系列元素从一个数组复制到另一个数组。
填充物 fill() 允许您为数组中的所有元素或某个范围的元素分配相同的值。
setAll() 允许您为数组中的所有元素或某个范围的元素赋值。这些值由生成器函数生成。
计算哈希代码 deepHashCode() 基于数组的“深层内容”返回哈希代码。对于多维数组,所有维度中的内容都包括在哈希代码的计算中。
hashCode() 基于指定数组的内容返回哈希代码。
并行更新 parallelPrefix() 使用提供的函数,并行累积数组中的每个元素。
parallelSetAll() 使用生成器函数计算每个元素,并行设置指定数组的所有元素。
整理 parallelSort() 使用并行排序对数组中的所有元素或某个范围的元素进行排序。
sort() 对数组中的所有元素或某个范围的元素进行排序。
获取拆分器 spliterator() 返回一个包含数组中所有或一系列元素的Spliterator

以下部分向您展示了如何使用其中的一些方法。您需要从java.util包中导入几个类和接口来运行示例代码片段。以下导入将完成这项工作:

import java.util.*;

将数组转换为其他类型

在这个类别中,Arrays类中的方法让您从数组中获得一个ListStreamStringasList(T... a)方法接受类型为T的 var-args 参数,并返回一个List<T>。以下是几个例子:

// Create a String array
String[] animals = {"rat", "dog", "cat"};
// Convert the array to a List
List<String> animalList = Arrays.asList(animals);
System.out.println("As a List: " + animalList);
// Convert the array to a String
String str = Arrays.toString(animals);
System.out.println("As a String: " + str);
// Get a sorted Stream of the array and print its elements
System.out.println("Sorted Stream of Animals: ");
Arrays.stream(animals)
      .sorted()
      .forEach(System.out::println);
As a List: [rat, dog, cat]
As a String: [rat, dog, cat]
Sorted Stream of Animals:
cat
dog
rat

搜索数组

使用binarySearch()方法在数组中搜索一个键。必须对数组进行排序,此方法才能起作用。如果搜索关键字包含在数组中,则方法返回该关键字的索引。否则,它返回一个负数,等于

(-(insertion point) - 1)

这里,插入点被定义为索引,在这个索引处,键将被插入到数组中。这保证了如果键不在数组中,返回值是负整数。下面是一个例子:

// Create an array to work with
int[] num = {2, 4, 3, 1};
System.out.println("Original Array: " + Arrays.toString(num));
// Sort the array before using the binary search
Arrays.sort(num);
System.out.println("Array After Sorting: " + Arrays.toString(num)); // Array After Sorting: [1, 2, 3, 4]
int index = Arrays.binarySearch(num, 3);
System.out.println("Found index of 3: " + index);    //Found index of 3: 2
index = Arrays.binarySearch(num, 200);
System.out.println("Found index of 200: " + index);  // Found index of 200: -5

由于 200 应该放在最后,在索引 4 处,binarySearch 方法返回–4–1,即–5。

比较数组

equals()方法让你比较两个数组是否相等。如果数组或切片中的元素数量相同,并且数组或切片中所有对应的元素对都相等,则认为两个数组相等。

compare()compareUnsigned()方法按字典顺序比较数组或数组切片中的元素。compareUnsigned()方法将整数值视为无符号的。一个null数组在字典序上小于一个非null数组。两个null数组相等。

mismatch()方法比较两个数组或数组片。The方法返回第一个不匹配的索引。如果没有不匹配,它返回-1。如果任一数组是null,它抛出一个NullPointerException。清单 19-17 包含了一个比较两个数组及其切片的完整程序。该程序使用int数组进行比较。

// ArrayComparison.java
package com.jdojo.array;
import java.util.Arrays;
public class ArrayComparison {
    public static void main(String[] args) {
        int[] a1 = {1, 2, 3, 4, 5};
        int[] a2 = {1, 2, 7, 4, 5};
        int[] a3 = {1, 2, 3, 4, 5};
        // Print original arrays
        System.out.println("Three arrays:");
        System.out.println("a1: " + Arrays.toString(a1));
        System.out.println("a2: " + Arrays.toString(a2));
        System.out.println("a3: " + Arrays.toString(a3));
        // Compare arrays for equality
        System.out.println("\nComparing arrays using equals() method:");
        System.out.println("Arrays.equals(a1, a2): " + Arrays.equals(a1, a2));
        System.out.println("Arrays.equals(a1, a3): " + Arrays.equals(a1, a3));
        System.out.println("Arrays.equals(a1, 0, 2, a2, 0, 2): "
                + Arrays.equals(a1, 0, 2, a2, 0, 2));
        // Compare arrays lexicographically
        System.out.println("\nComparing arrays using compare() method:");
        System.out.println("Arrays.compare(a1, a2): " + Arrays.compare(a1, a2));
        System.out.println("Arrays.compare(a2, a1): " + Arrays.compare(a2, a1));
        System.out.println("Arrays.compare(a1, a3): " + Arrays.compare(a1, a3));
        System.out.println("Arrays.compare(a1, 0, 2, a2, 0, 2): "
                + Arrays.compare(a1, 0, 2, a2, 0, 2));
        // Find the mismatched index in arrays
        System.out.println("\nFinding mismatch using the mismatch() method:");
        System.out.println("Arrays.mismatch(a1, a2): " + Arrays.mismatch(a1, a2));
        System.out.println("Arrays.mismatch(a1, a3): " + Arrays.mismatch(a1, a3));
        System.out.println("Arrays.mismatch(a1, 0, 5, a2, 0, 1): "
                + Arrays.mismatch(a1, 0, 5, a2, 0, 1));
    }
}
Three arrays:
a1: [1, 2, 3, 4, 5]
a2: [1, 2, 7, 4, 5]
a3: [1, 2, 3, 4, 5]
Comparing arrays using equals() method:
Arrays.equals(a1, a2): false
Arrays.equals(a1, a3): true
Arrays.equals(a1, 0, 2, a2, 0, 2): true
Comparing arrays using compare() method:
Arrays.compare(a1, a2): -1
Arrays.compare(a2, a1): 1
Arrays.compare(a1, a3): 0
Arrays.compare(a1, 0, 2, a2, 0, 2): 0
Finding mismatch using the mismatch() method:
Arrays.mismatch(a1, a2): 2
Arrays.mismatch(a1, a3): -1
Arrays.mismatch(a1, 0, 5, a2, 0, 1): 1

Listing 19-17Comparing Arrays and Array Slices Using the Arrays Class Methods

复制数组

copyOf()方法允许您通过为新数组指定新长度来复制原始数组的元素。如果新数组的长度大于原始数组的长度,则根据数组的类型为附加元素分配一个默认值。copyOfRange()方法允许您将一个数组的一部分复制到另一个数组中。这里有一个例子:

// Create an array to work with
int[] nums = {2, 4, 3, 1};
System.out.println("Original Array: " + Arrays.toString(nums));
// Copy of the truncated num to 2 elements
int[] numCopy1 = Arrays.copyOf(nums, 2);
System.out.println("Truncated Copy: " + Arrays.toString(numCopy1));
// Copy of the extended num to 6 elements
int[] numCopy2 = Arrays.copyOf(nums, 6);
System.out.println("Extended Copy: " +  Arrays.toString(numCopy2));
// Copy of the range index 2 (inclusive) to 4 (exclusive)
int[] numCopy3 = Arrays.copyOfRange(nums, 2, 4);
System.out.println("Range Copy: " +  Arrays.toString(numCopy3));
Original Array: [2, 4, 3, 1]
Truncated Copy: [2, 4]
Extended Copy: [2, 4, 3, 1, 0, 0]
Range Copy: [3, 1]

填充数组

您可以使用fill()方法用相同的值填充数组的所有元素或元素范围。setAll()方法允许您使用函数为数组中的所有元素设置值。向该函数传递数组的索引,它返回该索引处元素的值。以下是使用这两种方法的示例:

// Create an array to work with
int[] num = {2, 4, 3, 1};
System.out.println("Original Array: " + Arrays.toString(num));
// Fill elements of the array with 10
Arrays.fill(num, 10);
System.out.println("Array filled with 10: " + Arrays.toString(num));
// Fill elements of the array with a value (index + 1) * 10
Arrays.setAll(num, index -> (index + 1) * 10);
System.out.println("Array filled with a function: " + Arrays.toString(num));
Original Array: [2, 4, 3, 1]
Array filled with 10: [10, 10, 10, 10]
Array filled with a function: [10, 20, 30, 40]

计算哈希代码

使用hashCode()方法根据数组元素的值计算数组的散列码。如果传入的数组是null,方法返回 0。对于任意两个阵列a1a2使得Arrays.equals (a1, a2),对于Arrays.hashCode(a1) == Arrays.hashCode(a2)也是如此。这里有一个例子:

// Create an array to work with
int[] num = {2, 4, 3, 1};
System.out.println("Array: " + Arrays.toString(num));
// Compute the hash code of the array
int hashCode = Arrays.hashCode(num);
System.out.println("Hash Code: " + hashCode);
Array: [2, 4, 3, 1]
Hash Code: 987041

执行并行累加


int[] num = {2, 4, 3, 1};
System.out.println("Before: " + Arrays.toString(num));
Arrays.parallelPrefix(num, (n1, n2) -> n1 * n2);
System.out.println("After: " + Arrays.toString(num));
Before: [2, 4, 3, 1]
After: [2, 8, 24, 24]

排序数组

使用sort()parallelSort()方法对数组元素进行排序。前者适用于较小的阵列,后者适用于较大的阵列。这里有几个例子:

// Create an array to work with
int[] num1 = {2, 4, 3, 1};
System.out.println("Original Array: " + Arrays.toString(num1));
// Sort the array
Arrays.sort(num1);
System.out.println("Using sort(): " + Arrays.toString(num1));
// Create an array to work with
int[] num2 = {2, 4, 3, 1};
System.out.println("Original Array: " + Arrays.toString(num2));
// Sort the array
Arrays.parallelSort(num2);
System.out.println("Using parallelSort(): " + Arrays.toString(num2));
Original Array: [2, 4, 3, 1]
Using sort(): [1, 2, 3, 4]
Original Array: [2, 4, 3, 1]
Using parallelSort(): [1, 2, 3, 4]

摘要

数组是存储同一类型的多个数据值的数据结构。所有数组元素在内存中都被分配了连续的空间。使用数组元素的索引来访问数组元素。数组使用从零开始的索引。第一个元素的索引为零。每个数组都有一个名为length的属性,它包含数组中元素的数量。数组的长度可以为零。

Java 中的数组是对象。Java 支持定长数组。也就是说,一旦创建了数组,它的长度就不能更改。如果你需要一个可变长度的数组,使用一个ArrayListArrayList类提供了一个toArray()方法来将其元素转换成数组。Java 支持数组的数组形式的多维数组。您可以使用clone()方法克隆一个数组。该方法对引用数组执行浅层克隆。

java.util包中的Arrays类包含了几个静态的便利方法,让你可以对一个数组执行许多不同类型的操作,比如搜索、排序、比较、填充等等。

EXERCISES

  1. 什么是数组?命名数组的属性,该属性给出数组中元素的数目。

  2. 数组第一个元素的索引是什么?

  3. 编写代码以两种方式初始化一个int数组。该数组包含元素102030:

  4. 您必须在数组中存储值,但是您事先不知道元素的数量。在这种情况下,你将如何编码,以便最终得到一个数组中的所有元素?

  5. 完成以下代码片段,打印数组对象的类名:

    String[] names = {"Corky", "Bryce", "Paul", "Tony"};
    String className = names./* Your code goes here */;
    System.out.println("Class Name: " + className);
    
    
  6. 考虑下面这个名为test()的方法声明,它采用一个int[]数组作为参数:

    public static void test(int[] num) {
        if(num.length > 0) {
            num[0] = 100;
        }
        num = new int[]{1000, 2000};
     }
    
    

    在执行下面的代码时写出输出:

    int[] num = {2, 4, 3, 1};
    System.out.println("num[0] = " + num[0]);
    test(num);
    System.out.println("num[0] = " + num[0]);
    
    
  7. 下面哪个语句声明了一个二维int数组?

    int[][] y;
    int z[][];
    int[] x[];
    int[] x = {2, 2};
    
    
  8. 声明一个名为table的三行三列的二维数组。演示如何在声明和使用for循环的过程中用值10初始化数组的所有元素。

  9. Consider the following declaration for an array:

    int[] x = {10, 20, 30, 40};
    
    

    编写一个for循环和一个for-each循环,在标准输出的一行中打印数组中每个元素的值。

  10. Consider the following declaration for an array:

```java
int[] x = {10, 20, 30, 40};
System.out.println(x[5]);

```

当这段代码被执行时会发生什么?
  1. 你将使用Arrays类的什么方法对一个大数组进行排序:sort()还是parallelSort()

  2. Arrays类中命名将数组转换为其字符串表示的方法。

  3. Arrays类包含一个binarySearch()方法,允许您在数组中搜索一个值。在使用binarySearch()方法之前,阵列必须满足什么条件?

  4. 编写并解释以下代码片段的输出:

```java
int[][] table1 = {{1, 2, 3}, {10, 20, 30}};
int[][] table2 = {{1, 2, 3}, {10, 20, 30}};
boolean equal1 = Arrays.equals(table1, table2);
boolean equal2 = Arrays.deepEquals(table1, table2);
System.out.println(equal1);
System.out.println(equal2);

```
  1. 考虑下面的代码片段,它将一个名为table1的二维数组的内容复制到另一个名为table2的二维数组中。帮助这段代码的作者完成缺失的逻辑。您需要编写两行代码:
```java
int[][] table1 = {{1, 2, 3}, {10, 20, 30}};
int[][] table2 = new int[table1.length][];
// Complete missing logic
for(int i = 0; i < table1.length; i++) {
    /* Your one line code goes here */
    for(int j = 0; j < table1[i].length; j++) {
        /* Your one line code goes here */
    }
}
boolean equal = Arrays.deepEquals(table1, table2);
System.out.println(equal);
System.out.println(Arrays.deepToString(table1));
System.out.println(Arrays.deepToString(table2));

```

这段代码应该有以下输出:

```java
true
[[1, 2, 3], [10, 20, 30]]
[[1, 2, 3], [10, 20, 30]]

```

二十、继承

在本章中,您将学习:

  • 什么是继承

  • 如何从另一个类继承一个类

  • 早期绑定和晚期绑定的区别

  • 什么是方法重写以及如何重写方法

  • 什么是字段隐藏和方法隐藏以及如何使用它们

  • 什么是抽象类以及在哪里使用它们

  • 如何声明final类和方法

  • “是一个”、“有一个”和“部分”关系之间的区别

  • 如何对类使用模式匹配

  • 如何使用密封类

本章中的所有示例程序都是清单 20-1 中声明的jdojo.inheritance模块的成员。

// module-info.java
module jdojo.inheritance {
    exports com.jdojo.inheritance;
}

Listing 20-1The Declaration of a jdojo.inheritance Module

什么是继承?

有时,您可能需要在应用程序的多个地方使用相同的功能。有不同的方法来编写代码来实现这一点。一种方法是在所有需要相同功能的地方复制相同的代码。如果您遵循这种逻辑,那么当功能发生变化时,您需要在所有地方进行更改。考虑一个例子,您在三个不同的地方需要相同的功能。假设您有一个处理三种对象的应用程序:行星、雇员和经理。进一步,假设这三种对象都有一个名字。您创建了三个类,PlanetEmployeeManager,来表示这三种对象。每个类都有一个名为name的实例变量和两个名为getName()setName()的方法。如果你想一想在三个类的代码中维护它们对象的名字,你会发现它们是一样的。您可能为一个类编写了代码,并将其复制到另外两个类中。当相同的代码被复制到多个地方时,您可能会意识到维护这种代码的问题。如果以后需要对名称进行不同的处理,您将需要在三个地方进行更改。继承是面向对象编程的特性,在这种情况下有助于避免在多个地方复制相同的代码,从而促进代码重用。继承还允许您在不更改现有代码的情况下自定义代码。继承提供的不仅仅是代码重用和定制。

继承是面向对象编程语言的基石之一。它允许您通过重用现有类中的代码来创建一个新类。新的类称为子类,现有的类称为超类。超类包含由子类重用和定制的代码。据说子类继承了超类。超类也称为基类或父类。子类也称为派生类或子类。从技术上讲,从任何现有的类继承一个类是可能的。然而,实际上这样做并不总是一个好主意。软件开发中的继承与正常人类生活中的继承工作方式非常相似。你从父母那里继承了一些东西;你的父母从父母那里继承了一些东西;等等。如果你观察人类生活中的遗传,就会发现人类之间存在着遗传发生的关系。类似地,超类和子类的对象之间也存在关系。为了使继承有效,超类和子类之间必须存在的关系称为“is-a”关系。在从类P继承类Q之前,你需要问自己一个简单的问题:“类Q的对象也是类P的对象吗?”换句话说,“类Q的对象的行为像类P?的对象吗?”如果答案是肯定的,那么类Q可以从类P继承。考虑三个类:PlanetEmployeeManager。让我们逐个使用这三个类来问同样的问题:

  • 行星是员工吗?也就是说,一个星球和一个雇员之间存在“是-是”的关系吗?答案是否定的,员工是星球吗?答案是否定的。

  • 一个星球是管理者吗?答案是否定的,管理者是一个星球吗?答案是否定的。

  • 员工是经理吗?答案是可能。雇员可以是经理、职员、程序员或任何其他类型的雇员。然而,员工不一定总是经理。经理是员工吗?答案是肯定的。

你用三个类问了六个问题。你只在一种情况下得到“是”的答案。这是唯一适合使用继承的情况。Manager类应该继承自Employee类。

继承类

一个类如何从另一个类继承?从另一个类继承一个类非常简单。您需要在子类的类声明中使用关键字extends,后跟超类名。一般语法如下:

[modifiers] class <subclass-name> extends <superclass-name> {
    // Code for the subclass goes here
}

例如,下面的代码声明了一个类Q,它继承自类P,假设类P已经存在:

public class Q extends P {
    // Code for class Q goes here
}

在类声明中,可以使用超类的简单名称或完全限定名称。如果子类和超类不在同一个包中,您可能需要导入超类名称,以便在extends子句中使用它的简单名称。假设类PQ的完全限定名分别是pkg1.Ppkg2.Q。前面的声明可以用以下两种方式之一重写—使用超类的简单名称或使用超类的完全限定名称:

// #1 – Use the simple name of P in the extends clause and use an import statement.
package pkg2;
import pkg1.P;
public class Q extends P {
    // Code for class Q goes here
}
// #2 – Use the fully qualified name of P. No need to use an import statement.
package pkg2;
public class Q extends pkg1.P {
    // Code for class Q goes here
}

让我们看看 Java 中最简单的继承例子。让我们从一个Employee类开始,如清单 20-2 所示。

// Employee.java
package com.jdojo.inheritance;
public class Employee {
    private String name = "Unknown";
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}

Listing 20-2An Employee Class

Employee是一个简单的类,有一个私有实例变量name,和两个公共方法setName()getName()。实例变量用于存储雇员的姓名,这两个方法用于获取和设置 name 实例变量。注意在Employee类中没有特殊的代码。这是你能用 Java 编写的最简单的类之一。很容易编写和理解下面使用Employee类的代码片段:

Employee emp = new Employee();
emp.setName("John Jacobs");
String empName = emp.getName();
System.out.println("Employee Name: " + empName);
Employee Name: John Jacobs

清单 20-3 包含一个Manager类的代码,它继承自Employee类。注意关键字extends的使用表明Employee类是超类而Manager类是子类。Manager类不包含任何代码,除了它的声明。这就是你现在在Manager职业中所需要的。

// Manager.java
package com.jdojo.inheritance;
public class Manager extends Employee {
    // No code is needed for now
}

Listing 20-3A Manager Class

我们来测试一下Manager类。清单 20-4 包含了测试程序。

// SimplestInheritanceTest.java
package com.jdojo.inheritance;
public class SimplestInheritanceTest {
    public static void main(String[] args) {
        // Create an object of the Manager class
        Manager mgr = new Manager();
        // Set the manager name
        mgr.setName("Leslie Zanders");
        // Get the manager name
        String mgrName = mgr.getName();
        // Print the manager name
        System.out.println("Manager Name: " + mgrName);
    }
}
Manager Name: Leslie Zanders

Listing 20-4Testing the Manager Class

即使你没有为Manager类编写任何代码,它也和Employee类一样工作,因为它继承了Employee类。使用Manager类的构造器创建一个管理器对象:

Manager mgr = new Manager();

在创建了Manager对象之后,代码看起来类似于您用来处理Employee对象的代码。您对Manager对象使用了setName()getName()方法:

mgr.setName("Leslie Zanders");
String mgrName = mgr.getName();

注意,Manager类没有声明setName()getName()方法。它也没有声明name实例变量。然而,看起来它们都是在Manager类中声明的,因为它在声明中使用了"extends Employee"子句。当一个类从另一个类继承时,它继承它的超类成员(实例变量、方法等)。).管理继承的规则有很多。我们将在本章的后面逐一详细讨论这些规则。

Object类是默认的超类

如果一个类没有在其类声明中使用关键字extends指定超类,那么它继承自java.lang.Object类。例如,类P的以下两个类声明是相同的:

// #1 – "extends Object" is implicitly added for class P
public class P {
    // Code for class P goes here
}
// #2 – "extends Object" is explicitly added for class P
public class P extends Object {
    // Code for class P goes here
}

在前面的章节中,您没有使用extends子句来声明您的类。它们是从Object类中隐式继承的。这就是那些类的对象能够使用Object类的方法的原因。考虑以下代码片段:

Employee emp = new Employee();
int hc = emp.hashCode();
String str = emp.toString();

Employee类没有使用 extends 子句指定它的超类。这意味着它继承自Object类。Object类声明了hashCode()toString()方法。因为Employee类是Object类的一个子类,它可以使用这些方法,就好像它们已经包含在它自己的声明中一样。尽管您没有意识到,但是您已经从您编写的第一个 Java 程序中使用了继承。本节展示了代码重用带来的继承能力。在本章的后面你会看到继承的其他好处。

继承和等级关系

我们在上一节中提到了这一点,即只有当子类和超类之间存在“is-a”关系时,才应该使用继承。一个子类可以有自己的子类,子类又可以有自己的子类,依此类推。继承链中的所有类形成一个树状结构,这就是所谓的继承层次结构或类层次结构。继承层次结构中一个类之上的所有类都被称为该类的祖先。继承层次结构中某个类下面的所有类都称为该类的后代。

Java 允许一个类的单一继承。也就是说,一个类只能有一个超类(或父类)。然而,一个类可以是多个类的超类。Java 中的所有类都有一个超类,除了Object类。Object类位于继承层次的顶端。图 20-1 使用 UML(统一建模语言)图展示了Employee类及其后代的继承层次结构。在 UML 图中,超类和子类使用从子类指向超类的箭头连接。

img/323069_3_En_20_Fig1_HTML.png

图 20-1

示例继承类层次结构

有时,术语“直接超类”用于表示祖先类,它是继承层次结构中的上一级,而术语“超类”用于表示任何级别的祖先类。本书使用术语“超类”来表示一个类的祖先,它是继承层次结构中的上一级。例如,ProgrammerSystemProgrammer的超类,而EmployeeObjectSystemProgrammer的祖先。有时,术语“直接子类”用于表示继承层次结构中的下一级子类,而术语“子类”用于表示任何级别的子类。本书使用术语“子类”来表示一个类的后代,它是继承层次结构中的下一级。例如,Employee是 Object 的子类,而ClerkProgrammerManagerEmployee的子类。ClerkProgrammerApplicationProgrammerSystemProgrammerDatabaseProgrammerManagerFullTimeManagerPartTimeManager都是Employee的后代。如果一个类是另一个类的后代,它也是那个类的祖先的后代。例如,Employee类的所有后代也是Object类的后代。所有Manager类的后代也是Employee类和Object类的后代。

子类继承了什么?

子类并不从它的超类继承一切。然而,子类可以直接或间接地使用超类中的任何东西。让我们来讨论“子类从其超类继承一些东西”和“子类使用其超类的一些东西”之间的区别

让我们举一个真实世界的例子。假设你的父母(一个超类)在银行账户里有钱。这笔钱属于你的父母。你(一个子类)需要一些钱。如果你继承了这笔钱,你会随意使用这笔钱,就好像这笔钱是你的一样。如果你只能用钱,你就不能直接拿到父母的钱。相反,你需要向你的父母要钱,他们会给你的。在这两种情况下,你都用了你父母的钱。在继承的情况下,这笔钱看起来是属于你的。也就是说,您可以直接访问它。在第二种情况下,你父母的钱你可以使用,但你不能直接使用。在后一种情况下,你必须通过你的父母来使用他们的钱。

子类继承其超类的非私有成员。我们稍后将详细讨论这个规则。请注意,构造器和初始化函数(静态和实例)不是类的成员,因此,它们不会被继承。类的成员是在类体中声明的所有成员以及它从超类继承的成员。这种对类成员的定义具有涓滴效应。

假设有三个类:ABC。类A继承自Object类。类B继承自类A,类C继承自类B。假设类A声明了一个私有成员m1和一个非私有成员m2。类A的成员是m1m2,以及所有从Object类继承的成员。注意,A类的m1m2成员是声明成员,而其他成员是继承成员。类B的成员将是在类B中声明的任何成员以及类A的所有非私有成员。成员m1在类A中被声明为私有,因此它不会被类B继承。同样的逻辑也适用于类C的成员。Object类的非私有成员通过继承层次向下渗透到ABC类。类A的非私有成员通过继承层次向下渗透到类B,后者又向下渗透到类C

Tip

超类和它的子类可能在不同的模块中。模块P中的子类可以从模块Q中的超类继承,前提是该子类已经被声明为公共的,并且模块Q至少将包含超类的包导出到模块P。关于跨模块边界访问类的更多信息,请参考第十章。

有四个访问修饰符:privatepublicprotected和包级别。缺少privatepublicprotected访问修饰符被认为是默认或包级访问。类成员的访问级别修饰符决定了两件事:

  • 谁可以直接访问(或使用)该类成员

  • 无论子类是否继承该类成员

访问修饰符也用于类的非成员(例如构造器)。在这种情况下,访问修饰符的角色只有一个:“谁可以访问那个非成员?”

如果一个类成员被声明为private,那么它只能在声明它的类内部被访问。一个private类成员不能被该类的子类继承。

假设类本身是可访问的,那么从任何地方都可以访问类成员。子类继承其超类的所有public成员。

如果一个类成员被声明为protected,那么它在声明它的包中是可访问的。无论子类和类在同一个包中还是在不同的包中,受保护的类成员在子类的主体中总是可访问的。受保护的类成员由子类继承。当您希望子类访问和继承类成员时,可以使用protected访问修饰符。请注意,受保护的类成员既可以通过声明它的包访问,也可以通过子类访问。如果你想只从类成员的包内部提供对它的访问,你应该使用包级访问修饰符,而不是protected访问修饰符。

如果一个类成员被声明为包级别的,那么它只能在声明该类的包中被访问。只有当超类和子类在同一个包中时,才会继承包级的类成员。如果超类和子类在不同的包中,子类不会从其超类继承包级成员。

Tip

访问修饰符建立在彼此的基础上,从没有对外界的访问开始(private)并添加到它:首先,package(缺省);然后,子类(protected);然后是世界(public)。

让我们看看清单 20-2 和 20-3 中的继承示例。Employee类有三个成员:一个name字段、一个getName()方法和一个setName()方法。字段name已经被声明为private,因此它在Manager类中是不可访问的,因为它没有被继承。getName()setName()方法已经被声明为public,它们可以从任何地方访问,包括Manager类。它们由Manager类从Employee类继承而来,尽管因为它们是public,它们被继承的事实并不重要。

向上铸造和向下铸造

现实世界中的“是-是”关系转化为软件中的继承类层次结构。类是 Java 中的一种类型。当您使用继承来表达“is-a”关系时,您创建了一个子类,它是超类的一个更具体的类型。例如,ManagerEmployee的一种特定类型。安Employee是一种特殊类型的Object。当您在继承层次结构中向上移动时,您从一个特定的类型移动到一个更通用的类型。继承如何影响客户端代码?在这个上下文中,客户端代码是使用类层次结构中的类的任何代码。继承保证了一个类中存在的任何行为也将存在于它的子类中。类中的方法代表该类对象的行为。这意味着客户代码期望出现在类中的任何行为也将出现在该类的子类中。这导致了一个结论,如果客户端代码与一个类一起工作,它也将与该类的子类一起工作,因为子类至少保证了与其超类相同的行为。例如,Manager类至少提供了与其超类Employee相同的行为。考虑以下代码片段:

Employee emp;
emp = new Employee();
emp.setName("Richard Castillo");
String name = emp.getName();

这段代码编译时没有任何错误。当编译器遇到emp.setName("Richard Castillo")emp.getName()调用时,它会检查emp变量的声明类型。它发现emp变量的声明类型是Employee。它确保了Employee类具有符合正在进行的调用的setName()getName()方法。它发现Employee类确实有一个将String作为参数的setName()方法。它发现Employee类确实有一个getName()方法,该方法不接受任何参数并返回一个String。在验证了这两个事实之后,编译器就可以调用emp.setName()emp.getName()方法了。

记住,子类至少保证与其超类相同的行为(方法),考虑下面的代码片段:

Employee emp;
emp = new Manager(); // A Manager object assigned to an Employee variable
emp.setName("Richard Castillo");
String name = emp.getName();

编译器也会编译这段代码,尽管这次您已经修改了代码,将 emp 变量赋给了一个Manager类的对象。它将在与上一个案例相同的基础上传递setName()getName()方法调用。它还传递赋值语句:

emp = new Manager();

new Manager()表达式的编译时类型是Manager类型。emp变量的编译时类型(或声明类型)是Employee类型。因为Manager类继承自Employee类,所以Manager类的一个对象是Employee类的一个对象。简单来说,你说经理永远是员工。这样的赋值(从子类到超类)被称为向上转换,在 Java 中它总是被允许的。它也被称为扩大转换,因为一个Manager类(更具体的类型)的对象被分配给一个Employee类型(更一般的类型)的引用变量。以下所有赋值都是允许的,它们都是向上转换的例子:

Object obj;
Employee emp;
Manager mgr;
PartTimeManager ptm;
// An employee is always an object
obj = emp;
// A manager is always an employee
emp = mgr;
// A part-time manager is always a manager
mgr = ptm;
// A part-time manager is always an employee
emp = ptm;
// A part-time manager is always an object
obj = ptm;

使用一个简单的规则来检查赋值是否是向上转换。看一下赋值操作符右边表达式的编译时类型(声明类型)(如a = b中的b)。如果右边操作数的编译时类型是左边操作数的编译时类型的子类,则是向上转换的情况,并且赋值是安全的和允许的。向上转换是子类的对象也是超类的对象这一事实的直接技术翻译。

Tip

具备向上转换和后期绑定能力的继承是 Java 中包含多态的基础。参见第一章了解更多关于内含物多态的信息。

向上转换是继承的一个非常强大的特性。它允许您编写多态代码,处理现有的类和将来要添加的类。它允许你用一个超类来编码你的应用程序逻辑,这个超类将总是和所有的子类(现有的子类或者将来要添加的子类)一起工作。它允许您编写通用代码,而不必担心代码在运行时使用的特定类型(类)。清单 20-5 是一个简单的实用程序类,用来测试你的向上转换规则。它有一个printName()静态方法,接受一个Employee类型的参数。该方法使用Employee类的getName()方法来获取 employee 对象的名称,并将该名称打印在标准输出上。

// EmpUtil.java
package com.jdojo.inheritance;
public class EmpUtil {
    public static void printName(Employee emp){
        // Get the name of employee
        String name = emp.getName();
        // Print employee name
        System.out.println(name);
    }
}

Listing 20-5A Utility Class That Uses an Employee-Type Parameter in Its printName() Method

清单 20-6 包含一个使用EmpUtil类测试向上转换规则的程序。

// UpcastTest.java
package com.jdojo.inheritance;
public class UpcastTest {
    public static void main(String[] args) {
        Employee emp = new Employee();
        emp.setName("Ken Wood");
        Manager mgr = new Manager();
        mgr.setName("Ken Furr"); // Inheritance of setName() at work
        // Print names
        EmpUtil.printName(emp);
        EmpUtil.printName(mgr);  // Upcasting at work
    }
}
Ken Wood
Ken Furr

Listing 20-6A Test Class to Test the Upcasting Rule

main()方法创建两个对象(empmgr):一个是Employee类,一个是Manager类。它为两个对象设置名称。最后,它调用EmpUtil类的printName()方法来打印两个对象的名称。第一次调用EmpUtil.printName(emp)是可以的,因为printName()方法接受一个Employee对象,而您已经传递了一个Employee对象(emp)。第二个调用EmpUtil.printName(mgr),由于向上转换规则,是可以的。printName(Employee emp)接受一个Employee对象,您可以传递一个Manager对象(mgr),因为经理总是雇员,向上转换规则允许将子类对象分配给超类类型的变量。

将超类引用赋给子类变量称为向下转换(或收缩转换)。向下转换是向上转换的反义词。在向上造型中,赋值在类层次结构中向上移动,而在向下造型中,赋值在类层次结构中向下移动。Java 编译器无法在编译时确保向下转换是合法的。考虑以下代码片段:

Employee emp;
Manager mgr = new Manager();
emp = mgr; // OK. Upcasting
mgr = emp; // A compile-time error. Downcasting

由于向上转换,赋值emp = mgr被允许。然而,赋值mgr = emp是不允许的,因为这是一种向下转换的情况,其中超类(Employee)的变量被赋值给子类(Manager)的变量。编译器假设每个经理都是雇员是正确的。然而,并不是每个员工都是管理者(向下投射)。在前面的代码片段中,您希望向下转换能够工作,因为您肯定知道emp变量保存了对Manager的引用。为了让向下转换在编译时成功,Java 强加了一个额外的规则。您需要向编译器提供额外的保证,您已经考虑将超类引用赋值给子类引用变量,并且您希望编译器通过它。您可以通过向赋值中添加一个类型转换(或简单的转换)来提供这种保证,如下所示:

mgr = (Manager) emp; // OK using a typecast. Downcast at work

这种使用类型转换的向下转换在编译时会成功。但是,Java 运行时将执行额外的验证。编译器的工作只是确保mgr变量声明的类型(即Manager)与正在使用的类型转换(即Manager)是赋值兼容的。编译器无法检查emp变量在运行时实际引用的是什么类型的对象。Java 运行时验证前面语句中类型转换(Manager) emp的正确性。

emp变量在运行时引用的对象的类型也称为其运行时类型。运行时比较emp变量的运行时类型和Manager类型(类型转换中使用了Manager类型)。如果emp变量的运行时类型与类型转换中使用的类型是赋值兼容的,则类型转换在运行时成功。否则,运行时抛出一个java.lang.ClassCastException

考虑下面的代码片段,假设您有一个名为PartTimeManagerManager类的子类:

Employee emp;
Manager mgr = new Manager();
PartTimeManager ptm = new PartTimeManager();
emp = mgr;                   // Upcasting. OK
ptm = (PartTimeManager) emp; // Downcasting. OK at compile-time. A runtime error.

使用向下转换的最后一个赋值在编译时成功,因为ptm变量的声明类型和类型转换类型相同。emp的运行时类型是Manager,因为emp = mgr语句给它分配了一个Manager对象的引用。当运行时试图执行向下转换的"(PartTimeManager) emp"部分时,它发现emp的运行时类型Manager与类型转换类型PartTimeManager不兼容。这就是运行时会抛出一个ClassCastException的原因。

出于验证的目的,您可以将涉及向下转换的语句想象为包含两个部分。假设语句是a2 = (K) b2。编译器的工作是验证声明的类型a2与类型K是赋值兼容的。运行时的工作是验证运行时类型b2与类型K的赋值兼容。如果这两个检查中的任何一个失败了,根据哪个检查失败了,您会在编译时或运行时得到一个错误。图 20-2 描述了这种情况。

img/323069_3_En_20_Fig2_HTML.png

图 20-2

为向下转换进行的运行时和编译时检查

Tip

在 Java 中,Object类位于每个类层次结构的顶端。这允许您将任何类类型的引用分配给Object类型的变量。始终允许以下类型的分配:

Object obj = new AnyJavaClass(); // Upcasting

从一个Object类型向下转换到另一个类类型是否成功取决于向下转换规则,正如本节所讨论的。

运算符的实例

你如何确定向下转换在运行时总是成功的呢?Java 有一个instanceof操作符,它帮助你在运行时确定一个引用变量是否引用了一个类的对象或者这个类的子类。它接受两个操作数,并计算出一个booleantruefalse。其语法如下:

<reference-variable> instanceof <type-name>

如果<reference-variable>引用一个类型为<type-name>的对象或者它的任何后代,instanceof返回true。否则,它返回false。如果<reference-variable>null,则instanceof总是返回false

您应该在向下转换之前使用instanceof操作符来检查您试图向下转换的引用变量是否是您期望的类型。例如,如果您想在运行时检查一个Employee类型的变量是否引用了一个Manager对象,您应该编写以下代码:

Manager mgr = new Manager();
Employee emp = mgr;
if (emp instanceof Manager) {
    // The following downcast will always succeed
    mgr = (Manager) emp;
} else {
    // emp is not a Manager type
}

您可以使用 Java 16 中添加的模式匹配特性简化这一过程,如下所示:

Manager mgr = new Manager();
Employee emp = mgr;
if (emp instanceof Manager mgr) {
    // mgr is a variable of type Manager you can reference here
} else {
    // emp is not a Manager type
}

模式匹配 instanceof 运算符的语法如下:

<reference-variable> instanceof <type-name> <new-variable-name>

操作符经历两种类型的检查:编译时检查和运行时检查。编译器检查左操作数是否有可能引用右操作数的对象。此时,这张支票对你来说可能并不明显。使用instanceof操作符的目的是比较引用变量的运行时类型和类型。简而言之,它比较了两种类型。把一个芒果和一个员工相提并论有意义吗?你可能会说不。编译器使用instanceof操作符来检查这种不合逻辑的比较。它确保了instanceof操作符的左边操作数可以保存对右边操作数类型的对象的引用。如果不可能,编译器会生成一个错误。很容易发现编译器是否会因为使用instanceof操作符而产生错误。考虑以下代码片段:

Manager mgr = null;
if (mgr instanceof Clerk) { //  A compile-time error
}

变量mgr可以保存对Manager类型或其后代类型的引用。然而,它永远不能保存对Clerk类型的引用。Clerk类型和Manager类不在同一个继承链中,尽管它在同一个继承树中。出于同样的原因,以下 instanceof 运算符的使用将生成编译时错误,因为String类不在Employee类的继承链中:

String str = "test";
if (str instanceof Employee) { // A compile-time error
}

Tip

如果一个对象属于某个类的类型或其直接或间接的后代类型,则该对象被视为该类的实例。您可以使用instanceof操作符来检查一个对象是否是一个类的实例。

有时,您可能最终会编写使用instanceof操作符在一个地方测试多个条件的代码,如下所示:

Employee emp;
// Some logic goes here...
if (emp instanceof TempEmployee temp) {
    // Code to deal with a temp employee
} else if (emp instanceof Manager mgr) {
    // Code to deal with a manager
} else if (emp instanceof Clerk clerk) {
    // Code to deal with a clerk
}

您应该避免编写这种代码。如果您添加了一个新的子类Employee,您将需要添加新子类的逻辑到这个代码中。通常,这种代码表明存在设计缺陷。总是问自己这样一个问题,“当我向现有的类层次结构中添加一个新的类时,这段代码会继续工作吗?”如果答案是肯定的,你就很好。否则,重新考虑设计。

equals()方法中,您将经常使用instanceof操作符。它是在Object类中定义的,该方法由所有类继承。它以一个Object作为参数。如果参数和调用这个方法的对象被认为是相等的,它返回true。否则返回false。每个类的对象可以不同地比较相等性。例如,如果两个雇员为同一家公司和同一部门工作,并且具有相同的雇员 ID,则他们可能被认为是平等的。如果将一个Manager对象传递给Employee类的equals()方法会发生什么?既然管理者也是员工,就应该比较两者是否平等。清单 20-7 包含了Employee类的equals()方法的可能实现。

// Employee.java
package com.jdojo.inheritance;
public class Employee {
    private String name = "Unknown";
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public boolean equals(Object obj) {
        boolean isEqual = false;
        // We compare objects of the Employee class with the objects of
        // Employee class or its descendants
        if (obj instanceof Employee e) {
            // If two have the same name, consider them equal.
            String n = e.getName();
            isEqual = n.equals(this.name);
        }
        return isEqual;
    }
}

Listing 20-7Implementing the equals() Method for the Employee Class

equals()方法添加到Employee类后,可以编写如下代码,根据名称比较两个Employee类型的对象是否相等:

Employee emp = new Employee();
emp.setName("John Jacobs");
Manager mgr = new Manager();
mgr.setName("John Jacobs");
System.out.println(mgr.equals(emp));           // prints true
System.out.println(emp.equals(mgr));           // prints true
System.out.println(emp.equals("John Jacobs")); // prints false

在第三次比较中,您将一个Employee对象与一个String对象进行比较,后者返回false。比较一个Employee和一个Manager对象会返回true,因为它们通过子类化相关并且具有相同的名称。

Employee equals 方法可以减少到一行,因为 e 变量是由模式匹配 instanceof 运算符定义的,如下所示:

return (obj instanceof Employee e && e.getName().equals(this.name

有约束力的

类有方法和字段;我们编写代码来访问它们,如下所示。假设myMethod()xyzMyClass类的成员,该类是myObject引用变量的声明类型:

MyClass myobject = get an object reference;
myObject.myMethod();   // Which myMethod() to call?
int a = myObject.xyz;  // Which xyz to access?

绑定是识别被访问方法的代码(本例中为myMethod())或字段(本例中为xyz)的过程,代码执行时将会用到这些代码。换句话说,绑定是一个在代码执行时决定访问哪个方法的代码或字段的过程。绑定可以发生在两个阶段:编译时和运行时。当绑定发生在编译时,它被称为早期绑定。早期绑定也被称为静态绑定编译时绑定。当绑定发生在运行时,它被称为后期绑定。后期绑定也称为动态绑定运行时绑定

早期绑定

与后期绑定相比,早期绑定更容易理解。在早期绑定中,编译器在编译时决定访问哪个方法代码或字段。对于方法调用,当执行具有方法调用的代码时,编译器决定将执行哪个类中的哪个方法。对于字段访问,当执行具有字段访问的代码时,编译器决定将访问哪个类中的哪个字段。早期绑定用于类的以下类型的方法和字段:

  • 所有类型的字段:静态和非静态

  • 静态方法

  • 非静态最终方法

在早期绑定中,根据访问方法或字段的变量(或表达式)的声明类型(或编译时类型)来访问方法或字段。例如,如果早期绑定被用于一个a2.m1()方法调用,如果a2已经被声明为类型A,那么当a2.m1()被执行时,类A中的m1()方法将被调用。

让我们看一个演示早期绑定规则的详细示例。考虑清单 20-8 和 20-9 中显示的两个类。

// EarlyBindingSub.java
package com.jdojo.inheritance;
public class EarlyBindingSub extends EarlyBindingSuper{
    // An instance variable
    public String str = "EarlyBindingSub";
    // A static variable
    public static int count = 200;
    public static void print() {
        System.out.println("Inside EarlyBindingSub.print()");
    }
}

Listing 20-9An EarlyBindingSub Class, Which Inherits from the EarlyBindingSuper Class and Has a Static Field, an Instance Field, and a Static Method, Which Are of the Same Type as in Its Superclass

// EarlyBindingSuper.java
package com.jdojo.inheritance;
public class EarlyBindingSuper {
    // An instance variable
    public String str = "EarlyBindingSuper";
    // A static variable
    public static int count = 100;
    public static void print() {
        System.out.println("Inside EarlyBindingSuper.print()");
    }
}

Listing 20-8An EarlyBindingSuper Class That Has a Static Field, an Instance Field, and a Static Method

EarlyBindingSuper类声明了两个字段:strcountstr场被声明为非静态,count被声明为staticprint()方法被声明为static

EarlyBindingSub类继承自EarlyBindingSuper类,它声明了相同类型的字段和方法,它们具有相同的名称。字段被设置为不同的值,并且该方法在EarlyBindingSub类中打印不同的消息,因此您可以知道在执行代码时访问的是哪一个。清单 20-10 中的EarlyBindingTest类展示了早期绑定的结果。

// EarlyBindingTest.java
package com.jdojo.inheritance;
public class EarlyBindingTest {
    public static void main(String[] args) {
        var ebSuper = new EarlyBindingSuper();
        var ebSub = new EarlyBindingSub();
        // Will access EarlyBindingSuper.str
        System.out.println(ebSuper.str);
        // Will access EarlyBindingSuper.count
        System.out.println(ebSuper.count);
        // Will access EarlyBindingSuper.print()
        ebSuper.print();
        System.out.println("------------------------------");
        // Will access EarlyBindingSub.str
        System.out.println(ebSub.str);
        // Will access EarlyBindingSub.count
        System.out.println(ebSub.count);
        // Will access EarlyBindingSub.print()
        ebSub.print();
        System.out.println("------------------------------");
        // Will access EarlyBindingSuper.str
        System.out.println(((EarlyBindingSuper) ebSub).str);
        // Will access EarlyBindingSuper.count
        System.out.println(((EarlyBindingSuper) ebSub).count);
        // Will access EarlyBindingSuper.print()
        ((EarlyBindingSuper) ebSub).print();
        System.out.println("------------------------------");
        // Assign the ebSub to ebSuper
        ebSuper = ebSub; // Upcasting
        /* Now access methods and fields using ebSuper variable, which is
           referring to a EarlyBindingSub object
         */
        // Will access EarlyBindingSuper.str
        System.out.println(ebSuper.str);
        // Will access EarlyBindingSuper.count
        System.out.println(ebSuper.count);
        // Will access EarlyBindingSuper.print()
        ebSuper.print();
        System.out.println("------------------------------");
    }
}
EarlyBindingSuper
100
Inside EarlyBindingSuper.print()
------------------------------
EarlyBindingSub
200
Inside EarlyBindingSub.print()
------------------------------
EarlyBindingSuper
100
Inside EarlyBindingSuper.print()
------------------------------
EarlyBindingSuper
100
Inside EarlyBindingSuper.print()
------------------------------

Listing 20-10A Test Class to Demonstrate Early Binding for Fields and Methods

main()方法创建每种类型EarlyBindingSuperEarlyBindingSub的对象:

var ebSuper = new EarlyBindingSuper();
var ebSub = new EarlyBindingSub();

根据早期的绑定规则,语句ebSuper.strebSuper.countebSuper.print()将总是访问EarlyBindingSuper类的strcount字段以及print()方法,因为您已经声明了EarlyBindingSuper类型的ebSuper。这个决定是由编译器做出的,因为strcount是字段,对于字段,Java 总是使用早期绑定。print()方法是一个static方法,Java 总是对static方法使用早期绑定。当您使用ebSub变量访问这些成员时,同样的规则也适用。

以下语句的输出可能不明显:

// Will access EarlyBindingSuper.str
System.out.println(((EarlyBindingSuper)ebSub).str);
// Will access EarlyBindingSuper.count
System.out.println(((EarlyBindingSuper)ebSub).count);
// Will access EarlyBindingSuper.print()
((EarlyBindingSuper)ebSub).print();

这三个语句使用一个表达式来访问字段和方法。当你写ebSub.str时,你直接使用ebSub变量访问str域。很明显,ebSub变量属于EarlyBindingSub类型,因此,ebSub.str将访问EarlyBindingSub类的str字段。使用 typecast 时,表达式的编译时类型会发生变化。比如ebSub的编译时类型是EarlyBindingSub。但是,表达式(EarlyBindingSuper) ebSub的编译时类型是EarlyBindingSuper。这就是为什么前面三个语句都将从EarlyBindingSuper类访问字段和方法,而不是从EarlyBindingSub类,即使它们都使用ebSub变量,这是EarlyBindingSub类型。清单 20-10 的输出验证了关于早期绑定规则的解释讨论。

Tip

您还可以使用类的名称来访问类的static字段和方法,例如EarlyBindingSub.str。早期的绑定规则仍然适用,编译器会将对这些字段和方法的访问绑定到使用其名称来访问它们的类。为了可读性,最好使用类名来访问类的静态成员。

后期绑定

所有非静态、非最终方法的绑定都遵循后期绑定的规则。也就是说,如果你的代码访问一个非静态的方法,这个方法没有被声明为final,那么这个方法的哪个版本将在运行时被调用。将被调用的方法的版本取决于进行方法调用的对象的运行时类型,而不是其编译时类型。考虑下面的代码片段,它创建了一个Manager类的对象,并将引用赋给了一个Employee类的变量empemp变量访问setName()方法:

Employee emp = new Manager();
emp.setName("John Jacobs");

编译器只对这段代码中的emp.setName()方法调用执行一次检查。它确保了emp变量的声明类型,即Employee,有一个名为setName(String s)的方法。编译器检测到Employee类中的setName(String s)方法是实例方法,而不是final。对于实例方法调用,编译器不执行绑定。它将把这项工作留给运行时。方法调用emp.setName("John Jacobs")是后期绑定的一个例子。在运行时,JVM 决定应该调用哪个setName(String s)方法。JVM 获取emp变量的运行时类型。在这段代码中查看emp.setName("John Jacobs")语句时,emp变量的运行时类型是Manager。JVM 从emp变量的运行时类型(即Manager)开始遍历类层次结构,寻找setName(String s)方法的定义。首先,它查看Manager类,发现Manager类没有声明setName(String s)方法。JVM 现在在类层次结构中向上移动了一级,这就是Employee类。它发现Employee类声明了一个setName(String s)方法。一旦 JVM 找到匹配,它就将调用绑定到该方法并停止搜索。回想一下,在 Java 中,Object类总是位于所有类层次结构的顶端。JVM 继续搜索直到Object类的方法定义。如果在Object类中没有找到匹配,它抛出一个运行时异常。

让我们看一个演示后期绑定过程的例子。清单 20-11 和 20-12 分别有LateBindingSuperLateBindingSub类的代码。LateBindingSub类继承自LateBindingSuper类。它定义并覆盖了与在LateBindingSuper类中定义的相同的实例方法print()。两个类中的print()方法打印不同的消息,以便您可以看到哪个方法被调用。

// LateBindingSub.java
package com.jdojo.inheritance;
public class LateBindingSub extends LateBindingSuper {
    @Override
    public void print() {
        System.out.println("Inside LateBindingSub.print()");
    }
}

Listing 20-12A LateBindingSub Class, Which Has an Instance Method Named print()

// LateBindingSuper.java
package com.jdojo.inheritance;
public class LateBindingSuper {
    public void print() {
        System.out.println("Inside LateBindingSuper.print()");
    }
}

Listing 20-11A LateBindingSuper Class, Which Has an Instance Method Named print()

清单 20-13 展示了后期绑定的结果。

// LateBindingTest.java
package com.jdojo.inheritance;
public class LateBindingTest {
    public static void main(String[] args) {
        LateBindingSuper lbSuper = new LateBindingSuper();
        LateBindingSub lbSub = new LateBindingSub();
        // Will access LateBindingSuper.print()
        lbSuper.print(); // #1
        // Will access LateBindingSub.print()
        lbSub.print();   // #2
        // Will access LateBindingSub.print()
        ((LateBindingSuper) lbSub).print(); // #3
        // Assign the lbSub to lbSuper
        lbSuper = lbSub; // Upcasting
        // Will access LateBindingSub.print() because lbSuper
        // is referring to a LateBindingSub object
        lbSuper.print(); // #4
    }
}
Inside LateBindingSuper.print()
Inside LateBindingSub.print()
Inside LateBindingSub.print()
Inside LateBindingSub.print()

Listing 20-13A Test Class to Demonstrate Late Binding

main()方法创建每种类型的对象LateBindingSuperLateBindingSub:

LateBindingSuper lbSuper = new LateBindingSuper();
LateBindingSub lbSub = new LateBindingSub();

print()方法的调用被标记为#1、#2、#3 和#4,所以我们可以在讨论中引用它们。

两个变量lbSuperlbSub都用于访问print()实例方法。运行时决定调用哪个版本的print()方法。当你使用lbSuper.print()时,哪个print()方法被调用取决于lbSuper变量在那个时间点引用的对象。回想一下,一个类类型的引用变量也可以引用它的任何后代的对象。lbSuper变量可以引用LateBindingSuperLateBindingSub的对象。

当语句# 1lbSuper.print()准备好执行时,运行时将需要找到print()方法的代码。运行时寻找lbSuper变量的运行时类型,它发现lbSuper变量引用了一个LateBindingSuper类型的对象。它在LateBindingSuper类中寻找print()方法并找到它。因此,运行时将标记为#1 的语句中的print()方法调用绑定到LateBindingSuper类的print()方法。输出中的第一行证实了这一点。

在语句#2 中绑定print()方法的逻辑与标记为#1 的语句相同,但是这次的类是LateBindingSub

语句#3 是棘手的。当你使用像(LateBindingSuper) lbSub这样的类型转换时,lbSub在运行时引用的对象不会改变。使用一个类型转换,你所要说的就是你想使用lbSub变量引用的对象作为LateBindingSuper类型的对象。然而,对象本身永远不会改变。您可以使用下面的代码来验证这一点,该代码获取对象的类名:

// Both s1 and s2 have "com.jdojo.inheritance.LateBindingSub" class name LateBindingSub lbSub = new LateBindingSub();
String s1 = lbSub.getClass().getName();
String s2 = ((LateBindingSuper)lbSub).getClass().getName();

当语句#3 准备执行时,此时带有类型转换的表达式仍然引用一个LateBindingSub类型的对象,因此,将调用LateBindingSub类的print()方法。输出中的第三行证实了这一点。

考虑两行代码来讨论语句#4:

lbSuper = lbSub; // Upcasting
lbSuper.print(); // #4

第一个源代码行将lbSub分配给lbSuper。这一行的作用是lbSuper变量开始引用一个LateBindingSub类型的对象。当语句#4 准备好执行时,运行时需要找到print()方法的代码。运行时发现lbSuper变量的运行时类型是LateBindingSub类。它在LateBindingSub类中寻找print()方法,并在那里找到它。因此,语句#4 执行LateBindingSub类中的print()方法。输出中的第四行证实了这一点。

Tip

与早期绑定相比,后期绑定会导致较小的性能开销,因为方法调用是在运行时解析的。然而,编程语言可以使用许多技术(例如,虚拟方法表)来实现后期绑定,因此性能影响很小或者可以忽略不计。后期绑定的好处掩盖了对性能的轻微影响。它让您实现包含多态。当你编写类似于a2.print()的代码时,a2变量表现出相对于print()方法的多态行为。同样的代码,a2.print(),可能调用a2变量的类的print()方法或者它的任何子类,这取决于a2在运行时引用的Object类型。继承和后期绑定允许您编写多态代码,这些代码是根据超类类型编写的,也适用于所有子类类型。

方法覆盖

在类中重新定义从超类继承的实例方法称为方法重写。考虑下面的类A和类B的声明:

public class A {
    public void print() {
        System.out.println("A");
    }
}
public class B extends A {
    @Override
    public void print() {
        System.out.println("B");
    }
}

B是类A的子类。类B从它的超类继承了print()方法并重新定义了它。据说B类的print()方法覆盖了 a 类的print()方法,这就好比B类告诉A类,“感谢你做我的超类,让我继承了你的print()方法。然而,我需要不同的工作方式。我将以我的方式重新定义它,而不会以任何方式影响你的print()方法。你可以继续用你的print()方法。”如果一个类重写一个方法,它会影响重写的类及其子类。考虑下面的类声明C:

public class C extends B {
    // C inherits B.print()
}

C没有声明任何方法。类C继承了什么方法:A.print()B.print()或两者?它从类B继承了print()方法。一个类总是从它的直接超类(在超类中声明或由其超超类继承)继承可用的东西。如果一个类D从类C继承,它将通过类C继承类B的打印()方法:

public class D extends C {
    // D inherits B.print() through C
}

考虑另外两个类EF,它们分别继承自DE。类E覆盖了类Bprint()方法,它从类D继承而来:

public class E extends D {
    @Override
    public void print() {
        System.out.println("E");
    }
}
public class F extends E {
    // F inherits E.print() through E
}

以下代码片段的输出会是什么?

A a = new A();
a.print();     // will print A
a = new B();
a.print();     // will print B
a = new C();
a.print();     // will print B
a = new D();
a.print();     // will print B
a = new E();
a.print();     // will print E
a = new F();
a.print();     // will print E

代码中的注释告诉你什么将被打印。你能想出为什么得到这个输出吗?有三件事在起作用。首先,您可以将类A的后代的对象分配给类A类型的变量。这就是你在所有声明中称a.print()的原因。第二,print()方法已经被类层次结构中的类A的一些后代覆盖。第三,后期绑定根据变量在运行时引用的对象的类来执行调用适当的print()方法的魔法。考虑以下两个类别ST的定义:

public class S {
    public void print() {
        System.out.println("S");
    }
}
public class T extends S {
    public void print(String msg) {
        System.out.println(msg);
    }
}

T中的print()方法会覆盖其超类S中的print()方法吗?答案是否定的。类T中的print()方法不会覆盖类S中的print()方法。这被称为方法重载。类T现在将有两个print()方法:一个继承自它的超类S,不带参数,另一个在其中声明,带参数String。然而,类T的两个方法有相同的名字print。这就是它被称为方法重载的原因,因为同一个方法名在同一个类中被多次使用。

下面是当一个类被称为覆盖一个方法时的规则,这个方法是从它的超类继承的。

方法覆盖规则#1

该方法必须是实例方法。重写不适用于静态方法。

方法覆盖规则#2

重写方法必须与被重写的方法同名。

方法覆盖规则#3

重写方法必须具有与被重写方法相同数量、相同顺序的相同类型的参数。当方法使用泛型类型作为参数时,Java 5 中引入的泛型稍微改变了这个规则。当方法的参数使用泛型类型时,在与其他方法进行比较以检查一个方法是否重写另一个方法时,您需要考虑泛型类型参数的擦除,而不是泛型类型本身。我们稍后将再次讨论这条规则,并通过示例详细讨论它。现在,如果一个方法和另一个方法具有相同数量的相同类型的相同顺序的参数,那么可以认为它们覆盖了另一个方法。请注意,参数的名称无关紧要。例如,void print(String str)void print(String msg)被认为是同一个方法。参数strmsg的不同名称并不会使它们成为不同的方法。

方法覆盖规则#4

在 Java 5 之前,覆盖方法和被覆盖方法的返回类型必须相同。这个规则对于原始数据类型的返回类型是一样的。但是,对于引用数据类型的返回类型,规则是不同的。如果被重写方法的返回类型是引用类型,则重写方法的返回类型必须与被重写方法的返回类型赋值兼容。假设一个类有一个方法定义R1 m1(),它被一个方法定义R2 m1()覆盖。只有当 R2 的一个实例可以被赋给一个没有任何类型转换的R1类型的变量时,才允许这个方法覆盖。考虑下面的代码片段,它定义了三个类— PQR:

public class P {
    public Employee getEmp() {
        // Code goes here
    }
}
public class Q extends P {
    public Employee getEmp() {
        // code goes here
    }
}
public class R extends P {
    public Manager getEmp() {
        // code goes here
    }
}

P定义了一个getEmp()方法,该方法返回一个Employee类型的对象。类QgetEmp()方法覆盖了其超类PgetEmp()方法,因为它具有相同的名称、相同顺序的相同类型的参数数量(在本例中为零)以及相同的返回类型Employee。类RgetEmp()方法也覆盖类PgetEmp()方法,即使其返回类型Manager不同于被覆盖方法的返回类型Employee。类RgetEmp()方法覆盖了它的超类getEmp()方法,因为一个Manager类型的实例总是可以被赋给一个Employee类型的变量而不需要任何类型转换。

方法覆盖规则#5

重写方法的访问级别必须至少与被重写方法的访问级别相同或更宽松。三个访问级别是publicprotected和允许继承的包级别。回想一下private成员不是继承的,因此不能被覆盖。准入级别从最宽松到最严格的顺序是publicprotected、套餐级别。如果被覆盖的方法具有public访问级别,那么被覆盖的方法必须具有public访问级别,因为public是最宽松的访问级别。如果被覆盖的方法具有protected访问级别,则被覆盖的方法可能具有publicprotected访问级别。如果被重写的方法具有包级别的访问权限,那么重写的方法可能具有publicprotected或包级别的访问权限。表 20-1 总结了这一规律。我们将很快讨论为什么这个规则存在。

表 20-1

重写方法允许的访问级别

|

重写的方法访问级别

|

允许的重写方法访问级别

public Public
protected public, protected
包装级别 public, protected,包装级别

方法覆盖规则#6

一个方法可以在它的throws子句中包含一个检查异常的列表。虽然允许在方法的throws子句中包含未检查的异常,但这不是必需的。在这一节中,我们只讨论检查异常。重写方法不能向被重写方法的异常列表中添加新的异常。它可以移除一个或所有异常,或者用另一个异常替换一个异常,该异常是被重写的方法中列出的异常的后代之一。考虑下面的类定义:

public class G {
    public void m1() throws CheckedException1, CheckedException2 {
        // Code goes here
    }
}

如果一个类覆盖了类Gm1()方法,它不能添加任何新的检查异常。下面的代码不会编译,因为它在被覆盖的方法m1()中添加了一个新的检查异常CheckException3:

public class H extends G {
    public void m1() throws CheckedException1, CheckedException2, CheckedException3 {
        // Code goes here
    }
}

下面的类声明覆盖了类G中的m1()方法,它们都是有效的。在类I中,方法m1()移除了这两个异常。在J类中,它移除一个异常并保留一个。在类K中,它保留一个并用后代类型替换另一个,假设CheckedException22CheckedException2的后代类:

public class I extends G {
    // m1() removes all exceptions
    public void m1() {
        // Code goes here
    }
}
public class J extends G {
    // m1() removes one exception and keeps another
    public void m1() throws CheckedException1 {
        // Code goes here
    }
}
public class J extends G {
    // m1() removes one, keep one, and replaces one with a subclass
    public void m1() throws CheckedException1, CheckedException22 {
        // Code goes here
    }
}

关于重写方法的返回类型和异常列表的规则可能并不明显。这些规则背后有一个原因,那就是“一个类类型的变量可以保存对它的任何后代的对象的引用。”当您使用超类类型编写代码时,该代码也必须在不修改子类类型对象的情况下工作。假设EmpNotFoundException是一个被检查的异常类,考虑下面对类P的定义:

public class P {
    public Employee getEmp(int empId) throws EmpNotFoundException {
        // code goes here
    }
}

您可以编写以下代码片段:

P p = // get an object reference of P or its descendant;
try {
    Employee emp = p.getEmp(10);
} catch (EmpNotFoundException e) {
    // Handle the exception here
}

这段代码中需要考虑两点。首先,类型为P的变量p可以指向类型为P的对象或者类P的任何后代的对象。第二,当调用p.getEmp(10)方法时,编译器验证变量p ( P类)的声明类型有一个getEmp()方法,该方法接受一个int类型的参数,返回一个Employee类型的对象,并抛出EmpNotFoundException。这些信息由编译器用类P验证。编译器做出的关于getEmp()方法的假设(也得到验证)永远不应该在运行时失效。否则,将导致混乱——代码可以编译,但可能无法运行。

考虑覆盖getEmp()方法的一种可能情况,如下所示:

public class Q extends P {
    public Manager getEmp(int empId) {
        // code goes here
    }
}

如果变量p被分配了一个类Q的对象,则代码

Employee emp = p.getEmp(10);

try-catch块内仍然有效。在这种情况下,变量p将引用类Q的对象,其getEmp()方法返回一个Manager对象,并且不抛出任何异常。从getEmp()方法返回一个Manager对象很好,因为你可以将一个Manager对象赋给emp变量,这是一个向上转换的例子。不从getEmp()方法抛出异常也没问题,因为代码已经准备好处理异常(通过使用try-catch块)以防异常被抛出。

重写方法的访问级别规则背后的原因是什么?注意,当变量 p 访问getEmp()方法时,编译器验证使用p.getEmp()的代码可以访问类PgetEmp()方法。如果P的子类降低了访问级别,相同的代码p.getEmp()可能在运行时无法工作,因为执行该语句的代码可能无法访问类P的后代中的getEmp()方法。

考虑下面对类Q2的定义,它继承自类P。它覆盖了getEmp()方法,并用另一个名为BadEmpIdException的检查异常替换了EmpNoFoundException:

// Won't compile
public class Q2 extends P {
    public Manager getEmp(int empId) throws BadEmpIdException {
        // code goes here
    }
}

假设根据P类型编写的代码获得了一个Q2对象的引用,如下所示:

P p = new Q2();
try {
    Employee emp = p.getEmp(10);
} catch(EmpNotFoundException e) {
    // Handle exception here
}

注意,try-catch块不准备处理BadEmpIdException,因为Q2类的方法getEmp()可能会抛出。这就是为什么类Q2的声明不会被编译。

为了总结重写的规则,让我们将方法声明的各个部分分解如下:

  • 方法的名称

  • 参数数量

  • 参数类型

  • 参数的顺序

  • 方法的返回类型

  • 访问级

  • throws子句中检查的异常列表

在重写和被重写的方法中,前四个部分必须始终相同。在 Java 5 之前,重写方法和被重写方法中的返回类型必须相同。从 Java 5 开始,如果返回类型是引用类型,那么覆盖方法的返回类型也可以是被覆盖方法的返回类型的子类型(任何后代)。被覆盖方法中的访问级别和异常列表可以被认为是它的约束。重写方法可以放松(甚至移除)被重写方法的约束。但是,重写方法的约束不能比被重写方法的约束更多。

重写方法的规则很复杂。你可能要花很长时间才能掌握它们。编译器直接支持所有规则。如果你在重写一个方法的时候在源代码中犯了一个错误,编译器会生成一个好的(不总是)错误消息,给你一个错误的线索。有一条关于方法重写的黄金法则可以帮助你避免错误:“无论使用超类类型编写什么代码,都必须与子类类型一起工作。”

Tip

如果你想在你的类中覆盖一个方法,你应该用一个@Override注释来注释这个方法。对于用@Override注释的方法,编译器验证该方法确实覆盖了超类中的一个方法;否则,它会生成编译时错误。

访问被重写的方法

有时您可能需要从子类中访问被覆盖的方法。子类可以使用关键字super作为限定符来调用超类的覆盖方法。注意Object类没有超类。在Object类中使用关键字super是非法的。作为一名程序员,你永远不需要为Object类编写代码,因为它是 Java 类库的一部分。考虑清单 20-14 中AOSuper类的代码。它有一个print()方法,在标准输出上打印一条消息。

// AOSuper.java
package com.jdojo.inheritance;
public class AOSuper {
    public void print() {
        System.out.println("Inside AOSuper.print()");
    }
}

Listing 20-14An AOSuper Class

清单 20-15 中的代码包含对一个AOSub类的声明,该类继承自AOSuper类。

// AOSub.java
package com.jdojo.inheritance;
public class AOSub extends AOSuper {
    @Override
    public void print() {
        // Call print() method of AOSuper class
        super.print();
        // Print a message
        System.out.println("Inside AOSub.print()");
    }
    public void callOverriddenPrint() {
        // Call print() method of AOSuper class
        super.print();
    }
}

Listing 20-15An AOSub Class, Which Inherits from the AOSuper Class

AOSub类覆盖了AOSuper类的print()方法。注意在print()方法和AOSub类的callOverriddenPrint()方法中的super.print()方法调用。它将调用AOSuper类的print()方法。清单 20-16 的输出显示带有super限定符的方法调用调用了超类中被覆盖的方法。

// AOTest.java
package com.jdojo.inheritance;
public class AOTest {
    public static void main(String[] args) {
        AOSub aoSub = new AOSub();
        aoSub.print();
        aoSub.callOverriddenPrint();
    }
}
Inside AOSuper.print()
Inside AOSub.print()
Inside AOSuper.print()

Listing 20-16A Test Class to Test a Method Call with the super Qualifier

没有办法直接调用超类的超类的实例方法。您可以使用关键字super调用超类(只有直接祖先)的覆盖方法。假设有三个类,ABC,其中B类继承自A类,而C类继承自B类。无法从类C内部调用类A的方法。如果类C需要调用类A的方法,你需要在类B中提供一个方法来调用类A的方法。类C将调用类B的方法,后者又将调用类A的方法。

Tip

当使用关键字super进行方法调用时,Java 使用早期绑定,即使该方法是实例方法。Java 对实例方法调用使用早期绑定的另一个实例是private方法调用,因为private方法不能从其定义类外部调用。private方法也不能被覆盖。关键字super指的是它所在的类的直接祖先的实例字段、方法或构造器。

方法重载

在同一个类中有多个同名的方法称为方法重载。类中同名的方法可以是声明的方法、继承的方法或两者的组合。重载方法必须具有不同数量的参数和/或不同类型的参数。方法的返回类型、访问级别和throws子句在使其成为重载方法中不起任何作用。OME1类的m1()方法是重载方法的一个例子:

public class OME1 {
    public void m1(int a) {
        // Code goes here
    }
    public void m1(int a, int b) {
        // Code goes here
    }
    public int m1(String a) {
        // Code goes here
    }
    public int m1(String a, int b) throws CheckedException1 {
        // Code goes here
    }
}

下面是一个错误尝试重载类OME2中的m2()方法的例子:

// Won't compile
public class OME2 {
    public void m2(int p1) {
        // Code goes here
    }
    public void m2(int p2) {
        // Code goes here
    }
}

为参数使用不同的名称(p1p2)不会使m2()方法重载。OME2类的代码无法编译,因为它对m2()方法有重复的声明。这两种方法具有相同数量和类型的参数,这使得它不会重载。

参数的顺序可能会导致方法重载。因为参数类型不同,OME3类的m3()方法被重载。两种方法都有一个类型为int的参数和一个类型为double的参数。但是,它们的顺序不同:

public class OME3 {
    public void m3(int p1, double p2) {
        // Code goes here
    }
    public void m3(double p1, int p2) {
        // Code goes here
    }
}

使用一个简单的规则来检查两个方法是否可以被称为重载方法。从左到右列出方法的名称及其参数的类型,用逗号分隔。您可以使用任何其他分隔符。如果一个类的两个同名方法给你不同的列表,那么它们就是重载的。否则,它们不会过载。如果您为OME1OME2OME3类中的m1()m2()m3()方法列出这样的列表,您将得到以下结果:

// Method list for m1 in class OME1 - Overloaded
m1,int
m1,int,int
m1,String
m1,String,int
// Method list for m2 in class OME2 – Not Overloaded
m2,int
m2,int
// Method list for m3 in class OME3 - Overloaded
m3,int,double
m3,double,int

你应该意识到类OME2中的m2()方法的结果在两个版本中是相同的,因此OME2.m2()没有被重载。表 20-2 列出了方法覆盖和方法重载之间的一些重要区别。

表 20-2

方法重写和方法重载之间的一些重要区别

|

方法覆盖

|

方法重载

重写涉及继承和至少两个类。 重载与继承无关。重载只涉及一个类。
当一个类定义一个方法,该方法具有相同的名称和相同数量的相同类型的参数,并且参数的顺序与其超类定义的顺序相同时,就会发生这种情况。 当一个类定义了多个同名的方法时,就会出现这种情况。所有同名的方法必须至少在一个方面不同于其他方法——参数的数量、类型或顺序等。
重写方法的返回类型必须可以用被重写方法的返回类型进行赋值替换。 重载方法的返回类型在重载中不起作用。
除了被重写的方法之外,重写方法不能有额外的 throws 子句。它可以具有与重写方法相同或更少限制的异常列表。 重载方法的 Throws 子句在重载中不起作用。
重写仅适用于实例(非静态)方法。 任何方法(静态的或非静态的)都可以重载。

方法重载是另一种多态,其中相同的方法名有不同的含义。方法重载是在编译时绑定的,而方法重载是在运行时绑定的。编译器只解析将被调用的重载方法的版本。它确定将调用重载方法的哪个版本,将重载方法的实际参数与形式参数进行匹配。如果重载的方法是一个实例方法,那么在运行时使用后期绑定仍然可以确定执行哪个代码。

对于重载的方法调用,编译器选择最具体的方法。如果找不到完全匹配,它将使用自动类型扩展规则,通过将实际参数类型转换为更通用的类型,来尝试查找更通用的版本。清单 20-17 展示了编译器如何选择一个重载方法。

// OverloadingTest.java
package com.jdojo.inheritance;
public class OverloadingTest {
    public static void main(String[] args) {
        var ot = new OverloadingTest();
        int i = 10;
        int j = 15;
        double d1 = 10.4;
        double d2 = 2.5;
        float f1 = 2.3F;
        float f2 = 4.5F;
        short s1 = 2;
        short s2 = 6;
        ot.add(i, j);
        ot.add(d1, j);
        ot.add(i, s1);
        ot.add(s1, s2);
        ot.add(f1, f2);
        ot.add(f1, s2);
        Employee emp = new Employee();
        Manager mgr = new Manager();
        ot.test(emp);
        ot.test(mgr);
        emp = mgr;
        ot.test(emp);
    }
    public double add(int a, int b) {
        System.out.println("Inside add(int a, int b)");
        double s = a + b;
        return s;
    }
    public double add(double a, double b) {
        System.out.println("Inside add(double a, double b)");
        double s = a + b;
        return s;
    }
    public void test(Employee e) {
        System.out.println("Inside test(Employee e)");
    }
    public void test(Manager e) {
        System.out.println("Inside test(Manager m)");
    }
}
Inside add(int a, int b)
Inside add(double a, double b)
Inside add(int a, int b)
Inside add(int a, int b)
Inside add(double a, double b)
Inside add(double a, double b)
Inside test(Employee e)
Inside test(Manager m)
Inside test(Employee e)

Listing 20-17A Test Program That Demonstrates How the Compiler Chooses the Most Specific Method from Several Versions of an Overloaded Method

编译器只知道实参和形参的编译时类型(声明的类型)。看看ot.add(f1, s2)方法调用。实际参数的类型有floatshort。在OverloadingTest类中没有add(float, short)方法。编译器尝试将第一个参数的类型扩展为double类型,并根据第一个参数add(double, double)找到一个匹配。第二个参数类型仍然不匹配;实际类型为short,,形式类型为double。Java 允许从shortdouble的自动加宽。编译器将short类型转换为double类型,并将add(f1, s2)调用绑定到add(double, double)方法。当调用ot.test(mgr)时,编译器寻找精确匹配,在这种情况下,它找到一个test(Manager m),并将调用绑定到这个版本的test()方法。假设test(Manager m)方法不在OverloadingTest类中。编译器会将ot.test(mgr)调用绑定到test(Employee e)方法,因为Manager类型可以自动扩展(使用向上转换)为Employee类型。

有时,重载方法和自动类型扩展可能会使编译器困惑,从而导致编译时错误。考虑清单 20-18 和 20-19 中一个具有重载add()方法的Adder类,以及如何测试它。

// AdderTest.java
package com.jdojo.inheritance;
public class AdderTest {
    public static void main(String[] args) {
        Adder a = new Adder();
        // A compile-time error
        double d = a.add(2, 3);
    }
}

Listing 20-19Testing the add() Method of the Adder Class

// Adder.java
package com.jdojo.inheritance;
public class Adder {
    public double add(int a, double b) {
        return a + b;
    }
    public double add(double a, int b) {
        return a + b;
    }
}

Listing 20-18The Adder Class, Which Has an Overloaded add() Method

试图编译AdderTest类会产生以下错误:

" AdderTest.java ":对 add 的引用不明确,com.jdojo.inheritance.Adder 中的方法 add(int,double)和 com.jdojo.inheritance.Adder 中的方法 add(double,int)在第 7 行第 18 列匹配

错误消息指出编译器无法决定调用Adder类中两个add()方法中的哪一个来调用a.add(3, 7)方法。编译器在决定是否应该扩展 3 的int类型使其成为double类型 3.0 并调用add(double, int)或者是否应该扩展 7 的int类型使其成为double类型 7.0 并调用add(int, double)时感到困惑。在这种情况下,您需要使用类型转换来帮助编译器,如下所示:

double d1 = a.add((double) 2, 3); // OK. Will use add(double, int)
double d2 = a.add(2, (double) 3); // OK. Will use add(int, double)

继承和构造器

一个对象有两个东西:状态和行为。类中的实例变量代表其对象的状态。实例方法代表其对象的行为。一个类的每个对象都维护自己的状态。当您创建一个类的对象时,内存是为在该类中声明的所有实例变量以及在其所有级别的祖先中声明的所有实例变量分配的。您的Employee类声明了一个name实例变量。当你创建一个Employee类的对象时,内存是为它的name实例变量分配的。当一个Manager类的对象被创建时,内存被分配给出现在其超类Employee中的name字段。毕竟管理者和员工的状态差不多。经理的行为类似于雇员。我们来看一个例子。考虑两个类UV,如图所示:

public class U {
    private int id;
    protected String name;
}
public class V extends U {
    protected double salary;
    protected String address;
}

图 20-3 描述了创建UV类对象时的内存分配。当类U的对象被创建时,内存只分配给类U中声明的实例变量。当类V的对象被创建时,内存被分配给类U和类V中的所有实例变量。

img/323069_3_En_20_Fig3_HTML.png

图 20-3

对象的内存分配包括该类的所有实例变量及其所有祖先

让我们进入本节讨论的主题,即构造器。构造器不是类的成员,也不会被子类继承。它们用于初始化实例变量。当您创建一个类的对象时,该对象包含来自该类及其所有祖先的实例变量。要初始化祖先类的实例变量,必须调用祖先类的构造器。考虑下面的两个类,CSuperCSub,如清单 20-20 和 20-21 所示。清单 20-22 中的CTest类用于创建CSub类的一个对象。

// CTest.java
package com.jdojo.inheritance;
public class CTest {
    public static void main(String[] args) {
        CSub cs = new CSub();
    }
}
Inside CSuper() constructor.
Inside CSub() constructor.

Listing 20-22A Test Class, Which Demonstrates That Constructors for All Ancestors Are Called When an Object of a Class Is Created Starting at the Top of the Class Hierarchy and Going Down

// CSub.java
package com.jdojo.inheritance;
public class CSub extends CSuper {
    public CSub() {
        System.out.println("Inside CSub() constructor.");
    }
}

Listing 20-21A CSub Class, Which Inherits from the CSuper Class and Has a No-Args Constructor

// CSuper.java
package com.jdojo.inheritance;
public class CSuper {
    public CSuper() {
        System.out.println("Inside CSuper() constructor.");
    }
}

Listing 20-20A CSuper Class with a No-Args Constructor

CTest类的输出显示,首先调用CSuper类的构造器,然后调用CSub类的构造器。事实上,Object类的构造器在CSuper类的构造器之前被调用。你不能打印出Object类的构造器被调用的事实,因为Object类不是你的类,因此你不能修改它。问题是,“如何调用CSuper类的构造器?”这个问题的答案是基于这样的规则:当一个类的对象被创建时,内存被分配给所有的实例变量,包括它的所有祖先类中的实例变量。所有类的实例变量都必须通过调用它们的构造器来初始化。编译器会在很大程度上帮助您实施这一规则。编译器将调用注入到直接祖先的无参数构造器中,作为添加到类中的每个构造器的第一条语句。关键字super在很多情况下都会用到。它也指一个类的直接祖先。如果后跟括号,则表示超类的构造器。如果超类构造器接受参数,可以像方法调用一样传递括号内的参数列表。以下是调用超类的构造器的示例:

// Call no-args constructor of superclass
super();
// Call superclass constructor with a String argument
super("Hello");
// Call superclass constructor with two double arguments
super(10.5, 89.2);

您可以显式调用超类的构造器,或者让编译器为您注入对无参数构造器的调用。当你编译CSuperCSub类时,编译器会修改它们的构造器代码,如清单 20-23 和 20-24 所示。

// CSub.java
package com.jdojo.inheritance;
public class CSub extends CSuper {
    public CSub() {
        super();  // Injected by the compiler
        System.out.println("Inside CSub() constructor.");
    }
}

Listing 20-24Compiler Injection of a super() Call to Call the Immediate Ancestor’s No-Args Constructor

// CSuper.java
package com.jdojo.inheritance;
public class CSuper {
    public CSuper() {
        super();  // Injected by the compiler
        System.out.println("Inside CSuper() constructor.");
    }
}

Listing 20-23Compiler Injection of a super() Call to Call the Immediate Ancestor’s No-Args Constructor

Tip

关键字super指的是一个类的直接祖先。您可以使用关键字super作为构造器中的第一条语句来调用超类构造器。

也可以显式调用超类的无参数构造器或任何其他构造器,作为类的构造器中的第一条语句。只有在没有显式添加无参数构造器调用的情况下,编译器才会注入无参数构造器调用。让我们试着提高你的EmployeeManager类。让我们给Employee类添加一个构造器,它接受雇员的名字作为参数。您将调用新的类Employee2,如清单 20-25 所示。

// Employee2.java
package com.jdojo.inheritance;
public class Employee2 {
    private String name = "Unknown";
    public Employee2(String name) {
        this.name = name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
}

Listing 20-25Employee2 Class, Which Is a Modified Version of the Original Employee Class and Has a Constructor That Accepts a String Argument

让我们称您的新Manager类为Manager2,它继承自Employee2类:

// Manager2.java
package com.jdojo.inheritance;
// Won't compile
public class Manager2 extends Employee2 {
    // No code for now
}

前面的Manager2类代码无法编译。它会生成以下编译时错误:

Error(4,23): constructor Employee2() not found in class com.jdojo.inheritance.Employee2

您还没有为Manager2类添加任何构造器。因此,编译器将为它添加一个无参数构造器。它还将尝试注入一个super()调用作为无参数构造器内部的第一条语句,这将调用Employee2类的无参数构造器。然而,Employee2类没有无参数构造器。这就是你得到前一个错误的原因。在被编译器修改后,Manager2类的代码如下所示。您可能会注意到,super()调用是无效的,因为Employee2类没有无参数构造器:

// Code for Manager2 class after compiler injects a no-args constructor with a call to super()
package com.jdojo.inheritance;
// Won't compile
public class Manager2 extends Employee2 {
    // Injected by the compiler
    public Manager2() {
        // Injected by the compiler
        // Calls the nonexistent no-args constructor of Employee2 class
        super();
    }
}

那么如何修复Manager2类呢?有很多方法可以解决。修复Manager2类的一些方法如下。

您可以向Employee2类添加一个无参数构造器,如下所示:

public class Employee2 {
    // A no-args constructor
    public Employee2() {
    }
    /* All other code for class remains the same */
}

在将无参数构造器添加到Employee2类之后,Manager2类的代码将会编译良好。

您可以向Manager2类添加一个无参数构造器,并使用一个String参数显式调用Employee2类的构造器,如下所示:

public class Manager2 extends Employee2 {
    public Manager2() {
        // Call constructor of Employee2 class explicitly
        super("Unknown");
    }
}

您可以向Manager2类添加一个构造器,它接受一个String参数并将参数值传递给Employee2类构造器。这样,您可以通过将Manager2的名称作为参数传递给它的构造器来创建一个Manager2:

public class Manager2 extends Employee2 {
    public Manager2(String name) {
        // Call constructor of Employee2 class explicitly
        super(name);
    }
}

通常,第三个选项用于提供一种方法来用经理的名字创建一个Manager2类的对象。注意,Manager2类不能访问Employee2类的 name 实例变量。不过,您可以使用super关键字并调用Employee2类的构造器,从Manager2类初始化Employee2类中的 name 实例变量。清单 20-26 有将要编译的Manager2类的完整代码。清单 20-27 有测试Manager2类的代码,它的输出显示它像预期的那样工作。

Tip

每个类都必须直接或间接地从其构造器中调用其超类的构造器。如果超类没有无参数构造器,你必须显式调用超类的任何其他构造器,如清单 20-26 中所做的。

// Manager2Test.java
package com.jdojo.inheritance;
public class Manager2Test {
    public static void main(String[] args) {
        Manager2 mgr = new Manager2("John Jacobs");
        String name = mgr.getName();
        System.out.println("Manager name: " + name);
    }
}
Manager name: John Jacobs

Listing 20-27A Test Class to Test the Manager2 Class

// Manager2.java
package com.jdojo.inheritance;
public class Manager2 extends Employee2 {
    public Manager2(String name) {
        super(name);
    }
}

Listing 20-26A Manager2 Class That Has a Constructor That Accepts a String Argument and Calls the Constructor of the Employee2 Class Explicitly

我们需要讨论一些关于从子类使用超类构造器的规则。考虑下面的类XY的定义,它们在两个不同的包中:

// X.java
package com.jdojo.inheritance.pkg1;
public class X {
    // X() has package-level access
    X() {
    }
}
// Y.java
package com.jdojo.inheritance.pkg2;
import com.jdojo.inheritance.pkg1.X;
public class Y extends X {
    public Y() {
    }
}

Y的代码无法编译。它会生成如下编译时错误:

Error(7):  X() is not public in com.jdojo.inheritance.pkg1.X; cannot be accessed from outside package

该错误指出类X中的无参数构造器具有包级访问权限。因此,不能从不同包中的类Y访问它。您收到此错误是因为编译器将如下修改类Y的定义:

// Compiler modified version of class Y
// Y.java
package com.jdojo.inheritance.pkg2;
import com.jdojo.inheritance.pkg1.X;
public class Y extends X {
    public Y() {
        // Injected by the compiler to call X() constructor
        super();
    }
}

X的无参数构造器具有包级访问权限。因此,它只能从com.jdojo.inheritance.pkg1包中访问。你是怎么搞定Y班的?在这种情况下提出解决方案是很棘手的。解决方案取决于创建类X和类Y背后使用的设计。然而,对于要编译的类Y,您必须为类X创建一个构造器,它有一个公共的或受保护的访问,因此可以从类Y访问它。

下面是使用构造器和继承的另一个规则。必须使用super关键字从类的构造器内部显式或隐式调用超类构造器。然而,从类到超类构造器的访问是由超类构造器的访问级别控制的。有时,类的构造器的访问级别的结果可能是根本无法访问它。考虑名为NoSubclassingAllowed的类的如下定义:

public class NoSubclassingAllowed {
    private NoSubclassingAllowed() {
    }
    // Other code goes here
}

NoSubclassingAllowed类已经显式声明了一个private构造器。不能从任何地方访问private构造器,包括子类。对于一个存在的子类,该子类必须能够调用其超类的至少一个构造器。这表明NoSubclassingAllowed类不能被任何其他类继承。这是禁用类继承的方法之一。下面的代码将不会编译,它试图子类化NoSubclassingAllowed类,该类没有可访问的构造器:

// Won't compile.
public class LetUsTryInVain extends NoSubclassingAllowed {
}

您可能会注意到,没有人能够创建一个NoSubclassingAllowed类的对象,因为它的构造器不能从外部访问。像这样的类提供了创建它们的对象并将其返回给调用者的方法。这也是控制和封装类的对象创建的一种方式。

回想一下第九章中的内容,你可以使用this关键字从同一个类的另一个构造器中调用一个类的构造器,并且这个调用必须是构造器体中的第一条语句。当您查看调用同一类的另一个构造器和超类的构造器的规则时,您会发现两者都声明调用必须是构造器体中的第一个语句。这两条规则的结果是,从一个构造器中,你可以使用this()调用同一个类的另一个构造器,或者使用super()调用超类的一个构造器,但不能两者都用。这个规则也确保了超类的构造器总是被调用一次,并且只被调用一次。

方法隐藏

一个类也从它的超类继承所有非私有的static方法。在类中重新定义继承的static方法被称为方法隐藏。据说子类中重新定义的static方法隐藏了其超类的static方法。回想一下,在类中重新定义非静态方法称为方法重写。清单 20-28 包含了一个MHidingSuper类的代码,该类有一个static print()方法。清单 20-29 有继承自MHidingSuper类的MHidingSub类的代码。它重新定义了print()方法,隐藏了MHidingSuper类中的print()方法。MHidingSub中的print()方法就是方法隐藏的一个例子。

// MHidingSub.java
package com.jdojo.inheritance;
public class MHidingSub extends MHidingSuper {
    public static void print() {
        System.out.println("Inside MHidingSub.print()");
    }
}

Listing 20-29A MHidingSub Class That Hides the print() of Its Superclass

// MHidingSuper.java
package com.jdojo.inheritance;
public class MHidingSuper {
    public static void print() {
        System.out.println("Inside MHidingSuper.print()");
    }
}

Listing 20-28A MHidingSuper Class That Has a Static Method

关于方法隐藏的重定义方法的所有规则(名称、访问级别、返回类型和异常)与方法重写的规则相同。有关这些规则的更详细的讨论,请参考“方法覆盖”一节。方法隐藏的一个不同规则是绑定规则。早期绑定用于静态方法。基于表达式的编译时类型,编译器决定在运行时为一个static方法调用执行什么代码。注意,您可以使用类名以及引用变量来调用一个static方法。当您使用一个类名来调用一个static方法时,方法绑定不会有歧义。编译器绑定在类中定义(或重新定义)的static方法。如果一个类没有定义(或者重定义)?? 方法,编译器会绑定这个类从它的超类继承的方法。如果编译器没有在类中找到定义/重定义/继承的方法,它将生成一个错误。清单 20-30 包含的代码演示了一个类的静态方法的方法隐藏的早期绑定规则。

// MHidingTest.java
package com.jdojo.inheritance;
public class MHidingTest {
    public static void main(String[] args) {
        MHidingSuper mhSuper = new MHidingSub();
        MHidingSub mhSub = new MHidingSub();
        System.out.println("#1");
        // #1
        MHidingSuper.print();
        mhSuper.print();
        System.out.println("#2");
        // #2
        MHidingSub.print();
        mhSub.print();
        ((MHidingSuper) mhSub).print();
        System.out.println("#3");
        // #3
        mhSuper = mhSub;
        mhSuper.print();
        ((MHidingSub) mhSuper).print();
    }
}
#1
Inside MHidingSuper.print()
Inside MHidingSuper.print()
#2
Inside MHidingSub.print()
Inside MHidingSub.print()
Inside MHidingSuper.print()
#3
Inside MHidingSuper.print()
Inside MHidingSub.print()

Listing 20-30A Test Class to Demonstrate Method Hiding

测试代码有三个部分,分别标为#1、#2 和#3。让我们在每一节讨论编译器是如何执行早期绑定的。

// #1
MHidingSuper.print();
mhSuper.print();

第一个调用MHidingSuper.print()是使用一个类名进行的。编译器绑定这个调用来执行MHidingSuper类的print()方法。第二个调用mhSuper.print()是使用引用变量mhSuper进行的。mhSuper变量的编译时类型(或声明类型)是MHidingSuper。因此,编译器绑定这个调用来执行MHidingSuper类的print()方法。

// #2
MHidingSub.print();
mhSub.print();
((MHidingSuper)mhSub).print();

部分#2 中的前两个调用类似于部分#1 中的两个调用。它们被绑定到MHidingSub类的print()方法。第三个电话,((MHidingSuper) mhSub).print(),需要一点解释。mhSub变量的编译时类型是MHidingSub。当您在mhSub变量上使用类型转换(MHidingSuper)时,表达式(MHidingSuper) mhSub的编译时类型就变成了MHidingSuper。当您在这个表达式上调用print()方法时,编译器将它绑定到它的编译时类型,也就是MHidingSuper。因此,第二部分中的第三个方法调用被绑定到MHidingSuper类的print()方法。

// #3
mhSuper = mhSub;
mhSuper.print();
((MHidingSub)mhSuper).print();

第三部分中的第一条语句将对MHidingSub对象的引用分配给了引用变量mhSuper。第一条语句执行后,mhSuper变量引用了MHidingSub类的一个对象。当第一次调用print()方法时,编译器会查看mhSuper变量的编译时类型(或声明类型),即MHidingSuper。因此,编译器将调用mhSuper.print()绑定到MHidingSuper类的print()方法。对print()方法的第二次调用被绑定到MHidingSub类的print()方法,因为类型转换(MHidingSub)将整个表达式的类型设为MHidingSub

Tip

类的static方法不能隐藏其超类的实例方法。如果您想从类内部调用超类的隐藏方法,您需要用超类名限定隐藏方法调用。例如,如果你想从MHidingSub类内部调用MHidingSuper类的print()方法,你需要使用MHidingSuper.print()。在MHidingSub类内部,对print()方法的调用,没有使用类名或变量,而是引用了MHidingSub类的隐藏方法print()

野外隐藏

类中的字段声明(static或非静态)隐藏了其超类中同名的继承字段。在隐藏字段的情况下,不考虑字段的类型及其访问级别。字段隐藏完全基于字段名称。早期绑定用于字段访问。也就是说,类的编译时类型用于绑定字段访问。考虑下面两个类GH的声明:

public class G {
    protected int x = 200;
    protected String y = "Hello";
    protected double z = 10.5;
}
public class H extends G {
    protected int x = 400;       // Hides x in class G
    protected String y = "Bye";  // Hides y in class G
    protected String z = "OK";   // Hides z in class G
}

H中的字段声明xyz隐藏了类G中继承的字段 x、y 和 z。需要强调的是,一个类中的相同字段名单独隐藏了其超类的一个字段。隐藏字段和隐藏字段的数据类型无关紧要。例如,类G中 z 的数据类型是double,而类H中 z 的数据类型是String。尽管如此,类H中的字段z隐藏了类G中的字段z。类H中字段 x、y 和 z 的简单名称指的是隐藏字段,而不是继承字段。因此,如果在类H中使用简单名称 x,它指的是在类H中声明的字段x,而不是在类G中声明的字段。如果要从类H内部引用类G中的字段 x,需要使用关键字super,例如super.x

在清单 20-31 中,FHidingSuper类声明了字段numname。在清单 20-32 中,FHidingSub类继承了FHidingSuper类,并且继承了它的 num 和 name 字段。FHidingSub类的print()方法打印numname字段的值。print()方法使用了numname字段的简单名称,它们引用了从FHidingSuper类继承的字段。当您运行清单 20-33 中的FHidingTest类时,输出显示FHidingSub类确实从其超类继承了numname字段。

// FHidingTest.java
package com.jdojo.inheritance;
public class FHidingTest {
    public static void main(String[] args) {
        FHidingSub fhSub = new FHidingSub();
        fhSub.print();
    }
}
num: 100
name: John Jacobs

Listing 20-33A Test Class to Demonstrate Field Inheritance

// FHidingSub.java
package com.jdojo.inheritance;
public class FHidingSub extends FHidingSuper {
    public void print() {
        System.out.println("num: " + num);
        System.out.println("name: " + name);
    }
}

Listing 20-32FHidingSub Class, Which Inherits from the FHidingSuper Class and Inherits Fields num and name

// FHidingSuper.java
package com.jdojo.inheritance;
public class FHidingSuper {
    protected int num = 100;
    protected String name = "John Jacobs";
}

Listing 20-31FHidingSuper Class with Two Protected Instance Fields

考虑类FHidingSub2的定义,如清单 20-34 所示。

// FHidingSub2.java
package com.jdojo.inheritance;
public class FHidingSub2 extends FHidingSuper {
    // Hides num field in FHidingSuper class
    private int num = 200;
    // Hides name field in FHidingSuper class
    private String name = "Wally Inman";
    public void print() {
        System.out.println("num: " + num);
        System.out.println("name: " + name);
    }
}

Listing 20-34A FHidingSub2 Class That Inherits from FHidingSuper and Declares Two Variables with the Same Name as Declared in Its Superclass

FHidingSub2类继承自FHidingSuper类。它声明了两个字段,numname,这两个字段与其超类中声明的两个字段同名。这是一个藏场的例子。FHidingSub2中的numname字段隐藏了从FHidingSuper类继承的numname字段。当numname字段被它们在FHidingSub2类中的简单名称使用时,它们引用在FHidingSub2类中声明的字段,而不是从FHidingSuper类继承的字段。这可以通过运行FHidingTest2类来验证,如清单 20-35 所示。

// FHidingTest2.java
package com.jdojo.inheritance;
public class FHidingTest2 {
    public static void main(String[] args) {
        FHidingSub2 fhSub2 = new FHidingSub2();
        fhSub2.print();
    }
}
num: 200
name: Wally Inman

Listing 20-35A Test Class to Demonstrate Field Hiding

FHidingSub2类有四个字段,两个继承字段(numname)和两个声明字段(numname)。如果您想引用从超类继承的字段,您需要用关键字super限定字段名称。比如FHidingSub2里面的super.numsuper.name 是指FHidingSuper类中的numname字段。

清单 20-36 中FHidingSub3类的print()方法使用关键字super来访问超类的隐藏字段,并使用字段的简单名称来访问自己类中的字段。清单 20-37 的输出证实了这一点。

// FHidingTest3.java
package com.jdojo.inheritance;
public class FHidingTest3 {
    public static void main(String[] args) {
        FHidingSub3 fhSub3 = new FHidingSub3();
        fhSub3.print();
    }
}
num: 200
super.num: 100
name: Wally Inman
super.name: John Jacobs

Listing 20-37A Test Class That Accesses Hidden Fields

// FHidingSub3.java
package com.jdojo.inheritance;
public class FHidingSub3 extends FHidingSuper {
    // Hides the num field in FHidingSuper class
    private int num = 200;
    // Hides the name field in FHidingSuper class
    private String name = "Wally Inman";
    public void print() {
        // FHidingSub3.num
        System.out.println("num: " + num);
        // FHidingSuper.num
        System.out.println("super.num: " + super.num);
        // FHidingSub3.name
        System.out.println("name: " + name);
        // FHidingSuper.name
        System.out.println("super.name: " + super.name);
    }
}

Listing 20-36A FHidingSub3 Class That Demonstrates How to Access Hidden Fields of Superclass Using the super Keyword

回想一下,当创建一个对象时,Java 运行时为该对象及其所有祖先的类中的所有实例变量分配内存。当你创建一个FHidingSub2FHidingSub3类的对象时,内存将被分配给四个实例变量,如图 20-4 所示。

img/323069_3_En_20_Fig4_HTML.png

图 20-4

FHidingSuper 和 FHidingSub2 类对象的内存布局

以下是字段隐藏规则的摘要:

  • 当一个类声明一个与其超类继承的变量同名的变量时,就会发生字段隐藏。

  • 字段隐藏仅基于字段的名称。字段隐藏不考虑访问级别、数据类型和字段类型(static或非静态)。例如,static字段可以隐藏一个实例字段。一个int类型的字段可以隐藏一个String类型的字段,以此类推。类中的private字段可以隐藏其超类中的protected字段。类中的public字段可以隐藏其超类中的protected字段。

  • 一个类应该使用关键字super来访问超类的隐藏字段。该类可以使用简单的名称来访问其主体中重定义的字段。

禁用继承

您可以通过声明类final来禁用类的子类化。您之前已经看到了使用final关键字来声明常量。在类声明中使用了相同的final关键字。一个final类不能被子类化。下面的代码片段声明了一个名为Securityfinal类:

public final class Security {
    // Code goes here
}

下面的类声明CrackedSecurity不会编译:

// Won't compile. Cannot inherit from Security
public final class CrackedSecurity extends Security {
    // Code goes here
}

你也可以声明一个方法为final。一个final方法不能被子类覆盖或隐藏。因为final方法不能被覆盖或隐藏,所以对final方法的调用可以被代码优化器内联以获得更好的性能:

public class A {
    public final void m1() {
        // Code goes here
    }
    public void m2() {
        // Code goes here
    }
}
public class B extends A {
    // Cannot override A.m1() here because it is final in class A
    // OK to override m2() because it is not final in class A
    public void m2() {
        // Code goes here
    }
}

您会发现 Java 类库中有许多类和方法被声明为 final。最值得注意的是,String类是final。为什么要将一个类或方法声明为final?换句话说,为什么要阻止类的子类化或者方法的重写/隐藏呢?这样做的主要原因是安全性、正确性和性能。如果您的类由于安全原因很重要,您不希望有人从您的类继承并破坏您的类应该实现的安全性。有时,你声明一个类/方法final来保持程序的正确性。一个final方法可能会在运行时产生更好的性能,因为代码优化器可以自由地内联final方法调用。

密封类

另一种禁用继承的方法是 Java 17 中引入的密封类特性。它允许你定义一个类,以及什么样的类可以继承它。编译器将强制任何其他类都不能扩展你的类。例如,假设您要定义一个类 Security,它只能有两个子类,Password 和 Lock。您可以按如下方式定义这些类,例如:

public abstract sealed class Security permits Password, Lock {
      // code goes here..
}
public final class Password { ... }
public non-sealed class Lock { ... }

密封类的每个子类必须属于同一个模块,或者,如果是未命名的模块,必须属于同一个包。他们还必须定义其状态:最终、已密封或未密封。最后一个,non-sealed,是 Java 中第一个包含连字符的关键字,表示继承对所有类开放。必须使用这些修饰符中的一个。一个类不可能既是最终的又是密封的。请注意,尽管 Lock 允许其他类扩展它,但是任何类型为 Security 的引用都必须是可分配给 Password 或 Lock 的实例。

或者,不使用 permissions 关键字,您可以指定同一个类中的所有子类,编译器将使用它作为子类的详尽列表,例如:

public abstract sealed class Security {
// code here...
      public final class Password { .... }
      public final class Lock { ... }
}

抽象类和方法

有时你可能创建一个类只是为了表示一个概念,而不是表示对象。假设您想开发代表不同形状的类。形状只是一个想法或概念。现实中并不存在。假设有人让你画一个形状。你的第一反应会是,“你想让我画什么形状?”如果有人让你画一个圆或者一个矩形,对你来说是有意义的。Java 允许你创建一个不能创建对象的类。它的目的只是代表一种思想,这是其他类的对象所共有的。这样的类被称为抽象类。术语“具体类”用于表示非抽象的类,其对象可以被创建。到目前为止,你所有的类都是具体的类。

你需要在类声明中使用abstract关键字来声明一个抽象类。例如,下面的代码声明了一个Shape类抽象:

public abstract class Shape {
    // No code for now
}

因为Shape类已经被声明为抽象类,所以即使它有一个public构造器(编译器添加的默认构造器),也不能创建它的对象。可以像声明具体类一样声明抽象类的变量。以下代码片段显示了 Shape 类的一些有效和无效用法:

Shape s;     // OK
new Shape(); // A compile-time error. Cannot create a Shape object

如果你看一下Shape类的定义,除了在声明中使用了abstract关键字之外,它看起来和任何其他具体的类一样。一个类有实例变量和实例方法来定义它的对象的状态和行为。通过声明一个类抽象,您表明该类对于它的对象有一些不完整的方法定义(行为),对于对象创建的目的,它必须被认为是不完整的。

什么是类中不完整的方法?有声明但没有主体的方法是不完整的方法。方法的主体缺失并不意味着主体为空。意思是没有尸体。方法声明后面的大括号表示方法的主体。在不完整方法的情况下,大括号被分号替换。如果一个方法不完整,您必须在方法声明中使用abstract关键字来指出它。您的Shape类不知道如何绘制一个形状,直到您提到一个特定的形状。然而,有一点是肯定的——无论是什么形状,你都应该能够画出一个形状。在这种情况下,您知道行为名称(draw,但是您不知道如何实现它。因此,draw是在Shape类中被声明为抽象方法(或不完整方法)的一个很好的候选对象。Shape类如下所示,带有一个abstract draw()方法:

public abstract class Shape {
    public Shape() {
    }
    public abstract void draw();
}

当你声明一个抽象类时,并不意味着它至少有一个抽象方法。一个抽象类可能有所有的具体方法。它可能有所有的抽象方法。它可能有一些具体的和一些抽象的方法。如果你有一个抽象类,这意味着该类的对象不能存在。但是,如果一个类有一个抽象方法(声明的或继承的),它必须声明为抽象的。将一个类声明为抽象类就像在一栋建筑前放一个“正在建设中”的标志。如果一个“正在建设中”的标志被放在一个建筑物的前面,它不应该被使用(不应该在一个类的情况下被创建)。大楼是否完工并不重要。一个“建设中”的标志足以表明它不能使用。但是,如果构建的某些部分是不完整的(比如一个具有抽象方法的类),您必须在它前面放置一个“正在构建”的标记(必须声明该类是抽象的)以避免任何不幸,以防有人试图使用它。

Tip

您不能创建abstract类的对象。如果一个类有一个abstract方法,声明的或者继承的,这个类必须声明为abstract。如果一个类没有任何abstract方法,你仍然可以声明这个类abstract。一个abstract方法的声明方式与任何其他方法相同,除了它的主体仅由分号表示。

清单 20-38 有这个Shape类的完整代码。

// Shape.java
package com.jdojo.inheritance;
public abstract class Shape {
    private String name;
    public Shape() {
        this.name = "Unknown shape";
    }
    public Shape(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
    public void setName(String name) {
        this.name = name;
    }
    // Abstract methods
    public abstract void draw();
    public abstract double getArea();
    public abstract double getPerimeter();
}

Listing 20-38An Abstract Shape Class with One Instance Variable, Two Constructors, and One Abstract Method

每个形状都有一个名字。name 实例变量存储形状的名称。getName()setName()方法分别允许您读取和更改形状的名称。两个构造器让你设置形状的名字或者保留默认的名字"Unknown shape"。一个形状不知道怎么画,所以它声明了它的draw()方法abstract。一个形状也不知道如何计算它的面积和周长,所以它声明了getArea()getPerimeter()方法是抽象的。

抽象类至少在理论上保证了继承的使用。否则,抽象类本身是没有用的。例如,在有人提供了Shape类的抽象方法的实现之前,它的其他部分(实例变量、具体方法和构造器)是没有用的。您创建了一个abstract类的子类,它覆盖了为它们提供实现的abstract方法。清单 20-39 有一个Rectangle类的代码,它继承自Shape类。

// Rectangle.java
package com.jdojo.inheritance;
public class Rectangle extends Shape {
    private final double width;
    private final double height;
    public Rectangle(double width, double height) {
        // Set the shape name as "Rectangle"
        super("Rectangle");
        this.width = width;
        this.height = height;
    }
    // Provide an implementation for inherited abstract draw() method
    @Override
    public void draw() {
        System.out.println("Drawing a rectangle...");
    }
    // Provide an implementation for inherited abstract getArea() method
    @Override
    public double getArea() {
        return width * height;
    }
    // Provide an implementation for inherited abstract getPerimeter() method
    @Override
    public double getPerimeter() {
        return 2.0 * (width + height);
    }
}

Listing 20-39A Rectangle Class, Which Inherits from the Shape Class

请注意,您没有声明Rectangle类抽象,这意味着它是一个具体的类,并且可以创建它的对象。像任何其他方法一样,一个abstract方法也被一个子类继承。由于Rectangle类没有被声明为abstract,它必须覆盖其超类的所有三个abstract方法,并为它们提供实现。如果Rectangle类没有覆盖其超类的所有抽象方法并为它们提供实现,它就被认为是不完整的,必须声明为abstract。您的Rectangle类覆盖了Shape类的draw()getArea()getPerimeter()方法,并为它们提供了实现(括号内的主体)。实例变量widthheight用于跟踪矩形的宽度和高度。在构造器内部,使用super关键字super("Rectangle")调用Shape类的构造器来设置它的name。清单 20-40 有一个Circle类的代码,它继承自Shape类。它还覆盖了Shape类的三个抽象方法,并为它们提供了实现。

// Circle.java
package com.jdojo.inheritance;
public class Circle extends Shape {
    private final double radius;
    public Circle(double radius) {
        super("Circle");
        this.radius = radius;
    }
    // Provide an implementation for inherited abstract draw() method
    @Override
    public void draw() {
        System.out.println("Drawing a circle...");
    }
    // Provide an implementation for inherited abstract getArea() method
    @Override
    public double getArea() {
        return Math.PI * radius * radius;
    }
    // Provide an implementation for inherited abstract getPerimeter() method
    @Override
    public double getPerimeter() {
        return 2.0 * Math.PI * radius;
    }
}

Listing 20-40A Circle Class, Which Inherits from the Shape Class

是时候使用你的abstract Shape类及其具体子类RectangleCircle了。注意,当在代码中使用abstract类时,对它的唯一限制是不能创建它的对象。除了这一限制,您可以像使用具体类一样使用它。例如,你可以声明一个abstract类类型的变量;您可以使用该变量调用abstract类的方法;等等。如果你不能创建一个Shape类的对象,你如何调用一个Shape变量的方法?这是一个很好的观点。考虑以下代码片段:

// Upcasting at work
Shape s = new Rectangle(2.0, 5.0);
// Late binding at work. s.getArea() will call the getArea() method of the Rectangle class.
double area = s.getArea();

如果你看前面的代码,它是有意义的。第一条语句创建一个Rectangle并将它的引用赋给一个Shape类的变量,这是一个简单的向上转换的例子。在第二条语句中,您使用s变量调用了getArea()方法。编译器只验证Shape类中getArea()方法的存在,它是s变量的声明类型。编译器不关心Shape类中的getArea()方法是否不完整(抽象)。它不关心getArea()方法是否是Shape类中的abstract,因为它是一个实例方法,并且它知道运行时的后期绑定将决定执行getArea()方法的哪个代码。它所关心的是getArea()方法的方法声明的存在。在运行时,后期绑定进程发现变量 s 引用了一个Rectangle,并调用了Rectangle类的getArea()方法。这难道不像鱼与熊掌兼得吗——你能拥有一个abstract类(不完全类)并使用它吗?如果你看看前面两行代码,你会发现这神奇的两条语句涉及了这么多面向对象编程的概念:abstract类、abstract方法、向上转换、方法重写、后期绑定、运行时多态。所有这些特性都包含在前面两个语句中,从而使您能够编写泛型和多态代码。考虑一个ShapeUtil类,如清单 20-41 所示。

// ShapeUtil.java
package com.jdojo.inheritance;
public class ShapeUtil {
    public static void drawShapes(Shape[] list) {
        for (Shape s : list) {
            // Draw the shape, no matter what it is
            s.draw(); // Late binding
        }
    }
    public static void printShapeDetails(Shape[] list) {
        for (Shape s : list) {
            // Gather details about the shape
            String name = s.getName();           // Late Binding
            double area = s.getArea();           // Late binding
            double perimeter = s.getPerimeter(); // Late binding
            // Print details
            System.out.println("Name: " + name);
            System.out.println("Area: " + area);
            System.out.println("Perimeter: " + perimeter);
        }
    }
}

Listing 20-41A ShapeUtil Class Having Utility Methods to Draw Any Shapes and Print Details About Them

ShapeUtil类包含两个static方法:drawShapes()printShapeDetails()。两者都接受一组Shape对象作为参数。drawShapes()方法通过调用传递给它的数组中每个元素的draw()方法来绘制所有形状。printShapeDetails()方法打印传递给它的形状的详细信息——名称、面积和周长。ShapeUtil类中代码的美妙之处在于它从不引用Shape类的任何子类。它根本不知道RectangleCircle类。它甚至不关心Rectangle类或Circle类是否存在,尽管代码将与RectanglesCircles以及Shape类的任何后代的对象一起工作。您可能会争辩说,即使您没有将Shape类声明为abstract,您也可以编写相同的代码。那么将Shape类声明为抽象类有什么大不了的呢?将Shape类声明为abstract有两个好处:

  • 如果您没有声明Shapeabstract,您将被迫为该类中的三个abstract方法提供实现。因为Shape类不知道它将采用什么形状的对象,所以它不适合为这些方法提供实现。现在,让我们假设您可以通过为draw()方法提供一个空体并从Shape类中的getArea()getPerimeter()方法返回零(或者可能是一个负数)来处理这个问题。让我们转到下一个更有说服力的优势。

  • 您被迫将Shape类声明为abstract,因为您已经在其中声明了三个abstract方法。在一个类中声明一个abstract方法的最大好处是强制它的子类覆盖并为它提供实现。Shape类中的abstract方法迫使RectangleCircle子类覆盖它们并为它们提供实现。这不是你想要的吗?

清单 20-42 包含测试ShapeUtil类以及ShapeRectangleCircle类的代码。它创建了一个Shape对象的数组。它用一个Rectangle填充数组的一个元素,用一个Circle填充另一个元素。它将数组传递给ShapeUtil类的drawShapes()printShapeDetails()方法,后者根据数组中放置的Object类型绘制形状并打印它们的细节。

// ShapeUtilTest.java
package com.jdojo.inheritance;
public class ShapeUtilTest {
    public static void main(String[] args) {
        // Create some shapes, draw, and print their details
        Shape[] shapeList = new Shape[2];
        shapeList[0] = new Rectangle(2.0, 4.0);  // Upcasting
        shapeList[1] = new Circle(5.0);          // Upcasting
        // Draw all shapes
        ShapeUtil.drawShapes(shapeList);
        // Print details of all shapes
        ShapeUtil.printShapeDetails(shapeList);
    }
}
Drawing a rectangle...
Drawing a circle...
Name: Rectangle
Area: 8.0
Perimeter: 12.0
Name: Circle
Area: 78.53981633974483
Perimeter: 31.41592653589793

Listing 20-42A Test Class to Test Shape, Rectangle, Circle, and the ShapeUtil Class

我们已经讨论完了声明类和方法abstract的主要规则。然而,在 Java 程序中还有许多其他规则控制着abstract类和方法的使用。这里列出了大部分规则(如果不是全部的话)。所有的规则都指向一个基本规则:“抽象类应该被子类化,这样才是有用的,子类应该覆盖并提供抽象方法的实现。”

  • 一个类可以被声明为abstract,即使它没有abstract方法。

  • 如果一个类声明或继承了一个abstract方法,它必须声明为abstract。如果该类覆盖并提供了所有继承的abstract方法的实现,并且没有声明任何abstract方法,则不必声明abstract—al,尽管它可以声明为abstract

  • 您不能创建一个abstract类的对象。但是,您可以声明一个abstract类类型的变量,并使用它调用方法。

  • 一个abstract类不能被声明为final。回想一下,final类不能被子类化,这与abstract类必须被子类化才能真正有用的要求相冲突。

  • 一个abstract类不应该声明所有的构造器private。否则,abstract类就不能被子类化。注意,当一个类的对象被创建时,所有祖先类(包括一个abstract类)的构造器总是被调用。当你创建一个Rectangle类的对象时,也会调用Object类和Shape类的构造器。如果你声明了一个abstractprivate的所有构造器,你不能为你的abstract类创建一个子类,这与声明一个abstract final类是一样的。

  • 一个abstract方法不能被声明为static。注意,abstract方法必须被子类覆盖和实现。不能覆盖static方法。但是,可以隐藏。

  • 一个abstract方法不能被声明为private。回想一下,private方法没有被继承,因此不能被覆盖。对abstract方法的要求是子类必须能够覆盖并为其提供实现。

  • 一个abstract方法不能被声明为nativestrictfpsynchronized。这些关键字指的是方法的实现细节。native关键字表示一个方法是用本地代码实现的,而不是 Java 代码。strictfp关键字表示方法内部的代码使用浮点计算的 FP-strict 规则。strictfp 关键字在 Java 17 中已经过时,因为 FP-strict 现在总是处于启用状态。synchronized关键字表示调用该方法的对象必须被线程锁定,然后才能执行该方法的代码。由于abstract方法没有实现,暗示实现的关键字不能用于abstract方法。

  • 类中的abstract方法可以覆盖其超类中的abstract方法,而无需提供实现。子类abstract方法可以细化被覆盖的abstract方法的返回类型或异常列表。考虑下面的代码。类B覆盖了类Aabstract m1()方法,并且不提供它的实现。它只从throws子句中删除了一个异常。类C覆盖类Bm1()方法,并为其提供实现。请注意,返回类型或异常列表的更改,如类B和类Cm1()方法所示,必须遵循方法重写的规则:

  • 一个具体的实例方法可能被一个abstract实例方法覆盖。这可以强制子类为该方法提供实现。Java 中的所有类都继承了Object类的equals()hashCode()toString()方法。假设您有一个类CA,并且您希望它的所有子类覆盖并提供Object类的equals()hashCode()toString()方法的实现。您需要在类CA中覆盖这三个方法,并将它们声明为abstract,,如下所示:

public abstract class A {
    public abstract void m1() throws CE1, CE2;
}
public abstract class B extends A {
    public abstract void m1() throws CE1;
}
public class C extends B {
    public void m1() {
        // Code goes here
    }
}

  • 在这种情况下,Object类的具体方法已经被CA类中的abstract方法覆盖。所有CA的具体子类都被强制覆盖并为equals()hashCode()toString()方法提供实现。
public abstract class CA {
    public abstract int hashCode();
    public abstract boolean equals(Object obj);
    public abstract String toString();
    // Other code goes here
}

方法重写和泛型方法签名

Java 5 引入了泛型类型的概念。Java 允许你声明泛型方法。当编译具有泛型类型的代码时,泛型类型被转换为原始类型。用于转换泛型参数信息的过程被称为类型擦除。考虑清单 20-43 中的GenericSuper类。它有一个泛型参数T。它有两种方法,m1()m2()。它的m1()方法使用泛型类型T作为其参数类型。它的m2()方法定义了一个新的泛型类型作为它的参数类型。

// GenericSuper.java
package com.jdojo.inheritance;
public class GenericSuper<T> {
    public void m1(T a) {
        // Code goes here
    }
    public <P extends Employee> void m2(P a) {
        // Code goes here
    }
}

Listing 20-43A Sample Class That Uses Generic Type Parameters

GenericSuper类被编译时,擦除会在编译期间转换代码,结果代码(在字节码中编译的)看起来如清单 20-44 所示。

// GenericSuper.java
package com.jdojo.inheritance;
public class GenericSuper {
    public void m1(Object a) {
        // Code goes here
    }
    public void m2(Employee a) {
        // Code goes here
    }
}

Listing 20-44The GenericSuper Class Transformed Code During Compilation After Erasure Is Used

清单 20-45 中的GenericSub类继承了GenericSuper类。

// GenericSub.java
package com.jdojo.inheritance;
public class GenericSub extends GenericSuper {
    @Override
    public void m1(Object a) {
        // Code goes here
    }
    @Override
    public void m2(Employee a) {
        // Code goes here
    }
}

Listing 20-45A GenericSub Class Inherits from the GenericSuper Class and Overrides m1() and m2() Methods

GenericSub类中,m1()m2()方法覆盖了GenericSuper类中相应的方法。如果您比较清单 20-43 和 20-45 中的方法m1()m2()来覆盖规则,您会认为它们没有相同的签名,因为清单 20-43 中的代码使用了泛型。检查重写等效方法签名的规则是,如果方法使用泛型参数,您需要比较它的擦除,而不是它声明的泛型版本。当您比较GenericSuper类中m1()m2()方法声明的擦除(在清单 20-44 中)与清单 20-45 中m1()m2()方法声明时,您会发现m1()m2()方法在GenericSub类中被覆盖。

方法重写中的打字错误危险

当你试图重写一个类中的方法时,有时很容易出错。当一个方法没有被重写时,看起来你已经重写了它。考虑下面两个类,C1C2:

// C1.java
package com.jdojo.inheritance;
public class C1 {
    public void m1(double num) {
        System.out.println("Inside C1.m1(): " + num);
    }
}
// C2.java
package com.jdojo.inheritance;
public class C2 extends C1 {
    public void m1(int num) {
        System.out.println("Inside C2.m1(): " + num);
    }
}

目的是让类C2中的m1()方法覆盖类C1中的m1()方法。然而,事实并非如此。这是方法重载的情况,而不是方法重写的情况。C2中的m1()方法重载;m1(double num)是从类C1;继承的,而m1(int num)是在C2中声明的。当你开始运行你的程序并且没有得到想要的结果时,事情变得更加困难。考虑下面的代码片段:

C1 c = new C2();
c.m1(10); // Which method is called - C1.m() or C2.m2()?

执行前面的代码时应该打印什么?它打印以下内容:

Inside C1.m1(): 10.0

看到前面代码片段的输出,你是否感到惊讶?让我们详细讨论一下前面的代码片段被编译和执行时会发生什么。当编译器遇到第二条语句c.m1(10)时,它会做以下事情:找出引用变量 c 的编译时类型,即C1

它在C1中寻找一个名为m1的方法。传递给方法m1()的参数值 10 是一个int。编译器在C1中寻找一个名为m1(继承的或声明的)的方法,该方法带有一个int参数。它发现类C1有一个方法m1(double num),该方法接受一个double参数。它尝试类型扩大转换,发现类C1中的m1(double num)方法可用于c.m1(10)方法调用。此时,编译器为调用绑定方法签名。注意,编译器绑定的是方法签名,而不是方法代码。方法代码在运行时被绑定,因为m1()是一个实例方法。编译器不会决定为c.m1(10)执行哪个m1()方法的代码。请记住,编译器的决定完全基于它对类C1的了解。当c.m1(10)被编译时,编译器不知道(或不关心)任何其他类的存在,例如C2。您可以看到 Java 编译器为c.m1(10)方法调用生成了什么代码。您需要使用带有-c选项的javap命令行实用程序来反汇编编译后的代码,如下所示。您需要将类的完全限定名传递给javap命令:

javap -c <fully-qualified-class-name>

对于前面包含c.m1(10)调用的代码片段,javap将打印编译器生成的指令。我只展示一条指令:

12:  invokevirtual   #14; // Method com/jdojo/inheritance /C1.m1:(D)V

invokevirtual指令用于表示对将使用后期绑定的实例方法的调用。#14(对您来说可能不同)表示方法表条目编号,它是C1.m1(D)V方法的条目。语法对你来说可能有点晦涩。字符D表示double,是参数类型,V表示void,是方法m1()的返回类型。

在运行时,当 JVM 试图运行c.m1(10)时,它使用后期绑定机制来查找它将执行的方法代码。注意,JVM 将搜索m1(D)V方法签名,这是 void m1(double)的编译器语法。它通过查看运行时类型c开始搜索,运行时类型是类C2。类C2没有名为m1的方法,该方法接受类型为double的参数。搜索在类别层次结构中向上移动到类别C1。JVM 找到类C1中的方法并执行它。这就是为什么您得到的输出表明类C1中的m1(double num)是为c.m1(10)调用的。

这种错误很难追查。您可以通过使用@Override注释来避免这样的错误。你已经在这本书里多次看到这个注解了。关于注释的更多信息,请参考本系列的第二卷, More Java 17 。该注释支持编译器。编译器将确保用@Override注释标注的方法确实覆盖了其超类(或接口,我们将在下一章介绍)中的方法。否则,它将生成一个错误。使用@ Override标注很容易。只需将它添加到方法声明中方法返回类型之前的任何地方。下面的代码为类C2使用了m1()方法的@Override注释:

public class C2 extends C1 {
    @Override
    public void m1(int num) {
        System.out.println("Inside C2.m1(): " + num);
    }
}

当您为类C2编译前面的代码时,编译器将生成一个错误,指出类C2中的方法m1()没有覆盖其超类中的任何方法。使用@Override注释和一个应该覆盖超类方法的方法可以节省大量调试时间。注意,@Override注释并没有改变方法覆盖的工作方式。它被用作编译器的指示器,它需要确保该方法确实覆盖了其超类的方法。

是-a,有-a,和关系的一部分

基于面向对象范例设计的软件应用程序由交互对象组成。一个类的对象可能在某些方面与另一个类的对象相关。Is-a、has-a 和 part-of 是两个类的对象之间最常用的三种关系。我们已经讨论过,is-a 关系是使用两个类之间的继承来建模的。例如,关系“一个兼职经理是一个经理”通过从Manager类继承PartTimeManager类来建模。

有时一个类的对象包含另一个类的对象,表示整体-部分关系。这种关系叫做聚合。也就是所谓的有-个关系。has-a 关系的例子是“一个人有一个地址”作为整体-部分关系,人代表整体,称呼代表部分。Java 没有任何特殊的特性可以让你在代码中指出一个 has-a 关系。在 Java 代码中,聚合是通过在整体中使用一个实例变量来实现的,它的类型是 part。在这个例子中,Person类将有一个类型为Address的实例变量,如下所示。注意,Address类的一个对象是在Person类之外创建的,并被传递给Person类的构造器:

public class Address {
    // Code goes here
}
public class Person {
    // Person has-a Address
    private Address addr;
    public Person(Address addr) {
        this.addr = addr;
    }
    // Other code goes here
}

组合是整体控制部分生命周期的聚合的特例。它也被称为关系的部分。有时,has-a 和 part-of 关系可以互换使用。聚合和合成的主要区别在于,在合成中,整体控制部分的创建/破坏。在构图中,零件不能单独存在。更确切地说,部分是作为整体的一部分被创造和毁灭的。考虑“CPU 是计算机的一部分”的关系你也可以把这种关系重新表述为“一台计算机有一个 CPU”计算机外部 CPU 的存在有意义吗?答案是否定的,确实电脑和 CPU 代表的是整体和部分的关系。然而,这种整体与部分的关系还有更多限制,那就是“CPU 的存在只有在计算机内部才有意义。”您可以通过声明类型 part 的实例变量并创建 part 对象作为创建整体的一部分,在 Java 代码中实现组合,如下所示。创建一个Computer时就创建了一个 CPU。电脑毁了 CPU 也就毁了:

public class CPU {
    // Code goes here
}
public class Computer {
    // CPU part-of Computer
    private CPU cpu = new CPU();
    // Other code goes here
}

Java 有一个特殊的类类型叫做 inner class,也可以用来表示复合。内部类的对象只能存在于其封闭类的对象中。封闭类是整体,内部类是部分。您可以使用内部类来表示 CPU 和计算机之间的部分关系,如下所示:

public class Computer {
    private CPU cpu = new CPU();
    // CPU is an inner class of Computer
    private class CPU {
        // Code goes here
    }
    // Other code goes here for Computer class
}

将计算机和 CPU 之间合成的这种实现与前一种实现进行比较。当您使用内部类时,CPU类的对象不能在没有Computer类的对象的情况下存在。当同一个类的对象,比如说CPU,是一个组合关系中另一个对象的一部分时,这个限制可能会有问题。

组成也表示所有者拥有的关系。电脑是拥有者,CPU 是电脑拥有的。没有所有者对象,所拥有的对象就不能存在。通常,但不一定,当销毁所有者对象时,销毁所拥有的对象。有时,当销毁所有者对象时,它会将所拥有对象的引用传递给另一个所有者。在这种情况下,被拥有的对象在其当前所有者死后仍然存在。需要注意的是,被拥有的对象总是有一个所有者。

有时程序员会在使用继承和组合的选择之间感到困惑,他们使用继承而不是组合。您可以在 Java 类库中找到这种错误,其中的java.util.Stack类是从java.util.Vector类继承而来的。一个Vector是一个对象列表。一个Stack也是一个对象列表,但不像Vector那样是一个简单的对象列表。一个Stack应该允许你添加一个对象到它的顶部和从它的顶部移除一个对象。然而,Vector允许您在任何位置添加/删除对象。因为Stack类继承自Vector类,所以它也继承了允许你在任意位置添加/移除对象的方法,这些方法对于栈来说只是错误的操作。Stack类应该使用 composition 来使用一个Vector对象作为它的内部表示,而不是继承它。以下代码片段显示了StackVector类之间“has-a”关系的正确用法:

public class Stack {
    // Stack has-a Vector
    private Vector items = new Vector();
    // Other code goes here
}

Tip

每当你在组合和继承之间犹豫不决时,优先选择组合。两者都允许你共享代码。但是,继承会强制您的类位于特定的类层次结构中。继承也创建子类型,而组合用于创建新类型。

模式匹配开关

Java 17 引入了对 switch 语句和表达式的增强,允许您基于给定对象的类型进行切换。尽管您通常应该依赖多态和继承,并将行为实现到类本身中,但这并不总是一个选项。有些情况下,使用 switch 表达式或语句是更好或唯一的选择。语法类似于 instanceof 运算符,但应用于 switch 语句或表达式的 case 标签。

例如,假设您想要打印出一个对象的类型和一些自定义值,这些值基于它是哪个类的实例,而您没有能力更改这些类。对象可能是雇员、经理或职员。方法如下:

public static String getFormattedString(Object o) {
    return switch (o) {
        case Employee e -> String.format("Employee, ID is %d", e.getId());
        case Manager m -> String.format("Manager, salary is %f", m.getSalary());
        case Clerk c -> String.format("Clerk, total sales is %f", c.getTotalSales());
        default -> o.toString();
    };
}

如果您想进一步决定何时做某事,您还可以使用&操作符来使用 guard 语句或条件。下面的例子是如果你想区别对待高级经理的话:

public static String getFormattedString(Object o) {
    return switch (o) {
        case Employee e -> String.format("Employee, ID is %d", e.getId());
        case Manager m && m.isSenior() ->"Manager";
        case Manager m -> String.format("Manager, salary is %f", m.getSalary());
        case Clerk c -> String.format("Clerk, total sales is %f", c.getTotalSales());
        default -> o.toString();
    };
}

switch 中的模式匹配是 Java 17 中的一个预览特性,所以在运行 Java 和编译时必须使用- enable-preview 命令选项来启用它。

没有类的多重继承

通常,类表示实现。Java 不支持实现的多重继承。也就是说,Java 中的一个类不能有多个超类。继承让一个类从它的超类继承实现和/或接口。在实现继承的情况下,超类为它的子类继承和重用的功能提供实现。例如,Employee类实现了getName()setName()方法,它们由Manager类继承。在接口继承的情况下,超类为它的子类继承和实现的功能提供了规范。注意,在 Java 中声明abstract方法定义了一个规范,而声明一个具体(非抽象)方法定义了一个实现。例如,Shape类有一个draw()方法的规范,它被它的子类继承(例如RectangleCircle))。它没有为draw()方法提供任何实现。Shape类的所有具体子类必须为其draw()方法提供实现。

多重继承被定义为一个类从多个超类继承。当一个类从多个超类继承一个实现时,会带来一些问题。假设有两个类,SingerEmployee,它们都提供了处理薪水的实现(比如一个pay()方法)。此外,假设您有一个类SingerEmployee,它继承自SingerEmployee类。新类SingerEmployee从两个不同的超类继承了pay()方法,这两个超类有不同的实现。当在SingerEmployee上调用pay()方法时,应该使用哪个pay()方法——来自Employee类还是来自Singer类?

多重继承使得程序员的工作和语言设计者的工作一样复杂。Java 支持接口(或类型)的多重继承,而不是实现。它有一个不同于类的构造,叫做接口。一个接口可以从多个接口继承。一个类可以实现多个接口。Java 只支持类型的多重继承的方法避免了程序员及其设计者的问题。多类型继承比多实现继承更容易理解和设计。

摘要

继承允许您基于另一个类的定义来定义一个类。继承是实现包含多态的技术之一。它促进了代码重用。它让你根据一个类来编写代码,这个类为这个类及其所有子类工作。子类根据一些规则继承超类的成员。构造器不是类的成员,也不会被子类继承。

关键字extends用于从另一个类继承一个类。如果一个类声明不包含关键字extends,那么这个类将隐式继承自Object类。继承创建了一个树状的类层次结构——Object类位于所有类层次结构的顶端。Object类本身没有超类。

Java 支持两种类型的绑定:早期绑定和晚期绑定。在早期绑定中,编译器根据访问字段和方法的引用的编译时类型来确定将被访问的字段和方法。Java 使用早期绑定来访问所有类型的字段和静态方法。在后期绑定中,引用变量的运行时类型决定了要执行的方法。继承和后期绑定使得在 Java 中使用运行时多态成为可能。Java 使用后期绑定来访问实例方法。

一个超类的变量总是可以被赋予它的子类的引用。这叫做向上抛掷。当子类的一个变量被类型转换并赋给超类的一个变量时,就叫做向下转换。为了使向下转换在运行时成功,超类的变量必须包含子类的引用或子类的一个子类。instanceof操作符用于测试引用变量是否是特定类的实例。

您可以声明抽象类和方法。关键字abstract用于声明抽象类和方法。抽象类不能被实例化。如果一个类包含一个抽象方法,这个类必须被声明为抽象的。即使一个类不包含抽象方法,它也可以被声明为抽象的。抽象方法应该被重写,并由子类提供实现。

子类可以使用关键字super访问其超类的构造器、方法和字段。访问超类构造器的调用必须是子类构造器中的第一条语句。

在子类中重新定义超类的静态方法叫做方法隐藏。与超类中的字段同名的字段隐藏超类中的字段,称为字段隐藏。使用超类名作为方法的限定符,可以从子类访问隐藏的方法。您可以使用关键字super来访问子类中的隐藏字段。

类和方法可以声明为final。一个final类不能被子类化。一个final方法不能被覆盖。声明一个类private的所有构造器也会停止该类的子类化。

EXERCISES

  1. 你在类声明中使用什么关键字从另一个类继承你的类?

  2. 在下面的类声明中,超类和子类的名字是什么?

    public class Letter extends Document
    
    
  3. 写出类A的超类的全限定名,声明如下:

    public class A {
    }
    
    
  4. Java 中一个类可以有多少个超类?

  5. 用什么关键字调用超类的构造器?编写调用超类构造器的语句,该构造器以一个字符串作为参数。参数值为"Hello"

  6. 子类继承了超类的哪些成员类型:publicprivateprotected和包级别?

  7. 命名当您在类中重写方法时应该使用的批注,以便编译器可以验证您重写方法的意图。

  8. 如何从子类调用超类的重写实例方法?考虑下面的代码片段:

    public class A {
        public void print() {
            System.out.println("A");
        }
    }
    public class B extends A {
        @Override
        public void print() {
            /* Your one line code goes here */
            System.out.println("B");
        }
       public static void main(String[] args) {
           new B().print();
       }
    }
    
    

    完成类Bprint()方法中的代码,因此当您运行类B时,它应该打印如下内容。你要调用类A :

    A
    B
    
    

    print()方法

  9. 写出下面的类声明不能编译的原因:

    public abstract final class A {
        // Code goes here
    }
    
    
  10. 写出下面类B和类C的声明不能编译的原因:

```java
public class A {
    public A(int x) {
    }
}
public class B extends A {
}
public class C extends A {
    public C() {
    }
}

```
  1. 方法重载和方法重写有什么区别?

  2. 考虑下面对类A和类B的声明。运行B类时会打印什么?类B中方法m1()的声明是方法覆盖还是方法重载?解释你的答案:

```java
public class A {
    public void m1(int x) {
        System.out.println("A.m1(): " + x);
    }
}
public class B extends A {
    public void m1(Integer x) {
        System.out.println("B.m1(): " + x);
    }
    public static void main(String[] args) {
        B b = new B();
        b.m1(100);
    }
}

```
  1. 考虑下面两个类声明:
```java
public class A {
}
public class B extends A {
}

```

下面的一个语句不编译。描述编译时错误背后的原因并修复它。找出下列语句中向上转换和向下转换的例子:

```java
A a = new B();
B b = new B();
a = b;
b = a;

```
  1. 早绑定和晚绑定有什么区别?哪种类型的绑定完全由编译器决定?

  2. 运行下面的类B时,写入输出。这个练习是为了测试你对早绑定和晚绑定的知识:

```java
public class A {
    public void m1() {
        System.out.println("A.m1()");
    }
    public static void m2() {
        System.out.println("A.m2()");
    }
}
public class B extends A {
    @Override
    public void m1() {
        System.out.println("B.m1()");
    }
    public static void m2() {
        System.out.println("B.m2()");
    }
    public static void main(String[] args) {
        A a = new B();
        a.m1();
        a.m2();
        ((B)a).m2();
        A.m2();
        B.m2();
    }
}

```
  1. 命名在向下转换引用之前应该使用的运算符,这样向下转换总是会成功。

  2. 编写以下代码片段的输出:

```java
public class A {
}
public class B extends A {
}
A a = new B();
System.out.println("a instanceof A: " + (a instanceof A));
System.out.println("a instanceof B: " + (a instanceof B));
System.out.println("a instanceof Object: " + (a instanceof Object));
System.out.println("null instanceof A: " + (null instanceof A));
System.out.println("null instanceof B: " + (null instanceof B));

```
  1. 解释为什么下面对类B的声明不能编译:
```java
public abstract class A {
    public abstract void print();
}
public class B extends A {
}

```
  1. 解释为什么下面对类B的声明不能编译:
```java
public class A {
    private A() {
        System.out.println("Hello");
    }
}
public class B extends A {
}

```
  1. 运行下面的类B时,写入输出。这个练习是为了测试你对字段隐藏、方法覆盖以及使用super关键字调用超类的方法
```java
public class A {
    protected int x = 100;
    public A() {
        System.out.println("x = " + x);
    }
    public void print() {
        System.out.println("x = " + x);
    }
}
public class B extends A {
    private final int x = 200;
    public B() {
        System.out.println("x = " + x);
    }
    @Override
    public void print() {
        super.print();
        System.out.println("x = " + x);
    }
    public static void main(String[] args) {
        A a = new B();
        a.print();
    }
}

```

的知识

二十一、接口

在本章中,您将学习:

  • 什么是接口

  • 如何声明接口

  • 如何在接口中声明抽象、默认、私有和静态方法

  • 如何在类中完全和部分实现接口

  • 接口发布后如何发展

  • 如何从其他接口继承一个接口

  • 通过接口使用instanceof操作符

  • 什么是标记接口

  • 如何使用接口实现多态

  • 动态绑定如何应用于接口类型变量的方法调用

  • 什么是功能接口以及如何使用它们

本章中的所有示例程序都是清单 21-1 中声明的jdojo.interfaces模块的成员。

// module-info.java
module jdojo.interfaces {
    exports com.jdojo.interfaces;
}

Listing 21-1The Declaration of a jdojo.interfaces Module

什么是接口?

一个接口在 Java 中是一个非常重要的概念。Java 开发人员的知识是不完整的,除非他们理解接口的作用。通过例子比通过正式定义更容易理解。在我们提供接口的正式定义之前,让我们讨论一个简单的例子,它将为关于接口需求的详细讨论奠定基础。

Java 应用程序由交互对象组成。一个对象通过发送消息与其他对象进行交互。对象接收消息的能力是通过在对象的类中提供方法来实现的。假设有一个名为Person的类,它提供了一个walk()方法。walk()方法为Person类的每个对象提供了接收“行走”消息的能力。让我们将Person类定义如下:

public class Person {
    private String name;
    public Person(String name) {
        this.name = name;
    }
    public void walk() {
        System.out.println(name + " (a person) is walking.");
    }
}

Person类的一个对象将有一个名字,这个名字将在它的构造器中设置。当它接收到一个“walk”消息时,也就是说,当它的walk()方法被调用时,它在标准输出上打印一条消息。

让我们创建一个名为Walkables的实用程序类,用于向一组对象发送特定的消息。让我们假设您想要向Walkables类添加一个letThemWalk()静态方法,它接受一个Person对象数组。它向数组中的所有元素发送“行走”消息。你可以如下定义你的Walkables类。该方法顾名思义;也就是它让大家走路!

public class Walkables {
    public static void letThemWalk(Person[] list) {
        for (Person person : list) {
            person.walk();
        }
    }
}

以下代码片段可用于测试PersonWalkable的类:

public class WalkablesTest {
    public static void main(String[] args) {
        Person[] persons = new Person[3];
        persons[0] = new Person("Jack");
        persons[1] = new Person("Jeff");
        persons[2] = new Person("John");
        // Let everyone walk
        Walkables.letThemWalk(persons);
    }
}
Jack (a person) is walking.
Jeff (a person) is walking.
John (a person) is walking.

到目前为止,您还没有看到PersonWalkables类的设计有任何问题,对吗?它们执行它们被设计来执行的动作。Person类的设计保证了它的对象将响应“行走”消息。通过将Person数组声明为Walkables类中letThemWalk()方法的参数类型,编译器确保对persons[i].walk()的调用是有效的,因为Person对象保证会响应“walk”消息。

让我们通过添加一个名为Duck的新类来扩展这个项目,它代表了现实世界中的一只鸭子。我们都知道鸭子也会走路。一只鸭子能做许多其他人能做或不能做的事情。然而,为了我们讨论的目的,我们将只关注鸭子的行走能力。您可以如下定义您的Duck类:

public class Duck {
    private String name;
    public Duck(String name) {
        this.name = name;
    }
    public void walk() {
        System.out.println(name + " (a duck) is walking.");
    }
}

您可能会注意到在Person类和Duck类之间有一个相似之处。两个类的对象都可以响应“行走”消息,因为它们都提供了一个walk()方法。然而,这两个类之间的相似之处仅此而已。除了它们都以Object类作为它们的共同祖先之外,它们之间没有任何其他的联系。Duck类的引入扩展了应用程序中对象的行走能力。在有鸭子之前,只有人会走路。添加了Duck类后,鸭子也能走路了。

现在,您想使用您的Walkables类让鸭子走路。你的Walkables课能让鸭子走路吗?不。它不能让鸭子走路,除非你做一些改变。一辆Duck的行走能力对现有的Walkables级来说不构成任何问题。此时的问题是,letThemWalk()方法已经将其参数类型声明为一个数组Person。一个Duck不是一个Person。您不能编写此处显示的代码。不能将Duck对象分配给Person类型的引用变量。以下代码片段不会编译:

Person[] list = new Person[3];
list[0] = new Person("Jack");
list[1] = new Duck("Jeff"); // A compile-time error
list[2] = new Person("John");
Walkables.letThemWalk(list);

你怎么解决这个问题让你的Walkables类让一个人和一只鸭子走在一起?根据您现有的 Java 编程语言知识,有三种方法可以解决这个问题。请注意,我们在这一点上不是在谈论接口。在本节的末尾,您将使用接口有效而正确地解决这个问题。让我们暂时忘记这一章的标题,这样你就能体会到接口在 Java 编程中扮演的重要角色。解决这个问题的三种方法如下:

  • Walkables类的letThemWalk()方法的参数类型从数组Person改为数组Object。使用反射对传入数组的所有元素调用walk()方法。现在不要担心“反射”这个术语。

  • Walkables类中定义一个名为letDucksWalk(Duck[] ducks)的新静态方法。想让鸭子走路就调用这个方法。

  • 从一个共同的祖先类继承PersonDuck类,比如说Animal类,在Animal类中增加一个walk()方法。将Walkables类的letThemWalk()方法的参数类型从数组Person改为数组Animal

我们来详细看看这三种解决方案。

提议的解决方案#1

您可以通过将这两个方法添加到Walkables类来实现第一个解决方案,如下所示:

// Walkables.java
import java.lang.reflect.Method;
public class Walkables {
    public static void letThemWalk(Object[] list) {
        for (Object obj : list) {
            // Get the walk method reference
            Method walkMethod = getWalkMethod(obj);
            if (walkMethod != null) {
                try {
                    // Invoke the walk() method
                    walkMethod.invoke(obj);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public static Method getWalkMethod(Object obj) {
        Class<?> c = obj.getClass();
        try {
            Method walkMethod = c.getMethod("walk");
            return walkMethod;
        } catch (NoSuchMethodException e) {
            // walk() method does not exist
        }
        return null;
    }
}

getWalkMethod()方法在指定对象的类中寻找walk()方法。如果找到一个walk()方法,它将返回该方法的引用。否则返回null。您已经将letThemWalk()方法的参数类型从数组Person更改为数组Object。您可以使用下面的代码片段来测试修改后的Walkables类:

Object[] list = new Object[4];
list[0] = new Person("Jack");
list[1] = new Duck("Jeff");
list[2] = new Person("John");
list[3] = new Object(); // Does not have a walk() method
// Let everyone walk
Walkables.letThemWalk(list);
Jack (a person) is walking.
Jeff (a duck) is walking.
John (a person) is walking.

输出表明您的解决方案是可行的。它让人和鸭子一起走。同时,如果一个物体不知道如何行走,它也不会强迫该物体行走。您向letThemWalk()方法传递了四个对象,并且没有尝试对数组的第四个元素调用walk()方法,因为Object类没有walk()方法。

让我们拒绝这个解决方案,原因很简单,您使用反射对传入的所有对象调用了walk()方法,并且您依赖于这样一个事实,即所有知道如何行走的对象都有一个名为“walk”的方法如果您更改方法名,比如说在Person类中,从walk()更改为walkMe(),这个解决方案很容易被悄悄地破解。你的程序将继续工作,不会出现任何错误,但是当你用一个Person对象调用letThemWalk()方法时,它改变后的walkMe()方法将不会被调用。

提议的解决方案#2

让我们看看第二个建议的解决方案。您建议向您的Walkables类添加一个新方法letDucksWalk(),如下所示:

public class Walkables {
    public static void letThemWalk(Person[] list) {
        for (Person person : list) {
            person.walk();
        }
    }
    public static void letDucksWalk(Duck[] list) {
        for (Duck duck : list) {
             duck.walk();
        }
    }
}

这在某种意义上解决了问题,它将让所有的鸭子走路。然而,这也不是一个理想的解决方案。它还是不会让人和鸭子走在一起。这种解决方案的另一个问题是可扩展性。它不是一个可扩展的解决方案。如果你看一下letThemWalk()letDucksWalk()这两个方法,你会发现除了参数类型PersonDuck之外,它们的逻辑是一样的。如果你添加一个名为Cat的新类,它的对象也会走路,会发生什么?这个解决方案将迫使您向Walkables类添加另一个方法letCatsWalk(Cat[] cats)。因此,您应该拒绝这种解决方案,因为它不可扩展。

提议的解决方案#3

让我们看看第三个建议的解决方案。它建议从一个共同的祖先类继承Person类和Duck类,比如说Animal,后者有一个walk()方法。它还会让您将Walkables类中的letThemWalk()方法的参数从Person数组更改为Animal数组。这个解决方案与您正在寻找的解决方案非常接近,在某些情况下,它可能被认为是一个好的解决方案。但是,由于以下两个原因,您拒绝了这个解决方案:

  • 这种解决方案迫使您在类层次结构中拥有一个共同的祖先。例如,其对象知道如何行走的所有类必须有相同的祖先(直接或间接)。假设您创建了一个名为Dog的新类,它的对象可以行走。在这个提议的解决方案中,Dog类必须从Animal类继承,所以您可以使用letThemWalk()方法让Dog行走。有时你想给一个类的对象增加行走功能,这个类已经从另一个类继承了。在这种情况下,不可能将现有类的超类更改为Animal类。

  • 假设你继续这个解决方案。您添加了一个名为Fish的新类,它继承了Animal类。一个Fish类的对象不知道如何行走。因为Fish类继承了Animal类,所以它也会继承walk()方法,也就是行走的能力。毫无疑问,您需要在Fish类中覆盖walk()方法。现在问题来了,Fish类应该如何实现walk()方法?它是否应该回答说“我是一条鱼,我不知道如何走路”?它应该抛出一个异常声明“要求鱼走路是非法的”吗?

你可以看到第三个解决方案似乎是一个非常接近的解决方案。然而,它不是一个理想的。这也证明了一点,在 Java 程序中使用继承是一件好事,但它并不总是提供理想的解决方案。

理想的解决方案

您正在寻找一种解决方案,它能提供两件事:

  • Walkables类中的一个单独的方法letThemWalk(),应该能够发送一个“行走”消息给所有作为参数传递给它的对象(例如,调用walk()方法)。这个方法应该适用于所有类型的可以行走的物体(你现在拥有的或者将来会拥有的)。

  • 如果你想增加遍历一个现有类的能力,你不应该被迫改变这个类的超类。

Java 中的接口在这种情况下提供了一个完美的解决方案。在我们开始详细讨论接口之前,让我们先完成本节中提出的问题的解决方案。首先,您需要定义一个接口。现在,只要把接口想象成一个编程结构。

使用关键字“interface”声明一个接口,它可以有abstract方法声明。注意,abstract方法没有主体。每个接口都应该有一个名称。你的接口被命名为Walkable。它包含一个叫做walk()的方法。清单 21-2 显示了您的Walkable接口的完整代码。

// Walkable.java
package com.jdojo.interfaces;
public interface Walkable {
    void walk();
}

Listing 21-2The Declaration for a Walkable Interface

所有对象可以行走的类都应该实现Walkable接口。一个类可以在其声明中使用关键字implements实现一个或多个接口。通过实现一个接口,一个类保证它将为接口中声明的所有abstract方法提供一个实现,或者这个类必须声明自己abstract。现在,让我们忽略第二部分,假设这个类实现了它实现的接口的所有abstract方法。如果一个类实现了Walkable接口,它必须为walk()方法提供一个实现。

PersonDuck类的对象需要行走的能力。你需要实现这些类的Walkable接口。清单 21-3 和 21-4 有这些类的完整修订代码。

// Duck.java
package com.jdojo.interfaces;
public class Duck implements Walkable {
    private String name;
    public Duck(String name) {
        this.name = name;
    }
    public void walk() {
        System.out.println(name + " (a duck) is walking.");
    }
}

Listing 21-4The Revised Duck Class, Which Implements the Walkable Interface

// Person.java
package com.jdojo.interfaces;
public class Person implements Walkable {
    private String name;
    public Person(String name) {
        this.name = name;
    }
    public void walk() {
        System.out.println(name + " (a person) is walking.");
    }
}

Listing 21-3The Revised Person Class, Which Implements the Walkable Interface

请注意,修改后的类的声明与其原始声明略有不同。他们都在声明中增加了一个新的“implements Walkable”条款。因为它们都实现了Walkable接口,所以它们必须提供在Walkable接口中声明的walk()方法的实现。您不必定义一个新的walk()方法,因为您从一开始就已经实现了它。如果这些类没有walk()方法,您必须在这个阶段将它添加到它们中。

在您修改您的Walkables类的代码之前,让我们看看您可以用Walkable接口做的其他事情。像类一样,接口定义了一个新的引用类型。当你定义一个类时,它定义了一个新的引用类型,并允许你声明该类型的变量。同样,当你定义一个新的接口时(例如Walkable,你可以定义一个新接口类型的引用变量。变量范围可以是局部的、实例的、静态的或方法参数。以下声明有效:

// w is a reference variable of type Walkable
Walkable w;

您不能创建接口类型的对象。以下代码无效:

// A compile-time error
new Walkable();

您只能创建一个类类型的对象。然而,接口类型变量可以引用任何其类实现该接口的对象。因为PersonDuck类实现了Walkable接口,所以Walkable类型的引用变量可以引用这些类的对象:

Walkable w1 = new Person("Jack"); // OK
Walkable w2 = new Duck("Jeff");   // OK
// A compile-time error as the Object class does not implement the Walkable interface
Walkable w3 = new Object();

你能用接口类型的引用变量做什么?您可以使用接口的引用类型变量来访问接口的任何成员。由于您的Walkable接口只有一个成员,即walk()方法,您可以编写如下所示的代码:

// Let the person walk
w1.walk();
// Let the duck walk
w2.walk();

当您在w1上调用walk()方法时,它会调用Person对象的walk()方法,因为w1正在引用一个Person对象。当您在w2上调用walk()方法时,它会调用Duck对象的walk()方法,因为w2正在引用一个Duck对象。当您使用接口类型的引用变量调用方法时,它会调用它所引用的对象上的方法。有了这些关于接口的知识,让我们来修改你的Walkables类的代码。清单 21-5 包含了修改后的代码。请注意,在修改后的letThemWalk()方法代码中,您所要做的就是将参数类型从Person更改为Walkable。其他一切都保持不变。

// Walkables.java
package com.jdojo.interfaces;
public class Walkables {
    public static void letThemWalk(Walkable[] list) {
        for (Walkable w : list) {
            w.walk();
        }
    }
}

Listing 21-5The Revised Walkables Class

清单 21-6 展示了如何用Walkable接口测试你修改过的类。它创建了一个Walkable类型的数组。允许声明接口类型的数组,因为数组提供了创建许多相同类型变量的快捷方式。这一次,您可以将一个Walkable类型的数组中的Person类和Duck类的对象传递给Walkables类的letThemWalk()方法,这样大家就可以一起走了,如输出所示。

// WalkablesTest.java
package com.jdojo.interfaces;
public class WalkablesTest {
    public static void main(String[] args) {
        Walkable[] w = new Walkable[3];
        w[0] = new Person("Jack");
        w[1] = new Duck("Jeff");
        w[2] = new Person("John");
        // Let everyone walk
        Walkables.letThemWalk(w);
    }
}
Jack (a person) is walking.
Jeff (a duck) is walking.
John (a person) is walking.

Listing 21-6A Test Class to Test the Revised Person, Duck, and Walkables Classes

如果你想创建一个名为Cat的新类,它的对象应该具有行走能力,那么你现有的代码会有什么变化?您可能会惊讶地发现,您不需要更改现有代码中的任何内容。Cat类应该实现Walkable接口,仅此而已。清单 21-7 包含了Cat类的代码。

// Cat.java
package com.jdojo.interfaces;
public class Cat implements Walkable {
    private String name;
    public Cat(String name) {
        this.name = name;
    }
    public void walk() {
        System.out.println(name + " (a cat) is walking.");
    }
}

Listing 21-7A Cat Class

您可以使用以下代码片段用现有代码测试新的Cat类。查看输出,您已经通过使用Walkable接口使人、鸭子和猫一起行走!这是接口在 Java 中的用途之一——它让您将原本不相关的类放在一个保护伞下:

Walkable[] w = new Walkable[4];
w[0] = new Person("Jack");
w[1] = new Duck("Jeff");
w[2] = new Person("John");
w[3] = new Cat("Jace");
// Let everyone walk
Walkables.letThemWalk(w);
Jack (a person) is walking.
Jeff (a duck) is walking.
John (a person) is walking.
Jace (a cat) is walking.

您已经实现了使用接口构造使不同种类的对象一起行走的目标。那么到底什么是接口呢?

Java 中的接口定义引用类型来指定抽象概念。它由提供概念实现的类来实现。在 Java 8 之前,接口只能包含抽象方法。Java 17 允许一个接口拥有静态、私有和默认的方法,这些方法也可以包含实现。但是,接口不能有非最终变量。接口让你通过抽象的概念定义不相关的类之间的关系。在我们的例子中,Walkable接口代表了一个概念,使您能够以相同的方式对待两个不相关的类PersonDuck,因为它们都实现了相同的概念(行走)。

是时候详细了解如何在 Java 程序中创建和使用接口了。当我们讨论接口的技术细节时,我们也回顾了接口的正确使用和常见误用。

声明接口

接口可以声明为顶级接口、嵌套接口或注释类型。我们将在本章后面讨论嵌套接口。注释类型的接口将在本系列的第二卷中讨论。我们使用术语接口来表示顶级接口。声明接口的一般(不完整)语法如下:

[modifiers] interface <interface-name> {
    <constant-declaration>
    <method-declaration>
    <nested-type-declaration>
}

接口声明以可选的修饰符列表开始。像类一样,接口可以有公共或包级范围。关键字public用于表示接口具有公共范围。可以从应用程序的任何地方引用公共接口。跨模块引用一个接口取决于模块可访问性规则,如第十章所讨论的。缺少范围修饰符表示接口具有包级别的范围。具有包级范围的接口只能在其包的成员中引用。

关键字interface用于声明一个接口。关键字后面是接口的名称。接口的名称是有效的 Java 标识符。接口体跟在它的名字后面,名字放在大括号内。接口的成员是在主体内部声明的。在特殊情况下,接口体可以是空的。下面是最简单的接口声明:

package com.jdojo.interfaces;
interface Updatable {
    // The interface body is empty
}

这段代码声明了一个名为Updatable的接口,它有一个包级别的作用域。它只能在com.jdojo.interfaces包内使用,因为它有包级范围。它不包含任何成员声明。

像类一样,接口有一个简单的名称和一个完全限定的名称。关键字接口后面的标识符是它的简单名称。接口的完全限定名是由它的包名和用点分隔的简单名组成的。在前面的例子中,Updatable是简单名称,com.jdojo.interfaces.Updatable是完全限定名称。使用简单和完全限定的接口名称的规则与使用类名称的规则相同。

下面的代码声明了一个名为ReadOnly的接口。它有一个公共范围。也就是说,ReadOnly接口的定义在同一个模块或其他模块中的任何地方都是可用的,这取决于模块可访问性规则:

package com.jdojo.interfaces;
public interface ReadOnly {
    // The interface body is empty
}

接口声明是隐式抽象的。您可以如下声明UpdatableReadOnly接口,而不改变它们的含义。换句话说,一个接口声明总是abstract,不管你是否显式声明它abstract:

abstract interface Updatable {
    // The interface body is empty
}
public abstract interface ReadOnly {
    // The interface body is empty
}

Note

Java 中的接口是隐式的abstract。在它们的声明中使用关键字abstract已经过时,不应该在新程序中使用。前面的例子仅用于说明目的。

声明接口成员

一个接口可以有三种类型的成员:

  • 常量字段

  • 抽象、静态、私有和默认方法

  • 静态类型(嵌套接口和类)

注意,接口声明很像类声明,除了接口不能有可变的实例和类变量。与类不同,接口不能被实例化。接口的所有成员都是隐式公共的。

Tip

直到 Java 8,接口中所有类型的成员都是隐式公共的。Java 9 和更高版本允许你在一个接口中拥有私有方法,这一点我们将在本章后面讨论。

常量字段声明

你可以在一个接口中声明常量字段,如清单 21-8 所示。它声明了一个名为Choices的接口,该接口有两个int字段的声明:YESNO

// Choices.java
package com.jdojo.interfaces;
public interface Choices {
    public static final int YES = 1;
    public static final int NO = 2;
}

Listing 21-8Declaring Fields in an Interface

一个接口中的所有字段都是隐式的publicstaticfinal。尽管接口声明语法允许在字段声明中使用这些关键字,但它们的使用是多余的。建议在接口中声明字段时不要使用这些关键字。Choices接口可以声明如下,不改变其含义:

public interface Choices {
    int YES = 1;
    int NO = 2;
}

您可以使用如下形式的点符号来访问界面中的字段:

<interface-name>.<field-name>

您可以使用Choices.YESChoices.NO来访问Choices界面中YESNO字段的值。清单 21-9 展示了如何使用点符号来访问接口的字段。

// ChoicesTest.java
package com.jdojo.interfaces;
public class ChoicesTest {
    public static void main(String[] args) {
        System.out.println("Choices.YES = " + Choices.YES);
        System.out.println("Choices.NO = " + Choices.NO);
    }
}
Choices.YES = 1
Choices.NO = 2

Listing 21-9Accessing Fields of an Interface

无论关键字final是否在声明中使用,接口中的字段总是final。这意味着您必须在声明时初始化字段。可以用编译时或运行时常量表达式初始化字段。因为一个final字段(常量字段)只被赋值一次,所以你不能设置一个接口的字段的值,除非在它的声明中。以下代码片段会生成编译时错误:

Choices.YES = 5; // A compile-time error

以下代码片段显示了接口的一些有效和无效字段声明:

/* All fields declarations are valid in the ValidFields interface */
public interface ValidFields {
    int X = 10;
    // You can use one field to initialize another if the referenced
    // field is declared before the one that references it.
    int Y = X;
    double N = X + 10.5;
    boolean YES = true;
    boolean NO = false;
    // Assuming Test is a class that exists
    Test TEST = new Test();
}
/* Examples of invalid field declarations. */
public interface InvalidFields {
    int X;      // Invalid. X is not initialized
    int Y = Z;  // Invalid. Forward referencing of Z is not allowed.
    int Z = 10; // Valid by itself.
    Test TEST;  // Invalid. TEST is not initialized, assuming a Test class exists
}

Tip

在接口中的字段名称中使用全部大写字母来表示它们是常量是一种约定。然而,Java 对接口字段的命名没有任何限制,只要它们遵循标识符的命名规则。一个接口的字段总是public。然而,从声明包外部对public字段的可访问性取决于接口的范围。例如,如果一个接口被声明为具有包级范围,那么它的字段在包外是不可访问的,因为接口本身在包外是不可访问的,即使它的字段是public

建议您不要声明一个只有常量字段的接口。接口的正确(也是最常用的)用法是声明一组定义 API 的方法。如果你想在一个构造中组合常量,使用枚举,而不是接口。如果不能使用枚举,则使用类来声明常量。使用枚举为常量提供类型安全和编译时检查。枚举包含在第二十二章中。

方法声明

您可以在接口中声明四种类型的方法:

  • 抽象方法

  • 静态方法

  • 默认方法

  • 私有方法

在 Java 8 之前,你只能在接口中声明abstract方法。修饰符staticdefaultprivate分别用于声明静态、默认和私有方法。缺少一个staticdefaultprivate修饰符会产生一个方法abstract。下面是一个包含所有四种类型方法的接口示例:

interface AnInterface {
    // An abstract method
    int m1();
    // A static method
    static int m2() {
    // The method implementation goes here
    }
    // A default method
    default int m3() {
    // The method implementation goes here
    }
    // A private method
    private int m4() {
    // The method implementation goes here
    }
}

以下部分详细讨论了每个方法类型声明。

抽象方法声明

声明接口的主要目的是通过声明零个或多个抽象方法来创建一个抽象规范(或概念)。接口中的所有方法声明都是隐式抽象和公共的,除非它们被声明为staticdefault。像在类中一样,接口中的abstract方法没有实现。abstract方法的主体总是用分号表示,而不是用一对大括号表示。下面的代码片段声明了一个名为Player的接口:

public interface Player {
    public abstract void play();
    public abstract void stop();
    public abstract void forward();
    public abstract void rewind();
}

Player接口有四种方法:play()stop()forward()rewind()Player接口是音频/视频播放器的规范。一个真正的播放器,例如 DVD 播放器,将通过实现Player接口的所有四个方法来提供规范的具体实现。

在接口的方法声明中使用abstractpublic关键字是多余的,即使编译器允许,因为接口中的方法是隐式抽象和公共的。在不改变其含义的情况下,前面的Player接口声明可以重写如下:

public interface Player {
    void play();
    void stop();
    void forward();
    void rewind();
}

接口中的抽象方法声明可能包括参数、返回类型和一个throws子句。下面的代码片段声明了一个ATM接口。它声明了四个方法。如果账户信息错误,login()方法抛出一个AccountNotFoundException。当用户试图提取一笔金额时,withdraw()方法抛出一个InsufficientBalanceException,这会将余额减少到低于所需最小余额的金额:

public interface ATM {
    boolean login(int account) throws AccountNotFoundException;
    boolean deposit(double amount);
    boolean withdraw(double amount) throws InsufficientBalanceException;
    double getBalance();
}

接口的抽象方法由实现该接口的类继承,类重写它们以提供实现。这意味着接口中的抽象方法不能被声明为 final,因为方法声明中的关键字final表示该方法是 final,并且不能被覆盖。然而,一个类可以声明一个接口 final 的重写方法,表明子类不能重写该方法。

静态方法声明

让我们参考清单 21-5 中显示的Walkables类的代码。它是一个包含名为letThemWalk()的静态方法的实用程序类。在 Java 8 之前,创建这样一个实用程序类来提供使用接口的静态方法是很常见的。你会在 Java 库中找到许多接口/实用程序类对,例如,Collection/CollectionsPath/PathsChannel/ChannelsExecutor/Executors等。遵循这个约定,您将您的接口/实用程序类对命名为Walkable/Walkables。Java 设计者意识到了额外的实用程序类和接口的必要性。在 Java 8 中,接口中可以有静态方法。静态方法的声明包含static修饰符。它们是隐式公共的。您可以重新定义Walkable接口,如清单 21-10 所示,以包含letThemWalk()方法并完全去掉Walkables类。

// Walkable.java
package com.jdojo.interfaces;
public interface Walkable {
    // An abstract method
    void walk();
    // A static convenience method
    public static void letThemWalk(Walkable[] list) {
        for (Walkable w : list) {
            w.walk();
        }
    }
}

Listing 21-10The Revised Walkable Interface with an Additional Static Convenience Method

您可以使用点标记法来使用接口的静态方法:

<interface-name>.<static-method>

下面的代码片段调用了Walkable.letThemWalk()方法:

Walkable[] w = new Walkable[4];
w[0] = new Person("Jack");
w[1] = new Duck("Jeff");
w[2] = new Person("John");
w[3] = new Cat("Jace");
// Let everyone walk
Walkable.letThemWalk(w);
Jack (a person) is walking.
Jeff (a duck) is walking.
John (a person) is walking.
Jace (a cat) is walking.

与类中的静态方法不同,接口中的静态方法不会被实现的类或子接口继承。从另一个接口继承的接口称为子接口。只有一种方法可以调用接口的静态方法:使用接口名。必须使用I.m()调用接口I的静态方法m()。您可以使用方法的非限定名m()来调用它,只在接口体中或者当您使用静态import语句导入方法时。

默认方法声明

接口中的默认方法是用修饰符default声明的。默认方法为实现接口的类提供方法的默认实现,但不重写默认方法。

默认方法是在 Java 8 中引入的。在 Java 8 之前,接口只能有抽象方法。为什么在 Java 8 中增加了默认方法?简而言之,添加它们是为了让现有的接口可以发展。在这一点上,答案可能很难理解。让我们看一个例子来澄清这一点。

假设在 Java 8 之前,您想为可移动对象创建一个规范来描述它们在 2D 平面中的位置。让我们通过创建一个名为Movable的接口来创建规范,如清单 21-11 所示。

// Movable.java
package com.jdojo.interfaces;
public interface Movable {
    void setX(double x);
    void setY(double y);
    double getX();
    double getY();
}

Listing 21-11A Movable Interface

该接口声明了四个抽象方法。setX()setY()方法让Movable使用绝对定位改变位置。getX()getY()方法根据xy坐标返回当前位置。

考虑清单 21-12 中的Pen类。它实现了Movable接口,并且作为规范的一部分,它为接口的四个方法提供了实现。该类包含两个实例变量,名为xy,用于跟踪笔的位置。

// Pen.java
package com.jdojo.interfaces;
public class Pen implements Movable {
    private double x;
    private double y;
    public Pen() {
        // By default, the pen is at (0.0, 0.0)
    }
    public Pen(double x, double y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public void setX(double x) {
        this.x = x;
    }
    @Override
    public void setY(double y) {
        this.y = y;
    }
    @Override
    public double getX() {
        return x;
    }
    @Override
    public double getY() {
        return y;
    }
    @Override
    public String toString() {
        return "Pen(" + x + ", " + y + ")";
    }
}

Listing 21-12A Pen Class That Implements the Movable Interface

以下代码片段使用了Movable接口和Pen类:

// Create a Pen and assign its reference to a Movable variable
Movable p1 = new Pen();
System.out.println(p1);
// Move the Pen
p1.setX(10.0);
p1.setY(5.0);
System.out.println(p1);
Pen(0.0, 0.0)
Pen(10.0, 5.0)

到目前为止,Movable接口和Pen类没有什么特别之处。假设Movable接口是你开发的库的一部分。您已经将该库分发给您的客户。客户已经在他们的类中实现了Movable接口。

现在故事发生了转折。一些客户要求Movable界面包含使用相对坐标改变位置的规范。他们希望您向Movable接口添加一个move()方法,如下所示。请求的部分以粗体显示:

public interface Movable {
    void setX(double x);
    void setY(double y);
    double getX();
    double getY();
    void move(double deltaX, double deltaY);
}

你是一个很好的商人;你总是想要一个快乐的顾客。你满足了顾客的要求。您进行更改并重新分发新版本的库。几小时后,你接到几个愤怒的顾客打来的电话。他们很生气,因为新版本的库破坏了他们现有的代码。我们来分析一下哪里出了问题。

在 Java 8 之前,接口中的所有方法都是隐式抽象的。因此,新方法move()是一个抽象方法。所有实现Movable接口的类都必须提供新方法的实现。注意,客户已经有了几个类,例如,Pen类,它实现了Movable接口。除非将新方法添加到这些类中,否则所有这些类都不会再编译。这个故事的寓意是,在 Java 8 之前,如果不破坏现有的代码,就不可能在发布给公众的接口上添加方法。

Java 库已经发布了数百个接口,这些接口被世界各地的客户使用了数千次。Java 设计者迫切需要一种在不破坏现有代码的情况下改进现有接口的方法。他们探索了几种解决方案。默认方法是演化接口的公认解决方案。默认方法可以添加到现有接口中。它为方法提供了默认实现。所有实现接口的类都将继承默认实现,因此不会破坏它们。类可以选择重写默认实现。

使用关键字default声明默认方法。默认方法不能声明为抽象或静态。它必须提供一个实现。否则,会发生编译时错误。清单 21-13 显示了Movable接口的修改代码。它包含一个名为move()的默认方法,该方法是根据现有的四种方法定义的。

// Movable.java
package com.jdojo.interfaces;
public interface Movable {
    void setX(double x);
    void setY(double y);
    double getX();
    double getY();
    // A default method
    default void move(double deltaX, double deltaY) {
        double newX = getX() + deltaX;
        double newY = getY() + deltaY;
        setX(newX);
        setY(newY);
    }
}

Listing 21-13The Movable Interface with a Default Method

任何实现了Movable接口的现有类,包括Pen类,都将像以前一样继续编译和工作。新的move()方法及其默认实现可用于所有这些类。清单 21-14 展示了MovablePen类接口的新旧方法。

// MovableTest.java
package com.jdojo.interfaces;
public class MovableTest {
    public static void main(String[] args) {
        // Create a Pen and assign its reference to a Movable variable
        Movable p1 = new Pen();
        System.out.println(p1);
        // Move the Pen using absolute coordinates
        p1.setX(10.0);
        p1.setY(5.0);
        System.out.println(p1);
        // Move the Pen using relative coordinates
        p1.move(5.0, 2.0);
        System.out.println(p1);
    }
}
Pen(0.0, 0.0)
Pen(10.0, 5.0)
Pen(15.0, 7.0)

Listing 21-14Testing the New Movable Interface with the Existing Pen Class

默认方法的另一个常见用途是在接口中声明可选方法。考虑一个Named接口,如清单 21-15 所示。

// Named.java
package com.jdojo.interfaces;
public interface Named {
    void setName(String name);
    default String getName() {
        return "John Doe";
    }

    default void setNickname(String nickname) {
        throw new UnsupportedOperationException("setNickname");
    }
    default String getNickname() {
        throw new UnsupportedOperationException("getNickname");
    }
}

Listing 21-15A Named Interface Using Default Methods to Provide Optional Methods

该接口提供了获取和设置正式名称和昵称的规范。不是所有的东西都有昵称。该接口提供了获取和设置昵称的方法作为默认方法,使它们成为可选方法。如果一个类实现了Named接口,它可以覆盖setNickname()getNickname()方法来提供实现,如果这个类支持昵称的话。否则,该类不必为这些方法做任何事情。它们只是抛出一个运行时异常来表明它们不受支持。该接口将getName()方法声明为默认方法,并通过返回"John Doe"作为默认名称来为其提供一个合理的默认实现。实现Named接口的类应该覆盖getName()方法以返回真实名称。

就默认方法给 Java 语言带来的好处和能力而言,这只是冰山一角。它赋予了现有 Java APIs 新的生命。在 Java 8 中,默认方法被添加到 Java 库中的几个接口中,以便为现有的 API 提供更多的表达能力和功能。

类中的具体方法和接口中的默认方法有什么异同?

  • 两者都提供了一个实现。

  • 两者都以相同的方式访问关键字this。也就是说,关键字this是在其上调用方法的对象的引用。

  • 主要区别在于对对象状态的访问。类中的具体方法可以访问该类的实例变量。但是,默认方法不能访问实现该接口的类的实例变量。默认方法可以访问接口的其他成员,例如,其他方法、常量和类型成员。例如,Movable接口中的默认方法是使用其他成员方法getX()getY()setX()setY()编写的。

  • 不用说,这两种类型的方法都可以使用它们的参数。

  • 两种方法都可以有一个throws子句。

我们还没有使用完默认方法。我们将很快讨论它们在继承中的作用。

接口中的私有方法

JDK 8 为接口引入了静态和默认方法。如果您必须在这些方法中多次执行相同的逻辑,您别无选择,只能重复该逻辑或将该逻辑移动到另一个类来隐藏实现。考虑名为Alphabet的接口,如清单 21-16 所示。

// Alphabet.java
package com.jdojo.interfaces;
public interface Alphabet {
    default boolean isAtOddPos(char c) {
        if (!Character.isLetter(c)) {
            throw new RuntimeException("Not a letter: " + c);
        }
        char uc = Character.toUpperCase(c);
        int pos = uc - 64;
        return pos % 2 == 1;
    }
    default boolean isAtEvenPos(char c) {
        if (!Character.isLetter(c)) {
            throw new RuntimeException("Not a letter: " + c);
        }
        char uc = Character.toUpperCase(c);
        int pos = uc - 64;
        return pos % 2 == 0;
    }
}

Listing 21-16An Interface Named Alphabet Having Two Default Methods Sharing Logic

isAtOddpos()isAtEvenPos()方法检查指定的字符在字母顺序上是奇数还是偶数,假设我们只处理英文字母。该逻辑假设'A''a'在位置 1,'B''b'在位置 2,依此类推。注意,两种方法中的逻辑仅在return语句中有所不同。除了最后的语句之外,这些方法的整体是相同的。我们需要重构这个逻辑。理想的情况是将公共逻辑转移到另一个方法中,并从两个方法中调用新方法。然而,你不希望在 JDK 8 中这样做,因为接口只支持公共方法。这样做会使第三种方法公开化,从而暴露给外界,这是你不想做的。

Java 9 帮了大忙,它允许你在接口中声明私有方法。清单 21-17 显示了使用私有方法的Alphabet接口的重构版本,该私有方法包含两个方法使用的公共逻辑。这一次,我们将接口命名为AlphabetJdk9,只是为了确保我可以在源代码中包含两个版本。这两种现有的方法变成了一行程序。

// AlphabetJdk9.java
package com.jdojo.interfaces;
public interface AlphabetJdk9 {
    default boolean isAtOddPos(char c) {
        return getPos(c) % 2 == 1;
    }
    default boolean isAtEvenPos(char c) {
        return getPos(c) % 2 == 0;
    }
    private int getPos(char c) {
        if (!Character.isLetter(c)) {
            throw new RuntimeException("Not a letter: " + c);
        }
        char uc = Character.toUpperCase(c);
        int pos = uc - 64;
        return pos;
    }
}

Listing 21-17An Interface Named AlphabetJdk9 That Uses a Private Method

在 JDK 9 之前,接口中的所有方法都是隐式公共的。记住这些适用于所有 Java 程序的简单规则:

  • 一个private方法没有被继承,因此不能被覆盖。

  • 不能覆盖final方法。

  • 一个abstract方法被继承并且意味着被覆盖。

  • 一个default方法是一个实例方法,并提供一个默认的实现;它可以被覆盖。

在接口中声明方法时,您需要遵循一些规则。不支持所有修饰符组合— abstractpublicprivatestaticfinal—因为它们没有意义。表 21-1 列出了接口方法声明中支持和不支持的修饰符组合。注意在接口的方法声明中不允许使用final修饰符。根据这个列表,您可以在一个接口中拥有一个私有方法,它可以是非抽象的、非默认的实例方法,也可以是静态方法。

表 21-1

接口中方法声明中支持的修饰符

|

修饰语

|

支持?

|

描述

public static 从 JDK 8 开始支持。
public abstract 从 JDK 1 开始支持。
public default 从 JDK 8 开始支持。
private static 从 JDK 9 开始支持。
Private 从 JDK 9 开始支持。这是非抽象的实例方法。
private abstract 这种组合没有意义。私有方法不是继承的,所以它不能被重写,而抽象方法必须被重写才能使用。
private default 这种组合没有意义。私有方法不被继承,所以它不能被重写,而默认方法则意味着在需要时被重写。

嵌套类型声明

接口中的嵌套类型声明定义了一个新的引用类型。您可以将类、接口、枚举和批注声明为嵌套类型。我们还没有讨论 enum 和 annotation,所以我们将在本节中讨论嵌套接口和类。在接口内部声明的接口/类称为嵌套接口/类。

接口和类定义新的引用类型,嵌套接口和嵌套类也是如此。有时一个类型作为嵌套类型更有意义。假设您有一个ATM接口,并且您想要定义另一个名为ATMCard的接口。ATMCard接口可以定义为ATM的顶层接口或嵌套接口。由于ATM卡与ATM一起使用,将ATMCard定义为ATM接口的嵌套接口可能更有意义。因为您将ATMCard定义为ATM的嵌套接口,所以您也可以将"ATM"从其名称中删除,您可以将其命名为Card,如图所示:

public interface ATM {
    boolean login(int account) throws AccountNotFoundException;
    boolean deposit(double amount);
    boolean withdraw(double amount) throws InsufficientFundsException;
    double getBalance();
    // Card is a nested interface. You can omit the keywords public and static.
    public static interface Card {
        String getNumber();
        String getSecurityCode();
        LocalDate getExpirationDate();
        String getCardHolderName();
    }
}

嵌套接口总是通过其封闭接口来访问。在前面的代码片段中,ATM是一个顶级接口(或者简单地说是一个接口),而Card是一个嵌套接口。ATM接口也被称为Card接口的封闭接口。ATMCard接口的全限定名分别是com.jdojo.interfaces.ATMcom.jdojo.interfaces.ATM.Card。所有嵌套类型都是隐式公共和静态的。前面的代码片段使用了关键字publicstatic来声明ATMCard接口,这是多余的。

也可以在接口中声明嵌套类。除非您知道如何实现接口,否则您可能无法理解本节中描述的嵌套类的用法,下一节将对此进行描述。下面的讨论是为了完成关于接口嵌套类型的讨论。在阅读了接下来几节中关于如何实现接口的内容后,您可以再次阅读本节。

在接口中声明嵌套类并不常见。但是,如果您发现一个接口将嵌套类声明为其成员,您应该不会感到惊讶。在一个接口中有一个嵌套类有什么好处?这样做只有一个好处,就是更好地组织相关实体:接口和类。假设您想开发一个Job界面,让用户向作业调度器提交作业。以下是Job接口的代码:

public interface Job {
    void runJob();
}

假设每个部门必须每天提交一个作业,即使他们没有要运行的东西。它表明,有时你需要一份空工作或一份无事可做的工作。接口Job的开发者可能会提供一个常量,它代表清单 21-18 中列出的Job接口的一个简单实现。它有一个名为EmptyJob的嵌套类,实现了封闭的Job接口。

// Job.java
package com.jdojo.interfaces;
public interface Job {
    // A nested class
    class EmptyJob implements Job {
        private EmptyJob() {
            // Do not allow outside to create its object
        }
        @Override
        public void runJob() {
            System.out.println("Nothing serious to run...");
        }
    }
    // A constant field
    Job EMPTY_JOB = new EmptyJob();
    // An abstract method
    void runJob();
}

Listing 21-18The Job Interface with a Nested Class and a Constant Field

如果一个部门没有要提交的有意义的作业,它可以使用Job.EMPTY_JOB常量作为作业。EmptyJob类的全限定名是com.jdojo.interfaces.Job.EmptyJob。注意,封闭接口JobEmptyJob类提供了一个额外的名称空间。在Job接口内部,EmptyJob类可以通过它的简单名称EmptyJob来引用。但是在Job接口之外,必须简称为Job.EmptyJob。您可能会注意到一个普通的作业对象由Job.EMPTY_JOB常量表示。您已经将EmptyJob嵌套类的构造器设为私有,因此接口之外的任何人都不能创建它的对象。清单 21-19 展示了如何使用这个类。通常,在这种情况下,您会将Job.EmptyJob类的构造器设为私有,这样它的对象就不能在Job接口之外创建,因为EMPTY_JOB常量已经提供了这个类的一个对象。

// JobTest.java
package com.jdojo.interfaces;
public class JobTest {
    public static void main(String[] args) {
        submitJob(Job.EMPTY_JOB);
    }
    public static void submitJob(Job job) {
        job.runJob();
    }
}
Nothing serious to run...

Listing 21-19A Test Program to Test the Job Interface and Its Nested EmptyJob Class

接口定义了一个新的类型

接口定义了一个新的引用类型。您可以在任何可以使用引用类型的地方使用接口类型。例如,您可以使用接口类型来声明变量(实例、静态或局部),或者在方法中声明参数类型,作为方法的返回类型,等等。

考虑下面名为Swimmable的接口声明,它声明了一个方法swim(),如清单 21-20 所示。清单 21-21 中的SwimmableTest类展示了如何使用Swimmable接口作为参考数据类型。

// SwimmableTest.java
package com.jdojo.interfaces;
public class SwimmableTest {
    // Interface type to define instance variable
    private Swimmable iSwimmable;
    // Interface type to define parameter type for a constructor
    public SwimmableTest(Swimmable aSwimmable) {
        this.iSwimmable = aSwimmable;
    }
    // Interface type to define return type of a method
    public Swimmable getSwimmable() {
        return this.iSwimmable;
    }
    // Interface type to define parameter type for a method
    public void setSwimmable(Swimmable newSwimmable) {
        this.iSwimmable = newSwimmable;
    }
    public void letItSwim() {
        // Interface type to declare a local variable
        Swimmable localSwimmable = this.iSwimmable;
        // An interface variable can be used to invoke any method
        // declared in the interface and the Object class
        localSwimmable.swim();
    }
}

Listing 21-21A Test Class That Demonstrates the Use of an Interface Type as a Variable Type

// Swimmable.java
package com.jdojo.interfaces;
public interface Swimmable {
    void swim();
}

Listing 21-20The Declaration for a Swimmable Interface

SwimmableTest类以多种方式使用由Swimmable接口定义的新类型。该类的目的只是演示如何使用一个新类型的接口。它使用Swimmable接口作为类型来声明以下内容:

  • 名为iSwimmable的实例变量。

  • 为其构造器命名为aSwimmable的参数。

  • getSwimmable()方法的返回类型。

  • 为其setSwimmable()方法命名为newSwimmable的参数。

  • 在其letItSwim()方法内名为localSwimmable的局部变量。在方法内部,您可以直接在实例变量iSwimmable上调用swim()。我们使用局部变量只是为了证明接口类型可以用在任何可以使用类型的地方。

此时,需要回答两个关于接口的问题:

  • 接口类型的变量指的是内存中的什么对象?

  • 你能用一个接口类型的变量做什么?

因为接口定义了引用类型,那么接口类型的变量引用内存中的什么对象呢?让我们用一个例子来展开这个问题。您有一个Swimmable接口,您可以声明一个类型为Swimmable的引用变量,如下所示:

Swimmable sw;

此时变量sw的值是多少?引用数据类型的变量引用内存中的对象。准确的说,让我们把问题重新表述为“内存中的什么对象sw指的是?”在这一点上,你不能完全回答这个问题。不完整且无法解释的答案是,接口类型的变量指的是内存中的对象,该对象的类实现了该接口。当我们在下一节中讨论实现接口时,答案将会更加清晰。您不能创建接口类型的对象。接口是隐式抽象的,它没有构造器。也就是说,不能使用带有 new 运算符的接口类型来创建对象。以下代码无法编译:

Swimmable sw2 = new Swimmable(); // A compile-time error

在这个语句中,使用new操作符会导致编译时错误,而不是Swimmable sw2部分。Swimmable sw2部分是变量声明,是有效的。

但是,有一点是肯定的:一个接口类型的变量可以引用内存中的一个对象。该场景如图 21-1 所示。

img/323069_3_En_21_Fig1_HTML.png

图 21-1

引用内存中对象的可游泳类型变量(sw)

我们来回答第二个问题。你能用一个接口类型的变量做什么?引用类型变量的所有规则同样适用于接口类型的变量。您可以对引用类型的变量做以下几件重要的事情:

  • 您可以在内存中分配一个对象的引用,包括一个null引用值:

  • 您可以使用接口类型的变量或直接使用接口名称来访问接口中声明的任何常量字段。最好使用接口名来访问接口的常量。考虑带有两个常量YESNOChoices接口。您可以使用接口的简单名称Choices.YESChoices.NO并使用接口引用变量sw2.YESsw2.NO来访问这两个常量的值。

  • 您可以使用接口类型的变量来调用接口中声明的任何方法。例如,Swimmable类型的变量可以调用swim()方法,如下所示:

Swimmable sw2 = null;

  • 接口类型的变量可以调用Object类的任何方法。这个规律不是很明显。然而,如果你仔细想想,这是一个非常简单而重要的规则。接口类型的变量可以引用内存中的对象。无论它引用内存中的什么对象,该对象总是属于类类型。Java 中的所有类都必须将Object类作为它们的直接/间接父类。因此,Java 中的所有对象都可以访问Object类的所有方法。因此,允许一个接口类型的变量访问Object类的所有方法是合乎逻辑的。下面的代码片段使用一个Swimmable类型的变量调用了Object类的hashCode()getClass()toString()方法:
Swimmable sw3 = get an object instance of the Swimmable type...
sw3.swim();

  • 另一个需要记住的重要规则是,默认情况下,接口类型的实例或静态变量被初始化为null。与所有类型的局部变量一样,默认情况下,接口类型的局部变量不会初始化。在使用它之前,您必须显式地为它赋值。
Swimmable sw4 = get a Swimmable type object...
int hc = sw4.hashCode();
Class c = sw4.getClass();
String str = sw4.toString();

实现接口

接口定义了对象与其他对象通信方式的规范。规范是对象行为的契约或协议。理解两个术语规范(或合同)和实施的区别是非常重要的。规范是一组语句,而实现是这些语句的实现。

让我们举一个真实世界的例子。语句“杰克将在 2014 年 6 月 8 日给约翰十美元”是一个规范。当杰克在 2014 年 6 月 8 日给约翰十美元时,规范被执行。你可以重新表述为,当 2014 年 6 月 8 日杰克给约翰十美元时,规范实现了。有时,在讨论接口时,规范也被称为合同、协议、协定、计划或草案。无论你用哪个术语来指代一个规范,它总是抽象的。规范的实现可以是部分的,也可以是完整的。杰克可能在 2014 年 6 月 8 日给约翰七美元;而且,在这种情况下,规范还没有完全实现。

接口指定了一个对象在与其他对象交互时保证提供的协议。它根据抽象和默认方法来指定协议。规范是在某个时候由某人实现的,接口也是如此。接口是由类实现的。当一个类实现一个接口时,该类为该接口的所有抽象方法提供实现。类可以提供接口抽象方法的部分实现,在这种情况下,类必须声明自己是抽象的。

实现一个接口(或多个接口)的类使用一个implements子句来指定接口的名称。一个implements子句由关键字implements组成,后跟一个逗号分隔的接口类型列表。一个类可以实现多个接口。现在让我们关注一个只实现一个接口的类。实现接口的类声明的一般语法如下所示:

[modifiers] class <class-Name> implements <comma-separated-list-of-interfaces> {
    // Class body goes here
}

假设有一个Fish类:

public class Fish {
    // Code for Fish class goes here
}

现在,您想在Fish类中实现Swimmable接口。下面的代码显示了Fish类,并声明它实现了Swimmable接口:

public class Fish implements Swimmable {
    // Code for the Fish class goes here
}

粗体文本显示了更改后的代码。这个Fish类的代码不会被编译。一个类从它实现的接口继承所有的抽象和默认方法。因此,Fish类从Swimmable接口继承了抽象的swim()方法。如果一个类包含(继承的或声明的)抽象方法,它必须声明为抽象的。你还没有声明Fish类的抽象。这就是前面的声明无法编译的原因。在下面的代码中,Fish类覆盖了swim()方法以提供一个实现:

public class Fish implements Swimmable {
    //  Override and implement the swim() method
    @Override
    public void swim() {
        // Code for swim method goes here
    }
    // More code for the Fish class goes here
}

实现接口的类必须重写以实现接口中声明的所有抽象方法。否则,该类必须声明为抽象的。请注意,接口的默认方法也由实现类继承。实现类可以选择(但不是必需的)覆盖默认方法。接口中的静态方法不会被实现类继承。

实现接口的类可以有其他不从实现的接口继承的方法。其他方法可以与实现的接口中声明的方法具有相同的名称和不同数量和/或类型的参数。

在这种情况下,对Fish类的唯一要求是它必须有一个swim()方法,该方法不接受任何参数并返回在Swimmable接口中声明的void。下面的代码定义了Fish类中的两个swim()方法。第一个没有参数的方法实现了Swimmable接口的swim()方法。第二个,swim(double distanceInYards),与Fish类实现的Swimmable接口无关:

public class Fish implements Swimmable {
    // Override the swim() method in the Swimmable interface
    @Override
    public void swim() {
        // More code goes here
    }
    // A valid method for the Fish class. This method declaration has nothing to do
    // with the Swimmable interface's swim() method
    public void swim(double distanceInYards) {
        // More code goes here
    }
}

清单 21-22 显示了Fish类的完整代码。一个Fish对象将有一个名字,这个名字在它的构造器中提供。它实现了 Swimmable 接口的swim()方法,以在标准输出中打印一条消息。

// Fish.java
package com.jdojo.interfaces;
public class Fish implements Swimmable {
    private String name;
    public Fish(String name) {
        this.name = name;
    }
    @Override
    public void swim() {
        System.out.println(name + " (a fish) is swimming.");
    }
}

Listing 21-22Code for the Fish Class That Implements the Swimmable Interface

如何创建实现接口的类的对象?不管一个类是否实现了一个接口,你都可以用同样的方式创建一个类的对象(通过使用带有构造器的new操作符)。您可以创建一个Fish类的对象,如下所示:

// Create an object of the Fish class
Fish fifi = new Fish("Fifi");

当您执行语句new Fish("Fifi")时,它会在内存中创建一个对象,该对象的类型是Fish(由其类定义的类型)。当一个类实现一个接口时,它的对象就多了一个类型,就是被实现的接口定义的类型。在您的例子中,通过执行创建的对象有两种类型:FishSwimmable。事实上,它还有第三种类型,那就是Object类型,因为Fish类继承了Object类,这是默认情况下发生的。由于一个Fish类的对象有两种类型,即FishSwimmable,你可以将一个Fish对象的引用分配给一个Fish类型的变量以及一个Swimmable类型的变量。以下代码对此进行了总结:

Fish guppi = new Fish("Guppi");
Swimmable hilda = new Fish("Hilda");

变量guppi属于Fish类型。它指的是Guppi鱼的对象。在第一次赋值中没有什么惊奇的,一个Fish对象被赋值给一个Fish类型的变量。第二个赋值也是有效的,因为Fish类实现了Swimmable接口,并且Fish类的每个对象也是Swimmable类型。此时hilda是一个Swimmable类型的变量。它指的是 Hilda fish 对象。以下赋值始终有效:

// A Fish is always Swimmable
hilda = guppi;

但是,另一种方式是不成立的。将Swimmable类型的变量赋给Fish类型的变量会产生编译时错误:

// A Swimmable is not always a Fish
guppi = hilda; // A compile-time error

为什么前面的赋值会产生编译时错误?原因很简单。因为Fish类实现了Swimmable接口,所以Fish类的对象总是Swimmable。因为一个Fish类型的变量只能引用一个Fish对象,这个对象总是Swimmable,赋值hilda = guppi总是有效的。然而,Swimmable类型的变量可以引用其类实现了Swimmable接口的任何对象,不一定只引用Fish对象。例如,考虑一个类Turtle,它实现了Swimmable接口:

public class Turtle implements Swimmable {
    @Override
    public void swim() {
        System.out.println("A turtle can swim too!");
    }
}

您可以将一个Turtle类的对象分配给hilda变量:

hilda = new Turtle(); // OK. A Turtle is always Swimmable

如果此时允许赋值guppi = hilda,那么Fish变量guppi将指向Turtle对象!这将是一场灾难。Java 运行时会抛出一个异常,即使编译器允许这种赋值。这种赋值是不允许的,原因如下:

鱼总是可以游泳的。然而,并不是每个游泳者都是鱼。

假设您(以编程方式)确定Swimmable类型的变量包含对Fish对象的引用。如果你想把Swimmable类型的变量赋给Fish类型的变量,你可以通过使用类型转换来实现,如下所示:

// The compiler will pass it. The runtime may throw a ClassCastException.
guppi = (Fish)hilda;

编译器不会抱怨这个语句。它假设您已经确保了hilda变量引用了一个Fish对象,并且强制转换(Fish) hilda将在运行时成功。如果万一hilda变量没有引用Fish对象,Java 运行时将抛出一个ClassCastException。例如,下面的代码片段将通过编译器检查,但会抛出一个ClassCastException:

Fish fred = new Fish("Fred");
Swimmable turti = new Turtle();
// OK for the compiler, but not OK for the runtime. turti is a Turtle, not a Fish at
// runtime. fred can refer to only a Fish, not a Turtle
fred = (Fish) turti;

清单 21-23 展示了让您测试Fish类和Swimmable接口的简短而完整的代码。

// FishTest.java
package com.jdojo.interfaces;
public class FishTest {
    public static void main(String[] args) {
        Swimmable finny = new Fish("Finny");
        finny.swim();
    }
}
Finny (a fish) is swimming.

Listing 21-23Demonstrating That a Variable of an Interface Can Store the Reference of the Object of the Class Implementing the Interface

实现接口方法

当一个类完全实现一个接口时,它通过重写这些方法为接口的所有抽象方法提供一个实现。接口中的方法声明包括方法的约束(或规则)。例如,一个方法可以在其声明中声明一个throws子句。方法声明中的throws子句是该方法的约束。如果throws子句声明了一些检查过的异常,该方法的调用者必须准备好处理它们。接口中的方法是隐式公共的。这为方法定义了另一个约束,即接口的所有方法都可以公开访问,并假设接口本身可以公开访问。考虑一个Banker接口,定义如下:

public interface Banker {
    double withdraw(double amount) throws InsufficientFundsException;
    void deposit(double amount) throws FundLimitExceededException;
}

Banker接口声明了两个名为withdraw()deposit()的方法。考虑下面在MinimumBalanceBank类中Banker接口的实现。该类中被覆盖的方法具有与在Banker接口中定义的相同的约束。这两个方法都被声明为public,,并且都抛出了与在Banker接口中声明的相同的异常:

public class MinimumBalanceBank implements Banker {
    public double withdraw(double amount) throws InsufficientFundsException {
        // Code for this method goes here
    }
    public void deposit(double amount) throws FundLimitExceededException {
        // Code for this method goes here
    }
}

考虑下面的NoLimitBank类中Banker接口的实现。NoLimitBank规定客户可以无限透支(但愿这发生在现实中),并且余额没有上限。NoLimitBank在覆盖Banker接口的withdraw()deposit()方法时删除了throws子句:

public class NoLimitBank implements Banker {
    public double withdraw(double amount) {
        // Code for this method goes here
    }
    public void deposit(double amount) {
        // Code for this method goes here
    }
}

尽管覆盖了Banker接口方法的两个方法删除了throws子句,但是NoLimitBank的代码是有效的。当一个类重写一个接口方法时,删除约束(在这种情况下是异常)是允许的。throws子句中的异常强加了一个限制,即调用者必须处理异常。如果您使用Banker类型编写代码,下面是您调用withdraw()方法的方式:

Banker b = get a Banker type object...;
try {
    double amount = b.withdraw(1000.90);
    // More code goes here
} catch (InsufficientFundsException e) {
    // Handle the exception here
}

在编译时,当调用b.withdraw()方法时,编译器强迫你处理从withdraw()方法抛出的异常,因为它知道变量b的类型是Banker,而Banker类型的withdraw()方法抛出一个InsufficientFundsException。如果在前面的代码中将一个对象NoLimitBank赋给变量b,那么当调用b.withdraw()时,不会从NoLimitBank类的withdraw()方法中抛出异常,即使对withdraw()方法的调用是在try-catch块中。编译器无法检查变量b的运行时类型。其安全检查基于变量b的编译时类型。如果运行时抛出的异常量量比代码中预期的要少或者没有异常,这永远不会成为问题。考虑下面由UnstablePredictableBank类实现的Banker接口:

// The following code will not compile
public class UnstablePredictableBank implements Banker {
    public
double withdraw(double amount) throws InsufficientFundsException, ArbitraryException {
        // Code for this method goes here
    }
    public void deposit(double amount) throws FundLimitExceededException {
        // Code for this method goes here
    }
}

这一次,withdraw()方法添加了一个新的异常,ArbitraryException,它向被覆盖的方法添加了一个新的约束。绝不允许向被重写的方法添加约束。考虑以下代码片段:

Banker b = new UnstablePredictableBank();
try {
    double amount = b.withdraw(1000.90);
    // More code goes here
} catch (InsufficientFundsException e) {
    // Handle exception here
}

编译器不知道,在运行时,Banker类型的变量b将引用UnstablePredictableBank类型的对象。因此,当你调用b.withdraw()时,编译器会强迫你只处理InsufficientFundsException。运行时从withdraw()方法抛出ArbitraryException会发生什么?您的代码还没有准备好处理它。这就是为什么您不能向类中的方法声明添加新的异常,这将重写其实现的接口中的方法。

如果一个类重写了实现的接口的方法,该方法必须声明为公共的。回想一下,接口中的所有方法都是隐式公共的,public 是对方法限制最少的范围修饰符。将重写接口方法的类中的方法声明为私有、受保护或包级别,就像限制被重写方法的范围一样(就像放置更多约束)。由于withdraw()deposit()方法未声明为公共方法,以下代码片段将无法编译:

// Code would not compile
public class UnstablePredictableBank implements Banker{
    // withdraw() method must be public
    private double withdraw(double amount) throws InsufficientFundsException {
        // Code for this method goes here
    }
    // deposit() method must be public
    protected void deposit(double amount) throws FundLimitExceededException {
        // Code for this method goes here
    }
}

使用一般的经验法则来检查在类方法中是否允许添加或删除约束,这将重写接口的方法。使用分配给实现接口的类的对象的接口类型变量编写代码。如果代码对您有意义(当然,对编译器也有意义),它是允许的。否则是不允许的。假设J是一个接口,它声明了一个方法m1()。假设类C实现了接口J,并且修改了方法m1()的声明。如果下面的代码有意义并且可以编译,那么在类C中的m1()声明中的修改是正确的:

J obj = new C(); // Or any object of any subclass of C
obj.m1();

另一个经验法则是查看类中的重写方法是否放松了接口中为同一方法声明的限制。如果重写方法放松了被重写方法的约束,那就没问题。否则,编译器将生成错误。

实现多个接口

一个类可以实现多个接口。类实现的所有接口都列在类声明中的关键字implements之后。接口名称由逗号分隔。通过实现多个接口,该类同意为所有接口中的所有抽象方法提供实现。假设有两个名为AdderSubtractor的接口,声明如下:

public interface Adder {
    int add(int n1, int n2);
}
public interface Subtractor {
    int subtract(int n1, int n2);
}

如果一个ArithOps类实现了这两个接口,它的声明如下所示:

public class ArithOps implements Adder, Subtractor {
    // Override the add() method of the Adder interface
    @Override
    public int add(int n1, n2) {
        return n1 + n2;
    }
    // Override the subtract() method of the Subtractor interface
    @Override
    public int subtract(int n1, int n2) {
        return n1 - n2;
    }
    // Other code for the class goes here
}

一个类实现的接口的最大数量没有限制。当一个类实现一个接口时,它的对象获得一个额外的类型。如果一个类实现了多个接口,那么它的对象获得的新类型就和实现的接口一样多。考虑一下ArithOps类的对象,它可以通过执行new ArithOps()来创建。ArithOps类的对象获得了两个额外的类型,分别是AdderSubtractor。下面的代码片段显示了ArithOps类的对象获得两个新类型的结果。您可以将ArithOps的对象视为ArithOps类型、Adder类型或Subtractor类型。当然,Java 中的每个对象都可以被视为一个Object类型:

ArithOps a = new ArithOps();
Adder b = new ArithOps();
Subtractor c = new ArithOps();
b = a;
c = a;

让我们看一个更具体更完整的例子。您已经有了两个接口,WalkableSwimmable。如果一个类实现了Walkable接口,它必须提供walk()方法的实现。如果你希望一个类的对象被视为Walkable类型,这个类将实现Walkable接口。同样的理由也适用于Swimmable接口。如果一个类实现了两个接口WalkableSwimmable,那么它的对象可以被视为Walkable类型和Swimmable类型。该类必须做的唯一一件事是为walk()swim()方法提供实现。让我们创建一个Turtle类,它实现了这两个接口。一个Turtle物体将具有行走和游泳的能力。

清单 21-24 包含了Turtle类的代码。乌龟也会咬人。通过向Turtle类添加一个bite()方法,您已经向Turtle对象添加了这个行为。注意,将bite()方法添加到Turtle类与这两个接口的实现无关。实现接口的类可以拥有任意数量的自己的附加方法。

// Turtle.java
package com.jdojo.interfaces;
public class Turtle implements Walkable, Swimmable {
    private String name;
    public Turtle(String name) {
        this.name = name;
    }
    // Adding a bite() method to the Turtle class
    public void bite() {
        System.out.println(name + " (a turtle) is biting.");
    }
    // Implementation for the walk() method of the Walkable interface
    @Override
    public void walk() {
        System.out.println(name + " (a turtle) is walking.");
    }
    // Implementation for the swim() method of the Swimmable interface
    @Override
    public void swim() {
        System.out.println(name + " (a turtle) is swimming.");
    }
}

Listing 21-24A Turtle Class, Which Implements the Walkable and Swimmable Interfaces

清单 21-25 显示了使用一个Turtle对象作为Turtle类型、Walkable类型和Swimmable类型。

// TurtleTest.java
package com.jdojo.interfaces;
public class TurtleTest {
    public static void main(String[] args) {
        Turtle turti = new Turtle("Turti");
        // Using Turtle type as Turtle, Walkable and Swimmable
        letItBite(turti);
        letItWalk(turti);
        letItSwim(turti);
    }
    public static void letItBite(Turtle t) {
        t.bite();
    }
    public static void letItWalk(Walkable w) {
        w.walk();
    }
    public static void letItSwim(Swimmable s) {
        s.swim();;
    }
}
Turti (a turtle) is biting.
Turti (a turtle) is walking.
Turti (a turtle) is swimming.

Listing 21-25Using the Turtle Class

请注意,Turtle类型的变量可以访问所有三种方法— bite()walk()swim()—如下所示:

Turtle t = new Turtle("Turti");
t.bite();
t.walk();
t.swim();

当你使用一个Turtle对象作为Walkable类型时,你只能访问walk()方法。当您使用一个Turtle对象作为Swimmable类型时,您只能访问swim()方法。以下代码片段演示了这一规则:

Turtle t = new Turtle("Trach");
Walkable w = t;
w.walk(); // OK. Using w, you can access only the walk() method of Turtle object
Swimmable s = t;
s.swim(); // OK. Using s you can access only the swim() method

部分实现接口

一个类同意为它实现的接口的所有抽象方法提供一个实现。然而,一个类不必为所有方法提供实现。换句话说,一个类可以提供已实现接口的部分实现。回想一下,一个接口是隐式的abstract(意味着不完整)。如果一个类不提供接口的完整实现,它必须被声明为抽象的(意味着不完整)。否则,编译器将拒绝编译该类。考虑一个名为IABC的接口,它有三个方法— m1()m2()m3():

package com.jdojo.interfaces;
public interface IABC {
    void m1();
    void m2();
    void m3();
}

假设一个名为ABCImpl的类实现了IABC接口,但它没有为所有三个方法提供实现:

package com.jdojo.interfaces;
// A compile-time error
public class ABCImpl implements IABC {
    // Provides implementation for only one method of the IABC interface
    @Override
    public void m1() {
        // Code for the method goes here
    }
}

前面的ABCImpl类代码无法编译。它同意为IABC接口的所有三个方法提供实现。然而,阶级的主体并不遵守诺言。它只提供了一种方法的实现,m1()。因为类ABCImpl没有为IABC接口的另外两个方法提供实现,所以ABCImpl类是不完整的,它必须被声明为抽象的以表明它的不完整。如果试图编译ABCImpl类,编译器会产生以下错误:

Error(3,14): class com.jdojo.interfaces.ABCImpl should be declared abstract; it does not define method m2() of interface com.jdojo.interfaces.IABC
Error(3,14): class com.jdojo.interfaces.ABCImpl should be declared abstract; it does not define method m3() of interface com.jdojo.interfaces.IABC

编译器错误一清二楚。它声明ABCImpl类必须被声明为抽象的,因为它没有实现IABC接口的m2()m3()方法。以下代码片段通过声明抽象类来修复编译器错误:

package com.jdojo.interfaces;
public abstract class ABCImpl implements IABC {
    @Override
    public void m1() {
        // Code for the method goes here
    }
}

将一个类声明为抽象类意味着它不能被实例化。以下代码将生成编译时错误:

new ABCImpl(); // A compile-time error. ABCImpl is abstract

使用ABCImpl类的唯一方法是从它继承另一个类,并为IABC接口的m2()m3()方法提供缺失的实现。下面是一个新类DEFImpl的声明,它继承自ABCImpl类:

package com.jdojo.interfaces;
public class DEFImpl extends ABCImpl {
    // Other code goes here
    @Override
    public void m2() {
        // Code for the method goes here
    }
    @Override
    public void m3() {
        // Code for the method goes here
    }
}

DEFImpl类提供了ABCImpl类的m2()m3()方法的实现。注意,DEFImpl类从它的超类ABCImpl继承了m1()m2()m3()方法。编译器不再强迫你将DEFImpl类声明为抽象类。如果你愿意,你仍然可以声明DEFImpl类抽象。

您可以创建一个DEFImpl类的对象,因为它不是抽象的。DEFImpl类的对象有哪些类型?它有四种类型:DEFImplABCImplObjectIABCDEFImpl类的对象也是ABCImpl类型,因为DEFImpl继承自ABCImpl。因为ABCImpl实现了IABC接口,所以ABCImpl类的一个对象也属于IABC类型。既然一个DEFImpl是一个ABCImpl,一个ABCImpl是一个IABC,那么从逻辑上来说一个DEFImpl也是一个IABC。下面的代码片段演示了这条规则。一个DEFImpl类的对象被分配给DEFImplObjectABCImplIABC类型的变量:

DEFImpl d = new DEFImpl();
Object obj = d;
ABCImpl a = d;
IABC ia = d;

超类型-子类型关系

实现类的接口建立了超类型-子类型的关系。该类成为它实现的所有接口的子类型,所有接口成为该类的超类型。替换规则适用于这种超类型-子类型关系。替换的规则是子类型可以在任何地方替换它的父类型。考虑下面这个类C的类声明,它实现了三个接口JK,L:

public class C implements J, K, L {
    // Code for class C goes here
}

前面的代码在三个接口JKL与类C之间建立了超类型-子类型关系。回想一下,接口声明定义了一个新的类型。假设您已经声明了三个接口:JKL。三个接口声明定义了三种类型:类型J、类型K和类型L。类C的声明定义了第四种类型:类型CJKLC四种类型之间是什么关系?类别C是类型JKL的子类型;类型J是类型C的超类型;类型K是类型C的超类型;而类型L是类型C的超类型。这种超类型-子类型关系的含义是,只要需要类型JKL的值,就可以安全地用类型C的值来替代。以下代码片段演示了这一规则:

C cObject = new C();
// cObject is of type C. It can always be used where J, K or L type is expected.
J jobject = cObject; // OK
K kobject = cObject; // OK
L lobject = cObject; // OK

接口继承

一个接口可以从另一个接口继承。与类不同,一个接口可以从多个接口继承。考虑清单 21-26 到 21-28 中显示的SingerWriterPlayer接口。

// Player.java
package com.jdojo.interfaces;
public interface Player {
    void play();
    void setRate(double rate);
    default double getRate() {
        return 300.0;
    }
}

Listing 21-28A Player Interface

// Writer.java
package com.jdojo.interfaces;
public interface Writer {
    void write();
    void setRate(double rate);
    double getRate();
}

Listing 21-27A Writer Interface

// Singer.java
package com.jdojo.interfaces;
public interface Singer {
    void sing();
    void setRate(double rate);
    double getRate();
}

Listing 21-26A Singer Interface

所有这三种类型的专业人士(歌手、作家和演奏者)都各司其职,而且都有报酬。这三个接口包含两种类型的方法。一种方法表示他们所做的工作,例如,sing()write()play()。另一种方法表示他们的最低时薪。SingerWriter接口已经声明了setRate()getRate()方法是抽象的,让实现类指定它们的实现。Player接口声明了setRate()方法抽象,并为getRate()方法提供了默认实现。

就像一个类从另一个类继承一样,一个接口使用关键字extends从其他接口继承。关键字extends后面是逗号分隔的继承接口名称列表。继承的接口称为超级接口,继承它们的接口称为子接口。接口继承其超接口的下列成员:

  • 抽象和默认方法

  • 常量字段

  • 嵌套类型

    提示一个接口不从它的超接口继承静态或私有方法。

一个接口可以重写从它的超接口继承的抽象和默认方法。如果接口包含的常量字段和嵌套类型的名称与从超接口继承的常量字段和嵌套类型的名称相同,则接口中的常量字段和嵌套类型隐藏了它们各自的继承对应项的名称。

假设您想要创建一个接口来表示不收费的慈善歌手。慈善歌手也是歌手。您将创建一个名为CharitySinger的接口,它继承自Singer接口,如下所示:

public interface CharitySinger extends Singer {
}

此时,CharitySinger接口从Singer接口继承了三个抽象方法。任何实现CharitySinger接口的类都需要实现这三个方法。因为慈善歌手唱歌不收费,CharitySinger接口可能会覆盖setRate()getRate()方法,并使用清单 21-29 中所示的默认方法提供一个默认实现。

// CharitySinger.java
package com.jdojo.interfaces;
public interface CharitySinger extends Singer {
    @Override
    default void setRate(double rate) {
        // A no-op method
    }
    @Override
    default double getRate() {
        return 0.0;
    }
}

Listing 21-29A CharitySinger Interface

setRate()方法是一个空操作。getRate()方法返回零。实现CharitySinger接口的类需要实现sing()方法并为其提供一个实现。这个类将继承默认的方法setRate()getRate()

有可能同一个人既是歌手又是作家。你可以创建一个名为SingerWriter的接口,它继承了两个接口SingerWriter,如清单 21-30 所示。

// SingerWriter.java
package com.jdojo.interfaces;
public interface SingerWriter extends Singer, Writer {
    // No code
}

Listing 21-30A SingerWriter Interface That Inherits from Singer and Writer Interfaces

SingerWriter接口有多少方法?它从Singer接口继承了三个抽象方法,从Writer接口继承了三个抽象方法。它继承了方法setRate()getRate()两次——一次来自Singer接口,一次来自Writer接口。这些方法在两个超接口中有相同的声明,并且它们是抽象的。这不会引起问题,因为两种方法都是抽象的。实现SingerWriter接口的类只需要为这两种方法提供一次实现。

清单 21-31 显示了实现SingerWriter接口的Melodist类的代码。注意,它只覆盖了setRate()getRate()方法一次。

// Melodist.java
package com.jdojo.interfaces;
public class Melodist implements SingerWriter {
    private String name;
    private double rate = 500.00;
    public Melodist(String name) {
        this.name = name;
    }
    @Override
    public void sing() {
        System.out.println(name + " is singing.");
    }
    @Override
    public void setRate(double rate) {
        this.rate = rate;
    }
    @Override
    public double getRate() {
        return rate;
    }
    @Override
    public void write() {
        System.out.println(name + " is writing");
    }
}

Listing 21-31A Melodist Class That Implements the SingerWriter Interface

以下代码片段显示了如何使用Melodist类:

SingerWriter purcell = new Melodist("Henry Purcell");
purcell.setRate(700.00);
purcell.write();
purcell.sing();
Henry Purcell is writing
Henry Purcell is singing.

一个人可以唱歌,也可以玩游戏。我们来创建一个SingerPlayer界面来表现这类人。让我们从SingerPlayer接口继承接口,如下所示:

public interface SingerPlayer extends Singer, Player {
    // No code for now
}

尝试编译SingerPlayer接口会导致以下错误:

SingerPlayer.java:4: error: interface SingerPlayer inherits abstract and default for getRate() from types Player and Singer

该错误是由getRate()方法的两个继承版本中的冲突引起的。Singer接口声明了getRate()方法抽象,Player接口声明它是默认的。这导致了冲突。编译器无法决定继承哪个方法。当同一默认方法的多个版本从不同的超接口继承时,可能会出现这种冲突。考虑下面这个CharitySingerPlayer接口的声明:

public interface CharitySingerPlayer extends CharitySinger, Player {
}

尝试编译CharitySingerPlayer接口会导致以下错误:

CharitySingerPlayer.java:4:错误:接口 CharitySingerPlayer 继承了

getRate() from types CharitySinger and Player
CharitySingerPlayer.java:4: error: interface CharitySingerPlayer inherits abstract and default for setRate(double) from types CharitySinger and Player

这一次,错误是因为两个原因:

  • 该接口继承了两个默认的getRate()方法,一个来自CharitySinger接口,一个来自Player接口。

  • 该接口从CharitySinger接口继承了一个默认的setRate()方法,从Player接口继承了一个抽象的setRate()方法。

这种类型的冲突在 Java 8 之前是不可能的,因为缺省方法不可用。当遇到抽象-默认或默认-默认方法的组合时,编译器不知道要继承哪个方法。为了解决这种冲突,接口需要重写接口中的方法。有几种方法可以解决冲突——都涉及到在接口中重写冲突的方法:

  • 您可以用抽象方法覆盖冲突的方法。

  • 您可以用默认方法重写冲突的方法,并提供新的实现。

  • 您可以用默认方法覆盖冲突的方法,并调用超接口的方法之一。

让我们在SingerPlayer界面中解决冲突。清单 21-32 包含了一个接口声明,它用一个抽象的getRate()方法覆盖了getRate()方法。任何实现SingerPlayer接口的类都必须为getRate()方法提供一个实现。

// SingerPlayer.java
package com.jdojo.interfaces;
public interface SingerPlayer extends Singer, Player {
    // Override the getRate() method with an abstract method
    @Override
    double getRate();
}

Listing 21-32 Overriding

the Conflicting Method with an Abstract Method

清单 21-33 中对SingerPlayer接口的声明通过用默认的getRate()方法覆盖getRate()方法来解决冲突,默认的方法只是返回一个值 700.00。任何实现这个SingerPlayer接口的类都将继承getRate()方法的默认实现。

// SingerPlayer.java
package com.jdojo.interfaces;
public interface SingerPlayer extends Singer, Player {
    // Override the getRate() method with a default method
    @Override
    default double getRate() {
         return 700.00;
    }
}

Listing 21-33Overriding the Conflicting Method with a Default Method

有时一个接口可能想要访问它的超接口的被覆盖的默认方法。Java 8 引入了一种新的语法,用于从一个接口调用直接超接口的被覆盖的默认方法。新语法使用关键字super,如下所示:

<superinterface-name>.super.<superinterface-default-method(arg1, arg2...)>

Tip

使用关键字super,只能访问直接超级接口的默认方法。语法不支持访问超接口的超接口的默认方法。使用这种语法不能访问超接口的抽象方法。

清单 21-34 包含了对SingerPlayer接口的声明,它通过用默认的getRate()方法覆盖getRate()方法来解决冲突。该方法使用Player.super.getRate()调用来调用Player接口的getRate()方法,将该值乘以 3.5,并将其返回。它只是实现了一个规则,即SingerPlayer的报酬至少是Player的 3.5 倍。任何实现SingerPlayer接口的类都将继承getRate()方法的默认实现。

// SingerPlayer.java
package com.jdojo.interfaces;
public interface SingerPlayer extends Singer, Player{
    // Override the getRate() method with a default method that calls the
    // Player superinterface getRate() method
    @Override
    default double getRate() {
        double playerRate = Player.super.getRate();
        double singerPlayerRate = playerRate * 3.5;
        return singerPlayerRate;
    }
}

Listing 21-34Overriding the Conflicting Method with a Default Method That Calls the Method in the Superinterface

清单 21-35 包含了CharitySingerPlayer接口的代码。它用抽象方法覆盖了setRate()方法,用默认方法覆盖了getRate()方法。getRate()方法调用Player接口的默认getRate()方法

// CharitySingerPlayer.java
package com.jdojo.interfaces;
public interface CharitySingerPlayer extends CharitySinger, Player {
    // Override the setRate() method with an abstract method
    @Override
    void setRate(double rate);
    // Override the getRate() method with a default method that calls the
    // Player superinterface getRate() method
    @Override
    default double getRate() {
        return Player.super.getRate();
    }
}

Listing 21-35Overriding the Conflicting Methods in the CharitySinger Interface

超级接口-子接口关系

接口继承建立了超接口-子接口(也称为*超类型-子类型)*关系。当接口CharitySinger继承Singer接口时,Singer接口称为CharitySinger接口的超接口,CharitySinger接口称为Singer接口的子接口。一个接口可以有多个超级接口,一个接口可以是多个接口的子接口。子接口的引用可以分配给超接口的变量。考虑下面的代码片段来演示超级接口-子接口关系的使用。代码中的注释解释了为什么赋值会成功或失败:

public interface Shape {
    // Code goes here
}
public interface Line extends Shape {
    // Code goes here
}
public interface Circle extends Shape {
    // Code goes here
}

下面是您可以使用这些接口编写的示例代码,并带有解释代码应该做什么的注释:

Shape shape = get an object reference of a Shape...;
Line line = get an object reference of a Line...;
Circle circle = get an object reference of a Circle...;
/* More code goes here... */
shape = line;   // Always fine. A Line is always a Shape.
shape = circle; // Always fine. A Circle is always a Shape.
// A compile-time error. A Shape is not always a Line. A Shape may be a Circle.
// Must use a cast to compile.
line = shape;
// OK with the compiler. The shape variable must refer to a Line at runtime.
// Otherwise, the runtime will throw a ClassCastException.
line =(Line) shape;

继承冲突的实现

在 Java 8 之前,一个类不可能从多个超类型继承多个实现(非抽象方法)。默认方法的引入使得一个类从它的超类和超接口继承冲突的实现成为可能。当一个类从多个路径(超类和超接口)继承了具有相同签名的方法时,Java 使用三个简单的规则来解决冲突:

  • 超类总是赢:如果一个类从它的超类继承了一个方法(抽象的或具体的)并且从它的一个超接口继承了一个具有相同签名的方法,那么这个超类就赢了。也就是说,类继承了超类的方法,而超接口中的方法被忽略。如果接口中的默认方法在整个类层次结构中不可用,则此规则将该方法视为备用方法。

  • 最具体的超级接口胜出:如果第一个规则不能解决冲突,则使用该规则。如果继承的默认方法来自多个超接口,则来自最特定超接口的方法由该类继承。

  • 类必须覆盖冲突的方法:如果前两个规则没有解决冲突,就使用这个规则。在这种情况下,开发人员必须在类中重写冲突的方法。

让我们讨论这三个规则适用的不同场景。

超类总是赢

这条规则应用起来很简单。如果一个类继承或声明了一个方法,那么超接口中具有相同签名的方法将被忽略。

示例#1

考虑下面两个类,EmployeeManager:

public abstract class Employee {
    private double rate;
    public abstract void setRate(double rate);
    public double getRate() {
        return rate;
    }
}
public abstract class Manager extends Employee implements CharitySinger {
    // Code goes here
}

Manager类继承自Employee类。以下五种方法可供Manager类继承:

  • CharitySinger.sing()方法抽象

  • 默认的CharitySinger.setRate()方法

  • 默认的CharitySinger.getRate()方法

  • Employee.setRate()方法抽象

  • 具体的Employee.getRate()方法

对于sing()方法没有冲突。因此,Manager类从CharitySinger接口继承了sing()方法。setRate()getRate()方法有两种选择。这两个方法在超类中是可用的。因此,Manager类从Employee类继承了setRate()getRate()方法。

实施例 2

“超类总是赢”规则意味着在Object类中声明的方法不能被接口中的默认方法覆盖。以下对Runner接口的声明将不会编译:

// Won't compile
public interface Runner {
    void run();
    // Not allowed
    @Override
    default String toString() {
        return "WhoCares";
    }
}

在我给出这个规则背后的原因之前,让我们假设Runner接口编译。假设一个Thinker类实现了Runner接口,如下所示:

public class Thinker implements Runner {
    @Override
    public void run() {
        System.out.println();
    }
    // Which method is inherited - Object.toString() or Runner.toString()?
}

Thinker类有两个继承toString()方法的选择:一个来自超类Object,一个来自超接口Runner。记住超类总是获胜,因此,Thinker类从Object类继承了toString()方法,而不是Runner接口。这个论点适用于Object类中的所有方法和所有类。因为接口中的默认方法不会被任何类使用,所以不允许接口用默认方法覆盖Object类的方法。

实施例 3

接口中的默认方法不能声明为 final,原因有两个:

  • 默认方法旨在类中被重写。

  • 如果在现有接口中添加默认方法,所有实现类如果包含具有相同签名的方法,都应该继续工作。

考虑一个Sizeable接口和一个实现该接口的Bag类:

public interface Sizeable {
    int size();
}
public class Bag implements Sizeable {
    private int size;
    @Override
    public int size() {
        return size;
    }
    public boolean isEmpty() {
        return (size == 0);
    }
    // More code goes here
}

Bag类覆盖了Sizeable接口的size()方法。该类包含一个名为isEmpty()的额外的具体方法。这一点没有问题。现在,Sizeable界面的设计者决定给界面添加一个默认的isEmpty()方法,如下所示:

public interface Sizeable {
    int size();
    // A new default method. Cannot declare it final
    default boolean isEmpty() {
        return (size() == 0);
    }
}

在新的默认isEmpty()方法被添加到Sizeable接口后,Bag类将继续工作。该类简单地覆盖了Sizeable接口的默认isEmpty()方法。如果允许它声明默认的isEmpty()方法 final,它将导致一个错误,因为 final 方法不允许被覆盖。不允许最终默认方法的规则确保了向后兼容性。如果现有类包含方法,并且在该类实现的接口中添加了具有相同签名的默认方法,则该类将继续工作。

最具体的超级接口胜出

此规则试图解决来自多个接口的具有相同签名的冲突方法的继承。如果相同的方法(抽象或默认)通过不同的路径从多个超接口继承,则使用最具体的路径。假设I1是一个带有方法m()的接口。I2I1的子接口,I2覆盖了方法m()。如果一个类Test实现了两个接口I1I2,那么它有两个选择来继承m()方法——即I1.m()I2.m()。在这种情况下,I2.m()被认为是最具体的,因为它覆盖了I1.m()。这些规则可以总结如下:

  • 列出从不同超接口获得的具有相同签名的方法的所有选择。

  • 从列表中移除已被列表中其他方法覆盖的所有方法。

  • 如果你只有一个选择,那就是这个类将继承的方法。

考虑下面的Employee类。它实现了SingerSingerPlayer接口:

public class Employee implements Singer, SingerPlayer {
    // Code goes here
}

继承从Player接口继承的play()方法没有冲突。继承sing()方法没有冲突,因为两个超接口都指向Singer接口中的同一个sing()方法。哪个setRate()方法被Employee类继承了?您有以下选择:

  • Singer.setRate()

  • SingerPlayer.setRate()

这两种选择都会导致抽象的setRate()方法。因此,不存在冲突。然而,SingerPlayer.setRate()方法在这种情况下最为特殊,因为它覆盖了Singer.setRate()方法。

哪个getRate()方法被Employee类继承?您有以下选择:

  • Singer.getRate()

  • SingerPlayer.getRate()

Singer.getRate()方法已经被SingerPlayer.getRate()方法覆盖。因此,Singer.getRate()作为一个选项被删除,这样你只剩下一个选项,SingerPlayer.getRate()。因此,Employee类从SingerPlayer接口继承了默认的getRate()方法。

该类必须重写冲突的方法

如果前面的两条规则不能解决冲突的方法继承,该类必须重写该方法,并选择它想在该方法中做什么。它可能以一种全新的方式实现该方法,也可能选择调用超接口中的一个方法。可以使用以下语法调用类的一个超接口的默认方法:

<superinterface-name>.super.<superinterface-default-method(arg1, arg2...)>

如果要调用某个类的超类中的某个方法,可以使用以下语法:

<class-name>.super.<superclass-method(arg1, arg2...)>

考虑下面对一个继承自SingerPlayer接口的MultiTalented类的声明:

// Won't compile
public abstract class MultiTalented implements Singer, Player {
}

此类声明不会编译。该类继承了sing()play()setRate()方法,没有任何冲突。继承getRate()方法有两种选择:

  • Singer.getRate()方法抽象

  • 默认的Player.getRate()方法

两个版本的getRate()方法都不比另一个更具体。在这种情况下,MultiTalented类必须覆盖getRate()方法来解决冲突。下面的MultiTalented类代码将会编译:

public abstract class MultiTalented implements Singer, Player {
    // A MultiTalented is paid the rate of a Player plus 200.00
    @Override
    public double getRate() {
        // Get the default rate for a Player from the Player interface
        double playerRate = Player.super.getRate();
        double rate = playerRate + 200.00;
        return rate;
    }
}

该类覆盖了getRate()方法来解决冲突。该方法调用Player接口的默认getRate()方法,并执行其他逻辑。这个类仍然被声明为抽象的,因为它没有实现来自SingerPlayer接口的抽象方法。

运算符的实例

您可以使用instanceof操作符来评估引用类型变量是否引用特定类的对象或由其类实现的特定接口。它是一个两个操作数的操作符,计算结果是一个boolean值。instanceof操作符的一般语法如下:

<reference-variable> instanceof <reference-type>

考虑下面的代码片段,它定义了两个接口(GenerousMunificent)和四个类(GiverGenerousGiverMunificentGiverStingyGiver):

public interface Generous {
    void give();
}
public interface Munificent extends Generous {
    void giveALot();
}
public class Giver {
}
public class GenerousGiver extends Giver implements Generous {
    @Override
    public void give() {
    }
}
public class MunificentGiver extends Giver implements Munificent {
    @Override
    public void give() {
    }
    @Override
    public void giveALot() {
    }
}
public final class StingyGiver extends Giver {
    public void giveALittle() {
    }
}

图 21-2 显示了这些接口和类的类图。

img/323069_3_En_21_Fig2_HTML.jpg

图 21-2

显示接口和类之间关系的类图:慷慨、慷慨、对象、给予者、慷慨给予者、慷慨给予者和吝啬给予者

Java 中的每个表达式都有两种类型,编译时类型和运行时类型。编译时类型也称为静态类型或声明类型。运行时类型也称为动态类型或实际类型。表达式的编译时类型在编译时是已知的。当表达式被实际执行时,表达式的运行时类型是已知的。考虑以下语句:

Munificent john = new MunificentGiver();

这段代码包含一个变量声明Munificent john和一个表达式new MunificentGiver()。变量john的编译时类型是Munificent。表达式new MunificentGiver()的编译时类型是MunificentGiver。在运行时,变量john将引用MunificentGiver类的一个对象,其运行时类型将是MunificentGiver。表达式new MunificentGiver()的运行时类型将与其编译时类型MunificentGiver相同。

操作符执行编译时检查和运行时检查。在编译时,它检查其左侧操作数是否有可能指向其右侧操作数类型的实例。允许左边的操作数指向null引用。如果左边的操作数有可能引用右边的操作数类型,则代码通过编译器检查。例如,以下代码将在运行时编译并打印true:

Munificent john = new MunificentGiver();
if (john instanceof Munificent) {
    System.out.println("true");
} else {
    System.out.println("false");
}

查看john的编译时类型,即Munificent,编译器确信john将引用null或其类实现Munificent接口的对象。所以编译器不会抱怨john instanceof Munificent表达式。

考虑下面的代码片段,它编译并打印false:

Giver donna = new Giver();
if (donna instanceof Munificent) {
    System.out.println("true");
} else {
    System.out.println("false");
}

变量donna的编译时类型是Giver。在运行时,它还指向一个Giver类型的对象。也就是它的运行时类型是Giver。当编译器试图编译donna instanceof Munificent表达式时,它会问一个问题:“编译时类型为Giver的变量donna是否可能指向实现Munificent接口的类的对象?”答案是肯定的。你被答案弄糊涂了吗?编译器在计算instanceof运算符时,不会查看前面代码片段中的整个语句Giver donna = new Giver();。它只是查看变量donna的编译时类型,也就是GiverGiver类本身不实现Munificent接口。然而,Giver类的任何子类都可能实现Munificent接口,变量donna可能引用任何此类的对象。例如,可以编写如下所示的代码:

Giver donna = new MunificentGiver();

在这种情况下,变量donna的编译时类型仍然是Giver类型。然而,在运行时,它将引用一个对象,该对象的类实现了Munificent接口。编译器的工作只是确定一种“可能性”,在运行时可能是truefalse。当变量donna引用Giver类的一个对象时,donna instanceof Munificent表达式会在运行时返回false,因为Giver类没有实现Munificent接口。当变量donna引用MunificentGiver类的对象时,donna instanceof Munificent表达式将在运行时返回true,因为MunificentGiver类实现了Munificent接口。

以下代码片段将编译并打印false:

Giver kim = new StingyGiver();
if (kim instanceof Munificent) {
    System.out.println("true");
} else {
    System.out.println("false");
}

考虑前面代码的一个变体,如下所示:

StingyGiver jim = new StingyGiver();
if (jim instanceof Munificent) { // A compile-time error
    System.out.println("true");
} else {
    System.out.println("false");
}

这一次,编译器将拒绝编译代码。让我们应用逻辑,并尝试找出代码的问题所在。编译器将生成一个关于jim instanceof Munificent表达式的错误。也就是说,它肯定知道在运行时,变量jim不可能引用一个其类实现了Munificent接口的对象。编译器怎么能如此确定这种可能性?这很容易。您已经将StingyGiver类声明为 final,这意味着它不能被子类化。这意味着编译时类型为StingyGiver的变量jim只能引用类为StingyGiver的对象。编译器也知道StingyGiver类及其祖先类不实现Munificent接口。有了所有这些推理,编译器确定你的程序中有一个逻辑错误,你需要修复它。

如果instanceof操作符在运行时返回true,这意味着它的左边操作数可以安全地转换为右边操作数所表示的类型。通常,当您需要使用instanceof操作符时,您的逻辑如下:

ABC a = null;
DEF d = null;
if (x instanceof ABC) {
    // Safe to cast x to ABC type
    a = (ABC) x;
} else if (x instanceof DEF) {
    // Safe to cast x to DEF type
    d = (DEF) x;
}

这可以通过使用 JDK 16 中引入的以下模式匹配语法实例来减少:

if (x instanceof ABC a) {
    // a is ABC type variable here
} else if (x instanceof DEF d) {
    // d is DEF type variable here
}

如果instanceof操作符的左操作数是null或引用变量,在运行时指向null,则返回false。下面的代码片段也将打印false:

Giver ken = null;
if (ken instanceof Munificent) {
    System.out.println("true");
} else {
    System.out.println("false");
}

你可以得出结论,如果v instanceof XYZ返回true,你可以安全地假设以下两件事:

  • v不是null。也就是说,v指向内存中的一个有效对象。

  • 演员阵容总是会成功的。也就是说,下面的代码保证在没有ClassCastException的情况下运行:

XYZ x = (XYZ) v;

标记接口

可以声明一个没有成员的接口。请注意,接口可以通过两种方式拥有成员:声明自己的成员或从其超接口继承成员。当一个接口没有成员(声明的或继承的)时,它被称为标记接口。标记接口也称为标签接口。

标记接口有什么用?为什么任何类都要实现标记接口?顾名思义,标记接口用于标记具有特殊含义的类,这些特殊含义可以在特定的上下文中使用。由标记接口添加到类中的含义取决于上下文。标记接口的开发者必须记录接口的含义,接口的消费者将利用其预期的含义。例如,让我们声明一个名为Funny的标记接口,如下所示。这个Funny接口的意义取决于使用它的开发人员:

public interface Funny {
    // No code goes here
}

每个接口都定义一个新的类型,标记接口也是如此。因此,您可以声明一个类型为Funny的变量:

Funny simon = an object of a class that implements the Funny interface;

使用类型为Funny的变量simon可以访问什么?你不能使用simon变量访问任何东西,除了Object类的所有方法。您也可以在不实现您的类的Funny接口的情况下做到这一点。通常,标记接口与instanceof操作符一起使用,以检查引用类型变量是否引用了其类实现标记接口的对象。例如,您可以编写如下代码:

Object obj = any java object;
...
if (obj instanceof Funny) {
    // obj is an object whose class implements the Funny interface. Display a message on the
    // standard output that we are using a Funny object. Or, do something that is intended
    // by the developer of the Funny interface
    System.out.println("Using a Funny object");
}

Java API 有许多标记接口。Java 类库中的两个标记接口是java.lang.Cloneablejava.io.Serializable。如果一个类实现了Cloneable接口,这意味着该类的开发者打算允许克隆该类的对象。您需要采取额外的步骤来覆盖您的类中的Objectclone()方法,这样就可以在您的类的对象上调用clone()方法,因为clone()方法已经在Object类中被声明为受保护的。即使你的类覆盖了clone()方法,你的类的对象也不能被克隆,直到你的类实现了Cloneable标记接口。您可以看到,实现 Cloneable 接口将一种含义与类相关联,即它的对象可以被克隆。当调用Object类的clone()方法时,Java 会检查对象的类是否实现了Cloneable接口。如果对象的类没有实现Cloneable接口,它会在运行时抛出一个异常。

Java 5 引入了注释。要定义一个注释,可以使用关键字@interface,但它们实际上根本不是接口。它们类似于标记接口,因为它们标记一些东西。它们可以用来将一个含义与任何元素相关联,例如,一个类、一个方法、一个变量、一个包等等。一个 Java 程序的。注释在更多 Java 17 中有详细介绍。您已经多次看到一个注释,即标记方法的@Override 注释。

功能界面

只有一个抽象方法的接口被称为功能接口。您可以用首字母缩写 SAM(单一抽象方法)来记住这一点。静态和默认方法不被认为是将一个接口指定为功能接口。除了我们已经讨论过的步骤之外,不需要额外的步骤来将接口声明为功能性的。作为版本 8 的一部分,Java 引入了函数接口的概念,它可以通过方法引用和 lambda 表达式来实现。本书在前几章已经展示了这些例子,但是没有命名为功能接口,因为我们还没有涉及接口。

WalkableSwimmable接口是函数接口的例子,因为它们只包含一个抽象方法。Singer接口是非功能性接口的一个例子,因为它包含不止一个抽象方法。可以用@FunctionalInterface注释来注释一个函数接口,编译器会验证被注释的接口真的只包含一个抽象方法;否则,接口声明将不会编译。下面是一个使用@FunctionalInterface注释的功能接口的例子:

@FunctionalInterface
public interface Runner {
    public void run();
}

由于该接口中的抽象方法没有参数和 void 返回类型,因此它可以由任何没有参数和不返回值的 lambda 表达式实现。请注意,这里不能使用“var ”,因为您需要告诉 Java 您正在实现哪个函数接口,例如:

Runner r = () -> System.out.println("Running");

函数接口可以有任何类型的方法。例如,作为 JDK 的一部分提供的谓词接口如下所示:

public interface Predicate {
      boolean test(Object o);
}

它可以使用 lambda 表达式实现,如下所示(如果给定的对象不为空,该谓词将返回 true):

Predicate p = (Object o) -> o != null;

函数接口可以出现在普通 Java 类型出现的任何地方,比如字段类型、变量类型或参数类型。它们也可以通过方法引用来实现,只要被引用的方法与函数接口的抽象方法的方法签名相匹配。例如,Objects nonNull 方法可以用来表示与上一个示例相同的含义:

Predicate p = Objects::nonNull;

函数接口在 More Java 17 中有更详细的介绍,但是现在你应该对它们的使用有了一个基本的了解。

比较对象

当您有一组对象时,有时您可能希望根据某些标准对它们进行排序。java.lang.Comparablejava.util.Comparator是用于排序对象的两个常用接口。我将在本节中讨论这两种接口。

使用可比接口

如果一个类的对象需要进行比较以便排序,那么这个类就实现了Comparable接口。例如,在对数组或列表中的人员集合进行排序时,您可能想要比较一个Person类的两个对象。用于比较两个对象的标准取决于上下文。例如,当您需要显示许多人时,您可能希望按照他们的姓氏、个人 id、地址或电话号码来显示他们。

Comparable接口强加给一个类的对象的排序也被称为该类的自然排序。接口Comparable包含一个抽象的compareTo()方法,它接受一个参数。如果被比较的两个对象被认为相等,则该方法返回零;如果对象小于指定的参数,则返回负整数;如果对象大于指定的参数,则返回正整数。Comparable接口是一个通用接口,声明如下:

public interface Comparable<T> {
    public int compareTo(T o);
}

String类和包装类(IntegerDoubleFloat等)。)实现 Comparable 接口。String类的compareTo()方法按字典顺序对字符串进行排序。数字基元类型的所有包装类从数字上比较这两个对象。

比较相同类型的对象是很典型的。下面的类A的类声明使用A作为其泛型类型实现了Comparable<A>接口,声明类A只支持比较其自身类型的对象:

public class A implement Comparable<A> {
    public int compareTo(A a) {
        // Code goes here
    }
}

清单 21-36 包含实现Comparable<ComparablePerson>接口的ComparablePerson类的代码。在compareTo()方法中,首先,根据姓氏比较两个对象。如果姓氏相同,你就比较他们的名字。您已经使用了String类的compareTo()方法来比较两个可比较的人的姓和名。注意,compareTo()方法不处理null值。

// ComparablePerson.java
package com.jdojo.interfaces;
public class ComparablePerson implements Comparable<ComparablePerson> {
    private String firstName;
    private String lastName;
    public ComparablePerson(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public String getFirstName() {
        return firstName;
    }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    // Compares two persons based on their last names. If last names are
    // the same, use first names
    @Override
    public int compareTo(ComparablePerson anotherPerson) {
        int diff = getLastName().compareTo(anotherPerson.getLastName());
        if (diff == 0) {
            diff = getFirstName().compareTo(anotherPerson.getFirstName());
        }
        return diff;
    }
    @Override
    public String toString() {
        return getLastName() + ", " + getFirstName();
    }
}

Listing 21-36A ComparablePerson Class That Implements the Comparable Interface

清单 21-37 包含了通过对数组中的对象进行排序来测试ComparablePerson类的代码。输出显示了ComparablePerson类的对象按照姓氏和名字排序。

// ComparablePersonTest.java
package com.jdojo.interfaces;
import java.util.Arrays;
public class ComparablePersonTest {
     public static void main(String[] args) {
        ComparablePerson[] persons = new ComparablePerson[] {
                new ComparablePerson("John", "Jacobs"),
                new ComparablePerson("Jeff", "Jacobs"),
                new ComparablePerson("Wally", "Inman")};
        System.out.println("Before sorting...");
        print(persons);
        // Sort the persons list
        Arrays.sort(persons);
        System.out.println("\nAfter sorting...");
        print(persons);
    }
    public static void print(ComparablePerson[] persons) {
        for(ComparablePerson person: persons){
            System.out.println(person);
        }
    }
}
Before sorting...
Jacobs, John
Jacobs, Jeff
Inman, Wally
After sorting...
Inman, Wally
Jacobs, Jeff
Jacobs, John

Listing 21-37A Test Class to Test the ComparablePerson Class and the Comparable Interface

使用比较器接口

我在上一节中解释过的Comparable接口在一个类的对象上强加了一个指定的顺序。有时,您可能希望为该类的对象指定一个不同的顺序,而不是由Comparable接口在该类中指定的顺序。有时你可能想为一个没有实现Comparable接口的类的对象指定一个特定的顺序。例如,您可能希望根据名字和姓氏来指定对ComparablePerson类的对象的排序,这与由Comparable接口的compareTo()方法指定的排序相反,后者是姓氏和名字。Comparator接口允许你在任何类的对象上指定一个定制的顺序。通常,处理对象集合的 Java API 需要一个Comparator对象来指定定制的顺序。Comparator接口是一个通用接口:

public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
    // Default and static methods are not shown here
}

在 Java 8 中,Comparator接口已经被彻底检修过了。接口中添加了几个静态和默认方法。我们在本章中讨论一些新方法。

通常,您不需要实现Comparator接口的equals()方法。Java 中的每个类都从Object类继承了equals()方法,这在大多数情况下是没问题的。compare()方法有两个参数,它返回一个整数。如果第一个参数小于、等于或大于第二个参数,则它分别返回负整数、零或正整数。清单 21-38 和 21-39 包含了Comparator接口的两个实现:一个基于名字比较两个ComparablePerson对象,另一个基于姓氏。

// LastNameComparator.java
package com.jdojo.interfaces;
import java.util.Comparator;
public class LastNameComparator implements Comparator<ComparablePerson> {
    @Override
    public int compare(ComparablePerson p1, ComparablePerson p2) {
        String lastName1 = p1.getLastName();
        String lastName2 = p2.getFirstName();
        int diff = lastName1.compareTo(lastName2);
        return diff;
    }
}

Listing 21-39A Comparator Comparing ComparablePersons Based on Their Last Names

// FirstNameComparator.java
package com.jdojo.interfaces;
import java.util.Comparator;
public class FirstNameComparator implements Comparator<ComparablePerson> {
    @Override
    public int compare(ComparablePerson p1, ComparablePerson p2) {
        String firstName1 = p1.getFirstName();
        String firstName2 = p2.getFirstName();
        int diff = firstName1.compareTo(firstName2);
        return diff;
    }
}

Listing 21-38A Comparator Comparing ComparablePersons Based on Their First Names

使用Comparator很容易。创建它的对象,并将其传递给接受对象集合和比较器来比较它们的方法。例如,要对一组ComparablePerson对象进行排序,将该数组和一个FirstNameComparator传递给Arrays类的静态sort()方法:

ComparablePerson[] persons = create and populate the array...
// Sort the persons array based on first name
Comparator fnComparator = new FirstNameComparator();
Arrays.sort(persons, fnComparator);

您可以使用类似的逻辑根据姓氏对数组进行排序:

// Sort the persons array based on last name
Comparator lnComparator = new LastNameComparator();
Arrays.sort(persons, lnComparator);

在 Java 8 之前,如果想先根据名字再根据姓氏对数组进行排序,就需要创建另一个Comparator接口的实现。由于 Java 8 向接口引入了默认方法,您不需要创建一个新的Comparator接口实现。Comparator类包含一个thenComparing()默认方法,声明如下:

default Comparator<T> thenComparing(Comparator<? super T> other)

该方法将一个Comparator作为参数,并返回一个新的Comparator。如果使用原来的Comparator比较的两个对象相等,则使用新的Comparator进行排序。下面的代码片段结合了名和姓Comparator s 来创建一个新的Comparator:

// Sort using first name, then last name
Comparator firstLastComparator = fnComparator.thenComparing(lnComparator);
Arrays.sort(persons, firstLastComparator);

Tip

您可以将对thenComparing()方法的调用链接起来,以创建一个Comparator,对几个嵌套层次进行排序。

Java 8 中的Comparator接口还有一个有用的附加功能:名为reversed()的默认方法。该方法返回一个新的Comparator,它对原来的Comparator进行反向排序。如果要按降序先按名字再按姓氏对数组进行排序,可以按如下方式进行:

// Sort using first name, then last name in reversed order
Comparator firstLastReverseComparator = firstLastComparator.reversed();
Arrays.sort(persons, firstLastReverseComparator);

比较器不能很好地处理null值。通常,他们会抛出一个NullPointerException。Java 8 向Comparator接口添加了以下两个有用的、空友好的、方便的静态方法:

  • static <T> Comparator<T> nullsFirst(Comparator<? super T> comparator)

  • static <T> Comparator<T> nullsLast(Comparator<? super T> comparator)

这些方法接受一个Comparator并返回一个空友好的Comparator,将空值放在第一个或最后一个。您可以按如下方式使用这些方法:

// Sort using first name, then last name, placing null values first
Comparator nullFirstComparator = Comparator.nullsFirst(firstLastComparator);
Arrays.sort(persons, nullFirstComparator);

清单 21-40 使用这个类的对象对ComparablePerson类的对象进行排序。如输出所示,这一次您可以根据名字和姓氏对可比较人员的列表进行排序。如果您想以任何其他顺序对ComparablePerson的对象列表进行排序,您需要使用Comparator接口的一个对象,它强加了想要的顺序。

// ComparablePersonTest2.java
package com.jdojo.interfaces;
import java.util.Arrays;
import java.util.Comparator;
public class ComparablePersonTest2 {
    public static void main(String[] args) {
        ComparablePerson[] persons = new ComparablePerson[]{
            new ComparablePerson("John", "Jacobs"),
            new ComparablePerson("Jeff", "Jacobs"),
            new ComparablePerson("Wally", "Inman")};
        System.out.println("Original array...");
        print(persons);
        // Sort using first name
        Comparator<ComparablePerson> fnComparator = new FirstNameComparator();
        Arrays.sort(persons, fnComparator);
        System.out.println("\nAfter sorting on first name...");
        print(persons);
        // Sort using last name
        Comparator<ComparablePerson> lnComparator = new LastNameComparator();
        Arrays.sort(persons, lnComparator);
        System.out.println("\nAfter sorting on last name...");
        print(persons);
        // Sort using first name, then last name
        Comparator<ComparablePerson> firstLastComparator
                = fnComparator.thenComparing(lnComparator);
        Arrays.sort(persons, firstLastComparator);
        System.out.println("\nAfter sorting on first, then last name...");
        print(persons);
        // Sort using first name, then last name in reversed order
        Comparator<ComparablePerson> firstLastReverseComparator
                = firstLastComparator.reversed();
        Arrays.sort(persons, firstLastReverseComparator);
        System.out.println("\nAfter sorting on first, then last name in reversed...");
        print(persons);
        // Sort using first name, then last name using null first
        Comparator<ComparablePerson> nullFirstComparator
                = Comparator.nullsFirst(firstLastComparator);
        ComparablePerson[] personsWithNulls = new ComparablePerson[]{
            new ComparablePerson("John", "Jacobs"),
            null,
            new ComparablePerson("Jeff", "Jacobs"),
            new ComparablePerson("Wally", "Inman"),
            null};
        Arrays.sort(personsWithNulls, nullFirstComparator);
        System.out.println("\nAfter sorting on first, then last name "
                + "using null first...");
        print(personsWithNulls);
    }
    public static void print(ComparablePerson[] persons) {
        for (ComparablePerson person : persons) {
            System.out.println(person);
        }
    }
}
Original array...
Jacobs, John
Jacobs, Jeff
Inman, Wally
After sorting on first name...
Jacobs, Jeff
Jacobs, John
Inman, Wally
After sorting on last name...
Inman, Wally
Jacobs, John
Jacobs, Jeff
After sorting on first, then last name...
Jacobs, Jeff
Jacobs, John
Inman, Wally
After sorting on first, then last name in reversed...
Inman, Wally
Jacobs, John
Jacobs, Jeff
After sorting on first, then last name using null first...
null
null
Jacobs, Jeff
Jacobs, John
Inman, Wally

Listing 21-40A Test Class That Uses a Comparator Object to Sort ComparablePerson Objects

多态:一个对象,多个视图

多态指的是一个对象呈现多种形式的能力。我使用术语“视图”而不是术语“表单”术语“视图”可以更好地理解接口上下文中的多态。让我们重新表述一下多态的定义:它是一个对象提供其不同视图的能力。接口允许您创建多态对象。考虑清单 21-24 中显示的Turtle类声明。它实现了SwimmableWalkable接口。您创建了一个Turtle对象,如图所示:

Turtle turti = new Turtle("Turti");

因为Turtle类实现了WalkableSwimmable接口,所以可以将turti对象视为WalkableSwimmable:

Walkable turtiWalkable = turti;
Swimmable turtiSwimmable = turti;

因为 Java 中的每个类都继承自Object类,所以您也可以将turti对象视为一个Object:

Object turiObject = turti;

图 21-3 显示了同一Turtle物体的四个不同视图。注意,只有一个对象,它属于Turtle类。当你从不同的方向(上、前、后、左、右等)看房子时。),你得到的是同一个房子的不同看法。然而,只有一所房子。当你从房子的正面看它时,你看不到它的其他视图,例如,后视图或左视图。像房子一样,Java 对象可以展示自己的不同视图,这被称为多态。

img/323069_3_En_21_Fig3_HTML.png

图 21-3

多态:一个对象,多个视图。乌龟物体的四种不同视图

是什么定义了 Java 对象的特定视图,以及如何获得该对象的视图?视图是对外部人员可用的东西(用技术术语来说,是对客户或类的用户)。在一个类型(一个类或一个接口)中定义的一组方法(客户端可以访问)定义了该类型对象的视图。例如,Walkable类型定义了一个方法:walk()。如果您获得一个对象的Walkable视图,这意味着您只能访问该对象的walk()方法。类似地,如果您有一个对象的Swimmable视图,那么您只能访问该对象的swim()方法。拥有一个Turtle对象的Turtle视图怎么样?Turtle类定义了三种方法:bite()walk()swim()。它也从Object类继承方法。因此,如果您有一个对象的Turtle视图,您可以访问Turtle类中可用的所有方法(直接声明或从其超类和超接口继承)。Java 中的每个类都直接或间接地继承自Object类。因此,Java 中的每个对象至少有两个视图:一个视图由Object类中可用(声明或继承)的方法集定义,另一个视图由Object类中定义的方法集定义。当您使用对象的Object视图时,您只能访问Object类的方法。

通过使用不同类型的引用变量访问一个对象,可以获得该对象的不同视图。例如,要获得一个Turtle对象的Walkable视图,您可以执行以下任一操作:

Turtle t = new Turtle("Turti");
Walkable w2 = t;            // w2 gives Walkable view of the Turtle object
Walkable w3 = new Turtle(); // w3 gives Walkable view of the Turtle object

了解了可以支持不同视图的 Java 对象之后,让我们看看instanceof操作符的用法。它用于测试一个对象是否支持特定的视图。考虑以下代码片段:

Object anObject = get any object reference...;
if(anObject instanceof Walkable) {
    // anObject has a Walkable view
    Walkable w = (Walkable) anObject;
    // Now access the Walkable view of the object using w
} else {
    // anObject does not have a Walkable view
}

anObject变量指的是一个对象。instanceof操作符用于测试anObject变量引用的对象是否支持Walkable视图。注意,仅仅在一个类中定义一个walk()方法并不能为该类的对象定义一个Walkable视图。该类必须实现Walkable接口并实现walk()方法,以使其对象拥有Walkable视图。对象的视图与其类型同义。回想一下,在一个类上实现一个接口给了该类的对象一个额外的类型(即,一个额外的视图)。一个类的对象可以有多少个视图?没有限制。一个类的对象可以有以下视图:

  • 由其类类型定义的视图

  • 由其类的所有超类(直接或间接)定义的视图

  • 由其类或超类(直接或间接)实现的所有接口定义的视图

动态绑定和接口

当使用接口类型的变量调用方法时,Java 使用动态绑定(也称为运行时绑定或后期绑定)。考虑以下代码片段:

Walkable john = a Walkable object reference...
john.walk();

变量john有两种类型:编译时类型和运行时类型。它的编译时类型就是它声明的类型,也就是Walkable。编译器知道变量的编译时类型。当代码john.walk()被编译时,编译器必须根据编译时可用的所有信息来验证这个调用是否有效。编译器为john.walk()方法调用添加了类似如下的指令:

invokeinterface #5,  1; //InterfaceMethod com/jdojo/interfaces/Walkable.walk:()V

前面的指令声明对接口类型Walkable的变量进行了john.walk()方法调用。变量john在运行时引用的对象是它的运行时类型。编译器不知道变量 john 的运行时类型。变量john可以指Person类、Turtle类、Duck类或任何其他实现Walkable接口的类的对象。当执行john.walk()时,编译器不会声明应该使用walk()方法的哪个实现。运行时决定调用walk()方法的实现,如下所示:

  1. It gets the information about the class of the object to which the variable john refers. For example, consider the following snippet of code:

    Walkable john = new Person("John"); // john refers to a Person object
    john.walk();
    
    

    这里,变量john在运行时引用的对象的类类型是Person

  2. 它在上一步确定的类中寻找walk()方法实现。如果在那个类中没有找到walk()方法的实现,运行时会递归地在祖先类中寻找walk()方法的实现。

  3. 如果在前面的步骤中找到了walk()方法的实现,那么一找到就执行。也就是说,如果在变量john所引用的对象的类中找到了walk()方法的实现,那么运行时将执行该方法的实现,而不再在它的祖先类中寻找该方法。

  4. 如果在类层次结构中没有找到walk()方法的实现,则搜索由该类实现的超接口的继承层次结构。如果使用前面描述的在接口中查找方法的最具体的规则找到了一个walk()方法,如果它是一个默认方法,则调用该方法。如果找到多个默认的walk()方法,就会抛出一个IncompatibleClassChangeError。如果找到一个抽象的walk()方法,抛出一个AbstractMethodError

  5. 如果仍然没有找到walk()方法的实现,就会抛出一个NoSuchMethodError。如果所有的类都是一致编译的,您应该不会得到这个错误。

摘要

接口是由类实现的规范。接口可能包含静态常量、抽象方法、默认方法、静态方法和嵌套类型的成员。接口不能有实例变量。无法实例化接口。

没有成员的接口称为标记接口。只有一个抽象方法的接口称为函数接口,可以通过方法引用或 lambda 表达式实现。

类实现接口。关键字implements在类声明中用于实现接口。实现接口的类继承接口的所有成员,静态方法除外。如果该类从实现的接口继承抽象方法,它需要重写它们并提供一个实现,或者该类应该声明自己是抽象的。实现接口的类是实现接口的子类型,而实现接口是类的超类型。如果一个类从多个具有相同签名的超类型(超类或超接口)继承了相同的方法,在这种情况下,超类的方法优先;如果所有方法都继承自超接口,则使用最具体的方法;如果仍然有多个候选项,该类必须重写方法来解决冲突。

一个接口可以从其他接口继承。关键字extends在接口声明中用于指定所有继承的接口。继承该接口的接口称为超级接口,接口本身称为子接口。子接口继承其超接口的所有成员,除了它们的静态方法。如果一个接口从多个超接口继承了具有相同签名的方法组合 default-default 或 default-abstract,则可能会发生冲突。冲突分两步解决:使用最特定的候选项;如果有多个最具体的候选,接口必须覆盖冲突的方法。

在 Java 8 之前,如果不破坏现有代码,就不可能在接口发布后对其进行更改。在 Java 8 中,您可以向现有接口添加默认和静态方法。在 Java 9 之前,接口中的所有方法都是隐式公共的,不允许拥有私有方法。Java 允许你在一个接口中拥有私有方法。

当使用接口类型的变量调用抽象或默认方法时,使用动态绑定。当调用接口的静态方法时,使用静态绑定。请注意,只能使用一种语法来调用接口的静态方法:

InterfaceName.staticMethodName(arg1, arg2...)

EXERCISES

  1. Java 中的接口是什么?什么是标记接口?什么是功能界面?

  2. 你用什么关键字来实现一个类的接口?

  3. 一个类可以实现多少个接口?

  4. 在接口声明中使用什么关键字来继承其他接口?

  5. 可以在接口中声明实例变量吗?

  6. 哪个版本的 Java SE 允许在接口中拥有私有方法?

  7. 接口中哪种方法可以被声明为私有的?接口中可以有抽象的私有方法吗?如果不是,解释你的答案。

  8. 你在一个类中实现什么接口来实现该类对象的自然排序?你用什么接口为类的对象实现自定义排序?

  9. 描述以下接口声明无法编译的原因,并建议一个修复方法:

    public interface Choices {
        int YES;
        int NO = 1;
        private int CANCEL = 2;
    }
    
    
  10. 下面的接口声明有什么问题?

```java
public interface ScheduledJob {
    public void run() {
        System.out.println("Running the job...");
    }
}

```
  1. 考虑下面这个名为Greeting :
```java
interface Greeting {
    void sayHello();
}

```

的接口声明,创建一个名为`Greeter`的类,它以这样一种方式实现`Greeting`接口,当执行下面的代码片段时,它在标准输出上打印:

```java
Greeting g = new Greeter();
g.sayHello();

```
  1. 下面的接口声明不编译。描述原因并建议解决方法:
```java
public final interface Colorable {
    public void color();
}

```
  1. 下面的接口声明有效吗?像Sensitive接口这样的接口有什么特别的名字?
```java
public interface Sensitive {
    // No code goes here
}

```
  1. 下面的接口声明会编译吗?如果没有,给出理由:
```java
@FunctionalInterface
public interface Runner {
    public void run();
}

```
  1. 以下对Printer接口的声明是有效的函数接口声明吗?描述你的理由,它如何符合或不符合功能接口的定义:
```java
@FunctionalInterface
public interface Printer {
    public void print();
    public default void sayHello() {
        System.out.println("Hello");
    }
}

```
  1. 考虑下面的声明:
```java
public interface Greeting {
    default void greet() {
        System.out.println("Hello");
    }
}
public class EnglishGreeting implements Greeting {
}
public class HispanicGreeting implements Greeting {
    @Override
    public void greet() {
        System.out.println("Ola");
    }
}

```

当执行下面的代码片段时,输出会是什么?

```java
Greeting usGreeting = new EnglishGreeting();
Greeting mxGreeting = new HispanicGreeting();
usGreeting.greet();
mxGreeting.greet();

```
  1. Consider the following partial declaration of an Item class:
```java
public class Item implements Comparable<Item> {
    private String name;
    private double price;
    /* Your code goes here */
}

```

通过添加所需的构造器来完成`Item`类,以允许项目名称和价格的初始值。还要为两个实例变量添加 getters 和 setters。添加所需的方法,因此该类实现了`Comparable<Item>`接口。对项目进行排序的自然顺序是根据它们的名称。
  1. 创建一个定制的比较器类——一个实现Comparator<Item>接口的类。comparator 类将先按价格,然后按名称对Item类的对象进行排序。

  2. 考虑下面对Greeting接口和Greeter类的声明:

```java
public interface Greeting {
    default void greet() {
        System.out.println("Namaste");
    }
}
public class Greeter implements Greeting {
    @Override
    public void greet() {
        /* Calls the greet() method of the Greeting interface here */
        System.out.println("Hello");
    }
}

```

通过添加一条语句作为`Greeter`类的`greet()`方法中的第一条语句来完成该方法中的代码。该语句应该调用`Greeting`接口的`greet()`方法。当执行下面的代码片段时,它应该打印出`"Namaste"`和`"Hello"`——每个单词占一行:

```java
Greeting g = new Greeter();
g.greet();

```

预期的输出如下:

```java
Namaste
Hello

```

二十二、枚举类型

在本章中,您将学习:

  • 什么是枚举类型

  • 如何声明枚举类型和枚举常量

  • 如何在switch语句中使用枚举

  • 如何将数据和方法与枚举常量相关联

  • 如何声明嵌套枚举

  • 如何实现枚举类型的接口

  • 如何对枚举常量执行反向查找

  • 如何使用EnumSet处理枚举常量的范围

本章中的所有示例程序都是清单 22-1 中声明的jdojo.enums模块的成员。

// module-info.java
module jdojo.enums {
    exports com.jdojo.enums;
}

Listing 22-1The Declaration of a jdojo.enums Module

什么是枚举类型?

枚举(也称为枚举和枚举数据类型)允许您创建一个常量的有序列表作为类型。在讨论什么是 enum 以及我们为什么需要它之前,让我们考虑一个问题,并使用 enum 之前的 Java 特性来解决它,enum 是在 Java 5 中引入的。假设您正在开发一个缺陷跟踪应用程序,您需要在其中表示一个缺陷的严重性。该应用程序允许您将缺陷的严重性指定为低、中、高和紧急。在 Java 5 之前,表示四种严重性的典型方式是在一个类中声明四个int常量,比如说Severity,如清单 22-2 所示。

// Severity.java
package com.jdojo.enums;
public class Severity {
    public static final int LOW = 0;
    public static final int MEDIUM = 1;
    public static final int HIGH = 2;
    public static final int URGENT = 3;
}

Listing 22-2A Severity Class with a Few Constants

假设您想要编写一个名为DefectUtil的实用程序类,它有一个方法来根据缺陷的严重性计算缺陷的预计周转天数。DefectUtil类的代码可能如清单 22-3 所示。

// DefectUtil.java
package com.jdojo.enums;
public class DefectUtil {
    public static int getProjectedTurnaroundDays(int severity) {
        int days = 0;
        switch (severity) {
            case Severity.LOW:
                days = 30;
                break;
            case Severity.MEDIUM:
                days = 15;
                break;
            case Severity.HIGH:
                days = 7;
                break;
            case Severity.URGENT:
                days = 1;
                break;
        }
        return days;
    }
    // Other code for the DefectUtil class goes here
}

Listing 22-3A DefectUtil Class

以下是这种方法在处理缺陷严重性时的一些问题:

  • 因为严重性被表示为一个整数常量,所以可以将任何整数值传递给getProjectedTurnaroundDays()方法,而不仅仅是严重性类型的有效值 0、1、2 和 3。您可能希望在此方法中添加一个检查,以便只有有效的严重性值可以传递给它。否则,该方法可能会引发异常。然而,这并不能永远解决问题。每当添加新的严重性类型时,都需要更新检查有效严重性值的代码。

  • 如果更改严重性常量的值,则必须重新编译使用它的代码以反映更改。当你编译DefectUtil类时,Severity.LOW被替换为0Severity.MEDIUM被替换为1,以此类推。如果您将Severity类中常量LOW的值更改为10,您必须重新编译DefectUtil类以反映这一更改。否则,DefectUtil类仍然会继续使用值0

  • 当你在磁盘上保存严重度的值时,会保存其对应的整数值,例如:012等。,不是字符串值LOWMEDIUMHIGH等。对于所有严重性类型,您必须维护一个单独的映射来将整数值转换为其相应的字符串表示形式。

  • 当你打印一个缺陷的严重性值时,它将打印一个整数,例如,012等。严重性的整数值对最终用户没有任何意义。

  • 缺陷的严重性类型有特定的顺序。例如,LOW严重缺陷的优先级低于MEDIUM严重缺陷。因为严重性由任意数字表示,所以您必须使用硬编码的值来编写代码,以保持在Severity类中定义的常量的顺序。假设您添加了另一个严重性类型VERY_HIGH,它的优先级比URGENT低,比HIGH高。现在,您必须更改处理严重性类型排序的代码,因为您已经在现有的严重性类型中间添加了一个。

  • 没有自动的方法(除了通过硬编码)让你列出所有的严重性类型。

您会同意使用整数常量来表示严重性类型是很难维护的。在 Java 5 之前,这是唯一容易实现的定义枚举常量的解决方案。在 Java 5 之前你就可以有效地解决这个问题了。然而,您必须编写的代码量与问题不成比例。Java 5 中的 enum 类型以简单有效的方式解决了这个问题。

根据韦氏词典在线词典,“列举”一词的意思是“一个接一个地指定”这正是枚举类型允许您做的。它允许您以特定的顺序指定常量。枚举类型中定义的常量是该枚举类型的实例。使用关键字enum定义一个枚举类型。它最简单的通用语法是

[access-modifier] enum <enum-type-name> {
    // List of comma separated names of enum constants
}

枚举的访问修饰符与类的访问修饰符相同:publicprivateprotected或包级别。枚举类型名是有效的 Java 标识符。枚举类型的主体放在名称后面的大括号中。枚举类型的主体可以有一列逗号分隔的常量和其他元素,这些元素类似于类中的元素,例如实例变量、方法等。大多数情况下,枚举体只包含常量。下面的代码声明了一个名为Gender的枚举类型,它声明了三个常量—NA、MALE,FEMALE:

public enum Gender {
    NA, MALE, FEMALE; // The semi-colon is optional in this case
}

Tip

用大写字母命名枚举常量是一种约定。如果常量列表后面没有代码,则最后一个枚举常量后面的分号是可选的。

清单 22-4 用四个枚举常量声明了一个名为Severity的公共枚举类型:LOWMEDIUMHIGHURGENT

// Severity.java
package com.jdojo.enums;
public enum Severity {
    LOW, MEDIUM, HIGH, URGENT;
}

Listing 22-4Declaration of a Severity Enum

可以从应用程序中的任何位置访问公共枚举类型。枚举类型的跨模块可访问性规则与其他类型的相同,本书在第十章中介绍了这些规则。

就像一个公共类一样,您需要将清单 22-4 中的代码保存在一个名为Severity.java的文件中。当你编译代码时,编译器会创建一个Severity.class文件。注意,除了使用enum关键字和主体部分之外,Severity枚举类型的所有内容看起来都像是一个类声明。事实上,Java 将 enum 类型实现为一个类。编译器为枚举类型做了很多工作,并为它生成了本质上是一个类的代码。您需要将一个枚举类型放入一个包中,就像您将所有类放入一个包中一样。可以使用import语句将枚举类型导入编译单元,就像导入类类型一样。

声明枚举类型变量的方式与声明类类型变量的方式相同:

// Declare defectSeverity variable of the Severity enum type
Severity defectSeverity;

您可以将null赋给一个枚举类型的变量,如下所示:

Severity defectSeverity = null;

还可以为枚举类型的变量赋什么值?枚举类型定义了两件事:

  • 枚举常量,是其类型的唯一有效值

  • 那些常量的顺序

Severity枚举类型定义了四个枚举常量。因此,Severity枚举类型的变量只能有四个值之一— LOWMEDIUMHIGHURGENT—null。通过将枚举类型名用作限定符,可以使用点标记来引用枚举常量。以下代码片段为Severity枚举类型的变量赋值:

Severity low = Severity.LOW;
Severity medium = Severity.MEDIUM;
Severity high = Severity.HIGH;
Severity urgent = Severity.URGENT;

不能实例化枚举类型。以下试图实例化Severity枚举类型的代码会导致编译时错误:

Severity badAttempt = new Severity(); // A compile-time error

Tip

枚举类型充当类型和工厂。它声明一个新类型和该类型的有效实例列表作为其常量。

枚举类型还为其所有常量分配一个顺序号(或位置号),称为序数。序号从零开始,当您在常量列表中从第一个移动到最后一个时,它会递增 1。第一个枚举常量被赋予序数值 0,第二个为 1,第三个为 2,依此类推。分配给在Severity枚举类型中声明的常量的序数值是 0 到LOW,1 到MEDIUM,2 到HIGH,3 到URGENT。如果更改枚举类型体中常量的顺序或添加新的常量,它们的序数值将相应地更改。

每个枚举常量都有一个名称。枚举常量的名称与其声明中为常量指定的标识符相同。例如,Severity枚举类型中的LOW常量的名称是"LOW"

您可以分别使用name()ordinal()方法读取枚举常量的名称和序号。每个枚举类型都有一个名为values()的静态方法,该方法按照常量在其主体中声明的顺序返回一个常量数组。清单 22-5 中的程序打印在Severity枚举类型中声明的所有枚举常量的名称和序号。

// ListEnumConstants.java
package com.jdojo.enums;
public class ListEnumConstants {
    public static void main(String[] args) {
        for(Severity s : Severity.values()) {
            String name = s.name();
            int ordinal = s.ordinal();
            System.out.println(name + "(" + ordinal + ")");
        }
    }
}
LOW(0)
MEDIUM(1)
HIGH(2)
URGENT(3)

Listing 22-5Listing Name and Ordinal of Enum Type Constants

枚举类型的超类

枚举类型类似于 Java 类类型。事实上,编译器会在编译枚举类型时创建一个类。出于各种实际目的,您可以将枚举类型视为类类型。但是,有一些规则仅适用于枚举类型。枚举类型也可以有构造器、字段和方法。我们不是说过枚举类型不能被实例化吗?(换句话说,new Severity()无效。)如果枚举类型不能实例化,为什么还需要构造器呢?

这就是为什么你需要一个枚举类型的构造器。枚举类型仅在编译器生成的代码中实例化。所有枚举常量都是同一枚举类型的对象。这些实例的创建和命名与编译器生成的代码中的枚举常量相同。编译器在耍花招。编译器为枚举类型生成代码,如下所示。下面的示例代码只是为了让您了解幕后发生的事情。编译器生成的实际代码可能与显示的不同。例如,valueOf()方法的代码给你一种感觉,它将名字与枚举常量名进行比较,并返回匹配的常量实例。实际上,编译器为调用Enum超类中的valueOf()方法的valueOf()方法生成代码:

// Transformed code for Severity enum type declaration
package com.jdojo.enums;
public final class Severity extends Enum {
    public static final Severity LOW;
    public static final Severity MEDIUM;
    public static final Severity HIGH;
    public static final Severity URGENT;
    // Create constants when class is loaded
    static {
         LOW    = new Severity("LOW", 0);
         MEDIUM = new Severity("MEDIUM", 1);
         HIGH   = new Severity("HIGH", 2);
         URGENT = new Severity("URGENT", 3);
    }
    // The private constructor to prevent direct instantiation
    private Severity(String name, int ordinal) {
        super(name, ordinal);
    }
    public static Severity[] values() {
        return new Severity[] { LOW, MEDIUM, HIGH, URGENT };
    }
    public static Severity valueOf(String name) {
        if (LOW.name().equals(name)) {
            return LOW;
        }
        if (MEDIUM.name().equals(name)) {
            return MEDIUM;
        }
        if (HIGH.name().equals(name)) {
             return HIGH;
        }
        if (URGENT.name().equals(name)) {
            return URGENT;
        }
        throw new IllegalArgumentException("Invalid enum constant " + name);
    }
}

通过查看Severity枚举声明的转换代码,可以得出以下几点:

表 22-1

所有枚举类型中都可用的枚举类中的方法列表

|

方法名

|

描述

public final String name() 返回枚举常量的名称,该名称与枚举类型声明中声明的名称完全相同。
public final int ordinal() 返回枚举类型声明中声明的枚举常量的顺序(或位置)。
public final boolean equals(Object other) 如果指定的对象等于枚举常量,则返回true。否则返回false。请注意,枚举类型不能直接实例化,它有固定数量的实例,这些实例等于它声明的枚举常量的数量。这意味着当对两个枚举常量使用==操作符和equals()方法时,它们返回相同的结果。
public final int hashCode() 返回枚举常量的哈希代码值。
public final int``compareTo(E o) 将此枚举常量的顺序与指定枚举常量的顺序进行比较。它返回此枚举常量和指定枚举常量的序数值之差。请注意,要比较两个枚举常量,它们必须是相同的枚举类型。否则,将引发运行时异常。
public final Class<E> getDeclaringClass() 返回声明枚举常量的类的Class对象。如果此方法为两个枚举常量返回相同的类对象,则这两个枚举常量被视为具有相同的枚举类型。注意,由getClass()方法返回的Class对象(每个枚举类型都从Object类继承而来)可能与该方法返回的类对象不同。当枚举常量有主体时,该枚举常量的对象的实际类与声明类不同。实际上,它是声明类的一个子类。
public String toString() 默认情况下,它返回枚举常量的名称,该名称与name()方法的返回值相同。请注意,此方法未声明为 final,因此您可以重写它,以便为每个枚举常量返回更有意义的字符串表示形式。
public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) 返回指定枚举类型和名称的枚举常量。例如,您可以使用以下代码获取代码中Severity枚举类型的LOW枚举常量值:Severity lowSeverity = Enum.valueOf(Severity.class, "LOW")
protected final Object clone() throws CloneNotSupportedException Enum类重新定义了clone()方法。它将方法声明为 final,因此它不能被任何枚举类型重写。方法总是引发异常。这样做是为了防止克隆枚举常量。这确保每个枚举类型只存在一组枚举常量。
protected final void finalize() Enum类被声明为 final,因此它不能被任何枚举类型覆盖。它提供了一个空的身体。因为除了常量之外,不能创建枚举类型的实例,所以为枚举类型创建一个finalize()方法是没有意义的。
  • 每个枚举类型都隐式扩展了java.lang.Enum类。这意味着在Enum类中定义的所有方法都可以用于所有枚举类型。表 22-1 列出了在Enum类中定义的方法。

  • 枚举类型是隐式最终类型。在某些情况下(稍后讨论),编译器不能像在Severity类的示例代码中那样将其声明为 final。

  • 编译器为每个枚举类型添加了两个静态方法,values()valueOf()values()方法按照枚举类型中声明的顺序返回枚举常量数组。您已经看到了清单 22-5 中values()方法的使用。valueOf()方法用于获取一个枚举类型的实例,使用常量名称作为字符串。例如,Severity.valueOf("LOW")将返回Severity.LOW常量。valueOf()方法有助于从字符串值到枚举类型值的反向查找。

  • Enum类实现了java.lang.Comparablejava.io.Serializable接口。这意味着每个枚举类型的实例都可以进行比较和序列化。Enum类确保在反序列化过程中,除了声明为枚举常量的实例之外,不会创建任何其他枚举类型的实例。您可以使用compareTo()方法来确定一个枚举常量是在另一个枚举常量之前还是之后声明的。请注意,您还可以通过比较两个枚举常量的序号来确定它们的顺序。compareTo()方法做了同样的事情,多了一项检查,即被比较的枚举常量必须是相同的枚举类型。下面的代码片段显示了如何比较两个枚举常量:

Severity s1 = Severity.LOW;
Severity s2 = Severity.HIGH;
// s1.compareTo(s2) returns s1.ordinal() - s2.ordinal()
int diff = s1.compareTo(s2);
if (diff > 0) {
    System.out.println(s1 + " occurs after " + s2);
} else {
    System.out.println(s1 + " occurs before " + s2);
}

在 switch 语句中使用枚举类型

您可以在switch语句中使用枚举类型。当switch表达式是枚举类型时,所有事例标签必须是相同枚举类型的非限定枚举常量。switch语句从表达式的类型中推导出枚举类型名。您可以包含一个默认标签。

清单 22-6 包含了使用switch语句的DefectUtil类的修改版本。现在,您不需要处理在getProjectedTurnaroundDays()方法的severity参数中接收null值的异常情况。如果switch语句的枚举表达式的计算结果为null,它将抛出一个NullPointerException

// DefectUtil.java
package com.jdojo.enums;
public class DefectUtil {
    public static int getProjectedTurnaroundDays(Severity severity) {
        int days = 0;
        switch (severity) {
            // Must use the unqualified name LOW, not Severity.LOW
            case LOW:
                days = 30;
                break;
            case MEDIUM:
                days = 15;
                break;
            case HIGH:
                days = 7;
                break;
            case URGENT:
                days = 1;
                break;
        }
        return days;
    }
}

Listing 22-6A Revised Version of the DefectUtil Class Using the Severity Enum

或者使用开关表达式:

    public static int getProjectedTurnaroundDays(Severity severity) {
        return switch (severity) {
            case LOW -> 30;
            case MEDIUM -> 15;
            case HIGH -> 7;
            case URGENT -> 1;
        };
    }

将数据和方法与枚举常量相关联

通常,你声明一个枚举类型只是为了拥有一些枚举常量,就像你在Severity枚举类型中所做的那样。因为一个枚举类型实际上是一个类类型,所以你可以在一个枚举类型主体中声明你可以在一个类主体中声明的几乎所有东西。让我们将一个数据元素,预计周转天数,与您的每个Severity枚举常量相关联。您将把增强的Severity枚举类型命名为SmartSeverity。清单 22-7 包含了SmartSeverity枚举类型的代码,这与Severity枚举类型的代码非常不同。

// SmartSeverity.java
package com.jdojo.enums;
public enum SmartSeverity {
    LOW(30), MEDIUM(15), HIGH(7), URGENT(1);
    // Declare an instance variable
    private int projectedTurnaroundDays;
    // Declare a private constructor
    private SmartSeverity(int projectedTurnaroundDays) {
        this.projectedTurnaroundDays = projectedTurnaroundDays;
    }
    // Declare a public method to get the turnaround days
    public int getProjectedTurnaroundDays() {
        return projectedTurnaroundDays;
    }
}

Listing 22-7A SmartSeverity Enum Type Declaration That Uses Fields, Constructors, and Methods

让我们讨论一下SmartSeverity枚举类型中的新内容:

  • 它声明了一个名为projectedTurnaroundDays的实例变量,该变量将存储每个枚举常量的预计周转天数的值:

  • 它定义了一个私有构造器,该函数接受一个int参数。它将其参数值存储在实例变量中。可以向一个枚举类型添加多个构造器。如果不添加构造器,则添加无参数构造器。不能向枚举类型添加公共或受保护的构造器。枚举类型声明中的所有构造器都要经过编译器的参数和代码转换,并且它们的访问级别被更改为 private。编译器会在枚举类型的构造器中添加或更改许多内容。作为一名程序员,您不需要知道编译器所做更改的细节:

// Declare an instance variable
private int projectedTurnaroundDays;

  • 它声明了一个公共方法getProjectedTurnaroundDays(),该方法返回 enum 常量(或者 enum 类型的实例)的预计周转天数的值。

  • 枚举常量声明已更改为LOW(30), MEDIUM(15), HIGH(7), URGENT(1);。此更改并不明显。现在,每个枚举常量名称后面都有一个圆括号中的整数值,例如,LOW(30)。这个语法是用int参数类型调用构造器的简写。创建枚举常量时,括号内的值将被传递给您添加的构造器。只需在常量声明中使用枚举常量的名称(例如,LOW)),就可以调用默认的无参数构造器。

// Declare a private constructor
private SmartSeverity(int projectedTurnaroundDays) {
    this.projectedTurnaroundDays = projectedTurnaroundDays;
}

清单 22-8 中的程序测试SmartSeverity枚举类型。它打印常量的名称、序数和预计周转天数。注意,计算预计周转天数的逻辑封装在枚举类型本身的声明中。SmartSeverity枚举类型结合了Severity枚举类型的代码和DefectUtil类中的getProjectedTurnaroundDays()方法。您不必再编写switch语句来获得预计的周转天数。每个 enum 常量都知道它的预计周转天数。

// SmartSeverityTest.java
package com.jdojo.enums;
public class SmartSeverityTest {
    public static void main(String[] args) {
        for (SmartSeverity s : SmartSeverity.values()) {
            String name = s.name();
            int ordinal = s.ordinal();
            int days = s.getProjectedTurnaroundDays();
            System.out.println("name=" + name + ", ordinal=" + ordinal
                    + ", days=" + days);
        }
    }
}
name=LOW, ordinal=0, days=30
name=MEDIUM, ordinal=1, days=15
name=HIGH, ordinal=2, days=7
name=URGENT, ordinal=3, days=1

Listing 22-8A Test Class to Test the SmartSeverity Enum Type

将主体与枚举常量关联

SmartSeverity是一个向枚举类型添加数据和方法的例子。对于所有的枚举常量,getProjectedTurnaroundDays()方法中的代码是相同的。您还可以将不同的主体与每个枚举常量相关联。主体可以有字段和方法。枚举常量的主体放在名字后面的大括号中。如果 enum 常量接受参数,则它的主体遵循其参数列表。将主体与枚举常量相关联的语法如下:

[access-modifier] enum <enum-type-name> {
    CONST1 {
        // Body for CONST1 goes here
    },
    CONST2 {
        // Body for CONST2 goes here
    },
    CONST3(arguments-list) {
        // Body of CONST3 goes here
    };
    // Other code goes here
}

当你添加一个主体到一个枚举常量时,这是一个有点不同的游戏。编译器创建一个匿名类,该类继承自枚举类型。它将枚举常量的主体移动到匿名类的主体。匿名类在本系列的第二卷中有更详细的介绍。我们简单地用它来完成枚举类型的讨论。现在,你可以把它看作是声明一个类,同时创建该类的对象的不同方式。

考虑一个ETemp枚举类型,如下所示:

public enum ETemp {
    C1 {
        // Body of constant C1
        public int getValue() {
            return 100;
        }
    },
    C2,
    C3;
}

ETemp枚举类型的主体声明了三个常量:C1C2C3。你在C1常量中增加了一个物体。编译器会将ETemp的代码转换成类似下面的代码:

public enum ETemp {
    public static final ETemp C1 = new ETemp() {
        // Body of constant C1
        public int getValue() {
            return 100;
        }
    };
    public static final ETemp C2 = new ETemp();
    public static final ETemp C3 = new ETemp();
    // Other code goes here
}

请注意,常量C1被声明为类型ETemp,并使用匿名类分配了一个对象。ETemp枚举类型不知道匿名类中定义的getValue()方法。因此,它对于所有的实际用途都是无用的,因为你不能像ETemp.C1.getValue()那样调用这个方法。

为了让客户端代码使用getValue()方法,您必须为ETemp枚举类型声明一个getValue()方法。如果你想让ETemp的所有常量覆盖并提供这个方法的实现,你需要将它声明为abstract。如果您希望它被一些(但不是全部)常量覆盖,您需要声明它是非抽象的,并为它提供一个默认实现。以下代码为ETemp枚举类型声明了一个getValue()方法,该方法返回 0:

public enum ETemp {
    C1 {
        // Body of constant C1
        public int getValue() {
            return 100;
        }
    },
    C2,
    C3;
    // Provide the default implementation for the getValue() method
    public int getValue() {
        return 0;
    }
}

C1常量有它的主体,它覆盖了getValue()方法并返回100。注意常量C2C3不一定要有体;他们不需要覆盖getValue()方法。现在,您可以在ETemp枚举类型上使用getValue()方法。

下面的代码重写了先前版本的ETemp并声明了getValue()方法abstract。一个 enum 类型的abstract方法强迫你为所有的常量提供一个主体并覆盖那个方法。现在所有的常量都有一个体。每个常量的主体覆盖并提供了getValue()方法的实现:

public enum ETemp {
    C1 {
        // Body of constant C1
        public int getValue() {
            return 100;
        }
    },
    C2 {
        // Body of constant C2
        public int getValue() {
            return 0;
        }
    },
    C3 {
        // Body of constant C3
        public int getValue() {
            return 0;
        }
    };
    // Make the getValue() method abstract
    public abstract int getValue();
}

让我们增强您的SmartSeverity枚举类型。您的枚举类型已经没有好的名称了。你将新的命名为SuperSmartSeverity。清单 22-9 有代码。

// SuperSmartSeverity.java
package com.jdojo.enums;
public enum SuperSmartSeverity {
    LOW("Low Priority", 30) {
        @Override
        public double getProjectedCost() {
            return 1000.0;
        }
    },

    MEDIUM("Medium Priority", 15) {
        @Override
        public double getProjectedCost() {
            return 2000.0;
        }
    },
    HIGH("High Priority", 7) {
        @Override
        public double getProjectedCost() {
            return 3000.0;
        }
    },
    URGENT("Urgent Priority", 1) {
        @Override
        public double getProjectedCost() {
            return 5000.0;
        }
    };
    // Declare instance variables
    private final String description;
    private final int projectedTurnaroundDays;
    // Declare a private constructor
    private SuperSmartSeverity(String description,
            int projectedTurnaroundDays) {
        this.description = description;
        this.projectedTurnaroundDays = projectedTurnaroundDays;
    }
    // Declare a public method to get the turn around days
    public int getProjectedTurnaroundDays() {
        return projectedTurnaroundDays;
    }
    // Override the toString() method in the Enum class to return description
    @Override
    public String toString() {
        return this.description;
    }
    // Provide getProjectedCost() abstract method, so all constants
    // override and provide implementation for it in their body
    public abstract double getProjectedCost();
}

Listing 22-9Using a Body for Enum Constants

以下是SuperSmartSeverity枚举类型中的新特性:

  • 它添加了一个抽象方法getProjectedCost()来返回每种严重程度的预计成本。

  • 它为每个常量提供了一个主体,为getProjectedCost()方法提供了实现。请注意,在枚举类型中声明抽象方法会强制您为其所有常量提供一个主体。

  • 它向构造器添加了另一个参数,这是严重性类型的一个更好的名称。

  • 它覆盖了Enum类中的toString()方法。Enum类中的toString()方法返回常量的名称。您的toString()方法为每个常量返回一个简短且更直观的名称。

    典型地,你不需要为一个枚举类型写这种复杂的代码。Java enum 非常强大。如果你需要的话,它有你可以利用的特性。

清单 22-10 中的代码演示了添加到SuperSmartSeverity枚举类型中的新特性的使用。

// SuperSmartSeverityTest.java
package com.jdojo.enums;
public class SuperSmartSeverityTest {
    public static void main(String[] args) {
        for (SuperSmartSeverity s : SuperSmartSeverity.values()) {
            String name = s.name();
            String desc = s.toString();
            int ordinal = s.ordinal();
            int projectedTurnaroundDays = s.getProjectedTurnaroundDays();
            double projectedCost = s.getProjectedCost();
            System.out.println("name=" + name
                    + ", description=" + desc
                    + ", ordinal=" + ordinal
                    + ", turnaround days="
                    + projectedTurnaroundDays
                    + ", projected cost=" + projectedCost);
        }
    }

}
name=LOW, description=Low Priority, ordinal=0, turnaround days=30, projected cost=1000.0
name=MEDIUM, description=Medium Priority, ordinal=1, turnaround days=15, projected cost=2000.0
name=HIGH, description=High Priority, ordinal=2, turnaround days=7, projected cost=3000.0
name=URGENT, description=Urgent Priority, ordinal=3, turnaround days=1, projected cost=5000.0

Listing 22-10A Test Class to Test the SuperSmartSeverity Enum Type

比较两个枚举常量

可以用三种方式比较两个枚举常量:

  • 使用Enum类的compareTo()方法

  • 使用Enum类的equals()方法

  • 使用==操作符

Enum类的compareTo()方法允许你比较相同枚举类型的两个枚举常量。它返回两个枚举常量的序数之差。如果两个枚举常量相同,则返回零。下面的代码片段将打印出-3,因为LOW(ordinal=0)URGENT(ordinal=3)的序数之差是–3。负值表示被比较的常量出现在被比较的常量之前:

Severity s1 = Severity.LOW;
Severity s2 = Severity.URGENT;
int diff = s1.compareTo(s2);
System.out.println(diff);
-3

假设您有另一个名为BasicColor的枚举,如清单 22-11 所示。

// BasicColor.java
package com.jdojo.enums;
public enum BasicColor {
    RED, GREEN, BLUE;
}

Listing 22-11A BasicColor Enum

下面的代码片段不会编译,因为它试图比较属于不同枚举类型的两个枚举常量:

int diff = BasicColor.RED.compareTo(Severity.URGENT); // A compile-time error

您可以使用Enum类的equals()方法来比较两个枚举常量是否相等。枚举常量只等于自身。注意,equals()方法可以在两个不同类型的枚举常量上调用。如果两个枚举常量来自不同的枚举类型,该方法返回false:

Severity s1 = Severity.LOW;
Severity s2 = Severity.URGENT;
BasicColor c = BasicColor.BLUE;
System.out.println(s1.equals(s1));
System.out.println(s1.equals(s2));
System.out.println(s1.equals(c));
true
false
false

您也可以使用相等运算符(==)来比较两个枚举常量是否相等。==运算符的两个操作数必须是相同的枚举类型。否则,您会得到一个编译时错误:

Severity s1 = Severity.LOW;
Severity s2 = Severity.URGENT;
BasicColor c = BasicColor.BLUE;
System.out.println(s1 == s1);
System.out.println(s1 == s2);
// A compile-time error. Cannot compare Severity and BasicColor enum types
//System.out.println(s1 == c);
true
false

嵌套枚举类型

可以有嵌套的枚举类型声明。可以在类、接口或其他枚举类型中声明嵌套枚举类型。嵌套枚举类型是隐式静态的。还可以在其声明中显式声明嵌套的枚举类型 static。由于枚举类型始终是静态的,无论是否声明,都不能声明局部枚举类型(例如,在方法体中)。对于嵌套的枚举类型,可以使用任何访问修饰符(publicprivateprotected或包级别)。清单 22-12 显示了在Person类中声明名为Gender的嵌套public枚举类型的代码。

// Person.java
package com.jdojo.enums;
public class Person {
    public enum Gender {MALE, FEMALE, NA}
}

Listing 22-12A Gender Enum Type as a Nested Enum Type Inside a Person Class

可以从同一个模块中的任何地方访问Person.Gender枚举类型,因为它已经被声明为公共的。在其他模块中访问它取决于模块可访问性规则。您需要导入枚举类型,以便在其他包中使用它的简单名称,如下面的代码所示:

// Test.java
package com.jdojo.enums.pkg1;
import com.jdojo.enums.Person.Gender;
public class Test {
    public static void main(String[] args) {
        Gender m = Gender.MALE;
        Gender f = Gender.FEMALE;
        System.out.println(m);
        System.out.println(f);
    }
}
MALE
FEMALE

通过使用静态导入来导入枚举常量,也可以使用枚举常量的简单名称。下面的代码片段使用了MALEFEMALE,它们是Person.Gender枚举类型的常量的简单名称。注意,需要第一个import语句来导入Gender类型本身,以便在代码中使用它的简单名称:

// Test.java
package com.jdojo.enums.pkg1;
import com.jdojo.enums.Person.Gender;
import static com.jdojo.enums.Person.Gender.*;
public class Test {
    public static void main(String[] args) {
        Gender m = MALE;
        Gender f = FEMALE;
        System.out.println(m);
        System.out.println(f);
    }
}
MALE
FEMALE

还可以将一个枚举类型嵌套在另一个枚举类型或接口中。以下是有效的枚举类型声明:

public enum OuterEnum {
    C1, C2, C3;
    public enum NestedEnum {
        C4, C5, C6;
    }
}
public interface MyInterface {
    int operation1();
    int operation2();
    public enum AnotherNestedEnum {
        CC1, CC2, CC3;
    }
}

实现枚举类型的接口

枚举类型可以实现接口。实现接口的枚举类型的规则与实现接口的类的规则相同。一个枚举类型永远不会被另一个枚举类型继承。因此,不能将枚举类型声明为抽象类型。这也意味着,如果一个枚举类型实现了一个接口,它也必须为该接口中的所有抽象方法提供实现。清单 22-13 中的程序声明了一个Command接口。

// Command.java
package com.jdojo.enums;
public interface Command {
    void execute();
}

Listing 22-13A Command Interface

清单 22-14 中的程序声明了一个名为CommandList的枚举类型,它实现了Command接口。每个枚举常量实现了Command接口的execute()方法。或者,您可以在枚举类型主体中实现execute()方法,并省略一些或所有枚举常量的实现。清单 22-15 演示了如何使用CommandList枚举类型中的枚举常量作为Command类型。

// CommandTest.java
package com.jdojo.enums;
public class CommandTest {
    public static void main(String... args) {
        // Execute all commands in the command list
        for(Command cmd : CommandList.values()) {
            cmd.execute();
        }
    }
}
Running...
Jumping...

Listing 22-15Using the CommandList Enum Constants as Command Types

// CommandList.java
package com.jdojo.enums;
public enum CommandList implements Command {
    RUN {
        @Override
        public void execute() {
            System.out.println("Running...");
        }
    },
    JUMP {
        @Override
        public void execute() {
            System.out.println("Jumping...");
        }
    };
    // Force all constants to implement the execute() method.
    @Override
    public abstract void execute();
}

Listing 22-14 A CommandList Enum Type Implementing the Command Interface

枚举常量的反向查找

如果知道枚举常量的名称或在列表中的位置,就可以得到它的引用。这被称为基于枚举常量的名称或序号的反向查找。您可以使用由编译器添加到枚举类型中的valueOf()方法,根据名称执行反向查找。您可以使用由values()方法返回的数组(由编译器添加到枚举类型中)按序号执行反向查找。由 values ()方法返回的数组中值的顺序与声明枚举常量的顺序相同。枚举常量的序号从零开始。这意味着枚举常量的序数值可以用作由values()方法返回的数组中的索引。下面的代码片段演示了如何反向查找枚举常量:

Severity low1 = Severity.valueOf("LOW"); // A reverse lookup using a name
Severity low2 = Severity.values()[0];    // A reverse lookup using an ordinal
System.out.println(low1);
System.out.println(low2);
System.out.println(low1 == low2);
LOW
LOW
true

枚举常量的反向查找区分大小写。如果在valueOf()方法中使用了无效的常量名,就会抛出一个IllegalArgumentException。例如,Severity.valueOf("low")将抛出一个IllegalArgumentException,声明在Severity枚举中不存在名为“low”的枚举常量。

枚举常量的范围

Java API 提供了一个java.util.EnumSet集合类来处理枚举类型的枚举常量范围。EnumSet类的实现非常高效。假设您有一个名为Day的枚举类型,如清单 22-16 所示。

// Day.java
package com.jdojo.enums;
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}

Listing 22-16A Day Enum Type

您可以使用EnumSet类处理一系列日期;例如,您可以获得从MONDAYFRIDAY之间的所有天数。一个EnumSet只能包含一个枚举类型的枚举常量。清单 22-17 展示了如何使用EnumSet类来处理枚举常量的范围。

// EnumSetTest.java
package com.jdojo.enums;
import java.util.EnumSet;
public class EnumSetTest {
    public static void main(String[] args) {
        // Get all constants of the Day enum
        EnumSet<Day> allDays = EnumSet.allOf(Day.class);
        print(allDays, "All days: ");
        // Get all constants from MONDAY to FRIDAY of the Day enum
        EnumSet<Day> weekDays = EnumSet.range(Day.MONDAY, Day.FRIDAY);
        print(weekDays, "Weekdays: ");
        // Get all constants that are not from MONDAY to FRIDAY of the Day enum.
        // Essentially, we will get days representing weekends.
        EnumSet<Day> weekends = EnumSet.complementOf(weekDays);
        print(weekends, "Weekends: ");
    }
    public static void print(EnumSet<Day> days, String msg) {
        System.out.print(msg);
        for (Day d : days) {
            System.out.print(d + " ");
        }
        System.out.println();
    }
}
All days: MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY SATURDAY SUNDAY
Weekdays: MONDAY TUESDAY WEDNESDAY THURSDAY FRIDAY
Weekends: SATURDAY SUNDAY

Listing 22-17A Test Class to Demonstrate How to Use the EnumSet Class

摘要

像类和接口一样,枚举在 Java 中定义了一个新的引用类型。枚举类型由一组预定义的有序值组成,这些值称为枚举类型的元素或常量。枚举类型的常量有一个名称和一个序号。您可以使用枚举常量的名称和序号来获取它的引用,反之亦然。通常,枚举类型用于定义类型安全常量。

枚举类型拥有类所拥有的一些东西。它有构造器、实例变量和方法。但是,枚举类型的构造器是隐式私有的。枚举类型也可以像类一样实现接口。

您可以声明枚举类型的变量。变量可以被赋值为null或者枚举类型的常量之一。每个枚举类型都隐式地继承自java.lang.Enum类。枚举类型可以实现接口。在switch语句或表达式中可以使用枚举类型。Java 提供了一个EnumSet类的有效实现,以处理一系列特定枚举类型的枚举常量。

QUESTIONS AND EXERCISES

  1. Java 中的枚举类型是什么?

  2. Java 中所有枚举的超类是什么?

  3. Java 中的一个枚举可以扩展另一个枚举吗?

  4. Java 中的 enum 可以实现一个或多个接口吗?

  5. 下面的枚举声明有效吗?如果是,它声明了多少个枚举常量?

    public enum Gender {
       MALE, FEMALE,
    }
    
    
  6. Consider the following declaration for an enum named Day:

    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
    }
    
    

    给定一串“星期五”,你将如何查找这一天。星期五枚举常量?

  7. Consider the following declaration for an enum named Day:

    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
    }
    
    

    你如何查找一天的序数?周日吗?

  8. 考虑下面名为Day :

    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
    }
    
    

    的枚举的声明,完成下面的代码片段,它将打印 DAY 枚举中星期二的序数。它应该打印 1:

    String dayName = "TUESDAY";
    int ordinal = /* Complete this statement. */;
    System.out.println(ordinal);
    
    
  9. Consider the following declaration for an enum named Day:

    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
    }
    
    

    使用 for-each 循环打印每一天的名称及其序号,如星期一(0)、星期二(1)等。

  10. 编写以下代码片段的输出:

```java
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}
EnumSet<Day> es = EnumSet.range(Day.TUESDAY, Day.FRIDAY);
for(Day d : es) {
    System.out.printf("%s(%d)%n", d.name(), d.ordinal());
}

```
  1. 编写以下代码片段的输出:
```java
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}
EnumSet<Day> es =
    EnumSet.complementOf(EnumSet.range(Day.TUESDAY, Day.FRIDAY));
for(Day d : es) {
    System.out.printf("%s(%d)%n", d.name(), d.ordinal());
}

```
  1. 考虑下面这个名为Country :
```java
public enum Country {
    BHUTAN("Bhutan", "BT"),
    BRAZIL("Brazil", "BR"),
    FIJI("Fiji", "FJ"),
    INDIA("India", "IN"),
    SPAIN("Spain", "ES");
    private final String fullName;
    private final String isoName;
    private Country(String fullName, String isoName) {
        this.fullName = fullName;
        this.isoName = isoName;
    }
    public String fullName() {
        return this.fullName;
    }
    public String isoName() {
        return this.isoName;
    }
    @Override
    public String toString() {
        return this.fullName;
    }
}

```

的枚举声明,当执行下面的代码片段时,写出输出:

```java
for(Country c : Country.values()) {
    System.out.printf("%s[%d, %s, %s]%n",
          c.name(), c.ordinal(), c, c.isoName());
}

```
  1. 考虑下面对一个Gender枚举的声明:
```java
public enum Gender {
    MALE, FEMALE
}

```

修改性别枚举的代码,以便下面代码片段的输出如代码后面的预期输出部分所示。您应该更改性别枚举的代码,而不是下面的代码片段:

```java
for(Gender c : Gender.values()) {
    System.out.printf("%s%n", c);
}

```

预期输出:

```java
Male
female

```
  1. 假设Color是一个枚举。下面的MyFavColor枚举声明是否有效?如果不是,请解释你的答案:
```java
public enum MyFavColor extends Color {
    WHITE, BLACK
}

```
  1. 当下面的代码片段运行时,输出会是什么?
```java
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}
Day[] days = {Day.FRIDAY, Day.MONDAY, Day.WEDNESDAY};
System.out.println(Arrays.toString(days));
Arrays.sort(days);
System.out.println(Arrays.toString(days));

```
  1. 以下代码片段的输出会是什么?
```java
public enum Gender {
    MALE, FEMALE, NA
}
System.out.println(Gender.MALE == Gender.MALE);
System.out.println(Gender.MALE.equals(Gender.MALE));

```

二十三、Java Shell

在本章中,您将学习:

  • Java shell 是什么

  • 什么是 JShell 工具和 JShell API

  • 如何配置 JShell 工具

  • 如何使用 JShell 工具评估 Java 代码片段

  • 如何使用 JShell API 评估 Java 代码片段

本章中的所有示例程序都是清单 23-1 中声明的jdojo.jshell模块的成员。

// module-info.java
module jdojo.jshell {
    exports com.jdojo.jshell;
    requires jdk.jshell;
}

Listing 23-1The Declaration of a jdojo.jshell Module

在你开始阅读本章之前,让我们先弄清楚本章中经常使用的以下三个短语的用法:

  • JShell 命令行工具或 JShell 工具

  • jshell

  • JShell API

在这一章中,讨论的主要话题是 JShell,它既可以用作命令行工具,也可以用作 Java API。“JShell 命令行工具”指的是 JShell 被用作命令行工具的能力。JShell 命令行工具名为jshell(全小写),当你在 Windows 上安装 JDK 时,它作为一个jshell.exe文件安装在JDK_HOME\bin目录下。“JShell API”指的是 JShell 作为 Java API 的能力。

什么是 Java Shell?

被称为 JShell 的 Java shell 是一个命令行工具,它提供了一种访问 Java 编程语言的交互方式。它让您评估 Java 代码片段,而不是强迫您编写整个 Java 程序。这是 Java 的一个REPL (read-eval-print 循环)。JShell 也是一个 API,您可以使用它来开发应用程序,以提供与 JShell 命令行工具相同的功能。

READ-Eval-Print 循环(REPL)是一个命令行工具(也称为交互式语言外壳),它让用户快速评估代码片段,而不必编写完整的程序。REPL这个名字来源于 Lisp 中的三个原始函数——readevalprint——在一个循环中使用。read函数读取用户输入并将其解析成数据结构;eval函数评估已解析的用户输入以产生结果;print函数打印结果。打印结果后,该工具准备好再次接受用户输入,从而触发读取-评估-打印循环。术语REPL用于一个交互式工具,让你与编程语言进行交互。图 23-1 为REPL的概念图。UNIX shell 或 Windows 命令提示符的作用类似于REPL,它读取操作系统命令,执行它,打印输出,并等待读取另一个命令。

img/323069_3_En_23_Fig1_HTML.png

图 23-1

读取-评估-打印循环的概念图

为什么在 JDK 9 中增加了 JShell?其中一个主要原因是来自学术界的反馈,即 Java 有一个陡峭的学习曲线。其他编程语言如 Lisp、Python、Ruby、Groovy 和 Clojure 也支持REPL很久了。只为写一句“你好,世界!”用 Java 编写程序,你必须求助于编辑-编译-执行循环(ECEL ),包括编写一个完整的程序,编译它,然后执行它。如果您需要进行更改,您必须重复这些步骤。除了一些其他的内务工作,比如定义目录结构、编译和执行程序,下面是打印“Hello,world!”Java 程序中的消息:

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

这个程序在执行时会在控制台上打印一条消息:"Hello, world!"。编写一个完整的程序来计算这样一个简单的表达式是多余的。这是学术界不再将 Java 作为初始编程语言教授给学生的主要原因。Java 设计者听取了教学社区的反馈,并在 JDK 9 中引入了 JShell 工具。要实现与 HelloWorld 程序相同的功能,您只需在jshell命令提示符下编写一行代码:

jshell> System.out.println("Hello, world!")
Hello, world!
jshell>

第一行是您在jshell命令提示符下输入的代码;第二行是输出。打印输出后,jshell提示符返回,可以输入另一个 Java 表达式进行求值。

Tip

Java 11 引入了运行单一 Java 源代码文件的能力。您仍然需要用 main 方法定义一个类。例如,您可以用前面列出的源代码创建一个名为HelloWorld.java的文件,并在命令行上运行“java HelloWorld.java”。Java 将编译并执行代码。您也可以像使用普通 Java 命令一样使用--class-path--module-path这样的选项。您可以使用--source选项来指定源代码的 Java 版本兼容性。例如,要指定 Java 17,您可以在命令行上运行“java --source 17 HelloWorld.java”。

JDK 附带了一个 JShell 命令行工具和JShell API。API 也支持该工具支持的所有特性。也就是说,您可以使用该工具或使用 API 以编程方式运行代码片段。在这个讨论中,你应该能够利用上下文来区分这两者。这一章的大部分都在解释这个工具。最后,我们用一个例子来描述这个 API。

JShell 不是一种新的语言或新的编译器。它是一个工具和 API,用于交互式访问 Java 编程语言。对于初学者来说,它提供了一种快速探索 Java 编程语言的方法。对于有经验的开发人员来说,它提供了一种快速查看代码片段结果的方法,而不必编译和运行整个程序。它还提供了一种使用增量方法快速开发原型的方法。您添加一段代码,获得即时反馈,然后添加另一段代码,直到原型完成。

JShell 架构

Java 编译器本身不识别诸如方法声明或变量声明之类的片段。只有类和import语句可以是顶级构造,它们可以独立存在。其他类型的代码片段必须是类的一部分。JShell 允许您执行 Java 代码片段,并允许您对它们进行改进。

当前 JShell 架构的指导原则是使用现有的 Java 语言支持和 JDK 中的其他 Java 技术来保持它与该语言的当前和未来版本的兼容性。随着 Java 语言的不断发展,它在 JShell 中的支持也将不断发展,只需对 JShell 实现做很少或不做任何修改。图 23-2 展示了 JShell 的高层架构。

img/323069_3_En_23_Fig2_HTML.jpg

图 23-2

JShell 架构

JShell 工具使用 JLine 的版本 2,这是一个用于处理控制台输入的 Java 库。标准的 JDK 编译器不知道如何解析和编译 Java 代码片段。因此,JShell 实现有自己的解析器来解析代码片段并确定代码片段的类型,例如,方法声明、变量声明等。一旦确定了代码片段类型,就使用以下规则将代码片段包装在合成类中:

  • 导入语句按“原样”使用也就是说,所有的import语句都“按原样”放在合成类的顶部。

  • 变量、方法和类声明成为合成类的静态成员。

  • 表达式和语句包装在合成类的合成方法中。

所有合成类都属于一个名为REPL的包。一旦代码片段被包装,标准 Java 编译器就会使用编译器 API 对包装后的源代码进行分析和编译。编译器将包装后的字符串格式的源代码作为输入,并将其编译成字节码,存储在内存中。生成的字节码通过套接字发送到运行 JVM 的远程进程进行加载和执行。有时,加载到远程 JVM 中的现有代码片段需要被 JShell 工具替换,这是使用 Java 调试器 API 完成的。

启动 JShell 工具

JDK 17 附带了一个 JShell 工具,它位于JDK_HOME\bin目录中。这个工具被命名为jshell。如果你在 Windows 上的C:\java17目录下安装了 JDK 17,你会有一个名为C:\java17\bin\jshell.exe的可执行文件,这就是 JShell 工具。要启动 JShell 工具,您需要打开命令提示符并输入jshell命令:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell>

在命令提示符下输入jshell命令可能会给你一个错误:

C:\JavaFun>jshell
'jshell' is not recognized as an internal or external command,
operable program or batch file.
C:\JavaFun>

此错误表明JDK_HOME\bin目录没有包含在您计算机上的PATH环境变量中。如果你在C:\java17目录下安装了 JDK 17,那么JDK_HOME就是你的C:\java17。要修复这个错误,要么在PATH环境变量中包含C:\java17\bin目录,要么使用jshell命令的完整路径,即C:\java17\bin\jshell。以下命令序列显示了如何在 Windows 上设置PATH环境变量并运行JShell工具:

C:\JavaFun>SET PATH=C:\java17\bin;%PATH%
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell>

以下命令显示了如何使用jshell命令的完整路径来启动该工具:

C:\JavaFun>C:\java17\bin\jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell>

jshell成功启动时,它会打印一条包含其版本信息的欢迎消息。它还打印命令,即/help intro。您可以使用此命令打印工具本身的简短介绍:

jshell> /help intro
|
|  intro
|
|  The jshell tool allows you to execute Java code, getting immediate results.
|  You can enter a Java definition (variable, method, class, etc), like:  int x = 8
|  or a Java expression, like:  x + x
|  or a Java statement or import.
|  These little chunks of Java code are called 'snippets'.
|
|  There are also jshell commands that allow you to understand and
|  control what you are doing, like:  /list
|
|  For a list of commands: /help
jshell>

如果您需要工具方面的帮助,您可以在jshell上输入命令/help来打印命令列表及其简短描述:

jshell> /help
<The output is not shown here.>
jshell>

Tip

NetBeans 和其他 ide 集成了对 JShell 工具的支持。您可以从 NetBeans IDE 中打开 JShell 提示符,方法是选择“工具”“➤”“打开 Java 平台外壳”菜单。NetBeans JShell 提示符为您提供了jshell命令行工具的所有功能以及更多功能——全部使用 UI 选项。例如,NetBeans 允许您使用其“保存到类”工具栏选项将所有代码片段保存为一个类。它还允许您像在普通 Java 编辑器中一样自动完成代码。IntelliJ IDEA 有一个内置的 JShell,Eclipse 和 Visual Studio 代码有在 IDE 中使用 JShell 的扩展。

您可以在jshell命令中使用几个命令行选项,将值传递给工具本身。例如,您可以将值传递给用于解析和编译代码片段的编译器,以及用于执行/评估代码片段的远程 JVM。运行带有--help选项的jshell程序,查看所有可用标准选项的列表。使用--help-extra-X选项运行它,查看所有可用的非标准选项列表。例如,使用这些选项,您可以为JShell工具设置类路径和模块路径。我们将在本章后面解释这些选项。

您还可以使用命令行--start选项定制jshell工具的启动脚本。您可以使用DEFAULTPRINTING作为该选项的参数。DEFAULT参数以几个import语句开始jshell,所以在使用jshell时不需要导入常用的类。以下两个命令以同样的方式启动jshell:

  • jshell

  • jshell --start DEFAULT

您可以使用System.out.println()方法将消息打印到标准输出。您可以使用带有PRINTING参数的--start选项来启动jshell,这将包括所有版本的System.out.print()System.out.println()System.out.printf()方法作为print()println()printf()顶级方法。这将允许您在jshell上使用print()println()printf()方法,而不是它们更长的版本System.out.print()System.out.println()System.out.printf():

C:\JavaFun>jshell --start PRINTING
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> println("hello")
hello
jshell>

您可以在启动jshell时重复--start选项,以包含默认的import语句和打印方法:

C:\JavaFun>jshell --start DEFAULT --start PRINTING
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell>

退出 JShell 工具

要退出jshell,在jshell提示符下输入/exit并按回车键。该命令打印一条再见消息,退出该工具,并返回到命令提示符:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /exit
|  Goodbye
C:\JavaFun>

这个工具在很多方面都是宽容的。如果在 Java 结构中使用了不支持的关键字,它就会忽略它。您可以使用部分命令。如果您输入的部分命令可以自动完成为唯一的命令名,该工具将像您输入完整命令一样工作。例如,/edit/exit是两个以/e开头的命令。如果您输入的是/ex而不是/exitjshell会将其解释为/exit命令:

jshell> /ex
|  Goodbye
C:\JavaFun>

如果您输入/e,您将收到一个错误,因为有多个可能的命令以/e开头:

jshell> /e
|  Command: '/e' is ambiguous: /edit, /exit, /env
|  Type /help for help.
jshell>

什么是代码片段和命令?

您可以使用 JShell 工具来

  • 评估 Java 代码片段,在 JShell 术语中简称为片段

  • 执行命令,这些命令用于查询 JShell 状态和设置 JShell 环境。

为了区分命令和片段,所有命令都以斜杠(/)开头。你已经在前面的章节中看到了一些,比如/exit/help。命令用于与工具本身进行交互,例如自定义其输出、打印帮助、退出工具以及打印命令和代码片段的历史记录。这本书后面会解释更多的命令。如果你有兴趣学习所有可用的命令,使用/help命令。

使用 JShell 工具,您可以一次编写一段 Java 代码并对其进行评估。这些代码片段被称为片段。代码片段必须遵循在 Java 语言规范中指定的语法。片段可以是

  • 进口申报

  • 类声明

  • 接口声明

  • 方法声明

  • 字段声明

  • 声明

  • 公式

Tip

您可以在 JShell 中使用所有的 Java 语言结构,除了包声明。JShell 中的所有代码片段都出现在名为REPL的内部包和内部合成类中。

JShell 工具知道您何时完成了代码片段的输入。当您按 Enter 键时,如果代码片段完成,该工具将执行它,或者将您带到下一行,等待您完成代码片段。如果一行以...>开头,说明代码片段不完整,需要输入更多的文本来完成代码片段。更多输入的默认提示是...>,可以定制。这里有几个例子:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> 2 + 2
$1 ==> 4
jshell> 2 +
   ...> 2
$2 ==> 4
jshell> 2
$3 ==> 2
jshell>

当你输入2 + 2并按回车键时,jshell将其视为一个完整的片段(一个表达式)。它对表达式求值并输出反馈,表明表达式的值为 4,结果被赋给一个名为$1的变量。名为$1的变量是由工具自动生成的。这本书将在后面更详细地解释工具生成的变量。当您输入2 +并按回车键时,jshell会提示您输入更多内容,因为2 +在 Java 中并不是一个完整的代码片段。当您在第二行输入2时,代码片段就完成了;jshell评估片段并打印反馈。当您输入2并按回车键时,jshell会评估代码片段,因为2本身就是一个完整的表达式。

评估表达式

您可以在jshell中执行任何有效的 Java 表达式。以下示例计算两个数字相加和相乘的表达式:

jshell> 2 + 2
$1 ==> 4
jshell> 9.0 * 6
$2 ==> 54.0

评估表达式时,如果表达式评估为一个值,jshell会打印反馈。在这些情况下,2 + 2计算为 4,9.0 * 6计算为54.0。表达式的值被赋给一个变量。反馈包含变量的名称和表达式的值。在第一种情况下,反馈$1 ==> 4意味着表达式2 + 2的计算结果为4,结果被赋给一个名为$1的变量。类似地,表达式9.0 * 6被评估为54.0,并且值被分配给名为$2的变量。您可以在其他表达式中使用这些变量名。只需输入它们的名称,就可以打印它们的值:

jshell> $1
$1 ==> 4
jshell> $2
$2 ==> 54.0
jshell> System.out.println($1)
4
jshell> System.out.println($2)
54.0

Tip

jshell中,你不需要像在 Java 程序中那样用分号结束一个语句。该工具将为您插入缺少的分号。

在 Java 中,每个变量都有一个数据类型。在这些例子中,名为$1$2的变量的数据类型是什么?在 Java 中,2 + 2的计算结果是int,,而9.0 * 6的计算结果是double。因此,$1$2变量的数据类型应该分别是intdouble。你如何证实这一点?让我们先来硬的。您可以将$1$2强制转换为Object并对它们调用getClass()方法,这将为您提供IntegerDouble。请注意,在这些示例中,当您将intdouble类型的原始值转换为Object类型时,它们被装箱为IntegerDouble引用类型:

jshell> 2 + 2
$1 ==> 4
jshell> 9.0 * 6
$2 ==> 54.0
jshell> ((Object)$1).getClass()
$3 ==> class java.lang.Integer
jshell> ((Object)$2).getClass()
$4 ==> class java.lang.Double
jshell>

有一种更简单的方法来确定由jshell创建的变量的数据类型——您只需告诉jshell给你详细的反馈,它将打印它创建的变量的数据类型以及更多!以下命令将反馈模式设置为verbose,并评估相同的表达式:

jshell> /set feedback verbose
|  Feedback mode: verbose
jshell> 2 + 2
$1 ==> 4
|  created scratch variable $1 : int
jshell> 9.0 * 6
$2 ==> 54.0
|  created scratch variable $2 : double
jshell>

注意,jshell将名为$1$2的变量的数据类型分别打印为intdouble。对于初学者来说,使用-retain选项执行以下命令会很有帮助,因此详细反馈模式会在jshell会话中持续:

jshell> /set feedback -retain verbose

您也可以使用/vars命令列出在jshell中定义的所有变量:

jshell> /vars
|    int $1 = 4
|    double $2 = 54.0
jshell>

如果想再次使用normal反馈模式,使用以下命令:

jshell> /set feedback -retain normal
|  Feedback mode: normal
Jshell>

你并不局限于计算简单的表达式,比如2 + 2。你可以计算任何 Java 表达式。以下示例评估字符串串联表达式并使用String类的方法。它还向您展示了如何使用for循环:

jshell> "Hello " + "world! " + 2017
$1 ==> "Hello world! 2017"
jshell> $1.length()
$2 ==> 17
jshell> $1.toUpperCase()
$3 ==> "HELLO WORLD! 2017"
jshell> $1.split(" ")
$4 ==> String[3] { "Hello", "world!", "2017" }
jshell> for(String s : $4) {
   ...>     System.out.println(s);
   ...> }
Hello
world!
2017
Jshell>

列表片段

无论你在jshell中输入什么,都会成为片段的一部分。每个代码片段都分配有一个唯一的代码片段 ID,您可以使用它在以后引用代码片段,例如,删除代码片段。/list命令列出了所有代码片段。它有以下形式:

  • /list

  • /list -all

  • /list -start

  • /list <snippet-name>

  • /list <snippet-id>

不带参数/选项的/list命令打印所有用户输入的活动片段,这些片段也可能是使用/open命令从文件中打开的。

使用-all选项列出所有片段——活动的、非活动的、错误的和启动的。

使用-start选项仅列出启动代码片段。启动代码片段被缓存,-start选项打印缓存的代码片段。即使您在当前会话中删除了启动代码片段,它也会打印它们。

一些代码片段类型有一个名称(例如,变量/方法声明),所有代码片段都有一个 ID。使用带有/list命令的代码片段的名称或 ID 打印由该名称或 ID 标识的代码片段。/list命令以下列格式打印代码片段列表:

<snippet-id> : <snippet-source-code>
<snippet-id> : <snippet-source-code>
<snippet-id> : <snippet-source-code>
...

JShell工具生成唯一的代码片段 id。启动片段分别为s1s2s3...123...为有效片段;以及e1e2e3...为错误片段。下面的jshell会话向您展示了如何使用/list命令列出代码片段。示例使用/drop命令删除使用代码段名称和代码段 ID 的代码段:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /list
jshell> 2 + 2
$1 ==> 4
jshell> /list
   1 : 2 + 2
jshell> int x = 100
x ==> 100
jshell> /list
   1 : 2 + 2
   2 : int x = 100;
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : 2 + 2
   2 : int x = 100;
jshell> /list -start
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
jshell> string str = "String type is misspelled as string"
|  Error:
|  cannot find symbol
|    symbol:   class string
|  string str = "String type is misspelled as string";
|  ^----^
jshell> /list
   1 : 2 + 2
   2 : int x = 100;
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : 2 + 2
   2 : int x = 100;
  e1 : string str = "String type is misspelled as string";
jshell> /drop 1
|  dropped variable $1
jshell> /list
   2 : int x = 100;
jshell> /drop x
|  dropped variable x
jshell> /list
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : 2 + 2
   2 : int x = 100;
  e1 : string str = "String type is misspelled as string";
jshell> /exit
|  Goodbye

变量、方法和类的名称成为代码片段的名称。请注意,Java 允许您拥有同名的变量、方法和类,因为它们出现在自己的名称空间中。您可以使用这些实体的名称,使用/list命令将其列出:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /list x
|  No such snippet: x
jshell> int x = 100
x ==> 100
jshell> /list x
   1 : int x = 100;
jshell> void x() {}
|  created method x()
jshell> /list x
   1 : int x = 100;
   2 : void x() {}
jshell> void x(int n) {}
|  created method x(int)
jshell> /list x
   1 : int x = 100;
   2 : void x() {}
   3 : void x(int n) {}
jshell> class x {}
|  created class x
jshell> /list x
   1 : int x = 100;
   2 : void x() {}
   3 : void x(int n) {}
   4 : class x {}
jshell> /exit
|  Goodbye

编辑片段

JShell 工具提供了几种编辑代码片段和命令的方法。您可以使用表 23-1 中列出的导航键在命令行上导航,同时在jshell中输入片段和命令。您可以使用表 23-2 中列出的按键来编辑jshell中一行输入的文本。

表 23-2

在 JShell 工具中修改文本的键

|

钥匙

|

描述

删除 删除光标下的字符。
退格 删除光标前的字符。
Ctrl+K 删除从光标到行尾的文本。
Meta+D (gold Alt+D) 删除从光标到单词末尾的文本。
Ctrl+W 组合键 删除光标处前一个空白区域的文本。
Ctrl+Y 将最近删除的文本粘贴(或拉)到行中。
Meta+Y(或 Alt+Y) 在 Ctrl+Y 之后,该组合键循环显示先前删除的文本。

表 23-1

在 JShell 工具中编辑时的导航键

|

钥匙

|

描述

进入 进入当前行。
向左箭头 向后移动一个字符。
右箭头 向前移动一个字符。
Ctrl+A 移动到行首。
Ctrl+E 组合键 移动到行尾。
Meta+B(或 Alt+B) 向后移动一个单词。
Meta+F(或 Alt+F) 向前移动一个单词。

很难在jshell中编辑多行代码片段,即使您可以访问丰富的编辑组合键。工具设计者意识到了这个问题,提供了内置的代码片段编辑器。您可以配置该工具,以使用您选择的特定于平台的代码片段编辑器。有关如何设置自己的编辑器的更多信息,请参考“设置代码片段编辑器”一节。

您需要使用/edit命令来开始编辑代码片段。该命令有三种形式:

  • /edit <snippet-name>

  • /edit <snippet-id>

  • /edit

您可以使用代码段名称或代码段 ID 来编辑特定的代码段。不带参数的/edit命令在编辑器中打开所有活动代码段进行编辑。默认情况下,/edit命令会打开一个名为 JShell Edit Pad 的内置编辑器,如图 23-3 所示。

img/323069_3_En_23_Fig3_HTML.png

图 23-3

内置的 JShell 编辑器称为 JShell Edit Pad

JShell Edit Pad 是用 Swing 编写的,它显示了一个带有一个JTextArea和三个JButtonJFrame,如果您编辑代码片段,请确保在退出窗口之前单击 Accept 按钮,以便编辑生效。如果您取消或退出编辑器而不接受更改,您的编辑将会丢失。

如果您知道变量、方法或类的名称,您可以使用其名称对其进行编辑。下面的jshell会话创建了一个具有相同名称x的变量、方法和类,并使用/edit x命令一次性编辑它们:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> int x = 100
x ==> 100
jshell> void x(){}
|  created method x()
jshell> void x (int n) {}
|  created method x(int)
jshell> class x{}
|  created class x
jshell> 2 + 2
$5 ==> 4
jshell> /edit x

/edit x命令打开 JShell 编辑板中所有名为x的代码片段,如图 23-4 所示。您可以编辑这些片段,接受更改,并退出编辑,继续进行jshell会话。

img/323069_3_En_23_Fig4_HTML.png

图 23-4

按名称编辑片段

重新运行以前的片段

在像jshell这样的命令行工具中,您可能经常想要重新运行之前的代码片段。您可以使用向上/向下箭头浏览代码片段/命令历史记录,然后在进入上一个代码片段/命令时按 Enter 键。您也可以使用以下三个命令之一来重新运行以前的代码片段(不是命令):

  • /!

  • /<snippet-id>

  • /-<n>

/!命令重新运行最后一段代码。/<snippet-id>命令重新运行由<snippet-id>标识的片段。/-<n>命令重新运行n的最后一个片段。例如,/-1重新运行最后一个代码段,/-2重新运行倒数第二个代码段,依此类推。/!/-1命令具有相同的效果——它们都重新运行上一个代码片段。

声明变量

您可以像在 Java 程序中一样在jshell中声明变量。变量声明可能出现在顶级、方法内部,或者作为类中的字段声明。顶级变量声明中不允许使用staticfinal修饰符。如果您使用它们,它们将被忽略并发出警告。static修饰符指定了一个类上下文,final修饰符限制你改变变量值。不允许使用这些修饰符,因为该工具允许您声明想要通过随时间改变其值来进行试验的独立变量。以下示例说明了如何声明变量:

jshell> int x
x ==> 0
jshell> int y = 90
y ==> 90
jshell> side = 90
|  Error:
|  cannot find symbol
|    symbol:   variable side
|  side = 90
|  ^--^
jshell> static double radius = 2.67
|  Warning:
|  Modifier 'static'  not permitted in top-level declarations, ignored
|  static double radius = 2.67;
|  ^----^
radius ==> 2.67
jshell> String str = new String("Hello")
str ==> "Hello"
jshell>

在顶级表达式中使用未声明的变量会产生错误。注意在前面的例子中使用了一个名为side的未声明变量,这产生了一个错误。稍后我们将向您展示,您可以在方法体中使用未声明的变量。

也可以改变变量的数据类型。您可以将一个名为x的变量声明为int,并在以后将其重新声明为doubleString。以下示例显示了此功能:

jshell> int x = 10;
x ==> 10
jshell> int y = x + 2;
y ==> 12
jshell> double x = 2.71
x ==> 2.71
jshell> y
y ==> 12
jshell> String x = "Hello"
x ==> "Hello"
jshell> y
y ==> 12
jshell>

请注意,当数据类型或x的值改变时,名为y的变量的值没有改变或没有被重新计算。

您也可以使用/drop命令删除一个变量,该命令将变量名作为一个参数。以下命令将删除名为x的变量:

jshell> /drop x

您可以使用/vars命令列出jshell中的所有变量。它将列出用户声明的变量和由jshell自动声明的变量,这发生在jshell评估结果承载表达式时。该命令具有以下形式:

  • /vars

  • /vars <variable-name>

  • /vars <variable-snippet-id>

  • /vars -start

  • /vars -all

不带参数的命令列出当前会话中的所有活动变量。如果您使用代码段名称或 ID,它会列出具有该代码段名称或 ID 的变量声明。如果将它与-start选项一起使用,它会列出添加到启动脚本中的所有变量。如果将它与-all选项一起使用,它会列出所有变量,包括失败、覆盖、丢弃和启动。以下示例向您展示了如何使用/vars命令:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /vars
jshell> 2 + 2
$1 ==> 4
jshell> /vars
|    int $1 = 4
jshell> int x = 20;
x ==> 20
jshell> /vars
|    int $1 = 4
|    int x = 20
jshell> String str = "Hello";
str ==> "Hello"
jshell> /vars
|    int $1 = 4
|    int x = 20
|    String str = "Hello"
jshell> double x = 90.99;
x ==> 90.99
jshell> /vars
|    int $1 = 4
|    String str = "Hello"
|    double x = 90.99
jshell> /drop x
|  dropped variable x
jshell> /vars
|    int $1 = 4
|    String str = "Hello"
jshell>

导入报表

可以在jshell中使用import语句。回想一下,在 Java 程序中,java.lang包中的所有类型都是默认导入的。要使用其他包中的类型,您需要在您的编译单元中添加适当的import语句。我们从一个例子开始。我们尝试创建三个对象:一个String、一个List<Integer>和一个ZonedDateTime。注意String类在java.lang包中;ListInteger类分别在java.utiljava.lang包中;ZonedDateTime类在java.time包中:

jshell> String str = new String("Hello")
str ==> "Hello"
jshell> List<Integer> nums = List.of(1, 2, 3, 4, 5)
nums ==> [1, 2, 3, 4, 5]
jshell> ZonedDateTime now = ZonedDateTime.now()
|  Error:
|  cannot find symbol
|    symbol:   class ZonedDateTime
|  ZonedDateTime now = ZonedDateTime.now();
|  ^-----------^
|  Error:
|  cannot find symbol
|    symbol:   variable ZonedDateTime
|  ZonedDateTime now = ZonedDateTime.now();
|                      ^-----------^
jshell>

如果您试图使用java.time包中的ZonedDateTime类,这些示例会产生一个错误。当我们试图创建一个List时,我们也会遇到类似的错误,因为它在java.util包中,默认情况下,它不会被导入到 Java 程序中。

JShell 工具的唯一目的是让开发人员在评估代码片段时更加轻松。为了实现这个目标,默认情况下,该工具从几个包中导入所有类型。那些类型被导入的默认包是什么?您可以使用/imports命令打印出jshell中所有活动imports的列表:

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

注意默认的import语句,它从java.util包中导入所有类型。这就是你可以不用进口就能使用List的原因。您也可以将自己的导入添加到jshell。下面的例子展示了如何导入并使用ZonedDateTime类。当jshell打印带有时区的当前日期值时,您将得到不同的输出:

jshell> /imports
|    import java.util.*
|    import java.io.*
|    import java.math.*
|    import java.net.*
|    import java.util.concurrent.*
|    import java.util.prefs.*
|    import java.util.regex.*
jshell> import java.time.*
jshell> /imports
|    import java.io.*
|    import java.math.*
|    import java.net.*
|    import java.nio.file.*
|    import java.util.*
|    import java.util.concurrent.*
|    import java.util.function.*
|    import java.util.prefs.*
|    import java.util.regex.*
|    import java.util.stream.*
|    import java.time.*
jshell> ZonedDateTime now = ZonedDateTime.now()
now ==> 2017-08-19T13:01:33.060708200-05:00[America/Chicago]
jshell>

请注意,当您退出会话时,您添加到jshell会话的任何导入都将丢失。您还可以删除import语句——默认导入和您添加的语句。您需要知道代码段 ID 才能删除代码段。启动片段的 id 有s1s2s3等。;对于用户定义的片段,它们是 1、2、3 等。以下示例向您展示了如何在jshell中添加和删除import语句:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> import java.time.*
jshell> List<Integer> list = List.of(1, 2, 3, 4, 5)
list ==> [1, 2, 3, 4, 5]
jshell> ZonedDateTime now = ZonedDateTime.now()
now ==> 2017-02-19T21:08:08.802099-06:00[America/Chicago]
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : import java.time.*;
   2 : List<Integer> list = List.of(1, 2, 3, 4, 5);
   3 : ZonedDateTime now = ZonedDateTime.now();
jshell> /drop s5
jshell> /drop 1
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : import java.time.*;
   2 : List<Integer> list = List.of(1, 2, 3, 4, 5);
   3 : ZonedDateTime now = ZonedDateTime.now();
jshell> /imports
|    import java.io.*
|    import java.math.*
|    import java.net.*
|    import java.nio.file.*
|    import java.util.concurrent.*
|    import java.util.function.*
|    import java.util.prefs.*
|    import java.util.regex.*
|    import java.util.stream.*
jshell> List<Integer> list2 = List.of(1, 2, 3, 4, 5)
|  Error:
|  cannot find symbol
|    symbol:   class List
|  List<Integer> list2 = List.of(1, 2, 3, 4, 5);
|  ^--^
|  Error:
|  cannot find symbol
|    symbol:   variable List
|  List<Integer> list2 = List.of(1, 2, 3, 4, 5);
|                        ^--^
jshell> import java.util.*
|    update replaced variable list, reset to null
jshell> List<Integer> list2 = List.of(1, 2, 3, 4, 5)
list2 ==> [1, 2, 3, 4, 5]
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : import java.time.*;
   2 : List<Integer> list = List.of(1, 2, 3, 4, 5);
   3 : ZonedDateTime now = ZonedDateTime.now();
  e1 : List<Integer> list2 = List.of(1, 2, 3, 4, 5);
   4 : import java.util.*;
   5 : List<Integer> list2 = List.of(1, 2, 3, 4, 5);
jshell> /imports
|    import java.io.*
|    import java.math.*
|    import java.net.*
|    import java.nio.file.*
|    import java.util.concurrent.*
|    import java.util.function.*
|    import java.util.prefs.*
|    import java.util.regex.*
|    import java.util.stream.*
|    import java.util.*
jshell>

方法声明

可以在jshell中声明和调用方法。您可以声明顶级方法,这些方法是直接在jshell中输入的,并且不在任何类中。也可以用方法声明类(见下一节)。在本节中,我们将向您展示如何声明和调用顶级方法。也可以调用现有类的方法。下面的例子声明了一个名为square()的方法,并调用它:

jshell> long square(int n) {
   ...>    return n * n;
   ...> }
|  created method square(int)
jshell> square(10)
$2 ==> 100
jshell> long n2 = square(37)
n2 ==> 1369
jshell>

方法体中允许前向引用。也就是说,您可以在方法体中引用尚未声明的方法或变量。在定义所有缺少的引用之前,不能调用正在声明的方法:

jshell> long multiply(int n) {
   ...>     return multiplier * n;
   ...> }
|  created method multiply(int), however, it cannot be invoked until variable multiplier is declared
jshell> multiply(10)
|  attempted to call method multiply(int) which cannot be invoked until variable multiplier is declared
jshell> int multiplier = 2
multiplier ==> 2
jshell> multiply(10)
$6 ==> 20
jshell> void printCube(int n) {
   ...>     System.out.printf("Cube of %d is %d.%n", n, cube(n));
   ...> }
|  created method printCube(int), however, it cannot be invoked until method cube(int) is declared
jshell> long cube(int n) {
   ...>     return n * n * n;
   ...> }
|  created method cube(int)
jshell> printCube(10)
Cube of 10 is 1000.
jshell>

这个例子声明了一个名为multiply(int n)的方法。它将参数与一个名为multiplier的变量相乘,这个变量还没有声明。注意声明这个方法后的反馈。反馈明确指出,在声明multiplier变量之前,不能调用multiply()方法。调用方法会生成错误。后来,声明了multiplier变量,并成功调用了multiply()方法。

Tip

还可以使用前向引用声明递归方法。

类型声明

您可以像在 Java 中一样在jshell中声明所有类型,比如类、接口、枚举和注释。下面的jshell会话创建一个名为Counter的类,创建它的对象,并调用它的方法:

jshell> class Counter {
   ...>     private int counter;
   ...>     public synchronized int next() {
   ...>         return ++counter;
   ...>     }
   ...>
   ...>     public int current() {
   ...>         return counter;
   ...>     }
   ...> }
|  created class Counter
jshell> Counter c = new Counter();
c ==> Counter@25bbe1b6
jshell> c.current()
$3 ==> 0
jshell> c.next()
$4 ==> 1
jshell> c.next()
$5 ==> 2
jshell> c.current()
$6 ==> 2
jshell>

您可以使用/types命令来打印jshell中所有已声明类型的列表。该命令具有以下形式:

  • /types

  • /types <type-name>

  • /types <snippet-id>

  • /types -start

  • /types -all

不带参数的命令列出当前活动的jshell类、接口、枚举和注释。具有类型名称和代码段 ID 参数的命令分别列出具有指定名称和指定代码段 ID 的类型。带有-start选项的命令列出了自动添加的启动类型。带-all选项的命令列出所有类型,包括失败、覆盖、丢弃和启动。接下来的jshell是之前示例会话的延续;它显示了如何打印在jshell会话中定义的所有活动类型:

jshell> /types
|    class Counter
jshell>

Counter班小。您可能会很快意识到在命令行上输入较大类的源代码并不容易。您可能希望使用您喜欢的 Java 源代码编辑器(如 NetBeans)来编写源代码,并在jshell中快速测试您的类。您可以使用/open命令在jshell中打开一个源代码文件作为源输入。语法如下:

/open <file-path>

您可以在bj9f/src/jdojo.jshell/Counter.java文件中找到Counter类的源代码。下面的jshell会话向您展示如何在jshell中打开保存的Counter.java文件。假设你已经在 Windows 上的C:\中保存了这本书的源代码。如果您使用的是另一个操作系统,只需遵循您的操作系统和目录结构的文件路径命名约定,即可使用以下示例:

jshell> /open C:\bj9f\src\jdojo.jshell\Counter.java
jshell> Counter c = new Counter()
c ==> Counter@25bbe1b6
jshell> c.current()
$3 ==> 0
jshell> c.next()
$4 ==> 1
jshell> c.next()
$5 ==> 2
jshell> c.current()
$6 ==> 2
jshell>

注意,Counter类的源代码不包含包声明,因为jshell不允许在包中声明类(或任何类型)。在jshell中声明的所有类型都被认为是内部合成类的静态类型。但是,您可能想要测试您自己的包中的类。你可以在jshell中使用一个已经编译好的类,它在一个包中。当您使用库来开发您的应用程序,并且想要通过针对库类编写代码片段来试验您的应用程序逻辑时,您通常会需要它。您将需要使用/env命令设置类路径,这样您的类可能会被找到。

本书的源代码中包含了com.jdojo.jshell包中的一个Person类。类声明如清单 23-2 所示。

// Person.java
package com.jdojo.jshell;
public class Person {
    private String name;
    public Person() {
        this.name = "Unknown";
    }
    public Person(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}

Listing 23-2The Source Code for a Person Class

下面的jshell会话设置 Windows 上的类路径,假设这本书的源代码存储在C:\中。如果您的操作系统的类路径字符串和计算机上的源代码位置与假定的不同,请使用它们的语法:

jshell> /env -class-path C:\JavaFun\build\modules\jdojo.jshell
|  Setting new options and restoring state.
jshell> Person guy = new Person("Martin Guy Crawford")
|  Error:
|  cannot find symbol
|    symbol:   class Person
|  Person guy = new Person("Martin Guy Crawford");
|  ^----^
|  Error:
|  cannot find symbol
|    symbol:   class Person
|  Person guy = new Person("Martin Guy Crawford");
|                   ^----^

你知道这个错误的原因吗?我们使用了简单的类名Person,没有导入它;而jshell却找不到类。我们需要导入Person类或者使用它的完全限定名。以下是修复此错误的jshell会话的延续:

jshell> import com.jdojo.jshell.Person
jshell> Person guy = new Person("Martin Guy Crawford")
guy ==> com.jdojo.jshell.Person@192b07fd
jshell> guy.getName()
$9 ==> "Martin Guy Crawford"
jshell> guy.setName("Forrest Butts")
jshell> guy.getName()
$11 ==> "Forrest Butts"
jshell>

设置执行环境

在上一节中,您学习了如何使用/env命令设置类路径。该命令可用于设置执行上下文的许多其他组件,如模块路径。还可以用它来解析模块,这样就可以在jshell上的模块中使用类型。其完整语法如下:

/env [-class-path <path>] [-module-path <path>] [-add-modules <modules>]
[-add-exports <m/p=n>]

不带参数的/env命令打印当前执行上下文的值。-class-path选项设置类路径。-module-path选项设置模块路径。-add-modules选项将模块添加到默认的根模块集中,因此它们可以被解析。- add-exports选项将一个模块中未导出的包导出到一组模块中。这些选项的含义与使用javacjava命令时的含义相同。

Tip

在命令行上,这些选项必须以两个破折号(连字符)开头,例如--module-path。在jshell中,他们可以以一个破折号或两个破折号开始。比如-module-path--module-pathjshell都是允许的。

当您设置执行上下文时,当前会话将被重置,并且当前会话中以前执行的所有代码段都将以安静模式重播。也就是说,不会显示重播的片段。但是,将显示重放过程中的错误。

您可以使用/env/reset/reload命令设置执行上下文。这些命令中的每一个都有不同的效果。上下文选项如-class-path-module-path的意思是一样的。您可以使用命令/help context列出所有可用于设置执行上下文的选项。

让我们看一个使用/env命令来使用模块相关设置的例子。你在第三章创建了一个jdojo.intro模块。该模块包含一个名为com.jdojo.intro的包,但它不导出该包。现在,您想要调用非导出包中的Welcome类的静态main(String[] args)方法。以下是您需要在jshell中执行的步骤:

  1. 设置模块路径,以便找到该模块。

  2. 通过将模块添加到默认的根模块集中来解析该模块。您可以通过使用/env命令的-add-modules选项来完成此操作。

  3. 使用-add-exports命令导出包。在jshell中输入的代码片段在一个未命名的模块中执行,所以您需要使用ALL-UNNAMED关键字将包导出到所有未命名的模块。如果您没有在-add-exports选项中提供目标模块,则假定为ALL-UNNAMED,并且该包被导出到所有未命名的模块。

  4. 如果您想在代码片段中使用简单的名称,可以选择导入com.jdojo.intro.Welcome类。

  5. 现在,您将能够从jshell调用Welcome.main()方法。

下面的jshell会话将向您展示如何执行这些步骤。假设您正在以C:\JavaFun作为当前目录启动jshell会话,并且C:\JavaFun\build\modules\jdojo.intro目录包含了jdojo.intro模块的编译代码。如果您的目录结构和当前目录不同,请用您的目录路径替换会话中使用的目录路径:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /env -module-path build\modules\jdojo.intro
|  Setting new options and restoring state.
jshell> /env -add-modules jdojo.intro
|  Setting new options and restoring state.
jshell> /env -add-exports jdojo.intro/com.jdojo.intro=ALL-UNNAMED
|  Setting new options and restoring state.
jshell> import com.jdojo.intro.Welcome
jshell> Welcome.main(null)
Welcome to Java 17!
jshell> /env
|     --add-modules jdojo.intro
|     --module-path build\modules\jdojo.intro
|     --add-exports jdojo.intro/com.jdojo.intro=ALL-UNNAMED
jshell>

没有已检查的异常

在 Java 程序中,如果您调用一个抛出检查异常的方法,您必须使用一个try-catch块或通过添加一个throws子句来处理这些异常。JShell 工具应该是评估代码片段的一种快速而简单的方法,所以您不需要在代码片段中处理检查异常。如果一个代码片段在执行时抛出一个检查过的异常,jshell将打印堆栈跟踪并继续:

jshell> FileReader fr = new FileReader("secrets.txt")
|   java.io.FileNotFoundException thrown: secrets.txt (The system cannot find the file specified)
|        at FileInputStream.open0 (Native Method)
|        at FileInputStream.open (FileInputStream.java:196)
|        at FileInputStream.<init> (FileInputStream.java:139)
|        at FileInputStream.<init> (FileInputStream.java:94)
|        at FileReader.<init> (FileReader.java:58)
|        at (#1:1)
jshell>

这个代码片段抛出了一个FileNotFoundException,因为当前目录中不存在一个名为secrets.txt的文件。如果该文件存在,您可以创建一个FileReader,而不必使用try-catch块。请注意,如果您尝试在方法中使用这个代码片段,则适用普通的 Java 语法规则,并且您的方法声明将不会编译:

jshell> void readSecrets() {
   ...> FileReader fr = new FileReader("secrets.txt");
   ...> // More code goes here
   ...> }
|  Error:
|  unreported exception java.io.FileNotFoundException; must be caught or declared to be thrown
|  FileReader fr = new FileReader("secrets.txt");
|                  ^---------------------------^
jshell>

自动完成

JShell工具具有自动完成功能,您可以通过输入部分文本并按 Tab 键来调用该功能。当您输入命令或代码片段时,此功能可用。该工具将检测上下文并帮助您自动完成命令。当有多个可能性时,它会显示所有可能性,您需要手动输入其中一个。当它发现一个独特的可能性时,它将完成文本。要查看自动完成快捷方式的完整描述,请使用/help shortcuts命令。有三种自动完成的组合键:

  • 标签

  • Shift+Tab+V

  • Shift+Tab+I

在表达式中间按 Tab 键,jshell将完成表达式或显示可能的选项。按 Shift+Tab+V 可以将表达式转换为变量声明。快捷方式中的 V 代表变量。按 Shift+Tab+I 可以导入无法解析的标识符的类型。我们将详细讨论这些快捷方式的示例。

以下是该工具寻找多种可能性的示例。需要输入/e并按 Tab 键。命令中的<Tab>表示需要按 Tab 键:

jshell> /e <Tab>
/edit /env /exit
<press tab again to see synopsis>
jshell> /e <Tab>
/edit
edit a source entry referenced by name or id
/env
view or change the evaluation context
/exit
exit jshell
<press tab again to see full documentation>
jshell>

该工具检测到您正在尝试输入命令,因为您的文本以斜杠(/)开头。有三个命令(/edit/env/exit)以/e开头,它们是为你打印的。现在,您需要通过输入命令的其余部分来完成命令。对于命令,如果您输入足够的文本使命令名唯一并按 enter 键,该工具将执行该命令。在这种情况下,可以输入/ed/en/ex并按回车键分别执行/edit/env/exit命令。如果在按 Tab 键后显示多个选项,可以再次按 Tab 键查看所有选项的说明。第三次按 Tab 会显示所有选项的完整文档。如果您尝试这样自动完成一个 Java 表达式,您可以查看整个 Javadoc for Java 实体,比如一个类的方法。

您可以输入斜线(/)并按 Tab 键查看所有可用的jshell命令列表:

jshell> /
/!          /?          /drop       /edit       /env        /exit       /help       /history
/imports    /list       /methods    /open       /reload     /reset      /save       /set
/types      /vars
<press tab again to see synopsis>

下面的代码片段创建了一个名为strString变量,初始值为"GoodBye":

jshell> String str = "GoodBye"
str ==> "GoodBye"

继续此jshell会话,输入str.并按 Tab 键:

jshell> str.<Tab>
charAt(                chars()                codePointAt(           codePointBefore(
codePointCount(        codePoints()           compareTo(             compareToIgnoreCase(
concat(                contains(              contentEquals(         endsWith(
equals(                equalsIgnoreCase(      getBytes(              getChars(
getClass()             hashCode()             indexOf(               intern()
isEmpty()              lastIndexOf(           length()               matches(
notify()               notifyAll()            offsetByCodePoints(    regionMatches(
replace(               replaceAll(            replaceFirst(          split(
startsWith(            subSequence(           substring(             toCharArray()
toLowerCase(           toString()             toUpperCase(           trim()
wait(
jshell> str.

这个代码片段打印了您可以在变量str上调用的String类的所有方法名。请注意,一些方法名称以()结尾,而其他方法名称仅以(结尾。这不是 bug。如果一个方法没有参数,它的名字后面会有一个()。如果一个方法有参数,它的名字后面会跟一个(

继续这个例子,输入str.sub并按 Tab 键:

jshell> str.sub <Tab>
subSequence(   substring(

这一次,该工具在String类中找到了两个以sub开头的方法。您可以输入整个方法调用str.substring(0, 4),然后按Enter来评估代码片段:

jshell> str.substring(0, 4)
$2 ==> "Good"

或者,您可以通过输入str.subs让工具自动完成方法名。当您输入str.subs并按 Tab 键时,该工具会完成方法名,插入一个(,并等待您输入方法的参数:

jshell> str.substring(
substring(
jshell> str.substring(

现在,您可以输入方法的参数并按 enter 键来计算表达式:

jshell> str.substring(0, 4)
$3 ==> "Good"
jshell>

当一个方法接受参数时,您很可能希望看到这些参数的类型。输入完整的方法/构造器名和左括号后,按 Tab 键可以看到方法的概要。在前面的例子中,如果您输入str.substring(并按 Tab,该工具将打印substring()方法的概要:

jshell> str.substring(
Signatures:
String String.substring(int beginIndex)
String String.substring(int beginIndex, int endIndex)
<press tab again to see documentation>
jshell> str.substring(

注意输出。它说如果你再次按下 Tab,它会显示出substring()方法的 Javadoc。在下面的提示中,我们再次按 Tab 键来打印 Javadoc。如果需要显示更多 Javadoc,请再次按 Tab 键。

有时,您输入一个表达式,并希望将表达式的值赋给适当类型的变量。有时你知道类型,有时你不知道。在您输入完整的表达式后,JShell工具将帮助您自动完成赋值部分。输入完整的表达式,然后按 Shift+Tab。现在,按 V,这将通过添加适当的变量类型并将光标放在可以输入变量名的位置来自动完成表达式赋值。按键的顺序如下:

  1. 按 Shift 键。

  2. 一直按住 Shift 并按 Tab。

  3. 释放标签。

  4. 松开换档。

  5. 按 v。

让我们走完这些步骤。在jshell中输入表达式2 + 2:

jshell> 2 + 2

现在,按前面列出的按键顺序。jshell自动完成赋值表达式,并等待您输入变量名:

jshell> int  = 2 + 2

光标正好位于=符号之前。输入x作为变量名,并按回车键:

jshell> int x = 2 + 2
x ==> 4
jshell>

让我们使用 Shift+Tab+I 快捷键来导入一个未解析标识符的缺失导入。您需要按以下顺序按下组合键:

  1. 按 Shift 键。

  2. 一直按住 Shift 并按 Tab。

  3. 释放标签。

  4. 松开换档。

  5. 普里斯岛。

  6. jshell将打印选项编号为 0、1、2、3 等的可能的导入报表。jshell等待您输入选项。

  7. 输入选项号,jshell将执行import语句。

假设您想使用java.time包中的LocalDate类。下面的jshell会话将向您展示如何使用快捷键导入java.time.LocalDate类。在jshell上输入LocalDate后,需要按 Shift+Tab+I 快捷键:

jshell> LocalDate
0: Do nothing
1: import: java.time.LocalDate
Choice:
Imported: java.time.LocalDate
jshell> LocalDate.now()
$1 ==> 2017-08-19

代码片段和命令的历史记录

JShell 维护您在所有会话中输入的所有命令和代码片段的历史记录。您可以使用上下箭头键浏览历史记录。您也可以使用/history命令打印您在当前会话中输入的所有内容的历史记录:

jshell> 2 + 2
$1 ==> 4
jshell> System.out.println("Hello")
Hello
jshell> /history
2 + 2
System.out.println("Hello")
/history
jshell>

此时,按一下向上箭头显示/history,按两下显示System.out.println("Hello"),按三下显示2 + 2。第四次按向上箭头将显示上次jshell会话输入的命令/片段。如果要执行之前输入的代码片段/命令,请使用向上箭头,直到显示所需的命令/代码片段,然后按 Enter 键执行。按下向下箭头可以导航到列表中的下一个命令或代码片段。假设您按下向上箭头五次,导航到倒数第五个代码片段/命令。现在按下向下箭头将导航到倒数第四个代码片段/命令。当您位于第一个或最后一个代码片段/命令时,按下向上箭头或向下箭头没有任何作用。

正在读取 JShell 堆栈跟踪

jshell上输入的片段是合成类的一部分。Java 不允许你声明一个顶级方法。方法声明必须是类型的一部分。当 Java 程序中抛出异常时,堆栈跟踪会打印类型名和行号。在jshell中,一个代码片段可能会抛出一个异常。在这种情况下,打印合成的类名和行号会产生误导,对开发人员来说毫无意义。代码段在堆栈跟踪中的位置格式如下:

at <snippet-name> (#<snippet-id>:<line-number-in-snippet>)

请注意,有些片段可能没有名称。例如,输入一个片段2 + 2不会给它一个名字。有些代码段有名称,例如声明变量的代码段被赋予与变量名相同的名称;方法和类型声明也是如此。有时,您可能有两个同名的代码段,例如,通过用相同的名称声明一个变量和一个方法/类型。jshell为所有片段分配唯一的片段 ID。您可以使用/list -all命令找到代码片段的 ID。

下面的jshell会话声明了一个divide()方法,并打印了一个运行时ArithmeticException异常的异常堆栈跟踪,该异常在整数被零除时抛出:

jshell> int divide(int x, int y) {
   ...> return x/y;
   ...> }
|  created method divide(int,int)
jshell> divide(10, 2)
$2 ==> 5
jshell> divide(10, 0)
|  java.lang.ArithmeticException thrown: / by zero
|        at divide (#1:2)
|        at (#3:1)
jshell> /list -all
  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;
   1 : int divide(int x, int y) {
       return x/y;
       }
   2 : divide(10, 2)
   3 : divide(10, 0)
jshell>

让我们尝试读取堆栈跟踪。最后一行at (#3:1),声明异常是在代码片段 3 的第 1 行引起的。注意在/list -all命令的输出中,代码片段 3 是导致异常的表达式divide(10, 0)。第二行at divide (#1:2)表示堆栈跟踪中的第二级位于名为divide的代码片段的第 2 行,其代码片段 ID 为 1,行号为 2。

重用 JShell 会话

您可以在一个jshell会话中输入许多片段和命令,并且可能希望在其他会话中重用它们。您可以使用/save命令将命令和代码片段保存到文件中,并使用/open命令加载之前保存的命令和代码片段。/save命令的语法如下:

/save <option> <file-path>

这里,<option>可以是-all-history-start中的一个选项。<file-path>是保存代码片段/命令的文件路径。

不带选项的/save命令保存当前会话中的所有活动片段。请注意,它不保存任何命令或失败的代码片段。

带有-all选项的/save命令将当前会话的所有代码片段保存到指定文件,包括失败和启动代码片段。请注意,它不保存任何命令。

-history选项的/save命令会保存你在jshell中输入的所有内容。

-start选项的/save命令将默认启动定义保存到指定文件。

您可以使用/open命令从文件中重新加载代码片段。该命令将文件名作为参数。

下面的jshell会话声明一个名为Counter的类,创建它的对象,并调用对象上的方法。最后,它将所有活动的代码片段保存到一个名为jshell.jsh的文件中。注意文件扩展名.jshjshell文件的惯用扩展名。您可以使用您想要的任何其他扩展名:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> class Counter {
   ...>    private int count;
   ...>    public synchronized int next() {
   ...>      return ++count;
   ...>    }
   ...>    public int current() {
   ...>      return count;
   ...>    }
   ...> }
|  created class Counter
jshell> Counter counter = new Counter()
counter ==> Counter@25bbe1b6
jshell> counter.current()
$3 ==> 0
jshell> counter.next()
$4 ==> 1
jshell> counter.next()
$5 ==> 2
jshell> counter.current()
$6 ==> 2
jshell> /save jshell.jsh
jshell> /exit
|  Goodbye

此时,您应该在当前目录中有一个名为jshell.jsh的文件,其内容如清单 23-3 所示。

class Counter {
   private int count;
   public synchronized int next() {
     return ++count;
   }
   public int current() {
     return count;
   }
}
Counter counter = new Counter();
counter.current()
counter.next()
counter.next()
counter.current()

Listing 23-3Contents of the jshell.jsh File

接下来的jshell会话打开jshell.jsh文件,该文件将重放在之前的会话中保存的所有片段。打开文件后,您可以开始调用counter变量上的方法:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /open jshell.jsh
jshell> counter.current()
$7 ==> 2
jshell> counter.next()
$8 ==> 3
jshell>

重置 JShell 状态

您可以使用/reset命令重置 JShell 的执行状态。执行此命令具有以下效果:

  • 您在当前会话中输入的所有代码片段都将丢失,因此在执行此命令之前要小心。

  • 重新执行启动代码片段。

  • 工具的执行状态被重新启动。

  • 使用/set命令设置的jshell配置被保留。

  • 使用/env命令设置的执行环境被保留。

下面的jshell会话声明一个变量,重置会话,并试图打印变量值。请注意,重置会话时,所有声明的变量都将丢失,因此找不到以前声明的变量:

jshell> int x = 987
x ==> 987
jshell> /reset
|  Resetting state.
jshell> x
|  Error:
|  cannot find symbol
|    symbol:   variable x
|  x
|  ^
jshell>

重新加载 JShell 状态

假设您在一个jshell会话中使用了许多代码片段,并退出了该会话。现在你想回去重放那些片段。一种方法是启动一个新的jshell会话,重新输入这些片段。在jshell中重新输入几个片段是一件麻烦事。有一种简单的方法可以实现这一点——使用/reload命令。/reload命令重置jshell状态,并以之前输入的相同顺序重放所有有效片段和/drop命令。您可以使用-restore-quiet选项自定义其行为。

不带任何选项的/reload命令重置jshell状态,并重放以下先前动作/事件之一的有效历史,以最后发生的为准:

  • 当前会话开始

  • 执行最后一个/reset命令的时间

  • 执行最后一个/reload命令的时间

您可以在/reload命令中使用-restore选项。它重置并重放以下两个动作/事件之间的历史记录,以最后两个动作/事件为准:

  • jshell的发射

  • 执行/reset命令

  • 执行/reload命令

-restore选项执行/reload命令的效果有点难以理解。它的主要目的是恢复以前的执行状态。如果您在每个jshell会话开始时执行该命令,从第二个会话开始,您的会话将包含您在jshell会话中执行过的所有代码片段!这是一个强大的功能。也就是说,您可以评估代码片段,关闭jshell,重启jshell,并执行/reload -restore命令作为您的第一个命令,并且您永远不会丢失您之前输入的任何代码片段。有时,您会在一个会话中执行两次/reset命令,并希望恢复这两次重置之间的状态。您可以通过使用此命令来实现这一结果。

下面的jshell会话在每个会话中创建一个变量,并通过在每个会话开始时执行/reload -restore命令来恢复前一个会话。该示例显示第四个会话使用了在第一个会话中声明的名为x1的变量:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> int x1 = 10
x1 ==> 10
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /reload -restore
|  Restarting and restoring from previous state.
-: int x1 = 10;
jshell> int x2 = 20
x2 ==> 20
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /reload -restore
|  Restarting and restoring from previous state.
-: int x1 = 10;
-: int x2 = 20;
jshell> int x3 = 30
x3 ==> 30
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /reload -restore
|  Restarting and restoring from previous state.
-: int x1 = 10;
-: int x2 = 20;
-: int x3 = 30;
jshell> System.out.println("x1 is " + x1)
x1 is 10
jshell>

/reload命令显示它重放的历史。您可以使用-quiet选项抑制回放显示。您可以使用此选项,也可以不使用-restore选项。-quiet选项不抑制重放历史时可能产生的错误信息。下面的例子使用了两个jshell会话。第一个会话声明一个名为x1的变量。第二个会话使用带有/reload命令的-quiet选项。注意,这一次,您没有看到变量x1在第二个会话中被重新加载的重放显示,因为您使用了-quiet选项:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> int x1 = 10
x1 ==> 10
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /reload -restore -quiet
|  Restarting and restoring from previous state.
jshell> x1
x1 ==> 10
jshell>

配置 JShell

使用/set命令,您可以定制jshell会话,从启动代码片段和命令到设置特定于平台的代码片段编辑器。在本节中,我们将详细解释这些定制。

设置代码片段编辑器

JShell工具带有一个默认的代码片段编辑器。在jshell中,您可以使用/edit命令来编辑所有代码片段或特定的代码片段。/edit命令在编辑器中打开代码片段。代码片段编辑器是一个特定于平台的程序,比如 Windows 上的notepad.exe,它将被调用来编辑代码片段。您可以使用带有editor参数的/set命令来设置或删除编辑器设置。该命令的有效形式如下:

  • /set editor [-retain] [-wait] <command>

  • /set editor [-retain] -default

  • /set editor [-retain] -delete

如果使用-retain选项,该设置将在jshell个会话中保持不变。

如果指定命令,该命令必须是特定于平台的。也就是说,您需要在 Windows 上指定一个 Windows 命令,在 UNIX 上指定一个 UNIX 命令,等等。该命令可能包含标志。JShell工具将待编辑的片段保存在临时文件中,并将临时文件的名称附加到命令中。编辑器打开时,您不能使用jshell。如果您的编辑器立即退出,您应该指定-wait选项,这将使jshell一直等到编辑器关闭。以下命令将记事本设置为 Windows 上的编辑器:

jshell> /set editor -retain notepad.exe

-default选项将代码片段编辑器设置为默认编辑器。-delete选项删除当前的编辑器设置。如果-retain选项与-delete选项一起使用,保留的编辑器设置将被删除:

jshell> /set editor -retain -delete
|  Editor set to: -default
jshell>

在以下环境变量之一中设置的编辑器— JSHELLEDITORVISUALEDITOR—优先于默认编辑器。这些环境变量在编辑器中按顺序查找。如果没有设置这些环境变量,将使用默认编辑器。所有这些规则背后的意图是始终拥有一个编辑器,然后使用默认编辑器作为后备。没有任何参数和选项的/set editor命令打印关于当前编辑器设置的信息。

下面的jshell会话将记事本设置为 Windows 上的编辑器。请注意,此示例不能在 Windows 以外的平台上运行,在 Windows 中,您需要将特定于平台的程序指定为编辑器:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set editor
|  /set editor -default
jshell> /set editor -retain notepad.exe
|  Editor set to: notepad.exe
|  Editor setting retained: notepad.exe
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set editor
|  /set editor -retain notepad.exe
jshell> 2 + 2
$1 ==> 4
jshell> /edit
jshell> /set editor -retain -delete
|  Editor set to: -default
jshell> /exit
|  Goodbye
C:\JavaFun>SET JSHELLEDITOR=notepad.exe
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set editor
|  /set editor notepad.exe
jshell>

设置反馈模式

当您执行片段或命令时,jshell会打印反馈。反馈的数量和格式取决于反馈模式。您可以使用四种预定义反馈模式之一或自定义反馈模式:

  • silent

  • concise

  • normal

  • verbose

silent模式完全不给你反馈,verbose模式给你的反馈最多。concise模式提供与normal模式相同的反馈,但形式更紧凑。默认反馈模式是normal

表 23-3 显示了每个内置反馈模式的细节。提示栏包含提示,其中\n表示新的一行。其他列显示反馈显示或不显示的位置,如果显示,则显示反馈的格式。“声明”、“更新”和“命令”列分别显示了声明、对现有代码片段的更新和命令的反馈。“带值的代码片段”列显示了输入结果代码片段时的反馈格式。

表 23-3

内置反馈模式的特性

|

方式

|

提示

|

申报

|

更新

|

命令

|

带有值的代码段

沉默的 ->
简明的 jshell> name == >值(仅用于表达式)
标准 \njshell> name == >值
冗长的 \njshell> name == >值(带描述)

设置反馈模式的命令如下:

/set feedback [-retain] <mode>

这里,<mode>是四种反馈模式之一。如果您想在jshell会话中保持反馈模式,请使用-retain选项。

您也可以在特定的反馈模式下启动jshell:

jshell --feedback <mode>

以下命令在verbose反馈模式下启动jshell:

C:\JavaFun>jshell --feedback verbose

以下示例显示了如何设置不同的反馈模式:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> 2 + 2
$1 ==> 4
jshell> /set feedback verbose
|  Feedback mode: verbose
jshell> 2 + 2
$2 ==> 4
|  created scratch variable $2 : int
jshell> /set feedback concise
jshell> 2 + 2
$3 ==> 4
jshell> /set feedback silent
-> 2 + 2
-> System.out.println("Hello")
Hello
-> /set feedback verbose
|  Feedback mode: verbose
jshell> 2 + 2
$6 ==> 4
|  created scratch variable $6 : int

jshell中设置的反馈模式是临时的。它仅针对当前会话设置。要在jshell个会话中保持反馈模式,使用带有feedback参数和-retain选项的/set命令:

jshell> /set feedback -retain

该命令将保持当前的反馈模式。当您再次启动jshell时,它将配置您执行该命令前设置的反馈模式。仍然可以在会话中临时更改反馈模式。如果您想永久设置一个新的反馈模式,您需要使用/set feedback <mode>命令并再次执行该命令来保存新的设置。

也可以设置一个新的反馈模式,同时通过使用-retain选项为将来的会话保留该模式。以下命令会将反馈模式设置为verbose,并在以后的会话中保留:

jshell> /set feedback -retain verbose

要确定当前的反馈模式,执行带有feedback参数的/set命令。它将用于设置当前反馈模式的命令打印在第一行,后跟所有可用的反馈模式,如下所示:

jshell> /set feedback
|  /set feedback normal
|
|  Available feedback modes:
|     concise
|     normal
|     silent
|     verbose
jshell>

Tip

当学习jshell时,建议您在verbose反馈模式下开始,这样您可以获得许多关于命令和代码片段执行状态的细节。这将帮助您更快地学习该工具。

创建自定义反馈模式

四种预配置的反馈模式适合与jshell配合使用。它们为您提供不同的粒度级别来定制您的jshell。你可以有自己的自定义反馈模式。我们怀疑你是否需要自定义反馈模式,但是如果你需要的话,这个功能就在那里。创建自定义反馈模式稍微复杂一些。您必须编写几个定制步骤。最有可能的情况是,您希望在预定义的反馈模式中自定义一些项目。您可以从头开始创建自定义反馈模式,也可以从现有的反馈模式中复制一个,然后有选择地进行自定义。创建自定义反馈模式的语法如下:

/set mode <mode> [<old-mode>] [-command|-quiet|-delete]

这里,<mode>是自定义反馈模式的名称;例如,kverbose. <old-mode>是现有反馈模式的名称,其设置将被复制到新模式。使用-command选项显示模式设置时的信息,而使用-quiet选项不显示模式设置时的任何信息。-delete选项用于删除模式。

以下命令通过复制预定义的verbose反馈模式的所有设置,创建一个名为kverbose的新反馈模式:

/set mode kverbose verbose -command

以下命令将保留新的反馈模式以供将来使用:

/set mode kverbose -retain

您需要使用-delete选项来删除自定义反馈模式。您不能删除预定义的反馈模式。如果您保留了自定义反馈模式,您可以使用-retain选项将其从当前和所有未来会话中删除。以下命令将删除kverbose反馈模式:

/set mode kverbose -delete -retain

此时,预定义的verbose模式和自定义的kverbose模式没有区别。创建反馈模式后,您需要自定义三个设置:

  • 提示

  • 输出截断限值

  • 输出格式

Tip

一旦你完成了自定义反馈模式的定义,你需要使用/set feedback <new-mode>命令来开始使用它。

您可以为反馈模式设置两种类型的提示-主提示和继续提示。当jshell准备好读取新的片段/命令时,显示主提示。当您输入多行代码段时,继续提示会显示在行首。设置提示的语法如下:

/set prompt <mode> "<prompt>" "<continuation-prompt>"

这里,<prompt>是主提示,<continuation-prompt>是继续提示。

以下命令设置kverbose模式的提示:

/set prompt kverbose "\njshell-kverbose> " "more... "

您可以使用以下命令为反馈模式的每种类型的动作/事件设置显示的最大字符数:

/set truncation <mode> <length> <selectors>

这里,<mode>是您设置截断极限的反馈模式;<length>是指定选择器显示的最大字符数。<selectors>是一个逗号分隔的选择器列表,它决定了截断限制所适用的上下文。选择器是预定义的关键字,代表特定的上下文,例如,vardecl是一个选择器,代表一个变量声明,不需要初始化。使用以下命令了解有关设置截断限制和选择器的更多信息:

/help /set truncation

以下命令将所有内容的截断限制设置为 80 个字符,变量值或表达式的截断限制为 5 个字符:

/set truncation kverbose 80
/set truncation kverbose 5 expression,varvalue

请注意,最具体的选择器决定了要使用的实际截断限制。以下设置使用两个选择器,一个用于所有类型的代码段(80 个字符),另一个用于表达式和变量值(5 个字符)。对于表达式,第二个设置是最具体的设置。在这种情况下,如果变量的值超过五个字符,则在显示时会被截断为五个字符。

设置输出格式是一项复杂的工作。您需要根据动作/事件为所有类型的输出设置格式。我们不会定义所有类型的输出格式。有关设置输出格式的更多信息,请使用以下命令:

/help /set format

设置输出格式的语法如下:

/set format <mode> <field> "<format>" <selectors>

这里,<mode>是您正在设置输出格式的反馈模式的名称;<field>是要定义的上下文特定的格式;<format>用于显示输出。<format>可以在大括号中包含预定义字段的名称,例如{name}{type}{value}等。,它将被替换为基于上下文的实际值。<selectors>是决定使用这种格式的上下文的选择器。

当添加、修改或替换输入片段的表达式时,以下命令设置反馈的显示格式。整个命令在一行中输入:

/set format kverbose display "{result}{pre}created a temporary variable named {name} of type {type} and initialized it with {value}{post}" expression-added,modified,replaced-primary

下面的jshell会话通过复制预定义的verbose反馈模式的所有设置,创建一个名为kverbose的新反馈模式。它定制提示、截断限制和输出格式。它使用verbosekverbose反馈模式来比较jshell行为。请注意,以下示例中的所有命令都需要在一行中输入,即使它们有时会出现在书中的多行中:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set feedback
|  /set feedback -retain normal
|
|  Available feedback modes:
|     concise
|     normal
|     silent
|     verbose
jshell> /set mode kverbose verbose -command
|  Created new feedback mode: kverbose
jshell> /set mode kverbose -retain
jshell> /set prompt kverbose "\njshell-kverbose> " "more... "
jshell> /set truncation kverbose 5 expression,varvalue
jshell> /set format kverbose display "{result}{pre}created a temporary variable named {name} of type {type} and initialized it with {value}{post}" expression-added,modified,replaced-primary
jshell> /set feedback kverbose
|  Feedback mode: kverbose
jshell-kverbose> 2 +
more... 2
$2 ==> 4
|  created a temporary variable named $2 of type int and initialized it with 4
jshell-kverbose> 111111 + 222222
$3 ==> 33333
|  created a temporary variable named $3 of type int and initialized it with 33333
jshell-kverbose> /set feedback verbose
|  Feedback mode: verbose
jshell> 2 +
   ...> 2
$4 ==> 4
|  created scratch variable $4 : int
jshell> 111111 + 222222
$5 ==> 333333
|  created scratch variable $5 : int
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set feedback
|  /set feedback -retain normal
|
|  Retained feedback modes:
|     kverbose
|  Available feedback modes:
|     concise
|     kverbose
|     normal
|     silent
|     verbose
jshell>

在这些jshell会话中,您为kverbose反馈模式将表达式和变量值的截断限制设置为五个字符。这就是为什么在kverbose反馈模式下,表达式111111 + 222222的值被打印为33333,而不是333333。这不是 bug。这是由您的设置造成的。

请注意,命令/set feedback显示了用于设置当前反馈模式的命令和可用反馈模式列表,其中列出了名为kverbose的新反馈模式。

创建自定义反馈模式时,了解现有反馈模式的所有设置会很有帮助。您可以使用以下命令打印所有反馈模式的所有设置列表:

/set mode

您还可以通过将模式名称作为参数传递给命令来打印特定反馈模式的所有设置列表。以下命令打印出silent反馈模式的所有设置列表。输出中的第一行是用于创建silent模式的命令:

jshell> /set mode silent
|  /set mode silent -quiet
|  /set prompt silent "-> " ">> "
|  /set format silent display ""
|  /set format silent err "%6$s"
|  /set format silent errorline "    {err}%n"
|  /set format silent errorpost "%n"
|  /set format silent errorpre "|  "
|  /set format silent errors "%5$s"
|  /set format silent name "%1$s"
|  /set format silent post "%n"
|  /set format silent pre "|  "
|  /set format silent type "%2$s"
|  /set format silent unresolved "%4$s"
|  /set format silent value "%3$s"
|  /set truncation silent 80
|  /set truncation silent 1000 expression,varvalue

设置启动代码片段

您可以使用带有start参数的/set命令来设置您的启动代码片段和命令。当您启动jshell时,启动代码片段和命令会自动执行。您已经看到了从一些常用的包中导入类型的默认启动代码片段。通常,您可以使用一个/env命令和import语句来设置启动脚本的类路径和模块路径。

您可以使用/list -start命令打印默认启动代码片段列表。请注意,该命令打印默认的启动代码片段,而不是当前的启动代码片段。请记住,您也可以删除启动代码片段。默认的启动片段包括您启动jshell时得到的内容。当前启动代码片段包括默认启动代码片段,不包括您在当前jshell会话中丢弃的代码片段。您可以使用以下形式的/set命令来设置启动片段/命令:

  • /set start [-retain] <file>

  • /set start [-retain] -default

  • /set start [-retain] -none

使用-retain选项是可选的。如果使用,该设置将在jshell会话中保持不变。

第一种形式用于从文件中设置启动代码片段/命令。当在当前会话中执行/reset/reload命令时,文件的内容将被用作启动片段/命令。一旦你从一个文件中设置了启动代码,jshell就会缓存该文件的内容以备将来使用。修改文件内容不会影响启动代码,直到您再次设置启动代码片段/命令。

第二种形式用于将启动片段/命令设置为内置默认值。

第三种形式用于设置空启动。也就是说,启动时不会执行任何代码片段/命令。

没有任何选项或文件的/set start命令显示当前的启动设置。如果从文件设置启动,它将显示文件名、启动代码片段和设置启动代码片段的时间。

考虑下面的场景。本书源代码中的JavaFun/build/modules/jdojo.jshell目录包含一个com.jdojo.jshell.Person类。让我们在jshell中测试这个类,并使用java.time包中的类型。为此,您的启动设置将类似于清单 23-4 中所示的内容。

/env -class-path C:\JavaFun\build\modules\jdojo.jshell
import java.io.*
import java.math.*
import java.net.*
import java.nio.file.*
import java.util.*
import java.util.concurrent.*
import java.util.function.*
import java.util.prefs.*
import java.util.regex.*
import java.util.stream.*
import java.time.*;
import com.jdojo.jshell.*;
void printf(String format, Object... args) { System.out.printf(format, args); }

Listing 23-4Contents of a File Named startup.jsh

将设置保存在当前目录下名为startup.jsh的文件中。如果您将它保存在任何其他目录中,则在使用本示例时,您可以使用该文件的绝对路径。注意,第一个命令是 Windows 的/env -class-path命令,假设您将源代码存储在C:\目录中。根据您的平台和本书源代码在您计算机上的位置来更改类路径值。

注意startup.jsh文件中的最后一段代码。它定义了一个名为printf()的顶级函数,它是System.out.printf()方法的包装器。默认情况下,printf()功能包含在JShell工具的初始版本中。后来,它被删除了。如果您想使用一个短的方法名,比如用printf()而不是System.out.printf()在标准输出中打印消息,您可以在启动脚本中包含这个代码片段。如果想默认使用jshell中的println()printf()顶层方法,需要如下启动jshell:

C:\JavaFun>jshell --start DEFAULT --start PRINTING

DEFAULT参数将包括所有默认的import语句,而PRINTING参数将包括print()println()printf()方法的所有版本。使用该命令启动jshell后,执行/list -start命令查看命令中使用的两个--start选项添加的所有启动import和方法。

以下jshell会话向您展示了如何从文件中设置启动设置及其在后续会话中的使用:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set start
|  /set start -default
jshell> /set start -retain startup.jsh
jshell> Person p;
|  created variable p, however, it cannot be referenced until class Person is declared
jshell> /reset
|  Resetting state.
jshell> Person p;
p ==> null
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set start
|  /set start -retain startup.jsh
|  ---- startup.jsh @ Aug 20, 2017, 9:58:11 AM ----
|  /env -class-path C:\JavaFun\build\modules\jdojo.jshell
|  import java.io.*
|  import java.math.*
|  import java.net.*
|  import java.nio.file.*
|  import java.util.*
|  import java.util.concurrent.*
|  import java.util.function.*
|  import java.util.prefs.*
|  import java.util.regex.*
|  import java.util.stream.*
|  import java.time.*;
|  import com.jdojo.jshell.*;
|  void printf(String format, Object... args) { System.out.printf(format, args); }
jshell> Person p;
p ==> null
jshell> LocalDate.now()
$15 ==> 2017-08-20
jshell> printf("2 + 2 = %d%n", 2 + 2)
2 + 2 = 4
jshell>

Tip

在您重新启动jshell、执行/reset或执行/reload命令之前,设置启动片段/命令不会生效。不要在启动文件中包含/reset/reload命令。这将导致一个无限循环时,你的启动文件加载。

有三个预定义脚本,其名称如下:

  • DEFAULT

  • PRINTING

  • JAVASE

DEFAULT脚本包含常用的导入语句,如您在“导入语句”一节中所见。PRINTING脚本定义了顶级 JShell 方法,这些方法重定向到PrintStream中的print()println()printf()方法,如本节所示。JAVASE脚本导入所有的 Java SE 包,这很大,需要几秒钟才能完成。以下命令显示了如何将这些脚本保存为启动脚本:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> println("Hello")
|  Error:
|  cannot find symbol
|    symbol:   method println(java.lang.String)
|  println("Hello")
|  ^-----^
jshell> /set start -retain DEFAULT PRINTING
jshell> /exit
|  Goodbye
C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> println("Hello")
Hello
jshell>

注意,第一次使用println()方法导致了一个错误。将PRINTING脚本保存为启动脚本并重启工具后,该方法就可以工作了。

使用 JShell 文档

JShell工具附带了大量文档。因为它是一个命令行工具,所以在命令行上阅读文档有点困难。您可以使用/help/?命令来显示命令列表及其简要描述:

jshell> /help
|  Type a Java language expression, statement, or declaration.
|  Or type one of the following commands:
|  /list [<name or id>|-all|-start]  -- list the source you have typed
|  /edit <name or id>  -- edit a source entry referenced by name or id
|  /drop <name or id>  -- delete a source entry referenced by name or id
|  ...

您可以使用一个特定的命令作为/help命令的参数来获取关于该命令的信息。以下命令打印关于/help命令本身的信息:

jshell> /help /help
|
|  /help
|
|  Display information about jshell.
|  /help
|       List the jshell commands and help subjects.
|
|  /help <command>
|       Display information about the specified command. The slash must be included.
|       Only the first few letters of the command are needed -- if more than one
|       each will be displayed.  Example:  /help /li
|
|  /help <subject>
|       Display information about the specified help subject. Example: /help intro

以下命令将显示关于/list/set命令的信息。未显示输出,因为它们很长:

jshell> /help /list
|...
jshell> /help /set
|...

有时,一个命令用于多个主题,例如,/set命令可用于设置反馈模式、片段编辑器、启动脚本等。如果你想打印一个命令的特定主题的信息,你可以使用以下格式的/help命令:

/help /<command> <topic-name>

以下命令打印关于设置反馈模式的信息:

jshell> /help /set feedback

以下命令打印有关创建自定义反馈模式的信息:

jshell> /help /set mode

使用带有主题作为参数的/help命令来打印关于主题的信息。目前有三个预定义的主题:introshortcutscontext。以下命令将打印 JShell 工具的介绍:

jshell> /help intro

以下命令将打印您可以在 JShell 工具中使用的快捷方式及其描述的列表:

jshell> /help shortcuts

以下命令将打印用于设置执行上下文的选项列表。这些选项与/env/reset/reload命令一起使用:

jshell> /help context

JShell API

JShell API 为您提供了对代码片段评估引擎的编程访问。作为开发人员,您可能不使用此 API。它旨在供 NetBeans IDE 之类的工具使用,NetBeans IDE 包括一个相当于 JShell 命令行工具的 UI,允许开发人员从 IDE 内部评估代码片段,而不是打开命令提示符来执行相同的操作。在这一节中,我们简要介绍 JShell API,并通过一个简单的例子展示它的用法。

JShell API 在jdk.jshell模块和jdk.jshell包中。如果你使用 JShell API,你的模块将需要读取jdk.jshell模块。JShell API 很简单。它主要由三个抽象类和一个接口组成:

  • JShell

  • Snippet

  • SnippetEvent

  • SourceCodeAnalysis

JShell类的一个实例代表一个代码片段评估引擎。这是JShell API 中的主类。一个JShell实例维护所有代码片段执行时的状态。

一个代码片段由一个Snippet类的实例表示。一个JShell实例在执行代码片段时生成代码片段事件。

片段事件由一个SnippetEvent接口的实例表示。snippet 事件包含 snippet 的当前和以前的状态、承载结果的 snippet 的值、导致事件的 snippet 的源代码、在 snippet 执行期间发生异常时的Exception对象等。

SourceCodeAnalysis类的一个实例为代码片段提供了源代码分析和建议功能。它回答了如下问题:

  • 是完整的片段吗?

  • 这个片段可以通过添加分号来完成吗?

一个SourceCodeAnalysis实例也提供了一个建议列表,例如,对于制表符结束和访问文档。该类旨在由提供 JShell 功能的工具使用。我们不会进一步讨论它。如果您有兴趣进一步研究它,请参考这个类的 Javadoc。

图 23-5 显示了 JShell API 不同组件的用例图。在随后的章节中,我们将解释这些类及其用途。我们将在最后一节向您展示一个完整的示例。

img/323069_3_En_23_Fig5_HTML.jpg

图 23-5

JShell API 组件的用例图

创建 JShell

JShell类是抽象的。它提供了两种创建其实例的方法:

  • 使用它的静态create()方法

  • 使用名为JShell.Builder的静态构建器类

create()方法返回一个预先配置好的JShell实例。下面的代码片段展示了如何使用create()方法创建一个JShell:

// Create a JShell instance
JShell shell = JShell.create()

通过让您指定代码片段 ID 生成器、临时变量名称生成器、用于打印输出的打印流、用于读取代码片段的输入流和用于记录错误的错误输出流,JShell.Builder类允许您配置JShell实例。您可以使用JShell类的builder()静态方法获得JShell.Builder类的实例。下面的代码片段展示了如何使用JShell.Builder类创建一个JShell,其中代码中的myXXXStream是对您的流对象的引用:

// Create a JShell instance
JShell shell = JShell.builder()
                     .in(myInputStream)
                     .out(myOutputStream)
                     .err(myErrorStream)
                     .build();

一旦有了一个JShell实例,就可以开始使用它的eval(String snippet)方法评估代码片段。您可以使用它的drop(PersistentSnippet snippet)方法删除一个代码片段。您可以使用它的addToClasspath(String path)方法将路径附加到类路径上。这三个方法改变了JShell实例的状态。

Tip

当您使用完一个JShell实例时,您需要调用它的close()方法来释放资源。JShell类实现了AutoCloseable接口,因此,使用try-with-resources块来处理JShell实例是确保它在不再使用时被关闭的最佳方式。一个JShell实例是可变的,并且不是线程安全的。

您可以使用JShell类的onSnippetEvent (Consumer<SnippetEvent> listener)onShutdown(Consumer<JShell> listener)方法注册代码片段事件处理程序和JShell关闭事件处理程序。当某个代码段的状态因第一次评估而发生更改,或者因评估另一个代码段而更新其状态时,将触发代码段事件。

JShell类中的sourceCodeAnalysis()方法返回了SourceCodeAnalysis类的一个实例,您可以用它来实现代码辅助功能。

JShell类中的其他方法用于查询状态。例如,snippets()types()methods()variables()方法分别返回所有代码段、所有带有活动类型声明的代码段、带有活动方法声明的代码段和带有活动变量声明的代码段的列表。

eval()方法是JShell类中最常用的方法。它评估/执行指定的代码片段并返回一个List<SnippetEvent>。您可以在列表中查询代码片段事件的执行状态。下面是使用eval()方法的一段代码:

// Create a snippet
String snippet = "int x = 100;";
// Evaluate the snippet
List<SnippetEvent> events = shell.eval(snippet);
// Process the results
events.forEach((SnippetEvent se) -> {
    /* Handle the snippet event here */
});

使用片段

Snippet类的一个实例代表一个片段。该类不提供创建其对象的方法。您将代码片段作为字符串提供给一个JShell,并接收作为代码片段事件一部分的Snippet类的实例。代码片段事件还为您提供代码片段的以前和当前状态。如果您有一个Snippet对象,您可以使用JShell类的status(Snippet s)方法查询它的当前状态,该方法返回一个Snippet.Status

Tip

Snippet类是不可变的和线程安全的。

Java 中有几种类型的代码片段,例如,变量声明、带初始化的变量声明、方法声明、类型声明等。Snippet类是一个抽象类,有一个子类来表示每个特定类型的代码片段。图 23-6 显示了Snippet类及其后代的类图。

img/323069_3_En_23_Fig6_HTML.jpg

图 23-6

Snippet 类及其后代的类图

Snippet类的子类的名字很直观。例如,PersistentSnippet的一个实例代表一个存储在JShell中的片段,可以重用,比如类声明或方法声明。Snippet类包含以下方法:

  • 字符串 id()

  • 字符串源()

  • 片段。善良善良()

  • 片段。子类子类()

id()方法返回代码片段的唯一 ID,而source()方法返回其源代码。kind()subKind()方法返回代码片段的类型和子类型。

片段的类型是Snippet.Kind枚举的常量之一,例如IMPORTTYPE_DECLMETHODVAR等。代码片段的子类型提供了关于其类型的更具体的信息,例如,如果代码片段是类型声明,它的子类型将告诉您它是类、接口、枚举还是注释声明。片段的子类型是Snippet.SubKind枚举的常量之一,如CLASS_SUBKINDENUM_SUBKIND等。Snippet.Kind枚举包含一个isPersistent属性,如果这种类型的代码片段是持久的,则该属性的值为true,否则为false

Snippet类的子类添加了更多的方法来返回关于特定类型代码片段的特定信息。例如,VarSnippet类包含一个typeName()方法,它返回变量的数据类型。MethodSnippet类包含parameterTypes()signature()方法,它们以字符串形式返回参数类型和方法的完整签名。

代码片段不包含其状态。A JShell执行并保持 a Snippet的状态。请注意,执行一个代码片段可能会影响其他代码片段的状态。例如,声明变量的代码片段可能会将声明方法的代码片段的状态从有效更改为无效,反之亦然(如果方法引用了变量)。如果您需要代码片段的当前状态,使用JShell类的status(Snippet s)方法,该方法返回Snippet.Status枚举的下列常量之一:

  • DROPPED:该代码片段是不活动的,因为它是使用JShell类的drop()方法删除的。

  • NONEXISTENT:该代码段不活动,因为它尚不存在。

  • OVERWRITTEN:该代码片段无效,因为它已被新代码片段替换。

  • RECOVERABLE_DEFINED:代码段是包含未解析引用的声明代码段。该声明具有有效的签名,并且对其他代码段可见。当其他代码片段将其状态更改为VALID时,可以恢复并使用它。

  • RECOVERABLE_NOT_DEFINED:代码段是包含未解析引用的声明代码段。该代码段的签名无效,并且对其他代码段不可见。当它的状态变为VALID时,可以使用它。

  • REJECTED:该代码片段是不活动的,因为它在初始评估时编译失败,并且它不能随着对JShell状态的进一步改变而变得有效。

  • VALID:该片段在当前JShell状态的上下文中有效。

处理代码片段事件

一个JShell实例生成片段事件作为片段评估或执行的一部分。您可以通过使用JShell类的onSnippetEvent()方法注册事件处理程序,或者通过使用JShell类的eval()方法的返回值(这是一个List<SnippetEvent>)来处理片段事件。以下代码片段向您展示了如何使用eval()方法的返回值来处理代码片段事件:

try (JShell shell = JShell.create()) {
    // Create a snippet
    String snippet = "int x = 100;";
    shell.eval(snippet)
         .forEach((SnippetEvent se) -> {
              Snippet s = se.snippet();
              System.out.printf("Snippet: %s%n", s.source());
              System.out.printf("Kind: %s%n", s.kind());
              System.out.printf("Sub-Kind: %s%n", s.subKind());
              System.out.printf("Previous Status: %s%n", se.previousStatus());
              System.out.printf("Current Status: %s%n", se.status());
              System.out.printf("Value: %s%n", se.value());
        });
}

一个例子

让我们看看 JShell API 的实际应用。清单 23-5 包含了一个名为JShellApiTest的类的完整代码,它是jdojo.jshell模块的成员。

// JShellApiTest.java
package com.jdojo.jshell;
import jdk.jshell.JShell;
import jdk.jshell.Snippet;
import jdk.jshell.SnippetEvent;
public class JShellApiTest {
    public static void main(String[] args) {
        // Create an array of snippets to evaluate/execute
        // them sequentially
        String[] snippets = {"int x = 100;",
            "double x = 190.89;",
            "long multiply(int value) {return value * multiplier;}",
            "int multiplier = 2;",
            "multiply(200)",
            "mul(99)"
        };
        try (JShell shell = JShell.create()) {
            // Register a snippet event handler
            shell.onSnippetEvent(JShellApiTest::snippetEventHandler);
            // Evaluate all snippets
            for (String snippet : snippets) {
                shell.eval(snippet);
                System.out.println("------------------------");
            }
        }
    }
    public static void snippetEventHandler(SnippetEvent se) {
        // Print the details of this snippet event
        Snippet snippet = se.snippet();
        System.out.printf("Snippet: %s%n", snippet.source());
        // Print the cause of this snippet event
        Snippet causeSnippet = se.causeSnippet();
        if (causeSnippet != null) {
            System.out.printf("Cause Snippet: %s%n", causeSnippet.source());
        }
        System.out.printf("Kind: %s%n", snippet.kind());
        System.out.printf("Sub-Kind: %s%n", snippet.subKind());
        System.out.printf("Previous Status: %s%n", se.previousStatus());
        System.out.printf("Current Status: %s%n", se.status());
        System.out.printf("Value: %s%n", se.value());
        Exception e = se.exception();
        if (e != null) {
            System.out.printf("Exception: %s%n", se.exception().getMessage());
        }
    }
}
Snippet: int x = 100;
Kind: VAR
Sub-Kind: VAR_DECLARATION_WITH_INITIALIZER_SUBKIND
Previous Status: NONEXISTENT
Current Status: VALID
Value: 100
---------------------------------------------------------------
Snippet: double x = 190.89;
Kind: VAR
Sub-Kind: VAR_DECLARATION_WITH_INITIALIZER_SUBKIND
Previous Status: VALID
Current Status: VALID
Value: 190.89
Snippet: int x = 100;
Cause Snippet: double x = 190.89;
Kind: VAR
Sub-Kind: VAR_DECLARATION_WITH_INITIALIZER_SUBKIND
Previous Status: VALID
Current Status: OVERWRITTEN
Value: null
---------------------------------------------------------------
Snippet: long multiply(int value) {return value * multiplier;}
Kind: METHOD
Sub-Kind: METHOD_SUBKIND
Previous Status: NONEXISTENT
Current Status: RECOVERABLE_DEFINED
Value: null
---------------------------------------------------------------
Snippet: int multiplier = 2;
Kind: VAR
Sub-Kind: VAR_DECLARATION_WITH_INITIALIZER_SUBKIND
Previous Status: NONEXISTENT
Current Status: VALID
Value: 2
Snippet: long multiply(int value) {return value * multiplier;}
Cause Snippet: int multiplier = 2;
Kind: METHOD
Sub-Kind: METHOD_SUBKIND
Previous Status: RECOVERABLE_DEFINED
Current Status: VALID
Value: null
---------------------------------------------------------------
Snippet: multiply(200)
Kind: VAR
Sub-Kind: TEMP_VAR_EXPRESSION_SUBKIND
Previous Status: NONEXISTENT
Current Status: VALID
Value: 400
---------------------------------------------------------------
Snippet: mul(99)
Kind: ERRONEOUS
Sub-Kind: UNKNOWN_SUBKIND
Previous Status: NONEXISTENT
Current Status: REJECTED
Value: null
---------------------------------------------------------------

Listing 23-5A JShellApiTest Class to Test the JShell API

main()方法创建以下六个代码片段,并将它们存储在一个String数组中:

  • "int x = 100;"

  • "double x = 190.89;"

  • "long multiply(int value) {return value * multiplier;}"

  • "int multiplier = 2;"

  • "multiply(200)"

  • "mul(99)"

一个try-with-resources块用于创建一个JShell实例。snippetEventHandler()方法被注册为一个片段事件处理程序。该方法打印关于片段的细节,例如其源代码、导致片段状态更新的片段的源代码、片段的先前和当前状态、其值等。最后,使用一个for-each循环遍历所有代码片段,并调用eval()方法来执行它们。

让我们浏览一下执行每个代码片段时JShell引擎的状态:

  • 当执行代码片段#1 时,该代码片段不存在,所以它从NONEXISTENT状态转换到VALID状态。它是一个变量声明片段,其计算结果为100

  • 当代码片段#2 被执行时,它已经存在了。注意,它用不同的数据类型声明了同一个名为x的变量。它以前的状态是VALID,现在的状态也是VALID。这个代码片段的执行改变了代码片段#1 的状态,它的状态从VALID变为OVERWRITTEN,因为不能有两个同名的变量。

  • 代码片段#3 声明了一个名为multiply()的方法,该方法在其主体中使用了一个名为multiplier的未声明变量,因此其状态从NONEXISTENT变为RECOVERABLE_DEFINED。该方法已定义,这意味着它可以被引用,但不能被调用,直到定义了适当类型的名为multiplier的变量。

  • 代码片段#4 定义了一个名为multiplier的变量,这使得代码片段#3 有效。

  • 代码片段#5 计算一个调用multiply()方法的表达式。该表达式是有效的,其计算结果为400

  • 代码片段#6 计算一个调用mul()方法的表达式,这个方法您从未定义过。该片段是错误的,因此被拒绝。

通常,您不会一起使用 JShell API 和 JShell 工具。然而,让我们一起使用它们只是为了好玩。JShell API 只是 Java 中的另一个 API,它也可以在 JShell 工具内部使用。下面的jshell会话实例化一个JShell,注册一个代码片段事件处理程序,并评估两个代码片段:

C:\JavaFun>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> /set feedback silent
-> import jdk.jshell.*
-> JShell shell = JShell.create()
-> shell.onSnippetEvent(se -> {
>>  System.out.printf("Snippet: %s%n", se.snippet().source());
>>  System.out.printf("Previous Status: %s%n", se.previousStatus());
>>  System.out.printf("Current Status: %s%n", se.status());
>>  System.out.printf("Value: %s%n", se.value());
>> });
-> shell.eval("int x = 100;");
Snippet: int x = 100;
Previous Status: NONEXISTENT
Current Status: VALID
Value: 100
-> shell.eval("double x = 100.89;");
Snippet: double x = 100.89;
Previous Status: VALID
Current Status: VALID
Value: 100.89
Snippet: int x = 100;
Previous Status: VALID
Current Status: OVERWRITTEN
Value: null
-> shell.close()
-> /exit
C:\JavaFun>

摘要

被称为 JShell 的 Java shell 是一个命令行工具,它提供了一种访问 Java 编程语言的交互方式。它让您评估 Java 代码片段,而不是强迫您编写整个 Java 程序。它是 Java 的一个REPL。JShell 也是一个 API,为其他工具(如 ide)的 Java 代码提供对REPL功能的编程访问。

您可以通过运行安装 JDK 时复制到JDK_HOME\bin目录的jshell程序来启动 JShell 命令行工具。该工具支持执行代码片段和命令。片段是 Java 代码的片断。在评估/执行代码片段时,JShell 会保持其状态。它还跟踪所有输入片段的状态。您可以使用命令查询 JShell 状态并配置jshell环境。为了区分命令和片段,所有的命令都以斜杠(/)开始。

JShell 包含几个特性,可以提高开发人员的工作效率,并提供更好的用户体验,比如自动完成代码和在工具中显示 Javadoc。它试图使用 JDK 中已经存在的功能,例如编译器 API 来解析、分析和编译代码片段,以及 Java 调试器 API 来用 JVM 中的新代码片段替换现有的代码片段。它的设计使得无需对 JShell 工具本身进行任何修改或稍加修改就可以在 Java 语言中使用新的构造。

EXERCISES

  1. 什么是 Java shell?

  2. 您使用什么命令来启动 JShell 命令行工具?

  3. 您使用什么命令来退出 JShell 命令行工具?

  4. 在 JShell 工具中,使用什么命令来打印帮助?

  5. JShell 工具如何区分代码片段和命令?

  6. 为什么在您的代码片段中不能有一个在jshell中输入的包声明?

  7. 您使用什么命令来列出所有活动代码片段、所有代码片段和所有启动代码片段?

  8. 在 JShell 工具中用什么命令来设置模块路径和类路径?

  9. 如何运行jshell中的前一个片段?

  10. 当你执行jshell中的一个片段时,抛出一个检查过的异常会发生什么?

  11. 在 JShell 工具中,您使用什么键来自动完成代码?

  12. 你用什么组合键把一个表达式自动转换成适当类型的变量声明?

  13. 您使用什么组合键来自动导入代码片段中未解析的类型?

  14. JShell 工具内置的四种反馈模式是什么?学习 JShell 工具时,您应该使用哪种反馈模式?可以自定义内置反馈模式吗?

  15. 编写将当前和所有未来会话的反馈模式设置为verbose的命令。

  16. 执行/reset命令有什么效果?

  17. 执行/reload命令有什么效果?

  18. 您使用什么命令将jshell会话中的片段保存到文件中,并将片段从文件加载到jshell会话中?

  19. 描述 JShell API 中的JShellSnippetSnippetEvent类的作用。

  20. 如何创建一个JShell类的实例?

  21. 如何在你的程序中获得一个Snippet类的实例?

  22. 如何启动 JShell 工具,以便可以使用println()函数打印消息,而不是使用System.out.println()。显示在 JShell 工具中使该设置永久化的命令。

posted @ 2024-08-06 16:34  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报