Java-学习指南第六版-全-

Java 学习指南第六版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

本书介绍了 Java 编程语言和环境。无论你是软件开发人员还是日常生活中使用互联网的人,你无疑都听说过 Java。它的到来是网络历史上最激动人心的发展之一,Java 应用程序继续推动互联网上的业务。Java 可以说是世界上最流行的编程语言,被数百万开发人员在几乎所有类型的计算机上使用。Java 已经超过了诸如 C++和 Visual Basic 等语言在开发人员需求方面,并已成为某些类型的开发的事实标准语言——尤其是网络服务方面。大多数大学现在在他们的入门课程中使用 Java,与其他重要的现代语言并列。也许你现在就在你的课堂上使用这本书!

本书全面介绍了 Java 基础知识和语法。学习 Java,第六版,试图实践其名,勾勒出 Java 语言及其类库、编程技巧和习惯用法。我们将深入研究有趣的领域,至少对其他热门主题进行初步了解。O'Reilly 的其他书籍将进一步提供更全面的 Java 特定领域和应用信息。

在可能的情况下,我们提供引人入胜、现实而有趣的例子,并避免仅仅罗列特性。这些例子简单明了,但暗示了可能的操作。我们不会在这些页面上开发下一个伟大的“杀手级应用”,但我们希望为你提供许多小时的实验和启发性的小玩意的起点,这将带领你自己开发一个。

读者对象

本书适用于计算机专业人士、学生、技术人员和芬兰黑客。对于所有需要动手实践使用 Java,并着眼于构建真实应用的人来说,本书都很有用。这本书也可以被视为面向对象编程、线程和用户界面的速成课程。当你了解 Java 时,你也将学习到一种强大而实用的软件开发方法,从 Java 基础知识的深入理解开始。

表面上看,Java 看起来像 C 或 C++,因此如果你对这些语言有一些经验,使用本书时你会有一点优势。如果没有,不用担心。在许多方面,Java 的行为类似于 Smalltalk 和 Lisp 等更动态的语言。了解其他面向对象的编程语言肯定会有帮助,尽管你可能需要改变一些想法并摒弃一些习惯。Java 比 C++和 Smalltalk 等语言简单得多。如果你善于从简明的例子和个人实验中学习,你会喜欢这本书。

新发展

我们涵盖了 Java 的最新“长期支持”版本的所有重要功能,官方称为 Java 标准版(SE)21,OpenJDK 21。Sun Microsystems(Java 在 Oracle 之前的所有者)多年来已多次更改了命名方案。Sun 创造了术语 Java 2 来涵盖 Java 版本 1.2 中引入的主要新功能,并放弃了 JDK 这个术语,取而代之的是 SDK。在第六个版本中,Sun 直接从 Java 版本 1.4 跳到 Java 5.0,但重新使用了 JDK 这个术语并保留了其编号惯例。此后,我们有了 Java 6、Java 7 和 Java 8。从 Java 9 开始,Oracle 宣布了一个常规(加速)的发布节奏。每年发布两次新版本,截至 2023 年我们处于 Java 21。

Java 的这个版本反映出一门成熟的语言,偶尔会有语法变化和包和库的更新。我们尝试捕捉这些新特性,并更新本书中的每个示例,以反映当前的 Java 风格和最佳实践。

本版本新增内容(Java 15、16、17、18、19、20、21)

本书的这一版本延续了我们尽可能保持最新的传统。它包含了来自 Java 的最新发布的变化,从 Java 15 到 Java 21(早期访问)的变化。本版本的新主题包括:

  • 虚拟线程使得在需要大量线程的情景中获得了令人印象深刻的性能提升

  • 新增对用于数据处理的函数流的覆盖

  • Lambda 表达式的扩展覆盖

  • 整本书的示例和分析都进行了更新

  • 每章的复习问题和练习,帮助加强讨论的主题。

使用本书

本书的组织结构如下:

  • 第一章和第二章为 Java 概念的基本介绍提供了一个入门教程,帮助你快速开始 Java 编程。

  • 第三章讨论了 Java 开发的基本工具(编译器、解释器、jshell 和 JAR 文件包)。

  • 第四章和第五章先介绍了编程基础,然后描述了 Java 语言本身,从基本语法开始,涵盖了类和对象、异常、数组、枚举、注解等等。

  • 第六章涵盖了 Java 中的异常、错误以及本地的日志记录设施。

  • 第七章涵盖了 Java 中的集合以及泛型和参数化类型。

  • 第八章涵盖了文本处理、格式化、扫描、字符串实用工具以及许多核心 API 实用工具。

  • 第九章涵盖了语言内置的线程设施,包括新的虚拟线程。

  • 第十章涵盖了 Java 文件 I/O 和 NIO 包。

  • 第十一章涵盖了 Java 中的函数编程技术。

  • 第十二章介绍了使用 Swing 开发图形用户界面 (GUI) 的基础知识。

  • 第十三章涵盖了客户端和服务器的网络通信以及访问网络资源的方法。

如果您和我们一样,您不会从头到尾地阅读一本书。如果您真的像我们一样,您通常根本不会阅读序言。但是,有可能您会及时看到这些信息,这里有几点建议:

  • 如果您已经是程序员,只需在接下来的五分钟学习 Java,您可能正在寻找示例。您可能想先看一下第二章中的教程。如果那不符合您的口味,至少应该看一下第三章中的信息,该章节解释了如何使用编译器和解释器。这应该可以帮助您入门。

  • 第十二章讨论了 Java 的图形功能和组件架构。如果您有兴趣编写桌面图形 Java 应用程序,您应该阅读此章节。

  • 第十三章是您如果对编写网络应用程序或与基于 Web 的服务进行交互感兴趣的地方。网络编程仍然是 Java 中更有趣和重要的部分之一。

在线资源

有许多关于 Java 的在线信息源。

查看Oracle 的官方网站获取有关 Java 软件、更新和 Java 发行版等内容。这里是 JDK 的参考实现,包括编译器、解释器和其他工具。

Oracle 也维护着OpenJDK 网站。这是 Java 及其相关工具的主要开源版本。本书中的所有示例都将使用 OpenJDK。

您还应该访问O’Reilly 网站。在那里,您将找到有关其他 Java 书籍及其他主题的信息。您还应该查看在线学习和会议选项——O’Reilly 是各种教育形式的真正支持者。

当然,您也可以查看Learning Java 的主页!

本书中使用的约定

本书中使用的字体约定非常简单。

斜体 用于:

  • 路径名、文件名和程序名

  • 互联网地址,如域名和 URL

  • 在定义新术语时使用

  • 程序名、编译器、解释器、实用程序和命令

  • 强调重要点

常量宽度 用于:

  • 可能出现在 Java 程序中的任何内容,包括方法名、变量名和类名

  • 在 HTML 或 XML 文档中可能出现的标签

  • 关键字、对象和环境变量

常量宽度粗体 用于:

  • 用户在命令行或对话框中输入的文本

常量宽度斜体 用于:

  • 代码中的可替换项
提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

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

在正文的主体中,我们始终在方法名后使用一对空括号,以区分方法和变量、类和其他内容。

在 Java 源代码清单中,我们遵循 Java 社区中最常用的编码约定。类名以大写字母开头;变量和方法名以小写字母开头。常量名中的所有字母都大写。我们不使用下划线来分隔长名称中的单词;根据惯例,我们将首字母之后的单词大写,并将单词连在一起。例如:thisIsAVariablethisIsAMethod()ThisIsAClassTHIS_IS_A_CONSTANT。此外,请注意,我们在引用静态方法和非静态方法时进行了区分。与某些书籍不同的是,我们从不使用Foo.bar()来表示Foobar()方法,除非bar()是一个静态方法(在这种情况下与 Java 语法并行)。

对于来自示例程序的源代码清单,清单将以注释开头,指示相关的文件名(如果需要,还包括方法名):

// filename: ch02/examples/HelloWorld.java

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

您可以随意在编辑器或 IDE 中查看所指定的文件。我们鼓励您编译和运行示例。特别鼓励您进行尝试!

对于jshell中的工作,我们将始终保留jshell提示符:

jshell> System.out.println("Hello, jshell!")
Hello, jshell!

没有文件名或jshell提示符的其他代码片段旨在说明有效的语法和结构,或者展示处理编程任务的假设方法。这些未装饰的清单不一定意味着可以执行,尽管我们始终鼓励您创建自己的类来尝试书中的任何主题。

使用代码示例

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

我们感谢,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN 号码。例如:“学习 Java,作者 Marc Loy、Patrick Niemeyer 和 Daniel Leuck(O’Reilly)。版权所有 2023 年 Marc Loy,978-1-098-14553-8。”

如果您认为您使用的代码示例超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

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

致谢

许多人为完成这本书作出了贡献,无论是其探索 Java版本还是其当前的学习 Java形式。首先,我们要感谢 Brian Guerin,高级采编编辑,以及 Zan McQuade,内容总监,为本版的启动作出了贡献。事实上,我们要感谢 O’Reilly 的整个团队,包括开发编辑 Sarah Grey;制作编辑 Ashley Stussy;以及内容服务经理 Kristen Brown。很难想象有一家公司比他们更致力于员工,作者和读者的成功。Sarah 在编辑本版时功不可没,她几乎定期提高了我们的写作质量,也提振了我们的情绪。

在准备这个版本时,有几位评论者提供了宝贵的反馈意见。Duncan MacGregor 指导了几个主题走向更加有用的方向。Eric van Hoose 使我们的文笔更加紧凑。David Calabrese 指出了新程序员可能需要更多背景知识的地方。Alex Faber 帮助验证了所有示例和练习中的代码。正如许多事情一样,额外的眼睛是不可或缺的。我们很幸运在这一过程中有这么细心的伙伴。

词汇表的原始版本来自 David Flanagan 的书籍 Java in a Nutshell(O’Reilly)。我们还借用了 David 的书中的几张类层次结构图表。这些图表基于 Charles L. Perkins 的类似图表。

最后,衷心感谢 Ron Becker 的明智建议和富有趣味的想法,这些来自一个与编程世界完全脱节的外行人的视角。

第一章:现代语言

当今软件开发者面临的最大挑战和最激动人心的机遇在于利用网络的力量。无论今天创建的应用程序的预期范围或受众如何,几乎肯定会在由全球计算资源连接的机器上运行。网络的日益重要性正对现有工具提出新的要求,并推动对全新类型应用程序的迅速增长需求。

作为用户,我们希望软件能够一直正常工作,在任何平台上都表现良好,并且与其他应用程序兼容。我们希望动态应用程序能够利用连接的世界,并能够访问各种不同和分布的信息源。我们希望真正分布式的软件可以无缝扩展和升级。我们希望智能应用程序能够在云中为我们漫游,搜寻信息并作为电子使者服务。我们已经知道想要什么样的软件已有一段时间了,但真正开始得到这样的软件,实际上是在过去几年里。

历史上的问题在于构建这些应用程序的工具一直不足够完善。速度和可移植性的要求在很大程度上是互相矛盾的,而安全性则大多被忽视或误解。过去,真正可移植的语言往往又臃肿、解释性差且运行速度慢。这些语言之所以流行,除了其高级功能外,还因为它们的可移植性。快速语言通常通过绑定到特定平台来提供速度,因此它们只能在一定程度上满足可移植性要求。甚至有一些语言督促程序员编写更好、更安全的代码,但它们主要是可移植语言的衍生物,并遭遇同样的问题。Java 是一种现代语言,同时解决了这三个方面:可移植性、速度和安全性。这就是为什么在其推出近三十年后,它仍然是编程世界的主导语言。

Java 的介绍

Java 编程语言被设计为一种机器无关的编程语言,既安全到足以在网络上传输,又强大到可以替代本地可执行代码。Java 解决了在这里提出的问题,并在互联网的发展中扮演了重要角色,导致我们今天的现状。

Java 已经成为基于网络的应用程序和网络服务的首选平台。这些应用程序使用诸如 Java Servlet API、Java Web Services 以及许多流行的开源和商业 Java 应用服务器和框架的技术。Java 的可移植性和速度使其成为现代业务应用程序的首选平台。运行在开源 Linux 平台上的 Java 服务器是当今商业和金融界的核心。

最初,大多数对 Java 的热情集中在其构建 Web 嵌入式应用程序,即applets的能力上。但在早期,Java 编写的 applets 和其他客户端图形用户界面(GUIs)是有限的。如今,Java 拥有 Swing,一个用于构建 GUI 的高级工具包。这一发展使 Java 成为开发传统客户端应用软件的可行平台,尽管许多其他竞争者已经进入了这个拥挤的领域。

本书将向您展示如何使用 Java 完成实际编程任务。在接下来的章节中,我们将向您介绍 Java 的各种特性,包括文本处理、网络编程、文件处理以及使用 Swing 构建桌面应用程序。

Java 的起源

Java 的种子在 1990 年由 Sun Microsystems 的创始人及首席研究员比尔·乔伊(Bill Joy)播下。当时,Sun 在一个相对较小的工作站市场中竞争,而微软则开始主导更为主流的基于 Intel 的 PC 世界。当 Sun 错过了 PC 革命的机会后,乔伊退居到科罗拉多州的阿斯彭,致力于高级研究。他坚信通过简单的软件完成复杂任务的理念,并创立了名为 Sun Aspen Smallworks 的公司。

在乔伊在阿斯彭组建的小团队中的原始成员中,詹姆斯·戈斯林(James Gosling)将被铭记为 Java 的奠基人。戈斯林在 1980 年代初因编写 Gosling Emacs 而成名,Gosling Emacs 是第一版用 C 语言编写且运行在 Unix 上的流行 Emacs 编辑器。Gosling Emacs 很快被 Emacs 原始设计者编写的免费版本 GNU Emacs 所取代。当时,戈斯林已转向设计 Sun 的网络可扩展窗口系统(NeWS),该系统在 1987 年短暂地与 X Window System 竞争 Unix GUI 桌面的控制权。尽管有些人认为 NeWS 优于 X,但由于 Sun 将其保持为专有且未发布源代码,而 X 的主要开发者成立了 X Consortium 并采取了相反的方法,NeWS 最终失利。

设计 NeWS 让戈斯林(Gosling)意识到将表达语言与网络感知的窗口化 GUI 集成的强大功能。它还让 Sun 了解到,互联网编程社区最终将拒绝接受任何专有标准,无论其多么优秀。NeWS 的失败播下了 Java 许可证方案和开放(即便不是“开源”)代码的种子。戈斯林将他学到的知识带到了比尔·乔伊(Bill Joy)新成立的阿斯彭项目。1992 年,项目的工作促成了 Sun 子公司 FirstPerson, Inc.的成立。其使命是将 Sun 带入消费电子世界。

FirstPerson 团队致力于开发信息设备软件,如手机和个人数字助理(PDA)。目标是通过廉价红外线和传统分组网络实现信息和实时应用程序的传输。内存和带宽限制要求代码小巧高效。应用程序的性质还要求它们安全可靠。Gosling 和他的队友开始用 C++ 编程,但很快发现这种语言对于任务来说过于复杂、笨重且不安全。他们决定从头开始,并开始开发了被称为 "C++ 减减" 的东西。

随着苹果 Newton 的失败(苹果最早的手持电脑),PDA 的时代还未到来变得显而易见,因此 Sun 将 FirstPerson 的努力转向了互动电视(ITV)。ITV 机顶盒的编程语言选择是 Java 的近祖语言 Oak。尽管 Oak 具有优雅和提供安全交互的能力,但它无法拯救 ITV 的失利。客户不喜欢它,Sun 很快放弃了这个概念。

那时,Joy 和 Gosling 聚在一起为他们的创新语言制定新策略。那是 1993 年,对 Web 的兴趣爆发带来了新的机遇。Oak 是小巧、安全、架构无关和面向对象的。恰好这些特点也是通用、适应互联网的编程语言的要求之一。Sun 迅速转变了焦点,并稍作调整,Oak 成为了 Java。

成长过程

可以毫不夸张地说,Java(以及面向开发者的捆绑包 Java 开发工具包或 JDK)如火如荼地流行起来。甚至在其正式发布之前,当 Java 仍然是一个非产品时,几乎所有主要行业参与者都跟随了 Java 的热潮。Java 的许可证持有者包括 Microsoft、Intel、IBM 和几乎所有主要硬件和软件供应商。然而,尽管有这些支持,Java 在最初几年经历了许多挫折和成长的痛苦。

由于 Sun 和 Microsoft 之间关于 Java 分发和其在 Internet Explorer 中使用的违约和反垄断诉讼一系列事件,阻碍了其在全球最常见的桌面操作系统——Windows 上的部署。Microsoft 参与 Java 也成为一个更大联邦诉讼的焦点,这场诉讼涉及公司严重的反竞争行为。法庭证词显示,这家软件巨头试图通过在其语言版本中引入不兼容性来破坏 Java。与此同时,Microsoft 推出了自己的基于 Java 的语言 C#(C-sharp),作为其 .NET 计划的一部分,并取消了在 Windows 中包含 Java 的计划。C# 自成一派,近年来的创新比 Java 更多。

但 Java 在各种平台上继续传播。当我们开始查看 Java 架构时,你会发现 Java 的许多激动人心之处来自 Java 应用程序运行的自包含虚拟机环境。Java 经过精心设计,以便支持体系结构可以在现有计算机平台上以软件形式实现,或者在定制硬件上实现。Java 的硬件实现用于某些智能卡和其他嵌入式系统。你甚至可以购买带有 Java 解释器的“可穿戴”设备,例如戒指和狗牌。Java 的软件实现可用于所有现代计算机平台,甚至包括便携式计算设备。今天,Java 平台的一个衍生是谷歌的 Android 操作系统的基础,该操作系统为数十亿台手机和其他移动设备提供动力。

2010 年,Oracle Corporation 收购了 Sun Microsystems,并成为 Java 语言的管理者。在其任期开始时有些波折,Oracle 起诉谷歌使用 Java 语言开发 Android,并失败了。2011 年 7 月,Oracle 发布了 Java 标准版 7¹,这是一个重要的 Java 版本,包括一个新的 I/O 包。2017 年,Java 9 引入了模块,以解决 Java 应用程序编译、分发和执行方面长期存在的一些问题。Java 9 还启动了一个快速更新流程,其中一些 Java 版本被指定为“长期支持”,其他版本则为标准的短期版本。(有关这些和其他版本的更多信息,请参见“Java 路线图”。)Oracle 继续领导 Java 开发;但是,它还通过将主要的 Java 部署环境移动到昂贵的商业许可证,同时提供一个免费的 OpenJDK 选项,保留了许多开发人员喜欢和期望的可访问性,使 Java 世界分裂。

一个虚拟机

在我们继续深入之前,了解 Java 所需的环境更有帮助。如果你对我们接下来要提到的内容不太理解,也没关系。你可能会在后面的章节中看到任何陌生的术语都会得到解释。我们只是想为你提供 Java 生态系统的概览。该生态系统的核心是Java 虚拟机(JVM)。

Java 既是一种编译语言,也是一种解释语言。Java 源代码被转换成简单的二进制指令,类似于普通的微处理器机器码。然而,C 或 C++源代码被转换为特定型号处理器的本机指令,而 Java 源代码被编译成一种通用格式——称为字节码的虚拟机指令。

Java 字节码由 Java 运行时解释器执行。运行时系统执行硬件处理器的所有常规活动,但是在安全的虚拟环境中执行。它执行基于堆栈的指令集,并像操作系统一样管理内存。它创建和操作原始数据类型,并加载和调用新引用的代码块。最重要的是,它是根据严格定义的开放规范执行所有这些操作,任何希望生产符合 Java 规范的虚拟机的人都可以实现。虚拟机和语言定义共同提供了完整的规范。没有基本 Java 语言留下未定义或依赖于实现的特性。例如,Java 指定了其所有原始数据类型的大小和数学属性,而不是由平台实现决定。

Java 解释器相对轻量且小巧;它可以以适合特定平台的任何形式实现。解释器可以作为单独的应用程序运行,也可以嵌入到其他软件中,如 Web 浏览器中。总之,这意味着 Java 代码具有隐式的可移植性。相同的 Java 应用程序字节码可以在任何提供 Java 运行时环境的平台上运行,如图 1-1 所示。您无需为不同的平台制作替代版本的应用程序,也无需向最终用户分发源代码。

ljv6 0101

图 1-1. Java 运行时环境

Java 代码的基本单元是。与其他面向对象的语言一样,类是小型、模块化的应用组件,包含可执行代码和数据。编译后的 Java 类以包含 Java 字节码和其他类信息的通用二进制格式分发。类可以离散维护,并存储在本地文件或网络服务器上。在运行时,类根据应用程序的需要动态定位和加载。

除了特定于平台的运行时系统之外,Java 还有一些包含架构相关方法的基本类。这些本地方法作为 Java 虚拟机与现实世界之间的门户。它们在主机平台上以本地编译语言实现,并提供对网络、窗口系统和主机文件系统等资源的低级访问。然而,绝大部分的 Java 是用 Java 自身编写的——从这些基本部分引导出来的,并因此具有可移植性。这包括像 Java 编译器这样重要的 Java 工具,也是用 Java 编写的,因此在所有 Java 平台上以完全相同的方式可用,无需移植。

从历史上看,解释器一直被认为速度较慢,但 Java 不是传统的解释性语言。除了将源代码编译成可移植的字节码外,Java 还经过精心设计,使得运行时系统的软件实现可以通过即时将字节码编译为本地机器代码来进一步优化性能。这称为动态或即时(JIT)编译。通过 JIT 编译,Java 代码可以像本地代码一样快速执行,并保持其可移植性和安全性。

这个 JIT 特性是在想要比较语言性能的人中经常被误解的一个点。编译后的 Java 代码在运行时只有一个内在的性能惩罚,用于安全性和虚拟机设计——数组边界检查。除此之外,所有其他部分都可以像静态编译语言一样优化到本地代码。此外,Java 语言包含比许多其他语言更多的结构信息,提供了更多类型的优化可能性。还要记住,这些优化可以在运行时进行,考虑到实际应用程序的行为和特性。什么可以在编译时完成,而在运行时不能更好地完成?嗯,这其中存在一个时间上的权衡。

传统的即时编译(JIT)的问题在于优化代码需要时间。虽然 JIT 编译器可以产生不错的结果,但在应用程序启动时可能会遇到显著的延迟。对于长期运行的服务器端应用通常不是问题,但对于客户端软件和运行在性能有限设备上的应用程序来说,这是一个严重的问题。为了解决这个问题,Java 的编译器技术,称为 HotSpot,使用了一种称为自适应编译的技巧。如果你看一下实际程序花费时间在做什么,会发现它们几乎全部时间都在反复执行一小部分代码。虽然这部分反复执行的代码可能只占总程序的一小部分,但其行为决定了程序的整体性能。自适应编译允许 Java 运行时利用新型优化,这是静态编译语言无法做到的,因此有时声称 Java 代码在某些情况下可以比 C/C++ 运行得更快。

为了充分利用这种自适应能力,HotSpot 起初是一个普通的 Java 字节码解释器,但有所不同:它在执行过程中测量(profile)代码,以查看哪些部分被重复执行。一旦确定了代码中哪些部分对性能至关重要,HotSpot 将这些部分编译为最佳的本机机器代码。由于它仅将程序的一小部分编译为机器代码,因此它可以花费必要的时间来优化这些部分。程序的其余部分可能根本不需要编译——只需要解释——从而节省内存和时间。事实上,Java 虚拟机可以以两种模式之一运行:客户端和服务器,它们确定虚拟机是强调快速启动时间和内存节约,还是强调性能。自 Java 9 以来,如果最小化应用程序的启动时间非常重要,您还可以使用提前编译(AOT)。

此时一个自然的问题是,为什么每次应用程序关闭时都要丢弃所有这些好的分析信息呢?嗯,Sun 在 Java 5.0 发布中部分解决了这个问题,通过使用共享的只读类以优化的形式持久存储。这显著减少了在给定机器上运行许多 Java 应用程序的启动时间和开销。这样做的技术是复杂的,但思路很简单:优化需要快速执行的程序部分,而不必担心其余部分。

当然,“其余部分”中可能包含进一步优化的代码。2022 年,OpenJDK 的雷登项目启动,旨在进一步减少启动时间,最小化 Java 应用程序的大尺寸,并减少所有先前提到的优化所需的时间。雷登项目提出的机制相当复杂,因此我们在本书中不会讨论它们。但我们想要强调不断努力开发和改进 Java 及其生态系统的工作。即使在其首次亮相 30 年之后,Java 仍然是一种现代语言。

Java 与其他语言比较

Java 的开发者在选择功能时汲取了许多年使用其他语言进行编程的经验。值得一提的是,不论你有其他编程经验还是需要了解背景的新手,都应该花点时间将 Java 与一些其他语言在高层面进行比较。虽然本书确实希望你对计算机和软件应用有一定的了解,但我们并不指望你对任何特定的编程语言有所了解。当我们通过比较提到其他语言时,希望这些评论都是不言而喻的。

至少有三个支撑通用编程语言的支柱是必需的:可移植性、速度和安全性。图 1-2 显示了 Java 与创建时流行的几种语言的比较。

ljv6 0102

图 1-2. 编程语言比较

你可能听说过 Java 很像 C 或 C++,但这只在表面上是真的。当你首次看到 Java 代码时,你会发现其基本语法看起来像 C 或 C++。但相似之处就止步于此。Java 绝非是 C 的直接后裔或是下一代 C++。如果你比较语言特性,你会发现 Java 实际上更多地与 Smalltalk 和 Lisp 等高度动态的语言相似。事实上,Java 的实现与本地的 C 相去甚远。

如果你熟悉当前的语言格局,你会注意到这个比较中缺少了一种流行的语言 C#。C#主要是微软对 Java 的回应,诚然在其上面加了一些便利之处。鉴于它们共同的设计目标和方法(如使用虚拟机、字节码和沙箱),这些平台在速度或安全特性上并没有显著的区别。C#和 Java 一样具有高度的可移植性。与 Java 类似,C#在很大程度上借鉴了 C 语法,但实际上更接近动态语言的亲戚。大多数 Java 开发人员发现学习 C#相对容易,反之亦然。你在从一种语言转向另一种语言时,大部分时间会花在学习标准库上。

突出的是,这些语言与 Java 表面上的相似之处值得注意。Java 在语法上大量借鉴了 C 和 C++,因此你会看到简洁的语言结构,包括大量的花括号和分号。Java 奉行 C 的哲学,即一个优秀的语言应该紧凑;换句话说,它应该足够小而规范,以至于程序员能够一次性掌握其所有能力。就像 C 可以通过库进行扩展一样,Java 类的包可以被添加到核心语言组件中以扩展其词汇量。

C 之所以成功,是因为它提供了一个功能丰富的编程环境,具有高性能和可接受的可移植性。Java 也试图在功能性、速度和可移植性之间取得平衡,但其方式大不相同。C 为了可移植性而牺牲了一些功能性;Java 最初为了可移植性而牺牲了速度。Java 还解决了 C 没有解决的安全问题(尽管在现代系统中,许多这些问题现在已在操作系统和硬件中得到解决)。

Perl、Python 和 Ruby 等脚本语言仍然很受欢迎。脚本语言也可以适用于安全的、网络化的应用程序,这并不是没有道理的。但大多数脚本语言不太适合于严肃的、大规模的编程。人们对脚本语言的吸引力在于它们是动态的;它们是快速开发的强大工具。一些脚本语言,例如 Tcl(在 Java 开发时更受欢迎),也有助于程序员完成特定任务,比如快速创建图形界面,而这是更通用的语言觉得难以驾驭的。脚本语言在源代码级别也非常易于移植。

与 Java 不同,JavaScript 是一种由网景公司最初为网络浏览器开发的基于对象的脚本语言。它作为一种网页浏览器常驻语言,用于动态、交互式、基于网络的应用程序。JavaScript 的名称来源于它与 Java 的集成和相似之处,但比较实际上在这里结束了。然而,JavaScript 在浏览器之外也有重要的应用,比如 Node.js,²,并且在各个领域的开发者中继续备受青睐。有关 JavaScript 的更多信息,请参阅 David Flanagan(O'Reilly)撰写的JavaScript: 权威指南

脚本语言的问题是它们对程序结构和数据类型相当随意。它们有简化的类型系统,通常不提供变量和函数的复杂作用域。这些特点使得它们不太适合构建大型、模块化的应用程序。速度是脚本语言的另一个问题;这些语言通常高级、通常由源代码解释,使得它们的速度相当慢。

对于各个脚本语言的支持者可能会对这些概括提出异议,毫无疑问,在某些情况下他们是正确的。最近几年,脚本语言已经有所改进,尤其是 JavaScript,它已经投入了大量的研究来提高性能。但基本的权衡是不可否认的:脚本语言诞生为系统编程语言的松散、不太结构化的选择,它们通常对于各种原因不太适合用于大型或复杂的项目。

Java 提供了一些脚本语言的基本优势:它高度动态,还具有低级语言的额外好处。Java 具有一个强大的正则表达式包,可与 Perl 一起用于处理文本。它还具有简化使用集合、变量参数列表、方法的静态导入等语言功能的语法糖,使其更加简洁。

逐步开发面向对象组件,再加上 Java 的简洁性,使得能够快速开发和轻松变更应用程序成为可能。研究表明,基于语言特性,使用 Java 开发比使用 C 或 C++更快。Java 还配备了大量的标准核心类,用于常见任务,如构建 GUI 和处理网络通信。Maven 中央仓库是一个外部资源,拥有大量的库和包,可以快速集成到您的环境中,帮助您解决各种新的编程问题。除了这些特性,Java 还具有更静态语言的可扩展性和软件工程优势。它提供了一个安全的结构,可以构建更高级别的框架(甚至其他语言)。

正如我们之前所说,Java 在设计上类似于 Smalltalk 和 Lisp 等语言。然而,这些语言主要用作研究工具,而不是用于开发大规模系统。其中一个原因是这些语言从未开发出标准的可移植绑定到操作系统服务,如 C 标准库或 Java 核心类。Smalltalk 被编译为解释的字节码格式,并且可以动态地即时编译为本地代码,就像 Java 一样。但 Java 通过使用字节码验证器改进了设计,以确保编译后的 Java 代码的正确性。这个验证器使 Java 在性能上优于 Smalltalk,因为 Java 代码需要较少的运行时检查。Java 的字节码验证器还有助于处理安全问题,而 Smalltalk 则没有这方面的解决方案。

在本章的其余部分,我们将从宏观角度介绍 Java 语言。我们将解释 Java 的新特性和不那么新的特性,以及其背后的原因。

设计的安全性

毫无疑问,你肯定听说过 Java 被设计为一种安全语言。但是安全是指什么?安全免受什么或者谁的影响?Java 安全功能中最引人注目的是那些使新类型的动态可移植软件成为可能的功能。Java 提供了几层保护,防止危险缺陷代码以及更加恶意的事物,如病毒和木马。在接下来的部分中,我们将看看 Java 虚拟机体系结构如何在代码运行之前评估其安全性,以及 Java 的类加载器(Java 解释器的字节码加载机制)如何在不信任的类周围构建防护墙。这些功能为可以基于应用程序的基础安全策略提供了基础。

在本节中,我们将看一下 Java 编程语言的一些常规特性。也许比具体的安全特性更重要的是,虽然在安全争论中经常被忽略,但 Java 通过解决常见的设计和编程问题提供了安全性。Java 的目标是尽可能地安全,以避免程序员自己制造的简单错误,以及我们从遗留软件中继承的错误。Java 的目标是保持语言简单,提供已证明其有用的工具,并在需要时让用户在语言之上构建更复杂的设施。

简化,简化,简化……

在 Java 中,简单规则。由于 Java 从一张干净的纸开始,它避开了在其他语言中已经被证明混乱或有争议的功能。例如,Java 不允许程序员定义的运算符重载(在某些语言中,允许程序员重新定义基本符号如+和-的含义)。Java 没有源代码预处理器,因此它没有宏、#define语句或条件源代码编译之类的东西。这些构造主要存在于其他语言中以支持平台依赖性,因此从这个意义上讲,它们在 Java 中是不需要的。条件编译通常也用于调试,但 Java 的复杂运行时优化和诸如断言之类的特性更加优雅地解决了这个问题。⁴

Java 为组织类文件提供了一个明确定义的结构。包系统允许编译器处理一些传统make工具(用于从源代码构建可执行文件的工具)的功能。编译器还可以直接处理已编译的 Java 类,因为所有类型信息都得到了保留;不像 C/C++中那样需要外部的源“头”文件。所有这些意味着 Java 代码需要更少的上下文来阅读。事实上,你有时可能会发现查看 Java 源代码比参考类文档更快。

Java 也采用了一种与其他语言不同的结构特性。例如,Java 仅支持单一继承类层次结构(每个类只能有一个“父”类),但允许多继承接口。接口,类似于 C++中的抽象类,指定了对象的行为而不定义其实现。这是一个非常强大的机制,允许开发人员为对象行为定义一个“合约”,该合约可以独立于任何特定对象实现而被使用和引用。Java 中的接口消除了对类的多重继承及相关问题的需求。

正如您将在第四章中看到的,Java 是一种相当简单和优雅的编程语言,这仍然是它吸引人的重要原因。

类型安全和方法绑定

语言的一种属性是它所使用的类型检查的类型。一般来说,语言被归类为静态动态,这指的是在编译时已知变量信息的数量与应用程序运行时已知信息的数量。

在严格静态类型的语言中,比如 C 或 C++,数据类型在源代码编译时就已经确定了。编译器通过这个特性获益,因为它能够在代码执行之前捕获许多种类的错误。例如,编译器不会允许你将浮点值存储在整数变量中。因此,代码不需要运行时类型检查,因此可以编译成小巧且快速的形式。但是,静态类型的语言是不灵活的。它们不像具有动态类型检查的语言那样自然地支持集合,并且在应用程序运行时无法安全地导入新的数据类型。

相比之下,诸如 Smalltalk 或 Lisp 之类的动态语言具有一个在应用程序执行时管理对象类型并执行必要类型检查的运行时系统。这些类型的语言允许更复杂的行为,并在许多方面更为强大。然而,它们通常更慢,不太安全,并且更难调试。

语言之间的差异被类比为汽车种类之间的差异。像 C++这样的静态类型语言类似于跑车:相当安全和快速,但只有在平整的道路上才有用。而高度动态的语言,如 Smalltalk 更像越野车:它们为你提供了更多自由,但可能有些笨拙。在郊外呼啸而过可能很有趣(有时也更快),但你也可能会被卡在沟里或被熊攻击。

语言的另一个属性是它将方法调用与其定义绑定的方式。在静态语言(如 C 或 C++)中,方法的定义通常在编译时绑定,除非程序员另有规定。另一方面,诸如 Smalltalk 之类的语言被称为late binding,因为它们在运行时动态地定位方法的定义。早期绑定对于性能至关重要;它让应用程序在运行时不需要为了查找方法而产生额外的开销。但是晚期绑定更加灵活。在一个支持动态加载新类型并且只有运行时系统能够确定要运行哪个方法的面向对象语言中,它也是必需的。

Java 提供了 C++ 和 Smalltalk 的一些优点;它是一种静态类型、后期绑定的语言。Java 中的每个对象都有一个在编译时就确定的明确类型。这意味着 Java 编译器可以像 C++ 一样进行静态类型检查和使用分析。因此,你不能将一个对象分配给错误类型的变量,也不能在对象上调用不存在的方法。Java 编译器甚至进一步防止你使用未初始化的变量和创建不可达的语句(见第四章)。

然而,Java 也完全支持运行时类型。Java 运行时系统跟踪所有对象,并能在执行期间确定它们的类型和关系。这意味着你可以在运行时检查对象以确定其类型。与 C 或 C++ 不同,Java 运行时系统检查从一个对象类型到另一个对象类型的强制转换,并且可以使用一定程度的类型安全加载新类型的动态加载对象。由于 Java 使用后期绑定,因此可以编写在运行时替换某些方法定义的代码。

增量开发

Java 将所有数据类型和方法签名信息从源代码到编译后的字节码形式都携带在一起。这意味着 Java 类可以逐步开发。你自己的 Java 源代码也可以安全地与编译器从未见过的其他源代码的类一起编译。换句话说,你可以编写引用二进制类文件的新代码,而不会失去源代码提供的类型安全性。

Java 不会遭受“脆弱基类”问题的困扰。在诸如 C++ 的语言中,基类的实现可以被有效冻结,因为它有许多派生类;改变基类可能需要重新编译所有派生类,这对类库开发者来说是一个特别困难的问题。Java 通过动态定位类内的字段来避免这个问题。只要一个类保持其原始结构的有效形式,它就可以在不破坏从它派生或使用它的其他类的情况下进化。

动态内存管理

Java 与低级语言(如 C 或 C++)之间一些最重要的区别涉及 Java 如何管理内存。Java 消除了对任意内存区域的即兴引用(在其他语言中称为指针),并在语言中添加了一些高级数据结构。Java 还有效且自动地清理未使用的对象(称为垃圾收集)。这些特性有效地消除了许多安全性、可移植性和优化方面的难题。

仅仅通过垃圾收集就已经拯救了无数程序员免受 C 或 C++ 中显式内存分配和释放带来的最大编程错误的困扰。除了在内存中维护对象外,Java 运行时系统还跟踪所有对这些对象的引用。当一个对象不再使用时,Java 会自动将其从内存中删除。在很大程度上,你可以简单地忽略不再使用的对象,并确信解释器会在适当的时候清理它们。

Java 使用了一个复杂的垃圾收集器,它在后台运行,这意味着大多数垃圾收集发生在空闲时间:在 I/O 暂停、鼠标点击或键盘敲击之间。一些运行时系统,如 HotSpot,具有更先进的垃圾收集机制,可以区分对象的使用模式(如短期使用与长期使用),并优化它们的收集。Java 运行时现在可以根据应用程序的行为自动调整内存的最佳分配。通过这种运行时分析,自动内存管理比大多数勤勉管理资源的程序员更快,这是一些老派程序员难以相信的。

我们说过 Java 没有指针。严格来说,这种说法是正确的,但也有误导性。Java 提供的是引用——一种更安全的指针。引用是一个强类型的对象句柄。在 Java 中,除了原始数值类型,所有对象都通过引用访问。你可以使用引用来构建所有 C 程序员习惯用指针构建的常规数据结构,如链表、树等。唯一的区别是,使用引用时必须以类型安全的方式进行操作。

在 Java 中,引用不能像在 C 等语言中更改指针那样更改。引用是一个原子事物;你不能通过除将其分配给对象外的任何方式操纵引用的值。引用是按值传递的,你不能通过超过单一间接级别来引用对象。保护引用是 Java 安全性的基本方面之一。这意味着 Java 代码必须遵循规则;它不能窥视不应该窥视的地方以规避这些规则。

最后,我们应该提到,在 Java 中,数组(基本上是索引列表)是真正的一级对象。它们可以像其他对象一样动态分配和分配。数组知道它们自己的大小和类型。虽然你不能直接定义或子类化数组类,但它们确实基于其基本类型的关系具有良好定义的继承关系。语言中的真正数组减少了指针算术的需求,比如在 C 或 C++ 中使用的那种。

错误处理

Java 的根源在于网络设备和嵌入式系统。对于这些应用程序,具有健壮和智能的错误管理是很重要的。Java 具有处理异常的强大机制,与较新的 C++实现类似。异常提供了一种更自然和优雅的处理错误的方式。异常允许您将错误处理代码与正常代码分离,从而实现更清晰、更易读的应用程序。

当发生异常时,它会导致程序执行流转移到预先指定的“catch”代码块。异常携带一个对象,其中包含引发问题的情况信息。Java 编译器要求方法要么声明它可以生成的异常,要么自己捕获并处理它们。这将错误信息提升到与方法参数和返回类型同等重要的水平。作为 Java 程序员,你清楚地知道你必须处理的异常情况,并且在编写正确的软件时,编译器提供了帮助,使它们不会未被处理。

线程

现代应用程序需要高度的并行性。即使是非常专注的应用程序也可能拥有复杂的用户界面,这需要并发活动。随着计算机速度的提高,用户对占用其时间的不相关任务越来越没有耐心。线程为客户端和服务器应用程序提供了有效的多处理和任务分配。Java 使得线程易于使用,因为它们的支持内置于语言中。

并发很好,但编程中线程还有更多内容,不仅仅是同时执行多个任务。在大多数情况下,线程需要同步(协调),没有显式语言支持可能会很棘手。Java 支持基于监视器模型的同步,这是一种用于访问资源的锁定和解锁系统。关键字synchronized指定了方法和代码块,用于在对象内部进行安全的、序列化的访问。还有简单的原始方法,用于在线程之间等待和信号传递,这些线程对同一对象感兴趣。

Java 拥有一个高级并发包,提供了强大的实用程序,解决了多线程编程中的常见模式,例如线程池、任务协调和复杂的锁定。通过并发包及其相关实用程序的添加,Java 提供了任何语言中一些最先进的与线程相关的实用程序。而且,当您需要许多线程时,您可以利用 Java 19 中作为预览功能开始的 Project Loom 虚拟线程的世界。

尽管一些开发者可能永远不需要编写多线程代码,但学习使用线程编程是掌握 Java 编程的重要组成部分,也是所有开发者应该掌握的技能。请参阅第九章讨论这个主题。特别是“虚拟线程”介绍了虚拟线程并突出了它们的一些性能优势。

可伸缩性

正如我们早先指出的,Java 程序主要由类组成。在类的基础上,Java 提供了,这是一种将类组织成功能单元的结构层。包为组织类提供了命名约定,并在 Java 应用程序中提供了第二层次的组织控制,用于控制变量和方法的可见性。

在一个包内,类要么是公共可见的,要么受到外部访问的保护。包形成了更接近应用程序级别的另一种作用域。这有助于构建可重用的组件,这些组件在系统中协同工作。包还有助于设计可扩展的应用程序,而不至于使代码变得紧密耦合成一团。重用和规模问题在 Java 9 中增加的模块系统中得到了真正的强化。⁶

实施安全性

创建一个防止自己踩到坑的语言是一回事;创建一个防止别人踩到坑的语言则是另一回事。

封装是将数据和行为隐藏在类内部的概念;它是面向对象设计的重要组成部分。它帮助你编写干净、模块化的软件。然而,在大多数语言中,数据项的可见性只是程序员与编译器之间关系的一部分。这是语义问题,而不是关于在运行程序环境中实际数据安全性的断言。

当 C++的创造者Bjarne Stroustrup选择关键字private来指定 C++类中的隐藏成员时,他可能考虑的是保护开发者免受其他开发者代码中混乱细节的干扰,而不是保护开发者的类和对象免受他人病毒和特洛伊木马的攻击。在 C 或 C++中,任意的类型转换和指针算术使得在不违反语言规则的情况下就能轻易违反类的访问权限。考虑以下代码:

// C++ code
class Finances {
    private:
        char creditCardNumber[16];
        // ...
};

main() {
    Finances finances;

    // Forge a pointer to peek inside the class
    char *cardno = (char *)&finances;
    printf("Card Number = %.16s\n", cardno);
}

在这个小小的 C++ 情节中,我们编写了一些违反Finances类封装的代码,并提取了一些秘密信息。这种花招——滥用无类型指针——在 Java 中是不可能的。如果这个例子看起来不现实,请考虑保护运行环境基础(系统)类免受类似攻击的重要性。如果不受信任的代码可以破坏提供对真实资源(如文件系统、网络或窗口系统)访问的组件,那么它肯定有机会窃取你的信用卡号码。

Java 随着互联网的发展而成长,以及那里充斥着的不受信任的来源。它曾经需要比现在更多的安全性,但它仍然保留了一些安全特性:类加载器处理从本地存储或网络加载类,而所有系统安全性最终都依赖于 Java 验证器,它保证了传入类的完整性。

Java 字节码验证器是 Java 运行时系统的一个特殊模块和固定部分。然而,类加载器是可以由不同应用程序(如服务器或网页浏览器)不同实现的组件。所有这些部分都需要正常工作,以确保 Java 环境的安全性。

验证器

Java 的第一道防线是字节码验证器。验证器在运行前读取字节码,并确保它表现良好,遵守 Java 字节码规范的基本规则。受信任的 Java 编译器不会生成不符合规范的代码。然而,一个恶作剧的人可以故意组装出有问题的 Java 字节码。检测这些问题就是验证器的工作。

一旦代码经过验证,它就被认为是免受某些无意或恶意错误的安全的。例如,经过验证的代码不能伪造引用或违反对象的访问权限(如我们的信用卡示例)。它不能执行非法强制类型转换或以非预期的方式使用对象。它甚至不能引起某些类型的内部错误,比如溢出或下溢出内部堆栈。这些基本保证构成了 Java 安全性的基础。

也许你会想,这种安全性在很多解释性语言中是隐含的吧?确实,你不应该用一个虚假的 BASIC 代码行来破坏 BASIC 解释器,但要记住,大多数解释性语言的保护发生在更高的级别。这些语言通常有重量级的解释器,在运行时做大量的工作,因此它们必然更慢、更繁琐。

相比之下,Java 字节码是一个相对轻量级的低级指令集。在执行之前静态验证 Java 字节码的能力,使得 Java 解释器在后续全速运行时可以安全地运行,而无需昂贵的运行时检查。这是 Java 中的一个基本创新。

验证器是一种类型的数学“定理证明器”。它逐步通过 Java 字节码并应用简单的归纳规则来确定字节码的某些行为方面。这种分析是可能的,因为编译后的 Java 字节码包含比其他类似语言的目标代码更多的类型信息。字节码还必须遵守一些额外的规则,以简化其行为。首先,大多数字节码指令只操作单个数据类型。例如,在堆栈操作中,对于对象引用和 Java 中每种数值类型都有单独的指令。类似地,将每种类型的值移入和移出本地变量也有不同的指令。

其次,任何操作产生的对象类型始终是预先知道的。没有字节码操作会消耗值并产生多个可能类型的值作为输出。因此,始终可以查看下一个指令及其操作数,并知道将产生的值的类型。

因为操作总是产生已知类型,所以可以通过查看起始状态来确定堆栈和本地变量中所有项目的类型在未来任何时间的类型。在任何给定时间收集到的所有这些类型信息称为堆栈的类型状态。这是 Java 在运行应用程序之前尝试分析的内容。此时,Java 并不了解堆栈和变量项的实际值;它只知道它们是什么类型的项。但这已足够强制执行安全规则,并确保对象不被非法操纵。

为了使分析堆栈的类型状态变得可行,Java 对其字节码指令的执行添加了额外的限制:所有到达代码中同一点的路径必须具有完全相同的类型状态。

类加载器

Java 通过类加载器添加了第二层安全性。类加载器负责将 Java 类的字节码带入解释器中。每个从网络加载类的应用程序都必须使用类加载器来处理此任务。

加载和通过验证的类保持与其类加载器相关联。因此,类基本上根据其来源被分隔成不同的命名空间。当一个加载的类引用另一个类名时,新类的位置由原始类加载器提供。这意味着从特定源检索的类可以限制只与从同一位置检索的其他类进行交互。例如,一个支持 Java 的网络浏览器可以使用类加载器为从给定 URL 加载的所有类构建一个单独的空间。还可以使用基于加密签名类的复杂安全性来实现类加载器。

类搜索始终从内置的 Java 系统类开始。这些类是从 Java 解释器的classpath指定的位置加载的(参见第三章)。 Classpath 中的类仅由系统加载一次,不可替换。这意味着应用程序无法用其自己的版本替换基本系统类以改变其功能。

应用程序和用户级安全性

在有足够的能力做一些有用事情和有权做任何想做的事之间存在一条细微的界线。Java 提供了一个安全环境的基础,其中不受信任的代码可以被隔离、管理和安全执行。然而,除非您满足于将该代码保持在一个小黑盒子中并仅为其自身运行,否则您将不得不授予它至少某些系统资源的访问权限,以使其有用。每种访问方式都伴随着一定的风险和利益。例如,在云服务环境中,授予不受信任(未知)代码访问云服务器文件系统的优点是,它可以比您下载并在本地处理大文件更快地找到和处理。相关的风险是,该代码可能会绕过云服务器并可能发现不应查看的敏感信息。

在一端,运行应用程序仅仅为其提供了一个资源——计算时间——它可能会用于有益用途或者草率地浪费。防止不受信任的应用程序浪费您的时间甚至尝试“拒绝服务”攻击是困难的。在另一端,一个强大的、受信任的应用程序可能理所当然地需要访问各种系统资源(如文件系统、进程创建或网络接口);恶意应用程序可能会对这些资源造成严重破坏。这里的信息是,您必须在程序中解决重要且有时复杂的安全问题。

在某些情况下,简单要求用户“确认”请求可能是可以接受的。Java 语言提供了实现任何所需安全策略的工具。然而,你选择什么策略最终取决于你是否信任所涉代码的身份和完整性。这就是数字签名发挥作用的地方。

数字签名与证书一起,是验证数据确实来自所声称的源并且在传输过程中未被修改的技术。如果 Boofa 银行签署其支票应用程序,您可以验证该应用实际来自银行而不是冒名顶替者,并且未被修改。因此,您可以告知您的系统信任具有 Boofa 银行签名的代码。

Java 路线图

随着对 Java 的不断更新,很难跟踪目前有哪些功能可用,什么被承诺了,以及有些功能已经存在了一段时间。以下部分构成了 Java 过去、现在和未来的一张路线图。至于 Java 的版本,Oracle 的发布说明包含了良好的总结,并链接到进一步的细节。如果你在工作中使用旧版本,请考虑阅读Oracle 技术资源文档

过去:Java 1.0–Java 20

Java 1.0 为 Java 开发提供了基本框架:语言本身以及让你编写小程序和简单应用程序的包。虽然 1.0 已经正式过时,但仍然存在一些符合其 API 的小程序。

Java 1.1 取代了 1.0,在 Abstract Window Toolkit(Java 的原始 GUI 工具包)中进行了重大改进,引入了新的事件模式、反射和内部类等新的语言功能以及许多其他关键功能。Java 1.1 是多年来大多数版本的 Netscape 和 Microsoft Internet Explorer 本地支持的版本。出于各种政治原因,浏览器世界在这种状态下冻结了很长时间。

Java 1.2,由 Sun 称为“Java 2”,是 1998 年 12 月的一个重大发布。它提供了许多改进和新增内容,主要是在捆绑到标准发行版中的 API 集合方面。最显著的增加是将 Swing GUI 包含为核心 API 和全新的完整 2D 绘图 API。Swing 是 Java 的高级 UI 工具包,具有远远超过旧 AWT 的功能。 (Swing、AWT 和其他一些包有时被称为 JFC,或 Java 基础类)。Java 1.2 还为 Java 添加了适当的集合 API。

Java 1.3 于 2000 年初发布,添加了一些小的功能,但主要集中在性能上。通过 1.3 版本,Java 在许多平台上显著提高了性能,并且 Swing 接收了许多错误修复。在此期间,Java 企业 API 如 Servlets 和 Enterprise JavaBeans 也得到了成熟。

Java 1.4 于 2002 年发布,集成了一组新的重要 API 和许多期待已久的功能。这包括语言断言、正则表达式、首选项和日志 API、面向高容量应用的新 I/O 系统、标准 XML 支持、AWT 和 Swing 的基本改进,以及大大成熟的 Java Servlets API 用于 Web 应用程序。

Java 5,发布于 2004 年,是一次重大的发布,引入了许多期待已久的语言语法增强功能,包括泛型、类型安全的枚举、增强型 for 循环、可变参数列表、静态导入、基本类型的自动装箱和拆箱,以及类的高级元数据。新的并发 API 提供了强大的线程能力,还添加了类似于 C 语言的格式化打印和解析 API。远程方法调用(RMI)也进行了全面改进,消除了对编译的存根和骨架的需要。标准 XML API 中也有重大的新增功能。

Java 6,于 2006 年末发布,是一个相对较小的版本,未向 Java 语言添加任何新的语法特性,但捆绑了诸如 XML 和 Web 服务的新扩展 API。

Java 7,发布于 2011 年,代表了一次相当重要的更新。在发布 Java 6 后的五年中,语言进行了几次小的调整,例如允许在switch语句中使用字符串(稍后详述!),同时还有主要的新增内容,比如java.nio新 I/O 库。

Java 8,于 2014 年发布,完成了一些在 Java 7 中因版本发布日期反复推迟而被删除的功能,如 lambda 表达式和默认方法。此版本还对日期和时间支持进行了一些工作,包括创建不可变日期对象的能力,在支持的 lambda 中非常方便。

Java 9,经历了一些延迟后于 2017 年发布,引入了模块系统(Project Jigsaw),以及 Java 的交互式命令行工具:jshell。在本书的剩余部分中,我们将大量使用jshell来快速探索 Java 的许多特性。Java 9 还从 JDK 中删除了 JavaDB。

Java 10,于 2018 年初在 Java 9 之后不久发布,更新了垃圾回收,并引入了其他功能,如根证书到 OpenJDK 构建。添加了对不可修改集合的支持,并删除了旧的外观包(如苹果的 Aqua)的支持。

Java 11,于 2018 年末发布,添加了标准的 HTTP 客户端和传输层安全性(TLS)1.3。JavaFX 和 Java EE 模块被移除(JavaFX 被重新设计为独立库)。Java 小程序也被移除。与 Java 8 一样,Java 11 是 Oracle 的长期支持(LTS)版本之一。某些版本,如 Java 8、Java 11、Java 17 和 Java 21,将会得到更长时间的支持。Oracle 试图改变客户和开发者与新版本互动的方式,但仍有充分的理由选择已知的版本。您可以在 Oracle 技术网络的Oracle Java SE 支持路线图中详细了解 Oracle 的思路和计划。

Java 12,于 2019 年初发布,添加了一些次要的语言语法增强,如预览版的 switch 表达式。

Java 13,于 2019 年 9 月发布,包括更多语言特性预览,如文本块,以及套接字 API 的重大重新实现。根据官方设计文档,这一令人印象深刻的努力提供了“更简单和现代化的实现,易于维护和调试。”

Java 14,于 2020 年 3 月发布,增加了更多语言语法增强预览,如记录,更新了垃圾收集功能,并移除了 Pack200 工具和 API。还将在 Java 12 首次预览的switch表达式移出预览状态并纳入标准语言。

Java 15,于 2020 年 9 月发布,将文本块(多行字符串)支持从预览状态移出,并添加了隐藏类和密封类,允许新的方式限制对某些代码的访问。(密封类保持为预览功能。)文本编码支持也更新到 Unicode 13.0。

Java 16,于 2021 年 3 月发布,保持密封类处于预览状态,但将记录移出预览状态。扩展了网络 API 以包括 Unix 域套接字。还为 Streams API 添加了列表输出选项。

Java 17,于 2021 年 9 月发布,作为 LTS 版本,将密封类升级为语言的常规特性。增加了switch语句的模式匹配预览功能,并在 macOS 上进行了多项改进。现在可以使用数据报套接字加入多播组。

Java 18,于 2022 年 3 月发布,最终将 UTF-8 设置为 Java SE API 的默认字符集。引入了一个适用于原型设计或测试的简单静态 Web 服务器,并扩展了 IP 地址解析的选项。

Java 19,于 2022 年 9 月发布,预览了虚拟线程、结构化并发和记录模式。Unicode 支持升级到了版本 14.0,并添加了一些额外的日期时间格式。

Java 20,于 2023 年 3 月发布,最终移除了早在 JDK 1.2 中标记为不安全的多达 20 年的多线程操作(停止/暂停/恢复)。改进了字符串解析以支持图素,例如组合表情符号。

现在:Java 21

本书涵盖了截至 2023 年 9 月发布的 Java 21 的所有最新改进。随着每六个月一次的发布节奏,当您阅读本书时,新版本的 JDK 几乎肯定已经推出。如上所述,Oracle 希望开发人员将这些发布视为功能更新。除了覆盖虚拟线程的示例,Java 17 足以处理本书中的代码。在我们使用更新功能的罕见情况下,我们将注明所需的最低版本。在阅读时,您无需“跟进”,但如果您在已发布的项目中使用 Java,请考虑查看 Oracle 的官方路线图,以确定保持最新状态是否有意义。

功能概述

下面是当前 Java 核心 API 中最重要的功能的简要概述,这些功能位于标准库之外:

  • Java 数据库连接(JDBC)

与数据库交互的通用设施(Java 1.1 中引入)。

远程方法调用(RMI)

Java 的分布式对象系统。RMI 允许您在网络上运行某处的服务器上托管的对象的方法调用(Java 1.1 中引入)。

Java 安全性

控制访问系统资源的设施,结合统一的加密接口。Java 安全性是签名类的基础。

Java 桌面

从 Java 9 开始的大量功能的通用收集,包括 Swing UI 组件;“可插入的外观和感觉”,允许您自定义和主题整个 UI 本身;拖放;2D 图形;打印;图像和声音的显示、播放和操作;以及可以与视觉或其他障碍的人使用的特殊软件和硬件集成的无障碍功能。

国际化

能够编写能够适应用户希望使用的语言和区域设置的程序。程序会自动以适当的语言显示文本(Java 1.1 中引入)。

Java 命名和目录接口(JNDI)

用于查找资源的通用服务。JNDI 统一访问目录服务,如 LDAP、Novell 的 NDS 等。

以下是“标准扩展”API。有些与 Java 标准版捆绑在一起,如用于处理 XML 和 Web 服务的 API;有些必须单独下载并与您的应用程序或服务器一起部署:

JavaMail

用于编写电子邮件软件的统一 API。

Java 媒体框架

另一个用于协调显示多种媒体的通用组件的收集,包括 Java 2D、Java 3D、Java 语音(用于语音识别和合成)、Java 音频(高质量音频)、Java TV(用于互动电视和类似应用程序)等。

Java Servlets

一个能让您在 Java 中编写服务器端 Web 应用程序的设施。

Java 加密

密码算法的实际实现。(出于法律原因,此包已从 Java 安全性中分离出来。)

可扩展标记语言/可扩展样式表语言(XML/XSL)

用于创建和操作 XML 文档的工具,验证它们,将它们映射到 Java 对象,以及使用样式表进行转换。

我们将尽量涉及这些特性。对我们来说很不幸(但对于 Java 软件开发者来说很幸运),Java 环境变得如此丰富,以至于不可能在一本书中涵盖所有内容。我们会注意到其他覆盖我们无法深入讲解的主题的书籍和资源。

未来展望

现在的 Java 绝对不是新手,但它仍然是 Web 和应用程序开发中最受欢迎的平台之一。尤其是在 Web 服务、Web 应用框架和 XML 工具领域。虽然 Java 并没有像预期的那样主导移动平台,但你可以使用 Java 语言和核心 API 为 Google 的 Android 移动操作系统编程,Android 操作系统在全球亿万台设备上使用。在 Microsoft 阵营,源自 Java 的 C# 语言已经接管了大量的 .NET 开发,并将核心 Java 语法和模式带到了这些平台。

JVM 本身也是一个有趣的探索和成长领域。新语言不断涌现,以利用 JVM 的功能集和普及度。Clojure 是一种强大的函数式语言,拥有越来越多的粉丝,应用范围从业余爱好者到最大的零售商。还有 Kotlin,这是一种通用语言,正以极大的热情占据 Android 开发市场。它在新环境中 gaining traction,同时保持与 Java 的良好互操作性。

目前 Java 最令人兴奋的变化领域可能是在朝着更轻量、更简单的业务框架发展,并且将 Java 平台与动态语言结合起来,用于脚本编写网页和扩展。还有更多有趣的工作等待着我们。

你有多个选择用于 Java 开发环境和运行时系统。Oracle 的 Java 开发工具包可在 macOS、Windows 和 Linux 上使用。访问 Oracle 的 Java 网站 获取有关获取最新官方 JDK 的更多信息。

自 2017 年起,Oracle 官方支持开源 OpenJDK 的更新。个人和小型(甚至中型)公司可能会发现这个免费版本足够使用。该版本的发布滞后于商业 JDK 的发布,并且不包括 Oracle 的技术支持,但 Oracle 已明确表示将坚定地维护 Java 的免费和开放访问。书中的所有示例都是使用 OpenJDK 编写和测试的。你可以通过 OpenJDK 网站 从“马嘴”(Oracle?)那里获取更多详细信息。

快速 为了快速安装 Java 19 的免费版本(足够应付本书中的几乎所有示例,尽管我们会提到一些后续版本的语言特性),Amazon 在线提供了其 Corretto 发行版,配有友好的、熟悉的安装程序,支持所有三大主流平台。第二章将指导你在 Windows、macOS 和 Linux 上进行基本的 Corretto 安装。

也有一系列受欢迎的 Java 集成开发环境(IDE)。我们将在本书中讨论其中之一:JetBrains 的免费社区版IntelliJ IDEA。这款一体化开发环境让您可以使用先进的工具编写、测试和打包软件。

练习

每章结束时,我们都会提供一些问题和代码练习供您复习。问题的答案可以在附录 B 中找到。代码练习的解决方案包含在GitHub的其他代码示例中。(附录 A 提供了下载和使用本书代码的详细信息。)我们鼓励您回答这些问题并尝试这些练习。如果您不得不返回章节并阅读更多内容以找到答案或查找某些方法名称,不要担心!这就是目的!学习如何使用本书作为参考将在未来派上用场。

  1. 目前谁在维护 Java?

  2. Java 的开源开发工具包的名称是什么?

  3. Java 安全运行字节码的两个主要组件是什么?

¹ 标准版(SE)这个名词早在 Java 历史的早期出现,当 Sun 发布了 J2EE 平台或 Java 2 企业版时。现在企业版改名为“Jakarta EE”。

² 如果你对 Node.js 感兴趣,请查看 Andrew Mead 的学习 Node.js 开发和 Shelley Powers 的学习 Node,位于 O’Reilly 网站上。

³ 例如,查看 G. Phipps 的“比较 Java 和 C++的观察到的错误和生产率率”软件—实践与经验,第 29 卷,1999 年。

⁴ 断言不在本书的讨论范围内,但在你对 Java 有更深入了解后,它们是一个值得探索的话题。你可以在Oracle Java SE Documentation中找到一些基本的详情。

⁵ 车辆类比的荣誉归功于 Marshall P. Cline,C++ FAQ的作者。

⁶ 模块不在本书的讨论范围内,但它们是 Paul Bakker 和 Sander Mak(O’Reilly)的Java 9 模块化的唯一焦点。

第二章:第一个应用程序

在深入讨论 Java 语言之前,让我们先通过一些工作代码来熟悉一下。在本章中,我们将构建一个友好的小应用程序,展示本书中使用的许多概念。我们将利用这个机会介绍 Java 语言和应用程序的一般特性。

这一章还作为 Java 面向对象和多线程方面的简要介绍。如果这些概念对你来说是新的,我们希望在这里首次接触 Java 时能够有一个简单而愉快的体验。如果你已经在其他面向对象或多线程编程环境中工作过,你应该会特别欣赏 Java 的简洁和优雅。本章仅旨在为你提供 Java 语言的概览和它的使用感受。如果你在这里介绍的任何概念上有困难,可以放心,它们将在本书的后面更详细地介绍。

我们无法过分强调在学习新概念时进行实验的重要性,无论是在这里还是在整本书中。不要只是阅读示例——运行它们。在可以的情况下,我们将向你展示如何使用 jshell(详见“尝试 Java”)实时尝试。本书示例的源代码可以在 GitHub 找到。编译这些程序并尝试运行它们。然后,将我们的示例变成你的示例:玩弄它们,改变它们的行为,打破它们,修复它们,并希望在此过程中享受一些乐趣。

Java 工具和环境

虽然只需使用 Oracle 的开源 Java 开发工具包(OpenJDK)和一个简单的文本编辑器(如 vi 或 Notepad)就可以编写、编译和运行 Java 应用程序,但今天绝大多数 Java 代码都是使用集成开发环境(IDE)编写的。使用 IDE 的好处包括将 Java 源代码的一切功能集中到一个视图中,具有语法高亮显示、导航帮助、源代码控制、集成文档、构建、重构和部署等功能。因此,我们将跳过学术性的命令行处理,从一个流行的免费 IDE — IntelliJ IDEA CE(社区版)开始。如果你不喜欢使用 IDE,可以使用命令行命令 javac HelloJava.java 进行编译,java HelloJava 运行即将出现的示例。

IntelliJ IDEA 需要安装 Java。本书涵盖 Java 21 语言功能,因此尽管本章的示例可以与旧版本一起使用,但最好安装 JDK 21 以确保本书中的所有示例都能编译通过。(Java 19 也有所有最重要的功能可用,尽管其中许多技术上处于“预览”模式。)JDK 包含几个开发工具,我们将在第三章中讨论这些工具。你可以通过在命令行中输入java -version来检查已安装的版本。如果没有安装 Java,或者版本旧于 JDK 19,你应该安装一个更新的版本,如在“安装 JDK”中讨论的那样。本书示例所需的仅仅是基本的 JDK。

安装 JDK

需要在开头声明的是,你可以自由下载和使用 Oracle 的官方商业JDK用于个人使用。Oracle 下载页面提供了最新版本和最新的长期支持版本(目前版本都是 21),并附有旧版本的链接,以便管理遗留兼容性。例如,Java 8 和 Java 11 仍然是许多大型组织后端的重要版本。

然而,如果计划在任何商业或共享环境中使用 Java,Oracle JDK 现在带有严格的(并且付费的)许可条款。因此,基于此等理由,我们主要使用之前提到的 OpenJDK,如在“成长”中所述。不幸的是,这个开源版本并不包括所有不同平台的安装程序。但由于是开源的,其他团体可以介入并提供任何缺失的部分,事实上已经有几个基于 OpenJDK 的安装程序包存在。亚马逊一直以Corretto名义发布及时的安装程序。我们将在本章中介绍 Corretto 在 Windows、Mac 和 Linux 上的基本安装步骤。

对于那些希望使用最新版本且不介意进行一些配置工作的用户,可以考虑安装 OpenJDK。虽然不像使用典型的本地安装程序那样简单,但在你选择的操作系统上安装 OpenJDK 通常只需解压下载的文件到一个文件夹,并确保几个环境变量(JAVA_HOMEPATH)设置正确。无论你使用哪种操作系统,如果要使用 OpenJDK,你需要前往Oracle 的 OpenJDK 下载页面。在那里,他们列出了当前的版本以及任何可用的早期访问版本。

在 Linux 上安装 Corretto

对于流行的 Debian 和 Red Hat 发行版,你可以下载相应的文件(.deb.rpm),然后使用你通常的包管理器安装 JDK。用于通用 Linux 系统的文件是一个可以在你选择的任何共享目录中解压的压缩 tar 文件(tar.gz)。我们将介绍解压和配置这个压缩的tar文件的步骤,因为它适用于大多数 Linux 发行版。这些步骤使用 Java 的 17 版本,但适用于所有当前和 LTS 版本的 Corretto 下载。

决定你想要安装 JDK 的位置。我们将把我们的存储在 /usr/lib/jvm 中,但其他发行版可能使用其他位置,如 /opt/usr/share/usr/local。如果你是系统上唯一使用 Java 的用户,你甚至可以在你的家目录下解压文件。使用你喜欢的终端应用程序,切换到你下载文件的目录,并运行以下命令来安装 Java:

~$ cd Downloads

~/Downloads$ sudo tar xzf amazon-corretto-17.0.5.8.1-linux-x64.tar.gz \
  --directory /usr/lib/jvm

~/Downloads$ /usr/lib/jvm/amazon-corretto-17.0.5.8.1-linux-x64/bin/java -version
openjdk version "17.0.5" 2022-10-18 LTS
OpenJDK Runtime Environment Corretto-17.0.5.8.1 (build 17.0.5+8-LTS)
OpenJDK 64-Bit Server VM Corretto-17.0.5.8.1 (build 17.0.5+8-LTS, mixed mode,
 sharing)

你可以看到版本信息的第一行以LTS结尾。这是确定你是否使用长期支持版本的简单方法。Java 成功解压后,你可以通过设置JAVA_HOMEPATH环境变量来配置终端以使用该版本:

$ export JAVA_HOME=/usr/lib/jvm/amazon-corretto-17.0.5.8.1-linux-x64
$ export PATH=$JAVA_HOME/bin:$PATH

你可以使用 -version 标志来检查 Java 的版本,如 图 2-1 中所示,以测试这个设置是否工作。

你需要通过更新启动或 rc 脚本来使JAVA_HOMEPATH的更改永久化。例如,如果你使用bash作为你的 shell,你可以将 图 2-1 中的两行export命令添加到你的 .bashrc 文件中。

ljv6 0201

图 2-1. 在 Linux 上验证你的 Java 版本

在 macOS 上安装 Corretto

对于 macOS 系统的用户,Corretto 下载 和安装过程非常简单。选择你想使用的 JDK 版本,然后从随后的下载页面中选择 .pkg 链接。双击下载的文件以启动向导。

在 图 2-2 中显示的安装向导并不允许进行太多的实际定制。JDK 将被安装在运行 macOS 的磁盘上,其文件夹位于 /Library/Java/JavaVirtualMachines 目录下,并将以符号链接形式链接到 /usr/bin/java。虽然你可以选择备用的安装位置,例如在具有独立硬盘的 macOS 上,但默认设置适用于本书的目的。

ljv6 0202

图 2-2. macOS 中的 Corretto 安装向导

安装完成后,您可以通过打开通常位于全局应用程序文件夹下实用工具文件夹中的终端应用程序来测试 Java。键入java -version,您应该看到类似于图 2-3 的输出。我们在此系统上安装了版本 19,但您应该在输出中看到您下载的版本号。

ljv6 0203

图 2-3. 在 macOS 中验证您的 Java 版本

在 Windows 上安装 Corretto

Windows 上的 Corretto 安装程序(从亚马逊的网站下载.msi文件)遵循典型的 Windows 安装向导,如图 2-4 所示。您可以按照简短的提示接受默认设置,或者如果熟悉管理任务(如配置环境变量和注册表条目),也可以进行微调。如果提示允许安装程序对系统进行更改,请继续选择是。

ljv6 0204

图 2-4. Windows 中的 Corretto 安装向导

或许您不经常在 Windows 中使用命令行,但新版本的 Windows 中的终端应用程序(或旧版本中的命令提示符应用程序)具有与 macOS 或 Linux 中类似应用程序相同的功能。从 Windows 菜单中,您可以搜索termcmd,如图 2-5 所示。

ljv6 0205

图 2-5. 在 Windows 中定位终端应用程序

单击相应的结果以启动您的终端,并通过键入java -version来检查 Java 的版本。在我们的示例中,我们运行的是版本 19;您应该看到与图 2-6 类似的输出,但带有您的版本号。

ljv6 0206

图 2-6. 在 Windows 中检查 Java 版本

当然,您可以继续使用终端,但现在您还可以将其他应用程序(如 IntelliJ IDEA)指向已安装的 JDK,并简单地使用这些工具。说到 IntelliJ IDEA,让我们更详细地看一下其安装步骤。

安装 IntelliJ IDEA 并创建项目

IntelliJ IDEA 是一款 IDE,可以在JetBrains 的网站上找到。对于本书的目的和一般开始使用 Java,Community Edition 就足够了。下载是一个可执行安装程序或压缩存档:在 Windows 上是.exe,在 macOS 上是.dmg,在 Linux 上是.tar.gz。安装程序(和存档)都遵循标准程序,应该感觉很熟悉。如果您需要一点额外的指导,JetBrains 网站上的安装指南是一个很好的资源。

让我们创建一个新项目。在应用程序菜单中选择 文件 → 新建 → 项目,并在对话框顶部的“名称”字段中输入 Learning Java,如 图 2-7 所示。选择一个 JDK(版本 19 或更高版本),确保选中“添加示例代码”复选框。

ljv6 0207

图 2-7. 新建 Java 项目对话框

您可能会注意到对话框左侧的生成器列表。默认的“新项目”非常适合我们的需求。但您可以使用 Kotlin 或 Android 等模板启动其他项目。默认包括一个带有可执行 main() 方法的最小 Java 类。接下来的章节将更详细地介绍 Java 程序的结构以及可以放置在这些程序中的命令和语句。在左侧选择默认选项后,点击“创建”按钮。(如果看到下载共享索引的提示,请选择“是”。共享索引并不是必需的,但会让 IDEA 运行得更快。)您应该会得到一个包含 Main.java 文件的简单项目,如 图 2-8 所示。

ljv6 0208

图 2-8. IDEA 中的 Main

恭喜!现在您有一个 Java 程序。您将运行此示例,并在此基础上增加一些特色。接下来的章节将展示更多有趣的示例,逐步组合更多 Java 元素。尽管如此,我们始终会在类似的设置中构建这些示例。这些起步步骤是您的良好开始。

运行项目

从 IDEA 提供的简单模板开始,这样可以让您顺利运行您的第一个程序。回顾 图 2-8。注意代码编辑器左侧第 1 和第 2 行旁边的绿色三角形,分别位于 Main 类和 main() 方法旁边。IDEA 理解 Main 可以被执行。您可以单击任何这些按钮来运行您的代码。(在左侧项目大纲中的 src 文件夹下列出的 Main 类也有一个小绿色“播放”按钮。)您可以右键单击该类条目,并选择 Run ‘Main.main()’ 选项,如 图 2-9 所示。

ljv6 0209

图 2-9. 运行您的 Java 项目

无论您使用编辑器边栏按钮还是上下文菜单,现在可以运行您的代码了。您应该能在编辑器底部的运行选项卡中看到“Hello World!”消息显示,类似于 图 2-10。

ljv6 0210

图 2-10. 我们的第一个 Java 程序输出

IDE 也包括一个方便的终端选项。这允许你打开一个具有命令提示符的标签或窗口。你可能不经常需要这个选项,但它绝对会派上用场。例如,在 IDEA 中,你可以从 View → Tool Windows → Terminal 菜单选项中打开终端标签,或者通过点击主窗口底部的 Terminal 快捷方式来打开,如 Figure 2-11 所示。

ljv6 0211

Figure 2-11. IntelliJ IDEA 中的终端标签

在 VS Code 中,你可以使用 Terminal → New Terminal 菜单选项来打开一个类似的 IDE 部分,如 Figure 2-12 所示。

ljv6 0212

Figure 2-12. Microsoft 的 VS Code 中的终端标签

随时可以自行尝试终端。在 IDE 中打开终端窗口后,导航至 Learning Java 文件夹。(大多数 IDE 会自动在项目的基本目录下打开终端。)使用 java 命令来运行我们的 Main 程序,如 Figure 2-13 所示。

ljv6 0213

Figure 2-13. 在终端标签中运行 Java 程序

无论你选择哪种方式,再次祝贺你!你现在已经成功运行了你的第一个 Java 程序!

抓取示例

代码示例和练习解决方案可以在线获取,位于本书的 GitHub 仓库。GitHub 已成为公共及私有开源项目的事实标准云存储库站点。GitHub 除了简单的源代码存储和版本控制外,还有许多有用的工具。如果你打算开发一个希望与他人共享的应用程序或库,值得在 GitHub 上设置一个账户并深入探索。幸运的是,你也可以仅仅通过下载公共项目的 ZIP 文件来使用它,如 Figure 2-14 所示。

ljv6 0214

Figure 2-14. 从 GitHub 下载 ZIP 文件

你应该获得一个名为 learnjava6e-main.zip 的文件(因为你正在抓取这个仓库的“main”分支的存档)。如果你熟悉 GitHub 的其他项目,可以随意克隆该仓库,但静态 ZIP 文件包含了你阅读本书其余部分时尝试示例所需的一切内容。当你解压下载时,你会找到所有包含示例的章节文件夹,以及一个完成的 game 文件夹,其中包含一个有趣、轻松的苹果投掷游戏,以帮助在整本书中展示的许多编程概念统一应用。在接下来的章节中,我们将详细介绍示例和游戏。

如前所述,您可以从 ZIP 文件中的命令行直接编译和运行示例。您也可以将代码导入到您喜欢的 IDE 中。附录 A 详细介绍了如何将这些示例最佳地导入到 IntelliJ IDEA 中,但其他流行的 IDE,如微软的 VS Code,也可以工作。

HelloJava

为了遵循介绍性编程文本的传统,我们将从 Java 的“Hello World”应用程序等效开始,即HelloJava

在完成之前,我们会对这个示例进行几次修改(HelloJavaHelloJava2等),添加功能并介绍新概念。但让我们从最简版本开始。在您的工作空间中创建一个名为HelloJava.java的新文件(如果您使用的是 IDEA,可以从菜单中操作:文件 → 新建 → Java 类。然后给它一个名字HelloJava,不要带后缀,文件名后缀.java会自动添加)。接着,填写与创建新项目时提供的Main演示相同的main()方法即可。

// ch02/examples/HelloJava.java

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

这个五行程序声明了一个名为HelloJava的类和非常重要的main()方法。它使用了一个预定义的方法println()来输出一些文本。这是一个命令行程序,意味着它在终端或 DOS 窗口中运行,并在那里打印输出。这种方法有点老派,所以在进一步之前,我们将为HelloJava添加一个图形用户界面(GUI)。现在不要担心代码;只需跟着这里的进展走,稍后我们会回来解释。

替换包含println()方法的行,我们将使用一个JFrame对象将窗口显示在屏幕上。我们可以用以下三行代码替换println行:

// filename: ch02/examples/HelloJava.java
// method:   main()
    JFrame frame = new JFrame("Hello, Java!");
    frame.setSize(300, 150);
    frame.setVisible(true);

这段代码创建了一个标题为“Hello, Java!”的JFrame对象。JFrame代表一个图形窗口。为了显示它,我们简单地通过调用setSize()方法配置它在屏幕上的大小,并通过调用setVisible()方法使其可见。

如果我们停在这里,我们会在屏幕上看到一个空窗口,窗口的标题是“Hello, Java!”。但我们想要的是把我们的消息放在窗口里,而不只是在顶部。为了把东西放在窗口里,我们需要再加几行代码。以下完整的示例添加了一个JLabel对象,在我们的窗口中心显示文本。顶部额外的import行是必需的,告诉 Java 编译器在哪里找到我们使用的JFrameJLabel对象的定义:

// ch02/examples/HelloJava.java
package ch02.examples;

import javax.swing.*;

public class HelloJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    frame.setSize(300, 150);
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    frame.add(label);
    frame.setVisible(true);
  }
}

现在,要编译和运行这个源代码,可以右键单击你的HelloJava.java类,然后使用上下文菜单,或者在编辑器左边的绿色箭头之一上单击。参见图 2-15。

ljv6 0215

图 2-15. 运行 HelloJava 应用程序

你应该看到在图 2-16 中显示的声明。再次祝贺,你现在已经运行了你的第二个 Java 应用程序!花点时间沉浸在你的显示器的光辉中。

ljv6 0216

图 2-16. HelloJava 应用程序的输出

请注意,当您点击窗口的关闭按钮时,窗口会关闭,但您的程序仍在运行。(我们很快将修复此关闭行为。)要停止 IDEA 中的 Java 应用程序,请单击绿色播放按钮右侧的红色方形“停止”按钮。如果您在命令行上运行示例,请键入 Ctrl-C。

HelloJava可能是一个小程序,但背后的工作却不少。这几行代码代表了一个令人印象深刻的冰山尖端。表面下的是 Java 语言及其 Swing 库提供的功能层级。请记住,在本章中,我们将快速涵盖大量内容,以便向您展示整体情况。我们将尽量提供足够的细节,以便深入理解每个示例中发生的事情,但将详细说明推迟到适当的章节。这既适用于 Java 语言的元素,也适用于适用于它们的面向对象概念。说了这么多,现在让我们来看看我们第一个示例中正在发生的事情。

第一个示例定义了一个名为HelloJava的类:

public class HelloJava {
  // ...
}

类是大多数面向对象语言的基本构建块。是一组具有关联功能的数据项,可以对这些数据执行操作。类中的数据项称为变量或有时称为字段;在 Java 中,函数称为方法。面向对象语言的主要好处在于类单元中数据和功能的关联以及类能够封装或隐藏细节,使开发人员不必担心低级细节。我们将在第五章中详细展开这些优点,填充类的结构。

在应用程序中,一个类可以表示具体的东西,比如屏幕上的一个按钮或电子表格中的信息,也可以表示更抽象的东西,比如排序算法或视频游戏角色的无聊感。例如,代表电子表格的类可能具有表示其各个单元格值的变量,并且执行对这些单元格的操作的方法,如“清除行”或“计算值”。

我们的HelloJava类是一个完整的 Java 应用程序,全部定义在一个类中。它只定义了一个方法,main(),其中包含了我们程序的主体:

public class HelloJava {
  public static void main(String[] args) {
    // ...
  }
}

当应用程序启动时,首先调用的是main()方法。标记为String [] args的部分允许我们向应用程序传递命令行参数。我们将在下一节中详细讨论main()方法。

最后,虽然这个版本的 HelloJava 没有将任何变量定义为其类的一部分,但它确实在其 main() 方法中使用了两个变量,framelabel。我们以后还会详细介绍变量。

main() 方法

当我们运行示例时,可以看到运行 Java 应用程序意味着选择一个特定的类,并将其名称作为参数传递给 Java 虚拟机。当我们这样做时,java 命令会查找我们的 HelloJava 类,看它是否包含了具有恰当形式的特殊方法名为 main()。它有,这个方法就会被执行。如果 main() 方法不存在,我们将收到一个错误消息。main() 方法是应用程序的入口点。每个独立的 Java 应用程序都包含至少一个具有 main() 方法的类,该方法执行启动程序其余部分所需的操作。

我们的 main() 方法设置了一个窗口(一个 JFrame)来容纳 HelloJava 类的可视输出。现在,main() 在应用程序中承担着所有工作。但在面向对象的应用程序中,我们通常将责任委托给许多不同的类。在我们示例的下一个版本中,我们将执行这样的拆分——创建第二个类——我们将看到随着示例的演变,main() 方法保持不变,仅保持启动过程。

让我们快速浏览一下我们的 main() 方法,这样你就知道它的作用。首先,main() 创建了一个 JFrame,这个窗口将容纳我们的示例:

    JFrame frame = new JFrame("Hello, Java!");

代码中这一行的 new 关键字非常重要。JFrame 是一个代表屏幕上窗口的类的名称,但这个类本身只是一个模板,就像一个建筑计划一样。new 关键字告诉 Java 分配内存并实际创建一个特定的 JFrame 对象。在这种情况下,括号内的参数告诉 JFrame 在其标题栏中显示什么。我们本可以省略“Hello, Java!”文本,并使用空括号创建一个没有标题的 JFrame,但这仅仅是因为 JFrame 明确允许我们这样做。

当框架窗口首次创建时,它们非常小。在显示 JFrame 之前,让我们将其大小设置为合理的值:

    frame.setSize(300, 150);

这是在特定对象上调用方法的一个例子。在这种情况下,setSize() 方法由 JFrame 类定义,并影响我们放置在变量 frame 中的特定 JFrame 对象。与框架一样,我们还创建了 JLabel 的实例来在窗口内部保存我们的文本:

    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);

JLabel 很像一个实际的标签。它在特定位置保存一些文本——在我们的框架上,在这种情况下。这是一个非常面向对象的概念:使用对象来保存一些文本,而不是简单地调用一个方法来“绘制”文本并继续。这背后的原理将在稍后变得更清楚。

接下来,我们必须将标签放入我们创建的框架中:

    frame.add(label);

在这里,我们调用一个名为add()的方法,将我们的标签放在JFrame内。JFrame是一种可以容纳物件的容器。稍后我们会详细讨论这个。main()的最后任务是显示窗体窗口及其内容,否则它们将是不可见的。一个看不见的窗口会使应用程序变得非常无聊:

    frame.setVisible(true);

这就是整个main()方法。当我们在本章的示例中继续前进时,它将在其周围进化的HelloJava类基本保持不变。

类和对象

类是应用程序部分的蓝图;它包含组成该组件的方法和变量。在应用程序运行时,可以存在许多给定类的个体工作副本。这些个体化的实例被称为该类的实例对象。给定类的两个实例可能包含不同的数据,但它们始终具有相同的方法。

Button类为例。只有一个Button类,但一个应用程序可以创建许多不同的Button对象,每个都是同一类的一个实例。此外,两个Button实例可能包含不同的数据,也许给每个提供不同的外观和执行不同的操作。在这个意义上,类可以被认为是制造它所代表的对象的模具,就像一个曲奇饼干切割机在计算机的内存中制造它的工作实例一样。正如你后来会看到的,类实际上可以在其实例之间共享信息,但现在这个解释足够了。第五章中有关类和对象的完整内容。

在 Java 中,术语对象非常通用,有时几乎可以与互换使用。对象是所有面向对象语言中以某种形式引用的抽象实体。我们将对象用作类的实例的通用术语。因此,我们可能会将Button类的一个实例称为按钮,一个Button对象,或者不加区分地称为对象。在接下来的章节中,你会经常看到这个术语,并且第五章会更详细地讨论类和对象。

在上一个示例中,main()方法创建了JLabel类的一个实例,并在JFrame类的一个实例中显示它。你可以修改main()以创建许多JLabel的实例,也许每个在一个单独的窗口中。

变量和类类型

在 Java 中,每个类都定义了一个新的类型(数据类型)。你可以声明这种类型的变量,然后它可以保存该类的实例。例如,变量可以是Button类型,并保存Button类的实例,或者是SpreadSheetCell类型,并保存SpreadSheetCell对象,就像它可以是更简单的类型之一,比如intchar。变量具有类型并且不能简单地保存任何类型的对象,这是 Java 的另一个重要特性,确保了代码的安全性和正确性。

暂时不考虑main()方法中使用的变量,我们的简单HelloJava示例中只声明了另一个变量。它出现在main()方法的声明中:

  public static void main(String [] args) {
    // ...
  }

就像其他语言中的函数一样,Java 中的方法声明一个接受参数(变量)的列表作为参数,并指定这些参数的类型。在这种情况下,主method要求在调用时,传递一个名为argsString对象数组作为参数。String是 Java 中表示文本的基本对象。正如我们早些时候暗示的那样,Java 使用args参数将任何提供给 Java 虚拟机的命令行参数传递到你的应用程序中(我们这里没有使用它们,但稍后会用到)。

到目前为止,我们宽泛地讨论变量保存对象的问题。实际上,具有类类型的变量不会保存对象——它们只是引用对象。引用是指向对象的指针或句柄。如果你声明一个类类型的变量但没有为其分配对象,它将被赋予默认值null,表示“无值”。如果你尝试像操作指向真实对象一样使用具有null值的变量,将会发生运行时错误,即NullPointerException

当然,对象引用必须来自某处。在我们的例子中,我们使用new运算符创建了两个对象。稍后在本章节,我们会更详细地讨论对象的创建。

HelloComponent

到目前为止,我们的HelloJava示例一直包含在一个单独的类中。实际上,因为它的简单性,它真的只是一个大方法。尽管我们已经使用了一些对象来显示我们的 GUI 消息,但我们自己的代码并没有展示任何面向对象的结构。

嗯,我们现在要通过添加第二个类来修正这个问题。为了在本章节中有所建树,我们将接管JLabel类的工作(再见,JLabel!),并将其替换为我们自己的图形类:HelloComponent。我们的HelloComponent类将从简单开始,只在固定位置显示我们的“Hello, Java!”消息。稍后我们会添加更多功能。

我们的新类代码很简单;我们只需要几行代码。首先,我们需要在HelloJava.java文件的顶部加上另一个import语句:

import java.awt.*;

此行告诉编译器在哪里找到我们需要填充HelloComponent逻辑的额外类。这就是那个逻辑:

class HelloComponent extends JComponent {
  public void paintComponent(Graphics g) {
    g.drawString("Hello, Java!", 125, 95);
  }
}

HelloComponent类定义可以放在我们的HelloJava类的上方或下方。然后,要在main()方法中使用我们的新类来替换引用标签的两行代码:

    // Delete or comment out these two lines
    //JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    //frame.add(label);

    // And add this line
    frame.add(new HelloComponent());

这次当您编译HelloJava.java时,请查看生成的.class文件。(这些文件将位于您当前目录(如果您正在使用终端)或您选择放置 IDEA 项目的Learn Java/out/production/Learn Java文件夹中。在 IDEA 中,您也可以在项目导航窗格的左侧展开out文件夹。)无论您如何安排源代码中的类,您都应该看到两个二进制类文件:HelloJava.classHelloComponent.class。运行代码应该看起来很像JLabel版本,但是如果您调整窗口大小,您会注意到我们的新组件不会自动调整以使文本居中。

那么我们到底做了什么,为什么要如此费力地侮辱完全正常的JLabel组件?我们创建了我们的新HelloComponent类,扩展了一个称为JComponent的通用图形类。扩展一个类只是指向现有类添加功能,从而创建一个新类。我们将在下一节更详细地介绍这个过程。

在我们当前的示例中,我们创建了一种新的JComponent类型,其中包含一个称为paintComponent()的方法,负责绘制我们的消息。paintComponent()方法接受一个名为(有些简洁)g的参数,类型为Graphics。当调用paintComponent()方法时,将一个Graphics对象分配给g,我们在方法体中使用它。稍后我们会详细介绍paintComponent()Graphics类。至于为什么这样做,待我们稍后为我们的新组件添加各种新功能时,您就会理解。

继承

Java 类以父子层次结构排列,其中父类和子类分别称为超类子类。我们将在第五章中更深入地探讨这些概念。在 Java 中,每个类都恰好有一个超类(一个单一的父类),但可能有许多子类。唯一的例外是Object类,它位于整个类层次结构的顶端;它没有超类。(可以提前查看在图 2-17 中显示的 Java 类层次结构的一个小片段。)

在前面示例中声明我们的类时,使用关键字extends指定HelloComponentJComponent类的一个子类:

class HelloComponent extends JComponent { ... }

子类可以继承其超类的一些或所有变量和方法。继承使得子类可以访问其超类的变量和方法,就像它自己声明了它们一样。子类可以添加自己的变量和方法,并且还可以覆盖或改变继承方法的含义。当我们使用子类时,被覆盖的方法被子类自己的版本所隐藏(替换)。通过这种方式,继承提供了一个强大的机制,使得子类可以改进或扩展其超类的功能。

例如,假设电子表格类可以派生为新的科学电子表格类,其中内置了特殊的常量。在这种情况下,科学电子表格的源代码可能声明了用于特殊常量的变量,但新的科学类仍然具有构成标准电子表格正常功能的所有变量(和方法)。同样,这些标准元素是从父电子表格类继承而来。这也意味着科学电子表格保持其作为电子表格的身份;它仍然可以执行较简单电子表格的所有功能。这个想法,即更具体的类仍然可以执行更一般的父类或祖先的所有职责,具有深远的意义。我们称这个想法为多态性,我们将在整本书中继续探讨它。多态性是面向对象编程的基础之一。

我们的 HelloComponent 类是 JComponent 类的一个子类,并继承了许多在我们源代码中没有明确声明的变量和方法。这使得我们微小的类能够在 JFrame 中作为组件使用,仅需少量定制。

JComponent

JComponent 类提供了构建各种 UI 组件的框架。特定的组件,如按钮、标签和列表框,都作为 JComponent 的子类来实现。

我们提到子类可以继承一个方法并重写它以实现某些特定行为。但是为什么我们要改变已经对超类有效的东西的行为呢?许多类从最小功能开始。最初的程序员希望其他人来添加有趣的部分。JComponent 就是这样的一个类。它为您处理与计算机窗口系统的大量通信,但它留下了空间让您添加特定的呈现和行为细节。

paintComponent() 方法是 JComponent 类的一个重要方法;我们重写它来实现我们特定组件在屏幕上的显示方式。paintComponent() 的默认行为根本不进行任何绘制。如果我们在子类中没有重写它,我们的组件将会是空的。在这里,我们重写 paintComponent() 来做一些稍微有趣的事情。我们不重写 JComponent 的任何其他继承成员,因为它们提供了基本功能和合理的默认值,适用于这个(微不足道的)示例。随着 HelloJava 的发展,我们将深入研究继承成员并使用额外的方法。我们还会添加一些特定于应用程序的方法和变量,以满足 HelloComponent 的需求。

JComponent 实际上是另一个被称为 Swing 的冰山的顶端。Swing 是 Java 的 UI 工具包,在我们的示例中通过顶部的 import 语句表示;我们将在 第十二章 中详细讨论 Swing。

关系和指向

您可以将子类化视为创建一个“is a”关系,其中子类“is a”其超类的一种。因此,HelloComponentJComponent 的一种。当我们提到对象的一种类型时,我们指的是该对象类的任何实例或其任何子类的任何实例。稍后,我们将更详细地查看 Java 类层次结构,并看到 JComponent 本身是 Container 类的子类,后者进一步派生自一个称为 Component 的类,如 图 2-17 所示。

在这个意义上,HelloComponent 对象是 JComponent 的一种,而 JComponent 又是 Container 的一种,所有这些最终都可以被认为是 Component 的一种。正是从这些类中,HelloComponent 继承了它的基本 GUI 功能,以及(稍后我们将讨论的)嵌入在其中的其他图形组件的能力。

ljv6 0217

图 2-17. Java 类层次结构的部分

Component 是顶级 Object 类的一个子类,因此所有这些类都是 Object 的类型。Java API 中的每个其他类都从 Object 继承行为,Object 定义了一些基本方法,正如你将在 第五章 中看到的。我们将继续使用 object(小写 o)一词以通用方式指代任何类的实例;我们将使用 Object 来具体指代这个类的类型。

包和导入

我们之前提到我们示例的第一行告诉 Java 在哪里找到我们使用的一些类:

import javax.swing.*;

具体来说,它告诉编译器我们将使用来自 Swing GUI 工具包的类(在本例中是JFrameJLabelJComponent)。这些类组织成一个名为javax.swing的 Java 。在 Java 中,包是按目的或应用程序相关联的一组类。同一包中的类彼此之间具有特殊的访问权限,并且可能被设计为紧密协作。

包名称采用点分隔的分层方式命名,例如java.utiljava.util.zip。包中的类通常存储在匹配其包名称的嵌套文件夹中。它们的“全名”或正确术语称为完全限定名称中也包含包的名称作为其一部分。例如,JComponent类的完全限定名称是javax.swing.JComponent。我们本可以直接用这个名字引用它,而不使用import语句:

class HelloComponent extends javax.swing.JComponent {...}

使用完全限定名称可能会令人厌烦。语句import javax.swing.*使我们能够通过它们的简单名称引用javax.swing包中的所有类。我们不必使用完全限定名称来引用JComponentJLabelJFrame类。

当我们添加第二个示例类时,我们看到在给定的 Java 源文件中可能会有一个或多个import语句。这些import语句有效地创建了一个“搜索路径”,告诉 Java 在何处寻找我们用简单、未限定名称引用的类。(实际上它并不是路径,但它避免了可能导致错误的模糊名称。)我们已经看到的import使用点星(.*)符号来指示整个包应该被导入。但你也可以指定单个类。例如,我们当前的示例只使用了java.awt包中的Graphics类。我们本可以使用import java.awt.Graphics而不是使用通配符*来导入所有抽象窗口工具包(AWT)的类。但是,我们预计稍后会使用此包中的几个其他类。

java.javax.包层次结构是特殊的。任何以java.开头的包都是核心 Java API 的一部分,并且在支持 Java 的任何平台上都可用。javax.包通常表示核心平台的标准扩展,可能已安装或未安装。然而,近年来,许多标准扩展已添加到核心 Java API 中而未重命名。javax.swing包就是一个例子;尽管其名称如此,它仍然是核心 API 的一部分。Figure 2-18 展示了一些核心 Java 包,展示了每个包中的一个或两个典型类。

ljv6 0218

图 2-18. 一些核心 Java 包

java.lang 包含 Java 语言本身所需的基本类; 这个包被自动导入,这就是为什么在我们的示例中使用 StringSystem 等类名时不需要 import 语句的原因。 java.awt 包含较旧的图形窗口系统的类; java.net 包含网络类; 依此类推。

随着您对 Java 的经验越来越丰富,您将意识到熟练掌握可用于您的包、它们的作用以及何时以及如何使用它们是成为成功的 Java 开发人员的关键部分。

paintComponent() 方法

我们的 HelloComponent 类的源代码定义了一个方法,paintComponent(),它重写了 JComponent 类的 paintComponent() 方法:

  public void paintComponent(Graphics g) {
    g.drawString("Hello, Java!", 125, 95);
  }

当我们的示例需要在屏幕上绘制自己时,将调用 paintComponent() 方法。 它接受一个参数,一个 Graphics 对象,并且不会向其调用者返回任何类型的值(void)。

修饰符 是放置在类、变量和方法之前的关键字,用于改变它们的可访问性、行为或语义。 在这里 paintComponent() 被声明为 public,这意味着它可以被除了 HelloComponent 之外的类中的方法调用。 在这种情况下,是 Java 窗口环境调用我们的 paintComponent() 方法。 相比之下,被声明为 private 的方法或变量只能从它自己的类中访问。

Graphics 对象,Graphics 类的一个实例,表示特定的图形绘制区域。(它也被称为图形上下文。) 它包含可以用于在此区域绘制的方法,以及表示特征的变量,如剪切或绘图模式。 我们在 paintComponent() 方法中收到的特定 Graphics 对象对应于我们的 HelloComponent 屏幕上的区域,位于我们的框架内部。

Graphics 类提供了用于呈现形状、图像和文本的方法。 在 HelloComponent 中,我们调用我们的 Graphics 对象的 drawString() 方法来在指定的坐标上书写我们的消息。

正如我们之前所见,我们通过将一个点(.)和其名称附加到持有它的对象上来访问对象的方法。 我们以这种方式调用了 Graphics 对象(由我们的 g 变量引用)的 drawString() 方法:

    g.drawString("Hello, Java!", 125, 95);

在这里,我们可以看到如何重写继承的方法提供了新的功能。 单独看,JComponent 的实例不知道要向用户显示什么信息,也不知道如何响应鼠标点击等操作。 我们扩展了 JComponent 并添加了一点自定义逻辑:我们在屏幕上显示一点文本。 但是我们还可以做得更多!

HelloJava2: 续集

现在我们已经掌握了一些基础知识,让我们让我们的应用程序更加交互。以下小升级允许我们用鼠标拖动消息文本。如果你是新手程序员,这个升级可能并不那么小。不要担心!我们将在后面的章节中仔细查看这个示例中涉及的所有主题。现在,享受玩这个例子,并将其用作创建和运行 Java 程序的机会,即使你对代码内部感觉不那么自在。

我们将这个示例称为HelloJava2,而不是通过继续扩展旧示例来引起混淆,但这里和以后的主要变化在于向HelloComponent类添加功能,并简单地对名称进行相应的更改,以保持它们的清晰性(例如,HelloComponent2HelloComponent3等)。刚刚看到继承的作用,你可能会想知道为什么我们不创建HelloComponent的子类,并利用继承来构建我们之前示例的基础上扩展其功能。嗯,在这种情况下,这并没有提供太多优势,所以为了清晰起见,我们简单地重新开始。

连续两个斜杠表示该行的其余部分是注释。我们已经向HelloJava2添加了一些注释,以帮助你跟踪一切:

//file: HelloJava2.java
import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class HelloJava2 {
  public static void main(String[] args) {
    JFrame frame = new JFrame("HelloJava2");
    frame.add(new HelloComponent2("Hello, Java!"));
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(300, 300);
    frame.setVisible(true);
  }
}

class HelloComponent2 extends JComponent
    implements MouseMotionListener {
  String theMessage;
  int messageX = 125, messageY = 95; // Coordinates of the message

  public HelloComponent2(String message) {
    theMessage = message;
    addMouseMotionListener(this);
  }

  public void paintComponent(Graphics g) {
    g.drawString(theMessage, messageX, messageY);
  }

  public void mouseDragged(MouseEvent e) {
    // Save the mouse coordinates and paint the message.
    messageX = e.getX();
    messageY = e.getY();
    repaint();
  }

  public void mouseMoved(MouseEvent e) { }
}

如果你正在使用 IDEA,请创建一个名为HelloJava2的新 Java 类,并复制上面的代码。如果你继续使用终端,请将此示例的文本放入一个名为HelloJava2.java的新文件中。无论哪种方式,你都希望像以前一样进行编译。你应该得到新的类文件HelloJava2.classHelloComponent2.class作为结果。

如果你在 IDEA 中进行跟进,请点击HelloJava2旁边的运行按钮。如果你使用终端,请使用以下命令运行示例:

C:\> java HelloJava2

随意用你自己的胜利性评论替换“Hello, Java!”消息,并享受用鼠标拖动文本多个小时的乐趣。注意,现在当你点击窗口的关闭按钮时,应用程序会正常退出;当我们讨论事件时,我们将在稍后解释这一点。让我们深入了解一下发生了什么变化。

实例变量

我们在我们的示例中向HelloComponent2类添加了一些变量:

    int messageX = 125, messageY = 95;
    String theMessage;

messageXmessageY是保存我们可移动消息的当前坐标的整数。我们已经将它们设置为默认值,应该将消息放置在窗口的大致中心。Java 整数是 32 位有符号数,因此它们可以轻松保存我们的坐标值。变量theMessageString类型,可以保存String类的实例。

您应该注意,这三个变量声明在类定义的大括号内,但不是在该类的任何特定方法内。这些变量称为实例变量,它们属于整个对象。每个类的各个实例中都会有它们的独立副本。实例变量始终对它们所属类内的所有方法可见(并可用)。根据其修饰符,它们也可能可以从类外部访问。

除非另有初始化(程序员术语表示设置某物的第一个值),否则实例变量将被设置为其类型的默认值:0falsenull,具体取决于其类型。数值类型被设置为0,布尔变量被设置为false,类类型变量始终具有null值。

实例变量与方法参数和其他在特定方法作用域内声明的变量不同。后者称为局部变量。它们实际上是只能被方法内部代码看到的私有变量。Java 不会初始化局部变量,因此您必须自行分配值。如果尝试使用尚未分配值的局部变量,您的代码将生成编译时错误。局部变量只在方法执行期间存在,然后消失,除非其他内容保存了它们的值。每次调用方法时,都会重新创建其局部变量,并且必须为其分配值。

我们已经使用了新的变量来使我们之前单调的paintComponent()方法更加动态。现在drawString()调用中的所有参数都由这些变量确定。

构造方法

HelloComponent2类包含一种特殊类型的方法,称为构造方法。构造方法用于设置类的新实例。当创建一个新对象时,Java 为其分配存储空间,将实例变量设置为它们的默认值,并调用类的构造方法来执行任何应用级别的设置。

构造方法的名称始终与其类的名称相同。例如,HelloComponent2类的构造方法称为HelloComponent2()。构造方法没有返回类型,但您可以将它们视为创建其类类型对象的方法。与其他方法一样,构造方法可以有参数。它们的唯一使命是配置和初始化新创建的类实例,可能使用传递给它们的参数中的信息。

使用new运算符创建对象时,需指定类的构造方法和任何必要的参数。¹ 创建的对象实例作为返回值返回。在我们的示例中,main()方法中通过以下行创建了一个新的HelloComponent2实例:

    frame.add(new HelloComponent2("Hello, Java!"));

这一行实际上做了两件事情。为了更清楚地表达,我们可以将它们写成两个单独的行,这样更容易理解:

    HelloComponent2 newObject = new HelloComponent2("Hello, Java!");
    frame.add(newObject);

第一行是重要的一行,这里创建了一个新的HelloComponent2对象。HelloComponent2的构造函数接受一个String作为参数,并且按照我们的安排使用该参数来设置在窗口中显示的消息。通过 Java 编译器的一些魔法,Java 源代码中的引号文本被转换为一个String对象(参见第八章对String类的更深入讨论)。第二行简单地将我们的新组件添加到框架中,以使其可见,就像我们在前面的示例中所做的那样。

顺便说一下,如果你想让我们的消息可配置,你可以将构造函数调用改为以下形式之一:

    HelloComponent2 newobj = new HelloComponent2(args[0]);

现在你可以在运行应用程序时通过以下命令在命令行传递文本:

C:\> java HelloJava2 "Hello, Java!"

args[0]指的是第一个命令行参数。在我们讨论数组时,它的意义会变得更清晰(参见第四章)。如果你在使用 IDE,你需要配置它以接受你的参数然后再运行它。IntelliJ IDEA 有一个叫做run configuration的东西,你可以在点击绿色播放按钮时从弹出的菜单中编辑它。Run configuration 有很多选项,但我们关注的是“Program Arguments”文本框,如图 2-19 所示。请注意,在命令行和 IDE 中,你必须用双引号将你的短语括起来,以确保文本被视为一个参数。如果你不加引号,Hello,Java!会被视为两个独立的参数。

ljv6 0219

图 2-19. IDEA 对话框用于提供命令行参数

HelloComponent2的构造函数接着做了两件事情:它设置了theMessage实例变量的文本,并调用了addMouseMotionListener()。这个方法是事件机制的一部分,我们接下来会讨论它。它告诉系统:“嘿,我对任何涉及鼠标移动的事情感兴趣”:

  public HelloComponent2(String message) {
    theMessage = message;
    addMouseMotionListener(this);
  }

特殊的只读变量this用于显式地引用我们的对象(“当前”对象上下文)在调用addMouseMotionListener()时。一个方法可以使用this来引用持有它的对象的实例。因此,以下两个语句是将值赋给theMessage实例变量的等效方式:

    theMessage = message;

或者:

    this.theMessage = message;

通常,我们会使用更短的、隐式形式来引用实例变量,但当我们必须显式地将对象的引用传递给另一个类中的方法时,我们会需要使用this。我们经常传递这样的引用,以便其他类中的方法可以调用我们的公共方法或使用我们的公共变量。

事件

HelloComponent2的最后两个方法,mouseDragged()mouseMoved(),告诉 Java 传递任何可能从鼠标获取的信息。每当用户执行操作,比如在键盘上按键,移动鼠标,或者可能在触摸屏上撞击头部时,Java 就会生成一个事件。事件代表发生的动作;它包含关于动作的信息,比如时间和位置。大多数事件与应用程序中特定的 GUI 组件相关联。例如,按下键盘可以对应将字符输入到特定的文本输入字段中。点击鼠标按钮可以激活屏幕上的特定按钮。甚至只是在屏幕的某个区域内移动鼠标也可以触发效果,如突出显示文本或更改光标的形状。

要处理这些事件,我们已经导入了一个新的包,java.awt.event,它提供了特定的Event对象,我们用这些对象来从用户那里获取信息。(请注意,导入java.awt.*并不会自动导入event包。导入不是递归的。包实际上并不包含其他包,即使层次命名方案会暗示它们包含。)

其中有数十种事件类,包括MouseEventKeyEventAction​E⁠vent。在大多数情况下,这些事件的含义相当直观。当用户使用鼠标时,会发生MouseEvent,当用户按下或释放键时会发生KeyEvent,等等。ActionEvent有点特殊;我们将在第十二章中看到它的运作。现在,我们将专注于处理MouseEvent

Java 中的 GUI 组件为特定类型的用户操作生成事件。例如,如果您在组件内部点击鼠标,组件将生成鼠标事件。对象可以请求从一个或多个组件接收事件,方法是通过将事件源的监听器注册到该组件。例如,要声明监听器希望接收组件的鼠标移动事件,可以调用该组件的addMouseMotionListener()方法,并将监听器对象作为参数传递。这就是我们示例在其构造函数中正在执行的操作。在这种情况下,组件调用其自己的addMouseMotionListener()方法,并将参数this传递进去,意思是“我希望接收自己的鼠标移动事件”。

这就是我们注册以接收事件的方式。但是我们如何实际获取它们呢?这就是我们类中两个与鼠标相关的方法的作用。mouseDragged()方法在监听器上自动调用以接收用户拖动鼠标时生成的事件,即移动鼠标并点击任意按钮。当用户在未点击按钮的情况下移动鼠标时,mouseMoved()方法被调用。

在这种情况下,我们将这些方法放在我们的HelloComponent2类中,并让它注册自己作为监听器。这对于我们的新文本拖动组件来说是完全适当的。更普遍地说,良好的设计通常规定事件监听器应该作为适配器类来实现,这样可以更好地分离 GUI 和“业务逻辑”。适配器类是一个方便的中间类,它实现了接口的所有方法并提供一些默认行为。我们将在第十二章中详细讨论事件、监听器和适配器。

我们的mouseMoved()方法很无聊:它什么也不做。我们忽略简单的鼠标移动,保留我们的注意力在拖动上。但是我们必须提供某种实现——即使是空实现——因为MouseMotionListener接口包含它。另一方面,我们的mouseDragged()方法有一些内容。窗口系统会重复调用此方法,以向我们提供用户拖动鼠标时鼠标位置的更新。这是它的工作方式:

  public void mouseDragged(MouseEvent e) {
    messageX = e.getX();
    messageY = e.getY();
    repaint();
  }

mouseDragged()的唯一参数是一个MouseEvent对象,e,它包含关于此事件的所有信息。我们通过调用它的getX()getY()方法询问MouseEvent来告诉我们鼠标当前位置的xy坐标。我们将这些保存在messageXmessageY实例变量中,以便在其他地方使用。

事件模型的美妙之处在于您只需要处理您想要的事件类型。如果您不关心键盘事件,您就不会为它们注册监听器;用户可以随心所欲地输入,而您则不会受到干扰。如果没有特定类型事件的监听器,Java 甚至不会生成它。结果是,事件处理非常高效。²

在讨论事件时,我们应该提到我们在HelloJava2中添加的另一个小的补充:

    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

此行告诉框架在单击其关闭按钮时退出应用程序。它被称为“默认”关闭操作,因为这种操作像几乎每个其他 GUI 交互一样,都受事件控制。我们可以注册一个窗口监听器来在用户单击关闭按钮时通知我们,并采取任何我们喜欢的操作,但这种方便的方法处理了常见情况。

最后,我们在这里绕了几个其他问题。系统如何知道我们的类包含必要的mouseDragged()mouseMoved()方法?这些名称从哪里来?为什么我们必须提供一个不做任何事情的mouseMoved()方法?这些问题的答案与接口有关。在处理完repaint()的一些未完成的事务后,我们将涉及接口。

repaint()方法

因为我们在拖动鼠标时更改消息的坐标,所以我们希望HelloComponent2重新绘制自己。我们通过调用repaint()来实现这一点,这会请求系统在稍后的时间重新绘制屏幕。我们不能直接调用paintComponent(),即使我们想这样做,因为我们没有要传递给它的图形上下文。

我们可以使用JComponent类的repaint()方法来请求重新绘制我们的组件。repaint()会导致 Java 窗口系统在下一个可能的时间调用我们的paintComponent()方法;Java 会提供必要的Graphics对象,如图 2-20所示。

ljv6 0220

图 2-20. 调用repaint()方法

这种操作模式不仅仅是因为没有正确的图形上下文而带来的不便。它的最大优势在于重绘行为由其他部分处理,而我们可以自由地继续进行我们的业务。Java 系统有一个单独的专用执行线程来处理所有的repaint()请求。它可以根据需要调度和合并repaint()请求,这有助于防止在像滚动这样的绘图密集型场景中使窗口系统不堪重负。另一个优点是,所有的绘图功能必须通过我们的paintComponent()方法封装;我们不会被诱惑将它分散到应用程序的各个部分(这可能会增加维护的难度)。

接口

现在是时候解决我们之前避开的一些问题了:系统如何知道在鼠标事件发生时调用mouseDragged()?它仅仅是知道mouseDragged()是我们事件处理方法必须具有的某种魔法名称吗?不完全是;答案涉及到接口的讨论,这是 Java 语言中最重要的特性之一。

接口的第一个迹象出现在引入HelloComponent2类的代码行上。我们说这个类实现MouseMotionListener接口:

class HelloComponent2 extends JComponent implements MouseMotionListener {
  // ...
  public void mouseMoved(MouseEvent e) {
    // Your own logic goes here
  }

  public void mouseDragged(MouseEvent e) {
    // Your own logic goes here
  }
}

本质上,接口是类必须具有的方法列表;这个特定的接口要求我们的类具有称为mouseDragged()mouseMoved()的方法。接口并不规定这些方法必须做什么;事实上,我们的mouseMoved()根本什么也不做。它确实指出这些方法必须以MouseEvent作为参数并且返回无值(这就是void的含义)。

接口是您、代码开发人员和编译器之间的契约。通过声明您的类实现MouseMotionListener接口,您表示这些方法将供系统的其他部分调用。如果您没有提供它们,将会发生编译错误。这就是为什么我们需要一个mouseMoved()方法的原因;即使我们提供的这个方法什么也不做,MouseMotionListener接口也要求我们必须有一个。

Java 分发版附带许多定义类必须执行的接口。编译器与类之间的这种契约概念非常重要。有许多情况,如我们刚刚看到的,您并不关心某个东西的具体类别;您只关心它具备某些功能,例如监听鼠标事件。接口为我们提供了一种根据对象能力而不知道或不关心其实际类型来操作对象的方式。在我们作为面向对象语言使用 Java 方面,它们是一个极其重要的概念。我们将在第五章中详细讨论它们。

第五章 还讨论了接口如何为 Java 规则提供了某种逃脱口,即任何新类只能扩展一个类(“单继承”)。在 Java 中,一个类只能扩展一个类,但可以实现任意多个接口。接口可以用作数据类型,可以扩展其他接口(但不能扩展类),并且可以被类继承(如果类 A 实现了接口 B,则 A 的子类也实现了 B)。关键的区别在于,类并不实际从接口继承方法;接口仅仅指定了类必须拥有的方法。

再见和再见

嗯,是时候告别 HelloJava 了。我们希望您已经对 Java 语言的一些特性以及编写和运行 Java 程序的基础有了一定的了解。这个简短的介绍应该有助于您探索使用 Java 进行编程的详细内容。如果您对这里介绍的一些材料感到有些困惑,不要灰心。我们将在整本书中的各自章节中再次详细讨论这里介绍的所有主要内容。这个教程旨在通过让您理解重要的概念和术语,使您的大脑为下次听到它们时有所准备。

在下一章中,我们将更好地了解 Java 世界的工具。我们将详细了解我们已经介绍的命令,比如 javac,以及其他重要的实用程序。继续阅读,向 Java 开发人员中的几位新朋友打招呼!

复习问题

这里有一些复习问题,以确保您掌握了本章的关键内容:

  1. 您用什么命令来编译 Java 源文件?

  2. 当运行 Java 类时,JVM 如何知道从何处开始?

  3. 在创建新类时,能够扩展多个类吗?

  4. 在创建新类时,能够实现多个接口吗?

  5. 哪个类代表图形应用程序中的主窗口?

代码练习

对于你的第一个编程练习,³ 创建一个GoodbyeJava类,它的功能与第一个 HelloJava 程序一样,只是显示“Goodbye, Java!”的消息而已。尝试命令行版本或图形版本——或两者都试试!随意复制原始程序的尽可能多的部分。记得编译并运行你的GoodbyeJava类,以帮助练习执行 Java 应用程序的过程。在接下来的几章中,你肯定会得到更多的练习,但是现在更多地熟悉你的 IDE 或者javacjava命令将有助于你阅读接下来的几章。

¹ 参数参数这两个术语经常被交替使用。这大多数情况下都没问题,但从技术上讲,当定义方法或构造函数时,你提供参数的类型和名称。在调用方法或构造函数时,你提供参数来填充这些参数。

² Java 1.0 中的事件处理是一个完全不同的故事。在早期,Java 没有事件监听器的概念,所有的事件处理都是通过覆盖基础 GUI 类中的方法来完成的。这种做法效率低下,导致设计不佳,高度专业化的组件层出不穷。

³ 你可以在源代码的exercises文件夹中找到每章编程挑战的解决方案。 附录 A 包含了关于下载和使用源代码的详细信息。 附录 B 包含了每章末尾问题的答案以及对每章代码解决方案的提示。

第三章:工具介绍

虽然您几乎可以肯定大部分 Java 开发都会在诸如 VS Code 或 IntelliJ IDEA 的 IDE 中进行,但您下载的 JDK 中包含了构建 Java 应用程序所需的所有核心工具。当我们编写 Java 源代码时,Java 编译器—javac—将我们的源代码转换为可用的字节码。当我们想要测试该字节码时,Java 命令本身—java—是我们用来执行程序的工具。当我们编译并使所有类一起工作时,Java 存档工具—jar—允许我们将这些类捆绑起来进行分发。在本章中,我们将讨论一些这些命令行工具,您可以使用它们来编译、运行和打包 Java 应用程序。JDK 中还包含许多其他开发工具,例如用于交互式工作的 jshell 或用于反编译类文件的 javap。我们无法在本书中讨论所有这些工具,但无论何时另一个工具可能有用,我们都会提到它。(而且我们肯定会看看 jshell。它非常适合快速尝试新的类或方法。)

我们希望您能熟悉这些命令行工具,即使您通常不在终端或命令窗口中工作。这些工具的一些功能在 IDE 中不易访问。您可能还会遇到 IDE 不实用或根本无法使用的情况。例如,系统管理员和 DevOps 工程师通常只能通过文本连接访问其在时髦数据中心运行的服务器。如果您需要通过这种连接修复 Java 问题,这些命令行工具将是必不可少的。

JDK 环境

安装 JDK 后,java 运行命令通常会自动出现在您的路径中(可用于运行),尽管并非总是如此。此外,除非将 Java 的 bin 目录添加到执行路径中,否则 JDK 提供的许多其他命令可能不可用。为确保无论您的设置如何都能访问所有工具,以下命令展示了如何在 Linux、macOS 和 Windows 上正确配置开发环境。您需要为 Java 的位置定义一个新的环境变量,并将该 bin 文件夹追加到现有的路径变量中。(操作系统使用 环境变量 存储应用程序运行时可以使用和可能共享的信息碎片。)当然,您需要根据您安装的 Java 版本更改我们示例中的路径:

# Linux
export JAVA_HOME=/usr/lib/jvm/jdk-21-ea14
export PATH=$PATH:$JAVA_HOME/bin

# Mac OS X
export JAVA_HOME=/Users/marc/jdks/jdk-21-ea14/Contents/Home
export PATH=$PATH:$JAVA_HOME/bin

# Windows
set JAVA_HOME=c:\Program Files\Java\jdk21
set PATH=%PATH%;%JAVA_HOME%\bin

在 macOS 上,情况可能更加混乱,因为操作系统的最新版本预装了 Java 命令的“存根”。苹果不再提供自己的 Java 实现,因此如果您尝试运行这些命令之一,操作系统将提示您在那时下载 Java。

如果有疑问,确定 Java 是否已安装以及您正在使用的工具版本的首选测试是在javajavac命令上使用-version标志:

% java -version

openjdk version "21-ea" 2023-09-19
OpenJDK Runtime Environment (build 21-ea+14-1161)
OpenJDK 64-Bit Server VM (build 21-ea+14-1161, mixed mode, sharing)

% javac -version

javac 21-ea

我们版本输出中的ea表示这是一个“早期访问”版本。(在我们撰写本版本时,Java 21 仍在测试中。)

Java 虚拟机

Java 虚拟机(VM)是实现 Java 运行时系统并执行 Java 应用程序的软件。它可以是像 JDK 附带的java命令一样的独立应用程序,也可以内置到像 Web 浏览器这样的较大应用程序中。通常,解释器本身是一个本地应用程序,为每个平台提供,然后启动用 Java 语言编写的其他工具。例如,Java 编译器和 IDE 通常直接使用 Java 实现,以最大化其可移植性和可扩展性。例如,Eclipse 是一个纯 Java 应用程序。

Java 虚拟机执行 Java 的所有运行时活动。它加载 Java 类文件,验证来自不受信任来源的类,并执行编译后的字节码。它管理内存和系统资源。良好的实现还执行动态优化,将 Java 字节码编译成本机机器指令。

运行 Java 应用程序

独立 Java 应用程序必须至少有一个包含名为main()的方法的类,这是启动时要执行的第一段代码。要运行应用程序,请启动 VM,将该类指定为参数。您还可以指定要传递给应用程序的选项以及解释器的参数:

% java [interpreter options] class_name [program arguments]

类应指定为完全限定的类名,包括包名(如果有)。但是,请注意,不要包含.class文件扩展名。以下是您可以在ch03/examples文件夹中终端中尝试的一些示例:

% cd ch03/examples
% java animals.birds.BigBird
% java MyTest

解释器在classpath中搜索类,classpath是存储类的目录和存档文件的列表。您可以通过类似于上面的JAVA_HOME的环境变量或使用命令行选项-classpath指定类路径。如果两者都存在,则 Java 使用命令行选项。我们将在下一节详细讨论类路径。

您还可以使用java命令启动“可执行”Java ARchive(JAR)文件:

% java -jar spaceblaster.jar

在这种情况下,JAR 文件包含有启动类的元数据,该启动类包含main()方法的名称,并且类路径变为 JAR 文件本身。我们将在“JAR 文件”中更详细地讨论 JAR 文件。

如果您主要在 IDE 中工作,请记住,您仍然可以使用我们在“运行项目”中提到的内置终端选项尝试之前的命令。

加载第一个类并执行其main()方法后,应用程序可以引用其他类,启动其他线程,并创建其用户界面或其他结构,如图 3-1 所示。

ljv6 0301

图 3-1。启动 Java 应用程序

main() 方法必须具有正确的方法签名。方法签名是定义方法的一组信息。它包括方法的名称、参数和返回类型,以及类型和可见性修饰符。main() 方法必须是一个 publicstatic 方法,它以 String 对象数组作为参数,并且不返回任何值(void):

  public static void main (String [] myArgs)

main() 是一个 publicstatic 方法的事实仅意味着它是全局可访问的,并且可以直接按名称调用。我们将在第四章和第五章讨论可见性修饰符如 public 的含义和 static 的含义。

main() 方法的单个参数,String 对象数组,保存传递给应用程序的命令行参数。参数的名称无关紧要;只有类型是重要的。在 Java 中,myArgs 的内容是一个数组。(关于数组的更多信息,请参阅第四章。)在 Java 中,数组知道它们包含多少个元素,并且可以愉快地提供该信息:

    int numArgs = myArgs.length;

myArgs[0] 是第一个命令行参数,依此类推。

Java 解释器继续运行,直到初始类文件的 main() 方法返回,以及它启动的任何线程也退出。(关于线程的更多信息,请参阅第九章。)被指定为守护线程的特殊线程在应用程序的其余部分完成时自动终止。

系统属性

虽然可以从 Java 中读取主机环境变量,但 Oracle 不建议将其用于应用程序配置。相反,Java 允许您在启动 VM 时向应用程序传递任意数量的系统属性值。系统属性只是可通过静态 System.getProperty() 方法对应用程序可用的名称-值字符串对。您可以使用这些属性作为为应用程序提供一般配置信息的更结构化和可移植的替代方案,而不是使用命令行参数和环境变量。您可以使用命令行将每个系统属性传递给解释器,使用 -D 选项后跟 name=value。例如:

% java -Dstreet=sesame -Dscene=alley animals.birds.BigBird

然后,您可以通过以下方式在程序中访问 street 属性的值:

    String street = System.getProperty("street");

当然,应用程序可以以无数其他方式获取其配置,包括通过文件或在运行时通过网络。

类路径

路径的概念应该对于任何在 DOS 或 Unix 平台工作过的人都很熟悉。它是一个环境变量,为应用程序提供了一个查找资源的位置列表。最常见的例子是可执行程序的路径。在 Unix shell 中,PATH 环境变量是一个由冒号分隔的目录列表,用户键入命令名称时按顺序搜索这些目录。类似地,Java 的 CLASSPATH 环境变量是一个包和 Java 类搜索的位置列表。

类路径的一个元素可以是目录或 JAR 文件。JAR 文件是简单的归档文件,包括描述每个归档内容的额外文件(元数据)。JAR 文件是使用 JDK 的 jar 实用程序创建的。许多用于创建 ZIP 归档的工具都是公开可用的,可以用来检查或创建 JAR 文件[¹]。归档格式使得大量类及其资源可以分发在一个单一的、紧凑的文件中;Java 运行时根据需要自动从归档中提取单个类文件。我们将在 “jar 实用程序” 中更详细地了解 JAR 文件和 jar 命令。

设置类路径的具体方法和格式因系统而异。我们来看看如何做到这一点。

Unix 和 macOS 上的 CLASSPATH

在 Unix 系统上(包括 macOS),你可以使用冒号分隔的目录和类存档文件设置 CLASSPATH 环境变量:

% export CLASSPATH=/home/vicky/Java/classes:/home/josh/lib/foo.jar:.

此示例指定了一个类路径,其中包括三个位置:用户主目录中的一个目录,另一个用户目录中的一个 JAR 文件,以及当前目录,通常用点 (.) 表示。类路径的最后一个组件,当前目录,在你进行类调试时非常有用。

Windows 上的 CLASSPATH

在 Windows 系统上,CLASSPATH 环境变量是由分号分隔的目录和类存档文件列表:

C:\> set CLASSPATH=C:\home\vicky\Java\classes;C:\home\josh\lib\foo.jar;.

Java 启动器和其他命令行工具知道如何找到核心类,即每个 Java 安装中包含的类。例如 java.langjava.iojava.netjavax.swing 包中的类都是核心类,因此你不需要在类路径中包含这些类的库或目录。

CLASSPATH 通配符

CLASSPATH 环境变量也可以包括“*”通配符,匹配目录中的所有 JAR 文件。例如:

% export CLASSPATH=/home/sarah/libs/*

要找到其他类,Java 解释器按照它们列出的顺序搜索类路径中的元素。搜索结合了路径位置和完全限定类名的组成部分。例如,考虑对animals.birds.BigBird类的搜索,如图 3-2 所示。搜索类路径目录/usr/lib/java意味着解释器在/usr/lib/java/animals/birds/BigBird.class寻找单个类文件。在类路径上搜索 ZIP 或 JAR 归档,比如/home/sarah/zoo.jar,意味着解释器在该归档中查找animals/birds/BigBird.class文件。

ljv6 0302

图 3-2. 在类路径中查找完全限定名称

对于 Java 运行时的java和 Java 编译器javac,类路径也可以使用-classpath选项指定。例如,在 Linux 或 macOS 机器上:

% javac -classpath /home/pat/classes:/utils/utils.jar:. Foo.java

在 Windows 上基本相同,但您必须遵循系统路径分隔符(分号)并使用驱动器字母来启动绝对路径。

如果您未指定CLASSPATH环境变量或命令行选项,则类路径默认为当前目录(.);这意味着当前目录中的文件通常是可用的。如果更改类路径并且不包括当前目录,则这些文件将不再可访问。

我们怀疑许多新手学习 Java 时遇到的问题与类路径有关。特别注意在开始时设置和检查类路径。如果您在 IDE 中工作,可能会减轻部分或全部管理类路径的负担。然而,理解类路径并确切知道在应用程序运行时其中包含什么,对您的长期心理健康非常重要。

模块

Java 9 引入了模块方法用于 Java 应用程序。模块允许更精细的、高性能的应用程序部署,即使应用程序非常大。 (对于大型应用程序,模块并不是必需的。如果符合您的需求,可以继续使用经典的类路径方法。)使用模块需要额外的设置,因此我们不会在本书中详细讨论它们,但是更大的、商业分发的应用程序可能是基于模块的。如果开始考虑将工作分享到公共存储库之外,请查看Java 9 模块化(Paul Bakker 和 Sander Mak 著,O’Reilly)以获取更多详细信息和帮助模块化您自己的大型项目。

Java 编译器

javac 命令行实用程序是 JDK 中的编译器。该编译器完全用 Java 编写,因此适用于支持 Java 运行时系统的任何平台。javac 将 Java 源代码转换为包含 Java 字节码的编译类。按照惯例,源文件以 .java 扩展名命名;生成的类文件以 .class 扩展名结尾。每个源代码文件被视为单个编译单元。(如你将在第五章中看到,给定编译单元中的类共享某些特性,如 packageimport 语句。)

javac 允许每个文件一个公共类,并坚持文件必须与类名相同。如果文件名和类名不匹配,javac 将发出编译错误。单个文件可以包含多个类,只要这些类中只有一个是公共的,并且命名与文件名相同。避免将太多类打包到单个源文件中。在 .java 文件中将类打包在一起只是表面上将它们关联起来。

继续,在 ch03/examples/animals/birds 文件夹中创建一个名为 Bluebird.java 的新文件。你可以使用你的集成开发环境(IDE)完成此步骤,或者你可以打开任何旧文本编辑器并创建一个新文件。创建文件后,将以下源代码放入文件中:

package animals.birds;

public class Bluebird {
}

接下来,使用以下命令进行编译:

% cd ch03/examples
% javac animals/birds/Bluebird.java

我们的小文件目前什么都没做,但编译应该正常工作。你不应该看到任何错误。

与 Java 解释器不同,它只需类名作为参数,javac 需要一个文件名(包括 .java 扩展名)来处理。前述命令会在与源文件相同的目录下生成类文件 Bluebird.class。尽管在这个例子中看到类文件与源文件在同一目录下很好,但对于大多数真实应用程序,你需要将类文件存储在类路径中的适当位置。

你可以使用 javac-d 选项来指定用于存储 javac 生成的类文件的替代目录。指定的目录被用作类层次结构的根,因此 .class 文件被放置在此目录或其子目录中,这取决于类是否包含在包中。(如果需要,编译器会自动创建中间子目录。)例如,我们可以使用以下命令将 Bluebird.class 文件创建在 /home/vicky/Java/classes/animals/birds/Bluebird.class

% javac -d /home/vicky/Java/classes Bluebird.java

你可以在单个 javac 命令中指定多个 .java 文件;编译器为每个给定的源文件创建一个类文件。只要这些类在类路径中以源代码或编译形式存在,你不需要列出类引用的其他类。在编译期间,Java 使用类路径解析所有其他类引用。

Java 编译器比一般的编译器更智能。例如,javac 比较所有类的源文件和类文件的修改时间,并根据需要重新编译它们。已编译的 Java 类会记住它编译自哪个源文件,只要源文件可用,javac 就可以在需要时重新编译它。在前面的例子中,如果类BigBird引用另一个类,比如animals.furry.Groverjavac 就会在animals.furry包中寻找源文件Grover.java,如果需要的话,会重新编译该文件,以更新Grover.class类文件。

默认情况下,javac 只检查直接从其他源文件引用的源文件。这意味着,如果你有一个过时的类文件,只有一个更新的类文件引用它,可能不会被注意到并重新编译。因此,大多数项目使用像Gradle这样的实际构建工具来管理构建、打包等等,有很多其他的理由也是如此。

最后,需要注意的是,javac 可以编译应用程序,即使只有一些类的编译(二进制)版本可用。你不需要所有对象的源代码。Java 类文件包含源文件包含的所有数据类型和方法签名信息,因此针对二进制类文件进行编译和针对 Java 源代码进行编译一样好。(当然,如果需要进行更改,你仍然需要源文件。)

尝试 Java

Java 9 引入了一个叫做jshell的实用工具,允许你尝试 Java 代码的片段并立即看到结果。jshell 是一个 REPL—即读取-求值-输出循环。许多语言都有它们,在 Java 9 之前有许多第三方变体可用,但没有一个内置在 JDK 本身。让我们更仔细地看看它的能力。

你可以使用操作系统中的终端或命令窗口,或者在 IntelliJ IDEA 中打开一个终端选项卡,如图 Figure 3-3 所示。只需在命令提示符处输入jshell,你将看到一些版本信息以及如何在 REPL 中查看帮助的快速提醒。

ljv6 0303

图 3-3. 在 IDEA 中启动 jshell

现在让我们继续尝试那个帮助命令:

|  Welcome to JShell -- Version 19.0.1
|  For an introduction type: /help intro

jshell> /help intro
|
|                                   intro
|                                   =====
|
|  The jshell tool allows you to execute Java code, getting immediate results.
|  You can enter a Java definition (variable, method, class, etc),
|  like:  int x = 8
|  or a Java expression, like:  x + x
|  or a Java statement or import.
|  These little chunks of Java code are called 'snippets'.
|
|  There are also the jshell tool commands that allow you to understand and
|  control what you are doing, like:  /list
|
|  For a list of commands: /help

jshell 非常强大,虽然在本书中我们不会使用它的所有功能。但是,在剩余大部分章节中,我们肯定会用它来尝试 Java 代码。回想一下我们的HelloJava2示例,“HelloJava2: The Sequel”。你可以直接在 REPL 中创建像JFrame这样的 UI 元素,然后操作它们,同时得到即时反馈!无需保存、编译、运行、编辑、保存、编译、运行等等。让我们试一试:

jshell> JFrame frame = new JFrame("HelloJava2")
|  Error:
|  cannot find symbol
|    symbol:   class JFrame
|  JFrame frame = new JFrame("HelloJava2");
|  ^----^
|  Error:
|  cannot find symbol
|    symbol:   class JFrame
|  JFrame frame = new JFrame("HelloJava2");
|                     ^----^

糟糕!jshell很聪明,功能丰富,但也非常字面。记住,如果你想使用默认包中没有包含的类,你必须导入它。这在 Java 源文件中是真实的,在使用jshell时也是如此。让我们再试一次:

jshell> import javax.swing.*

jshell> JFrame frame = new JFrame("HelloJava2")
frame ==> javax.swing.JFrame[frame0,0,23,0x0,invalid,hidden ... led=true]

好多了。可能有点奇怪,但比以前好多了。我们的frame对象已经创建了。==>箭头后面的额外信息只是关于我们的JFrame的细节,比如它的大小(0x0)和屏幕上的位置(0,23)。其他类型的对象将显示其他细节。让我们像之前一样给我们的框架设置宽度和高度,并将我们的框架显示在屏幕上,以便我们可以看到它:

jshell> frame.setSize(300,200)

jshell> frame.setLocation(400,400)

jshell> frame.setVisible(true)

你应该看到一个窗口在你眼前弹出!它将会展示现代的装饰,如图 3-4 所示。

ljv6 0304

图 3-4. 从 jshell 显示JFrame

顺便说一句,在 REPL 中不要担心犯错。你会看到一个错误消息,但你可以纠正错误并继续。举个例子,想象一下试图在改变框架大小时打字错误:

jshell> frame.setsize(300,100)
|  Error:
|  cannot find symbol
|    symbol:   method setsize(int,int)
|  frame.setsize(300,100)
|  ^-----------^

Java 区分大小写,所以setSize()setsize()不一样。jshell提供与 Java 编译器类似的错误信息,但是在线呈现。纠正这个错误,并观察框架变小一点(图 3-5)!

ljv6 0305

图 3-5. 改变我们的框架大小

真棒!嗯,也许这不那么有用,但我们刚刚开始。让我们使用JLabel类添加一些文本:

jshell> JLabel label = new JLabel("Hi jshell!")
label ==> javax.swing.JLabel[,0,0,0x0, ...rticalTextPosition=CENTER]

jshell> frame.add(label)
$8 ==> javax.swing.JLabel[,0,0,0x0, ...text=Hi, ...]

Neat, but why didn’t our label show up in the frame? We’ll go into much more detail on this in 第十一章,但在 Java 中,有些图形变化在显示到屏幕上之前会先积累起来。这是一个非常高效的技巧,但有时会让你措手不及。让我们强制框架重新绘制自己(图 3-6):

jshell> frame.revalidate()

jshell> frame.repaint()

ljv6 0306

图 3-6. 向我们的框架添加JLabel

现在我们可以看到我们的标签了。有些操作会自动触发对revalidate()repaint()的调用。例如,在我们显示框架之前添加到框架的任何组件,将会在我们显示框架时立即出现。或者我们可以类似地删除标签。再次观察,看看当我们立即在删除标签后改变框架大小时会发生什么(图 3-7):

jshell> frame.remove(label) // as with add(), things don't change immediately

jshell> frame.setSize(400,150)

ljv6 0307

图 3-7. 删除标签并调整我们的框架大小

看到了吗?我们有了一个新的、更苗条的窗口,没有标签——全部都没有强制重绘。我们将在后面的章节中继续处理 UI 元素,但让我们尝试对标签做一些微调,只是为了向你展示在文档中查找的新想法或方法有多容易。例如,我们可以使标签的文本居中,结果就像图 3-8 那样:

jshell> frame.add(label)
$45 ==> javax.swing.JLabel[,0,0,300x278,...,text=Hi jshell!,...]

jshell> frame.revalidate()

jshell> frame.repaint()

jshell> label.setHorizontalAlignment(JLabel.CENTER)

ljv6 0308

图 3-8. 将文本居中显示在我们的标签上

我们知道这又是一次快速浏览,其中包含几段代码可能还不太容易理解。为什么 CENTER 全部大写?为什么在我们的居中对齐之前使用类名 JLabel?我们现在无法回答每一个问题,但我们希望您跟着输入,可能会犯一些小错误,然后纠正它们,看到结果会让您想要了解更多。我们希望确保您拥有在阅读本书的其余部分时继续参与的工具。就像许多其他技能一样,编程除了阅读之外还受益于实践!

JAR 文件

Java ARchive(JAR)文件是 Java 的手提箱。它们是将 Java 应用程序的所有部分打包成一个紧凑的包用于分发或安装的标准和可移植的方式。您可以将任何东西放入 JAR 文件中:Java 类文件、序列化对象、数据文件、图像、音频等。JAR 文件还可以携带一个或多个数字签名,以证明其完整性和真实性,附加到文件整体或文件中的单个项目上。

Java 运行时系统可以直接从 CLASSPATH 环境变量中的归档文件加载类文件,如前所述。包含在您的 JAR 文件中的非类文件(数据、图像等)也可以通过应用程序使用 getResource() 方法从类路径中检索。使用此功能,您的代码不需要知道任何资源是普通文件还是 JAR 归档的成员。无论给定的类或数据文件是 JAR 文件中的项目还是类路径上的单个文件,您始终可以以标准方式引用它,并让 Java 的类加载器解析其位置。

存储在 JAR 文件中的项目使用标准 ZIP 文件压缩进行压缩。² 压缩使得通过网络下载类文件变得更快。快速调查标准 Java 发行版显示,典型的类文件在压缩时可以缩小约 40%。包含英文单词的文本文件,如 HTML 或 ASCII,通常可以压缩至原始大小的十分之一或更少。(另一方面,图像文件通常在压缩时不会变小,因为大多数常见的图像格式本身就是压缩格式。)

jar 实用程序

JDK 提供的 jar 实用程序是用于创建和读取 JAR 文件的简单工具。其用户界面并不特别友好。它模仿 Unix 的磁带归档命令 tar。如果您熟悉 tar,您将会认出以下命令,它们都采用了 图 3-9 中的格式:

jar -cvf jar 文件路径 [ 路径 ] [ …​ ]

创建包含 路径(们) 的 jar 文件

jar -tvf jar 文件 [ 路径 ] [ …​ ]

列出 jar 文件 的内容,可选地仅显示 路径(们)。

jar -xvf jar 文件 [ 路径 ] [ …​ ]

提取 jar 文件 的内容,可选地仅提取 路径(们)。

ljv6 0309

图 3-9. jar 命令行工具的重要元素

在这些命令中,标志字母 ctx 告诉 jar 它是在创建归档、列出归档内容还是从归档中提取文件。f 标志表示接下来的参数是要操作的 JAR 文件的名称。

提示

可选的 v 标志告诉 jar 命令在显示有关文件信息时要详细。在详细模式下,你将获得有关文件大小、修改时间和压缩比率的信息。

命令行中的后续项目(除了告诉 jar 要做什么以及 jar 应该操作的文件之外的几乎所有内容)被视为归档项目的名称。如果你正在创建一个归档,你列出的文件和目录将被放入其中。如果你正在提取,只有你列出的文件名会从归档中提取。(如果你没有列出任何文件,则 jar 会提取归档中的所有内容。)

例如,假设我们刚刚完成了我们的新游戏“Space Blaster”。与游戏相关的所有文件都在三个目录中。Java 类本身位于 spaceblaster/game 目录中,spaceblaster/images 包含游戏的图像,spaceblaster/docs 包含相关游戏数据。我们可以用这个命令将所有这些打包成一个归档:

% jar -cvf spaceblaster.jar spaceblaster/

因为我们请求了详细输出,jar 告诉我们它正在做什么:

added manifest
adding: spaceblaster/(in = 0) (out= 0)(stored 0%)
adding: spaceblaster/docs/(in = 0) (out= 0)(stored 0%)
adding: spaceblaster/docs/help1.html(in = 502) (out= 327)(deflated 34%)
adding: spaceblaster/docs/help2.html(in = 562) (out= 360)(deflated 35%)
adding: spaceblaster/game/(in = 0) (out= 0)(stored 0%)
adding: spaceblaster/game/Game.class(in = 362) (out= 270)(deflated 25%)
adding: spaceblaster/game/Planetoid.class(in = 606) (out= 418)(deflated 31%)
adding: spaceblaster/game/SpaceShip.class(in = 1084) (out= 629)(deflated 41%)
adding: spaceblaster/images/(in = 0) (out= 0)(stored 0%)
adding: spaceblaster/images/planetoid.png(in = 3434) (out= 3439)(deflated 0%)
adding: spaceblaster/images/spaceship.png(in = 2760) (out= 2765)(deflated 0%)

jar 创建了文件 spaceblaster.jar 并添加了目录 spaceblaster,将 spaceblaster 中的目录和文件添加到了归档中。在详细模式下,jar 报告了通过压缩归档文件获得的节省。

我们可以用这个命令解包归档:

% jar -xvf spaceblaster.jar

解压 JAR 文件就像解压 ZIP 文件一样。文件夹会在命令发出的位置创建,文件会按照正确的层次结构放置。我们还可以通过提供一个额外的命令行参数来提取单个文件或目录:

% jar -xvf spaceblaster.jar spaceblaster/docs/help2.html

这将提取 help2.html 文件,但它将被放置在 spaceblaster/docs 文件夹中——这两者如有需要将被创建。当然,通常你不必解压 JAR 文件来使用其内容;Java 工具知道如何自动从归档中提取文件。如果你只想看看 JAR 文件里面有什么,可以用下面的命令列出我们 JAR 文件的内容:

% jar -tvf spaceblaster.jar

这是输出结果。它列出了所有文件、它们的大小和创建时间:

     0 Tue Feb 07 18:33:20 EST 2023 META-INF/
    63 Tue Feb 07 18:33:20 EST 2023 META-INF/MANIFEST.MF
     0 Mon Feb 06 19:21:24 EST 2023 spaceblaster/
     0 Mon Feb 06 19:31:30 EST 2023 spaceblaster/docs/
   502 Mon Feb 06 19:31:30 EST 2023 spaceblaster/docs/help1.html
   562 Mon Feb 06 19:30:52 EST 2023 spaceblaster/docs/help2.html
     0 Mon Feb 06 19:41:14 EST 2023 spaceblaster/game/
   362 Mon Feb 06 19:40:22 EST 2023 spaceblaster/game/Game.class
   606 Mon Feb 06 19:40:22 EST 2023 spaceblaster/game/Planetoid.class
  1084 Mon Feb 06 19:40:22 EST 2023 spaceblaster/game/SpaceShip.class
     0 Mon Feb 06 16:30:06 EST 2023 spaceblaster/images/
  3434 Mon Feb 06 16:30:06 EST 2023 spaceblaster/images/planetoid.png
  2760 Mon Feb 06 16:27:26 EST 2023 spaceblaster/images/spaceship.png

如果在解压或创建操作中省略详细标志,你将看不到任何输出(除非出现问题)。对于目录内容操作,如果省略详细标志,它只会简单打印每个文件或目录的路径和名称,不提供任何额外信息。

JAR 清单

请注意,jar命令会自动向我们的存档中添加一个名为META-INF的目录。META-INF目录包含描述 JAR 文件内容的文件。它始终至少包含一个文件:MANIFEST.MFMANIFEST.MF文件通常包含一个“打包列表”,列出存档中的重要文件,以及每个条目的可定义属性集。

清单是一个包含一组以关键字: 值形式的行的文本文件。清单默认情况下大部分是空的,只包含 JAR 文件版本信息:

Manifest-Version: 1.0
Created-By: 1.7.0_07 (Oracle Corporation)

还可以使用数字签名对 JAR 文件进行签名。这样做时,为存档中的每个项目向清单添加摘要(校验和)信息(如下所示),META-INF目录将包含存档中项目的数字签名文件:

Name: com/oreilly/Test.class
SHA1-Digest: dF2GZt8G11dXY2p4olzzIc5RjP3=
...

当你创建存档时,可以通过指定自己的补充清单文件来向清单描述中添加自己的信息。这是存储关于存档文件的其他简单属性信息的一种可能的位置,例如版本或作者信息。

例如,我们可以创建一个包含以下关键字: 值行的文件:

Name: spaceblaster/images/planetoid.gif
RevisionNumber: 42.7
Artist-Temperament: moody

要将此信息添加到我们存档中的清单中,请将其放在当前目录中名为myManifest.mf³的文件中,并执行以下jar命令:

% jar -cvmf myManifest.mf spaceblaster.jar spaceblaster

请注意,在紧凑的标志列表中,我们包含了一个额外的选项m,它指定jar应从命令行给定的文件中读取额外的清单信息。jar如何知道哪个文件是哪个文件?因为m位于f之前,它期望在创建的 JAR 文件名称之前找到清单文件名称信息。如果您认为这很笨拙,那么您是对的;如果名称顺序不对,jar会执行错误操作。幸运的是,更正起来很容易:只需删除不正确的文件,并使用正确顺序的名称创建一个新文件。

如果你感兴趣,应用程序可以使用java.util.jar.Manifest类从 JAR 文件中读取自己的清单信息。其详细信息超出了我们本书所需的范围,但可以自由查阅文档中的java.util.jar包。Java 应用程序可以对 JAR 文件的内容进行相当多的操作。

使 JAR 文件可运行

现在回到我们的新清单文件。除了属性之外,您还可以在清单文件中放入几个特殊值。其中之一是Main-Class,允许您指定一个包含 JAR 中主main()方法的类:

Main-Class: spaceblaster.game.Game

第五章有关于包名称的更多信息。如果将此信息添加到您的 JAR 文件清单中(使用前面描述的m选项),则可以直接从 JAR 运行应用程序:

% java -jar spaceblaster.jar

遗憾的是,大多数操作系统已经放弃了从文件浏览器中双击 JAR 应用程序的能力。这些天,用 Java 编写的专业桌面应用程序通常具有可执行包装器(例如 Windows 中的 .bat 文件或 Linux 或 macOS 中的 .sh 文件)以获得更好的兼容性。

工具总结

在 Java 生态系统中显然有很多工具——它们在最初将所有内容捆绑到 Java 开发“套件”中时就已经取得了正确的名称。您不会立即使用上述所有工具,因此如果工具列表看起来有点令人不知所措,请不要担心。当您需要时,我们将专注于使用 javac 编译器和 jshell 交互式实用工具。本章的目标是确保您知道现有的工具,以便在需要时可以返回查看详细信息。

复习问题

  1. 哪个语句允许您访问您的应用程序中的 Swing 组件?

  2. 哪个环境变量决定 Java 编译或执行时查找类文件的位置?

  3. 不解压即可查看 JAR 文件内容的选项是什么?

  4. MANIFEST.MF 文件中需要哪个条目才能使 JAR 文件可执行?

  5. 什么工具允许您以交互方式尝试 Java 代码?

代码练习

本章的编程挑战不需要任何编程。相反,我们想看看如何创建和执行 JAR 文件。此练习允许您练习从 JAR 文件启动 Java 应用程序。首先,在您安装示例的任何位置的 quiz 文件夹中找到交互式复习应用程序 lj6review.jar。使用 java 命令(Java 17 或更高版本)的 -jar 标志启动复习应用程序:

% cd quiz
% java -jar lj6review.jar

一旦开始,您可以通过回答本书所有章节的复习问题来测试您的记忆和新技能。当然不是一次性完成!但是随着您的阅读进展,您可以继续返回复习应用程序。该应用程序以多项选择题的形式呈现每章末尾的相同问题。如果您答错了,我们还提供了一些简要的解释,这将帮助您指出正确的方向。

如果您想查看幕后情况,这个小型复习应用程序的源代码包含在 quiz/src 文件夹中。

高级代码练习

对于额外的挑战,创建一个可执行的 JAR 文件。编译 HelloJar.java 并将生成的类文件(应该有两个)与 manifest.mf 文件一起放入您的归档文件中。将 JAR 文件命名为 hello.jar。您需要进行一些修改:您将需要更新 manifest.mf 文件以指示主类。在这个应用程序中,HelloJar 类包含启动所需的 main() 方法。完成后,您应该能够从终端窗口或 IDE 中的终端选项卡执行以下命令:

% java -jar hello.jar

一个友好的图形化问候,类似于我们的HelloComponent示例从HelloComponent应该会在您的屏幕上弹出。不要偷懒!我们使用了“JAR 清单”中提到的一些方法来读取清单文件的内容。如果您仅编译和运行应用程序而不创建 JAR 文件,您的问候将不会那么称赞。

最后,如果您喜欢,可以查看程序的源代码。它包含了我们将在下一章中讨论的一些 Java 的新元素。

¹ JAR 文件基本上是带有额外元数据的传统 ZIP 文件。因此,Java 也支持传统 ZIP 格式的存档,但这种情况很少见。

² 您甚至可以使用标准的 ZIP 实用程序来检查或解压 JAR 文件。

³ 实际名称完全由您决定,但.mf文件扩展名是常见的。

⁴ 如果您在构建这个 JAR 文件时遇到任何问题,附录 B 中的练习解决方案包含更详细的步骤帮助您。

第四章:Java 语言

作为人类,我们通过反复试验来学习口语的微妙之处。我们学会了在动词旁边放置主语以及如何处理时态和复数等问题。当然,我们在学校学习了高级语言规则,但即使是最年幼的学生也可以向老师提出可理解的问题。计算机语言也具有类似的特点:有作为可组合构建块的“词类”。有声明事实和提出问题的方式。在这一章中,我们将研究 Java 中的这些基本编程单元。试错仍然是一位伟大的老师,因此我们还将看看如何玩转这些新单元并练习您的技能。

由于 Java 的语法源自 C 语言,我们会对该语言的某些特性进行比较,但不需要事先了解 C 语言。第五章在此基础上讨论了 Java 的面向对象的一面,并完成了对核心语言的讨论。第七章讨论了泛型和记录,这些特性增强了 Java 语言中类型工作的方式,使您能够更灵活、更安全地编写某些类型的类。

之后,我们将深入 Java API,看看语言能做什么。本书的其余部分充满了在各种领域做有用事情的简短示例。如果在这些介绍性章节之后您有任何问题,我们希望您在查看代码时能得到解答。当然,总是有更多东西要学习!在此过程中,我们将尝试指出其他资源,这些资源可能有助于希望在我们覆盖的主题之外继续他们的 Java 学习旅程的人们。

对于刚开始编程旅程的读者来说,网络可能会是一个不断的伴侣。许多许多网站、维基百科文章、博客文章以及Stack Overflow的整体都可以帮助您深入研究特定主题或回答可能出现的小问题。例如,虽然本书涵盖了 Java 语言及如何使用 Java 及其工具编写有用程序,但我们并未详细讨论像算法这样的低级核心编程主题。这些编程基础将自然出现在我们的讨论和代码示例中,但您可能会喜欢一些超链接的支线,以帮助巩固某些想法或填补我们必然遗漏的空白。

如前所述,本章中许多术语可能会让您感到陌生。如果偶尔感到有些困惑,不必担心。由于 Java 的广泛应用,我们不得不偶尔省略解释或背景细节。随着您的学习进展,我们希望您有机会重新阅读一些早期章节。新的信息有点像拼图游戏。如果您已经连接了一些相关的知识点,那么添加新的知识点就会更容易。当您花时间编写代码,这本书逐渐成为您的参考书而不是指南时,这些早期章节的主题会更加容易理解。

文本编码

Java 是一种面向互联网的语言。由于各个用户使用多种不同的语言进行交流和书写,Java 必须能够处理大量的语言。它通过 Unicode 字符集进行国际化处理,这是一个支持大多数语言文字的全球标准[¹]。Java 的最新版本基于 Unicode 14.0 标准,内部使用至少两个字节来表示每个符号。您可能还记得来自《过去:Java 1.0–Java 20》的内容,Oracle 致力于跟踪最新的 Unicode 标准发布情况。您使用的 Java 版本可能包含更新的 Unicode 版本。

Java 源代码可以使用 Unicode 编写,并以任意数量的字符编码进行存储。这使得 Java 相对友好,可以包含非英语内容。程序员可以在向用户显示信息的同时,还可以在其自己的类、方法和变量名称中使用 Unicode 丰富的字符集。

Java 的char类型和String类本地支持 Unicode 值。文本在内部使用字符数组或字节数组进行存储;但 Java 语言和 API 对您来说是透明的,通常您不需要考虑这些细节。Unicode 对 ASCII 也非常友好(ASCII 是英语中最常见的字符编码)。前 256 个字符被定义为与 ISO 8859-1(Latin-1)字符集中的前 256 个字符相同,因此 Unicode 实际上与最常见的英语字符集向后兼容。此外,Unicode 的一种最常见的文件编码称为 UTF-8,保留了 ASCII 值的单字节形式。编译后的 Java 类文件默认使用此编码,因此对于英语文本,存储保持紧凑。

大多数平台无法显示所有当前定义的 Unicode 字符。作为一种解决方法,Java 程序可以使用特殊的 Unicode 转义序列进行编写。Unicode 字符可以用以下转义序列表示:

\uxxxx

xxxx 是一个包含一到四个十六进制数字的序列。转义序列表示一个 ASCII 编码的 Unicode 字符。这也是 Java 用来在不支持它们的环境中输出(打印)Unicode 字符的形式。Java 附带了用于在特定编码中读写 Unicode 字符流的类,包括 UTF-8。

与技术领域中许多长寿的标准一样,Unicode 最初设计时有很多额外的空间,以至于没有任何可想象的字符编码需要超过 64K 个字符。唉。自然,我们已经超越了这个限制,一些 UTF-32 编码正在广泛流通。最值得注意的是,分散在消息应用程序中的表情符号字符超出了 Unicode 字符的标准范围。(例如,标准笑脸表情符号的 Unicode 值为 1F600。)Java 支持这些字符的多字节 UTF-16 转义序列。并不是每个支持 Java 的平台都支持表情符号输出,但您可以启动 jshell 来查看您的环境是否可以显示表情符号(参见 图 4-1)。

ljv6 0401

图 4-1. 在 macOS Terminal 应用程序中打印表情符号

尽管如此,使用这些字符要小心。我们必须使用屏幕截图确保您可以在 Mac 上看到 jshell 中运行的这些可爱的小东西。您可以使用 jshell 来测试您自己的系统。您可以创建一个与我们的 HelloJava 类似的最小图形应用程序,例如 HelloJava。创建一个 JFrame,添加一个 JLabel,并使框架可见:

jshell> import javax.swing.*

jshell> JFrame f = new JFrame("Emoji Test")
f ==> javax.swing.JFrame[frame0 ...=true]

jshell> f.add(new JLabel("Hi \uD83D\uDE00"))
$12 ==> javax.swing.JLabel[ ...=CENTER]

jshell> f.setSize(300,200)

jshell> f.setVisible(true)

希望您看到笑脸,但这将取决于您的系统。图 4-2 显示了我们在 macOS 和 Linux 上进行此精确测试时得到的结果。

ljv6 0402

图 4-2. 在各种系统上测试表情符号的显示效果

并不是说您不能在应用程序中使用或支持表情符号,只是您必须注意输出特性的差异。确保您的用户在运行您的代码时有良好的体验。

警告

在导入 Swing 包中的图形组件时,要注意使用正确的 javax 前缀而不是标准的 java 前缀。有关 Swing 的所有内容,请参阅 第十二章。

注释

现在我们知道程序文本是如何存储的,我们可以专注于要存储的内容!程序员经常在代码中包含 注释 来帮助解释复杂的逻辑部分或为其他程序员提供阅读代码的指南。(很多时候,“其他程序员”是几个月或几年后的您自己。)注释中的文本完全被编译器忽略。注释对您的应用程序的性能或功能没有影响。因此,我们非常支持编写良好的注释。Java 支持既可以跨多行的 C 风格 块注释,用 /**/ 分隔,也可以跨一行的 C++ 风格 行注释,用 // 表示:

    /*  This is a
 multiline
 comment.    */

    // This is a single-line comment
    // and so // is this

块注释具有起始和结束序列,并且可以覆盖大量文本。但是,它们不能“嵌套”,这意味着您不能将一个块注释放在另一个块注释内部,以免与编译器发生冲突。单行注释只有一个起始序列,并且由行的结束界定;单行内的额外 // 指示符没有效果。行注释对于方法内的短注释非常有用;它们不与块注释冲突。您仍然可以将单行注释出现的代码块包裹在块注释中。这通常称为 注释掉 代码段的常用技巧——用于调试大型应用程序。由于编译器忽略所有注释,您可以在行或代码块周围放置注释,以查看在删除该代码时程序的行为如何。²

Javadoc 注释

特殊的以 /** 开头的块注释表示 文档注释。文档注释旨在被自动化文档生成器提取,例如 JDK 自带的 javadoc 程序或许多集成开发环境中的上下文感知工具提示。文档注释以接下来的 */ 结束,就像常规的块注释一样。在文档注释中,以 @ 开头的行被解释为文档生成器的特殊指令,为其提供有关源代码的信息。按照惯例,文档注释的每一行都以 * 开头,如下例所示,但这是可选的。文档注释中每行的前导空格和每行的 * 都会被忽略:

/**
 * I think this class is possibly the most amazing thing you will
 * ever see. Let me tell you about my own personal vision and
 * motivation in creating it.
 * <p>
 * It all began when I was a small child, growing up on the
 * streets of Idaho. Potatoes were the rage, and life was good...
 *
 * @see PotatoPeeler
 * @see PotatoMasher
 * @author John 'Spuds' Smith
 * @version 1.00, 19 Nov 2022
 */
class Potato { ... }

javadoc 命令行工具通过读取源代码并提取嵌入的注释和 @ 标签为类创建 HTML 文档。在此示例中,标签在类文档中创建作者和版本信息。@see 标签生成到相关类文档的超文本链接。

编译器也会查看文档注释;特别是它对 @deprecated 标签感兴趣,这意味着该方法已被声明为过时,应在新程序中避免使用。编译后的类包含有关任何已弃用方法的信息,因此当您在代码中使用已弃用的功能时,编译器会警告您(即使源代码不可用)。

文档注释可以出现在类、方法和变量定义之上,但某些标签可能并不适用于所有这些情况。例如,@exception 标签只能应用于方法。表 4-1 总结了文档注释中使用的标签。

表格 4-1. 文档注释标签

标签 描述 适用于
@see 相关的类名 类、方法或变量
@code 源代码内容 类、方法或变量
@link 相关的 URL 类、方法或变量
@author 作者姓名
@version 版本字符串
@param 参数名和描述 方法
@return 返回值的描述 方法
@exception 异常名称和描述 方法
@deprecated 声明一个项目已过时 类、方法或变量
@since 记录项目添加的 API 版本 变量

Javadoc 注释中的标签代表关于源代码的元数据;换句话说,它们提供了关于代码结构或内容的描述信息,严格来说,这些信息并不是应用程序的一部分。一些额外的工具扩展了 Javadoc 风格标签的概念,包括与 Java 程序相关的其他元数据,这些元数据与编译后的代码一起传递,并且可以更方便地被应用程序用来影响其编译或运行时行为。Java 的注解功能提供了一种更正式和可扩展的方式,用于向 Java 类、方法和变量添加元数据。这些元数据在运行时也是可用的。

注解

@ 前缀在 Java 中还有另一个作用,与标签类似。Java 支持 注解 的概念,作为标记某些内容以便进行特殊处理的一种方式。您将注解应用于代码之外的地方。注解可以提供对编译器或您的 IDE 有用的信息。例如,@SuppressWarnings 注解会导致编译器(通常也包括您的 IDE)隐藏关于潜在问题(如无法访问的代码)的警告。当您开始在 “Advanced Class Design” 中创建更有趣的类时,可能会看到您的 IDE 向您的代码中添加 @Overrides 注解。此注解告诉编译器执行一些额外的检查;这些检查旨在帮助您编写有效的代码,并在您(或您的用户)运行程序之前捕捉错误。

您甚至可以创建自定义注解来与其他工具或框架一起使用。虽然深入讨论注解超出了本书的范围,但我们希望您了解它们,因为像 @Overrides 这样的标签将出现在我们的代码中,以及您可能在网上找到的示例或博客文章中。

变量和常量

尽管向代码添加注释对于生成可读性强、易于维护的文件至关重要,但在某些时候,你必须开始编写一些可编译的内容。编程是操纵这些内容的艺术。几乎所有语言中,此类信息存储在变量和常量中,以便程序员更轻松地使用。Java 同时具备这两者。变量存储您计划随时间改变和重用的信息(或者是预先不知道的信息,如用户的电子邮件地址)。常量存储的是不会变化的信息。即使在我们的简单入门程序中,我们也已经看到了这两种元素的示例。回顾一下我们在 “HelloJava” 中的简单图形标签:

import javax.swing.*;

public class HelloJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    frame.add(label);
    frame.setSize(300, 300);
    frame.setVisible(true);
  }
}

在这段代码中,frame是一个变量。我们在第 5 行用JFrame类的新实例装载它。然后我们在第 7 行中再次使用同一个实例来添加我们的标签。我们再次重用变量在第 8 行中设置我们框架的大小,在第 9 行中使其可见。所有这些重用正是变量发挥作用的地方。

第 6 行包含一个常量:JLabel.CENTER。常量包含一个在程序执行过程中永远不会改变的特定值。不会改变的信息似乎奇怪地存储起来——为什么不每次都直接使用这些信息呢?常量比它们的数据更容易使用;Math.PI可能比它代表的值3.141592653589793更容易记住。而且,由于您可以在代码中选择常量的名称,另一个好处是您可以以有用的方式描述信息。JLabel.CENTER可能仍然有点难以理解,但至少单词CENTER至少给了您一些关于正在发生的事情的提示。

使用命名常量还允许更简单地进行未来的更改。如果您编写了某种资源的最大数量,如果只需更改给定给常量的初始值,那么修改该限制要容易得多。如果您使用像 5 这样的文字数字,每次代码需要检查最大值时,您都必须搜索所有的 Java 文件来跟踪每个 5 的出现并进行更改——如果那个特定的 5 确实是指资源限制的话。这种手动搜索和替换容易出错,也非常乏味。

我们将在下一节中详细了解变量和常量的类型和初始值。与往常一样,可以随意使用jshell自己探索和发现其中的一些细节!由于解释器的限制,您不能在jshell中声明自己的顶级常量。您仍然可以使用类似JLabel.CENTER定义的常量或在自己的类中定义它们。

尝试将以下语句输入jshell中,使用Math.PI计算并将圆的面积存储在变量中。这个练习还证明了重新分配常量是行不通的。(再次说明,我们必须介绍一些新概念,比如赋值——将一个值放入变量中——以及乘法运算符*。如果这些命令仍然感觉奇怪,请继续阅读。我们将在本章的其余部分更详细地讨论所有这些新元素。)

jshell> double radius = 42.0;
radius ==> 42.0

jshell> Math.PI
$2 ==> 3.141592653589793

jshell> Math.PI = 3;
|  Error:
|  cannot assign a value to final variable PI
|  Math.PI = 3;
|  ^-----^

jshell> double area = Math.PI * radius * radius;
area ==> 5541.769440932396

jshell> radius = 6;
radius ==> 6.0

jshell> area = Math.PI * radius * radius;
area ==> 113.09733552923255

jshell> area
area ==> 113.09733552923255

注意当我们尝试将Math.PI设置为3时的编译器错误。在声明和初始化它们之后,您可以更改radius甚至area。但是变量一次只能保存一个值,所以最新的计算是仅存留在变量area中的东西。

类型

编程语言的类型系统描述了它的数据元素(我们刚刚提到的变量和常量)如何与内存中的存储关联以及它们如何彼此关联。在静态类型语言中,如 C 或 C ++,数据元素的类型是一个简单的、不变的属性,通常直接对应于一些底层的硬件现象,比如寄存器或指针值。在动态类型语言中,如 Smalltalk 或 Lisp,变量可以被分配任意元素,并且可以在其生命周期内有效地改变它们的类型。在这些语言中,需要大量的开销来验证运行时发生的事情。脚本语言,如 Perl,通过提供极其简化的类型系统来实现易用性,在这种类型系统中,只有特定的数据元素可以存储在变量中,并且值被统一到一个通用的表示形式中,比如字符串。

Java 结合了静态类型语言和动态类型语言的许多最佳特性。与静态类型语言一样,在 Java 中每个变量和编程元素都有一个在编译时已知的类型,因此运行时系统通常不必在代码执行时检查类型之间的赋值的有效性。与传统的 C 或 C ++不同,Java 还维护关于对象的运行时信息,并使用这些信息来允许真正的动态行为。Java 代码可以在运行时加载新类型并以完全面向对象的方式使用它们,从而允许强制转换(在类型之间转换)和完整的多态性(将多个类型的特征结合在一起)。Java 代码还可以在运行时“反射”或检查其自身的类型,从而允许高级的应用行为,如可以与编译程序动态交互的解释器。

Java 数据类型分为两类。原始类型表示语言中具有内置功能的简单值;它们表示数字、布尔(true 或 false)值和字符。引用类型(或类类型)包括对象和数组;它们被称为引用类型,因为它们“引用”一个大的数据类型,该数据类型是通过“引用”传递的,我们稍后会解释。泛型是对现有类型进行细化的引用类型,同时提供编译时类型安全性。例如,Java 有一个List类,可以存储一系列项。使用泛型,您可以创建一个List<String>,它是一个只能包含StringList。或者我们可以创建一个包含JLabel对象的List<JLabel>的列表。我们将在第七章中看到更多关于泛型的内容。

原始类型

数字、字符和布尔值是 Java 的基本元素。与一些其他(也许更纯粹的)面向对象语言不同,它们不是对象。对于那些希望将原始值视为对象的情况,Java 提供了“包装”类。(稍后详细介绍。)将原始值视为特殊值的主要优势在于,Java 编译器和运行时可以更容易地优化它们的实现。原始值和计算仍然可以映射到硬件上,就像在低级语言中一直做的那样。

Java 的一个重要的可移植性特性是原始类型的精确定义。例如,你永远不用担心 int 在特定平台上的大小;它始终是一个 32 位的有符号数字。数值类型的“大小”决定了你可以存储的值有多大(或多精确)。例如,byte 类型是一个 8 位的有符号值,用于存储从 -128 到 127 的小数字。³ 上述的 int 类型可以处理大部分数值需求,存储大约 +/- 20 亿之间的值。表 4-2 总结了 Java 的原始类型及其容量。

表格 4-2. Java 原始数据类型

类型 定义 大致范围或精度
boolean 逻辑值 truefalse
char 16 位,Unicode 字符 64K 字符
byte 8 位,有符号整数 -128 到 127
short 16 位,有符号整数 -32,768 到 32,767
int 32 位,有符号整数 -2.1e9 到 2.1e9
long 64 位,有符号整数 -9.2e18 到 9.2e18
float 32 位,IEEE 754,浮点数 6-7 位有效十进制位数
double 64 位,IEEE 754 15 位有效十进制位数

如果你有 C 语言背景,可能会注意到原始类型看起来像是在 32 位机器上 C 标量类型的理想化,你是对的。这就是它们的设计初衷。Java 的设计者做了一些改变,比如支持 16 位字符用于 Unicode,并且放弃了特定指针。但总体而言,Java 原始类型的语法和语义源自于 C 语言。

那么为什么还要有大小?再次回到效率和优化。足球比赛中的进球数很少超过个位数 —— 它们可以放在一个 byte 变量中。然而,观看这场比赛的球迷人数则需要更大的东西。在所有世界杯国家的所有足球比赛中,所有球迷花费的总金额则需要更大的东西。通过选择合适的大小,你可以给编译器提供最佳的优化机会,从而使你的应用程序运行更快、消耗更少的系统资源,或者两者兼而有之。

一些科学或密码应用程序需要您存储和操作非常大(或非常小)的数字,并且重视准确性而非性能。如果需要比原始类型提供的更大数字,请查看java.math包中的BigIntegerBigDecimal类。这些类提供接近无限大小或精度。(如果您想看到这些大数字的实际应用,我们在“创建自定义约简器”中使用BigInteger计算阶乘值。)

浮点精度

Java 中的浮点运算遵循IEEE 754国际规范,这意味着浮点计算的结果通常在不同的 Java 平台上相同。但是,Java 允许在支持的平台上进行扩展精度。这可能会导致高精度操作结果中出现极小值和晦涩的差异。大多数应用程序永远不会注意到这一点,但如果要确保应用程序在不同平台上产生完全相同的结果,可以在包含浮点操作的类上使用特殊关键字strictfp作为类修饰符(我们在第五章中介绍类)。然后,编译器禁止这些特定于平台的优化。

变量声明和初始化

您使用类型名称后跟一个或多个逗号分隔的变量名称在方法和类内部声明变量。例如:

    int foo;
    double d1, d2;
    boolean isFun;

你可以选择在声明变量时使用适当类型的表达式进行初始化:

    int foo = 42;
    double d1 = 3.14, d2 = 2 * 3.14;
    boolean isFun = true;

如果未初始化作为类成员声明的变量(参见第五章),这些变量将设置为默认值。在这种情况下,数值类型默认为适当类型的零,字符设置为空字符(\0),布尔变量的值为false。(引用类型也有默认值null,但我们很快会在“引用类型”中详细讨论。)

另一方面,局部变量在方法内声明,仅在方法调用期间存在,必须在使用之前显式初始化。正如我们将看到的,编译器强制执行此规则,因此不会忘记。

整数文字

可以用二进制(基数 2)、八进制(基数 8)、十进制(基数 10)或十六进制(基数 16)指定整数文字。在处理低级文件或网络数据时,二进制、八进制和十六进制基数主要用于表示单个位的有用分组:1、3 和 4 位。十进制值没有这样的映射,但对于大多数数字信息来说,它们更加人性化。十进制整数由以字符 1–9 开头的数字序列指定:

    int i = 1230;

二进制数由前导字符0b0B(零“b”)表示,后跟一组零和一:

    int i = 0b01001011;            // i = 75 decimal

八进制数与十进制数的区别在于简单的前导零:

    int i = 01230;             // i = 664 decimal

十六进制数以前导字符0x0X(零“x”)开头,后面跟着一组数字和表示十进制值 10 到 15 的字符 a-f 或 A-F:

    int i = 0xFFFF;            // i = 65535 decimal

整数字面值的类型是int,除非它们后缀为L,表示它们将作为long值产生:

    long l = 13L;
    long l = 13;           // equivalent: 13 is converted from type int
    long l = 40123456789L;
    long l = 40123456789;  // error: too big for an int without conversion

(小写字母l也可以工作,但应避免使用,因为它经常看起来像数字1。)

当数值类型在赋值或涉及“更大”范围的类型的表达式中使用时,它可以提升到更大的类型。在上一个示例的第二行中,数字13具有int的默认类型,但在赋值给long变量时被提升为long类型。

某些其他数值和比较操作也会导致这种算术提升,以及涉及多种类型的数学表达式。例如,当将byte值乘以int值时,编译器首先将byte提升为int

    byte b = 42;
    int i = 43;
    int result = b * i;  // b is promoted to int before multiplication

你永远不能反过来将数值赋给一个具有较小范围的类型而不进行显式转换,显式转换是一种特殊的语法,你可以使用它告诉编译器你需要什么类型:

    int i = 13;
    byte b = i;        // Compile-time error, explicit cast needed
    byte b = (byte) i; // OK

第三行中括号中的(byte)短语是我们的变量i之前的内容。由于可能存在精度损失,从浮点数到整数类型的转换总是需要显式转换的。

最后也许是最不重要的,你可以通过在数字之间使用“_”(下划线)字符来为你的数字字面值添加一点格式。如果你有特别长的数字串,你可以像下面的例子一样分开它们:

    int RICHARD_NIXONS_SSN = 567_68_0515;
    int for_no_reason = 1___2___3;
    int JAVA_ID = 0xCAFE_BABE;
    long grandTotal = 40_123_456_789L;

下划线只能出现在数字之间,不能出现在数字的开头或结尾,也不能出现在L长整型标识符旁边。在jshell中试试一些大数字。注意,如果你试图存储一个long值而没有L标识符,你会得到一个错误。你可以看到格式化实际上只是为了方便你。它不会被存储;只有实际值被保留在你的变量或常量中:

jshell> long m = 41234567890;
|  Error:
|  integer number too large
|  long m = 41234567890;
|           ^

jshell> long m = 40123456789L;
m ==> 40123456789

jshell> long grandTotal = 40_123_456_789L;
grandTotal ==> 40123456789

尝试一些其他例子。了解你认为可读性如何是有用的。这也可以帮助你学习可用或需要的促进和转换的类型。没有什么比立即反馈更能强调这些细微差别了!

浮点字面值

浮点值可以用十进制或科学计数法指定。浮点字面值的类型是double,除非它们后缀为fF,表示它们是较小精度的float值。与整数字面值一样,你可以使用下划线字符来格式化浮点数,但同样,只能在数字之间使用。你不能把它们放在开头、结尾、小数点旁边或数字的F标识符旁边:

    double d = 8.31;
    double e = 3.00e+8;
    float f = 8.31F;
    float g = 3.00e+8F;
    float pi = 3.1415_9265F;

字符字面值

可以将文字符值指定为单引号字符或转义的 ASCII 或 Unicode 序列,同样在单引号内:

    char a = 'a';
    char newline = '\n';
    char smiley = '\u263a';

通常你会处理收集到String中的字符,但仍然有些地方需要单个字符。例如,如果你在应用程序中处理键盘输入,可能需要逐个处理每个char键按。

引用类型

在像 Java 这样的面向对象语言中,通过创建从简单的基本类型创建新的复杂数据类型。然后每个类作为语言中的新类型。例如,在 Java 中创建一个名为Car的新类,也隐式地创建了一个名为Car的新类型。项目的类型决定了它的使用方式和可以分配的位置。与基本类型一样,Car类型的项目通常可以分配给Car类型的变量或作为接受Car值的方法的参数传递。

类型不仅仅是一个简单的属性。类可以与其他类有关系,它们所代表的类型也一样。在 Java 中,所有类都存在于父子层次结构中,其中子类或子类是其父类的特殊化类型。相应的类型也具有相同的关系,其中子类的类型被视为父类的子类型。因为子类继承其父类的所有功能,子类类型的对象在某种意义上等同于或扩展了父类型。子类型的对象可以用来替换父类型的对象。

例如,如果你创建一个新的类,Dog,它继承自Animal,那么新类型Dog被视为Animal的子类型。Dog类型的对象可以在任何需要Animal类型对象的地方使用;Dog类型的对象可以被分配给Animal类型的变量。这被称为子类型多态性,是面向对象语言的主要特征之一。我们将在第五章更详细地研究类和对象。

Java 中的基本类型被用作“按值”传递。这意味着当将像int这样的基本值分配给变量或作为参数传递给方法时,其会被复制。另一方面,引用类型(类类型)始终通过“引用”访问。引用是对象的句柄或名称。引用类型变量保存的是指向其类型对象(或子类型,如前所述)的“指针”。当你将引用分配给变量或传递给方法时,只会复制引用,而不是对象本身。引用类似于 C 或 C++中的指针,但其类型严格执行。引用值本身不能显式创建或更改。你必须分配一个适当的对象以给引用类型变量赋予引用值。

让我们通过一个例子来运行。我们声明了一个名为 myCar 的类型为 Car 的变量,并将其赋值为一个合适的对象:⁴

    Car myCar = new Car();
    Car anotherCar = myCar;

myCar 是一个引用类型的变量,它持有对新构造的 Car 对象的引用。(暂时不要担心创建对象的细节;我们将在第五章中介绍。)我们声明了第二个 Car 类型的变量 anotherCar,并将其赋值给同一个对象。现在有两个相同的引用:myCaranotherCar,但只有一个实际的 Car 对象实例。如果我们改变 Car 对象本身的状态,无论使用哪个引用查看,都会看到相同的效果。我们可以通过 jshell 试一下看一些幕后情况:

jshell> class Car {}
|  created class Car

jshell> Car myCar = new Car()
myCar ==> Car@21213b92

jshell> Car anotherCar = myCar
anotherCar ==> Car@21213b92

jshell> Car notMyCar = new Car()
notMyCar ==> Car@66480dd7

注意创建和赋值的结果。在这里,您可以看到 Java 引用类型带有一个指针值(21213b92@ 的右侧)和它们的类型(Car@ 的左侧)。当我们创建一个新的 Car 对象 notMyCar 时,我们得到一个不同的指针值。myCaranotherCar 指向同一个对象;notMyCar 指向第二个独立的对象。

推断类型

现代版本的 Java 在许多情况下不断改进了推断变量类型的能力。从 Java 10 开始,您可以在声明和初始化变量时使用 var 关键字,让编译器推断正确的类型:

jshell> class Car2 {}
|  created class Car2

jshell> Car2 myCar2 = new Car2()
myCar2 ==> Car2@728938a9

jshell> var myCar3 = new Car2()
myCar3 ==> Car2@6433a2

注意在 jshell 中创建 myCar3 时的(确实有点丑陋的)输出。尽管我们没有像为 myCar2 那样明确给出类型,编译器可以轻松地理解要使用的正确类型,并且实际上我们得到了一个 Car2 对象。

传递引用

对象引用以相同的方式传递给方法。在这种情况下,要么 myCar 要么 anotherCar 将作为某个假设类中某个假设方法 myMethod() 的等效参数:

    myMethod(myCar);

一个重要但有时令人困惑的区别是,引用本身是一个值(一个内存地址)。当您将其分配给变量或在方法调用中传递时,该值会被复制。根据我们之前的例子,在方法中传递的参数(从方法的角度来看是一个局部变量)实际上是对 Car 对象的第三个引用,除了 myCaranotherCar

该方法可以通过调用 Car 对象的方法或更改其变量来改变 Car 对象的状态。然而,myMethod() 无法改变调用者对 myCar 引用的理解:也就是说,该方法无法将调用者的 myCar 指向不同的 Car 对象;它只能更改自己的引用。这在我们后面讨论方法时会更加明显。

引用类型总是指向对象(或 null),对象总是由类定义的。与原生类型类似,如果在声明变量实例或类变量时未初始化,编译器将分配默认值 null。此外,像原生类型一样,具有引用类型的局部变量默认情况下 初始化,因此必须在使用之前设置自己的值。然而,两种特殊类型的引用类型——数组和接口——在指定它们所指向的对象类型时有些微不同。

在 Java 中,数组 是一种有趣的对象类型,自动创建以容纳某种其他类型的对象集合,称为基本类型。数组中的单个元素将具有该基本类型。(因此,类型为 int[] 的数组的一个元素将是 int,类型为 String[] 的数组的一个元素将是 String。)声明数组隐式创建了新的类类型,设计为其基本类型的容器,稍后在本章中您将看到。

接口 稍微复杂些。接口定义了一组方法,并为该集合赋予了相应的类型。实现接口方法的对象可以用该接口类型引用,以及其自身的类型。变量和方法参数可以声明为接口类型,就像其他类类型一样,任何实现接口的对象都可以分配给它们。这增加了类型系统的灵活性,使 Java 能够跨越类层次结构的边界,并使对象有效地具有多种类型。我们还将在第五章中详细讨论接口。

泛型类型参数化类型,正如我们之前提到的,是 Java 类语法的一个扩展,允许在类与其他 Java 类型交互时进行额外的抽象。泛型允许程序员专门化一个类而不更改该类的任何代码。我们将在第七章中详细介绍泛型。

关于字符串的一点说明

Java 中的字符串是对象;因此它们属于引用类型。String 对象在 Java 编译器中有一些特殊的帮助,使它们看起来更像原始类型。在 Java 源代码中,字面字符串值(在双引号之间的一系列字符或转义序列)将由编译器转换为 String 对象。您可以直接使用 String 字面值,将其作为方法的参数传递,或将其赋值给 String 类型变量:

    System.out.println("Hello, World...");
    String s = "I am the walrus...";
    String t = "John said: \"I am the walrus...\"";

在 Java 中,+符号被 重载 以处理字符串和常规数字。重载是一种在允许您使用相同方法名或操作符号处理不同数据类型的语言中使用的术语。对于数字,+执行加法。对于字符串,+执行 连接,这是程序员称之为将两个字符串粘在一起的操作。虽然 Java 允许方法的任意重载(详见“方法重载”),+是 Java 中少数几个重载的运算符之一:

    String quote = "Fourscore and " + "seven years ago,";
    String more = quote + " our" + " fathers" +  " brought...";

    // quote is now "Fourscore and seven years ago,"
    // more is now " our fathers brought..."

Java 从串联的字符串文字构建单个String对象,并将其作为表达式的结果提供。(有关所有String的更多信息,请参见第八章。)

语句和表达式

Java 的 语句 出现在方法和类中。它们描述 Java 程序的所有活动。变量声明和赋值,例如前一节中的内容,都是语句,基本的语言结构如 if/then 条件和循环也是语句。(在本章后面的部分中会进一步介绍这些结构。)以下是 Java 中的几个语句:

    int size = 5;
    if (size > 10)
      doSomething();
    for (int x = 0; x < size; x++) {
      doSomethingElse();
      doMoreThings();
    }

表达式产生值;Java 评估表达式以生成结果。这个结果可以作为另一个表达式的一部分或者语句中使用。方法调用、对象分配和当然数学表达式都是表达式的例子:

    // These are all valid Java expressions
    new Object()
    Math.sin(3.1415)
    42 * 64

Java 的一个原则是保持事情简单和一致。为此,在没有其他约束的情况下,Java 中的评估和初始化总是按照它们在代码中出现的顺序进行——从左到右,从上到下。您将看到此规则用于赋值表达式的评估,方法调用和数组索引等多种情况。在其他一些语言中,评估的顺序更复杂,甚至是实现相关的。Java 通过精确定义代码的评估方式,消除了这种危险因素。

这并不意味着你应该开始编写晦涩和复杂的语句。在复杂的方式中依赖表达式的评估顺序是一个不好的编程习惯,即使它能工作。它生成的代码难以阅读,更难修改。

语句

在任何程序中,语句执行真正的魔法。语句帮助我们实现本章开头提到的那些算法。事实上,它们不仅仅是帮助,它们恰恰是我们使用的编程成分;算法中的每一步都对应一个或多个语句。语句通常做四件事中的一件:

  • 收集输入以分配给变量

  • 写输出(到你的终端,到一个JLabel等等)

  • 做出关于执行哪些语句的决定

  • 重复一个或多个其他语句

Java 中的语句和表达式都出现在一个代码块中。代码块包含一系列由开放大括号({)和闭合大括号(})括起来的语句。代码块中的语句可以包括变量声明和我们之前提到的大多数其他类型的语句和表达式:

  {
    int size = 5;
    setName("Max");
    // more statements could follow...
  }

从某种意义上讲,方法只是带有参数并且可以通过其名称调用的代码块——例如,一个假设的方法setUpDog()可能会像这样开始:

  setUpDog(String name) {
    int size = 5;
    setName(name);
    // do any other setup work ...
  }

变量声明在 Java 中是有作用域的。它们仅限于其封闭的代码块内部——也就是说,你不能在最近的大括号外部看到或使用变量:

    {
      // Scopes are like Vegas...
      // What's declared in a scope, stays in that scope
      int i = 5;
    }

    i = 6;  // Compile-time error, no such variable i

通过这种方式,你可以使用代码块任意分组语句和变量。然而,代码块最常见的用途是定义用于条件或迭代语句中的一组语句。

if/else条件语句

编程中的一个关键概念是做出决策。“如果这个文件存在”或“如果用户有 WiFi 连接”都是计算机程序和应用程序经常做出的决策的示例。Java 使用流行的if/else语句来进行许多此类决策。⁵ Java 将if/else子句定义如下:

    if (condition)
      statement1;
    else
      statement2;

在英语中,你可以将if/else语句理解为“如果条件为真,则执行statement1。否则,执行statement2。”

condition是一个布尔表达式,必须用括号括起来。布尔表达式本身是一个布尔值truefalse)或者求值为这些值之一的表达式。⁶ 例如,i == 0是一个布尔表达式,用于测试整数i是否持有值0

// filename: ch04/examples/IfDemo.java
    int i = 0;
    // you can use i now to do other work and then
    // we can test it to see if anything has changed
    if (i == 0)
      System.out.println("i is still zero");
    else
      System.out.println("i is most definitely not zero");

前面示例的整体本身就是一个语句,并且可以嵌套在另一个if/else子句中。if子句具有执行“一行代码”或一个代码块的常见功能。我们将在下一节讨论的循环中看到相同的模式。如果你只有一个语句要执行(就像前面片段中简单的println()调用一样),你可以在if测试或else关键字之后放置那个单独的语句。如果你需要执行多于一个语句,你可以使用一个代码块。代码块的形式如下:

    if (condition) {
      // condition was true, execute this block
      statement;
      statement;
      // and so on...
    } else {
      // condition was false, execute this block
      statement;
      statement;
      // and so on...
    }

在这里,对于被选中的任何分支,代码块中的所有语句都会执行。当我们需要做更多事情而不仅仅是打印一条消息时,我们可以使用这种形式。例如,我们可以保证另一个变量,也许是j,不是负数。

// filename: ch04/examples/IfDemo.java
    int j = 0;
    // you can use j now to do work like i before,
    // then make sure that work didn't drop
    // j's value below zero
    if (j < 0) {
      System.out.println("j is less than 0! Resetting.");
      j = 0;
    } else {
      System.out.println("j is positive or 0\. Continuing.");
    }

注意,我们在if子句中使用了大括号,其中有两个语句,并在else子句中使用了一个单独的println()调用。如果你愿意,你总是可以使用一个代码块。但如果只有一个语句,那么带有大括号的代码块是可选的。

switch 语句

许多语言支持常见的“多个之一”条件,通常称为switchcase语句。给定一个变量或表达式,switch语句提供多个可能匹配的选项。我们确实指的是可能。一个值不必匹配任何switch选项;在这种情况下什么也不发生。如果表达式确实匹配一个case,那么执行该分支。如果有多个case匹配,那么第一个匹配将获胜。

Java switch语句最常见的形式是接受一个整数(或可以自动提升为整数类型的数值类型参数)或字符串,并在多个常量case分支中选择:^(7)

    switch (expression) {
      case constantExpression :
        statement;
      [ case constantExpression :
        statement;  ]
      // ...
      [ default :
        statement;  ]
    }

每个分支的 case 表达式必须在编译时评估为不同的常量整数或字符串值。字符串使用Stringequals()方法进行比较,我们将在第八章中详细讨论这个方法。

您可以指定一个可选的default情况来捕获未匹配的条件。当执行时,switch 简单地找到与其条件表达式匹配的分支(或默认分支)并执行相应的语句。但故事并没有结束。也许有些出乎意料的是,switch语句然后继续执行匹配分支后面的分支,直到达到 switch 的末尾或称为break的特殊语句。这里有几个例子:

// filename: ch04/examples/SwitchDemo.java
    int value = 2;

    switch(value) {
      case 1:
        System.out.println(1);
      case 2:
        System.out.println(2);
      case 3:
        System.out.println(3);
    }
    // prints both 2 and 3

使用break来终止每个分支更为常见:

// filename: ch04/examples/SwitchDemo.java
    int value = GOOD;

    switch (value) {
      case GOOD:
        // something good
        System.out.println("Good");
        break;
      case BAD:
        // something bad
        System.out.println("Bad");
        break;
      default:
        // neither one
        System.out.println("Not sure");
        break;
    }
    // prints only "Good"

在这个例子中,只执行一个分支——GOODBAD或默认值。switch的“继续进行”行为在你想用同一个语句(们)覆盖几种可能的情况值而不是复制大量代码时是合理的:

// filename: ch04/examples/SwitchDemo.java
    int value = MINISCULE;
    String size = "Unknown";

    switch(value) {
      case MINISCULE:
      case TEENYWEENY:
      case SMALL:
        size = "Small";
        break;
      case MEDIUM:
        size = "Medium";
        break;
      case LARGE:
      case EXTRALARGE:
        size = "Large";
        break;
    }

    System.out.println("Your size is: " + size);

该示例有效地将六个可能的值分组为三个案例。并且这种分组功能现在可以直接出现在表达式中。Java 12 以预览功能提供了switch 表达式,并在 Java 14 中经过完善后成为永久功能。

例如,与上面示例中打印尺寸名称不同,我们可以直接将我们的尺寸标签分配给一个变量:

// filename: ch04/examples/SwitchDemo.java

    int value = EXTRALARGE;

    String size = switch(value) {
      case MINISCULE, TEENYWEENY, SMALL -> "Small";
      case MEDIUM -> "Medium";
      case LARGE, EXTRALARGE -> "Large";
      default -> "Unknown";
    }; // note the semicolon! It completes the switch statement

    System.out.println("Your size is: " + size);
    // prints "Your size is Large"

注意新的“箭头”(一个连字符后跟大于符号)语法。您仍然使用单独的case条目,但是使用这种表达式语法,案例值以逗号分隔的列表形式给出,而不是作为单独的级联条目。然后在列表和返回值之间使用->。这种形式可以使switch表达式更加紧凑和(希望)更可读。

do/while 循环

在控制哪个语句执行下一个(程序员术语中的控制流或流程控制)的另一个主要概念是重复。计算机非常擅长重复做事。使用循环来重复代码块。在 Java 中有许多不同类型的循环语句。每种类型的循环都有其优缺点。现在让我们来看看这些不同类型。

dowhile 迭代语句会持续运行,只要布尔表达式(通常称为循环的条件)返回true值。这些循环的基本结构很简单:

    while (condition)
      statement; // or block

    do
      statement; // or block
    while (condition);

while循环非常适合等待某些外部条件,例如获取新的电子邮件:

    while(mailQueue.isEmpty())
      wait();

当然,这个假设的wait()方法需要有一个限制(通常是时间限制,比如等待一秒钟),这样它就会完成并给循环另一个运行的机会。但一旦你有了一些电子邮件,你也希望处理所有到达的消息,而不仅仅是一个。同样,while循环是完美的。如果需要在循环中执行多于一个语句的代码块,可以使用花括号内的语句块。考虑一个简单的倒计时打印机:

// filename: ch04/examples/WhileDemo.java

    int count = 10;
    while(count > 0) {
      System.out.println("Counting down: " + count);
      // maybe do other useful things
      // and decrement our count
      count = count - 1;
    }
    System.out.println("Done");

在这个例子中,我们使用>比较运算符来监视我们的count变量。我们希望在倒计时为正时继续工作。在循环体内,我们打印出当前的count值,然后将其减少一再重复。当我们最终将count减少到0时,循环将停止,因为比较返回false

不同于while循环首先测试其条件,do-while循环(或更常见的仅do循环)总是至少执行其语句主体一次。一个典型的例子是验证用户输入。你知道你需要获取一些信息,所以你在循环的主体中请求该信息。循环的条件可以检测错误。如果有问题,循环将重新开始并再次请求信息。该过程可以重复,直到你的请求无错误返回,并且你知道你有了良好的信息。

    do {
      System.out.println("Please enter a valid email: ");
      String email = askUserForEmail();
    } while (email.hasErrors());

再次,do 循环的主体至少执行一次。如果用户第一次给出有效的电子邮件地址,我们就不重复循环。

for循环

另一种流行的循环语句是for循环。它擅长计数。for循环的最一般形式也是来自于 C 语言的传统。它看起来可能有点凌乱,但却紧凑地表示了相当多的逻辑:

    for (initialization; condition; incrementor)
      statement; // or block

变量初始化部分可以声明或初始化仅限于for主体范围内的变量。然后,for循环开始可能的一系列轮次,首先检查条件,如果为真,则执行主体语句(或块)。在每次执行主体后,评估增量表达式,以便在下一轮开始之前更新变量。考虑一个经典的计数循环:

// filename: ch04/examples/ForDemo.java

    for (int i = 0; i < 100; i++) {
      System.out.println(i);
      int j = i;
      // do any other work needed
    }

这个循环将执行 100 次,打印从 0 到 99 的值。我们声明并初始化一个变量i为零。我们使用条件子句来检查i是否小于 100。如果是,Java 就执行循环体。在增量子句中,我们将i增加一。 (我们将在下一节“表达式”中进一步讨论比较运算符如<>,以及增量快捷方式++。)i增加后,循环回到条件检查。Java 重复执行这些步骤(条件、循环体、增量),直到i达到 100。

请记住变量j只在块内可见(仅对其中的语句可见),并且在for循环后的代码中将无法访问。如果for循环的条件在第一次检查时返回false(例如,如果我们在初始化子句中将i设置为 1000),则永远不会执行循环体和增量部分。

你可以在for循环的初始化和增量部分中使用多个逗号分隔的表达式。例如:

// filename: ch04/examples/ForDemo.java

    // generate some coordinates
    for (int x = 0, y = 10; x < y; x++, y--) {
      System.out.println(x + ", " + y);
      // do other stuff with our new (x, y)...
    }

你也可以在初始化块中从for循环外部初始化现有变量。如果希望在其他地方使用循环变量的结束值,则可能会这样做。这种做法通常不受欢迎:容易出错,使代码难以理解。尽管如此,它是合法的,你可能会遇到这种情况,它对你来说是最合理的:

    int x;
    for(x = 0; x < someHaltingValue; x++) {
      System.out.print(x + ": ");
      // do whatever work you need ...
    }
    // x is still valid and available
    System.out.println("After the loop, x is: " + x);

实际上,如果你想使用已经有一个良好起始值的变量,完全可以省略初始化步骤:

    int x = 1;
    for(; x < someHaltingValue; x++) {
      System.out.print(x + ": ");
      // do whatever work you need ...
    }

注意,你仍然需要分号来分隔初始化步骤和条件。

增强的 for 循环

Java 的称为“增强的for循环”的特性类似于其他一些语言中的foreach语句,可以迭代数组或其他类型的集合中的一系列值:

    for (varDeclaration : iterable)
      statement_or_block;

增强的for循环可以用来遍历任何类型的数组以及实现了java.lang.Iterable接口的任何 Java 对象。(我们将在第五章详细讨论数组、类和接口。)这包括 Java 集合 API 的大多数类(参见第七章)。以下是一些示例:

// filename: ch04/examples/EnhancedForDemo.java

    int [] arrayOfInts = new int [] { 1, 2, 3, 4 };
    int total = 0;

    for(int i  : arrayOfInts) {
      System.out.println(i);
      total = total + i;
    }
    System.out.println("Total: " + total);

    // ArrayList is a popular collection class
    ArrayList<String> list = new ArrayList<String>();
    list.add("foo");
    list.add("bar");

    for(String s : list)
      System.out.println(s);

此示例中,我们还未讨论数组或ArrayList类及其特殊语法。我们展示的是增强的for循环语法,它可以迭代数组和字符串值列表。这种形式的简洁性使得在需要处理项目集合时非常流行。

break/continue

Java 的 break 语句及其朋友 continue 也可以通过跳出来缩短循环或条件语句。break 使 Java 停止当前循环(或 switch)语句并跳过其余部分。Java 继续执行后续代码。在下面的示例中,while 循环无休止地进行,直到 watchForErrors() 方法返回 true,触发 break 语句停止循环,并在标记为“while 循环后”处继续执行:

    while(true) {
      if (watchForErrors())
        break;
      // No errors yet so do some work...
    }
    // The "break" will cause execution to
    // resume here, after the while loop

continue 语句使 forwhile 循环通过返回到它们检查条件的点来进行下一次迭代。以下示例打印数字 0 到 9,跳过数字 5:

// filename: ch04/examples/ForDemo.java

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

breakcontinue 语句看起来像 C 语言中的那些,但是 Java 的形式具有将标签作为参数并跳出代码多个级别到标记点作用域的额外能力。这种用法在日常 Java 编码中并不常见,但在特殊情况下可能很重要。以下是具体表现:

    labelOne:
      while (condition1) {
        // ...
        labelTwo:
          while (condition2) {
            // ...
            if (smallProblem)
              break; // Will break out of just this loop

            if (bigProblem)
              break labelOne; // Will break out of both loops
          }
        // after labelTwo
      }
    // after labelOne

诸如代码块、条件和循环之类的封闭语句可以用像 labelOnelabelTwo 这样的标识符标记。在此示例中,没有参数的 breakcontinue 具有与前面示例相同的效果。break 使处理恢复到标记为“labelTwo 后”的点;continue 立即导致 labelTwo 循环返回到其条件测试。

我们可以在 smallProblem 语句中使用 break labelTwo 语句。它与普通的 break 语句具有相同的效果,但是像在 bigProblem 语句中看到的 break labelOne 语句会跳出两个级别并在标记为“labelOne之后”的点处继续。类似地,continue labelTwo 将作为正常的 continue,但 continue labelOne 将返回到 labelOne 循环的测试。多级 breakcontinue 语句消除了对 C/C++ 中备受诟病的 goto 语句的主要理由。⁸

现在我们不会讨论几个 Java 语句。 trycatchfinally 语句用于异常处理,正如我们将在 第六章 中讨论的那样。Java 中的 synchronized 语句用于协调多个执行线程之间的访问语句;有关线程同步的讨论,请参阅 第九章。

不可达语句

最后需要注意的是,Java 编译器会将无法到达的语句标记为编译时错误。无法到达的语句是指编译器判断永远不会被调用的语句。当然,你的程序中可能有很多方法或代码块实际上从未被调用过,但编译器仅检测那些可以在编译时“证明”永远不会被调用的部分。例如,一个在方法中有无条件return语句的方法会导致编译时错误,就像编译器能够判断永远不会被满足的条件语句一样:

    if (1 < 2) {
      // This branch always runs and the compiler knows it
      System.out.println("1 is, in fact, less than 2");
      return;
    } else {
      // unreachable statements, this branch never runs
      System.out.println("Look at that, seems we got \"math\" wrong.");
    }

在完成编译之前,您必须纠正无法到达的错误。幸运的是,大多数此类错误只是易于修复的拼写错误。在极少数情况下,此编译器检查揭示了逻辑而非语法上的错误,您总是可以重新排列或删除无法执行的代码。

表达式

表达式在评估时会产生一个结果或值。表达式的值可以是数值类型,如算术表达式;引用类型,如对象分配;或特殊类型void,这是一个不返回值的方法声明的类型。在最后一种情况下,表达式仅用于其副作用;即,它除了产生值之外还执行的工作。编译器知道表达式的类型。在运行时产生的值将具有这种类型,或者在引用类型的情况下,具有兼容的(可分配的)子类型。(关于此兼容性的更多内容,请参见第五章。)

我们已经在示例程序和代码片段中看到了几个表达式。在“赋值”一节中,我们还将看到更多表达式的例子(参见#learnjava6-CHP-4-SECT-5.2.2)。

运算符

运算符帮助您以各种方式组合或改变表达式。它们“操作”表达式。Java 支持几乎所有来自 C 语言的标准运算符。这些运算符在 Java 中的优先级与它们在 C 中的优先级相同,如表 4-3 所示。⁹

表 4-3. Java 运算符

优先级 运算符 操作数类型 描述
1 ++, — 算术 自增和自减
1 +, - 算术 正负号
1 ~ 整数 按位取反
1 ! 布尔 逻辑非
1 ( 类型 ) 任意 强制类型转换
2 *, /, % 算术 乘法、除法、取余
3 +, - 算术 加法和减法
3 + 字符串 字符串连接
4 << 整数 左移
4 >> 整数 带符号右移
4 >>> 整数 无符号右移
5 <, <=, >, >= 算术 数值比较
5 instanceof 对象 类型比较
6 ==, != 原始类型 值的相等性和不等性
6 ==, != 对象 引用的相等性和不等性
7 & 整型 按位与
7 & 布尔 逻辑与
8 ^ 整型 按位异或
8 ^ 布尔 逻辑异或
9 | 整型 按位或
9 | 布尔 逻辑或
10 && 布尔 条件与
11 || 布尔 条件或
12 ?: N/A 条件三元操作符
13 = 任意类型 赋值

我们还应注意百分号(%)操作符不严格是模运算而是余数运算,它可能具有负值。尝试在jshell中玩一些这些操作符,以更好地理解它们的效果。如果你对编程有些陌生,熟悉运算符及其优先级顺序尤其有助于你。即使在代码中执行日常任务时,你也会经常遇到表达式和操作符:

jshell> int x = 5
x ==> 5

jshell> int y = 12
y ==> 12

jshell> int sumOfSquares = x * x + y * y
sumOfSquares ==> 169

jshell> int explicitOrder = (((x * x) + y) * y)
explicitOrder ==> 444

jshell> sumOfSquares % 5
$7 ==> 4

Java 还添加了一些新的操作符。正如我们所见,你可以使用+操作符来进行String值的连接。因为 Java 中所有的整数类型都是有符号的,你可以使用>>操作符进行带符号右移操作。>>>操作符将操作数视为无符号数进行右移操作,不进行符号扩展。作为程序员,我们不需要像以前那样经常操纵变量中的各个位,因此你可能不经常看到这些移位操作符。如果它们确实出现在你阅读的在线编码或二进制数据解析示例中,请随时进入jshell查看它们的工作原理。这种玩法是我们对jshell最喜欢的用法之一!

赋值

虽然声明和初始化变量被视为没有结果值的语句,但仅变量赋值实际上是一个表达式:

    int i, j;   // statement with no resulting value
    int k = 6;  // also a statement with no result
    i = 5;      // both a statement and an expression

通常,我们仅依赖赋值的副作用,就像上面的前两行那样,但赋值也可以作为表达式的一部分的值使用。一些程序员会利用这一事实同时将给定值赋给多个变量:

    j = (i = 5);
    // both j and i are now 5

在大量依赖评估顺序(在这种情况下,使用复合赋值)可能会使代码变得晦涩和难以阅读。我们不推荐这样做,但这种类型的初始化确实在在线示例中出现过。

空值

表达式null可以被赋给任何引用类型。它表示“无引用”。null引用不能用于引用任何东西,试图这样做会在运行时生成NullPointerException异常。请回顾来自“引用类型”的内容,null是未初始化的类和实例变量的默认值;确保在使用引用类型变量之前执行初始化,以避免该异常。

变量访问

点(.)运算符用于选择类或对象实例的成员(我们将在以下章节详细讨论成员)。它可以检索对象实例(对象)的实例变量的值或类的静态变量的值。它还可以指定要在对象或类上调用的方法:

    int i = myObject.length;
    String s = myObject.name;
    myObject.someMethod();

引用类型表达式可以通过选择进一步的变量或方法来在复合评估中使用(在一个表达式中多次使用点操作):

    int len = myObject.name.length();
    int initialLen = myObject.name.substring(5, 10).length();

第一行通过调用String对象的length()方法找到我们的name变量的长度。在第二种情况下,我们采取了一个中间步骤,并要求name字符串的子字符串。String类的substring方法也返回一个String引用,我们要求其长度。像这样的连续操作也称为链式方法调用。我们已经经常使用的一种链式选择操作是在System类的变量out上调用println()方法:

    System.out.println("calling println on out");

方法调用

方法是存在于类中的函数,可以通过类或其实例访问,具体取决于方法的类型。调用方法意味着执行其主体语句,传入任何必需的参数变量,并可能返回一个值。方法调用是一个表达式,其结果是一个值。该值的类型是方法的返回类型

    System.out.println("Hello, World...");
    int myLength = myString.length();

在这里,我们在不同对象上调用了方法println()length()length()方法返回一个整数值;println()的返回类型是void(无返回值)。值得强调的是,println()产生输出,但没有。我们无法像上面的length()那样将该方法赋给一个变量:

jshell> String myString = "Hi there!"
myString ==> "Hi there!"

jshell> int myLength = myString.length()
myLength ==> 9

jshell> int mistake = System.out.println("This is a mistake.")
|  Error:
|  incompatible types: void cannot be converted to int
|  int mistake = System.out.println("This is a mistake.");
|                ^--------------------------------------^

方法占据了 Java 程序的大部分内容。虽然您可以编写一些完全存在于类的单个main()方法内的微不足道的应用程序,但很快您会发现需要分解它们。方法不仅使您的应用程序更易读,还为您打开了复杂、有趣和有用的应用程序的大门,这些应用程序如果没有方法,根本不可能实现。确实,请回顾我们在“HelloJava”中用于JFrame类的几种方法定义的图形化 Hello World 应用程序。

这些都是简单的示例,但在第五章中,当同一类中存在具有相同名称但参数类型不同的方法,或者当在子类中重新定义方法时,情况会变得更加复杂。

语句、表达式和算法

让我们组装一组不同类型的语句和表达式来实现一个实际目标。换句话说,让我们编写一些 Java 代码来实现一个算法。一个经典的算法示例是欧几里得算法,用于查找两个数的最大公约数(GCD)。它使用重复减法的简单(虽然乏味)过程。我们可以使用 Java 的 while 循环、if/else 条件语句和一些赋值来完成这项工作:

// filename: ch04/examples/EuclidGCD.java

    int a = 2701;
    int b = 222;
    while (b != 0) {
      if (a > b) {
        a = a - b;
      } else {
        b = b - a;
      }
    }
    System.out.println("GCD is " + a);

它并不花哨,但它有效——这正是计算机程序擅长执行的任务类型。这就是你在这里的原因!嗯,你可能不是为了计算 2701 和 222 的最大公约数(顺便说一句,是 37),但你确实在这里开始制定解决问题的算法,并将这些算法转化为可执行的 Java 代码。

希望编程难题的几个拼图能够逐渐形成完整的图景。但如果这些想法仍然模糊,不要担心。整个编码过程需要大量的实践。在本章的一个编码练习中,我们希望您尝试将上述代码块放入 main() 方法内的一个真实的 Java 类中。尝试更改 ab 的值。在 第八章 中,我们将看到如何将字符串转换为数字,以便您可以再次运行程序,将两个数作为参数传递给 main() 方法,如 图 2-10 所示,而无需重新编译。

对象创建

Java 中的对象是使用 new 操作符分配的:

    Object o = new Object();

new 的参数是类的 构造函数。构造函数是一个与类名相同的方法,用于指定创建对象实例所需的任何参数。new 表达式的值是所创建对象类型的引用。对象总是有一个或多个构造函数,尽管它们可能不总是对您可见。

我们详细查看了对象创建的细节在 第五章。暂时只需注意对象创建也是一种类型的表达式,其结果是一个对象引用。一个小小的奇特之处是 new 的绑定比点 (.) 选择器“更紧密”。这个细节的一个流行的副作用是,你可以创建一个新对象并在其上调用一个方法,而不必将对象分配给引用类型变量。例如,你可能只需要一天中的当前小时数,而不需要 Date 对象中的其余信息。你不需要保留对新创建日期的引用,你可以简单地通过链式操作获取所需的属性:

jshell> int hours = new Date().getHours()
hours ==> 13

Date 类是一个表示当前日期和时间的实用类。在这里,我们使用 new 运算符创建了 Date 的一个新实例,并调用其 getHours() 方法来获取当前小时数作为整数值。Date 对象引用的生命周期足够长,以服务于 getHours() 方法调用,然后被释放并最终进行垃圾回收(参见“垃圾回收”)。

以这种方式从一个新对象引用调用方法是一种风格问题。显然,分配一个中间变量作为 Date 类型以保存新对象,然后调用其 getHours() 方法会更清晰。然而,像我们上面获取小时数那样结合操作是常见的。随着你学习 Java 并熟悉其类和类型,你可能会采纳其中一些模式。但在此之前,不要担心在代码中“啰嗦”。在你阅读本书的过程中,清晰和可读性比风格华丽更重要。

instanceof 运算符

你可以使用 instanceof 运算符来在运行时确定对象的类型。它测试一个对象是否与目标类型相同或是其子类型。(再次提醒,后续将详细介绍这个类层次结构!)这与询问对象是否可以分配给目标类型的变量相同。目标类型可以是类、接口或数组类型。instanceof 返回一个 boolean 值,指示对象是否与类型匹配。让我们在 jshell 中尝试一下:

jshell> boolean b
b ==> false

jshell> String str = "something"
str ==> "something"

jshell> b = (str instanceof String)
b ==> true

jshell> b = (str instanceof Object)
b ==> true

jshell> b = (str instanceof Date)
|  Error:
|  incompatible types: java.lang.String cannot be converted to java.util.Date
|  b = (str instanceof Date)
|       ^-^

最后的 instanceof 测试返回一个错误。由于其强大的类型感知能力,Java 在编译时经常能捕获到不可能的组合。与不可达代码类似,编译器在你修复问题之前不会让你继续进行。

instanceof 运算符还能正确地报告对象是否是数组类型:

    if (myVariable instanceof byte[]) {
      // now we're sure myVariable is an array of bytes
      // go ahead with your array work here...
    }

还要注意 null 的值不被视为任何类的实例。无论你给变量什么类型,下面的测试都会返回 false

jshell> String s = null
s ==> null

jshell> Date d = null
d ==> null

jshell> s instanceof String
$7 ==> false

jshell> d instanceof Date
$8 ==> false

jshell> d instanceof String
|  Error:
|  incompatible types: java.util.Date cannot be converted to java.lang.String
|  d instanceof String
|  ^

因此,null 永远不是任何类的“实例”,但 Java 仍然跟踪变量的类型,并且不会让你在不兼容类型之间测试(或强制转换)。

数组

数组是一种特殊类型的对象,可以容纳有序的元素集合。数组元素的类型称为数组的基本类型;它包含的元素数量是其长度的固定属性。Java 支持所有基本类型和引用类型的数组。例如,要创建一个基本类型为 byte 的数组,你可以使用 byte[] 类型。类似地,你可以使用 String[] 来创建基本类型为 String 的数组。

如果您在 C 或 C++ 中做过任何编程,Java 数组的基本语法应该看起来很熟悉。您可以创建指定长度的数组,并使用索引运算符[]访问元素。然而,与这些语言不同,Java 中的数组是真正的一流对象。数组是特殊的 Java array类的实例,并在类型系统中有对应的类型。这意味着要使用数组,就像使用任何其他对象一样,您首先声明适当类型的变量,然后使用new运算符创建其实例。

数组对象在 Java 中与其他对象有三个不同之处:

  • 当我们声明新类型的数组时,Java 隐式地为我们创建了一个特殊的Array类类型。要使用数组,不一定需要严格了解此过程,但后续了解其结构及其与 Java 中其他对象的关系会有所帮助。

  • Java 允许我们使用[]运算符访问和分配数组元素,使得数组看起来像许多有经验的程序员所期望的样子。我们可以实现自己的类来模拟数组,但必须使用像get()set()这样的方法,而不是使用特殊的[]符号。

  • Java 提供了一个相应的new运算符的特殊形式,让我们能够使用[]符号构造具有指定长度的数组实例,或直接从值的结构化列表初始化它。

数组使得处理相关信息块变得容易,比如文件中的文本行或者这些行中的单词。我们在本书的示例中经常使用它们;在本章和接下来的章节中,您将看到许多使用[]符号创建和操作数组的示例。

数组类型

数组变量由基本类型后跟空括号[]表示。另外,Java 还接受括号放置在数组名称后的 C 风格声明。

下面的声明是等效的:

    int[] arrayOfInts;   // preferred
    int [] arrayOfInts;  // spacing is optional
    int arrayOfInts[];   // C-style, allowed

在每种情况下,我们将arrayOfInts声明为整数数组。数组的大小尚不成问题,因为我们只是声明了一个数组类型的变量。我们还没有创建array类的实际实例,也没有与之关联的存储。甚至在声明数组类型变量时指定数组长度是不可能的。大小严格是数组对象本身的一个函数,而不是对它的引用。

引用类型的数组可以以相同的方式创建:

    String[] someStrings;
    JLabel someLabels[];

数组的创建和初始化

您可以使用new运算符创建数组的实例。在new运算符之后,我们用方括号括起来的整数表达式指定数组的基本类型及其长度。我们可以使用此语法为我们最近声明的变量创建具有实际存储的数组实例。由于允许表达式,我们甚至可以在括号内做一点计算:

    int number = 10;
    arrayOfInts = new int[42];
    someStrings = new String[ number + 2 ];

我们还可以将声明和分配数组的步骤组合起来:

    double[] someNumbers = new double[20];
    Component[] widgets = new Component[12];

数组索引从零开始。因此,someNumbers[] 的第一个元素索引为 0,最后一个元素索引为 19。创建后,数组元素本身被初始化为其类型的默认值。对于数值类型,这意味着元素最初为零:

    int[] grades = new int[30];
    // first element grades[0] == 0
    // ...
    // last element grades[19] == 0

对象数组的元素是对象的引用 —— 就像个别变量指向的那样 —— 但它们实际上不包含对象的实例。因此,每个元素的默认值是 null,直到我们分配适当对象的实例为止:

    String names[] = new String[42];
    // names[0] == null
    // names[1] == null
    // ...

这是一个重要的区别,可能会导致混淆。在许多其他语言中,创建数组的行为与为其元素分配存储空间相同。在 Java 中,新分配的对象数组实际上只包含引用变量,每个变量的值为 null。¹¹ 这并不意味着空数组没有关联的内存;内存用于保存这些引用(数组中的空“槽”)。图 4-3 描述了前述示例中 names 数组的情况。

ljv6 0403

图 4-3. 一个 Java 数组

我们将 names 变量构建为字符串数组(String[])。这个特定的 String[] 对象包含四个 String 类型的变量。我们已经为前三个数组元素分配了 String 对象。第四个元素具有默认值 null

Java 支持 C 风格的花括号 {} 结构来创建数组并初始化其元素:

jshell> int[] primes = { 2, 3, 5, 7, 7+4 };
primes ==> int[5] { 2, 3, 5, 7, 11 }

jshell> primes[2]
$12 ==> 5

jshell> primes[4]
$13 ==> 11

隐式创建了一个正确类型和长度的数组对象,并将逗号分隔的表达式列表的值分配给其元素。注意,我们没有在此处使用 new 关键字或数组类型。Java 推断从赋值中使用 new

我们还可以在对象数组中使用 {} 语法。在这种情况下,每个表达式必须评估为可以分配给数组基本类型或值 null 的对象。以下是一些示例:

jshell> String[] verbs = { "run", "jump", "hide" }
verbs ==> String[3] { "run", "jump", "hide" }

jshell> import javax.swing.JLabel

jshell> JLabel yesLabel = new JLabel("Yes")
yesLabel ==> javax.swing.JLabel...

jshell> JLabel noLabel = new JLabel("No")
noLabel ==> javax.swing.JLabel...

jshell> JLabel[] choices={ yesLabel, noLabel,
   ...> new JLabel("Maybe") }
choices ==> JLabel[3] { javax.swing.JLabel ... ition=CENTER] }

jshell> Object[] anything = { "run", yesLabel, new Date() }
anything ==> Object[3] { "run", javax.swing.JLabe ... 2023 }

下面的声明和初始化语句是等价的:

    JLabel[] threeLabels = new JLabel[3];
    JLabel[] threeLabels = { null, null, null };

显然,当您有大量要存储的内容时,第一个示例更好。大多数程序员只有在准备好要存储在数组中的真实对象时才使用花括号初始化。

使用数组

数组对象的大小可以通过公共变量 length 获得:

jshell> char[] alphabet = new char[26]
alphabet ==> char[26] { '\000', '\000' ... , '\000' }

jshell> String[] musketeers = { "one", "two", "three" }
musketeers ==> String[3] { "one", "two", "three" }

jshell> alphabet.length
$24 ==> 26

jshell> musketeers.length
$25 ==> 3

length 是数组的唯一可访问字段;它是一个变量,不像许多其他语言中的方法。令人高兴的是,编译器会在您偶尔使用括号,比如 alphabet.length() 时提醒您。

在 Java 中,数组访问就像许多其他语言中的数组访问一样;您通过在数组名称后放置整数值表达式来访问元素。此语法既适用于访问个别现有元素,也适用于分配新元素。我们可以这样获取我们的第二个火枪手:

// remember the first index is 0!
jshell> System.out.println(musketeers[1])
two

以下示例创建了一个名为keyPadJButton对象数组。然后,使用我们的方括号和循环变量作为索引填充数组:

    JButton[] keyPad = new JButton[10];
    for (int i=0; i < keyPad.length; i++)
      keyPad[i] = new JButton("Button " + i);

记住,我们也可以使用增强型for循环来遍历数组值。在这里,我们将使用它来打印我们刚刚分配的所有值:

    for (JButton b : keyPad)
      System.out.println(b);

尝试访问超出数组范围的元素会生成ArrayIndexOutOfBoundsException。这是一种RuntimeException,因此您可以自行捕获和处理它(如果确实预期会发生),或者像我们将在第六章中讨论的那样忽略它。这是 Java 用于包装此类可能有问题的代码的try/catch语法的一小段示例:

    String [] states = new String [50];

    try {
      states[0] = "Alabama";
      states[1] = "Alaska";
      // 48 more...
      states[50] = "McDonald's Land";  // Error: array out of bounds
    } catch (ArrayIndexOutOfBoundsException err) {
      System.out.println("Handled error: " + err.getMessage());
    }

复制一段元素从一个数组到另一个数组是一个常见的任务。一种复制数组的方法是使用System类的低级arraycopy()方法:

    System.arraycopy(source, sourceStart, destination, destStart, length);

以下示例将之前示例中的names数组的大小加倍:

    String[] tmpVar = new String [ 2 * names.length ];
    System.arraycopy(names, 0, tmpVar, 0, names.length);
    names = tmpVar;

在这里,我们分配并分配一个临时变量tmpVar作为一个新的数组,大小是names的两倍。我们使用arraycopy()names的元素复制到新数组中。最后,我们将临时数组赋给names。如果在将新数组分配给names后没有剩余对旧数组names的引用,那么旧数组将在下一轮进行垃圾回收。

或许更简单的完成相同任务的方法是使用java.util.Arrays类的copyOf()copy OfRange()方法:

jshell> byte[] bar = new byte[] { 1, 2, 3, 4, 5 }
bar ==> byte[5] { 1, 2, 3, 4, 5 }

jshell> byte[] barCopy = Arrays.copyOf(bar, bar.length)
barCopy ==> byte[5] { 1, 2, 3, 4, 5 }

jshell> byte[] expanded = Arrays.copyOf(bar, bar.length+2)
expanded ==> byte[7] { 1, 2, 3, 4, 5, 0, 0 }

jshell> byte[] firstThree = Arrays.copyOfRange(bar, 0, 3)
firstThree ==> byte[3] { 1, 2, 3 }

jshell> byte[] lastThree = Arrays.copyOfRange(bar, 2, bar.length)
lastThree ==> byte[3] { 3, 4, 5 }

jshell> byte[] plusTwo = Arrays.copyOfRange(bar, 2, bar.length+2)
plusTwo ==> byte[5] { 3, 4, 5, 0, 0 }

copyOf()方法接受原始数组和目标长度。如果目标长度大于原始数组长度,则新数组将填充(用零或空值)。copyOfRange()接受起始索引(包含)和结束索引(不包含),以及所需的长度,如果需要还会填充。

匿名数组

通常情况下,创建一次性数组非常方便:即在一个地方使用并且不再在其他任何地方引用的数组。这样的数组不需要名称,因为在该上下文中再也不会引用它们。例如,您可能想创建一组对象以作为某个方法的参数传递。创建普通命名数组很容易,但如果您实际上不使用数组(如果您只将数组用作某个集合的容器),则不需要命名该临时容器。Java 使创建“匿名”(无名称)数组变得非常容易。

假设您需要调用一个名为setPets()的方法,该方法接受一个Animal对象数组作为参数。假设CatDogAnimal的子类,下面是如何使用匿名数组调用setPets()的示例:

    Dog pete = new Dog ("golden");
    Dog mj = new Dog ("black-and-white");
    Cat stash = new Cat ("orange");
    setPets (new Animal[] { pete, mj, stash });

语法看起来类似于变量声明中数组初始化。我们隐式定义数组的大小,并使用大括号符号填充其元素。但是,因为这不是变量声明,所以我们必须显式使用new运算符和数组类型来创建数组对象。

多维数组

Java 支持以数组形式的多维数组。你可以使用类似 C 的语法创建多维数组,使用多个方括号对,每个维度一个。你还可以使用这种语法访问数组中各种位置的元素。以下是一个表示虚构棋盘的多维数组示例:

    ChessPiece[][] chessBoard;
    chessBoard = new ChessPiece[8][8];
    chessBoard[0][0] = new ChessPiece.Rook;
    chessBoard[1][0] = new ChessPiece.Pawn;
    chessBoard[0][1] = new ChessPiece.Knight;
    // setup the remaining pieces

图 4-4 展示了我们创建的数组的数组。

ljv6 0404

图 4-4. 一个棋子数组的数组

这里,chessBoard被声明为ChessPiece[][]类型的变量(ChessPiece数组的数组)。这个声明隐式地创建了ChessPiece[]类型。该示例说明了用于创建多维数组的new操作符的特殊形式。它创建了一个ChessPiece[]对象的数组,然后依次将每个元素转换为ChessPiece对象的数组。然后我们通过索引chessBoard来指定特定ChessPiece元素的值。

当然,你可以创建超过两个维度的数组。以下是一个略显不切实际的例子:

    Color [][][] rgb = new Color [256][256][256];
    rgb[0][0][0] = Color.BLACK;
    rgb[255][255][0] = Color.YELLOW;
    rgb[128][128][128] = Color.GRAY;
    // Only 16 million to go!

我们可以指定多维数组的部分索引以获取具有较少维度的数组类型对象的子数组。在我们的示例中,变量chessBoard的类型为ChessPiece[][]。表达式chessBoard[0]是有效的,指的是chessBoard的第一个元素,它在 Java 中是ChessPiece[]类型的。例如,我们可以逐行填充我们的棋盘:

    ChessPiece[] homeRow =  {
      new ChessPiece("Rook"), new ChessPiece("Knight"),
      new ChessPiece("Bishop"), new ChessPiece("King"),
      new ChessPiece("Queen"), new ChessPiece("Bishop"),
      new ChessPiece("Knight"), new ChessPiece("Rook")
    };

    chessBoard[0] = homeRow;

我们不一定需要使用单个new操作指定多维数组的维度大小。new操作符的语法允许我们留下某些维度的大小未指定。至少需要指定第一维(数组的最重要的维度)的大小,但可以将任意数量的尾部较次要的数组维度大小留空。我们可以稍后赋予适当的数组类型值。

我们可以使用这种技术创建一个布尔值的简化版棋盘,该棋盘可以假设跟踪给定方格的占用状态:

    boolean [][] checkerBoard = new boolean [8][];

这里,checkerBoard被声明并创建,但其元素,下一级的八个boolean[]对象,保持为空。使用这种类型的初始化,直到我们显式创建一个数组并分配给它,checkerBoard[0]都是null,如下所示:

    checkerBoard[0] = new boolean [8];
    checkerBoard[1] = new boolean [8];
    // ...
    checkerBoard[7] = new boolean [8];

前两个片段的代码等效于:

    boolean [][] checkerBoard = new boolean [8][8];

你可能希望将数组的维度留空是为了能够存储稍后给我们的数组。

注意,由于数组的长度不是其类型的一部分,棋盘中的数组不一定需要具有相同的长度;换句话说,多维数组不一定是矩形的。考虑下图所示的整数“三角形”数组,其中第一行有一列,第二行有两列,依此类推:图 4-5。

ljv6 0405

图 4-5。一个三角形的数组

章节末尾的练习给了你机会设置和初始化这个数组!

类型、类和数组,哦,我的天啊!

Java 有各种类型用于存储信息,每种类型都有自己表示信息字面量的方式。随着时间的推移,你会熟悉和适应intdoublecharString。但不要着急——这些基本构建块正是jshell设计用来帮助你探索的东西。检查你对变量可以存储什么的理解总是值得的。特别是数组可能会受益于一些实验。你可以尝试不同的声明技术,并确认你掌握了如何访问单维和多维结构中的各个元素。

你也可以在jshell中玩耍,例如我们的if分支和while循环语句的简单控制流。在偶尔输入多行代码片段时需要一点耐心,但我们无法过分强调,随着你将更多 Java 细节加载到你的大脑中,玩耍和实践是多么有用。编程语言当然不像人类语言那样复杂,但它们仍然有许多相似之处。你可以像学习英语(或你用来阅读本书的语言,如果你有翻译的话)一样获得 Java 的读写能力。即使你不立即理解细节,你也会开始感受代码的意图。

Java 的某些部分,如数组,显然是充满细节的。我们之前注意到数组在 Java 语言中是特殊数组类的实例。如果数组有类,它们在类层次结构中的位置以及它们如何相关呢?这些都是很好的问题,但在回答它们之前,我们需要更多地讨论 Java 的面向对象方面。这是第五章的主题。现在,只需相信数组适合于类层次结构即可。

复习问题

  1. Java 在编译后的类中默认使用哪种文本编码格式?

  2. 用什么字符来包围多行注释?这些注释可以嵌套吗?

  3. Java 支持哪些循环结构?

  4. 在一系列if/else if测试中,如果多个条件为真会发生什么?

  5. 如果你想将美国股市的总市值(大约在 2022 财年结束时为 31 万亿美元)以整数美元的形式存储,你可以使用什么原始数据类型?

  6. 表达式18 - 7 * 2的计算结果是什么?

  7. 你如何创建一个数组来保存一周中每天的名称?

代码练习

对于你的编码练习,我们将建立在本章的两个示例之上:

  1. 将欧几里得的最大公约数算法实现为名为Euclid的完整类。回顾算法的基础:

        int a = 2701;
        int b = 222;
        while (b != 0) {
          if (a > b) {
            a = a - b;
          } else {
            b = b - a;
          }
        }
        System.out.println("GCD is " + a);
    

    为了你的输出,你能想到一种方法来显示ab的原始值以及共同的分母吗?理想的输出应该像这样:

    % java Euclid
    The GCD of 2701 and 222 is 37
    
  2. 尝试将前一节的三角形数组扩展为一个简单的类或者在jshell中。下面是一种方法:

        int[][] triangle = new int[5][];
        for (int i = 0; i < triangle.length; i++) {
          triangle[i] = new int [i + 1];
          for (int j = 0; j < i + 1; j++)
            triangle[i][j] = i + j;
        }
    

    现在扩展该代码以将triangle的内容打印到屏幕上。要帮助记忆,可以使用System.out.println()方法打印数组元素的值:

        System.out.println(triangle[3][1]);
    

    你的输出可能会是一个长长的垂直数字列,像这样:

    0
    1
    2
    2
    3
    4
    3
    4
    5
    6
    4
    5
    6
    7
    8
    

高级练习

  1. 如果你想要更多挑战,尝试将输出排列成一个视觉三角形。上面的语句会将一个元素打印到一行。内置的System.out对象还有另一个输出方法:print()。这个方法在打印传入的参数后不会打印换行符。你可以链式调用几个System.out.print()来产生一行输出:

        System.out.print("Hello");
        System.out.print(" ");
        System.out.print("triangle!");
        System.out.println(); // We do want to complete the line
        // Output:
        // Hello triangle!
    

    最终的输出应该类似于这样:

    % java Triangle
    0
    1 2
    2 3 4
    3 4 5 6
    4 5 6 7 8
    

¹ 查看官方Unicode 网站获取更多信息。有趣的是,作为“过时和古老”的列在 Unicode 标准中不再支持的脚本之一是爪哇语——印度尼西亚爪哇岛人的历史语言。

² 使用注释来“隐藏”代码比简单删除代码更安全。如果你想要恢复代码,只需去掉注释分隔符。

³ Java 使用一种称为“二进制补码”的技术来存储整数。这种技术使用数值开头的一位来确定它是正值还是负值。这种技术的一个怪癖是,负数范围总是比正数范围大一。

⁴ 在 C++中的可比较代码如下:

Car& myCar = *(new Car());

Car& anotherCar = myCar;

⁵ 我们说它流行是因为许多编程语言都有这个相同的条件语句。

⁶ “布尔”一词来自英国数学家乔治·布尔,他为逻辑分析奠定了基础。这个词应该是大写的,但许多计算机语言使用小写的“boolean”类型,包括 Java。你无论在网上看到哪个版本都会看到两种变体。

⁷ 我们在这里不会涉及其他形式,但 Java 也支持在switch语句中使用枚举类型和类匹配。

⁸ 跳转到命名标签仍然被认为是不良形式。

⁹ 你可能还记得术语优先级——以及它的可爱记忆口诀,“请原谅我亲爱的舅舅萨利”——来自高中代数。Java 首先计算(p)括号,然后计算(e)指数,接着是(m)乘法和(d)除法,最后是(a)加法和(s)减法。

¹⁰ 计算机以两种方式表示整数:有符号整数允许负数,而无符号整数不允许。例如,有符号字节的范围是-128…​127。无符号字节的范围是 0…​255。

¹¹ 在 C 或 C++中的类比是指针数组。然而,在 C 或 C++中,指针本身是二、四或八字节的值。实际上,分配指针数组实际上是分配某些指针值的存储空间。概念上类似于引用数组,尽管引用本身不是对象。我们无法操作引用或引用的部分,除非通过赋值,它们的存储需求(或缺乏需求)不是高级 Java 语言规范的一部分。

第五章:Java 中的对象

在本章中,我们深入探讨了 Java 的面向对象的特性。术语面向对象设计指的是将应用程序分解为一些对象的艺术,这些对象是自包含的应用程序组件,彼此协作。目标是将您的问题分解为更简单、更易处理和维护的小问题。多年来,基于对象的设计已经证明其效果,而像 Java 这样的面向对象语言为编写从非常小到非常大的应用程序提供了坚实的基础。Java 是从头开始设计为一种面向对象的语言,所有 Java API 和库都围绕着稳固的基于对象的设计模式构建。

对象设计的方法论是一个系统或一组规则,旨在帮助您将应用程序分解为对象。通常这意味着将现实世界的实体和概念(有时称为问题域)映射到应用程序组件中。各种方法论尝试帮助您将应用程序因子化为一组良好的可重用对象。从原则上讲,这是有益的,但问题在于良好的面向对象设计仍然更多地是艺术而不是科学。虽然您可以从现成的设计方法中学习,但没有一种方法适用于所有情况。事实上,经验无可替代。

我们不会在这里试图强行推荐某种方法论;有很多书可以做到这一点。¹ 相反,我们会在你开始的过程中提供一些常识性的提示。

类是 Java 应用程序的构建块。 可以包含方法(函数)、变量、初始化代码,以及稍后我们将讨论的其他类(这些类通常被打包在中,帮助你组织更大的项目。即使是我们到目前为止看到的简单示例中,每个类也属于某个包。接口 可以描述在其他不同的类之间存在的某些共同点。类可以通过扩展彼此相关联,通过实现接口与接口相关联。图 5-1 展示了这段非常密集的段落中的思想。

ljv6 0501

图 5-1. 类、接口和包概述

Object(在左上角)是 Java 中每个其他类的基础类。它是核心 Java 包java.lang的一部分。Java 还有一个用于其图形用户界面元素的包javax.swing。在该包内,JComponent类定义了所有图形元素的低级通用属性,如框架、按钮和画布。例如,JLabel扩展JComponent类。这意味着JLabel继承了JComponent的细节,但添加了特定于标签的内容。您可能注意到JComponent本身也从Object继承,或者至少最终会返回到Object。为了简洁起见,我们省略了中间的类和包。

你也可以定义自己的类和包。例如,右下角的ch05.examples.game包是我们为一个简单游戏构建的自定义包,允许物理学家扔苹果。(牛顿会报仇的!)在这个包中,我们有一些类,如AppleField,它们是我们应用程序的一部分。你还可以看到GamePiece接口,它包含所有游戏元素的一些共同必需元素,并由AppleTreePhysicist类实现。(在我们的游戏中,Field类是所有游戏元素显示的地方,但它本身不是一个游戏元素。注意它并没有实现GamePiece接口。)

本章将详细介绍每个概念,并提供更多示例。我们强烈建议您在学习过程中尝试这些示例,并使用jshell工具(在“尝试 Java”中讨论)来帮助巩固对新主题的理解。

声明和实例化类

类(class)作为制作实例(instances)的蓝图,这些实例是运行时对象(独立副本),实现了类的结构。你使用class关键字和自己选择的名称声明一个类。例如,在我们的游戏中,物理学家、苹果和树都是很好的类对象。在类中,我们添加存储详细信息或其他有用信息的变量,以及描述我们可以对类实例执行的方法。

让我们从苹果类开始。按照(强烈的!)约定,类名以大写字母开头。这使得Apple成为一个很好的名称使用。我们不会立即将我们游戏苹果的每一个细节放入类中,只是一些元素,帮助说明类、变量和方法如何结合在一起:

package ch05.examples;

class Apple {
  float mass;
  float diameter = 1.0f;
  int x, y;

  boolean isTouching(Apple other) {
    // Code will eventually go here that performs
    // distance calculations and returns true if
    // this apple is touching another apple
  }

  // More methods will go here as we fill out more
  // details of our apple
}

Apple类包含四个变量:massdiameterxy。它还定义了一个名为isTouching()的方法,该方法以另一个Apple的引用作为参数,并返回一个布尔值作为结果。变量和方法声明可以以任何顺序出现,但变量初始化器不能对稍后出现的其他变量进行“前向引用”。(在我们的小片段中,diameter变量可以使用mass变量来帮助计算其初始值,但mass不能使用diameter变量来执行相同的操作。)一旦我们定义了Apple类,我们就可以为我们的游戏创建一个Apple对象(该类的实例),如下所示:

    // Two steps, the declaration then the instantiation
    Apple a1;
    a1 = new Apple();

    // Or all in one line...
    Apple a2 = new Apple();

请记住,我们对变量a1的声明并没有创建一个Apple对象;它只是创建一个引用类型为Apple的变量。我们仍然需要使用new关键字创建对象,就像前面代码片段的第二行所示。但是你可以将这些步骤合并成一行,就像我们对a2变量所做的那样。当然,在幕后仍然是分开的动作。有时,合并声明和初始化的方式会比多行版本更易读。

现在我们创建了一个Apple对象,我们可以访问它的变量和方法,就像我们在第四章的几个例子中或者甚至我们的图形“Hello”应用程序中看到的那样。尽管这并不是非常令人兴奋,但我们现在可以构建另一个类PrintAppleDetails,它是一个完整的应用程序,用于创建一个Apple实例并打印其详细信息:

package ch05.examples;

public class PrintAppleDetails {
  public static void main(String args[]) {
    Apple a1 = new Apple();
    System.out.println("Apple a1:");
    System.out.println("  mass: " + a1.mass);
    System.out.println("  diameter: " + a1.diameter);
    System.out.println("  position: (" + a1.x + ", " + a1.y +")");
  }
}

如果你编译并运行这个示例,你应该在你的终端或 IDE 的终端窗口中看到以下输出:

Apple a1:
  mass: 0.0
  diameter: 1.0
  position: (0, 0)

但是,为什么a1没有质量呢?如果你回顾一下我们为Apple类声明变量的方式,我们只初始化了diameter。所有其他变量由于它们是数值类型,都会得到 Java 分配的默认值0。² 我们理想情况下希望有一个更有趣的苹果。让我们看看如何提供这些有趣的部分。

访问字段和方法

一旦你获得了对象的引用,你就可以使用点符号来使用和操作它的变量和方法,就像你在第四章的几个例子中或者我们的图形“Hello”应用程序中看到的那样。让我们创建一个新类PrintAppleDetails2,为我们的a1实例提供一些质量和位置的值,然后打印新的详细信息:

package ch05.examples;

public class PrintAppleDetails2 {
  public static void main(String args[]) {
    Apple a1 = new Apple();
    System.out.println("Apple a1:");
    System.out.println("  mass: " + a1.mass);
    System.out.println("  diameter: " + a1.diameter);
    System.out.println("  position: (" + a1.x + ", " + a1.y +")");
    // fill in some information on a1
    a1.mass = 10.0f;
    a1.x = 20;
    a1.y = 42;
    System.out.println("Updated a1:");
    System.out.println("  mass: " + a1.mass);
    System.out.println("  diameter: " + a1.diameter);
    System.out.println("  position: (" + a1.x + ", " + a1.y +")");
  }
}

这是新的输出:

Apple a1:
  mass: 0.0
  diameter: 1.0
  position: (0, 0)
Updated a1:
  mass: 10.0
  diameter: 1.0
  position: (20, 42)

太棒了!a1看起来好多了。但再看看代码。我们不得不重复打印对象详细信息的三行代码。这种精确复制的情况需要一个方法

方法允许我们在类内部“做事情”。举个简单的例子,我们可以通过一个方法改进Apple类,提供这些打印语句:

public class Apple {
  float mass;
  float diameter = 1.0f;
  int x, y;

  // other apple-related variables and methods

  public void printDetails() {
    System.out.println("  mass: " + mass);
    System.out.println("  diameter: " + diameter);
    System.out.println("  position: (" + x + ", " + y +")");
  }
}

将这些详细语句重新定位后,我们可以创建比其前身更为简洁的PrintAppleDetails3

package ch05.examples;

public class PrintAppleDetails3 {
  public static void main(String args[]) {
    Apple a1 = new Apple();
    System.out.println("Apple a1:");

    // We can use our new method!
    a1.printDetails();

    // fill in some information on a1
    a1.mass = 10.0f;
    a1.x = 20;
    a1.y = 42;
    System.out.println("Updated a1:");
    // And look! We can easily reuse the same method
    a1.printDetails();
  }
}

再看看我们添加到Apple类的printDetails()方法。在类内部,我们可以通过名称直接访问类的变量和调用方法。打印语句只使用像massdiameter这样的简单名称。

或者考虑填写isTouching()方法。我们可以使用自己的xy坐标,不需要任何特殊前缀。但要访问其他苹果的坐标,我们需要回到点表示法。这是使用一些数学(在“java.lang.Math 类”中会有更多)和我们在“if/else 条件语句”中看到的if/then语句来编写该方法的一种方式:

// File: ch05/examples/Apple.java

  public boolean isTouching(Apple other) {
    double xdiff = x - other.x;
    double ydiff = y - other.y;
    double distance = Math.sqrt(xdiff * xdiff + ydiff * ydiff);
    if (distance < diameter / 2 + other.diameter / 2) {
      return true;
    } else {
      return false;
    }
  }

让我们填充我们的游戏并创建一个使用几个Apple对象的Field类。它作为成员变量创建实例,并在setupApples()detectCollision()方法中与这些对象一起工作,调用Apple方法并通过引用a1a2访问这些对象的变量,在图 5-2 中可视化:

package ch05.examples;

public class Field {
  Apple a1 = new Apple();
  Apple a2 = new Apple();

  public void setupApples() {
    a1.diameter = 3.0f;
    a1.mass = 5.0f;
    a1.x = 20;
    a1.y = 40;
    a2.diameter = 8.0f;
    a2.mass = 10.0f;
    a2.x = 70;
    a2.y = 200;
  }

  public void detectCollisions() {
    if (a1.isTouching(a2)) {
      System.out.println("Collision detected!");
    } else {
      System.out.println("Apples are not touching.");
    }
  }
}

ljv6 0502

图 5-2. Apple类的实例

我们可以通过应用程序的另一个迭代PrintAppleDetails4证明Field可以访问苹果的变量和方法:

package ch05.examples;

public class PrintAppleDetails4 {
  public static void main(String args[]) {
    Field f = new Field();
    f.setupApples();
    System.out.println("Apple a1:");
    f.a1.printDetails();
    System.out.println("Apple a2:");
    f.a2.printDetails();
    f.detectCollisions();
  }
}

我们应该看到熟悉的苹果细节,然后是两个苹果是否接触的答案:

% java PrintAppleDetails4
Apple a1:
  mass: 5.0
  diameter: 3.0
  position: (20, 40)
Apple a2:
  mass: 10.0
  diameter: 8.0
  position: (70, 200)
Apples are not touching.

太好了,正如我们所预期的那样。

在继续阅读之前,请尝试更改苹果的位置以使它们接触。您得到了预期的输出吗?

访问修饰符预览

影响类成员是否可以从另一个类访问的几个因素。您可以使用可见性修饰符publicprivateprotected来控制访问;类也可以放置在中,这会影响它们的范围。例如,private修饰符指定变量或方法仅供类本身的其他成员使用。在前面的示例中,我们可以将变量diameter的声明更改为private

class Apple {
  // ...
  private float diameter;
  // ...
}

现在我们无法从Field访问diameter

class Field {
  Apple a1 = new Apple();
  Apple a2 = new Apple();
  // ...
  void setupApples() {
    a1.diameter = 3.0f; // Compile-time error
    // ...
    a2.diameter = 8.0f; // Compile-time error
    // ...
  }
  // ...
}

如果我们仍然需要以某种方式访问diameter,通常会向Apple类添加公共getDiameter()setDiameter()方法:

public class Apple {
  private float diameter = 1.0f;
  // ...

  public void setDiameter(float newDiameter) {
    diameter = newDiameter;
  }

  public float getDiameter() {
    return diameter;
  }
  // ...
}

创建这样的方法是一个良好的设计规则,因为它允许将来在更改值的类型或行为时具有灵活性。我们将在本章后面更详细地查看包、访问修饰符以及它们如何影响变量和方法的可见性。

静态成员

正如我们所说,实例变量和方法与类的实例相关联并通过其实例访问(即通过特定对象,如前面示例中的a1f)。相反,使用static修饰符声明的成员位于类中并由类的所有实例共享。使用static修饰符声明的变量称为静态变量类变量;同样,这些类型的方法称为静态方法类方法。静态成员作为标志和标识符非常有用,可以从任何地方访问。我们可以向我们的Apple示例添加一个静态变量来存储由于重力而产生的加速度的值。这使我们可以在开始动画化我们的游戏时计算扔苹果的轨迹:

class Apple {
  // ...
  static float gravAccel = 9.8f;
  // ...
}

我们已将新的float变量gravAccel声明为static。这意味着它与类关联,而不是与单个实例关联,如果我们更改它的值(直接或通过任何Apple实例),则该值会对所有Apple对象更改,如图 5-3 所示。

ljv6 0503

图 5-3. 类的所有实例共享的静态变量

你可以类似于访问实例成员的方式访问静态成员。在我们的Apple类中,我们可以像访问任何其他变量一样引用gravAccel

class Apple {
  // ...
  float getWeight () {
    return mass * gravAccel;
  }
  // ...
}

然而,由于静态成员存在于类本身中,而不依赖于任何实例,因此我们还可以直接通过类访问它们。例如,如果我们想在火星上扔苹果,我们不需要像a1a2这样的Apple对象来获取或设置变量gravAccel。相反,我们可以使用类来更改变量以反映火星上的条件:

    Apple.gravAccel = 3.7f;

这会改变类及其所有实例的gravAccel值。我们不必手动设置每个Apple实例在火星上下落。静态变量对于在运行时共享的任何类型的数据非常有用。例如,您可以创建方法来注册您的对象实例,以便它们可以进行通信,或者以便您可以跟踪它们的所有实例。使用静态变量定义常量值也很常见。在这种情况下,我们使用static修饰符以及final修饰符。因此,如果我们只关心受地球引力影响的苹果,我们可能会如下更改Apple

class Apple {
  // ...
  static final float EARTH_ACCEL = 9.8f;
  // ...
}

在这里,我们遵循了一个常见的约定,并使用大写字母和下划线(如果名称有多个单词)命名了我们的常量。EARTH_ACCEL的值是一个常量;您可以通过类Apple或其实例访问它,但不能更改其值。

只有真正常量的事物才重要使用staticfinal的组合。编译器允许在引用它们的类中“内联”这些值。这意味着如果你改变了一个static final变量,你可能需要重新编译所有使用该类的代码(这确实是在 Java 中唯一需要这样做的情况)。静态成员对于构造实例本身所需的值也很有用。例如,我们可以声明多个静态值来表示不同大小的Apple对象:

class Apple {
  // ...
  static int SMALL = 0, MEDIUM = 1, LARGE = 2;
  // ...
}

然后我们可以在设置Apple大小的方法中使用这些选项,或者在稍后讨论的特殊构造函数中使用:

    Apple typicalApple = new Apple();
    typicalApple.setSize(Apple.MEDIUM);

再次,在Apple类内部,我们也可以直接通过名称使用静态成员。无需前缀Apple.

class Apple {
  // ...
  void resetEverything() {
    setSize (MEDIUM);
    // ...
  }
  // ...
}

方法

到目前为止,我们的示例类还相当简单。我们保留一些信息,比如苹果有质量,田地有几个苹果等等。但是我们也触及了让这些类做一些事情的想法。例如,我们的各种PrintAppleDetails类在运行程序时有一系列步骤要执行。正如我们之前简要提到的,在 Java 中,这些步骤被打包成一个方法。对于PrintAppleDetails来说,这就是main()方法。

每当您有步骤要执行或决策要做时,都需要一个方法。除了在我们的Apple类中存储诸如massdiameter之类的变量外,我们还添加了一些包含动作和逻辑的代码片段。方法对于类来说如此基本,以至于我们甚至在正式讨论它们之前就不得不创建几个!想想Apple中的printDetails()方法或Field中的setupApples()方法。即使是我们的第一个简单程序也需要一个main()方法。

希望到目前为止我们讨论的方法都足够直接,可以从上下文中跟随。但是方法可以做的远不止打印出几个变量或计算距离。它们可以包含本地变量声明和其他在方法被调用时执行的 Java 语句。方法还可以向调用者返回一个值。它们总是指定一个返回类型,可以是原始类型、引用类型,或特殊的void,表示没有返回值。方法可以接受参数,这些参数是由方法的调用者提供的值。

下面是一个接受参数的简单方法的示例:

class Bird {
  int xPos, yPos;

  double fly (int x, int y) {
    double distance = Math.sqrt(x*x + y*y);
    flap(distance);
    xPos = x;
    yPos = y;
    return distance;
  }
  // other bird things ...
}

在这个例子中,类Bird定义了一个方法fly(),它接受两个整数参数xy。它使用return关键字返回一个double类型的值作为结果。

我们的方法有固定数量的参数(两个);但是,方法可以有可变长度的参数列表,允许方法指定可以接受任意数量的参数,并在运行时自行排序。³

本地变量

我们的 fly() 方法声明了一个名为 distance 的局部变量,用于计算飞行距离。局部变量是临时的;它们仅存在于其方法的作用域(代码块)内。局部变量在方法被调用时分配;它们通常在方法返回时被销毁。它们无法从方法外部引用。如果方法在不同线程中并发执行,每个线程都有自己版本的方法的局部变量。方法的参数在方法的作用域内也充当局部变量;唯一的区别是它们通过方法的调用者传递来初始化。

在方法内创建并分配给局部变量的对象在方法返回后可能会存在或不存在。正如我们将在 “对象销毁” 中详细看到的那样,这取决于对象是否仍然有任何引用。如果创建对象并将其分配给局部变量,然后从未在其他任何地方使用该对象,则当局部变量从作用域中消失时,该对象不再被引用,因此垃圾收集器将移除该对象。但是,如果我们将对象分配给对象的实例变量,将其作为参数传递给另一个方法,或者将其作为返回值返回,则可能会由另一个变量持有其引用而保存下来。

遮蔽

如果一个局部变量或方法参数与实例变量同名,则局部变量 遮蔽 或隐藏了方法作用域内的实例变量名称。这听起来可能有点奇怪,但当实例变量具有常见或明显的名称时,这种情况经常发生。例如,我们可以在我们的 Apple 类中添加一个 move 方法。我们的方法将需要一个新的坐标,告诉它将苹果放在哪里。坐标参数的简单选择可能是 xy。但是我们已经有了相同名称的实例变量,用于保存苹果的当前位置:

class Apple {
  int x, y;

  public void moveTo(int x, int y) {
    System.out.println("Moving apple to " + x + ", " + y);
    // actual move logic would go here ...
  }
}

如果苹果当前位于位置(20, 40),你调用了 moveTo(40, 50), 你觉得 println() 语句会显示什么?在 moveTo() 中,xy 仅指代那些具有这些名称的方法参数。输出将是:

    Moving apple to 40, 50

如果我们无法访问 xy 实例变量,我们怎么移动苹果呢?Java 理解遮蔽并提供了一种解决这些情况的机制。

“this” 引用

每当需要显式引用当前对象或当前对象的成员时,你都可以使用特殊引用this。通常情况下,你不需要使用this,因为对当前对象的引用是隐式的;在类内部使用明确命名的实例变量时就是这种情况。但是,你可以使用this显式引用对象中的实例变量,即使它们被隐藏了。以下示例显示了如何使用this允许参数名称遮蔽实例变量名称。这是一种相当常见的技术,因为它避免了必须编造替代名称。这里是我们如何使用this来实现我们的moveTo()方法,其中包含了被遮蔽变量:

class Apple {
  int x, y;
  float diameter = 1.0f;

  public void moveTo(int x, int y) {
    System.out.println("Moving apple to " + x + ", " + y);
    // store the new x value
    this.x = x;
    // store the new y value if it is high enough
    if (y > diameter) {
      this.y = y;
    } else {
      // otherwise set y to the height of the apple
      this.y = (int)diameter;
    }
  }
}

在这个例子中,表达式this.x引用了实例变量x并将其赋值给本地变量x的值,否则它会隐藏它的名称。对于this.y我们做了同样的事情,但是稍作保护,以确保我们不会将苹果移动到地面以下。请注意,在此方法中diameter没有被遮蔽。由于在moveTo()中没有diameter参数,因此在使用它时我们不必说this.diameter

在前面的示例中,我们唯一需要使用this的原因是因为我们使用了隐藏实例变量的参数名称,并且我们想要引用这些实例变量。你还可以在任何时候使用this引用,以便将“当前”封闭对象的引用传递给其他方法,就像我们在我们的“Hello Java”应用程序的图形版本中为“HelloJava2: The Sequel”所做的那样。

静态方法

静态方法(有时称为类方法),像静态变量一样,属于类而不是类的各个实例。这是什么意思呢?首先,静态方法存在于任何特定实例之外。它可以通过类名和点运算符调用,而无需任何对象的存在。因为它不绑定到特定对象,静态方法只能访问类的其他静态成员(静态变量和其他静态方法)。它不能直接访问任何实例变量或调用任何实例方法,因为这样做会需要问:“在哪个实例上?”静态方法可以通过与实例方法相同的语法从实例中调用,但重要的是它们也可以独立使用。

我们的isTouching()方法使用了一个静态方法Math.sqrt(),它由java.lang.Math类定义;我们将在第八章详细探讨这个类。现在,需要注意的重要一点是,Math是一个类的名称,而不是Math对象的实例。⁴因为静态方法可以在类名可用的任何地方调用,类方法更接近于 C 风格函数。静态方法特别适用于执行与实例无关或在实例上工作的实用方法。例如,在我们的Apple类中,我们可以从我们在“访问字段和方法”中创建的常量中枚举所有可用大小为人类可读字符串。

class Apple {
  public static final int SMALL = 0;
  public static final int MEDIUM = 1;
  public static final int LARGE = 2;
  // other apple things...

  public static String[] getAppleSizes() {
    // Return names for our constants
    return new String[] { "SMALL", "MEDIUM", "LARGE" };
  }
}

在这里,我们定义了一个静态方法getAppleSizes(),它返回一个包含苹果尺寸名称的字符串数组。我们将该方法设置为静态,因为无论给定Apple实例的大小如何,大小列表始终相同。如果需要,我们仍然可以从Apple实例中使用getAppleSizes(),就像一个实例方法一样。例如,我们可以将(非静态)printDetails方法更改为打印大小名称而不是确切的直径:

  public void printDetails() {
    System.out.println("  mass: " + mass);
    // Print the exact diameter:
    //System.out.println("  diameter: " + diameter);
    // Or a nice, human-friendly approximate
    String niceNames[] = getAppleSizes();
    if (diameter < 5.0f) {
      System.out.println(niceNames[SMALL]);
    } else if (diameter < 10.0f) {
      System.out.println(niceNames[MEDIUM]);
    } else {
      System.out.println(niceNames[LARGE]);
    }
    System.out.println("  position: (" + x + ", " + y +")");
  }

然而,我们也可以从其他类中调用它,使用Apple类名和点符号。例如,第一个PrintAppleDetails类可以使用类似的逻辑来打印一个总结性语句,使用我们的静态方法和静态变量,就像这样:

public class PrintAppleDetails {
  public static void main(String args[]) {
    String niceNames[] = Apple.getAppleSizes();
    Apple a1 = new Apple();
    System.out.println("Apple a1:");
    System.out.println("  mass: " + a1.mass);
    System.out.println("  diameter: " + a1.diameter);
    System.out.println("  position: (" + a1.x + ", " + a1.y +")");
    if (a1.diameter < 5.0f) {
      System.out.println("This is a " + niceNames[Apple.SMALL] + " apple.");
    } else if (a1.diameter < 10.0f) {
      System.out.println("This is a " + niceNames[Apple.MEDIUM] + " apple.");
    } else {
      System.out.println("This is a " + niceNames[Apple.LARGE] + " apple.");
    }
  }
}

在这里,我们有我们可靠的Apple类的实例a1,但我们不需要a1来获取大小列表。请注意,我们在a1甚至存在之前加载了漂亮名称列表。但一切仍然正常,如输出所示:

Apple a1:
  mass: 0.0
  diameter: 1.0
  position: (0, 0)
This is a SMALL apple.

静态方法还在各种设计模式中发挥着重要作用,其中你将类的new操作符的使用限制为一个方法——一个称为工厂方法的静态方法。我们将在“构造函数”中进一步讨论对象构造。工厂方法没有命名约定,但是在类似这样的用法中很常见:

    Apple bigApple = Apple.createApple(Apple.LARGE);

我们不会编写任何工厂方法,但是在查找类似于 Stack Overflow 的网站上的问题时,你可能会发现它们。

初始化局部变量

与实例变量不同,如果我们没有提供显式值,局部变量必须在使用之前进行初始化。如果尝试访问未分配值的局部变量,将会收到编译时错误:

  // instance variables always get default values if
  // you don't initialize them
  int foo;

  void myMethod() {
    // local variables do not get default values
    int bar;

    foo += 1;  // This is ok, foo has the value 0
    bar += 1;  // compile-time error, bar is uninitialized

    bar = 99;  // This is ok, we're setting bar's initial value
    bar += 1;  // Now this calculation is ok
  }

注意,这并不意味着你在声明它们时总是要初始化局部变量,只是在第一次引用它们之前必须为它们赋值。当在条件语句内部进行赋值时,会出现更微妙的可能性:

  void myMethod {
    int bar;
    if (someCondition) {
      bar = 42;
    }
    bar += 1;   // Still a compile-time error, bar may not be initialized
  }

在这个例子中,仅当someConditiontrue时,才会初始化bar。编译器不会让你这么赌博,因此在if语句之后使用bar会被标记为错误。

我们可以通过几种方法来纠正这种情况。我们可以提前将变量初始化为默认值,或者将使用移动到条件内部。我们还可以确保控制流不通过其他方式到达未初始化的变量,这取决于对我们特定应用程序有意义的内容。例如,如果someCondition为假,我们可以简单地确保在else分支中为bar分配一个值。或者我们可以突然从方法返回:

  void myMethod {
    int bar;
    if (someCondition) {
      bar = 42;
    } else {
      return;
    }
    bar += 1;  // This is ok!
  }

在这种情况下,要么someCondition为真,bar被设置为 42,要么为假并从myMethod()返回。不可能在未初始化状态下访问bar,所以编译器允许在条件语句之后使用bar

为什么 Java 对局部变量如此挑剔?在其他语言(如 C 或 C++)中,最常见(也是最隐秘)的错误之一就是忘记初始化局部变量。在那些语言中,局部变量以看似随机的值开始,给程序员带来各种各样的沮丧。Java 试图帮助你,强制你分配好、已知的值。

参数传递和引用

在第四章的开头,我们描述了原始类型(通过复制按值传递)和对象(通过引用传递)之间的区别。既然你对 Java 中的方法有了更好的掌握,让我们通过一个例子来详细说明:

  // declare a method with some arguments
  void myMethod(int num, SomeKindOfObject o) {
    // do some useful stuff with num and o
  }

  // use the method
  int i = 0;
  SomeKindOfObject obj = new SomeKindOfObject();
  myMethod(i, obj);

这段代码调用了myMethod(),并传递了两个参数。第一个参数i是按值传递的;当调用方法时,i的值被复制到方法的第一个参数(一个名为num的局部变量)中。如果myMethod()改变了num的值,它只改变了它自己的局部变量。我们的i不会受影响。

同样地,Java 将obj的引用副本放入myMethod()的参数o中。但由于它是一个引用,objo都指向同一个对象。通过oobj进行的任何更改都会影响实际的对象实例。如果我们改变了,比如说,o.size的值,这种更改在myMethod()内部的o.sizemyMethod()完成后调用者的obj.size上都是可见的。然而,如果myMethod()重新分配引用o以指向不同的对象,那么这个分配只影响它的局部变量引用。将o赋给其他东西不会影响调用者的变量obj,它仍然指向原始对象。

将引用传递给方法让我们体验了我们之前提到的this关键字的另一种用法。你可以使用this将当前对象的引用传递给其他对象。让我们看一些代码来看看这是如何工作的:

class Element {
  int num;
  double weight;

  void printMyDetails() {
    System.out.println(this);
  }
}

当然,我们的例子是刻意构造的,但语法是正确的。在 printMyDetails() 方法内部,我们调用了我们的老朋友 System.out.println()。我们传递给 println() 的参数是 this,意味着我们希望打印当前元素对象。在后面的章节中,我们将处理更复杂的对象关系,并且我们经常需要访问当前实例。this 关键字提供了这种访问权限。

原始类型的包装器

正如我们在“原始类型”中简要提到的,Java 世界在类类型(对象)和原始类型(数字、字符和布尔值)之间存在分歧。出于效率原因,Java 接受了这种权衡。当您在进行数字计算时,希望您的计算是轻量级的;使用对象来表示原始类型会复杂化性能优化。虽然不常见,但有时需要将原始值存储为对象。对于这些场合,Java 为每个原始类型提供了标准的包装器类,如表 5-1 所示。

表 5-1. 原始类型包装器

原始类型 包装器
void java.lang.Void
boolean java.lang.Boolean
char java.lang.Character
byte java.lang.Byte
short java.lang.Short
int java.lang.Integer
long java.lang.Long
float java.lang.Float
double java.lang.Double

包装器类的实例封装了其对应类型的单个值。它是一个不可变对象,用于容纳值并允许您稍后检索它。您可以从原始值或值的 String 表示构造包装器对象。以下语句是等价的:

    Float pi = new Float(3.14);
    Float pi = new Float("3.14");

数字包装器的构造函数在解析字符串时遇到错误时会抛出 NumberFormatException

每个数字包装器都实现了 java.lang.Number 接口,该接口提供了访问其各种原始形式值的“value”方法。您可以使用 doubleValue()floatValue()longValue()intValue()shortValue()byteValue() 方法检索标量值:

    Double size = new Double (32.76);

    double d = size.doubleValue();     // 32.76
    float f = size.floatValue();       // 32.76f
    long l = size.longValue();         // 32L
    int i = size.intValue();           // 32

这段代码等效于将原始 double 值转换为各种类型。

这些包装器最常用的情况是当您想要将原始值传递给需要对象的方法时。例如,在第七章中,我们将学习 Java 集合,这是一组用于处理对象组(如列表、集合和映射)的复杂类。集合仅适用于对象类型,因此在存储原始类型时必须进行包装。正如我们将在下一节看到的,Java 会自动透明地处理此包装过程。但现在让我们手动做一下。正如我们将看到的,ListObject 的可扩展集合。我们可以使用包装器在 List 中存储简单的数字(以及其他对象):

    // Manually wrapping an integer
    List myNumbers = new ArrayList();
    Integer thirtyThree = new Integer(33);
    myNumbers.add(thirtyThree);

在这里,我们创建了一个Integer包装器对象,以便我们可以将该数字插入List中,使用接受对象的add()方法。稍后,当我们从List中提取元素时,我们可以如下恢复int值:

    // Manually unwrapping an integer
    Integer theNumber = (Integer)myNumbers.get(0);
    int n = theNumber.intValue();           // 33

令人高兴的是,Java 可以自动完成这些工作的大部分。Java 调用原始类型的自动包装和拆包称为自动装箱。正如我们之前提到的,允许 Java 为我们完成这些工作使得代码更加简洁和安全。编译器大部分时间都会为我们隐藏包装类的使用,但它确实在内部使用。以下是包含额外类型信息(在计算机语言术语中称为泛型)并使用自动装箱的另一个示例:

    // Using autoboxing and generics
    List<Integer> myNumbers = new ArrayList<Integer>();
    myNumbers.add(33);
    int n = myNumbers.get(0);

注意,我们没有创建任何Integer包装器的显式实例,尽管在声明变量时我们在尖括号(<Integer>)中包含了额外的类型信息。我们将在第七章中看到更多有关泛型的内容。

方法重载

方法重载是在类中定义多个具有相同名称的方法的能力;当调用该方法时,编译器根据传递给方法的参数选择正确的方法。这意味着重载方法必须具有不同数量或类型的参数。在“方法重写”中,我们将查看方法重写,它发生在我们在子类中声明具有相同签名的方法时。

方法重载(也称为特定多态性)是一种强大而有用的特性。其理念是创建能够在不同类型参数上以相同方式操作的方法。这造成了单个方法能够在许多类型的参数上操作的假象。标准PrintStream类中的print()方法就是方法重载的一个很好的例子。正如你现在可能已经推断出的那样,你可以使用以下表达式打印几乎任何东西的字符串表示:

    System.out.print(argument);

变量out是一个对象引用(一个PrintStream),它定义了print()方法的九个不同的“重载”版本。这些版本接受以下类型的参数:ObjectStringchar[]charintlongfloatdoubleboolean

class PrintStream {
  void print(Object arg) { ... }
  void print(String arg) { ... }
  void print(char[] arg) { ... }
  // ...
}

您可以使用任何这些类型之一作为参数调用print()方法,并且该值将以适当的方式打印出来。在没有方法重载的语言中,这将需要更多的繁琐操作,例如为每种对象类型命名一个唯一的打印方法。在这种情况下,您需要确定每种数据类型使用哪种方法。

在 Java 中,print() 方法已经被重载以支持两种引用类型:ObjectString。如果我们尝试使用其他引用类型调用 print() 方法会怎样呢?比如,一个 Date 对象?当没有精确的类型匹配时,编译器会寻找一个可接受的、可赋值的 匹配。由于 Date 类,像所有类一样,是 Object 的子类,因此可以将 Date 对象赋给类型为 Object 的变量。因此,这是一个可接受的匹配,编译器选择了 Object 版本的方法。

如果有多个可能的匹配怎么办?例如,如果我们想打印字面值 "Hi there",那么这个字面值既可以分配给 String(因为它是一个 String),也可以分配给 Object,即 String 的父类。在这种情况下,编译器决定哪个匹配“更好”并选择该方法。在这种情况下,它选择了 String 版本。

选择 String 版本的直觉解释是,String 类在继承层次结构中与我们的字面值 "Hi there" 类型“更接近”。它是一个更具体 的匹配。更严格地说,可以说,如果第一个方法的参数类型都可以赋值给第二个方法的参数类型,则给定方法比另一个方法更具体。在这种情况下,String 方法更具体,因为类型 String 可以赋值给类型 Object。反之则不成立。

如果您非常注意,您可能已经注意到我们说编译器解析重载的方法。方法重载不是在运行时发生的事情;这是一个重要的区别。在编译期间做出这个决定意味着一旦选择了重载的方法,即使包含调用方法的类稍后被修订并添加了更具体的重载方法,选择也是固定的,直到代码重新编译。

这种编译时选择与 重写 方法形成对比,后者在运行时定位并可以找到,即使在调用类编译时它们不存在也可以。在实践中,这种区别通常对您不会有太大影响,因为您可能会同时重新编译所有必要的类。我们将在本章后面讨论方法重写。

对象创建

Java 中的对象被分配在系统的“堆”内存空间中。然而,与其他一些语言不同的是,我们不需要自行管理该内存。Java 会为您处理内存的分配和释放。在您使用new操作符创建对象时,Java 明确为其分配存储空间。更重要的是,当对象不再被引用时,通过垃圾回收机制将其移除。

构造函数

对象使用new运算符分配,使用构造函数。构造函数是一个与其类名相同且没有返回类型的特殊方法。当创建新的类实例时调用它,这为类设置对象使用的机会。构造函数像其他方法一样可以接受参数,并且可以重载。然而,它们不像其他方法那样被继承:

class Date {
  int day;
  // other date variables ...

  // Simple "default" constructor
  Date() {
    day = currentDay();
  }

  Date(String date) {
    day = parseDay(date);
  }

  // other Date methods ...
}

在此代码片段中,Date类有两个构造函数。第一个不带参数;它被称为默认构造函数。默认构造函数有着特殊的角色:如果您没有为类定义任何构造函数,编译器将为您提供一个空的默认构造函数。默认构造函数是在您通过不带参数调用其构造函数创建对象时调用的函数。

在这里,我们已实现了默认构造函数,以便它通过调用一个假设的方法currentDay()设置实例变量day,该方法可能知道如何查找当前日期。第二个构造函数接受一个String参数。在这种情况下,String包含一个可以解析为设置day变量的日期字符串。有了这些构造函数,我们可以通过以下方式创建Date对象:

    Date now = new Date();
    Date christmas = new Date("Dec 25, 2022");

在每种情况下,Java 根据重载方法选择的规则在编译时选择适当的构造函数。

如果以后删除了分配的对象的所有引用,则它将被垃圾收集,我们将很快讨论:

    christmas = null;  // christmas is now fair game for the garbage collector

将此引用设置为null意味着它不再指向"Dec 25, 2022"日期对象。将变量christmas设置为其他任何值将产生相同的效果。除非另一个变量也引用原始日期对象,否则日期现在是不可访问的,并且可以被垃圾收集。我们并不建议您必须将引用设置为null才能进行值的垃圾收集。通常情况下,当局部变量超出范围时,这种情况自然发生,但是对象的实例变量(通过引用)会随着对象本身的生存而存在,而静态变量则会永久存在。

在这里,您可以使用与其他方法相同的可见性修饰符(publicprivateprotected)声明构造函数,以控制它们的可访问性。然而,您不能将构造函数设为abstractfinalsynchronized。我们将在本章后面详细讨论abstractfinal和可见性修饰符,并将在第九章中讨论synchronized

使用重载构造函数

构造函数可以使用thissuper引用的特殊形式引用同一类或直接超类中的另一个构造函数。我们将在这里讨论第一种情况,并在更多地讨论创建子类(通常简称为子类化)和继承之后返回超类构造函数的情况。构造函数可以使用自引用方法调用this()来调用其类中的另一个重载构造函数,以选择所需的构造函数。如果一个构造函数调用另一个构造函数,则必须将其作为其第一条语句进行:

class Car {
  String model;
  int doors;

  Car(String model, int doors) {
    this.model = model;
    this.doors = doors;
    // other, complicated stuff
  }

  Car(String model) {
    this(model, 4 /* doors */);
  }
}

在这个例子中,类Car有两个构造函数。第一个更明确的构造函数接受指定汽车型号和车门数量的参数。第二个构造函数只接受模型作为参数,并调用第一个构造函数,使用默认值四个车门。这种方法的优点在于,您可以让单个构造函数完成所有复杂的设置工作;其他更方便的构造函数只需将适当的参数传递给该主要构造函数。

this()的特殊调用必须出现作为我们委托构造函数中的第一条语句。语法以这种方式受限是因为在调用构造函数时需要确定一个清晰的命令链。在链的末端,Java 会调用超类的构造函数(如果我们在代码中没有明确调用它),以确保继承成员在继续之前被正确初始化。

在调用超类构造函数后的链中,当前类的实例变量的初始化器被评估。在这一点之前,我们甚至不能引用我们类的实例变量。在我们讨论完继承之后,我们将再次详细解释这种情况。

目前,您只需知道,您只能作为构造函数的第一条语句调用第二个构造函数(委托给它)。例如,以下代码是非法的并导致编译时错误:

  Car(String m) {
    int doors = determineDoors();
    this(m, doors);   // Error: constructor call
                        // must be first statement
  }

简单的模型名称构造函数在调用更明确的构造函数之前不能进行任何额外的设置。它甚至不能引用实例成员来获取常量值:

class Car {
  final int default_doors = 4;

  Car(String m) {
    this(m, default_doors); // Error: referencing
                              // uninitialized variable
  }
}

实例变量defaultDoors在设置对象的构造函数调用链的后续过程中才被初始化,因此编译器不允许我们访问它。幸运的是,我们可以通过使用静态变量而不是实例变量来解决这个特定的问题:

class Car {
  static final int DEFAULT_DOORS = 4;

  Car(String m) {
    this(m, DEFAULT_DOORS);  // Okay!
  }
}

类的静态成员在首次加载到虚拟机时初始化。编译器可以确定这些静态成员的值,因此在构造函数中访问它们是安全的。

对象销毁

现在我们已经看到了如何创建对象,是时候谈谈如何销毁它们了。如果你习惯于使用 C 或 C++进行编程,那么你可能已经花费了时间来追踪你的代码中的内存泄漏。程序员通常会因为创建对象(消耗内存)而忘记在对象不再需要时销毁它们(返回分配的内存)而导致内存泄漏。Java 会为你处理对象销毁;你不必担心传统的内存泄漏问题,可以专注于更重要的编程任务。⁵

垃圾回收

Java 使用一种称为垃圾回收的技术来删除不再需要的对象。垃圾收集器是 Java 的收割者。它在后台徘徊,潜伏着对象并等待它们的死亡。它找到它们并观察它们,定期计算对它们的引用,以确定它们的寿命何时结束。当一个对象的所有引用都消失了,它不再可访问时,垃圾收集机制会声明该对象不可达,并将其空间收回到可用资源池中。一个不可达的对象是指在运行应用程序的任何“活”引用组合中都无法找到的对象。

垃圾收集使用各种算法;Java 虚拟机架构并不需要特定的方案。然而,值得注意的是,一些 Java 的实现是如何完成这项任务的。最初,Java 使用了一种称为“标记-清除”的技术。在这种方案中,Java 首先遍历所有可访问对象引用的树,并将它们标记为存活的。然后它扫描堆,寻找未标记的可识别对象。使用这种技术,Java 可以找到堆上的对象,因为它们以特定的方式存储,并且在其句柄中具有特定的位签名,这不太可能自然产生。这种算法不会因循环引用问题而混乱,即对象可以相互引用并且在它们死亡时仍然看起来存活(Java 会自动处理此问题)。然而,这种方案并不是最快的方法,并且会导致程序暂停。自那时以来,实现已经变得更加复杂。

现代 Java 垃圾收集器在不强制执行任何长时间延迟的情况下有效地持续运行 Java 应用程序。因为它们是运行时系统的一部分,它们还可以完成一些静态无法完成的任务。例如,Java 将内存堆分为几个区域,用于存放估计生命周期不同的对象。短生命周期对象被放置在堆的特定部分,这大大减少了回收它们所需的时间。生命周期较长的对象可以被移动到堆的其他不那么易变的部分。在最近的实现中,垃圾收集器甚至可以通过根据实际应用程序性能调整堆分区大小来“调优”自己。自从早期版本以来,Java 的垃圾收集改进非常显著,这也是 Java 现在速度大致相当于许多传统语言的原因之一,那些语言把内存管理的负担放在程序员的肩上。

一般来说,你不需要关心垃圾回收过程。但是一种垃圾回收方法在调试时可能会有用。你可以通过调用System.gc()方法显式地促使垃圾收集器进行一次完整的清理。这个方法完全依赖于实现,并且可能什么都不做,但是如果你希望在执行某个活动之前确保 Java 至少尝试清理内存,你可以使用它。

即使我们只用简单的例子,你可能也注意到在 Java 中解决问题需要创建类。在上面的游戏类中,我们有苹果、树和游戏场地等等。对于更复杂的应用程序或库,你可能会有数百甚至数千个类。你需要一种方法来组织这些内容,Java 使用的概念来完成这项任务。让我们看几个例子。

回顾我们在第二章中的第二个 Hello World 示例。文件的前几行向我们展示了代码的所在地的很多信息:

package ch02.examples;

import javax.swing.*;

public class HelloJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    // ...
  }
}

我们根据该文件中的主类(HelloJava)将 Java 文件命名为HelloJava.java。当我们讨论如何组织文件中的内容时,你可能自然会想到使用文件夹来进一步组织这些文件。这基本上就是 Java 所做的。在这个例子中,我们使用package关键字并为其指定一个包名ch02.examples。包映射到文件夹名称的方式与类映射到文件名的方式类似。因此,从安装本书示例的目录开始,这个类应该可以在文件ch02/examples/HelloJava.java中找到。回顾一下图 5-1,我们将一些类分组到它们的包中。例如,如果你正在查看我们在HelloJava中使用的 Swing 组件的 Java 源代码,你会发现一个名为javax的文件夹,它下面有一个名为swing的文件夹,而在其中你会找到像JFrame.javaJLabel.java这样的文件。

每个类都属于一个包。包名遵循与其他 Java 标识符相同的一般规则,并且按约定全部小写。如果不指定包,Java 将把你的类分配给“默认”包。对于一次性演示,使用默认包是可以的,但除此之外,你应该使用 package 来为你的类指定包。默认包有几个限制——例如,默认包中的类无法与 jshell 一起使用——并且不应该仅用于测试之外的用途。

导入类

Java 的一个最大优势在于其庞大的支持库集合,涵盖商业和开源许可下的各种库。需要导出 PDF 吗?有适合的库。需要导入电子表格吗?也有适合的库。需要从云中的 Web 服务器控制地下室的智能灯泡吗?同样有适合的库。如果计算机正在执行某项任务,几乎总能找到一个 Java 库来帮助你编写执行该任务的代码。

要使用这些优秀的库中的任何一个,你可以使用巧妙命名的 import 关键字进行导入。我们在 HelloJava 的示例中使用了 import,这样我们就可以从 Swing 图形库中添加框架和标签组件。你可以导入单个类或整个包。让我们看一些例子。

导入单个类

在编程中,你经常会听到“少即是多”的最大。少量代码更易维护。少量开销意味着更高的吞吐量,等等。(尽管在追求这种编码方式时,我们确实要提醒您遵循爱因斯坦的另一句名言:“事物应该尽可能简单,但不应过于简单。”)如果你只需要来自外部包的一个或两个类,你可以精确导入这些类。这可以使你的代码更易读——其他人可以确切知道你将使用哪些类。

让我们重新审视之前 HelloJava 的片段。我们使用了全局导入(下一节会详细介绍),但我们可以通过仅导入我们需要的类来稍微优化一下,如下所示:

package ch02.examples;

import javax.swing.JFrame;
import javax.swing.JLabel;

public class HelloJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    // ...
  }
}

这种类型的导入设置显然更加冗长,但同样,它意味着任何阅读或编译你代码的人都知道它的确切依赖关系。许多集成开发环境甚至有一个“优化导入”功能,可以自动查找这些依赖关系并逐个列出。一旦你习惯了列出和看到这些明确的导入,你会惊讶地发现它们在定位自己在一个新的(或者说是长期遗忘的)类时是多么有用。

导入整个包

当然,并非每个包都适合单独导入。同样是 Swing 包,javax.swing 就是一个很好的例子。如果你正在编写图形桌面应用程序,几乎肯定会使用 Swing,以及大量的组件。你可以使用我们之前忽略的语法来导入包中的每个类:

import javax.swing.*;

public class HelloJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    // ...
  }
}

*是类导入的通配符。这种导入语句的版本告诉编译器准备好使用包中的每个类。你会经常看到这种类型的导入语句,例如 AWT、Swing、utilities 和 I/O 等常见 Java 包。再次强调,它适用于任何包,但在可以更具体的情况下,你将获得一些编译时性能提升,并提高代码的可读性。

警告

尽管使用通配符import自然而然地包括命名包的类以及任何子包中的类,Java 不允许递归导入。如果你需要一些来自java.awt的类和更多来自java.awt.event的类,你必须为每个包提供单独的import语句。

跳过导入

你有另一种选择可以在你的代码中直接使用其他包的完全限定名称来使用外部类。例如,我们的HelloJava类使用了javax.swing包中的JFrameJLabel类。如果需要,我们可以只导入JLabel类:

import javax.swing.JLabel;

public class HelloJava {
  public static void main(String[] args) {
    javax.swing.JFrame frame = new javax.swing.JFrame("Hello, Java!");
    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    // ...
  }
}

对于在我们创建框架的一行中显得过于冗长的情况,但在具有已经很长的导入列表的大类中,一次性的用法实际上可以使你的代码更易读。这种完全限定名称的使用通常指向文件中仅使用此类的情况。如果你多次使用该类,你将import它或其包。这种全名使用方式从不是必需的,但你偶尔会在现实中看到它。

自定义包

随着你继续学习 Java 并编写更多代码并解决更大的问题,无疑会开始积累越来越多的类。你可以使用包来帮助组织这些类。使用package关键字声明自定义包,然后将带有你的类的文件放置在与包名对应的文件夹结构中。作为一个快速提醒,包使用全小写名称(按约定)以句点分隔,例如我们的图形界面包javax.swing

包名的另一个广泛应用的约定是称为“反向域名”命名。除了与 Java 直接关联的包外,第三方库和其他贡献的代码通常使用公司或个人电子邮件地址的域名进行组织。例如,Mozilla 基金会向开源社区贡献了各种 Java 库。大多数这些库和工具将位于以 Mozilla 的域名mozilla.org反向命名的包中:org.mozilla。这种反向命名有一个方便的(也是预期的)副作用,即保持顶层文件夹结构相对较小。对于使用来自comorg顶级域的库的好规模的项目来说,这并不少见。

如果你在自己的项目中构建包,与任何公司或合同工作无关,你可以使用你的电子邮件地址,并进行反向,类似于公司域名。另一个在线分发代码的流行选项是使用你的托管提供商的域名。例如,GitHub 托管了许多,许多 Java 爱好者和爱好者的项目。你可能会创建一个名为com.github.myawesomeproject的包(其中myawesomeproject显然应替换为你的实际项目名称)。请注意,像 GitHub 这样的网站上的存储库通常允许在包名称中不合法的名称。你可能有一个名为my-awesome-project的存储库,但在包名称的任何部分中都不允许使用破折号。通常这些非法字符会被简单地省略以创建一个有效的名称。

你可能已经注意到,我们将这本书中的各种示例放在了包中。虽然在包内组织类 内部 是一个毫无明确最佳实践的难题,但我们采取了一种方法,旨在使读者在阅读书籍时能轻松找到示例。对于每一章的完整示例,你会看到一个像ch05.examples这样的包。对于正在进行的游戏示例,我们使用了一个game子包。我们将章节末尾的练习放在了ch05.exercises中。

注意

当你编译一个打包的类时,你需要告诉编译器实际文件在文件系统中的位置,因此你使用它的路径,路径元素之间用你的文件系统分隔符(通常是/\)分隔开。另一方面,当你运行一个打包的类时,你指定它的完全限定的,点分隔的名称。

如果你使用的是一个集成开发环境(IDE),它将很高兴为你管理这些包问题。只需创建和组织你的类,并继续识别启动你程序的主类。

成员可见性和访问

我们已经谈到了在声明变量和方法时可以使用的访问修饰符。将某些内容设置为public意味着任何地方都可以看到你的变量或调用你的方法。将某些内容设置为protected意味着任何子类都可以访问变量,调用方法,或者重写方法,以提供适合你子类的替代功能。private修饰符表示该变量或方法仅在类本身内部可用。

包影响protected成员。除了可以被任何子类访问外,这些成员还可被同一包中的其他类看到和重写。如果你完全省略修饰符,包也会起作用。考虑一些自定义包mytools.text中的示例文本组件,如图 5-4 所示。

ljv6 0504

图 5-4. 包和类的可见性

TextComponent没有修饰符。它具有默认可见性或“包私有”可见性。这意味着同一包中的其他类可以访问该类,但是包外的任何类都不能访问。这对于特定于实现的类或内部辅助类非常有用。您可以在构建代码时自由使用包私有元素,但其他程序员只能使用您的publicprotected元素。图 5-5 显示了更多细节,包括子类和外部类使用示例类的变量和方法。

ljv6 0505

图 5-5. 包和成员可见性

请注意,扩展TextArea类可以让您访问公共的getText()setText()方法,以及protected方法formatText()。但是MyTextDisplay(稍后在“子类化和继承”中详细介绍)无法访问包私有变量linecount。然而,在我们创建TextEditor类的mytools.text包中,我们可以访问linecount以及那些是publicprotected的方法。我们用于存储内容的内部变量text保持私有,除了TextArea类本身之外,其他任何人都无法访问它。

表格 5-2 总结了 Java 中可用的可见性级别;一般情况下从最严格到最不严格的顺序排列。方法和变量始终在声明类内部可见,因此该表不涉及该范围。

表 5-2. 可见性修饰符

修饰符 类外可见性
private
无修饰符(默认) 包内的类
protected 包内和包外子类
public 所有类

使用包编译

您已经看到了使用完全限定的类名编译简单示例的几个示例。如果您不使用 IDE,还有其他选项可供选择。例如,您可能希望编译给定包中的所有类。如果是这样,您可以这样做:

% javac ch02/examples/*.java
% java ch02.examples.HelloJava

请注意,对于商业应用程序,您经常会看到包含多个段的更复杂的包名称。正如我们之前提到的,一个常见做法是反转公司的互联网域名。例如,O’Reilly 的这本书可能更适合使用类似于com.oreilly.learningjava6e的完整包前缀。每个章节将是该前缀下的一个子包。在这些包中编译和运行类非常简单,只是有点啰嗦:

% javac com/oreilly/learningjava6e/ch02/examples/*.java
% java com.oreilly.learningjava6e.ch02.examples.HelloJava

javac命令还了解基本的类依赖关系。如果您的主类使用同一源代码层次结构中的几个其他类,即使它们不都在同一个包中,编译该主类也会“捎带”其他依赖类并将它们一起编译。

虽然简单程序中可能只有一个包中的几个类,但实际上更可能依赖于你的 IDE 或诸如 Gradle 或 Maven 等构建管理工具。这些工具超出了本书的范围,但在线上有很多关于它们的参考资料。特别是 Maven,它在管理具有许多依赖的大型项目方面非常流行。请参阅Maven: The Definitive Guide(O’Reilly),由 Maven 的创始人 Jason Van Zyl 及其 Sonatype 团队撰写,详细探讨这一强大工具的功能和能力。⁶

高级类设计

你可能还记得在 “HelloJava2: The Sequel” 中,我们在同一个文件中有两个类。这简化了编写和编译过程,但并没有赋予任何一个类对另一个类的特殊访问权限。当你开始思考更复杂的问题时,你会遇到更高级的类设计,它不仅仅是方便,而且是编写可维护代码的关键。

子类化与继承

Java 中的类存在层次结构。你可以使用 extends 关键字在 Java 中声明一个类作为另一个类的子类。子类从其超类那里继承变量和方法,并且可以像在子类内部声明的一样使用它们:

class Animal {
  float weight;

  void eat() {
    // do eating stuff
  }
  // other animal stuff
}

class Mammal extends Animal {
  // inherits weight
  int heartRate;

  // inherits eat()
  void breathe() {
    // respire
  }
}

在这个例子中,类型为 Mammal 的对象同时具有实例变量 weight 和方法 eat(),它们是从 Animal 继承而来的。

一个类只能扩展另一个类。使用正确的术语,Java 允许对类实现单继承。本章后面我们将讨论接口,它们取代了其他语言中的多继承

子类可以进一步被子类化。通常情况下,通过子类化可以通过添加变量和方法来专门化或改进类(不可以通过子类化移除或隐藏变量或方法)。例如:

class Cat extends Mammal {
  // inherits weight and heartRate
  boolean longHair;

  // inherits eat() and breathe()
  void purr() {
    // make nice sounds
  }
}

Cat 类是 Mammal 的一种类型,最终是 Animal 的一种类型。Cat 对象继承了 Mammal 对象的所有特征,并且进而继承了 Animal 对象的特征。此外,Cat 还通过 purr() 方法和 longHair 变量提供了额外的行为。我们可以在图示中表示类之间的关系,如 Figure 5-6 所示。

ljv6 0506

图 5-6. 类层次结构

子类继承其超类中未标记为 private 的所有成员。正如我们即将讨论的那样,其他可见性级别影响了类的继承成员能否从类外部及其子类中看到,但至少,子类始终具有与其父类相同的可见成员集合。因此,子类的类型可以被视为其父类的子类型,子类型的实例可以在任何允许使用父类型实例的地方使用。考虑以下示例:

    Cat simon = new Cat();
    Animal creature = simon;

在这个例子中,Cat 实例 simon 可以赋值给 Animal 类型变量 creature,因为 CatAnimal 的子类型。同样地,接受 Animal 对象的任何方法也将接受 Cat 实例或任何 Mammal 类型的实例。这是面向对象语言如 Java 中多态性的重要方面。我们将看到它如何用于细化类的行为,以及为其添加新的能力。

阴影变量

我们已经看到,与实例变量同名的局部变量会遮蔽(隐藏)实例变量。类似地,子类中的实例变量可以遮蔽其父类中同名的实例变量,如 图 5-7 所示。

ljv6 0507

图 5-7. 阴影变量的作用域

变量 weight 在三个地方声明:作为 Mammal 类的 foodConsumption() 方法中的局部变量,作为 Mammal 类本身的实例变量,以及作为 Animal 类的实例变量。您在代码中引用的实际变量将取决于您正在工作的作用域以及您如何限定对它的引用。

在前面的例子中,所有变量都是相同类型的。稍微更有可能的使用阴影变量的情况是改变它们的类型。例如,我们可以在需要十进制值而不是整数值的子类中用 double 变量来阴影 int 变量。我们可以在不改变现有代码的情况下做到这一点,因为顾名思义,当我们阴影变量时,我们并不是替换它们,而是遮蔽它们。两个变量仍然存在;超类的方法看到原始变量,子类的方法看到新版本。各种方法看到的变量是在编译时确定的。

这是一个简单的例子:

class IntegerCalculator {
  int sum;
  // other integer stuff ...
}

class DecimalCalculator extends IntegerCalculator {
  double sum;
  // other floating point stuff ...
}

在这个例子中,我们阴影实例变量 sum,将其类型从 int 改变为 double。类 IntegerCalculator 中定义的方法看到整数变量 sum,而类 DecimalCalculator 中定义的方法看到浮点数变量 sum。然而,对于给定的 DecimalCalculator 实例,这两个变量都是实际存在的,并且它们可以具有独立的值。事实上,DecimalCalculator 继承自 IntegerCalculator 的任何方法实际上看到整数变量 sum。如果这听起来令人困惑——确实如此。在可能的情况下,应避免使用阴影。但是并非总是能够避免,所以我们希望确保您已经看到了一些例子,尽管有些有点牵强。

因为 DecimalCalculator 中存在这两个变量,我们需要一种方法来引用从 IntegerCalculator 继承的变量。我们可以使用 super 关键字作为引用的限定符:

    int s = super.sum;

DecimalCalculator 中,以这种方式使用的 super 关键字选择了在超类中定义的 sum 变量。我们稍后将更详细地解释 super 的用法。

关于遮蔽变量的另一个重要观点是,当我们通过一个不太派生的类型(父类型)的变量引用对象时,它们的工作方式是如何的。例如,我们可以通过一个IntegerCalculator类型的变量来引用一个DecimalCalculator对象。如果我们这样做,然后访问变量sum,我们得到的是整数变量,而不是小数变量:

    DecimalCalculator dc = new DecimalCalculator();
    IntegerCalculator ic = dc;

    int s = ic.sum;       // accesses IntegerCalculator sum

如果我们使用显式转换到IntegerCalculator类型来访问对象,或者将实例传递给接受该父类型的方法时,情况也是如此。

重申一下,变量的遮蔽使用是有限的。在其他方面,抽象化变量的使用比使用复杂的作用域规则要好得多。然而,在我们讨论如何使用方法做同样的事情之前,理解这里的概念是很重要的。当方法遮蔽其他方法或者用正确的术语说,重写其他方法时,我们会看到一种不同而且更动态的行为。在子类中重写方法非常常见且非常强大。

方法重写

我们已经看到我们可以在一个类中声明重载的方法(具有相同名称但不同数量或类型的参数的方法)。重载方法的选择方式与我们在类中描述的方式一样,包括继承的方法。这意味着子类可以定义额外的重载方法,以补充超类提供的重载方法。

子类不仅可以这样做;它还可以定义一个与其超类中方法签名(名称和参数类型)完全相同的方法。在这种情况下,子类中的方法会覆盖超类中的方法,并有效地替换其实现,如图 5-8 所示。通过重写方法来改变对象行为被称为子类型多态性。这是大多数人在谈论面向对象语言的强大功能时所考虑的用法。

ljv6 0508

图 5-8. 方法重写

在图 5-8 中,Mammal重写了Animalreproduce()方法,可能是为了将方法专门化为哺乳动物生产活仔的行为。Cat对象的睡眠行为也被重写,以与一般Animal的不同,可能是为了适应猫的小睡。Cat类还增加了更独特的行为,如咕噜声和捕猎老鼠。

从你目前看到的内容来看,重写方法可能看起来像是在超类中遮蔽方法,就像变量一样。但是重写的方法实际上比这更强大。当对象在其继承层次结构中具有多个方法实现时,位于“最派生”类中(层次结构最低端)的方法始终会重写其他方法,即使我们通过一个超类类型的引用来引用对象。⁹

例如,如果我们有一个Cat实例分配给一个更一般类型Animal的变量,并且我们调用它的sleep()方法,我们仍然得到Cat类中实现的sleep()方法,而不是Animal类中的方法:

    Cat simon = new Cat();
    Animal creature = simon;
    // ...
    creature.sleep();       // accesses Cat sleep();

换句话说,对于行为(调用方法)来说,一个Cat表现得像一个Cat,无论你是否将其称为这样。你可能记得,通过我们的Animal变量creature访问一个被屏蔽的变量将在Animal类中找到该变量,而不是Cat类中。然而,因为方法是动态定位的,首先搜索子类,运行时会调用Cat类中适当的方法,即使我们将其更一般地视为Animal对象。这意味着对象的行为是动态的。我们可以将专门的对象处理为更一般的类型,并且仍然利用它们的行为专用实现。

抽象类和方法

有时你没有一个好的默认行为来实现一个方法。想想动物如何交流。狗会叫。猫会喵。牛会哞。实际上没有标准的声音。在 Java 中,你可以创建一个抽象方法来精确定义一个方法应该如何看起来,而不指定任何特定的行为。在声明方法时使用abstract修饰符。而不是提供方法体,你只需在定义末尾加上一个分号。考虑我们动物的makeSound()方法:

public class Animal {
  float weight;
  // other animal traits ...

  public abstract void makeSound(int duration);
  // other animal behaviors ...
}

注意,我们制作声音的方法有一个完整的签名(回顾“运行 Java 应用程序”)。它是void(没有返回值),并且接受一个int类型的参数。但是它没有方法体。这种类型的方法明确设计为被覆盖。你不能调用一个抽象方法;你会得到一个编译时错误。在你可以使用它之前,你必须创建一个提供抽象方法逻辑的新子类:

public class Cat extends Animal {
  // ...
  public void makeSound(int duration) {
    for (int count = 0; count < duration; count++) {
      // assume our sound takes one second to make and we can
      // repeat the sound to match the requested duration
      System.out.println("meow!");
    }
  }
  // ...
}

现在我们有了一个Cat的实例,我们可以调用makeSound()方法,编译器知道该怎么做了。但是因为Animal现在包含一个抽象方法,我们不能创建该类的实例。要使用Animal,我们必须创建一个子类并像我们的Cat一样填写makeSound()

实际上,如果我们在我们的类中包含一个抽象方法,我们还必须将该类本身声明为抽象。我们上面的Animal片段将不能编译。我们在类声明时使用相同的abstract关键字:

public abstract class Animal {
  // Notice the class definition needs the "abstract" modifier

  public abstract void makeSound(int duration);
  // ...
}

此声明告诉编译器(和其他开发人员),你设计这个类来作为一个更大程序的一部分。你期望(事实上,要求)子类扩展你的抽象类并填补任何缺失的细节。这些子类反过来可以被实例化并且可以执行真正的工作。

抽象类仍然可以包含典型的、带有方法体的方法。例如,对于Animal,我们可以添加一些动物体重的帮助方法:

public abstract class Animal {
  private double weight;
  // ...
  public abstract void makeSound(int duration);
  // ...

  public void setWeight(double w) {
    this.weight = w;
  }

  public double getWeight() {
    return weight;
  }
}

这是一种常见且良好的设计实践。你的Animal类包含尽可能多的基本共享信息和行为。但是对于不共享的事物,你可以创建一个具有所需特性和操作的子类。

接口

Java 通过接口扩展了抽象方法的概念。通常希望指定一组抽象方法来定义对象的某种行为,而不将其与任何具体实现绑定。在 Java 中,这称为接口。接口定义了一个类必须实现的一组方法。在 Java 中,如果一个类实现了所需的方法,它可以声明它implements一个接口。与扩展抽象类不同,实现接口的类不必继承自继承层次结构的任何特定部分或使用特定实现。

接口有点像童子军的徽章。一个学会如何建造鸟屋的童子军可以穿着一个带有鸟屋图案的布贴或肩带四处走动。这向世界宣告:“我知道如何建造鸟屋。”同样,接口是为对象定义一组行为的方法列表。实现接口中列出的每个方法的任何类都可以在编译时声明它实现了该接口,并且佩戴一种额外的类型——接口的类型,作为它的徽章。

接口类型就像类类型。你可以声明变量为接口类型,可以声明方法的参数接受接口类型,也可以指定方法的返回类型为接口类型。在每种情况下,你都在说任何实现接口的对象(即佩戴了正确徽章的对象)都可以担任这个角色。在这个意义上,接口与类层次结构是正交的。它们跨越了一个物品属于什么类型的界限,而是仅通过它可以做什么来处理它。你可以为任何给定的类实现尽可能多的接口。Java 中的接口在其他语言中大部分需要多重继承的需求(以及真正的多重继承所带来的混乱复杂性)都得到了解决。

接口在本质上看起来像一个纯abstract类(只有abstract方法的类)。你使用interface关键字定义一个接口,并列出它的方法,但不带有任何具体内容,只有原型(签名):

interface Driveable {
  boolean startEngine();
  void stopEngine();
  float accelerate(float acc);
  boolean turn(Direction dir);
}

前面的例子定义了一个名为Driveable的接口,其中包含四个方法。这是可以接受的,但不是必须的,将接口中的方法声明为abstract修饰符;我们在这里没有这样做。更重要的是,接口的方法始终被视为public,你可以选择将它们声明为public。为什么是public?因为接口的使用者否则可能看不到它们,并且接口通常旨在描述对象的行为,而不是其实现方式。

接口定义了能力,因此通常根据它们的能力命名接口是很常见的。DriveableRunnableUpdateable是很好的接口名称。任何实现了所有方法的类都可以通过在其类定义中使用特殊的implements子句声明它实现了该接口。例如:

class Automobile implements Driveable {
  // Automobile traits could go here ...

  // build all the Driveable methods
  public boolean startEngine() {
    if (notTooCold)
      engineRunning = true;
    // ...
  }

  public void stopEngine() {
    engineRunning = false;
  }

  public float accelerate(float acc) {
    // ...
  }

  public boolean turn(Direction dir) {
    // ...
  }

  // Do other car things ...
}

在这里,类Automobile实现了Driveable接口的方法,并使用implements关键字声明自己是Driveable类型。

如图 5-9 所示,另一个类,如Lawnmower,也可以实现Driveable接口。该图示例了Driveable接口被两个不同类实现的情况。虽然AutomobileLawnmower可能都来源于某种原始类型的车辆,但在这种情况下并非如此。

ljv6 0509

图 5-9. 实现Driveable接口

在声明接口之后,我们有了一个新的类型,Driveable。我们可以声明Driveable类型的变量,并分配任何Driveable对象的实例:

    Automobile auto = new Automobile();
    Lawnmower mower = new Lawnmower();
    Driveable vehicle;

    vehicle = auto;
    vehicle.startEngine();
    vehicle.stopEngine();

    vehicle = mower;
    vehicle.startEngine();
    vehicle.stopEngine();

AutomobileLawnmower都实现了Driveable,因此它们可以被视为该类型的可互换对象。

正如我们之前提到的,接口在 Java 的功能和流行性中起着至关重要的作用。我们将在剩余的章节中一直使用它们。如果它们还不太明白,请继续阅读并继续进行代码练习。你将会更多地通过实践来掌握它们。练习并不总是能够达到完美,但它确实会使某些东西变得不那么奇怪和晦涩。

内部类

所有我们在本书中到目前为止看到的类都是顶级的,“独立的”类,它们在文件和包级别声明。但是在 Java 中,类实际上可以在任何作用域级别声明,在任何花括号中——换句话说,几乎可以放置任何其他 Java 语句的地方。这些内部类作为变量一样属于另一个类或方法,并且其可见性可能被限制在其范围内。

内部类是用于结构化代码的一个有用且美观的功能。它们的姊妹类,匿名内部类,是一种更强大的简写方式,使得在 Java 的静态类型环境中似乎可以动态创建新类型的对象。在 Java 中,匿名内部类扮演其他语言中闭包的角色的一部分,产生处理状态和行为独立于类的效果。(你还可以在许多内部或匿名内部类适用的地方使用lambda。Lambda 封装了逻辑的片段,在许多函数式语言和 LISP 中都很常见。我们将在第十一章中更详细地介绍它们。)

然而,当我们深入其内部工作时,我们会发现内部类并不像它们看起来那样美观或动态。内部类只是一种语法糖;Java 运行时不支持它们。相反,编译器将内部类的代码映射到一个巧妙命名的常规类。作为程序员,您可能永远不需要知道这一点;您可以像任何其他语言构造一样依赖内部类。但是,您应该了解它们的工作原理,以更好地理解编译后的代码,并注意一些潜在的副作用。

内部类本质上是嵌套类。例如:

class Animal {
  double weight;

  class Brain {
    double volume;
    // more brain stuff ...
  }
}

在这里,类Brain是一个内部类:它是在Animal类的范围内声明的类。虽然具体的含义需要稍作解释,但我们先从 Java 尝试尽可能使其与其他成员(方法和变量)在同一级别的作用域中的含义相同开始。例如,让我们向Animal类添加一个方法:

class Animal {
  double weight;

  class Brain {
    double volume;
    // more brain stuff ...
  }

  void performBehavior() { ... }
}

内部类Brain,方法performBehavior()和变量weight都在Animal的范围内。因此,在Animal的任何地方,我们都可以直接通过名称引用BrainperformBehavior()weight。在Animal内部,我们可以调用Brain的构造函数(new Brain())获取一个Brain对象或者调用performBehavior()执行该方法的功能。但这些元素在没有额外限定的情况下通常无法在Animal类外部访问。

在内部Brain类和performBehavior()方法的主体内,我们可以直接访问weight变量以及Animal类的所有其他方法和变量。因此,正如performBehavior()方法可以使用Brain类并创建Brain实例一样,Brain类内的方法可以调用AnimalperformBehavior()方法或处理weight变量。Brain类在其范围内“看到”Animal类的所有方法和变量。

一个大脑对Animal的变量和方法的访问具有重要的后果。从Brain内部,我们可以调用performBehavior()方法;也就是说,在Brain的实例内部,我们可以调用Animal的实例的performBehavior()方法。那么,是哪个Animal的实例呢?如果我们周围有几个Animal对象(比如几只CatDog),我们需要知道我们调用的是谁的performBehavior()方法。类定义“内部”另一个类定义是什么意思?答案是Brain对象总是存在于单个Animal实例内:即在创建时告知它的那个实例。我们将包含任何Brain实例的对象称为封闭实例

Brain对象不能存在于Animal对象的封闭实例之外。无论何处看到Brain的实例,它都将与Animal的实例相连。虽然可能从其他地方(可能是另一个类)构造Brain对象,但Brain始终需要一个Animal的封闭实例来“容纳”它。如果你确实找到了Animal类的Brain,它仍将明确与Animal关联为Animal.Brain类。与performBehavior()方法一样,可以应用修饰符来限制其可见性。所有通常的可见性修饰符都适用,内部类也可以声明为static,我们将在后面的章节中讨论。

匿名内部类

现在我们来到了最精彩的部分。作为一个一般规则,我们的类封装得越深、作用域越有限,我们在命名它们时就越自由。我们在早期迭代器示例中看到了这一点。这不仅仅是一个纯粹的审美问题。命名是写可读性强、可维护的代码的重要组成部分。一般来说,使用最简洁、最有意义的名称。作为一个推论,避免为只会被使用一次的临时对象赋予名称。

匿名内部类是new操作语法的扩展。当你创建一个匿名内部类时,你将类声明与分配该类的实例结合在一起,有效地创建一个“一次性”类和一个实例。在new关键字之后,你指定一个类的名称或一个接口,然后是一个类体。类体成为一个内部类。它要么扩展指定的类,要么在接口的情况下,被期望实现该接口。创建并返回该类的单个实例作为值。

例如,我们可以重新审视来自“HelloJava2: The Sequel”的图形应用程序。您可能还记得,该应用程序创建了一个扩展JComponent并实现MouseMotionListener接口的HelloComponent2。现在这个例子是否更有意义了呢?我们从不指望HelloComponent2会响应来自其他组件的鼠标移动事件。创建一个匿名内部类来特别移动我们的“Hello”标签可能更合理。

实际上,由于HelloComponent2真正只用于我们的演示,我们可以重构(这是优化或改进已经工作的代码的常见开发者过程),将那个单独的类重构为内部类。现在我们对构造函数和继承了解更多后,我们也可以将我们的类扩展为JFrame,而不是在我们的main()方法内构建一个窗体。为了给这个新重构的部分增添点亮色,我们可以将我们的鼠标监听器代码移动到专门为我们的自定义组件设计的匿名内部类中。

这里是我们的HelloJava3,已经进行了这些巧妙的重构:

package ch05.examples;

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;

public class HelloJava3 extends JFrame {
  public static void main(String[] args) {
    HelloJava3 demo = new HelloJava3();
    demo.setVisible(true);
  }

  public HelloJava3() {
    super("HelloJava3");
    add(new HelloComponent3("Hello, Inner Java!"));
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    setSize(300, 300);
  }

  class HelloComponent3 extends JComponent {
    String theMessage;
    int messageX = 125, messageY = 95; // message coordinates

    public HelloComponent3(String message) {
      theMessage = message;
      addMouseMotionListener(new MouseMotionListener() {
        public void mouseDragged(MouseEvent e) {
            messageX = e.getX();
            messageY = e.getY();
            repaint();
        }

        public void mouseMoved(MouseEvent e) { }
      });
    }

    public void paintComponent(Graphics g) {
      g.drawString(theMessage, messageX, messageY);
    }
  }
}

尝试编译和运行此示例。它应该与原始的HelloJava2应用程序行为完全相同。真正的区别在于我们如何组织类以及谁能访问它们(以及其中的变量和方法)。与HelloJava2相比,HelloJava3可能看起来有点累赘,并且对于这样一个小的演示而言,它很冗长。

随着您开发更复杂的应用程序,内部类和接口的功能将开始发挥作用。通过使用这些功能的结构和规则进行练习,将有助于您长期编写更易于维护的代码。

组织内容并为失败做计划

类是 Java 中最重要的概念。它们构成了每个可执行程序、可移植库或助手的核心。我们已经查看了类的内容以及类在较大项目中如何相互关联。我们了解了如何根据我们编写的类来创建和销毁对象。我们还看到了内部类(和匿名内部类)如何帮助我们编写更易于维护的代码。当我们深入研究诸如第 9 章中的线程和第 12 章中的 Swing 等更深入的主题时,我们将会看到更多这些内部类。

在构建类时,请牢记以下几点指导方针:

尽可能隐藏实现细节

永远不要公开比您需要的更多的对象内部信息。这是构建可维护、可重用代码的关键。避免在对象中使用公共变量,其中一个值得注意的例外是常量。相反,定义访问器方法来设置和返回值。即使它们是简单的类型也很有用——考虑一下像getWeight()setWeight()这样的方法。在不破坏依赖于它们的其他类的情况下,您将能够修改和扩展对象的行为。

使用组合而不是继承

只有在必要时才对对象进行特化。当您使用现有形式的对象作为新对象的一部分时,您正在组合对象。当您改变或完善对象的行为(通过子类化)时,您正在使用继承。尽量通过组合而不是继承来重用对象。当您组合对象时,您正在充分利用现有的工具。继承涉及到打破对象的封装,因此只有在确实有优势时才这样做。问问自己,您是否真的需要继承整个类(您希望它成为该对象的“一种”吗?)还是您只需在自己的类中包含该类的一个实例并将一些工作委托给该包含的实例。

将对象之间的关系最小化,并尝试将相关对象组织在包中。

Java 包(回顾图 5-1)还可以隐藏对不是普遍兴趣的类的访问。只公开您打算其他人使用的类。您的对象之间的耦合度越低,以后重用它们就越容易。

即使在小项目中,我们也可以应用这些原则。ch05/examples 文件夹包含我们将用来创建苹果投掷游戏的类和接口的简单版本。花一点时间看看AppleTreePhysicist 类如何实现GamePiece 接口,就像每个类都包括的draw() 方法一样。注意Field 扩展了JComponent,以及主游戏类AppleToss 扩展了JFrame。您可以看到这些简单的组件如何一起运行在图 5-10 中。

ljv6 0510

图 5-10. 我们的第一个游戏类在操作中

本章的最后一个编码练习将帮助您入门。查看类中的注释。尝试调整一些东西。再添加一个树。更多的实践总是有好处的。在接下来的章节中,我们将继续构建这些类,因此熟悉它们如何配合将会更容易。

无论您如何组织类中的成员、包中的类或项目中的包,都将不得不处理错误。有些错误是您在编辑器中修复的简单语法错误。其他错误更有趣,可能只在程序实际运行时出现。下一章将涵盖 Java 对这些问题的概念,并帮助您处理它们。

复习问题

  1. Java 中的主要组织单位是什么?

  2. 您使用什么运算符来从类创建对象(或实例)?

  3. Java 不支持经典的多重继承。Java 提供了哪些替代机制?

  4. 如何组织多个相关类?

  5. 如何在您自己的代码中包含来自其他包的类以供使用?

  6. 如何称呼在另一个类的作用域内定义的类?这样的类在某些情况下有哪些有用的特征?

  7. 如何称呼一个设计为被重写的方法,它有一个名称、返回类型和参数列表,但没有方法体?

  8. 什么是重载方法?

  9. 如果您希望确保没有其他类可以使用您定义的变量,应该使用哪种访问修饰符?

编码练习

  1. 对于您的第一个编码实践,创建一个能够交流的小动物园。ch05/exercises/Zoo.java 文件包含了创建几种动物并让它们通过内部类“说话”的完整概述。首先,通过完成的Lion内部类的例子填写Gibbon类的speak()方法。当您编译并运行您的Zoo时,您应该看到类似这样的输出(当然,您自己的动物叫声):

    % cd ch05/exercises
    % javac Zoo.java
    % java Zoo
    Let&rsquo;s listen to some animals!
    The lion goes "roar"
    The gibbon goes "hoot"
    
  2. 现在向动物园添加您自己的动物。创建一个类似于Lion 的新内部类。为您的动物填写适当的声音,并将它们添加到listen() 方法的输出部分。您的新输出将类似于:

    % java Zoo
    Let&rsquo;s listen to some animals!
    The lion goes "roar"
    The gibbon goes "hoot"
    The seal goes "bark"
    
  3. 让我们来清理一下listen()方法。我们目前为每个动物使用单独的print()println()方法。如果我们添加另一个(或几个)动物,那将需要复制、粘贴和调整输出行——这些任务可能会引入错误。在Animal中添加另一个抽象方法称为getSpecies()。在子类中,此方法应返回动物的名称作为String,例如上面的“狮子”或“海豹”。

    有了这个方法,重构输出部分以将你的动物放入一个小数组中,然后使用循环生成输出。(可以编辑现有的Zoo类。我们的解决方案在一个新类Zoo2中,这样你可以查看这个问题的解决方案以及上一个练习的解决方案。)

  4. 运行苹果投掷游戏。使用“自定义包”中讨论的步骤编译和运行ch05/exercises/game文件夹中的ch05.exercises.game.AppleToss类。

高级练习

  1. 对于更高级的挑战,让我们通过创建一种新类型的障碍物来扩展我们的苹果投掷游戏。使用Tree类作为模板创建Hedge类。你可以将你的篱笆绘制为绿色的矩形。要绘制一个矩形:

      public void paintComponent(Graphics g) {
        // x and y set the upper left corner
        g.fillRect(x,y,width,height);
      }
    

    在你的田野上添加一道篱笆。最终游戏应该看起来像图 5-11 那样。

    ljv6 0511

    图 5-11。我们田野上的新篱笆障碍物

我们将在整本书中继续扩展这个游戏,但现在请随意进行一些扩展。尝试绘制其他形状或更改当前元素的颜色。在线文档Graphics类对你有所帮助。

¹ 一旦你对基本面向对象的概念有了一些经验,你可能想看看《设计模式:可复用面向对象软件的基础》(Addison-Wesley)由 Erich Gamma 等人编写。这本书总结了多年来得到改进的有用面向对象设计。许多这些模式出现在 Java API 的设计中。

² char也得到一个 0,但通常表示为空字符\0boolean变量默认为false,引用类型默认为null

³ 我们不深入讨论这些参数列表的细节,但如果你感兴趣并想自己阅读一些内容,请在网上搜索程序员术语“varargs”。

⁴ 原来Math类根本无法实例化。它仅包含静态方法。尝试调用new Math()将导致编译器错误。

⁵ 仍然有可能在 Java 中编写代码,无意中永久持有对象(我们确定是无意的),不断消耗更多的内存。这并不是一个内存泄漏,而更像是囤积。在 Java 中这种囤积通常比 C 中的内存泄漏更容易追踪。

⁶ Maven 已经在 Java 中改变了依赖管理的格局,甚至其他基于 JVM 的语言现在也可以找到像Gradle这样基于 Maven 成功的工具。

⁷ 请注意,设计我们的计算器的更好方式是拥有一个抽象的Calculator类,有两个独立的子类:IntegerCalculatorDecimalCalculator

⁸ 鸭嘴兽是一个非常不寻常的产卵哺乳动物。我们可以在Mammal的子类中再次重写Platypusreproduce()行为。

⁹ 在 Java 中,重写的方法就像 C++中的virtual方法一样。

第六章:错误处理

在现实世界中,你总会遇到错误。你如何处理这些错误可以显示你代码的质量。

Java 的根源是嵌入式系统——运行在专用设备内部的软件,例如手持计算机、手机以及我们今天可能认为属于物联网(IoT)一部分的高级烤面包机。在这些应用中,稳健地处理软件错误尤为重要。大多数用户都认为,他们的手机经常崩溃或者他们的面包(甚至房子)因为某些软件故障而烧焦是不可接受的。鉴于我们无法消除软件错误的可能性,有条不紊地识别和处理应用级错误是朝正确方向迈出的良好步骤。

一些语言完全将处理错误的责任交给程序员。语言本身不提供帮助来识别错误类型,也没有简便的工具来处理它们。例如,在 C 语言中,函数通常通过返回一个“不合理”的值(如习惯上的 -1null)来表示失败。作为程序员,你必须知道什么构成了一个糟糕的结果以及它意味着什么。在正常的数据流路径中传递错误值的限制通常让人感到尴尬。¹ 更糟糕的问题是,某些类型的错误几乎可以在任何地方合法地发生,而在软件的每个点显式测试它们是缓慢且昂贵的。

在本章中,我们将探讨 Java 如何处理问题。我们将讨论异常的概念,看看它们为何如何以及何时出现,以及如何在何处处理它们。我们还将研究错误和断言。错误是更严重的问题,通常在运行时无法修复,但仍可以记录以进行调试。断言是通过事先验证安全条件来使你的代码免受异常或错误的流行方法。

异常

Java 提供了一个优雅的解决方案,通过异常来帮助程序员解决常见的编码和运行时问题。(Java 异常处理与 C++ 中的异常处理类似,但并非完全相同。)异常 表示异常条件或错误条件。当问题发生时,运行时将控制(或“抛出”)到您代码中的一个特定指定的部分,该部分可以处理(或“捕获”)这种条件。通过这种方式,错误处理与程序的正常流程无关。我们不需要为所有方法返回特殊值;错误通过一个单独的机制处理。Java 可以在非常深的嵌套过程中传递控制并在需要时在单个位置处理错误,或者错误可以立即在其源头处理。一些标准的 Java 方法仍然返回特殊值,比如 -1,但这些通常仅限于期望和处理特殊值相对简单的情况。²

您必须指定方法可能抛出的任何已知异常,编译器会确保调用方法的代码处理它们。通过这种方式,Java 对方法可能产生的错误信息的处理与其参数和返回类型的重要性水平相同。您可能仍然决定忽略一些错误,但在 Java 中,您必须明确地这样做。(我们将讨论运行时异常和错误,这些异常不需要此显式声明。)

异常和错误类

异常由 java.lang.Exception 类及其子类的实例表示。Exception 的子类可以保存不同种类异常条件的专用信息(可能还有行为)。然而,它们更常见的是简单的“逻辑”子类,仅用于标识新的异常类型。Figure 6-1 展示了 java.lang 包中 Exception 的子类。它应该让你对异常如何组织有所感觉。大多数包定义它们自己的异常类型,这些类型通常是 Exception 本身或其重要子类 RuntimeException 的子类,我们马上会谈到。

例如,让我们看另一个重要的异常类:java.io.IOExceptionIOException 类扩展了 Exception 并且有许多自己的子类,用于典型的 I/O 问题,比如 FileNotFoundException。请注意类名是多么明确(和有用)。许多网络异常进一步扩展了 IOException —— 它们确实涉及输入和输出 —— 但按照惯例,像 MalformedURLException 这样的异常属于 java.net 包,与其他网络类一起。

ljv6 0601

图 6-1. java.lang.Exception 的子类

在出现错误条件的地方,运行时会创建一个Exception对象。它可以设计为包含描述异常条件所需的任何信息。它还包括用于调试的完整堆栈跟踪。堆栈跟踪是调用所有方法及其调用顺序的列表,从您的main()方法到抛出异常的地方(有时可能很冗长)。Exception对象作为参数传递到处理代码块,与控制流一起。这就是抛出捕获这些术语的来源:Exception对象从代码的一个点抛出并在另一个点被捕获,执行恢复,如图 6-2 所示。

ljv6 0602

图 6-2。异常发生时的控制流

Java 还为不可恢复错误定义了java.lang.Error类。java.lang包中Error的子类如图 6-3 所示。一个显著的Error类型是AssertionError,它被 Java 的assert语句(本章后面将更详细地介绍此语句)用来指示失败。其他一些包定义了它们自己的Error子类,但是Error的子类比Exception的子类要少得多(也不太有用)。通常情况下,您不需要在代码中担心这些错误;它们旨在指示致命问题或虚拟机错误,通常会导致 Java 解释器显示消息并退出。Java 的设计人员积极不鼓励开发人员尝试捕获或恢复这些错误,因为它们应该指示一个致命的程序错误,可能是在 JVM 本身中而不是常规情况中。

ExceptionError都是Throwable的子类。Throwable类是可以使用throw语句“抛出”的对象的基类。虽然你在技术上可以扩展Throwable自己,但如果你想创建自己的可抛出类型,通常应该只扩展ExceptionError或它们的某个子类。

ljv6 0603

图 6-3。java.lang.Error的子类

异常处理

要捕获和处理斜体文本例外,您可以将代码块包装在try/catch守卫语句中:

    try {
      readFromFile("foo");
      // do other file things ...
    } catch (Exception e) {
      // Handle error
      System.out.println("Exception while reading file: " + e);
    }

在这个示例中,任何发生在try语句块内部的异常都会被传递到catch子句以进行可能的处理。catch子句的作用类似于一个方法;它指定了它想要处理的异常类型。如果被调用,该子句会接收Exception对象作为参数。在这里,我们将对象赋给变量e,并打印出来以及相应的消息。

我们可以自己尝试一下。回想一下使用欧几里得算法计算最大公约数(GCD)的简单程序,这是在第四章中完成的。我们可以增强该程序,允许用户通过main()方法中的args[]数组将两个数字ab作为命令行参数传递。然而,该数组是String类型的。如果我们稍微作弊,从“解析基本数字”中借用一些代码,我们可以使用文本解析方法将这些字符串转换为int值。然而,如果我们未传递有效数字,该解析方法可能会抛出异常。这里是我们新的Euclid2类的样子:

public class Euclid2 {
  public static void main(String args[]) {
    int a = 2701;
    int b = 222;
    // Only try to parse arguments if we have exactly 2
    if (args.length == 2) {
      try {
        a = Integer.parseInt(args[0]);
        b = Integer.parseInt(args[1]);
      } catch (NumberFormatException nfe) {
        System.err.println("Arguments were not both numbers.");
        System.err.println("Using defaults.");
      }
    } else {
      System.err.print("Wrong number of arguments");
      System.err.println(" expected 2).");
      System.err.println("Using defaults.");
    }
    System.out.print("The GCD of " + a + " and " + b + " is ");
    while (b != 0) {
      if (a > b) {
        a = a - b;
      } else {
        b = b - a;
      }
    }
    System.out.println(a);
  }
}

请注意,我们仅将try/catch限制为潜在有问题的代码。在方法中看到几个不同的try/catch块是很常见的。这个小范围允许我们更好地将catch块中的代码调整为我们预期的任何问题。在这种情况下,我们知道可能会从用户那里得到一些不良输入,因此我们可以检查非常具体的NumberFormatException并为用户打印友好的消息。

如果我们从终端窗口运行此程序或在 IDE 中使用命令行参数选项,就像我们在图 2-10 中所做的那样,我们现在可以在不重新编译的情况下找到多个数对的 GCD。

$ javac ch06/examples/Euclid2.java
$ java ch06.examples.Euclid2
The GCD of 18 and 6 is 6

$ java ch06.examples.Euclid2 547832 2798
The GCD of 547832 and 2798 is 2

但是,如果我们传入的参数不是数字,我们将得到NumberFormatException并看到我们的错误消息。但是,请注意,我们的代码可以优雅地恢复并仍然提供一些输出。这种恢复是错误处理的本质:

$ java ch06.examples.Euclid2 apples oranges
Arguments were not both numbers.
Using defaults.
The GCD of 2701 and 222 is 37

try语句可以有多个指定不同类型(子类)的Exceptioncatch子句:

    try {
      readFromFile("foo");
      // do any other file things
    } catch (FileNotFoundException e) {
      // Handle file not found
    } catch (IOException e) {
      // Handle read error
    } catch (Exception e) {
      // Handle all other errors
    }

catch子句按顺序评估,并且 Java 选择第一个可分配的匹配项。最多只执行一个catch子句,这意味着异常应该从最具体到最通用进行列出。在前面的示例中,我们预期假设的readFromFile()方法可能会抛出两种不同类型的异常:一种是文件未找到,另一种是更一般的读取错误。也许文件存在,但我们没有权限打开它。FileNotFoundExceptionIOException的子类,因此如果我们交换了前两个catch子句,更通用的IOException子句将捕获缺少文件异常。

如果完全颠倒catch子句的顺序会怎样?您可以将Exception的任何子类分配给父类型Exception,因此该子句捕获每个异常。如果您使用类型为Exceptioncatch,请始终将其放置在可能的最后一个子句中。它的作用类似于switch语句中的default情况,并处理任何剩余可能性。

try/catch 方案的一个优点是,try 块中的任何语句都可以假定该块中的所有前面语句都成功执行了。不会因为忘记检查方法的返回值而突然出现问题。如果早期语句失败,执行立即跳转到 catch 子句;try 内部的后续语句将不会执行。

使用多个 catch 子句的另一种选择是使用单个 catch 子句处理多个离散的异常类型,使用 或语法(使用管道字符“|”写成):

    try {
      // read from network...
      // write to file..
    } catch (ZipException | SSLException e) {
      logException(e);
    }

使用这种“|”语法,我们可以在同一个 catch 子句中接收两种类型的异常。

我们传递给日志方法的 e 变量的实际类型是什么?我们可以对它做什么?在这种情况下,e 的类型既不是 ZipException 也不是 SSLException,而是 IOException,它是这两种异常的最近公共祖先(它们都可以分配到的最近的父类类型)。在许多情况下,两个或多个参数异常类型之间的最近公共类型可能只是 Exception,所有异常类型的父类。

使用多类型 catch 子句捕获这些离散异常类型与仅捕获共同父异常类型的区别在于,我们将 catch 限制为仅这些特定枚举的异常类型。我们不会捕获任何其他 IOException 类型。将多类型 catch 子句与按照特定到广泛类型的顺序排列结合使用,可以在处理异常时提供很大的灵活性。在适当的情况下,您可以合并错误处理逻辑并避免重复代码。此功能还有更多细微差别,我们在讨论“抛出”和“重新抛出”异常后将返回这个话题。

冒泡上升

如果我们没有捕获异常会怎么样?它会去哪里?如果没有包围的 try/catch 语句,异常会从它产生的方法中弹出(停止该方法的进一步执行),并从该方法中抛出到其调用者。如果调用方法有 try 子句,控制将传递给相应的 catch 子句。否则,异常会继续向上传播到调用堆栈中的上一方法。异常会一直冒泡直到被捕获,或者直到从程序顶部弹出并以运行时错误消息终止程序。有时情况可能更为复杂;编译器可能会强制你在途中处理异常。“已检查和未检查的异常”更详细地讨论了这种区别。

让我们看另一个例子。在 图 6-4 中,方法 getContent()try/catch 语句内调用方法 openConnection()(图中步骤 1)。然后,openConnection() 调用方法 sendRequest()(步骤 2),后者调用方法 write() 以发送一些数据。

ljv6 0604

图 6-4. 异常传播

在这个图中,第二次调用write()抛出了一个IOException(步骤 3)。由于sendRequest()方法中没有try/catch语句来处理这个异常,它会再次从调用它的openConnection()方法(步骤 4)抛出。但是openConnection()也没有捕获这个异常,所以它会再次被抛出(步骤 5)。最后,它被getContent()方法中的try语句捕获,并由其catch子句处理。请注意,每个可能抛出异常的方法必须使用throws子句声明可以抛出特定类型的异常。我们将在“检查异常和未检查异常”中讨论这个问题。

在代码的早期添加一个高级别的try语句也可以帮助处理可能从后台线程冒出的错误。我们将在第九章中详细讨论线程,但是在更大更复杂的程序中,未捕获的异常可能导致调试困难。

堆栈跟踪

因为异常可能在被捕获和处理之前冒出相当长的距离,我们需要一种方法来确定它被抛出的确切位置。了解到问题代码是如何被触发的也很重要。哪些方法调用了哪些其他方法来到达这一点?对于调试,所有异常都可以通过打印它们的堆栈跟踪来列出它们的起源方法以及它们到达那里所经历的所有嵌套方法调用。当用户使用printStackTrace()方法打印堆栈跟踪时,最常见的是看到一个堆栈跟踪:

    try {
      // complex, deeply nested task
    } catch (Exception e) {
      // dump information about where the exception occurred
      e.printStackTrace(System.err);
    }

这样的异常堆栈跟踪可能看起来像这样:

java.io.FileNotFoundException: myfile.xml
      at java.io.FileInputStream.<init>(FileInputStream.java)
      at java.io.FileInputStream.<init>(FileInputStream.java)
      at MyApplication.loadFile(MyApplication.java:137)
      at MyApplication.main(MyApplication.java:5)

此堆栈跟踪指示在MyApplication类的main()方法的第 5 行(在您的源代码中)调用了loadFile()方法。然后,loadFile()方法尝试在第 137 行构造FileInputStream,它抛出了FileNotFound``Exception异常。

一旦堆栈跟踪到达 Java 系统类(如FileInputStream),行号可能会丢失。如果代码已经优化,也可能会发生这种情况。通常可以通过暂时禁用优化来找到确切的行号,但有时可能需要其他调试技术。我们将在本章后面讨论许多这些技术。

Throwable类中的方法允许您通过使用getStackTrace()方法以编程方式检索堆栈跟踪信息。(回想一下,ThrowableExceptionError的父类。)该方法返回一个StackTraceElement对象数组,每个对象表示堆栈上的一个方法调用。您可以使用getFileName()getClassName()getMethodName()getLineNumber()方法询问StackTraceElement关于该方法位置的详细信息。数组的第一个元素是堆栈的顶部,导致异常的最后一行代码;随后的元素逐步回退到原始main()方法,直到最后一个元素。

检查异常和未检查异常

我们之前提到过,Java 要求我们明确地处理错误,但并不需要在每种情况下都要求明确处理每种可能的错误类型。因此,Java 异常被分为两类:已检查未检查。大多数应用程序级异常都是已检查的,这意味着任何抛出它的方法必须在其定义中用特殊的throws子句声明它,如下所示:

  void readFile(String s) throws IOException, InterruptedException {
    // do some I/O work, maybe using threads for background processing
  }

我们的readFile()方法预计会抛出两种类型的异常:通过自身生成异常(正如我们将在“抛出异常”中讨论的那样),以及忽略在其内部发生的异常。目前,你只需知道方法必须声明其可能抛出或允许抛出的已检查异常。

throws子句告诉编译器,方法可能是该类型已检查异常的来源,并且调用该方法的任何人必须准备好处理它。然后,调用者必须使用try/catch块处理它,或者反过来,声明自己可以从自身抛出异常。

相反,属于java.lang.RuntimeException类或java.lang.Error类的子类的异常是未检查的。请参见图 6-1,了解RuntimeException的子类。忽略这些异常的可能性不是编译时错误;方法也不必声明它们可能会抛出它们。在其他方面,未检查异常的行为与其他异常相同。您可以选择捕获它们,但在这种情况下,您不是必须的。

已检查异常旨在涵盖应用程序级问题,例如丢失文件和不可用网络主机。作为优秀的程序员(和正直的公民),我们应该设计软件,从这些类型的情况中优雅地恢复。未检查异常旨在处理系统级问题,例如“数组索引超出范围”。虽然这些可能表明应用程序级编程错误,但它们几乎可以在任何地方发生。幸运的是,因为它们是未检查异常,你不必在每一个数组操作中都包装一个try/catch语句,也不必声明所有调用方法都可能是它们的来源。

总结一下,已检查异常是合理的应用程序应尝试优雅处理的问题。未检查异常(运行时异常或错误)是我们不希望软件通常能够恢复的问题,但您可以向用户提供礼貌且希望有用的消息,说明发生了什么。错误类型,例如“内存不足”错误,是我们通常无法从中恢复的条件。

抛出异常

我们可以抛出自己的异常——可以是Exception的实例,它的一个现有子类,或我们自己的专门异常类。我们所需做的就是创建适当异常类的实例,并用throw语句抛出它:

    throw new IOException();

执行会停止,并转移到最接近的封闭的try/catch语句,该语句能够处理异常类型。请注意,我们没有将新异常放入变量中。在这里,保留对我们创建的IOException对象的引用没有多少意义,因为throw语句会立即停止当前代码的执行流程。

异常的另一种构造方法允许我们指定带有错误消息的字符串:

    throw new IOException("Sunspots!");

您可以通过使用Exception对象的getMessage()方法来检索此字符串。不过,通常情况下,您可以直接打印异常对象本身以获取消息和堆栈跟踪信息。

按照惯例,所有类型的Exception都有一个像这样的String构造函数。“太阳黑子!”消息是异想天开的,但并不是非常有用。通常情况下,您会抛出一个更具体的Exception子类,它捕获关于故障的额外细节或至少提供更有用的字符串解释。这里是另一个例子:

  public void checkRead(String s) throws SecurityException {
    // ...
    if (new File(s).isAbsolute() || (s.indexOf("..") != -1))
      throw new SecurityException(
          "Access to file : "+ s +" denied.");
    // ...
  }

在这段代码中,我们部分实现了一个检查非法路径的方法。如果找到了一个,我们会抛出一个带有关于违规行为的一些信息的SecurityException

当然,我们可以在Exception的专门子类中包含任何其他有用的信息。不过,通常情况下,仅仅有一个新类型的异常已经足够了,因为它足以帮助控制流程。例如,如果我们正在构建一个程序来读取和解析网页的内容,我们可能希望制作我们自己的异常类型来指示特定的失败:

class ParseException extends Exception {
  private int lineNumber;

  ParseException() {
    super();
    this.lineNumber = -1;
  }

  ParseException(String desc, int lineNumber) {
    super(desc);
    this.lineNumber = lineNumber;
  }

  public int getLineNumber() {
    return lineNumber;
  }
}

请参见“构造函数”以获取有关类和类构造函数的完整描述。我们这里的Exception类体简单地允许按照传统方式(通用或带有少量额外信息)创建ParseException。现在我们有了我们的新异常类型,我们可以防范像这样任何格式不当的内容:

    // Get some input from a file and parse it
    try {
      parseStream(input);
    } catch (ParseException pe) {
      // Bad input... We can even tell them which line was bad!
      System.err.println("Bad input on line " + pe.getLineNumber());
    } catch (IOException ioe) {
      // Other, low-level communications problem
    }

即使没有特殊信息(比如我们的输入导致问题的行号),我们的自定义异常也能让我们区分解析错误和同一段代码中的其他 I/O 错误。

异常的链接和重新抛出

有时,您会希望根据异常采取一些操作,然后立即抛出一个新的异常。在构建处理低级详细异常并由更易于管理的高级异常表示的框架时,这是常见的做法。例如,您可能想在通信包中捕获IOException,可能执行一些清理工作,然后抛出自己的高级异常,也许类似于LostServerConnection

您可以通过简单捕获异常然后抛出新异常的方式来完成这一点,但是这样会丢失重要信息,包括原始“因果”异常的堆栈跟踪。为了处理这个问题,您可以使用异常链技术。这意味着您在抛出的新异常中包含原因异常。Java 明确支持异常链。基本的Exception类可以用异常作为参数或标准的String消息和异常来构造:

    throw new Exception("Here's the story...", causalException);

您可以稍后使用getCause()方法访问包装的异常。更重要的是,如果打印异常或向用户显示异常,Java 会自动打印两个异常及其各自的堆栈跟踪。

您可以在自己的异常子类中添加这种构造方法(委托给父构造函数)。您还可以通过在构造自己的异常后并在抛出异常之前使用Throwable方法initCause()显式设置因果异常来利用此模式:

    try {
      // ...
    } catch (IOException cause) {
      Exception e =
        new IOException("What we have here is a failure to communicate...");
      e.initCause(cause);
      throw e;
    }

有时仅需执行一些日志记录或采取一些中间操作,然后重新抛出原始异常即可:

    try {
      // ...
    } catch (IOException cause) {
      log(cause); // Log it
      throw cause;  // re-throw it
    }

当异常中不包含足够的信息来进行本地处理时,您会看到这种模式出现。您可以利用可用的信息做些事情(比如在调试期间打印错误消息以帮助),但是您没有足够的信息来解决问题。您必须传递异常,并希望某个资源更丰富的调用方法知道该怎么做。

尝试蠕动

try语句对它保护的语句施加了一个条件。它表示如果其中发生异常,剩余的语句将被放弃。这对局部变量初始化有影响。如果编译器无法确定放置在try/catch块内的局部变量赋值是否会发生,它就不会允许我们使用该变量。例如:

  void myMethod() {
    int foo;

    try {
      foo = getResults();
    }
    catch (Exception e) {
      // handle our exception ...
    }

    int bar = foo; // Compile-time error: foo may not have been initialized
  }

在这个示例中,我们无法在指定的地方使用foo,因为有可能从未给它赋值。一种选择是将bar的赋值移到try语句内部:

    try {
      foo = getResults();
      int bar = foo;  // Okay because we get here only
                      // if previous assignment succeeds
    }
    catch (Exception e) {
      // handle our exception ...
    }

有时这样做效果很好。然而,如果我们稍后在myMethod()中想使用bar,我们就会遇到同样的问题。如果不小心的话,我们可能会将所有内容都引入到try语句中。然而,如果在catch子句中控制方法的传递,情况就会发生变化:

    try {
      foo = getResults();
    }
    catch (Exception e) {
      // log our exception or show the user a warning message
      return;
    }
    int bar = foo;  // Okay because we get here only
                    // if previous try block succeeds

编译器足够聪明,知道如果try子句中发生错误,我们就不会达到bar赋值,因此允许我们引用foo。您的代码可能有不同的要求;我们只是希望您意识到这些选项。

最终子句

如果我们在一个 catch 子句中退出方法之前有重要的事情要做怎么办?为了避免在每个 catch 分支中重复代码并使清理更加明确,您可以使用 finally 子句。 finally 子句可以在 try 块及其相关的 catch 子句之后添加。 finally 子句体中的任何语句都保证会在控制权离开 try 体时执行,无论是否抛出异常:

    try {
      // Do something here
    }
    catch (FileNotFoundException e) {
      // handle a missing file ...
    }
    catch (IOException e) {
      // handle other file problems ...
    }
    catch (Exception e) {
      // eek, handle even bigger problems ...
    }
    finally {
      // Any cleanup here is always executed
    }

在此示例中,清理点处的语句最终会被执行,无论控制权如何离开 try。如果控制权转移到 catch 子句之一,则在 catch 完成后执行 finally 中的语句。如果没有 catch 子句处理异常,则在异常传播到下一级之前执行 finally 中的语句。

即使 try 中的语句执行干净,或者我们执行 returnbreakcontinuefinally 子句中的语句仍然会执行。为了保证某些操作将运行,我们甚至可以在没有任何 catch 子句的情况下使用 tryfinally

    try {
      // Do something here that might cause an exception
      return;
    } finally {
      System.out.println("Whoo-hoo!");
    }

发生在 catchfinally 子句中的异常会被正常处理;在执行 finally 之后,搜索包围异常 try/catch 的范围开始于异常 try 语句之外。

使用资源的尝试

finally 子句的一个常见用途是确保清理 try 子句中使用的资源,无论代码如何退出该块。考虑打开网络套接字(有关详细信息,请参见 第十三章):

    try {
      Socket sock = new Socket(...);
      // work with the socket
    } catch(IOException e) {
      // handle our network problem ...
    } finally {
      if (sock != null) { sock.close(); }
    }

这里所说的“清理”是指释放昂贵的资源或关闭与诸如文件、网络套接字或数据库等事物的连接。在某些情况下,这些资源可能会在 Java 回收垃圾时自动清理,但最好的情况是,这将在未来的某个未知时间发生。在最坏的情况下,清理可能永远不会发生,或者可能在您耗尽资源之前不会发生。您应该始终防范这些情况。

然而,在现实世界中,保持对这种资源分配的控制存在两个问题。首先,需要额外的工作来在所有代码中执行正确的清理模式,包括重要的事项,如我们的假设示例中所示的空值检查。其次,如果您在单个 finally 块中操作多个资源,您的清理代码本身可能会引发异常(例如,在 close() 上),并且使工作未完成。

“使用资源的尝试”形式的 try 子句可以帮助。在这种扩展形式中,您在 try 关键字后的括号中放置一个或多个资源初始化语句。当控制权离开 try 块时,这些资源将自动“关闭”:

    try (
      Socket sock = new Socket("192.168.100.1", 80);
      FileWriter file = new FileWriter("foo");
    )
    {
      // work with sock and file
    } catch (IOException e) {
      // ...
    }
    // Both sock and file have been cleaned up by this point

在此示例中,我们在try-with-resources子句中初始化了一个Socket对象和一个FileWriter对象,并且可以在try语句的主体中使用它们。当控制离开try语句时,无论是因为成功完成还是因为异常,Java 都会通过调用它们各自的close()方法自动关闭这两个资源。Java 按你构造它们的相反顺序关闭这些资源,因此你可以适应它们之间的任何依赖关系。

Java 支持任何实现了AutoCloseable接口的类的此行为(目前超过一百种不同的内置类实现了该接口)。该接口的close()方法被规定用于释放与对象相关联的所有资源,并且你也可以轻松地在自己的类中实现这一点。现在,使用try-with-resources时,我们不必专门添加任何代码来关闭文件或套接字;它会自动完成。

try-with-resources解决的另一个问题是在关闭操作期间抛出异常的麻烦情况。回顾我们在先前示例中使用finally子句进行清理的情况,如果close()方法引发了异常,新异常将在那一点抛出,完全放弃try子句体中的原始异常。但是try-with-resources保留了原始异常。如果在try体内发生异常,并且在随后的自动关闭操作中引发了一个或多个异常,将是try体中的原始异常将向上传递给调用方。让我们看一个例子:

    try (
      // potential exception #3
      Socket sock = new Socket("192.168.100.1", 80);
      // potential exception #2
      FileWriter file = new FileWriter("foo");
    )
    {
      // work with sock and file // potential exception #1
    }

一旦try块开始执行,如果在异常点#1 处发生异常,Java 将尝试按相反的顺序关闭两个资源,导致可能在位置#2 和#3 出现异常。在这种情况下,调用代码仍将接收到异常#1。然而,异常#2 和#3 并未丢失;它们仅仅是“抑制”了。你可以通过抛出给调用方的异常的getSuppressed()方法来检索它们。此方法返回所有被抑制的异常的数组。

性能问题

由于 Java 虚拟机的实现方式,使用try块来防范抛出异常是免费的,这意味着它不会增加代码的执行开销。然而,抛出异常是不免费的。当抛出异常时,Java 必须定位适当的try/catch块,并在运行时执行其他耗时的活动。

这就是为什么你应该只在真正的“异常”情况下抛出异常,并避免在预期的条件下使用它们,特别是在性能是一个问题时。例如,如果你有一个循环,每次通过可能更好地执行一次小的测试,并避免try块,而不是在循环运行过程中可能多次抛出异常。另一方面,如果异常只在亿万次中抛出一次,你可能希望消除小测试的开销,并且不必担心抛出那个非常罕见异常的成本。一般的规则应该是异常用于异常情况,而不是例行或预期条件(比如文件结束或缺少的网络资源)。

断言

Java 支持断言(assertions)作为验证程序状态的另一种机制。断言 是在应用程序运行时执行的一种简单的通过/失败测试某些条件的方式。你可以使用断言来“健全检查”你的代码。断言与其他类型的测试不同,因为它们检查在逻辑层面上不应该被违反的条件:如果断言失败,意味着你编写的某些代码未能完成其工作,应用程序通常会以适当的错误消息停止运行。Java 语言直接支持断言,并且可以在运行时打开或关闭它们,以消除将其包含在代码中带来的任何性能损失。

使用断言来测试应用程序的正确行为是一种简单但强大的技术,用于确保软件质量。它填补了编译器可以自动检查的软件方面与那些由“单元测试”或人工测试更普遍检查的方面之间的空白。断言测试你对程序行为的假设,并将这些假设转化为保证(至少在激活断言时是这样)。

如果你以前有编程经验,可能见过类似以下的内容:³

    if (!condition)
      throw new AssertionError("fatal error: 42");

在 Java 中,断言等同于这个例子,但你使用assert关键字。它接受一个布尔条件和一个可选的表达式值。如果断言失败,将抛出AssertionError,通常导致 Java 应用程序停止运行。中止应用程序的想法是,断言失败揭示了代码中的逻辑缺陷,作为程序员,你有责任找出并修复这个缺陷。

可选表达式可以评估为原始值或对象类型。无论哪种方式,它的唯一目的是将其转换为字符串,并在断言失败时显示给用户。通常情况下,你会显式地使用一个字符串消息。以下是一些示例:

    assert false;
    assert (array.length > min);
    assert a > 0 : a  // shows value of a to the user
    assert foo != null :  "foo is null!" // shows message "foo is null!" to user

在失败的情况下,前两个断言仅打印一个通用消息。第三个打印a的值,最后一个打印foo is null!消息。

断言的重要之处不仅在于它们比等效的if条件更简洁,而且你可以在运行应用程序时启用或禁用它们。禁用断言意味着它们的测试条件甚至不会被评估,因此在你的代码中包含它们不会产生性能惩罚(除了在加载时可能仍然占用一小部分类文件空间之外)。

启用和禁用断言

你可以在运行时打开或关闭断言。当禁用时,断言仍然存在于类文件中,但不会被执行,也不会消耗 CPU 时间。你可以为整个应用程序、按包或甚至按类启用和禁用断言。请记住,断言是为了在开发过程中的理智检查而设计的。它们通常不是为了被你的最终用户所看到。在你开发项目时使用它们,但在“生产”中禁用它们是一种常见的策略。

在 Java 中,默认情况下,断言是关闭的。要为你的代码启用它们,请使用java命令标志-ea-enableassertions

% java -ea MyApplication

要为特定类启用断言,请附加类名:

% java -ea:com.oreilly.examples.Myclass MyApplication

要仅为特定包启用断言,附加包名称后跟省略号(…​):

% java -ea:com.oreilly.examples... MyApplication

当你为一个包启用断言时,Java 还会启用所有下级包的断言(例如,com.oreilly.examples.text)。然而,你可以通过使用相应的-da-disableassertions标志来对个别包或类进行否定,以实现更有选择性的操作。你可以将所有这些组合起来,实现任意分组,就像这样:

% java -ea:com.oreilly.examples... \
    -da:com.oreilly.examples.text \
    -ea:com.oreilly.examples.text.MonkeyTypewriters \
    MyApplication

此示例为整个com.oreilly.examples包启用了断言,排除了包com.oreilly.examples.text,但然后为该包中的MonkeyTypewriters类启用了异常。

使用断言

断言是对代码中应该稳定的事物的规则的强制执行,否则这些事物将不会被检查。你可以在任何想要验证编译器可能无法检查的程序行为假设的地方使用断言来增加安全性。

一个常见的情况是测试多个条件或值,其中应始终找到一个。在这种情况下,作为默认或“穿透”行为的断言失败表明代码有问题。例如,假设我们有一个称为direction的值,它应始终包含两个常量之一,LEFTRIGHT

    if (direction == LEFT)
      goLeft();
    else if (direction == RIGHT)
      goRight()
    else
      assert false : "bad direction";

对于开关的默认情况也是一样的:

    switch (direction) {
      case LEFT:
        goLeft();
        break;
      case RIGHT:
        goRight();
        break;
      default:
        assert false;
    }

一般来说,您不应该使用断言来检查方法的参数有效性。您希望这种验证行为成为应用程序的一部分,而不仅仅是可以关闭的质量控制测试。方法需要有效的输入作为其前置条件的一部分,如果未满足任何前置条件,通常应该抛出异常。使用异常将前置条件提升为方法与用户之间的“合同”一部分。然而,在返回结果之前使用断言检查方法的正确性是有用的。这些封装检查被称为后置条件

有时候确定什么是前置条件或者不是前置条件取决于您的观点。例如,当一个方法在类内部使用时,其前置条件可能已由调用它的方法保证。类的公共方法在违反前置条件时可能会抛出异常,但私有方法可能使用断言,因为它们的调用者总是与正确行为密切相关的代码。

真实世界的异常

Java 采用异常作为错误处理技术,使得开发者编写健壮代码变得更加简单。编译器强制您提前考虑检查异常。未经检查的异常肯定会出现,但断言可以帮助您注意这些运行时问题,希望能够避免崩溃。

使用 try-with-resources 功能使开发者在处理有限系统资源(如文件和网络连接)时更加简洁和“正确”。正如我们在本章开头提到的,其他语言当然也有处理这些问题的设施或习惯。作为一种语言,Java 努力帮助您深思熟虑代码中可能出现的问题。您越是解决这些问题,您的应用程序(以及您的用户)就会更加稳定。

到目前为止,我们的许多示例都很直接,并且实际上并不需要任何复杂的错误检查。请放心,我们将探讨更多需要异常处理的有趣代码。后续章节将涵盖多线程编程和网络编程等主题。这些主题充满了可能在运行时出现问题的情况,例如大型计算失控或 WiFi 连接断开。原谅这句双关语,但您很快就会学习到所有这些新的异常技巧!

复习问题

  1. 在您的代码中管理潜在异常时,应该使用哪个语句?

  2. 编译器在哪些异常情况下要求您处理或抛出?

  3. try 块中使用后,您应该将任何清理代码放在哪里?

  4. 当禁用时,断言会对性能产生多大的影响?

代码练习

  1. Pause.java程序位于ch06/exercises文件夹中,无法编译。它使用了Thread.sleep()方法来暂停程序五秒钟。这个sleep()方法可能会抛出一个已检查异常。修复程序使其能够编译和运行。(我们将在第九章中更深入地学习线程和Thread.sleep()。)

  2. 练习包括我们在第二章中另一个“Hello, World”程序的变体,名为HelloZero。使用断言确保图形消息的初始 x 和 y 坐标大于零。

    尝试运行程序并启用断言。如果将负数赋值给其中一个坐标,会发生什么?再次运行程序,但不要禁用断言。(记住,“禁用”是默认行为,所以只需不启用它们。)在这种情况下会发生什么?

高级练习

  1. 假设最大公约数(GCD)为 1 是一个错误情况,我们需要标记它。创建一个名为Euclid3的新类,它将执行查找 GCD 的常规工作,但如果公约数为 1,则会抛出异常。(可以从其他欧几里得类中复制开始。)创建一个名为GCDException的自定义异常类,将导致异常的数字对作为异常的详细信息存储起来。

    修改Euclid3以测试是否存在最大公约数为 1,并在结果为 1 时抛出新的GCDException异常。(提供两个素数是确保结果为 1 的快速方法。)

    在添加任何异常处理代码之前,请尝试编译。javac是否提醒您有异常?应该会!继续添加try/catch保护或编辑main()方法的定义以抛出异常。如果您处理了新的异常,请确保向用户输出包含捕获异常中“坏”数字的友好错误消息。

¹ 在 C 语言中,有些相对晦涩的setjmp()longjmp()语句可以保存代码执行的点,并能从深埋的位置无条件地返回到该点。从某种有限的意义上来说,这正是我们在本章中探讨的 Java 异常的功能。

² 例如,AWT Image 类的getHeight()方法如果高度尚未知晓,则返回-1。这并不表示发生了错误;一旦图像加载完成,高度将会得到更新。在这种情况下,抛出异常会显得过度,并可能影响性能。

³ 如果您已经做过一些编程,希望您的错误消息不会像这样难以理解!错误消息越有帮助和解释性,就越好。

第七章:集合与泛型

随着我们利用日益增长的对象知识来处理更多有趣的问题,一个经常出现的问题是如何存储我们在解决这些问题过程中操作的数据?我们肯定会使用各种不同类型的变量,但我们还需要更大更复杂的存储选项。我们在“数组”章节中讨论过的数组是一个开始,但是数组有一些限制。在本章中,我们将看到如何使用 Java 集合的概念来高效、灵活地访问大量数据。我们还将看到如何处理我们想要存储在这些大容器中的各种类型的数据,就像我们处理变量中的单个值一样。这就是泛型的用武之地。我们将在“类型限制”中深入讨论它们。

集合

集合是所有类型编程中基础的数据结构。每当我们需要引用一组对象时,就会涉及某种类型的集合。在核心语言级别上,Java 通过数组支持集合。但是数组是静态的,由于长度固定,对于应用程序生命周期内增长和缩小的对象组来说显得笨拙。数组也不擅长表示对象之间的抽象关系。在早期,Java 平台仅有两个基本类来满足这些需求:java.util.Vector类代表动态对象列表,java.util.Hashtable类保存键/值对映射。如今,Java 有了更全面的方法,称为集合框架。该框架标准化了处理各种集合的方式。旧的类仍然存在,但已经被整合到框架中(带有一些古怪之处),通常不再使用。

虽然在概念上简单,集合是任何编程语言中最强大的部分之一。它们实现了管理复杂问题核心的数据结构。基础计算机科学致力于描述如何以最高效的方式实现某些类型的算法来操作集合。 (如何在大型集合中快速找到某物?如何对集合中的项目进行排序?如何高效地添加或删除项目?)掌握这些工具并理解如何使用它们可以使您的代码变得更小更快。它还可以避免重复造轮子。

原始的集合框架有两个主要缺陷。第一个是集合由于需要是无类型的,只能使用未区分的Object而不能使用特定类型如DateString。这意味着每次从集合中取出对象时都必须进行类型转换。这与 Java 的编译时类型安全相悖。但实际上,这不是问题,只是非常繁琐和乏味。第二个问题是,出于实际原因,集合只能处理对象而不能处理原始类型。这意味着每当你想将数字或其他原始类型放入集合时,你必须首先将其存储在包装类中,然后在检索时解包。这些因素的结合使得使用集合的代码更加难以阅读和更加危险。

泛型类型(稍后在“类型限制”中详述)使得真正类型安全的集合可以由程序员控制。除了泛型,原始类型的自动装箱和拆箱意味着在涉及集合时,你通常可以将对象和原始类型视为相等。这些新特性的结合增加了一些安全性,并且可以显著减少你编写的代码量。正如我们将看到的,现在所有的集合类都利用了这些特性。

集合框架围绕java.util包中的少数接口展开。这些接口分为两个层次结构。第一个层次结构从Collection接口派生。这个接口(及其子类)代表一个容器,用来保存其他对象。第二个独立的层次结构基于Map接口,另一个容器,表示一组键值对,其中键可以用来以高效的方式检索值。

集合接口

所有集合的鼻祖是一个名为Collection的接口。它作为容器来保存其他对象,即它的元素。它并不明确指定对象的组织方式;例如,它并不说明是否允许重复对象或对象是否以某种方式有序。这些细节留给子接口或实现类处理。尽管如此,Collection接口定义了一些对所有集合通用的基本操作:

public boolean add( element )

将提供的对象添加到此集合。如果操作成功,则此方法返回true。如果对象已经存在于此集合中并且集合不允许重复,则返回false。此外,某些集合是只读的。如果调用此方法,则这些集合会抛出UnsupportedOperationException

public boolean remove( element )

从此集合中移除指定对象。类似于add()方法,如果从集合中移除对象,则此方法返回true。如果对象在此集合中不存在,则返回false。只读集合在调用此方法时会抛出UnsupportedOperationException

public boolean contains( element )

如果集合包含指定对象,则返回true

public int size()

返回此集合中的元素数。

public boolean isEmpty()

如果此集合没有元素,则返回true

public Iterator iterator()

检查此集合中的所有元素。此方法返回一个Iterator,这是一个可以用来遍历集合元素的对象。我们将在下一节详细讨论迭代器。

另外,方法addAll()removeAll()containsAll()接受另一个Collection,并添加、移除或测试供应集合的所有元素。

集合类型

Collection接口有三个子接口。Set表示不允许重复元素的集合。List是其元素具有特定顺序的集合。Queue接口是具有“头”元素概念的对象缓冲区,该元素是下一个要处理的元素。

集合

Set除了从Collection继承的方法外,没有其他方法。它只是强制执行不允许重复的规则。如果尝试添加已存在于Set中的元素,则add()方法简单地返回falseSortedSet按照规定的顺序维护元素;类似于无法包含重复项的排序列表。您可以使用subSet()headSet()tailSet()方法检索子集(这些子集也是排序的)。这些方法接受一个或两个标记边界的元素。first()last()调用提供对第一个和最后一个元素的访问。comparator()方法返回用于比较元素的对象(关于此方法的更多信息请参见“深入了解:sort() 方法”)。

NavigableSet扩展了SortedSet并添加了一些方法,用于在Set的排序顺序内找到大于或小于目标值的最接近匹配。您可以使用跳跃表等技术有效地实现此接口,使得查找有序元素变得更快。

列表

Collection的下一个子接口是ListList是一个有序集合,类似于数组,但具有用于操作列表中元素位置的方法:

public boolean add(E element )

将指定元素从列表末尾移除。

public void add(int index , E element )

在列表中指定位置插入给定对象。如果位置小于零或大于列表长度,则抛出IndexOutOfBoundsException。原来在指定位置的元素和其后的所有元素都将向上移动一个索引位置。

public void remove(int index )

移除指定位置的元素。所有后续元素向下移动一个索引位置。

public E get(int index )

返回给定位置的元素,但不更改列表。

public Object set(int index , E element )

将给定位置的元素更改为指定对象。必须已经有一个对象在索引处,否则会抛出 IndexOutOfBoundsException。不会影响列表的其他元素。

这些方法中的类型 EList 类的参数化元素类型。CollectionSetList 都是接口类型。这是我们在本章开头提到的泛型特性的一个示例,我们将很快看到这些类型的具体实现。

队列

Queue 是一个行为类似缓冲区的集合。队列维护放入其中的项目的插入顺序,并且有“头”项目的概念。队列可以是先进先出(FIFO 或“按顺序”)或后进先出(LIFO,有时是“最近”或“逆序”),这取决于实现:

public boolean offer(E element), public boolean add(E element)

offer() 方法尝试将元素放入队列,如果成功则返回 true。不同的 Queue 类型可能对元素类型(包括容量)有不同的限制或限制。该方法与从 Collection 继承的 add() 方法不同,它返回一个布尔值而不是抛出异常以指示集合无法接受元素。

public E poll(), public E remove()

poll() 方法移除队列头部的元素并返回它。该方法与 Collectionremove() 方法不同,如果队列为空,则返回 null 而不是抛出异常。

public E peek()

返回头部元素,但不从队列中删除它。如果队列为空,则返回 null

映射接口

集合框架还包括 java.util.Map,它是一组键值对的集合。映射的其他名称包括“字典”或“关联数组”。映射存储和检索具有键值的元素;它们对于像缓存和最小数据库这样的东西非常有用。将值存储在映射中时,您将一个键对象与该值关联起来。当您需要查找值时,映射使用键检索它。

使用泛型(再次出现的 E 类型),Map 类型是使用两种类型进行参数化的:一种用于键,一种用于值。以下片段使用 HashMap,这是一种高效但无序的映射实现类型,我们稍后会讨论它:

    Map<String, Date> dateMap = new HashMap<String, Date>();
    dateMap.put("today", new Date());
    Date today = dateMap.get("today");

在旧代码中,映射简单地将 Object 类型映射到 Object 类型,并需要适当的类型转换来检索值。

Map 的基本操作很简单。在以下方法中,类型 K 是指键参数类型,类型 V 是指值参数类型:

public V put(K key , V value )

将指定的键/值对添加到地图中。如果地图已经包含指定键的值,则旧值将被替换并作为结果返回。

public V get(K key )

从地图中检索与key对应的值。

public V remove(K key )

从地图中删除与key对应的值。返回已删除的值。

public int size()

返回此地图中键/值对的数量。

使用以下方法可以检索地图中的所有键或值:

public Set keySet()

此方法返回一个Set,其中包含此地图中的所有键。

public Collection values()

使用此方法检索此地图中的所有值。返回的Collection可以包含重复的元素。

public Set entrySet()

此方法返回一个Set,该集合包含此地图中的所有键/值对(作为Map.Entry对象)。

Map有一个子接口,SortedMapSortedMap根据键的特定顺序维护其键/值对排序。它提供了subMap()headMap()tailMap()方法来检索排序地图的子集。与SortedSet一样,它还提供了一个comparator()方法,该方法返回一个对象,该对象确定地图键的排序方式。我们将在“更近距离看:sort()方法”中详细讨论这一点。Java 7 添加了一个NavigableMap,其功能与NavigableSet类似;也就是说,它添加了搜索排序元素的方法,以查找大于或小于目标值的元素。

最后,我们应该明确指出,虽然它们相关,但Map不是Collection的一种类型(Map不扩展Collection接口)。你可能会想为什么。Collection接口的所有方法似乎都适用于Map,除了iterator()。再次强调,Map有两组对象:键和值,并且分别有迭代器。这就是为什么Map不实现Collection的原因。如果您确实想要Map的类似Collection的视图,包含键和值,您可以使用entrySet()方法。

关于地图的另一个说明:某些地图实现(包括 Java 的标准HashMap)允许使用null作为键或值,但其他地图则不允许。

类型限制

泛型是关于抽象的。泛型允许您创建在不同类型的对象上以相同方式工作的类和方法。术语generic源于我们希望能够编写通用算法,这些算法可以广泛地重用于许多类型的对象,而不是必须使我们的代码适应每种情况。这个概念并不新鲜;这正是面向对象编程背后的推动力。Java 泛型并不是向语言添加新功能,而是使可重用的 Java 代码更易于编写和阅读。

泛型将重用推向了一个新的水平,通过使我们处理的对象的 类型 成为泛型代码的显式参数。因此,泛型也被称为 参数化类型。对于泛型类来说,开发者在使用泛型类型时指定一个类型作为参数(一个参数),代码则根据提供的类型进行自适应。

在其他语言中,泛型有时被称为 模板,这更多是一种实现术语。模板就像是中间类,等待其类型参数以便使用。Java 走了一条不同的路线,这既有利也有弊,我们将在本章节详细描述。

Java 泛型有很多值得探讨的地方。一些细节起初可能显得有点晦涩,但不要灰心。你将会大量使用泛型,例如使用现有的类如 ListSet,这些都是简单直观的。设计和创建你自己的泛型需要更谨慎的理解,以及一点耐心和试验。

我们从直觉的角度开始讨论泛型的最引人注目的案例:刚才提到的容器类和集合。接下来,我们退一步,看看 Java 泛型的好坏与丑陋。最后,我们将看几个 Java 中真实世界的泛型类。

容器:打造更好的捕鼠器

请记住,在像 Java 这样的面向对象编程语言中,多态性 意味着对象总是在某种程度上可互换的。任何类型对象的子类都可以替代其父类型,最终,每个对象都是 java.lang.Object 的子类:可以说是面向对象的“夏娃”。

Java 中最一般类型的容器通常与类型 Object 一起工作,因此它们可以容纳几乎任何内容。通过 容器,我们指的是以某种方式持有其他类实例的类。我们在前一节中看到的 Java 集合框架就是容器的最佳例子。List,简而言之,持有一个类型为 Object 的有序元素集合。而 Map 则持有键值对的关联,其键和值也是最一般的类型 Object。通过原始类型的包装器的帮助,这种安排已经为我们服务良好。但是(不要太深奥),“任何类型的集合”也是“没有类型的集合”,而且使用 Object 带来了开发者很大的责任。

这有点像对象的化装派对,每个人都戴着同样的面具,消失在集合的人群中。一旦对象穿上Object类型的服装,编译器就再也看不到真正的类型并且无法跟踪它们。用户需要稍后使用类型转换来穿透对象的匿名性。就像试图拔掉派对参与者的假胡须一样,您最好确保类型转换是正确的,否则会得到一个不受欢迎的惊喜:

    Date date = new Date();
    List list = new ArrayList();
    list.add(date);
    // other code that might add or remove elements ...
    Date firstElement = (Date)list.get(0); // Is the cast correct? Maybe.

List接口有一个接受任何类型Objectadd()方法。在这里,我们分配了一个ArrayList的实例,它只是List接口的一个实现,并添加了一个Date对象。这个例子中的转换是否正确?这取决于省略的“其他代码”段内发生了什么。

Java 编译器知道这种类型的活动是危险的,并且在您向简单的ArrayList添加元素时发出警告,就像上面的例子一样。我们可以通过一个小小的jshell迂回看到这一点。在从java.utiljavax.swing包导入后,尝试创建一个ArrayList并添加一些不同的元素:

jshell> import java.util.ArrayList;

jshell> import javax.swing.JLabel;

jshell> ArrayList things = new ArrayList();
things ==> []

jshell> things.add("Hi there");
|  Warning:
|  unchecked call to add(E) as a member of the raw type java.util.ArrayList
|  things.add("Hi there");
|  ^--------------------^
$3 ==> true

jshell> things.add(new JLabel("Hi there"));
|  Warning:
|  unchecked call to add(E) as a member of the raw type java.util.ArrayList
|  things.add(new JLabel("Hi there"));
|  ^--------------------------------^
$5 ==> true

jshell> things
things ==> [Hi there, javax.swing.JLabel[...,text=Hi there,...]]

无论您添加的是什么类型的对象,您都可以看到警告是相同的。在最后一步,当我们显示things的内容时,普通的String对象和JLabel对象都在列表中。编译器并不担心使用不同的类型;它友好地警告您,它不知道像上面的(Date)转换在运行时是否会起作用。

可以修复容器吗?

自然而然地会问,是否有办法改善这种情况。如果我们知道我们只会将Date放入我们的列表中,我们不能只创建一个只接受Date对象的列表,消除转换,再次让编译器帮助我们吗?也许令人惊讶的答案是,不行。至少,不是以一种令人满意的方式。

我们的第一反应可能是尝试在子类中“重写”ArrayList的方法。但当然,重写add()方法在子类中实际上并没有覆盖任何东西;它会添加一个新的重载方法:

    public void add(Object o) { ... } // still here
    public void add(Date d) { ... }   // overloaded method

结果对象仍然接受任何类型的对象——它只是调用不同的方法来实现这一点。

继续前进,我们可能会承担更大的任务。例如,我们可以编写自己的DateList类,该类不是扩展ArrayList,而是将其方法的实质部分委托给ArrayList的实现。通过相当多的单调工作,我们可以得到一个对象,它可以做所有List做的事情,但以一种编译器和运行时环境都能理解和强制执行的方式处理Date。然而,我们现在给自己挖了个大坑,因为我们的容器不再是List的一个实现。这意味着我们不能与所有处理集合的实用程序(如Collections.sort())互操作,也不能使用Collection addAll()方法将其添加到另一个集合中。

总结一下,问题在于我们并不想细化对象的行为,我们真正想做的是改变它们与用户的契约。我们希望调整它们的方法签名以适应更具体的类型,而多态性无法做到这一点。所以我们是否为我们的集合困于Object?这就是泛型的用武之地。

进入泛型

如前一节介绍类型限制时所指出的,泛型增强了允许我们为特定类型或一组类型定制类的语法。泛型类在引用类类型时需要一个或多个类型参数。它们用于自定义自身。

例如,如果你查看List类的源代码或 Javadoc,你会看到它定义了类似这样的内容:

public class List< E > {
  // ...
  public void add(E element) { ... }
  public E get(int i) { ... }
}

角括号(<>)之间的标识符E类型参数。¹ 它指示List类是泛型的,并需要一个 Java 类型作为参数以使其完整。名称E是任意的,但随着我们继续,会看到一些惯例。在这种情况下,类型参数E代表我们希望存储在列表中的元素类型。List类在其体和方法中引用类型参数,就好像它是一个真实的类型,稍后会被替换。类型参数可以用于声明实例变量、方法参数和方法的返回类型。在这种情况下,E用作我们将通过add()方法添加的元素的类型,以及get()方法的返回类型。让我们看看如何使用它。

当我们想使用List类型时,同样的角括号语法提供了类型参数:

    List<String> listOfStrings;

在这个片段中,我们使用了泛型类型List声明了一个名为listOfStrings的变量,其类型参数为StringString指的是String类,但我们也可以有一个以任何 Java 类类型为类型参数的专门化List。例如:

    List<Date> dates;
    List<java.math.BigDecimal> decimals;
    List<HelloJava> greetings;

通过提供其类型参数来完成类型称为实例化该类型。有时也称为调用该类型,类比于调用方法并提供其参数。与普通的 Java 类型不同,我们简单地通过名称引用类型,像List<>这样的泛型类型必须在使用时用参数实例化。² 具体而言,这意味着我们必须在可以出现类型的任何地方实例化类型:作为变量的声明类型(如本代码片段所示),作为方法参数的类型,作为方法的返回类型,或者在使用new关键字的对象分配表达式中。

回到我们的listOfStrings,现在实际上是一个List,其中String类型已经替换了类体中的类型变量E

public class List< String > {
  // ...
  public void add(String element) { ... }
  public String get(int i) { ... }
}

我们已将List类专门化为仅与String类型的元素一起使用。此方法签名不再能接受任意的Object类型。

List 只是一个接口。要使用该变量,我们需要创建一些实际的 List 实现的实例。正如我们在介绍中所做的那样,我们将使用 ArrayList。与以前一样,ArrayList 是实现 List 接口的类,但在这种情况下,ListArrayList 都是泛型类。因此,在使用它们的地方需要类型参数来实例化它们。当然,我们将创建我们的 ArrayList 来保存 String 元素以匹配我们的 ListString

    List<String> listOfStrings = new ArrayList<String>();
    // Or shorthand in Java 7.0 and later
    List<String> listOfStrings = new ArrayList<>();

如往常一样,new 关键字接受一个 Java 类型和可能包含类构造函数参数的括号。在这种情况下,类型是 ArrayList<String>——泛型 ArrayList 类型实例化为 String 类型。

声明变量(如上例中第一行所示)有点麻烦,因为它要求我们在变量类型的左侧和初始化表达式的右侧各提供一次泛型参数类型。在复杂情况下,泛型类型可以变得非常冗长且相互嵌套。

编译器足够智能,可以从您分配给变量的表达式的类型中推断出初始化表达式的类型。这称为泛型类型推断,其本质在于您可以通过在变量声明的右侧省略 <> 符号的内容来使用简写,如示例的第二个版本所示。

现在我们可以使用我们专门的字符串 List。编译器甚至阻止我们尝试将除 String 对象(如果有的话还有子类型)之外的任何东西放入列表中。它还允许我们使用 get() 方法获取 String 对象,而无需进行任何强制转换:

jshell> ArrayList<String> listOfStrings = new ArrayList<>();
listOfStrings ==> []

jshell> listOfStrings.add("Hey!");
$8 ==> true

jshell> listOfStrings.add(new JLabel("Hey there"));
|  Error:
|  incompatible types: javax.swing.JLabel cannot be converted to java.lang.String
|  listOfStrings.add(new JLabel("Hey there"));
|                    ^---------------------^

jshell> String s = strings.get(0);
s ==> "Hey!"

让我们从 Collections API 中再举一个例子。Map 接口提供了类似字典的映射,将键对象与值对象关联起来。键和值不必是相同类型。泛型 Map 接口需要两个类型参数:一个是键的类型,另一个是值的类型。Javadoc 如下所示:

public class Map< K, V > {
  // ...
  public V put(K key, V value) { ... } // returns any old value
  public V get(K key) { ... }
}

我们可以创建一个 Map,用于按 Integer 类型的“员工 ID”号存储 Employee 对象,如下所示:

    Map< Integer, Employee > employees = new HashMap<Integer, Employee>();
    Integer bobsId = 314; // hooray for autoboxing!
    Employee bob = new Employee("Bob", ...);

    employees.put(bobsId, bob);
    Employee employee = employees.get(bobsId);

在这里,我们使用了 HashMap,它是实现 Map 接口的泛型类。我们用类型参数 IntegerEmployee 实例化了两种类型。现在,Map 只能使用类型为 Integer 的键,并保存类型为 Employee 的值。

我们在这里使用 Integer 来保存我们的数字的原因是,泛型类的类型参数必须是类类型。我们不能使用原始类型(例如 intboolean)参数化泛型类。幸运的是,在 Java 中,原始类型的自动装箱(参见“原始类型的包装类”)几乎使其看起来像是我们可以通过允许我们像使用包装类型一样使用原始类型。

超过 Collections 的许多其他 API 都使用泛型来使您能够将它们适应特定类型。我们将在本书的各个部分讨论它们。

谈论类型

在我们转向更重要的事情之前,我们应该对我们如何描述泛型类的特定参数化方式说几句话。因为最常见和最引人注目的泛型案例是用于类似容器的对象,所以通常会以泛型类型“持有”参数类型的方式来思考。在我们的示例中,我们称我们的 List<String> 为“字符串列表”,因为确实是这样的。类似地,我们可能会称我们的员工映射为“员工 ID 到员工对象的映射”。然而,这些描述更专注于类的行为而不是类型本身。

取而代之的是,考虑一个名为 Trap<E> 的单个对象容器,可以实例化为 Mouse 类型或 Bear 类型的对象;也就是说,Trap<Mouse>Trap<Bear>。我们本能地称新类型为“捕鼠器”或“熊夹”。我们也可以将我们的字符串列表看作是一个新类型。我们可以讨论“字符串列表”,或将我们的员工映射描述为新的“整数员工对象映射”类型。您可以使用您喜欢的任何措辞,但后一种描述更专注于将泛型视为类型的概念,并且在讨论泛型类型在类型系统中如何相关时,可能会帮助您保持术语的清晰性。在那里,我们将看到容器术语实际上有点反直觉。

在接下来的部分中,我们将从不同的角度讨论 Java 中的泛型类型。我们已经看到它们能做些什么;现在我们需要讨论它们如何做到这一点。

“没有勺子”

在电影 The Matrix 中,主人公尼奥被提出了一个选择:服用蓝色药丸并留在幻想世界中,或者服用红色药丸并看到事物的真实面目。在处理 Java 中的泛型时,我们面临着类似的本体论困境。在讨论泛型时,我们只能走得那么远,然后不得不面对它们如何实现的现实。我们的幻想世界是编译器为了让我们编写代码更容易接受而创造的一个地方。我们的现实(虽然不像电影中的反乌托邦噩梦那样严峻)是一个更加艰难的地方,充满了看不见的危险和问题。为什么强制类型转换和测试在泛型中不能正常工作?为什么我不能在一个类中实现看似两个不同的泛型接口?为什么我可以声明一个泛型类型的数组,即使在 Java 中无法创建这样的数组?!

我们将在本章的其余部分回答这些问题,您甚至无需等待续集。您将很快能够弯曲勺子(好吧,类型)。让我们开始吧。

擦除

Java 通用类型的设计目标是雄心勃勃的:在语言中添加一个全新的语法,安全地引入参数化类型,并且不影响性能,并且,哦,顺便兼容所有现有的 Java 代码,并且不以任何严重的方式改变编译后的类。令人惊讶的是,他们实际上满足了这些条件,也不奇怪这需要一些时间。但是一如既往,一些必要的妥协导致了一些头痛。

为了实现这一功能,Java 采用了一种称为擦除的技术。擦除与这样一个想法有关:由于我们与通用类型的大多数操作都是在编译时静态应用的,通用信息不需要在编译后的类中保留。编译器强制执行的类的通用特性可以在二进制类中被“擦除”,以保持与非通用代码的兼容性。

虽然 Java 在编译形式中保留了关于类的通用特性的信息,但这些信息主要由编译器使用。Java 运行时根本不知道通用类型(generics),也不会浪费任何资源在其上。

我们可以使用jshell来确认参数化的List<E>在运行时仍然是一个List

jshell> import java.util.*;

jshell> List<Date> dateList = new ArrayList<Date>();
dateList ==> []

jshell> dateList instanceof List
$3 ==> true

但是我们的通用dateList显然没有实现刚刚讨论的List方法:

jshell> dateList.add(new Object())
|  Error:
|  incompatible types: java.lang.Object cannot be converted to java.util.Date
|  dateList.add(new Object())
|               ^----------^

这说明了 Java 通用类型的有些古怪的性质。编译器相信它们,但运行时却说它们是幻觉。如果我们尝试一些更简单的事情并检查我们的dateList是否是一个List<Date>

jshell> dateList instanceof List<Date>;
|  Error:
|  illegal generic type for instanceof
|  dateList instanceof List<Date>;
|                      ^--------^

这次编译器直截了当地说:“不行。” 你不能在instanceof操作中测试一个通用类型。由于在运行时没有可辨别不同参数化的List的类(每个List仍然是一个List),instanceof运算符无法区分一个List的不同实例。所有的通用安全检查都是在编译时完成的,因此在运行时我们只是处理一个单一的实际List类型。

事实上是这样的:编译器抹去了所有的尖括号语法,并在我们的List类中用一个在运行时可以与任何允许的类型一起工作的类型替换了类型参数:在这种情况下,是Object。我们似乎回到了起点,只是编译器仍然具有在编译时强制我们使用通用类型的知识,并且因此可以为我们处理类型转换。如果你反编译一个使用了List<Date>(使用javap命令和-c选项显示字节码,如果你敢的话),你会看到编译后的代码实际上包含了到Date的转换,尽管我们自己并没有写。

现在我们可以回答本节开始时提出的一个问题:“为什么我不能在一个类中实现看起来是两个不同的泛型接口?”我们不能有一个类同时实现两个不同的泛型List实例化,因为它们在运行时实际上是相同类型,没有办法区分它们:

public abstract class DualList implements List<String>, List<Date> { }
// Error: java.util.List cannot be inherited with different arguments:
//    <java.lang.String> and <java.util.Date>

幸运的是,总有办法解决。例如,在这种情况下,您可以使用一个共同的超类或创建多个类。虽然这些替代方法可能不那么优雅,但您几乎总能找到一个干净的答案,即使有点冗长。

原始类型

尽管编译器在编译时将泛型类型的不同参数化视为不同的类型(具有不同的 API),但我们已经看到在运行时只存在一个真正的类型。例如,List<Date>List<String>共享旧式的 Java 类ListList被称为泛型类的原始类型。每个泛型都有一个原始类型。它是“普通”的 Java 形式,所有泛型类型信息已被移除,类型变量被一般的 Java 类型如Object替换。

在 Java 中可以使用原始类型。然而,Java 编译器在以“不安全”方式使用它们时生成警告。在jshell之外,编译器仍然会注意到这些问题:

    // nongeneric Java code using the raw type
    List list = new ArrayList(); // assignment ok
    list.add("foo"); // Compiler warning on usage of raw type

此代码片段像 Java 5 之前的老式 Java 代码一样使用了原始的List类型。不同之处在于现在 Java 编译器在我们尝试向列表中插入对象时会发出未经检查的警告

% javac RawType.java
Note: RawType.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

编译器指导我们使用-Xlint:unchecked选项,以获取有关不安全操作位置的更详细信息:

% javac -Xlint:unchecked MyClass.java
RawType.java:6: warning: [unchecked] unchecked call to add(E)
as a member of the raw type List
    list.add("foo");
            ^
  where E is a type-variable:
    E extends Object declared in interface List

请注意,创建和分配原始的ArrayList并不会生成警告。只有当我们尝试使用“不安全”的方法(引用类型变量的方法)时才会收到警告。这意味着仍然可以使用与原始类型相关的旧式非泛型 Java API。只有在我们自己的代码中做一些不安全的操作时才会收到警告。

在我们继续之前,还有关于擦除的一件事。在前面的示例中,类型变量被Object类型替换,它可以表示适用于类型变量E的任何类型。后面我们会看到,并非总是这样。我们可以对参数类型设置限制或边界,当我们这样做时,编译器对类型的擦除可以更加严格,例如:

class Bounded< E extends Date > {
  public void addElement(E element) { ... }
}

此参数类型声明表示元素类型E必须是Date类型的子类型。在这种情况下,addElement()方法的擦除因此比Object更加严格,编译器使用Date

  public void addElement(Date element) { ... }

Date被称为此类型的上界,这意味着它是对象层次结构的顶部。您只能在Date或“较低”(更派生或子类化)类型上实例化参数化类型。

现在我们对泛型类型的真实含义有了一些了解,我们可以更详细地探讨它们的行为。

参数化类型关系

我们现在知道参数化类型共享一个普通的原始类型。这就是为什么我们的参数化 List<Date> 在运行时只是一个 List。事实上,如果需要,我们可以将 List 的任何实例分配给原始类型:

    List list = new ArrayList<Date>();

我们甚至可以反过来,将原始类型分配给泛型类型的特定实例:

    List<Date> dates = new ArrayList(); // unchecked warning

此语句在分配时生成了未检查的警告,但之后,编译器相信该列表在分配之前只包含 Date。您可以尝试将 new ArrayList() 强制转换为 List<Date>,但这不会解决警告。我们将在“类型转换”中讨论向泛型类型的转换。

无论运行时类型如何,编译器都掌控着一切。它不允许我们分配明显不兼容的事物:

    List<Date> dates = new ArrayList<String>(); // Compile-time Error!

当然,ArrayList<String> 没有实现编译器需要的 List<Date> 方法,所以这些类型是不兼容的。

但更有趣的类型关系呢?例如,List 接口是更一般的 Collection 接口的子类型。你能将泛型 List 的特定实例分配给某个泛型 Collection 实例吗?这是否取决于类型参数及其关系?显然,List<Date> 不是 Collection<String>。但 List<Date> 是否是 Collection<Date>List<Date> 可以是 Collection<Object> 吗?

首先,我们先快速说出答案,然后详细讲解。到目前为止,我们讨论的简单泛型实例的规则是继承仅适用于“基本”泛型类型,而不适用于参数类型。此外,仅当两个泛型类型在完全相同的参数类型上实例化时,可赋值性才适用。换句话说,仍然存在一维继承,遵循基本泛型类类型,但有一个附加限制,即参数类型必须完全相同。

例如,由于 ListCollection 的一种类型,当类型参数完全相同时,我们可以将 List 的实例分配给 Collection 的实例:

    Collection<Date> cd;
    List<Date> ld = new ArrayList<Date>();
    cd = ld; // Ok!

这段代码片段表明 List<Date>Collection<Date> —— 非常直观。但在参数类型变化的情况下尝试相同的逻辑则失败:

    List<Object> lo;
    List<Date> ld = new ArrayList<Date>();
    lo = ld; // Compile-time Error!  Incompatible types.

虽然我们的直觉告诉我们,List 中的 Date 可以作为 Object 幸福地存活,但分配是一个错误。我们将在下一节中详细解释原因,但现在只需注意类型参数并非完全相同,并且在泛型中,参数类型之间没有继承关系。

这是一个有助于以类型而不是以实例化对象所做的事情的角度来思考的情况。这些实际上并不是“日期列表”和“对象列表”——更像是一个DateList和一个ObjectList,它们之间的关系并不显而易见。

试着挑出下面示例中哪些是可以的,哪些是不可以的:

    Collection<Number> cn;
    List<Integer> li = new ArrayList<Integer>();
    cn = li;

一个List的实例化可以是一个Collection的实例化,但前提是参数类型完全相同。继承不遵循参数类型,所以这个示例中的最后一个赋值失败了。

之前我们提到过,这个规则适用于本章我们讨论过的实例化的简单类型。还有哪些类型呢?嗯,到目前为止我们所见过的实例化类型,我们将一个实际的 Java 类型作为参数插入的那种类型被称为具体类型实例化。稍后,我们将讨论通配符实例化,它们类似于类型的数学集合操作(比如并集和交集)。还可能有更多的泛型实例化,其中类型关系实际上是二维的,取决于基类型和参数化。但不用担心:这种情况并不经常发生,也没有听起来那么可怕。

为什么List<Date>不是List<Object>

这是一个合理的问题。为什么我们不能将我们的List<Date>分配给List<Object>并将Date元素作为Object类型来使用?

原因在于泛型的理论基础:改变编程契约。在最简单的情况下,假设一个DateList类型扩展了一个ObjectList类型,那么DateList将拥有所有ObjectList的方法,我们可以向其中插入Object。现在,你可能会反对说泛型让我们改变了方法签名,所以这不再适用。这是正确的,但有一个更大的问题。如果我们能够将DateList分配给ObjectList变量,我们就可以使用Object方法将类型不是Date的元素插入其中。

我们可以将DateList(提供替代性、更广泛的类型)别名ObjectList。使用别名对象,我们可以试图欺骗它接受其他类型:

    DateList dateList = new DateList();
    ObjectList objectList = dateList; // Can't really do this
    objectList.add(new Foo()); // should be runtime error!

当实际的DateList实现被呈现错误类型的对象时,我们预期会得到一个运行时错误。

这就是问题所在。Java 泛型没有运行时表示。即使这个功能很有用,Java 也无法在运行时知道该做什么。这个特性非常危险——它允许在运行时发生无法在编译时捕获的错误。通常,我们希望在编译时捕获类型错误。

如果您认为 Java 可以通过禁止这些赋值来在编译时不生成未检查的警告来保证代码的类型安全性。不幸的是它不能,但这种限制与泛型无关;它与数组有关。(如果这些对你听起来很熟悉,那是因为我们在第四章中提到了这个问题,与 Java 数组有关。)数组类型具有一种继承关系,允许这种别名发生:

    Date [] dates = new Date[10];
    Object [] objects = dates;
    objects[0] = "not a date"; // Runtime ArrayStoreException!

数组在运行时具有不同的类表示。它们在运行时进行自检,在这种情况下会抛出ArrayStoreException。如果你以这种方式使用数组,Java 编译器无法保证你的代码的类型安全性。

强制类型转换

现在我们已经谈论了泛型类型之间甚至泛型类型与原始类型之间的关系。但我们还没有真正探讨在泛型世界中的强制类型转换的概念。

当我们用泛型与它们的原始类型交换时,是不需要强制类型转换的。但我们会触发编译器的未检查警告:

    List list = new ArrayList<Date>();
    List<Date> dl = list;  // unchecked warning

通常情况下,我们在 Java 中使用强制类型转换来处理可能可赋值的两种类型。例如,我们可以尝试将一个Object转换为Date,因为Object可能是一个Date值。然后强制类型转换会在运行时进行检查,以查看我们是否正确。

在不相关的类型之间进行强制类型转换是一个编译时错误。例如,我们甚至无法尝试将一个Integer转换为String。这些类型之间没有继承关系。那么在兼容的泛型类型之间进行转换呢?

    Collection<Date> cd = new ArrayList<Date>();
    List<Date> ld = (List<Date>)cd; // Ok!

这段代码片段展示了从更一般的Collection<Date>List<Date>的有效转换。这里的转换是合理的,因为一个Collection<Date>可以赋值并且实际上可能是一个List<Date>

类似地,下面的强制类型转换捕获了我们的错误:我们将TreeSet<Date>别名为Collection<Date>,然后尝试将其转换为List<Date>

    Collection<Date> cd = new TreeSet<Date>();
    List<Date> ld = (List<Date>)cd; // Runtime ClassCastException!
    ld.add(new Date());

但是有一种情况下,泛型的强制类型转换是无效的,那就是在尝试根据它们的参数类型来区分类型时:

    Object o = new ArrayList<String>();
    List<Date> ld = (List<Date>)o; // unchecked warning, ineffective
    Date d = ld.get(0); // unsafe at runtime, implicit cast may fail

在这里,我们将一个ArrayList<String>别名为一个普通的Object。接下来,我们将o强制类型转换为List<Date>。不幸的是,Java 在运行时无法区分List<String>List<Date>之间的区别,所以这种强制类型转换是无效的。编译器通过在强制类型转换位置生成未检查的警告来提醒我们。当我们尝试使用强制类型转换的对象ld时,我们可能会发现它是不正确的。由于擦除和缺乏类型信息,泛型类型上的强制类型转换在运行时是无效的。

在集合和数组之间进行转换

尽管它们没有直接的继承关系或共享接口,但在集合和数组之间进行转换仍然很简单。为了方便起见,您可以使用以下方法将集合的元素作为数组检索出来:

    public Object[] toArray()
    public <E> E[] toArray(E[] a)

第一个方法返回一个普通的 Object 数组。通过第二种形式,我们可以更具体地返回正确元素类型的数组。如果我们提供了足够大小的数组,它将用值填充。但如果数组长度太短(例如,长度为零),Java 将创建一个所需长度的相同类型的新数组,并返回它。因此,您可以像这样传递一个空数组来获取正确类型的数组:

    Collection<String> myCollection = ...;
    String [] myStrings = myCollection.toArray(new String[0]);

这个技巧有点笨拙。如果 Java 允许我们使用 Class 引用显式指定类型,会更好,但出于某种原因,它并没有这样做。

另一种方法是,您可以使用 java.util.Arrays 辅助类的静态 asList() 方法将对象数组转换为 List 集合:

    String [] myStrings = { "a", "b", "c" };
    List list = Arrays.asList(myStrings);

编译器还足够智能,能够识别对 List<String> 变量的有效赋值。

迭代器

迭代器 是一种允许您逐步浏览一系列值的对象。这种操作非常常见,因此有一个标准接口:java.util.IteratorIterator 接口有三个有趣的方法:

public E next()

此方法返回关联集合的下一个元素(泛型类型 E 的元素)。

public boolean hasNext()

如果尚未遍历完 Collection 的所有元素,则此方法返回 true。换句话说,如果可以调用 next() 获取下一个元素,则返回 true

public void remove()

此方法从关联的 Collection 中移除从 next() 返回的最近对象。

以下示例显示了如何使用 Iterator 打印集合的每个元素:

  public void printElements(Collection c, PrintStream out) {
    Iterator iterator = c.iterator();
    while (iterator.hasNext()) {
      out.println(iterator.next());
    }
  }

使用 next() 获取下一个元素后,有时可以使用 remove() 将其移除。例如,通过待办事项清单的方式进行处理时,可能会遵循以下模式:“获取一个项目,处理该项目,移除该项目”。但是,迭代器的移除功能并不总是合适,也不是所有迭代器都实现了 remove()。例如,无法从只读集合中移除元素是没有意义的。

如果不允许删除元素,则从此方法中抛出 UnsupportedOperationException。如果在首次调用 next() 之前调用 remove(),或者连续两次调用 remove(),则会抛出 IllegalStateException

遍历集合

在 “for 循环” 中描述的一种形式的 for 循环可以操作所有 Iterable 类型,这意味着它可以迭代所有 Collection 对象类型,因为该接口扩展了 Iterable。例如,它现在可以遍历类型化的 Date 对象集合的所有元素,如下所示:

    Collection<Date> col = ...
    for (Date date : col) {
      System.out.println(date);
    }

Java 内置 for 循环的这个特性称为“增强” for 循环(与预泛型、仅数字 for 循环相对)。增强 for 循环仅适用于 Collection 类型的集合,而不适用于 Map。但在某些情况下,遍历映射可能很有用。您可以使用 Map 方法 keySet()values()(甚至 entrySet() 如果您希望每个键/值对作为单个实体)从您的映射中获取一个可以使用这个增强 for 循环的集合:

    Map<Integer, Employee> employees = new HashMap<>();
    // ...
    for (Integer id : employees.keySet()) {
      System.out.print("Employee " + id);
      System.out.println(" => " + employees.get(id));
    }

键的集合是一个简单的无序集合。上面的增强 for 循环将显示所有您的员工,但它们的打印顺序可能看起来有些随机。如果您希望按其 ID 或者也许是它们的名称列出它们,您需要首先对键或值进行排序。幸运的是,排序是一个非常常见的任务——集合框架可以帮助。

详细解析:sort() 方法

java.util.Collections 类中查找,我们找到了各种用于处理集合的静态实用方法。其中之一是这个好东西——静态泛型方法 sort()

<T extends Comparable<? super T>> void sort(List<T> list) { ... }

另一个我们要解决的难题。让我们专注于边界的最后部分:

Comparable<? super T>

这是我们在 “参数化类型关系” 中提到的通配符实例化。在这种情况下,它是一个接口,因此我们可以将 sort() 方法返回类型中的 extends 理解为 implements

Comparable 包含一个 compareTo() 方法,用于某个参数类型。Comparable<String> 意味着 compareTo() 方法接受类型 String。因此,Comparable<? super T> 是在 T 及其所有超类上的 Comparable 实例化的集合。Comparable<T> 足够,并且在另一端,Comparable<Object> 也是如此。

这在英语中意味着元素必须与它们自己的类型可比较,或者与它们自己的类型的某个超类型可比较,以便 sort() 方法可以使用它们。这确保了所有元素可以相互比较,但并不像说它们都必须自己实现 compareTo() 方法那样具有限制性。一些元素可以从一个知道如何仅与 T 的某个超类型比较的父类继承 Comparable 接口,这正是允许的。

应用:田野上的树木

本章中有很多理论。不要害怕理论——它可以帮助您预测新场景中的行为,并激发解决新问题的解决方案。但实践同样重要,所以让我们回顾一下我们在 “类” 中开始的游戏。特别是,现在是存储每种类型多个对象的时候了。

在 第十三章 中,我们将介绍网络和创建需要存储多个物理学家的两人游戏设置。现在,我们仍然只有一个物理学家可以一次扔一个苹果。但我们可以在我们的场地上种植几棵树作为靶子练习。

让我们添加六棵树。我们将使用一对循环,这样您可以轻松增加树木数量(如果愿意)。我们的Field当前仅存储一个树实例。我们可以将该存储升级为一个类型化列表(我们称之为trees)。从那里,我们可以以多种方式添加和移除树木:

  • 我们可以为Field创建一些处理列表的方法,也许还可以实施其他一些游戏规则(例如管理最大数量的树木)。

  • 我们可以直接使用列表,因为List类已经有了大多数我们想做的事情的好方法。

  • 我们可以结合这些方法的一些组合:适合我们的游戏的特殊方法,以及其他所有地方直接操作。

由于我们确实有一些特定于Field的游戏规则,因此我们将在此处采取第一种方法。(但是请查看示例,并考虑如何修改以直接使用树木列表。)我们将从一个addTree()方法开始。采用这种方法的一个好处是,我们还可以将树实例的创建重定位到我们的方法中,而不是单独创建和操作树。以下是在场地上添加树木的一种方法:

  List<Tree> trees = new ArrayList<>();
  // other field state

  public void addTree(int x, int y) {
    Tree tree = new Tree();
    tree.setPosition(x,y);
    trees.add(tree);
  }

有了这种方法,我们可以很快地添加一些树木:

    Field field = new Field();
    // other setup code
    field.addTree(100,100);
    field.addTree(200,100);

这两行代码并排添加了一对树木。让我们继续编写我们需要创建六棵树的循环:

    Field field = new Field();
    // other setup code
    for (int row = 1; row <= 2; row++) {
      for (int col = 1; col <=3; col++) {
        field.addTree(col * 100, row * 100);
      }
    }

现在你能看到,如果要添加八、九或一百棵树是多么容易了吗?计算机在重复方面做得很好。

为了创建我们的苹果目标森林万岁!尽管我们遗漏了一些关键细节。最重要的是,我们需要在屏幕上显示我们的新森林。我们还需要更新Field类的绘图方法,以便正确理解和使用我们的树木列表。随着我们为游戏添加更多功能,我们也将对物理学家和苹果执行相同的操作。此外,我们还需要一种方法来移除不再活动的元素。但首先,让我们看看我们的森林!

// File: Field.java
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    for (Tree t : trees) {
      t.draw(g);
    }
    physicist.draw(g);
    apple.draw(g);
  }

由于我们已经在存储我们的树木的Field类中,没有必要编写一个单独的函数来提取单个树并对其进行绘制。我们可以使用巧妙的增强型for循环结构,快速将所有树木放在场地上,如图 7-1 所示。

ljv6 0701

图 7-1。在我们的List中渲染所有树木

有用的特性

Java 集合和泛型是语言中非常强大和有用的附加功能。尽管本章后半部分深入探讨的一些细节可能看起来令人生畏,但通常使用却非常简单和引人注目:泛型使集合更好。随着您对泛型的使用增加,您会发现您的代码变得更加可读和可维护。集合允许优雅、高效的存储。泛型使您之前根据使用推断出来的内容显式化。

复习问题

  1. 如果你想存储一个包含姓名和电话号码的联系人列表,哪种类型的集合最适合?

  2. 你使用什么方法来获取Set中项的迭代器?

  3. 如何将List转换为数组?

  4. 如何将数组转换为List

  5. 你应该实现哪个接口以使用Collections.sort()方法对列表进行排序?

代码练习

  1. ch07exercises文件夹中的EmployeeList类包含了一些员工,加载到一个员工 ID 和Employee对象的映射中,与前面几个示例中使用的类似。我们提到要按 ID 号对这些员工进行排序,但没有展示任何代码。尝试按照他们的 ID 号排序员工。你可能需要使用keySet()方法,然后从该集合创建一个临时但可排序的列表。

  2. 在高级练习 5.1 中,你创建了一个新的障碍类Hedge。更新游戏,使得你可以拥有多个树篱,类似于多个树木。确保当你运行程序时所有的树木和树篱都能正确绘制。

高级练习

  1. 在上述代码练习 1 中,你可能对映射的键进行了排序,然后使用正确排序的键来获取相应的员工。为了更具挑战性,尝试在你的Employee类中实现Comparable接口。你可以决定如何组织员工:按 ID、姓氏、全名或者可能是这些属性的某种组合。而不是对keySet()集合进行排序,尝试直接通过从你的映射的values()中构建临时列表来对你新建的可比较员工进行排序。

¹ 你可能还会看到术语类型变量。Java 语言规范大多使用参数,所以我们尽量坚持这个术语,但你可能会看到两个名称都在使用。

² 也就是说,除非你想以非泛型方式使用泛型类型。我们稍后会讨论“原始”类型。

³ 如果你们中有些人想要了解本节标题的背景,这里是它的来源。我们的英雄 Neo 正在学习他的超能力。

男孩:不要试图弯曲勺子。那是不可能的。相反,只需试图意识到真相。

Neo:什么真相?

男孩:没有勺子。

Neo:没有勺子?

男孩:那么你会看到不是勺子弯曲,只有你自己在弯曲。

—瓦卓斯基姐弟。黑客帝国。136 分钟。华纳兄弟,1999 年。

第八章:文本和核心实用程序

如果你按顺序阅读本书,你已经学习了所有关于核心 Java 语言构造的内容,包括语言的面向对象方面和线程的使用。现在是时候转变思路,开始讨论组成标准 Java 包并随每个 Java 实现提供的类集合了。Java 的核心包是其最显著的特点之一。许多其他面向对象的语言具有类似的功能,但没有一个像 Java 那样拥有如此广泛的标准化类和工具集。这既是 Java 成功的反映,也是其成功的原因之一。

字符串

我们将首先仔细查看 Java 的String类(更具体地说是java.lang.String)。因为与String一起工作如此基础,了解它们的实现方式及其可执行的操作是非常重要的。String对象封装了一系列 Unicode 字符。在内部,这些字符存储在常规的 Java 数组中,但String对象嫉妒地保护这个数组,并且只能通过其自己的 API 访问它。这是为了支持String是不可变的想法;一旦创建了String对象,就无法更改其值。对String对象的许多操作似乎会改变字符串的字符或长度,但实际上它们只是返回一个新的String对象,该对象复制或内部引用原始所需的字符。Java 实现会努力将在同一类中使用的相同字符串合并为共享字符串池,并在可能的情况下共享String的部分。

所有这一切最初的动机都是性能。不可变的String可以节省内存,并且 Java 虚拟机可以优化它们的使用以提高速度。但它们并非神奇。为了避免在性能受到影响的地方创建过多的String对象,您应该对String类有基本的理解。¹

构造字符串

字面字符串在源代码中用双引号定义,并可以分配给String变量:

    String quote = "To be or not to be";

Java 会自动将字面字符串转换为String对象,并将其分配给变量。

String对象在 Java 中跟踪其自身的长度,因此不需要特殊的终止符。您可以使用length()方法获取String的长度。您还可以通过使用isEmpty()测试零长度字符串:

    int length = quote.length();
    boolean empty = quote.isEmpty();

String可以利用 Java 中唯一的重载运算符+进行字符串连接。以下两行产生的字符串是等效的:

    String name = "John " + "Smith";
    // or, equivalently:
    String name = "John ".concat("Smith");

对于大于一个名称的文本块,Java 13 引入了文本块。我们可以通过使用三个双引号来标记多行块的开始和结束,轻松存储一首诗。这个特性甚至以聪明的方式保留了前导空格:最左边的非空格字符成为左侧的“边缘”。在后续行中,在该边缘左侧的空格将被忽略,但该边缘后面的空格将被保留。考虑在jshell中重新制作我们的诗:

jshell> String poem = """
   ...> Twas brillig, and the slithy toves
   ...>    Did gyre and gimble in the wabe:
   ...> All mimsy were the borogoves,
   ...>    And the mome raths outgrabe.
   ...> """;
poem ==> "Twas brillig, and ... the mome raths outgrabe.\n"

jshell> System.out.print(poem);
Twas brillig, and the slithy toves
   Did gyre and gimble in the wabe:
All mimsy were the borogoves,
   And the mome raths outgrabe.

jshell>

在源代码中嵌入长文本通常不是您想做的事情。对于超过几十行的文本,第十章介绍了从文件加载String的方法。

除了从文字表达中生成字符串外,你还可以直接从字符数组构造String

    char [] data = new char [] { 'L', 'e', 'm', 'm', 'i', 'n', 'g' };
    String lemming = new String(data);

你还可以从字节数组构造一个String

    byte [] data = new byte [] { (byte)97, (byte)98, (byte)99 };
    String abc = new String(data, "ISO8859_1");

在这种情况下,String构造函数的第二个参数是字符编码方案的名称。String构造函数使用它来将指定编码中的原始字节转换为运行时选择的内部编码。如果不指定字符编码,则使用系统上的默认编码方案。²

相反,String类的charAt()方法允许你以类似数组的方式访问String的字符:

    String s = "Newton";
    for (int i = 0; i < s.length(); i++)
      System.out.println(s.charAt(i) );

此代码逐个打印字符串的字符。

String类实现java.lang.CharSequence接口,这个概念将String定义为字符序列,并指定了length()charAt()方法作为获取字符子集的方式。

从事物中获取字符串

Java 中的对象和原始类型可以被转换为一个默认的文本表示作为String。对于诸如数字之类的原始类型,字符串应该是显而易见的;对于对象类型,则由对象本身控制。我们可以通过静态的String.valueOf()方法获取项目的字符串表示。这个方法的各种重载版本接受每个原始类型:

    String one = String.valueOf(1); // integer, "1"
    String two = String.valueOf(2.384f);  // float, "2.384"
    String notTrue = String.valueOf(false); // boolean, "false"

Java 中的所有对象都有一个从Object类继承而来的toString()方法。对于许多对象,这个方法返回一个有用的结果,显示对象的内容。例如,java.util.Date对象的toString()方法返回它表示的日期格式化为字符串。对于不提供表示的对象,字符串结果只是一个可以用于调试的唯一标识符。当针对对象调用String.valueOf()方法时,它会调用对象的toString()方法并返回结果。使用这个方法的唯一真正的区别是,如果传递给它一个空对象引用,它会为你返回String“null”,而不是产生NullPointerException

    Date date = new Date();
    // Equivalent, e.g., "Fri Dec 19 05:45:34 CST 1969"
    String d1 = String.valueOf(date);
    String d2 = date.toString();

    date = null;
    d1 = String.valueOf(date);  // "null"
    d2 = date.toString();  // NullPointerException!

字符串连接在内部使用valueOf()方法,因此如果使用加号运算符(+)“添加”对象或原始类型,则会得到一个String

    String today = "Today's date is :" + date;

有时你会看到人们使用空字符串和加号运算符(+)作为快捷方式来获取对象的字符串值。例如:

    String two = "" + 2.384f;
    String today = "" + new Date();

这有点欺骗,但确实有效,而且视觉上简洁。

比较字符串

标准的 equals() 方法可以比较字符串是否 相等;它们必须按相同顺序包含完全相同的字符。你可以使用 equalsIgnoreCase() 方法以不区分大小写的方式检查字符串的等价性:

    String one = "FOO";
    String two = "foo";

    one.equals(two);             // false
    one.equalsIgnoreCase(two);   // true

在 Java 中,初学者常见的错误是在需要 equals() 方法时使用 == 运算符比较字符串。请记住,Java 中字符串是对象,== 测试对象的 身份:即测试两个被测试参数是否是同一个对象。在 Java 中,很容易创建两个具有相同字符但不是同一个字符串对象的字符串。例如:

    String foo1 = "foo";
    String foo2 = String.valueOf(new char [] { 'f', 'o', 'o' });

    foo1 == foo2         // false!
    foo1.equals(foo2)  // true

这个错误特别危险,因为它通常对比较“字面字符串”(直接在代码中用双引号声明的字符串)有效。这是因为 Java 尝试通过组合它们来有效管理字符串。在编译时,Java 找出给定类中的所有相同字符串,并为它们创建一个对象。这是安全的,因为字符串是不可变的,不能改变,但这确实为此比较问题留下了空间。

compareTo() 方法比较 String 的词法值与另一个 String,使用 Unicode 规范比较两个字符串在“字母表”中的相对位置。(我们用引号是因为 Unicode 不仅包含英语字母的许多更多字符。)它返回一个整数,小于、等于或大于零:

    String abc = "abc";
    String def = "def";
    String num = "123";

    if (abc.compareTo(def) < 0) { ... }  // true
    if (abc.compareTo(abc) == 0) { ... } // true
    if (abc.compareTo(num) > 0) { ... }  // true

compareTo() 方法返回的实际值有三种可能性,你不能真正使用它们。任何负数,比如 -1-5-1,000,仅意味着第一个字符串“小于”第二个字符串。compareTo() 方法严格按照 Unicode 规范中字符的位置比较字符串。这对简单文本有效,但不能很好地处理所有语言变体。如果你需要更复杂的比较及更广泛的国际化支持,请查阅 java.text.Collator 类的文档

搜索

String 类提供了几个简单的方法来查找字符串中的固定子字符串。startsWith()endsWith() 方法分别与 String 的开头和结尾的参数字符串进行比较:

    String url = "http://foo.bar.com/";
    if (url.startsWith("http:"))  // true

indexOf() 方法搜索字符或子字符串的第一个出现位置,并返回起始字符位置,如果未找到子字符串,则返回 -1

    String abcs = "abcdefghijklmnopqrstuvwxyz";
    int i = abcs.indexOf('p');     // 15
    int i = abcs.indexOf("def");   // 3
    int I = abcs.indexOf("Fang");  // -1

类似地,lastIndexOf() 向后搜索字符串中字符或子字符串的最后一个出现位置。

contains() 方法处理一个非常常见的任务,即检查目标字符串中是否包含给定的子字符串:

    String log = "There is an emergency in sector 7!";
    if  (log.contains("emergency")) pageSomeone();

    // equivalent to
    if (log.indexOf("emergency") != -1) ...

对于更复杂的搜索,可以使用正则表达式 API,它允许您查找和解析复杂模式。我们将在本章后面讨论正则表达式。

字符串方法摘要

表 8-1 总结了 String 类提供的方法。我们包含了本章未讨论的几种方法。可以在 jshell 中尝试这些方法,或者查看在线文档

表 8-1. 字符串方法

方法 功能
charAt() 获取字符串中特定位置的字符
compareTo() 比较字符串与另一个字符串
concat() 将字符串与另一个字符串连接起来
contains() 检查字符串是否包含另一个字符串
copyValueOf() 返回与指定字符数组等效的字符串
endsWith() 检查字符串是否以指定后缀结尾
equals() 比较字符串与另一个字符串是否相等
equalsIgnoreCase() 忽略大小写比较字符串与另一个字符串
getBytes() 将字符从字符串复制到字节数组
getChars() 将字符串中的字符复制到字符数组
hashCode() 返回字符串的哈希码
indexOf() 在字符串中搜索字符或子字符串的第一次出现位置
intern() 从全局共享字符串池中获取字符串的唯一实例
isBlank() 如果字符串长度为零或仅包含空白字符,则返回 true
isEmpty() 如果字符串长度为零,则返回 true
lastIndexOf() 在字符串中搜索字符或子字符串的最后一次出现位置
length() 返回字符串的长度
lines() 返回由行终止符分隔的流
matches() 确定整个字符串是否与正则表达式模式匹配
regionMatches() 检查字符串的区域是否与另一个字符串的指定区域匹配
repeat() 返回重复给定次数的此字符串的连接
replace() 将字符串中所有出现的字符替换为另一个字符
replaceAll() 使用模式替换字符串中所有正则表达式模式的匹配项
replaceFirst() 使用模式替换字符串中第一次出现的正则表达式模式
split() 使用正则表达式模式作为分隔符,将字符串拆分为字符串数组
startsWith() 检查字符串是否以指定前缀开头
strip() 根据 Character.isWhitespace() 定义,移除字符串的前导和尾随空白
stripLeading() 类似于 strip(),移除前导空白
stripTrailing() 类似于 strip(),移除尾随空白
substring() 返回字符串的子串
toCharArray() 返回字符串的字符数组
toLowerCase() 将字符串转换为小写
toString() 返回对象的字符串值
toUpperCase() 将字符串转换为大写
trim() 删除前导和尾随空白,这里定义为任何 Unicode 位置(称为其代码点)小于或等于 32 的字符(“空格”字符)
valueOf() 返回值的字符串表示形式

字符串的用途

解析和格式化文本是一个庞大而开放的主题。到目前为止,在本章中,我们只研究了字符串的原始操作—创建、搜索和将简单值转换为字符串。现在我们想要转向更结构化的文本形式。Java 有一套丰富的 API 用于解析和打印格式化的字符串,包括数字、日期、时间和货币值。我们将在本章中涵盖大多数这些主题,但我们将等待在“本地日期和时间”中讨论日期和时间格式化。

我们将从解析开始—从字符串中读取原始数字和值,并将长字符串切割成标记。然后我们将看一下正则表达式,Java 提供的最强大的文本解析工具。正则表达式允许您定义任意复杂度的模式,搜索它们并从文本中解析它们。

解析原始数字

在 Java 中,数字、字符和布尔值是原始类型—而不是对象。但是对于每种原始类型,Java 还定义了一个原始包装类。具体来说,java.lang包包括以下类:ByteShortIntegerLongFloatDoubleCharacterBoolean。我们在“原始类型的包装器”中谈到过这些,但我们现在提到它们是因为这些类包含了解析其各自类型的静态实用方法。每个这些原始包装类都有一个静态的“parse”方法,它读取一个String并返回相应的原始类型。例如:

    byte b = Byte.parseByte("16");
    int n = Integer.parseInt("42");
    long l = Long.parseLong("99999999999");
    float f = Float.parseFloat("4.2");
    double d = Double.parseDouble("99.99999999");
    boolean b = Boolean.parseBoolean("true");

你可以找到其他将字符串转换为基本类型并再次转换的方法,但这些包装类方法简单直接易于阅读。在IntegerLong的情况下,您还可以提供一个可选的radix参数(数字系统的基数;例如,十进制数字的基数为 10)来转换带有八进制或十六进制数字的字符串。(处理诸如加密签名或电子邮件附件等内容时,非十进制数据有时会出现。)

分词文本

你很少会遇到只有一个数字要解析或只有你需要的单词的字符串。将长字符串解析为由一些分隔符字符(如空格或逗号)分隔的单个单词或标记是一项更常见的编程任务。

程序员们谈论标记(tokens)作为讨论文本中不同值或类型的通用方式。标记可以是一个简单的单词,一个用户名,一个电子邮件地址或一个数字。让我们看几个例子。

考虑下面的样本文本。第一行包含由单个空格分隔的单词。剩下的一对行包括以逗号分隔的字段:

    Now is the time for all good people

    Check Number, Description,      Amount
    4231,         Java Programming, 1000.00

Java 有几种(不幸地重叠)处理此类情况的方法和类。我们将使用 String 类中强大的 split() 方法。它利用正则表达式允许你根据任意模式分割字符串。我们稍后会讨论正则表达式,但现在为了向你展示它是如何工作的,我们先告诉你必要的魔法。

split() 方法接受描述分隔符的正则表达式。它使用该表达式将字符串分割成一个较小的 String 数组:

    String text1 = "Now is the time for all good people";
    String [] words = text1.split("\\s");
    // words = "Now", "is", "the", "time", ...

    String text2 = "4231,         Java Programming, 1000.00";
    String [] fields = text2.split("\\s*,\\s*");
    // fields = "4231", "Java Programming", "1000.00"

在第一个例子中,我们使用了正则表达式 \\s,它匹配单个空白字符(空格、制表符或换行符)。在我们的 text1 变量上调用 split() 返回一个包含八个字符串的数组。在第二个例子中,我们使用了一个更复杂的正则表达式 \\s*,\\s*,它匹配由任意量的可选空白字符包围的逗号。这将我们的文本减少为三个漂亮整洁的字段。

正则表达式

现在是时候在我们通过 Java 的旅程中稍作停顿,进入 正则表达式 的领域了。正则表达式,简称 regex,描述了一个文本模式。正则表达式与许多工具一起使用——包括 java.util.regex 包、文本编辑器和许多脚本语言——提供了复杂的文本搜索和字符串操作能力。

正则表达式可以帮助你在大文件中找到所有的电话号码。它们可以帮助你找到带有特定区号的所有电话号码。它们可以帮助你找到没有特定区号的所有电话号码。你可以使用正则表达式在网页源码中找到链接。甚至可以使用正则表达式在文本文件中进行一些编辑。例如,你可以查找带有括号区号的电话号码,如 (123) 456-7890,并将其替换为更简单的 123-456-7890 格式。正则表达式的强大之处在于,你可以找到文本块中带有括号的 每一个 电话号码,并对其进行转换,而不仅仅是一个特定的电话号码。

如果你已经熟悉了正则表达式的概念以及它们如何与其他语言一起使用,你可能想要快速浏览一下这一部分,但不要完全跳过。至少,你需要稍后查看本章中的 “The java.util.regex API”,它涵盖了使用它们所需的 Java 类。如果你想知道正则表达式到底是什么,那就准备好一罐或一杯你最喜欢的饮料吧。你将在几页之内了解到文本操作工具中最强大的工具,以及一种语言中的微小语言。

正则表达式符号

正则表达式(regex)描述了文本中的模式。通过 模式,我们指的是几乎可以想象出的任何你可以单纯从文本中的文字了解的特征,而不必真正理解它们的含义。这包括诸如单词、单词组合、行和段落、标点、大写或小写,以及更一般地说,具有特定结构的字符串和数字。 (想想电话号码、电子邮件地址或邮政编码之类的东西。)使用正则表达式,你可以搜索字典中所有包含字母“q”但其旁边没有它的“u”的单词,或者以相同字母开头和结尾的单词。一旦你构建了一个模式,你就可以使用简单的工具在文本中搜索它,或确定给定的字符串是否与之匹配。

一次编写,一次逃避

正则表达式构成了一种简单的编程语言形式。想一想我们之前引用的例子。我们需要类似一种语言来描述甚至是简单模式——比如电子邮件地址——它们具有共同的元素但形式上也有些变化。

计算机科学教科书会将正则表达式分类为计算机语言的底层,无论是从它们能描述的内容还是你可以用它们做什么来看。但是,它们仍然有可能非常复杂。与大多数编程语言一样,正则表达式的元素很简单,但你可以将它们组合起来创建一些相当复杂的东西。而这种潜在的复杂性正是事情开始变得棘手的地方。

由于正则表达式适用于字符串,而 Java 代码中的字符串无处不在,因此具有非常紧凑的表示法是很方便的。但紧凑的表示法可能很神秘,经验表明,编写一个复杂的语句要比稍后再次阅读它要容易得多。这就是正则表达式的诅咒。你可能会发现自己在一个深夜、咖啡因推动的灵感时刻,写下一个辉煌的模式来简化你程序的其余部分到一行。然而,当你第二天回来阅读这一行时,它可能对你来说就像埃及象形文字一样难以理解。更简单通常更好,但如果你能更清晰地将问题分解为几个步骤,也许你应该这样做。

转义字符

现在您已经得到了适当的警告,在我们重建您之前,我们还需要介绍一件事情。正则表达式的表示法不仅可能有些复杂,而且在普通的 Java 字符串中使用时也有些模糊。表示法的一个重要部分是转义字符——带有反斜杠的字符。例如,在正则表达式表示法中,转义字符\d(反斜杠d)是任意单个数字字符(0-9)的缩写。然而,您不能简单地在 Java 字符串中写\d,因为 Java 使用反斜杠来表示自己的特殊字符和指定 Unicode 字符序列(\uxxxx)。幸运的是,Java 给了我们一个替代方案:转义的反斜杠:两个反斜杠(\\)。它代表一个字面上的反斜杠。规则是,当您希望在正则表达式中出现反斜杠时,必须用额外的一个反斜杠对其进行转义:

    "\\d" // Java string that yields \d in a regex

它变得更加奇怪了!因为正则表达式表示法本身使用反斜杠来表示特殊字符,所以它必须为自己提供相同的“逃逸舱口”。如果您希望您的正则表达式匹配一个字面上的反斜杠,您需要双倍反斜杠。它看起来像这样:

    "\\\\"  // Java string yields two backslashes; regex yields one

本节中大多数“魔术”运算符字符都作用于它们之前的字符,所以如果要保留它们的字面意义,就必须对它们进行转义。这些字符包括.*+{}()。一个可以匹配标准美国电话号码(带有括号内的区号)的表达式看起来是这样的:

    "\\(\\d\\d\dd\\) \\d\\d\\d-\\d\\d\\d\\d"

如果您需要创建一个表达式的一部分,其中包含许多字面上的字符,您可以使用特殊的分隔符\Q\E来帮助您。出现在\Q\E之间的任何文本都会自动转义。 (您仍然需要 Java 的String转义——对于反斜杠,双反斜杠,但不是四倍。)还有一个名为Pattern.quote()的静态方法,它执行相同的操作,返回您给定字符串的正确转义版本。

当我们在处理这些示例时,我们还有一个建议可以帮助您保持冷静。在实际的 Java 字符串(在其中必须加倍所有反斜杠)上面写出纯正则表达式,我们也倾向于在其中包含一个带有希望匹配的文本示例的注释。这里再次是带有这种注释方法的美国电话号码示例:

    // US phone number: (123) 456-7890
    // regex: \(\d\d\d\) \d\d\d-\d\d\d\d
    "\\(\\d\\d\dd\\) \\d\\d\\d-\\d\\d\\d\\d"

还有别忘了jshell!它可以是一个非常强大的测试和调整模式的场所。我们将在“java.util.regex API”中看到几个在jshell中测试模式的例子。但首先,让我们看看更多可以用来构造模式的元素。

字符和字符类

现在,让我们深入了解实际的正则表达式语法。正则表达式的最简单形式是纯文本,它没有特殊含义,直接(逐个字符)与输入匹配。这可以是一个或多个字符。例如,在下面的字符串中,模式s可以匹配单词“rose”和“is”中的字符s

    "A rose is $1.99."

模式rose只能匹配字面上的单词 rose。但这并不是非常有趣。我们通过引入一些特殊字符和字符“类”的概念来提高一点难度:

任何字符:点.

特殊字符点 (.) 匹配任何单个字符。模式 .ose 匹配“rose”、“nose”、“_ose”(空格后跟“ose”),或者任何其他字符后跟序列“ose”。两个点匹配任何两个字符(“prose”、“close”),依此类推。点操作符非常广泛;通常仅在行终止符(换行符、回车符或两者的组合)时停止。将 . 视为表示所有字符的类。

空白或非空白字符:\s, \S

特殊字符 \s 匹配空白字符。空白字符包括与文本中的视觉空间相关的任何字符,或标记行尾的字符。常见的空白字符包括文字空格字符(按键盘空格键时得到的内容)、\t(制表符)、\r(回车符)、\n(换行符)和 \f(换页符)。对应的特殊字符 \S 则相反,匹配空白字符。

数字或非数字字符\d, \D

\d 匹配从 0 到 9 的任何数字。\D 则相反,匹配除数字外的所有字符。

字母或非字母字符\w, \W

\w 匹配通常在“单词”中找到的字符,例如大写和小写字母 A–Z,a–z,数字 0–9 和下划线字符 (_)。\W 匹配除这些字符以外的所有内容。

自定义字符类

您可以使用方括号 ([ ]) 定义自己的字符类,包围您想要的字符。以下是一些示例:

    [abcxyz]     // matches any of a, b, c, x, y, or z
    [02468]      // matches any even digit
    [aeiouAEIOU] // matches any vowel, upper- or lowercase
    [AaEeIiOoUu] // also matches any vowel

特殊 x-y 范围表示法 可以用作连续运行的字母数字字符的简写:

    [LMNOPQ]     // Explicit class of L, M, N, O, P, or Q
    [L-Q]        // Equivalent shorthand version
    [12345]      // Explicit class of 1, 2, 3, 4, or 5
    [1-5]        // Equivalent shorthand version

在方括号内将插入符号 (^) 作为第一个字符会反转字符类,匹配除了方括号中包括的字符以外的任何字符:

    [^A-F]       // G, H, I, ..., a, b, c, 1, 2, $, #... etc.
    [^aeiou]     // Any character that isn't a lowercase vowel

嵌套字符类简单地将它们连接成一个单一的类:

    [A-F[G-Z]\s] // A-Z plus whitespace

您可以使用 && 逻辑 AND 表示法(类似于我们在 “运算符” 中看到的布尔运算符)来取交集(共同的字符):

    [a-p&&[l-z]] // l, m, n, o, p
    [A-Z&&[^P]]  // A through Z except P

位置标记

模式 [Aa] rose(包括大写或小写字母 A)在以下短语中匹配三次:

    "A rose is a rose is a rose"

位置字符允许您指定匹配在行内的相对位置。最重要的是 ^$,分别匹配行的开头和结尾:

    ^[Aa] rose  // matches "A rose" at the beginning of line
    [Aa] rose$  // matches "a rose" at end of line

要更加精确一些,^$ 匹配“输入”的开头和结尾,通常这是单行。如果您处理多行文本,并希望匹配单个大字符串内行的开头和结尾,可以通过标志打开“多行”模式,如后面在 “特殊选项” 中描述。

位置标记符\b\B匹配单词边界(空格、标点或行的开头或结尾),或非单词边界(单词的中间),分别如下。例如,第一个模式匹配“rose”和“rosemary”,但不匹配“primrose”。第二个模式匹配“primrose”和“prose”,但不匹配单词开头的“rose”或独立的“rose”:

    \brose      // rose, rosemary, roses; NOT prose
    \Brose      // prose, primrose; NOT rose or rosemary

当需要查找或排除前缀或后缀时,通常使用这些位置标记符。

迭代(多重性)

简单地匹配固定字符模式将无法使我们走得更远。接下来,我们看一下可以计数字符(或更一般地说,模式的出现次数,正如我们将在“模式”中看到的那样)的操作符。

任意(零个或多个迭代):星号*

在字符或字符类之后放置星号(*)表示“允许任意数量的该类型字符”——换句话说,零个或更多。例如,下面的模式匹配具有任意数量前导零的数字(可能没有):

    0*\d   // match a digit with any number of leading zeros

一些(一个或多个迭代):加号 (+)

加号(+)表示“一个或多个”迭代,等同于 XX*(模式后跟模式星号)。例如,下面的模式匹配一个带有一个或多个数字的数字,加上可选的前导零:

    0*\d+   // match a number (one or more digits) with optional
            // leading zeros

在表达式开头匹配零似乎是多余的,因为零是一个数字,因此会被表达式中\d+部分匹配。然而,稍后我们将展示如何使用正则表达式分析字符串并仅获取想要的部分。在这种情况下,您可能希望去掉前导零,只保留数字。

可选(零个或一次迭代):问号?

问号操作符(?)允许零次或一次迭代。例如,下面的模式匹配信用卡过期日期,中间可能有或可能没有斜杠:

    \d\d/?\d\d  // match four digits with optional slash in the middle

范围(介于 x 和 y 次迭代之间,包括 x 和 y){x,y}

{x,y} 花括号范围运算符是最常见的迭代运算符。它指定一个精确的匹配范围。一个范围接受两个参数:一个下界和一个上界,用逗号分隔。这个正则表达式匹配任何具有五到七个字符的单词:

    \b\w{5,7}\b  // match words with 5, 6, or 7 characters

至少 x 次或更多次迭代(y 是无穷大){x,}

如果省略上界,只留下范围中的悬挂逗号,上界将变为无限大。这是指定具有无最大次数的最小出现次数的方法。

替换

竖线(|)操作符表示逻辑 OR 操作,也称为替换选择|操作符不操作单个字符,而是应用于其两侧的所有内容。它将表达式分成两部分,除非受到括号分组的限制。例如,对解析日期的稍微天真的方法可能如下所示:

    \w+, \w+ \d+, \d+|\d\d/\d\d/\d\d  // pattern 1 OR pattern 2

在这个表达式中,左侧匹配诸如“Fri, Oct 12, 2001,” 这样的模式,右侧匹配“10/12/01”。

以下正则表达式可能用于匹配具有netedugov三个域名中的电子邮件地址之一:

    \w+@[\w.]+\.(net|edu|gov)
    // email address ending in .net, .edu, or .gov

这个模式在真实有效的电子邮件地址方面并不完整。但它确实突显了如何使用交替来构建具有一些有用特性的正则表达式。

特殊选项

几个特殊选项会影响正则表达式引擎的匹配方式。这些选项可以通过两种方式应用:

  • 你可以在Pattern.compile()步骤中提供一个或多个特殊参数(标志)(见“java.util.regex API”)。

  • 你可以在你的正则表达式中包含一个特殊的代码块。

我们将在这里展示后一种方法。为此,在一个特殊块(?x)中包含一个或多个标志,其中x是我们想要打开选项的标志。通常,你在正则表达式的开头这样做。你也可以通过添加减号来关闭标志(?-x),这允许你对模式的部分区域应用标志。

可用以下标志:

不区分大小写(?i)

(?i)标志告诉正则表达式引擎在匹配时忽略字符大小写。例如:

    (?i)yahoo   // matches Yahoo, yahoo, yahOO, etc.

点全部(?s)

(?s)标志打开了“点任意”模式,允许点字符匹配任何内容,包括行尾字符。如果你要匹配跨多行的模式,这很有用。s代表“单行模式”,这个名字有点令人困惑,来源于 Perl。

多行模式(?m)

默认情况下,^$实际上不匹配行的开头和结尾(由回车或换行符组合定义)。相反,它们匹配整个输入文本的开头或结尾。在许多情况下,“一行”与整个输入是同义的。

如果你有一个大文本块需要处理,通常会出于其他原因将该块分成单独的行。如果你这样做,检查给定行是否符合正则表达式将会很简单,并且^$将会按预期行为。然而,如果你想要在包含多行的整个输入字符串上使用正则表达式(由那些回车或换行符组合分隔),你可以打开多行模式(?m)。此标志导致^$匹配文本块内单个行的开头和结尾,以及整个块的开头和结尾。具体来说,这意味着第一个字符之前的位置,最后一个字符之后的位置,以及字符串内的行终止符之前和之后的位置。

Unix 行(?d)

(?d)标志将^$.特殊字符的行终止符定义限制为仅 Unix 风格的换行符(\n)。默认情况下,也允许回车换行符(\r\n)。

java.util.regex API

现在我们已经讨论了如何构建正则表达式的理论部分,困难的部分已经过去了。剩下的就是调查 Java API,看看如何应用这些表达式。

模式

正如我们所说,我们写成字符串形式的正则表达式模式实际上是描述如何匹配文本的小程序。在运行时,Java 正则表达式包将这些小程序编译成一种可以针对某个目标文本执行的形式。几个简单的便捷方法直接接受字符串以用作模式。

静态方法Pattern.matches()接受两个字符串——一个正则表达式和一个目标字符串——并确定目标是否与正则表达式匹配。如果您想在应用程序中进行快速测试,这非常方便。例如:

    Boolean match = Pattern.matches("\\d+\\.\\d+f?", myText);

这行代码可以测试字符串myText是否包含类似“42.0f.”这样的 Java 风格浮点数。请注意,字符串必须完全匹配才能被认为是匹配的。如果你想查看一个小模式是否包含在较大的字符串中,但不关心字符串的其余部分,你必须使用Matcher,如Matcher中所述。

让我们尝试另一个(简化的)模式,一旦我们开始让多个玩家相互竞争,我们可以在我们的游戏中使用。许多登录系统使用电子邮件地址作为用户标识符。当然,这样的系统并不完美,但电子邮件地址将符合我们的需求。我们希望邀请用户输入他们的电子邮件地址,但在使用之前,我们希望确保它看起来是有效的。正则表达式可以快速进行这样的验证[³]。

就像编写算法来解决编程问题一样,设计正则表达式需要您将模式匹配问题分解为易于处理的部分。如果我们考虑电子邮件地址,几个模式立即显而易见。最明显的是每个地址中间的@符号。依赖于这个事实的一个天真(但比没有好!)的模式可以构建如下:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches(".*@.*", sample);

但这个模式太宽容了。它确实能够识别有效的电子邮件地址,但也会识别许多无效的地址,比如"bad.address@""@also.bad"甚至"@@"。让我们在jshell中测试一下:

jshell> String sample = "my.name@some.domain";
sample ==> "my.name@some.domain"

jshell> Pattern.matches(".*@.*", sample)
Pattern.matches(".*@.*", sample)$2 ==> true

jshell> Pattern.matches(".*@.*", "bad.address@")
Pattern.matches(".*@.*", "bad.address@")$3 ==> true

jshell> Pattern.matches(".*@.*", "@@")
Pattern.matches(".*@.*", "@@")$4 ==> true

试着自己制造一些更糟糕的例子。你很快就会发现,我们简单的电子邮件模式确实太简单了。

我们如何做出更好的匹配?一个快速的调整是使用+修饰符而不是*。升级后的模式现在要求@符号两边至少有一个字符。但我们对电子邮件地址还了解其他一些情况。例如,地址的左半部分(名称部分)不能包含@字符。同样,域部分也不能包含。对于这种下一个升级,我们可以使用自定义字符类:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+", sample);

这个模式更好一些,但仍然允许一些无效地址,比如"still@bad",因为域名至少有一个名称,后面跟着一个点(.),然后是顶级域(TLD),比如“oreilly.com.” 所以也许可以像这样设置一个模式:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+\\.(com|org)", sample);

那个模式修复了我们在像"still@bad"这样的地址上的问题,但我们可能有点过火了。有许多、许多顶级域名后缀 —— 即使我们忽略了随着新的顶级域名后缀的添加而保持该列表的问题,也无法合理地列出所有顶级域名后缀。⁴ 所以让我们稍微退一步。我们会保留域名部分的“点”,但移除特定的顶级域名后缀,只接受简单的字母序列:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+\\.[a-z]+", sample);

好多了。我们可以添加最后一个微调,以确保我们不用担心地址的大小写,因为所有电子邮件地址都是不区分大小写的。只需在我们的模式字符串的开头添加(?i)标志即可:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", sample);

再次强调,这绝不是一个完美的电子邮件验证器,但它绝对是一个很好的开始,足以满足我们虚拟的登录系统的需求:

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "good@some.domain")
$1 ==> true

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "good@oreilly.com")
$2 ==> true

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "oreilly.com")
$3 ==> false

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "bad@oreilly@com")
$4 ==> false

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "me@oreilly.COM")
$5 ==> true

jshell> Pattern.matches("[^@]+@[^@]+\\.[a-z]+", "me@oreilly.COM")
$6 ==> false

在这些例子中,我们只需一次输入完整的Pattern.matches(…​)行。之后只需简单地按向上箭头、编辑,然后按回车键即可获取接下来的五行。你能找出我们最终模式中导致匹配失败的缺陷吗?

注意

如果你想调整验证模式并进行扩展或改进,请记住你可以使用键盘的上箭头在jshell中“重复使用”行。使用向上箭头检索前一行。确实,你可以使用上箭头和下箭头来导航你最近的所有行。在一行内,使用左箭头和右箭头移动、删除、添加或编辑你的命令。然后只需按回车键运行新修改的命令 —— 你不需要在按回车键之前将光标移动到行尾。

Matcher

Matcher关联模式与字符串,并提供测试、查找和迭代匹配项的工具。Matcher是“有状态的”。例如,find()方法每次调用时都会尝试找到下一个匹配项。但你可以通过调用其reset()方法来清除Matcher并重新开始。

要创建一个Matcher对象,首先需要使用静态的Pattern.compile()方法将模式字符串编译成一个Pattern对象。有了该模式对象后,你可以使用matcher()方法获取你的Matcher,如下所示:

    String myText = "Lots of text with hyperlinks and stuff ...";
    Pattern urlPattern = Pattern.compile("https?://[\\w./]*");
    Matcher matcher = urlPattern.matcher(myText);

如果你只对“一次大匹配”感兴趣 —— 也就是说,你期望你的字符串要么与模式匹配要么不匹配 —— 你可以使用matches()lookingAt()。这些方法大致对应于String类的equals()startsWith()方法。matches()方法询问字符串是否完全匹配模式(没有多余的字符串字符)并返回truefalselookingAt()方法做同样的事情,只是它只询问字符串是否以该模式开头,并不在乎模式是否使用完所有字符串的字符。

更普遍地,您希望能够搜索字符串并找到一个或多个匹配项。为此,可以使用find()方法。每次调用find()返回模式的下一个匹配项的truefalse,并在内部记录匹配文本的位置。您可以使用Matcher start()end()方法获取匹配文本的起始和结束字符位置,或者简单地使用group()方法检索匹配的文本。例如:

import java.util.regex.*;

// ...

    String text="A horse is a horse, of course of course...";
    String pattern="horse|course";

    Matcher matcher = Pattern.compile(pattern).matcher(text);
    while (matcher.find())
      System.out.println(
        "Matched: '"+matcher.group()+"' at position "+matcher.start());

前面的代码片段打印了单词“horse”和“course”的起始位置(总共四个):

    Matched: 'horse' at position 2
    Matched: 'horse' at position 13
    Matched: 'course' at position 23
    Matched: 'course' at position 33

字符串拆分

非常常见的需求是根据某个分隔符(例如逗号)将字符串解析为一堆字段。这是一个如此常见的问题,以至于String类中包含了一个专门用于此目的的方法。split()方法接受一个正则表达式,并返回围绕该模式分割的子字符串数组。考虑以下字符串和split()调用:

    String text = "Foo, bar ,   blah";
    String[] badFields = text.split(",");
    // { "Foo", " bar ", "   blah" }
    String[] goodFields = text.split("\\s*,\\s*");
    // { "Foo", "bar", "blah" }

第一个split()返回一个String数组,但是使用逗号进行简单分隔的结果意味着我们的text变量中的空格字符仍然粘在更有趣的字符上。我们得到了“Foo”作为一个单词,正如预期的那样,但接着我们得到了“bar<space>”,最后是“<space><space><space>blah”。哎呀!第二个split()也产生一个String数组,但这次包含了预期的“Foo”, “bar”(没有尾随空格),和“blah”(没有前导空格)。

如果你在代码中要多次使用这样的操作,你应该编译模式并使用它的split()方法,该方法与String中的版本相同。String split()方法等同于:

    Pattern.compile(pattern).split(string);

正如我们之前提到的,关于正则表达式的知识远远超出了我们在这里介绍的几个正则表达式功能。查看模式文档。在自己的jshell上玩耍。修改ch08/examples/ValidEmail.java文件,看看能否创建更好的电子邮件验证器!这绝对是一个需要实践的主题。

数学工具

当然,字符串操作和模式匹配并不是 Java 唯一能做的操作。Java 直接支持整数和浮点数算术。通过java.lang.Math类支持更高级别的数学运算。正如您所见,基本数据类型的包装类允许您将它们视为对象处理。包装类还包含一些基本转换的方法。

让我们快速浏览一下 Java 中的内置算术功能。Java 通过抛出ArithmeticException来处理整数算术中的错误:

    int zero = 0;

    try {
      int i = 72 / zero;
    } catch (ArithmeticException e) {
      // division by zero
    }

要在这个示例中生成错误,我们创建了中间变量zero。编译器有点狡猾。如果我们直接尝试除以0,它会抓住我们。

另一方面,浮点算术表达式不会抛出异常。相反,它们会采用在表 8-2 中显示的特殊超出范围值。

表 8-2. 特殊浮点值

数学表示
POSITIVE_INFINITY 1.0/0.0
NEGATIVE_INFINITY -1.0/0.0
NaN 0.0/0.0

下面的例子生成一个无限的结果:

    double zero = 0.0;
    double d = 1.0/zero;

    if (d == Double.POSITIVE_INFINITY)
      System.out.println("Division by zero");

特殊值NaN(不是一个数字)表示将零除以零的结果。这个值在数学上有特殊的区别,即它不等于自身(NaN != NaN计算结果为true)。使用Float.isNaN()Double.isNaN()来测试NaN

java.lang.Math

java.lang.Math 类是 Java 的数学库。它包含一系列静态方法,涵盖了所有常见的数学操作,如 sin()cos()sqrt()Math 类不是很面向对象(不能创建 Math 的实例)。相反,它只是一个方便的静态方法的容器,更像是全局函数。正如我们在第五章看到的,可以使用静态导入功能将静态方法和常量的名称直接导入到我们类的范围内,并通过它们简单而不加修饰地使用它们。

表 8-3 总结了 java.lang.Math 中的方法。

Table 8-3. java.lang.Math 中的方法

方法 参数类型 功能
Math.abs(a) int, long, float, double 绝对值
Math.acos(a) double 反余弦
Math.asin(a) double 反正弦
Math.atan(a) double 反正切
Math.atan2(a,b) double 矩形到极坐标转换的角度部分
Math.ceil(a) double 大于或等于 a 的最小整数
Math.cbrt(a) double a 的立方根
Math.cos(a) double 余弦
Math.cosh(a) double 双曲余弦
Math.exp(a) double Math.Ea 次幂
Math.floor(a) double 小于或等于 a 的最大整数
Math.hypot(a,b) double 精确计算 sqrt()a2 + b2
Math.log(a) double a 的自然对数
Math.log10(a) double a 的以 10 为底的对数
Math.max(a, b) int, long, float, double ab 更接近 Long.MAX_VALUE
Math.min(a, b) int, long, float, double ab 更接近 Long.MIN_VALUE
Math.pow(a, b) double ab 次幂
Math.random() None 随机数生成器
Math.rint(a) double 将双精度值转换为双精度格式中的整数值
Math.round(a) float, double 四舍五入到整数
Math.signum(a) float, double 获取数字的符号,为 1.0,-1.0 或 0
Math.sin(a) double 正弦
Math.sinh(a) double 双曲正弦
Math.sqrt(a) double 平方根
Math.tan(a) double 正切
Math.tanh(a) double 双曲正切
Math.toDegrees(a) double 将弧度转换为角度
Math.toRadians(a) double 将角度转换为弧度

方法 log()pow()sqrt() 可能会抛出运行时的 ArithmeticException。方法 abs()max()min() 都针对所有标量值(intlongfloatdouble)进行了重载,并返回相应的类型。Math.round() 的版本接受 floatdouble,分别返回 intlong,其余的方法均操作并返回 double 值:

    double irrational = Math.sqrt(2.0); // 1.414...
    int bigger = Math.max(3, 4);  // 4
    long one = Math.round(1.125798); // 1

只是为了突显静态导入选项的便利性,请在 jshell 中尝试这些简单的函数:

jshell> import static java.lang.Math.*

jshell> double irrational = sqrt(2.0)
irrational ==> 1.4142135623730951

jshell> int bigger = max(3,4)
bigger ==> 4

jshell> long one = round(1.125798)
one ==> 1

Math 还包含静态的最终双精度常量 EPI。例如,要找到圆的周长:

    double circumference = diameter  * Math.PI;

数学的实际应用

我们已经介绍了如何在 “访问字段和方法” 中使用 Math 类及其静态方法。我们可以再次使用它,通过随机化树木出现的位置使我们的游戏更加有趣。Math.random() 方法返回一个大于或等于 0 且小于 1 的随机 double。通过一些算术运算和舍入或截断,您可以使用该值创建任何所需范围内的随机数。特别地,将该值转换为所需范围的方法如下:

    int randomValue = min + (int)(Math.random() * (max - min));

试一试!尝试在 jshell 中生成一个随机的四位数。您可以将 min 设置为 1,000,将 max 设置为 10,000,如下所示:

jshell> int min = 1000
min ==> 1000

jshell> int max = 10000
max ==> 10000

jshell> int fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 9603

jshell> fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 9178

jshell> fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 3789

要放置我们的树木,我们需要两个随机数来获取 x 和 y 坐标。我们可以设置一个边缘周围的范围,通过在边缘周围留出一些空白来保持树木在屏幕上。对于 x 坐标,可能会像这样:

  private int goodX() {
    // at least half the width of the tree plus a few pixels
    int leftMargin = Field.TREE_WIDTH_IN_PIXELS / 2 + 5;
    // now find a random number between a left and right margin
    int rightMargin = FIELD_WIDTH - leftMargin;

    // And return a random number starting at the left margin
    return leftMargin + (int)(Math.random() * (rightMargin - leftMargin));
  }

设置一个类似的方法来查找 y 值,你应该会看到类似于 图 8-1 中显示的图像。您甚至可以使用我们在 第五章 中讨论过的 isTouching() 方法,以避免将任何树木放置在与我们的物理学家直接接触的位置。这是我们升级后的树木设置循环:

  for (int i = field.trees.size(); i < Field.MAX_TREES; i++) {
    Tree t = new Tree();
    t.setPosition(goodX(), goodY());

    // Trees can be close to each other and overlap,
    // but they shouldn't intersect our physicist
    while(player1.isTouching(t)) {
      // We do intersect this tree, so let's try again
      System.err.println("Repositioning an intersecting tree...");
      t.setPosition(goodX(), goodY());
    }
    field.addTree(t);
  }

ljv6 0801

图 8-1. 随机分布的树木

尝试退出游戏并再次启动它。您应该会看到每次运行应用程序时树木的不同位置。

大/精确数值

如果 longdouble 类型对您来说不够大或精确,那么 java.math 包提供了两个类,BigIntegerBigDecimal,支持任意精度的数字。这些功能齐全的类具有大量方法,用于执行任意精度数学运算并精确控制余数的舍入。在以下示例中,我们使用 BigDecimal 来添加两个非常大的数字,然后创建一个带有 100 位小数的分数:

    long l1 = 9223372036854775807L; // Long.MAX_VALUE
    long l2 = 9223372036854775807L;
    System.out.println(l1 + l2); // -2 ! Not good.

    try {
      BigDecimal bd1 = new BigDecimal("9223372036854775807");
      BigDecimal bd2 = new BigDecimal(9223372036854775807L);
      System.out.println(bd1.add(bd2) ); // 18446744073709551614

      BigDecimal numerator = new BigDecimal(1);
      BigDecimal denominator = new BigDecimal(3);
      BigDecimal fraction =
          numerator.divide(denominator, 100, BigDecimal.ROUND_UP);
      // 100-digit fraction = 0.333333 ... 3334
    }
    catch (NumberFormatException nfe) { }
    catch (ArithmeticException ae) { }

如果您为了乐趣实施加密或科学算法,BigInteger 是至关重要的。反过来,BigDecimal 可在涉及货币和财务数据的应用程序中找到。除此之外,您可能不太需要这些类。

日期和时间

如果没有适当的工具,处理日期和时间可能会很繁琐。Java 包含三个类来处理简单的情况。java.util.Date 类封装了一个时间点。java.util.GregorianCalendar 类继承自抽象类 java.util.Calendar,在时间点和日历字段(如月份、日期和年份)之间进行转换。最后,java.text.DateFormat 类知道如何生成和解析多种语言和区域设置下的日期和时间的字符串表示。

虽然 DateCalendar 类涵盖了许多用例,但它们缺乏精确性和其他功能。出现了几个第三方库,旨在使开发人员更容易处理日期、时间和时间段。Java 8 在这方面提供了非常必要的改进,引入了 java.time 包。本章的其余部分将探讨该包,但你仍然会遇到很多 DateCalendar 的示例,因此了解它们的存在是有用的。正如始终如此,在线文档 是回顾我们未涉及的 Java API 部分的宝贵资源。

本地日期和时间

java.time.LocalDate 类代表您本地区域的无时间信息的日期。想象一年一度的事件,例如每年的冬至,即 12 月 21 日。类似地,java.time.LocalTime 表示没有任何日期信息的时间。也许你的闹钟每天早上 7:15 分响起。java.time.LocalDateTime 存储日期和时间值,例如与眼科医生的约会(这样您就可以继续阅读关于 Java 的书)。所有这些类都提供了静态方法来创建新实例,可以使用适当的数值和 of() 方法,或者使用 parse() 方法解析字符串。让我们进入 jshell 并尝试创建一些示例:

jshell> import java.time.*

jshell> LocalDate.of(2019,5,4)
$2 ==> 2019-05-04

jshell> LocalDate.parse("2019-05-04")
$3 ==> 2019-05-04

jshell> LocalTime.of(7,15)
$4 ==> 07:15

jshell> LocalTime.parse("07:15")
$5 ==> 07:15

jshell> LocalDateTime.of(2019,5,4,7,0)
$6 ==> 2019-05-04T07:00

jshell> LocalDateTime.parse("2019-05-04T07:15")
$7 ==> 2019-05-04T07:15

创建这些对象的另一个很棒的静态方法是 now(),它会提供当前的日期、时间或日期时间,正如你期望的那样:

jshell> LocalTime.now()
$8 ==> 15:57:24.052935

jshell> LocalDate.now()
$9 ==> 2023-03-31

jshell> LocalDateTime.now()
$10 ==> 2023-03-31T15:57:37.909038

很棒!在导入 java.time 包后,您可以为特定时刻或“现在”创建每个 Local…​ 类的实例。您可能已经注意到用 now() 创建的对象包括时间的秒和纳秒。如果需要,您可以向 of()parse() 方法提供这些值。虽然这里没有太多激动人心的内容,但一旦您拥有这些对象,您可以做很多事情。继续阅读!

比较和操作日期和时间

使用 java.time 类的一个重要优势是可用于比较和修改日期和时间的一致方法集。例如,许多聊天应用程序会显示消息发送“多久前”的信息。java.time.temporal 子包正是我们所需的:ChronoUnit 接口。它包含几个日期和时间单位,如 MONTHS, DAYS, HOURS, MINUTES 等。这些单位可用于计算时间差。例如,我们可以使用 between() 方法在 jshell 中计算创建两个示例日期时间所需的时间:

jshell> LocalDateTime first = LocalDateTime.now()
first ==> 2023-03-31T16:03:21.875196

jshell> LocalDateTime second = LocalDateTime.now()
second ==> 2023-03-31T16:03:33.175675

jshell> import java.time.temporal.*

jshell> ChronoUnit.SECONDS.between(first, second)
$12 ==> 11

视觉检查显示,确实花费大约 11 秒的时间输入创建我们的 second 变量的行。查看 ChronoUnit 的文档 获取完整列表,但你将获得从纳秒到千年的全范围。

这些单位还可以帮助你使用 plus()minus() 方法操作日期和时间。例如,设置一周后的提醒:

jshell> LocalDate today = LocalDate.now()
today ==> 2023-03-31

jshell> LocalDate reminder = today.plus(1, ChronoUnit.WEEKS)
reminder ==> 2023-04-07

很棒!但是这个 reminder 示例提出了你可能需要偶尔执行的另一个操作。你可能希望在第 7 天的特定时间提醒。你可以使用 atDate()atTime() 方法轻松在日期、时间和日期时间之间进行转换:

jshell> LocalDateTime betterReminder = reminder.atTime(LocalTime.of(9,0))
betterReminder ==> 2023-04-07T09:00

现在你将在上午 9 点收到提醒。但是,如果你在亚特兰大设置提醒然后飞往旧金山,闹钟会在什么时候响?LocalDateTime 是本地的!所以 T09:00 部分无论你何时运行程序都是上午 9 点。但是如果你要处理像安排会议这样涉及不同时区的事情,就不能忽视不同的时区了。幸运的是 java.time 包也考虑到了这一点。

时区

java.time 包的作者鼓励你尽可能使用时间和日期类的本地变体。支持时区意味着向你的应用程序添加复杂性——他们希望你尽可能避免这种复杂性。但是有许多场景是无法避免支持时区的。你可以使用 ZonedDateTimeOffsetDateTime 类处理带“区域”日期和时间。区域变体理解命名时区和夏令时调整等内容。偏移量变体是与 UTC/Greenwich 的恒定简单数值偏移量。

大多数用户界面上使用日期和时间的地方会使用命名区域方法,因此让我们看一下创建带区域日期时间的方法。为了附加一个区域,我们使用 ZoneId 类,它具有用于创建新实例的常见 of() 静态方法。你可以提供一个区域区作为 String 来获取你的区域值:

jshell> LocalDateTime piLocal = LocalDateTime.parse("2023-03-14T01:59")
piLocal ==> 2023-03-14T01:59

jshell> ZonedDateTime piCentral = piLocal.atZone(ZoneId.of("America/Chicago"))
piCentral ==> 2023-03-14T01:59-05:00[America/Chicago]

现在,你可以确保巴黎的朋友可以在正确的时刻加入你,使用命名为 withZoneSameInstant() 的方法:

jshell> ZonedDateTime piAlaMode =
piCentral.withZoneSameInstant(ZoneId.of("Europe/Paris"))
piAlaMode ==> 2023-03-14T07:59+01:00[Europe/Paris]

如果您有其他朋友并非方便位于主要都会区域,但您也希望他们参与,您可以使用ZoneIdsystemDefault()方法以编程方式选择他们的时区:

jshell> ZonedDateTime piOther =
piCentral.withZoneSameInstant(ZoneId.systemDefault())
piOther ==> 2023-03-14T02:59-04:00[America/New_York]

我们在美国东部时区的笔记本电脑上运行jshellpiOther的输出正如预期的那样。systemDefault()时区 ID 是一种非常方便的方式,可以快速调整来自其他时区的日期时间,以便与用户的时钟和日历匹配。在商业应用中,您可能希望让用户告诉您他们首选的时区,但通常systemDefault()是一个很好的猜测。

解析和格式化日期和时间

对于使用字符串创建和显示我们的本地日期时间和带区域的日期时间,我们一直依赖于遵循 ISO 值的默认格式。这些通常在我们需要接受或显示日期和时间的任何地方起作用。但是正如每个程序员所知,“通常”并非“总是”。幸运的是,您可以使用实用类java.time.format.DateTimeFormatter来帮助解析输入和格式化输出。

DateTimeFormatter的核心在于构建一个格式字符串,该字符串管理解析和格式化。您可以通过在表 8-4 中列出的部分选项来构建格式。这里我们仅列出了部分选项,但这些选项应该能够涵盖您遇到的大部分日期和时间。请注意,在使用上述字符时大小写是敏感的!

表 8-4。流行且有用的DateTimeFormatter元素

字符 描述 示例
a 上午/下午 PM
d 一个月中的日期 10
E 一周中的日期 周二; Tuesday; T
G 纪元 BCE, CE
k 一天中的时钟小时数(1-24) 24
K 上午/下午的小时数(0-11) 0
L 月份 7 月; July; J
h 上午/下午的时钟小时数(1-12) 12
H 一天中的小时数(0-23) 0
m 一个小时中的分钟数 30
M 月份 7; 07
s 分钟的秒数 55
S 秒的小数部分 033954
u 年份(不包含纪元) 2004; 04
y 纪元年份 2004; 04
z 时区名称 太平洋标准时间; PST
Z 时区偏移 +0000; -0800; -08:00

举例来说,如果要创建一个常见的美国短格式,您可以使用Mdy字符。您可以通过静态的ofPattern()方法来构建格式化器。现在,您可以使用(并重复使用)任何日期或时间类的parse()方法来使用该格式化器:

jshell> import java.time.format.DateTimeFormatter

jshell> DateTimeFormatter shortUS =
   ...> DateTimeFormatter.ofPattern("MM/dd/yy")
shortUS ==> Value(MonthOfYe ...) ... (YearOfEra,2,2,2000-01-01)

jshell> LocalDate valentines = LocalDate.parse("02/14/23", shortUS)
valentines ==> 2023-02-14

jshell> LocalDate piDay = LocalDate.parse("03/14/23", shortUS)
piDay ==> 2023-03-14

正如我们之前提到的,格式化器可以双向工作。只需使用您的格式化器的format()方法,即可生成日期或时间的字符串表示:

jshell> LocalDate today = LocalDate.now()
today ==> 2023-12-14

jshell> shortUS.format(today)
$30 ==> "12/14/23"

jshell> shortUS.format(piDay)
$31 ==> "03/14/23"

当然,格式化器同样适用于时间和日期时间!

jshell> DateTimeFormatter military =
   ...> DateTimeFormatter.ofPattern("HHmm")
military ==> Value(HourOfDay,2)Value(MinuteOfHour,2)

jshell> LocalTime sunset = LocalTime.parse("2020", military)
sunset ==> 20:20

jshell> DateTimeFormatter basic =
   ...> DateTimeFormatter.ofPattern("h:mm a")
basic ==> Value(ClockHourOfAmPm)': ... ,SHORT)

jshell> basic.format(sunset)
$42 ==> "8:20 PM"

jshell> DateTimeFormatter appointment =
DateTimeFormatter.ofPattern("h:mm a MM/dd/yy z")
appointment ==>
Value(ClockHourOfAmPm)':' ...
0-01-01)' 'ZoneText(SHORT)

注意,在接下来的ZonedDateTime部分中,我们将时区标识符(z字符)放在了最后——这可能不是您预期的位置!

jshell> ZonedDateTime dentist =
ZonedDateTime.parse("10:30 AM 11/01/23 EST", appointment)
dentist ==> 2023-11-01T10:30-04:00[America/New_York]

jshell> ZonedDateTime nowEST = ZonedDateTime.now()
nowEST ==> 2023-12-14T09:55:58.493006-05:00[America/New_York]

jshell> appointment.format(nowEST)
$47 ==> "9:55 AM 12/14/23 EST"

我们希望说明这些格式的强大之处。您可以设计一个格式,以适应非常广泛的输入或输出样式。传统数据和设计不良的 Web 表单显然是直接需要DateTimeFormatter帮助的例子。

解析错误

尽管您可以随时利用这种解析能力,但有时候事情会出错。遗憾的是,您看到的异常通常过于模糊,无法立即派上用场。考虑以下尝试解析包含小时、分钟和秒的时间:

jshell> DateTimeFormatter withSeconds =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss")
withSeconds ==>
Value(ClockHourOfAmPm,2)':' ...
Value(SecondOfMinute,2)

jshell> LocalTime.parse("03:14:15", withSeconds)
|  Exception java.time.format.DateTimeParseException:
|  Text '03:14:15' could not be parsed: Unable to obtain
|  LocalTime from TemporalAccessor: {MinuteOfHour=14, MilliOfSecond=0,
|  SecondOfMinute=15, NanoOfSecond=0, HourOfAmPm=3,
|  MicroOfSecond=0},ISO of type java.time.format.Parsed
|        at DateTimeFormatter.createError (DateTimeFormatter.java:2020)
|        at DateTimeFormatter.parse (DateTimeFormatter.java:1955)
|        at LocalTime.parse (LocalTime.java:463)
|        at (#33:1)
|  Caused by: java.time.DateTimeException:
  Unable to obtain LocalTime from ...
|        at LocalTime.from (LocalTime.java:431)
|        at Parsed.query (Parsed.java:235)
|        at DateTimeFormatter.parse (DateTimeFormatter.java:1951)
|        ...

糟糕!Java 在无法解析字符串输入时会抛出DateTimeParseException异常。在我们上面的例子中,即使正确从字符串中解析了字段,但未提供足够的信息来创建LocalTime对象时,Java 也会抛出异常。也许不太明显,但我们的时间“3:14:15,”可能是下午或清晨。我们选择的hh模式作为小时的原因是罪魁祸首。我们可以选择一个使用明确的 24 小时制的小时模式,或者添加显式的上午/下午元素:

jshell> DateTimeFormatter valid1 =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss a")
valid1 ==> Value(ClockHourOfAmPm,...y,SHORT)

jshell> DateTimeFormatter valid2 =
   ...> DateTimeFormatter.ofPattern("HH:mm:ss")
valid2 ==> Value(HourOfDay,2)': ... Minute,2)

jshell> LocalTime piDay1 =
   ...> LocalTime.parse("03:14:15 PM", valid1)
piDay1 ==> 15:14:15

jshell> LocalTime piDay2 =
   ...> LocalTime.parse("03:14:15", valid2)
piDay2 ==> 03:14:15

如果您曾经遇到DateTimeParseException,但您的输入看起来是格式正确的匹配,请确保您的格式本身包括创建日期或时间所需的所有内容。关于这些异常的最后一点思考:如果您的日期不自然地包括一个诸如CE的纪元指示符,您可能需要使用非助记符u字符来解析年份。

有关DateTimeFormatter的详细信息还有很多,很多。对于这一点,相较于大多数实用程序类而言,阅读在线文档是值得的。

格式化日期和时间

现在您知道如何创建、解析和存储日期和时间了,接下来需要展示这些便捷的数据。幸运的是,您可以使用同一格式化程序创建用于解析日期和时间的漂亮、易读的字符串。还记得我们的withSecondsmilitary格式化程序吗?您可以获取当前时间并快速将其转换为任何格式,如下所示:

jshell> DateTimeFormatter withSeconds =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss")
withSeconds ==> Value(ClockHou ... OfMinute,2)

jshell> DateTimeFormatter military =
   ...> DateTimeFormatter.ofPattern("HHmm")
military ==> Value(HourOfDay,2)Value(MinuteOfHour,2)

jshell> LocalTime t = LocalTime.now()
t ==> 09:17:34.356758

jshell> withSeconds.format(t)
$7 ==> "09:17:34"

jshell> military.format(t)
$8 ==> "0917"

您可以使用从 Table 8-4 中显示的部分构建任何日期或时间模式,以生成这种格式化的输出。进入jshell并尝试创建几个格式。您可以使用LocalTime.now()LocalDate.now()方法创建一些易于格式化测试的目标。

时间戳

java.time理解的另一个流行日期时间概念是时间戳。在任何需要跟踪信息流的情况下,您都需要记录信息生成或修改的确切时间。您仍然会看到java.util.Date类用于存储这些时间点,但java.time.Instant类提供了生成时间戳所需的一切,同时还具备java.time包中其他类的所有其他优势:

jshell> Instant time1 = Instant.now()
time1 ==> 2019-12-14T15:38:29.033954Z

jshell> Instant time2 = Instant.now()
time2 ==> 2019-12-14T15:38:46.095633Z

jshell> time1.isAfter(time2)
$54 ==> false

jshell> time1.plus(3, ChronoUnit.DAYS)
$55 ==> 2019-12-17T15:38:29.033954Z

如果您的工作中涉及日期或时间,java.time包将是一个非常有用的助手。 您有一套成熟、设计良好的工具,用于处理这些数据——无需第三方库!

其他有用的工具

我们已经查看了 Java 的一些构建块,包括字符串和数字,以及其中一个最受欢迎的组合——日期——在LocalDateLocalTime类中。 我们希望这些实用程序的范围为您展示了 Java 如何处理您可能会遇到的许多元素。

请务必阅读关于java.utiljava.textjava.time包的文档,以了解更多可能有用的工具。 例如,您可以查看使用java.util.Random生成图 8-1 中树木随机坐标的方法。 有时,“实用程序”工作实际上是复杂的,并需要仔细的细节注意。 在线搜索其他开发人员编写的代码示例甚至完整库可能加快您的工作进度。

接下来,我们将开始构建这些基本概念。 Java 之所以如此受欢迎,是因为它除了包含基础支持外,还包括更高级技术的支持。 其中之一是“线程”功能,它们已经内置。 线程提供了更好的访问现代强大系统的方式,即使处理许多复杂任务,您的应用程序也能保持高效。 我们将向您展示如何利用这一标志性特性在第九章中。

复习问题

  1. 哪个类包含常量π? 需要导入该类以使用π吗?

  2. 哪个包含了用于替代原java.util.Date类的更好的替代品?

  3. 用于格式化日期以便用户友好输出的类应该是哪个?

  4. 您会在正则表达式中使用什么符号来帮助匹配“yes”和“yup”这两个单词?

  5. 如何将字符串“42”转换为整数 42?

  6. 如何比较两个字符串(例如“yes”和“YES”),以忽略任何大写?

  7. 哪个运算符用于连接字符串?

代码练习

让我们重新审视我们的图形化 Hello Java 应用程序,并使用本章讨论的一些新实用程序和字符串功能进行升级。 您可以从exercises/ch08文件夹中的HelloChapter8类开始。 我们希望程序支持一些用于消息和初始位置的命令行参数。

您的程序应接受 0、1 或 2 个参数:

  • 零参数应该将文本“Hello, utilities!”居中开始。

  • 一个参数应该被视为要显示的消息;应该居中开始:

    • 请记住,多个单词的消息必须用引号括起来。

    • 如果消息是单词today,您的代码应生成一个格式化日期以用作消息。

  • 两个参数分别表示消息和初始坐标以确定显示位置:

    • 坐标应为带有逗号和可选空格分隔的一对数字的引用字符串。以下都是有效的坐标字符串:

      • 150,150

      • 50, 50

      • 100, 220

    • 坐标参数也可以是单词random,意味着您的代码应生成一个随机的初始位置。

以下是一些示例:

$ java HelloChapter8
// "Hello, utilities!" centered in the window
$ java HelloChapter8 "It works!"
// "It works!" centered in the window
$ java HelloChapter8 "I feel cornered" "20,20"
// "I feel cornered" in the upper left corner

如果用户尝试传递三个或更多参数,您的代码应生成错误消息并退出。

从测试参数数量开始。如果您的程序至少获得一个参数,请使用第一个参数作为消息。如果获得两个参数,您需要拆分坐标并将其转换为数字。如果您获得random参数,请确保生成的随机数将使消息保持可见。(您可以假设消息的默认长度是合理的;如果更长的消息右侧被截断,这是可以接受的。)

使用几次运行测试您的解决方案。尝试不同的坐标。尝试随机化选项。连续几次尝试随机化选项以确保起始位置确实改变。如果在第二个参数中拼写random错误会发生什么?

对于进一步的升级:尝试编写一个正则表达式来接受一些random的变体,同时忽略大小写:

  • random

  • rand

  • rndm

  • r

始终可以在附录 B 中找到关于这个问题的一些提示。我们的解决方案位于源代码的ch08/exercises文件夹中。

¹ 当存在疑问时,请测量它!如果您的String操作代码干净且易于理解,请不要重写它,直到有人向您证明它速度太慢。有可能他们是错误的。不要被相对比较所愚弄。毫秒比微秒慢一千倍,但对于您应用程序的整体性能可能是可以忽略不计的。

² 在大多数平台上,默认的编码是 UTF-8。您可以在官方 Javadoc 文档中获取有关 Java 支持的字符集、默认集和标准集的更多详细信息。

³ 验证电子邮件地址比我们在这里能够解决的要困难得多。正则表达式可以涵盖大多数有效的地址,但如果您正在为商业或其他专业应用程序进行验证,您可能希望调查第三方库,例如来自Apache Commons的库。

⁴ 如果您手头有几十万美元,欢迎申请您自己的定制全球顶级域名

float 类型是“单精度”的,而 double 类型则是“双精度”的。(因此得名!)double 类型可以保留大约两倍于 float 类型的精度。任意精度意味着你可以在小数点前后拥有需要的任意位数的数字。公平地说,NASA 使用的 π 值精确到 15 位小数,这对 double 类型来说处理得很好。

第九章:线程

我们理所当然地认为现代计算机系统可以同时管理许多应用程序和操作系统(OS)任务,并使所有软件看起来同时运行。今天大多数系统都配备了多个处理器或多个核心,有了这些,它们可以实现令人印象深刻的并发度。操作系统仍然在更高层次上调度应用程序,但是它的注意力转向下一个应用程序的速度如此之快,以至于它们也看起来在同时运行。

注意

在编程中,并发操作表示多个通常不相关的任务同时运行。想象一下,一个快餐厨师在烤架上同时准备多份订单。并行操作通常涉及将一个大任务分解为相关的子任务,这些子任务可以并行运行以更快地产生最终结果。我们的厨师可以通过同时在烤架上放置两个肉饼和一些培根来“并行”准备一份双层芝士汉堡。无论哪种情况,程序员通常更广泛地讨论这些任务和子任务同时发生的情况。这并不意味着一切都在完全相同的瞬间开始和停止,但确实意味着这些任务的执行时间是重叠的。

在旧时,操作系统的并发单位是应用程序或进程。对于操作系统来说,一个进程或多或少是一个自行决定要做什么的黑盒子。如果一个应用程序需要更高的并发性,它只能通过运行多个进程并在它们之间进行通信来实现,但这是一种笨重的方法,不太优雅。

后来,操作系统引入了线程的概念。从概念上讲,线程是程序内的控制流。(例如,你可能听说过“执行线程”)线程在应用程序自己的控制下提供了细粒度的并发性。线程已经存在很长时间,但历来使用起来比较棘手。Java 并发工具集解决了多线程应用程序中的常见模式和实践,并将它们提升到了可操作的方法和类的级别。总体来说,这意味着 Java 在更高和更低的层次上都支持线程。

这种广泛的支持使得程序员更容易编写多线程代码,并且使编译器和运行时可以对该代码进行优化。这也意味着 Java 的 API 充分利用了线程,因此在探索 Java 的早期阶段,熟悉这些概念至关重要。并非所有开发人员都需要编写明确使用线程或并发性的应用程序,但大多数人会使用涉及它们的某些功能。

线程在许多 Java API 的设计中起着重要作用,特别是那些涉及客户端应用程序、图形和声音的部分。例如,在我们看 GUI 编程时的第十二章,你会看到组件的paint()方法不是直接由应用程序调用,而是由 Java 运行时系统内的一个单独的绘图线程调用。在任何给定时间,许多这样的后台线程可能会在你的应用程序旁边执行活动,但你仍然会及时更新屏幕。在服务器端,Java 线程同样存在,为每个请求提供服务并运行你的应用程序。了解你的代码如何适应这种环境至关重要。

在本章中,我们将讨论编写显式创建和使用自己线程的应用程序。我们将首先讨论集成到 Java 语言中的低级线程支持,然后讨论java.util.concurrent线程实用工具包。我们还将讨论在 Java 19 中预览的新虚拟线程,项目称为 Project Loom。

引入线程

线程类似于进程或正在运行的程序的概念,不同的是,同一应用程序中的不同线程比同一台机器上运行的不同程序更密切相关,并且共享大部分相同的状态。这有点像许多高尔夫球手同时使用的高尔夫球场。线程协作以共享工作区域。它们轮流等待其他线程。它们可以访问相同的对象,包括其应用程序内的静态和实例变量。但是,线程拥有其自己的局部变量副本,就像球员共享高尔夫球场或高尔夫球车但不共享球棒或球一样。

应用程序中的多个线程面临与球场上高尔夫球手相同的问题,简言之,同步。就像不能同时有两组球员在同一绿地上打球一样,不能有多个线程尝试在没有某种协调的情况下访问相同的变量。否则,某些人可能会受伤。线程可以保留使用对象的权利,直到完成其任务,就像高尔夫聚会在每个球员完成比赛之前独占绿地。更重要的线程可以提高其优先级,断言其“优先通过”的权利。

当然,细节决定成败,长久以来,线程细节使其难以使用。幸运的是,Java 通过直接将一些这些概念集成到语言中,使创建、控制和协调线程变得更简单。

当你第一次使用线程时,很容易会遇到困难。创建一个线程将同时练习你的新 Java 技能。只要记住在运行线程时始终涉及两个主要角色:一个 Java Thread对象代表线程本身,以及一个包含线程将执行的方法的任意目标对象。稍后,我们将看到如何结合这两个角色,但这些方法只是改变了封装,而不是改变了它们的关系。

线程类和可运行接口

在 Java 中,所有的执行都与一个Thread对象相关联,从 JVM 启动的“主”线程开始,用于启动你的应用程序。当你创建java.lang.Thread类的一个实例时,就会诞生一个新的线程。Thread对象表示 Java 解释器中的一个真实线程,并作为控制和协调其执行的句柄。通过它,你可以启动线程,等待它完成,使其休眠一段时间,或者中断其活动。

Thread类的构造函数接受关于线程应该从哪里开始执行的信息。我们想告诉它要运行哪个方法。有很多方法可以做到这一点。经典的方法使用java.lang.Runnable接口来标记包含“可运行”方法的对象。

Runnable定义了一个单一的通用run()方法:

  public interface Runnable {
    abstract public void run();
  }

每个线程都通过执行run()方法来启动其生命周期,该方法位于一个Runnable对象中,这个对象是传递给线程构造函数的“目标对象”。run()方法可以包含任何代码,但必须是公共的,不接受任何参数,没有返回值,并且不会抛出已检查异常。

任何包含适当run()方法的类都可以声明实现Runnable接口。该类的一个实例成为一个可运行对象,可以作为新线程的目标。如果你不想直接将run()方法放在你的对象中(很多时候确实不想这样做),你总是可以创建一个作为你的Runnable的适配器类。适配器的run()方法可以在线程启动后调用任何它想要的方法。稍后我们会展示这些选项的示例。

创建和启动线程

一个新生的线程保持空闲,直到我们通过调用其start()方法来唤醒它。线程随后醒来并继续执行其目标对象的run()方法。start()方法在线程的生命周期中只能调用一次。一旦线程启动,它会继续运行,直到目标对象的run()方法返回或抛出未检查异常。

下面的类Animator实现了一个run()方法来驱动绘图循环。我们可以在游戏中类似地使用它来更新游戏场地:

class Animator implements Runnable {
  boolean animating = true;

  public void run() {
    while (animating) {
      // move active apples one "frame"
      // repaint the field
      // pause
      // ...
    }
  }
}

要使用它,创建一个Thread对象,将一个Animator的实例作为其目标对象传递给它,并调用其start()方法:

    Animator myAnimator = new Animator();
    Thread myThread = new Thread(myAnimator);
    myThread.start();

我们创建了 Animator 类的一个实例,并将其作为参数传递给 myThread 的构造函数。正如在 Figure 9-1 中所示,当我们调用 start() 方法时,myThread 开始执行 Animatorrun() 方法。

ljv6 0901

图 9-1. 作为 Runnable 实现的动画师

让表演开始!

天生的线程

Runnable 接口允许你将任意对象作为线程的目标,就像前面的例子一样。这是 Thread 类最重要的通用用法。在大多数需要使用线程的情况下,你会创建一个类(可能是一个简单的适配器类),该类实现了 Runnable 接口。

另一种创建线程的设计选项是使我们的目标类成为已经可运行的类型的子类。事实证明,Thread 类本身方便地实现了 Runnable 接口;它有自己的 run() 方法,我们可以直接重写它来完成我们的任务:

class Animator extends Thread {
  boolean animating = true;

  public void run() {
    while (animating) {
      // draw Frames
      // do other stuff ...
    }
  }
}

我们的 Animator 类的骨架看起来与之前大致相同,只是现在我们的类是 Thread 的子类。为了配合这个方案,Thread 类的默认构造函数使自己成为默认目标——也就是说,默认情况下,当我们调用 start() 方法时,Thread 执行它自己的 run() 方法,正如 Figure 9-2 中所示。现在我们的子类可以简单地重写 Thread 类中的 run() 方法。(Thread 本身定义了一个空的 run() 方法。)

ljv6 0902

图 9-2. 作为 Thread 子类的动画师

接下来,我们创建了 Animator 的一个实例,并调用了它的 start() 方法(它也继承自 Thread):

    Animator bouncy = new Animator();
    bouncy.start();

扩展 Thread 看起来可能是打包线程及其目标 run() 方法的便利方式。然而,这种方法通常不是最佳设计。如果你扩展 Thread 来实现一个线程,那么你是在说你需要一个新类型的对象,它是 Thread 的一种,公开 Thread 类的所有公共方法。虽然将一个主要关注执行任务的对象变成 Thread 有一种满足感,但实际情况下,你需要创建 Thread 子类的情况应该并不常见。在大多数情况下,更自然的做法是让程序的需求决定类结构,并使用 Runnable 来连接程序的执行和逻辑。

控制线程

现在你已经看到了使用 start() 方法来开始执行一个新线程,让我们来看看在运行时显式控制线程行为的实例方法:

Thread.sleep() 方法

使当前正在执行的线程等待指定的一段时间(多多少少),而不消耗太多(或可能根本没有)CPU 时间。

wait()join() 方法

协调两个或多个线程的执行。在本章后面讨论线程同步时,我们将详细讨论它们。

interrupt()方法

唤醒正在执行sleep()wait()操作中的线程,或者正在长时间 I/O 操作中被阻塞的线程。¹

弃用的方法

我们还应该提到三个已弃用的线程控制方法:stop()suspend()resume()stop()方法与start()方法相辅相成;它销毁线程。start()方法和已弃用的stop()方法只能在线程生命周期中调用一次。相比之下,已弃用的suspend()resume()方法会任意暂停然后重新启动线程的执行。

尽管这些弃用的方法仍然存在于最新版本的 Java 中(而且可能会永远存在),但不应在新代码开发中使用。stop()suspend()方法的问题在于它们以不协调和粗暴的方式控制线程的执行。

您可以创建并监视一些变量作为影响线程执行的更好方式(如果这些变量是boolean类型,您可能会看到它们被称为“标志”)。本书中早期的线程示例在某种程度上使用了这种技术。后续示例将介绍并发类可用的其他一些控制特性。

sleep()方法

程序员通常需要告诉一个线程在一段时间内保持空闲或“睡眠”。例如,您可能需要等待某些外部资源变为可用。即使是我们简单的动画线程在帧之间也会有小的暂停。当一个线程在睡眠或其他方式被某种形式的输入阻塞时,它不会消耗 CPU 时间或与其他线程竞争处理。对于这样的暂停,我们可以调用静态方法Thread.sleep(),它会影响当前执行的线程。调用使得线程空闲指定的毫秒数:

    try {
      // The current thread
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      // someone woke us up prematurely
    }

如果sleep()方法被interrupt()方法中断,可能会抛出InterruptedException异常(详见下文)。正如您在前面的代码中看到的那样,线程可以捕获此异常并利用这个机会执行某些操作——比如检查一个变量来确定线程是否应该退出——然后继续睡眠。

join()wait()notify()方法

如果需要通过等待另一个线程完成其任务来协调线程的活动,可以使用join()方法。调用线程的join()方法会导致该线程阻塞,直到目标线程完成为止。或者,可以将join()方法与等待的毫秒数作为参数一起调用。在这种形式下,调用线程会等待直到目标线程完成或指定的时间段过去。这是一种非常粗糙的线程同步方式。

如果您需要将线程的活动与其他资源协调起来,例如检查文件或网络连接的状态,则可以使用wait()notify()方法。在线程上调用wait()将暂停它,类似于使用join(),但它会保持暂停状态,直到另一个线程通过interrupt()中断它,或者您自己在线程上调用notify()

Java 支持更一般和更强大的机制来协调线程活动,位于java.util.concurrent包中。我们将在本章后面向您展示此包的更多内容。

interrupt() 方法

interrupt() 方法基本上做了它说的事情。它中断了线程的正常执行流程。如果该线程在sleep()wait()或耗时的 I/O 操作中空闲,则会唤醒。当中断线程时,其中断状态标志将被设置。您可以使用isInterrupted()方法测试此标志。您还可以使用一个替代形式isInterrupted(boolean)来指示您是否希望线程在检索当前值后清除其中断状态。

尽管您可能不经常使用interrupt(),但它绝对会派上用场。如果您曾经因桌面应用程序试图连接到服务器或数据库而变得不耐烦,但却失败了,那么您就经历过其中一个可能需要中断的时刻。

让我们通过一个小型图形应用程序模拟这种情况。我们将在屏幕上显示一个标签,并将其移动到每五秒钟的新位置。在那五秒的暂停期间,屏幕上的任何位置的点击都会中断暂停。我们将更改消息,然后再次启动随机移动周期。您可以从ch09/examples/Interruption.java运行完整的示例,但图 9-3 突出了调用interrupt()的流程和效果。

ljv6 0903

图 9-3. 中断线程

重温线程动画

管理动画是图形界面中的常见任务。有时动画是微妙的过渡;其他时候,它们是应用程序的焦点,就像我们的苹果投掷游戏一样。我们将介绍处理动画的两种方式:在sleep()函数旁边使用简单的线程,以及使用计时器。将其中一种选项与某种类型的步进或“下一帧”函数配对是一种流行且易于理解的方法。

你可以使用类似于“创建和启动线程”的线程来生成真实的动画。基本思想是绘制或定位所有动画对象,暂停,将它们移动到它们的下一个位置,然后重复。让我们首先看看如何在没有动画的情况下绘制游戏场地的一些部分。除了现有的树木和篱笆列表外,我们还将为任何活跃的苹果添加一个新的List。您可以从ch09/examples/game文件夹中的编辑器中检索此代码:

// From the Field class...
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    physicist.draw(g);
    for (Tree t : trees) { t.draw(g); }
    for (Hedge h : hedges) { h.draw(g); }
    physicist.draw(g);
    for (Apple a : apples) { a.draw(g); }
  }

// And from the Apple class...
  public void draw(Graphics g) {
    // Make sure our apple will be red, then paint it!
    g.setColor(Color.RED);
    g.fillOval(x, y, scaledLength, scaledLength);
  }

我们首先绘制背景场地,然后是树木和篱笆,然后是我们的物理学家,最后是任何苹果。最后绘制苹果可以确保它们显示在其他元素的上方。

当您玩游戏时屏幕上发生了什么变化?真正可以移动的只有两个“可移动”物品:我们的物理学家瞄准的苹果,以及被投掷后正在飞行的任何苹果。物理学家响应用户输入(通过移动鼠标或单击按钮)来瞄准,这不需要单独的动画,所以我们将在第十二章中添加这个功能。现在,我们可以专注于处理飞行的苹果。

我们游戏的动画步骤应该根据重力规则移动每个处于活跃状态的苹果。首先,在我们的Apple类中添加一个toss()方法,我们可以使用来自我们物理学家的信息设置苹果的初始条件(因为物理学家目前还没有交互,我们将伪造一些数据)。然后,在step()方法中为给定的苹果进行一次移动:

// File: ch09/examples/game/Apple.java

  // Parameters provided by the physicist
  public void toss(float angle, float velocity) {
    lastStep = System.currentTimeMillis();
    double radians = angle / 180 * Math.PI;
    velocityX = (float)(velocity * Math.cos(radians) / mass);
    // Start negative since "up" means smaller values of y
    velocityY = (float)(-velocity * Math.sin(radians) / mass);
  }

  public void step() {
    // Make sure the apple is still moving
    // using our lastStep tracker as a sentinel
    if (lastStep > 0) {
      // Apply the law of gravity to the apple's vertical position
      long now = System.currentTimeMillis();
      float slice = (now - lastStep) / 1000.0f;
      velocityY = velocityY + (slice * Field.GRAVITY);
      int newX = (int)(centerX + velocityX);
      int newY = (int)(centerY + velocityY);
      setPosition(newX, newY);
    }
  }

我们首先在toss()方法中计算苹果将移动的速度(velocityXvelocityY变量)。在我们的step()方法中,根据这两个速度更新苹果的位置,然后根据重力强度调整垂直速度。这不是很花哨,但它将为苹果产生一个不错的弧线。然后我们将该代码放入一个循环中,该循环将执行更新计算、重绘场地和苹果、暂停并重复:

// File: ch09/examples/game/Field.java

// duration of an animation frame in milliseconds
public static final int STEP = 40;

// ...
// A simple inner class with our run() method
class Animator implements Runnable {
  public void run() {
    // "animating" is a global variable that allows us
    // to stop animating and conserve resources
    // if there are no active apples to move
    while (animating) {
      System.out.println("Stepping " + apples.size() +
          " apples");
      for (Apple a : apples) {
        a.step();
      }
      // Reach back to our outer class instance to repaint
      Field.this.repaint();
      // And get rid of any apples on the ground
      cullFallenApples();
      try {
        Thread.sleep(STEP);
      } catch (InterruptedException ie) {
        System.err.println("Animation interrupted");
        animating = false;
      }
    }
  }
}

我们将在一个简单的线程中使用这个Runnable的实现。我们的Field类将保留一个线程的实例,并包含以下简单的start方法:

// File: ch09/examples/game/Field.java
  Thread animationThread;

  // other state and methods ...

  void startAnimation() {
    animationThread = new Thread(new Animator());
    animationThread.start();
  }

我们将在“事件”中讨论事件;您将使用这些事件来命令启动一个苹果。现在,我们将自动启动一个苹果,如图 9-4 所示。

ljv6 0904

图 9-4:动作中的可抛苹果

它作为静止截图看起来并不起眼,但实际上却令人惊叹。 ;^)

线程的终结

一切美好的事情都有结束的时候。一个线程会一直执行,直到发生以下三种情况之一:

  • 它明确地从其目标run()方法返回。

  • 它遇到了一个未捕获的运行时异常。

  • 讨厌的、已弃用的stop()方法被调用。

如果这些情况都不发生,线程的run()方法永远不会终止会发生什么?即使创建它的代码已经完成,线程仍然可以继续存在。您必须了解线程如何最终终止,否则您的应用程序可能会继续运行未必要地消耗资源,甚至在本应退出时仍保持应用程序的生存。

在许多情况下,你需要在应用程序中执行简单周期任务的后台线程。你可以使用 setDaemon() 方法创建其中一个后台工作者,并将线程标记为守护线程。守护线程可以像其他线程一样终止,但如果启动它们的应用程序正在退出,当没有其他非守护应用程序线程存在时,它们应该被终止和丢弃。² 通常,Java 解释器会继续运行直到所有线程完成。但是当只有守护线程仍然存活时,解释器将退出。

这是一个使用守护线程的“恶魔式”大纲:

class Devil extends Thread {
  Devil() {
    setDaemon(true);
    start();
  }
  public void run() {
    // perform some tasks
  }
}

在这个例子中,Devil 线程在创建时设置了它的守护进程状态。如果我们的应用程序在其他方面完成后仍有任何 Devil 线程存在,运行时系统会为我们终止它们。我们不需要担心清理它们。

关于优雅终止线程的最后一点说明。新的开发人员在第一次使用图形 Swing 组件创建应用程序时经常遇到一个常见问题:他们的应用程序永远不会退出。在一切都完成并且应用程序窗口关闭之后,Java VM 似乎会无限期地挂起。Java 创建一个 UI 线程来处理输入和绘画事件。这个 UI 线程不是守护线程,因此当其他应用程序线程完成时,它不会自动退出。开发人员必须显式调用 System.exit()。如果你想想的话,这是有道理的。因为大多数 GUI 应用程序是事件驱动的并且等待用户输入,否则它们会在启动代码完成后退出。

虚拟线程

在 Java 19 中预览,并在 Java 21 中最终确定,Project Loom³ 为 Java 带来了轻量级虚拟线程。Project Loom 的主要目标之一是改进 Java 中的线程生态系统,使开发人员可以花更少的精力来保持多线程应用程序的稳定性,更多地解决高层次的问题。

预览特性插曲

“在 Java 19 中预览”的意思是什么?从 Java 12 开始,Oracle 开始引入一些语言特性作为预览。这些预览特性有明确的规范并且完全实现,但并不完全成熟。Oracle 可能在未来的版本中做出重大修改。最终,这些特性要么成为 JDK 的永久部分,要么被移除。Oracle 为每个新的 Java 发布版本制作语言更新页面,其中包含对语言最近更改的良好历史以及预览特性概述

因为任何给定的预览特性最终可能被从 Java 中删除,Oracle 要求你在编译或运行使用它的应用程序时包含特殊标志。这个要求是一个小的防护栏,以确保你不会意外使用可能在将来的 Java 版本中不起作用的代码。

配置 IDE 以使用预览特性

如果您使用 IDE 进行演示和练习,可能需要配置它以支持预览功能。例如,IntelliJ IDEA 默认情况下不支持预览功能。您需要在“File → Project Structure”对话框中更改设置,如图 9-5 所示。

ljv6 0905

图 9-5. 在 IntelliJ IDEA 中启用 Java 的预览功能

在 SDK 下拉菜单中选择您想要的 Java 版本后,您可以通过在“Language level”下拉菜单中选择适当选项来启用预览功能支持。(IDEA 列出的特性与版本号旁边的特性不是详尽列表。)设置语言级别后,单击“OK”,IDEA 应该已准备好编译和运行任何具有预览功能的代码。

重命名预览源文件

VirtualDemo 类(ch09/examples/VirtualDemo.java.preview)使用虚拟线程在发出我们最喜欢的“Hello Java”问候之前暂停片刻。在您可以编译或运行它之前,您需要将其重命名。我们在包含代码中的任何预览功能的文件上添加了 .preview 后缀。该后缀阻止像 IntelliJ IDEA 这样的 IDE 在您配置预览支持之前积极地编译它们,就像我们在前一节中提到的那样。

您可以在 IntelliJ IDEA 中使用上下文(右键单击)菜单,在“重构”菜单项下重命名文件。您还可以在 Linux 或 macOS 终端中使用 mv 命令快速重命名文件:

% cd ch09/examples
% mv VirtualDemo.java.preview VirtualDemo.java
% cd ../..

在 Windows 终端或命令提示符中,您可以使用 rename 命令:

C:\> cd ch09\examples
C:\> rename VirtualDemo.java.preview VirtualDemo.java
C:\> cd ..\..

编译具有预览功能的类

Oracle 为使用预览功能编译代码添加了一对命令行选项。例如,如果您尝试使用 Java 19 编译我们的 VirtualDemo 源文件,您可能会看到类似于这样的错误:

% javac --version
javac 19.0.1

% javac VirtualDemo.java
VirtualDemo.java:4: error: startVirtualThread(Runnable)
 is a preview API and is disabled by default.
    Thread thread = Thread.startVirtualThread(runnable);
                          ^
  (use --enable-preview to enable preview APIs)

错误为我们提供了一个关于我们应该如何继续的提示。让我们尝试添加建议的标志并再次编译:

% javac --enable-preview VirtualDemo.java
error: --enable-preview must be used with either -source or --release

糟糕!又是另一个不同的错误。至少它也包含了一些提示。要编译,您需要提供两个标志:--enable-preview,然后是 -source--release。⁴ 编译器使用 -source 来指定适用于正在编译的源代码的语言规则。(编译后的字节码仍针对与您的 JDK 相同版本的 Java。)您可以使用 --release 选项来同时指定源版本和字节码版本。

虽然有许多场景可能需要为旧系统编译,但预览功能是用于当前 JDK 版本的。因此,当我们在书中使用任何预览功能时,我们将使用 --enable-preview--release 并简单地给出与我们的 Java 版本相同的发布版本号。例如,返回到我们的虚拟线程预览功能,我们可以使用 Java 19 来尝试它。我们最终的正确 javac 调用如下所示:

% javac --version
javac 19.0.1

% javac --enable-preview --release 19 VirtualDemo.java
Note: VirtualDemo.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.

"notes"这个名词在编译完成后出现,纯粹是提供信息。它们提醒您的代码依赖于未来可能不再可用的不稳定功能。这些注释并非意在劝阻您使用这些功能,但如果您计划与其他用户或开发者共享代码,则需要记住一些额外的兼容性约束。

如果您好奇,您还可以使用注释中提到的-Xlint:preview选项来查看到底是哪些预览代码引起了警告:

src$ javac --enable-preview --release 19 -Xlint:preview VirtualDemo.java
VirtualDemo.java:4: warning: [preview] startVirtualThread(Runnable)
 is a preview API and may be removed in a future release.
    Thread thread = Thread.startVirtualThread(runnable);
                          ^
1 warning

没有什么意外的,但话说回来,这只是一个微小的演示程序。对于更大的程序或团队开发的代码来说,那额外的-Xlint:preview标志就非常方便了。

运行预览类文件

运行包含预览功能的 Java 类也需要--enable-preview标志。如果您尝试像运行任何其他类一样使用 Java 19 运行VirtualDemo,您将收到如下错误:

% java VirtualDemo
Error: LinkageError occurred while loading main class VirtualDemo
        java.lang.UnsupportedClassVersionError: Preview features are not
        enabled for VirtualDemo (class file version 63.65535).
        Try running with '--enable-preview'

再次,您可以使用错误中提到的--enable-preview标志,然后您可以开始:

% java --enable-preview VirtualDemo
Hello virtual thread! ID: 20

如果您想在jshell中尝试预览功能,也可以提供相同的--enable-preview标志:

% jshell --enable-preview

包含该标志将允许 Java 19 的jshell会话使用虚拟线程,就像它允许我们运行上面的演示程序一样。

快速比较

Loom 团队设计了它的虚拟线程,以便在您已经具有一些 Java 线程技能的情况下易于使用。让我们重新设计我们用来测试--enable-preview标志的微不足道的线程示例,以展示两种类型的线程:

public class VirtualDemo2 {
  public static void main(String args[]) throws Exception {
    Runnable runnable = new Runnable() {
      public void run() {
        Thread t = Thread.currentThread();
        System.out.println("Hello thread! " +
            (t.isVirtual() ? "virtual " : "platform ") +
            "ID: " + t.threadId());
      }
    };
    Thread thread1 = new Thread(runnable);
    thread1.start();
    Thread thread2 = Thread.startVirtualThread(runnable);
    thread1.join();
    thread2.join();
  }
}

在这次重新编排中,我们扩展了我们的匿名Runnable内部类,对当前线程进行了一些侦查。我们打印出线程的标识号以及它是否是虚拟线程。但请看启动这两个线程的行代码多么相似(以及简单):它们都接受我们的runnable对象并“适合”在Thread类中。对于已有代码的开发者来说,切换到使用这些虚拟线程应该很简单。编译并运行后的输出如下(当然要使用适当的预览标志):

$ javac --enable-preview --source 19 VirtualDemo2.java
Note: VirtualDemo.java uses preview features of Java SE 19.
Note: Recompile with -Xlint:preview for details.

$ java --enable-preview VirtualDemo2
Hello thread! virtual ID: 21
Hello thread! platform ID: 20

两个线程都如预期地运行。一个线程确实报告自己是虚拟线程。我们使用术语平台来描述另一个线程,因为这是 Oracle 文档称呼它们的方式。平台线程表示与操作系统(平台)提供的本机线程之间的直接一对一关系。另一方面,虚拟线程与来自操作系统的本机线程之间有间接的多对一关系,如图 9-6 所示。

ljv6 0906

图 9-6。平台线程和虚拟线程在映射到本地线程时存在差异

这种分离是虚拟线程的关键设计特征之一。它允许 Java 同时运行许多(许多!)线程,而无需创建和管理相应的本机线程的性能成本。虚拟线程被设计为创建廉价且一旦运行起来非常高效。

同步

每个线程都有自己的思路。通常,线程在不考虑应用程序中其他线程的情况下进行操作。线程可以被时间片,这意味着它们可以按照操作系统的指示以任意的突发方式运行。在多处理器或多核系统上,许多不同的线程甚至可以同时在不同的 CPU 上运行。本节讨论协调两个或多个线程的活动,以便它们可以共同使用相同的变量和方法(而不会发生冲突,就像高尔夫球场上的球员一样)。

Java 提供了一些简单的结构来同步线程的活动。它们都基于监视器的概念,这是一种广泛使用的同步方案。您不必了解监视器的工作细节即可使用它们,但牢记图 9-7 可能会对您有所帮助。

ljv6 0907

图 9-7. 使用监视器同步访问

监视器本质上是一把锁。这把锁附加在一个资源上,许多线程可能需要访问该资源,但只能一次由一个线程访问。这很像带锁的卫生间门;如果门没锁,你可以进去,使用时把门锁上。如果资源未被使用,线程可以获取锁并访问资源。当线程完成时,它释放锁,就像你打开卫生间门留给下一个人(或线程)一样。

然而,如果另一个线程已经获取了资源的锁,则所有其他线程必须等待,直到当前线程完成并释放锁。这就像当你到达时卫生间已被占用一样:你必须等到当前用户完成并解锁门。

Java 让资源访问同步变得相当容易。语言处理设置和获取锁;你只需指定需要同步的资源即可。

方法访问序列化

在 Java 中,同步线程的最常见原因是将它们对资源(如对象或变量)的访问序列化,换句话说,确保一次只有一个线程可以操作该对象。⁵ 在 Java 中,每个类和类的每个实例都有自己的锁。synchronized关键字标记了线程必须在继续之前获取锁的地方。

比如,假设我们实现了一个包含say()方法的SpeechSynthesizer类。我们不希望多个线程同时调用say(),因为我们将无法理解合成器说的内容。因此,我们将say()方法标记为synchronized,这意味着线程必须在发声之前获取SpeechSynthesizer对象上的锁:

class SpeechSynthesizer {
  synchronized void say(String words) {
    // speak the supplied words
  }
}

say()完成后,调用线程释放锁,这允许下一个等待的线程获取锁并运行方法。无论线程是属于SpeechSynthesizer本身还是其他对象,都必须从SpeechSynthesizer实例获取相同的锁。如果say()是类(静态)方法而不是实例方法,我们仍然可以将其标记为synchronized。在这种情况下,因为没有涉及实例对象,所以锁在SpeechSynthesizer类对象本身上。

经常情况下,您希望同步同一类的多个方法,以便一次只有一个方法修改或检查类中的数据。类中的所有静态同步方法都使用相同的类对象锁。同样,类中的所有实例方法都使用相同的实例对象锁。这保证了一次只有一组同步方法在运行。例如,一个SpreadSheet类可能包含表示单元格值的多个实例变量,以及一些操作整行单元格的方法:

class SpreadSheet {
  int cellA1, cellA2, cellA3;

  synchronized int sumRow() {
    return cellA1 + cellA2 + cellA3;
  }

  synchronized void setRow(int a1, int a2, int a3) {
    cellA1 = a1;
    cellA2 = a2;
    cellA3 = a3;
  }
  // other spreadsheet stuff ...
}

方法setRow()sumRow()都访问单元格值。您可以看到,如果一个线程在setRow()中更改变量的值的同时,另一个线程在sumRow()中读取值,可能会出现问题。为了防止这种情况发生,我们将这两个方法都标记为synchronized。当线程遇到同步资源时,只有一个线程运行。如果一个线程在执行setRow()时,另一个线程试图调用sumRow(),那么第二个线程必须等到第一个线程完成setRow()的执行,然后才能运行sumRow()。这种同步允许我们保持SpreadSheet的一致性。最好的部分是,所有这些锁定和等待都由 Java 处理;对程序员来说是不可见的。

除了同步整个方法外,synchronized关键字还可以用在特殊结构中,以保护方法内部的较小代码块。在这种形式下,它还需要一个明确的参数,指定要获取哪个对象的锁:

    synchronized (myObject) {
      // Functionality that needs exclusive access to resources
    }

这个同步块可以出现在任何方法中。当一个线程到达它时,线程必须在继续之前获取myObject上的锁。通过这种方式,我们可以像同一类中的方法那样同步不同类中的方法(或方法的部分)。

这意味着同步实例方法等效于在当前对象上同步其语句的方法:

  synchronized void myMethod () {
    // method body
  }

等价于:

  void myMethod () {
    synchronized (this) {
      // method body
    }
  }

我们可以用经典的“生产者/消费者”场景来演示同步的基本原理。假设我们有一些生产者创建新的资源,而消费者获取并使用这些相同的资源:例如一系列网络爬虫收集在线图片。这里的“生产者”可以是一个或多个线程,实际上加载和解析网页以查找图像及其 URL。我们可以让它将这些 URL 放入一个共享的队列中。“消费者”线程会从队列中获取下一个 URL,并将图像下载到文件系统或数据库中。我们不会在这里尝试进行所有的实际 I/O 操作(有关 URL 和网络的更多信息请参见第十三章),但让我们设置一些生产和消费线程来展示同步如何工作。

同步队列中的 URL

让我们先看看用于存储 URL 的队列。它只是一个列表,我们可以将 URL(作为字符串)追加到末尾并从前面取出。我们将使用LinkedList,类似于我们在第七章中看到的ArrayList。我们需要一个设计用于高效访问和操作的结构:

package ch09.examples;

import java.util.LinkedList;

public class URLQueue {
  LinkedList<String> urlQueue = new LinkedList<>();

  public synchronized void addURL(String url) {
    urlQueue.add(url);
  }

  public synchronized String getURL() {
    if (!urlQueue.isEmpty()) {
      return urlQueue.removeFirst();
    }
    return null;
  }

  public boolean isEmpty() {
    return urlQueue.isEmpty();
  }
}

请注意,并非每个方法都是同步的!任何线程都可以询问队列是否为空,而不会阻塞可能正在添加或删除项目的其他线程。这意味着isEmpty()可能会报告错误的答案 —— 如果不同线程的时间恰好不对。幸运的是,我们的系统有一定的容错性,所以在不锁定队列的情况下检查其大小的效率胜过更完美的知识。⁶

现在我们知道如何存储和检索 URL,我们可以创建生产者和消费者类。生产者将通过循环来模拟网络爬虫,制造假的 URL,并在 URL 前面加上生产者 ID,然后将它们存储在我们的队列中。这是URLProducerrun()方法:

  public void run() {
    for (int i = 1; i <= urlCount; i++) {
      String url = "https://some.url/at/path/" + i;
      queue.addURL(producerID + " " + url);
      System.out.println(producerID + " produced " + url);
      try {
        Thread.sleep(delay.nextInt(500));
      } catch (InterruptedException ie) {
        System.err.println("Producer " + producerID + " interrupted. Quitting.");
        break;
      }
    }
  }

消费者类类似,唯一的区别是从队列中取出 URL。它将取出一个 URL,加上消费者 ID,并在生产者完成生成并且队列为空时重新开始:

  public void run() {
    while (keepWorking || !queue.isEmpty()) {
      String url = queue.getURL();
      if (url != null) {
        System.out.println(consumerID + " consumed " + url);
      } else {
        System.out.println(consumerID + " skipped empty queue");
      }
      try {
        Thread.sleep(delay.nextInt(1000));
      } catch (InterruptedException ie) {
        System.err.println("Consumer " + consumerID +
            " interrupted. Quitting.");
        break;
      }
    }
  }

我们可以从很小的数字开始运行我们的模拟:两个生产者和两个消费者。每个生产者只会创建三个 URL:

public class URLDemo {
  public static void main(String args[]) {
    URLQueue queue = new URLQueue();
    URLProducer p1 = new URLProducer("P1", 3, queue);
    URLProducer p2 = new URLProducer("P2", 3, queue);
    URLConsumer c1 = new URLConsumer("C1", queue);
    URLConsumer c2 = new URLConsumer("C2", queue);
    System.out.println("Starting...");
    Thread tp1 = new Thread(p1);
    tp1.start();
    Thread tp2 = new Thread(p2);
    tp2.start();
    Thread tc1 = new Thread(c1);
    tc1.start();
    Thread tc2 = new Thread(c2);
    tc2.start();
    try {
      // Wait for the producers to finish creating urls
      tp1.join();
      tp2.join();
    } catch (InterruptedException ie) {
      System.err.println("Interrupted waiting for producers to finish");
    }
    c1.setKeepWorking(false);
    c2.setKeepWorking(false);
    try {
      // Now wait for the workers to clean out the queue
      tc1.join();
      tc2.join();
    } catch (InterruptedException ie) {
      System.err.println("Interrupted waiting for consumers to finish");
    }
    System.out.println("Done");
  }
}

即使涉及到这些小数字,您仍然可以看到使用多个线程来完成工作的影响:

Starting...
C1 skipped empty queue
C2 skipped empty queue
P2 produced https://some.url/at/path/1
P1 produced https://some.url/at/path/1
P1 produced https://some.url/at/path/2
P2 produced https://some.url/at/path/2
C2 consumed P2 https://some.url/at/path/1
P2 produced https://some.url/at/path/3
P1 produced https://some.url/at/path/3
C1 consumed P1 https://some.url/at/path/1
C1 consumed P1 https://some.url/at/path/2
C2 consumed P2 https://some.url/at/path/2
C1 consumed P2 https://some.url/at/path/3
C1 consumed P1 https://some.url/at/path/3
Done

线程不会完美地轮流执行,但每个线程都至少会有一些工作时间。消费者不会锁定到特定的生产者。我们的想法是有效利用有限的资源。生产者可以继续添加任务,而不必担心每个任务需要多长时间或分配给谁。消费者反过来可以获取任务,而不必担心其他消费者。如果一个消费者得到一个简单的任务并在其他消费者之前完成,它可以立即返回并获取一个新任务。

尝试自己运行这个示例,并增加一些这些数字。当有数百个 URL 时会发生什么?当有数百个生产者或消费者时会发生什么?在规模上,这种类型的多任务处理几乎是必需的。你不会找到一个不使用线程来管理其后台工作的大型程序。即使你的应用程序很小,Java 自己的图形包 Swing 也需要一个单独的线程来保持 UI 的响应和正确性。

同步虚拟线程

那虚拟线程呢?它们有相同的并发问题吗?大多数是的。虽然轻量级,虚拟线程仍代表标准的“执行线程”概念。它们仍然可以以混乱的方式相互中断,并且仍必须协调对共享资源的访问。但幸运的是,Project Loom 的设计目标拯救了我们。我们可以使用虚拟线程重用所有同步技巧。事实上,要使我们的 URL 生产和消费演示变为虚拟线程,我们只需要替换 main() 方法中启动线程的代码块:

// file: URLDemo2.java
    System.out.println("Starting virtual threads...");
    // Convert these two-step lines:
    //Thread tp1 = new Thread(p1);
    //tp1.start();

    // To these simpler, create-and-start lines:
    Thread vp1 = Thread.startVirtualThread(p1);
    Thread vp2 = Thread.startVirtualThread(p2);
    Thread vc1 = Thread.startVirtualThread(c1);
    Thread vc2 = Thread.startVirtualThread(c2);

虚拟线程遵守我们 URLQueue 方法中的 synchronized 关键字,并理解 join() 调用,就像平台线程一样。如果你编译并运行 URLDemo2.java(不要忘记可能需要启用预览功能),你将看到与之前相同的输出,当然,会有一些来自随机暂停的小变化。

我们说虚拟线程 大多数情况 下与平台线程具有相同的并发问题。我们之所以这样说是因为创建和运行虚拟线程比管理一组平台线程要便宜得多,因此你不会压垮操作系统。 (请记住,每个平台线程都映射到一个本地线程。)你不需要对虚拟线程进行池化——你只需要创建更多。

从多个线程访问类和实例变量

SpreadSheet 示例中,我们通过一个同步方法保护对一组实例变量的访问,以避免一个线程在另一个线程读取其他变量时改变其中一个变量,以保持它们协调。

但是对于个别变量类型呢?它们需要同步吗?通常不需要。在 Java 中,几乎所有对基本类型和对象引用类型的操作都是 原子的:即 JVM 在一步中处理它们,没有两个线程会碰撞。这可以防止线程在其他线程访问它们时查看引用。

警告

注意——JVM 规范不保证会原子地处理 doublelong 原始类型。这两种类型都表示 64 位值。问题在于 JVM 的堆栈工作方式。你应该通过访问器方法同步访问你的 doublelong 实例变量,或者使用原子包装类,我们将在“并发工具”中描述。

调度和优先级

Java 对线程如何调度几乎没有任何保证。几乎所有的线程调度都留给了 Java 实现和在一定程度上的应用程序。Java 的设计者可以规定一个调度算法,但单一的算法并不适合 Java 可以扮演的所有角色。相反,Java 的设计者让您编写能够在任何调度算法下都能正常工作的健壮代码,并且让实现调整算法以达到最佳适配。

Java 语言规范中的优先级规则被精心措辞为线程调度的一般指南。你应该能够在统计意义上依赖这种行为,但编写依赖调度器非常具体特性的代码并不是一个好主意。相反,请使用本章描述的控制和同步工具来协调您的线程。⁷

每个线程都有一个优先级。一般来说,只要高于当前线程优先级的线程变为可运行状态(启动、停止休眠或被通知),它将抢占低优先级线程并开始执行。在某些系统上,具有相同优先级的线程按轮转法调度,这意味着一旦线程开始运行,它将一直运行,直到执行以下操作之一:

  • 通过调用Thread.sleep()wait()来休眠

  • 等待锁以运行同步方法

  • 阻塞 I/O 操作,例如在read()accept()调用中

  • 通过调用yield()显式地让出控制权

  • 通过完成其目标方法来终止⁸

此情况类似于 Figure 9-8。

ljv6 0908

图 9-8. 优先级抢占式轮转调度

您可以使用setPriority()方法在平台线程上设置优先级,并且可以使用配套的getPriority()调用查看线程的当前优先级。优先级必须落在Thread类常量MIN_PRIORITYMAX_PRIORITY定义的范围内。默认优先级存储在常量NORM_PRIORITY中。

注意

虚拟线程都以NORM_PRIORITY运行。如果在虚拟线程上调用setPriority(),则传递的新优先级将被简单忽略。

线程状态

在其生命周期的任何时刻,线程处于五种一般状态之一。您可以使用Thread类的getState()方法来查询它们:

NEW

线程已创建但尚未启动。

RUNNABLE

线程处于其正常的活动状态,即使它在执行 I/O 操作中被阻塞,例如读取或写入文件或网络连接。

BLOCKED

线程被阻塞,等待进入同步方法或代码块。这包括在调用notify()后被唤醒并试图在wait()后重新获取其锁的时候。

WAITING, TIMED_WAITING

线程正在等待另一个线程通过调用wait()join()。在TIMED_WAITING情况下,调用具有超时。

TERMINATED

线程由于返回、异常或停止而完成。

您可以使用以下代码片段显示当前线程组中所有平台线程的状态:

    Thread [] threads = new Thread [ 64 ]; // max threads to show
    int num = Thread.enumerate(threads);
    for(int i = 0; i < num; i++)
       System.out.println(threads[i] +":"+ threads[i].getState());

Thread.enumerate()调用将填充我们的threads数组,直到其长度。你可能不会在一般编程中使用这个方法,但它对于实验和学习 Java 线程非常有趣和有用。

时间分片

除了优先级排序外,所有现代系统(除了一些嵌入式和“微”Java 环境)都实现了线程时间分片。在时间分片系统中,线程处理被切割,以便每个线程在上下文切换到下一个线程之前运行一小段时间,如图 9-9 所示。

ljv6 0909

图 9-9. 优先级抢占时间分片调度

在此方案中,高优先级线程仍然可以抢占低优先级线程。添加时间分片会混合处理相同优先级的线程;在多处理器机器上,线程甚至可以同时运行。这可能会改变不正确使用线程和同步的应用程序的行为。

严格来说,因为 Java 不保证时间分片,你不应该编写依赖这种调度类型的代码;你编写的任何软件都应该在轮转调度下正常运行。如果你想知道你的 Java 版本的行为,可以尝试以下实验:

public class Thready {
  public static void main(String args []) {
    new Thread(new ShowThread("Foo")).start();
    new Thread(new ShowThread("Bar")).start();
  }

  static class ShowThread implements Runnable {
    String message;

    ShowThread(String message) {
      this.message = message;
    }
    public void run() {
      while (true)
        System.out.println(message);
    }
  }
}

运行此示例时,您将看到您的 Java 实现如何进行调度。Thready类启动两个ShowThread对象。ShowThread是一个进入无限循环⁹(通常不好的形式,但对于此演示很有用)并打印其消息的线程。因为我们没有为任何线程指定优先级,它们都继承了它们创建者的优先级,所以它们具有相同的优先级。在轮转方案下,只应该打印FooBar不应该出现。在时间分片实现中,您偶尔会看到FooBar消息交替出现。

ch09/examples文件夹还包含一个VirtualThready示例,如果您想看看虚拟线程的行为。它们使用“工作窃取”调度程序运行。(请随意深入研究官方 Oracle 文档,了解该算法在分支/合并框架中的实现。)我们必须向虚拟线程版本添加一些join()调用。与平台线程不同,虚拟线程在没有这些显式的等待请求时不会使 JVM“保持唤醒”状态。

优先级

线程优先级是 JVM 在竞争线程之间分配时间的一般指导原则。不幸的是,Java 平台线程以复杂的方式映射到本机线程,以至于你不能依赖优先级的确切含义。相反,把它们视为 JVM 的一个提示。

让我们来调整我们线程的优先级:

class Thready2 {
  public static void main(String args []) {
    Thread foo = new ShowThread("Foo");
    foo.setPriority(Thread.MIN_PRIORITY);
    Thread bar = new ShowThread("Bar");
    bar.setPriority(Thread.MAX_PRIORITY);

    foo.start();
    bar.start();
  }
}

您可能期望通过这对我们的Thready2类的更改,Bar线程将完全接管。如果您在旧版 Solaris 实现的 Java 5.0 上运行此代码,确实会发生这种情况。但对于大多数现代版本的 Java 并非如此。同样地,如果将优先级更改为除最小和最大之外的值,则可能根本看不到任何区别。优先级和性能的微妙之处与 Java 线程和优先级如何映射到操作系统中的实际线程有关。因此,通常应保留调整线程优先级的权利供系统和框架开发使用。

线程性能

线程的使用决定了几个 Java 包的形式和功能。

同步的成本

获取锁来同步线程需要时间,即使没有竞争。在旧版 Java 实现中,这段时间可能是显著的。使用较新的 JVM,这几乎可以忽略不计。然而,不必要的低级别同步仍然可能通过阻塞线程来减慢应用程序。为了避免这种惩罚,两个重要的 API,Java 集合框架和 Swing API,特别设计为让开发人员控制同步。

java.util 集合框架用更全面功能的未同步类型(ListMap)替代了早期简单的 Java 聚合类型,即VectorHashtable。集合框架反而让应用程序代码来同步访问集合时必要的部分,并提供特殊的“快速失败”功能以帮助检测并发访问并抛出异常。它还提供同步“包装器”,可以以旧式风格提供安全访问。作为 java.util.concurrent 包的一部分,特殊的支持并发访问的 MapQueue 集合的实现进一步允许高度并发的访问,而无需用户同步。

Java Swing API 采用了一种不同的方法来提供速度和安全性。Swing 使用单个线程来修改其组件,有一个例外:事件分发线程,也称为事件队列。Swing 通过强制一个超级线程控制 GUI 来解决性能问题和任何事件顺序问题。应用程序通过简单的接口间接地访问事件分发线程,通过将命令推送到队列中来实现。我们将在第十二章中详细了解如何做到这一点。

线程资源消耗

Java 中的一个基本模式是启动多个线程来处理异步外部资源,比如套接字连接。为了最大效率,Web 开发人员可能会诱惑创建每个客户端连接的线程。当每个客户端有自己的线程时,I/O 操作可以根据需要阻塞和恢复。但尽管对于给定客户端的吞吐量而言这可能是高效的,但这是一种非常低效的服务器资源利用方式。

线程会消耗内存;每个线程都有自己的“栈”用于本地变量,并在运行线程之间切换时(称为上下文切换)会增加 CPU 开销。线程相对较轻量级。在大型服务器上可以运行数百或数千个线程是可能的。但在某一点之后,管理现有线程的成本开始超过启动更多线程的好处。为每个客户端创建一个线程并不总是可扩展的选择。

另一种方法是创建“线程池”,在这里固定数量的线程从队列中拉取任务,并在完成后返回以继续工作。这种线程的重复使用使得系统具有良好的可伸缩性,但在 Java 服务器上高效实现这一点通常较为困难。Java 的基本 I/O(如套接字)并不完全支持非阻塞操作。java.nio 包,即 New I/O(或简称 NIO),具有异步 I/O 通道。通道可以执行非阻塞读写操作。它们还具有测试流准备好传输数据的能力。线程可以异步关闭通道,使交互更加优雅。我们将在接下来讨论与文件和网络连接工作的章节中讨论 NIO。

Java 提供了线程池和作业“执行器”服务作为 java.util.concurrent 包的一部分。这意味着你不必自己编写这些功能。在讨论 Java 的并发工具时,我们将对它们进行总结。

虚拟线程性能

Project Loom 的目标是提高线程性能——尤其是当涉及数千甚至数百万个线程时。在平台线程与虚拟线程上运行 run() 方法的速度没有任何差异。然而,创建和管理这些线程的速度更快。

让我们再次看看我们的URLDemo类。而不是总共四个线程,我们将这个数字提升到数千个。我们将取消生产者,并预先填充队列以便我们可以专注于我们的新消费者。我们将创建只需消耗一个 URL 的消费者——在获取另一个 URL 之前没有随机的、人为的延迟。这种行为模仿了虚拟线程的一个真实用例:一个单一的服务器在短时间内处理数百万个小请求。我们还将修改我们的打印语句,以便在里程碑出现而不是在每个 URL 被消耗后出现。我们的新URLDemo3将接受两个可选的命令行参数:要创建的 URL 数量(默认为 100,000)以及是否使用平台线程还是虚拟线程(默认为平台),这样我们可以比较性能的差异。

查看ch09/examples文件夹中URLConsumer3的源代码,以查看我们为这个新变体所做的调整。然后让我们仔细看看main()方法中的处理循环,看看它如何处理新的消费者。这里是相关部分:

    // Create and populate our shared queue object
    URLQueue queue = new URLQueue();
    for (int u = 1; u <= count; u++) {
      queue.addURL("http://some.url/path/" + u);
    }

    // Now the fun begins! Make one consumer for every URL
    for (int c = 0; c < count; c++) {
      URLConsumer3 consumer = new URLConsumer3("C" + c, queue);
      if (useVirtual) {
        Thread.startVirtualThread(consumer);
      } else {
        new Thread(consumer).start();
      }
    }

这段代码并不尝试重用消费者。顺便说一句,在现实世界中不重用线程有一些有效的原因。例如,你必须在使用之间手动清理一些共享数据。忘记了这一点的“行政琐事”,你可能会泄露敏感信息。(如果你在处理银行交易,你不会想意外使用之前的账号。)你可以通过假设一个单一线程会完成所有工作然后终止来简化你的代码。无论你是否使用虚拟线程,这一点都是正确的。

我们在中等的 Linux 桌面系统上使用 1,000,000 个 URL 测试了这个版本。平台线程在近一分钟内清除了队列(根据粗略的time实用程序为 58.661 秒)。效果不错!另一方面,虚拟线程在不到2 秒(1.867 秒)的时间内清除了队列。测试一个里程碑 URL 以打印是微不足道的。拖慢速度的不是每个消费者所做的任务。平台线程的真正瓶颈是数千次请求操作系统获取新的、昂贵的资源。Project Loom 消除了许多这种开销。使用虚拟线程并不保证更好的性能,但在这种情况下,它肯定会有所好处!

并发工具

到目前为止,在这一章中,我们演示了如何使用 Java 语言基元创建和同步线程。java.util.concurrent包和子包在此基础上构建,添加了重要的线程实用程序,并通过提供标准实现来编码一些常见的设计模式。这些领域的通用性大致按照以下顺序排列:

线程感知的集合实现

java.util.concurrent包在第七章中通过几种特定线程模型的实现增强了 Java 集合 API。这些包括Queue接口的定时等待和阻塞实现,以及非阻塞、并发访问优化的QueueMap接口实现。该包还为极其高效的“几乎总是读取”情况添加了“写时复制”ListSet实现。这些听起来可能很复杂,但它们很好地涵盖了一些常见情况。

Executors

Executor们运行任务,包括Runnable们,并将线程创建和池化的概念从用户抽象出来(这意味着你不需要自己编写)。Executors旨在作为一个高级别的替代品,用于创建新线程以处理一系列作业。与Executor一起,CallableFuture接口允许管理、返回值和异常处理。

低级别同步构造

java.util.concurrent.locks包包含一组类,包括LockCondition,这些类与 Java 语言级别的同步原语类似,并将它们提升到具体 API 的级别。例如,LockSupport辅助类包括两种方法,park()unpark(),它们替代了Thread类中已弃用的suspend()resume()方法。锁包还添加了非排他读/写锁的概念,允许在同步数据访问中实现更大的并发性。

高级别同步构造

这包括CyclicBarrierCountDownLatchSemaphoreExchanger类。这些类实现了从其他语言和系统中借鉴的常见同步模式,并可以作为新高级工具的基础。

原子操作(听起来非常像詹姆斯·邦德,是吧?)

java.util.concurrent.atomic包提供了对原始类型和引用执行原子“全有或全无”操作的包装器和实用程序。这包括简单组合原子操作,如在设置值之前测试值,并在一个操作中获取和增加数字。

除了 Java VM 对atomic操作包的优化外,所有这些实用程序都是用纯 Java 实现的,基于标准的 Java 语言同步构造。这意味着它们从某种意义上说只是便利工具,并没有真正为语言添加新的功能。它们的主要作用是在 Java 线程编程中提供标准模式和习惯用法,使其更安全和高效。一个很好的例子是Executor实用程序,它允许用户在预定义的线程模型中管理一组任务,而无需深入创建线程。这样的高级 API 不仅简化了编码,还允许更大程度的优化常见情况。

升级我们的队列演示

许多内建到 Java 中的并发特性在大型、更复杂的项目中会更加有用。但我们可以通过使用java.util.concurrent包中的线程安全的ConcurrentLinkedQueue类来升级我们那个简陋的 URL 处理演示。我们可以对其类型进行参数化,并完全摒弃我们自定义的URLQueue类:

// Directory: ch09/examples
// in URLDemo4.java
    ConcurrentLinkedQueue<String> queue =
        new ConcurrentLinkedQueue<>();

// in URLProducer4.java, just "add" instead of "addURL"
    queue.add(producerID + " " + url);

// in URLConsumer4.java, "poll" rather than "getURL"
    String url = queue.poll();
    // ...

我们需要稍微调整消费者和生产者的代码,但只是一点点,并且主要是为了使用普通队列操作的名称addpoll,而不是我们自定义的、以 URL 为中心的方法名称。但我们根本不需要担心URLQueue类。有时候你会需要自定义数据结构,因为现实世界是混乱的。但如果你能使用其中一个内置的同步存储选项,你就知道你得到了可以安全在多线程应用中使用的健壮存储和访问。

另一个考虑的升级是原子便利类。您可能还记得我们的消费者类有一个布尔标志,可以设置为 false 以结束消费者的处理循环。由于我们可以合理地假设多个线程可能访问我们的消费者,我们可以将该标志重新制作为AtomicBoolean类的实例,以确保战斗中的线程不能摧毁我们可怜的标志。(当然,我们可以使我们的访问方法synchronized,但我们想要突出显示 JDK 中已经存在的一些选项。)以下是URLConsumer4的有趣部分的简要概述:

  AtomicBoolean keepWorking;
  //...

  public void run() {
    while (keepWorking.get() || !queue.isEmpty()) {
      String url = queue.poll();
      //...
    }
  }

  public void setKeepWorking(boolean newState) {
    keepWorking.set(newState);
  }

使用AtomicBoolean需要更多的打字工作——调用设置/获取方法而不是简单的赋值或比较——但您可以得到所有您希望的安全处理。当您在各处具有复杂的多线程逻辑时,您可能会进行自己的状态管理。然而,在没有太多其他需要同步的代码的情况下,这些便利类确实非常方便。

结构化并发

除了虚拟线程为高并发应用带来的令人印象深刻的改进之外,Project Loom 还为 Java 引入了结构化并发。您可能听说过在线程世界中的“并行编程”。当您可以将一个较大的问题分解为可以分别解决且同时解决的更小问题(并行处理)时,您可以选择追求并行编程解决方案(明白了吧?)。

将大任务分解成子任务的这个概念与我们使用生产者和消费者的演示有许多相似之处,但这两种类型的问题并不完全相同。一个重大的区别在于如何处理错误。例如,如果我们在URLDemo类中创建消费者失败,我们可以简单地创建另一个并继续进行。但如果并行计算中的一个子任务失败,如何恢复就不那么明显了。应该取消所有其他子任务吗?如果其中一些已经完成了怎么办?如果我们想取消更大的“父”任务怎么办?

Java 19 引入了一个孵化器特性,StructuredTaskScope类,用于更好地封装子任务的工作。 (如果您把像虚拟线程这样的预览功能称为“β”增强功能,那么孵化器特性将是“α”增强功能。)您可以在JEP 428中了解其设计目标和实现细节。虽然本书不会介绍结构化并发性或执行器,但重要的是要知道 Java 为开发人员在处理并行和并发应用程序时提供了许多工具。事实上,Java 在这个领域为开发人员提供的支持正是为什么它仍然是生产后端的流行工具。

那么多要处理的线程

虽然本章我们不会深入研究并发包,但如果并发对您有趣或在您遇到的问题类型中证明有用,我们希望您知道接下来可以深入了解的地方。正如我们在“同步 URL 队列”中所注明的,《Java 并发编程实践》(https://jcip.net by Brian Goetz 是实现真实世界多线程项目所必需的阅读材料。我们也要向 Doug Lea 致敬,他是《Java 并发编程》(Addison-Wesley)的作者,领导了添加这些包到 Java 中的团队,并且在创建它们方面负有重大责任。

除了线程之外,Java 对基本文件输入和输出(I/O)的本机支持在生产应用程序中占据了重要位置。在下一章中,我们将查看典型 I/O 的主要类。

复习问题

  1. 什么是线程?

  2. 如果要求线程“轮流”调用方法(意味着不能同时执行该方法的两个线程以避免损坏共享数据),可以向方法添加什么关键字?

  3. 哪些标志允许您编译包含预览功能代码的 Java 程序?

  4. 哪些标志允许您运行包含预览功能代码的 Java 程序?

  5. 一个本机线程支持多少平台线程?

  6. 一个本机线程支持多少虚拟线程?

  7. 语句x = x + 1;对变量x来说是原子操作吗?

  8. 哪个包包含流行集合类(如QueueMap)的线程安全版本?

代码练习

  1. 让我们建立一个时钟!使用一个JLabel和一个Thread(或者可以理解为一个指针和一个线程),制作一个小型图形时钟应用程序。Clock.java文件在ch09/exercises文件夹中包含一个骨架应用程序,它会在窗口中显示一个简单的JLabel对象。我们增加了标签字体的大小以提高可读性。您的时钟应该至少显示小时、分钟和秒。创建一个线程,让它睡眠一秒钟,然后增加时钟的显示。可以回顾一下“格式化日期和时间”中的日期和时间格式化示例。

  2. ch09/exercises/game 文件夹中的苹果投掷游戏目前使用平台线程,按照“重访线程动画”中的讨论,启动游戏时苹果会被抛出。苹果不会撞到任何东西,但会像被投掷一样弧线移动。我们将在第十二章中使这个动画更有趣和更具互动性。

    一旦你掌握了预期动画的感觉,将平台线程转换为虚拟线程。编译你的新版本并验证它仍然按预期工作。(请记住,根据你的 Java 版本,你可能需要使用额外的预览标志进行编译和运行。)

¹ 从历史上看,interrupt() 在所有 Java 实现中并不一致。

² “守护进程”一词(在 Unix 圈子里通常读作 day-mun)灵感来自Maxwell's demon,指的是希腊词汇中对较低神灵的称呼,而非恶意精灵。

³ 许多 Java 增强功能最初都是带有“Loom”之类时髦名称的工作进展。

⁴ 不幸的是,这些选项上的单破折号和双破折号前缀并非打字错误。命令行参数有着相当悠久的历史,Java 及其工具足够古老,以至于继承了一些遗留模式,同时仍需适应现代方法。大多数选项支持任意前缀,但偶尔需要遵循看似未写下的规则。犹豫不决时,像javac这样的工具支持另一选项:-help(或 --help)。提供该参数将打印出简明的选项列表和相关细节。

⁵ 不要混淆此上下文中的“序列化”术语与 Java 中的“对象序列化”,后者是一种使对象持久化的机制。然而,基础含义(依次放置一件事物)是相同的。在对象序列化的情况下,对象的数据按照一定顺序逐字节布局。对于线程而言,每个线程依次访问同步资源。

⁶ 即使能够容忍对象状态的轻微差异,现代多核系统在没有完美应用知识的情况下可能会造成严重破坏。而实现完美是困难的!如果你打算在现实世界中使用线程,Java 并发编程实战(由 Brian Goetz 等人编著,Addison-Wesley 出版社)是必读之作。

⁷ 《Java Threads》(由 Scott Oaks 和 Henry Wong 编著,O'Reilly 出版社)详细讨论了同步、调度和其他与线程相关的问题。

⁸ 从技术上讲,线程也可以通过已废弃的 stop() 调用来终止,但正如我们在本章开头提到的那样,这种做法有很多问题。

⁹ 当你看腻了飞来飞去的Foos时,可以按 Control-C 退出演示。

第十章:文件输入和输出

将数据存储在文件中并在以后检索是桌面和企业应用程序至关重要的功能。在本章中,我们将介绍java.iojava.nio包中一些最受欢迎的类。这些包为基本输入和输出(I/O)提供了丰富的工具集,并为 Java 中所有文件和网络通信的框架提供支持。图 10-1 展示了java.io包的广度。

我们首先来看看java.io中的流类,这些类是基本InputStreamOutputStreamReaderWriter类的子类。然后我们将检查File类,并讨论如何使用java.io中的类来读取和写入文件。我们还快速浏览一下数据压缩和序列化。在此过程中,我们介绍了java.nio包。这个“新”I/O 包(或 NIO)增加了专门用于构建高性能服务的重要功能。NIO 主要关注使用缓冲区(你可以在其中存储数据以更有效地利用其他资源)和通道(你可以高效地将数据放入其中,其他程序同样高效地从中读取数据)。在某些情况下,NIO 还提供了更好的 API,可以替代一些java.io功能。¹

ljv6 1001

图 10-1. java.io类层次结构

Java 中大多数 I/O 操作都是基于流的。在概念上,表示一种数据的流动,其中一个写入器位于一端,一个读取器位于另一端。当你使用java.io包执行终端输入和输出、读取或写入文件或通过 Java 网络套接字进行通信时(更多关于网络的内容请参阅第十三章),你将使用各种类型的流。当我们研究 NIO 包时,我们将发现一个类似的概念叫做通道。两者的主要区别在于流是围绕字节或字符而设计的,而通道则是围绕包含这些数据类型的“缓冲区”而设计的。缓冲区通常是用于数据的快速临时存储,从而更容易优化吞吐量。它们都大致完成相同的工作。让我们从流开始。以下是最受欢迎的流类的快速概述:

InputStreamOutputStream

抽象类定义了读取或写入无结构字节序列的基本功能。Java 中的所有其他字节流都建立在基本InputStreamOutputStream之上。

ReaderWriter

抽象类定义了读取或写入字符数据序列的基本功能,支持 Unicode。Java 中的所有其他字符流都建立在ReaderWriter之上。

InputStreamReaderOutputStreamWriter

通过按照特定字符编码方案(如 ASCII 或 Unicode)进行转换,将字节流和字符流进行桥接的类。请记住:在 Unicode 中,一个字符不一定是一个字节!

DataInputStreamDataOutputStream

专门的流过滤器增加了读写多字节数据类型(如数值原始数据和String对象)的能力,以标准化格式进行。

ObjectInputStreamObjectOutputStream

专门的流过滤器,能够写入整组序列化的 Java 对象并重新构造它们。

BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter

专门的流过滤器增加了缓冲以提高效率。在真实的 I/O 操作中,几乎总是会使用缓冲。

PrintStreamPrintWriter

简化文本打印的专门流。

PipedInputStreamPipedOutputStreamPipedReaderPipedWriter

在应用程序内移动数据的配对类。写入PipedOutputStreamPipedWriter的数据将从其对应的PipedInputStreamPipedReader中读取。

FileInputStreamFileOutputStreamFileReaderFileWriter

实现了从本地文件系统读取和写入文件的InputStreamOutputStreamReaderWriter

Java 中的流是单向的。java.io输入和输出类只代表简单流的两端。对于双向对话,您将使用每种类型的一个流。

InputStreamOutputStream,如图 10-2 所示,是定义所有字节流的最底层接口的抽象类。它们包含用于读取或写入无结构的字节级数据的方法。由于这些类是抽象的,你不能创建通用的输入或输出流。

ljv6 1002

图 10-2. 基本输入和输出流功能

Java 为诸如从文件读取和写入或与网络连接通信等活动实现了这些类的子类。由于所有字节流都继承自InputStreamOutputStream的结构,因此可以互换使用各种类型的字节流。还可以在基本流周围堆叠或包装特定类型的专门流,以添加缓冲、过滤、压缩或处理更高级别数据类型等功能。

ReaderWriterInputStreamOutputStream非常相似,不同之处在于它们处理的是字符而不是字节。作为真正的字符流,这些类可以正确处理 Unicode 字符,而字节流则并非总是如此。通常需要在这些字符流和物理设备(如磁盘和网络)的字节流之间进行桥接。InputStreamReaderOutputStreamWriter是特殊的类,它们使用像 ASCII 或 UTF-8 这样的字符编码方案来在字符流和字节流之间进行转换。

本节描述了几种流类型,但不包括FileInputStreamFileOutputStreamFileReaderFileWriter。我们将在下一节中讨论文件流,在那里我们将涵盖如何在 Java 中访问文件系统。

基本输入/输出

InputStream对象的典型示例是 Java 应用程序的标准输入。与 C 语言中的stdin或 C++中的cin类似,这是命令行(非 GUI)程序的输入来源。它是来自环境的输入流,通常是一个终端窗口或可能是另一个命令的输出。java.lang.System类是系统相关资源的通用存储库,在静态变量System.in中提供了对标准输入流的引用。它还在outerr变量中分别提供了标准输出流标准错误流。²以下示例显示了它们之间的对应关系:

    InputStream stdin = System.in;
    OutputStream stdout = System.out;
    OutputStream stderr = System.err;

这段代码隐藏了System.outSystem.err不仅仅是OutputStream对象,而是更专门和有用的PrintStream对象的事实。我们稍后会在“PrintWriter and PrintStream”中解释这些内容,但目前我们可以将outerr引用为OutputStream对象,因为它们都是从OutputStream派生出来的。

您可以使用InputStreamread()方法从标准输入一次读取一个字节。如果您仔细查看在线文档,您会发现基础InputStream类的read()方法是一个抽象方法。System.in背后是InputStream的特定实现,它提供了read()方法的实际实现:

    try {
      int val = System.in.read();
    } catch (IOException e) {
      // ...
    }

尽管我们说read()方法读取字节值,但示例中的返回类型是int而不是byte。这是因为 Java 中基本输入流的read()方法使用了从 C 语言继承过来的约定,用特殊值指示流的结束。字节值在 0 到 255 之间返回,并且特殊值-1用于指示已到达流的结尾。在使用简单的read()方法时,您可以测试这种条件。然后,如果需要,可以将值转换为字节。以下示例从输入流中读取每个字节并打印其值:

    try {
      int val;
      while((val=System.in.read()) != -1) {
        System.out.println((byte)val);
      }
    } catch (IOException e) {
      // Oops. Handle the error or print an error message
    }

如我们在示例中所示,read() 方法也可能抛出 IOException,如果在底层流源中读取时出现错误。IOException 的各种子类可能表示源(如文件或网络连接)发生了错误。此外,读取比单个字节更复杂数据类型的高级流可能会抛出 EOFException(“文件结尾”),这表明流的意外或过早结束。

read() 的重载形式会将字节数组填充为可能的最大数据,并返回读取的字节数:

    byte [] buff = new byte [1024];
    int got = System.in.read(buff);

理论上,我们还可以使用 available() 方法在给定时间内检查 InputStream 上可用于读取的字节数。有了这些信息,我们可以创建一个恰好大小的数组:

    int waiting = System.in.available();
    if (waiting > 0) {
      byte [] data = new byte [ waiting ];
      System.in.read(data);
      // ...
    }

但是,这种技术的可靠性取决于底层流实现是否能够检测到可以检索多少数据。它通常适用于文件,但不应该依赖于所有类型的流。

这些 read() 方法会阻塞,直到读取到至少一些数据(至少一个字节)。一般来说,您必须检查返回的值,以确定您读取了多少数据,并且是否需要继续读取。 (我们在本章后面将介绍非阻塞 I/O。)InputStreamskip() 方法提供了一种跳过一定数量字节的方法。根据流的实现方式,跳过字节可能比读取它们更有效率。

close() 方法关闭流并释放任何关联的系统资源。在使用完流后记得关闭大多数类型的流对性能很重要。在某些情况下,当对象被垃圾回收时,流可能会自动关闭,但依赖这种行为并不是一个好主意。try-with-resources 功能在 “try with Resources” 中讨论,可以更容易地自动关闭流和其他可关闭实体。我们将在 “File Streams” 中看到一些示例。接口 java.io.Closeable 标识了所有可以关闭的流、通道和相关实用类。

字符流

在早期的 Java 版本中,一些 InputStreamOutputStream 类型包含了用于读取和写入字符串的方法,但大多数情况下它们是通过天真地假设 16 位 Unicode 字符等同于流中的 8 位字节来操作的。这对于拉丁-1(ISO 8859-1)字符有效,但对于与不同语言一起使用的其他编码的世界则不适用。

java.io ReaderWriter字符流类被引入为仅处理字符数据的流。当您使用这些类时,您仅考虑字符和字符串数据。您允许底层实现处理字节到特定字符编码的转换。正如您将看到的,有一些ReaderWriter的直接实现,例如用于读取和写入文件的实现。

更一般地说,两个特殊类InputStreamReaderOutputStreamWriter弥合了字符流和字节流之间的差距。它们分别是ReaderWriter,可以包装在任何底层字节流周围,使其成为字符流。编码方案在字节(可能以表示多字节字符的组形式出现)和 Java 的双字节字符之间进行转换。编码方案可以在InputStreamReaderOutputStreamWriter的构造函数中通过名称指定。为方便起见,默认构造函数使用系统的默认编码方案。

让我们看看如何使用读取器和java.text.NumberFormat类从命令行中的用户检索数字输入。我们假设来自System.in的字节使用系统的默认编码方案:

// file: ch10/examples/ParseKeyboard.java

    try {
      InputStream in = System.in;
      InputStreamReader charsIn = new InputStreamReader(in);
      BufferedReader bufferedCharsIn = new BufferedReader(charsIn);

      String line = bufferedCharsIn.readLine();
      int i = NumberFormat.getInstance().parse(line).intValue();
      // ...
    } catch (IOException e) {
      // ...
    } catch (ParseException pe) {
      // ...
    }

首先,我们在System.in周围包装一个InputStreamReader。该读取器使用默认编码方案将System.in的传入字节转换为字符。然后,我们在InputStreamReader周围包装一个BufferedReaderBufferedReader添加了readLine()方法,我们可以使用该方法将一整行文本(最多到达平台特定的行终止符字符组合)读入String中。然后,使用第八章中描述的技术将字符串解析为整数。自己试试看。提示时,尝试提供不同的输入。如果输入“0”会发生什么?如果只输入您的名字会发生什么?

我们刚刚采取了面向字节的输入流System.in,并安全地将其转换为Reader以读取字符。如果我们希望使用与系统默认值不同的编码,则可以在InputStreamReader的构造函数中指定它,如下所示:

    InputStreamReader reader = new InputStreamReader(System.in, "UTF-8");

对于从读取器读取的每个字符,InputStreamReader读取一个或多个字节,并执行必要的 Unicode 转换。

当我们讨论java.nio.charset包时,我们将回到字符编码的主题“新 I/O 文件 API”,该包允许您查找和使用编码器和解码器。InputStreamReaderOutputStreamWriter都可以接受Charset编解码器对象以及字符编码名称。

流包装器

如果您想要做的不仅仅是读取和写入字节或字符序列怎么办?我们可以使用 过滤流,它是 InputStreamOutputStreamReaderWriter 的一种类型,它包装另一个流并添加新功能。过滤流将目标流作为其构造函数的参数,并进行一些额外的处理,然后将调用委托给目标。例如,我们可以构造一个 BufferedInputStream 来包装系统标准输入:

    InputStream bufferedIn = new BufferedInputStream(System.in);

BufferedInputStream 预先读取并缓冲一定量的数据。它在底层流周围包装了一个额外的功能层。Figure 10-3 显示了 DataInputStream 的这种排列方式,它可以读取更高级别的数据类型,如 Java 的基本类型和字符串。

ljv6 1003

图 10-3. 层叠流

正如您从前面的代码片段中看到的那样,BufferedInputStream 过滤器是 InputStream 的一种类型。因为过滤流本身是基本流类型的子类,所以它们可以作为其他过滤流的构造参数。这使得可以将过滤流层叠在一起,以提供不同的功能组合。例如,我们可以首先用 BufferedInputStream 包装我们的 System.in 来获得输入缓冲,然后再用 DataInputStream 包装 BufferedInputStream 来读取带缓冲区的特殊数据类型。

Java 提供了用于创建新类型过滤流的基类:FilterInputStreamFilterOutputStreamFilterReaderFilterWriter。这些超类通过将它们所有的方法调用委托给它们的底层流来提供过滤的基本机制。要创建自己的过滤流,可以扩展这些类并重写各种方法以添加所需的额外处理。

数据流

DataInputStreamDataOutputStream 是过滤流,允许您读取或写入字符串(而不是单个字符)和由多个字节组成的原始数据类型。DataInputStreamDataOutputStream 分别实现了 DataInputDataOutput 接口。这些接口定义了用于读取或写入字符串以及所有 Java 原始类型的方法,包括数字和布尔值。DataOutputStream 对这些值进行编码,以便在任何机器上正确读取,然后将它们写入其底层的字节流。DataInputStream 从其底层字节流中获取编码的数据并将其解码为原始类型和值。

您可以从 InputStream 构造一个 DataInputStream,然后使用诸如 readDouble() 这样的方法来读取原始数据类型:

    DataInputStream dis = new DataInputStream(System.in);
    double d = dis.readDouble();

此片段将标准输入流包装在 DataInputStream 中,并使用它来读取一个 double 值。readDouble() 方法从流中读取字节,并从中构造一个 double 值。DataInputStream 方法期望数字数据类型的字节采用网络字节顺序,这是一种标准,指定多字节值的高阶字节先发送(也称为大端序;参见“字节顺序”)。

DataOutputStream 类提供了与 DataInputStream 的读取方法对应的写入方法。我们输入片段的补充如下:

    double d = 3.1415926;
    DataOutputStream dos = new DataOutputStream(System.out);
    dos.writeDouble(d);
警告

DataOutputStreamDataInputStream 处理二进制数据,而不是人类可读的文本。通常,您会使用 DataInputStream 来读取由 DataOutputStream 生成的内容。这些过滤流非常适合直接处理像图像文件之类的内容。

DataInputStreamDataOutputStreamreadUTF()writeUTF() 方法使用 UTF-8 字符编码读取和写入 Java String,该编码使用 Unicode 字符。正如在第八章中讨论的那样,UTF-8 是一种广泛使用的 ASCII 兼容的 Unicode 字符编码。并非所有编码都保证能够保存所有 Unicode 字符,但 UTF-8 可以。您还可以通过将 UTF-8 指定为编码名称,将其与 ReaderWriter 流一起使用。

缓冲流

BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter 类在流路径中添加了一个指定大小的数据缓冲区。缓冲区可以通过减少与 read()write() 方法调用相对应的物理读取或写入操作次数来提高效率,如图 10-4 所示。

ljv6 1004

图 10-4. 使用缓冲区和不使用缓冲区读取数据

您可以通过适当的输入或输出流和缓冲区大小创建一个缓冲流。(您也可以将另一个流包装在缓冲流中,以便它从缓冲中获益。)以下是一个简单的缓冲输入流示例:

    BufferedInputStream bis = new BufferedInputStream(myInputStream, 32768);
    // bis will store up to 32K of data from myInputStream at a time
    // we can then read from bis at any time
    byte b = bis.read();

在这个例子中,我们指定了一个 32 KB 的缓冲区大小。如果在构造函数中没有指定缓冲区的大小,Java 会为我们创建一个合理大小的缓冲区(当前默认为 8 KB)。在我们第一次调用 read() 方法时,bis 会尝试用数据填充整个 32 KB 的缓冲区(如果数据可用)。之后,对 read() 的调用会从缓冲区中检索数据,并在必要时重新填充缓冲区。

BufferedOutputStream 的工作方式类似。调用 write() 方法将数据存储在缓冲区中;只有当缓冲区填满时,数据才实际写入到底层流中。你也可以使用 flush() 方法随时将 BufferedOutputStream 的内容写出。flush() 方法实际上是 OutputStream 类本身的方法。它允许你确保所有底层流中的数据已保存或发送。

BufferedReaderBufferedWriter类的工作方式与它们的基于字节的对应类相同,不同之处在于它们操作的是字符而不是字节。

PrintWriter 和 PrintStream

另一个有用的包装类是java.io.PrintWriter。这个类提供了一系列重载的print()方法,将它们的参数转换为字符串并将它们推送到流中。一组补充的println()便捷方法在字符串末尾添加了一个新行。对于格式化文本输出,printf()和完全相同的format()方法允许您向流中写入 C printf风格的格式化文本。

PrintWriter是一个不同寻常的字符流,因为它可以包装OutputStream或另一个WriterPrintWriter是传统的PrintStream字节流的更强大的大哥。System.outSystem.err流都是PrintStream对象,这一点在本书中已经多次见识过:

    System.out.print("Hello, world...\n");
    System.out.println("Hello, world...");
    System.out.printf("The answer is %d\n", 17);
    System.out.println(3.14);

创建PrintWriter对象时,可以在构造函数中传递一个额外的布尔值,指定是否“自动刷新”。如果这个值为truePrintWriter在每次发送换行符时会自动执行flush()操作,刷新底层的OutputStreamWriter

    // Stream automatically flushes after a newline.
    PrintWriter pw = new PrintWriter(myOutputStream, true);
    pw.println("Hello!");

当您将此技术与缓冲输出流一起使用时,它就像一个终端,逐行输出数据。

PrintStreamPrintWriter相比常规字符流的另一个重大优势在于,它们可以屏蔽底层流抛出的异常。与其他流类的方法不同,PrintWriterPrintStream的方法不会抛出IOException。相反,它们提供了一个方法来显式检查错误(如果需要的话)。这使得打印文本的常见操作变得更加容易。您可以使用checkError()方法来检查错误:

    System.out.println(reallyLongString);
    if (System.out.checkError()) {
      // uh oh
    }

PrintStreamPrintWriter的这个特性意味着您通常可以将文本输出到各种目标,而无需将每个打印语句都包装在try块中。但如果您正在写入重要信息并希望确保没有任何错误发生,它仍然会让您访问到发生的任何错误。

java.io.File 类

打印输出的一个流行目标是文件。java.io.File类封装了关于文件或目录的信息访问。您可以使用File获取文件的属性信息,列出目录中的条目,并执行基本的文件系统操作,比如删除文件或创建新目录。虽然File对象处理这些“元”操作,但它不提供读写文件数据的 API;您需要使用文件流来完成这些操作。

文件构造函数

您可以从String路径名创建File的实例:

    File fooFile = new File("/tmp/foo.txt");
    File barDir = new File("/tmp/bar");

您还可以使用以 JVM 当前工作目录为起点的相对路径来创建文件:

    File f = new File("foo");

您可以通过读取System属性列表中的user.dir属性来确定当前工作目录:

    System.getProperty("user.dir"); // e.g.,"/Users/pat"

File 构造函数的重载版本允许你将目录路径和文件名指定为单独的 String 对象:

    File fooFile = new File("/tmp", "foo.txt");

还有另一种变化,你可以使用 File 对象指定目录,用 String 指定文件名:

    File tmpDir = new File("/tmp"); // File for directory /tmp
    File fooFile = new File (tmpDir, "foo.txt");

这些 File 构造函数实际上都不创建文件或目录,并且为不存在的文件创建 File 对象不会报错。File 对象只是文件或目录的句柄,你可能希望读取、写入或测试其属性。例如,你可以使用 exists() 实例方法来了解文件或目录是否存在。许多应用程序在保存文件之前执行此测试。如果所选文件不存在,太好了!应用程序可以安全地保存其数据。如果文件已经存在,则通常会收到一个覆盖警告,以确保你确实要替换旧文件。

路径本地化

在 Java 中,路径名应遵循本地文件系统的约定。Windows 文件系统使用具有驱动器号的不同(顶级目录)(例如,“C:”)和反斜线 (),而不是 Linux 和 macOS 系统中使用的单个根和正斜线 (/) 路径分隔符。

Java 尝试弥补这种差异。例如,在 Windows 平台上,它接受斜线或反斜线的路径。然而,在 macOS 和 Linux 上,它只接受斜线。

你最好确保你遵循主机文件系统的文件名约定。如果你的应用程序有一个 GUI,它可以根据用户的请求打开和保存文件,那么你应该能够使用 Swing 的 JFileChooser 类来处理这个功能。这个类封装了一个图形文件选择对话框。JFileChooser 的方法会为你处理系统相关的文件名特性。

如果你的应用程序需要代表自己处理文件,那么事情就会变得有点复杂。File 类包含一些 static 变量,以使这项任务更加简单。File.separator 定义了一个 String,指定了本地主机上的文件分隔符(例如,在 Unix 和 macOS 系统上是 /,在 Windows 系统上是 \);File.separatorChar 提供了相同的信息,但以一个 char 的形式提供。

你可以以几种方式使用这些与系统相关的信息。可能最简单的本地化路径名的方法是选择一个你在内部使用的约定,比如正斜线 (/),然后使用 String 的替换方法来替换本地化的分隔符字符:

    // we'll use forward slash as our standard
    String path = "mail/2023/june";
    path = path.replace('/', File.separatorChar);
    File mailbox = new File(path);

或者,你可以使用路径名的组件并在需要时构建本地路径名:

    String [] path = { "mail", "2004", "june", "merle" };

    StringBuffer sb = new StringBuffer(path[0]);
    for (int i=1; i< path.length; i++) {
      sb.append(File.separator + path[i]);
    }
    File mailbox = new File(sb.toString());
注意

请记住,在 Java 中,当反斜杠字符 (\) 在源代码中用作 String 时,Java 会将其解释为转义字符。要获得一个字面上的反斜杠,你必须使用双反斜杠:\\

为了解决具有多个“根目录”(例如,在 Windows 上是C:\)的文件系统的问题,File类提供了静态方法listRoots(),它返回一个File对象数组,对应于文件系统根目录。你可以在jshell中尝试这个:

jshell> import java.io.File;

// On a Linux box:
jshell> File.listRoots()
$2 ==> File[1] { / }

// On Windows:
jshell> File.listRoots()
$3 ==> File[2] { C:\, D:\ }

同样,在 GUI 应用程序中,图形文件选择对话框通常会屏蔽您免受这个问题的影响。

文件操作

一旦我们有了一个File对象,我们就可以使用它对其表示的文件或目录执行许多标准操作。几个方法让我们向File询问问题。例如,如果File表示一个普通文件,则isFile()返回true,而如果它是一个目录,则isDirectory()返回trueisAbsolute()指示File是否封装了绝对或相对路径规范。相对路径是相对于应用程序的工作目录的。绝对路径是一个系统相关的概念,表示该路径不与工作目录或当前驱动器绑定。在 Unix 和 macOS 中,绝对路径以斜杠开头:/Users/pat/foo.txt。在 Windows 中,它是包括驱动器号的完整路径:C:\Users\pat\foo.txt(而且,再次强调,如果系统中有多个驱动器,则它可以位于与工作目录不同的驱动器上)。

通过getName()getPath()getAbsolutePath()getParent()方法可以获得路径名的各个组成部分。getName()方法返回一个没有任何目录信息的文件名的String。如果File具有绝对路径规范,则getAbsolutePath()返回该路径。否则,它会返回相对路径附加到当前工作目录(尝试将其转换为绝对路径)。getParent()方法返回文件或目录的父目录。

getPath()getAbsolutePath()返回的字符串可能不遵循与底层文件系统相同的大小写约定。你可以通过使用getCanonicalPath()方法来检索文件系统自己的(或“规范的”)版本的文件路径。例如,在 Windows 中,你可以创建一个File对象,它的getAbsolutePath()C:\Autoexec.bat,但它的getCanonicalPath()C:\AUTOEXEC.BAT;两者实际上指向同一个文件。这对于比较文件名或向用户显示文件名很有用。

你可以使用lastModified()setLastModified()方法获取或设置文件或目录的修改时间。该值是一个long,表示自 Unix 纪元(Unix 中的“第一个”日期的名称:1970 年 1 月 1 日 00:00:00 GMT)以来的毫秒数。我们还可以使用length()方法获取文件的大小,以字节为单位。

这里有一段打印文件信息的代码片段:

    File fooFile = new File("/tmp/foo.txt");

    String type = fooFile.isFile() ? "File " : "Directory ";
    String name = fooFile.getName();
    long len = fooFile.length();
    System.out.println(type + name + ", " + len + " bytes ");

如果File对象对应的是一个目录,我们可以使用list()方法或listFiles()方法列出目录中的文件:

    File tmpDir = new File("/tmp");
    String [] fileNames = tmpDir.list();
    File [] files = tmpDir.listFiles();

list()返回一个String对象数组,其中包含文件名。listFiles()返回一个File对象数组。请注意,在任何情况下文件都不保证以任何形式(例如按字母顺序)排序。您可以使用集合 API 按字母顺序对字符串进行排序,如下所示:

    List list = Arrays.asList(fileNames);
    Collections.sort(list);

如果File引用不存在的目录,我们可以使用mkdir()mkdirs()创建目录。mkdir()方法最多创建单个目录级别,因此路径中的任何中间目录都必须已经存在。mkdirs()创建必要的所有目录级别以创建File规范的完整路径。在任何情况下,如果无法创建目录,则该方法返回false。使用renameTo()重命名文件或目录,使用delete()删除文件或目录。

虽然可以使用File对象创建目录,但通常不使用File来创建文件;这通常是在使用FileOutputStreamFileWriter写入数据时隐含完成的,稍后我们会讨论。例外是createNewFile()方法,您可以使用它在File位置创建一个新的零长度文件。

从文件系统的所有其他文件创建操作方面来看,createNewFile()操作是原子的³。Java 从createNewFile()返回一个布尔值,告诉您文件是否已创建。以这种方式创建新文件在您还使用deleteOnExit()的情况下特别有用,后者标记文件在 Java 虚拟机退出时自动删除。此组合允许您保护资源或创建一次只能在单个实例中运行的应用程序。

File类本身相关的另一种文件创建方法是静态方法createTempFile(),它使用自动生成的唯一名称在指定位置创建文件。通常与deleteOnExit()结合使用createTempFile()。网络应用程序经常使用这种组合来创建临时文件,用于存储请求或构建响应。

toURL()方法将文件路径转换为file: URL 对象。URL 是一种抽象,允许您指向网络上任何类型的对象。将File引用转换为 URL 可能对与处理 URL 的更一般实用程序保持一致性有用。例如,Java 的 NIO 使用 URL 引用直接在 Java 代码中实现的新类型的文件系统。

表 10-1 总结了File类提供的方法。

表 10-1. 文件方法

方法 返回类型 描述
canExecute() boolean 文件是否可执行?
canRead() boolean 文件(或目录)是否可读?
canWrite() boolean 文件(或目录)是否可写?
createNewFile() boolean 创建一个新文件。
createTempFile (String pfx, Stringsfx) File 静态方法,在默认临时文件目录中创建一个带有指定前缀和后缀的新文件。
delete() boolean 删除文件(或目录)。
deleteOnExit() Void Java 运行时系统在退出时删除文件。
exists() boolean 文件(或目录)是否存在?
getAbsolutePath() String 返回文件(或目录)的绝对路径。
getCanonicalPath() String 返回文件(或目录)的绝对路径,大小写正确,并且解析了相对元素。
getFreeSpace() long 获取包含此路径的分区上未分配空间的字节数,如果路径无效则返回 0。
getName() String 返回文件(或目录)的名称。
getParent() String 返回文件(或目录)的父目录名称。
getPath() String 返回文件(或目录)的路径。(不要与toPath()混淆。)
getTotalSpace() long 获取包含文件路径的分区的大小(以字节为单位),如果路径无效则返回 0。
getUseableSpace() long 获取包含此路径的分区上用户可访问的未分配空间的字节数,如果路径无效则返回 0。此方法试图考虑用户的写权限。
isAbsolute() boolean 文件名(或目录名)是否是绝对的?
isDirectory() boolean 该项是否为目录?
isFile() boolean 该项是否为文件?
isHidden() boolean 该项是否隐藏?(依赖于系统。)
lastModified() long 返回文件(或目录)的最后修改时间。
length() long 返回文件的长度。
list() String [] 返回目录中文件的列表。
listFiles() File[] 返回目录内容作为File对象数组。
listRoots() File[] 返回根文件系统的数组,如果有的话(例如,C:/,D:/)。
mkdir() boolean 创建目录。
mkdirs() boolean 创建路径中的所有目录。
renameTo(File dest ) boolean 重命名文件(或目录)。
setExecutable() boolean 设置文件的执行权限。
setLastModified() boolean 设置文件(或目录)的最后修改时间。
setReadable() boolean 设置文件的读权限。
setReadOnly() boolean 设置文件为只读状态。
setWriteable() boolean 设置文件的写权限。
toPath() java.nio.file.Path 将文件转换为 NIO 文件路径。(不要与getPath()混淆。)
toURL() java.net.URL 生成文件(或目录)的 URL 对象。

File Streams

你可能已经对文件听得耳朵生茧了,但我们甚至还没有写一个字节呢!现在,让我们开始享受乐趣吧。Java 提供了两种基本流用于从文件中读取和写入:FileInputStreamFileOutputStream。这些流提供了基本的字节导向 InputStreamOutputStream 功能,用于读取和写入文件。它们可以与前面描述的过滤流结合使用,以与其他流通信方式相同的方式处理文件。

可以从 String 路径名或 File 对象创建 FileInputStream

    FileInputStream in = new FileInputStream("/etc/motd");

创建 FileInputStream 时,Java 运行时系统尝试打开指定的文件。因此,如果指定的文件不存在,FileInputStream 构造函数可能会抛出 FileNotFoundException,或者在发生其他 I/O 错误时抛出 IOException。你必须在代码中捕获这些异常。在可能的情况下,习惯上使用 try-with-resources 结构来自动关闭文件是一个好习惯:

  try (FileInputStream fin = new FileInputStream("/etc/motd") ) {
    // ....
    // fin will be closed automatically if needed
    // upon exiting the try clause.
  }

当你首次创建流时,它的 available() 方法和 File 对象的 length() 方法应该返回相同的值。

要将文件中的字符作为 Reader 读取,可以将 InputStreamReader 包装在 FileInputStream 周围。你也可以使用提供的便利类 FileReaderFileReader 实际上只是一个带有一些默认值的 InputStreamReader 包装在 FileInputStream 中。

下面的类 ListIt 是一个小型实用程序,将文件或目录的内容打印到标准输出:

//file: ch10/examples/ListIt.java
import java.io.*;

class ListIt {
  public static void main (String args[]) throws Exception {
    File file =  new File(args[0]);

    if (!file.exists() || !file.canRead()) {
      System.out.println("Can't read " + file);
      return;
    }

    if (file.isDirectory()) {
      String [] files = file.list();
      for (String file : files)
        System.out.println(file);
    } else {
      try {
        Reader ir = new InputStreamReader(
            new FileInputStream(file) );

        BufferedReader in = new BufferedReader(ir);
        String line;
        while ((line = in.readLine()) != null)
          System.out.println(line);
      }
      catch (FileNotFoundException e) {
          System.out.println("File Disappeared");
      }
    }
  }
}

ListIt 从其第一个命令行参数构造一个 File 对象,并测试该 File 是否存在且可读。如果 File 是一个目录,ListIt 输出目录中文件的名称。否则,ListIt 按行读取并输出文件内容。试试看!你能在 ListIt.java 上使用 ListIt 吗?

对于写入文件,可以从 String 路径名或 File 对象创建 FileOutputStream。然而,与 FileInputStream 不同的是,FileOutputStream 构造函数不会抛出 FileNotFoundException。如果指定的文件不存在,FileOutputStream 将创建文件。FileOutputStream 构造函数可能会在发生其他 I/O 错误时抛出 IOException,因此仍然需要处理此异常。

如果指定的文件存在,FileOutputStream 将打开它进行写入。随后调用 write() 方法时,新数据将覆盖文件的当前内容。如果需要向现有文件追加数据,可以使用一个接受布尔型 append 标志的构造函数形式:

    FileOutputStream fooOut =
        new FileOutputStream(fooFile); // overwrite fooFile
    FileOutputStream pwdOut =
        new FileOutputStream("/etc/passwd", true); // append

另一种向文件追加数据的方式是使用 RandomAccessFile,我们将稍后讨论。

与读取一样,如果要向文件写入字符(而不是字节),可以在 FileOutputStream 周围包装一个 OutputStreamWriter。如果要使用默认的字符编码方案,可以使用 FileWriter 类,这是一个方便的选择。

下面的代码从标准输入读取一行数据,并将其写入文件 /tmp/foo.txt

    String s = new BufferedReader(
        new InputStreamReader(System.in) ).readLine();
    File out = new File("/tmp/foo.txt");
    FileWriter fw = new FileWriter (out);
    PrintWriter pw = new PrintWriter(fw);
    pw.println(s);
    pw.close();

注意我们如何将 FileWriter 包装在 PrintWriter 中以便写入数据。此外,作为一个良好的文件系统使用者,在完成操作后调用 close() 方法。在这里,关闭 PrintWriter 也会关闭底层的 Writer

RandomAccessFile

java.io.RandomAccessFile 类提供了在文件中任意位置读取和写入数据的能力。RandomAccessFile 实现了 DataInputDataOutput 接口,因此你可以像使用 DataInputStreamDataOutputStream 一样在文件中任意位置读取和写入字符串和基本类型数据。但是,因为这个类提供对文件数据的随机访问而不是顺序访问,所以它不是 InputStreamOutputStream 的子类。

可以根据 String 路径名或 File 对象创建 RandomAccessFile。构造函数还接受第二个 String 参数,指定文件的模式。使用字符串 "r" 表示只读文件,使用 "rw" 表示读/写文件:

    try {
      RandomAccessFile users = new RandomAccessFile("Users", "rw")
    } catch (IOException e) { ... }

当以只读模式创建 RandomAccessFile 时,Java 尝试打开指定的文件。如果文件不存在,RandomAccessFile 会抛出 IOException。然而,如果以读/写模式创建 RandomAccessFile,如果文件不存在,对象会创建该文件。构造函数仍然可能因为其他 I/O 错误而抛出 IOException,因此你仍然需要处理这个异常。

创建了 RandomAccessFile 后,你可以调用任何常规的读取和写入方法,就像使用 DataInputStreamDataOutputStream 一样。如果尝试向只读文件写入数据,写入方法会抛出 IOException

RandomAccessFile 的特殊之处在于 seek() 方法。这个方法接受一个 long 值,并将其用于设置文件中的读写位置。你可以使用 getFilePointer() 方法来获取当前位置。如果需要向文件末尾追加数据,可以使用 length() 确定位置,然后 seek() 到该位置。你可以写入或定位到文件末尾以外的位置,但不能从文件末尾以外读取。如果尝试这样做,read() 方法会抛出 EOFException 异常。

下面是一个简单数据库写入数据的示例:

    users.seek(userNum * RECORDSIZE);
    users.writeUTF(userName);
    users.writeInt(userID);

在这段代码中,我们假设 userNameString 长度以及其后的任何数据都适合指定的记录大小内。

新 I/O 文件 API

现在我们将注意力从原始的“经典”Java 文件 API 转向 NIO 文件 API。正如我们前面提到的,NIO 文件 API 可以被视为经典 API 的替代或补充。新 API 将 Java 移向更高性能和更灵活的 I/O 风格,支持可选择的和异步可中断的通道(后面将详细讨论选择和使用通道)。在处理文件时,新 API 的优势在于在 Java 中提供了更完整的文件系统抽象。

除了更好地支持现有的、真实世界中的文件系统类型——包括新的和受欢迎的复制和移动文件、管理链接以及获取详细文件属性如所有者和权限的能力——NIO 允许您直接在 Java 中实现全新类型的文件系统。最好的例子是 ZIP 文件系统提供者。您可以将 ZIP 归档文件“挂载”为文件系统。您可以使用标准 API 直接在归档文件中处理文件,就像处理任何其他文件系统一样。

NIO 文件包还提供了一些工具,这些工具多年来可以节省 Java 开发人员大量重复的代码,包括目录树变更监视、文件系统遍历、文件名“匹配”(使用通配符匹配文件名的行话)以及直接将整个文件读取到内存的便利方法。

我们将在本节介绍基本的 NIO 文件 API,并在章节末回到缓冲区和通道的主题。特别是,我们将讨论ByteChannelFileChannel,您可以将其视为用于读取和写入文件和其他类型数据的备选、基于缓冲区的流。

FileSystem 和 Path

java.nio.file 包中有三个主要角色:

FileSystem

Path 是底层存储机制并且作为Path对象的工厂。

FileSystems

FileSystem 对象的工厂。

Path

文件系统中文件或目录的位置。

Files

一个实用类,包含一组丰富的静态方法,用于操作 Path 对象以执行与经典 API 类似的所有基本文件操作。

FileSystems(复数形式)类是我们的起点。让我们创建几个文件系统:

    // The default host computer filesystem
    FileSystem fs = FileSystems.getDefault();

    // A custom filesystem for ZIP files, no special properties
    Map<String,String> props = new HashMap<>();
    URI zipURI = URI.create("jar:file:/Users/pat/tmp/MyArchive.zip");
    FileSystem zipfs = FileSystems.newFileSystem(zipURI, props);

正如本代码片段所示,我们请求默认的文件系统来在主机环境中操作文件。我们还使用FileSystems类来构建另一个FileSystem,通过一个统一资源标识符(或 URI,类似于 URL 的特殊标识符),该标识符引用自定义文件系统类型。我们使用jar:file作为我们的 URI 协议,以指示我们正在处理 JAR 或 ZIP 文件。

FileSystem 实现了 Closeable,当关闭一个 FileSystem 时,所有与其关联的打开文件通道和其他流对象也将被关闭。在那时尝试读取或写入这些通道将抛出异常。请注意,默认文件系统(与主机计算机关联)无法关闭。

一旦有了FileSystem,就可以将其用作代表文件或目录的Path对象的工厂。您可以使用字符串表示法获取Path,就像经典的File类一样。随后,您可以使用Files实用程序的方法创建、读取、写入或删除该项:

    Path fooPath = fs.getPath("/tmp/foo.txt");
    OutputStream out = Files.newOutputStream(fooPath);

此示例打开一个OutputStream以写入文件foo.txt。默认情况下,如果文件不存在,它将被创建;如果文件已存在,则在写入新数据之前将其截断(设置为零长度)—但您可以使用选项更改这些结果。我们将在下一节中详细讨论Files方法。

Path类实现了java.lang.Iterable接口,可用于迭代其字面路径组件,例如前面片段中的斜杠分隔的tmpfoo.txt。(如果要遍历路径以查找其他文件或目录,则可能更感兴趣的是我们稍后将讨论的DirectoryStreamFileVisitor。)Path还实现了java.nio.file.Watchable接口,允许对其进行监视以进行更改。

Path具有方便的方法来解析相对于文件或目录的路径:

    Path patPath =  fs.getPath("/User/pat/");

    Path patTmp = patPath.resolve("tmp"); // "/User/pat/tmp"

    // Same as above, using a Path
    Path tmpPath = fs.getPath("tmp");
    Path patTmp = patPath.resolve(tmpPath); // "/User/pat/tmp"

    // Resolving a given absolute path against any path just yields given path
    Path absPath = patPath.resolve("/tmp"); // "/tmp"

    // Resolve sibling to Pat (same parent)
    Path danPath = patPath.resolveSibling("dan"); // "/Users/dan"

在此片段中,我们展示了Path方法resolve()resolveSibling()用于查找相对于给定Path对象的文件或目录。resolve()方法通常用于将相对路径附加到表示目录的现有Path。如果提供给resolve()方法的参数是绝对路径,则仅会生成绝对路径(它的工作方式类似于 Unix 或 DOS 的cd命令)。resolveSibling()方法的工作方式相同,但是它相对于目标Path的父级;此方法对于描述move()操作的目标非常有用。

经典文件路径和返回

为了连接经典和新 API,分别在java.io.Filejava.nio.file.Path中提供了相应的toPath()toFile()方法,以将其转换为另一种形式。当然,从File生成的Path类型只能是默认主机文件系统中表示文件和目录的路径:

    Path tmpPath = fs.getPath("/tmp");
    File file = tmpPath.toFile();
    File tmpFile = new File("/tmp");
    Path path = tmpFile.toPath();

NIO 文件操作

一旦有了Path,我们可以使用Files实用程序的静态方法对其进行操作,以将路径创建为文件或目录,读取和写入它,并查询和设置其属性。我们将列出大部分方法,然后在进一步讨论一些更重要的方法。

表 10-2 总结了java.nio.file.Files类的这些方法。由于Files类处理所有类型的文件操作,因此它包含大量方法。为了使表格更易读,我们省略了相同方法的重载形式(接受不同类型参数的方法),并将对应及相关类型的方法组合在一起。

表 10-2. NIO Files 方法

方法 返回类型 描述
copy() long 或 Path 将流复制到文件路径、文件路径到流,或路径到路径。返回复制的字节数或目标 Path。如果目标文件存在,可以选择替换(默认为存在时操作失败)。复制目录将在目标处生成空目录(不复制内容)。复制符号链接会复制链接文件的数据(产生常规文件复制)。
createDirectory(), createDirectories() Path 创建单个目录或指定路径中的所有目录。如果目录已存在,createDirectory() 会抛出异常。createDirectories() 则会忽略已存在的目录,仅在需要时创建。
createFile() Path 创建一个空文件。此操作是原子性的,只有在文件不存在时才会成功。(此属性可用于创建标志文件以保护资源等。)
createLink(), createSymbolicLink(), isSymbolicLink(), readSymbolicLink(), createLink() boolean 或 Path 创建硬链接或符号链接,检测文件是否为符号链接,或读取符号链接指向的目标文件。符号链接是指引用其他文件的文件。普通(“硬”)链接是文件的低级镜像,其中两个文件名指向相同的底层数据。如果不确定使用哪种,建议使用符号链接。
createTempDirectory(), createTempFile() Path 创建一个带有指定前缀的临时目录或文件,确保名称唯一。可选择将其放置在系统默认的临时目录中。
delete(), deleteIfExists() void 删除文件或空目录。deleteIfExists() 如果文件不存在则不会抛出异常。
exists(), notExists() boolean 判断文件是否存在(notExists() 返回其相反值)。可选择是否跟踪链接(默认是跟踪)。
getAttribute(), set​Attri⁠bute(), getFile​Attri⁠buteView(), readAttributes() Object, MapFileAttributeView 获取或设置特定于文件系统的文件属性,如访问和更新时间、详细权限和所有者信息,使用实现特定的名称。
getFileStore() FileStore 获取表示路径所在文件系统上的设备、卷或其他类型分区的 FileStore 对象。
getLastModifiedTime(), setLastModifiedTime() FileTimePath 获取或设置文件或目录的最后修改时间。
getOwner(), setOwner() UserPrincipal 获取或设置代表文件所有者的 UserPrincipal 对象。使用 toString()getName() 获取用户名的字符串表示形式。
getPosixFile​Permis⁠sions(), setPosixFilePermissions() SetPath 获取或设置路径的完整 POSIX 用户-组-其他样式读写权限,作为 PosixFile​Per⁠mission 枚举值的集合。
isDirectory(), isExecutable(), isHidden(), isReadable(), isRegularFile(), isWritable() boolean 测试文件特性,如路径是否为目录和其他基本属性。
isSameFile() boolean 检查两个路径是否引用同一个文件(即使路径不完全相同也可能为真)。
move() Path 通过重命名或复制移动文件或目录,可选择是否替换现有目标。通常使用重命名,但如果需要在文件存储或文件系统之间复制文件以移动文件,则必须进行复制。仅当简单重命名可能或目录为空时,才能使用此方法移动目录。如果目录移动需要跨文件存储或文件系统复制文件,则方法会抛出 IOException。(在这种情况下,您必须自行复制文件。参见 walkFileTree()。)
newBufferedReader(), newBufferedWriter() BufferedReaderBufferedWriter 通过 BufferedReader 打开文件进行读取,或通过 BufferedWriter 创建并打开文件进行写入。在两种情况下都要指定字符编码。
newByteChannel() SeekableByteChannel 创建一个新文件或打开一个现有文件作为可寻址的字节通道。(请参见本章后面有关 NIO 的完整讨论。)考虑使用 FileChannel.open() 作为替代方案。
newDirectoryStream() DirectoryStream 返回用于遍历目录层次结构的 DirectoryStream。可选择提供 glob 模式或过滤器对象以匹配文件。
newInputStream(), newOutputStream() InputStreamOutputStream 通过 InputStream 打开文件进行读取,或通过 OutputStream 创建并打开文件进行写入。可选择指定输出流的文件截断;如果要覆盖写入,则默认为截断现有文件。
probeContentType() String 如果能够通过安装的 FileTypeDetector 服务确定文件的 MIME 类型,则返回该类型;否则返回 null
readAllBytes(), readAllLines() byte[] 或 List<String> 使用指定的字符编码从文件中读取所有数据为字节数组或所有字符作为字符串列表。
size() long 获取指定路径文件的字节大小。
walkFileTree() Path FileVisitor 应用于指定的目录树,可选择是否跟随链接以及遍历的最大深度。
write() Path 将字节数组或字符串集合(使用指定的字符编码)写入到指定路径的文件中,并关闭文件,可选择追加和截断行为。默认情况下是截断并写入数据。

使用这些方法,我们可以获取给定文件的输入或输出流,或者使用缓冲读写器和写入器。我们还可以将路径创建为文件和目录,并遍历文件层次结构。我们将在下一节讨论目录操作。

作为提醒,Pathresolve()resolveSibling()方法对于构建copy()move()操作的目标非常有用:

    // Move the file /tmp/foo.txt to /tmp/bar.txt
    Path foo = fs.getPath("/tmp/foo.txt");
    Files.move(foo, foo.resolveSibling("bar.txt"));

为了快速读取和写入文件内容而不使用流,我们可以使用各种readAll…​write方法,在单个操作中移动字节数组或字符串进出文件:

    // Read and write collection of String (e.g., lines of text)
    Charset asciiCharset = Charset.forName("US-ASCII");
    List<String> csvData = Files.readAllLines(csvPath, asciiCharset);
    Files.write(newCSVPath, csvData, asciiCharset);

    // Read and write bytes
    byte [] data = Files.readAllBytes(dataPath);
    Files.write(newDataPath, data);

这些对于容易适应内存的文件非常方便。

NIO 包

让我们回到java.nio包,完善我们关于核心 Java I/O 的讨论。NIO 的一个方面就是简单地更新和增强经典的java.io包的功能。实际上,许多通用的 NIO 功能确实与现有的 API 重叠。然而,NIO 首先引入是为了解决大系统的可伸缩性问题,特别是在网络应用中。接下来的几节概述了 NIO 的基本要素。

异步 I/O

大多数对 NIO 包的需求驱动力来自于希望在 Java 中添加非阻塞可选择的 I/O。在 NIO 出现之前,Java 中的大多数读写操作都绑定到线程,并被迫阻塞不确定的时间。尽管某些 API(如套接字,我们将在“套接字”中看到)提供了特定的方法来限制 I/O 调用的持续时间,但这只是一种弥补缺乏更一般机制的权宜之计。在许多语言中,即使没有线程,也可以通过将 I/O 流设置为非阻塞模式并测试它们是否准备好发送或接收数据来高效地进行 I/O。在非阻塞模式下,读取或写入只完成可以立即执行的工作——填充或清空缓冲区然后返回。结合测试准备就绪的能力,这使得单线程应用程序可以高效地连续服务许多通道。主线程“选择”一个准备好的通道,与之协作直到它阻塞,然后转移到另一个通道。在单处理器系统上,这与使用多线程基本上是等效的。

除了非阻塞和可选择的 I/O 外,NIO 包还能够异步关闭和中断 I/O 操作。正如在第九章讨论的那样,在 NIO 出现之前,没有可靠的方法来停止或唤醒在 I/O 操作中阻塞的线程。使用 NIO 后,被阻塞在 I/O 操作中的线程总是在被中断或另一个线程关闭通道时唤醒。此外,如果在线程阻塞在 NIO 操作时中断该线程,其通道将自动关闭。(因为线程中断而关闭通道可能看起来过于严格,但通常这样做是正确的。保持通道打开可能导致意外行为或使通道受到不必要的操纵。)

性能

I/O 通道设计围绕缓冲区的概念,这是一种专门用于通信任务的复杂数组形式。NIO 包支持直接缓冲区的概念 —— 这些缓冲区在主机操作系统中维护它们的内存而不是在 Java 虚拟机内部。因为所有真实的 I/O 操作最终都必须通过在主机操作系统中维护缓冲区空间来工作,使用直接缓冲区可以使许多操作更有效率。在两个外部端点之间传输的数据可以在不先复制到 Java 中再返回的情况下进行转移。

映射和锁定文件

NIO 提供了两个在java.io中找不到的通用文件相关功能:内存映射文件和文件锁定。内存映射文件表现得好像它的所有内容都在内存中的数组中而不是在磁盘上。内存映射文件超出了本章的范围,但如果你处理大量数据并且偶尔需要非常快的读/写访问,请在线查阅MappedByteBuffer文档

文件锁支持文件区域上的共享和排他锁 —— 对于多个应用程序的并发访问非常有用。我们将在“文件锁定”中讨论文件锁定。

通道

虽然java.io处理流,java.nio处理通道。通道是通信的端点。虽然实际上通道与流类似,但通道的基本概念同时更抽象和更原始。java.io中的流根据读取或写入字节的方法定义,而基本通道接口并不涉及通信的方式。它只是具有打开或关闭的概念,通过isOpen()close()方法支持。然后为文件、网络套接字或任意设备的通道实现添加自己的操作方法,如读取、写入或传输数据。NIO 提供以下通道:

  • FileChannel

  • Pipe.SinkChannel, Pipe.SourceChannel

  • SocketChannel, ServerSocketChannel, DatagramChannel

我们将在“文件通道”中涵盖FileChannel及其异步姊妹AsynchronousFileChannel。(异步版本通过线程池缓冲其所有操作,并通过异步 API 报告结果。)Pipe通道只是java.io Pipe工具的通道等价物。套接字和数据报通道参与 Java 的网络世界,我们将在第十三章中进行讨论。与网络相关的通道也有异步版本:AsynchronousSocketChannel, AsynchronousServerSocketChannelAsynchronousDatagramChannel

所有这些基本通道都实现了ByteChannel接口,该接口设计用于具有读取和写入方法(如 I/O 流)的通道。然而,ByteChannel读取和写入ByteBuffer,而不是简单的字节数组。

除了这些通道实现外,您还可以使用java.io I/O 流和读写器与通道进行桥接,以实现互操作性。然而,如果混合使用这些功能,您可能无法获得 NIO 提供的全部好处和性能。

缓冲区

大多数java.iojava.net包的实用程序操作的是字节数组。NIO 包的对应工具是围绕ByteBuffer(对于文本,还有一个基于字符的缓冲区CharBuffer)构建的。字节数组很简单,为什么需要缓冲区?它们具有几个目的:

  • 它们规范了缓冲数据的使用模式,提供了诸如只读缓冲区之类的功能,并跟踪大缓冲区空间内的读/写位置和限制。它们还提供了类似于java.io​.BufferedInputStream的标记/重置功能。

  • 它们提供了用于处理表示原始类型的原始数据的附加 API。您可以创建“查看”您的字节数据为一系列较大原始类型(如shortintfloat)的缓冲区。最通用的数据缓冲区类型ByteBuffer包括让您像DataInputStreamDataOutputStream对流所做的那样读取和写入所有原始类型的方法。

  • 它们抽象了数据的底层存储,允许 Java 优化吞吐量。具体而言,缓冲区可以分配为直接缓冲区,这些直接缓冲区使用主机操作系统的本机缓冲区,而不是 Java 内存中的数组。与缓冲区一起工作的 NIOChannel工具可以自动识别直接缓冲区,并尝试优化与它们的交互。例如,从文件通道读取到 Java 字节数组通常需要 Java 将数据复制为从主机操作系统到 Java 内存的读取。使用直接缓冲区,数据可以保留在主机操作系统中,超出 Java 正常内存空间,直到需要为止。

缓冲区操作

基本java.nio.Buffer类有点像具有状态的数组。它不指定它保存的元素类型(由子类型决定),但它确实定义了所有数据缓冲区通用的功能。缓冲区具有固定大小,称为容量。尽管所有标准缓冲区都提供对其内容的“随机访问”,但缓冲区通常期望按顺序读取和写入,因此缓冲区维护下一个元素被读取或写入的位置的概念。除了位置,缓冲区还可以维护另外两个状态信息:限制,在读模式下通常表示可用数据,在写模式下表示文件的容量;以及一个标记,可用于记住未来回忆的早期位置。

Buffer的实现添加了特定的类型化获取和放置方法,用于读取和写入缓冲区内容。例如,ByteBuffer是字节的缓冲区,它有get()put()方法用于读取和写入字节及字节数组(以及许多其他有用的方法,稍后我们将讨论)。从Buffer获取或放置数据会改变位置标记,因此Buffer类似于流一样跟踪其内容。试图读取或写入超出限制标记的数据会生成BufferUnderflowExceptionBufferOverflowException异常。

标记、位置、限制和容量的值始终遵守以下公式:

    mark <= position <= limit <= capacity

Buffer的读写位置始终在标记(mark)和限制(limit)之间,其中标记作为下界,限制作为上界。容量表示缓冲区空间的物理范围。

您可以使用position()limit()方法显式设置位置和限制标记。提供了几个便利方法用于常见的使用模式。reset()方法将位置重置为标记。如果未设置标记,则会抛出InvalidMarkException异常。clear()方法将位置重置为0,并将限制设置为容量,准备好接收新数据(标记被丢弃)。请注意,clear()方法实际上并不对缓冲区中的数据执行任何操作;它只是改变位置标记。

flip()方法用于常见模式,将数据写入缓冲区,然后再读取出来。flip方法将当前位置设置为限制,并将当前位置重置为0(任何标记都被丢弃),这样就不需要跟踪读取了多少数据。另一个方法rewind()简单地将位置重置为0,但保持限制不变。您可以使用它再次写入相同大小的数据。以下是使用这些方法从一个通道读取数据并写入两个通道的代码片段:

    ByteBuffer buff = ...
    while (inChannel.read(buff) > 0) { // position = ?
      buff.flip();    // limit = position; position = 0;
      outChannel.write(buff);
      buff.rewind();  // position = 0
      outChannel2.write(buff);
      buff.clear();   // position = 0; limit = capacity
    }

第一次看可能会让人困惑,因为在这里,从Channel读取实际上是向Buffer写入,反之亦然。因为此示例将所有可用数据写入限制,所以在这种情况下,flip()rewind()具有相同的效果。

缓冲区类型

各种缓冲区实现添加了用于读写特定数据类型的获取和放置方法。每个 Java 原始类型都有一个关联的缓冲区类型:ByteBufferCharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer。每个类型都提供了用于读取和写入其类型及其类型数组的获取和放置方法。其中,ByteBuffer是最灵活的,因为它具有所有缓冲区中最细粒度的"get"和"put"方法,用于读写除byte外的所有其他数据类型。以下是一些ByteBuffer的方法:

    byte get()
    char getChar()
    short getShort()
    int getInt()
    long getLong()
    float getFloat()
    double getDouble()

    void put(byte b)
    void put(ByteBuffer src)
    void put(byte[] src, int offset, int length)
    void put(byte[] src)
    void putChar(char value)
    void putShort(short value)
    void putInt(int value)
    void putLong(long value)
    void putFloat(float value)
    void putDouble(double value)

所有标准缓冲区也支持随机访问。对于ByteBuffer的上述每种方法,还有一个带索引的额外形式,例如:

    getLong(int index)
    putLong(int index, long value)

但这还不是全部!ByteBuffer还可以提供它自己的“视图”,作为任何粗粒度类型。例如,你可以用asShortBuffer()方法从ByteBuffer获取一个ShortBuffer视图。ShortBuffer视图是由ByteBuffer支持的,这意味着它们在相同的数据上工作,对其中一个的更改会影响另一个。视图缓冲区的范围从ByteBuffer的当前位置开始,其容量是剩余字节数除以新类型的大小。 (例如,每个short占两个字节,每个float占四个字节,每个longdouble占八个字节。)视图缓冲区对于在ByteBuffer内读取和写入大块连续类型数据非常方便。

CharBuffer也很有趣,主要是因为它们与String的集成。CharBufferString都实现了java.lang.CharSequence接口。这个接口提供了标准的charAt()length()方法。Java 的许多其他部分(比如java.util.regex包)允许你可以互换地使用CharBufferString。在这种情况下,CharBuffer就像一个可修改的String,具有用户可配置的逻辑起始和结束位置。

字节顺序

因为我们正在讨论大于一个字节的类型的读写,所以问题就来了:多字节值(比如shortint)的字节写入顺序是什么?在这个世界上有两个派别:大端序小端序。[⁵] 大端序意味着最重要的字节首先出现;小端序则相反。如果你要写入某些本地应用程序消费的二进制数据,这一点非常重要。兼容 Intel 的计算机使用小端序,许多运行 Unix 的工作站使用大端序。ByteOrder类封装了这个选择。你可以通过ByteBuffer order()方法指定要使用的字节顺序,使用标识符ByteOrder.BIG_ENDIANByteOrder.LITTLE_ENDIAN,例如:

    byteArray.order(ByteOrder.BIG_ENDIAN);

你可以使用静态方法ByteOrder.nativeOrder()获取你的平台的本地字节顺序。我们知道你很好奇:

jshell> import java.nio.ByteOrder;

jshell> ByteOrder.nativeOrder()
$4 ==> LITTLE_ENDIAN

我们在一台装有英特尔芯片的 Linux 桌面上运行了这个程序。你也可以在自己的系统上试试看!

分配缓冲区

你可以通过显式分配使用allocate()或者通过包装现有的普通 Java 数组类型来创建缓冲区。每种缓冲区类型都有一个静态的allocate()方法,接受一个容量(大小),以及一个wrap()方法,接受一个现有的数组:

    CharBuffer cbuf = CharBuffer.allocate(64*1024);
    ByteBuffer bbuf = ByteBuffer.wrap(someExistingArray);

直接缓冲区的分配方式与allocateDirect()方法相同:

    ByteBuffer bbuf2 = ByteBuffer.allocateDirect(64*1024);

正如我们之前描述的那样,直接缓冲区可以使用操作系统的内存结构,这些结构针对某些类型的 I/O 操作进行了优化。这样做的权衡是,分配直接缓冲区比普通缓冲区的操作稍慢且更重量级,因此您应该尽量将它们用于长期缓冲区。

字符编码器和解码器

字符编码器和解码器将字符转换为原始字节,反之亦然,将 Unicode 标准映射到特定的编码方案。在 Java 中,编码器和解码器早已存在,供 ReaderWriter 流使用,并在 String 类的处理字节数组的方法中使用。然而,早期并没有用于显式处理编码的 API;您只需按名称将编码器和解码器引用到需要的地方作为 Stringjava.nio.charset 包使用 Charset 类正式化了 Unicode 字符集编码的概念。

Charset 类是一个 Charset 实例的工厂,它们知道如何将字符缓冲区编码为字节缓冲区,并解码字节缓冲区为字符缓冲区。您可以使用静态 Charset.forName() 方法按名称查找字符集并在转换中使用它:

    Charset charset = Charset.forName("US-ASCII");
    CharBuffer charBuff = charset.decode(byteBuff);  // to ascii
    ByteBuffer byteBuff = charset.encode(charBuff);  // and back

您还可以使用静态 Charset.isSupported() 方法测试编码是否可用。

以下字符集是保证提供的:

  • US-ASCII

  • ISO-8859-1

  • UTF-8

  • UTF-16BE(大端序)

  • UTF-16LE(小端序)

  • UTF-16

您可以使用静态 availableCharsets() 方法列出平台上提供的所有编码器:

    Map map = Charset.availableCharsets();
    Iterator it = map.keySet().iterator();
    while (it.hasNext())
      System.out.println(it.next());

availableCharsets() 的结果是一个映射,因为字符集可能具有“别名”并且可能出现在多个名称下。

除了 java.nio 包的面向缓冲区的类之外,java.io 包的 InputStreamReaderOutputStreamWriter 桥接类也与 Charset 一起工作。您可以指定编码作为 Charset 对象或名称。

CharsetEncoder 和 CharsetDecoder

通过使用 Charset newEncoder()newDecoder() 方法创建 CharsetEncoderCharsetDecoder(一个编解码器),您可以更加控制编码和解码过程。在前面的片段中,我们假设所有数据都在单个缓冲区中可用。然而,更常见的情况是我们可能需要按块处理数据。编码器/解码器 API 通过提供更一般的 encode()decode() 方法来允许这样做,这些方法接受一个标志,指定是否期望更多数据。编解码器需要知道这一点,因为在数据耗尽时可能会中断多字节字符转换。如果它知道还有更多数据要到来,它不会因为这种不完整的转换而抛出错误。

在以下片段中,我们使用解码器从 ByteBuffer bbuff 中读取并将字符数据累积到 CharBuffer cbuff 中:

    CharsetDecoder decoder = Charset.forName("US-ASCII").newDecoder();

    boolean done = false;
    while (!done) {
      bbuff.clear();
      done = (in.read(bbuff) == -1);
      bbuff.flip();
      decoder.decode(bbuff, cbuff, done);
    }
    cbuff.flip();
    // use cbuff. . .

在这里,我们在in通道上寻找输入结束条件来设置done标志。注意,我们利用ByteBufferflip()方法来设置数据读取的限制并重置位置,以便一步完成解码操作。在遇到问题时,encode()decode()方法都会返回一个结果对象CoderResult,它可以确定编码的进度。CoderResultisError()isUnderflow()isOverflow()方法分别指定编码停止的原因:错误、输入缓冲区中字节不足或输出缓冲区已满。

FileChannel

现在我们已经介绍了通道和缓冲区的基础知识,是时候看一看真正的通道类型了。FileChannel是 NIO 中java.io.RandomAccessFile的等效物,但它除了性能优化外,还提供了几个增强功能。特别是,如果需要使用文件锁定、内存映射文件访问或高度优化的文件和网络通道之间的数据传输,可以使用FileChannel代替简单的java.io文件流。这些都是相当高级的用例,但如果你从事后端工作或处理大量数据,它们肯定会派上用场。

使用静态的FileChannel.open()方法可以为Path创建一个FileChannel

    FileSystem fs = FileSystems.getDefault();
    Path p = fs.getPath("/tmp/foo.txt");

    // Open default for reading
    try (FileChannel channel = FileChannel.open(p)) {
      // read from the channel ...
    }

    // Open with options for writing
    import static java.nio.file.StandardOpenOption.*;

    try (FileChannel channel =
        FileChannel.open(p, WRITE, APPEND, ...) ) {
      // append to foo.txt if it already exists,
      // otherwise, create it and start writing ...
    }

默认情况下,open()创建一个文件的只读通道。我们可以通过传递额外的选项来打开写入或追加通道,并控制其他更高级的特性,如前面示例的第二部分所示。表格 10-3 总结了这些选项。

表格 10-3. java.nio.file.StandardOpenOption

Option Description
APPEND 打开文件以进行写入;所有写操作定位于文件末尾。
CREATE WRITE一起使用,打开文件并在需要时创建它。
CREATE_NEW WRITE一起使用,原子性地创建文件;如果文件已存在,则操作失败。
DELETE_ON_CLOSE 尝试在关闭文件时或在虚拟机退出时删除文件(如果文件已打开)。
READ, WRITE 以只读或只写(默认为只读)模式打开文件。使用两者可进行读写操作。
SPARSE 在创建新文件时使用;请求文件是稀疏的。在支持的文件系统上,稀疏文件可以处理非常大且大部分为空的文件,而不会为空部分分配太多实际存储空间。
SYNC, DSYNC 在可能的情况下,保证写操作阻塞,直到所有数据写入存储介质。SYNC会对所有文件更改(包括数据和元数据(属性))执行此操作,而DSYNC仅对文件的数据内容添加此要求。
TRUNCATE_EXISTING 对现有文件使用WRITE;在打开文件时将文件长度设为零。

FileChannel也可以通过经典的FileInputStreamFileOutputStreamRandomAccessFile构造:

    FileChannel readOnlyFc = new FileInputStream("file.txt")
        .getChannel();
    FileChannel readWriteFc = new RandomAccessFile("file.txt", "rw")
        .getChannel();

从这些文件输入流和输出流创建的FileChannel分别是只读或只写的。要获取读/写FileChannel,必须像前面的示例一样使用读/写选项构造RandomAccessFile

使用FileChannel就像使用RandomAccessFile一样,但它使用ByteBuffer而不是字节数组:

    ByteBuffer bbuf = ByteBuffer.allocate(...);
    bbuf.clear();
    readOnlyFc.position(index);
    readOnlyFc.read(bbuf);
    bbuf.flip();
    readWriteFc.write(bbuf);

您可以通过设置缓冲区的位置和限制标记或使用另一种接受缓冲区起始位置和长度的读/写形式来控制读取和写入的数据量。您还可以通过提供索引与读写方法来随机读写到某个位置:

    readWriteFc.read(bbuf, index)
    readWriteFc.write(bbuf, index2);

在每种情况下,实际读取或写入的字节数取决于几个因素。该操作试图读取或写入缓冲区的限制,绝大多数情况下,这就是本地文件访问的情况。该操作仅保证阻塞,直到至少处理了一个字节。无论发生什么,返回处理的字节数并相应更新缓冲区位置,准备重复操作直到完成(如果需要)。这是使用缓冲区的便利之一;它们可以为您管理计数。与标准流一样,通道的read()方法在达到输入结束时返回-1

使用size()方法始终可以获取文件的大小。如果您写入超出文件末尾,文件大小可能会更改。反之,您可以使用truncate()方法将文件截断为指定的长度。

并发访问

FileChannel可安全地供多个线程使用,并保证在同一虚拟机中的通道之间对数据的一致视图。但是,除非指定了SYNCDSYNC选项,否则通道不保证写入的传播速度。如果您只偶尔需要确保数据在继续之前是安全的,可以使用force()方法将更改刷新到磁盘。此方法接受一个布尔参数,指示是否必须包括文件元数据,包括时间戳和权限。某些系统跟踪文件的读取以及写入,因此,如果将标志设置为false,表示您不关心立即同步该元数据,则可以节省大量更新。

与所有Channel一样,任何线程都可以关闭FileChannel。一旦关闭,所有通道的读/写和位置相关方法都会抛出ClosedChannelException

文件锁定

通过lock()方法,FileChannel支持对文件区域进行独占锁定和共享锁定。

    FileLock bigLock = fileChannel.lock();

    int start = 0, len = fileChannel2.size();
    FileLock readLock = fileChannel2.lock(start, len, true);

锁可以是共享的或独占的。独占 锁可阻止其他人在指定文件或文件区域上获取任何类型的锁。共享 锁允许其他人获取重叠的共享锁,但不允许获取独占锁。这些分别用作写锁和读锁。在写入时,您不希望其他人能够写入直到您完成,但在读取时,您只需要阻止其他人写入,而不是阻止其他人读取。

在前面的例子中,lock() 方法没有参数尝试获取整个文件的独占锁。第二种形式接受起始和长度参数,以及指示锁定是共享(true)还是独占(false)的标志。lock() 方法返回的 FileLock 对象可用于释放锁定:

    bigLock.release();
警告

文件锁仅能保证是协作的。它们仅在所有线程都遵守它们时起作用;它们不一定能阻止非协作线程读取或写入已锁定的文件。一般而言,保证锁被遵守的唯一方法是双方尝试获取锁,并仅在尝试成功后继续。

此外,某些系统上未实现共享锁定,此时所有请求的锁都将是独占的。您可以使用 isShared() 方法测试锁是否为共享的。

FileChannel 锁定持有直到通道关闭或中断,因此在 try-with-resources 语句中执行锁定将有助于更可靠地释放锁定:

  try (FileChannel channel = FileChannel.open(p, WRITE) ) {
    channel.lock();
    // ...
  }

FileChannel 示例

让我们看一些通道和缓冲区的具体用法。我们将创建一个小型文本文件,其中包含我们的程序访问该文件的次数计数。然后,我们将打开文件,读取当前计数,增加该计数,然后将计数重新写入(实际上是覆盖)文件。您可以在 ch10/examples 文件夹中的 AccessNIO.java 文件中尝试下面片段的完整版本。

注意

您完全可以使用 java.io 中的标准 I/O 类来处理此项目。NIO 套件并非旨在完全替换旧类,而是在不破坏依赖这些类的代码的情况下添加缺失的功能。如果您觉得 NIO 有点复杂或密集,可以在需要使用文件锁定或操作元数据等缺失功能时再考虑使用。

我们的第一个任务是查看我们的计数文件是否存在(本例中为 access.txt,但名称是任意的)。如果不存在,我们需要创建它(并将内部访问计数器设置为 1)。我们可以使用 Path 对象和 Files 静态帮助方法来开始:

public class AccessNIO {
  String accessFileName = "access.txt";
  Path   accessFilePath = Path.of(accessFileName);
  int    accessCount = 0;
  FileChannel accessChannel;

  public AccessNIO() {
    // ...
    boolean initial = !Files.exists(accessFilePath);
    accessChannel = FileChannel.open(accessFilePath, CREATE, READ, WRITE);
    // ...
  }
}

如果文件尚不存在,我们可以写入一个初始消息(“此文件已访问 0 次。”),然后倒回到新文件的开头。这样我们就有了从一开始文件就存在的基准:

    if (initial) {
      String msg = buildMessage(); // helper for consistency
      accessChannel.write(ByteBuffer.wrap(msg.getBytes()));
      accessChannel.position(0);
    }

如果文件已经存在,我们需要确保能够从中读取并向其中写入。我们可以通过构造函数中创建的 accessChannel 对象收集这些信息。当然,我们可以添加其他测试和更详细的错误消息,但这些最小的检查非常有用:

  public boolean isReady() {
    return (accessChannel != null && accessChannel.isOpen());
  }

现在我们来到我们的主要用例。文件已经存在并且具有一些内容。我们拥有我们想做的一切适当的权限。我们将以读/写模式打开文件,并将其内容读入字符串中:

    int fsize = (int)accessChannel.size();
    // Give ourselves extra room in case the count
    // goes over a digit boundary (9 -> 10, 99 -> 100, etc.)
    ByteBuffer in = ByteBuffer.allocate(fsize + 2);
    accessChannel.read(in);
    String current = new String(in.array());

我们希望文件本身是人类可读的,因此我们不会利用 FileChannel 读写二进制数据的能力。我们可以利用我们对单行文本结构的了解来解析我们的访问计数:

    int countStart = 28;
    // We know where the count number starts, so get
    // everything from that position to the next space
    String rawCount = current.substring(countStart,
        current.indexOf(" ", countStart));
    accessCount = Integer.parseInt(rawCount) + 1;

最后,我们可以重置位置并用新的更新行覆盖前一行。请注意,我们还截断文件以保存消息的末尾。我们留出了额外的空间以容纳更大的数字,但我们不希望实际文件中存在多余的空间:

    String msg = buildMessage();
    accessChannel.position(0);
    accessChannel.write(ByteBuffer.wrap(msg.getBytes()));
    accessChannel.truncate(accessChannel.position());
    accessChannel.close();

尝试多次编译和运行此示例。计数是否按预期增加?如果您在另一个程序(如文本编辑器)中打开文件会发生什么?不幸的是,Java NIO 只是 感觉 像魔术。使用任何其他程序访问文件不一定会按照我们小例子的规则更改其内容。

wrap() 完成

几乎任何准备发布的应用程序都需要处理文件 I/O。Java 在高效处理本地文件方面提供了强大的支持,包括对文件和目录的元数据访问。Java 在处理文本文件时提供了多种字符编码选项,显示了其广泛兼容性的承诺。Java 在处理非本地文件方面也是众所周知的。我们将在第十三章中探讨网络 I/O 和 web 资源。

复习问题

  1. 如何检查给定文件是否已经存在?

  2. 如果必须使用旧的编码方案(例如 ISO 8859)处理遗留文本文件,您如何设置读取器以正确将其内容转换为类似 UTF-8 的内容?

  3. 哪个包中的类最适合非阻塞文件 I/O?

  4. 当你需要解析诸如 JPEG 压缩图像之类的二进制文件时,你可能会使用哪种类型的输入流?

  5. System 类中有哪三个标准文本流?

  6. 绝对路径从根目录开始(例如 / 或 *C:*)。相对路径从哪里开始?更具体地说,相对路径相对于什么?

  7. 如何从现有的 FileInputStream 获取 NIO 通道?

代码练习

对于这些练习,一个骨架Count.java文件位于ch10/exercises文件夹中,但可以随意从自己的类开始。我们在一个项目上进行迭代,因此您可以将第一个练习的解决方案作为第二个的起点,依此类推。因为测试程序需要在命令行上提供不同的文件,所以您可能会发现从终端或命令窗口运行此程序更容易。您当然也可以使用 IDE 中的终端选项卡:

  1. 使用java.io包的类,创建一个小程序,将打印出命令行中指定的文件的大小。例如:

    C:\> java Count ../examples/ListIt.java
    Analyzing ListIt.java
      Size: 1011 bytes
    

    如果没有给出文件参数,则向System.err打印错误消息。

  2. 扩展上一个练习,打开给定的文件并计算行数。(对于这些简单的练习,可以假设正在分析的文件是文本文件。)如果您想要练习一些来自第八章的工具,可以根据空白拆分每一行,并在输出中包含单词计数。(您可以使用正则表达式在更复杂的模式上拆分单词,如标点符号,但这不是必需的。)

    C:\> java Count ../examples/ListIt.java
    Analyzing ListIt.java
      Size: 1011 bytes
      Lines: 36
      Words: 178
    

    与之前一样,如果没有给出文件参数,则向System.err打印错误消息。

  3. 将您之前的解决方案转换为使用 NIO 类,如PathFiles,而不是读取器。您可以使用java.niojava.nio.file包中的任何部分。当然,你几乎肯定还需要“旧”I/O 中的java.io.IOException类。

高级练习

  1. 接受第二个命令行,其中包含统计日志文件的名称。而不是将各种计数打印回终端,而是追加一行,其中包含当前时间戳、文件名和其三个计数。该行的确切格式并不重要,但应该看起来像这样:

    2023-02-02 08:14:25 Count1.java  36  147  1002
    

    您可以使用 NIO 或旧的 I/O(OIO?)解决方案中的任何一个作为起点。如果选择 NIO 版本,请尝试使用ByteBufferFileChannel进行写入。

    如果只提供一个命令行参数,则恢复以前将统计信息打印到屏幕上的方式。如果没有提供参数,或者第二个参数不可写,则向System.err打印错误信息。

    运行此版本几次,对几个文件进行测试。检查您的日志,确保每个新结果都正确追加到日志文件的末尾,而不是覆盖它。

¹ 虽然 NIO 是在 Java 1.4 中引入的——因此不再很新了——但它比原始的基本包要新,而且这个名称已经固定下来了。

² 标准错误(stderr)通常是保留给与命令行应用程序的用户显示有关的错误相关文本消息的流。它与标准输出(stdout)不同,后者通常被重定向到日志文件或另一个应用程序,并且不被用户看到。

³ 这个术语来源于线程的世界,意味着同样的事情:原子文件创建不会被其他线程中断。

⁴ 在面向对象编程中,工厂这个术语通常指静态帮助器,可以构造和定制某些对象。工厂(或工厂方法)类似于构造函数,但额外的定制可以为新对象添加细节,这些细节可能在构造函数中很难(或不可能)指定。

大端序小端序这两个术语源自乔纳森·斯威夫特的小说格列佛游记,在小说中它们分别指代利利普特人的两个阵营:一个从大头吃蛋,一个从小头吃蛋。

第十一章:Java 中的函数式方法

Java 是一个始终如一的面向对象语言。我们在第五章中看到的所有设计模式和类型仍然是大多数开发人员编写 Java 代码的核心。Java 也是灵活的,个人和公司的贡献者提出并进行改进。随着函数式编程(FP)再次引起关注,Java 正在跟上。FP 代表了一种编程的替代方式:函数而不是对象是重点。

从 Java 8 开始,Java 支持了合理的函数式特性集合,包括java.util.function包。该包包括几个类和接口,允许开发人员使用流行的函数式方法解决问题。我们将在本章中探讨其中一些方法,但我们想强调的是,这个动词允许。如果你不喜欢函数式编程,你可以安全地忽略本章。不过,我们希望你尝试一些示例。有些很好的功能可以使你的代码更加简洁,同时保持其可读性。

函数基础

函数式编程的根源可以追溯到 20 世纪 30 年代,美国数学家阿隆佐·邱奇和他的λ演算。邱奇并没有在任何硬件上运行他的演算,但λ演算形式化了一种解决问题的方式,这种方式后来导致了为真实操作的计算机编写的早期编程语言¹。Lisp 语言在 20 世纪 50 年代在 MIT 开发,并在像 IBM 700 系列这样的现代计算机的早期版本上运行。如果你能想象一张旧的黑白照片,上面有书架大小的闪烁灯光墙,你就能理解 FP 思想和模式在计算机历史中有多久。

但是 FP 并不是编程计算机的唯一方式。其他范式,如过程式编程和面向对象编程(OOP),经常争相流行。幸运的是,你可以在任何这些范式中实现相同的目标。你选择的范式通常取决于问题域和一些个人偏好。

考虑将两个数字相加并将结果分配给变量的简单任务。我们可以在像 Java 这样的面向对象语言中,也可以在像 Clojure 这样的函数式语言中²,或者像 C 这样的过程化语言中完成这个任务:

// Java objects
BigInteger five = new BigInteger(5);
BigInteger twelve = new BigInteger(12);
BigInteger sum = five.add(twelve);

// Clojure
(def five 5)
(def twelve 12)
(def sum (+ five twelve))

// C
int five = 5;
int twelve = 12;
int sum = five + twelve;

Java 在面向对象编程(OOP)再次兴起时进入了数字场景,并且反映了这些根源。然而,函数式编程(FP)始终有其狂热的拥护者。Java 8 在语言中提供了一些重要的增强,并为喜欢函数式编程的人们打开了大门。让我们看看其中一些新增功能,并看看它们如何与 Java 的更大世界融合。

Lambda 表达式

受λ演算的启发,lambda 表达式(或简称为 lambdas)构成了 Java 中函数式编程的核心单元。在函数式语言中,函数是“一等公民”,可以像 Java 中的对象一样被创建、存储、引用、使用和传递。为了模仿这种功能,Java 8 引入了一些新的语法以及几个特殊的接口。这些新增功能允许你快速定义一个可以替代整个内部类的函数。当然,这种定义的结果在内部实现上仍然是一个对象,但其“对象性”大多数情况下是隐藏的。

在接下来的本节中,我们将更详细地介绍 lambda 表达式和那些特殊的接口。然后我们将看一个流行的具体例子,演示如何使用这些表达式来进行实际工作。

Lambda 表达式

Lambda 表达式是一小段代码,可以接受参数并返回值,就像方法一样。不过,与方法不同的是,你可以轻松地将 lambda 作为参数传递给其他方法,或者像操作对象引用一样将其存储在变量中。函数式编程支持者看重这种能力,因为它允许你像处理数据一样编写有趣且动态的代码,而不需要创建内部或匿名内部类。

注意

Lambda 表达式并不意味着提供性能提升。尽管精心使用 lambda 通常会导致更紧凑、更简洁的源代码,但这种压缩并未减少任何复杂性。Lambda 可能需要更少的打字,但它们并不做更少的工作。

回想一下线程中经常见到的run()方法,我们在第九章中看到过它。我们创建了多个实现了Runnable接口的小类,用来为我们的线程提供“主体”。那些不包含任何状态作为实例变量的小类是使用 lambda 的最佳候选对象:你有一个明确定义的任务,在一个明确定义的情境中使用它。

让我们重新审视我们的一个线程演示,然后看看如何使用 lambda 表达式作为使用Runnable的明确替代方案。我们将简化来自“线程的结束”的VirtualDemo类,并专注于匿名内部类:

public class VirtualDemo2 {
  public static void main(String args[]) throws Exception {
    Runnable runnable = new Runnable() {
      public void run() {
        System.out.println("Hello thread! ID: " +
            Thread.currentThread().threadId());
      }
    };
    Thread t = Thread.startVirtualThread(runnable);
    t.join();
  }
}

我们创建一个新的Runnable实例,其中包含一个简单的run()方法,用于打印问候语和线程的 ID 号:

% java --enable-preview VirtualDemo2
Hello thread! ID: 20

很好。一切都如预期那样工作。现在让我们用 lambda 表达式替换那个runnable变量:

public class VirtualDemo3 {
  public static void main(String args[]) throws Exception {
    Thread t = Thread.startVirtualThread(() ->
      System.out.println("Hello thread! ID: " +
          Thread.currentThread().threadId())
    );
    t.join();
  }
}

我们仍然启动一个新的虚拟线程,并将该线程存储在一个变量中(在两个示例中都是t),但是没有Runnable接口的迹象。我们传递了一个有点奇怪的参数给startVirtualThread()方法,而不是一个指向某个对象的引用。那个“奇怪的参数”就是我们的 lambda 表达式,详见图 11-1。

ljv6 1101

图 11-1. lambda 表达式的基本结构

这个特定的 lambda 非常简单。我们不传递任何参数给它,它也不返回任何值。通常这就是你需要的全部。但是 lambda 可以做得更多。Lambda 表达式还支持参数,可以返回值,并且可以有更有趣的主体。

传递参数

如果我们将 lambda 视为代码片段,将它们与常规方法进行比较是合理的。常规方法确实封装了逻辑,就像 lambda 一样。但是在前几章中看到的许多方法还接受参数。我们可以给 lambda 提供参数吗?

考虑一个遍历 Java 集合元素的迭代器。我们在第七章中看到了几个示例。在这些示例中,我们在循环中使用迭代器,循环的主体在每次通过时对集合的给定元素执行某些操作。回想一下我们从“应用程序:树在田野上”中的树绘制循环:

// File: Field.java
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    for (Tree t : trees) {
      t.draw(g);
    }
    // ...
  }

替代的for循环使用了来自trees的迭代器来获取每棵独立的树,然后告诉该树在我们的领域上绘制自己。我们可以用一个 lambda 和Iterable接口的forEach()方法替换那个循环:

// File: Field.java
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    trees.forEach(t -> t.draw(g));
    // ...
  }

你可以看到相同的箭头操作符,但是左边没有一对空括号,而是一个变量t。该变量从trees集合中一次接收一棵树,就像第一个片段中的替代for循环一样。就像那个for循环的主体一样,你可以在表达式的右边使用当前树。通过这种安排,我们得到了一个稍微简洁的循环版本,但它保留了其可读性。你可以在任何实现了Iterable接口的集合中使用这个方便的技巧。

注意

简洁可读性这样的术语是主观判断。函数式编程的支持者确实发现 lambda 更紧凑的语法更易读,但这些人已经对这种符号感到满意。我们希望你能尝试本章中的例子和练习,以获得一点熟悉感。我们确实喜欢 lambda,并在许多情况下使用它们,但它们从来不是必需的。如果你尝试过后发现 lambda 对你没有用处或不易读,则不需要在自己的代码中使用它们。

你可能已经注意到,我们第一个简单线程主体的 lambda 表达式没有接受任何参数,所以我们在左边用了一个空括号。但在这个最近的例子中,我们有一个参数却没有括号。单个参数形式是如此常见,以至于编译器允许这种简写,即没有括号。如果你没有参数,或者有多个参数,则需要括号。

表达式主体

Lambda 表达式在你原本需要使用匿名内部类的情况下表现出色。你不能将 lambda 替换为所有需要匿名内部类的情况,但是在 Java 中有许多意外的情况可以使用 lambda。随着这种应用的多样化,需要更复杂的计算超出了打印语句。例如,如果你需要执行几个语句或使用临时变量,你可以将表达式体放在花括号中,就像一个方法一样。

想象一下我们游戏中的树木是季节性的。你可以在绘制之前指定它们叶子的颜色。你仍然可以使用 lambda:

    trees.forEach(t -> {
      t.setLeafColor(getSeasonalColor());
      t.draw(g);
    });

假设我们虚构的getSeasonalColor()方法执行一些基于日期的漂亮计算,并返回一个合适的颜色。注意,在我们的 lambda 表达式中,你可以使用来自类其余部分的方法(和大多数变量)。Lambda 非常强大。但是它们的一部分力量来自于明智的使用——一个 20 行的表达式体可能会影响你代码的可读性。但是如果你有几个只有几行的 lambda,那你就很好了。

除了保持你的 lambda 表达式易读之外,我们想指出一些可能会出现的特殊情况。如果你确实想要使用来自封闭作用域的局部变量,则必须“有效地是最终的”,如文档所述。记住,final变量是不能被修改的。有效最终变量是那些虽然可能没有官方的final关键字在它们的声明中,但实际上并没有被修改的变量。如果你试图使用一个非最终的局部变量,编译器会报错。幸运的是,这个限制只适用于局部变量。你可以自由地使用(甚至修改)声明为封闭类成员的变量。

关于关键字this的另一个特殊之处是。如果你还记得“this 引用”,this给你一个对当前对象的引用。当方法或构造函数的参数名与成员变量重叠时,这非常方便:

public class Position {
  int x, y;

  public Position(int x, int y) {
    this.x = x;
    this.y = y;
  }
  // ...
}

尽管你可能合理地认为 lambda 体内的this会引用 lambda 本身,实际上它仍然引用封闭类。这种特殊情况确保你可以像前面的构造函数一样在 lambda 内部使用this。它确保你的 lambda 可以访问类中的内容,即使一个局部变量本来会掩盖某些内容。

返回值

无论你的 lambda 是一个简短的一行还是包含了十几行的代码,你也可以返回一个值。一个(看似)简单的例子是一个递增函数,它接受一个整数参数并返回比输入多一的整数。表达式本身看起来会像这样:

x -> x + 1

在这种形式下,Java 将计算x + 1的答案并返回它。如果我们有一个多行体应该返回一个值,我们可以使用return关键字:

x -> {
  System.out.println("Input: " + x);
  return x + 1;
}

当你的表达式中包含if语句时,显式返回可能会很方便。但如果你的表达式适合简单形式,则简单形式更可取。

功能接口

也许你想知道 Java 如何对我们简单的 lambda 表达式进行分类。它是像输入一样的int,还是结果?它是像 Java 的很多对象一样的对象?还是我们尚未见过的东西?让我们看看jshell是否能为此提供任何线索:

jshell> x -> x + 1
|  Error:
|  incompatible types: java.lang.Object is not a functional interface
|  x -> x + 1
|  ^--------^

嗯,这并不是我们所希望的,但短语功能接口是一个线索。让我们尝试在“推断类型”中看到的var关键字,看看我们的 lambda 表达式是否可以推断出来:

jshell> var inc = x -> x + 1
|  Error:
|  cannot infer type for local variable inc
|    (lambda expression needs an explicit target-type)
|  var inc = x -> x + 1;
|  ^-------------------^

糟糕。jshell 确实识别了我们的 lambda 表达式,但这种识别并不足以确定类型。

那么 lambda 表达式的类型是什么?对于我们简单的递增 lambda 来说,它的类型是IntFunction,一个接受一个int作为参数并返回一个int的函数。IntFunction接口位于java.util.function包中,与该包中的其他几个功能接口并列。让我们试试看:

jshell> import java.util.function.*

jshell> IntFunction inc = x -> x + 1
inc ==> $Lambda$24/0x0000000840087840@23ab930d

万岁!我们没有得到错误!(尽管结果看起来相当令人生畏。)幸运的是,只要我们可以将其应用于一些数据,我们就不需要担心 lambda 的内部细节。但是我们该如何应用它呢?

查看在线文档以了解界面,你会发现它有一个方法,apply(),相当恰当:

jshell> inc.apply(7)
$16 ==> 8

另一个万岁!我们的增量器增加了!其他接口有类似定义的方法。

我们提到的形状涵盖了 lambda 表达式参数和结果的不同排列方式。例如,如果我们想使用double值而不是int,我们可以使用DoubleFunction接口。如果我们想要提供一个对象作为参数但不需要返回值,我们可以使用Consumer<T>接口。(由于Consumer适用于引用类型,它是参数化的。如果我们确实想存储接受字符串的 lambda 表达式,我们将使用类型Consumer<String>。)或者也许我们有一个不接受参数但生成long值的 lambda:LongSupplier接口将解决问题。我们不会在此重现所有功能接口的完整列表,但值得查看包摘要在线查看。

当您发现更多可以使用 lambda 的情况时,您会看到所有这些不同的形状如何被使用。但重要的是要指出,“功能接口”这个术语可以应用于具有单个抽象方法的任何接口(文档中通常缩写为 SAM)。例如,在第十二章中,我们将使用 lambda 来处理用户界面事件,比如单击按钮。按钮事件被报告给ActionListenerActionListener接口有一个抽象方法,actionPerformed(),因此它符合功能接口的条件,即使在添加这些功能特性之前,它早已是 Java 的一部分。

方法引用

与 Java 的功能性方法相关的另一个特性是方法引用。有时,您的 lambda 表达式实际上只是其他方法的包装器。考虑到一个非常流行的任务,即打印集合的内容。我们可以使用刚学到的forEach()方法,并使用 lambda 打印列表的元素:

jshell> List<String> names = new ArrayList<>()
names ==> []
jshell> names.add("Kermit");
$3 ==> true
jshell> names.add("Fozzie");
$4 ==> true
jshell> names.add("Gonzo");
$5 ==> true
jshell> names.add("Piggy");
$6 ==> true

jshell> names.forEach(n -> System.out.println(n))
Kermit
Fozzie
Gonzo
Piggy

您可以看到我们的 lambda 表达式只是将每个字符串传递到System.out.println()方法。这正是方法引用的合适候选者。

我们用一个双冒号运算符来指定这样的引用,将方法与其对象(或其类,对于static方法而言)分开:

jshell> names.forEach(System.out::println)
Kermit
Fozzie
Gonzo
Piggy

非常紧凑但仍然易读。方法引用只在一组狭窄的情况下起作用,但它们是受欢迎的选择,只要允许的地方都可以使用。与一般的 lambda 表达式一样,使用方法引用并没有真正的性能优势。事实上,Java 编译器在幕后将我们的方法引用创建为 lambda 表达式:

jshell> Consumer<String> printer = System.out::println
printer ==> $Lambda$27/0x0000000840087440@63c12fb0

如果您发现阅读起来更容易,可以随意使用方法引用来适应它们,但如果您觉得更容易阅读,则使用明确的 lambda 表达式也是可以的。

实用的 Lambda 表达式:排序

天哪,那是很多的理论!现在是时候在一些您在真实应用程序中经常找到的代码中使用这些 lambda 表达式了:对数据进行排序。排序是一个常见的任务;我们在讨论集合时已经谈过它了“更近距离观察:sort()方法”。lambda 在哪里适合?

要对任何列表进行排序,您需要一种比较列表中两个元素的方法,以便知道哪个应该排在前面。有些列表——比如员工薪水列表或给定目录中文件和子文件夹名称列表——有一个相当自然的排序,在大多数情况下足够。但有时您需要更复杂的排序,比如将子文件夹在文件之前排序。您可以像以前一样实现Comparable接口,或者创建一个实现紧密相关的Comparator接口的自定义类,但也可以提供 lambda。

对于 lambda 表达式来帮助排序,它需要像Comparatorcompare()方法那样运行。我们需要一个接受两个参数,比如ab,并返回三个int值之一的表达式:

  • 如果 a < b 则为负数

  • 如果 a > b 则为正数

  • 如果 a == b 则为零

lambda 的魔力使我们能够动态地决定如何组织列表。java.util.Collections 辅助类包含一个 sort() 方法,该方法接受要排序的集合,以及提供排序顺序的比较器。我们可以使用 lambda 来进行比较。例如,我们可以创建一个简单的 lambda 来按字母顺序排序我们的 names 列表:

jshell> Collections.sort(names, (a, b) -> a.compareTo(b))

jshell> names
names ==> [Fozzie, Gonzo, Kermit, Piggy]

排序如预期一样,虽然我们可以使用 Java 的任何排序技巧来获得这种默认排序。让我们反转排序:

jshell> Collections.sort(names, (a, b) -> b.compareTo(a))

jshell> names
names ==> [Piggy, Kermit, Gonzo, Fozzie]

不错!我们只需使用 compareTo() 方法交换参数的顺序。

当然,lambda 可以做更多的事情,特别是当您需要对一些比名字列表更复杂的东西进行排序时。想象一下,将我们苹果投掷游戏中的树按照它们距离原点 (0,0) 的距离进行排序,使用一个稍微有趣的 lambda:

Collections.sort(trees, (t1, t2) -> {
  var t1x = t1.getPositionX();
  var t1y = t1.getPositionY();
  var t2x = t2.getPositionX();
  var t2y = t2.getPositionY();
  var dist1 = Math.sqrt(t1x * t1x + t1y * t1Y);
  var dist2 = Math.sqrt(t2x * t2x + t2y * t2y);
  return dist1 - dist2;
});

我们将这个表达式体写得比必要的更冗长,以强调 lambda 可以有多行代码。这个 lambda 可能会使可读性下降,但它也突显了这种表达式的一个方便的副作用:您可以在排序时直接看到用于排序的代码。这个自我记录的特性是 FP 有这么多支持者的另一个原因。

正如我们之前所指出的,lambda 表达式并没有做任何其他 Java 功能无法完成的事情,但它们提供了一种不同的解决问题的方式。在同样的思路下,Java Streams API(不要与所有各种“Stream”类混淆,比如 java.io 包中的 PrintStream)提供了一种不同的处理数据的方式。

您可以从 java.util.stream 包中的某个类中获取流,或者使用集合的 stream() 方法。流提供了对象的稳定流动,您在遇到对象时对每个对象执行 操作。操作可以过滤掉不需要的对象,计数它们,甚至在将它们传递之前对它们进行修改。在有大量数据的情况下,流提供了一种简洁的方式来处理所有这些数据。作为程序员,您可以专注于如何处理单个对象,并让流完成为您准备这些单个对象的工作。

来源和操作

要尝试流,我们需要数据源。一个简单的开始是在任何实现 Collection 接口或其后代之一的类上使用 stream() 方法。(数组没有内置的流选项,但您可以使用 Stream.of() 静态方法轻松创建一个。)

一旦我们有了流,就可以对其进行操作。我们将在接下来看到更多操作,但是一个受欢迎且简单的起点是 count() 操作。毫不奇怪,此操作在流中的每个元素经过时计数并生成单一结果。例如,我们可以在 jshell 中使用我们的 names 列表并找出列表中有多少个朋友:

jshell> names
names ==> [Fozzie, Gonzo, Kermit, Piggy]

jshell> names.stream().count()
$24 ==> 4

诚然,这个示例并没有做什么惊人的事情,但我们将逐步进行到更复杂的操作。重要的是注意我们如何将操作附加到我们的流上。stream() 返回一个流对象,我们立即使用点操作符 (.) 来获取我们的计数。

我们可以使用另一个操作来打印出我们的名称:

jshell> names.stream().forEach(System.out::println)
Fozzie
Gonzo
Kermit
Piggy

我们为 forEach() 操作提供了一个方法引用,但您也可以提供一个接受一个参数(流中的当前名称)且不返回值的 lambda。

流的重用

您可能已经注意到,在我们的 count() 示例和类似的 forEach() 示例之间,我们没有将流存储在可重用的变量中。流是单向且一次性的。实际上可以将流存储在变量中,但是如果在处理完流后尝试重复使用它,将会出错:

jshell> Stream<String> nameStream = names.stream()
nameStream ==> java.util.stream.ReferencePipeline$Head@621be5d1

jshell> nameStream.count()
$27 ==> 4

jshell> nameStream.forEach(System.out::println)
|  Exception java.lang.IllegalStateException: stream has
|     already been operated upon or closed
|        at AbstractPipeline.sourceStageSpliterator
|           (AbstractPipeline.java:279)
|        at ReferencePipeline$Head.forEach
|           (ReferencePipeline.java:658)
|        at (#28:1)

我们为我们的 String 对象流创建了一个参数化的 Stream 对象。我们成功启动了流并使用了 count() 操作,但是未能在 forEach() 操作中使用相同的流。处理流不会改变原始来源,因此您可以安全地根据需要频繁地启动新的流。但是一旦流结束,就无法重新启动它。

流生成器

流的另一个数据来源是生成器。生成器根据某些规则创建数据。一些生成器重复生成固定值,而其他生成器生成随机内容。您可以生成简单的东西,如数字,也可以生成复杂的对象。如果获取真实数据是昂贵的操作,您可以使用生成器更轻松地测试流逻辑。同样,您可以使用生成器创建良好(或古怪、错误填充)的数据来测试应用程序的其他部分。

Stream.generate() 方法接受一个 Supplier 接口的实例。供应商的工作是提供无限流的元素。它只有一个方法:get(),它返回适当类型的元素。元素的类型实际上是 Java 对生成器施加的唯一限制。让我们尝试生成一些简单的东西:一个稳定的数字 42 的流:

jshell> Stream.generate(() -> 42).limit(3).forEach(System.out::println)
42
42
42

在这种情况下,我们的Supplier是一个非常简单的 lambda,() → 42。它没有参数,每次使用或评估 lambda 表达式时,结果都是 42。注意,在我们的generate()方法后面跟着一个新方法limit(),它位于生成器和我们的println()步骤之间。单独使用生成器会无限生成。我们将在下一节讨论limit()和其他相关方法,但我们需要在短期内找到一些方法来限制我们的生成器。如果你不相信我们,试试删除那一部分。只需准备好快速(并且重复地)按下 Ctrl-C 来停止无限的 42!

当我们需要更有趣的生成数据集时,我们可以在类中实现Supplier接口(或者像IntSupplier这样的基类型表亲)。考虑一个随机日期名称流。我们需要一个随机数生成器和一个有效日期的列表。这些要求可能会导致一个混乱的内联 lambda,但在一个小类中却很简单:

import java.util.Random;
import java.util.stream.Stream;
import java.util.function.Supplier;

public class WeekDayGenerator implements Supplier<String> {
  private static String[] days =
    { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
  private Random randSrc = new Random();

  public String get() {
    return days[randSrc.nextInt(days.length)];
  }

  public static void main(String args[]) {
    Stream.generate(new WeekDayGenerator())
          .limit(5)
          .forEach(System.out::println);
  }
}

这里的main()方法并不是必需的,但它使得测试变得更加容易。只需编译并从ch11/examples文件夹运行该类。你将看到一周中的五个随机日期:

% cd ch11/examples
% javac WeekDayGenerator.java
% java WeekDayGenerator
Sun
Thu
Fri
Sun
Mon

尝试运行几次以确认随机特性是否正常工作。你的生成类可以尽可能丰富。你只需确保get()返回一个合适的对象或值:注意我们实现了接口的参数化版本:Supplier<String>,我们的get()方法返回String。现在你可以开始了!

流迭代器

除了生成器外,流还可以从迭代器构建。这些迭代器与用于遍历集合的迭代器不完全相同,但思想是相似的。流迭代器具有与集合迭代器相同的“下一个”值的概念,但对于流来说,下一个值来自于对前一个值执行计算。例如,如果你需要一系列顺序数字,迭代器就是理想的选择:

jshell> IntStream.iterate(1, i -> i + 1).limit(5).forEach(System.out::println)
1
2
3
4
5

iterate()源方法接受两个参数:起始值和一个 lambda 表达式。Lambda 接受一个参数,并用它创建下一个元素。第二个元素将通过相同的 lambda 表达式放回,以创建第三个元素,依此类推。我们当然可以用自定义的Supplier来完成这个操作,但对于许多序列来说,迭代器提供了一个更简单的入口点。而且你不仅仅限于迭代数字,你可以迭代任何符合你需求的对象类型。只要你有一种方法来计算流的下一个对象,你就可以使用迭代器作为源。让我们尝试创建一个LocalDate对象的序列作为示例:

jshell> import java.time.LocalDate

jshell> import java.time.temporal.ChronoUnit

jshell> Stream.iterate(LocalDate.now(),
   ...>   d -> d.plus(1, ChronoUnit.DAYS)).limit(5).forEach(System.out::println)
2023-02-10
2023-02-11
2023-02-12
2023-02-13
2023-02-14

我们使用LocalDate.now()静态方法来获取当前日期作为我们的起始值。迭代表达式接受一个LocalDate对象作为输入,使用plus()方法添加一天,并返回新的LocalDate。(然后我们以这样一个可爱的日期结束。)

过滤流

前面片段中的count()forEach()操作都是终端操作的示例。终端操作“终止”了流。在处理流时,只能有一个最终的终端操作。相比之下,limit()操作是中间操作的示例。中间操作可能会修改或删除流中的某些数据,但流仍然存在。过滤是一种流行的中间操作类型,限制继续流下去的元素数量是一种过滤形式。但你可以基于各种原因进行过滤。你可以过滤以选择理想的数据,或者排除不想要的数据。你可以过滤掉重复的数据。你可以将一系列对象输入到过滤器中,并使其输出一个基本上新的流供下一个操作使用。

幸运的是,通用的过滤器只是返回boolean值的 lambda 表达式。这是来自java.util.function包中大列表功能接口中的Predicate形式。你将一个参数传入,然后会得到true或者false。例如,我们可以使用一个过滤器来计算包含字母“o”的名字的数量,像这样:

jshell> names.stream().filter(n -> n.indexOf("o") > -1).count()
$30 ==> 2

我们的过滤 lambda 接收一个名字,并使用indexOf()操作来查看名字是否包含“o”。由于indexOf()返回一个int值,我们将其与一个不可能的索引-1 进行比较,以生成所需的boolean结果。如果谓词返回true,该名字将被传递下去。如果谓词返回false,则该名字将从流中被简单地丢弃。

再次重要的细节是过滤器的“中间”特性。我们可以持续对流进行操作。例如,堆叠多个过滤器是很常见的。每个过滤器选择不同的所需元素(或者从另一个角度看,移除不需要的元素)。另一个流行的内置过滤器是distinct()操作,它可以除去重复项。让我们在列表中添加一些重复的名字,然后尝试使用两个中间操作:

jshell> names.add("Gonzo")
$32 ==> true

jshell> names
names ==> [Fozzie, Gonzo, Kermit, Piggy, Gonzo]

jshell> names.stream().
   ...> filter(n -> n.indexOf("o") > -1).count()
$34 ==> 3

jshell> names.stream().
   ...> filter(n -> n.indexOf("o") > -1).
   ...> distinct().count()
$35 ==> 2

你可以堆叠任意多个过滤器,尽管保持代码可读性仍然很重要(如果你有一连串的 20 个过滤器,可能需要重新考虑如何处理流的源)。但是你可以做的不仅仅是过滤流中的元素:你可以将它们转换成其他形式!

映射流

在流中,映射 是在传递元素之前修改流中元素的过程。与过滤类似,您使用 lambda 表达式执行修改。您可以映射简单的更改,如向价格流中添加销售税,或者创建将一种对象类型转换为完全不同类型的复杂映射。或者两者兼而有之!映射也是一个中间操作,因此您可以像使用过滤器一样堆叠映射操作。事实上,您会在许多在线示例中看到程序员将映射和过滤器混合使用以达到最终结果。

让我们从尝试添加销售税的任务开始。我们将从一个包含double值和 5%税率的简短列表开始。我们可以这样将税应用到价格上:

jshell> double[] prices = { 5.99, 9.99, 20.0, 8.5};
prices ==> double[4] { 5.99, 9.99, 20.0, 8.5 }

jshell> DoubleStream.of(prices).forEach(System.out::println)
5.99
9.99
20.0
8.5

jshell> DoubleStream.of(prices).map(p -> p*1.05).
   ...>   forEach(System.out::println)
6.2895
10.489500000000001
21.0
8.925

我们的价格格式并不是很精致,但税已经正确应用。虽然它们很方便,但我们可以尝试另一个有用的终端操作,sum(),将所有价格相加:

jshell> DoubleStream.of(prices).map(p -> p*1.05).sum()
$7 ==> 46.70400000000001

再次说明,输出并不是很好地格式化,但我们在一行中总结了整个数组的数字!

映射对象属性

您还可以使用映射来查看对象的内部。让我们创建一个简化版本的Employee类,它增加了一个额外的salary属性,这个版本我们称为PaidEmployee

public class PaidEmployee {
  private int id;
  private String name;
  private int salary; // annual, in whole dollars

  public PaidEmployee(String fullname, int id, int salary) {
    this.name = fullname;
    this.id = id;
    this.salary = salary;
  }

  public String getName() { return name; }
  public int getID() { return id; }
  public int getSalary() { return salary; }
}

在一个员工流中,我们现在可以使用map()来提取特定的属性,比如他们的名字。让我们编写一个测试类,创建几个示例员工对象,然后使用流来处理这些员工:

import java.util.*;

public class Report {
  List<PaidEmployee> employees = new ArrayList<>();

  void buildEmployeeList() {
    employees.add(new PaidEmployee("Fozzie", 4, 30_000));
    employees.add(new PaidEmployee("Gonzo", 2, 50_000));
    employees.add(new PaidEmployee("Kermit", 1, 60_000));
    employees.add(new PaidEmployee("Piggy", 3, 80_000));
  }

  public void publishNames() {
    employees.stream().map(e -> e.getName()).forEach(System.out::println);
  }

  public static void main(String args[]) {
    Report r = new Report();
    r.buildEmployeeList();
    r.publishNames();
  }
}

publishNames()方法使用map()来获取我们的PaidEmployee对象并获取员工的名字。这个名字(一个简单的String对象)继续在流中传递。有了这些名字,我们可以添加过滤器,比如我们之前示例中的“带有 o 的名字”过滤器,或者注意避免重复的员工记录。每当您需要处理数据时,map()方法就是要使用的方法。

映射转换

在上一个示例中,我们悄悄地将我们的流从一个具有PaidEmployee对象的流转换为一个具有String对象的流。因为这两种类型都是引用类型,我们实际上不必担心我们在之前和之后的类型不同的问题。如果您需要从引用类型移动到基本类型,或者反之,则必须更加明确地进行转换。这绝对是一个常见的任务,所以 Java 提供了一些很方便的map()的变体来完成这个目的。让我们对所有员工的年薪进行汇总,以了解我们的工资预算应该是多少:

  public void publishBudget() {
    int b = employees.stream().mapToInt(e -> e.getSalary()).sum();
    System.out.println("Annual budget is " + b);
  }

图 11-2 说明了通过这个预算计算流的数据移动。

ljv6 1102

图 11-2. 在流中在对象和 int 之间转换

还存在将转移到另外两种基本类型的相似类:mapToDouble()mapToLong()。如果您已经有一个数字流并想转移到一个对象,比如IntStream这样的基本类型流,都包含mapToObj()操作。

Flatmaps

我们想介绍另一种与流常用的映射操作:flatmap。flatmap 操作将块状输入平滑地转换为单一(你甚至可以说是平坦的!)元素流。什么是块状输入?这主要是一种说多维数据的可爱方式。考虑我们在“多维数组”中讨论的数组数组棋盘。我们可以在jshell中使用简单的int值进行类似设置。“棋盘”是一组行,其中每行是一组数字。如果我们尝试从我们的二维数组开始一个流会发生什么?让我们试试一个减少为 4 × 4 矩阵的示例:

jshell> int[][] board = {
   ...>   { 2, 0, 4, 2 },
   ...>   { 0, 3, 0, 1 },
   ...>   { 5, 0, 1, 0 },
   ...>   { 2, 3, 0, 2 } }
board ==> int[4][] { int[4] ... , int[4] { 2, 3, 4, 2 } }

jshell> Arrays.stream(board).forEach(System.out::println)
[I@5a10411
[I@2ef1e4fa
[I@306a30c7
[I@b81eda8

嗯,那似乎是一个int[]对象流,而不是单个整数流。(那些丑陋的斑点是 Java 默认的打印没有漂亮toString()方法的对象的方式。“对象类型 @ 内存地址”的格式。)如果我们尝试从第一行开始一个流会发生什么?

jshell> Arrays.stream(board[0]).forEach(System.out::println)
2
3
4
2

那个输出看起来更好——这是我们虚构的棋子价值清单,但只有一行。我们可以将流放在for循环的中间,并处理每一行的独立流,但这看起来很麻烦,而且会使任何计数或求和步骤变得更加困难。

为了一个友好的、适合流的方式将所有棋子放入一个流中,我们将使用flatMap(),或者在我们的情况下,从一个对象(每行是一个数组对象)到一个基本类型(每个棋子是一个int),我们将使用flatMapToInt()

jshell> Arrays.stream(board).
   ...>   flatMapToInt(r -> Arrays.stream(r)).
   ...>   forEach(System.out::println)
2
3
4
2
0
0
0
0
0
0
0
0
2
3
4
2

万岁,成功了!我们从一组密集对象开始,将这些密集对象拆分为更小部分的单一流。您可以使用flatMap()及其基本类型的衍生物将任何表格化、立方体化或通用的多维数据转换为一流的个体元素。

让我们看看另一个示例,结合我们到目前为止已经讨论过的几个流主题。系统和网络管理员的常见任务是解析日志文件。例如,Web 服务器记录每个访问者的 Internet Protocol(IP)地址和他们请求的资源。这里是一个小例子,为了可读性,长行被截断了:

54.152.182.118 - - [20/Sep/2020:08:28:46 -0400] "GET / ...
107.150.59.82 - - [20/Sep/2020:09:33:02 -0400] "GET / ...
66.249.65.234 - - [20/Sep/2020:09:33:54 -0400] "GET /robots.txt ...
66.249.65.243 - - [20/Sep/2020:09:33:54 -0400] "GET /robots.txt ...

每行包含大量信息:IP 地址、请求的日期和时间、请求的内容、请求的方式,以及(如果您查看ch11/examples文件夹中的真实日志文件)有关哪个浏览器或用户代理发出请求的信息。真实世界的日志文件可能非常庞大,管理员通常将它们压缩存储在磁盘上。

让我们运用所有的流处理技能。我们将从一个 GZIP 文件开始,并使用一些 I/O 流加载其内容,然后将未压缩的数据分解成行。我们可以使用flatMap()将行的函数流转换为以空格分隔的令牌流。有了这些令牌,我们最终可以得到我们真正想要的信息:独特访客 IP 地址的计数。

import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.GZIPInputStream;
import java.io.*;

public class UniqueIPs {
  public static void main(String args[]) {
    Pattern separator = Pattern.compile("\\s+");
    Pattern ipAddress = Pattern.compile("\\d+\\.\\d+\\.\\d+\\.\\d+");
    try {
      // Open the file...
      FileInputStream fis = new FileInputStream("sample-access.log.gz");
      // Then decompress the file with a wrapper...
      GZIPInputStream gis = new GZIPInputStream(fis);
      // Then wrap the decompressed input in a reader
      InputStreamReader ir = new InputStreamReader(gis);
      // Then layer on a buffered reader...
      BufferedReader br = new BufferedReader(ir);

      // That finally gives us our stream!
      // Now let's process that stream to get some interesting information
      long result = br.lines()
          .flatMap(ls -> separator.splitAsStream(ls))
          .filter(word -> ipAddress.matcher(word).matches())
          .distinct()
          .count();
      System.out.println("Found " + result + " unique IPs.");

    } catch (IOException ioe) {
      System.err.println("Oh no! Something went wrong: " + ioe);
    }
  }
}

非常好。一旦我们从解压后的文件中获得了行流,我们就可以使用函数式处理数据的方式来获取计数,这种方式紧凑、高效且可读的步骤列表。再次强调,你并不必须使用函数式流和 lambda 表达式来解决问题,但越来越多的程序员正在研究这种解决问题的方式——即使是在像 Java 这样的面向对象语言中。

减少和收集

在本章中,我们已经看过几个使用流的示例。每个示例都以三种终端操作中的一种结束:使用forEach()来打印元素,使用count()来知道有多少元素,或者使用sum()来将所有数值元素相加。计数和求和都是对流进行减少的示例。你将所有流中的元素“减少”为一个单一的答案。

Java 中的流有几个内置的减少器,如表 11-1 所示。

表 11-1. 终端减少操作

操作 描述
count() 返回流中元素的数量
findAny() 返回流中的任意一个元素(如果存在)
findFirst() 返回流中的第一个元素(如果存在)
matchAll() 如果流中所有元素都符合给定条件,则返回true
matchAny() 如果流中至少有一个元素符合给定条件,则返回true
max() 使用提供的比较器,返回“最大”的元素(如果有的话)
min() 使用提供的比较器,返回“最小”的元素(如果有的话)

或许你会想知道我们几次使用的sum()操作为什么没有列出来。它确实是一个减少器,但内置版本仅适用于基本类型流:IntStreamLongStreamDoubleStream

可选值

在深入研究减少器(包括如何创建自定义减少器)之前,我们需要准备好可能出现的一种严峻结果:空流。

每当过滤流时,可能会在过滤器的另一端没有任何剩余内容。举个快速的例子,如果我们的names示例中的过滤器寻找的是字母“a”而不是“o”呢?我们的名字列表中没有一个名字包含字母“a”,所以过滤器最终会将列表中的所有名字都删除掉。count()操作可以很好地处理这种情况:它简单地返回零作为答案。但是如果我们使用了min()或者findFirst()呢?这些减少器期望从流中给出一个匹配的元素。如果没有剩余元素,减少器应该返回什么?在某些情况下,返回null值可能是可以接受的,但是如果你的流以基本类型元素结束,比如int值,你就不能使用null了。

Java 流支持可选答案的概念,而不是强迫你构建一些奇怪的规则或抛出异常。这些答案被包装在一个称为Optional的类中,它来自java.util包。Optional对象有两个关键方法,我们在本节中将使用它们:isPresent()告诉我们值是否存在,get()返回该值。(如果在没有值的情况下调用get(),你将会得到一个NoSuchElementException。)

我们可以通过重新访问我们的名称过滤示例来测试这个可选的想法。不过,与其计算结果的数量,我们将使用findFirst()来返回第一个匹配的名称。因为可能根本没有匹配项,所以我们会得到一个包装在Optional中的结果。如果你的jshell中仍然有names集合,可以自由地重用它,但这里是一个快速回顾:

jshell> List<String> names = new ArrayList<>()
names ==> []

jshell> names.add("Kermit")
jshell> names.add("Fozzie")
jshell> names.add("Gonzo")
jshell> names.add("Piggy")

jshell> names
names ==> [Kermit, Fozzie, Gonzo, Piggy]

现在让我们将这些名称通过过滤器进行过滤,并查找第一个匹配项。我们将使用一个“o”来尝试我们的过滤器(应该有一个答案),然后用一个“a”来尝试(应该没有答案)。注意我们如何使用Optional的结果:

jshell> Optional matched = names.stream().
   ...>   filter(n -> n.indexOf("o") > -1).findFirst()
matched ==> Optional[Fozzie]

jshell> System.out.println(matched.isPresent() ?
   ...>   matched.get() : "N/A")
Fozzie

jshell> Optional matched = names.stream().
   ...>   filter(n -> n.indexOf("a") > -1).findFirst()
matched ==> Optional.empty

jshell> System.out.println(matched.isPresent() ?
   ...>   matched.get() : "N/A")
N/A

尽管将你的值isPresent()进行测试使得你的代码稍微冗长,但Optional提供了一个清晰的接口来处理流处理的良好和“坏”结果。与这个函数领域中的许多其他类和方法一样,你可以使用OptionalIntOptionalLongOptional Double类来捕获可能缺失的基本类型结果。

创建一个自定义的 Reducer

如果内置的 Reducers 不能满足你的需求怎么办?你可能不会感到惊讶,你可以提供一个 lambda 来创建一个自定义的 Reducer。Stream类包括一个reduce()操作,它接受一个 lambda,lambda 的形式是我们在“函数接口”讨论过的java.util.function包中的BinaryOperatorBinaryOperator接受两个相同类型的参数并返回一个相同类型的值。根据你的需求,你可以只使用带有二元操作 lambda 的reduce(),或者你可以使用第二种形式,该形式还接受与二元操作符相同类型的初始值。让我们尝试使用第二种形式来创建一个自定义的阶乘 Reducer。

阶乘是一个很大的数字——或者可以是的。如果这个术语听起来不熟悉,它类似于一个求和操作,但是不是将序列中的每个数字相加,而是相乘。你通常使用感叹号来表示这个操作:5!(读作“五的阶乘”)将 5 和 4 相乘得到 20,然后 20 × 3 得到 60,然后 60 × 2 得到 120,最后 120 × 1 得到 120。看起来可能很简单,但是阶乘数很快就变得非常大:

jshell> 5 * 4 * 3 * 2 * 1
$17 ==> 120

// we already know what 5! is, so reuse that value
jshell> 10 * 9 * 8 * 7 * 6 * 120
$18 ==> 3628800

jshell> 12 * 11 * 3628800
$19 ==> 479001600

如果你仔细观察12!的结果,你会注意到它只略低于 50 亿,因此仍然适合int类型的(正)值范围。但是13!将大约为 65 亿,因此我们无法用int存储该答案。我们可以用long计算它,但即使是这种类型也无法在20!之后保存任何内容。幸运的是,Java 准备了一些有趣的类来自java.mathBigIntegerBigDecimal。这些类可以容纳任意大的值,非常适合在我们的阶乘工作中消除基本类型的限制。

我们可以使用简单的迭代器作为我们的源,因为乘法不要求特定的操作顺序。我们的阶乘减少器将始终产生类似于count()sum()的答案,³ 因此我们将使用第二种形式,起始值为 1。我们可以在jshell中尝试这个:

// First make a quick alias for our "1" value
jshell> one = BigInteger.ONE
one ==> 1

// Test with 12!
jshell> Stream.iterate(one, count -> count.add(one)).
   ...>   limit(12).reduce(one, (a, b) -> a.multiply(b))
$32 ==> 479001600

// It matches. Yay! Can we get 13!?
jshell> Stream.iterate(one, count -> count.add(one)).
   ...>   limit(13).reduce(one, (a, b) -> a.multiply(b))
$33 ==> 6227020800

// Hooray. Big test next, can we get 21!?
jshell> Stream.iterate(one, count -> count.add(one)).
   ...>   limit(21).reduce(one, (a, b) -> a.multiply(b))
$36 ==> 51090942171709440000

// Sure did! Now, just to be silly, try 99!
jshell> Stream.iterate(one, count -> count.add(one)).
   ...>   limit(99).reduce(one, (a, b) -> a.multiply(b))
$37 ==> 933262154439441526816992388562667004907159682643
        816214685929638952175999932299156089414639761565
        182862536979208272237582511852109168640000000000
        000000000000

计算99!的结果非常大,我们不得不任意截取部分内容以适应这本书的印刷版。⁴ 但是我们自定义的减少器有效!

提示

如果你的减少逻辑对于简单的内联 Lambda 来说太复杂,你可以在一个类中实现BinaryOperator接口。然后,您可以将该类的实例提供给reduce(),而不是我们在示例中使用的 Lambda。

收集器

减少器产生的答案通常非常有用。你处理了多少行?你看到了特定单词多少次?在表格数据中某一列的平均值是多少?但是如果你想要的不只是一个答案呢?例如,在过滤时,您可能希望保留与流匹配的所有项目,而不是计数或总和它们。

如果你想要一个新的只包含字母“o”的名字列表,我们可以使用一个收集器。

java.util.stream.Collector接口允许在处理流时以令人印象深刻的灵活性收集和组织结果。在本书中我们不会涉及自定义收集器,但幸运的是,相关的Collectors类包含几种常见的收集器作为静态方法。例如,我们可以使用其中一个静态方法来获取我们感兴趣的包含字母o的名字列表:

jshell> List<String> onames = names.stream().
   ...>   filter(n -> n.indexOf("o") > -1).
   ...>   collect(Collectors.toList())
onames ==> [Fozzie, Gonzo]

太好了。现在onames是一个常规的List<String>对象,我们可以在任何需要的地方使用它。在收集器的在线文档中还有许多其他收集方法,我们鼓励您去看一看。本章末尾的代码练习让您有机会尝试另一个流行的收集器groupingBy(),但我们没有时间覆盖所有其他精彩的选项。

直接使用 Lambda

我们想要强调 Java 中 lambda 的另一个特性:你可以在自己的代码中使用它们。虽然你可能首先在一些任务中使用 lambda,比如对集合进行排序或过滤长数据流,但最终你可能希望编写接受 lambda 作为参数的方法,在该方法的主体中使用它们。由于 lambda 表达式只是某个功能接口的实例,Java 使得接受 lambda 相当简单。

考虑一个数字传感器:也许是连接到 USB 端口的某种小装置。许多这些传感器是稳定且一致的,但它们一直存在某种因素的偏差。也许一个温度计认为你的家庭办公室永远比实际温度高三度,或者一个光传感器低估了 10%的环境光。你可以编写单独的调整方法来“加 3”到一个读数或“减少 10%”每个值,但你也可以使用 lambda 创建一个通用调整方法,并让调用者提供调整逻辑。

让我们看看你可能如何编写这样一个方法。要使你的方法接受一个 lambda,你需要决定它应该具有什么形状。当然,你总是可以创建自己的形状,但通常你可以简单地使用java.util.function包中的接口之一。对于我们的传感器读数调整,我们将使用DoubleUnaryOperator形状。(一元运算符操作一个值,就像二元运算符操作两个值一样。)我们将接受一个double参数,并返回一个调整后的double作为结果。我们可以将我们这个非常灵活的调整器放入一个简单的测试框架中来试验:

import java.util.function.DoubleUnaryOperator;

public class Adjuster {
  public static double adjust(double val,
                              DoubleUnaryOperator adjustment)
  {
    return adjustment.applyAsDouble(val);
  }

  public static void main(String args[]) {
    double sample = 70.2;
    System.out.println("Initial reading: " + sample);
    System.out.print("Adding 3: ");
    System.out.println(adjust(sample, s -> s + 3));
    System.out.print("Reducing by 10%: ");
    System.out.println(adjust(sample, s -> s * 0.9));
  }
}

你可以看到我们的adjust()方法接受两个参数:我们想要调整的值,以及将执行调整的 lambda。(是的,你可以在一个类中实现DoubleUnaryOperator并提供该实现的实例作为另一种选择。)当我们调用adjust()时,我们可以使用与 JDK 官方其他部分相同的紧凑语法。它感觉有点像使用禁忌的魔法,但完全是受鼓励的!

如果你编译并运行这个演示,你应该看到类似于以下的输出:

$ java Adjuster
Initial reading: 70.2
Adding 3: 73.2
Reducing by 10%: 63.18000000000001

恰如我们所预期的。而且我们可以写其他调整,而不必重写我们实际的adjust()方法。你可能不会在每个解决的 Java 问题中都需要这种动态逻辑,但将这个技巧放入你的工具箱中是值得的,这样你在需要时可以拿出来使用。

下一步操作

就像 Java 的许多特性一样,我们可以单独撰写一本关于 lambda 表达式或流的书。其他人已经做到了! 我们希望这篇介绍能激发您对更多函数式编程主题学习的兴趣。如果您希望通过这些主题进行更多交互式练习,我们强烈推荐您使用 O’Reilly 的 在线平台 上提供的实验室。我们的 Marc Loy 创建了两个系列,一个是关于 Java lambda,另一个是关于 Java streams,两者都有本章节涉及的实际示例。这些实验室利用了 O’Reilly 的交互式学习环境,您可以在浏览器中编辑、编译和执行 Java 代码。

复习问题

  1. Java 8 中引入的大多数函数接口位于哪个包中?

  2. 在编译或运行使用 lambda 等函数特性的 Java 应用程序时,您需要使用任何特殊标志吗?

  3. 如何创建包含多条语句的 lambda 表达式的?

  4. lambda 表达式可以是 void 吗?它们可以返回值吗?

  5. 在处理完流之后,您可以重复使用它吗?

  6. 如何将对象流转换为整数流?

  7. 如果您有一个从文件中过滤掉空行的流,您可能会使用什么操作告诉您有多少行具有一些内容?

代码练习

  1. 我们的 Adjuster 演示允许我们传递任何接受并返回 double 值的 lambda。我们并不局限于像添加固定量这样的简单更改。再添加一行输出,将数字从华氏度转换为摄氏度。(作为一个快速复习,C = (F – 32) * 5 / 9。我们的读数 70.2 应该大约是 21.2。)

  2. 使用 “映射对象属性” 中的 PaidEmployeeReport 类,添加一个简单的报告,类似于 publishBudget(),显示所有员工的平均工资。

高级练习

  1. 让我们进一步探讨我们在章节末尾提到的收集器。在 PaidEmployee 类中添加一个 role 属性(类型为 String)。确保在 Report 类的 buildEmployeeList() 方法中也更新角色分配。随意选择您喜欢的角色,但确保至少有两个员工共享相同的角色(用于测试目的)。

    现在查看 groupingBy() 收集器的文档。它返回一个分组和其成员的映射。在我们的示例中,该映射的键将是您创建的角色。相关值将是共享该角色的所有员工的列表。您可以在 Report 类中添加一个创建此映射并打印角色及其相关员工的“报告”。

¹ 实际上,丘奇的学生和计算机先驱艾伦·图灵证明,λ演算与图灵自己的系统(基础图灵机)在执行计算方面是等效的。

² 我们提到 Clojure 而不是其他许多现代函数式语言,因为它可以在 JVM 上运行,并且可以与 Java 类和方法集成。真不错!

³ 阶乘过程的一个怪癖是 0! 被定义为“在空集合中排列项目的方法数”—这正好是一个。即使我们的流没有元素,我们仍然可以正确返回起始值。

⁴ 如果科学记数法对您在这种大小下更容易解析,那就是 9.3e+155。流行的宇宙中已知原子数量估计约为 10e+82,以防您想知道 99! 究竟有多大。

第十二章:桌面应用程序

Java 凭借 applet 的力量一跃成名——这些在网页上的惊人的 交互式 元素。现在听起来很普通,但在当时,这简直是一个奇迹。Java 还具有跨平台支持,并且可以在 Windows、Unix 和 macOS 系统上运行相同的代码。早期的 JDK 具有一组基本的图形组件,统称为抽象窗口工具包(AWT)。AWT 中的“抽象”来自于使用通用类(ButtonWindow 等)与本地实现。你使用抽象的、跨平台的代码编写 AWT 应用程序;你的计算机运行你的应用程序并提供具体的、本地的组件。

不幸的是,这种抽象和本地结合的巧妙组合带来了一些相当严重的限制。在抽象领域,你会遇到“最低公共分母”设计,这些设计只能让你访问 JDK 支持的每个平台上可用的功能。在本地实现中,即使某些功能在各平台上大致可用,但在实际渲染屏幕时,它们也有显著的差异。许多早期使用 Java 的桌面开发人员开玩笑说,“一次编写,到处运行”的标语实际上是“一次编写,到处调试”。Java Swing 套件旨在改善这种令人遗憾的状态。虽然 Swing 没有解决跨平台应用程序交付的所有问题,但它确实使得在 Java 中进行严肃的桌面应用程序开发成为可能。你可以找到许多优质的开源项目,甚至一些商业应用程序是用 Swing 编写的。确实,在附录 A 中详细介绍的集成开发环境 IntelliJ IDEA 就是一个 Swing 应用程序!它在性能和可用性上显然可以与本地集成开发环境媲美。¹

如果你查看 javax.swing² 包的文档,你会发现它包含大量的类。你仍然需要一些原始的 java.awt 领域的内容。有关 AWT 的整本书籍(Java AWT Reference,Zukowski,O’Reilly)以及关于 Swing 的书籍(Java Swing,Loy 等人,O’Reilly),甚至关于 AWT 子包,如 2D 图形的书籍(Java 2D Graphics,Knudsen,O’Reilly)。在本章中,我们将专注于介绍一些流行的组件,如按钮和文本字段。我们将讨论如何在应用程序窗口中布局它们以及如何与它们交互。通过这些简单的起步主题,你可能会惊讶于你的应用程序可以变得多么复杂。如果你在阅读本书后继续进行桌面开发,你可能也会对 Java 中有多少更多的图形用户界面(GUI)内容感到惊讶。我们希望在激起你的兴趣的同时,也承认有许多 很多 更多的 UI 讨论是我们必须留给你以后发现的。话虽如此,让旋风之旅开始吧!

按钮、滑块和文本字段,哦!

那么从哪里开始呢?我们有点“先有鸡还是先有蛋”的问题:我们需要讨论要放在屏幕上的“东西”,比如我们在“HelloJava”中使用的JLabel对象。但我们还需要讨论你把那些东西放在哪里。而且你把那些东西放在哪里也值得讨论,因为这是一个非平凡的过程。实际上,我们似乎有一个先有鸡、再有蛋,然后是早午餐的问题。拿杯咖啡或者一杯香槟,我们就开始吧。我们将首先介绍一些流行的组件(“东西”),然后介绍它们的容器,最后讨论如何在这些容器中布置组件的话题。一旦你能在屏幕上放置一个漂亮的小部件集,我们将讨论如何与它们进行交互以及如何在多线程世界中处理用户界面。

组件层次结构

正如我们在前几章中讨论的那样,Java 类是以分层方式设计和扩展的。JComponentJContainer 位于 Swing 类层次结构的顶部,如图 12-1 所示。我们不会详细介绍这两个类,但请记住它们的名称。当你阅读 Swing 文档时,你会在这些类中找到几个常见的属性和方法。随着你在编程方面的进步,你可能会想要构建自己的组件。JComponent 是一个很好的起点。我们在第二章中构建图形 Hello Component 时使用了 JComponent

ljv6 1201

图 12-1. 部分(非常部分)Swing 类层次结构

我们将涵盖上述简化层次结构中提到的大多数其他类,但你肯定会想访问在线文档来查看我们不得不遗漏的许多组件。

模型视图控制器架构

Swing 对“东西”的基础是一种被称为模型视图控制器(MVC)的设计模式。Swing 包的作者们努力确保一致地应用这种模式,以便当你遇到新的组件时,它们的行为和使用应该感觉熟悉。MVC 架构旨在将你所看到的(视图)与幕后状态(模型)以及改变这些部分的交互集合(控制器)分离开来。这种关注点的分离使你能够专注于每个部分的正确性。网络流量可以在幕后更新模型。视图可以在感觉顺畅并且对用户响应迅速的正常间隔同步。MVC 为构建任何桌面应用程序提供了一个强大而易于管理的框架。

当我们查看我们的一小部分组件时,我们将突出显示模型和视图元素。然后我们将更详细地讨论“事件”中的控制器。如果您对编程模式的概念感兴趣,可重用面向对象软件的设计模式(Addison-Wesley)由 Gamma、Helm、Johnson 和 Vlissides(著名的四人帮)是经典之作。关于在 Swing 中特别使用 MVC 模式的更多细节,请参阅 Loy 等人的Java Swing的介绍章节。

标签和按钮

最简单的 UI 组件不出所料地也是最受欢迎的之一。标签(label)被广泛用于指示功能、显示状态和聚焦。我们在第二章中的第一个图形应用程序中使用了一个标签。在构建更有趣的程序时,我们将会使用更多的标签。

JLabel 组件是一个多功能工具。让我们看看如何使用 JLabel 并自定义其许多属性的一些示例。我们将从对我们的“Hello, Java”程序进行一些准备性调整开始:

import javax.swing.*;
import java.awt.*;

public class Labels {

  public static void main(String[] args) {
    JFrame frame = new JFrame("JLabel Examples");
    frame.setLayout(new FlowLayout()); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/1.png)
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/2.png)
    frame.setSize(300, 150);

    JLabel basic = new JLabel("Default Label"); ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/3.png)
    frame.add(basic);
    frame.setVisible(true);
  }
}

简言之,有趣的部分包括:

1

设置框架使用的布局管理器。

2

设置在使用操作系统的“关闭”按钮时采取的操作(在本例中,是窗口左上角的红点)。我们在这里选择的操作是退出应用程序。

3

创建我们的简单标签并将其添加到框架中。

您声明和初始化标签,然后将其添加到框架中。这应该是很熟悉的操作。可能新的是我们使用了FlowLayout实例,它帮助我们生成了图 12-2 中显示的屏幕截图。

ljv6 1202

图 12-2. 一个简单的 JLabel

我们将在“容器和布局”中更详细地讨论布局管理器,但我们需要一些东西来让我们起步,同时还允许将多个组件添加到单个容器中。FlowLayout类通过水平居中组件顶部来填充容器,从左到右添加,直到该“行”用完空间,然后继续在下一行上添加。这种排列方式在较大的应用程序中可能不太实用,但对于快速在屏幕上显示几件事物而言是理想的。

让我们通过向框架添加几个更多的标签来证明这一点。查看图 12-3 中显示的结果:

public class Labels {

  public static void main(String[] args) {
    JFrame frame = new JFrame("JLabel Examples");
    frame.setLayout(new FlowLayout());
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(300, 150);

    JLabel basic = new JLabel("Default Label");
    JLabel another = new JLabel("Another Label");
    JLabel simple = new JLabel("A Simple Label");
    JLabel standard = new JLabel("A Standard Label");

    frame.add(basic);
    frame.add(another);
    frame.add(simple);
    frame.add(standard);

    frame.setVisible(true);
  }
}

ljv6 1203

图 12-3. 几个基本的 JLabel 对象

不错,对吧?再次强调,这种简单的布局并不适用于大多数生产应用程序中的内容,但对于您开始时肯定是有用的。关于布局,我们还想提一点,因为您以后会遇到这个想法:FlowLayout也处理标签的大小。在这个示例中很难注意到这一点,因为标签默认具有透明背景。如果我们导入java.awt.Color类,我们可以使用该类来帮助使它们不透明并赋予它们特定的背景颜色:

    JLabel basic = new JLabel("Default Label");
    basic.setOpaque(true);
    basic.setBackground(Color.YELLOW);
    JLabel another = new JLabel("Another Label");
    another.setOpaque(true);
    another.setBackground(Color.GREEN);

    frame.add(basic);
    frame.add(another);
    // other frame setup

如果我们对我们所有的标签都做同样的操作,我们现在可以看到它们的真实大小和它们之间的间隙在图 12-4 中。但是如果我们可以控制标签的背景颜色,我们还能做什么呢?我们可以改变前景色吗?(可以。)我们可以改变字体吗?(可以。)我们可以改变对齐方式吗?(可以。)我们可以添加图标吗?(可以。)我们可以创建最终构建 Skynet 并导致人类灭绝的自我意识标签吗?(也许,但可能不太容易。也好。)图 12-5 展示了其中一些可能的调整。

ljv6 1204

图 12-4. 不透明,彩色标签

ljv6 1205

图 12-5. 更多带有花哨选项的标签

这里是构建这种多样性的相关源代码:

    // a white label with a forced size and text centered inside
    JLabel centered = new JLabel("Centered Text", JLabel.CENTER);
    centered.setPreferredSize(new Dimension(150, 24));
    centered.setOpaque(true);
    centered.setBackground(Color.WHITE);

    // a white label with an alternate, larger font
    JLabel times = new JLabel("Times Roman");
    times.setOpaque(true);
    times.setBackground(Color.WHITE);
    times.setFont(new Font("TimesRoman", Font.BOLD, 18));

    // a white label using inline HTML for styling
    JLabel styled = new JLabel("<html>Some <b><i>styling</i></b>"
        + " is also allowed</html>");
    styled.setOpaque(true);
    styled.setBackground(Color.WHITE);

    // a label with both an icon and text
    JLabel icon = new JLabel("Verified",
        new ImageIcon("ch10/examples/check.png"), JLabel.LEFT);
    icon.setOpaque(true);
    icon.setBackground(Color.WHITE);

    // finally, add all our new labels to the frame
    frame.add(centered);
    frame.add(times);
    frame.add(styled);
    frame.add(icon);

我们使用了一些其他类来帮助,例如java.awt.Fontjavax.swing.ImageIcon。我们可以回顾更多选项,但我们需要查看一些其他组件。如果您想玩转这些标签,并尝试更多您在 Java 文档中看到的选项,请尝试导入我们为jshell构建的助手并玩耍。我们的几行代码的结果显示在图 12-6 中:

$ javac ch12/examples/Widget.java
$ jshell
|  Welcome to JShell -- Version 21-ea
|  For an introduction type: /help intro

jshell> import javax.swing.*
jshell> import java.awt.*
jshell> import ch12.examples.Widget

jshell> Widget w = new Widget()
w ==> ch10.Widget[frame0,0,23,300x150,layout=java.awt.B ... abled=true]

jshell> JLabel label1 = new JLabel("Green")
label1 ==> javax.swing.JLabel[,0,0,0x0,invalid,alignmentX=0\. ... ion=CENTER]

jshell> label1.setOpaque(true)
jshell> label1.setBackground(Color.GREEN)

jshell> w.add(label1)
$8 ==> javax.swing.JLabel[,0,0,0x0,...]

jshell> w.add(new JLabel("Quick test"))
$9 ==> javax.swing.JLabel[,0,0,0x0,...]

ljv6 1206

图 12-6. 在jshell中使用我们的Widget

我们希望您现在能够看到创建标签(或其他组件,例如我们将要探索的按钮)并交互式地调整其参数是多么容易。这是熟悉用于创建 Java 桌面应用程序的可用构建块的绝佳方式。如果您经常使用我们的Widget,您可能会发现其reset()方法很方便。此方法会移除所有当前组件并刷新窗口,以便您可以快速重新开始。

按钮

图形应用程序中您将需要的另一个几乎通用组件是按钮。JButton类是 Swing 中您的首选按钮。(您还会在文档中找到其他流行的按钮类型,例如JCheckboxJToggleButton。)创建按钮与创建标签非常相似,如图 12-7 所示。

import javax.swing.*;
import java.awt.*;

public class Buttons {
  public static void main(String[] args) {
    JFrame frame = new JFrame("JButton Examples");
    frame.setLayout(new FlowLayout());
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(300, 150);

    JButton basic = new JButton("Try me!");
    frame.add(basic);

    frame.setVisible(true);
  }
}

ljv6 1207

图 12-7. 一个简单的JButton

您可以像处理标签一样控制按钮的颜色、文本和图像对齐、字体等。当然,不同之处在于您可以单击按钮并在程序中做出反应,而标签大多是静态显示。尝试运行此示例并单击按钮。即使在我们的程序中它不执行任何其他功能,它应该会改变颜色并感觉“按下”。在我们讨论“对按钮点击做出反应”的概念之前(在 Swing 中称为“事件”),我们希望再介绍几个组件,但如果您等不及了,可以跳到 “Events”!

文本组件

在今天的桌面或 Web 应用程序中,几乎无法想象没有文本输入字段。这些输入元素允许自由输入信息,并且在在线表单中几乎无处不在。您可以输入姓名、电子邮件地址、电话号码和信用卡号码。您可以在组成其字符的语言中执行所有这些操作,也可以在从右到左读取的其他语言中执行这些操作。Swing 有三个主要的文本组件:JTextFieldJTextAreaJTextPane;它们都是从共同的父类 JTextComponent 扩展而来。JTextField 是一个经典的文本字段,用于简短的单词或单行输入。JTextArea 允许跨多行输入更多内容。JTextPane 是一个专门用于编辑富文本的组件。

文本字段

让我们在我们简单流动的应用程序中运行一个文本输入的例子。我们将简化到两个标签和相应的文本字段:

import javax.swing.*;
import java.awt.*;

public class TextInputs {
  public static void main(String[] args) {
    JFrame frame = new JFrame("JTextField Examples");
    frame.setLayout(new FlowLayout());
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(400, 200);

    JLabel nameLabel = new JLabel("Name:");
    JTextField nameField = new JTextField(10);
    JLabel emailLabel = new JLabel("Email:");
    JTextField emailField = new JTextField(24);

    frame.add(nameLabel);
    frame.add(nameField);
    frame.add(emailLabel);
    frame.add(emailField);

    frame.setVisible(true);
  }
}

注意在 Figure 12-8 中,文本字段的大小由我们在其构造函数中指定的列数决定。这不是初始化文本字段的唯一方式,但在没有其他布局机制决定字段宽度时是很有用的。(在这里,FlowLayout 在一定程度上让我们失望了——“电子邮件:”标签没有与电子邮件文本字段保持在同一行上。随着我们了解更多有关布局的信息,我们将很快修复它。)继续输入一些内容吧!您可以输入和删除文本;用鼠标在字段内部突出显示内容;以及按预期的方式剪切、复制和粘贴。

ljv6 1208

图 12-8. 简单标签和 JTextField

如果您向我们的演示应用程序添加一个文本字段,就像在 jshell 中显示的 Figure 12-9 那样,您可以调用它的 getText() 方法来查看内容确实可用。

ljv6 1209

图 12-9. 检索 JTextField 的内容
jshell> w.reset()

jshell> JTextField emailField = new JTextField(15)
emailField ==> javax.swing.JTextField[,0,0,0x0, ... lignment=LEADING]

jshell> w.add(new JLabel("Email:"))
$12 ==> javax.swing.JLabel[,0,0,0x0, ... sition=CENTER]

jshell> w.add(emailField)
$13 ==> javax.swing.JTextField[,0,0,0x0, ... lignment=LEADING]

// Enter an sample address, we typed in "me@some.company"

jshell> emailField.getText()
$14 ==> "me@some.company"

请注意,text 属性是可读写的。您可以在文本字段上调用 setText() 来程序化地更改其内容。这对于设置默认值、自动格式化诸如电话号码或从网络收集信息填充表单非常有用。在 jshell 中试试吧。

文本区域

当您需要的空间不仅限于简单的单词甚至长的 URL 输入时,您可能会转向 JTextArea,以便为用户提供多行输入空间。您可以使用类似 JTextField 的构造函数创建一个空文本区域。对于 JTextArea,您除了指定列数外,还要指定行数。看一下我们添加文本区域到我们的文本输入演示应用的代码:

    JLabel bodyLabel = new JLabel("Body:");
    JTextArea bodyArea = new JTextArea(10,30);

    frame.add(bodyLabel);
    frame.add(bodyArea);

结果显示在 图 12-10 中。您可以看到我们有多行文本的空间。请运行这个新版本并自己尝试一下。当您超过一行的末尾时会发生什么?按下 Return 键时会发生什么?您会得到您熟悉的行为吗?您仍然可以像使用文本字段一样访问其内容。

ljv6 1210

图 12-10. 添加 JTextArea

让我们在 jshell 中将一个文本区域添加到我们的小部件中,这样我们就可以玩转它的属性:

jshell> w.reset()

jshell> w.add(new JLabel("Body:"))
$16 ==> javax.swing.JLabel[,0,0,0x0, ... ition=CENTER]

jshell> JTextArea bodyArea = new JTextArea(5,20)
bodyArea ==> javax.swing.JTextArea[,0,0,0x0, ... word=false,wrap=false]

jshell> w.add(bodyArea)
$18 ==> javax.swing.JTextArea[,0,0,0x0, ... lse,wrap=false]

jshell> bodyArea.getText()
$19 ==> "This is the first line.\nThis should be the second.\nAnd the third..."

太棒了!我们可以看到我们在 图 12-11 中键入的 Return 键被编码为我们检索到的字符串中的 \n 字符。

ljv6 1211

图 12-11. 检索 JTextArea 的内容

但是,如果您尝试输入一个长的、无法停止的句子,使其超过行末会发生什么?您可能会得到一个奇怪的文本区域,它会扩展到您的窗口大小甚至更大,如 图 12-12 所示。

ljv6 1212

图 12-12. 简单 JTextArea 中的过长行

我们可以通过查看 JText``Area 的一对属性来修复不正确的大小行为,如 表 12-1 所示。

表 12-1. JTextArea 的换行属性

属性 默认值 描述
lineWrap false 表中的行是否应该完全换行
wrapStyleWord false 如果行有换行,是否应该在单词或字符边界上换行

那么,让我们重新开始并启用单词换行。我们可以使用 setLineWrap(true) 来确保文本换行。但这可能还不够。我们将添加一个调用 setWrapStyleWord(true) 来确保文本区域不仅仅在单词中断。这应该看起来类似于 图 12-13。

ljv6 1213

图 12-13. 在简单 JTextArea 中的包装行

您可以在 jshell 中或您自己的应用中尝试。当您从 bodyArea 对象中检索文本时,您不应该在第三行中看到一个换行符(\n)在第二个“on”和“but”之间。

文本滚动

如果我们有太多行会发生什么?独立使用 JTextArea 时,它会采用一个奇怪的“增长直到无法”技巧,如 图 12-14 所示。

ljv6 1214

图 12-14. 在简单的 JTextArea 中有太多行

要解决这个问题,我们需要调用标准的 Swing 辅助组件: JScrollPane。这是一个通用的容器,可以轻松地在有限的空间中展示大组件。为了向您展示这有多简单,让我们来修复我们的文本区域:⁴。

jshell> w.remove(bodyArea); // So we can start with a fresh text area

jshell> bodyArea = new JTextArea(5,20)
bodyArea ==> javax.swing.JTextArea[,0,0,0x0,inval... word=false,wrap=false]

jshell> w.add(new JScrollPane(bodyArea))
$17 ==> javax.swing.JScrollPane[,47,5,244x84, ... ortBorder=]

您可以在 图 12-15 中看到,文本区域不再超出帧的边界。您还可以看到侧面和底部的标准滚动条。如果您只需要简单的滚动,您已经完成了!但是,与 Swing 中的大多数其他组件一样,JScrollPane 有许多细节可以根据需要进行调整。我们不会在这里覆盖大部分内容,但我们确实想向您展示如何处理一个常见的设置:文本区域的换行(按单词换行)与垂直滚动,即不水平滚动。

ljv6 1215

图 12-15. 在 JScrollPane 中嵌入 JTextArea 的行数过多
    JLabel bodyLabel = new JLabel("Body:");
    JTextArea bodyArea = new JTextArea(10,30);
    bodyArea.setLineWrap(true);
    bodyArea.setWrapStyleWord(true);
    JScrollPane bodyScroller = new JScrollPane(bodyArea);
    bodyScroller.setHorizontalScrollBarPolicy(
        JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
    bodyScroller.setVerticalScrollBarPolicy(
        JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);

    frame.add(bodyLabel);
    // note we don't add bodyArea, it's already in bodyScroller
    frame.add(bodyScroller);

您应该会得到一个类似于 图 12-16 中所示的文本区域。

ljv6 1216

图 12-16. 在 JScrollPane 中的良好形式的 JTextArea

太棒了!您现在已经体验到了最常见的 Swing 组件,包括标签、按钮和文本字段。但是,我们实际上只是浅尝辄止了这些组件的功能。请查阅 Java 文档,并在 jshell 或您自己的小型应用程序中玩耍,以更深入地了解每个组件。

习惯于 UI 设计需要实践。如果您将要构建桌面应用程序,我们建议您查阅其他书籍和在线资源,但是在键盘上的实践时间是无可替代的。

其他组件

如果您已经查看了 javax.swing 包的文档,您会知道还有几十种其他组件可供使用。在这个大列表中,有几个我们想要强调的⁵。

JSlider

当您希望用户从一系列值中进行选择时,滑块是一个巧妙且高效的输入组件:例如,字体大小选择器、颜色选择器和缩放选择器等。对于我们苹果投掷游戏中所需的角度和力量值,滑块非常合适。我们的角度范围从 0 到 180,力量值范围从 0 到 20(一个任意的最大值)。图 12-17 展示了这些滑块的位置(暂时忽略我们如何实现布局)。

ljv6 1217

图 12-17. 在我们的苹果投掷游戏中使用 JSlider

要创建一个新的滑块,您需要提供三个值:最小值(我们的角度滑块为 0),最大值(180)和初始值(游戏中为 90 中间位置)。您可以像这样将这样的滑块添加到我们的 jshell 游乐场:

// reset the widget
jshell> w.reset()

jshell> JSlider slider = new JSlider(0, 180, 90);
slider ==> javax.swing.JSlider[,0,0,0x0, ... ks=false,snapToValue=true]

jshell> w.add(slider)
$20 ==> javax.swing.JSlider[,0,0,0x0, ... alue=true]

将滑块移动到像图 12-18 中所示的位置,然后使用 getValue() 方法查看其当前值:

jshell> slider.getValue()
$21 ==> 112

ljv6 1218

图 12-18. jshell 中简单的 JSlider

在“事件”中,我们将看到如何在用户更改这些值时实时接收它们。

JSlider构造函数使用整数作为最小和最大值,并且getValue()返回一个整数。 如果您需要分数值,则需要自行处理。 例如,在我们的游戏中的力量滑块将受益于支持超过 21 个离散级别。 我们可以通过使用较大范围的整数构建滑块,然后将当前值除以适当的比例因子来解决这个问题:

jshell> JSlider force = new JSlider(0, 200, 100)
force ==> javax.swing.JSlider[,0,0,0x0, ... ks=false,snapToValue=true]

jshell> w.add(force)
$23 ==> javax.swing.JSlider[,0,0,0x0,invalid ... alue=true]

jshell> force.getValue()
$24 ==> 68

jshell> float myForce = force.getValue() / 10.0f;
myForce ==> 6.8

JList

如果您有一组离散的值,但这些值不是简单的连续数字范围,则“列表”UI 元素是一个很好的选择。 JList是此输入类型的 Swing 实现。 您可以设置它以允许单个或多个选择,并且如果您深入研究 Swing 的功能,您可以生成自定义视图,显示列表中的项目及其额外信息或详细信息。(例如,您可以制作图标列表,或图标和文本,或多行文本等)。

与我们迄今看到的其他组件不同,JList需要更多信息才能启动。 要创建一个有用的列表组件,您需要使用接受您打算显示的数据的构造函数之一。 最简单的这种构造函数接受一个Object数组。 虽然您可以传递任何类型的对象数组,但JList的默认行为是显示列表中对象的toString()方法的输出。 使用String对象数组非常常见且产生预期结果。 图 12-19 显示了一个简单的城市列表。

ljv6 1219

图 12-19. jshell中四个城市的简单JList
jshell> w.reset()

jshell> String[] cities = new String[] { "Atlanta", "Boston",
   ...>   "Chicago", "Denver" };
cities ==> String[4] { "Atlanta", ..., "Denver" }

jshell> JList cityList = new JList<String>(cities);
cityList ==> javax.swing.JList[,0,0,0x0, ...entation=0]

jshell> w.add(cityList)
$29 ==> javax.swing.JList[,0,0,0x0,invalid ... ation=0]

我们在构造函数中使用与创建参数化集合对象(例如ArrayList,请参见“类型限制”)时相同的<String>类型信息。 由于 Swing 是在泛型之前添加的,因此您可能会在在线或书籍示例中找到未添加类型信息的示例。 省略它不会阻止代码编译或运行,但是您会在编译时收到与集合类相同的unchecked警告消息。

与获取滑块当前值类似,您可以随时使用四种方法之一检索列表中选择的项目或项目:

getSelectedIndex()

对于单选列表,返回一个int

getSelectedIndices()

对于多选列表,返回一个int数组

getSelectedValue()

对于单选列表,返回一个对象

getSelectedValues()

对于多选列表,返回一个对象数组

主要区别在于选择项目的索引还是实际值对您更有用。 在jshell中操作我们的城市列表时,我们可以这样提取出所选城市:

jshell> cityList.getSelectedIndex()
$31 ==> 2

jshell> cityList.getSelectedIndices()
$32 ==> int[1] { 2 }

jshell> cityList.getSelectedValue()
$33 ==> "Chicago"

jshell> cities[cityList.getSelectedIndex()]
$34 ==> "Chicago"

对于大列表,您可能希望有一个滚动条。 Swing 在其代码中促进了可重用性,因此您可以像对文本区域一样使用JScrollPaneJList

容器和布局

那些组件的庞大列表只是可用部件的子集。在本节中,您将把我们讨论过的组件布局到有用的排列中。这些排列发生在一个容器内,这是 Java 中用于可以包含其他组件的组件的术语。让我们从查看最常见的容器开始。

框架和窗口

每个桌面应用程序至少需要一个窗口。这个术语早于 Swing 并且被大多数三大操作系统的图形界面使用,包括 Windows(无关)。如果您需要,Swing 确实提供了一个低级别的 JWindow 类,但最有可能您会在 JFrame 中构建您的应用程序。图 12-20 展示了 JFrame 的类层次结构。我们将坚持其基本特性,但随着您的应用程序变得更加丰富,您可能希望使用层次结构中更高级的元素创建定制窗口。

ljv6 1220

图 12-20. JFrame 类的层次结构

让我们重新审视来自第 2 章中的第一个图形应用程序的创建,并更加关注 JFrame 对象:

import javax.swing.*;

public class HelloJavaAgain {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Hello, Java!");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(300, 150);

    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    frame.add(label);

    frame.setVisible(true);
  }
}

我们传递给 JFrame 构造函数的字符串将成为窗口的标题。然后我们在对象上设置一些特定的属性。确保当用户关闭窗口时,我们退出程序。(这似乎是显而易见的,但是复杂的应用可能会有多个窗口,例如工具面板或支持多文档。在这些应用中关闭一个窗口可能并不意味着“退出”。)

然后我们选择窗口的起始大小,并将实际的标签组件添加到框架中(这将标签放置在其内容面板中,稍后会详细介绍)。一旦组件被添加,我们会使窗口可见,结果是图 12-21。

ljv6 1221

图 12-21. 一个简单的 JFrame,带有一个添加的标签

这个基本过程是每个 Swing 应用程序的基础。您的应用程序的有趣部分来自于您如何处理那个内容面板。

那么内容面板是什么?框架使用其自己的一组容器来持有典型应用程序的各种部分。您可以将自己的内容面板设置为任何继承自 java.awt.Container 的对象,但是我们暂时将坚持使用默认的内容面板。

我们还使用了一个快捷方式来添加我们的标签。JFrame 版本的 add() 将委托给内容面板的 add()。以下片段显示了如何在没有快捷方式的情况下添加标签:

    JLabel label = new JLabel("Hello, Java!", JLabel.CENTER);
    frame.getContentPane().add(label);

JFrame 类并不是所有功能都有快捷方式。阅读文档并使用存在的快捷方式。如果没有,不要犹豫,通过 getContentPane() 获取引用,然后根据需要配置或调整该容器。

JPanel

默认内容面板是一个 JPanel,在 Swing 中通常用作容器。它像 JButtonJLabel 一样是一个组件,因此您的面板可以包含其他面板。这种嵌套在应用程序布局中通常起着重要作用。例如,您可以创建一个 JPanel 来容纳文本编辑器中的格式化按钮,然后将该工具栏添加到内容面板。这种安排使用户可以轻松显示、隐藏或移动它。

JPanel 允许您向屏幕添加和移除组件。(这些方法从 Container 类继承,但我们通过 JPanel 对象访问它们。)如果有什么变化并且您想更新您的 UI,还可以使用 repaint() 方法重绘面板。

我们可以使用 jshell 中的 playground 小部件来看到 add()remove() 方法的效果,如 图 12-22 所示:

jshell> Widget w = new Widget()
w ==> ch10.Widget[frame0,0,23,300x300, ... kingEnabled=true]

jshell> JLabel emailLabel = new JLabel("Email:")
emailLabel ==> javax.swing.JLabel[,0,0,0x0 ... ition=CENTER]

jshell> JTextField emailField = new JTextField(12)
emailField ==> javax.swing.JTextField[,0,0,0x0, ... LEADING]

jshell> JButton submitButton = new JButton("Submit")
submitButton ==> javax.swing.JButton[,0,0,0x0, ... ble=true]

jshell> w.add(emailLabel);
$8 ==> javax.swing.JLabel[,0,0,0x0, ... ition=CENTER]
// Left screenshot in image above

jshell> w.add(emailField)
$9 ==> javax.swing.JTextField[,0,0,0x0, ... nment=LEADING]

jshell> w.add(submitButton)
$10 ==> javax.swing.JButton[,0,0,0x0, ... pable=true]
// Now we have the middle screenshot

jshell> w.remove(emailLabel)
// And finally the right screenshot

请尝试自己操作!大多数应用程序不会随意添加和移除组件。通常,您会通过添加所需的内容来构建界面,然后让它保持不变。沿途可能会启用或禁用一些按钮,但请尽量避免让用户感到意外,出现部分组件消失或新元素弹出的情况。

ljv6 1222

图 12-22. 在 JPanel 中添加和移除组件

布局管理器

类似 JPanel 的容器负责布置您添加的组件。Java 提供了多个 布局管理器 来帮助您实现所需的结果。

BorderLayout

您已经看到 FlowLayout 的效果。在不知不觉中,您使用了另一个布局管理器:JFrame 的内容面板默认使用 BorderLayout。图 12-23 展示了 BorderLayout 控制的五个区域及其区域。请注意,NORTHSOUTH 区域与应用程序窗口一样宽,但只有足够容纳标签的高度。同样,EASTWEST 区域填充了 NORTHSOUTH 区域之间的垂直间隙,但仅宽到足够容纳,留下剩余空间由 CENTER 区域横向和纵向填充:

import java.awt.*;
import javax.swing.*;

public class BorderLayoutDemo {
  public static void main(String[] args) {
    JFrame frame = new JFrame("BorderLayout Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(400, 200);

    JLabel northLabel = new JLabel("Top - North", JLabel.CENTER);
    JLabel southLabel = new JLabel("Bottom - South", JLabel.CENTER);
    JLabel eastLabel = new JLabel("Right - East", JLabel.CENTER);
    JLabel westLabel = new JLabel("Left - West", JLabel.CENTER);
    JLabel centerLabel = new JLabel("Center (everything else)",
        JLabel.CENTER);

    // Color the labels so we can see their boundaries better
    northLabel.setOpaque(true);
    northLabel.setBackground(Color.GREEN);
    southLabel.setOpaque(true);
    southLabel.setBackground(Color.GREEN);
    eastLabel.setOpaque(true);
    eastLabel.setBackground(Color.RED);
    westLabel.setOpaque(true);
    westLabel.setBackground(Color.RED);
    centerLabel.setOpaque(true);
    centerLabel.setBackground(Color.YELLOW);

    frame.add(northLabel, BorderLayout.NORTH);
    frame.add(southLabel, BorderLayout.SOUTH);
    frame.add(eastLabel, BorderLayout.EAST);
    frame.add(westLabel, BorderLayout.WEST);
    frame.add(centerLabel, BorderLayout.CENTER);

    frame.setVisible(true);
  }
}

ljv6 1223

图 12-23. 使用 BorderLayout 可用的区域

在这种情况下,add() 方法需要额外的参数,并将其传递给布局管理器。(并非所有的管理器都需要此参数,就像您在 FlowLayout 中看到的那样。)

图 12-24 展示了在应用程序中嵌套 JPanel 对象的示例。我们在中心使用文本区域显示了一个大消息,然后在底部的面板上添加了一些操作按钮。同样,在接下来的部分中我们会涵盖的事件中,这些按钮目前并不起作用,但我们想向您展示如何使用多个容器。如果需要,您可以继续嵌套 JPanel 对象。

有时,更好的顶层布局选择可以使您的应用程序更易维护,性能更佳:

public class NestedPanelDemo {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Nested Panel Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(400, 200);

    // Create the text area and add it to the center
    JTextArea messageArea = new JTextArea();
    frame.add(messageArea, BorderLayout.CENTER);

    // Create the button container
    JPanel buttonPanel = new JPanel(new FlowLayout());

    // Create the buttons
    JButton sendButton = new JButton("Send");
    JButton saveButton = new JButton("Save");
    JButton resetButton = new JButton("Reset");
    JButton cancelButton = new JButton("Cancel");

    // Add the buttons to their container
    buttonPanel.add(sendButton);
    buttonPanel.add(saveButton);
    buttonPanel.add(resetButton);
    buttonPanel.add(cancelButton);

    // And finally, add that container to the bottom of the app
    frame.add(buttonPanel, BorderLayout.SOUTH);

    frame.setVisible(true);
  }
}

ljv6 1224

图 12-24. 一个简单的嵌套容器示例

在这个例子中有两点需要注意。首先,当我们创建 JTextArea 对象时没有指定行数或列数,与 FlowLayout 不同,BorderLayout 会在可能的情况下设置组件的大小。对于顶部和底部,这意味着使用组件自身的高度,类似于 FlowLayout 的工作方式,然后设置组件的宽度以填充框架。侧边使用它们的组件宽度,但布局管理器设置高度。BorderLayout 在中心设置组件的宽度和高度。

其次,在将 messageAreabuttonPanel 对象添加到 frame 时,我们在 frameadd() 方法中指定了额外的“where”参数。然而,当我们将按钮本身添加到 buttonPanel 时,我们使用了更简单的 add() 版本,只有组件参数。容器的布局管理器决定我们需要使用 add() 的哪个变体。因此,尽管 buttonPanel 在使用 BorderLayoutSOUTH 区域,saveButton 及其同伴位于它们自己的封闭容器中,不知道也不关心容器外部发生的事情。

GridLayout

许多时候,你需要(或希望)你的组件或标签占据对称的空间。想想确认对话框底部的“是”,“否”和“取消”按钮。(Swing 也可以制作这些对话框;详见 “模态和弹出窗口”。)GridLayout 类可以帮助实现这样均匀的间距。让我们尝试在前面的例子中使用 GridLayout 来排列这些按钮。我们只需要改变一行代码:

    // Create the button container. Old version:
    // JPanel buttonPanel = new JPanel(new FlowLayout());
    JPanel buttonPanel = new JPanel(new GridLayout(1,0));

add() 的调用保持完全相同;不需要单独的约束参数。

正如你在 图 12-25 中所看到的,GridLayout 的按钮尺寸相同,尽管“取消”按钮的文本比其他按钮稍长。

ljv6 1225

图 12-25. 使用 GridLayout 布局一行按钮

在创建布局管理器时,我们告诉它我们只想要一行,没有列数的限制 (1, 0)。网格也可以是二维的,具有多行和多列。图 12-26 以手机键盘布局为例。

public class PhoneGridDemo {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Nested Panel Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(200, 300);

    // Create the phone pad container
    JPanel phonePad = new JPanel(new GridLayout(4,3));

    // Create and add the 12 buttons, top-left to bottom-right
    phonePad.add(new JButton("1"));
    phonePad.add(new JButton("2"));
    phonePad.add(new JButton("3"));

    phonePad.add(new JButton("4"));
    phonePad.add(new JButton("5"));
    phonePad.add(new JButton("6"));

    phonePad.add(new JButton("7"));
    phonePad.add(new JButton("8"));
    phonePad.add(new JButton("9"));

    phonePad.add(new JButton("*"));
    phonePad.add(new JButton("0"));
    phonePad.add(new JButton("#"));

    // And finally, add the pad to the center of the app
    frame.add(phonePad, BorderLayout.CENTER);

    frame.setVisible(true);
  }
}

从左到右,从上到下依次添加按钮,应该能看到 图 12-26 中的应用程序。

ljv6 1226

图 12-26. 手机键盘的二维网格布局

如果你需要完全对称的元素,这非常方便且非常简单。但如果你想要一个大部分对称的布局呢?想象一下流行的网页表单,左侧是标签列,右侧是文本字段列。GridLayout 可以处理类似这样的基本两列表单,但很多时候你的标签简短简单,而文本字段却较宽,给用户更多输入空间。Java 如何适应这些布局?

GridBagLayout

如果您需要更有趣的布局,但又不想嵌套大量面板,考虑使用GridBagLayout。它设置更复杂,但允许创建复杂的布局,仍然可以使元素在视觉上对齐和大小合适。与BorderLayout类似,您需要使用额外的参数添加组件。然而,GridBagLayout的参数是丰富的GridBagConstraints对象,而不是简单的字符串。

GridBagLayout中的“grid”确实是这样,一个被分成各种行和列的矩形容器。“bag”的部分来自于如何使用这些行和列创建的单元格的“抓袋”概念。行和列可以有自己的高度或宽度,并且组件可以占据任何矩形的一系列单元格。我们可以利用这种灵活性,通过单个JPanel构建出我们的游戏界面,而不是使用几个嵌套的面板。图 12-27 展示了将屏幕分为四行三列的一种方式,并放置组件。

ljv6 1227

图 12-27. GridBagLayout使用的示例网格

您可以看到不同的行高和列宽。有些组件占用多个单元格。这种类型的布局不适用于每个应用程序,但对于需要更复杂布局的许多用户界面来说是强大且有效的。

要使用GridBagLayout构建应用程序,您需要在添加组件时保留几个引用。让我们首先设置网格:

    public static final int SCORE_HEIGHT = 30;
    public static final int CONTROL_WIDTH = 300;
    public static final int CONTROL_HEIGHT = 40;
    public static final int FIELD_WIDTH = 3 * CONTROL_WIDTH;
    public static final int FIELD_HEIGHT = 2 * CONTROL_WIDTH;
    public static final float FORCE_SCALE = 0.7f;

    GridBagLayout gameLayout = new GridBagLayout();

    gameLayout.columnWidths = new int[]
        { CONTROL_WIDTH, CONTROL_WIDTH, CONTROL_WIDTH };
    gameLayout.rowHeights = new int[]
        { SCORE_HEIGHT, FIELD_HEIGHT, CONTROL_HEIGHT, CONTROL_HEIGHT };

    JPanel gamePane = new JPanel(gameLayout);

这一步需要您进行一些计划,但一旦您在屏幕上放置了几个组件,就很容易调整。要添加这些组件,您需要创建和配置GridBagConstraints对象。幸运的是,您可以重用相同的对象来配置所有组件——您只需要在添加每个元素之前重复配置部分。这里是如何添加主游戏场组件的示例:

    GridBagConstraints gameConstraints = new GridBagConstraints();

    gameConstraints.fill = GridBagConstraints.BOTH;
    gameConstraints.gridy = 1;
    gameConstraints.gridx = 0;
    gameConstraints.gridheight = 1;
    gameConstraints.gridwidth = 3;

    Field field = new Field();
    gamePane.add(field, gameConstraints);

注意我们如何设置字段将占用的单元格。我们通过指定左上角的矩形来指定一个矩形,即给出行(gridy)和列(gridx)。然后我们指定我们的字段将占用的行数(gridheight)和列数(gridwidth)。这是配置网格包约束的核心。

您还可以调整诸如组件如何填充其占用的单元格(fill)以及每个组件获得多少边距等内容。我们已经决定简单地填充一组单元格中的所有可用空间(水平和垂直填充),但您可以在GridBagConstraints的文档中了解更多选项。

让我们在顶部添加一个记分标签:

    gameConstraints.fill = GridBagConstraints.BOTH;
    gameConstraints.gridy = 0;
    gameConstraints.gridx = 0;
    gameConstraints.gridheight = 1;
    gameConstraints.gridwidth = 1;

    JLabel scoreLabel = new JLabel(" Player 1: 0");
    gamePane.add(scoreLabel, gameConstraints);

对于第二个组件,您是否看到约束设置如何与处理游戏场类似?每当看到这种类似性时,考虑将这些相似的步骤提取到一个可以重用的函数中:

    private GridBagConstraints buildConstraints(int row, int col,
        int rowspan, int colspan)
    {
      // Use our global reference to the gameConstraints object
      gameConstraints.fill = GridBagConstraints.BOTH;
      gameConstraints.gridy = row;
      gameConstraints.gridx = col;
      gameConstraints.gridheight = rowspan;
      gameConstraints.gridwidth = colspan;
      return gameConstraints;
    }

然后,您可以像这样重写先前的记分标签和游戏字段代码块:

    GridBagConstraints gameConstraints = new GridBagConstraints();

    JLabel scoreLabel = new JLabel(" Player 1: 0");
    Field field = new Field();
    gamePane.add(scoreLabel, buildConstraints(0,0,1,1));
    gamePane.add(field, buildConstraints(1,0,1,3));

有了这个功能,您可以快速添加各种其他组件和标签,以完成游戏界面。例如,右下角的图 12-27 中的投掷按钮可以设置如下:

    JLabel tossButton = new JButton("Toss");
    gamePane.add(tossButton, buildConstraints(2,2,2,1));

更干净!我们只需继续创建我们的组件并将它们放置在正确的行和列上,具有适当的跨度。最后,我们在单个容器中拥有一组相当有趣的组件。

与本章的其他部分一样,我们没有时间涵盖每个布局管理器,甚至不涵盖我们讨论的每个布局管理器的每个功能。请务必查阅 Java 文档,并尝试创建一些虚拟应用程序来测试不同的布局。作为起点,BoxLayout是对网格概念的良好升级,而GroupLayout可以生成数据输入表单。不过,现在我们要继续前进。现在是将所有这些组件“连接”起来,并响应所有键入、点击和按钮推送的时间了——这些在 Java 中被编码为事件。

事件

如在“模型-视图-控制器架构”中讨论的那样,MVC 设计中的模型和视图元素是直接的。但是控制器方面呢?在 Swing(以及更广泛的 Java 应用中),用户与组件之间的交互通过事件进行通信。事件包含一般信息,例如交互发生的时间,以及事件类型特有的信息,例如您点击鼠标的屏幕上的点,或者您在键盘上输入的键。监听器(或处理程序)接收消息并可以以某种有用的方式响应。将组件连接到监听器就允许用户控制您的应用程序。

鼠标事件

最简单的开始方法就是生成和处理事件。让我们跟随我们第一个快速应用程序的脚步,使用HelloMouse应用程序并专注于处理鼠标事件。当我们点击鼠标时,我们将使用该点击事件确定我们的JLabel的位置。(顺便说一句,这将需要移除布局管理器。我们希望手动设置标签的坐标。)

当您查看此示例的源代码时,请注意一些特定项,如下所示:

// filename: ch12/examples/HelloMouse.java
package ch10.examples;

import java.awt.*;
import javax.swing.*;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;

public class HelloMouse extends JFrame implements MouseListener {![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/1.png)
  JLabel label;

  public HelloMouse() {
    super("MouseEvent Demo");
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    // remove the layout manager
    setLayout(null);
    setSize(300, 100);

    label = new JLabel("Hello, Mouse!", JLabel.CENTER);
    label.setOpaque(true);
    label.setBackground(Color.YELLOW);
    label.setSize(100,20);
    label.setLocation(100,100);
    add(label);

    getContentPane().addMouseListener(this); ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/4.png)
  }

  public void mouseClicked(MouseEvent e) { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/2.png)
    label.setLocation(e.getX(), e.getY());
  }

  public void mousePressed(MouseEvent e) { } ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/lrn-java-6e/img/3.png)
  public void mouseReleased(MouseEvent e) { }
  public void mouseEntered(MouseEvent e) { }
  public void mouseExited(MouseEvent e) { }

  public static void main(String[] args) {
    HelloMouse frame = new HelloMouse();
    frame.setVisible(true);
  }
}

1

当您点击时,Java 会从硬件(计算机、鼠标、键盘)捕获低级事件并将其交给适当的监听器。监听器是接口。您可以创建特殊的类来实现接口,或者您可以将监听器作为主应用程序类的一部分实现,就像我们在这里所做的那样。您选择处理事件的位置确实取决于您需要对其响应的操作。在本书的其余部分中,您将看到许多这两种方法的示例。

2

除了扩展JFrame,我们还实现了MouseListener接口。我们必须为MouseListener中列出的每个方法提供一个方法体,但我们在mouseClicked()中完成了真正的工作。此方法从event对象中获取点击的坐标,并使用它们来更改标签的位置。MouseEvent类包含关于事件的丰富信息:发生时间,发生在哪个组件上,涉及哪个鼠标按钮,事件发生的(x,y)坐标等等。尝试在某些未实现的方法中打印一些信息,如mouseDown()

3

我们添加了许多其他类型的鼠标事件方法,但我们没有使用。这在低级事件(如鼠标和键盘事件)中很常见。监听器接口旨在为相关事件提供一个集中的收集点。您必须实现接口中的每个方法,但可以响应您关心的特定事件,并将其他方法留空。

4

新代码的另一个关键部分是为我们的内容窗格调用addMouseListener()。语法可能看起来有点奇怪,但这是一种标准方法。使用getContentPane()表示“这是生成事件的组件”,并将this作为参数表示“这是接收(处理)事件的类”。在这个例子中,来自框架内容窗格的事件将返回给相同的类,这是我们放置所有鼠标处理代码的地方。

现在运行应用程序。你将得到一个变体的熟悉的“Hello, World”图形应用程序,如图 12-28 所示。友好的消息应该会随着你点击而跟随鼠标移动。

ljv6 1228

图 12-28. 使用MouseEvent定位标签

鼠标适配器

如果您想尝试辅助类方法,可以向文件添加一个单独的类并在该类中实现MouseListener。如果是这样,您可以利用 Swing 为许多侦听器提供的一种快捷方式。MouseAdapter类是MouseListener接口的简单实现,其中为每个事件编写了空方法。当您extend此类时,您只需要覆盖您关心的方法。这样可以使处理程序变得干净而简洁:

// filename: ch12/examples/HelloMouseHelper.java
package ch12.examples;

import java.awt.*;
import java.awt.event.MouseEvent;
import java.awt.event.MouseAdapter;
import javax.swing.*;

public class HelloMouseHelper {
  public static void main(String[] args) {
    JFrame frame = new JFrame("MouseEvent Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setLayout(null);
    frame.setSize(300, 300);

    JLabel label = new JLabel("Hello, Mouse!", JLabel.CENTER);
    label.setOpaque(true);
    label.setBackground(Color.YELLOW);
    label.setSize(100,20);
    label.setLocation(100,100);
    frame.add(label);

    LabelMover mover = new LabelMover(label);
    frame.getContentPane().addMouseListener(mover);
    frame.setVisible(true);
  }
}

/**
 * Helper class to move a label to the position of a mouse click.
 * Recall from Chapter 5 that secondary classes included in the same
 * public class must not be public themselves. They can be protected,
 * file as private, or package private (with no qualifier).
 */
class LabelMover extends MouseAdapter {
  JLabel labelToMove;

  public LabelMover(JLabel label) {
    labelToMove = label;
  }

  public void mouseClicked(MouseEvent e) {
    labelToMove.setLocation(e.getX(), e.getY());
  }
}

请记住,辅助类需要引用它们所接触的每个对象。我们将标签传递给了我们适配器的构造函数。这是建立必要连接的一种流行方式,但只要处理程序在开始接收事件之前具有对其所需每个对象的引用即可随时添加所需的访问。

动作事件

虽然几乎每个 Swing 组件上都可以获得低级别的鼠标和键盘事件,但这可能有点乏味。大多数 UI 库都提供更简单思考的高级事件,Swing 也不例外。例如,JButton类支持一个ActionEvent,让你知道按钮已被点击。大多数情况下,这正是你想要的。但如果需要特殊行为,比如响应来自不同鼠标按钮的点击或在触摸屏上区分长按和短按,仍然可以使用鼠标事件。

用于演示按钮点击事件的一种流行方式是构建一个简单的计数器,就像你在图 12-29 中看到的那样。每次点击按钮时,程序会更新标签。这个简单的概念验证显示了你可以接收和响应 UI 事件。让我们看看这个演示所需的连接:

package ch12.examples;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class ActionDemo1 extends JFrame implements ActionListener {
  int counterValue = 0;
  JLabel counterLabel;

  public ActionDemo1() {
    super("ActionEvent Counter Demo");
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    setLayout(new FlowLayout());
    setSize(300, 180);

    counterLabel = new JLabel("Count: 0", JLabel.CENTER);
    add(counterLabel);

    JButton incrementer = new JButton("Increment");
    incrementer.addActionListener(this);
    add(incrementer);
  }

  public void actionPerformed(ActionEvent e) {
    counterValue++;
    counterLabel.setText("Count: " + counterValue);
  }

  public static void main(String[] args) {
    ActionDemo1 demo = new ActionDemo1();
    demo.setVisible(true);
  }
}

ljv6 1229

图 12-29. 使用ActionEvent来增加一个计数器

我们在actionPerformed()方法内更新一个简单的计数器变量,并在其中显示结果,这是ActionListener对象接收其事件的地方。我们使用了直接的监听器实现方法,但我们也可以像在“鼠标事件”中的LabelMover示例中所做的那样创建一个帮助类。

动作事件很直接;它们没有像鼠标事件那样多的细节可用,但它们确实携带一个“命令”属性。所涉及的命令只是一个任意字符串。对于 Java 而言,这并不意味着什么,但你可以根据自己的需求自定义此属性。对于按钮而言,Java 默认使用按钮标签的文本。如果在JTextField类中按下回车键输入文本,则也会生成一个动作事件。但在这种情况下,字段中当前的文本用作命令。图 12-30 展示了如何将按钮和文本字段连接到标签上。

ljv6 1230

图 12-30. 来自不同来源的ActionEvent的使用
public class ActionDemo2 {
  public static void main(String[] args) {
    JFrame frame = new JFrame("ActionListener Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setLayout(new FlowLayout());
    frame.setSize(300, 180);

    JLabel label = new JLabel("Results go here", JLabel.CENTER);
    ActionCommandHelper helper = new ActionCommandHelper(label);

    JButton simpleButton = new JButton("Button");
    simpleButton.addActionListener(helper);

    JTextField simpleField = new JTextField(10);
    simpleField.addActionListener(helper);

    frame.add(simpleButton);
    frame.add(simpleField);
    frame.add(label);

    frame.setVisible(true);
  }
}

/**
 * Helper class to show the command property of any ActionEvent in a given label.
 */
class ActionCommandHelper implements ActionListener {
  JLabel resultLabel;

  public ActionCommandHelper(JLabel label) {
    resultLabel = label;
  }

  public void actionPerformed(ActionEvent ae) {
    resultLabel.setText(ae.getActionCommand());
  }
}

请注意,我们使用一个ActionListener对象来处理按钮和文本字段的事件。这是 Swing 处理事件的监听器方法的一个很好的特性:生成给定类型事件的任何组件都可以向接收该类型事件的任何监听器报告。有时你的事件处理程序是唯一的,你会为每个组件构建一个单独的处理程序。但许多应用程序提供多种完成相同任务的方式。你通常可以用单个监听器处理这些不同的输入来源。而且你的代码越少,出错的可能性就越小!

变更事件

出现在几个 Swing 组件中的另一种事件类型是ChangeEvent。这是一个简单的事件,用于通知您某些事情已经发生了变化。JSlider类使用此机制报告滑块位置的更改。ChangeEvent类引用了发生变化的组件(事件的),但没有关于可能在该组件内部发生的具体变化的详细信息。您需要询问组件以获取这些详细信息。这种监听-查询的过程可能看起来很繁琐,但它确实允许高效地通知需要更新,而无需创建成百上千的方法来覆盖可能出现的所有事件变化。

我们不会在这里复制整个应用程序,但让我们看看AppleToss类如何使用ChangeListener将瞄准滑块映射到我们的物理学家:

// file: ch12/examples/game/AppleToss.java
    gamePane.add(buildAngleControl(), buildConstraints(2, 0, 1, 1));

    // other setup stuff ...

    private JSlider buildAngleControl() {
      // Our aim can range from 0 to 180 degrees
      JSlider slider = new JSlider(0,180);

      // but trigonometric 0 is on the right side, not the left
      slider.setInverted(true);

      // Any time the slider value changes, update the player
      slider.addChangeListener(new ChangeListener() {
        public void stateChanged(ChangeEvent e) {
          player1.setAimingAngle((float)slider.getValue());
          field.repaint();
        }
      });
      return slider;
    }

在这个片段中,我们使用工厂模式创建我们的滑块,并将其返回供gamePane容器的add()方法使用。我们创建了一个简单的匿名内部类。更改我们的瞄准滑块会产生一定的影响,而且只有一种方法可以瞄准苹果。由于无法重用类,我们选择了一个匿名内部类。创建一个完整的辅助类,并将player1field元素作为参数传递给构造函数或初始化方法并没有错,但您会经常发现上述方法在实践中经常使用。

我们想指出处理简单事件如ChangeEventActionEvent的另一种选项。这些事件的监听器具有单个抽象方法。这个短语听起来熟悉吗?这是 Oracle 描述其函数式接口的方式。所以我们可以使用 lambda!

    // And now, any time the slider value changes, we should update
    slider.addChangeListener(e -> {
      player1.setAimingAngle((float)slider.getValue());
      field.repaint();
    });

不幸的是,许多侦听器处理一系列相关事件。对于具有多个方法的任何侦听器接口,都无法使用 lambda。但是,如果您喜欢它们,lambda 可以与按钮和菜单项一起使用,因此在您的图形应用程序中仍然可以发挥重要作用。

jshell中,我们的Widget并不适合尝试与事件相关的代码。虽然您可以在命令行上编写匿名内部类或多行 lambda 表达式,但这可能很繁琐,并且很难从同一命令行修复错误。通常情况下,编写小而专注的演示应用程序会更简单,就像本章的许多示例一样。虽然我们鼓励您启动苹果投掷游戏,以玩耍并调整上述代码中显示的滑块,但您也应该尝试几个原创应用程序。

其他事件

java.awt.eventjavax.swing.event包中分布着数十种其他事件和监听器。浏览文档只是为了了解您可能会遇到的其他类型的事件是值得的。表 12-2 展示了到目前为止在本章中讨论过的组件相关的事件和监听器,以及一些在更多使用 Swing 时值得查看的内容。

表格 12-2. Swing 和 AWT 事件及其关联的监听器

S/A 事件类 监听器接口 生成组件
A ActionEvent ActionListener JButton, JMenuItem, JTextField
S ChangeEvent ChangeListener JSlider
A ItemEvent ItemListener JCheckBox, JRadioButton
A KeyEvent KeyListener 组件的后代
S ListSelectionEvent ListSelectionListener JList
A MouseEvent MouseListener 组件的后代
A MouseMotionEvent MouseMotionListener 组件的后代
AWT 事件(A)来自 java.awt.event,Swing 事件(S)来自 javax.swing.event

如果您不确定特定组件支持哪些事件,请查看其文档以查找类似 addXYZListener() 的方法。XYZ 代表需要查看文档其他位置的提示。回想一下,我们的滑块使用了 addChangeListener()。因此,XYZ 在这种情况下是 Change。您可以推断事件名称(ChangeEvent)和监听器接口(ChangeListener)从这个提示。一旦您有了监听器的文档,请尝试实现每个方法并简单打印报告的事件。通过这种方式,您可以了解各种 Swing 组件如何响应键盘和鼠标事件。

线程注意事项

如果您在阅读本章节时阅读了任何关于 Swing 的 JDK 文档,可能会注意到一个警告:Swing 组件不是线程安全的。正如您在第九章中学到的,Java 支持多线程执行以利用现代计算机的处理能力。多线程应用程序可能允许两个线程竞争相同的资源或同时更新相同变量但具有不同的值。不知道数据是否正确可能会严重降低调试程序或甚至只是信任其输出的能力。对于 Swing 组件,此警告提醒程序员其 UI 元素可能受到此类破坏的影响。

为了帮助保持一致的 UI,Swing 鼓励您在 AWT 事件分发线程 上更新组件。这是自然处理按钮点击等事物的线程。如果您响应事件(例如我们在“动作事件”中的计数按钮和标签)更新组件,那么您就已经设置好了。这个想法是,如果应用程序中的每个其他线程将 UI 更新发送到唯一的事件分发线程,则没有组件可以受到同时可能发生冲突的更改的不利影响。

图形应用程序中线程的一个常见例子是动画旋转器,它在你等待大文件下载时显示在屏幕上。但是如果你变得不耐烦呢?如果看起来下载失败了,但旋转器仍在运行呢?如果你的长时间运行的任务使用事件分发线程,你的用户将无法点击取消按钮或采取任何行动。长时间运行的任务应由可以在后台运行的单独线程处理,使你的应用程序响应和可用。但是当后台线程完成时,我们如何更新 UI 呢?Swing 为你准备了一个辅助类。

SwingUtilities 和组件更新

你可以从任何线程使用SwingUtilities类以安全、稳定的方式更新你的 UI 组件。有两个静态方法可用于与你的 UI 通信:

  • invokeAndWait()

  • invokeLater()

如它们的名称所示,第一个方法运行一些 UI 更新代码,并使当前线程在继续之前等待该更新完成。第二个方法将一些 UI 更新代码交给事件分发线程,然后立即在当前线程上继续执行。(事件分发线程有时称为事件分发队列。你可以附加事件或更新,事件分发线程将按照它们被添加的顺序大致处理它们,就像在队列中一样。)你使用哪一个方法取决于你的后台线程是否需要在继续之前知道 UI 的状态。例如,如果你要向接口添加一个新按钮,你可能希望使用invokeAndWait(),这样在后台线程继续执行之前,它可以确保未来的更新实际上有一个按钮来更新。

如果你不太关心何时更新某些内容,但仍希望它被事件分发线程安全处理,invokeLater() 是完美的选择。想象一下在下载大文件时更新进度条。你的代码可能会在下载完成的过程中触发多次更新。在恢复下载之前,你不需要等待这些图形更新完成。如果进度更新被延迟或非常接近第二次更新,没有真正的危害。但你不希望忙碌的图形界面打断你的下载,特别是如果服务器对暂停敏感的情况下。

我们将在第十三章看到几个此类网络/UI 交互的示例,但让我们假装一些网络流量并更新一个小标签来展示SwingUtilities。我们可以设置一个开始按钮,它将用一个简单的百分比显示更新一个状态标签,并启动一个后台线程,该线程每秒休眠一次,然后增加进度。每次线程唤醒时,它将使用invokeLater()正确设置标签的文本。首先,让我们设置我们的演示:

package ch12.examples;

public class ProgressDemo {
  public static void main(String[] args) {
    JFrame frame = new JFrame("SwingUtilities 'invoke' Demo");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setLayout(new FlowLayout());
    frame.setSize(300, 180);

    JLabel label = new JLabel("Download Progress Goes Here!",
        JLabel.CENTER);
    Thread pretender = new Thread(new ProgressPretender(label));

    JButton simpleButton = new JButton("Start");
    simpleButton.addActionListener(e -> {
      simpleButton.setEnabled(false);
      pretender.start();
    });

    JLabel checkLabel = new JLabel("Can you still type?");
    JTextField checkField = new JTextField(10);

    frame.add(label);
    frame.add(simpleButton);
    frame.add(checkLabel);
    frame.add(checkField);
    frame.setVisible(true);
  }
}

大部分内容应该看起来很熟悉,但请看我们如何创建我们的线程。我们将 new ProgressPretender() 作为参数传递给我们的 Thread 构造函数。我们本可以将该调用分解为单独的部分,但由于我们不再直接引用我们的 ProgressPretender 对象,我们可以坚持使用这种更整洁、更密集的方法。然而,我们确实引用了线程本身,因此我们为它创建了一个适当的变量。然后,我们可以在按钮的 ActionListener 中启动我们的线程运行。此时,我们还禁用了我们的“开始”按钮。我们不希望用户尝试(重新)启动已经运行的线程!

我们为您添加了一个文本字段供您输入。在更新进度的同时,您的应用程序应继续响应用户输入,如键入。试试看!虽然文本字段与任何内容都没有连接,但您应该能够在观看进度计数器缓慢上升的同时输入和删除文本,如 图 12-31 所示。

ljv6 1231

图 12-31. 线程安全更新进度标签

那么我们是如何在不锁定应用程序的情况下更新标签的呢?让我们看看 ProgressPretender 类并检查 run() 方法:

package ch12.examples;

class ProgressPretender implements Runnable {
  JLabel label;
  int progress;

  public ProgressPretender(JLabel label) {
    this.label = label;
    progress = 0;
  }

  public void run() {
    while (progress <= 100) {
      SwingUtilities.invokeLater(
        () -> label.setText(progress + "%");
      );
      try {
        Thread.sleep(1000);
      } catch (InterruptedException ie) {
        System.err.println("Someone interrupted us. Skipping download.");
        break;
      }
      progress++;
    }
  }
}

在这个类中,我们存储了传递给构造函数的标签,这样我们就知道在哪里显示我们更新后的进度。run() 方法有三个基本步骤:1)更新标签,2)睡眠 1,000 毫秒,3)增加我们的进度。

在第 1 步中,我们传递给 invokeLater() 的 lambda 参数是基于 第九章 中的 Runnable 接口。我们可以使用内部类或匿名内部类,但对于这样一个简单的任务,lambda 是完美的选择。lambda 主体更新标签与当前进度值。事件分派线程将执行 lambda。这就是使文本字段保持响应性的神奇之处,尽管我们的“进度”线程大部分时间都在睡眠。

第 2 步是标准的线程睡眠。sleep() 方法知道它可以被中断,所以编译器会确保你像我们上面做的那样提供一个 try/catch 块。处理中断的方法有很多种,但在这种情况下,我们选择简单地break出循环。

最后,我们增加我们的进度计数器并重新启动整个过程。一旦达到 100,循环就结束了,我们的进度标签应该停止变化。如果您耐心等待,您将看到最终值。应用程序本身应该保持活动状态。您仍然可以在文本字段中输入内容。我们的下载完成了,世界万事大吉!

定时器

Swing 库包括一个设计用于 UI 空间的计时器。javax.swing.Timer类非常直观。它等待指定的时间段,然后触发一个动作事件(与点击按钮相同类型的事件)。它可以一次性或重复性地触发该动作。您将发现许多理由在图形应用程序中使用计时器。除了提供另一种驱动动画循环的方式外,您可能希望自动取消某些操作,例如加载网络资源如果时间太长。反之,您可能希望显示一个小的“请稍候”旋转器或对话框,让用户知道操作正在进行中。您可能希望在用户在指定时间内没有响应时关闭对话框提示。Swing 的Timer可以处理所有这些场景。

使用计时器进行动画

让我们修改来自“使用线程重新访问动画”的飞行苹果,并尝试使用Timer实现动画。Timer类会为我们处理这些细节。我们仍然可以使用我们第一次尝试动画时Apple类中的step()方法。我们只需修改启动方法,并保持一个适当的变量用于计时器:

  public static final int STEP = 40;  // frame duration in milliseconds
  Timer animationTimer;

  // other member declarations ...

  void startAnimation() {
    if (animationTimer == null) {
      animationTimer = new Timer(STEP, this);
      animationTimer.setActionCommand("repaint");
      animationTimer.setRepeats(true);
      animationTimer.start();
    } else if (!animationTimer.isRunning()) {
      animationTimer.restart();
    }
  }

  // other methods ...

  public void actionPerformed(ActionEvent event) {
    if (animating && event.getActionCommand().equals("repaint")) {
      System.out.println("Timer stepping " + apples.size() + " apples");
      for (Apple a : apples) {
        a.step();
        detectCollisions(a);
      }
      repaint();
      cullFallenApples();
    }
  }

这种方法有两个好处。它确实更易读,因为我们不需要负责动作之间的暂停。我们通过将事件之间的时间间隔和接收事件的ActionListener(在这种情况下是我们的Field类)传递给构造函数来创建Timer。我们为计时器提供一个简单但唯一的动作命令,使其成为重复计时器,并启动它!

另一个好处特定于 Swing 和图形应用程序:javax.swing.Timer事件调度线程上触发其动作事件。您无需将任何响应包装在invokeAndWait()invokeLater()中。只需将基于计时器的代码放在附加监听器的actionPerformed()方法中,一切都搞定!

因为有几个组件会生成ActionEvent对象,所以我们通过为我们的计时器设置actionCommand属性来预防碰撞。在我们的情况下,这一步并非必须,但这样做可以让Field类在不破坏我们的动画的情况下处理其他事件。

其他计时器用途

成熟、精练的应用程序在许多小细节上都会受益于一次性计时器。与大多数商业应用或游戏相比,我们的苹果游戏显得比较简单,但即使在这里,我们也可以通过计时器增加一些“真实感”:在扔苹果后,我们可以暂停一下,然后让物理学家再扔一个苹果。也许物理学家需要弯下腰从桶里拿另一个苹果再瞄准或扔。这种延迟正是另一个Timer的完美应用场景。

我们可以在Field类中扔苹果的代码中加入这个暂停:

  public void startTossFromPlayer(Physicist physicist) {
    if (!animating) {
      System.out.println("Starting animation!");
      animating = true;
      startAnimation();
    }
    if (animating) {
      // Check to make sure we have an apple to toss
      if (physicist.aimingApple != null) {
        Apple apple = physicist.takeApple();
        apple.toss(physicist.aimingAngle, physicist.aimingForce);
        apples.add(apple);
        Timer appleLoader = new Timer(800, physicist);
        appleLoader.setActionCommand("New Apple");
        appleLoader.setRepeats(false);
        appleLoader.start();
      }
    }
  }

注意,这次我们通过调用 setRepeats(false) 将定时器设置为仅运行一次。这意味着不到一秒钟后,将向我们的物理学家发送一个事件。物理学家类反过来需要在类定义中添加 implements Action Listener 部分,并包括适当的 actionPerformed() 函数,如下所示:

// other imports ...
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class Physicist implements ActionListener {

  // Current Physicist stuff ...

  // New event handler for getting a new apple
  public void actionPerformed(ActionEvent e) {
    if (e.getActionCommand().equals("New Apple")) {
      getNewApple();
      if (field != null) {
        field.repaint();
      }
    }
  }
}

在 Swing 中,使用 Timer 并不是完成此类任务的唯一方式,但是高效定时事件的结合和自动使用事件分发线程使其值得考虑。至少,它使原型设计变得容易。如果需要,您随时可以返回并重构应用程序以使用自定义线程代码。

但等等,还有更多

正如我们在本章开头提到的那样,Java 图形应用程序的世界中有更多更多的讨论、主题和探索可供参考。例如,Java 有一个专门用于存储用户偏好设置的整个包。而且,O’Reilly 出版了一本由 Jonathan Knudsen 撰写的Java 2D 图形的整本书。我们将让你自行探索,但是如果你计划开发桌面应用程序,我们想首先讨论一些值得关注的关键主题。

菜单

虽然在技术上不是必需的,但大多数桌面应用程序都有一个应用程序范围的菜单,用于常见任务,例如保存更改后的文件或设置首选项。具有特定功能的应用程序(例如电子表格)可能具有用于对列或选择数据进行排序的菜单。JMenuJMenuBarJMenuItem 类帮助您向 Swing 应用程序添加此功能。菜单位于菜单栏内部,菜单项位于菜单内部。Swing 有三个预构建的菜单项类:JMenuItem 用于基本菜单条目,JCheckBoxMenuItem 用于选项条目,JRadioButtonMenuItem 用于像当前选定的字体或颜色主题之类的分组菜单项。JMenu 类本身是一个有效的菜单项,因此您可以构建嵌套菜单。JMenuItem 的行为类似按钮(其单选框和复选框伙伴也是如此),您可以使用相同的侦听器捕获菜单事件。

图 12-32 显示了一个简单菜单栏的示例,其中包含一些菜单和菜单项。

ljv6 1232

图 12-32. JMenuJMenuItem 在 macOS 和 Linux 上

这里是此演示的源代码:

package ch12.examples;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class MenuDemo extends JFrame implements ActionListener {
  JLabel resultsLabel;

  public MenuDemo() {
    super("JMenu Demo");
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    setLayout(new FlowLayout());
    setSize(300, 180);

    resultsLabel = new JLabel("Click a menu item!");
    add(resultsLabel);

    // Now let's create a couple menus and populate them
    JMenu fileMenu = new JMenu("File");
    JMenuItem saveItem = new JMenuItem("Save");
    saveItem.addActionListener(this);
    fileMenu.add(saveItem);
    JMenuItem quitItem = new JMenuItem("Quit");
    quitItem.addActionListener(this);
    fileMenu.add(quitItem);

    JMenu editMenu = new JMenu("Edit");
    JMenuItem cutItem = new JMenuItem("Cut");
    cutItem.addActionListener(this);
    editMenu.add(cutItem);
    JMenuItem copyItem = new JMenuItem("Copy");
    copyItem.addActionListener(this);
    editMenu.add(copyItem);
    JMenuItem pasteItem = new JMenuItem("Paste");
    pasteItem.addActionListener(this);
    editMenu.add(pasteItem);

    // And finally build a JMenuBar for the application
    JMenuBar mainBar = new JMenuBar();
    mainBar.add(fileMenu);
    mainBar.add(editMenu);
    setJMenuBar(mainBar);
  }

  public void actionPerformed(ActionEvent event) {
    resultsLabel.setText("Menu selected: " + event.getActionCommand());
  }

  public static void main(String args[]) {
    MenuDemo demo = new MenuDemo();
    demo.setVisible(true);
  }
}

显然,在这里我们没有涉及菜单项动作,但它们说明了您如何开始构建专业应用程序的预期部分。

弹出窗口和弹出式窗口

事件让用户吸引到你的注意,或者至少是你应用程序中的某个方法的注意。但如果你需要引起用户的注意呢?这项任务的流行 UI 机制是弹出窗口。你经常会听到这样的窗口被称为模态对话框甚至模态对话框。术语对话框来自于这样一个事实,即这些弹出窗口向用户展示一些信息,并期望或者要求用户做出回应。也许这种快速问答过程并不像苏格拉底的座谈会那样高深,但仍然。模态的名称指的是一些需要回应的对话框实际上会禁用应用程序的其余部分——将其置于受限模式中——直到你提供回应为止。也许你在其他桌面应用程序中已经体验过这样的对话框。例如,如果你的软件要求你保持最新版本,它可能会“灰掉”应用程序,表示你无法使用它,然后显示一个模态对话框,其中包含一个按钮以启动更新过程。

术语弹出更加通用。尽管你当然可以有模态弹出,但你也可以有不阻止你使用应用程序其他部分的普通(“非模态”)弹出。想象一下在文字处理应用程序中的搜索对话框,你可以将其保持可用,并且只需将其移至主窗口的一侧。

Swing 提供了一个基本的JDialog类,你可以用它来创建自定义对话框窗口。对于与用户的典型弹出交互,包括警报、确认和输入对话框,JOptionPane类具有一些非常方便的快捷方式。对于简单的警报和错误消息,可以使用showMessageDialog()调用。这种类型的对话框包括可定制的标题、一些文本和一个确认(和关闭)弹出的按钮。如果你需要用户做出是或否选择,showConfirmDialog()非常适合。如果你需要从用户那里得到简短的基于文本的答案,你将想要使用showInputDialog()。图 12-33 展示了这三个对话框的示例。

ljv6 1233

图 12-33. JOptionPane 弹出窗口的主要变体

要创建一个消息对话框,你必须提供四个参数。第一个参数是指“拥有”弹出窗口的框架或窗口。当显示时,JOptionPane会尝试将对话框居中于其拥有者上。你也可以为此参数指定null,这告诉JOptionPane没有主要窗口,因此在用户屏幕上居中弹出。第二个和第三个参数是对话框消息和标题的String。最后一个参数指示弹出窗口的“类型”,这主要影响你看到的图标。你可以指定几种类型:

  • ERROR_MESSAGE,红色停止图标

  • INFORMATION_MESSAGE,杜克⁶ 图标

  • WARNING_MESSAGE,黄色三角形图标

  • QUESTION_MESSAGE,杜克图标

  • PLAIN_MESSAGE,无图标

返回jshell,尝试创建一个消息弹出窗口。您可以使用方便的null选项作为所有者:

jshell> import javax.swing.*

jshell> JOptionPane.showMessageDialog(null, "Hi there", "jshell Alert",
   ...>   JOptionPane.ERROR_MESSAGE)

弹出窗口的另一个常见任务是验证用户的意图。许多应用程序会询问您是否确定要退出或删除某些内容。JOptionPane已经为您考虑到了这一点。您可以在jshell中尝试这个确认对话框:

jshell> JOptionPane.showConfirmDialog(null, "Are you sure?")
$18 ==> 0

showConfirmDialog()方法的两个参数版本会生成一个带有“是”、“否”和“取消”按钮的弹出窗口。您可以通过保留返回值(一个int)来确定用户选择的答案。我们点击了“是”按钮,它返回0,但您不必记住返回值。JOptionPane提供了涵盖各种响应的常量:

  • YES_OPTION

  • NO_OPTION

  • CANCEL_OPTION

  • OK_OPTION

  • CLOSED_OPTION

如果用户使用窗口控件关闭对话框而不是单击对话框中的任何可用按钮,则JOptionPane返回CLOSED_OPTION值。此外,您对这些按钮有一些控制权。您可以使用带有标题和按钮选择之一的四个参数版本:

  • YES_NO_OPTION

  • OK_CANCEL_OPTION

  • YES_NO_CANCEL_OPTION

在大多数情况下,同一个对话框中同时包含“否”和“取消”选项会让用户感到困惑。我们建议使用前两个选项中的一个。如果用户不想做出选择,他们可以使用标准窗口控件关闭对话框。让我们尝试创建一个 Yes/No 对话框:

jshell> int answer = JOptionPane.showConfirmDialog(null,
   ...>   "Are you sure?", "Confirm", JOptionPane.YES_NO_OPTION)
answer ==> 1

jshell> if (answer == JOptionPane.NO_OPTION)
   ...>   System.out.println("They declined")
They declined

一些弹出窗口要求快速输入。您可以使用showInputDialog()方法提问并允许用户输入答案。这个答案(一个String)可以像保存确认选项一样保存。让我们在jshell中再试一个弹出窗口:

jshell> String pin = JOptionPane.showInputDialog(null, "Please enter your PIN:")
pin ==> "1234"

对话框适用于一次性请求,但如果您需要向用户询问一系列问题,则不建议使用它们。您应该将模态对话框限制在快速且不频繁的任务中。它们通过设计中断用户。有时候这种中断正是您所需要的。然而,如果您滥用用户的注意力,很可能会惹恼用户,他们会学会简单地忽略您应用程序的每一个弹出窗口。

文件ch12/examples/ModalDemo.java包含一个可以创建各种模态对话框的小应用程序。随意玩弄它,并尝试不同的消息类型或确认按钮选项。并且不要害怕修改这些示例应用程序!有时修改简单的应用程序并重新编译比尝试在jshell中输入多行示例更容易。

用户界面和用户体验

这是对桌面应用程序中一些更常见的 UI 元素(如JButtonJLabelJTextField)的快速介绍。我们讨论了如何使用布局管理器在容器中排列这些组件,并介绍了几个其他组件。

当然,桌面应用程序只是一个方面。第十三章介绍了网络基础知识,包括从网络获取内容和简单的客户端/服务器应用程序。

复习问题

  1. 您会使用哪个组件向用户显示一些文本?

  2. 您会使用哪个组件(或组件)允许用户输入文本?

  3. 点击按钮会生成什么事件?

  4. 如果您想知道用户何时更改所选项,应该附加哪个侦听器到JList

  5. JPanel的默认布局管理器是什么?

  6. Java 中负责处理事件的线程是哪一个?

  7. 在后台任务完成后,您将使用什么方法来更新像JLabel这样的组件?

  8. 什么容器包含JMenuItem对象?

代码练习

  1. 创建一个带有按钮和文本显示的计算器界面。您可以使用ch12/exercises文件夹中的起始Calculator类。它扩展了JFrame并实现了ActionListener接口。显示元素应位于计算器的顶部,并显示右对齐文本。按钮应包括数字 0-9,小数点,加法,减法,乘法,除法以及用于显示结果的“等于”按钮。您可以在图 12-34 中看到它的样子。

    ljv6 1234

    图 12-34. 一个示例计算器界面

    暂时不要担心连接按钮使计算器工作——暂时。我们将在高级练习中解决这个问题。

  2. ch12/exercises/game 文件夹中的苹果投掷游戏具有滑块和按钮,用于瞄准和投掷苹果。目前这些苹果只是以弧线飞行,最终超出窗口的边界。添加必要的代码来捕捉苹果与障碍物(如树或篱笆)之间的碰撞。您的解决方案应该移除苹果和障碍物,然后刷新屏幕。

高级练习

  1. 将您在第一个代码练习中为计算器创建的视觉外壳连接起来,使其功能化。点击数字按钮应将相应的数字显示在显示器上。点击操作按钮(如加法或除法)应存储要执行的操作,并允许用户输入第二个数字。点击“=”按钮应显示操作的结果。

此练习整合了前几章的几次讨论。进行渐进式更改,不要害怕查看“高级练习”以获取进一步操作的提示。

¹ 如果您对这个主题感兴趣,并希望了解商业桌面 Java 应用程序的幕后情况,JetBrains 发布了社区版的源代码。

² javax包前缀早期由 Sun 引入,以适应 Java 中分发但不属于“核心”的包。这一决定引起了一定的争议,但javax已经成为惯例,并且还与其他包一起使用。

³ 你需要从包含编译示例的顶级目录开始启动jshell。如果你使用的是 IntelliJ IDEA,可以启动它的终端并使用cd out/production/LearningJava6e切换目录,然后启动jshell

⁴ 在这些jshell示例中,我们创建 Swing 组件时,为了节省空间,我们将省略大部分输出。jshell会打印有关每个组件的大量信息,但在情况过于极端时也会使用省略号。在你进行实验时,如果看到有关元素属性的额外细节,不必惊慌。

⁵ 我们还应该注意,有许多开源项目具有更复杂的组件,用于处理文本中的语法高亮显示,各种选择辅助工具,图表,复合输入(如日期或时间选择器)等功能。

⁶ “杜克”是官方的 Java 吉祥物。你可以在OpenJDK wiki上了解更多信息。

第十三章:Java 网络编程

当你想到网络时,你可能会想到基于网络的应用和服务。如果你被要求深入探讨,你可能会考虑支持这些应用程序并在网络中传输数据的工具,例如网络浏览器和网络服务器。在本章中,我们将看一下 Java 如何与网络服务交互。我们还会稍微窥探一下底层的网络类,例如 java.net 包中的一些低级网络类。

统一资源定位符

统一资源定位符(URL)指向互联网上的一个对象。它是一个文本字符串,用于标识一个项目,告诉你在哪里找到它,并指定与之通信或从其源获取它的方法。URL 可以指向任何类型的信息源:静态数据,例如本地文件系统上的文件、Web 服务器或 FTP 站点。它可以指向更动态的对象,例如 RSS 新闻订阅或数据库中的记录。URL 还可以引用其他资源,例如电子邮件地址。

因为在互联网上定位项目有许多不同的方法,不同的介质和传输需要不同类型的信息,URL 可以具有多种形式。最常见的形式包含如 图 13-1 所示的四个组件:网络主机或服务器、项目名称、其在主机上的位置以及主机应该使用的协议。

ljv6 1301

图 13-1. URL 的常见元素

protocol(也称为“方案”)是诸如 httphttpsftp 的标识符;hostname 通常是互联网主机和域名;pathresource 组件形成了一个唯一的路径,用于标识该主机上的对象。

这种形式的变体在 URL 中包含额外的信息。例如,你可以指定片段标识符(以“#”字符开头的后缀),用来引用文档内的各个部分。还有其他更专门的 URL 类型,比如用于电子邮件地址的“mailto” URL,或者用于定位诸如数据库组件之类的 URL。这些定位符可能不严格遵循此格式,但通常包含协议、主机和路径。一些更适当地称为 统一资源标识符(URI)的内容,可以指定有关资源名称或位置的更多信息。URL 是 URI 的子集。

因为大多数 URL 具有层次结构或路径的概念,所以我们有时会说一个 URL 相对于另一个 URL,称为 基本 URL。在这种情况下,我们使用基本 URL 作为起点,并提供额外的信息来相对于该 URL 定位一个对象。例如,基本 URL 可能指向 Web 服务器上的一个目录,而相对 URL 可能命名该目录中的特定文件或子目录中的文件。

URL 类

Java 的java.net.URL类表示 URL 地址,并为访问服务器上的文档和应用程序等网络资源提供了一个简单的 API。它可以使用可扩展的协议和内容处理程序来执行必要的通信,理论上甚至可以进行数据转换。使用URL类,应用程序只需几行代码就可以连接到服务器并检索内容。

URL类的一个实例管理 URL 字符串中的所有组件信息,并提供了用于检索其标识的对象的方法。我们可以从完整字符串或组件部分构造一个URL对象:

try {
  URL aDoc =
    new URL("http://foo.bar.com/documents/homepage.html");
  URL sameDoc =
    new URL("http","foo.bar.com","/documents/homepage.html");
} catch (MalformedURLException e) {
  // Something wrong with our URL
}

这两个URL对象指向同一个网络资源,即服务器foo.bar.com上的homepage.html文档。我们无法知道资源是否实际存在并且可用,直到我们尝试访问它。新的URL对象仅包含有关对象位置及其访问方式的数据。创建URL对象不会建立任何网络连接。

注意

Oracle 在 Java 20 中已经废弃了URL构造函数。废弃并不会移除方法或类,但意味着您应该考虑其他实现您目标的方法。废弃的项目的 Javadoc 通常包含建议的替代方法。在这种情况下,URI类具有更好的验证代码,因此 Oracle 建议使用new URI("http://your.url/").toURL()作为替代方案。

如果您正在使用 Java 20 或更高版本,可以随意更新代码示例以使用URI,以摆脱编译器的过时警告。尽管如此,由于这是最近的废弃,您仍然会在在线示例中广泛看到URL构造函数的使用。

我们可以使用getProtocol()getHost()getFile()方法来检查URL的各个部分。我们还可以使用sameFile()方法(一个可能不指向文件的不幸命名的方法)将其与另一个URL进行比较,该方法确定两个 URL 是否指向相同的资源。虽然sameFile()并不是绝对可靠的,但它比仅比较 URL 字符串是否相等的方法更加智能;它考虑了一个服务器可能有多个名称以及其他因素。

当你创建一个URL时,Java 会解析 URL 的规范以识别协议组件。然后,它会尝试将它从你的 URL 解析出来的内容与协议处理程序进行匹配。协议处理程序本质上是一个可以使用给定协议并根据协议规则检索资源的助手。如果 URL 的协议不合理,或者 Java 找不到兼容的协议处理程序,URL构造函数会抛出一个MalformedURLException

Java 为httphttps(安全 HTTP)和ftp提供了 URL 协议处理程序,以及本地file URL 和引用 JAR 存档内文件的jar URL。Java 还为第三方库提供了必要的低级结构,以添加对其他类型 URL 的支持。

流数据

URL 获取数据的最低级和最通用的方式是通过调用 openStream() 方法来获取 URLInputStream。如果您想要从动态信息源接收持续更新,作为流获取数据可能也是有用的。不幸的是,您必须自己解析这个流的内容。并非所有类型的 URL 都支持 openStream() 方法,因为并非所有类型的 URL 都指向具体的数据;如果 URL 不支持,您将会得到一个 UnknownServiceException

以下代码(对 ch13/examples/Read.java 文件的简化)会打印出来自虚构 Web 服务器的 HTML 文件的内容:

  try {
    URL url = new URL("http://some.server/index.html");

    BufferedReader bin = new BufferedReader(
        new InputStreamReader(url.openStream()));

    String line;
    while ((line = bin.readLine()) != null) {
      System.out.println(line);
    }
    bin.close();
  } catch (Exception e) {
    e.printStackTrace();
  }

在这个片段中,我们使用 openStream() 从我们的 url 获取一个 InputStream,并将其包装在 BufferedReader 中以读取文本行。因为我们在 URL 中指定了 http 协议,所以我们利用了 HTTP 协议处理程序的服务。我们还没有讨论内容处理程序。因为我们直接从输入流中读取,所以不需要内容处理程序来转换内容。

获取作为对象的内容

正如我们之前所说,openStream() 是访问 Web 内容的最通用方法,但它将数据解析留给程序员。URL 类支持更复杂的、可插拔的内容处理机制,但是 Java 社区从未真正标准化实际的处理程序,因此它的实用性有限。

许多开发者对通过网络加载对象感兴趣,因为他们需要从 URL 加载图像。Java 提供了几种替代方法来完成这个任务。最简单的方法是使用 javax.swing.ImageIcon 类,它有一个接受 URL 参数的构造方法:

//file: ch13/examples/IconLabel.java
    URL fav = new URL("https://www.oracle.com/.../favicon-192.png");
    ImageIcon image1 = new ImageIcon(fav);
    JLabel iconLabel = new JLabel(image1);
    // iconLabel can be placed in any panel, just as other labels

如果您需要将网络流转换为其他类型的对象,可以查看 URL 类的 getContent() 方法。不过,您可能需要自己编写处理程序。关于这个高级主题,我们推荐阅读 Java 网络编程 一书,作者是 Elliotte Rusty-Harold(O’Reilly)。

管理连接

URL 上调用 openStream() 方法时,Java 会查阅协议处理程序,并建立到远程服务器或位置的连接。连接由 URLConnection 对象表示,它的子类管理不同的协议特定通信,并提供有关源的额外元数据。例如,HttpURLConnection 类处理基本的网络请求,还添加了一些 HTTP 特定功能,比如解释 “404 Not Found” 消息和其他 Web 服务器错误。我们稍后会详细讨论 HttpURLConnection

我们可以通过 openConnection() 方法直接从我们的 URL 获取一个 URLConnection。我们可以在读取数据之前询问 URLConnection 的对象内容类型。例如:

URLConnection connection = myURL.openConnection();
String mimeType = connection.getContentType();
InputStream in = connection.getInputStream();

尽管其名称如此,URLConnection对象最初处于原始未连接状态。在本例中,直到我们调用getContentType()方法之前,网络连接实际上并未初始化。URLConnection在数据请求或显式调用其connect()方法之前不会与源通信。在连接之前,我们可以设置网络参数并提供协议特定的详细信息。例如,我们可以设置连接到服务器的初始连接和读取尝试的超时时间:

URLConnection connection = myURL.openConnection();
connection.setConnectTimeout(10000); // milliseconds
connection.setReadTimeout(10000); // milliseconds
InputStream in = connection.getInputStream();

正如我们将在“使用 POST 方法”中看到的那样,通过将URLConnection转换为其特定子类型,我们可以获得协议特定的信息。

与 Web 应用程序通信

Web 浏览器是 Web 应用程序的通用客户端。它们检索文档以进行显示,并通过 HTML、JavaScript 和诸如图像之类的链接文档作为用户界面。在本节中,我们将编写客户端 Java 代码,使用URL类通过 HTTP 处理 Web 应用程序。这种组合允许我们直接使用GETPOST操作与 Web 应用程序交互。

我们在这里讨论的主要任务是将数据发送到服务器,特别是 HTML 表单编码数据。浏览器以特殊格式对 HTML 表单字段的名称/值对进行编码,并使用两种方法之一将其发送到服务器(通常)。第一种方法使用 HTTP GET命令,将用户输入编码到 URL 本身并请求相应文档。服务器识别 URL 的第一部分引用一个程序,并调用它,将 URL 的另一部分编码的信息作为参数传递给它。第二种方法使用 HTTP POST命令要求服务器接受编码数据,并将其作为流传递给 Web 应用程序。

使用GET方法

使用GET方法可以快速利用网络资源。只需创建指向服务器程序的 URL,并使用简单的约定附加构成数据的编码名称/值对即可。例如,以下代码片段打开了一个指向服务器myhost上名为login.cgi的老式 CGI 程序的 URL,并传递了两个名称/值对。然后,它打印出 CGI 发送回来的任何文本:

  URL url = new URL(
    // this string should be URL-encoded
    "http://myhost/cgi-bin/login.cgi?Name=Pat&Password=foobar");

  BufferedReader bin = new BufferedReader(
    new InputStreamReader(url.openStream()));

  String line;
  while ((line = bin.readLine()) != null) {
    System.out.println(line);
  }

为了使用带参数的 URL,我们从login.cgi的基本 URL 开始。我们添加一个问号(?),标志着参数数据的开始,后面跟着第一个“name=value”对。我们可以添加任意多个名称/值对,用和号(&)字符分隔。我们的其余代码只是简单地打开流并从服务器读回响应。请记住,创建 URL 并不实际打开连接。在这种情况下,当我们调用openStream()时,URL 连接是隐式建立的。尽管我们在这里假设服务器返回文本,但它可以发送任何东西,包括图像、音频或 PDF 文件。

我们在这里跳过了一步。这个示例之所以有效,是因为我们的名称/值对恰好是简单的文本。如果任何“非可打印”或特殊字符(包括?&)在这些对中,它们必须首先进行编码。java.net.URLEncoder类提供了一个编码数据的实用工具。我们将在“使用 POST 方法”中的下一个示例中展示如何使用它。

虽然这个小示例发送了一个密码字段,但你不应该使用这种简单的方法发送敏感数据。这个示例中的数据以明文形式通过网络发送(未加密)。即使使用 HTTPS(HTTP 安全)也不会模糊 URL。而且在这种情况下,密码字段也会出现在 URL 打印的任何地方,包括服务器日志、浏览器历史记录和书签中。

使用 POST 方法

对于更大量的输入数据或敏感内容,你可能会使用POST选项。这是一个小应用程序,它的行为类似于 HTML 表单。它从两个文本字段——namepassword——收集数据,并使用 HTTP POST方法将数据发送到 Postman Echo 服务¹的 URL。这个基于 Swing 的客户端应用程序就像一个 Web 浏览器一样工作,并与 Web 应用程序连接。

这是执行请求并处理响应的关键网络方法:

//file: ch13/examples/Post.java

  protected void postData() {
    StringBuilder sb = new StringBuilder();
    String pw = new String(passwordField.getPassword());
    try {
      sb.append(URLEncoder.encode("Name", "UTF-8") + "=");
      sb.append(URLEncoder.encode(nameField.getText(), "UTF-8"));
      sb.append("&" + URLEncoder.encode("Password", "UTF-8") + "=");
      sb.append(URLEncoder.encode(pw, "UTF-8"));
    } catch (UnsupportedEncodingException uee) {
      System.out.println(uee);
    }
    String formData = sb.toString();

    try {
      URL url = new URL(postURL);
      HttpURLConnection urlcon =
          (HttpURLConnection) url.openConnection();
      urlcon.setRequestMethod("POST");
      urlcon.setRequestProperty("Content-type",
          "application/x-www-form-urlencoded");
      urlcon.setDoOutput(true);
      urlcon.setDoInput(true);
      PrintWriter pout = new PrintWriter(new OutputStreamWriter(
          urlcon.getOutputStream(), "8859_1"), true);
      pout.print(formData);
      pout.flush();

      // Did the post succeed?
      if (urlcon.getResponseCode() == HttpURLConnection.HTTP_OK)
        System.out.println("Posted ok!");
      else {
        System.out.println("Bad post...");
        return;
      }

      // Hooray! Go ahead and read the results
      InputStream is = urlcon.getInputStream();
      InputStreamReader isr = new InputStreamReader(is);
      BufferedReader br = new BufferedReader(isr);
      String line;
      while ((line = br.readLine()) != null) {
        System.out.println(line);
      }
      br.close();

    } catch (MalformedURLException e) {
      System.out.println(e);     // bad postURL
    } catch (IOException e2) {
      System.out.println(e2);    // I/O error
    }
  }

应用程序的开头使用 Swing 元素创建表单,就像我们在第十二章中所做的那样。所有的魔法都在受保护的postData()方法中发生。首先,我们创建一个StringBuilder并用用&分隔的名称/值对加载它。(当我们使用POST方法时,我们不需要初始问号,因为我们不是在 URL 上追加。)每对都首先使用静态的URLEncoder.encode()方法进行编码。即使在这个示例中,名称字段不包含任何特殊字符,我们也会通过编码器运行名称字段。这个额外的步骤是最佳实践,只是一个好习惯。字段名称可能并不总是如此简单。

接下来,我们设置与服务器的连接。在我们之前的例子中,我们不需要执行任何特殊操作来发送数据,因为请求是通过在服务器上打开 URL 简单完成的。在这里,我们必须承担与远程 Web 服务器通信的一些工作。幸运的是,HttpURLConnection对象为我们完成了大部分工作;我们只需告诉它我们要发送的数据类型及如何发送。我们通过openConnection()方法获取一个URLConnection对象。由于我们使用的是 HTTP 协议,所以可以安全地将其强制转换为HttpURLConnection类型,它具有我们需要的支持。因为 HTTP 是一种有保证的协议之一,我们可以安全地做出这个假设。(说到安全性,我们在这里仅仅出于演示目的使用 HTTP。如今许多数据被视为敏感数据。行业指南已经默认使用 HTTPS;稍后在“SSL 和安全 Web 通信”中详细讨论。)

我们使用setRequestMethod()告知连接我们要进行POST操作。还使用setRequestProperty()设置我们的 HTTP 请求的Content-Type字段为适当的类型——在这种情况下,编码表单数据的正确媒体类型²(这是必要的,告诉服务器我们发送的数据类型,我们的情况下是"application/x-www-form-urlencoded")。

对于最后的配置步骤,我们使用setDoOutput()setDoInput()方法告知连接我们要发送和接收流数据。URL 连接从这个组合推断我们将进行POST操作,并期望得到一个响应。

要发送数据,我们从连接中获取一个输出流使用getOutputStream(),并创建一个PrintWriter以便轻松编写我们的编码表单内容。发送数据后,我们的应用程序调用getResponseCode()来查看服务器的 HTTP 响应代码是否指示POST成功。其他响应代码(在HttpURLConnection中定义为常量)表示各种失败情况。

尽管表单编码数据(如我们为Content-Type字段指定的媒体类型所示)很常见,但也有其他类型的通信方式。我们可以使用输入和输出流与服务器程序交换任意数据类型。POST操作可以发送任何类型的数据;服务器应用程序只需知道如何处理即可。最后注意:如果你正在编写一个需要解码表单数据的应用程序,可以使用java.net.URLDecoder来撤消URLEncoder的操作。调用decode()时务必指定 UTF-8。

HttpURLConnection

HttpURLConnection 中还可以获取请求的其他信息。我们可以使用 getContentType()getContentEncoding() 来确定响应的 MIME 类型和编码。我们还可以通过使用 getHeaderField() 来查询 HTTP 响应头(HTTP 响应头是随响应一起传输的元数据名称/值对)。便捷方法可以获取整数和日期格式的头字段,getHeaderFieldInt()getHeaderFieldDate(),它们分别返回 intlong 类型。内容长度和上次修改日期可以通过 getContentLength()getLastModified() 获得。

SSL 和安全的 Web 通信

之前的一些示例发送了敏感数据到服务器。标准的 HTTP 不提供加密来隐藏我们的数据。幸运的是,像这样为 GETPOST 操作添加安全性对于客户端开发者来说是很容易的(实际上是微不足道的)。在可用的情况下,你只需要使用 HTTP 协议的安全形式 — HTTPS。考虑 Post 示例中的测试 URL:

https://postman-echo.com/post

HTTPS 是标准 HTTP 协议运行在安全套接字层(SSL)之上的一个版本,它使用公钥加密技术来加密浏览器与服务器之间的通信。大多数 Web 浏览器和服务器目前都内置支持 HTTPS(或原始的 SSL 套接字)。因此,如果你的 Web 服务器支持并配置了 HTTPS,你可以通过在 URL 中指定 https 协议来简单地发送和接收安全数据。关于 SSL 和安全相关方面还有很多内容需要学习,比如验证你实际在与谁通信,但是就基本数据加密而言,这就是你需要做的一切。这不是你的代码直接处理的事情。Java 提供了 SSL 和 HTTPS 的支持。

网络编程

Web 主导了开发者对网络的讨论,但在这其中不仅仅是 HTML 页面!随着 Java 的网络 API 的成熟,Java 也成为了实现传统客户端/服务器应用程序和服务的首选语言。在本节中,我们将看看 java.net 包,其中包含了用于通信和处理网络资源的基本类。

java.net 包的类分为两类:Sockets API,用于处理低级网络协议,以及与 URL 一起工作的高级、面向 Web 的 API,正如我们在前一节中看到的。图 13-2 展示了 java.net 包的大部分层次结构。

ljv6 1302

图 13-2. java.net 包的主要类和接口

Java 的套接字 API 提供了对主机间通信所使用的标准协议的访问。套接字 是所有其他种类便携式网络通信的基础机制。套接字是通用网络工具箱中的最低级工具——你可以使用套接字进行客户端和服务器或对等应用程序之间的任何类型的通信,但你必须实现自己的应用程序级协议来处理和解释数据。更高级别的网络工具,如远程方法调用、HTTP 和 web 服务,都是在套接字之上实现的。

套接字

套接字是用于网络通信的低级编程接口。它们在可能或可能不在同一主机上的应用程序之间发送数据流。

套接字起源于 BSD Unix,在某些编程语言中,它们是一些混乱、复杂的东西,有很多小部分可能会断开并引起混乱。这是因为大多数套接字 API 可以与几乎任何类型的底层网络协议一起使用。由于传输数据的协议可能具有根本不同的特性,套接字接口可能会非常复杂。

java.net 包支持一个简化的、面向对象的套接字接口,使网络通信变得更加容易。如果你以前使用其他语言的套接字进行过网络编程,你会惊讶地发现当对象封装了繁琐的细节时,事情可以变得多么简单。如果这是你第一次接触套接字,你会发现与另一个应用程序在网络上通信就像读取文件或获取用户输入一样简单。Java 中的大多数 I/O 形式,包括大多数网络 I/O,都使用了 “Streams” 中描述的流类。流提供了统一的 I/O 接口,使得在互联网上进行读取或写入类似于在本地系统上进行读取或写入。除了面向流的接口之外,Java 网络 API 还可以与用于高度可扩展应用程序的 Java NIO 缓冲区 API 一起使用。

Java 提供套接字支持三种不同的底层协议类:SocketDatagramSocketMulticastSocket。在本节中,我们将介绍 Java 的基本 Socket 类,它使用了 面向连接可靠 的协议。面向连接的协议提供了类似于电话对话的功能。建立连接后,两个应用程序可以来回发送数据流,即使没有人在说话,连接也会保持在那里。由于协议是可靠的,它还确保没有数据丢失(必要时重新发送数据),并且你发送的任何内容都会按照你发送的顺序到达。

我们将留下另外两个使用 无连接不可靠 协议的类,让您自行探索。(再次参见 Java 网络编程,由 Elliotte Rusty-Harold 详细讨论。)无连接协议类似于邮政服务。应用程序可以向彼此发送短消息,但事先不建立端到端连接,并且不尝试保持消息的顺序。甚至不能保证消息会到达。MulticastSocketDatagramSocket 的变体,执行多播 —— 同时向多个接收者发送数据。类似于使用数据报套接字,使用多播套接字工作时只是有更多的接收者。

理论上,套接字层下面几乎可以使用任何协议。实际上,互联网上只有一个重要的协议族,并且只有一个 Java 支持的协议族:互联网协议(IP)。Socket 类通过 IP(通常被称为 TCP/IP)使用 TCP,传输控制协议;而无连接的 DatagramSocket 类通过 IP 使用 UDP,用户数据报协议。

客户端和服务器

在编写网络应用程序时,通常会谈论客户端和服务器。这两者之间的区别越来越模糊,但客户端通常启动对话,而服务器通常接受传入请求。这些角色有许多微妙之处,⁴ 但为简单起见,我们将使用这个定义。

客户端和服务器之间的一个重要区别在于,客户端可以随时创建套接字以启动与服务器应用程序的对话,而服务器必须事先准备好以侦听传入的对话请求。java.net.Socket 类代表客户端和服务器上单个套接字连接的一侧。此外,服务器使用 java.net.ServerSocket 类来侦听来自客户端的新连接。在大多数情况下,作为服务器的应用程序会创建一个 ServerSocket 对象并等待,通过调用其 accept() 方法被阻塞,直到请求到达。当客户端尝试连接时,accept() 方法会创建一个新的 Socket 对象,服务器用该对象与客户端进行通信。ServerSocket 实例会将有关客户端的详细信息传递给新的 Socket,如 图 13-3 所示。

ljv6 1303

图 13-3. 使用 SocketServerSocket 的客户端和服务器

该套接字继续与客户端进行对话,使得ServerSocket能够恢复其监听任务。这样,服务器就可以同时与多个客户端进行对话。仍然只有一个ServerSocket,但服务器拥有多个Socket对象——每个客户端一个。

客户端

在套接字级别,客户端需要两个信息来定位和连接到互联网中的服务器:一个主机名(用于查找主机计算机的网络地址)和一个端口号。端口号是一个标识符,用于区分同一主机上的多个网络服务或连接。

服务器应用程序在预先安排的端口上监听,同时等待连接。客户端向那个预先安排的端口号发送请求。如果你把主机计算机想象成一个酒店,而各种可用的服务作为客人,那么端口就像客人的房间号码。要连接到一个服务,你必须知道酒店名称和正确的房间号码。

客户端应用程序通过构造一个指定这两个信息的Socket,来打开与服务器的连接。

    try {
      Socket sock = new Socket("wupost.wustl.edu", 25);
    } catch (UnknownHostException e) {
      System.out.println("Can't find host.");
    } catch (IOException e) {
      System.out.println("Error connecting to host.");
    }

这段客户端代码试图将一个Socket连接到主机的 25 号端口(SMTP 邮件服务),该主机为wupost.wustl.edu。客户端必须处理主机名无法解析(UnknownHostException)和服务器可能不接受新连接(IOException)的情况。Java 使用 DNS,即标准的域名服务(DNS),来将主机名解析为一个IP 地址

IP 地址(来自互联网协议)是互联网的电话号码,DNS 是全球电话簿。连接到互联网的每台计算机都有一个 IP 地址。如果你不知道那个地址,就通过 DNS 查询。但如果你知道服务器的地址,Socket构造函数也可以接受一个包含原始 IP 地址的字符串:

    Socket sock = new Socket("22.66.89.167", 25);

无论你如何开始,一旦sock连接上,你就可以通过getInputStream()getOutputStream()方法检索输入和输出流。以下(相当任意的)代码通过流发送和接收一些数据:

    try {
      Socket server = new Socket("foo.bar.com", 1234);
      InputStream in = server.getInputStream();
      OutputStream out = server.getOutputStream();

      // write a byte
      out.write(42);

      // write a newline or carriage return delimited string
      PrintWriter pout = new PrintWriter(out, true);
      pout.println("Hello!");

      // read a byte
      byte back = (byte)in.read();

      // read a newline or carriage return delimited string
      BufferedReader bin =
        new BufferedReader(new InputStreamReader(in) );
      String response = bin.readLine();

      server.close();
    } catch (IOException e) {
      System.err.println(e);
    }

在这个交换过程中,客户端首先创建一个Socket,用于与服务器通信。Socket构造函数指定服务器的主机名(foo.bar.com)和一个预先安排的端口号(1234)。一旦客户端连接上,它就使用OutputStreamwrite()方法向服务器写入一个字节。为了更方便地发送一串文本,它随后将一个PrintWriter包装在OutputStream周围。接下来,它执行互补操作:使用InputStreamread()方法从服务器读取一个字节,然后创建一个BufferedReader,以便获取完整的文本字符串。客户端随后使用close()方法终止连接。所有这些操作都有可能生成IOException;我们的代码片段通过将整个对话包装在一个try/catch块中来处理这些检查异常。

服务器

在对话的另一端,在建立连接之后,服务器应用程序使用相同类型的Socket对象与客户端进行通信。然而,要接受来自客户端的连接,它必须首先创建绑定到正确端口的ServerSocket。让我们从服务器的角度重新创建以前的对话:

    // Meanwhile, on foo.bar.com...
    try {
      ServerSocket listener = new ServerSocket(1234);

      while (!finished) {
        Socket client = listener.accept();  // wait for connection

        InputStream in = client.getInputStream();
        OutputStream out = client.getOutputStream();

        // read a byte
        byte someByte = (byte)in.read();

        // read a newline or carriage-return-delimited string
        BufferedReader bin =
          new BufferedReader(new InputStreamReader(in) );
        String someString = bin.readLine();

        // write a byte
        out.write(43);

        // say goodbye
        PrintWriter pout = new PrintWriter(out, true);
        pout.println("Goodbye!");

        client.close();
      }

      listener.close();
    } catch (IOException e) {
      System.err.println(e);
    }

首先,我们的服务器创建一个绑定到端口 1234 的ServerSocket。在大多数系统上,有关应用程序可以使用哪些端口的规则。端口号是无符号的 16 位整数,这意味着它们的范围可以从 0 到 65535。低于 1024 的端口号通常保留给系统进程和标准的“众所周知”服务,因此我们选择一个不在此保留范围内的端口号。⁵我们只需创建一次ServerSocket;之后,它可以接受到达的任意数量的连接。

接下来,我们进入一个循环,等待ServerSocketaccept()方法返回来自客户端的活动Socket连接。当建立连接后,我们执行对话的服务器端,然后关闭连接并返回循环顶部等待另一个连接。最后,当服务器应用程序想要完全停止监听连接时,它调用ServerSocketclose()方法。

此服务器是单线程的;它一次处理一个连接,在完成与一个客户端的完整对话后返回循环顶部,并调用accept()以侦听另一个连接。一个更现实的服务器将有一个循环,同时接受连接,并将它们传递到它们自己的线程进行处理。尽管我们不打算创建一个 MMORPG,⁶我们确实展示了如何使用线程每客户端方法进行对话,在“分布式游戏”中展示。如果您想进行一些独立阅读,您还可以查找非阻塞的 NIO 等效ServerSocketChannel

DateAtHost客户端

在过去,许多网络计算机运行了一个简单的时间服务,该服务在一个众所周知的端口上分发其时钟的本地时间。时间协议是 NTP 的前身,更一般的网络时间协议。我们将坚持使用时间协议因其简单性,但如果您想要同步网络系统的时钟,NTP 是一个更好的选择。⁷

下一个示例,DateAtHost,包括一个java.util.Date的子类,该子类从远程主机获取时间,而不是从本地时钟初始化自己。(参见第八章讨论Date类,虽然在某些用途上仍然有效,但已大部分被其更新、更灵活的衍生类LocalDateLocalTime替代。)

DateAtHost连接到时间服务(端口 37),并读取表示远程主机时间的四个字节。这四个字节有一个特定的规范,我们解码以获取时间。以下是代码:

//file: ch13.examples.DateAtHost.java
package ch13.examples;

import java.net.Socket;
import java.io.*;

public class DateAtHost extends java.util.Date {
  static int timePort = 37;
  // seconds from start of 20th century to Jan 1, 1970 00:00 GMT
  static final long offset = 2208988800L;

  public DateAtHost(String host) throws IOException {
    this(host, timePort);
  }

  public DateAtHost(String host, int port) throws IOException {
    Socket server = new Socket(host, port);
    DataInputStream din =
      new DataInputStream(server.getInputStream());
    int time = din.readInt();
    server.close();

    setTime((((1L << 32) + time) - offset) * 1000);
  }
}

就是这样。即使稍微有些花哨,它也不是很长。我们为DateAtHost提供了两个可能的构造函数。通常我们会使用第一个构造函数,它简单地将远程主机的名称作为参数。第二个构造函数指定了远程时间服务的主机名和端口号。(如果时间服务在非标准端口上运行,则使用第二个构造函数指定备用端口号。)第二个构造函数负责建立连接并设置时间。第一个构造函数只是调用第二个构造函数(使用this()构造)并使用默认端口作为参数。在 Java 中,提供简化的构造函数,这些构造函数调用带有默认参数的同级构造函数是一种常见且有用的模式;这也是我们在这里展示的主要原因。

第二个构造函数在远程主机上指定端口打开一个套接字。它创建一个DataInputStream来包装输入流,然后使用readInt()方法读取一个四字节整数。这些字节的顺序正确并非巧合。Java 的DataInputStreamDataOutputStream类使用网络字节顺序(从最高有效位到最低有效位)处理整数类型的字节。时间协议(以及处理二进制数据的其他标准网络协议)也使用网络字节顺序,因此我们不需要调用任何转换例程。如果我们使用非标准协议,特别是与非 Java 客户端或服务器通信时,可能需要进行显式数据转换。在这种情况下,我们必须逐字节读取并重新排列以获取我们的四字节值。读取数据后,我们完成套接字操作,因此关闭它以终止与服务器的连接。最后,构造函数通过使用计算出的时间值调用DatesetTime()方法来初始化对象的其余部分。

时间值的四个字节被解释为表示 20 世纪初以来的秒数的整数。DateAtHost将其转换为 Java 的绝对时间概念——自 1970 年 1 月 1 日起的毫秒计数(这是由 C 和 Unix 标准化的任意日期)。转换首先创建一个long值,它是整数time的无符号等效值。它减去一个偏移量以使时间相对于时代(1970 年 1 月 1 日)而不是世纪,并乘以 1,000 以转换为毫秒。转换后的时间用于初始化对象。

DateAtHost类几乎可以像Date与本地主机上的时间一样与从远程主机检索的时间一起工作。唯一的额外开销是处理DateAtHost构造函数可能抛出的可能的IOException异常:

    try {
      Date d = new DateAtHost("time.nist.gov");
      System.out.println("The time over there is: " + d);
    }
    catch (IOException e) {
      System.err.println("Failed to get the time: " + e);
    }

这个示例获取来自主机time.nist.gov的时间并打印其值。

分布式游戏

我们可以利用我们新发现的网络技能来扩展我们的苹果投掷游戏,并进行双人游戏。我们必须将这次尝试保持简单,但您可能会对我们能够多快地创建一个概念验证感到惊讶。虽然有几种机制可以让两个玩家连接以共享体验,但我们的示例使用了我们在本章中讨论过的基本客户端/服务器模型。一个用户将启动服务器,第二个用户将作为客户端联系该服务器。一旦两个玩家连接,他们将竞赛看谁能最快地清理树木和篱笆!

设置用户界面

让我们从给我们的游戏添加一个菜单开始。回想一下“Menus”中所述的,菜单位于菜单栏中,并与ActionEvent对象一起工作,就像按钮一样。我们需要一个选项来启动服务器,另一个选项是加入已经启动的服务器的游戏。这些菜单项的核心代码很简单;我们可以在AppleToss类中使用另一个辅助方法:

    private void setupNetworkMenu() {
      JMenu netMenu = new JMenu("Multiplayer");
      multiplayerHelper = new Multiplayer();

        JMenuItem startItem = new JMenuItem("Start Server");
        startItem.addActionListener(
            e -> multiplayerHelper.startServer());
        netMenu.add(startItem);

        JMenuItem joinItem = new JMenuItem("Join Game...");
        joinItem.addActionListener(e -> {
          String otherServer = JOptionPane.showInputDialog(
              AppleToss.this, "Enter server name or address:");
          multiplayerHelper.joinGame(otherServer);
        });
        netMenu.add(joinItem);

        JMenuItem quitItem = new JMenuItem("Disconnect");
        quitItem.addActionListener(
            e -> multiplayerHelper.disconnect());
        netMenu.add(quitItem);

      // build a JMenuBar for the application
      JMenuBar mainBar = new JMenuBar();
      mainBar.add(netMenu);
      setJMenuBar(mainBar);
    }

对于每个菜单的ActionListener使用 lambda 表达式应该很熟悉。我们还使用在“Modals and Pop-Ups”中讨论过的JOptionPane来询问第二个玩家第一个玩家正在等待的服务器的名称或 IP 地址。网络逻辑由一个单独的类处理。

我们将在接下来的章节中更详细地查看Multiplayer类,但您可以看到我们将要实现的方法。游戏的这个版本的代码(在ch13/examples/game文件夹中)包含了setupNetworkMenu()方法,但是 lambda 监听器只是弹出一个信息对话框,指示选择了哪个菜单项。您可以构建Multiplayer类并在章节末尾的练习中调用实际的多人游戏方法。但是,欢迎查看ch13/solutions/game文件夹中已完成的游戏,包括网络部分。

游戏服务器

正如我们在“Servers”中所做的那样,我们需要选择一个端口并设置一个监听传入连接的套接字。我们将使用端口 8677——在电话号码键盘上为“TOSS”。我们可以在我们的Multiplayer类中创建一个Server内部类来驱动一个准备好进行网络通信的线程。readerwriter变量将用于发送和接收实际的游戏数据。关于这一点在“The game protocol”中会详细讨论:

class Server implements Runnable {
  ServerSocket listener;

  public void run() {
    Socket socket = null;
    try {
      listener = new ServerSocket(gamePort);
      while (keepListening) {
        socket = listener.accept();  // wait for connection

        InputStream in = socket.getInputStream();
        BufferedReader reader =
            new BufferedReader(new InputStreamReader(in));
        OutputStream out = socket.getOutputStream();
        PrintWriter writer = new PrintWriter(out, true);

        // ... game protocol logic starts here
      }
    } catch (IOException ioe) {
      System.err.println(ioe);
    }
  }
}

我们设置我们的ServerSocket,然后在循环内等待一个新的客户端。虽然我们计划一次只玩一个对手,但这使我们能够接受后续的客户端而不必重新进行所有的网络设置。

要实际启动服务器监听第一次,我们只需要一个使用我们的Server类的新线程:

    // from Multiplayer
    Server server;

    // ...

    public void startServer() {
      keepListening = true;
      // ... other game state can go here
      server = new Server();
      serverThread = new Thread(server);
      serverThread.start();
    }

我们在我们的Multiplayer类中保持对Server实例的引用,这样我们就可以随时访问,以便在用户从菜单中选择“断开连接”选项时关闭连接,如下所示:

// from Multiplayer
  public void disconnect() {
    disconnecting = true;
    keepListening = false;
    // Are we in the middle of a game and regularly checking these flags?
    // If not, just close the server socket to interrupt the blocking
    // accept() method.
    if (server != null && keepPlaying == false) {
      server.stopListening();
    }

    // ... clean up other game state here
  }

一旦我们进入游戏循环,我们主要使用keepPlaying标志,但是在上面也很方便。如果我们有一个有效的server引用,但当前没有玩游戏(keepPlaying为 false),则我们知道要关闭监听器套接字。

Server内部类中的stopListening()方法很简单:

  public void stopListening() {
    if (listener != null && !listener.isClosed()) {
      try {
        listener.close();
      } catch (IOException ioe) {
        System.err.println("Error disconnecting listener: " +
            ioe.getMessage());
      }
    }
  }

我们快速检查我们的服务器,并仅在存在并且仍然打开时尝试关闭listener

游戏客户端

客户端的设置和拆卸与之相似——当然没有监听ServerSocket。我们将使用一个Client内部类来镜像Server内部类,并构建一个智能的run()方法来实现我们的客户端逻辑:

class Client implements Runnable {
  String gameHost;
  boolean startNewGame;

  public Client(String host) {
    gameHost = host;
    keepPlaying = false;
    startNewGame = false;
  }

  public void run() {
    try (Socket socket = new Socket(gameHost, gamePort)) {

      InputStream in = socket.getInputStream();
      BufferedReader reader =
          new BufferedReader(new InputStreamReader(in) );
      OutputStream out = socket.getOutputStream();
      PrintWriter writer = new PrintWriter(out, true);

      // ... game protocol logic starts here
    } catch (IOException ioe) {
      System.err.println(ioe);
    }
  }
}

我们将服务器的名称传递给Client构造函数,并依赖于Server使用的公共gamePort变量来设置套接字。我们使用了“try with Resources”中讨论的“try with resource”技术来创建套接字,并确保在完成后对其进行清理。在该资源try块内,我们创建了客户端对话半部分的readerwriter实例,如 Figure 13-4 所示。

ljv6 1304

图 13-4。游戏客户端和服务器连接

为了使其运行,我们将在我们的Multiplayer辅助类中添加另一个帮助方法:

// from Multiplayer

  public void joinGame(String otherServer) {
    clientThread = new Thread(new Client(otherServer));
    clientThread.start();
  }

我们不需要单独的disconnect()方法——我们可以使用服务器使用的相同状态变量。对于客户端,server引用将为null,因此我们不会尝试关闭不存在的监听器。

游戏协议

您可能注意到我们忽略了ServerClient类的run()方法的大部分内容。在我们构建和连接数据流之后,剩下的工作都涉及协作地发送和接收关于游戏状态的信息。这种结构化的通信就是游戏的协议。每个网络服务都有一个协议。想一想 HTTP 中的“P”。即使我们的DateAtHost示例也使用了(非常简单的)协议,以便客户端和服务器知道谁应该在任何给定时刻说话,谁必须听取。如果两边同时尝试交谈,信息很可能会丢失。如果两边最终都等待对方说些什么(例如,服务器和客户端都在reader.readLine()调用上阻塞),则连接将看起来会挂起。

管理这些通信期望是任何协议的核心,但是该说什么以及如何响应也很重要。协议的这一部分通常需要开发人员付出最多的努力。部分困难在于,您实际上需要两边都测试您的工作。没有客户端,无法测试服务器,反之亦然。随着工作的进行,构建两侧可能会感到乏味,但是额外的努力是值得的。与其他类型的调试一样,修复小的增量变化比弄清楚可能存在的大块代码中的问题要简单得多。

在我们的游戏中,我们将由服务器引导对话。这个选择是任意的——我们可以使用客户端,或者我们可以构建一个更复杂的基础,并允许客户端和服务器同时负责某些事情。然而,做出了“服务器负责”的决定后,我们可以在我们的协议中尝试一个非常简单的第一步。我们将让服务器发送一个"NEW_GAME"命令,然后等待客户端回应一个"OK"答案。服务器端的代码(与客户端建立连接后)如下所示:

    // Create a new game with the client
    writer.println("NEW_GAME");

    // If the client agrees, send over the location of the trees
    String response = reader.readLine();
    if (response != null && response.equals("OK")) {
      System.out.println("Starting a new game!")
      // ... write game data here
    } else {
      System.err.println("Unexpected start response: " + response);
      System.err.println("Skipping game and waiting again.");
      keepPlaying = false;
    }

如果我们得到了预期的"OK"响应,我们可以继续设置一个新游戏,并与对手分享树木和树篱的位置——稍后再说。 (如果我们没有收到"OK",我们会显示一个错误并重置等待其他尝试。) 这个第一步的相应客户端代码流程类似:

    // We expect to see the NEW_GAME command first
    String response = reader.readLine();

    // If we don't see that command, disconnect and return
    if (response == null || !response.equals("NEW_GAME")) {
      System.err.println("Unexpected initial command: " + response);
      System.err.println("Disconnecting");
      writer.println("DISCONNECT");
      return;
    }
    // Yay! We're going to play a game. Send an acknowledgement
    writer.println("OK");

如果你想尝试当前的情况,你可以从一个系统启动你的服务器,然后从第二个系统加入该游戏。(你也可以只是从一个单独的终端窗口启动游戏的第二个副本。在这种情况下,“其他主机”的名称将是网络关键词localhost。)几乎在从第二个游戏实例加入后不久,你应该在第一个游戏的终端中看到“开始新游戏!”的确认打印。恭喜!你正在设计一个游戏协议。让我们继续。

我们需要确保公平竞技——字面上的意思。服务器会告诉游戏建立一个新场地,然后将所有新障碍的坐标发送给客户端。客户端则可以接受所有传入的树木和树篱,并将它们放置在一个干净的场地上。一旦服务器发送了所有树木,它就可以发送一个"START"命令,游戏就可以开始了。我们将继续使用字符串来传递我们的消息。以下是我们可以将树木细节传递给客户端的一种方式:

    gameField.setupNewGame();
    for (Tree tree : gameField.trees) {
      writer.println("TREE " + tree.getPositionX() + " " +
          tree.getPositionY());
    }
    // do the same for hedges or any other shared elements ...

    // Attempt to start the game, but make sure the client is ready
    writer.println("START");
    response = reader.readLine();
    keepPlaying = response.equals("OK");

在客户端,我们可以调用readLine()在一个循环中用于"TREE"行,直到我们看到“START”行,就像这样(还加入了一些错误处理):

    // And now gather the trees and set up our field
    gameField.trees.clear();
    response = reader.readLine();
    while (response.startsWith("TREE")) {
      String[] parts = response.split(" ");
      int x = Integer.parseInt(parts[1]);
      int y = Integer.parseInt(parts[2]);
      Tree tree = new Tree();
      tree.setPosition(x, y);
      gameField.trees.add(tree);
      response = reader.readLine();
    }
    // Do the same for hedges or other shared elements

    // After all the obstacle lists have been sent, the server will issue
    // a START command. Make sure we get that before playing
    if (!response.equals("START")) {
      // Hmm, we should have ended the lists of obstacles with a START,
      // but didn't. Bail out.
      System.err.println("Unexpected start to the game: " + response);
      System.err.println("Disconnecting");
      writer.println("DISCONNECT");
      return;
    } else {
      // Yay again! We're starting a game. Acknowledge this command
      writer.println("OK");
      keepPlaying = true;
      gameField.repaint();
    }

此时,两个游戏应该具有相同的障碍,玩家可以开始清除它们。服务器将进入轮询循环,并每秒钟发送一次当前分数。客户端将回复其当前分数。请注意,肯定还有其他选项可以共享分数变化的方法。虽然轮询很简单,但更先进的游戏,或者需要更即时反馈关于远程玩家的游戏,可能会使用更直接的通信选项。目前,我们主要想专注于良好的网络来回,所以轮询会使我们的代码更简单。

服务器应该持续发送当前分数,直到本地玩家清除所有内容或我们从客户端看到游戏结束的响应为止。我们需要解析客户端的响应以更新另一位玩家的分数,并关注他们请求结束游戏的情况。我们还必须准备好客户端可能会简单断开连接。该循环看起来像这样:

    while (keepPlaying) {
      try {
        if (gameField.trees.size() > 0) {
          writer.print("SCORE ");
        } else {
          writer.print("END ");
          keepPlaying = false;
        }
        writer.println(gameField.getScore(1));
        response = reader.readLine();
        if (response == null) {
          keepPlaying = false;
          disconnecting = true;
        } else {
          String parts[] = response.split(" ");
          switch (parts[0]) {
            case "END":
              keepPlaying = false;
            case "SCORE":
              gameField.setScore(2, parts[1]);
              break;
            case "DISCONNECT":
              disconnecting = true;
              keepPlaying = false;
              break;
            default:
              System.err.println("Warning. Unexpected command: " +
                  parts[0] + ". Ignoring.");
          }
        }
        Thread.sleep(500);
      } catch(InterruptedException e) {
        System.err.println("Interrupted while polling. Ignoring.");
      }
    }

客户端将复制这些操作。幸运的是对于客户端来说,它只是对来自服务器的命令做出反应。在这里我们不需要单独的轮询机制。我们阻塞等待读取一行,解析它,然后构建我们的响应:

    while (keepPlaying) {
      response = reader.readLine();
      String[] parts = response.split(" ");
      switch (parts[0]) {
        case "END":
          keepPlaying = false;
        case "SCORE":
          gameField.setScore(2, parts[1]);
          break;
        case "DISCONNECT":
          disconnecting = true;
          keepPlaying = false;
          break;
        default:
          System.err.println("Unexpected game command: " +
          response + ". Ignoring.");
      }
      if (disconnecting) {
        // We're disconnecting or they are. Acknowledge and quit.
        writer.println("DISCONNECT");
        return;
      } else {
        // If we're not disconnecting, reply with our current score
        if (gameField.trees.size() > 0) {
          writer.print("SCORE ");
        } else {
          keepPlaying = false;
          writer.print("END ");
        }
        writer.println(gameField.getScore(1));
      }
    }

当玩家清除了所有的树木和篱笆时,他们发送(或回复)一个包含他们最终分数的"END"命令。此时,我们会询问是否同样的两位玩家想再玩一次。如果是,我们可以继续为服务器和客户端使用相同的“读取器”和“写入器”实例。如果不是,我们将让客户端断开连接,服务器将继续监听另一位玩家加入:

    // If we're not disconnecting, ask about playing again
    if (!disconnecting) {
      String message = gameField.getWinner() +
          " Would you like to ask them to play again?";
      int myPlayAgain = JOptionPane.showConfirmDialog(gameField,
          message, "Play Again?", JOptionPane.YES_NO_OPTION);

      if (myPlayAgain == JOptionPane.YES_OPTION) {
        // If they haven't disconnected, ask to play again
        writer.println("PLAY_AGAIN");
        String playAgain = reader.readLine();
        if (playAgain != null) {
          switch (playAgain) {
            case "YES":
              startNewGame = true;
              break;
            case "DISCONNECT":
              keepPlaying = false;
              startNewGame = false;
              disconnecting = true;
              break;
            default:
              System.err.println("Warning. Unexpected response: "
                  + playAgain + ". Not playing again.");
          }
        }
      }
    }

最后客户端的一个互为对等的代码:

    if (!disconnecting) {
      // Check to see if they want to play again
      response = reader.readLine();
      if (response != null && response.equals("PLAY_AGAIN")) {
        // Do we want to play again?
        String message = gameField.getWinner() +
            " Would you like to play again?";
        int myPlayAgain = JOptionPane.showConfirmDialog(gameField,
            message, "Play Again?", JOptionPane.YES_NO_OPTION);
        if (myPlayAgain == JOptionPane.YES_OPTION) {
          writer.println("YES");
          startNewGame = true;
        } else {
          // Not playing again so disconnect.
          disconnecting = true;
          writer.println("DISCONNECT");
        }
      }
    }

表格 13-1 总结了我们的简单协议。

表格 13-1. 苹果投掷游戏协议

服务器命令 参数(可选) 客户端响应 参数(可选)
NEW_GAME OK
TREE x y
START OK
SCORE 分数

分数

END

断开连接

|

分数

分数

|

END 分数

分数

断开连接

分数
PLAY_AGAIN

YES

断开连接

DISCONNECT

更多探索

我们可以花费更多的时间来开发我们的游戏。我们可以扩展协议以允许多个对手。我们可以将目标更改为清除障碍物并摧毁您的对手。我们可以使协议更双向,允许客户端启动一些更新。我们可以使用 Java 支持的备用低级协议,如 UDP 而不是 TCP。事实上,有整整一本书专门讨论游戏、网络编程和编程网络游戏!

但哇!你成功了!说我们涵盖了很多领域真是大大低估了。我们希望您对 Java 的语法和核心类有扎实的理解。您可以利用这种理解继续学习其他有趣的细节和高级技巧。选择一个你感兴趣的领域,深入研究一下。如果您对 Java 仍然感到好奇,可以尝试连接本书的各个部分。例如,您可以尝试使用正则表达式来解析我们的苹果投掷游戏协议。或者,您可以构建一个更复杂的协议,通过网络传输小块二进制数据而不是简单的字符串。为了练习编写更复杂的程序,您可以将游戏中的一些内部和匿名类重写为独立的、独立的类,甚至用 lambda 表达式替换它们。

如果您想继续探索其他 Java 库和包,同时又坚持一些已经使用过的示例,您可以深入了解 Java2D API,使苹果和树木看起来更漂亮。您可以尝试一些其他集合对象,如TreeMapDeque。您可以研究流行的JSON 格式,并尝试重新编写多人通信代码。使用 JSON 作为协议可能会让您有机会使用一个库。

当您准备好进一步探索时,您可以尝试一些 Android 开发,了解 Java 在桌面之外的工作方式。或者查看大型网络环境和 Eclipse 基金会的 Jakarta 企业版。也许大数据正引起您的注意?Apache 基金会有几个项目,如 Hadoop 或 Spark。Java 有它的批评者,但它仍然是专业开发者世界中充满活力和重要的一部分。

现在我们已经列出了一些未来研究的途径,我们准备结束本书的主要部分。术语表包含了我们涵盖的许多有用术语和主题的快速参考。附录 A 详细说明了如何将代码示例导入到 IntelliJ IDEA 中。附录 B 包括了所有复习问题的答案以及一些提示和指导,以及代码练习的指导。

希望您享受本书的第六版学习 Java。这实际上是该系列的第八版,始于二十多年前的探索 Java。在这段时间里,观察 Java 的发展真是一段漫长而惊人的旅程,我们感谢多年来与我们同行的您们。正如往常一样,我们期待您的反馈,以帮助我们在未来使这本书变得更好。准备好迎接 Java 的另一个十年了吗?我们准备好了!

复习问题

  1. URL类默认支持哪些网络协议?

  2. 您能使用 Java 从在线源下载二进制数据吗?

  3. 使用 Java 将表单数据发送到 Web 服务器的高级步骤是什么?涉及哪些类?

  4. 您用于侦听传入网络连接的类是什么?

  5. 创建类似于您为游戏创建的服务器时,是否有选择端口号的任何规则?

  6. 用 Java 编写的服务器应用程序能支持多个同时客户端吗?

  7. 给定的客户端Socket实例可以连接到多少个同时服务器?

代码练习

  1. 创建您自己的人性化DateAtHost客户端(在我们的解决方案中是FDClient,友好日期客户端)和服务器(FDServer)。使用“日期和时间”中的类和格式化程序,生成一个发送包含当前日期和时间的一行格式良好文本的服务器。您的客户端应在连接后读取该行并将其打印出来。(您的客户端不需要扩展Instant甚至在打印之外存储响应。)

  2. 我们的游戏协议尚不包括对树篱障碍物的支持。(树篱仍然存在于游戏中,但它们尚未包含在网络通信中。)请查看 Table 13-1,并添加类似于我们TREE行的HEDGE条目支持。可能首先更新客户端会更容易,尽管你需要更新两端,以使树篱对于两名玩家的功能类似于树木。

进阶练习

  1. 升级你的FDServer类,以处理多个同时连接的客户端,可以使用线程或虚拟线程。你可以将客户端处理代码放入 lambda 表达式、匿名内部类或单独的辅助类中。你应该能够在不重新编译的情况下使用第一个练习中的FDClient类。如果使用虚拟线程,请记住它们可能仍然是 Java 版本中的预览功能。编译和运行时请使用适当的标志。(我们对这个练习的解决方案在FDServer2中。)

  2. 这个练习更像是一个推动,去探索 Web 服务的世界,现在你已经看过使用 Java 与在线 API 交互的一些示例。在线搜索一个具有免费开发者账户选项的服务(可能仍需注册),并编写一个 Java 客户端来访问该服务。许多在线服务都需要某种形式的身份验证,比如API 令牌API 密钥(通常是长字符串,类似于唯一的用户名)。像random.orgopenweathermap.org这样的网站可能是开始的有趣地方。(我们在解决方案中提供了一个完整的客户端NetworkInt,用于从random.org获取随机整数。为使客户端正常工作,你需要在源代码中提供自己的 API 密钥。)

¹ Postman 是 Web 开发人员的绝佳工具。你可以在Postman 网站了解更多。它们托管的测试服务位于postman-echo.com,接受 GET 和 POST 请求,并回显请求及任何表单或 URL 参数。

² 你可能之前听过“MIME 类型”的短语。MIME 起源于电子邮件,术语“媒体”意图更为通用。

³ 详细讨论这些低级套接字,请参阅Unix 网络编程,作者为 W. Richard Stevens 等人(Prentice-Hall)。

⁴ 例如,点对点环境具有同时执行两种角色的机器。

⁵ 欲了解更多著名服务及其标准端口号,请参阅由互联网编号分配机构(Internet Assigned Numbers Authority)托管的官方列表

⁶ 大型多人在线角色扮演游戏,如果像作者一样,你也对这个缩写感到陌生。叹息。

⁷ 实际上,我们使用的来自 NIST 的公开网站强烈建议用户升级。有关 NIST 互联网时间服务器的详细信息,请参阅介绍性说明

附录 A. 代码示例和 IntelliJ IDEA

本附录将帮助您通过整本书中找到的代码示例快速上手。这里的一些步骤已经在第二章中提到过,但我们希望在这里稍微详细地介绍一下如何在 JetBrains 的免费社区版 IntelliJ IDEA 中使用书中的示例。正如在“安装 IntelliJ IDEA 和创建项目”中所述,您可以从jetbrains.com网站获取 IntelliJ。如果您需要更多帮助设置,请参阅他们的安装指南

安装 IDEA 后,您需要确保选择了最新版本的 JDK。 (如果您仍需要安装 Java 本身,请参阅 “安装 JDK” ,详细介绍了各个主要平台的详情。)图 A-1 中显示的“文件 → 项目结构”对话框允许您从已安装的任何 JDK 中进行选择。为了本书的目的,您至少需要 Java 19。您还需要将所选 JDK 的“语言级别”选项设置为预览版本。

ljv6 0A01

图 A-1. 在 IntelliJ IDEA 中启用 Java 的预览功能

在这个示例中,我们使用 Corretto 20 并选择了 20 (Preview) 的语言级别。如果您想获取有关在 IntelliJ IDEA 中启用 Java 的预览功能的更多信息,请在线查看他们的预览功能教程

我们还想重申,IntelliJ IDEA 不是唯一一款友好的 Java 集成开发环境。它甚至不是唯一一款免费的!微软的VS Code 可以快速配置以支持 Java。而由 IBM 维护的Eclipse 仍然可用。初学者寻找一款旨在帮助他们进入 Java 编程和 Java IDE 世界的工具,可以看看由伦敦国王学院创建的BlueJ

获取主要的代码示例

无论您使用哪种 IDE 或编辑器,您都会希望从 GitHub 获取本书的代码示例。虽然在讨论特定主题时我们经常包含完整的源代码列表,但出于简洁和可读性的考虑,我们经常选择省略诸如importpackage语句,或者封闭的class结构。可下载的代码示例旨在完整,以帮助巩固书中的讨论内容。

您可以在浏览器中访问 GitHub,浏览单个示例,而无需下载任何内容。只需前往 learnjava6e 存储库。(如果该链接无法正常工作,请转到 github.com 并搜索术语“learnjava6e”)。对 GitHub 进行一般的探索可能会值得,因为它已成为开源开发人员甚至企业团队的主要聚集地。您可以查看存储库的历史记录,并报告错误并讨论与代码相关的问题。

该网站的名称指的是 git 工具,这是一种源代码控制系统或源代码管理器,开发人员用于管理代码项目团队之间的修订。但请回忆一下 图 2-14,您不必使用 git。您可以通过将项目的主分支作为 ZIP 存档 下载来获取所有示例的整个批次。下载完成后,只需将文件解压缩到您可以轻松找到示例的文件夹中。您应该看到类似于 图 A-2 的文件夹结构。

ljv6 0A02

图 A-2. 代码示例的文件夹结构

如果您对 Git 感兴趣,但您的系统尚未具备 git 命令,您可以从 Git 网站 下载它。GitHub 还有自己的网站,可帮助您了解 git,网址为 try.github.io。安装 git 后,您可以将项目克隆到计算机上的一个文件夹中。您可以从克隆中工作,或者将其保留为代码示例的干净副本。作为一个小小的奖励,如果我们将来发布任何修复或更新,您也可以轻松同步您的克隆文件夹。

导入示例

在我们将任何东西导入 IntelliJ IDEA 之前,您可能想要重命名从 GitHub 下载代码示例的文件夹。克隆或解压后,您可能会得到一个名为 learnjava6e-main 的文件夹。这是一个完全可以接受的名称,但如果您想要一个更友好(或更短)的名称,请立即重命名文件夹。我们选择将文件夹重命名为 learnjava6e(不带 -main 后缀)。

启动 IntelliJ IDEA,并从 图 A-3 显示的欢迎界面中选择“打开”选项。如果您已经使用过 IntelliJ IDEA 并且没有看到欢迎界面,则还可以从菜单栏中选择 文件 → 打开。

ljv6 0A03

图 A-3. IntelliJ IDEA 欢迎界面

转到您的代码示例文件夹,如 图 A-4 所示。确保您选择包含所有章节的顶层文件夹,而不是单个章节文件夹之一。

ljv6 0A04

图 A-4. 导入代码示例文件夹

打开示例文件夹后,您可能会被要求“信任”包含示例的文件夹。IDEA 提出此问题是为了确认文件夹中的潜在可执行类是否可以安全运行。

你可能还需要指定要用于编译和运行示例的 Java 版本。使用左侧的项目层次结构,打开 ch02/examples 文件夹并点击 HelloJava 类。我们类的源文件应该出现在右侧。如果你看到类似于 图 A-5 中显示的淡黄色横幅,请点击右上角的 Setup SDK 链接文本。(SDK 意为软件开发工具包,在我们的情况下与 JDK 同义。)从弹出的对话框中选择要使用的 JDK。

ljv6 0A05

图 A-5. 选择一个 JDK

对于本示例,我们选择了长期支持版本(21),但你可以选择安装的任何大于版本 19 的版本。(你始终可以使用文件 → 项目结构对话框更改选择或启用预览功能,如 图 A-1 所示。)

要检查一切是否正常工作,请在左侧树中右键单击 HelloJava 类,并从上下文菜单中选择运行 HelloJava.main() 项目项。

ljv6 0A06

图 A-6. 在 IDEA 中直接运行应用程序

恭喜!IntelliJ IDEA 已经设置好了,现在可以开始探索令人惊奇和令人满足的 Java 编程世界。

附录 B. 练习答案

本附录包含每章末尾的复习问题答案(通常还包含一些背景信息)。代码练习的答案随附示例程序的源码下载,存储在 exercises 文件夹中。附录 A 中详细介绍了获取源码并在 IntelliJ IDEA 中设置的方法。

第一章:现代语言

  1. 哪家公司当前维护 Java?

    尽管 Java 是在 1990 年代由 Sun Microsystems 开发的,但 Oracle 在 2009 年购买了 Sun(因此也购买了 Java)。Oracle 拥有并积极参与其商业 JDK 和开源 OpenJDK 的开发和分发。

  2. Java 的开源开发工具包的名称是什么?

    JDK 的开源版本被称为 OpenJDK。

  3. 名称 Java 安全运行字节码的两个主要组件。

    Java 具有许多与安全相关的特性,但在每个 Java 应用程序中起作用的主要组件是类加载器和字节码验证器。

第二章:首个应用程序

  1. 您应该使用什么命令来编译 Java 源文件?

    如果您在终端中工作,javac 命令会编译 Java 源文件。虽然在使用像 IntelliJ IDEA 这样的 IDE 时,细节通常被隐藏,但是这些 IDE 在幕后也使用 javac

  2. 当您运行 Java 应用程序时,JVM 如何知道从哪里开始?

    任何可执行的 Java 类必须定义 main() 方法。JVM 使用此方法作为入口点。

  3. 在创建新类时,您可以扩展多个类吗?

    不。Java 不支持从多个单独的类直接进行多重继承。

  4. 在创建新类时,您可以实现多个接口吗?

    是的。Java 允许您实现尽可能多的接口。使用接口为程序员提供了多重继承的大部分有用功能,而避开了许多陷阱。

  5. 哪个类代表图形应用程序中的窗口?

    JFrame 类代表 Java 图形应用程序中使用的主窗口,尽管后续章节将介绍一些也能创建特定窗口的低级类。

代码练习

一般情况下,我们不会在本附录中列出代码解决方案,但我们希望可以轻松地检查您对这个第一个程序的解决方案。简单文本版的“Goodbye, Java!” 应该看起来类似于这样:

public class GoodbyeJava {
  public static void main(String[] args) {
    System.out.println("Goodbye, Java!");
  }
}

对于图形版本,您的代码应该类似于这样:

import javax.swing.*;

public class GoodbyeJava {
  public static void main(String[] args) {
    JFrame frame = new JFrame("Chapter 2 Exercises");
    frame.setSize(300, 150);
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    JLabel label = new JLabel("Goodbye, Java!", JLabel.CENTER);
    frame.add(label);
    frame.setVisible(true);
  }
}

请注意,我们添加了额外的 EXIT_ON_CLOSE,这是在 HelloJava2 中引入的,以便在关闭应用程序时正确退出。如果您使用 IDEA,可以使用 IDE 内部的绿色播放按钮运行任何一个类。如果您使用终端,可以切换到 GoodbyeJava.java 所在的目录并输入以下命令:

% javac GoodbyeJava.java
% java GoodbyeJava

第三章:工具

  1. 哪个语句允许您在应用程序中访问 Swing 组件?

    import 语句从指定的类或包加载编译器需要的信息。对于 Swing 组件,您通常使用 import javax.swing.*; 导入整个包。

  2. 什么环境变量确定 Java 在编译或执行时查找类文件的位置?

    CLASSPATH 环境变量保存了一个包含其他类或 JAR 文件的目录列表,这些文件可用于编译和执行。如果您使用的是 IDE,CLASSPATH 仍然被定义,但通常不需要您自己编辑。

  3. 您可以使用哪些选项查看 JAR 文件的内容而不解压缩它?

    您可以运行以下命令来显示 JAR 文件的内容,而不实际将其解压到当前目录:

    % jar tvf MyApp.jar
    

    tvf 标志代表目录(t)、详细信息(v)和文件(f 后跟文件名)的表格。

  4. 使 JAR 文件可执行所需的 MANIFEST.MF 文件条目是什么?

    您必须包含一个 Main-Class 条目,该条目给出具有有效 main() 方法的类的完全限定名称,以使给定的 JAR 文件可执行。

  5. 什么工具允许您交互式地尝试 Java 代码?

    您可以从终端运行 jshell 来交互式地尝试简单的 Java 代码。

代码练习

您将在 ch03/solutions 文件夹中找到我们对代码练习的解决方案。(附录 A 中有关于下载示例的详细信息。) 我们的解决方案并不是解决这些问题的唯一或者最佳方式。我们尝试呈现整洁、可维护的代码,并遵循最佳实践,但解决编码问题还有其他方法。希望您能够编写和运行自己的答案,但如果遇到困难,这里有一些提示。

要创建可执行的 hello.jar 文件,我们将在 ch03/exercises 文件夹中的终端中进行所有工作。(您当然也可以在IDEA 中进行此类工作。)请打开终端并切换到该文件夹。

在创建 JAR 文件本身之前,我们需要编辑 manifest.mf 文件。添加 Main-Class 条目。最终文件应如下所示:

Manifest-Version: 1.0
Created-By: 11.0.16
Main-Class: ch03.exercises.HelloJar

现在,您可以使用以下命令创建和测试您的 JAR 文件:

% jar cvmf manifest.mf hello.jar *.class
% java -jar hello.jar

请记住,标志中的 m 元素是必要的,以包含我们的清单。还值得提醒的是,mf 标志的顺序决定了随后跟随的 manifest.mfhello.jar 命令行参数的顺序。您还记得如何查看您新创建的 JAR 的内容以验证清单是否存在吗?¹

第四章:Java 语言

  1. Java 在编译类时默认使用什么文本编码格式?

    默认情况下,Java 使用 8 位 Unicode 转换格式(UTF-8)编码。8 位(或一个字节)编码可以容纳单字节和多字节字符。

  2. 用于包围多行注释的字符是什么?这些注释可以嵌套吗?

    Java 借鉴了 C 和 C++的注释语法。单行注释以双斜杠(//)开头,而多行注释则用/**/括起来。多行样式也可以用于在代码行中嵌入小注释。

  3. Java 支持哪些循环结构?

    Java 支持for循环(传统的 C 风格和用于遍历集合的增强形式)、while循环和do/while循环。

  4. if/else if/else测试链中,如果多个条件都为真会发生什么?

    与第一个评估为true的测试相关联的块将被执行。在该块完成后,控制将在整个链路后继续执行——无论有多少其他测试也会返回true

  5. 如果你想要将美国股市的总市值(大约在 2022 财年结束时为 31 万亿美元)存储为整数,你可以使用什么原始数据类型?

    你可以使用long整型;它可以存储高达 9 千亿(正负数皆可)的数字。虽然你也可以使用double类型,但随着数字变大,精度会降低。而且,“整数”意味着没有小数部分,整数类型更为合理。

  6. 表达式18 – 7 * 2的计算结果是什么?

    这是一个优先级问题,以确保你的高中代数老师最终得到了一些肯定,经历了所有那些“但我什么时候会用到这个?”的质疑。首先进行 7 和 2 的乘法运算,然后进行减法运算。最终答案是 4。(你可能得到了 22,这是从左到右执行操作的结果。如果你确实需要那个结果,你可以将18 – 7部分用括号括起来……就像这个旁注一样。)

  7. 如何创建一个包含一周的天数名称的数组?

    可以使用花括号创建和初始化数组。对于一周的天数,我们需要一个String数组,就像这样:

        String[] dayNames = {
          "Sunday", "Monday", "Tuesday", "Wednesday",
          "Thursday", "Friday", "Saturday"
        };
    

    列表中名称的间距是可选的。你可以将所有内容列在一行上,将每个名称列在单独的行上,或者像我们在这里做的那样,采用一些组合。

代码练习

  1. 有多种方法可以将原始的ab以及计算得到的最大公约数打印到屏幕上。你可以在开始计算之前使用print()语句(而不是 println()),然后在计算结束时使用println()打印答案。或者你可以在开始计算之前将ab复制到第二组变量中。找到答案后,你可以打印出复制的值以及结果。

  2. 要以简单的行形式输出三角形数据,可以使用填充三角形的相同嵌套循环:

        for (int i; i < triangle.length; i++)
          for (int j; j < triangle[i].length; j++)
            System.out.println(triangle[i][j]);
    

高级练习

  1. 要在视觉三角形中呈现输出,可以在内部j循环中使用print()语句。(确保在每个数字后打印一个空格。)内部循环完成后,可以使用空的println()来结束该行并准备下一行。

第五章:Java 中的对象

  1. Java 中的主要组织单元是什么?

    Java 中的主要“单元”是一个类。当然,许多其他结构也起着重要作用,但是你至少需要一个类才能使用其他任何东西。

  2. 你使用什么运算符来从类创建一个对象(或实例)?

    new 操作符实例化一个类的对象并调用适当的构造函数。

  3. Java 不支持经典的多重继承。Java 提供哪些机制作为替代?

    Java 使用接口来完成许多其他面向对象语言中多重继承的目标。

  4. 如何组织多个相关的类?

    你将相关的类放在一个包中。在你的文件系统中,包显示为嵌套文件夹。在你的代码中,包使用点分隔的名称。

  5. 如何将其他包中的类包含到你自己的代码中以供使用?

    你可以 import 其他单独的类或整个包供你自己使用。

  6. 如何称呼定义在另一个类范围内的类?在某些情况下,使这样的类变得有用的一些特性是什么?

    在另一个类的大括号内部(不仅仅在同一个文件中)定义的简单类称为内部类。内部类具有对外部类的所有变量和方法的访问权限,包括私有成员。它们可以帮助将代码分割成可管理和可重用的片段,同时提供对谁可以使用它们的良好控制。

  7. 如何称呼旨在被重写的方法,它具有名称、返回类型和参数列表,但没有主体?

    只有它们的签名定义的方法称为抽象方法。在类中包含抽象方法也使得该类成为抽象类。抽象类不能被实例化。你必须创建一个子类,然后为抽象方法提供一个真实的体来使用它。

  8. 什么是重载方法?

    Java 允许你使用相同的方法名以不同类型或数量的参数。如果两个方法共享相同的名称,它们被称为重载。重载使得在不同参数上执行相同逻辑工作的方法批量创建成为可能。Java 中重载方法的经典示例是 System.out.println(),它可以接受多种类型的参数并将它们全部转换为字符串以打印到终端。

  9. 如果你希望确保没有其他类可以使用你定义的变量,你应该使用什么访问修饰符?

    private 访问修饰符用于变量(或方法,或者整个内部类),将其使用限制在它定义的类中。

代码练习

  1. 对于我们的 Zoo 中的第一个问题,你只需在内部 Gibbon 类的空 speak() 方法中添加一个 print() 语句。希望 Lion 的示例容易跟随。

  2. 添加另一个动物也应该很简单;你可以复制整个Lion类。重命名类并在speak()方法中打印适当的声音。你还需要复制listen()方法中的几行代码,以便将你的动物声音添加到输出中。

  3. 为了重构listen()方法,我们注意到每个动物的输出非常相似,但显然每个动物的名称都会改变。如果我们将这个名称移到动物各自的类中,我们就可以创建一个循环,其主体打印出一个动物的细节(名称和声音)。然后我们迭代我们的动物数组。如果你再创建另一个动物,你只需要将你的新内部类的实例添加到数组中。

  4. 此练习的AppleToss类是exercises.ch05包的一部分。(game文件夹包含了已完成的游戏,其中包含了我们将在本书中构建的所有功能。该文件夹中的类属于game包。您可以编译和运行该版本,但它有一些我们尚未讨论的功能。)要从终端编译游戏,你可以进入ch05/exercises目录并在那里编译 Java 类,或者留在你解压源代码的顶层文件夹中,并在编译时给出路径:

    % javac ch05/exercises/AppleToss.java
    

    要运行游戏,你需要在顶层文件夹中。从那里,你可以使用java命令运行exercises.ch05.AppleToss类。

高级练习

  1. 希望添加一个Hedge看起来很简单。你可以从Tree类的副本开始。将文件重命名为Hedge.java。编辑类以反映我们的新Hedge障碍,并更新其paintComponent()方法。在Field类内部,你需要为Hedge添加一个成员变量。创建一个类似于setupTree()setupHedge()方法,并确保在FieldpaintComponent()方法中包含你的树篱。

    最后,但肯定不是最不重要的,更新setupFieldForOnePlayer()方法以调用我们的setupHedge()方法。编译并运行游戏,就像你在前面的练习中做的那样。你的新树篱应该会出现!

第六章:错误处理

  1. 在你的代码中,你使用什么语句来处理潜在的异常?

    你可以在可能生成异常的任何语句或语句组周围使用try/catch块。

  2. 编译器要求你处理或抛出哪些异常?

    在 Java 中,术语checked exception指的是编译器理解并要求程序员承认的异常类别。你可以在可能发生 checked exceptions 的方法中使用try/catch块,或者你可以在方法的签名中的throws子句中添加异常。

  3. try块中使用完资源后,你会把任何“清理”代码放在哪里?

    finally子句将在try块结束时无论发生什么都会运行。如果没有问题,finally子句中的代码将运行。如果有异常并且catch块处理了它,finally仍然会运行。如果发生未处理的异常,finally子句在控制转移回调用方法之前仍会运行。

  4. 禁用断言会对性能造成很大的惩罚吗?

    不是的。这是设计如此。断言通常更多用于开发或调试。当你关闭它们时,它们会被跳过。即使在生产应用中,你可能也会在代码中保留断言。如果用户报告了问题,可以临时打开断言以允许用户收集任何输出并帮助你找到原因。

代码练习

  1. 要使Pause.java文件编译通过,你需要在调用Thread.sleep()周围添加一个try/catch块。对于这个简单的练习,你只需要封装Thread.sleep()行。

  2. 我们需要的断言语句将具有以下形式:

        assert x > 0 : "X is too small";
        assert y > 0 : "Y is too small";
    

    更重要的问题是:我们应该把它们放在哪里?我们只需要检查消息的起始位置,所以我们不希望断言在paintComponent()方法内部。更好的地方可能是在HelloComponent0()构造函数中,在我们存储提供的message参数之后。

    要测试断言,你需要编辑源文件以更改xy的值并重新编译。

高级练习

  1. 你的GCDException类可能看起来像这样:

    package ch06.exercises;
    
    public class GCDException extends Exception {
      private int badA;
      private int badB;
    
      GCDException(int a, int b) {
        super("No common factors for " + a + ", " + b);
        badA = a;
        badB = b;
      }
    
      public int getA() { return badA; }
      public int getB() { return badB; }
    }
    

    你可以用一个简单的if语句测试你的 GCD 计算结果。如果结果是 1,你可以使用我们原来的ab作为参数调用你的新GCDException构造函数,像这样:

        if (a == 1) {
          throw new GCDException(a1, b1);
        }
        // ...
    

第七章:集合和泛型

  1. 如果你想存储一个包含姓名和电话号码的联系人列表,哪种集合类型最适合?

    Map是个好办法。键可以是简单的字符串,包含联系人的姓名,值可以是一个简单(尽管包装过的)长数字。或者你可以创建一个Person类和一个PhoneNumber类,然后地图可以使用你的自定义类。

  2. 你用什么方法为Set中的项目获取迭代器?

    Collection接口中富有创意的iterator()方法将为你获取迭代器。

  3. 如何将List转换为数组?

    你可以使用toArray()方法将List转换为Object类型的数组或列表的参数化类型的数组。

  4. 如何将数组转换为List

    Arrays辅助类包括方便的asList()方法,接受一个数组并返回相同类型的参数化列表。

  5. 要使用Collections.sort()方法对列表进行排序,你应该实现什么接口?

    尽管有许多方法可以对集合进行排序,但Comparable对象列表(表示其类实现了Comparable接口的对象)可以使用Collections辅助类提供的标准sort()方法。

代码练习

  1. 正如我们在本章中提到的,您不能像对列表或数组进行排序一样直接对简单映射进行排序。甚至Set通常也不可排序。²不过,您可以对列表进行排序,因此使用keySet()方法填充列表应该可以满足您的需求:

        List<Integer> ids = new ArrayList<>(employees.keySet());
        ids.sort();
        for (Integer id : ids) {
          System.out.println(id + ": " + employees.get(id));
        }
    
  2. 希望你对于支持多种对冲的扩展感觉很直观。我们主要是复制已有的树代码。使用List允许我们使用增强的for循环快速遍历所有对冲:

    // File: ch07/exercises/Field.java
      List<Hedge> hedges = new ArrayList<>();
      // ...
    
      protected void paintComponent(Graphics g) {
        // ...
        for (Hedge h : hedges) {
          h.draw(g);
        }
        // ...
      }
    

高级练习

  1. 您可以使用values()输出创建并排序类似于代码练习 1 解决方案的列表。这个练习的有趣部分是使用Employee类实现Comparable接口。(实际上,在ch07/solutions文件夹中,可排序的员工类是Employee2。我们希望将原始的Employee类保留为第一个练习的有效解决方案。)这是一个使用员工姓名进行字符串比较的示例:

    public class Employee2 implements Comparable<Employee2> {
      // ...
      public int compareTo(Employee2 other) {
        // Let's be a little fancy and sort on a constructed name
        String myName = last + ", " + first;
        String otherName = other.last + ", " + other.first;
        return myName.compareToIgnoreCase(otherName);
      }
      // ...
    }
    

    当然,您可以使用其他Employee属性进行其他比较。尝试玩弄一些其他排序,并查看是否得到您预期的结果。如果想进一步深入,请查看java.util.TreeMap类,以一种无需列表转换的方式将员工存储为排序方式。

第八章:文本和核心工具

  1. 哪个类包含常量π?需要导入该类来使用π吗?

    java.lang.Math类包含常量PIjava.lang包中的所有类都会默认导入;使用它们无需显式import

  2. 哪个包含原始java.util.Date类的新的、更好的替代品?

    java.time包包含各种质量类,用于处理日期、时间、时间戳(或由日期和时间组成的“时刻”)以及时间跨度或持续时间。

  3. 你使用哪个类来为用户友好输出格式化日期?

    java.text包中的DateFormat类具有非常灵活(有时不透明)的格式化引擎,用于呈现日期和时间。

  4. 正则表达式中使用什么符号来帮助匹配单词“yes”和“yup”?

    您可以使用交替运算符|(竖线)创建表达式,例如yes|yup,用作模式。

  5. 如何将字符串“42”转换为整数 42?

    各种数值包装类都有字符串转换方法。对于像 42 这样的整数,Integer.parseInt()方法是适用的。包装类都属于java.lang包。

  6. 如何比较两个字符串以查看它们是否匹配,忽略大小写,例如“yes”和“YES”?

    String类有两个主要的比较方法:equals()equalsIgnoreCase()。后者会忽略大小写,顾名思义。

  7. 哪个运算符允许简单的字符串连接?

    Java 通常不支持运算符重载,但加号(+)在与数字基本类型一起使用时执行加法,在与String对象一起使用时执行连接。如果你使用+来“添加”一个字符串和一个数字,结果将是一个字符串。(因此,7 + "is lucky"将得到字符串“7is lucky”。注意,连接不会插入任何空格。如果你要组装一个典型的句子,你必须在部分之间添加自己的空格。)

代码练习

有很多方法可以完成这个练习的目标。测试参数数量应该很简单。然后,你可以使用String类的一些特性来判断你是否有随机关键字或一对坐标。你可以使用split()方法分割坐标,或者编写一个正则表达式来分离数值。在创建随机坐标时,你可以使用Math.random(),类似于我们在“数学实践中”中为游戏定位树木的方式。

第九章:线程

  1. 什么是线程?

    线程代表程序内的“执行线程”。线程有自己的状态,并且可以独立于其他线程运行。通常,你使用线程来处理可以放在后台运行的长时间任务,而更重要的任务可以继续进行。Java 既有平台线程(与操作系统提供的本地线程一一对应)又有虚拟线程(纯 Java 构造,保留了本地线程的语义和好处,但没有操作系统的开销)。

  2. 如果你希望线程在调用方法时“轮流”执行(即不希望两个线程同时执行该方法以避免破坏共享数据),你可以为该方法添加哪个关键字?

    你可以在任何读取或写入共享数据的方法上使用synchronized修饰符。如果两个线程需要使用同一个方法,第一个线程设置一个锁,阻止第二个线程调用该方法。一旦第一个线程完成,锁将被清除,第二个线程可以继续。

  3. 哪些标志允许你编译包含预览特性代码的 Java 程序?

    在编译依赖于预览特性的 Java 类时,你必须提供--enable-preview以及-source--release标志给javac

  4. 哪些标志允许你运行包含预览特性代码的 Java 程序?

    运行包含预览特性的编译类时,你只需要提供--enable-preview标志。

  5. 一个本地线程能支持多少平台线程?

    只有一个。使用Thread类创建一个平台线程,并带有Runnable目标或使用java.util.concurrent包中的ExecutorService类,都需要操作系统提供一个线程。

  6. 单个本机线程可以支持多少个虚拟线程?

    一个单独的本机线程可以支持许多虚拟线程。Project Loom 旨在将 Java 程序中使用的线程与操作系统管理的线程分开。对于某些场景,轻量级虚拟线程在 Java 负责其调度时表现更佳。虚拟线程与本机线程的数量之间没有固定的比率,但虚拟线程的关键洞见是,其数量不与本机线程的数量挂钩。

  7. 对于int变量x,语句x = x + 1;是否是原子操作?

    尽管这看起来是一个小操作,但涉及到几个低级步骤。这些低级步骤中的任何一个都可能被中断,并且x的值可能会受到不利影响。如果需要保证线程安全的增量,可以使用AtomicInteger或将语句包装在同步块中。

  8. 哪个包含了像QueueMap这样的流行集合类的线程安全版本?

    java.util.concurrent包含几个 Java 定义为“并发”的集合类,例如ConcurrentLinkedQueueConcurrentHashMap。并发除了纯线程安全的读写之外,还涉及几个其他行为,但线程安全是得到保证的。

代码练习

  1. 你将会在startClock()方法内完成大部分工作。(当然,除了使用的 AWT 和 Swing 包外,你仍需导入其他内容。)你可以创建一个单独的类、内部类或匿名内部类来处理时钟更新循环。记住,可以通过调用其repaint()方法请求 GUI 元素的刷新。Java 支持几种“无限”循环的机制。你可以使用像while (true) { …​ }这样的结构,或者巧妙命名的“forever”循环:for (;;) { …​ }。一切就绪后,别忘了启动你的线程!

  2. 希望对你来说,这个练习相对简单。作为 Java 现有代码库与虚拟线程的整体兼容性的证明,你只需更改演示苹果投掷动画开始的几行代码。在这个游戏的这一轮中,所有设置和启动代码都发生在Field类中。查找类似new Thread()new Runnable()的代码。你应该能够在不做任何修改的情况下重用实际的动画逻辑。

第十章:文件输入和输出

  1. 如何检查给定的文件是否已经存在?

    有几种方法可以检查文件是否存在,但其中两种最简单的方法依赖于java.iojava.nio包中的辅助方法。java.io.File的实例可以使用exists()方法。静态的java.nio.file.Files.exists()方法可以测试Path对象以查看所表示的文件是否存在。

  2. 如果必须使用旧的编码方案(如 ISO 8859)处理遗留文本文件,如何设置读取器以正确将内容转换为 UTF-8?

    你可以向FileReader的构造函数提供适当的字符集(java.nio.charset.Charset),安全地将文件转换为 Java 字符串。

  3. 哪个包中有最适合非阻塞文件 I/O 的类?

    java.nio包及其子包的主要特性之一是支持非阻塞 I/O。

  4. 你可能会使用哪种类型的输入流来解析二进制文件,比如 JPEG 压缩的图片?

    java.io,你可以使用DataInputStream类。对于 NIO,通道和缓冲区(如ByteBuffer)自然地支持二进制数据。

  5. System类内置了哪三个标准文本流?

    System类提供了两个输出流,System.outSystem.err,以及一个输入流System.in。这些流分别连接到操作系统的stdoutstderrstdin句柄。

  6. 绝对路径从根目录开始(例如/C:\)。相对路径从哪里开始?更具体地说,相对路径相对于什么?

    相对路径是相对于“工作目录”的,通常这是你启动程序的地方,如果你使用命令行启动你的应用程序。在大多数 IDE 中,工作目录是可以配置的。

  7. 如何从现有的FileInputStream中检索一个 NIO 通道?

    如果你已经有一个FileInputStream实例,你可以使用它的getChannel()方法返回与输入流关联的FileChannel

代码练习

  1. 我们的第一个Count迭代只需使用本章讨论的其中一个工具。你可以使用作为命令行参数给定路径的File类。从那里,exists()方法将告诉你是否可以继续,或者是否应该打印友好的错误消息;length()方法将给出文件的大小,以字节为单位。(此示例的解决方案是位于ch10/solutions文件夹中的Count1.java。)

  2. 对于第二次迭代,在给定文件中显示行数和单词数,你需要读取和解析文件的内容。其中一个Reader类会很好,但有多种读取文本文件的方式。无论如何打开文件,你可以计算每一行,然后用String.split()或正则表达式将该行分解为单词。(此练习的解决方案是Count2.java。)

  3. 这第三个版本中没有任何新功能,但我们希望你能借此机会尝试一些 NIO 类和方法。看看java.nio.file.Files类的方法。你会惊讶于这个助手类有多大帮助!(此练习的解决方案是Count3.java。)

高级练习

  1. 对于这次最终的升级,您可以写入文件或通道。根据您选择在版本 2 或 3 中读取内容的方式,这可能代表我们类中的相当重要的添加。您需要检查确保第二个参数(如果给定!)是可写的。然后使用允许追加的类之一,如RandomAccessFile,或为FileChannel包括APPEND选项。(此练习的解决方案是Count4.java。我们使用了之前的Count3与 NIO,但您可以从Count2开始并使用标准 I/O 类。)

第十一章:Java 中的函数式方法

  1. 哪个包含大多数功能接口?

    虽然函数式接口分散在整个 JDK 中,但您会发现大多数“官方”接口定义在java.util.function包中。我们在“官方”上加了引号,因为任何具有单个抽象方法(SAM)的接口都可以视为函数式接口。

  2. 在编译或运行使用像 lambda 这样的函数特性的 Java 应用程序时,是否需要使用任何特殊标志?

    是的。目前 Java 中的许多函数式编程功能都是 JDK 的完整成员。编译或执行使用它们的 Java 代码时不需要预览或功能标志。

  3. 如何创建具有多个语句的 lambda 表达式主体?

    lambda 表达式的主体遵循与诸如while循环之类的主体相同的规则:单个语句不需要括号括起来,但多个语句需要。如果您的 lambda 返回一个值,您也可以使用标准的return语句。

  4. lambda 表达式可以是 void 吗?它们可以返回值吗?

    在这两个方面都是可以的。Lambda 表达式可以运行与方法相同的各种选项。您可以有不接受参数且不返回值的 lambda 表达式。您可以有消耗参数但不产生结果的 lambda 表达式。您可以有不接受参数但返回值的 lambda 生成器。最后,您可以有接受一个或多个参数并返回值的 lambda 表达式。

  5. 在处理完流后能否重用它?

    不行。一旦您开始处理流,那就是它了。试图重用流将导致异常。如果需要,您通常可以重用原始源来创建一个全新但完全相同的流。

  6. 如何将对象流转换为整数流?

    您可以使用Stream类的mapToInt()变体之一:mapToInt()flatMapToInt()mapMultiToInt()。反过来,IntStream类有一个mapToObj()方法以在相反方向进行转换。

  7. 如果您有一个从文件中过滤空行的流,您可能会使用什么操作来告诉您有多少行包含内容?

    计算剩余行数的最简单方法是使用count()终端操作。你也可以创建自己的规约器,或者使用一个收集器然后查询结果列表的长度。

代码练习

  1. 希望我们对调整使用更有趣的公式感到顺利。我们不需要任何替代语法或额外方法;我们只需将摄氏度转换公式 C =(F - 32)* 5 / 9 放入我们的 lambda 体中,如下所示:

        System.out.print("Converting to Celsius: ");
        System.out.println(adjust(sample, s -> (s - 32) * 5 / 9));
    

    这并不是非常引人注目,但我们想指出,Lambda 表达式可以开启一些非常聪明的可能性,这些可能性超出了最初的计划。

  2. 对于这种平均值任务,你有多种选择可用。你可以编写一个平均值规约器。你可以将工资收集到一个更简单的容器中,然后编写自己的平均代码。但是,如果你查看不同流的文档,你会注意到数值流已经有了完美的操作:average()。它返回一个OptionalDouble对象。你仍然需要启动流,然后使用类似mapToInt()的东西来获取你的数值流。

高级练习

  1. groupingBy()收集器需要一个从流的每个元素中提取键并返回键与所有具有匹配键的元素列表的映射的函数。对于我们的PaidEmployee示例,你可能会有类似这样的内容:

        Map<String, List<PaidEmployee>> byRoles =
          employees.stream().collect(
          Collectors.groupingBy(PaidEmployee::getRole));
    

    我们映射中键的类型必须与我们在groupingBy()操作中提取的对象类型相匹配。我们在这里使用了一个方法引用,但是任何返回员工角色的 lambda 也将起作用。

    我们不想使先前的解决方案复杂化,所以我们复制了报告和员工类,并分别命名为Report2PaidEmployee2

第十二章:桌面应用程序

  1. 你会用哪个组件向用户显示一些文本?

    虽然你可以使用多种基于文本的组件,但JLabel是向用户展示一些(只读)文本信息的最简单方式。

  2. 你会用哪些组件来允许用户输入文本?

    根据用户期望获得的信息量,你可以使用JTextFieldJTextArea。 (还有其他文本组件存在,但它们提供更专业化的用途。)

  3. 单击按钮会生成什么事件?

    单击按钮或类似按钮的组件(如JMenuItem)会生成ActionEvent

  4. 如果你想知道用户何时更改所选项目,应该将监听器附加到JList

    你可以实现来自javax.swing.event包的ListSelectionListener来接收JList对象的列表选择(和取消选择)事件。

  5. JPanel的默认布局管理器是什么?

    默认情况下,JPanel使用FlowLayout管理器。这个默认的一个显著例外是JFrame的内容窗格。该窗格是一个JPanel,但是框架会自动将窗格的管理器更改为BorderLayout

  6. 在 Java 中,哪个线程负责处理事件?

    事件分发线程,有时被称为事件分发队列,管理着事件的传递和屏幕上组件的更新。

  7. 在后台任务完成后,你会用什么方法来更新像JLabel这样的组件?

    如果你希望在处理其他事件之前等待标签更新完成,可以使用SwingUtilities.invokeAndWait()。如果不在乎标签何时更新完成,可以使用Swing Utilities.invokeLater()

  8. 什么容器持有JMenuItem对象?

    JMenu对象可以持有JMenuItem对象以及嵌套的JMenu对象。菜单本身则包含在JMenuBar中。

代码练习

  1. 你可以以两种方式处理计算器布局:使用嵌套面板或使用GridBagLayout。(我们在 ch12/solutions/Calculator.java 中的解决方案使用了嵌套面板来布置按钮。)从简单开始。将文本字段添加到框架顶部。然后在中心添加一个按钮。现在决定如何处理添加剩余按钮。如果你的按钮使用了Calculator实例(使用我们在 “Shadowing” 中讨论的关键字this作为它们的监听器),你应该看到任何点击的按钮标签都会打印到终端上。

  2. 这个练习并不需要太多新的图形代码。但是你需要在 UI 事件线程中安全地修改场地上显示的障碍物。你可以从简单的打印消息或使用JOptionPane来显示警告开始慢慢进行,只要苹果碰到树或篱笆就触发。在你对距离测量有信心后,回顾一下如何从列表中移除对象。移除障碍物后,记得重新绘制场地。

高级练习

  1. 计算器的逻辑相对简单,但绝对不是琐碎的。首先让各种数字按钮(1、2、3 等)与显示器连接起来。你需要追加数字来创建完整的数字。当用户点击“–”等操作按钮时,将当前显示的数字及将来使用的操作存储起来。让用户输入第二个数字。点击“=”应该存储这第二个数字,然后执行实际的计算。将结果显示在显示器上,然后让用户重新开始。

    在完整的专业计算器应用程序中有许多(许多!)微妙之处。如果你的早期尝试限制为仅处理单个数字,不要担心。这个练习的重点是练习响应事件。即使只是在用户点击按钮后让一个数字显示在显示字段中,也值得庆祝!

第十三章:Java 网络编程

  1. URL类默认支持哪些网络协议?

    Java 的URL类包含对 HTTP、HTTPS 和 FTP 协议的支持。这三种协议涵盖了在线资源的大部分内容,但如果你处理除 Web 或文件服务器之外的系统,你可以创建自己的协议处理程序。

  2. 你可以使用 Java 从在线源下载二进制数据吗?

    可以。字节流是 Java 中所有网络数据的核心。你可以读取原始字节,或者可以链接其他更高级别的流。例如,InputStreamReaderBufferedReader非常适合文本。DataInputStream可以处理二进制数据。

  3. 如何使用 Java 将表单数据发送到 Web 服务器?(不需要完整功能的应用程序,我们只想让你考虑涉及的高级步骤和涉及的 Java 类。)

    你可以使用URL类打开到 Web 服务器的连接。在发出任何请求之前,你可以配置双向通信的连接。HTTP POST命令允许你在请求的正文中向服务器发送数据。

  4. 你用什么类来监听传入的网络连接?

    你使用java.net.ServerSocket类来创建一个网络监听器。

  5. 当像我们的游戏一样创建自己的服务器时,有关于选择端口号的规则吗?

    可以。端口号必须在 0 到 65,535 之间,通常为小于 1,024 的端口保留给常见服务,通常需要特殊权限才能使用。

  6. 用 Java 编写的服务器应用程序可以支持多个同时客户端吗?

    是的。虽然你只能在给定端口上创建一个ServerSocket,但你可以接受数百甚至数千个客户端并在线程中处理他们的请求。

  7. 给定的客户端Socket实例可以与多少个同时服务器通信?

    一个。客户端套接字与一个主机在一个端口上通信。客户端应用程序可以允许使用多个独立的套接字与多个服务器同时通信,但每个套接字仍然只能与一个服务器通信。

代码练习

  1. 向我们的游戏协议添加一个功能需要更新服务器和客户端代码。幸运的是,我们可以在两端重复使用TREE条目的逻辑。更幸运的是,我们所有的网络通信代码都在Multiplayer类中。

    客户端和服务器是内部类,分别被创造性地命名为ClientServer。对于服务器,在run()方法中添加一个循环来在发送树数据后立即发送树篱数据。对于客户端,在run()方法中添加一个HEDGE段,接受树篱的位置并将其添加到场地上。

    一旦为两名玩家设置了字段,在游戏内部分协议中只报告分数和断开连接。我们不必修改任何此代码。每个玩家都将面对相同的树篱障碍,并有机会用扔苹果来移除它们。

  2. 一个人类可读的日期/时间服务器应该相当简单,但是我们希望您练习从头设置自己的套接字。您需要为服务器选择一个端口号。3283 在电话键盘上拼写“DATE”,如果您需要一些灵感。我们建议在接受连接后立即处理客户端请求。(高级练习为您提供了尝试使用线程更复杂方法的机会。)

    对于客户端,唯一的可配置数据是服务器的名称。如果您计划在本地机器上的两个终端窗口上测试您的解决方案,则可以随意硬编码“localhost”。我们的解决方案还接受一个可选的命令行参数,默认为“localhost”,如果您不提供参数。

高级练习

  1. 要处理使用线程的客户端,您需要隔离与客户端通信的代码。可以使用帮助类(内部类、匿名类或独立类都可以),或者使用 lambda 表达式。您仍然需要让ServerSocket完成其工作并accept()新连接,但一旦接收到,您可以立即将接受的Socket对象移交给您的帮助类。

    真的很难测试这个类,因为您需要同时有许多客户端在同一时刻请求当前日期。但至少,您的当前FDClient类应该可以在不更改的情况下工作,并且您应该仍然收到正确的日期。

  2. 使用在线 API 可以很有趣,但也需要注意细节。通常在开始创建客户端时,您需要回答一些问题:

    • API 的基本 URL 是什么?

    • API 使用标准的 Web 表单编码或 JSON 吗?如果不是,是否有支持编码和解码的库?

    • 您能够发起多少请求或下载多少数据有限制吗?

    • 网站是否有良好的文档,包含发送和检索数据的常见示例?

随着实践,您将会形成自己对开始使用新 API 所需信息的感觉。但是,您确实需要练习。在构建第一个客户端之后,查找另一个服务。为该 API 编写客户端,并查看您是否已经能够识别常见问题或更好地重用第一个客户端的代码。

¹ 您可以使用jar tvf <jarfile>查看任何 JAR 或 ZIP 文件。

² 不过,您可以使用SortedSetTreeMap,它们都会保持其条目排序。对于TreeMap,键保持有序。

posted @   绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
历史上的今天:
2020-06-15 布客&#183;ApacheCN 编程/后端/大数据/人工智能学习资源 2020.6
2020-06-15 布客·ApacheCN 编程/大数据/数据科学/人工智能学习资源 2020.1
2020-06-15 ApacheCN 编程/大数据/数据科学/人工智能学习资源 2019.11
2020-06-15 ApacheCN 公众号文章汇总 2019.9
点击右上角即可分享
微信分享提示