Java-技术手册第八版-一-

59 阅读1小时+

Java 技术手册第八版(一)

原文:zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是一本桌面 Java 参考书,设计为您编程时放在键盘旁边的忠实伴侣。第一部分,“介绍 Java” 是一本快节奏的“无废话”介绍,涵盖了 Java 编程语言和 Java 平台核心运行时方面的内容。第二部分,“使用 Java 平台” 是一个参考部分,混合了核心概念的阐述和重要核心 API 的示例。本书覆盖了 Java 17,但我们知道有些团队可能还未采用该版本,因此在可能的情况下,我们标注了某些功能在 Java 8 之后引入。

第八版的变化

本书的第七版覆盖了 Java 11,而本版则覆盖了 Java 17。然而,随着 Java 9 的到来,Java 的发布过程发生了重大变化,并且某些 Java 的发布现在被标记为 长期支持(LTS)版本。因此,Java 17 是 Java 11 之后的下一个 LTS 发布版本。

在第八版中,我们试图更新“概述”指南的概念。现代 Java 开发者需要了解的不仅仅是语法和 API。随着 Java 环境的成熟,诸如并发性、面向对象设计、内存和 Java 类型系统等主题对所有开发者都变得更加重要。

在本版中,我们采取的方法是,只有最新版本的 Java 才可能引起大多数 Java 开发者的兴趣,因此我们通常只在 Java 8 之后引入新功能时进行标注。

例如,模块系统(Java 9 中引入)对于一些开发者来说可能仍然是新事物,并且它代表了一个重大的变化。然而,它也是一个高级主题,并在某些方面与语言的其余部分分开,因此我们将其处理限制在了一个单独的章节中。

本书的内容

前六章记录了 Java 语言和 Java 平台的内容,它们都应被视为必读内容。本书对 Oracle/OpenJDK(开放 Java 开发工具包)的实现有所偏好,但并不是过于偏袒。使用其他 Java 环境的开发者仍然会找到大量内容可供参考。第一部分 包括:

第一章,“介绍 Java 环境”

本章是 Java 语言和 Java 平台的概述。它解释了 Java 的重要特性和优势,包括 Java 程序的生命周期。我们还涉及了 Java 安全性,并回答了一些关于 Java 的批评。

第二章,“从基础开始的 Java 语法”

本章详细说明了 Java 编程语言的细节,包括 Java 8 语言变化。这是一个长篇详细的章节,不假设有大量编程经验。有经验的 Java 程序员可以将其用作语言参考。有着诸如 C 和 C++之类语言丰富经验的程序员可以通过仔细阅读本章迅速掌握 Java 语法;而仅具有少量编程经验的初学者可以通过仔细学习本章来学习 Java 编程,尽管最好与入门文本(如 O’Reilly 的Head First Java,作者为 Kathy Sierra、Bert Bates 和 Trisha Gee)一起阅读。

第三章,“Java 中的面向对象编程”

本章描述了如何使用 Java 中的类和对象编写简单的面向对象程序的基本 Java 语法,该章节不假设有面向对象编程的先验经验。它可供新程序员作为教程使用,也可供有经验的 Java 程序员作为参考使用。

第四章,“Java 类型系统”

本章在 Java 中的面向对象编程的基本描述基础上,介绍了 Java 类型系统的其他方面,如泛型类型、枚举类型和注解。有了这更全面的视角,我们可以讨论 Java 8 中最大的变化——Lambda 表达式的引入。

第五章,“Java 中的面向对象设计简介”

本章是对设计声音面向对象程序中使用的一些基本技术的概述,并简要讨论设计模式及其在软件工程中的应用。

第六章,“Java 的内存和并发处理方法”

本章解释了 Java 虚拟机如何代表程序员管理内存,以及内存和可见性如何与 Java 对并发编程和线程的支持紧密交织在一起。

前六章教会你 Java 语言,并让你快速掌握 Java 平台的最重要概念。第二部分详细介绍了如何在 Java 环境中进行实际编程工作。它包含大量示例,并旨在补充其他一些文本中的食谱方法。这部分包括:

第七章,“编程和文档约定”

本章记录了重要且广泛采用的 Java 编程约定。还解释了如何通过包含特殊格式的文档注释使你的 Java 代码具有自文档性。

第八章,“使用 Java 集合”

本章介绍了 Java 的标准集合库。这些数据结构对几乎每个 Java 程序的正常运行至关重要,如ListMapSet。详细解释了新的Stream抽象和 lambda 表达式与集合之间的关系。

第九章,“处理常见数据格式”

本章讨论如何使用 Java 有效地处理非常常见的数据格式,如文本、数字和时间(日期和时间)信息。

第十章,“文件处理和 I/O”

本章涵盖了多种不同的文件访问方法——从旧版本 Java 中发现的更经典的方法,到更现代甚至异步的风格。本章最后简要介绍了使用核心 Java 平台 API 进行网络编程。

第十一章,“类加载、反射和方法句柄”

本章介绍了 Java 中元编程的微妙艺术——首先介绍了关于 Java 类型的元数据概念,然后转向类加载的主题以及 Java 安全模型与类型动态加载的关联。本章最后介绍了类加载的一些应用和相对较新的方法句柄特性。

第十二章,“Java 平台模块”

本章描述了 Java 平台模块系统(JPMS),这是 Java 9 的重要特性,并介绍了它带来的广泛变化。

第十三章,“平台工具”

Oracle 的 JDK(以及 OpenJDK)包含许多有用的 Java 开发工具,尤其是 Java 解释器和 Java 编译器。本章记录了这些工具,以及用于与模块化 Java 工作的新工具,如交互式环境jshell

附录

本附录涵盖了 Java 17 版本之外的 Java,包括 Java 18 和 19 的发布以及增强语言和 JVM 的研究和开发项目。

相关书籍

O’Reilly 出版了一整套关于 Java 编程的书籍,包括本书的几本配套书籍:

学习 Java 由 Patrick Niemeyer 和 Daniel Leuck 撰写

这本书是 Java 的全面教程介绍,包括 XML 和客户端 Java 编程等主题。

Java 8 Lambdas 由 Richard Warburton 撰写

本书详细记录了 Java 8 的 lambda 表达式新特性,并介绍了对于来自早期版本 Java 的开发者来说可能不熟悉的函数式编程概念。

Head First Java 由 Kathy Sierra、Bert Bates 和 Trisha Gee 撰写

本书采用了一种独特的方法来教授 Java。那些视觉思维的开发者通常会发现它是传统 Java 书籍的很好补充。

您可以在 http://java.oreilly.com 找到完整的 O’Reilly Java 书籍列表。

本书使用约定

以下是本书使用的排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素指示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在 https://github.com/kittylyst/javanut8 下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码合并到产品文档中需要许可。

我们欣赏但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Java in a Nutshell,第八版,作者本·埃文斯、杰森·克拉克和大卫·弗拉纳根(O’Reilly)。版权所有 2023 年由本杰明·J·埃文斯和杰森·克拉克,978-1-098-13100-5。”

如果您认为使用代码示例超出了合理使用范围或上述授权,请随时通过 permissions@oreilly.com 联系我们。

致谢

Melissa Potter 是第八版的编辑。她在整个过程中的指导和许多有益的贡献非常帮助。

特别感谢 Dan Heidinga、Ashutosh Mehra 和 Aleksey Shipilëv。

本版的技术审阅者包括 Mario Torre、Tony Mancill、Achyut Madhusudan、Jeff Maury、Rui Vieira、Jeff Alder、Kevin Earls 和 Ashutosh Mehra。

第一部分:介绍 Java

第一部分是介绍 Java 语言和 Java 平台。这些章节提供了足够的信息让你立即开始使用 Java:

  • 第一章,“Java 环境简介”

  • 第二章,“从基础开始的 Java 语法”

  • 第三章,“Java 中的面向对象编程”

  • 第四章,“Java 类型系统”

  • 第五章,“Java 中面向对象设计的介绍”

  • 第六章,“Java 内存和并发处理的方法”

第一章:介绍 Java 环境

欢迎来到 2023 年的 Java 世界。

你可能是从另一个传统来到 Java 世界,或者这可能是你第一次接触计算机编程。无论你是以何种方式来到这里,欢迎——我们很高兴你来了。

Java 是一个功能强大的通用编程环境。它是世界上使用最广泛的编程环境之一,在商业和企业计算方面取得了非常成功的成就超过 25 年。

在本章中,我们将通过描述 Java 语言(程序员编写应用程序的语言)、Java 执行环境(称为“Java 虚拟机”,实际运行这些应用程序的环境)和 Java 生态系统(为开发团队提供编程环境价值的大部分内容)来揭开序幕。

这三个概念(语言、执行环境和生态系统)通常被简称为“Java”,具体用法取决于上下文。实际上,它们是如此紧密相关的想法,以至于这并不像一开始看起来那么令人困惑。

我们将简要介绍 Java 语言和虚拟机的历史,然后讨论 Java 程序的生命周期,最后解答一些关于 Java 和其他环境的常见问题。

语言、JVM 和生态系统

Java 编程环境自上世纪 90 年代末就存在了。它由 Java 语言和支持的运行时环境 Java 虚拟机(JVM)组成。第三个要素——超出 Java 标准库的 Java 生态系统——由第三方提供,例如开源项目和 Java 技术供应商。

在 Java 最初开发的时候,这种分离被认为是一种新颖的方式,但是在接下来的几年里,软件开发的趋势使它变得更加普遍。值得注意的是,微软的.NET 环境在 Java 之后几年宣布采用了非常类似的平台架构。

微软的.NET 平台和 Java 之间的一个重要区别是,Java 始终被构想为一个相对开放的多供应商生态系统,尽管由一个拥有该技术的管理者领导。在 Java 的历史中,这些供应商在 Java 技术的各个方面既合作又竞争。

Java 成功的主要原因之一是这个生态系统是一个标准化环境。这意味着组成环境的技术有规范。这些标准使开发者和用户确信,即使这些技术来自不同的技术供应商,它们也将与其他组件兼容。

Java 目前的管理者是 Oracle Corporation(收购了 Java 的发起者 Sun Microsystems)。其他公司,如 Red Hat、IBM、Amazon、Microsoft、AliBaba、SAP、Azul Systems 和 Bellsoft,也参与生产标准化 Java 技术的实现。

提示

从 Java 7 开始,Java 的参考实现是开源的 OpenJDK(Java 开发工具包),许多公司在此基础上进行合作并发布他们的产品。

Java 最初由几个不同但相关的环境和规范组成,如 Java Mobile Edition(Java ME),¹ Java Standard Edition(Java SE)和 Java Enterprise Edition(Java EE)。² 在本书中,我们将只涵盖 Java SE 17,并附带某些历史注解,相关内容涉及某些功能何时引入到该平台中。一般而言,如果没有进一步的澄清,人们提到“Java”,通常指的是 Java SE。

我们稍后将详细讨论标准化问题,因此让我们继续讨论 Java 语言和 JVM 作为分开但相关的概念。

Java 语言是什么?

Java 程序以 Java 语言的源代码形式编写。这是一种人类可读的编程语言,严格基于类并面向对象。语法设计有意模仿 C 和 C++,明确旨在让那些从这些语言转来的程序员感到熟悉,这两种语言在 Java 创建时期非常主导。

注意

尽管源代码类似于 C++,但在实践中,Java 包括与 Smalltalk 等动态语言更多相似的特性和受控运行时。

Java 被认为相对易于阅读和编写(尽管有时有些啰嗦)。它具有严格的语法和简单的程序结构,旨在易于学习和教学。它建立在 C++等语言的行业经验之上,并尝试删除复杂的特性,同时保留了先前编程语言中有效的部分。

Java 语言由 Java 语言规范(JLS)管理,定义了符合实现的行为。

总体而言,Java 旨在为公司开发关键业务应用提供稳定、坚实的基础。作为一种编程语言,它设计相对保守,变化缓慢。这些特性是有意为之,旨在保护组织在 Java 技术上的投资。

自 1996 年创立以来,语言经历了逐步修订(但没有完全重写)。这意味着 Java 一些最初的设计选择,虽然在 90 年代末是权宜之计,但至今仍在影响着语言的发展—详见第二章和第三章了解更多细节。

另一方面,在过去的 10 多年中,Java 在语法上有所现代化,以解决啰嗦性和提供更为熟悉的功能,以吸引来自其他流行语言的程序员。

例如,在 2014 年,Java 8 增加了近十年来语言中看到的最激进的变化。 像 Lambda 表达式和引入 Streams API 等功能非常受欢迎,并永久改变了 Java 开发人员编写代码的方式。

正如我们将在本章后面讨论的那样,Java 项目已经过渡到了一个新的发布模型。 在这个新模型中,Java 版本每 6 个月发布一次,但只有特定版本(8、11 和 17)被认为是 LTS 合格的。 所有其他版本仅支持 6 个月,未被开发团队广泛采纳。

什么是 JVM?

JVM 是一个提供 Java 程序运行环境所必需的程序。 如果没有适合我们希望在其上执行的硬件和操作系统平台的 JVM,则 Java 程序无法运行。

幸运的是,JVM 已被移植到许多硬件环境中——从机顶盒或蓝光播放器到大型主机,几乎都可以为其提供 JVM。 JVM 有其自己的规范,即 Java 虚拟机规范,每个实现都必须符合该规范的规则。 当新的硬件类型进入主流市场时,可能会有公司或个人对该硬件感兴趣,从而启动一个项目来将 OpenJDK 移植到新的芯片上。 最近的一个例子是新的 Apple M1 芯片——Red Hat 将 JVM 移植到 AArch64 架构,然后微软对构建所需的 Apple 硅进行了移植更改。

Java 程序可以通过多种方式启动,但最简单(也是最早的)方法是从命令行启动:

java <*`arguments`*> <*`program` `name`*>

JVM 作为操作系统进程启动,提供 Java 运行环境,然后在全新启动(空)虚拟机的背景下执行我们的程序。

需要理解的是,当 JVM 接收 Java 程序进行执行时,程序不是以 Java 语言源代码的形式提供的。 相反,Java 语言源代码必须编译成称为 Java 字节码的形式。 Java 字节码以 class 文件的形式提供给 JVM(其扩展名始终为*.class*)。 Java 平台一直强调向后兼容性,编写 Java 1.0 的代码仍然可以在今天的 JVM 上运行,无需修改或重新编译。

JVM 为程序提供了执行环境。 它启动了一个字节码形式程序的解释器,逐条执行字节码指令。 然而,生产质量的 JVM 还提供了一个特殊的编译器,在 Java 程序运行时运行。 这个编译器(称为“JIT”或即时编译器)通过用等效的编译(和高度优化的)机器代码替换重要部分来加速程序的执行。

你还应该知道,JVM 和用户程序都能够生成额外的执行线程,因此用户程序可以同时运行许多不同的函数。

JVM 的原始设计基于对较早编程环境(特别是 C 和 C++)多年的经验,因此我们可以将其视为具有几个不同目标——全部旨在为程序员简化生活:

  • 构成应用代码运行的标准执行环境

  • 促进安全可靠的代码执行(与 C/C++ 相比)

  • 将低级内存管理从开发者手中拿走

  • 提供跨平台执行环境

这些目标经常在平台讨论中一起提到。

当我们讨论 JVM 及其字节码解释器时,已经提到了这些目标的第一个——它作为应用代码的容器。

当我们讨论 Java 环境如何处理内存管理时,我们将在第六章中讨论第二个和第三个目标。

第四个目标,有时被称为“一次编写,到处运行”(WORA),是 Java 类文件可以从一个执行平台移动到另一个,只要有 JVM 可用,它们就会运行不变。

这意味着可以在运行 macOS 上的 M1 芯片的机器上开发 Java 程序(并转换为类文件),然后将类文件移动到 Linux 或 Microsoft Windows 上的 Intel 硬件(或其他平台),Java 程序将无需进一步工作即可运行。

注意

Java 环境已经广泛移植,包括到与主流平台如 Linux、macOS 和 Windows 非常不同的平台。在本书中,我们使用术语“大多数实现”来表示大多数开发者可能会遇到的平台;macOS、Windows、Linux、BSD Unix 等都被视为“主流平台”并且在“大多数实现”中计数。

除了这四个主要目标之外,JVM 设计的另一个方面经常未被认可或讨论——它使用运行时信息进行自管理。

20 世纪 70 年代和 80 年代的软件研究揭示了程序的运行时行为有许多有趣和有用的模式,这些模式在编译时无法推断出。JVM 是第一个真正主流的编程环境,使用了这些研究结果。

它收集运行时信息以做出更好的代码执行决策。这意味着 JVM 能够监控并优化在其上运行的程序,这是其他平台无法实现的。

一个关键例子是运行时事实,即 Java 程序的各个部分在程序生命周期内被调用的可能性并不相等——某些部分远远比其他部分频繁调用。Java 平台利用这一事实的技术称为即时(JIT)编译。

在 HotSpot JVM(这是 Sun 在 Java 1.3 中首次发布的 JVM,并且至今仍在使用中),JVM 首先识别程序中调用最频繁的部分——“热方法”。然后,JVM 将这些热方法直接编译成机器代码,绕过 JVM 解释器。

JVM 利用可用的运行时信息提供比纯解释执行更高的性能。事实上,在许多情况下,JVM 现在使用的优化技术产生的性能甚至超过了编译的 C 和 C++代码。

描述 JVM 必须如何正常运行的标准称为 JVM 规范。

什么是 Java 生态系统?

与其他编程语言相比,Java 语言易于学习且包含相对较少的抽象。JVM 为 Java(或其他语言)提供了坚实、可移植、高性能的执行基础。总之,这两个相关技术为企业在选择其开发努力的基础时提供了可信赖的基础。

然而,Java 的好处并不仅限于此。自 Java 诞生以来,第三方库和组件的极其庞大生态系统已经形成。这意味着开发团队可以从几乎每种可想象的技术中获益,包括专有和开源的连接器和驱动程序。

在现代技术生态系统中,现在几乎找不到提供 Java 连接器的技术组件。从传统关系数据库到 NoSQL,再到各种企业监控系统,消息系统,到物联网(IoT)——一切都与 Java 集成。

正是这一事实成为企业和大公司采用 Java 技术的主要驱动因素之一。开发团队能够通过利用现有的库和组件释放其潜力。这促进了开发者的选择,并鼓励基于 Java 技术核心的开放式、最佳的架构。

注意

谷歌的 Android 环境有时被认为是“基于 Java 的”。然而,事实情况要复杂得多。Android 代码是用 Java(或 Kotlin 语言)编写的,但最初使用了 Java 类库的不同实现以及一个交叉编译器,将其转换为非 Java 虚拟机的不同文件格式。

丰富的生态系统与一流的虚拟机结合,并具有程序二进制的开放标准,使得 Java 平台成为一个非常吸引人的执行目标。实际上,有许多非 Java 语言针对 JVM 并与 Java 进行互操作(使它们可以依附于平台的成功)。这些语言包括 Kotlin、JRuby、Scala、Clojure 等。虽然它们与 Java 相比都很小,但它们在 Java 世界内有着明确的市场定位,并为 Java 提供了创新和健康竞争的源泉。

Java 程序的生命周期

要更好地理解 Java 代码是如何编译和执行的,以及 Java 与其他类型的编程环境之间的区别,请考虑 图 1-1 中的流水线。

JN7 0101

图 1-1. Java 代码是如何编译和加载的

这从 Java 源代码开始,通过 javac 程序生成包含已编译为 Java 字节码的源代码的类文件。类文件是平台处理的最小功能单元,也是将新代码导入运行程序的唯一方式。

新的类文件通过类加载机制(详见 第十章 了解更多有关类加载工作原理的细节)上载。这使得新代码(表示为类型)可供解释器执行,并且执行从 main() 方法开始。

Java 程序的性能分析和优化是一个重要话题,有兴趣的读者应参考专业文本,如 优化 Java(O’Reilly)。

常见问题

在本节中,我们将讨论关于 Java 和 Java 环境中编写的程序生命周期的一些最常见问题。

什么是虚拟机?

当开发人员首次接触虚拟机的概念时,他们有时会将其视为“计算机中的计算机”或“在软件中模拟的计算机”。然后很容易将字节码想象为“内部计算机的 CPU 的机器码”或“虚拟处理器的机器码”。然而,这种简单的直觉可能会误导。

什么是字节码?

实际上,JVM 字节码与真正运行在硬件处理器上的机器码并不十分相似。相反,计算机科学家会将字节码称为一种 中间表示 类型,介于源代码和机器码之间。

javac 是编译器吗?

编译器通常会生成机器码,但 javac 生成的是字节码,与机器码并不十分相似。然而,类文件有点像对象文件(如 Windows 的 .dll 文件或 Unix 的 .so 文件),它们肯定不是人类可读的。

从理论计算机科学的角度来看,javac最类似于编译器的前半部分——它创建了中间表示,随后可以用于生成(发射)机器代码。

然而,由于类文件的创建是一个单独的构建时间步骤,类似于 C/C++中的编译,许多开发人员认为运行javac是编译。在本书中,我们将使用术语“源代码编译器”或“javac编译器”来表示javac生成类文件的过程。

我们将保留“编译”作为一个独立的术语,指的是 JIT 编译——因为正是 JIT 编译实际上生成了机器代码。

为什么称为“字节码”?

指令代码(操作码)只是一个字节(有些操作还有随后的参数跟在字节流中),因此只有 256 个可能的指令。实际上,有些是未使用的——大约有 200 个在使用中,但某些最近版本的javac并没有发出它们。

字节码是否经过优化?

在平台早期阶段,javac生成了经过大量优化的字节码。这被证明是一个错误。

随着 JIT 编译的出现,重要的方法将被编译成非常快速的机器代码。因此,让 JIT 编译器的工作更容易非常重要——因为与优化字节码相比,JIT 编译可以带来更大的性能提升,而优化字节码仍然必须被解释。

字节码真的是机器无关的吗?诸如字节序之类的因素呢?

无论字节码是在何种类型的机器上创建的,其格式始终相同。这包括机器的字节顺序(有时称为“字节序”)。对于对细节感兴趣的读者,字节码始终是大端序的。

Java 是一种解释性语言吗?

JVM 基本上是一个解释器(通过 JIT 编译来大幅提升性能)。然而,大多数解释性语言直接从源代码形式解释程序(通常通过从输入源文件构造抽象语法树来实现)。另一方面,JVM 解释器需要类文件——当然,这需要使用javac进行单独的源代码编译步骤。

实际上,许多传统上是解释性的语言(如 PHP、Ruby 和 Python)的现代版本现在也具有 JIT 编译器,因此“解释性”和“编译性”语言之间的界限越来越模糊。再次验证了 Java 在其他编程环境中采用的设计决策的有效性。

其他语言可以在 JVM 上运行吗?

是的。JVM 可以运行任何有效的类文件,这意味着非 Java 语言可以通过多种方式在 JVM 上运行。首先,它们可以有一个类似于javac的源代码编译器,生成类文件,这些类文件可以像 Java 代码一样在 JVM 上运行(这是 Kotlin 和 Scala 等语言采用的方法)。

或者,一种非 Java 语言可以在 Java 中实现解释器和运行时,然后直接解释其语言的源形式。这种第二种选择是像 JRuby 这样的语言采取的方法(但在某些情况下 JRuby 具有非常复杂的运行时,能够进行二次 JIT 编译)。

