Java 学习指南第六版(一)
原文:
zh.annas-archive.org/md5/d44128f2f1df4ebf2e9d634772ea8cd1译者:飞龙
序言
本书介绍了 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 社区中最常用的编码约定。类名以大写字母开头;变量和方法名以小写字母开头。常量名中的所有字母都大写。我们不使用下划线来分隔长名称中的单词;根据惯例,我们将首字母之后的单词大写,并将单词连在一起。例如:thisIsAVariable,thisIsAMethod(),ThisIsAClass和THIS_IS_A_CONSTANT。此外,请注意,我们在引用静态方法和非静态方法时进行了区分。与某些书籍不同的是,我们从不使用Foo.bar()来表示Foo的bar()方法,除非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 所示。您无需为不同的平台制作替代版本的应用程序,也无需向最终用户分发源代码。
图 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 与创建时流行的几种语言的比较。
图 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 提供了下载和使用本书代码的详细信息。)我们鼓励您回答这些问题并尝试这些练习。如果您不得不返回章节并阅读更多内容以找到答案或查找某些方法名称,不要担心!这就是目的!学习如何使用本书作为参考将在未来派上用场。
-
目前谁在维护 Java?
-
Java 的开源开发工具包的名称是什么?
-
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_HOME和PATH)设置正确。无论你使用哪种操作系统,如果要使用 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_HOME和PATH环境变量来配置终端以使用该版本:
$ 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_HOME和PATH的更改永久化。例如,如果你使用bash作为你的 shell,你可以将 图 2-1 中的两行export命令添加到你的 .bashrc 文件中。
图 2-1. 在 Linux 上验证你的 Java 版本
在 macOS 上安装 Corretto
对于 macOS 系统的用户,Corretto 下载 和安装过程非常简单。选择你想使用的 JDK 版本,然后从随后的下载页面中选择 .pkg 链接。双击下载的文件以启动向导。
在 图 2-2 中显示的安装向导并不允许进行太多的实际定制。JDK 将被安装在运行 macOS 的磁盘上,其文件夹位于 /Library/Java/JavaVirtualMachines 目录下,并将以符号链接形式链接到 /usr/bin/java。虽然你可以选择备用的安装位置,例如在具有独立硬盘的 macOS 上,但默认设置适用于本书的目的。
图 2-2. macOS 中的 Corretto 安装向导
安装完成后,您可以通过打开通常位于全局应用程序文件夹下实用工具文件夹中的终端应用程序来测试 Java。键入**java -version**,您应该看到类似于图 2-3 的输出。我们在此系统上安装了版本 19,但您应该在输出中看到您下载的版本号。
图 2-3. 在 macOS 中验证您的 Java 版本
在 Windows 上安装 Corretto
Windows 上的 Corretto 安装程序(从亚马逊的网站下载*.msi*文件)遵循典型的 Windows 安装向导,如图 2-4 所示。您可以按照简短的提示接受默认设置,或者如果熟悉管理任务(如配置环境变量和注册表条目),也可以进行微调。如果提示允许安装程序对系统进行更改,请继续选择是。
图 2-4. Windows 中的 Corretto 安装向导
或许您不经常在 Windows 中使用命令行,但新版本的 Windows 中的终端应用程序(或旧版本中的命令提示符应用程序)具有与 macOS 或 Linux 中类似应用程序相同的功能。从 Windows 菜单中,您可以搜索term或cmd,如图 2-5 所示。
图 2-5. 在 Windows 中定位终端应用程序
单击相应的结果以启动您的终端,并通过键入**java -version**来检查 Java 的版本。在我们的示例中,我们运行的是版本 19;您应该看到与图 2-6 类似的输出,但带有您的版本号。
图 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 或更高版本),确保选中“添加示例代码”复选框。
图 2-7. 新建 Java 项目对话框
您可能会注意到对话框左侧的生成器列表。默认的“新项目”非常适合我们的需求。但您可以使用 Kotlin 或 Android 等模板启动其他项目。默认包括一个带有可执行 main() 方法的最小 Java 类。接下来的章节将更详细地介绍 Java 程序的结构以及可以放置在这些程序中的命令和语句。在左侧选择默认选项后,点击“创建”按钮。(如果看到下载共享索引的提示,请选择“是”。共享索引并不是必需的,但会让 IDEA 运行得更快。)您应该会得到一个包含 Main.java 文件的简单项目,如 图 2-8 所示。
图 2-8. IDEA 中的 Main 类
恭喜!现在您有一个 Java 程序。您将运行此示例,并在此基础上增加一些特色。接下来的章节将展示更多有趣的示例,逐步组合更多 Java 元素。尽管如此,我们始终会在类似的设置中构建这些示例。这些起步步骤是您的良好开始。
运行项目
从 IDEA 提供的简单模板开始,这样可以让您顺利运行您的第一个程序。回顾 图 2-8。注意代码编辑器左侧第 1 和第 2 行旁边的绿色三角形,分别位于 Main 类和 main() 方法旁边。IDEA 理解 Main 可以被执行。您可以单击任何这些按钮来运行您的代码。(在左侧项目大纲中的 src 文件夹下列出的 Main 类也有一个小绿色“播放”按钮。)您可以右键单击该类条目,并选择 Run ‘Main.main()’ 选项,如 图 2-9 所示。
图 2-9. 运行您的 Java 项目
无论您使用编辑器边栏按钮还是上下文菜单,现在可以运行您的代码了。您应该能在编辑器底部的运行选项卡中看到“Hello World!”消息显示,类似于 图 2-10。
图 2-10. 我们的第一个 Java 程序输出
IDE 也包括一个方便的终端选项。这允许你打开一个具有命令提示符的标签或窗口。你可能不经常需要这个选项,但它绝对会派上用场。例如,在 IDEA 中,你可以从 View → Tool Windows → Terminal 菜单选项中打开终端标签,或者通过点击主窗口底部的 Terminal 快捷方式来打开,如 Figure 2-11 所示。
Figure 2-11. IntelliJ IDEA 中的终端标签
在 VS Code 中,你可以使用 Terminal → New Terminal 菜单选项来打开一个类似的 IDE 部分,如 Figure 2-12 所示。
Figure 2-12. Microsoft 的 VS Code 中的终端标签
随时可以自行尝试终端。在 IDE 中打开终端窗口后,导航至 Learning Java 文件夹。(大多数 IDE 会自动在项目的基本目录下打开终端。)使用 java 命令来运行我们的 Main 程序,如 Figure 2-13 所示。
Figure 2-13. 在终端标签中运行 Java 程序
无论你选择哪种方式,再次祝贺你!你现在已经成功运行了你的第一个 Java 程序!
抓取示例
代码示例和练习解决方案可以在线获取,位于本书的 GitHub 仓库。GitHub 已成为公共及私有开源项目的事实标准云存储库站点。GitHub 除了简单的源代码存储和版本控制外,还有许多有用的工具。如果你打算开发一个希望与他人共享的应用程序或库,值得在 GitHub 上设置一个账户并深入探索。幸运的是,你也可以仅仅通过下载公共项目的 ZIP 文件来使用它,如 Figure 2-14 所示。
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。
在完成之前,我们会对这个示例进行几次修改(HelloJava,HelloJava2等),添加功能并介绍新概念。但让我们从最简版本开始。在您的工作空间中创建一个名为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 编译器在哪里找到我们使用的JFrame和JLabel对象的定义:
// 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。
图 2-15. 运行 HelloJava 应用程序
你应该看到在图 2-16 中显示的声明。再次祝贺,你现在已经运行了你的第二个 Java 应用程序!花点时间沉浸在你的显示器的光辉中。
图 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() 方法中使用了两个变量,frame 和 label。我们以后还会详细介绍变量。
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对象,就像它可以是更简单的类型之一,比如int或char。变量具有类型并且不能简单地保存任何类型的对象,这是 Java 的另一个重要特性,确保了代码的安全性和正确性。
暂时不考虑main()方法中使用的变量,我们的简单HelloJava示例中只声明了另一个变量。它出现在main()方法的声明中:
public static void main(String [] args) {
// ...
}
就像其他语言中的函数一样,Java 中的方法声明一个接受参数(变量)的列表作为参数,并指定这些参数的类型。在这种情况下,主method要求在调用时,传递一个名为args的String对象数组作为参数。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.class和HelloComponent.class*。运行代码应该看起来很像JLabel版本,但是如果您调整窗口大小,您会注意到我们的新组件不会自动调整以使文本居中。
那么我们到底做了什么,为什么要如此费力地侮辱完全正常的JLabel组件?我们创建了我们的新HelloComponent类,扩展了一个称为JComponent的通用图形类。扩展一个类只是指向现有类添加功能,从而创建一个新类。我们将在下一节更详细地介绍这个过程。
在我们当前的示例中,我们创建了一种新的JComponent类型,其中包含一个称为paintComponent()的方法,负责绘制我们的消息。paintComponent()方法接受一个名为(有些简洁)g的参数,类型为Graphics。当调用paintComponent()方法时,将一个Graphics对象分配给g,我们在方法体中使用它。稍后我们会详细介绍paintComponent()和Graphics类。至于为什么这样做,待我们稍后为我们的新组件添加各种新功能时,您就会理解。
继承
Java 类以父子层次结构排列,其中父类和子类分别称为超类和子类。我们将在第五章中更深入地探讨这些概念。在 Java 中,每个类都恰好有一个超类(一个单一的父类),但可能有许多子类。唯一的例外是Object类,它位于整个类层次结构的顶端;它没有超类。(可以提前查看在图 2-17 中显示的 Java 类层次结构的一个小片段。)
在前面示例中声明我们的类时,使用关键字extends指定HelloComponent是JComponent类的一个子类:
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”其超类的一种。因此,HelloComponent 是 JComponent 的一种。当我们提到对象的一种类型时,我们指的是该对象类的任何实例或其任何子类的任何实例。稍后,我们将更详细地查看 Java 类层次结构,并看到 JComponent 本身是 Container 类的子类,后者进一步派生自一个称为 Component 的类,如 图 2-17 所示。
在这个意义上,HelloComponent 对象是 JComponent 的一种,而 JComponent 又是 Container 的一种,所有这些最终都可以被认为是 Component 的一种。正是从这些类中,HelloComponent 继承了它的基本 GUI 功能,以及(稍后我们将讨论的)嵌入在其中的其他图形组件的能力。
图 2-17. Java 类层次结构的部分
Component 是顶级 Object 类的一个子类,因此所有这些类都是 Object 的类型。Java API 中的每个其他类都从 Object 继承行为,Object 定义了一些基本方法,正如你将在 第五章 中看到的。我们将继续使用 object(小写 o)一词以通用方式指代任何类的实例;我们将使用 Object 来具体指代这个类的类型。
包和导入
我们之前提到我们示例的第一行告诉 Java 在哪里找到我们使用的一些类:
import javax.swing.*;
具体来说,它告诉编译器我们将使用来自 Swing GUI 工具包的类(在本例中是JFrame、JLabel和JComponent)。这些类组织成一个名为javax.swing的 Java 包。在 Java 中,包是按目的或应用程序相关联的一组类。同一包中的类彼此之间具有特殊的访问权限,并且可能被设计为紧密协作。
包名称采用点分隔的分层方式命名,例如java.util和java.util.zip。包中的类通常存储在匹配其包名称的嵌套文件夹中。它们的“全名”或正确术语称为完全限定名称中也包含包的名称作为其一部分。例如,JComponent类的完全限定名称是javax.swing.JComponent。我们本可以直接用这个名字引用它,而不使用import语句:
class HelloComponent extends javax.swing.JComponent {...}
使用完全限定名称可能会令人厌烦。语句import javax.swing.*使我们能够通过它们的简单名称引用javax.swing包中的所有类。我们不必使用完全限定名称来引用JComponent、JLabel和JFrame类。
当我们添加第二个示例类时,我们看到在给定的 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 包,展示了每个包中的一个或两个典型类。
图 2-18. 一些核心 Java 包
java.lang 包含 Java 语言本身所需的基本类; 这个包被自动导入,这就是为什么在我们的示例中使用 String 或 System 等类名时不需要 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类添加功能,并简单地对名称进行相应的更改,以保持它们的清晰性(例如,HelloComponent2,HelloComponent3等)。刚刚看到继承的作用,你可能会想知道为什么我们不创建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.class和HelloComponent2.class作为结果。
如果你在 IDEA 中进行跟进,请点击HelloJava2旁边的运行按钮。如果你使用终端,请使用以下命令运行示例:
C:\> java HelloJava2
随意用你自己的胜利性评论替换“Hello, Java!”消息,并享受用鼠标拖动文本多个小时的乐趣。注意,现在当你点击窗口的关闭按钮时,应用程序会正常退出;当我们讨论事件时,我们将在稍后解释这一点。让我们深入了解一下发生了什么变化。
实例变量
我们在我们的示例中向HelloComponent2类添加了一些变量:
int messageX = 125, messageY = 95;
String theMessage;
messageX和messageY是保存我们可移动消息的当前坐标的整数。我们已经将它们设置为默认值,应该将消息放置在窗口的大致中心。Java 整数是 32 位有符号数,因此它们可以轻松保存我们的坐标值。变量theMessage是String类型,可以保存String类的实例。
您应该注意,这三个变量声明在类定义的大括号内,但不是在该类的任何特定方法内。这些变量称为实例变量,它们属于整个对象。每个类的各个实例中都会有它们的独立副本。实例变量始终对它们所属类内的所有方法可见(并可用)。根据其修饰符,它们也可能可以从类外部访问。
除非另有初始化(程序员术语表示设置某物的第一个值),否则实例变量将被设置为其类型的默认值:0、false或null,具体取决于其类型。数值类型被设置为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!会被视为两个独立的参数。
图 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包。导入不是递归的。包实际上并不包含其他包,即使层次命名方案会暗示它们包含。)
其中有数十种事件类,包括MouseEvent、KeyEvent和ActionEvent。在大多数情况下,这些事件的含义相当直观。当用户使用鼠标时,会发生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来告诉我们鼠标当前位置的x和y坐标。我们将这些保存在messageX和messageY实例变量中,以便在其他地方使用。
事件模型的美妙之处在于您只需要处理您想要的事件类型。如果您不关心键盘事件,您就不会为它们注册监听器;用户可以随心所欲地输入,而您则不会受到干扰。如果没有特定类型事件的监听器,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所示。
图 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 开发人员中的几位新朋友打招呼!
复习问题
这里有一些复习问题,以确保您掌握了本章的关键内容:
-
您用什么命令来编译 Java 源文件?
-
当运行 Java 类时,JVM 如何知道从何处开始?
-
在创建新类时,能够扩展多个类吗?
-
在创建新类时,能够实现多个接口吗?
-
哪个类代表图形应用程序中的主窗口?
代码练习
对于你的第一个编程练习,³ 创建一个GoodbyeJava类,它的功能与第一个 HelloJava 程序一样,只是显示“Goodbye, Java!”的消息而已。尝试命令行版本或图形版本——或两者都试试!随意复制原始程序的尽可能多的部分。记得编译并运行你的GoodbyeJava类,以帮助练习执行 Java 应用程序的过程。在接下来的几章中,你肯定会得到更多的练习,但是现在更多地熟悉你的 IDE 或者javac和java命令将有助于你阅读接下来的几章。
¹ 参数和参数这两个术语经常被交替使用。这大多数情况下都没问题,但从技术上讲,当定义方法或构造函数时,你提供参数的类型和名称。在调用方法或构造函数时,你提供参数来填充这些参数。
² 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 是否已安装以及您正在使用的工具版本的首选测试是在java和javac命令上使用-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 所示。
图 3-1。启动 Java 应用程序
main() 方法必须具有正确的方法签名。方法签名是定义方法的一组信息。它包括方法的名称、参数和返回类型,以及类型和可见性修饰符。main() 方法必须是一个 public、static 方法,它以 String 对象数组作为参数,并且不返回任何值(void):
public static void main (String [] myArgs)
main() 是一个 public 和 static 方法的事实仅意味着它是全局可访问的,并且可以直接按名称调用。我们将在第四章和第五章讨论可见性修饰符如 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.lang、java.io、java.net 和 javax.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文件。
图 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 扩展名结尾。每个源代码文件被视为单个编译单元。(如你将在第五章中看到,给定编译单元中的类共享某些特性,如 package 和 import 语句。)
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.Grover,javac 就会在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 中查看帮助的快速提醒。
图 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 所示。
图 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)!
图 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()
图 3-6. 向我们的框架添加JLabel
现在我们可以看到我们的标签了。有些操作会自动触发对revalidate()或repaint()的调用。例如,在我们显示框架之前添加到框架的任何组件,将会在我们显示框架时立即出现。或者我们可以类似地删除标签。再次观察,看看当我们立即在删除标签后改变框架大小时会发生什么(图 3-7):
jshell> frame.remove(label) // as with add(), things don't change immediately
jshell> frame.setSize(400,150)
图 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)
图 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 文件 的内容,可选地仅提取 路径(们)。
图 3-9. jar 命令行工具的重要元素
在这些命令中,标志字母 c、t 和 x 告诉 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.MF。MANIFEST.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 交互式实用工具。本章的目标是确保您知道现有的工具,以便在需要时可以返回查看详细信息。
复习问题
-
哪个语句允许您访问您的应用程序中的 Swing 组件?
-
哪个环境变量决定 Java 编译或执行时查找类文件的位置?
-
不解压即可查看 JAR 文件内容的选项是什么?
-
在 MANIFEST.MF 文件中需要哪个条目才能使 JAR 文件可执行?
-
什么工具允许您以交互方式尝试 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)。
图 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 上进行此精确测试时得到的结果。
图 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>,它是一个只能包含String的List。或者我们可以创建一个包含JLabel对象的List<JLabel>的列表。我们将在第七章中看到更多关于泛型的内容。
原始类型
数字、字符和布尔值是 Java 的基本元素。与一些其他(也许更纯粹的)面向对象语言不同,它们不是对象。对于那些希望将原始值视为对象的情况,Java 提供了“包装”类。(稍后详细介绍。)将原始值视为特殊值的主要优势在于,Java 编译器和运行时可以更容易地优化它们的实现。原始值和计算仍然可以映射到硬件上,就像在低级语言中一直做的那样。
Java 的一个重要的可移植性特性是原始类型的精确定义。例如,你永远不用担心 int 在特定平台上的大小;它始终是一个 32 位的有符号数字。数值类型的“大小”决定了你可以存储的值有多大(或多精确)。例如,byte 类型是一个 8 位的有符号值,用于存储从 -128 到 127 的小数字。³ 上述的 int 类型可以处理大部分数值需求,存储大约 +/- 20 亿之间的值。表 4-2 总结了 Java 的原始类型及其容量。
表格 4-2. Java 原始数据类型
| 类型 | 定义 | 大致范围或精度 |
|---|---|---|
boolean | 逻辑值 | true 或 false |
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包中的BigInteger和BigDecimal类。这些类提供接近无限大小或精度。(如果您想看到这些大数字的实际应用,我们在“创建自定义约简器”中使用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;
二进制数由前导字符0b或0B(零“b”)表示,后跟一组零和一:
int i = 0b01001011; // i = 75 decimal
八进制数与十进制数的区别在于简单的前导零:
int i = 01230; // i = 664 decimal
十六进制数以前导字符0x或0X(零“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,除非它们后缀为f或F,表示它们是较小精度的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,并将其赋值给同一个对象。现在有两个相同的引用:myCar 和 anotherCar,但只有一个实际的 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 时,我们得到一个不同的指针值。myCar 和 anotherCar 指向同一个对象;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 对象的第三个引用,除了 myCar 和 anotherCar。
该方法可以通过调用 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是一个布尔表达式,必须用括号括起来。布尔表达式本身是一个布尔值(true或false)或者求值为这些值之一的表达式。⁶ 例如,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 语句
许多语言支持常见的“多个之一”条件,通常称为switch或case语句。给定一个变量或表达式,switch语句提供多个可能匹配的选项。我们确实指的是可能。一个值不必匹配任何switch选项;在这种情况下什么也不发生。如果表达式确实匹配一个case,那么执行该分支。如果有多个case匹配,那么第一个匹配将获胜。
Java switch语句最常见的形式是接受一个整数(或可以自动提升为整数类型的数值类型参数)或字符串,并在多个常量case分支中选择:^(7)
switch (expression) {
case constantExpression :
statement;
[ case constantExpression :
statement; ]
// ...
[ default :
statement; ]
}
每个分支的 case 表达式必须在编译时评估为不同的常量整数或字符串值。字符串使用String的equals()方法进行比较,我们将在第八章中详细讨论这个方法。
您可以指定一个可选的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"
在这个例子中,只执行一个分支——GOOD、BAD或默认值。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 中有许多不同类型的循环语句。每种类型的循环都有其优缺点。现在让我们来看看这些不同类型。
do 和 while 迭代语句会持续运行,只要布尔表达式(通常称为循环的条件)返回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 语句使 for 和 while 循环通过返回到它们检查条件的点来进行下一次迭代。以下示例打印数字 0 到 9,跳过数字 5:
// filename: ch04/examples/ForDemo.java
for (int i = 0; i < 10; i++) {
if (i == 5)
continue;
System.out.println(i);
}
break 和 continue 语句看起来像 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
诸如代码块、条件和循环之类的封闭语句可以用像 labelOne 和 labelTwo 这样的标识符标记。在此示例中,没有参数的 break 或 continue 具有与前面示例相同的效果。break 使处理恢复到标记为“labelTwo 后”的点;continue 立即导致 labelTwo 循环返回到其条件测试。
我们可以在 smallProblem 语句中使用 break labelTwo 语句。它与普通的 break 语句具有相同的效果,但是像在 bigProblem 语句中看到的 break labelOne 语句会跳出两个级别并在标记为“labelOne之后”的点处继续。类似地,continue labelTwo 将作为正常的 continue,但 continue labelOne 将返回到 labelOne 循环的测试。多级 break 和 continue 语句消除了对 C/C++ 中备受诟病的 goto 语句的主要理由。⁸
现在我们不会讨论几个 Java 语句。 try、catch 和 finally 语句用于异常处理,正如我们将在 第六章 中讨论的那样。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 类中。尝试更改 a 和 b 的值。在 第八章 中,我们将看到如何将字符串转换为数字,以便您可以再次运行程序,将两个数作为参数传递给 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 数组的情况。
图 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
以下示例创建了一个名为keyPad的JButton对象数组。然后,使用我们的方括号和循环变量作为索引填充数组:
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对象数组作为参数。假设Cat和Dog是Animal的子类,下面是如何使用匿名数组调用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 展示了我们创建的数组的数组。
图 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。
图 4-5。一个三角形的数组
章节末尾的练习给了你机会设置和初始化这个数组!
类型、类和数组,哦,我的天啊!
Java 有各种类型用于存储信息,每种类型都有自己表示信息字面量的方式。随着时间的推移,你会熟悉和适应int、double、char和String。但不要着急——这些基本构建块正是jshell设计用来帮助你探索的东西。检查你对变量可以存储什么的理解总是值得的。特别是数组可能会受益于一些实验。你可以尝试不同的声明技术,并确认你掌握了如何访问单维和多维结构中的各个元素。
你也可以在jshell中玩耍,例如我们的if分支和while循环语句的简单控制流。在偶尔输入多行代码片段时需要一点耐心,但我们无法过分强调,随着你将更多 Java 细节加载到你的大脑中,玩耍和实践是多么有用。编程语言当然不像人类语言那样复杂,但它们仍然有许多相似之处。你可以像学习英语(或你用来阅读本书的语言,如果你有翻译的话)一样获得 Java 的读写能力。即使你不立即理解细节,你也会开始感受代码的意图。
Java 的某些部分,如数组,显然是充满细节的。我们之前注意到数组在 Java 语言中是特殊数组类的实例。如果数组有类,它们在类层次结构中的位置以及它们如何相关呢?这些都是很好的问题,但在回答它们之前,我们需要更多地讨论 Java 的面向对象方面。这是第五章的主题。现在,只需相信数组适合于类层次结构即可。
复习问题
-
Java 在编译后的类中默认使用哪种文本编码格式?
-
用什么字符来包围多行注释?这些注释可以嵌套吗?
-
Java 支持哪些循环结构?
-
在一系列
if/else if测试中,如果多个条件为真会发生什么? -
如果你想将美国股市的总市值(大约在 2022 财年结束时为 31 万亿美元)以整数美元的形式存储,你可以使用什么原始数据类型?
-
表达式
18 - 7 * 2的计算结果是什么? -
你如何创建一个数组来保存一周中每天的名称?
代码练习
对于你的编码练习,我们将建立在本章的两个示例之上:
-
将欧几里得的最大公约数算法实现为名为
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);为了你的输出,你能想到一种方法来显示
a和b的原始值以及共同的分母吗?理想的输出应该像这样:% java Euclid The GCD of 2701 and 222 is 37 -
尝试将前一节的三角形数组扩展为一个简单的类或者在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
高级练习
-
如果你想要更多挑战,尝试将输出排列成一个视觉三角形。上面的语句会将一个元素打印到一行。内置的
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 语言规范的一部分。