将 Java 与其他语言进行比较

在本节中,我们将简要介绍 Java 平台与您可能熟悉的其他编程环境之间的一些区别。

Java 与 JavaScript 比较

  • Java 是静态类型的;JavaScript 是动态类型的。

  • Java 使用基于类的对象;JavaScript 是基于原型的(JS 关键字 class 是语法糖)。

  • Java 提供了良好的对象封装;JavaScript 则没有。

  • Java 有命名空间;JavaScript 没有。

  • Java 是多线程的;JavaScript 不是。

Java 与 Python 比较

  • Java 是静态类型的;Python 是动态类型的(具有可选的、逐渐类型)。

  • Java 是一种带有函数式编程(FP)特性的面向对象语言;Python 是一种混合面向对象/过程化语言,具有一些 FP 支持。

  • Java 和 Python 都有字节码格式—Java 使用 JVM 类文件;Python 使用 Python 字节码。

  • Java 的字节码具有广泛的静态检查;Python 的字节码则没有。

  • Java 是多线程的;Python 一次只允许一个线程执行 Python 字节码(全局解释器锁)。

Java 与 C 比较

  • Java 是面向对象的;C 是过程化的。

  • Java 作为类文件是可移植的;C 需要重新编译。

  • Java 作为运行时的一部分提供了广泛的仪器设备。

  • Java 没有指针,也没有指针算术的等价物。

  • Java 通过垃圾收集实现了自动内存管理。

  • Java 当前无法在低级别上布局内存(没有结构体)。

  • Java 没有预处理器。

Java 与 C++ 比较

  • Java 的对象模型与 C++ 相比较简化。

  • Java 的方法分派默认是虚拟的。

  • Java 总是传值调用(但 Java 值的唯一可能性之一是对象引用)。

  • Java 不支持完全多继承。

  • Java 的泛型比 C++ 模板弱大(但也更安全)。

  • Java 没有操作符重载。

对 Java 的一些批评进行回答

Java 在公众眼中有着悠久的历史,因此多年来吸引了很多批评。这些负面报道的一部分可以归因于一些技术上的缺陷,再加上在 Java 的最初版本中过度热衷的营销。

尽管一些批评已经进入技术传说,但实际上已经不再很准确。在本节中,我们将看一下一些常见的抱怨以及它们对于现代版本的平台来说是否属实。

过于冗长

Java 核心语言有时被批评为过于冗长。甚至简单的 Java 语句如 Object o = new Object(); 似乎是重复的——类型 Object 在赋值语句的左右两侧都出现。批评者指出这基本上是多余的,其他语言不需要这种类型信息的重复,并且许多语言支持可以移除它的特性(例如类型推断)。

这一论点的反驳是,Java 从一开始就被设计成易于阅读(代码读取次数多于编写次数),许多程序员,特别是新手,在阅读代码时发现额外的类型信息非常有帮助。

Java 广泛应用于企业环境中,这些环境通常有单独的开发和运维团队。当你响应故障调用时,或者需要维护和修补已经由长时间离开的开发人员编写的代码时,额外的冗长通常是一种福音。

在最近的 Java 版本中,语言设计者试图通过找到语法可以变得更简洁的地方,并更好地利用类型信息来响应其中一些观点。例如:

// Files helper methods
byte[] contents =
  Files.readAllBytes(Paths.get("/home/ben/myFile.bin"));

// Diamond syntax for repeated type information
List<String> l = new ArrayList<>();

// Local variables can be type inferred
var threadPool = Executors.newScheduledThreadPool(2);

// Lambda expressions simplify Runnables
threadPool.submit(() -> { System.out.println("On Threadpool"); });

然而,Java 的总体哲学是非常缓慢和谨慎地修改语言,因此这些变化的步伐可能无法完全满足批评者的要求。

缓慢的变化

原始的 Java 语言现在已经超过 20 年历史,这段时间内没有进行完整的修订。许多其他语言(例如微软的 C#)在同一时期内发布了不兼容的版本,一些开发人员批评 Java 没有做同样的事情。

此外,近年来,Java 语言因为缓慢采纳现在其他语言普遍采用的语言特性而受到批评。

Sun(现在是 Oracle)采取的语言设计保守方法是为了避免在非常庞大的用户群体中强加错误特性的成本和外部性。许多 Java 开发团队对该技术进行了重大投资,语言设计者严肃地对待不干扰现有用户和安装基础的责任。

每个新的语言特性都需要非常谨慎地考虑——不仅是独立存在的,还要考虑它将如何与语言的所有现有特性相互作用。新特性有时会在其直接范围之外产生影响——而 Java 被广泛应用于非常庞大的代码库中,这里有更多潜在的地方可能出现意外的相互作用。

一旦推出后发现有错误的特性几乎不可能移除。Java 有一些误功能(如序列化机制),几乎无法安全地移除而不影响已安装基础。语言设计者认为,在演化语言时需要极度谨慎。

尽管如此,近期版本中引入的新语言特性显著地解决了最常见的功能缺失问题,它们应该涵盖开发人员一直在寻求的许多编程习惯。

性能问题

尽管 Java 平台有时被批评运行速度慢,但在所有批评中,这可能是最不合理的一个。这是对该平台的一个真正的误解。

Java 1.3 发布引入了 HotSpot 虚拟机及其 JIT 编译器。自那时以来,虚拟机及其性能已经持续创新和改进超过 15 年。如今,Java 平台运行速度极快,在流行框架的性能基准测试中经常获胜,甚至超越了本地编译的 C 和 C++。

在这一领域的批评似乎主要是由一个传统观念引起的,即 Java 在过去某个时刻运行缓慢。Java 应用于的一些较大、较复杂的架构也可能加深了这种印象。

事实是,任何一个大型架构都需要基准测试、分析和性能调优,以达到最佳状态——Java 也不例外。

平台的核心部分——语言和 JVM——过去和现在仍然是开发者可用的最快速的通用环境之一。

不安全

一些人历史上批评过 Java 在安全漏洞方面的记录。

许多这些漏洞涉及 Java 系统的桌面和 GUI 组件,并不会影响使用 Java 编写的网站或其他服务器端代码。

事实是,Java 从一开始就以安全性为设计核心;这使得它比许多其他现有的系统和平台具有明显优势。Java 安全架构是由安全专家设计的,并自平台成立以来得到了许多其他安全专家的研究和检验。普遍的共识是,该架构本身坚固可靠,设计上没有任何安全漏洞(至少目前没有被发现的)。

安全模型设计的核心是,字节码在表达能力上受到严格限制——例如,无法直接访问内存。这一设计消除了像 C 和 C++语言中存在的大量安全问题。此外,每当加载一个不受信任的类时,虚拟机会进行称为字节码验证的过程,进一步消除了另一大类问题(详见第十章了解更多关于字节码验证的内容)。

然而,尽管如此,没有任何系统可以保证 100%的安全性,Java 也不例外。

虽然设计在理论上仍然健壮,但安全架构的实现又是另一回事,Java 的特定实现中存在着漫长的安全漏洞历史。很可能会在 Java VM 的实现中继续发现(并修补)安全漏洞。

所有编程平台都可能存在安全问题,许多其他语言也有着相当数量的安全漏洞历史,只不过这些漏洞的公开程度要低得多。就实际的服务器端编码而言,Java 或许是目前可用的最安全的通用平台,特别是在保持最新补丁的情况下。

过于企业化

Java 是一个被企业开发者广泛使用的平台。因此,人们认为它过于企业化并不奇怪 —— Java 经常被认为缺乏更加面向社区的“自由发挥”风格的语言。

事实上,Java 一直都是一个非常广泛使用的语言,用于社区和免费或开源软件开发。它是 GitHub 和其他项目托管站点上托管的项目中最受欢迎的语言之一。不仅如此,Java 社区经常被认为是生态系统的真正优势之一 —— 具有用户组、会议、期刊等一切活跃和健康用户社区的最显著迹象。

最广泛使用的语言实现是基于 OpenJDK —— OpenJDK 本身是一个具有充满活力和增长的社区的开源项目。

Java 和 JVM 的简史

Java 1.0 (1996)

这是 Java 的第一个公开版本。它仅包含 212 个类,组织在八个包中。

Java 1.1 (1997)

这个版本的 Java 将 Java 平台的规模增加了一倍以上。这个版本引入了“内部类”和第一个版本的反射 API。

Java 1.2 (1998)

这是 Java 的一个非常重要的版本发布;它将 Java 平台的规模扩大了三倍。这个版本标志着 Java 集合 API 的首次亮相(包括集合、映射和列表)。1.2 版本中的许多新特性导致 Sun 将平台重新命名为“Java 2 平台”。然而,“Java 2”这个术语仅仅是一个商标,而不是实际的版本号。

Java 1.3 (2000)

这主要是一个维护版本,重点是修复错误、提高稳定性和性能。这个版本还引入了 HotSpot Java 虚拟机,至今仍在使用(尽管自那时以来已经经过了大幅修改和改进)。

Java 1.4 (2002)

这是另一个相当重要的版本发布,增加了一些重要的新功能,如更高性能的低级 I/O API;用于文本处理的正则表达式;XML 和 XSLT 库;SSL 支持;日志记录 API;以及加密支持。

Java 5 (2004)

这个大版本的 Java 引入了一些对核心语言本身的改变,包括泛型类型、枚举类型、注解、可变参数方法、自动装箱以及新的for循环。这些改变被认为足够重要以至于改变了主版本号的编号方式,开始以主要版本发布。此版本包含了 166 个包中的 3562 个类和接口。显著的增加包括用于并发编程的实用程序、远程管理框架以及用于 Java 虚拟机自身的远程管理和仪器化类。

Java 6 (2006)

这个版本主要是维护和性能优化版本。它引入了编译器 API,扩展了注解的使用和范围,并提供了绑定以允许脚本语言与 Java 互操作。还有大量内部错误修复和对 JVM 和 Swing GUI 技术的改进。

Java 7 (2011)

在 Oracle 掌管下的第一个 Java 发布版包括对语言和平台的一些重大升级,同时也是基于开源参考实现的第一个发布版。引入了try-with-resources 和 NIO.2 API,使开发者能够编写更安全、更少出错的处理资源和 I/O 的代码。方法句柄 API 提供了反射的简单和安全替代方案;此外,它还为invokedynamic(自 Java 1.0 版以来的第一个新字节码)开启了大门。

Java 8 (2014) (LTS)

这是一个重大的发布版本,可能是自 Java 5 以来(或可能是有史以来)对语言进行的最重要的改变。引入了 lambda 表达式,显著提高了开发者的生产力,集合框架也更新以利用 lambda 表达式,为实现这一点所需的机制标志着 Java 在面向对象方面的根本性变化。其他主要更新包括新的日期和时间 API 以及并发库的重大更新。

Java 9 (2017)

这个版本显著延迟,引入了新的平台模块化特性,允许 Java 应用程序打包成部署单元并模块化平台运行时。其他变更包括新的默认垃圾收集算法、用于处理进程的新 API 以及框架访问内部方式的一些变更。此外,这个版本还改变了发布周期本身,使得新版本每 6 个月发布一次,但只有 LTS 版本获得了广泛应用。因此,我们从此点开始只记录 LTS 版本的发布。

Java 11 (September 2018) (LTS)

这个版本是首个被视为长期支持(LTS)版本的模块化 Java。它添加了一些对开发者直接可见的新功能,主要包括对类型推断(var)、JDK 飞行记录器(JFR)和新的 HTTP/2 API 的改进支持。还有一些额外的内部变更和显著的性能改进,但这个 LTS 版本主要旨在 Java 9 之后的稳定化。

Java 17(2021 年 9 月)(LTS)

当前版本 LTS 发布。包括对 Java 面向对象模型的重要改变(封闭类、记录和巢状类),以及开关表达式、文本块和语言模式匹配的初版。JVM 还进行了额外的性能改进,并提供了更好的容器中运行支持。内部升级继续进行,并添加了两个新的垃圾收集器。

目前,仅有 LTS 版本 11 和 17 是当前生产版本。由于模块引入的重大变化,Java 8 被追溯地宣布为 LTS 版本,为团队和应用程序迁移到受支持的模块化 Java 提供了额外的时间。现在它被认为是一个“经典”版本,并强烈建议团队迁移到其中一种现代的 LTS 版本。

摘要

在本介绍性章节中,我们将 Java 放置在编程语言的整体格局和历史中进行了比较。我们将语言与其他流行的替代方案进行了比较,首次查看了 Java 程序编译和执行的基本解剖,并试图消除关于 Java 的一些流行误解。

下一章将从自下而上的角度,重点介绍 Java 语言的语法,专注于词法语法的各个基本单元并逐步构建。如果您已经熟悉类似 Java 的语言的语法(如 JavaScript、C 或 C++),您可以选择略读或跳过本章,并在遇到不熟悉的语法时参考。

¹ Java ME 是面向功能手机和第一代智能手机的一个较旧的标准。如今,Android 和 iOS 主导手机市场,Java ME 已不再更新。

² Java EE 现已移交至 Eclipse Foundation,继续作为 Jakarta EE 项目存在。

第二章:从基础开始的 Java 语法

本章内容较为密集,但应提供 Java 语法的全面介绍。主要面向对该语言新手,但具有一定编程经验的读者。对于没有任何编程经验的决心新手,也可能会发现它有用。如果你已经了解 Java,你会发现它是一个有用的语言参考。本章还包括一些将 Java 与 JavaScript、C 和 C++进行比较的内容,以帮助那些来自这些语言的程序员。

本章从 Java 语法的最低级别开始,逐步构建并移向越来越高层次的结构,来记录 Java 程序的语法。涵盖内容包括:

  • 用于编写 Java 程序的字符及其编码方式。

  • Java 程序中包含的字面值、标识符和其他标记。

  • Java 可以操作的数据类型。

  • Java 中用于将单个标记组合成较大表达式的运算符。

  • 语句,将表达式和其他语句分组形成 Java 代码的逻辑块。

  • 方法,是可以被其他 Java 代码调用的命名 Java 语句集合。

  • 类,是方法和字段的集合。类是 Java 中的中心程序元素,并且构成面向对象编程的基础。第三章完全专注于讨论类和对象。

  • 包,是相关类的集合。

  • Java 程序由一个或多个相互作用的类组成,这些类可以来自一个或多个包。

大多数编程语言的语法都很复杂,Java 也不例外。一般而言,不可能在不涉及尚未讨论的其他元素的情况下记录语言的所有元素。例如,不可能真正有意义地解释 Java 支持的运算符和语句而不涉及对象。但也不可能在不涉及语言的运算符和语句的情况下彻底记录对象。因此,学习 Java 或任何语言的过程是一个迭代的过程。

自上而下的 Java 程序

在我们开始自底向上探索 Java 语法之前,让我们来了解一下 Java 程序的概述。Java 程序由一个或多个 Java 源代码文件或编译单元组成。在本章末尾附近,我们描述了 Java 文件的结构,并解释了如何编译和运行 Java 程序。每个编译单元以一个可选的package声明开头,后跟零个或多个import声明。这些声明指定了编译单元将定义名称的命名空间以及编译单元从中导入名称的命名空间。我们将在本章后面再次看到packageimport,详见“包和 Java 命名空间”。

可选的packageimport声明后面可以跟零个或多个引用类型定义。我们将在第三章和第四章中详细介绍可能的引用类型,但现在需要注意的是,这些类型通常是classinterface定义之一。

在引用类型的定义中,我们将遇到成员,例如字段方法构造函数。方法是最重要的成员类型。方法是由语句组成的 Java 代码块。

通过定义这些基本术语,让我们从底层向上探索 Java 程序,通过检查词法标记的基本语法单元开始。

词法结构

本节解释了 Java 程序的词法结构。它从编写 Java 程序的 Unicode 字符集讨论开始。然后,它涵盖了组成 Java 程序的标记,解释了注释、标识符、保留字、字面量等内容。

Unicode 字符集

Java 程序使用 Unicode 编写。您可以在 Java 程序的任何地方使用 Unicode 字符,包括注释和标识符(如变量名)。与仅对英语有用的 7 位 ASCII 字符集以及仅对主要西欧语言有用的 8 位 ISO Latin-1 字符集不同,Unicode 字符集可以表示几乎全球所有通用的书面语言。

提示

如果您不使用支持 Unicode 的文本编辑器,或者不想强迫查看或编辑您代码的其他程序员使用支持 Unicode 的编辑器,您可以使用特殊的 Unicode 转义序列\u*xxxx*将 Unicode 字符嵌入到 Java 程序中——即反斜杠和小写 u,后跟四个十六进制字符。例如,\u0020是空格字符,\u03c0是π字符。

Java 在确保其 Unicode 支持一流的情况下投入了大量时间和工程努力。如果您的业务应用程序需要处理全球用户,特别是非西方市场的用户,那么 Java 平台是一个很好的选择。此外,Java 还支持多种编码和字符集,以便与不支持 Unicode 的非 Java 应用程序进行交互。

大小写敏感性和空白字符

Java 是大小写敏感的语言。它的关键字以小写形式编写并且必须始终使用。也就是说,WhileWHILEwhile关键字不同。同样,如果您在程序中声明了一个名为i的变量,您不能将其称为I

提示

总的来说,依赖大小写敏感性来区分标识符是一个糟糕的主意。标识符越相似,代码的可读性和理解难度就越大。不要在自己的代码中使用它,特别是不要给一个关键字相同但大小写不同的标识符。

Java 忽略空格、制表符、换行符和其他空白字符,除非它们出现在引号字符和字符串字面值内。程序员通常使用空白字符来格式化和缩进他们的代码以便于阅读,但这不会像 Python 中的缩进那样影响程序的行为。您将在本书的代码示例中看到常见的缩进约定。

注释

注释是为程序的人类读者而设计的自然语言文本。它们被 Java 编译器忽略。Java 支持三种类型的注释。第一种类型是单行注释,以字符 // 开始,并延续至当前行的结尾。例如:

int i = 0;   // Initialize the loop variable

第二种类型的注释是多行注释。它以字符 /* 开始,并在任意行数后续续,直到字符 */javac 忽略 /**/ 之间的任何文本。虽然这种注释风格通常用于多行注释,但也可以用于单行注释。这种类型的注释不能嵌套(即一个 /* */ 注释不能出现在另一个注释内)。编程人员在编写多行注释时经常使用额外的 * 字符来使注释突出显示。这是一个典型的多行注释示例:

/*
 * First, establish a connection to the server.
 * If the connection attempt fails, quit right away.
 */

第三种类型的注释是第二种的特例。如果一个注释以 /** 开头,它被视为特殊的文档注释。与常规的多行注释类似,文档注释以 */ 结尾,不能嵌套。当你编写一个希望其他程序员使用的 Java 类时,提供文档注释以将关于类及其每个方法的文档直接嵌入源代码中。一个名为 javadoc 的程序会提取这些注释并处理它们以创建类的在线文档。文档注释可以包含 HTML 标签,并且可以使用 javadoc 理解的额外语法。例如:

/**
 * Upload a file to a web server.
 *
 * @param file The file to upload.
 * @return <tt>true</tt> on success,
 *         <tt>false</tt> on failure.
 * @author David Flanagan
 */

更多关于文档注释语法的信息请参见第七章,更多关于 javadoc 程序的信息请参见第十三章。

注释可以出现在 Java 程序的任何标记之间,但不能出现在标记内部。特别是,注释不能出现在双引号字符串字面值内。字符串字面值内的注释简单地成为该字符串的字面部分。

保留字

下列单词在 Java 中是保留的(它们是语言的语法的一部分,不能用于变量名、类名等):

abstract   const      final        int         public        throw
assert     continue   finally      interface   return        throws
boolean    default    float        long        short         transient
break      do         for          native      static        true
byte       double     goto         new         strictfp      try
case       else       if           null        super         void
catch      enum       implements   package     switch        volatile
char       extends    import       private     synchronized  while
class      false      instanceof   protected   this
_ (underscore)

其中,truefalsenull 在技术上是字面值。

请注意 constgoto 虽然被保留但实际上在语言中并未使用,而 interface 有一个额外的变体形式——@interface,用于定义称为注解的类型。一些保留字(特别是 finaldefault)根据上下文有多种含义。

还有其他一些关键字不是通常的保留字,被称为上下文关键字

exports      opens      requires     uses
module       permits    sealed       var
non-sealed   provides   to           with
open         record     transitive   yield

var表示应该进行类型推断的局部变量。在定义类时使用sealednon-sealedrecord,我们将在第三章中遇到。yield出现在稍后本章中将遇到的switch表达式中,而其他上下文关键字涉及模块,其语法和用法在第十二章中有详细介绍。

警告

尽管允许使用上下文关键字作为变量名,但不建议这样做。var var = "var";可能是一个有效的语句,但是这是一个应该引起怀疑的有效语句。

标识符

标识符只是 Java 程序中某部分(如类、类内方法或方法内声明的变量)的名称。标识符可以任意长度,并且可以包含来自整个 Unicode 字符集的字母和数字。标识符不能以数字开头。

一般情况下,标识符不能包含标点符号字符。例外包括美元符号($)以及其他 Unicode 货币符号,如£¥

提示

货币符号用于自动生成的源代码,例如javac生成的代码。通过避免在您自己的标识符中使用货币符号,您就不必担心与自动生成的标识符发生冲突。

ASCII 下划线(_)也值得特别提及。最初,下划线可以自由地用作标识符或其一部分。然而,在包括 Java 17 在内的最新版本中,下划线不能作为标识符使用。

下划线字符仍然可以出现在 Java 标识符中,但不能再单独作为完整的标识符合法存在。这是为了支持即将推出的语言特性,其中下划线将获得新的特殊语法意义。

通常的 Java 约定是使用驼峰命名法命名变量。这意味着变量的第一个字母应小写,但标识符中其他单词的第一个字母应大写。

正式地说,标识符的起始和内部允许的字符由java.lang.Character类的isJavaIdentifierStart()isJavaIdentifierPart()方法定义。

以下是合法标识符的示例:

i    x1    theCurrentTime    current    獺

特别注意 UTF-8 标识符的例子,。这是表示“水獭”的汉字字符,作为 Java 标识符完全合法。在主要由西方人编写的程序中使用非 ASCII 标识符是不寻常的,但有时会见到。

字面量

字面量是源代码中直接表示常量值的源字符序列。它们包括整数和浮点数、单引号内的单个字符、双引号内的字符序列,以及保留字truefalsenull。例如,以下都是字面量:

1    1.0    '1'    1L    "one"    true    false    null

表达数字、字符和字符串字面值的语法在“原始数据类型”中详细说明。

标点符号

Java 还使用一些标点符号字符作为标记。Java 语言规范将这些字符(有些是任意的)分为两类,分隔符和操作符。这 12 种分隔符包括:

( ){ }[ ]
...@::
;,.

操作符包括:

+*/%&&#124;^<<>>>>>
+=-=*=/=%=&=&#124;=^=<<=>>=>>>=
===!=<<=>>=
!~&&||++--?:->

我们会在全书中看到分隔符,并且会在“表达式和操作符”中单独介绍每个操作符。

原始数据类型

Java 支持八种基本数据类型,称为原始类型,如表 2-1 所述。原始类型包括布尔类型、字符类型、四种整数类型和两种浮点类型。这四种整数类型和两种浮点类型在表示它们的位数和因此它们可以表示的数字范围上有所不同。请注意,这些类型的大小是 Java 语言中的概念大小。由于填充、对齐等原因,不同的 JVM 实现可能使用更多的实际空间来保存这些值。

表 2-1. Java 原始数据类型

类型包含默认大小范围
booleantrue or falsefalse1 位NA
charUnicode 字符\u000016 位\u0000\uFFFF
byte有符号整数08 位–128 到 127
short有符号整数016 位–32768 到 32767
int有符号整数032 位–2147483648 到 2147483647
long有符号整数064 位–9223372036854775808 到 9223372036854775807
floatIEEE 754 浮点数0.032 位1.4E–45 到 3.4028235E+38
doubleIEEE 754 浮点数0.064 位4.9E–324 到 1.7976931348623157E+308

下一节总结了这些原始数据类型。除了这些原始类型外,Java 还支持称为引用类型的非原始类型,这些类型在“引用类型”中介绍。

布尔类型

boolean 类型代表真值。这种类型只有两个可能的值,表示两个布尔状态:开启或关闭,是或否,真或假。Java 保留了 truefalse 用于表示这两个布尔值。

从其他语言(特别是 JavaScript、Python 或 C)转到 Java 的程序员应该注意,Java 对其布尔值要求比其他语言严格得多;特别是,boolean既不是整数类型也不是对象类型,不兼容的值不能用于boolean的位置。换句话说,在 Java 中不能像以下示例中那样使用捷径:

Object o = new Object();
int i = 1;

if (o) {     // Invalid!
  while(i) {
    //...
  }
}

Java 要求您通过明确声明想要的比较来编写更清晰的代码:

if (o != null) {
  while(i != 0) {
    // ...
  }
}

字符类型

char 类型表示 Unicode 字符。Java 在表示字符方面有着略微独特的方法—javac接受输入时将标识符和字面值作为 UTF-8(一种可变宽度编码)。然而,在内部,Java 以固定宽度编码表示字符—​在 Java 9 之前是 16 位编码,在 Java 9 及以后可能是 ISO-8859-1(一种用于西欧语言的 8 位编码,也称为 Latin-1)。

外部和内部表示之间的区别通常不需要开发人员关注。在大多数情况下,只需记住这条规则即可:要在 Java 程序中包含字符字面值,只需将其放在单引号(撇号)之间:

char c = 'A';

当然,您可以使用 \u Unicode 转义序列将 Unicode 字符作为字符字面值。此外,Java 还支持许多其他转义序列,使得表示常用的非打印 ASCII 字符(如 newline)以及转义某些在 Java 中具有特殊含义的标点字符变得更加容易。例如:

char tab = '\t', nul = '\000', aleph = '\u05D0', backslash = '\\';

表 2-2 列出了可以在 char 字面值中使用的转义字符。这些字符也可以用于字符串字面值,这将在下一节中介绍。

表 2-2. Java 转义字符

转义序列字符值
\b退格符
\t水平制表符
\n换行符
\f换页符
\r回车符
\"双引号
\'单引号
\\反斜杠
\*xxx*使用编码为 xxx 的 Latin-1 字符,其中 xxx 是一个在 000 到 377 之间的八进制(基数为 8)数字。形式 \x\xx 也是合法的,如 \0,但不建议使用,因为它们可能在字符串常量中引起困扰,后面跟随的是普通数字。一般不推荐此形式,而是更倾向于使用 \uXXXX 形式。
\u*xxxx*使用编码为 xxxx 的 Unicode 字符,其中 xxxx 是四位十六进制数字。Unicode 转义可以出现在 Java 程序的任何位置,不仅限于字符和字符串字面值中。

char值可以与各种整数类型相互转换,char数据类型是 16 位整数类型。但与byteshortintlong不同,char是无符号类型,只能接收 0 到 65535 范围内的值。Character类定义了一些有用的静态方法,用于处理字符,包括isDigit()isJavaLetter()isLowerCase()toUpperCase()

Java 语言及其char类型是以 Unicode 为基础设计的。然而,Unicode 标准在不断发展,每个新版本的 Java 都会采用新版本的 Unicode。Java 11 使用 Unicode 10.0.0,Java 17 使用 Unicode 13.0。

近期 Unicode 版本的复杂之处在于引入了一些字符,其编码或代码点不适合 16 位。这些补充字符大多是罕见使用的汉字,占用 21 位,无法用单个char值表示。相反,您必须使用一个int值来保存补充字符的代码点,或者使用所谓的“代理对”来编码成两个char值。

除非您经常编写使用亚洲语言的程序,否则不太可能遇到任何补充字符。如果您预计要处理不适合char的字符,已向CharacterString及相关类添加了方法,用于使用int代码点处理文本。

字符串字面量

除了char类型外,Java 还有一种用于处理文本字符串的数据类型(通常简称为字符串)。String类型是一个类,而不是语言的基本类型之一。然而,由于字符串被广泛使用,Java 确实有语法可以在程序中直接包含字符串值。String字面量由双引号中的任意文本组成(与char字面量的单引号相对)。例如:

"Hello World"
"'This' is a string!"

Java 的最新版本还引入了一种称为文本块的多行字符串字面量语法。文本块以"""和换行符开头,当看到另一个"""序列时结束。这些由javac编译器完全处理,并且在字节码中与普通"字符串字面量相同。

"""
Multi-line text blocks
Can use "double quotes" without escaping
"""

字符串字面量可以包含任何作为char字面量出现的转义序列(参见表 2-2)。使用\\"序列在标准String字面量中包含双引号。文本块允许使用这些转义序列,但在换行符或双引号时不需要它们。

因为String是引用类型,字符串字面量将在本章后面的“字符串字面量”中详细描述。第九章详细介绍了在 Java 中处理String对象的一些方法。

整数类型

Java 中的整数类型是byteshortintlong。如表 2-1 所示,这四种类型仅在位数和因此在每种类型可以表示的数字范围方面有所不同。所有整数类型都表示有符号数;与 C 和 C++中的unsigned关键字不同。

对于每种类型的字面量,它们的写法与你期望的完全相同:一系列十进制数字,可选地以减号开头。¹这些字面量中的数字可以用下划线(_)分隔以提高可读性。以下是一些合法的整数字面量:

0
1
123
9_000
-42000

整数字面量是 32 位值(因此被视为 Java 类型int),除非它们以字符Ll结尾,此时它们是 64 位值(并被理解为 Java 类型long):

1234        // An int value
1234L       // A long value
0xffL       // Another long value

整数字面量也可以用十六进制、二进制或八进制表示法表示。以0x0X开头的字面量被视为十六进制数,使用字母AF(或af)作为基数为 16 的数字所需的额外数字。

二进制整数字面量以0b开头,当然,只能包含数字 1 或 0。二进制字面量中使用下划线分隔符是非常常见的,因为二进制字面量可以非常长。

Java 还支持八进制(基数为 8)整数字面量。这些字面量以前导0开头,不能包括数字 8 或 9。它们并不经常使用,除非需要,应该避免使用。合法的十六进制、二进制和八进制字面量包括:

0xff              // Decimal 255, expressed in hexadecimal
0377              // The same number, expressed in octal (base 8)
0b0010_1111       // Decimal 47, expressed in binary
0xCAFEBABE        // A magic number used to identify Java class files

当超出给定整数类型的范围时,Java 中的整数运算永远不会产生溢出或下溢。相反,数字会简单地循环。例如,让我们看一个溢出的例子:

byte b1 = 127, b2 = 1;        // Largest byte is 127
byte sum = (byte)(b1 + b2);   // Sum wraps to -128, the smallest byte

以及相应的下溢行为:

byte b3 = -128, b4 = 5;        // Smallest byte is -128
byte sum2 = (byte)(b3 - b4);   // Sum wraps to a large byte value, 123

当发生这种情况时,Java 编译器和 Java 解释器都不会以任何方式警告你。在进行整数运算时,你必须确保所使用的类型对你打算的目的具有足够的范围。整数除以零和模零是非法的,并导致抛出ArithmeticException。(我们很快将在“已检查和未检查的异常”中详细了解更多异常)。

每种整数类型都有一个对应的包装类:ByteShortIntegerLong。每个这些类都定义了MIN_VALUEMAX_VALUE常量来描述该类型的范围。每个类还提供了一个静态的valueOf()方法,强烈建议使用该方法从原始值创建包装类的实例。虽然包装类具有接受原始类型的普通构造函数,但它们已被弃用,应避免使用。包装类还定义了一些有用的静态方法,如Byte.parseByte()Integer.parseInt(),用于将字符串转换为整数值。

浮点类型

在 Java 中,实数由floatdouble数据类型表示。如表 2-1 所示,float是 32 位单精度浮点值,而double是 64 位双精度浮点值。这两种类型都遵循 IEEE 754-1985 标准,该标准指定了数字的格式以及数字的算术行为。

浮点值可以直接作为 Java 程序中的可选数字字符串包含,后面跟着小数点和另一个数字字符串。以下是一些示例:

123.45
0.0
.01

浮点文字面量还可以使用指数或科学表示法,其中一个数字后跟着字母eE(指数),然后是另一个数字。第二个数字表示第一个数字乘以的 10 的幂。例如:

1.2345E02    // 1.2345 * 10² or 123.45
1e-6         // 1 * 10^-6 or 0.000001
6.02e23      // Avogadro's Number: 6.02 * 10²³

浮点文字面量默认为double值。要在程序中直接包含float值,请在数字后面跟上fF

double d = 6.02E23;
float f = 6.02e23f;

浮点文字面量不能用十六进制、二进制或八进制表示。

除了表示普通数字外,floatdouble类型还可以表示四个特殊值:正无穷大、负无穷大、零和 NaN。当浮点计算产生超出floatdouble可表示范围的值时,会得到无穷大值。

当浮点计算下溢到floatdouble的可表示范围时,将得到零值。

注意

我们可以想象重复地将双精度值1.0除以2.0(例如,在while循环中)。在数学上,无论我们进行多少次除法,结果永远不会变成零。然而,在浮点表示中,经过足够多的除法之后,结果最终会变得非常小,以至于与零几乎无法区分。

Java 浮点类型区分正零和负零,具体取决于下溢发生的方向。实际上,正零和负零的行为几乎相同。最后,最后一个特殊浮点值是 NaN,表示“不是一个数字”。当执行非法浮点操作(例如 0.0/0.0)时,将得到 NaN 值。以下是导致这些特殊值的语句示例:

double inf = 1.0/0.0;             // Infinity
double neginf = -1.0/0.0;         // Negative infinity
double negzero = -1.0/inf;        // Negative zero
double NaN = 0.0/0.0;             // Not a Number

floatdouble原始类型有相应的类,名为FloatDouble。每个类定义了以下有用的常量:MIN_VALUEMAX_VALUENEGATIVE_INFINITYPOSITIVE_INFINITYNaN。与整数包装类类似,浮点包装类也有一个用于构造实例的静态valueOf()方法。

注意

Java 浮点类型可以处理溢出到无穷大和下溢到零以及具有特殊 NaN 值的情况。这意味着浮点算术永远不会抛出异常,即使执行非法操作,如零除以零或对负数取平方根。

无限浮点值的行为如预期。例如,将任何有限值加或减无穷大将得到无穷大。负零的行为与正零几乎相同,事实上,==等号操作符报告负零等于正零。区分负零和正常零的一种方法是通过除以它来进行:1.0/0.0得到正无穷大,但1.0除以负零得到负无穷大。最后,因为 NaN 不是一个数字,==操作符表明它与任何其他数字(包括自身)都不相等!

double NaN = 0.0/0.0;             // Not a Number
NaN == NaN;                       // false
Double.isNaN(NaN);                // true

要检查floatdouble值是否为 NaN,必须使用Float.isNaN()Double.isNaN()方法。

原始类型转换

Java 允许在整数值和浮点值之间进行转换。此外,因为 Unicode 编码中的每个字符对应一个数字,char值可以在整数和浮点类型之间转换。事实上,boolean是 Java 中唯一不能转换为其他原始类型或从其他原始类型转换的类型。

有两种基本类型的转换。扩展转换是指将一种类型的值转换为更宽的类型,即具有更大合法值范围的类型。例如,当你将int字面量分配给double变量或char字面量分配给int变量时,Java 会自动执行扩展转换。

然而,窄化转换是另一回事。窄化转换是指将值转换为不比其宽的类型。窄化转换并不总是安全的:例如,将整数值 13 转换为byte是合理的,但将 13000 转换为byte是不合理的,因为byte只能保存在-128 到 127 之间的数字。由于在窄化转换中可能丢失数据,即使要转换的值实际上可以适合指定类型的更窄范围,javac也会在尝试任何窄化转换时发出警告:

int i = 13;
// byte b = i;    // Incompatible types: possible lossy conversion
                  // from int to byte

唯一的例外是,如果字面量在变量的范围内,可以将整数字面量(int值)分配给byteshort变量。

byte b = 13;

如果需要执行窄化转换并且确信不会丢失数据或精度,可以使用称为强制转换的语言结构强制 Java 执行转换。通过在要转换的值之前在括号中放置所需类型的名称来执行转换。例如:

int i = 13;
byte b = (byte) i;   // Force the int to be converted to a byte
i = (int) 13.456;    // Force this double literal to the int 13

原始类型的强制转换通常用于将浮点值转换为整数。这样做时,浮点值的小数部分会被简单截断(即浮点值向零舍入,而不是向最接近的整数舍入)。静态方法Math.round()Math.floor()Math.ceil()执行其他类型的舍入。

char 类型在大多数情况下像整数类型,因此 char 值可以在需要 intlong 值的任何地方使用。然而,请记住,char 类型是 无符号 的,因此它与 short 类型行为不同,即使两者都是 16 位宽度:

short s = (short) 0xffff; // These bits represent the number -1
char c = '\uffff';        // The same bits, as a Unicode character
int i1 = s;               // Converting the short to an int yields -1
int i2 = c;               // Converting the char to an int yields 65535

表格 2-3(#javanut8-CHP-2-TABLE-3)显示了哪些基本类型可以转换为哪些其他类型以及转换的执行方式。表中的字母 N 表示无法执行转换。字母 Y 表示这是一种自动扩展转换,因此 Java 会自动隐式执行。字母 C 表示这是一种缩小转换,需要显式转换。

最后,Y* 表示转换为自动扩展转换,但在转换过程中可能会丢失一些最低有效位数。当你将 intlong 转换为浮点类型时会发生这种情况——详细信息请参见表格。浮点类型的范围比整数类型更大,因此任何 intlong 都可以被表示为 floatdouble。然而,浮点类型是数字的近似值,不能始终保存与整数类型一样多的有效位数(详见第九章关于浮点数的详细信息)。

表 2-3. Java 基本类型转换

 转换为:       
从以下类型转换:booleanbyteshortcharintlongfloatdouble
---------------------------
boolean-NNNNNNN
byteN-YCYYYY
shortNC-CYYYY
charNCC-YYYY
intNCCC-YY*Y
longNCCCC-Y*Y*
floatNCCCCC-Y
doubleNCCCCCC-

表达式和运算符

到目前为止,在本章中,我们已经了解了 Java 程序可以操作的基本类型,并看到如何将基本值作为 字面值 包含在 Java 程序中。我们还使用了 变量 作为表示或保存值的符号名称。这些字面值和变量是构成 Java 程序的标记。

表达式 是 Java 程序中的下一个更高级的结构。Java 解释器 评估 表达式以计算其值。最简单的表达式称为 主表达式,由字面值和变量组成。因此,例如以下都是表达式:

1.7         // A floating-point literal
true        // A Boolean literal
sum         // A variable

当 Java 解释器评估字面表达式时,结果值是字面本身。当解释器评估变量表达式时,结果值是变量中存储的值。

主要表达式并不是很有趣。通过使用运算符来组合主要表达式可以创建更复杂的表达式。例如,以下表达式使用赋值运算符将两个主要表达式——一个变量和一个浮点数文字——组合成一个赋值表达式:

sum = 1.7

但是运算符不仅与主要表达式一起使用;它们还可以与任何复杂程度的表达式一起使用。以下都是合法的表达式:

sum = 1 + 2 + 3 * 1.2 + (4 + 8)/3.0
sum/Math.sqrt(3.0 * 1.234)
(int)(sum + 33)

运算符摘要

编程语言中可以编写的表达式种类完全取决于您可用的运算符集合。Java 拥有丰富的运算符,但要有效地使用它们,您必须理解两个重要的概念:优先级结合性。这些概念以及运算符本身将在以下章节中更详细地解释。

优先级

表格 2-4 的 P 列指定了每个运算符的优先级。优先级指定了操作执行的顺序。具有更高优先级的操作会在具有较低优先级的操作之前执行。例如,考虑这个表达式:

a + b * c

乘法运算符的优先级高于加法运算符,因此a加上bc的乘积,正如我们从初等数学中期望的那样。运算符优先级可以被视为运算符与其操作数绑定得有多紧密的度量。数字越高,绑定得越紧。

通过使用明确指定操作顺序的括号,可以覆盖默认的运算符优先级。前述表达式可以被重写为指定加法应在乘法之前执行:

(a + b) * c

Java 中的默认运算符优先级是为了与 C 兼容而选择的;C 的设计者选择了这种优先级,以便大多数表达式可以自然地写成而不需要括号。只有少数常见的 Java 习语需要括号。例如:

// Class cast combined with member access
((Integer) o).intValue();

// Assignment combined with comparison
while((line = in.readLine()) != null) { ... }

// Bitwise operators combined with comparison
if ((flags & (PUBLIC | PROTECTED)) != 0) { ... }

结合性

结合性是运算符的一个属性,定义了如何评估本来会产生歧义的表达式。当表达式涉及多个具有相同优先级的运算符时,这一点尤为重要。

大多数运算符都是从左到右结合的,这意味着操作是从左到右执行的。然而,赋值和一元运算符具有从右到左的结合性。表格 2-4 的 A 列指定了每个运算符或运算符组的结合性。值 L 表示从左到右,值 R 表示从右到左。

加法运算符都是从左到右结合的,因此表达式a+b-c从左到右进行评估:(a+b)-c。一元运算符和赋值运算符从右到左进行评估。考虑这个复杂表达式:

a = b += c = -~d

这被如下评估:

a = (b += (c = -(~d)))

操作符的结合性与操作符优先级一样,为表达式的默认评估顺序建立了一个默认顺序。可以通过使用括号来覆盖这个默认顺序。然而,在 Java 中,默认的操作符结合性被选择为产生自然的表达式语法。

操作符总结表

表格 2-4 总结了 Java 中可用的操作符。表格中的 P 和 A 列指定了每组相关操作符的优先级和结合性。表格按优先级从高到低排序。在需要时,可以将此表格作为操作符(尤其是它们的优先级)的快速参考。

表 2-4. Java 操作符

PA操作符操作数类型执行的操作
---------------
16L.object, member对象成员访问
[ ]array, int数组元素访问
( *args* )method, arglistMethod invocation
++, --variable后增量,后减量
15R++, --variable前增量,前减量
+, -number一元加,一元减
~integer位取反
!booleanBoolean NOT
14Rnewclass, arglist对象创建
( *type* )type, any强制类型转换
13L*, /, %number, number乘法,除法,求余
12L+, -number, number加法,减法
+string, any字符串连接
11L<<integer, integer左移
>>integer, integer带符号右移
>>>integer, integer无符号右移
10L<, <=number, number小于,小于等于
>, >=number, number大于,大于等于
instanceofreference, typeType comparison
9L==primitive, primitive等于(具有相同的值)
!=primitive, primitive不等于(具有不同的值)
==reference, reference等于(引用同一对象)
!=reference, reference不等于(引用不同对象)
8L&integer, integer位与
&boolean, boolean布尔与
7L^integer, integer位异或
^boolean, boolean布尔异或
6Lǀinteger, integer位或
ǀboolean, boolean布尔或
5L&&boolean, boolean条件与
4Lǀǀboolean, boolean条件或
3R? :boolean, any条件(三元)操作符
2R=variable, any赋值
*=, /=, %=,variable, anyAssignment with operation
+=, -=, <<=,
>>=, >>>=,
&=, ^=, ǀ=
1Rarglist, method bodylambda expression

操作数的数量和类型

表格 2-4 的第四列指定了每个运算符期望的操作数的数量和类型。有些运算符仅作用于一个操作数;这些被称为一元运算符。例如,一元减号运算符改变单个数字的符号:

-n             // The unary minus operator

大多数运算符都是二元运算符,操作两个操作数的值。减号运算符实际上有两种形式:

a – b          // The subtraction operator is a binary operator

Java 还定义了一个三元运算符,通常称为条件运算符。它类似于表达式中的if语句。它的三个操作数由问号和冒号分隔;第二和第三个操作数必须可以转换为相同的类型:

x > y ? x : y  // Ternary expression; evaluates to larger of x and y

除了期望特定数量的操作数外,每个运算符还期望特定类型的操作数。表中的第四列列出了操作数类型。该列中使用的一些代码需要进一步解释:

数字

整数、浮点值或字符(即任何原始类型,除了boolean)。自动拆箱(参见“装箱和拆箱转换”)意味着这些类型的包装类(如CharacterIntegerDouble)也可以在此上下文中使用。

整数

byteshortintlongchar值(数组访问操作符[ ]不允许long值)。使用自动拆箱,也可以允许ByteShortIntegerLongCharacter值。

引用

一个对象或数组。

变量

变量或其他任何可以分配值的内容,如数组元素。

返回类型

就像每个运算符期望其操作数具有特定的类型一样,每个运算符也产生特定类型的值。算术、递增和递减、位和移位运算符中,如果至少有一个操作数是double,则返回double。如果至少有一个操作数是float,则返回float。如果至少有一个操作数是long,则返回long。否则,返回int,即使两个操作数都是比int更窄的byteshortchar类型。

比较、相等和布尔运算符始终返回boolean值。每个赋值运算符返回其分配的值,该值与表达式左侧的变量兼容。条件运算符返回其第二或第三个参数的值(这两个参数必须可以转换为相同的类型)。

副作用

每个运算符基于一个或多个操作数的值计算一个值。然而,一些运算符除了基本计算外,还有副作用。如果一个表达式包含副作用,评估它会改变 Java 程序的状态,因此再次评估表达式可能会产生不同的结果。

例如,++增量运算符具有增加变量的副作用。表达式++a增加变量a并返回新增后的值。如果再次评估此表达式,则值将不同。各种赋值运算符也具有副作用。例如,表达式a*=2也可以写为a=a*2。表达式的值是a乘以 2 的值,但表达式具有将该值存储回a的副作用。

方法调用运算符()如果调用的方法具有副作用,则具有副作用。例如Math.sqrt()等一些方法仅计算并返回值,没有任何副作用。通常情况下,方法确实具有副作用。最后,new运算符具有创建新对象的深远副作用。

评估顺序

当 Java 解释器评估表达式时,它根据表达式中括号的顺序、运算符的优先级和运算符的结合性执行各种操作。然而,在执行任何操作之前,解释器首先评估运算符的操作数。(但是,&&||?运算符除外,它们并不总是评估所有操作数。)解释器总是按从左到右的顺序评估操作数。如果操作数中有包含副作用的表达式,则这很重要。例如,考虑以下代码:

int a = 2;
int v = ++a + ++a * ++a;

虽然乘法在加法之前执行,但+运算符的操作数首先被评估。因为++的操作数都是++a,它们被评估为34,因此表达式评估为3 + 4 * 5,即23

算术运算符

算术运算符可用于整数、浮点数,甚至字符(即它们可以用于除boolean以外的任何基本类型)。如果操作数中有任一操作数是浮点数,则使用浮点数算术;否则,使用整数算术。这很重要,因为整数算术和浮点数算术在执行除法的方式以及处理下溢和上溢的方式等方面有所不同。算术运算符包括:

加法 (+)

+运算符添加两个数字。正如我们将很快看到的,+运算符也可以用于连接字符串。如果+的任一操作数是字符串,则另一个操作数也将转换为字符串。当您希望将加法与连接结合时,请务必使用括号。例如:

System.out.println("Total: " + 3 + 4);   // Prints "Total: 34", not 7!

+运算符也可以作为一元运算符使用,表示正数,例如+42

减法 (-)

-运算符用作二元运算符时,它将其第二个操作数从第一个操作数中减去。例如,7-3的结果为4-运算符还可以执行一元否定。

乘法 (*)

*运算符将其两个操作数相乘。例如,7*3的结果为21

除法 (/)

/运算符将其第一个操作数除以第二个操作数。如果两个操作数都是整数,则结果是整数,并且任何余数都会丢失。但是,如果任一操作数是浮点值,则结果是浮点值。当你除以零时,整数除法会抛出ArithmeticException。然而,对于浮点数计算,除以零会简单地产生一个无限的结果或NaN

7/3          // Evaluates to 2
7/3.0f       // Evaluates to 2.333333f
7/0          // Throws an ArithmeticException
7/0.0        // Evaluates to positive infinity
0.0/0.0      // Evaluates to NaN

模运算%

%运算符计算第一个操作数除以第二个操作数的余数(即,当第一个操作数被第二个操作数整除时的余数)。例如,7%31。结果的符号与第一个操作数的符号相同。虽然模运算符通常用于整数操作数,但它也适用于浮点值。例如,4.3%2.1评估为0.1。当你操作整数时,尝试计算模零的值会导致ArithmeticException。当你使用浮点值时,任何值模0.0都会评估为NaN,正如无穷大模任何值一样。

一元减号-

-运算符用作一元运算符时——即在单个操作数之前时——它执行一元否定。换句话说,它将正值转换为等效的负值,反之亦然。

字符串连接运算符

除了添加数字外,+运算符(以及相关的+=运算符)还可以连接或拼接字符串。如果+的任一操作数是字符串,则运算符会将另一个操作数转换为字符串。例如:

// Prints "Quotient: 2.3333333"
System.out.println("Quotient: " + 7/3.0f);

因此,在将任何附加表达式与字符串连接时,务必将其放在括号中。如果不这样做,加法运算符将被解释为连接运算符。

Java 为所有基本类型提供了内置的字符串转换。对象通过调用其toString()方法转换为字符串。一些类定义了自定义的toString()方法,以便可以轻松地将该类的对象转换为字符串。但遗憾的是,并非所有类在转换为字符串时都返回友好的结果。例如,数组的内置toString()并不返回其内容的有用字符串表示,而仅返回有关数组对象本身的信息。

递增和递减运算符

++运算符递增其单个操作数,该操作数必须是变量、数组的元素或对象的字段,递增量为 1。此运算符的行为取决于其相对于操作数的位置。当用于操作数之前时,称为前递增运算符,它递增操作数并评估为递增后的值。当用于操作数之后时,称为后递增运算符,它递增其操作数但评估为递增前的值。

例如,以下代码将ij都设置为 2:

i = 1;
j = ++i;

但是,这些行将i设置为 2,j设置为 1:

i = 1;
j = i++;

同样地,--运算符将其单个数值操作数递减 1。与++运算符类似,--的行为取决于其相对于操作数的位置。当在操作数之前使用时,它会递减操作数并返回递减后的值。当在操作数之后使用时,它会递减操作数但返回未递减的值。

表达式x++x--分别等同于x = x + 1x = x - 1,但是当你使用增量和减量运算符时,x只计算一次。如果x本身是一个具有副作用的表达式,这将产生很大的不同。例如,这两个表达式并不等同,因为第二种形式会使i增加两次:

a[i++]++;             // Increments an element of an array

// Adds 1 to an array element and stores new value in another element
a[i++] = a[i++] + 1;

这些运算符,无论是前缀还是后缀形式,最常用于增加或减少控制循环计数器。然而,越来越多的程序员更喜欢避免使用增量和减量运算符,而是更喜欢使用显式的代码。这种观点是由于历史上由于操作符的错误使用而导致的大量 bug。

比较运算符

比较运算符包括测试值是否相等或不等的相等运算符和与有序类型(数字和字符)一起使用的关系运算符。这两种类型的运算符产生一个boolean结果,因此它们通常与if语句、三元条件运算符或whilefor循环一起使用,以进行分支和循环决策。例如:

if (o != null) ...;           // The not equals operator
while(i < a.length) ...;      // The less than operator

Java 提供以下相等运算符:

等于 (==)

==运算符在其两个操作数相等时求值为true,否则为false。对于原始操作数,它测试操作数值本身是否相同。然而,对于引用类型的操作数,它测试操作数是否引用同一个对象或数组。换句话说,它不测试两个不同的对象或数组的相等性。特别地,请注意,你不能使用此运算符测试两个不同的字符串是否相等。

警告

如果你通过==比较字符串,可能会看到结果表明它正常工作。这是 Java 内部字符串缓存的副作用,称为interning。比较字符串(或任何其他引用类型)的唯一可靠方法是使用equals()方法。

对于基本包装类也是一样的,所以new Integer(1) != new Integer(1),而推荐的Integer.valueOf(1) == Integer.valueOf(1)则相等。显然,任何非原始类型的等式比较都应该使用equals()方法。关于对象等式的更多讨论可以在equals()中找到。

如果使用==比较两个不同类型的数值或字符操作数,窄操作数在比较之前会被转换为宽操作数的类型。例如,当你比较一个short和一个float时,short会先转换为float再进行比较。对于浮点数,特殊的负零值与常规的正零值相等。此外,特殊的NaN(不是一个数字)值不等于任何其他数字,包括自身。要测试浮点数值是否为NaN,可以使用Float.isNan()Double.isNan()方法。

不等于 (!=)

!=运算符与==运算符正好相反。如果其两个基本操作数具有不同的值,或者其两个引用操作数引用不同的对象或数组,则评估为true。否则,评估为false

关系运算符可以用于数字和字符,但不能用于boolean值、对象或数组,因为这些类型没有顺序。

Java 提供以下关系运算符:

小于 (<)

如果第一个操作数小于第二个,则评估为true

小于或等于 (<=)

如果第一个操作数小于或等于第二个,则评估为true

大于 (>)

如果第一个操作数大于第二个,则评估为true

大于或等于 (>=)

如果第一个操作数大于或等于第二个,则评估为true

布尔运算符

正如我们刚刚看到的,比较运算符比较它们的操作数,并产生一个boolean结果,通常用于分支和循环语句。为了使基于条件的分支和循环决策更有趣,而不仅仅是单个比较,你可以使用布尔(或逻辑)运算符将多个比较表达式组合成一个单一的、更复杂的表达式。布尔运算符要求其操作数为boolean值,并评估为boolean值。这些运算符包括:

条件与 (&&)

此运算符对其操作数执行布尔 AND 操作。如果且仅当其两个操作数都为true时,评估为true。如果其中一个或两个操作数为false,则评估为false。例如:

if (x < 10 && y > 3) ... // If both comparisons are true

此运算符(以及所有布尔运算符,除了一元!运算符外)的优先级低于比较运算符。因此,像刚刚显示的代码行是完全合法的。但是,一些程序员喜欢使用括号来显式地指定评估顺序:

if ((x < 10) && (y > 3)) ...

你应该使用你觉得阅读更容易的风格。

这个运算符称为条件 AND,因为它有条件地评估其第二个操作数。如果第一个操作数评估为false,则表达式的值为false,无论第二个操作数的值如何。因此,为了提高效率,Java 解释器采取了一种捷径,并跳过第二个操作数。不能保证评估第二个操作数,因此在使用具有副作用的表达式与此运算符时必须小心。另一方面,此运算符的条件性质允许我们编写如下的 Java 表达式:

if (data != null && i < data.length && data[i] != -1)
    ...

此表达式中的第二个和第三个比较如果第一个或第二个比较结果为false将导致错误。幸运的是,由于&&运算符的条件行为,我们不必担心这个问题。

条件或 (||)

这个运算符在其两个boolean操作数上执行布尔 OR 操作。如果其任一或两个操作数为true,则评估为true。如果两个操作数都为false,则评估为false。与&&运算符类似,||运算符并不总是评估其第二个操作数。如果第一个操作数评估为true,则表达式的值为true,无论第二个操作数的值如何。因此,在这种情况下,该运算符简单地跳过第二个操作数。

布尔非 (!)

此一元运算符改变其操作数的boolean值。如果应用于true值,则评估为false,如果应用于false值,则评估为true。在这些表达式中很有用:

if (!found) ...          // found is a boolean declared somewhere
while (!c.isEmpty()) ... // The isEmpty() method returns a boolean

因为!是一元运算符,具有很高的优先级,并且通常必须与括号一起使用:

if (!(x > y && y > z))

布尔与 (&)

当与boolean操作数一起使用时,&运算符的行为类似于&&运算符,但始终评估两个操作数,而不管第一个操作数的值如何。然而,此运算符几乎总是作为整数操作数的位运算符使用,因此许多 Java 程序员甚至不会认识其在boolean操作数中作为合法 Java 代码的使用。

布尔或 (|)

这个运算符在其两个boolean操作数上执行布尔 OR 操作。它与||运算符类似,但始终评估两个操作数,即使第一个操作数为true|运算符几乎总是用作整数操作数的位运算符;在boolean操作数中很少见到其使用。

布尔异或 (^)

当与boolean操作数一起使用时,此运算符计算其操作数的异或(XOR)。如果两个操作数中恰好一个为true,则评估为true。换句话说,如果两个操作数都为false或者两个操作数都为true,则评估为false。与&&||运算符不同,此运算符必须始终评估两个操作数。^运算符在整数操作数中作为位运算符使用得更为常见。对于boolean操作数,此运算符等同于!=运算符。

位运算符和移位运算符

位与移位运算符是操作整数值中构成其个别位的低级运算符。位运算符在现代 Java 中不常用,除非进行低级工作(例如网络编程)。它们用于测试和设置值中的单个标志位。要理解它们的行为,您必须理解用于表示负整数的二进制(基数 2)数和两补码格式。

你不能将这些运算符与浮点、boolean、数组或对象操作数一起使用。当与boolean操作数一起使用时,&|^运算符执行不同的操作,如前一节所述。

如果位运算符的任一参数是long,则结果是long。否则,结果是int。如果位移运算符的左操作数是long,则结果是long;否则,结果是int。这些运算符是:

位取反~

一元~运算符称为位取反或位 NOT 运算符。它反转其单个操作数的每个位,将 1 转换为 0,将 0 转换为 1。例如:

byte b = ~12;           // ~00001100 =  => 11110011 or -13 decimal
flags = flags & ~f;     // Clear flag f in a set of flags

位与&

该运算符通过对其两个整数操作数的各自位执行布尔 AND 运算来结合它们。只有在两个操作数中的相应位都设置时,结果才具有一个位设置。例如:

10 & 7                   // 00001010 & 00000111 =  => 00000010 or 2
if ((flags & f) != 0)    // Test whether flag f is set

当使用boolean操作数时,&是之前描述的不常用的布尔 AND 运算符。

位或|

该运算符通过对其两个整数操作数的各自位执行布尔 OR 运算来结合它们。如果相应位在一个或两个操作数中被设置,则结果有一个位设置。仅在两个相应操作数位都为零时,它具有零位。例如:

10 | 7                   // 00001010 | 00000111 =  => 00001111 or 15
flags = flags | f;       // Set flag f

当与boolean操作数一起使用时,|是之前描述的不常用的布尔 OR 运算符。

位异或^

该运算符通过对其各自位执行布尔异或(exclusive OR)操作来结合其两个整数操作数的位。如果两个操作数中的对应位不同,则结果具有一个位设置。如果对应的操作数位都是 1 或都是 0,则结果位为 0。例如:

10 ^ 7               // 00001010 ^ 00000111 =  => 00001101 or 13

当与boolean操作数一起使用时,^是很少使用的布尔 XOR 运算符。

左移<<

<<运算符将左操作数的位左移右操作数指定的位数。左操作数的高阶位将丢失,并且从右侧移入零位。将整数左移n位等效于将该数字乘以 2^(n)。例如:

10 << 1    // 0b00001010 << 1 = 00010100 = 20 = 10*2
7 << 3     // 0b00000111 << 3 = 00111000 = 56 = 7*8
-1 << 2    // 0xFFFFFFFF << 2 = 0xFFFFFFFC = -4 = -1*4
           // 0xFFFF_FFFC == 0b1111_1111_1111_1111_1111_1111_1111_1100

如果左操作数是long,则右操作数应在 0 到 63 之间。否则,左操作数被视为int,右操作数应在 0 到 31 之间。如果超出这些范围,则可能会看到这些运算符的不直观的包装行为。

有符号右移>>

>> 运算符将左操作数的位向右移动右操作数指定的位数。左操作数的低位位移并丢失。位于左操作数中的高位位移相同于原始左操作数的高位位。换句话说,如果左操作数为正,则将 0 移入高位位。如果左操作数为负,则移入的是 1。这种技术称为符号扩展;它用于保留左操作数的符号。例如:

10 >> 1      // 00001010 >> 1 = 00000101 = 5 = 10/2
27 >> 3      // 00011011 >> 3 = 00000011 = 3 = 27/8
-50 >> 2     // 11001110 >> 2 = 11110011 = -13 != -50/4

如果左操作数为正,右操作数为 n,则 >> 运算符与整数除法相同除以 2^(n)。

无符号右移 (>>>)

此运算符类似于 >> 运算符,但它总是将零移入结果的高阶位,而不管左操作数的符号如何。这种技术称为零扩展;当左操作数被视为无符号值时(尽管 Java 整数类型都是有符号的),这是适当的。以下是示例:

0xff >>> 4    // 11111111 >>> 4 = 00001111 = 15  = 255/16
-50 >>> 2     // 0xFFFFFFCE >>> 2 = 0x3FFFFFF3 = 1073741811

赋值运算符

赋值运算符将值存储或分配到计算机的一部分内存中--通常称为存储位置。左操作数必须评估为适当的局部变量,数组元素或对象字段。

注意

赋值表达式的左操作数有时被称为 *lvalue*。在 Java 中,它必须引用一些可赋值的存储(即可写入的内存)。

右操作数(*rvalue*)可以是与变量兼容的任何类型的值。赋值表达式的评估结果是分配给变量的值。然而,更重要的是,该表达式具有实际执行分配的副作用—将 rvalue 存储在 lvalue 中。

提示

与所有其他二进制运算符不同,赋值运算符是右关联的,这意味着 a=b=c 中的赋值是从右向左执行的,如下所示: a=(b=c)

基本赋值运算符是 =。不要将其与相等运算符 == 混淆。为了区分这两个运算符,我们建议将 = 读作“被赋予值”。

除了这个简单的赋值运算符之外,Java 还定义了另外 11 个将赋值与 5 个算术运算符和 6 个位和移位运算符结合的运算符。例如,+= 运算符读取左变量的值,将右操作数的值添加到它中,作为副作用将总和存储回左变量,并返回总和作为表达式的值。因此,表达式 x+=2 几乎与 x=x+2 相同。这两个表达式之间的区别在于当您使用 += 运算符时,左操作数只被评估一次。当该操作数具有副作用时,这是有区别的。考虑以下两个不等式:

a[i++] += 2;
a[i++] = a[i++] + 2;

这些组合赋值运算符的一般形式是:

lvalue op= rvalue

这与以下内容等效(除非在 lvalue 中存在副作用):

lvalue = lvalue op rvalue

可用的运算符有:

+=    -=    *=    /=    %=    // Arithmetic operators plus assignment

&=    |=    ^=                // Bitwise operators plus assignment

<<=   >>=   >>>=              // Shift operators plus assignment

最常用的运算符是 +=-=,尽管 &=|= 在处理布尔值或位标志时也很有用。例如:

i += 2;          // Increment a loop counter by 2
c -= 5;          // Decrement a counter by 5
flags |= f;      // Set a flag f in an integer set of flags
flags &= ~f;     // Clear a flag f in an integer set of flags

条件运算符

条件运算符 ?: 是从 C 语言继承过来的一个有些晦涩的三元(三操作数)运算符。它允许你在表达式中嵌入条件判断。你可以将它视为if/else语句的运算符版本。条件运算符的第一和第二操作数之间用问号 (?) 分隔,第二和第三操作数之间用冒号 (:) 分隔。第一操作数必须求值为布尔值。第二和第三操作数可以是任何类型,但它们必须可转换为相同的类型。

条件运算符首先评估其第一个操作数。如果它为 true,则运算符评估其第二个操作数并将其用作表达式的值。另一方面,如果第一个操作数为 false,则条件运算符评估并返回其第三个操作数。条件运算符永远不会同时评估其第二和第三个操作数,因此在使用具有副作用的表达式时要小心。此运算符的示例有:

int max = (x > y) ? x : y;
String name = (value != null) ? value : "unknown";

注意,?: 运算符的优先级低于除赋值运算符之外的所有其他运算符,因此通常不需要在此运算符的操作数周围使用括号。然而,许多程序员发现如果将第一个操作数放在括号内,条件表达式更易于阅读。这一点尤其重要,因为条件 if 语句总是将其条件表达式写在括号内。

instanceof 运算符

instanceof 运算符与对象和 Java 类型系统的操作密切相关。如果这是您第一次了解 Java,可能最好先略过这个定义,等您对 Java 的对象有了较好的理解后再回到这部分。

instanceof 要求其左操作数为对象或数组值,右操作数为引用类型的名称。在其基本形式中,如果对象或数组是指定类型的实例,则它评估为 true;否则返回 false。如果左操作数为 nullinstanceof 总是评估为 false。如果 instanceof 表达式评估为 true,这意味着您可以安全地将左操作数强制转换并赋值给右操作数类型的变量。

instanceof 运算符只能与引用类型和对象一起使用,不能与基本类型和值一起使用。instanceof 的示例有:

// True: all strings are instances of String
"string" instanceof String
// True: strings are also instances of Object
"" instanceof Object
// False: null is never an instance of anything
null instanceof String

Object o = new int[] {1,2,3};
o instanceof int[]   // True: the array value is an int array
o instanceof byte[]  // False: the array value is not a byte array
o instanceof Object  // True: all arrays are instances of Object

// Use instanceof to make sure that it is safe to cast an object
if (object instanceof Account) {
   Account a = (Account) object;
}

在 Java 17 中,instanceof 有一个被称为模式匹配的扩展形式。上面最后的示例展示了一个常见的模式检查 instanceof,然后在条件中将其转换为类型。使用模式匹配,我们可以一次性地表达这一切,包括引用类型后面的变量。如果 instanceof 看到类型兼容,变量将被赋予转换后的对象。

if (object instanceof Account a) {
   // variable a is available in this scope
}

这种模式匹配是 Java 中的一个最新添加。预计未来的版本将在整个语言中提供更多此类便利功能。

历史上,鼓励使用 instanceof 以支持其他更面向对象的解决方案,我们将在 Chapter 5 中看到。然而,Java 对模式匹配的日益采用正在改变对这个运算符的态度。在通过 API 接收不可预测格式的数据的常见情况下,instanceof 尤其适合,并且这些天通常是一种务实的选择,而不是最后的手段。

特殊运算符

Java 有六种语言构造,有时被认为是运算符,有时被认为仅仅是基本语言语法的一部分。这些“运算符”在 Table 2-4 中列出,以显示它们相对于其他真正运算符的优先级。这些语言构造的使用在本书的其他地方有详细说明,但在这里简要描述,以便您能够在代码示例中识别它们:

成员访问.

一个对象是一组操作数据和操作该数据的方法的集合;对象的数据字段和方法称为其成员。点(.)运算符用于访问这些成员。如果 o 是一个求值为对象引用(或类名)的表达式,并且 f 是类的字段名,则 o.f 求值为该字段包含的值。如果 m 是一个方法名,则 o.m 引用该方法并允许使用稍后显示的 () 运算符调用它。

数组元素访问[]

一个数组是值的编号列表。数组的每个元素可以通过其编号或索引引用。[] 运算符允许您引用数组的单个元素。如果 a 是一个数组,并且 i 是一个求值为 int 的表达式,则 a[i] 引用数组 a 的一个元素。与处理整数值的其他运算符不同,此运算符将数组索引值限制为 int 类型或更窄。

方法调用()

方法是一组命名的 Java 代码,可以通过在方法名后跟随零个或多个逗号分隔的表达式括在括号中来运行或调用。这些表达式的值是方法的参数。方法处理这些参数并可选择返回一个值,该值成为方法调用表达式的值。如果o.m是一个不带参数的方法,则可以使用o.m()来调用该方法。例如,如果方法期望三个参数,则可以使用表达式o.m(x,y,z)来调用它。o称为方法的接收者 —— 如果o是一个对象,则称其为接收对象。在 Java 解释器调用方法之前,它会评估要传递给方法的每个参数。这些表达式将按从左到右的顺序进行评估(如果其中任何一个参数具有副作用,则这一点很重要)。

Lambda 表达式 (->)

Lambda 表达式是一个匿名的可执行 Java 代码集合,本质上是一个方法体。它由一个方法参数列表(零个或多个逗号分隔的表达式括在括号中)后跟 lambda箭头运算符,然后是一段 Java 代码块组成。如果代码块只包含单个语句,则可以省略通常用于标识块边界的大括号。如果 lambda 只接受一个参数,则可以省略参数周围的括号。

对象创建 (new)

在 Java 中,对象是使用new运算符创建的,后面跟着要创建的对象类型和用括号括起的要传递给对象构造函数的参数列表。构造函数是一个特殊的代码块,用于初始化新创建的对象,因此对象创建语法类似于 Java 方法调用语法。例如:

new ArrayList<String>();
new Account("Jason", 0.0, 42);

数组创建 (new)

数组是对象的一种特殊情况,它们也是使用new运算符创建的,但语法略有不同。关键字后跟要创建的数组类型和用方括号括起的数组大小 —— 例如,new int[5]。在某些情况下,还可以使用数组字面值语法创建数组。

类型转换或强制转换 (())

正如我们已经看到的,括号也可以用作运算符来执行类型转换或强制转换。此运算符的第一个操作数是要转换的类型;它位于括号之间。第二个操作数是要转换的值;它跟在括号后面。例如:

(byte) 28          // An integer literal cast to a byte type
(int) (x + 3.14f)  // A floating-point sum value cast to an integer
(String)h.get(k)   // A generic object cast to a string

语句

语句是 Java 语言中执行的基本单元 —— 它表达了程序员的单一意图。与表达式不同,Java 语句没有值。语句通常包含表达式和操作符(特别是赋值操作符),并且通常执行引起的副作用。

Java 定义的许多语句是流程控制语句,例如条件语句和循环语句,可以以明确定义的方式改变默认的线性执行顺序。表 2-5 总结了 Java 定义的语句。

表 2-5. Java 语句

语句用途语法
表达式副作用variable = expr ; expr ++; method (); new Type ( );
复合组合语句{ statements }
什么也不做;
带标签的命名语句label : statement
变量声明变量[final] type name [= value ] [, name [= value ]] …;
if条件if ( expr ) statement [ else statement ]
switch条件switch ( expr ) { [ case expr : statements ] … [ default: statements ] }
switch条件表达式switch ( expr ) { [ case expr , [ expr …] -> expr ;] … [ default -> expr ;] }
while循环while ( expr ) statement
do循环do statement while ( expr );
for简化循环for ( init ; test ; increment ) statement
foreach集合迭代for ( variable : iterable ) statement
break退出循环break [ label ] ;
continue重新开始循环continue [ label ] ;
return结束方法return [ expr ] ;
synchronized临界区synchronized ( expr ) { statements }
throw抛出异常throw expr ;
try处理异常try { statements } [ catch ( type name ) { statements } ] … [ finally { statements } ]
try处理异常,关闭资源try ([ variable = expr ]) { statements } [ catch ( type name ) { statements } ] … [ finally { statements } ]
assert验证不变性assert invariant [ error ];

表达式语句

正如我们在本章前面看到的那样,Java 的某些类型的表达式具有副作用。换句话说,它们不仅仅评估为某个值;它们还以某种方式改变程序状态。您可以使用具有副作用的任何表达式作为语句,只需在分号后面跟随它即可。表达式语句的合法类型包括赋值、增量和减量、方法调用和对象创建。例如:

a = 1;                             // Assignment
x *= 2;                            // Assignment with operation
i++;                               // Post-increment
--c;                               // Pre-decrement
System.out.println("statement");   // Method invocation

复合语句

复合语句 是任意数量和类型的语句在花括号内组合在一起。您可以在 Java 语法所需的任何位置使用复合语句作为语句:

for(int i = 0; i < 10; i++) {
   a[i]++;           // Body of this loop is a compound statement.
   b[i]--;           // It consists of two expression statements
}                    // within curly braces.

空语句

Java 中的空语句写为一个单分号。空语句不做任何事情,但语法偶尔会有用。例如,您可以在for循环中使用它来指示一个空的循环体:

for(int i = 0; i < 10; a[i++]++)  // Increment array elements
     /* empty */;                 // Loop body is empty statement

标记语句

标记语句简单地说就是给一个语句起了一个名字,方法是在其前面加上标识符和冒号。标签由breakcontinue语句使用。例如:

rowLoop: for(int r = 0; r < rows.length; r++) {        // Labeled loop
   colLoop: for(int c = 0; c < columns.length; c++) {  // Another one
     break rowLoop;                                    // Use a label
   }
}

本地变量声明语句

局部变量,通常简称为变量,是一个在方法或复合语句中定义的用于存储值的位置的符号名称。所有变量在使用前必须声明;这通过变量声明语句完成。因为 Java 是一种静态类型语言,所以变量声明指定变量的类型,只有该类型的值可以存储在变量中。

在其最简单的形式中,变量声明指定变量的类型和名称:

int counter;
String s;

变量声明还可以包括一个初始化器,这是一个指定变量初始值的表达式。例如:

int i = 0;
String s = readLine();
int[] data = {x+1, x+2, x+3}; // Array initializers are discussed later

Java 编译器不允许使用未初始化的局部变量,所以通常方便将变量声明和初始化结合为一个语句。初始化器表达式不必是编译器可以评估的文字值或常量表达式;它可以是在程序运行时计算值的任意复杂表达式。

如果变量有一个初始化器,那么程序员可以使用特殊的语法要求编译器自动计算类型,如果可能的话:

var i = 0;          // type of i inferred as int
var s = readLine(); // type of s inferred as String

这可能是一种有用的语法,但可能更难阅读。例如,我们的第二个例子需要您知道readLine()的返回类型是String,才能知道var将推断为何种类型。因此,在文本中,我们只在初始化器使类型完全冗余时在示例中使用var。当您学习 Java 语言时,这可能是一个合理的政策,因为您熟悉 Java 类型系统时需要遵循这个政策。

单变量声明语句可以声明和初始化多个变量,但所有变量必须是显式声明类型相同的。变量名称和可选的初始化器用逗号分隔:

int i, j, k;
float x = 1.0f, y = 1.0f;
String question = "Really Quit?", response;

变量声明语句可以以final关键字开头。这个修饰符指定了一旦为变量定义了初始值,那么该值就永远不允许更改:

final String greeting = getLocalLanguageGreeting();

我们稍后会更详细地讨论final关键字,特别是在谈论类的设计和编程的不可变风格时。

Java 变量声明语句可以出现在 Java 代码的任何地方;它们不限于方法或代码块的开头。局部变量声明也可以与for循环的初始化部分集成,我们将很快讨论。

局部变量只能在定义它们的方法或代码块内部使用。这称为它们的作用域词法作用域

void method() {            // A method definition
   int i = 0;              // Declare variable i
   while (i < 10) {        // i is in scope here
     int j = 0;            // Declare j; the scope of j begins here
     i++;                  // i is in scope here; increment it
   }                       // j is no longer in scope;
   System.out.println(i);  // i is still in scope here
}                          // The scope of i ends here

if/else 语句

if语句是一个基本的控制语句,允许 Java 进行决策,更准确地说,有条件地执行语句。if语句有一个关联的表达式和语句。如果表达式求值为true,解释器将执行语句。如果表达式求值为false,解释器将跳过该语句。

Java 允许表达式是包装类型Boolean而不是基本类型boolean。在这种情况下,包装对象将自动取消装箱。

以下是一个示例if语句:

if (username == null)         // If username is null,
   username = "John Doe";     // use a default value

尽管它们看起来是多余的,但是表达式周围的括号是if语句语法的必需部分。正如我们已经看到的,被花括号括起来的语句块本身就是一个语句,所以我们也可以编写如下形式的if语句:

if ((address == null) || (address.equals(""))) {
   address = "[undefined]";
   System.out.println("WARNING: no address specified.");
}

if语句可以包括一个可选的else关键字,后面跟着第二个语句。在这种形式的语句中,表达式被求值,如果它是true,则执行第一个语句。否则,执行第二个语句。例如:

if (username != null)
   System.out.println("Hello " + username);
else {
   username = askQuestion("What is your name?");
   System.out.println("Hello " + username + ". Welcome!");
}

当使用嵌套的if/else语句时,需要谨慎确保else子句与适当的if语句配对。考虑以下行:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
else
   System.out.println("i doesn't equal j");    // WRONG!!

在此示例中,内部if语句形成了外部if语句语法允许的单个语句。不幸的是,不清楚(除了缩进给出的提示之外)else与哪个if配对。在这个例子中,缩进提示是错误的。规则是这样的:这样的else子句与最近的if语句关联。正确缩进后,此代码如下所示:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
   else
     System.out.println("i doesn't equal j");    // WRONG!!

这是合法的代码,但显然不是程序员所想要的。在使用嵌套if语句时,应该使用花括号使您的代码更易于阅读。以下是编写代码的更好方法:

if (i == j) {
   if (j == k)
     System.out.println("i equals k");
}
else {
   System.out.println("i doesn't equal j");
}

else if 子句

if/else语句用于测试条件并选择要执行的两个语句或代码块之间的选择。但是,当您需要在几个代码块之间进行选择时呢?这通常使用else if子句来完成,这并不是真正的新语法,而是标准if/else语句的一种常见习惯用法。它看起来像这样:

if (n == 1) {
    // Execute code block #1
}
else if (n == 2) {
    // Execute code block #2
}
else if (n == 3) {
    // Execute code block #3
}
else {
    // If all else fails, execute block #4
}

这段代码并没有什么特别之处。它只是一系列if语句,每个if语句都是上一条语句的else子句的一部分。使用else if习语比完全嵌套形式更可取,并且更易读:

if (n == 1) {
   // Execute code block #1
}
else {
   if (n == 2) {
     // Execute code block #2
   }
   else {
     if (n == 3) {
       // Execute code block #3
     }
     else {
       // If all else fails, execute block #4
     }
   }
}

switch 语句

if语句会导致程序执行流程的分支。您可以使用多个if语句,如前一节所示,执行多路分支。然而,并非总是最佳解决方案,特别是当所有分支都依赖于单个变量的值时。

在这种情况下,重复的if语句可能会严重影响可读性,特别是如果代码经过了重构或具有多层嵌套的if

更好的解决方案是使用switch语句,这是从 C 编程语言继承而来的。然而,请注意,此语句的语法并不像 Java 的其他部分那样优雅。没有重新审视这一特性的设计被普遍认为是一个错误,这在最近的版本中部分得到了纠正,引入了我们将在稍后讨论的switch表达式形式。然而,该替代格式不会抹去语言中长期存在的switch语句的历史,因此理解它是有益的。

注意

switch语句以一个表达式开始,其类型为intshortcharbyte(或它们的包装类型)、String或枚举(详见第四章关于枚举类型的更多信息)。

此表达式后跟着一个带有多个入口点的代码块,这些入口点对应于表达式可能的值。例如,以下switch语句等效于前一节中显示的重复ifelse/if语句:

switch(n) {
   case 1:                         // Start here if n == 1
     // Execute code block #1
     break;                        // Stop here
   case 2:                         // Start here if n == 2
     // Execute code block #2
     break;                        // Stop here
   case 3:                         // Start here if n == 3
     // Execute code block #3
     break;                        // Stop here
   default:                        // If all else fails...
     // Execute code block #4
     break;                        // Stop here
}

正如您从示例中看到的那样,switch语句中的各个入口点要么用关键字case标记,后面跟着一个整数值和一个冒号,要么用特殊的default关键字标记,后面跟着一个冒号。当switch语句执行时,解释器计算括号中表达式的值,然后查找与该值匹配的case标签。如果找到匹配的标签,解释器将从case标签后的第一条语句开始执行代码块。如果没有找到具有匹配值的case标签,则解释器将从特殊的default:标签后的第一条语句开始执行。或者,如果没有default:标签,则解释器完全跳过switch语句的主体。

在前面的代码中,在每个case结尾处使用了break关键字,请注意。break语句将在本章后面进行解释,但在此示例中,它使解释器退出switch语句的主体。switch语句中的case子句仅指定所需代码的起始点。各个case不是独立的代码块,并且它们没有任何隐含的结束点。

警告

您必须显式指定每个 case 的结束,使用 break 或相关语句。在缺少 break 语句的情况下,switch 语句从匹配的 case 标签后的第一个语句开始执行代码,并继续执行语句,直到达到块的末尾。控制流会 fall through 到下一个 case 标签并继续执行,而不是退出块。

在罕见情况下,编写这样的代码是有用的,从一个 case 标签穿透到下一个 case 标签,但 99% 的情况下,您应该小心地结束每个 casedefault 部分,使得 switch 语句停止执行。通常使用 break 语句,但也可以使用 returnthrow 语句。

由于默认的 fall-through 特性,一个 switch 语句可以有多个标记相同语句的 case 子句。考虑下面方法中的 switch 语句:

boolean parseYesOrNoResponse(char response) {
   switch(response) {
     case 'y':
     case 'Y': return true;
     case 'n':
     case 'N': return false;
     default:
       throw new IllegalArgumentException("Response must be Y or N");
   }
}

switch 语句及其 case 标签有一些重要限制。首先,与 switch 语句关联的表达式必须具有适当的类型 —— bytecharshortint(或它们的包装类)、枚举类型或 String。不支持浮点型和 boolean 类型,即使 long 是整数类型也不支持。其次,与每个 case 标签关联的值必须是编译器可以评估的常量值或常量表达式。例如,case 标签不能包含涉及变量或方法调用的运行时表达式。第三,case 标签的值必须在用于 switch 表达式的数据类型的范围内。最后,不能有两个或更多具有相同值的 case 标签或一个以上的 default 标签是不合法的。

考虑到所有这些注意事项,让我们看看新的 switch 表达式如何提供更清晰的体验。

switch 表达式

经典 switch 语句的一个常见问题是捕获变量值时产生的问题。

Boolean yesOrNo = null;
switch(input) {
    case "y":
    case "Y":
        yesOrNo = true;
        break;
    case "n":
    case "N":
        yesOrNo = false;
        break;
    default:
        throw new IllegalArgumentException("Response must be Y or N");
}

变量在 switch 后仍然可用,必须在语句外声明并赋予初始值。然后,每个 case 必须确保设置变量。但是,我们没有保证,在比这个简单示例更多分支的代码中,很容易忽略并引入错误。

switch 表达式明确设计用于解决这些及其他缺陷。正如其名称所示,它是一个 表达式 —— 是语言中语法上较复杂的表达式之一,并因此产生一个值。

boolean yesOrNo = switch(input) {
    case "y" -> true;
    case "Y" -> true;
    case "N" -> false;
    case "n" -> false;
    default -> throw new IllegalArgumentException("Y or N");
};

就像 switch 语句一样,每个 case 在此处评估输入与其值。在 -> 之后,您提供整个 switch 表达式的结果值。在此示例中,我们将其分配给我们的变量 yesOrNo,这不再需要是可空包装类型。

我们这里编写的代码隐藏了switch表达式为我们提供的保护之一。如果我们移除default子句,编译器会报错,因为表达式不能始终完全评估。

boolean yesOrNo = switch(input) {
    case "y" -> true;
    case "Y" -> true;
    case "N" -> false;
    case "n" -> false;
};

// Compiler error:
//   the switch expression does not cover all possible input values

Switch 表达式不像语句形式那样会掉落到下一个case。为了支持多个值评估为相同结果,每个case可以接受逗号分隔的值列表,而不仅仅是单个值。

boolean yesOrNo = switch(input) {
    case "y", "Y" -> true;
    case "n", "N" -> false;
    default -> throw new IllegalArgumentException("Y or N");
};

我们的期望结果并非总是可以表示为单个值或方法调用。为了支持这一点,花括号可以引入一个语句。但是,该语句必须以yield结束以退出带有值的switch,或者使用return离开整个封闭方法。

boolean yesOrNo = switch(input) {
    case "y", "Y" -> { System.out.println("Got it"); yield true; }
    case "n", "N" -> { System.out.println("Nope"); yield false; }
    default -> throw new IllegalArgumentException("Y or N");
};

实际上,如果我们不使用switch表达式的结果,甚至可以仅用于副作用的语法,具有改进的分支检查和安全性。

switch(input) {
    case "y", "Y" -> System.out.println("Sure");
    case "n", "N" -> System.out.println("Nope");
    default -> throw new IllegalArgumentException("Y or N");
}

while语句

while语句是一个基本语句,允许 Java 执行重复的操作——换句话说,它是 Java 的主要循环结构之一。它的语法如下:

while (*`expression`*)
  *`statement`*

while语句首先评估*expression,该表达式必须返回booleanBoolean值。如果值为false,解释器跳过循环关联的statement,并移动到程序的下一条语句。然而,如果值为true,则执行循环体形成的statement,并重新评估expression。再次,如果expression的值为false,解释器继续执行程序的下一条语句;否则,它再次执行statement。这个循环在expression*保持true(即直到它评估为false)时继续,此时while语句结束,解释器继续执行程序的下一条语句。您可以使用语法while(true)创建无限循环。

这里是一个打印数字 0 到 9 的示例while循环:

int count = 0;
while (count < 10) {
   System.out.println(count);
   count++;
}

正如您所看到的,变量count在这个示例中从 0 开始,并且每次循环体运行时都会递增。一旦循环执行了 10 次,表达式变为false(即count不再小于 10),while语句结束,Java 解释器可以转到程序中的下一条语句。大多数循环都有像count这样的计数器变量。变量名ijk通常用作循环计数器,尽管如果使您的代码更易于理解,您应该使用更具描述性的名称。

do语句

do循环与while循环非常相似,不同之处在于循环表达式在循环体底部测试,而不是在顶部测试。这意味着循环体至少会执行一次。语法如下:

do
   *`statement`*
while (*`expression`*);

注意do循环与更普通的while循环之间的一些区别。首先,do循环需要do关键字标记循环的开始和while关键字标记结束并引入循环条件。此外,与while循环不同,do循环以分号结束。这是因为do循环以循环条件结束,而不仅仅是以标记循环体结束的花括号结束。以下的do循环打印了与前述while循环相同的输出:

int count = 0;
do {
   System.out.println(count);
   count++;
} while(count < 10);

do循环比它的while表兄弟要少见得多,因为实际上很少遇到您确信总是希望至少执行一次循环的情况。

for语句

for语句提供了一个循环结构,通常比whiledo循环更方便。for语句利用了一种常见的循环模式。大多数循环都有一个计数器或某种状态变量,在循环开始前初始化,在测试后确定是否执行循环体,并在循环体结束前以某种方式递增或更新。initialize、*testupdate*步骤是循环变量的三个关键操作,而for语句使这三个步骤成为循环语法的显式部分:

for(*`initialize`*; *`test`*; *`update`*) {
    *`statement`*
}

这个for循环基本上等同于以下的while循环:

*`initialize`*;
while (*`test`*) {
   *`statement`*;
   *`update`*;
}

将*initializetestupdate表达式放在for循环的顶部使得理解循环正在做什么特别容易,并且防止像忘记初始化或更新循环变量这样的错误。解释器会丢弃initializeupdate表达式的值,因此为了有用,这些表达式必须具有副作用。initialize通常是赋值表达式,而update*通常是增量、减量或其他一些赋值。

下面的for循环打印出 0 到 9 的数字,就像之前的whiledo循环所做的那样:

int count;
for(count = 0 ; count < 10 ; count++)
   System.out.println(count);

注意这种语法如何将关于循环变量的所有重要信息放在一行上,使得循环执行过程非常清晰。将*update*表达式放在for语句本身中也简化了循环体到一个单一语句;我们甚至不需要使用花括号来生成语句块。

for循环支持一些额外的语法,使其更加方便使用。因为许多循环仅在循环内部使用它们的循环变量,所以for循环允许*initialize*表达式是一个完整的变量声明,因此该变量的作用域仅限于循环体内部,在外部不可见。例如:

for(int count = 0 ; count < 10 ; count++)
   System.out.println(count);

此外,for循环的语法不限于只写使用单个变量的循环。for循环的*initializeupdate*表达式都可以使用逗号来分隔多个初始化和更新表达式。例如:

for(int i = 0, j = 10 ; i < 10 ; i++, j--)
     sum += i * j;

尽管到目前为止所有的示例都是计数数字,但for循环并不局限于计数数字的循环。例如,你可以使用for循环来遍历链表的元素:

for(Node n = listHead; n != null; n = n.nextNode())
   process(n);

for循环的*initializetestupdate表达式都是可选的;只有分号用于分隔表达式是必需的。如果省略了test*表达式,则假定为true。因此,你可以将一个无限循环写成for(;;)

foreach 语句

Java 的for循环适用于原始类型,但对处理对象集合来说过于笨拙。相反,另一种称为foreach循环的替代语法用于处理需要遍历的对象集合。

foreach 循环使用关键字for后跟一个开括号,一个变量声明(没有初始化器),一个冒号,一个表达式,一个闭括号,最后是组成循环体的语句(或块):

for( *`declaration`* : *`expression`* )
     *`statement`*

尽管其名称如此,foreach 循环并没有关键字foreach——相反,通常将冒号读作“in”——例如“foreach name in studentNames.”。

对于whiledofor循环,我们展示了一个打印 10 个数字的例子。foreach 循环也可以做到,但它需要一个要遍历的集合。为了循环 10 次(打印出 10 个数字),我们需要一个包含 10 个元素的数组或其他集合。下面是我们可以使用的代码:

// These are the numbers we want to print
int[] primes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
// This is the loop that prints them
for(int n : primes)
     System.out.println(n);

foreach 无法做到的事情

foreach 与whilefordo循环不同,因为它隐藏了循环计数器或Iterator。这是一个非常强大的想法,当我们讨论 lambda 表达式时会看到,但是有些算法用 foreach 循环表达起来并不自然。

例如,假设你想将数组的元素打印为逗号分隔的列表。为此,你需要在数组的每个元素后打印一个逗号,除了最后一个元素之外,或者等效地,在数组的每个元素之前打印一个逗号。使用传统的for循环,代码可能如下所示:

for(int i = 0; i < words.length; i++) {
     if (i > 0) System.out.print(", ");
     System.out.print(words[i]);
}

这是一个非常直接的任务,但你在不保留额外状态的情况下简单地无法使用 foreach 完成。问题在于 foreach 循环不提供循环计数器或其他方式来告诉你是第一次迭代、最后一次迭代还是中间某个迭代。

注意

当你使用 foreach 来遍历集合元素时,存在类似的问题。正如在数组上进行 foreach 循环时无法获取当前元素的数组索引一样,在集合上进行 foreach 循环时也无法获取正在用于枚举集合元素的Iterator对象。

这里有一些你不能在 foreach 风格循环中做的事情:

  • 反向迭代数组或 List 的元素。

  • 使用单个循环计数器访问两个不同数组的相同编号元素。

  • 使用调用其 get() 方法而不是调用其迭代器来遍历 List 的元素。

break 语句

break 语句会导致 Java 解释器立即跳过包含语句的末尾。我们已经看到 break 语句与 switch 语句一起使用。break 语句通常简单地写为关键字 break 后跟一个分号:

break;

在这种形式下,它会导致 Java 解释器立即退出最内层的包含的 whiledoforswitch 语句。例如:

for(int i = 0; i < data.length; i++) {
    if (data[i] == target) {  // When we find what we're looking for,
        index = i;              // remember where we found it
        break;                  // and stop looking!
    }
}   // The Java interpreter goes here after executing break

break 语句也可以跟随包含的标记语句的名称。在这种形式下,break 会导致 Java 解释器立即退出命名的块,该块可以是任何类型的语句,而不仅仅是循环或 switch。例如:

TESTFORNULL: if (data != null) {
   for(int row = 0; row < numrows; row++) {
     for(int col = 0; col < numcols; col++) {
       if (data[row][col] == null)
         break TESTFORNULL;           // treat the array as undefined.
     }
   }
}  // Java interpreter goes here after executing break TESTFORNULL

continue 语句

虽然 break 语句退出循环,但 continue 语句结束当前循环迭代并开始下一个迭代。continue 在其未标记和标记形式中,只能在 whiledofor 循环内使用。未带标记时,continue 导致最内层循环开始新的迭代。当使用包含循环名称的标签时,它导致命名循环开始新的迭代。例如:

for(int i = 0; i < data.length; i++) {  // Loop through data.
   if (data[i] == -1)                   // If a data value is missing,
     continue;                          // skip to the next iteration.
   process(data[i]);                    // Process the data value.
}

whiledofor 循环在 continue 启动新迭代的方式上略有不同:

  • 对于 while 循环,Java 解释器简单地返回到循环的顶部,重新测试循环条件,如果评估为 true,则再次执行循环体。

  • 对于 do 循环,解释器跳到循环的底部,检查循环条件以决定是否执行循环的另一个迭代。

  • 对于 for 循环,解释器跳到循环的顶部,首先评估 update 表达式,然后评估 test 表达式以决定是否再次循环。从这些例子中可以看出,带有 continue 语句的 for 循环的行为与之前介绍的“基本等效”的 while 循环的行为不同;updatefor 循环中被评估,但在等效的 while 循环中却不是。

return 语句

return 语句告诉 Java 解释器停止执行当前方法。如果方法声明要返回一个值,则 return 语句后必须跟表达式。表达式的值成为方法的返回值。例如,以下方法计算并返回一个数字的平方:

double square(double x) {      // A method to compute x squared
   return x * x;               // Compute and return a value
}

有些方法声明为void,表示它们不返回任何值。Java 解释器通过逐一执行它们的语句直到方法结束来运行这些方法。执行完最后一个语句后,解释器会隐式返回。然而,有时void方法必须在到达最后一个语句之前显式返回。在这种情况下,它可以使用return语句本身,不带任何表达式。例如,以下方法打印但不返回其参数的平方根。如果参数是负数,则打印之前返回而不打印任何内容:

// A method to print square root of x
void printSquareRoot(double x) {
   if (x < 0) return;                // If x is negative, return
   System.out.println(Math.sqrt(x)); // Print the square root of x
}                                    // Method end: return implicitly

同步语句

Java 一直为多线程编程提供支持。我们稍后会详细介绍这一点(特别是在“Java 对并发的支持”中);然而,请注意,编写正确的并发代码很难且有许多微妙之处。

特别是在处理多个线程时,通常需要注意防止多个线程同时修改对象的方式,这可能会破坏对象的状态。Java 提供了synchronized语句来帮助程序员防止这种破坏。语法是:

synchronized ( *`expression`* ) {
   *`statements`*
}

expression 是必须求值为对象(包括数组)的表达式。statements 构成可能会造成损害的部分的代码,并且必须用大括号括起来。

注意

在 Java 中,对象状态(即数据)的保护是并发原语的主要关注点。这与其他一些语言不同,其他语言更关注对临界区(即代码)的排除。

在执行语句块之前,Java 解释器首先获取由*expression*指定的对象或数组的独占锁。它在运行完块后释放锁。当一个线程持有对象的锁时,其他线程无法获取该锁。

除了块形式外,synchronized在 Java 中还可以作为方法修饰符使用。当应用于方法时,该关键字表示整个方法被视为synchronized

对于synchronized实例方法,Java 获取类实例的独占锁。(类方法和实例方法在第三章中有详细讨论。)它可以被视为覆盖整个方法的synchronized (this) { ... }块。

static synchronized 方法(类方法)会导致 Java 在执行该方法之前获取类(技术上对应于类型的类对象)的独占锁。

抛出语句

异常 是指示发生了某种异常条件或错误的信号。抛出异常是为了信号异常条件。捕获异常是为了处理它,采取必要的措施以从中恢复。

在 Java 中,throw语句用于抛出异常:

throw *`expression`*;

expression 必须评估为描述发生的异常或错误的异常对象。稍后我们将更详细地讨论异常的类型;现在你只需要知道,异常:

  • 由对象表示

  • 其类型是 Exception 的子类

  • 在 Java 语法中具有稍微专门化的角色

  • 可以是两种不同类型:checkedunchecked

下面是一个抛出异常的示例代码:

public static double factorial(int x) {
   if (x < 0)
     throw new IllegalArgumentException("x must be >= 0");
   double fact;
   for(fact=1.0; x > 1; fact *= x, x--)
     /* empty */ ;          // Note use of the empty statement
   return fact;
}

当 Java 解释器执行 throw 语句时,它立即停止正常的程序执行,并开始寻找能够捕获或处理异常的异常处理程序。异常处理程序使用 try/catch/finally 语句编写,下一节将对此进行描述。Java 解释器首先查看包含代码块,看看是否有相关联的异常处理程序。如果有,则退出该代码块,并开始运行与该代码块相关联的异常处理代码。运行完异常处理程序后,解释器继续执行跟在处理程序代码之后的语句。

如果包含代码块没有适当的异常处理程序,则解释器检查方法中的下一个更高的包含代码块。这将继续,直到找到处理程序。如果方法中没有可以处理 throw 语句抛出的异常的异常处理程序,则解释器停止运行当前方法,并返回给调用者。现在解释器开始在调用方法的代码块中寻找异常处理程序。通过这种方式,异常沿着 Java 方法的词法结构向上传播,沿着 Java 解释器的调用堆栈向上传播。如果异常从未被捕获,则它一直传播到程序的 main() 方法。如果在该方法中未处理它,则 Java 解释器打印错误消息,打印堆栈跟踪以指示异常发生的位置,然后退出。

try/catch/finally 语句

Java 有两种略有不同的异常处理机制。经典形式是 try/catch/finally 语句。此语句的 try 子句建立了一个用于异常处理的代码块。这个 try 块后面是零个或多个 catch 子句,每个 catch 子句是一组语句块,用于处理特定的异常。每个 catch 块可以处理多个不同的异常—为了指示一个 catch 块应该处理多个异常,我们使用 | 符号分隔不同的异常。catch 子句后面是一个可选的 finally 块,其中包含保证执行的清理代码,无论 try 块中发生了什么。

以下代码说明了 try/catch/finally 语句的语法和目的:

try {
   // Normally this code runs from the top of the block to the bottom
   // without problems. But it can sometimes throw an exception,
   // either directly with a throw statement or indirectly by calling
   // a method that throws an exception.
}
catch (SomeException e1) {
   // This block contains statements that handle an exception object
   // of type SomeException or a subclass of that type. Statements in
   // this block can refer to that exception object by the name e1.
}
catch (AnotherException | YetAnotherException e2) {
   // This block contains statements that handle an exception of
   // type AnotherException or YetAnotherException, or a subclass of
   // either of those types. Statements in this block refer to the
   // exception object they receive by the name e2.
}
finally {
   // This block contains statements that are always executed
   // after we leave the try clause, regardless of whether we leave it:
   //   1) normally, after reaching the bottom of the block;
   //   2) because of a break, continue, or return statement;
   //   3) with an exception that is handled by a catch clause above;
   //   4) with an uncaught exception that has not been handled.
   // If the try clause calls System.exit(), however, the interpreter
   // exits before the finally clause can be run.
}

try

try子句简单地建立一个代码块,要么处理其异常,要么在任何情况下终止时运行特殊的清理代码。try子句本身不执行任何有趣的操作;是catchfinally子句执行异常处理和清理操作。

捕获

一个try块后面可以跟随零个或多个catch子句,这些子句指定处理各种类型异常的代码。每个catch子句声明一个参数,指定该子句可以处理的异常类型(可能使用特殊的|语法来表示catch块可以处理多种类型的异常),并且为该子句提供一个名称,用于引用它当前正在处理的异常对象。catch块希望处理的任何类型必须是Throwable的某个子类。

当抛出异常时,Java 解释器会查找一个带有与异常对象类型相匹配或超类匹配的catch子句。解释器调用它找到的第一个这样的catch子句。catch块内的代码应采取必要的操作来处理异常情况。例如,如果异常是java.io.FileNotFoundException,你可以通过要求用户检查拼写并重试来处理它。

并非每个可能的异常都需要有一个catch子句;在某些情况下,正确的响应是允许异常传播并被调用方法捕获。在其他情况下,比如由NullPointerException信号的编程错误,正确的响应可能并不是捕获异常,而是允许其传播,并让 Java 解释器输出堆栈跟踪和错误消息。

最终

finally子句通常用于在try子句中的代码之后进行清理(例如关闭文件和关闭网络连接)。finally子句非常有用,因为它保证在执行try块的任何部分之后执行,无论try块中的代码如何完成。实际上,try子句退出而不允许执行finally子句的唯一方法是调用System.exit()方法,这将导致 Java 解释器停止运行。

在正常情况下,控制流程到达try块的末尾,然后继续执行finally块,执行任何必要的清理工作。如果控制流程因为returncontinuebreak语句离开try块,那么在转移到新目的地之前将执行finally块。

如果在try块中发生异常,并且有相关的catch块来处理异常,则控制首先转移到catch块,然后再到finally块。如果没有局部catch块来处理异常,则控制首先转移到finally块,然后传播到最近的能处理异常的包含catch子句。

如果finally块本身使用returncontinuebreakthrow语句转移控制,或通过调用抛出异常的方法,挂起的控制转移将被放弃,并处理这种新的转移。例如,如果finally子句抛出异常,则该异常将替换正在被抛出的任何异常。如果finally子句发出return语句,则方法会正常返回,即使已经抛出异常且尚未处理。

tryfinally可以一起使用,不带异常或任何catch子句。在这种情况下,finally块只是保证执行的清理代码,无论try子句中有任何breakcontinuereturn语句。

尝试使用资源语句

try块的标准形式非常通用,但在编写catchfinally块时,需要开发人员在操作需要在不再需要时清理或关闭的资源时特别小心。

Java 提供了一种非常有用的机制来自动关闭需要清理的资源。这就是try-with-resources(TWR),或称为尝试资源。我们在“经典 Java I/O”中详细讨论了 TWR,但为了完整起见,让我们现在介绍一下语法。以下示例展示了如何使用FileInputStream类打开文件(这会生成一个需要清理的对象):

try (InputStream is = new FileInputStream("/Users/ben/details.txt")) {
  // ... process the file
}

这种新形式的try接受的参数都是需要清理的对象,²这些对象的作用域限于此try块,然后无论如何退出此块,它们都会自动清理。开发人员不需要编写任何catchfinally块——Java 编译器会自动插入正确的清理代码。

所有涉及资源的新代码都应以 TWR 风格编写——这比手动编写catch块容易出错得多,并且不会遭受像最终化技术(详见“终结”)那样的问题。

断言语句

assert语句是在 Java 代码中验证设计假设的一种尝试。断言assert关键字后跟程序员认为应始终评估为true的布尔表达式组成。默认情况下,断言是禁用的,而assert语句实际上不执行任何操作。

可以将断言作为调试工具启用,但是当这样做时,assert 语句会评估表达式。如果它确实是 trueassert 不会执行任何操作。另一方面,如果表达式评估为 false,断言将失败,并且 assert 语句将抛出一个 java.lang.AssertionError

提示

除了核心的 JDK 库之外,assert 语句几乎极少被使用。事实证明,它对于测试大多数应用程序来说过于不灵活,并且普通开发人员很少使用它。相反,开发者使用普通的测试库,比如 JUnit。

assert 语句可以包含一个可选的第二表达式,用冒号与第一个表达式分隔。当启用断言并且第一个表达式评估为 false 时,第二个表达式的值将作为错误代码或错误消息,并传递给 AssertionError() 构造函数。语句的完整语法是:

assert *`assertion`*;

或者:

assert *`assertion`* : *`errorcode`*;

要有效地使用断言,您还必须了解一些细节。首先,请记住,您的程序通常会禁用断言并且仅在某些时候启用断言。这意味着您应该小心,不要编写包含副作用的断言表达式。

警告

您不应该从自己的代码中抛出 AssertionError,因为它可能会在平台的将来版本中产生意外的结果。

如果抛出 AssertionError,则表明程序员的某些假设不成立。这意味着代码正在超出其设计的参数范围,并且不能期望其能正常工作。简而言之,没有合理的方式可以从 AssertionError 中恢复,您不应尝试捕获它(除非您仅在顶层捕获它,以便以更用户友好的方式显示错误)。

启用断言

为了效率,每次执行代码时测试断言是没有意义的 — assert 语句编码了应始终为真的假设。因此,默认情况下禁用断言,assert 语句没有任何效果。但是,断言代码仍然编译在类文件中,因此始终可以为诊断或调试目的启用它们。您可以使用 Java 解释器的命令行参数启用断言,要么全面启用,要么选择性启用。

要在所有类中除系统类外启用断言,请使用 -ea 参数。要在系统类中启用断言,请使用 -esa。要在特定类内启用断言,请使用 -ea 后跟一个冒号和类名:

java -ea:com.example.sorters.MergeSort com.example.sorters.Test

要为包及其所有子包中的所有类启用断言,请在 -ea 参数后跟一个冒号,包名称和三个点:

java -ea:com.example.sorters... com.example.sorters.Test

您可以以相同的方式禁用断言,使用 -da 参数。例如,要在一个包中全面启用断言,然后在特定类或子包中禁用它们,请使用:

java -ea:com.example.sorters... -da:com.example.sorters.QuickSort
java -ea:com.example.sorters... -da:com.example.sorters.plugins..

最后,可以控制是否在类加载时启用或禁用断言。如果你在程序中使用自定义类加载器(有关自定义类加载的详细信息,请参见第十一章)并想启用断言,可能会对这些方法感兴趣。

方法

方法是一个由 Java 语句组成的命名序列,可以被其他 Java 代码调用。当调用一个方法时,会传递零个或多个值,称为参数。该方法执行一些计算,且可选择地返回一个值。正如前面在“表达式与运算符”中描述的,方法调用是一个由 Java 解释器评估的表达式。然而,由于方法调用可能有副作用,它们也可以用作表达式语句。本节不讨论方法调用,而是描述如何定义方法。

定义方法

你已经知道如何定义方法的主体;它只是一个任意的语句序列,括在花括号内。方法更有趣的是它的签名。(3)。签名指定:

  • 方法的名称

  • 方法使用的参数的数量、顺序、类型和名称

  • 方法返回值的类型

  • 方法可能抛出的已检查异常(签名也可以列出未检查异常,但不是必需的)

  • 提供关于方法的额外信息的各种方法修饰符

方法签名定义了在调用方法之前你需要知道的所有信息。它是方法规范,定义了方法的 API。要使用 Java 平台的在线 API 参考,你需要知道如何阅读方法签名。而且,编写 Java 程序时,你需要知道如何定义你自己的方法,每个方法都以方法签名开始。

方法签名如下所示:

*`modifiers` `type` `name`* (*`paramlist`*) [ throws exceptions ]

方法签名(方法规范)后是方法体(方法实现),它只是一个由 Java 语句组成的序列,括在花括号内。如果方法是抽象的(参见第三章),则省略实现,方法体用一个分号替换。

方法的签名也可能包括类型变量声明——此类方法被称为泛型方法。泛型方法和类型变量在第四章中讨论。

下面是一些示例方法定义,方法签名后面是方法体:

// This method is passed an array of strings and has no return value.
// All Java programs have an entry point with this name and signature.
public static void main(String[] args) {
     if (args.length > 0) System.out.println("Hello " + args[0]);
     else System.out.println("Hello world");
}

// This method is passed two double arguments and returns a double.
static double distanceFromOrigin(double x, double y) {
     return Math.sqrt(x*x + y*y);
}

// This method is abstract which means it has no body.
// Note that it may throw exceptions when invoked.
protected abstract String readText(File f, String encoding)
    throws FileNotFoundException, UnsupportedEncodingException;

*修饰符*是零个或多个特殊的修饰符关键字,由空格分隔。例如,一个方法可能使用publicstatic修饰符声明。允许的修饰符及其含义在下一节中描述。

方法签名中的*type指定方法的返回类型。如果方法不返回值,type*必须是void。如果方法声明具有非void返回类型,则必须包含一个返回语句,该语句返回声明类型的值(或可转换为其的值)。

构造函数是一段代码块,类似于方法,用于初始化新创建的对象。正如我们将在第三章中看到的,构造函数的定义方式与方法非常相似,只是它们的签名不包括这个*type*规范,并且必须与类名相同。

方法的*name遵循其修饰符和类型的规范。方法名像变量名一样是 Java 标识符,并且像所有 Java 标识符一样,可以包含由 Unicode 字符集表示的任何语言中的字母。定义具有相同名称的多个方法通常是合法且非常有用的,只要每个方法的版本具有不同的参数列表。定义具有相同名称的多个方法称为方法重载*。

提示

不同于其他一些语言,Java 没有匿名方法。相反,Java 8 引入了 lambda 表达式,它们类似于匿名方法,但 Java 运行时会自动将它们转换为适当命名的方法——详见“Lambda Expressions”了解更多细节。

例如,我们已经见过的System.out.println()方法是一个重载方法。同名的一个方法打印一个字符串,同名的其他方法打印各种基本类型的值。Java 编译器根据传递给方法的参数类型决定调用哪个方法。

当你定义一个方法时,方法名后总是跟着方法的参数列表,参数列表必须用括号括起来。参数列表定义了零个或多个传递给方法的参数。如果有参数规范,每个规范包括类型和名称,并且规范之间用逗号分隔(如果有多个参数)。当调用一个方法时,传递给它的参数值必须与该方法签名行中指定的参数的数量、类型和顺序匹配。传递的值不需要与签名中指定的类型完全相同,但必须可以在不进行强制转换的情况下转换为这些类型。

注意

当一个 Java 方法不希望有参数时,其参数列表仅为(),而不是(void)。Java 不将void视为一种类型——特别是 C 和 C++ 程序员应该注意。

Java 允许程序员定义和调用接受可变数量参数的方法,使用一种俗称为varargs的语法。有关 varargs 的详细信息稍后在本章中讨论。

方法签名的最后一部分是throws子句,用于列出方法可以抛出的受检异常。受检异常是一类必须在方法的throws子句中列出的异常类。

如果一个方法使用throw语句抛出一个受检异常,那么该方法必须声明它可以抛出该异常。在调用某个抛出受检异常的其他方法且调用方法没有明确捕获该异常的情况下,方法必须声明它可以抛出异常。

如果一个方法可能抛出一个或多个受检异常,它会通过在参数列表之后放置throws关键字并跟随异常类的名称来指定这一点。如果一个方法不会抛出任何受检异常,则不使用throws关键字。如果一个方法抛出多种类型的受检异常,使用逗号将异常类的名称分开。稍后详细介绍。

方法修饰符

方法的修饰符由零个或多个修饰符关键字组成,例如publicstaticabstract。以下是允许的修饰符及其含义列表:

abstract

abstract方法是没有实现的规范。方法的大括号和 Java 语句通常组成方法体的部分被替换为一个分号。包含abstract方法的类本身必须声明为abstract。这样的类是不完整的,不能被实例化(参见第三章)。

default

default方法只能在接口上定义。实现接口的所有类都会接收默认方法,除非它们直接覆盖它。在第三章中详细探讨了在类中实现接口。

final

final方法不能被子类重写或隐藏,这使得它适合进行编译器优化,这对于普通方法来说是不可能的。所有private方法都隐式地是final的,同样,所有声明为final的类的方法也是final的。

native

native修饰符指定方法实现是用某些“本地”语言编写的,比如 C,并且是外部提供给 Java 程序的。与abstract方法类似,native方法没有方法体:大括号被分号替代。

public, protected, private

这些访问修饰符指定方法是否以及在哪里可以在定义它的类的外部使用。这些非常重要的修饰符在第三章中有详细解释。

static

声明为static的方法是与类本身关联的类方法,而不是与类的实例关联的(我们在第三章中详细讨论这一点)。

strictfp

这个笨拙命名、很少使用的修饰符中的fp代表“浮点”。出于性能原因,在 Java 1.2 中,当使用某些浮点加速硬件时,语言允许对严格的 IEEE-754 标准进行微小的偏离。添加了strictfp关键字以强制 Java 严格遵守该标准。这些硬件考虑多年来已不再相关,因此 Java 17 将默认返回 IEEE 标准。使用strictfp关键字将会发出警告,因为它已不再必要。

synchronized

synchronized修饰符使方法具有线程安全性。在线程调用synchronized方法之前,它必须获取方法类(对于静态方法)或类的相关实例(对于非静态方法)的锁定。这可以防止两个线程同时执行该方法。

synchronized修饰符是一个实现细节(因为方法可以以其他方式使自己线程安全),并不是方法规范或 API 的正式部分。良好的文档明确指定方法是否线程安全;在处理多线程程序时,不应依赖于synchronized关键字的存在或缺失。

提示

注解是一个有趣的特例(详见第四章关于注解的更多内容)——它们可以被看作是方法修饰符和额外补充类型信息之间的一种中间形式。

Checked 和 Unchecked Exceptions

Java 的异常处理方案区分为两种类型的异常,称为checkedunchecked异常。

区分 checked 和 unchecked 异常与异常可能被抛出的情况有关。Checked 异常发生在特定而明确定义的情况下,并且应用程序可能能够部分或完全恢复。

例如,考虑一些可能在几个可能的目录中找到其配置文件的代码。如果尝试从不存在的目录中打开文件,则会抛出FileNotFoundException。在我们的例子中,我们希望捕获此异常并继续尝试文件的下一个可能位置。换句话说,虽然文件不存在是一个异常情况,但这是一个我们可以恢复的情况,并且是一种可以理解和预见的失败。

另一方面,在 Java 环境中,存在一组无法轻易预测或预见的失败,原因可能是运行时条件或滥用库代码。例如,无法有效预测OutOfMemoryError,而且任何使用对象或数组的方法,如果传递了无效的null参数,都可能抛出NullPointerException

这些是未检查的异常——基本上任何方法都可能在任何时候抛出未检查的异常。它们是 Java 环境版本的墨菲定律:“任何可能出错的事情,最终都会出错。” 由于它们的完全不可预测性,从未检查的异常中恢复通常非常困难,甚至是不可能的。

要确定异常是已检查还是未检查的,记住异常是 Throwable 对象,这些对象分为两大类,由 ErrorException 子类指定。任何 Error 类型的异常对象都是未检查的。还有一个名为 RuntimeExceptionException 子类——任何 RuntimeException 子类也都是未检查的异常。所有其他异常都是已检查的异常。

处理已检查的异常

Java 对处理已检查和未检查的异常有不同的规则。如果你编写一个会抛出已检查异常的方法,你必须在方法签名中使用 throws 子句来声明异常。Java 编译器会检查你是否在方法签名中声明了它们,如果没有声明就会产生编译错误(这就是它们被称为“已检查的异常”的原因)。

即使你自己从不抛出已检查的异常,有时你也必须使用 throws 子句来声明已检查的异常。如果你的方法调用了一个可能抛出已检查异常的方法,你必须要么包含处理异常的代码来处理该异常,要么使用 throws 来声明你的方法也可以抛出该异常。

例如,以下方法尝试估算网页的大小——它使用标准的 java.net 库和类 URL(我们将在第十章中了解到这些内容)来联系网页。它使用可能抛出各种类型的 java.io.IOException 对象的方法和构造函数,因此它使用 throws 子句声明了这一事实:

public static estimateHomepageSize(String host) throws IOException {
    URL url = new URL("htp://"+ host +"/");
    try (InputStream in = url.openStream()) {
        return in.available();
    }
}

实际上,前面的代码有一个错误:我们拼错了协议说明符——htp:// 并不存在这样的协议。所以,estimateHomepageSize() 方法将始终失败并抛出 MalformedURLException

你怎么知道你正在调用的方法是否会抛出已检查的异常?你可以查看它的方法签名来找出。或者,如果你调用了必须处理或声明异常的方法,Java 编译器会告诉你(通过报告编译错误)。

可变长度的参数列表

方法可以声明接受和被调用时传递可变数量的参数。这样的方法通常称为 varargs 方法。 “print formatted” 方法 System.out.printf() 以及相关的 Stringformat() 方法使用 varargs,java.lang.reflect 的 Reflection API 中的一些重要方法也是如此。

要声明一个可变长度的参数列表,请在方法的最后一个参数的类型后面跟着省略号(...),表示这个最后一个参数可以重复零次或更多次。例如:

public static int max(int first, int... rest) {
    /* body omitted for now */
}

可变参数方法由编译器纯粹处理。它们通过将可变数量的参数转换为数组来运作。对于 Java 运行时而言,max()方法与此方法无异:

public static int max(int first, int[] rest) {
    /* body omitted for now */
}

要将可变参数签名转换为“真实”签名,只需将...替换为[ ]。请记住,参数列表中只能出现一个省略号,并且它只能出现在列表中的最后一个参数上。

让我们稍微详细说明一下max()的示例:

public static int max(int first, int... rest) {
    int max = first;
    for(int i : rest) { // legal because rest is actually an array
        if (i > max) max = i;
    }
    return max;
}

这个max()方法声明了两个参数。第一个参数是一个普通的int值。然而,第二个参数可以重复零次或多次。以下所有调用max()方法的方式都是合法的:

max(0)
max(1, 2)
max(16, 8, 4, 2, 1)

因为可变参数方法被编译为期望一个参数数组的方法,调用这些方法被编译为包括创建和初始化这样一个数组的代码。因此,调用max(1,2,3)被编译为这样:

max(1, new int[] { 2, 3 })

实际上,如果您已经将方法参数存储在数组中,您可以合法地以这种方式将它们传递给方法,而不是逐个写出它们。您可以将任何...参数视为已声明为数组。然而,反之则不成立:只有当方法实际上使用省略号声明为可变参数方法时,您才能使用可变参数方法调用语法。

类和对象介绍

现在我们已经介绍了操作符、表达式、语句和方法,我们终于可以讨论类了。是一个命名的字段集合,其中包含存储数据值和操作这些值的方法。类只是 Java 支持的五种引用类型之一,但它们是最重要的类型。类在单独的章节中有详细的文档说明(第三章)。然而,我们在这里介绍它们,是因为它们是方法之后的下一个更高级别的语法,以及本章的其余部分需要对类的概念有基本的熟悉,以及定义类、实例化类和使用生成的对象的基本语法。

类最重要的一点是它们定义了新的数据类型。例如,您可以定义一个名为Account的类来表示一个持有余额的银行账户。该类将定义字段来存储数据项,如余额(可能表示为double)、账户持有人的姓名和地址(作为String实例),以及操作账户的方法。Account类就是一个新的数据类型。

在讨论数据类型时,区分数据类型本身和数据类型表示的值是很重要的。char是一个数据类型:它表示 Unicode 字符。但是char值表示一个具体的字符。类是一种数据类型;一个类的值称为对象。我们使用类名是因为每个类定义了一种对象的类型(或种类、类别、类)。Account类是一个表示银行账户的数据类型,而Account对象表示一个具体的账户。正如你可以想象的那样,类和它们的对象是紧密联系的。接下来的章节中,我们将讨论这两者。

定义一个类

这里是我们讨论过的Account类的一个可能定义:

/** Represents a customer bank account */
public class Account {
     public String name;
     public double balance;
     public int accountId;

     // A constructor that initializes the fields
     public Account(String name, double openingBalance, int id) {
         this.name = name;
         this.balance = openingBalance;
         this.accountId = id;
     }
}

此类定义存储在名为Account.java的文件中,并编译为名为Account.class的文件,可供 Java 程序和其他类使用。这里提供类定义是为了完整性和提供上下文,但不要期望立即理解所有细节;第三章大部分内容专注于类定义的主题。

请记住,在 Java 程序中,你不必定义每个想要使用的类。Java 平台包含成千上万的预定义类,保证在运行给定版本 Java 的每台计算机上都可用。

创建对象

现在我们已经将Account类定义为一个新的数据类型,我们可以使用以下行来声明一个变量,以保存Account对象:

Account a;

声明一个变量来持有Account对象并不会创建对象本身。要实际创建一个对象,你必须使用new操作符。这个关键字后面跟着对象的类(即其类型)和一个可选的参数列表在括号中。这些参数被传递给类的构造函数,构造函数初始化新对象的内部字段:

// Declare variable a and store a reference to new Account object
Account a = new Account("Jason Clark", 0.0, 42);

// Create some other objects as well
// An object that represents the current time
LocalDateTime d = new LocalDateTime();

// A HashSet object to hold a set of strings
Set<String> words = new HashSet<>();

在 Java 中,new关键字是创建对象最常见的方式。还有几种方式也值得一提。首先,符合特定标准的类非常重要,Java 为这些类型的对象创建定义了特殊的字面语法(如我们稍后在本节讨论)。其次,Java 支持一种机制,允许程序加载类并动态创建这些类的实例。详细信息请参见第十一章。最后,对象也可以通过反序列化来创建。已经保存其状态(通常是到文件)的对象可以使用java.io.ObjectInputStream类重新创建。

使用对象

现在我们已经看到如何定义类并通过创建对象进行实例化,我们需要查看允许我们使用这些对象的 Java 语法。请记住,一个类定义了一组字段和方法。每个对象都有它自己的这些字段的副本,并且可以访问这些方法。我们使用点字符(.)来访问对象的命名字段和方法。例如:

Account a = new Account("Jason", 0.0, 42);  // Create an object

double b  = a.balance;                 // Read a field of the object
a.balance = a.balance + 10.0;          // Set the value of a field

String s  = a.toString();              // Access a method of the object

当在面向对象的语言中编程时,这种语法非常常见,Java 也不例外。特别要注意,表达式a.toString()。这告诉 Java 编译器查找一个名为toString的方法(该方法由Account的父类Object定义),并使用该方法在对象a上执行计算。我们将在第三章中详细讨论此操作的细节。

对象字面量

在我们讨论原始类型时,我们看到每个原始类型都有一个文字语法,用于将该类型的值文字地包含到程序的文本中。Java 还定义了几种特殊引用类型的文字语法,如下所述。

字符串字面量

String类将文本表示为字符串。因为程序通常通过书面文字与用户进行交流,所以在任何编程语言中,操作文本字符串的能力非常重要。在 Java 中,字符串是对象;用于表示文本的数据类型是String类。现代 Java 程序通常使用比其他任何数据类型都更多的字符串数据。

因此,由于字符串是如此基础的数据类型,Java 允许您以两种格式之一直接在程序中包含文本。传统字符串放置在双引号(")字符之间,或者可以在三个双引号字符序列(""")之间使用较新的文本块形式。

传统的双引号字符串看起来像这样:

String name = "David";
System.out.println("Hello, " + name);

不要将包围字符串字面量的双引号字符与包围char字面量的单引号(或撇号)字符混淆。

任何一种形式的字符串字面量都可以包含char字面量可以使用的任何转义序列(见表 2-2)。传统的双引号字符串需要转义序列来嵌入双引号字符或换行符。它们还必须在我们的 Java 代码中是单行的。例如:

String story = "\t\"How can you stand it?\" he asked sarcastically.\n";

文本块的主要用途而不是传统字符串是表示多行字符串。文本块以"""开头,后跟换行符,并在遇到结尾的"""时结束。

除了支持多行字符串外,文本块还允许我们在不转义的情况下使用双引号。这通常使得文本块在阅读时更加容易,特别是在我们的 Java 代码中表达另一种编程语言(如 SQL 或 HTML)时。

String html = """
 <html>
 <body class="main-body">
 ...
 </body>
 </html>""";
System.out.println(html);

从这段代码的输出中可以看出文本块关于缩进的另一个有趣事实。上述内容在输出的第一列中打印<html>,没有前导空格。

编译器找到文本块各行中的最小缩进,并从每行中剥离相同数量的前导空格。如果不希望这样做,闭合的"""的放置位置也参与选择缩进。我们可以通过以下方式保留完整的空白:

String html = """
 <html>
 <body class="main-body">
 ...
 </body>
 </html>
""";  // As smallest indent (0), this leaves the text block as written

System.out.println(html);

在 Java 引入文本块之前,通常使用+将字符串字面量拆分为更易读的部分。与现有的许多代码库一样,如果您的字符串不应包含换行符,这仍然是一种有效的技术。

// This is illegal
// Traditional string literals cannot break across lines.
String x = "This is a test of the
 emergency broadcast system";

// Common before text blocks
// Still useful if avoiding newlines in the text
String s = "This is a test of the " +
           "emergency broadcast system";

当您的程序编译时,无论是传统文字块还是文字块,文字都会被连接起来,而不是在运行时,因此您不必担心任何性能损失的问题。

类型字面量

支持其特殊对象文字语法的第二类是名为Class的类。Class类的实例表示 Java 数据类型,并包含有关所引用类型的元数据。要在 Java 程序中直接包含Class对象,请在任何数据类型的名称后跟.class。例如:

Class<?> typeInt = int.class;
Class<?> typeIntArray = int[].class;
Class<?> typeAccount = Account.class;

空引用

null关键字是一个特殊的字面量值,它是对空值的引用,或者说是引用的缺失。null值之所以独特,是因为它是每种引用类型的成员。您可以将null赋给任何引用类型的变量。例如:

String s = null;
Account a = null;

Lambda 表达式

Java 8 引入了一个重要的新功能——lambda 表达式。这些是非常常见的编程语言构造,特别是在被称为函数式编程语言的语言家族中广泛使用(例如,Lisp,Haskell 和 OCaml)。Lambda 的强大和灵活性远远超出了仅仅在函数式语言中,它们几乎可以在所有现代编程语言中找到应用。

Lambda 表达式的语法如下:

( *`paramlist`* ) -> { *`statements`* }

一个简单而非常传统的例子:

Runnable r = () -> System.out.println("Hello World");

当 lambda 表达式用作值时,它会自动转换为正确类型的新对象,以便放入变量中。这种自动转换和类型推断对于 Java 的 lambda 表达式方法至关重要。不幸的是,它依赖于对 Java 类型系统作为整体的正确理解。"嵌套类型"提供了对 lambda 表达式的更详细解释——因此,现在简单地认识 lambda 的语法就足够了。

一个稍微复杂的例子:

ActionListener listener = (e) -> {
  System.out.println("Event fired at: "+ e.getWhen());
  System.out.println("Event command: "+ e.getActionCommand());
};

数组

数组是一种特殊类型的对象,它保存零个或多个原始值或引用。这些值保存在数组的元素中,这些元素是由其位置或索引引用的未命名变量。数组的类型由其元素类型所特征化,并且数组的所有元素都必须是该类型的。

数组元素从零开始编号,有效索引范围从零到元素数量减一。例如,索引为 1 的数组元素是数组中的第二个元素。数组的元素数量是其length。数组的长度在创建数组时指定,且永远不会改变(不像 Java 集合,在第八章中我们将会看到)。

数组的元素类型可以是任何有效的 Java 类型,包括数组类型。这意味着 Java 支持数组的数组,提供了一种多维数组的能力。Java 不支持某些语言中的矩阵式多维数组。

尽管 Java 的集合 API 在第八章中得到了全面的覆盖,通常比基本数组更灵活和功能丰富,但数组在整个平台上仍然很常见,值得了解其详细使用细节。

数组类型

数组类型是引用类型,就像类一样。数组的实例是对象,就像类的实例一样。⁴ 与类不同,数组类型不必被定义。只需在元素类型后面放置方括号即可。例如,以下代码声明了三个数组类型的变量:

byte b;                        // byte is a primitive type
byte[] arrayOfBytes;           // byte[] is an array of byte values
byte[][] arrayOfArrayOfBytes;  // byte[][] is an array of byte[]
String[] strings;              // String[] is an array of strings

数组的长度不是数组类型的一部分。例如,不可能声明一个期望恰好有四个int值的数组的方法。如果方法参数是int[]类型,调用者可以传递包括零在内的任意数量的元素的数组。

数组类型不是类,但数组实例是对象。这意味着数组继承了java.lang.Object的方法。数组实现了Cloneable接口,并重写了clone()方法以确保数组始终可以被克隆,并且clone()永远不会抛出CloneNotSupportedException异常。数组还实现了Serializable接口,因此如果其元素类型可以序列化,任何数组都可以被序列化。最后,所有数组都有一个名为lengthpublic final int字段,指定数组中元素的数量。

数组类型扩展转换

因为数组扩展了Object并实现了CloneableSerializable接口,任何数组类型都可以扩展到这三种类型中的任何一种。但某些数组类型也可以扩展到其他数组类型。如果数组的元素类型是引用类型T,并且T可以分配给类型S,则数组类型T[]可以分配给数组类型S[]。请注意,对于给定原始类型的数组,没有此类扩展转换。例如,以下代码行展示了合法的数组扩展转换示例:

String[] arrayOfStrings;      // Created elsewhere
int[][] arrayOfArraysOfInt;   // Created elsewhere

// String is assignable to Object,
// so String[] is assignable to Object[]
Object[] oa = arrayOfStrings;

// String implements Comparable, so a String[] can
// be considered a Comparable[]
Comparable[] ca = arrayOfStrings;

// An int[] is an Object, so int[][] is assignable to Object[]
Object[] oa2 = arrayOfArraysOfInt;

// All arrays are cloneable, serializable Objects
Object o = arrayOfStrings;
Cloneable c = arrayOfArraysOfInt;
Serializable s = arrayOfArraysOfInt[0];

这种将数组类型扩展到另一个数组类型的能力意味着数组的编译时类型并不总是与其运行时类型相同。

提示

这种扩展称为数组协变性,正如我们将在“有界类型参数”中看到的,根据现代标准,它被视为历史遗留和误操作,因为它暴露了编译时和运行时类型之间的不匹配。

编译器通常必须在将引用值存储到数组元素之前插入运行时检查,以确保值的运行时类型与数组元素的运行时类型匹配。如果运行时检查失败,则抛出ArrayStoreException

C 兼容性语法

正如我们所见,通过在元素类型后放置方括号,您可以简单地编写数组类型。但是,为了与 C 和 C++兼容,Java 还支持变量声明中的另一种语法:方括号可以放置在变量名称后,而不是或者除了元素类型之外。这适用于局部变量、字段和方法参数。例如:

// This line declares local variables of type int, int[] and int[][]
int justOne, arrayOfThem[], arrayOfArrays[][];

// These three lines declare fields of the same array type:
public String[][] aas1;   // Preferred Java syntax
public String aas2[][];   // C syntax
public String[] aas3[];   // Confusing hybrid syntax

// This method signature includes two parameters with the same type
public static double dotProduct(double[] x, double y[]) { ... }
提示

这种兼容性语法非常罕见,不建议使用。

创建和初始化数组

要在 Java 中创建数组值,您使用new关键字,就像创建对象一样。数组类型没有构造函数,但在创建数组时必须指定长度。将所需的数组大小指定为方括号内的非负整数:

// Create a new array to hold 1024 bytes
byte[] buffer = new byte[1024];
// Create an array of 50 references to strings
String[] lines = new String[50];

当使用此语法创建数组时,每个数组元素都会自动初始化为类字段使用的相同默认值:布尔元素为false,字符元素为\u0000,整数元素为0,浮点数元素为0.0,引用类型的元素为null

数组创建表达式也可用于创建和初始化数组的多维数组。这种语法稍微复杂,并且在本节后面有详细解释。

数组初始化器

要创建一个数组并在单个表达式中初始化其元素,省略数组长度,然后在方括号后跟随用逗号分隔的表达式列表,这些表达式在花括号内。每个表达式的类型必须可分配给数组元素类型,当然,所创建的数组的长度等于表达式的数量。在列表中的最后一个表达式后面包含尾逗号是合法的但不是必需的。例如:

String[] greetings = new String[] { "Hello", "Hi", "Howdy" };
int[] smallPrimes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, };

请注意,此语法允许创建、初始化和使用数组,而无需将其分配给变量。在某种意义上,这些数组创建表达式是匿名数组字面量。以下是示例:

// Call a method, passing an anonymous array literal that
// contains two strings
String response = askQuestion("Do you want to quit?",
                               new String[] {"Yes", "No"});

// Call another method with an anonymous array (of anonymous objects)
double d = sumAccounts(new Account[] { new Account("1st", 100.0, 1),
                                       new Account("2nd", 200.0, 2),
                                       new Account("3rd", 300.0, 3) });

当数组初始化器是变量声明的一部分时,您可以省略new关键字、元素类型并在花括号内列出所需的数组元素:

String[] greetings = { "Hello", "Hi", "Howdy" };
int[] powersOfTwo = {1, 2, 4, 8, 16, 32, 64, 128};

在程序运行时创建和初始化数组文字,而不是在编译程序时。考虑以下数组文字:

int[] perfectNumbers = {6, 28};

这被编译成等同于 Java 字节码的内容:

int[] perfectNumbers = new int[2];
perfectNumbers[0] = 6;
perfectNumbers[1] = 28;

Java 在运行时执行所有数组初始化的事实具有一个重要的推论。这意味着数组初始化器中的表达式可以在运行时计算,而不必是编译时常量。例如:

Account[] accounts = { findAccountById(1), findAccountById(2) };

使用数组

一旦数组被创建,您就可以开始使用它了。以下各节解释了对数组元素的基本访问,并涵盖了数组使用的常见习惯用法,例如遍历数组元素和复制数组或数组的一部分。

访问数组元素

数组的元素是变量。当数组元素出现在表达式中时,它会评估为元素中保存的值。当数组元素出现在赋值运算符的左侧时,会将新值存储到该元素中。然而,与普通变量不同,数组元素没有名称,只有一个数字。数组元素使用方括号表示法进行访问。如果a是一个评估为数组引用的表达式,那么您可以通过a[i]进行数组索引,并引用特定元素,其中i是一个整数字面值或一个评估为int的表达式。例如:

// Create an array of two strings
String[] responses = new String[2];
responses[0] = "Yes";  // Set the first element of the array
responses[1] = "No";   // Set the second element of the array

// Now read these array elements
System.out.println(question + " (" + responses[0] + "/" +
                   responses[1] + " ): ");

// Both the array reference and the array index may be more complex
double datum = data.getMatrix()[data.row() * data.numColumns() +
                   data.column()];

数组索引表达式必须是int类型,或者可以扩展为int的类型:byteshort,甚至char。显然,使用booleanfloatdouble值索引数组是不合法的。请记住,数组的length字段是一个int,并且数组的元素不得超过Integer.MAX_VALUE。使用long类型的表达式对数组进行索引会生成编译时错误,即使该表达式在运行时的值在int范围内也是如此。

数组边界

请记住,数组a的第一个元素是a[0],第二个元素是a[1],最后一个元素是a[a.length-1]

关于数组的一个常见 bug 是使用太小的索引(负索引)或太大的索引(大于或等于数组长度)。在像 C 或 C++这样的语言中,访问数组开始之前或结束之后的元素会产生不可预测的行为,这种行为可能会因调用方式和平台而异。这样的 bug 可能并不总是被捕获到,如果发生故障,则可能会在稍后的某个时间发生。虽然在 Java 中编写有错误的数组索引代码同样容易,但 Java 通过在运行时检查每次数组访问来保证可预测的结果。如果数组索引太小或太大,Java 会立即抛出一个ArrayIndexOutOfBoundsException

迭代数组

常见的做法是编写循环,按顺序遍历数组的每个元素,以执行某些操作。这通常使用for循环来完成。例如,以下代码计算整数数组的总和:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
int sumOfPrimes = 0;
for(int i = 0; i < primes.length; i++)
    sumOfPrimes += primes[i];

for循环的结构是惯用的,您将经常看到它。Java 还具有我们已经遇到的 foreach 语法。求和代码可以简洁地重写为:

for(int p : primes) sumOfPrimes += p;

复制数组

所有数组类型都实现了 Cloneable 接口,可以通过调用其 clone() 方法进行复制。需要注意的是,需要将返回值强制转换为适当的数组类型,但数组的 clone() 方法保证不会抛出 CloneNotSupportedException

int[] data = { 1, 2, 3 };
int[] copy = data.clone();

clone() 方法生成一个浅拷贝。如果数组的元素类型是引用类型,则只复制引用,而不是引用对象本身。由于是浅拷贝,任何数组都可以被克隆,即使元素类型本身不是 Cloneable

有时你只是想将一个现有数组的元素复制到另一个现有数组中。System.arraycopy() 方法旨在高效地完成此操作,并且可以假定 Java VM 实现会使用基础硬件上的高速块复制操作执行此方法。

arraycopy() 是一个简单的函数,唯一难以使用的地方在于需要记住五个参数。首先,传递要复制元素的源数组。其次,传递该数组中起始元素的索引。作为第三和第四个参数,传递目标数组和目标索引。最后,作为第五个参数,指定要复制的元素数量。

arraycopy() 即使在同一数组内进行重叠复制也能正确工作。例如,如果你从数组 a 中“删除”了索引为 0 的元素,并希望将索引在 1n 之间的元素向下移动一个位置,使它们占据索引 0n-1,你可以这样做:

System.arraycopy(a, 1, a, 0, n);

数组工具

java.util.Arrays 类包含许多用于处理数组的静态实用方法。大多数这些方法都有重载版本,用于每种基本类型的数组以及对象数组的另一个版本。

sort()binarySearch() 方法特别适用于对数组进行排序和搜索。equals() 方法允许你比较两个数组的内容。当你希望将数组内容转换为字符串(例如用于调试或记录输出)时,toString() 方法非常有用。如果你可以接受分配新数组而不是将其复制到现有数组中,copyOf() 是我们之前看到的 arraycopy() 的一个有用的替代方法。

Arrays 类还包括 deepEquals()deepHashCode()deepToString() 方法,适用于多维数组并能正确工作。

多维数组

正如我们所见,数组类型写作元素类型后跟一对方括号。char 类型的数组是 char[]char 数组的数组是 char[][]。当数组的元素本身是数组时,我们称该数组为 多维数组。为了处理多维数组,你需要了解一些额外的细节。

想象一下,你想使用多维数组来表示一个乘法表:

int[][] products;      // A multiplication table

每对方括号表示一个维度,因此这是一个二维数组。要访问这个二维数组中的单个int元素,必须指定两个索引值,一个用于每个维度。假设该数组实际上被初始化为一个乘法表,那么存储在任何给定元素处的int值将是两个索引的乘积。也就是说,products[2][4]将是 8,而products[3][7]将是 21。

要创建一个新的多维数组,使用new关键字并指定数组的两个维度的大小。例如:

int[][] products = new int[10][10];

在某些语言中,像这样的数组会创建为 100 个int值的单个块。但 Java 不是这样工作的。这行代码执行三件事:

  • 声明一个名为products的变量,用于保存一个int数组的数组。

  • 创建一个包含 10 个元素的数组,用于保存 10 个int数组。

  • 创建 10 个新数组,每个数组都是一个包含 10 个int元素的数组。它将这 10 个新数组分配给初始数组的元素。每个这 10 个新数组的每个int元素的默认值都是 0。

换句话说,前一行代码相当于以下代码:

int[][] products = new int[10][]; // An array to hold 10 int[] values
for(int i = 0; i < 10; i++)      // Loop 10 times...
    products[i] = new int[10];   // ...and create 10 arrays

new关键字自动为您执行此附加初始化。它也适用于超过两个维度的数组:

float[][][] globalTemperatureData = new float[360][180][100];

当使用new创建多维数组时,您不必为数组的所有维度指定大小,只需指定最左边的维度或维度。例如,以下两行是合法的:

float[][][] globalTemperatureData = new float[360][][];
float[][][] globalTemperatureData = new float[360][180][];

第一行创建一个单维数组,其中每个数组元素可以容纳一个float[][]。第二行创建一个二维数组,其中每个数组元素是一个float[]。然而,如果只为某些维度指定了大小,则这些维度必须是最左边的。以下行不合法:

float[][][] globalTemperatureData = new float[360][][100];  // Error!
float[][][] globalTemperatureData = new float[][180][100];  // Error!

像一维数组一样,可以使用数组初始化器初始化多维数组。只需使用嵌套的花括号来嵌套数组。例如,我们可以像这样声明、创建和初始化一个 5×5 的乘法表:

int[][] products = { {0, 0, 0, 0, 0},
                     {0, 1, 2, 3, 4},
                     {0, 2, 4, 6, 8},
                     {0, 3, 6, 9, 12},
                     {0, 4, 8, 12, 16} };

或者,如果要使用多维数组而不声明变量,可以使用匿名初始化器语法:

boolean response = bilingualQuestion(question, new String[][] {
                                                   { "Yes", "No" },
                                                   { "Oui", "Non" }});

当使用new关键字创建多维数组时,通常最好使用矩形数组:即每个维度的所有数组值都具有相同的大小。

引用类型

现在我们已经讨论了数组并引入了类和对象,我们可以转向对引用类型的更一般描述。类和数组是 Java 的五种引用类型之一。类在前面介绍过,并且详细介绍了接口,在第三章中进行了详细介绍。枚举类型和注解类型是在第四章中引入的引用类型。

本节不涵盖任何特定引用类型的具体语法,而是解释引用类型的一般行为,并说明它们与 Java 的原始类型的区别。在本节中,“对象”一词指的是任何引用类型的值或实例,包括数组。

引用类型与原始类型

引用类型和对象与原始类型及其原始值有很大的不同:

Java 语言定义了八种原始类型,程序员不能定义新的原始类型。

引用类型是用户定义的,因此它们的数量是无限的。例如,程序可以定义一个名为Account的类,并使用这种新定义类型的对象来存储和跟踪用户的银行账户。

原始类型表示单个值。

引用类型是聚合类型,可以持有零个或多个原始值或对象。例如,我们假设的Account类可能持有一个用于余额的数值,以及用于账户所有者的标识符。char[]Account[]数组类型是聚合类型,因为它们持有一系列原始char值或Account对象。

原始类型需要 1 到 8 字节的内存。

当原始值存储在变量中或传递给方法时,计算机会复制持有值的字节。另一方面,对象可能需要更多内存。对象的存储空间在对象创建时动态分配在堆上,并且当对象不再需要时,此存储空间会自动进行“垃圾收集”。

提示

当对象被分配给变量或传递给方法时,并不会复制表示对象的内存。相反,只会存储对该内存的引用在变量中或传递给方法。

在 Java 中,引用完全是不透明的,引用的表示是 Java 运行时的实现细节。然而,如果你是 C 程序员,可以安全地将引用想象为指针或内存地址。请记住,Java 程序不能以任何方式操作引用。

与 C 和 C++中的指针不同,Java 中的引用不能转换为或从整数转换,并且不能递增或递减。C 和 C++程序员还应注意,Java 不支持&取地址运算符或*->解引用运算符。

操纵对象和引用副本

以下代码操作一个原始的int值:

int x = 42;
int y = x;

执行这些行之后,变量y包含了变量x中持有值的副本。在 Java 虚拟机内部,有两个独立的 32 位整数 42 的副本。

现在想象一下,如果我们运行相同的基本代码,但使用引用类型而不是原始类型会发生什么:

Account a = new Account("Jason", 0.0, 42);
Account b = a;

代码运行后,变量b保存了变量a中保存的引用的副本。在虚拟机中仍然只有一个Account对象的拷贝,但现在有两个引用指向该对象。这有一些重要的含义。假设前两行代码后面跟着这段代码:

System.out.println(a.balance);  // Print out balance of a: 0.0
b.balance = 13.0;               // Now change balance of b
System.out.println(a.balance);  // Print a's balance again: 13.0

因为变量ab保存对同一对象的引用,因此可以使用任一变量来对对象进行更改,并且这些更改也会通过另一个变量可见。由于数组是一种对象,因此对数组也会发生同样的情况,如下面的代码所示:

// greet holds an array reference
char[] greet = { 'h','e','l','l','o' };
char[] cuss = greet;             // cuss holds the same reference
cuss[4] = '!';                   // Use reference to change an element
System.out.println(greet);       // Prints "hell!"

在将参数传递给方法时,原始类型和引用类型之间的行为也存在类似的差异。考虑以下方法:

void changePrimitive(int x) {
    while(x > 0) {
        System.out.println(x--);
    }
}

当调用此方法时,方法会获得用于调用方法的参数的私有副本,该参数保存在参数x中。方法中的代码将x用作循环计数器,并将其递减到零。由于x是原始类型,方法有自己的私有副本,因此这是完全合理的操作。

另一方面,考虑如果修改方法使参数成为引用类型会发生什么:

void changeReference(Account b) {
    while (b.balance > 0) {
        System.out.println(b.balance--);
    }
}

当调用此方法时,会传递一个对Account对象的私有引用副本,并可以使用此引用来改变Account对象。例如,考虑:

Account a = new Account("Jason", 3.0, 42);  // Account balance: 3.0
changeReference(a);             // Prints 3,2,1 and modifies the Account
System.out.println(a.balance);  // The balance of a is now 0!

当调用changeReference()方法时,会传递变量a中保存的引用的副本给方法。现在变量a和方法参数b都保存着指向同一对象的引用。方法可以使用它的引用来改变对象的内容。但请注意,它不能改变变量a的内容。换句话说,方法可以彻底改变Account对象,但不能改变变量a引用该对象的事实。

比较对象

我们已经看到,原始类型和引用类型在赋值给变量、传递给方法和复制时存在显著差异。这些类型在比较相等性时也存在差异。当与原始值一起使用时,等号操作符(==)简单地测试两个值是否相同(即它们是否具有完全相同的位)。然而,与引用类型一起使用时,==比较的是引用,而不是实际对象。换句话说,==测试两个引用是否引用同一对象;它不测试两个对象是否具有相同的内容。以下是一个例子:

String letter = "o";
String s = "hello";              // These two String objects
String t = "hell" + letter;      // contain exactly the same text.
if (s == t) System.out.println("equal"); // But they are not equal!

byte[] a = { 1, 2, 3 };
// A copy with identical content.
byte[] b = (byte[]) a.clone();
if (a == b) System.out.println("equal"); // But they are not equal!

当使用引用类型时,请记住有两种相等性:引用相等性和对象相等性。重要的是要区分这两种相等性。在谈论引用相等性时,一种方法是使用“相同”这个词,而在谈论拥有相同内容的两个不同对象时,使用“相等”。要测试两个非相同对象的相等性,将其中一个传递给另一个的equals()方法即可:

String letter = "o";
String s = "hello";              // These two String objects
String t = "hell" + letter;      // contain exactly the same text.
if (s.equals(t)) {               // And the equals() method
    System.out.println("equal"); // tells us so.
}

所有对象都继承了一个equals()方法(来自Object),但默认实现只是使用==来测试引用的身份,而不是内容的相等性。想要允许对象比较相等性的类可以定义自己版本的equals()方法。我们的Account类没有这样做,但String类做了,正如代码示例中所示。你可以在数组上调用equals()方法,但它与使用==运算符相同,因为数组始终继承默认的equals()方法,该方法比较引用而不是数组内容。你可以使用java.util.Arrays.equals()便捷方法来比较数组的相等性。

装箱和拆箱转换

原始类型和引用类型行为大不相同。有时将原始值视为对象是有用的,因此 Java 平台为每个原始类型包括一个包装类BooleanByteShortCharacterIntegerLongFloatDouble是不可变的、终态的类,它们的每个实例都持有单个原始值。当你想要在诸如java.util.List之类的集合中存储原始值时,通常使用这些包装类:

// Create a List-of-Integer collection
List<Integer> numbers = new ArrayList<>();
// Store a wrapped primitive
numbers.add(Integer.valueOf(-1));
// Extract the primitive value
int i = numbers.get(0).intValue();

Java 允许称为装箱和拆箱转换的类型转换。装箱转换将原始值转换为其对应的包装对象,而拆箱转换则相反。你可以在变量赋值或将值传递给方法时显式指定装箱或拆箱转换,但这是不必要的,因为当你将值分配给变量或将值传递给方法时,这些转换会自动进行。此外,如果在 Java 运算符或语句期望原始值时使用包装对象,则拆箱转换也是自动的。由于 Java 自动执行装箱和拆箱,这种语言特性通常称为自动装箱

这里是一些自动装箱和拆箱转换的示例:

Integer i = 0;   // int literal 0 boxed to an Integer object
Number n = 0.0f; // float literal boxed to Float and widened to Number
Integer i = 1;   // this is a boxing conversion
int j = i;       // i is unboxed here
i++;             // i is unboxed, incremented, and then boxed up again
Integer k = i+2; // i is unboxed and the sum is boxed up again
i = null;
j = i;           // unboxing here throws a NullPointerException

自动装箱使得处理集合变得更加容易。让我们看一个使用 Java 的泛型(我们将在“Java 泛型”中详细了解的语言特性)的示例,它允许我们限制可以放入列表和其他集合的类型:

List<Integer> numbers = new ArrayList<>(); // Create a List of Integer
numbers.add(-1);                           // Box int to Integer
int i = numbers.get(0);                    // Unbox Integer to int

包和 Java 命名空间

是一组命名的类、接口和其他引用类型。包用于组织相关类并为其包含的类定义命名空间。

Java 平台的核心类位于以java开头的包中。例如,语言的最基本类位于java.lang包中。各种实用类位于java.util中。输入和输出类位于java.io中,网络类位于java.net中。一些包含子包,如java.lang.reflectjava.util.regex。Oracle(或最初的 Sun)标准化的 Java 平台扩展通常具有以javax开头的包名。其中一些扩展,如javax.swing及其众多子包,后来被纳入核心平台。最后,Java 平台还包括几个“认可标准”,这些标准有以标准化组织名称命名的包,如org.w3corg.omg

每个类都有一个简单名称,在其定义中给出,以及一个完全限定名,其中包括其所属包的名称。例如,String类属于java.lang包,因此其完全限定名为java.lang.String

本节解释了如何将自己的类和接口放入包中,以及如何选择不会与其他包名冲突的包名。接下来,它解释了如何选择性地导入类型名称或静态成员到命名空间,以便您不必为每个使用的类或接口输入包名。

包声明

要指定类所属的包,您使用一个package声明。如果出现package关键字,则必须是 Java 代码中的第一个标记(即除注释和空格外的第一件事)。关键字后面应跟所需包的名称和一个分号。考虑一个以此指令开头的 Java 文件:

package org.apache.commons.net;

此文件定义的所有类都属于包org.apache.commons.net

如果在 Java 文件中没有package指令,那么该文件中定义的所有类都属于一个未命名的默认包。在这种情况下,类的限定名和非限定名是相同的。

提示

命名冲突的可能性意味着不应使用默认包。随着项目变得更加复杂,冲突几乎是不可避免的——最好从一开始就创建包。

全局唯一包名

包的重要功能之一是分隔 Java 命名空间,防止类之间的名称冲突。例如,只有它们的包名使得java.util.Listjava.awt.List类不同。但为了使此机制生效,包名必须是唯一的。作为 Java 的开发者,Oracle 控制着所有以javajavaxsun开头的包名。

一个常见的方案是使用您的域名,将其元素反转,作为所有包名称的前缀。例如,Apache 项目作为 Apache Commons 项目的一部分生产了一个网络库。Commons 项目可以在 http://commons.apache.org 找到,因此用于网络库的包名称是 org.apache.commons.net

请注意,这些包命名规则主要适用于 API 开发人员。如果其他程序员将使用您开发的类以及未知的其他类,则确保您的包名称在全球范围内是唯一的非常重要。另一方面,如果您正在开发一个 Java 应用程序,并且不会释放任何类供他人重用,那么您知道您的应用程序将部署的完整类集,并且无需担心意外的命名冲突。在这种情况下,您可以选择一个适合自己方便而不是全局唯一性的包命名方案。一个常见的方法是使用应用程序名称作为主要包名称(可以有其下的子包)。

导入类型

在 Java 代码中引用类或接口时,默认情况下必须使用类型的完全限定名称,包括包名称。如果您编写用于操作文件并且需要使用 java.io 包中的 File 类的代码,则必须输入 java.io.File。此规则有三个例外:

  • java.lang 包中的类型非常重要且常用,因此它们可以始终通过其简单名称引用。

  • 类型 p.T 中的代码可以通过其简单名称引用包 p 中定义的其他类型。

  • 使用 import 声明导入命名空间的类型可以通过其简单名称引用。

前两个例外被称为“自动导入”。java.lang 中的类型和当前包中的类型被“导入”到命名空间中,以便可以在不使用其包名称的情况下使用它们。快速输入不在 java.lang 或当前包中的常用类型的包名称会变得乏味,因此还可以明确地从其他包中导入类型到命名空间中。使用 import 声明完成此操作。

在 Java 文件中,import 声明必须出现在 package 声明(如果有的话)之后,任何类型定义之前的开头。您可以在文件中使用任意数量的 import 声明。一个 import 声明适用于文件中的所有类型定义(但不适用于其后的任何 import 声明)。

import 声明有两种形式。要将单个类型导入命名空间,请在 import 关键字后跟类型名称和分号:

import java.io.File;    // Now we can type File instead of java.io.File

这被称为“单类型导入”声明。

另一种import声明形式是“按需类型import”。在此形式中,您指定包的名称,后跟.*字符,以指示可以使用该包中的任何类型而无需其包名称。因此,如果您想要除File类之外的java.io包中的几个其他类,您可以简单地导入整个包:

import java.io.*;   // Use simple names for all classes in java.io

此按需import语法不适用于子包。如果我导入java.util包,我仍然必须通过其完全限定名或导入来引用java.util.zip.ZipInputStream类。

使用按需类型import声明与为包中的每个类型显式编写单个类型import声明并不相同。它更像是针对代码中实际使用的包中每个类型的显式单个类型import。这就是称为“按需”的原因;类型在使用时导入。

命名冲突和遮蔽

import声明对 Java 编程非常宝贵。然而,它们确实使我们面临命名冲突的可能性。考虑java.utiljava.awt包。两者都包含名为List的类型。

java.util.List是一个重要且常用的接口。java.awt包含许多在客户端应用程序中常用的重要类型,但java.awt.List已过时,不是这些重要类型之一。在同一个 Java 文件中导入java.util.Listjava.awt.List是非法的。以下单个类型import声明会产生编译错误:

import java.util.List;
import java.awt.List;

使用两个包的按需类型导入是合法的:

import java.util.*;  // For collections and other utilities.
import java.awt.*;   // For fonts, colors, and graphics.

然而,如果您确实尝试使用类型List,则会出现困难。此类型可以从任一包中“按需”导入,并且任何尝试将List作为未限定类型名称使用都会产生编译错误。在这种情况下的解决方法是明确指定您想要的包名称。

因为java.util.Listjava.awt.List更常用,将这两个按需类型import声明与单个类型import声明结合起来以消除我们在说List时的歧义是有用的:

import java.util.*;    // For collections and other utilities.
import java.awt.*;     // For fonts, colors, and graphics.
import java.util.List; // To disambiguate from java.awt.List

有了这些import声明,我们可以使用List来表示java.util.List接口。如果我们确实需要使用java.awt.List类,只要包括其包名称,我们仍然可以这样做。在java.utiljava.awt之间没有其他命名冲突,并且在使用时,它们的类型将被“按需”导入而无需包名称。

导入静态成员

除了类型,您还可以使用关键字import static导入类型的静态成员。(静态成员在第三章中有解释。如果您对它们还不熟悉,可以稍后再回到本节。)与类型import声明类似,这些静态import声明有两种形式:单个静态成员import和按需静态成员import。例如,假设您正在编写一个向System.out发送大量输出的文本程序。在这种情况下,您可以使用单个静态成员import来节省输入:

import static java.lang.System.out;

然后,您可以使用out.println()代替System.out.println()。或者假设您正在编写一个使用许多Math类的三角和其他函数的程序。在这种明显专注于数值方法的程序中,反复输入类名Math并没有增加代码的清晰度;它只是妨碍了代码的编写。在这种情况下,按需静态成员import可能是合适的:

import static java.lang.Math.*

使用此import声明,您可以自由地编写像sqrt(abs(sin(x)))这样简洁的表达式,而无需为每个静态方法名称添加类名Math的前缀。

另一个import static声明的重要用途是将常量的名称导入到您的代码中。这在枚举类型中特别有效(参见第四章)。例如,假设您希望在编写的代码中使用此枚举类型的值:

package climate.temperate;
enum Seasons { WINTER, SPRING, SUMMER, AUTUMN };

您可以导入类型climate.temperate.Seasons,然后使用类型名称前缀常量:Seasons.SPRING。为了更简洁的代码,您可以直接导入枚举值本身:

import static climate.temperate.Seasons.*;

对于常量,使用静态成员import声明通常比实现定义常量的接口更好。

静态成员导入和重载方法

静态import声明导入的是一个名称,而不是具有该名称的任何特定成员。因为 Java 允许方法重载并允许类型具有相同名称的字段和方法,单个静态成员import声明实际上可能导入多个成员。考虑以下代码:

import static java.util.Arrays.sort;

此声明将名称sort导入命名空间,而不是java.util.Arrays定义的 19 个sort()方法中的任何一个。如果您使用导入的名称sort调用方法,则编译器将查看方法参数的类型以确定您指的是哪个方法。

即使是合法的,也可以从两个或更多不同类型中导入具有相同名称的静态方法,只要这些方法都具有不同的签名。这里有一个自然的例子:

import static java.util.Arrays.sort;
import static java.util.Collections.sort;

你可能会期望这段代码会导致语法错误。事实上,它不会,因为Collections类定义的sort()方法的签名与Arrays类定义的所有sort()方法的签名不同。当你在代码中使用“sort”这个名字时,编译器会查看参数的类型来确定你是指哪一个 21 个可能的导入方法中的哪一个。

Java 源文件结构

本章带我们从 Java 语法的最小元素到最大的元素,从单个字符和标记到运算符、表达式、语句和方法,再到类和包。从实际角度来看,您最常处理的 Java 程序结构单元是 Java 文件。Java 文件是 Java 代码中可以被 Java 编译器编译的最小单元。一个 Java 文件由以下内容组成:

  • 一个可选的package指令

  • 零个或多个importimport static指令

  • 一个或多个类型定义

这些元素当然可以与注释交替出现,但它们必须按照这个顺序出现。这就是一个 Java 文件的全部内容。所有 Java 语句(除了packageimport指令,这些不是真正的语句,以及我们将在第十二章中讨论的专用模块描述符)必须出现在方法中,所有方法必须出现在类型定义中。

一个名为module-info.java的特殊 Java 文件仅用于声明我们在模块化 Java 应用程序中的包的结构和可见性。这些更高级的技术和语法在第十二章中有详细介绍。

Java 文件有一些其他重要的限制。首先,每个文件最多只能包含一个顶级public类。一个public类是为其他包中的类使用而设计的。一个类可以包含任意数量的public嵌套或内部类。我们将在第三章中更多地了解public修饰符和嵌套类。

第二个限制涉及 Java 文件的文件名。如果一个 Java 文件包含一个public类,文件名必须与类名相同,后缀为*.java*。因此,如果Account定义为一个public类,它的源代码必须出现在名为Account.java的文件中。无论你的类是否public,良好的编程习惯是每个文件只定义一个类,并给文件起一个与类名相同的名字。

当 Java 文件编译时,它定义的每个类都会编译成一个单独的class文件,其中包含 Java 字节码,由 Java 虚拟机执行。类文件的名称与其定义的类名称相同,并附加扩展名*.class*。因此,如果文件Account.java定义了一个名为Account的类,Java 编译器将其编译为名为Account.class的文件。在大多数系统上,类文件存储在与其包名称对应的目录中。例如,类com.davidflanagan.examples.Account由类文件com/davidflanagan/examples/Account.class定义。

Java 运行时知道标准系统类的类文件位于何处,并且可以根据需要加载它们。当解释器运行一个想要使用名为com.davidflanagan.examples.Account的类的程序时,它知道该类的代码位于名为com/davidflanagan/examples/的目录中,并且默认情况下会在当前目录中查找该名称的子目录。为了告诉解释器查找除当前目录以外的位置,您必须在调用解释器时使用-classpath选项或设置CLASSPATH环境变量。有关详细信息,请参阅 Java 可执行文件java的文档,位于第十三章中。

定义和运行 Java 程序

一个 Java 程序由一组互动的类定义组成。但并非每个 Java 类或 Java 文件都定义了一个程序。要创建一个程序,您必须定义一个具有以下签名的特殊方法的类:

public static void main(String[] args)

这个main()方法是程序的主入口点。Java 解释器从这里开始运行。此方法接收一个字符串数组并且不返回任何值。当main()方法返回时,Java 解释器退出(除非main()创建了单独的线程,在这种情况下,解释器会等待所有这些线程退出)。

要运行 Java 程序,您运行 Java 可执行文件java,指定包含main()方法的类的完全限定名称。请注意,您指定类的名称,而不是包含该类的类文件的名称。您在命令行上指定的任何其他参数都将作为其String[]参数传递给main()方法。您可能还需要指定-classpath选项(或-cp)以告知解释器程序所需的类的查找位置。考虑以下命令:

java -classpath /opt/Jude com.davidflanagan.jude.Jude datafile.jude

java是运行 Java 解释器的命令。-classpath /opt/Jude告诉解释器在哪里查找*.class*文件。com.davidflanagan.jude.Jude是要运行的程序的名称(即定义了main()方法的类的名称)。最后,datafile.jude是作为String对象数组的单个元素传递给main()方法的字符串。

运行程序有一种更简单的方法。如果程序及其所有辅助类(除了那些属于 Java 平台的类)已经被正确打包在一个 Java 归档(JAR)文件中,您只需指定 JAR 文件的名称即可运行该程序。在下一个示例中,我们展示如何启动日志分析器:

java -jar /usr/local/log-analyzer/log-analyzer.jar

一些操作系统可以自动执行 JAR 文件。在这些系统上,您可以简单地说:

/usr/local/log-analyzer/log-analyzer.jar

Java 17 还引入了直接对源文件运行 java 的能力,类似于 Python 等脚本语言。您仍然必须定义一个与文件名匹配的类和一个 main() 方法,然后可以执行以下程序:

java MyClass.java

查看第十三章 获取有关如何执行 Java 程序的更多详细信息。

摘要

在本章中,我们介绍了 Java 语言的基本语法。由于编程语言语法的相互关联性,如果您现在感觉还没有完全掌握语言的所有语法,这是完全可以的。通过实践,我们才能掌握任何语言,无论是人类语言还是计算机语言。

值得注意的是,语法的某些部分比其他部分更常用。例如,strictfpassert 关键字几乎不被使用。与其试图掌握 Java 语法的每个方面,不如开始掌握 Java 核心方面的技能,然后再回到可能仍然困扰您的语法细节。考虑到这一点,让我们继续下一章,讨论 Java 中的类和对象,以及 Java 面向对象编程的基础知识。

¹ 技术上来说,减号是一个操作符,作用于字面量本身,而不是字面量的一部分。

² 技术上来说,它们都必须实现AutoCloseable接口。

³ 在 Java 语言规范中,“签名”一词有一个技术上的含义,与此处使用的含义略有不同。本书使用了方法签名的较少正式的定义。

⁴ 在数组的讨论中存在术语难度。与类及其实例不同,我们用“数组”一词来表示数组类型和数组实例。实际上,从上下文中通常可以清楚地知道是讨论类型还是值。