面向-Android-开发的-Java-学习手册-全-

龙哥盟 / 2024-10-06 / 原文

面向 Android 开发的 Java 学习手册(全)

原文:Learn Java for Android development

协议:CC BY-NC-SA 4.0

零、简介

智能手机和平板电脑如今风靡一时。它们的受欢迎程度很大程度上是由于它们运行应用的能力。尽管拥有越来越多基于 Objective-C 的应用的 iPhone 和 iPad 占据了先机,但事实证明,拥有越来越多基于 Java 的应用的基于 Android 的智能手机和平板电脑是一个强有力的竞争对手。

不仅许多 iPhone/iPad 应用开发人员通过销售他们的应用赚钱,许多 Android 应用开发人员也通过销售类似的应用赚钱。据科技网站如 The Register(【www.theregister.co.uk/】)报道,一些安卓应用开发者正在赚大钱(【www.theregister.co.uk/2010/03/02/android_app_profit/】)。

在当今充满挑战的经济环境下,也许你想尝试开发 Android 应用并赚些钱。如果你有好的想法、毅力和一些艺术天赋(或者可能认识一些有天赋的人),你已经是实现这个目标的一部分了。

提示相比 iPhone/iPad 应用开发,考虑 Android 应用开发的一个很好的理由是使用 Android 会产生更低的启动成本。比如,你不需要购买 Mac 来开发 Android 应用(开发 iPhone/iPad 应用需要 Mac);您现有的 Windows、Linux 或 Unix 机器会做得很好。

最重要的是,在进入 Android 之前,您需要对 Java 语言和基础应用编程接口(API)有一个坚实的理解。毕竟,Android 应用是用 Java 编写的,并且与许多标准的 Java APIs(例如,线程和输入/输出 API)交互。

我写了Learn Java for Android Development来给你一个坚实的 Java 基础,你以后可以用 Android 架构、API 和工具细节的知识来扩展它。这本书将让你对 Java 语言和许多重要的 API 有很强的掌握,这些 API 是 Android 应用和其他 Java 应用的基础。它还将向您介绍关键的开发工具。

图书组织

这本书的第一版分为十章和一个附录。第二版由 14 章和三个附录组成。每一版的每一章都提供了一组练习,你应该完成这些练习才能从内容中获得最大的益处。他们的解决方案在附录中给出。

在第一章中,我首先通过关注 Java 的双重性(语言和平台)来介绍 Java。然后我简单介绍一下 Oracle 的 Java SE、Java EE、Java ME 版的 Java 开发软件,以及 Google 的 Android 版。接下来,您将学习如何下载和安装 Java SE 开发工具包(JDK ),并通过开发和使用两个简单的 Java 应用来学习一些 Java 基础知识。在简要介绍了 Eclipse IDE 之后,您将对本书中涉及的各种 API 有一个概述。

在第二章中,我将重点介绍语言基础,让你开始一次深入的 Java 语言之旅。您将了解注释、标识符(和保留字)、类型、变量、表达式(和文字)以及语句。

在第三章中,我继续关注类和对象。您将学习如何声明一个类并从该类中实例化对象,如何在该类中声明字段并访问这些字段,如何在该类中声明方法并调用它们,如何初始化类和对象,以及如何在不再需要对象时将其删除。你还会学到更多关于数组的知识,数组在第二章中首次介绍。

在第四章中,我通过向你介绍带你从基于对象的应用到面向对象的应用的语言特性,增加了第三章的基于对象的知识库。具体来说,您将了解与继承、多态性和接口相关的特性。在探索继承的过程中,您了解了 Java 的终极超类。此外,在探索接口时,您会发现为什么它们包含在 Java 语言中;接口不仅仅是 Java 缺乏对多实现继承支持的一种变通方法,它还有更高的目的。

在第五章中,我向你介绍了四类高级语言特性:嵌套类型、包、静态导入和异常。

在第六章中,我向你介绍了四个额外的高级语言特性类别:断言、注释、泛型和枚举。

在第七章中,我开始关注 API 多于语言特性的趋势。在本章中,我首先向您介绍 Java 的许多面向数学的类型(例如, Math、StrictMath、BigDecimal 和 BigInteger ),然后向您介绍它的面向字符串的类型(例如, String、StringBuffer 和 StringBuilder )。最后,您将探索用于获取包信息的包类。

在第八章中,我继续通过关注基本类型包装类、线程和面向系统的 API 来探索 Java 的基本 API。

在第九章中,我专门关注 Java 的集合框架,它为你提供了一个在列表、集合、队列和映射中组织对象的解决方案。您还将了解面向集合的工具类,并回顾 Java 的遗留工具类型。

在第十章中,我继续探索 Java 的工具 API,向您介绍并发工具、日期类(用于表示时间)、格式化程序类(用于格式化数据项)、随机类(用于生成随机数)、定时器和定时器任务类(用于偶尔或重复执行任务),以及用于处理 ZIP 和 JAR 文件的 API。

第十一章是关于传统的输入/输出(I/O)的,主要是从文件的角度来看。在这一章中,您将从文件类、随机访问文件类、各种流类和各种写/读类的角度探索经典的 I/O。我对流 I/O 的讨论包括 Java 的对象序列化和反序列化机制。

在第十二章中,我继续通过关注网络来探索传统的 I/O。您将了解到 Socket、ServerSocket、DatagramSocket 和 MulticastSocket 类以及相关类型。您还将了解到用于在更高层次上实现网络 I/O 的 URL 类。在了解了底层的 NetworkInterface 和 InterfaceAddress 类之后,您将根据 CookieHandler 和 CookieManager 类以及 CookiePolicy 和 CookieStore 接口来探索 cookie 管理。

在第十三章中,我向你介绍了新的 I/O。你将在这一章中学习缓冲区、通道和正则表达式。我也很想介绍选择器和字符集,但是由于空间不足,我无法这样做。为了讨论选择器,我还必须讨论套接字通道,但是我只能讨论文件通道。然而,第十一章确实给了你一点儿字符集的味道。

在第十四章中,我通过关注数据库来总结这本书的章节部分。您将首先了解 Java DB 和 SQLite 数据库产品,然后探索与通过这些产品创建的数据库进行通信的 JDBC。

在附录 A 中,我给出了第一章到第十四章中所有练习的答案。

在附录 B 中,我将向您介绍在四张牌背景下的应用开发,这是一款基于控制台的纸牌游戏。

在附录 C 中,这本书的代码附带了一个单独的 PDF 文件,我向您介绍了高级 API(如反射和引用)以及在 Android 应用环境中可能不太有用的 API(如首选项——Android 提供了自己的解决方案)。)

注意你可以下载这本书的源代码,方法是将你的网络浏览器指向【www.apress.com/book/view/1430257226】,点击源代码标签,然后点击立即下载链接。

第一版对第二版

这本书的第一版于 2010 年 9 月出版。总的来说,我对第一版很满意,我感谢所有购买它的人。然而,正如有人多次向我指出的那样,第一版是有缺陷的。除了一些小的技术错误,在这本书的开发过程中,我还遇到了一些组织和其他方面的问题。

首先,我不应该在《??》第一章中介绍四张同类型的纸牌游戏。对于许多读者来说,这一点太复杂了。因此,我把游戏移到了附录 B ,以免让 Java 初学者不知所措。

此外,我试图在同一章中用类和对象的基础知识来涵盖语言基础知识(例如,语句和表达式)。虽然有些人欣赏这种方法,但它对初学者来说太令人困惑了;我向有这种感觉的读者道歉。在第二版中,我将 Java 语言的这些方面分开来(希望)解决这个问题。在第二章中,我关注于语句、表达式和其他非类/非对象的基础知识;在第三章中,我关注类和对象。

另一个问题是包含了复杂的 API,这些 API 要么在开发 Android 应用时很少使用,要么与 Android 开发者无关。示例包括引用、反射、首选项和国际化。我把这些 API 移到了附录 C,这样我就可以涵盖更简单(也可能更有用)的 API,比如 ZIP 和 Timer。(我还在附录 c 中增加了新内容。)

在编写第一版时,我计划更进一步,介绍 Java 对网络和数据库访问(通过 JDBC)、安全性、XML、新 I/O 等的支持。我愚蠢地提出了一个写六个免费章节的计划,但是只完成了三个章节的一部分。

不幸的是,我最初的六个免费章节的计划是有缺陷的。例如,我计划在关于新 I/O 的免费章节之后写一个关于网络的免费章节。这不是一个好的组织,因为新 I/O 包括套接字通道,所以网络章节应该在新 I/O 章节之前。

此外,我还了解到(通过各种关于 Android 和安全的博客)Java 的安全特性在 Android 环境中并不必要。因为这本书在一定程度上侧重于展示最有用的 Java APIs,以供随后在 Android 环境中使用,所以对 Java 安全 API 的介绍可能并不重要(尽管我可能是错的)。

注意第二版没有免费章节补充。然而,附录 C 是免费的。此外,我可能最终会在我的网站上提供一些额外的材料(也许是关于套接字通道、选择器和字符集的内容)。

接下来是什么?

在你完成这本书之后,我建议你去买一本格兰特·艾伦(2012 年出版)的《Android 4 入门》,开始学习如何开发 Android 应用。在那本书里,你学习了 Android 基础知识,以及如何为 Android 4 移动设备创建创新的、可扩展的应用。没有给出 Android 开发的一些肤浅的细节,而是专注于教你 Java 语言和 API,比如你将需要在你的应用中使用的集合。如果不先了解 Java,怎么着手了解 Android 呢?

我还推荐你去看看第二版的安卓食谱(见【www.apress.com/9781430246145】)。虽然那本书的内容很大程度上包含了学习 Android 各种东西的独立食谱,第一章包含了对 Android 应用开发的总结和快速介绍。通过阅读那一章,你会学到很多关于 Android 的基础知识。

感谢您购买我的书。我希望你会发现这是一个有益的准备,我希望你能成功地成为一名令人满意且利润丰厚的 Android 应用开发人员。

一、Java 入门

Android 是谷歌用于移动设备的软件栈。这个堆栈由应用(或通常所说的应用)、运行应用的虚拟机(基于软件的处理器和相关环境)、中间件(位于操作系统之上并为虚拟机及其应用提供各种服务的软件),以及基于 Linux 的操作系统组成。

Android 应用是用 Java 编写的,使用各种 Java 应用接口(API)。因为你想写你自己的应用,但可能不熟悉 Java 语言和这些 API,这本书教你 Java,作为应用开发的第一步。它为您提供了开发应用时有用的 Java 语言和 Java APIs 的基础知识。

注意这本书通过非 Android Java 应用阐述了 Java 概念。新手掌握这些应用比对应的安卓应用更容易。

API 是指应用代码用来与其他代码通信的接口,通常存储在某种软件库中。关于这个术语的更多信息,可以查看维基百科的“应用编程接口”主题(en . Wikipedia . org/wiki/Application _ programming _ interface)。

这一章为你在开始 Android 应用开发生涯之前需要理解的基本 Java 概念奠定了基础。我首先回答“什么是 Java?”问题。接下来,我将向您展示如何安装 Java SE 开发工具包(JDK),并向您介绍用于编译和运行 Java 应用的 JDK 工具。

在向您展示了如何安装和使用开源 Eclipse IDE(集成开发环境)以便您可以更容易(更快速)地开发 Java 应用(以及最终的 Android 应用)之后,我将为您提供各种 Java APIs 的高级概述,您可以从您的 Java 应用和 Android 应用中访问这些 API。在后续章节中,您将更详细地探索这些和其他有用的 API。

注意 第一章简短而紧凑,介绍了许多你将在本书中更详细遇到的概念。如果您是 Java 新手,您可能会发现自己对这些概念有点不知所措。然而,当你继续阅读余下的章节时,任何迷雾都将消散。如果您仍然感到有些困惑,请联系我(Jeff @ tutortutor . ca),我会尽力帮助您。

Java 是什么?

Java 是 Sun Microsystems 首创的语言和平台。在这一节中,我将简要描述这种语言,并揭示它对 Java 作为一个平台意味着什么。为了满足各种需求,Sun 将 Java 组织成三个主要版本:Java SE、Java EE 和 Java ME。本节还简要探讨了这些版本以及 Android。

注意 Java 有一段有趣的历史,可以追溯到 1990 年 12 月。当时,詹姆斯·高斯林、帕特里克·诺顿和迈克·谢里丹(都是太阳微系统公司的雇员)被赋予了弄清计算领域下一个主要趋势的任务。他们的结论是,一个趋势将涉及计算设备和智能消费电器的融合。由此诞生了 绿色工程

绿色的果实是 Star7 ,这是一款手持无线设备,配有 5 英寸彩色液晶显示屏、SPARC 处理器、复杂的图形功能和 Unix 版本;以及由詹姆斯·高斯林开发的用于编写在 Star7 上运行的应用的语言 Oak ,他以生长在 Sun 公司他办公室窗外的一棵橡树命名了 star 7。为了避免与另一种同名语言的冲突,Gosling 博士将这种语言的名称改为 Java。

Sun Microsystems 随后发展了 Java 语言和平台,直到甲骨文在 2010 年初收购了 Sun。查看oracle.com/technetwork/java/index.html了解来自甲骨文的最新 Java 新闻。

Java 是一种语言

Java 是开发者表达源代码 (程序文本)的语言。Java 的语法(将符号组合成语言特性的规则)部分模仿了 C 和 C++ 语言,以缩短 C/C++开发人员的学习曲线。

下面列出了 Java 和 C/C++之间的一些相似之处:

  • Java 和 C/C++共享相同的单行和多行注释样式。注释让您记录源代码。
  • Java 的许多保留字与它们的 C/C++对应物是相同的(表示、 if】、 switch 和而是示例)和 C++对应物( catch 、 class 、 public 和 try 是示例)。
  • Java 支持字符、双精度浮点、浮点、整数、长整型和短整型原语类型,并通过相同的 char 、 double 、 float 、 int 、 long 和 short 保留字。
  • Java 支持许多相同的运算符,包括算术运算符( + 、 - 、 * 、 / 、 % )和条件运算符(?:)运算符。
  • Java 使用大括号字符( { 和 } )来分隔语句块。

下面列出了 Java 和 C/C++之间的一些差异:

  • Java 支持另一种称为 Javadoc 的注释风格。(我在第二章简单介绍一下 Javadoc。)
  • Java 提供了 C/C++中没有的保留字(扩展、 strictfp 、同步和瞬态就是例子)。
  • Java 不需要特定于机器的知识。支持字节整数类型(见en . Wikipedia . org/wiki/Integer _(computer _ science));不提供字符类型的有符号版本;并且不提供整数、长整数和短整数的无符号版本。此外,所有 Java 的基本类型都有保证的实现大小,这是实现可移植性的重要部分(稍后讨论)。在 C 和 C++中,等价的基本类型就不一样了。
  • Java 提供了 C/C++中没有的运算符。这些运算符包括的实例和 > > > (无符号右移)。
  • Java 提供了 C/C++中没有的带标签的 break 和 continue 语句。

你将在第二章中学习单行和多行注释。此外,在那一章中,您将学习保留字、基本类型、操作符、块和语句(包括标记为 break 和 continue 的语句)。

Java 被设计成比 C/C++更安全的语言。它实现安全性的部分原因是不让您重载操作符,并省略了 C/C++特性,如 指针(包含地址的变量—参见)http://en . Wikipedia . org/wiki/Pointer _(computer _ programming)。

Java 还通过修改某些 C/C++特性来实现安全性。例如,循环必须由布尔表达式控制,而不是由整数表达式控制,其中 0 为假,非零值为真。(在第二章的中有关于循环和表达式的讨论。)

假设您必须编写一个重复不超过 10 次的 C/C++ while 循环。累了,你指定 while(x)x++;(假设 x 是一个初始化为 0 的基于整数的变量——我在第二章中讨论变量)其中 x++ 给 x 的值加 1。当 x 达到 10 时,该循环不停止;你引入了一个错误。

这个问题在 Java 中不太可能出现,因为它在看到 while (x) 时会报错。这个投诉要求你重新检查你的表达,然后你很可能指定 while (x!= 10)。不仅提高了安全性(不能只指定 x ),还明确了含义: while (x!= 10) 比更有意义而(x) 。

这些和其他基本的语言特性支持类、对象、继承、多态和接口。Java 还提供了与嵌套类型、包、静态导入、异常、断言、注释、泛型、枚举等相关的高级特性。后续章节将探讨这些语言的大部分特性。

Java 是一个平台

Java 是一个由虚拟机和执行环境组成的平台。虚拟机是一个基于软件的处理器,提供一个指令集。执行环境由运行程序和与底层操作系统交互的库组成。

执行环境包括一个巨大的预建类文件库,这些类文件执行常见任务,如数学运算(例如,三角学)和网络通信。这个库通常被称为标准类库

一个被称为 Java 编译器的特殊 Java 程序将源代码翻译成由虚拟机执行的指令(和相关数据)。这些指令被称为字节码

编译器将程序的字节码和数据存储在带有的文件中。类扩展。这些文件被称为类文件 ,因为它们通常存储编译后的等价类,这是一个在第三章中讨论的语言特性。

Java 程序通过工具(例如, java )执行,该工具加载并启动虚拟机,并将程序的主类文件传递给机器。虚拟机使用一个类加载器 (一个虚拟机或执行环境组件)来加载类文件。

加载类文件后,虚拟机的字节码验证器组件确保类文件的字节码是有效的,并且不会危及安全性。当验证器发现字节码有问题时,它就终止虚拟机。

假设类文件的字节码一切正常,虚拟机的解释器一次解释一条指令。 解释包括识别字节码指令和执行等价的本机指令。

原生指令(也称原生代码)是底层平台物理处理器理解的指令。

当解释器得知字节码指令序列被重复执行时,它通知虚拟机的即时(JIT)编译器将这些指令编译成本机代码。

对于给定的字节码指令序列,JIT 编译只执行一次。因为执行的是本机指令,而不是相关的字节码指令序列,所以程序执行起来要快得多。

在执行过程中,解释器可能会遇到执行另一个类文件的字节码的请求。当发生这种情况时,它会要求类加载器加载类文件和字节码验证器,以便在执行字节码之前验证字节码。

Java 的平台端通过提供底层平台的抽象来提升 的可移植性。因此,相同的字节码在基于 Windows、基于 Linux、基于 Mac OS X 和其他平台上运行时没有变化。

注意 Java 推出时的口号是“一次编写,随处运行”尽管 Java 不遗余力地加强可移植性(例如,一个整数总是 32 位二进制数字,一个长整数总是 64 位——参见【http://en.wikipedia.org/wiki/Bit】了解二进制数字),但它并不总是成功的。例如,尽管大部分是独立于平台的,Java 的某些部分(例如,线程的调度,在第八章中讨论)在不同的基础平台上是不同的。

Java 的平台端也通过提供一个代码执行的安全环境(例如,字节码验证器)来提升 安全性。目标是防止恶意代码破坏底层平台(并可能窃取敏感信息)。

Java SE ,Java EE ,Java ME ,安卓

开发者使用 Java 平台的不同版本来创建运行在台式计算机、网络浏览器、网络服务器、移动信息设备(例如,功能电话)和嵌入式设备(例如,电视机顶盒)上的 Java 程序:

  • Java 平台,标准版(Java SE):开发应用的 Java 平台,这些应用是运行在桌面上的独立程序。Java SE 也用于开发小程序,这是在网络浏览器中运行的程序。
  • Java 平台, 企业版(Java EE ) :开发面向企业应用的 Java 平台和Servlet,是符合 Java EE 的 Servlet API 的服务器程序。Java EE 构建在 Java SE 之上。
  • Java 平台、 微型版(Java ME ) :开发MIDlets的 Java 平台,midlet 是运行在移动信息设备上的程序,xlet 是运行在嵌入式设备上的程序。

开发人员还使用谷歌创建的 Java 平台的特殊版本(见【http://developer.android.com/index.html】)来创建在支持 Android 的设备上运行的 Android 应用。这个版本被称为 安卓平台

谷歌的 Android 平台展示了一个 Dalvik 虚拟机,它运行在一个特别修改的 Linux 内核之上。一个 Android 应用的 Java 源代码被编译成 Java 类文件,然后被翻译成一个特殊的文件供 Dalvik 执行。

通过维基百科的“Android(操作系统)”词条(en . Wikipedia . org/wiki/Android _(operating _ system))了解更多关于 Android OS 的信息,通过维基百科的“Dalvik(软件)”词条(en . Wikipedia . org/wiki/Dalvik _(软件) )了解关于 dal vik 虚拟机的信息。

在本书中,我将介绍 Java 语言(Java SE 和 Android 支持)以及 Android 也支持的各种 Java SE APIs。我通过 Java 版本 5 关注语言特性,通过 Java 5 关注 Java APIs,还有少量 Java 6。

注意谷歌的 Android 平台是基于 Java 5T3 的开源版本。它没有正式识别比 Java 5 更新的语言特性,尽管有可能增加这种支持(参见 www.informit.com/articles/article.aspx?p=1966024T5)。关于 API,这个平台支持 Java 6 和以前 Java 版本的 API。此外,它还提供了自己独特的 API。

安装和探索 JDK

Java 运行时环境(JRE) 实现了 Java SE 平台,使得运行 Java 程序成为可能。公共 JRE 可以从 Oracle 的 Java SE 下载页面下载(Oracle . com/tech network/Java/javase/Downloads/index . html)。

然而,公共 JRE 并没有使开发 Java 程序成为可能。对于那个任务,你需要下载并安装 Java SE 开发包(JDK ) ,它包含开发工具(包括 Java 编译器)和一个私有的 JRE 。

注意 JDK 1.0 是第一个发布的 JDK(1995 年 5 月)。在 JDK 6 到来之前,JDK 代表 Java 开发工具包(SE 不是这个名称的一部分)。多年来,已经发布了许多 JDK,在撰写本文时,JDK 7 是最新的。

每个 JDK 版本号标识一个 Java 版本。比如 JDK 1.0 识别 Java 1.0,JDK 5 识别 Java 5.0。JDK 5 是第一个也提供内部版本号 1.5.0 的 JDK。

谷歌不提供 JDK。它确实提供了类似于 JRE 的功能,但是有一个 Android 焦点。

Java SE 下载页面还提供了对当前 JDK 的访问,在撰写本文时是 JDK 7 Update 9。点击下载 JDK 链接(在页面上的 处 http://Oracle . com/tech network/Java/javase/downloads/index . html)下载适用于您平台的最新 JDK 安装程序。

JDK 安装程序将 JDK 放在主目录中。(它也可以将公共 JRE 安装在另一个目录中。)在我的 Windows 7 平台上,主目录是 C:\ Program Files \ Java \ JDK 1 . 7 . 0 _ 06。(我目前使用的是 JDK 7 更新 6。)

提示安装完 JDK 后,你应该将 bin 子目录添加到你平台的 PATH 环境变量中(参见java.com/en/download/help/path.xml),这样你就可以从任何目录执行 JDK 工具了。另外,您可能希望在 JDK 的主目录下创建一个项目子目录来组织您的 Java 项目,并在项目中为每个项目创建一个单独的子目录。

主目录包含各种文件(例如,README.html,提供关于 JDK 的信息, src.zip ,提供标准类库源代码)和子目录,包括以下三个重要的子目录:

  • bin :该子目录包含各种 JDK 工具。在本书中,您将只使用其中的几个工具,主要是 javac (Java 编译器)、 java (Java 应用启动器)、 jar (Java 归档创建器、更新器和提取器),以及 javadoc (Java 文档生成器)。
  • jre :这个子目录包含了 JDK 的 jre 的私有副本,可以让你运行 Java 程序,而不必下载并安装公共的 JRE。
  • lib :这个子目录包含 JDK 工具使用的库文件。例如, tools.jar 包含 Java 编译器的类文件——编译器是用 Java 编写的。

注意 javac 不是 Java 编译器。它是一个加载并启动虚拟机的工具,将编译器的主类文件(位于 tools.jar 中)标识给虚拟机,并将正在编译的源文件的名称传递给编译器的主类文件。

您在命令行执行 JDK 工具,将命令行参数传递给工具。您可以通过维基百科的“命令行界面”条目()了解命令行和参数。

现在您已经安装了 JDK,并对其工具有所了解,您已经准备好探索一个小型的 DumpArgs 应用,它将其命令行参数输出到标准输出流。

注意标准输出流是标准 I/O(??【http://en.wikipedia.org/wiki/Standard_streams)的一部分,它也由标准输入和标准错误流组成,起源于 Unix 操作系统。标准 I/O 使得从不同来源(键盘或文件)读取文本和将文本写入不同目的地(屏幕或文件)成为可能。

文本从标准输入流中读取,标准输入流默认为键盘,但可以重定向到文件。文本被写入标准输出流,该输出流默认显示在屏幕上,但可以重定向到一个文件。错误消息文本被写入标准错误流,该错误流默认显示在屏幕上,但可以重定向到不同于标准输出文件的文件。

清单 1-1 展示了 DumpArgs 应用源代码。

清单 1-1 。通过 main()的 args 数组将命令行参数转储到标准输出流中

public class DumpArgs
{
   public static void main (String[] args )
   {
      System.out.println("Passed arguments:");
      for (int i = 0; i < args .length; i++)
         System.out.println(args[i]);
   }
}

清单 1-1 的 DumpArgs 应用由一个名为 DumpArgs 的类和该类中一个名为 main() 的方法组成,该方法是应用的入口点并提供要执行的代码。(你会在第三章中了解到类和方法。)

main() 方法包括一个标识该方法的头和一段位于左大括号( { )和右大括号( } )之间的代码。除了命名此方法之外,标头还提供了以下信息:

  • public :这个保留字使得 main() 对调用这个方法的启动代码可见。如果 public 不存在,编译器会输出一条错误消息,指出它找不到 main() 方法。(我在第二章讨论保留字。)
  • 静态:这个保留字使这个方法与这个类相关联,而不是与从这个类创建的任何对象相关联(在第三章中讨论)。因为调用 main() 的启动代码没有从类中创建一个对象来调用这个方法,所以它要求这个方法被声明为 static 。虽然当 static 丢失时编译器不会报错,但是运行 DumpArgs 将是不可能的,当正确的 main() 方法不存在时,它将不是一个应用。
  • void :这个保留字表示该方法不返回值。如果你把 void 改成一个类型的保留字(如 int ,然后插入一个返回这个类型的值的语句(如 return 0;),编译器不会报错。然而,您将无法运行 DumpArgs ,因为正确的 main() 方法将不存在。(我在第二章中讨论类型。)
  • (String[] args) :该参数列表由一个名为 args 的参数组成,类型为 String[] 。启动代码将一系列命令行参数传递给 args ,这使得这些参数可供在 main() 中执行的代码使用。您将在第三章中了解参数和自变量。

main() 用一组字符串(字符序列)调用,这些字符串标识应用的命令行参数。这些字符串存储在基于字符串的数组变量 args 中。(我在第二章和第三章中讨论了方法调用、数组和变量。)虽然数组变量命名为 args ,但是这个名字并没有什么特别之处。您可以为此变量选择另一个名称。

该代码块首先执行 System.out.println("传递的参数:");,用传递的参数:字符串调用 System.out 的 println() 方法。该字符串被写入标准输出流。

从左起写, System 标识系统工具的标准类别; out 标识位于系统中的一个对象变量,它的方法让你可以输出各种类型的值,后面可选地跟一个换行符到标准输出流;println 标识一个方法,该方法将其参数和一个换行符一起输出到标准输出中;和传递的参数:是一个字符串(一个由双引号字符分隔的字符序列,被视为一个单元),它作为参数传递给 println 并写入标准输出(起始 " 和结束 " 双引号字符未被写入);这些字符分隔但不是字符串的一部分)。

注意 System.out 提供对一系列 println() 方法和一系列 print() 方法的访问,用于输出不同种类的数据(例如,字符和整数序列)。与 println() 方法不同, print() 方法不终止当前行;后续输出在当前行继续。

每个 println() 方法通过输出行分隔符字符串来终止一行,该字符串由系统属性 line.separator 定义,并且不一定是单个换行符(在源代码中通过字符文字 '\n' )来标识)。(我在第八章中讨论系统属性,在第十一章中讨论行分隔符,在第二章中讨论字符文字。)例如,在 Windows 平台上,行分隔符字符串是一个回车符(其整数代码为 13),后跟一个换行符(其整数代码为 10)。

代码块接下来使用 for 循环重复执行 system . out . println(args[I]);。循环执行 args.length 次,或者对存储在 args 中的每个字符串执行一次。(我讨论 for 循环和。第二章中的长度。)

system . out . println(args[I]);方法调用读取存储在 args 数组的第 i 个条目中的字符串——第一个条目位于索引(位置)0;最后一个条目存储在索引 args.length - 1 处。这个方法调用然后将这个字符串输出到标准输出。

假设您熟悉您的平台的命令行界面,并且在命令行中,将 DumpArgs 作为您的当前目录,并将清单 1-1 复制到一个名为【DumpArgs.java】的文件。然后通过下面的命令行编译这个源文件:

javac DumpArgs.java

假设您已经包括了。java 扩展名,这是 javac 所需要的,DumpArgs.java 编译的,你应该会在当前目录下发现一个名为 DumpArgs.class 的文件。通过以下命令行运行该应用:

java DumpArgs

如果一切顺利,您应该会在屏幕上看到以下输出行:

Passed arguments:

为了获得更有趣的输出,您需要将命令行参数传递给 DumpArgs 。例如,执行下面的命令行,它指定卷毛、莫伊和拉里作为传递给 DumpArgs 的三个参数:

java DumpArgs Curly Moe Larry

这一次,您应该在屏幕上看到以下扩展输出:

Passed arguments:
Curly
Moe
Larry

您可以通过指定后跟文件名的大于号尖括号( > )将输出目标重定向到文件。例如,Java DumpArgs Curly Moe Larry>out . txt 将 DumpArgs 应用的输出存储在一个名为 out.txt 的文件中。

注意不要指定 System.out.println() ,可以指定 System.err.println() 将字符输出到标准错误流。( System.err 提供了与 System.out 相同系列的 println() 和 print() 方法。)但是,即使标准输出被重定向到一个文件,当您需要输出一个错误消息以便错误消息显示在屏幕上时,您也应该只从 System.out 切换到 System.err 。

祝贺您成功编译了您的第一个应用源文件并运行了该应用!清单 1-2 将源代码呈现给第二个应用,它将从标准输入流获得的文本回显到标准输出流。

清单 1-2 。将从标准输入读取的文本回显到标准输出

public class EchoText
{
   public static void main(String[] args) throws java.io.IOException
   {
      System.out.println("Please enter some text and press Enter!");
      int ch;
      while ((ch = System.in.read() ) != −1)
         System.out.print((char) ch);
      System.out.println();
   }
}

在输出一条提示用户输入一些文本的消息后, main() 引入了 int 变量 ch 来存储每个字符的整数表示。(你会在第二章中了解到 int 和 integer。)

main() 现在进入 while 循环(在第二章中讨论)来读取和回显字符。循环首先调用 System.in.read() 读取一个字符,并将其整数值赋给 ch 。当该值等于 1 时(不再有输入数据可用),环路结束。

注意当标准输入被重定向到一个文件时, System.in.read() 从文件中读取每个字符(该文件随后被转换成一个整数),直到不再有字符需要读取。此时,该方法返回 1。然而,当标准输入没有被重定向时,循环不会结束,因为永远看不到 1。在这种情况下,一行文本的结尾由回车符(整数值 13)后跟一个换行符(整数值 10)来表示(在 Windows 平台上)。确切的终止顺序取决于平台。您必须在 Windows 上同时按下 Ctrl 和 C 键(或者在非 Windows 平台上的等效键)来终止循环。

对于 ch 中的任何其他值,这个值通过 (char) 转换成一个字符,这是 Java 的 cast 运算符的一个例子(在第二章中讨论)。然后通过 System.out.print() 输出字符,这也不会终止当前行。最后的 system . out . println(); call 终止当前行,不输出任何内容。

注意当标准输入被重定向到一个文件,而 System.in.read() 无法从该文件中读取文本时(可能该文件存储在一个可移动存储设备上,而该设备在读取操作之前已经被移除), System.in.read() 失败,抛出一个描述该问题的对象。我通过在 main() 方法头的末尾添加 throws Java . io . io exception 来承认这种可能性。我在第五章的中讨论抛出,在第十一章的中讨论 java.io.IOException 。

通过 javac EchoText.java 编译清单 1-2 ,并通过 java EchoText 运行应用。系统会提示您输入一些文本。输入此文本并按 Enter 后,文本将被发送到标准输出。例如,考虑以下输出:

Please enter some text and press Enter!
Hello Java
Hello Java

通过指定小于尖括号( < )后跟文件名,可以将输入源重定向到文件。例如,Java echo text<EchoText.java 从 EchoText.java 读取文本,并将文本输出到屏幕上。

ANDROID 应用入口点

DumpArgs 和 EchoText 应用演示了 public static void main(String[]args)作为 Java 应用的入口点。这是应用开始执行的地方。相比之下,Android 应用不需要这个方法作为其入口点,因为应用的架构非常不同。

Android 应用基于交互组件的联盟,这些组件被称为活动、服务、广播接收器和内容供应器。活动提供用户界面屏幕,服务支持后台处理,广播接收器响应系统范围的广播,内容供应器提供便携式数据访问。

考虑活动。该组件被实现为一个类,该类从 Android 的 android.app.Activity 类继承生命周期方法,并有机会覆盖它们。(我在第三章的中讨论方法,在第四章的中讨论继承和重写。)例如,它可以覆盖 void onCreate(Bundle savedInstanceState)方法,以便在 Android 调用该方法时构造用户界面屏幕。

在这本书里,我用 public static void main(String[]args)方法呈现 Java 应用。我这样做是因为这本书的重点是学习 Java,作为进入 Android 应用开发的准备步骤。

除了下载和安装 JDK,您还需要访问 JDK 文档,特别是为了探索 Java APIs。您可以浏览两组文档:

  • 甲骨文的 JDK 7 文档()
  • 谷歌的 Java API 文档(developer.android.com/reference/packages.html)

Oracle 的 JDK 7 文档提供了许多 Android 不支持的 API。此外,它没有涵盖特定于 Android 的 API。这本书只关注 Google 文档中包含的 Java APIs。

安装和探索 Eclipse IDE

对于小项目来说,在命令行中使用 JDK 工具可能没问题。但是,对于大型项目,不推荐这种做法,因为没有 IDE 的帮助,大型项目很难管理。

IDE 由用于管理项目文件的项目管理器、用于输入和编辑源代码的文本编辑器、用于定位错误的调试器以及其他功能组成。Eclipse 是 Google 支持的用于开发 Android 应用的流行 IDE。

注意为了方便起见,除了我讨论和演示 Eclipse IDE 的这一节之外,我在整本书中都使用了 JDK 工具。

Eclipse IDE 是一个开源 IDE,用于开发 Java 和其他语言的程序(如 C、COBOL、PHP、Perl 和 Python)。Eclipse Classic 是这个 IDE 的一个发行版,可以下载;4.2.1 版是撰写本文时的最新版本。

您应该下载并安装 Eclipse Classic,以遵循本节的面向 Eclipse 的示例。首先将浏览器指向,并完成以下任务:

  1. 向下滚动页面,直到看到一个 Eclipse Classic 条目。(可能指的是 4.2.1 或者更新的版本。)
  2. 单击此项右侧的一个平台链接(例如,Windows 32 位)。
  3. 从随后显示的页面中选择一个下载镜像,并继续下载发行版的归档文件。

我为我的 Windows 7 平台下载了大约 183 MB 的 eclipse-SDK-4 . 2 . 1-win32-x86 _ 64 . zip 归档文件,解压缩这个文件,将生成的 eclipse 主目录移动到另一个位置,并创建了该目录的【eclipse.exe】文件的快捷方式。

安装完 Eclipse Classic 之后,运行这个应用。您应该会发现一个标识该 IDE 的闪屏和一个让您选择存储项目的工作区位置的对话框,然后是一个主窗口,如图图 1-1 所示。

9781430257226_Fig01-01.jpg

图 1-1 保留默认工作空间或选择另一个工作空间

单击 OK 按钮,您将被带到 Eclipse 的主窗口。参见图 1-2 。

9781430257226_Fig01-02.jpg

图 1-2 主窗口最初显示一个欢迎选项卡

主窗口最初显示一个欢迎选项卡,从中可以了解关于 Eclipse 的更多信息。单击该选项卡的 X 图标关闭该选项卡;您可以通过从菜单栏的帮助菜单中选择欢迎来恢复欢迎选项卡。

Eclipse 用户界面基于一个主窗口,该窗口由菜单栏、工具栏、工作台区域和状态栏组成。工作台提供了用于组织 Eclipse 项目、编辑源文件、查看消息等的窗口。

为了帮助您熟悉 Eclipse 用户界面,我将向您展示如何创建一个 DumpArgs 项目,其中包含一个带有清单 1-1 源代码的 DumpArgs.java 源文件。您还将学习如何编译和运行这个应用。

完成以下步骤来创建 DumpArgs 项目:

  1. 从“文件”菜单中选择“新建”,从出现的弹出菜单中选择“Java 项目”。
  2. 在生成的新 Java 项目对话框中,在项目名称文本字段中输入 DumpArgs 。保留所有其他默认值,然后单击 Finish 按钮。

第二步之后,你会看到一个类似于图 1-3 所示的工作台。

9781430257226_Fig01-03.jpg

图 1-3 一个 DumpArgs 条目出现在工作台的包浏览器中

在工作台的左侧,您会看到一个名为 Package Explorer 的窗口。该窗口以包的形式标识工作区的项目(在第五章中讨论)。目前,只有一个 DumpArgs 条目出现在该窗口中。

单击 DumpArgs 左侧的三角形图标可以展开此项,显示 src 和 JRE 系统库项目。src 项存储了 DumpArgs 项目的源文件,JRE 系统库标识了用于运行该应用的各种 JRE 文件。

现在,您将向 src 添加一个名为 DumpArgs.java 的新文件,如下所示:

  1. 突出显示 src,并从“文件”菜单中选择“新建”,然后从弹出菜单中选择“文件”。
  2. 在出现的新文件对话框中,在文件名文本字段中输入 DumpArgs.java,并点击完成按钮。

Eclipse 通过显示一个名为 DumpArgs.java 的编辑器窗口做出响应。将清单 1-1 的内容复制到该窗口。然后通过从 run 菜单中选择 Run 来编译并运行这个应用。(如果看到“保存并启动”对话框,请单击“确定”关闭该对话框。)图 1-4 显示了结果。

9781430257226_Fig01-04.jpg

图 1-4 。工作台底部的 Console 选项卡显示 DumpArgs 应用的输出

您必须向 DumpArgs 传递命令行参数,以查看该应用的附加输出。按如下方式完成此任务:

  1. 从运行菜单中选择运行配置。
  2. 在产生的“运行配置”对话框中,选择“参数”选项卡。
  3. 在程序参数文本区输入卷毛莫拉里 并点击关闭按钮。

再次从 Run 菜单中选择 Run 来运行 DumpArgs 应用。这一次,控制台选项卡在“传递的参数:”下面的单独行中显示 Curly、Moe 和 Larry。

这就是我对 Eclipse IDE 要说的全部内容。有关更多信息,请通过“欢迎”选项卡学习教程,通过“帮助”菜单访问 IDE 帮助,并在www.eclipse.org/documentation/浏览 Eclipse 文档。

Java APIs 概述

Oracle 将其标准类库 API 组织成包(见第五章),类似于文件夹。同样,Google 将其面向 Android 的标准类库 API 组织成包。在这一节中,我将概述 Oracle 和 Google 通用的各种 Java APIs。此外,我(在本书中)只讨论那些位于两个库中的 API。通过将我的讨论限制在通用 API,我避免了讨论在创建 Android 应用时不能使用的 Java APIs。

语言支持和其他面向语言的 API

Java 依靠几个 API 来支持基本的语言特性,比如字符串(见第七章)、异常(见第五章)和线程(见第八章)。例如, java.lang 包提供了支持字符串的 String 类,支持异常的 Throwable 类,以及支持线程的 Thread 类和 Runnable 接口。

Java 也提供 API 来完成面向语言的任务。例如, java.lang 提供了一个 StringBuffer 类(参见第七章)用于创建可变字符串,一个 Math 类(参见第七章)用于执行三角函数和其他基本数学运算,一个 Package 类(参见第七章)用于获取面向包的信息。

面向集合的 API

Java 的设计者开发了一个强大的 集合框架来组织对象(见第九章)。这个框架位于 java.util 包中,它基于接口,并允许您将对象存储在列表、队列、集合(排序或未排序)和映射(排序或未排序)中。这些接口与各种实现类相关联(例如, ArrayList )。

集合框架还提供了集合和数组类。这些工具类(由静态 [class]方法组成的类)提供了对集合和数组执行常见操作的各种方法。例如,收藏让您方便地搜索或排序收藏;并且数组可以让你方便地搜索、排序、复制或填充一个数组。

其他工具 API

Java 的设计者还开发了一个强大的 并发工具框架,它提供了低级线程的高级替代方案(见第十章)。这个框架的 API 被组织成 java.util.concurrent 、Java . util . concurrent . atomic 和 Java . util . concurrent . locks 包。第一个包中 API 的例子包括 Executor 接口和 CyclicBarrier 类。

位于 java.util 包中的其他工具 API 包括用于处理日期的 Date 类、用于格式化数据项(例如整数和字符串)的 Formatter 类、用于实现复杂随机数生成的 Random 类,以及用于将输入字符流解析为整数、字符串和其他值的 Scanner 类。我在第十章中讨论了这些 API。

最后, java.util.zip 包提供了从现有 zip 存档中提取信息和创建新 ZIP 存档的能力(参见第十章)。此外,相关的 java.util.jar 包通过提供 jar 文件所需的额外功能扩展了 java.util.zip ,具体来说,就是从 JAR 文件的清单中读取属性和向其中写入属性(参见第十章)。

经典输入/输出应用编程接口

输入和输出信息的能力对 Java 来说一直很重要。你已经发现了标准的 I/O,但是在第十一章中还有更多要探索的。例如, java.io 包提供了用于执行面向文件的操作(例如,列出一个目录的文件)的文件类,还提供了用于执行 I/O(通常涉及文件)的流/写/读类。

网络应用编程接口

尽管许多 I/O 发生在文件系统的环境中,Java 也提供了通过位于其 java.net 包中的各种类型在网络上执行 I/O 的能力(参见第十二章)。例如,您可以使用套接字和服务器套接字类来创建网络通信链接的客户端和服务器端。

套接字提供了一种通过网络进行通信的底层方法。在某些情况下,您将使用更高级的 URL 类通过 web 进行通信,也许是为了获得一个 Web 页面。当你与网络交互时,你会遇到可以通过额外的 java.net 接口和类来管理的 cookies,比如 CookiePolicy 和 CookieManager。

新的 I/O API

现代操作系统引入了复杂的 I/O 机制,如内存映射文件和就绪选择。Java 通过缓冲区、通道、选择器以及在 java.nio 和相关包中找到的相关类型来支持这些新的 I/O 机制。另外, java.util.regex 通过提供高性能的字符串操作来支持新的 I/O。参见第十三章。

数据库 API

数据库存储信息,关系数据库将这些信息存储在表中,这些表可以通过特殊的键列相互关联。Java 通过 java.sql 和 javax.sql 包支持数据库访问。前一个包包含了 DriverManager 和 ResultSet 等类和接口;后一个包提供了数据源、行集等等。参见第十四章中的。

练习

以下练习旨在测试您对第一章内容的理解:

  1. Java 是什么?
  2. 什么是虚拟机?
  3. Java 编译器的目的是什么?
  4. 是非判断:类文件的指令通常被称为字节码。
  5. 当虚拟机的解释器得知一个字节码指令序列被重复执行时,它会做什么?
  6. Java 平台如何促进可移植性?
  7. Java 平台如何提升安全性?
  8. 是非判断:Java SE 是用于开发 servlets 的 Java 平台。
  9. 什么是 JRE?
  10. 公有和私有 JREs 有什么区别?
  11. 什么是 JDK?
  12. 用哪个 JDK 工具编译 Java 源代码?
  13. 哪个 JDK 工具用于运行 Java 应用?
  14. 什么是标准输入输出?
  15. 如何指定 main() 方法的头?
  16. 什么是 IDE?确定 Google 支持开发 Android 应用的 IDE。

摘要

Java 是一种语言,也是一个平台。该语言部分模仿 C 和 C++语言,以缩短 C/C++开发人员的学习曲线。该平台由一个虚拟机和相关的执行环境组成。

开发人员使用不同版本的 Java 平台来创建运行在桌面计算机、web 浏览器、web 服务器、移动信息设备和嵌入式设备上的 Java 程序。这些版本被称为 Java SE、Java EE 和 Java ME。

开发人员还使用谷歌创建的 Java 平台的特殊版本来创建在支持 Android 的设备上运行的 Android 应用。这个版本被称为 Android 平台,它展示了一个 Dalvik 虚拟机,运行在一个经过特殊修改的 Linux 内核之上。

公共 JRE 实现了 Java SE 平台,并使运行 Java 程序成为可能。JDK 提供了开发 Java 程序的工具(包括 Java 编译器),还包括 JRE 的私有副本。

对于大型项目,不推荐在命令行使用 JDK 的工具,如果没有集成开发环境的帮助,大型项目很难管理。Eclipse 是 Google 支持的用于开发 Android 应用的流行 IDE。

Oracle 将其标准类库 API 组织成包,类似于文件夹。同样,Google 将其面向 Android 的标准类库 API 组织成包。在这一章中,我还概述了一些打包的 API。

第二章通过关注 Java 语言的基础知识,开始向您介绍 Java 语言。您将了解注释、标识符、类型、变量、表达式、语句等等。

二、学习语言基础

有抱负的 Android 应用开发人员需要了解 Java 语言,Java 语言用于表达应用的源代码。在第二章第一节,我开始通过关注这门语言的基础来介绍它。具体来说,您将了解注释、标识符(和保留字)、类型、变量、表达式(和文字)和语句。

注意美国信息交换标准码(ASCII) 传统上被用来编码程序的源代码。因为 ASCII 仅限于英语,所以开发了 Unicode(【http://unicode.org/】)作为替代。 Unicode 是一种计算行业标准,用于一致地编码、表示和处理世界上大多数书写系统中表达的文本。因为 Java 支持 Unicode,所以非面向英语的符号可以集成到 Java 源代码中或从 Java 源代码中访问。你会在这一章看到例子。

学习评论

源代码需要被文档化,以便你(和任何其他必须维护它的人)现在和以后都能理解它。源代码应该在编写和修改的时候被记录下来。如果这些修改影响现有文档,则必须更新文档,以便准确解释代码。

Java 提供了在源代码中嵌入文档的注释特性。编译源代码时,Java 编译器会忽略所有注释——不生成字节码。支持单行、多行和 Javadoc 注释。

单行注释

一个单行注释 占用一行源代码的全部或部分。该注释以 // 字符序列开始,并以解释文本继续。编译器忽略从 // 到出现 // 的行尾的所有内容。以下示例显示了单行注释:

System.out.println(Math.sqrt(10 * 10 + 20 * 20)); // Output distance from (0, 0) to (10, 20).

此示例计算笛卡尔 x/y 平面中(0,0)原点和点(10,20)之间的距离。对于这个任务,它使用公式距离=平方根(xx+yy) ,其中 x 是 10, y 是 20。Java 提供了一个 Math 类,其 sqrt() 方法 返回其单个数值参数的平方根。(我在第七章的中讨论数学,在第三章的中讨论论点。)

注意单行注释对于在代码中插入简短但有意义的源代码解释非常有用。不要用它们来插入无用的信息。比如在声明一个变量的时候,不要插入一个无意义的注释比如 //这个变量存储的是整数值。

多行注释

一个多行注释 占用一行或多行源代码。该注释以 /* 字符序列开始,以说明性文本继续,以 / 字符序列结束。编译器会忽略从 / 到 */ 的所有内容。以下示例演示了多行注释:

/*
   A year is a leap year if it is divisible by 400, or divisible by 4 and
   not also divisible by 100.
*/
return (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0));

这个例子引入了一个 return 语句(在第三章的中讨论过)来确定一年(存储在一个名为 year 的变量中);我将在本章后面讨论变量)是否是闰年。这段代码中需要掌握的重要部分是多行注释,它阐明了决定年的值是否代表闰年的表达式(稍后讨论)。

注意不能将一个多行注释放在另一个多行注释中。例如, //嵌套多行注释是非法的!// 不是有效的多行注释。

Javadoc 注释

一个 Javadoc 注释 占用一行或多行源代码。该注释以 /** 字符序列开始,以说明性文本继续,以 / 字符序列结束。编译器会忽略从 /* 到 */ 的所有内容。以下示例演示了 Javadoc 注释:

/**
 *  Application entry point
 *
 *  @param args array of command-line arguments passed to this method
 */
public static void main(String[] args)
{
   // TODO code application logic here
}

这个例子以一个 Javadoc 注释开始,描述了 main() 方法 ,我在第一章中讨论过。夹在 /** 和 */ 之间的是方法的描述和 @param Javadoc 标签(一个@-Javadoc 工具的前缀指令)。

下表列出了几种常用的标签:

  • @author 标识源代码的作者。
  • @deprecated 标识不应再使用的源代码实体(如方法)。
  • @param 标识方法的一个参数。
  • @see 提供了另见参考。
  • @因为标识了实体最初起源的软件版本。
  • @return 标识该方法返回的值的种类。
  • @throws 记录从方法中抛出的异常。我在第五章中讨论了异常。

清单 2-1 展示了第一章的 DumpArgs 应用源代码,以及描述 DumpArgs 类及其 main() 方法的 Javadoc 注释。

清单 2-1 。记录应用类及其 main()方法

/**
   Dump all command-line arguments to standard output.

   @author Jeff Friesen
*/

public class DumpArgs
{
   /**
      Application entry point.

      @param args array of command-line arguments.
   */

   public static void main(String[] args)
   {
      System.out.println("Passed arguments:");
      for (int i = 0; i < args.length; i++)
         System.out.println(args[i]);
   }
}

您可以使用 JDK 的 javadoc 工具将这些文档注释提取到一组 HTML 文件中,如下所示:

javadoc DumpArgs.java

javadoc 通过输出以下消息进行响应:

Loading source file DumpArgs.java...
Constructing Javadoc information...
Standard Doclet version 1.7.0_06
Building tree for all the packages and classes...
Generating \DumpArgs.html...
Generating \package-frame.html...
Generating \package-summary.html...
Generating \package-tree.html...
Generating \constant-values.html...
Building index for all the packages and classes...
Generating \overview-tree.html...
Generating \index-all.html...
Generating \deprecated-list.html...
Building index for all classes...
Generating \allclasses-frame.html...
Generating \allclasses-noframe.html...
Generating \index.html...
Generating \help-doc.html...

它还生成几个文件,包括 index.html 文档入口点文件。将你的浏览器指向这个文件,你应该会看到一个类似于图 2-1 所示的页面。

9781430257226_Fig02-01.jpg

图 2-1 。DumpArgs 文档的入口点页面描述了这个类

注意 附录 B 提供了另一个(更广泛的)例子,涉及 Javadoc 注释和 javadoc 工具。

学习标识符

类和方法等源代码实体需要命名,以便可以从代码中的其他地方引用它们。Java 为此提供了标识符特性。

一个标识符由字母(A-Z,A-Z,或其他人类字母表中的等价大写/小写字母)、数字(0-9 或其他人类字母表中的等价数字)、连接标点符号(如下划线)和货币符号(如美元符号$)组成。该名称必须以字母、货币符号或连接标点符号开头。并且它的长度不能超过它所在的行。

有效标识符的例子包括π(一些编辑可能对这种符号有问题)、 i 、计数器、 j2 、 first$name、和 _for 。无效标识符的示例包括 1name (以数字开头)和 first#name ( # 不是有效的标识符符号)。

注意 Java 是一种区分大小写的语言,这意味着只有大小写不同的标识符被认为是单独的标识符。例如,温度和温度是独立的标识符。

几乎可以选择任何有效的标识符来命名类、方法或其他源代码实体。然而,一些标识符是为特殊目的而保留的;它们被称为保留字。Java 保留了以下标识符:抽象,断言,布尔,断符,字节,大小写,捕捉,字符,类,常量,继续,默认,做 最后,浮动,为,转,如果,实现,导入,实例化, int ,接口,长,原生, 静态,严格,超,切换,同步,本,投,投,瞬变,真,试, 当您试图在使用上下文之外使用这些保留字时,编译器会输出一条错误消息。

Java 的大部分保留字也被称为关键词。三个例外是假、空和真,它们是字面量(逐字指定的值)的示例。

学习类型

应用处理不同类型的值,如整数、浮点值、字符和字符串。类型标识一组值(以及它们在内存中的表示)和一组将这些值转换成该组中其他值的操作。例如,整数类型标识没有小数部分和面向整数的数学运算的数值,例如将两个整数相加得到另一个整数。

注意 Java 是一种强类型语言,这意味着每一个表达式、变量等等都有一个编译器已知的类型。这种能力有助于编译器在编译时检测与类型相关的错误,而不是让这些错误在运行时显示出来。表达式和变量将在本章后面讨论。

Java 将类型分为基本类型类型、用户定义类型和数组类型。

原始类型

原始类型 是由语言定义的类型,其值不是对象。Java 支持布尔、字符、字节整数、短整数、整数、长整数、浮点和双精度浮点原语类型。在表 2-1 中对它们进行了描述。

表 2-1 。原始类型

image

表 2-1 描述了每个原语类型的保留字、大小、最小值和最大值。“-”条目表示它所在的列不适用于该条目的行中描述的基元类型。

size 列根据该类型的值在内存中所占的(二进制数字—每个数字为 0 或 1)的数量来标识每个原始类型的大小。除了 Boolean(其大小取决于实现——一个 Java 实现可能用一位存储一个布尔值,而另一个实现为了提高性能可能需要一个 8 位的字节),每个原语类型的实现都有一个特定的大小。

最小值和最大值列标识每种类型可以表示的最小和最大值。除了 Boolean(其值只有 true 和 false)之外,每个基本类型都有一个最小值和一个最大值。

字符类型的最小值和最大值指的是 Unicode。 Unicode 0 是“第一个 Unicode 码位”的简写——码位是一个整数,表示一个符号(比如 A)或一个控制字符(比如 newline 或 tab)或者与其他码位组合形成一个符号。

注意字符类型的限制意味着该类型是无符号的(所有字符值都是正的)。相比之下,每个数值类型都有符号(它支持正值和负值)。

字节整数、短整数、整数和长整数类型的最小值和最大值表明负值比正值多一个(0 通常不被视为正值)。这种不平衡的原因与整数的表示方式有关。

Java 将一个整数值表示为一个符号位(最左边的位—0 表示正值,1 表示负值)和幅度位(符号位右边的所有剩余位)的组合。当符号位为 0 时,幅度直接存储。然而,当符号位为 1 时,幅度使用二进制补码表示法存储,其中所有 1 翻转为 0,所有 0 翻转为 1,并且减号后面的数字加 1。使用二进制补码是为了让负整数可以自然地与正整数共存。例如,将 1 的表示与+1 相加得到 0。图 2-2 显示了字节整数 2 的直接表示和字节整数 2 的二进制补码表示。

9781430257226_Fig02-02.jpg

图 2-2 两个字节整数值的二进制表示以符号位开始

浮点和双精度浮点类型的最小值和最大值参考电气和电子工程师协会( IEEE) 754 ,这是一个在内存中表示浮点值的标准。查看维基百科的“IEEE 754-2008”条目(【http://en.wikipedia.org/wiki/IEEE_754】)来了解关于这个标准的更多信息。

那些认为 Java 应该只支持对象的开发人员对于在语言中包含原始类型并不满意。然而,Java 被设计成包括基本类型,以克服 20 世纪 90 年代早期设备的速度和内存限制,这也是 Java 最初的目标。

用户定义的类型

一个用户定义的类型 是一个经常被用来模拟现实世界概念的类型(例如,一种颜色或一个银行账户)。它由开发人员使用类、接口、枚举或注释类型来定义;它的值是对象。(我在第三章讨论类,在第四章讨论接口,在第六章讨论枚举和注释类型。)

例如,您可以创建一个 Color 类来模拟颜色;它的值可以将颜色描述为红/绿/蓝分量值。还有,Java 的 String 类定义了字符串自定义类型;它的值描述字符串,它的方法执行各种字符串操作,比如将两个字符串连接在一起。(我在第三章中讨论方法。)

用户定义的类型也被称为引用类型,因为该类型的变量存储了对存储该类型对象的内存区域的引用(内存地址或其他标识符)。相反,基本类型的变量直接存储值;它们不存储对这些值的引用。

数组类型

一个数组类型 是一个特殊的引用类型,表示一个数组,一个在大小相等且连续的槽中存储值的内存区域,通常被称为元素

这种类型由元素类型(原始类型或用户定义的类型)和一对或多对方括号组成,这些方括号表示维度(范围)的数量。一对括号表示一个一维数组(一个向量),两对括号表示一个二维数组(一个表),三对括号表示一个二维数组的一维数组(一个表的向量),以此类推。例如, int[] 表示一维数组(元素类型为 int ),而 double[][] 表示二维数组(元素类型为 double )。

学习变量

应用操作存储在内存中的值,这些值通过使用变量特性在源代码中象征性地表示出来。一个变量是一个存储某种类型值的命名内存位置。存储引用的变量通常被称为引用变量

变量必须在使用前声明。一个声明至少包含一个类型名,可选地后跟一系列方括号对,后跟一个名称,可选地后跟一系列方括号对,并以分号()结束;)。考虑下面的例子:

int counter;
double temperature;
String firstName;
int[] ages;
char gradeLetters[];
float[][] matrix;
double π;

第一个示例声明一个名为 counter 的整数变量,第二个示例声明一个名为 temperature 的(双精度浮点类型)变量,第三个示例声明一个名为 firstName 的字符串变量,第四个示例声明一个名为 ages 的一维整数数组变量,第五个示例声明一个名为 gradeLetters 的一维字符数组变量,第六个示例声明一个名为 matrix 的二维浮点数组变量,以及还没有字符串与名字关联,也没有数组与年龄、年级字母和矩阵关联。

注意方括号可以出现在类型名之后,也可以出现在变量名之后,但不能同时出现在这两个地方。比如编译器遇到 int[] x[],就报错;。通常的做法是在类型名后面加上方括号(如 int[]ages;)而不是变量名后(如 char grade letters[];),除非数组是在上下文中声明的,比如 int x,y[],z;。

您可以在一行中声明多个变量,方法是用逗号将每个变量与其前一个变量分隔开,如以下示例所示:

int x, y[], z;

这个例子声明了三个名为 x 、 y 和 z 的变量。每个变量共享相同的类型,恰好是整数。与存储单个整数值的 x 和 z 不同, y[] 表示元素类型为 integer 的一维数组——每个元素存储一个整数值。还没有数组与 y 相关联。

当数组与其他变量声明在同一行时,方括号必须出现在变量名之后。如果你把方括号放在变量名前,比如 int x,[]y,z;,编译器报错。如果将方括号放在类型名之后,如 int[] x,y,z;,这三个变量都表示一维整数数组。

学习表达

先前声明的变量没有显式初始化为任何值。因此,根据它们出现的上下文(在类内声明或在方法内声明),它们要么被初始化为默认值(比如 0 代表 int ,0.0 代表 double ),要么保持未初始化状态。在第三章中,我从字段、局部变量和参数的角度讨论了变量上下文。

Java 为初始化变量和其他目的提供了表达式特性。一个表达式是文字、变量名、方法调用和操作符的组合。在运行时,它计算出一个值,该值的类型称为表达式的类型。如果表达式被赋值给一个变量,表达式的类型必须与变量的类型一致;否则,编译器会报告错误。

Java 将表达式分为简单表达式和复合表达式。

简单表达

一个简单表达式 是一个文字(一个一字不差表达的值),一个变量的名字(包含一个值),或者一个方法调用(返回值)。Java 支持几种文字:字符串、布尔值 true 和 false 、字符、整数、浮点和 null 。

注意不返回值的方法调用——被调用的方法被称为 void 方法——是一种特殊的简单表达式;比如 System.out.println("Hello,World!");。此独立表达式不能赋给变量。尝试这样做(如在 int I = system . out . println(" X ");)导致编译器报告错误。

一个字符串文字 由一对双引号括起来的 Unicode 字符序列组成;比如“敏捷的棕色狐狸跳过懒狗。”它还可能包含转义序列,这是一种特殊的语法,用于表示某些可打印和不可打印的字符,否则这些字符不会出现在文本中。比如“那只敏捷的棕色“狐狸”跳过了那只懒狗。”使用 " 转义序列将 fox 用双引号括起来。

表 2-2 描述了所有支持的转义序列。

表 2-2 。转义序列

转义语法 描述
\ 反斜线符号
" 双引号
' 单引号
\b 退格
\f 换页
\n 换行符(也称为换行)
\r 回车
\t 横表

最后,字符串文字可能包含 Unicode 转义序列,这是表示 Unicode 字符的特殊语法。Unicode 转义序列以 \u 开始,以四个十六进制数字(0–9、A–F、A–F)继续,中间没有空格。例如, \u0041 代表大写字母 A, \u20ac 代表欧盟的欧元货币符号。

一个布尔文字 由保留字真或保留字假组成。

一个字符字面量 由一个 Unicode 字符和一对单引号组成(例如‘A’)。您还可以将转义序列(例如, ''' )或 Unicode 转义序列(例如, '\u0041' )表示为字符文字。

一个整数文字 由一系列数字组成。如果文字要表示一个长整型值,那么它的后缀必须是大写的 L 或者小写的 l ( L 更容易阅读)。如果没有后缀,文字表示 32 位整数(一个 int )。

整数可以用十进制、十六进制和八进制格式指定:

  • 十进制格式是默认格式;比如 127 。
  • 十六进制格式要求文字以 0x 或 0X 开头,以十六进制数字(0-9、A-F、A-F)继续;比如 0x7F 。
  • 八进制格式要求文字以 0 为前缀,以八进制数字(0–7)继续;比如 0177 。

一个浮点字面值由一个整数部分的、一个小数点(用句点字符[ 表示)组成。)、小数部分、指数(以字母 E 或 e 开头)、类型后缀(字母 D 、 d 、 F 或 f )。大多数部分是可选的,但是必须有足够的信息来区分浮点文字和整数文字。例子包括 0.1 (双精度浮点)、 89F (浮点)、 600D (双精度浮点)、以及 13.08E+23 (双精度浮点)。

最后,空值被赋给一个引用变量,表示该变量不引用对象。

以下示例使用文本来初始化前面提供的变量:

int counter = 10;
double temperature = 98.6; // Assume Fahrenheit scale.
String firstName = "Mark";
int[] ages = { 52, 28, 93, 16 };
char gradeLetters[] = { 'A', 'B', 'C', 'D', 'F' };
float[][] matrix = { { 1.0F, 2.0F, 3.0F }, { 4.0F, 5.0F, 6.0F }};
int x = 1, y[] = { 1, 2, 3 }, z = 3;
double π = 3.14159;

第四至第七个示例使用数组初始值设定项来初始化年龄、年级字母、矩阵和 y 数组。一个数组初始化器由一个用括号和逗号分隔的表达式列表组成,这些表达式(如矩阵示例所示)本身可能就是数组初始化器。矩阵示例生成如下所示的表格:

1.0F 2.0F 3.0F
4.0F 5.0F 6.0F

在内存中组织变量

也许你对变量在内存中的组织方式很好奇。图 2-3 展示了一个可能的高层组织,用于计数器、年龄和矩阵变量,以及分配给年龄和矩阵的数组。

9781430257226_Fig02-03.jpg

图 2-3 counter 变量存储一个 4 字节的整数值,而 ages 和 matrix 存储对各自数组的 4 字节引用

Figure 2-3 reveals that each of counter, ages, and matrix is stored at a memory address (starting at a fictitious 20001000 value in this example) and divisible by 4 (each variable stores a 4-byte value); that counter’s 4-byte value is stored at this address; and that each of the ages and matrix 4-byte memory locations stores the 32-bit address of its respective array (64-bit addresses would most likely be used on 64-bit virtual machines). Also, a one-dimensional array is stored as a list of values, whereas a two-dimensional array is stored as a one-dimensional row array of addresses, where each address identifies a one-dimensional column array of values for that row.Although Figure 2-3 implies that array addresses are stored in ages and matrix, which equates references with addresses, a Java implementation might equate references with handles (integer values that identify slots in a list). This alternative is presented in Figure 2-4 for ages and its referenced array.

9781430257226_Fig02-04.jpg

图 2-4 一个句柄存储在 ages 中,由这个句柄标识的列表条目存储相关数组的地址

句柄使得在垃圾收集期间在内存区域中移动变得容易(在第三章中讨论过)。如果多个变量通过同一个地址引用同一个数组,那么当数组移动时,每个变量的地址值都必须更新。但是,如果多个变量通过同一个句柄引用数组,那么只需要更新句柄的列表条目。使用句柄的一个缺点是,通过句柄访问内存比通过地址直接访问内存要慢。不管引用是如何实现的,这个实现细节对 Java 开发人员是隐藏的,以提高可移植性。

以下示例显示了一个简单表达式,其中一个文本被分配给一个变量,后面是一个简单表达式,其中一个变量被分配给另一个变量的值:

int counter1 = 1;
int counter2 = counter1;

最后,下面的例子展示了一个简单的表达式,它将方法调用的结果赋给一个名为 isLeap 的变量:

boolean isLeap = isLeapYear(2012);

前面的例子假设只有那些类型与它们正在初始化的变量的类型相同的表达式才能被赋给那些变量。然而,在某些情况下,有可能分配一个不同类型的表达式。例如,Java 允许你给短整型变量赋值特定的整型文字,如 short s = 20;,并将一个短整型表达式赋给一个整型变量,如 int I = s;。

Java 允许前一种赋值,因为 20 可以表示为一个短整数(不会丢失任何信息)。相比之下,Java 会抱怨短 s = 40000 因为整数字面量 40000 不能表示为短整数(32767 是短整数变量中可以存储的最大正整数)。Java 允许后一种赋值,因为当 Java 从一个值集较小的类型转换到一个值集较大的类型时,不会丢失任何信息。

Java 通过扩展转换规则支持以下原始类型转换:

  • 字节整数到短整数、整数、长整数、浮点或双精度浮点
  • 短整数到整数、长整数、浮点或双精度浮点
  • 字符转换为整数、长整数、浮点或双精度浮点
  • 整数到长整数、浮点或双精度浮点
  • 长整数到浮点或双精度浮点
  • 浮点到双精度浮点

注意从小整数转换到大整数时,Java 会将小整数的符号位复制到大整数的多余位。

在第四章的中,我讨论了在用户定义和数组类型的上下文中执行类型转换的扩展转换规则。

复合表达式

一个复合表达式是一系列简单的表达式和操作符,其中一个操作符(源代码中象征性表示的一系列指令)将其操作数表达式值转换为另一个值。例如, -6 是由运算符 - 和整数文字 6 作为操作数组成的复合表达式。这个表达式将 6 转换成它的负等价物。同样, x + 5 是一个复合表达式,由变量名 x ,整数文字量 5 ,以及夹在这些操作数之间的运算符 + 组成。当这个表达式被求值时,变量 x 的值被取出并加到 5 中。总和成为表达式的值。

注意当 x 的类型为字节整数或短整数时,该变量的值被加宽为整数。然而,当 x 的类型为长整型、浮点型或双精度浮点型时, 5 被加宽为适当的类型。加法运算在扩大转换发生后执行。

Java 提供了许多操作符,这些操作符是根据它们接受的操作数的数量来分类的。一个一元运算符 只取一个操作数(一元减[-]为例),一个二元运算符取两个操作数(加法[ + ]为例),Java 的单个三元运算符 (条件[ ?: ])采用三个操作数。

运算符也分为前缀、后缀和中缀。前缀运算符是位于其操作数之前的一元运算符(如在 6 中),而后缀运算符 是位于其操作数之后的一元运算符(如在 x++ 中),而中缀运算符 是夹在二元运算符的两个或三个操作数之间的二元或三元运算符(如在 x + 5 中)。

表 2-3 从符号、描述和优先级的角度展示了所有支持的操作符——我在这一节的最后讨论了优先级的概念。各种操作符描述都提到了“整数类型”,这是指定任何字节整数、短整数、整数或长整数的简写,除非“整数类型被限定为 32 位整数。此外,“数字类型”是指除浮点和双精度浮点之外的任何整数类型。

表 2-3。操作员

image
image
image
image
image
image

表 2-3 的运算符可分为加法、数组索引、赋值、按位、转换、条件、等式、逻辑、成员访问、方法调用、乘法、对象创建、关系、移位和一元减/加。

加法运算符

加法运算符由加法( + )、减法(-)、后减量(-)、后增量(+)、前增量(-)、前增量(+)和字符串串联( + )组成。加法返回其操作数之和(例如, 6 + 4 返回 10),减法返回其操作数之差(例如,6–4 返回 2,而 4–6 返回 2),后减量从其变量操作数中减去 1,并返回变量的前一个值(例如,x–),后增量相加 predecrement 从其变量操作数中减去 1 并返回变量的新值(例如,-x),preincrement 向其变量操作数加 1 并返回变量的新值(例如, ++x ),string concatenation 合并其字符串操作数并返回合并后的字符串(例如, "A" + "B" 返回 "AB" )。

加法、减法、后减量、后增量、前增量和前增量运算符可以生成溢出或下溢结果值类型限制的值。例如,将两个大的 32 位正整数值相加会产生一个无法表示为 32 位整数值的值。结果据说溢出来了。Java 不检测溢出和下溢。

Java 提供了一种特殊的扩大转换规则,用于字符串操作数和字符串连接运算符。当任一操作数不是字符串时,操作数在字符串连接之前首先转换为字符串。例如,当呈现为 "A" + 5 时,编译器生成的代码首先将 5 转换为 "5" ,然后执行字符串串联操作,得到 "A5" 。

数组索引运算符

数组索引运算符 ( [] )通过将数组元素的位置表示为整数索引来访问该元素。该运算符在数组变量的名称后指定,例如, ages[0] 。

索引是相对于 0 的,这意味着 ages[0] 访问第一个元素,而 ages[6] 访问第七个元素。索引必须大于或等于 0,并且小于数组的长度;否则,虚拟机抛出 ArrayIndexOutOfBoundsException(参考第五章了解异常)。

通过追加“”返回数组的长度。数组变量的长度。例如, ages.length 返回 ages 引用的数组的长度(元素个数)。类似地, matrix.length 返回矩阵二维数组中行元素的个数,而 matrix[0]。length 返回分配给该数组第一行元素的列元素个数。

赋值运算符

赋值运算符 ( = )将表达式的结果赋给一个变量(如 int x = 4;)。变量和表达式的类型必须一致;否则,编译器会报告错误。

Java 还支持几个复合赋值操作符,它们执行一个特定的操作并将结果赋给一个变量。例如, += 运算符计算右边的数值表达式,并将结果添加到左边的变量内容中。其他复合赋值运算符的行为方式类似。

按位运算符

按位运算符由按位 AND ( & )、按位补码(∾)、按位异或(【^】)和按位异或( | )组成。这些运算符设计用于处理字符或整数操作数的二进制表示。因为如果您以前没有在另一种语言中使用过这些运算符,这个概念可能很难理解,所以下面来自一个假设应用的输出演示了这些运算符:

∼00000000000000000000000010110101 results in 11111111111111111111111101001010
00011010 & 10110111 results in 00000000000000000000000000010010
00011010 ^ 10110111 results in 00000000000000000000000010101101
00011010 | 10110111 results in 00000000000000000000000010111111

最后三行中的 & 、【^】和 | 运算符在执行运算之前,首先将其字节整数操作数转换为 32 位整数值(通过符号位扩展,将符号位的值复制到额外的位中)。

铸造操作符

cast 运算符— ( 类型)—试图将其操作数的类型转换为类型。此运算符的存在是因为编译器不允许您将一个值从一种类型转换为另一种类型,如果不指定您的意图,信息将会丢失(通过 cast 运算符)。比如用短 s = 1.65 + 3 呈现时;,编译器会报告一个错误,因为试图将双精度浮点值转换为短整型会导致分数丢失。65 — s 将包含 4 而不是 4.65。

认识到信息丢失可能并不总是一个问题,Java 允许您通过强制转换到目标类型来明确表达您的意图。例如,short s =(short)1.65+3;告诉编译器你希望 1.65 + 3 被转换成一个短整数,并且你意识到这个分数会消失。

下面的示例提供了需要强制转换运算符的另一个示例:

char c = 'A';
byte b = c;

当遇到字节 b = c 时,编译器报告一个关于精度损失的错误;。原因是 c 可以表示从 0 到 65535 的任何无符号整数值,而 b 只能表示从 128 到+127 的有符号整数值。即使 'A' 等于+65,这可以在 b 的范围内,但是 c 可能很容易被初始化为 '\u0323' ,这是不合适的。

这个问题的解决方案是引入一个 (byte) 强制转换,如下所示,这使得编译器生成代码将 c 的字符类型强制转换为字节整数:

byte b = (byte) c;

Java 通过强制转换操作符支持以下基本类型转换:

  • 字节整数到字符
  • 短整数到字节整数或字符
  • 字符到字节整数或短整数
  • 整数到字节整数、短整数或字符
  • 长整数到字节整数、短整数、字符或整数
  • 浮点到字节整数、短整数、字符、整数或长整数
  • 双精度浮点到字节整数、短整数、字符、整数、长整数或浮点

当从更多位转换到更少位并且没有发生数据丢失时,并不总是需要转换运算符。比如当它遇到字节 b = 100,编译器生成代码将整数 100 赋给字节整数变量 b ,因为 100 可以很容易地放入赋给这个变量的 8 位存储位置。

条件运算符

条件运算符由条件与( & & )、条件或( || )和条件(?😃。前两个运算符总是计算其左操作数(计算结果为 true 或 false 的布尔表达式),并有条件地计算其右操作数(另一个布尔表达式)。第三个运算符基于第三个布尔操作数计算两个操作数之一。

条件,并且总是计算其左操作数,并且仅当其左操作数的计算结果为 true 时,才计算其右操作数。比如年龄>64&还在工作先评估年龄> 64 。如果该子表达式为真,则对 stillWorking 求值,其真或假值( stillWorking 为布尔变量)作为整个表达式的值。如果年龄> 64 为假,则仍在工作不被评估。

条件 OR 始终计算其左操作数,仅当其左操作数的计算结果为 false 时,才计算其右操作数。例如,值< 20 ||值> 40 先求值值< 20 。如果该子表达式为假,则评估值> 40 ,其真值或假值作为整个表达式的值。如果值< 20 为真,则值> 40 不求值。

条件 AND 和条件 OR 通过防止不必要的子表达式求值来提高性能,这被称为短路。例如,如果其左操作数为 false,则条件 and 的右操作数无法改变整个表达式的计算结果为 false 的事实。

如果不小心,短路可能会阻止副作用(子表达式求值后持续存在的子表达式的结果)的执行。例如,年龄>64&&++人数> 5 只对年龄大于 64 岁的员工递增人数。递增 numEmployees 是副作用的一个例子,因为 numEmployees 中的值在子表达式 ++numEmployees > 5 求值后仍然存在。

条件运算符通过根据第三个操作数的值计算并返回两个操作数中的一个来做出决策,这非常有用。以下示例将布尔值转换为其等效的整数(1 表示真,0 表示假):

boolean b = true;
int i = b ? 1 : 0; // 1 assigns to i

等式运算符

等式运算符由等式( == )和不等式()组成!= )。这些运算符比较它们的操作数,以确定它们是否相等。前一个运算符在相等时返回 true 后一个运算符在不相等时返回 true。比如 2 == 2 和 2 的每一个!= 3 评估为真,而 2 == 4 和 4!= 4 评估为假。

当涉及到对象操作数时(在第三章的中讨论),这些操作符不比较它们的内容。比如“ABC”= =“XYZ”不比较 a 和 x 。相反,因为字符串文字实际上是 String 对象(在第七章中我进一步讨论了这个概念), == 比较对这些对象的引用。

逻辑运算符

逻辑运算符由逻辑与( & )、逻辑补码(!)、逻辑异或( ^ )、逻辑异或( | )。虽然这些运算符与按位运算符相似,它们的操作数必须是整数/字符,但传递给逻辑运算符的操作数必须是布尔型的。比如!false 返回 true。此外,当遇到年龄> 64 &仍在工作时,逻辑 AND 会评估两个子表达式。这种模式同样适用于逻辑异或和逻辑包含或。

成员访问操作员

成员访问操作员 ( )。用于访问一个类的成员或一个对象的成员。比如 String s = " Hello ";int len = s . length();返回分配给变量 s 的字符串长度。它通过调用 String 类的 length() 方法成员来实现。在第三章中,我将更详细地讨论成员访问。

数组是特殊的对象,它只有一个长度的成员。当您指定一个数组变量,后跟成员访问操作符,再后跟长度时,结果表达式将数组中的元素数作为 32 位整数返回。例如, ages.length 返回 ages 引用的数组的长度(元素个数)。

方法调用运算符

方法调用操作符—()—用于表示一个方法(在第三章的中讨论)正在被调用。此外,它还标识了传递给方法的参数的数量、顺序和类型,这些参数将由方法的参数选取。system . out . println(" Hello ");就是一个例子。

乘法运算符

乘法运算符由乘法( * )、除法( / )和余数( % )组成。乘法返回其操作数的乘积(例如, 6*4 返回 24),除法返回其左操作数除以其右操作数的商(例如, 6/4 返回 1),余数返回其左操作数除以其右操作数的余数(例如, 6%4 返回 2)。

乘法、除法和余数运算符可以生成溢出或下溢结果值类型限制的值。例如,将两个大的 32 位正整数值相乘会产生一个无法用 32 位整数值表示的值。结果据说溢出来了。Java 不检测溢出和下溢。

将数值除以 0(通过除法或余数运算符)也会产生有趣的行为。将一个整数值除以整数 0 会导致操作符抛出一个算术异常对象(我会在第五章的中讨论异常)。将浮点/双精度浮点值除以 0 会导致运算符返回+无穷大或-无穷大,具体取决于被除数是正数还是负数。最后,将浮点 0 除以 0 会导致运算符返回 NaN(不是数字)。

对象创建操作符

对象创建操作符 ( new )从一个类创建一个对象,也从一个初始化器创建一个数组。我将在第三章中讨论这些话题。

关系运算符

关系运算符 由大于( > )、大于等于( > = )、小于( < )、小于等于( < = )和类型检查()组成。前四个运算符比较它们的操作数,当左操作数分别大于、大于或等于、小于或小于或等于右操作数时,返回 true。例如, 5.0 > 3 、 2 > = 2 、 16.1 < 303.3 、 54.0 < = 54.0 中的每一个都求值为真。

类型检查运算符用于确定对象是否属于特定类型。我在第四章中讨论了这个话题。

移位运算符

移位运算符由左移( < < )、有符号右移( > > )和无符号右移( > > > )组成。左移将左操作数的二进制表示向左移动右操作数指定的位数。每次移位相当于乘以 2。比如 2<3 将 2 的二进制表示左移 3 位;结果相当于 2 乘以 8。

每个有符号和无符号右移都将其左操作数的二进制表示向右移动由其右操作数指定的位置数。每次移位相当于除以 2。比如 16>3 将 16 的二进制表示右移 3 位;结果相当于 16 除以 8。

有符号右移和无符号右移的区别在于移位过程中符号位的变化。有符号右移位包括移位中的符号位,而无符号右移位忽略符号位。因此,有符号右移保留负数,但无符号右移不保留负数。例如,-4>>1(相当于 4 / 2 )的计算结果为 2,而 4 > > > 1 的计算结果为 2147483646。

提示移位运算符比乘以或除以 2 的幂要快。

一元减/加运算符

一元减号()和一元加号( + )是所有运算符中最简单的。一元减返回其操作数的负数(如 5 返回 5 和 - 5 返回 5 ),而一元加则逐字返回其操作数(如 +5 返回 5 和+-5 返回 5 )。一元加号不常用,但为了完整性而出现。

优先级和结合性和

当计算一个复合表达式时,Java 会考虑每个操作符的优先级(重要性级别),以确保表达式的计算符合预期。例如,当用表达式 60 + 3 * 6 表示时,您期望乘法在加法之前执行(乘法的优先级高于加法),最终结果是 78。你不会期望加法首先发生,产生 378 的结果。

表 2-3 最右边的一栏显示了一个值,表示一个操作符的优先级:数字越大,优先级越高。例如,加法的优先级别是 10,乘法的优先级别是 11,这意味着乘法在加法之前执行。

可以通过在表达式中引入左括号和右括号 ( 和 ) 来规避优先级,其中首先计算最里面的一对嵌套括号。例如,计算 2 * ((60 + 3) * 6) 会导致首先计算 (60 + 3) ,然后计算 (60 + 3) * 6 ,最后计算整体表达式。类似地,在表达式 60/(3–6)中,在除法之前执行减法。

在求值过程中,具有相同优先级的操作符(如加法和减法,都具有 10 级)根据它们的结合性进行处理(这是一个属性,确定当缺少括号时,具有相同优先级的操作符如何分组)。

例如,因为 * 和 / 是从左到右的关联运算符,所以表达式 9 * 4 / 3 被视为 (9 * 4) / 3 。相比之下,表达式 x = y = z = 100 被求值,就好像它是 x =(y =(z = 100))—100 被赋给 z , z 的新值(100)被赋给 y,y 的新值(100)被赋给

Java 的大多数操作符都是从左到右关联的。从右到左的关联运算符包括赋值、按位补码、强制转换、复合赋值、条件、逻辑补码、对象创建、预递增、预递增、一元减号和一元加号。

注意与 C++等语言不同,Java 不允许重载操作符。然而,Java 重载了 + 、 ++ 和 - 运算符。

学习语句 s

语句是一个程序的主力。它们给变量赋值,通过决策和/或重复执行其他语句来控制程序流程,并执行其他任务。一个语句可以用简单语句或复合语句来表达:

  • 简单语句 是用于执行某些任务的单个独立源代码指令;它以分号结束。
  • 复合语句 是夹在左大括号和右大括号之间的简单语句和其他复合语句的(可能为空)序列——分隔符是标记某个部分的开始或结束的字符。方法体(如 main() 方法体)就是一个例子。复合语句可以出现在简单语句出现的任何地方,或者被称为

*在这一节中,我将向您介绍 Java 的许多语句。其他声明将在后面的章节中介绍。例如,在第三章中,我讨论了 return 语句。

赋值语句

赋值 语句是给变量赋值的表达式。该语句以变量名开始,以赋值操作符( = )或复合赋值操作符(如 += )继续,以赋值兼容表达式和分号结束。以下是三个例子:

x = 10;
ages[0] = 25;
counter += 10;

第一个例子将整数 10 赋给变量 x ,它大概也是整数类型。第二个示例将整数 25 分配给年龄数组的第一个元素。第三个示例将 10 加到存储在计数器中的值,并将总和存储在计数器中。

注意在变量声明中初始化一个变量(比如 int counter = 1;)可以认为是赋值语句的一种特殊形式。

决策声明

前面描述的条件运算符(?:)对于在两个表达式之间选择求值是有用的,不能用于在两个语句之间选择。为此,Java 提供了三个决策语句:if、if-else 和 switch。

If 语句

if 语句评估一个布尔表达式,并在该表达式评估为真时执行另一个语句。该语句具有以下语法:

if (*Boolean expression*)
   *statement*

If 由保留字 if 组成,后面是圆括号中的布尔表达式,后面是当布尔表达式评估为真时执行的语句

以下示例演示了该语句:

if (numMonthlySales > 100)
   wage += bonus;

如果月销售额超过 100, numMonthlySales > 100 计算为真,工资+=奖金;赋值语句执行。否则,这个赋值语句不会执行。

注意有些人喜欢用大括号将一条语句括起来,以防止出错。因此,他们会按如下方式编写前面的示例:

if (numMonthlySales > 100){
   wage += bonus;
}

我不会对单个语句这样做,因为我认为多余的大括号是不必要的混乱。然而,你可能会有不同的感受。使用任何让你最舒服的方法。

If-Else 语句

if-else 语句该语句具有以下语法:

if (*Boolean expression*)
   *statement1*
else
 *statement2*

If-else 由保留字 if 组成,后面是圆括号中的布尔表达式,后面是当布尔表达式评估为真时执行的语句 1 ,后面是当布尔表达式评估为假时执行的语句 2

以下示例演示了该语句:

if ((n & 1) == 1)
   System.out.println("odd");
else
   System.out.println("even");

这个例子假设存在一个名为 n 的 int 变量,它已经被初始化为一个整数。然后,它继续确定该整数是奇数(不能被 2 整除)还是偶数(能被 2 整除)。

布尔表达式首先对 n & 1 求值,然后将 n 的值与 1 进行位与运算。然后将结果与 1 进行比较。如果相等,则输出一条消息,说明 n 的值为奇数;否则,会输出一条消息,说明 n 的值为偶数。

括号是必需的,因为 == 的优先级高于 & 。如果没有这些括号,表达式的求值顺序将变为首先对 1 == 1 求值,然后尝试对布尔结果与 n 的整数值进行位与运算。由于类型不匹配,这种顺序会导致编译器错误信息:不能将整数与布尔值进行位 AND 运算。

您可以重写这个 if-else 语句示例以使用条件运算符,如下所示:system . out . println((n&1)= = 1?“奇”:“偶”);。但是,在以下示例中,您无法做到这一点:

if ((n & 1) == 1)
   odd();
else
   even();

本例假设存在不返回任何内容的 odd() 和 even() 方法。因为条件运算符要求其第二个和第三个操作数的计算结果都是一个值,所以当试图编译 (n & 1) == 1 时,编译器会报告一个错误?奇数() :偶数()。

您可以将多个 if-else 语句链接在一起,产生以下语法:

if (*Boolean expression1*)
   *statement1*
else
if (*Boolean expression2*)
 *statement2*
else
   ...
else
   *statementN*

如果布尔表达式 1 评估为真,则语句 1 执行。否则,如果布尔表达式 2 评估为真,则语句 2 执行。这种模式一直持续到这些表达式中的一个计算为真并且其相应的语句执行,或者到达最后的 else 并且语句 N (默认语句)执行。

以下示例演示了这种链接:

if (testMark >= 90)
{
   gradeLetter = 'A';
   System.out.println("You aced the test.");
}
else
if (testMark >= 80)
{
   gradeLetter = 'B';
   System.out.println("You did very well on this test.");
}
else
if (testMark >= 70)
{
   gradeLetter = 'C';
   System.out.println("Not bad, but you need to study more for future tests.");
}
else
if (testMark >= 60)
{
   gradeLetter = 'D';
   System.out.println("Your test result suggests that you need a tutor.");
}
else
{
   gradeLetter = 'F';
   System.out.println("Your test result is pathetic; you need summer school.");
}

悬挂-否则问题

当 if 和 if-else 一起使用时,如果源代码没有正确缩进,就很难确定哪个 if 与 else 相关联。例如,请参见以下内容:

if (car.door.isOpen())

if (car.key.isPresent())

car . start();

else car . door . open();

开发人员是否打算让 else 与内部 if 匹配,但却错误地格式化了代码,使其看起来不匹配?例如,请参见以下内容:

if (car.door.isOpen())

if (car.key.isPresent())

car . start();

其他

car . door . open();

如果 car.door.isOpen() 和 car.key.isPresent() 各自返回 true, car.start() 执行。如果 car.door.isOpen() 返回 true, car.key.isPresent() 返回 false,car . door . open();执行。试图打开一扇敞开的门毫无意义。

开发人员肯定希望 else 匹配外部 if,但是忘记了 else 匹配最近的 if。这个问题可以通过用大括号将内部 if 括起来来解决,如下所示:

if (car.door.isOpen())

{

if (car.key.isPresent())

car . start();

}

其他

car . door . open();

当 car.door.isOpen() 返回 true 时,复合语句执行。当此方法返回 false 时,car . door . open();执行,有道理。

忘记 else 匹配最近的 if 并使用糟糕的缩进来掩盖这一事实被称为悬空-else 问题

开关语句

与等效的链式 if-else 语句相比, switch 语句 允许您以更有效的方式从几个执行路径中进行选择。该语句具有以下语法:

switch (*selector expression* )
{
   case*value1* :*statement1* [break;]
   case*value2* :*statement2* [break;]
   ...
   case*valueN* :*statementN* [break;]
   [default:*statement* ]
}

Switch 由保留字 switch 组成,后面是圆括号中的选择器表达式,后面是案例体。选择器表达式是任何计算结果为整数或字符值的表达式。例如,它可能计算 32 位整数或 16 位字符。

每个 case 以保留字 case 开头;以一个文字值和一个冒号字符( : )继续;继续执行一条语句;并且可选地以 break 语句结束,这使得执行在 switch 语句之后继续。

在对选择器表达式求值后,switch 将该值与每个事例的值进行比较,直到找到匹配项。当有匹配时,执行 case 语句。例如,当选择器表达式的值与值 1 匹配时,语句 1 执行。

可选的 break 语句(方括号中的任何内容都是可选的),由保留字 break 后跟一个分号组成,阻止执行流继续执行下一个 case 语句。而是继续执行 switch 后面的第一条语句。

注意你通常会在案件陈述后放置一个中断陈述。忘记包含 break 会导致一个很难发现的 bug。但是,有些情况下,您希望将几个案例组合在一起,并让它们执行相同的代码。在这种情况下,您可以从参与案例中省略 break 语句。

如果没有一个案例的值与选择器表达式的值匹配,并且如果存在一个默认案例(由后面跟有冒号的默认保留字表示),则执行默认案例的语句。

以下示例演示了该语句:

switch (direction)
{
   case  0: System.out.println("You are travelling north."); break;
   case  1: System.out.println("You are travelling east."); break;
   case  2: System.out.println("You are travelling south."); break;
   case  3: System.out.println("You are travelling west."); break;
   default: System.out.println("You are lost.");
}

这个例子假设方向存储一个整数值。当该值在 0-3 范围内时,输出适当的方向信息;否则,输出关于丢失的消息。

Strong 这个例子硬编码了值 0、1、2 和 3,这在实践中并不是一个好主意。相反,应该使用常数。在第三章中,我会向你介绍常数。

循环语句

经常需要重复执行一条语句,这种重复执行被称为循环。Java 提供了三种循环语句:for、while 和 do-while。在本节中,我首先讨论这些陈述。然后,我研究了空语句循环的主题。最后,我讨论了 break、标记为 break、continue 和标记为 continue 的语句,这些语句用于提前结束全部或部分循环。

对于语句

语句 的可以让你循环一个语句特定的次数,甚至是无限的。该语句具有以下语法:

for ([*initialize*]; [*test*]; [*update*])
   *statement*

For 由用于的保留字组成,后面是括号中的头,后面是要执行的语句。头部由可选的初始化部分、可选的测试部分、可选的更新部分组成。一个非可选的分号将前两个部分与下一个部分分开。

initialize 部分由逗号分隔的变量声明或变量赋值列表组成。这些变量中的一些或全部通常用于控制循环的持续时间,被称为循环控制变量

测试部分由一个布尔表达式组成,它决定了循环执行的时间。只要该表达式的计算结果为 true,执行就会继续。

最后,更新部分由逗号分隔的表达式列表组成,这些表达式通常修改循环控制变量。

For 非常适合于在一个数组上迭代(循环)。每次迭代(循环执行)通过一个数组 [ 索引 ] 表达式访问数组的一个元素,其中数组是被访问元素的数组,索引是被访问元素的从零开始的位置。

以下示例使用 for 语句迭代传递给 main() 方法的命令行参数数组:

public static void main(String[] args)
{
   for (int i = 0; i < args.length; i++)
      System.out.println(args[i]);
}

For 的初始化部分声明变量 i 用于控制循环,其测试部分将 i 的当前值与 args 数组的长度进行比较,以确保该值小于数组的长度,其更新部分将 i 递增 1。循环继续,直到 i 的值等于数组的长度。

每次迭代通过 args[i] 表达式访问数组的一个值。这个表达式返回这个数组的第 i 个值(在这个例子中恰好是一个字符串对象)。第一个值存储在 args[0] 中。

注意虽然我已经将包含命令行参数的数组命名为 args ,但是这个名称并不是强制性的。我可以很容易地把它命名为论据(或者甚至其他名称)。

以下示例使用 for 输出先前声明的矩阵数组的内容,为了方便起见,在此重新声明:

float[][] matrix = { { 1.0F, 2.0F, 3.0F }, { 4.0F, 5.0F, 6.0F }};
for (int row = 0; row < matrix.length; row++)
{
   for (int col = 0; col < matrix[row].length; col++)
      System.out.print(matrix[row][col] + " ");
   System.out.print("\n");
}

表达式 matrix.length 返回该表格数组中的行数。对于每一行,表达式矩阵【行】。length 返回该行的列数。后一个表达式表明每一行可以有不同的列数,尽管在本例中每一行都有相同的列数。

System.out.print() 与 System.out.println() 密切相关。与后一种方法不同, System.out.print() 输出它的参数时不带尾随换行符。

此示例生成以下输出:

1.0 2.0 3.0
4.0 5.0 6.0

而声明

while 语句重复执行另一个语句,同时其布尔表达式的值为 true。该语句具有以下语法:

while (*Boolean expression*)
   *statement*

While 由保留字 while 组成,后面是带括号的布尔表达式,后面是要重复执行的语句

while 语句首先评估布尔表达式。如果为真,则执行另一个语句。再次对布尔表达式求值。如果仍然为真,则重新执行语句。这种循环模式继续下去。

提示用户输入特定字符是 while 有用的一种情况。例如,假设您希望提示用户输入一个特定的大写字母或其小写等效字母。以下示例提供了一个演示:

int ch = 0;
while (ch != 'C' && ch != 'c')
{
   System.out.println("Press C or c to continue.");
   ch = System.in.read();
}

这个例子从初始化变量 ch 开始。此变量必须初始化;否则,当编译器试图在 while 语句的布尔表达式中读取 ch 的值时,它将报告一个未初始化的变量。

该表达式使用条件 AND 运算符( & & )来测试 ch 的值。这个操作符首先计算它的左操作数,恰好是表达式 ch!= 'C' 。(第!= 运算符在比较之前将 'C' 从 16 位无符号 char 类型转换为 32 位有符号 int 类型。)

如果 ch 不包含 C (此时不包含— 0 刚刚被赋值给 ch ),那么这个表达式的计算结果为 true。

& & 运算符接下来计算其右操作数,恰好是表达式 ch!= 'c' 。因为该表达式的计算结果也为 true,所以条件表达式返回 true,而 while 执行复合语句。

复合语句首先通过 System.out.println() 方法调用输出一条消息,提示用户在有或没有 Shift 键的情况下按 C 键。接下来它通过 System.in.read() 读取输入的按键,将其整数值保存在 ch 中。

从左到右,系统标识系统工具的标准类,中的标识位于系统中的对象,该对象提供从标准输入设备输入 1 个或更多字节的方法, read() 返回下一个字节(或当没有更多的字节时返回 1)。

在这个赋值之后,复合语句结束,while 重新计算它的布尔表达式。

假设 ch 包含 C 的整数值。有条件和评价 ch!= 'C' ,计算结果为假。看到表达式已经为 false,条件 AND 通过不计算其右操作数来缩短其计算,并返回 false。while 语句随后检测到该值并终止。

假设 ch 包含 c 的整数值。有条件和评价 ch!= 'C' ,计算结果为真。眼见表情为真,有条件的又评价 ch!= 'c' ,计算结果为假。while 语句再次终止。

注意for 语句可以编码为 while 语句。例如,

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

相当于

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

Do-While 语句

do-while 语句 重复执行一个布尔表达式为真的语句。与在循环顶部计算布尔表达式的 while 语句不同,do-while 在循环底部计算布尔表达式。该语句具有以下语法:

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

Do-while 由 do 保留字组成,后面是要重复执行的语句,后面是 while 保留字,后面是带括号的布尔表达式,后面是分号。

do-while 语句首先执行另一个语句。然后它评估布尔表达式的。如果为真,do-while 执行另一个语句。再次对布尔表达式求值。如果仍然为真,do-while 重新执行语句。这种循环模式继续下去。

下列范例示范 do-while 提示使用者输入特定的大写字母或其小写对等字母:

int ch;
do
{
   System.out.println("Press C or c to continue.");
   ch = System.in.read();
}
while (ch != 'C' && ch != 'c');

这个例子与其前身相似。因为在测试之前不再执行复合语句,所以不再需要在布尔表达式求值之前初始化 ch — ch 被赋予 System.in.read() 的返回值。

循环空语句

Java 引用一个分号字符作为空语句。循环语句重复执行空语句有时很方便。loop 语句执行的实际工作发生在语句头中。考虑以下示例:

for (String line; (line = readLine()) != null; System.out.println(line));

此示例使用 for 来呈现一种编程习惯用法,用于将从某个源读取的文本行(在此示例中通过虚构的 readLine() 方法)复制到某个目的地(在此示例中通过 System.out.println() )。复制继续进行,直到 readLine() 返回空值。注意行尾的分号(空语句)。

小心空语句,因为它会给你的代码带来微妙的错误。例如,下面的循环应该在 10 行中输出字符串 Hello 。相反,只输出该字符串的一个实例,因为它是空语句,而不是执行了 10 次的 System.out.println() :

for (int i = 0; i < 10; i++); // this ; represents the empty statement
   System.out.println("Hello");

Break 和标注 Break 的语句和

做什么用(;;);、 while(真实);和做;while(真);有什么共同点?这些循环语句中的每一个都代表了一个无限循环(一个永不结束的循环)的极端例子。无限循环是应该避免的,因为它的无休止执行会导致应用挂起,从应用用户的角度来看,这是不可取的。

注意由于许多浮点值具有不精确的内部表示,通过等式或不等式运算符将浮点值与非零值进行比较的循环布尔表达式也会产生无限循环。例如,下面的例子永远不会结束,因为 0.1 没有确切的内部表示:

for (double d = 0.0; d != 1.0; d += 0.1)
   System.out.println(d);

然而,有时通过使用上述编程习惯用法之一,可以方便地编写一个循环,就好像它是无限的一样。例如,您可以编写一个 while (true) 循环,重复提示特定的击键,直到按下正确的键。当按下正确的键时,循环必须结束。Java 为此提供了 break 语句。

break 语句将执行转移到 switch 语句(如前所述)或循环之后的第一条语句。在任一场景中,该语句由保留字 break 后跟一个分号组成。

以下示例使用 break with if decision 语句在用户按下 C 或 C 键时退出基于 while (true) 的无限循环:

int ch;
while (true)
{
   System.out.println("Press C or c to continue.");
   ch = System.in.read();
   if (ch == 'C' || ch == 'c')
      break;
}

break 语句在有限循环的上下文中也很有用。例如,考虑这样一个场景,在一个值数组中搜索一个特定的值,当找到这个值时,您希望退出循环。以下示例揭示了这种情况:

int[] employeeIDs = { 123, 854, 567, 912, 224 };
int employeeSearchID = 912;
boolean found = false;
for (int i = 0; i < employeeIDs.length; i++)
   if (employeeSearchID == employeeIDs[i])
   {
      found = true;
      break;
   }
System.out.println((found) ? "employee " + employeeSearchID + " exists"
                           : "no employee ID matches " + employeeSearchID);

该示例使用 for 和 if 来搜索雇员 ID 数组,以确定特定的雇员 ID 是否存在。如果找到这个 ID,If 的复合语句将 true 赋给 found 。因为继续搜索没有意义,所以它使用 break 退出循环。

标记的 break 语句将执行转移到循环后面的第一条语句,该语句前面有一个标签(一个标识符,后跟一个冒号)。它由保留字 break 组成,后跟一个匹配标签必须存在的标识符。此外,标签必须紧接在循环语句之前。

标签 break 对于跳出嵌套循环(循环中的循环)很有用。以下示例显示了标记为 break 的语句将执行转移到外部 for 循环后面的第一条语句:

outer:
for (int i = 0; i < 3; i++)
   for (int j = 0; j < 3; j++)
      if (i == 1 && j == 1)
         break outer;
      else
         System.out.println("i=" + i + ", j=" + j);
System.out.println("Both loops terminated.");

当 i 的值为 1, j 的值为 1 时,破外;执行以终止两个 for 循环。该语句将执行转移到外层 for 循环之后的第一条语句,恰好是 System.out.println("两个循环都终止了"));。

将生成以下输出:

i=0, j=0
i=0, j=1
i=0, j=2
i=1, j=0
Both loops terminated.

继续和标注继续的语句

continue 语句跳过当前循环迭代的剩余部分,重新计算循环的布尔表达式,并执行另一次迭代(如果为真)或终止循环(如果为假)。继续由保留字继续后跟分号组成。

考虑一个 while 循环,它从源中读取行,并以某种方式处理非空行。因为它不应处理空行,而在检测到空行时会跳过当前迭代,如以下示例所示:

String line;
while ((line = readLine()) != null)
{
   if (isBlank(line))
      continue;
   processLine(line);
}

这个例子使用了一个虚构的 isBlank() 方法 来确定当前读取的行是否是空白的。如果此方法返回 true,则执行 continue 语句以跳过当前迭代的剩余部分,并在检测到空行时读取下一行。否则,将调用虚构的 processLine() 方法来处理该行的内容。

仔细看看这个例子,你应该意识到 continue 语句是不需要的。相反,这个清单可以通过重构(重写源代码以提高其可读性、组织性或可重用性)来缩短,如下例所示:

String line;
while ((line = readLine()) != null)
{
   if (!isBlank(line))
      processLine(line);
}

本例的重构将 if 的布尔表达式修改为使用逻辑补码运算符(!)。每当 isBlank() 返回 false 时,该运算符将该值翻转为 true,并执行 processLine() 。虽然在这个例子中 continue 不是必需的,但是您会发现在重构不容易执行的更复杂的代码中使用这个语句很方便。

标记为的 continue 语句跳过一个或多个嵌套循环的剩余迭代,并将执行转移到标记的循环。它由保留字 continue 组成,后跟一个标识符,必须存在与之匹配的标签。此外,标签必须紧接在循环语句之前。

带标签的 continue 对于在继续执行带标签的循环的同时跳出嵌套循环非常有用。以下示例显示了终止内部 for 循环迭代的带标签的 continue 语句:

outer:
for (int i = 0; i < 3; i++)
   for (int j = 0; j < 3; j++)
      if (i == 1 && j == 1)
         continue outer;
      else
         System.out.println("i=" + i + ", j=" + j);
System.out.println("Both loops terminated.");

当 i 的值为 1, j 的值为 1,继续外层;执行以终止内部 for 循环,并在其下一个值 i 处继续外部 for 循环。两个循环都继续,直到结束。

将生成以下输出:

i=0, j=0
i=0, j=1
i=0, j=2
i=1, j=0
i=2, j=0
i=2, j=1
i=2, j=2
Both loops terminated.

练习

以下练习旨在测试您对第二章内容的理解:

  1. 什么是 Unicode?
  2. 什么是评论?
  3. 识别 Java 支持的三种注释。
  4. 什么是标识符?
  5. 是非判断:Java 是一种不区分大小写的语言。
  6. 什么是类型?
  7. 定义原始类型。
  8. 识别 Java 的所有基本类型。
  9. 定义用户定义的类型。
  10. 定义数组类型。
  11. 什么是变量?
  12. 什么是表达式?
  13. 识别两种表达式类别。
  14. 什么是文字?
  15. 是字符串文字“敏捷的棕色狐狸‘跳过’懒狗。”合法还是非法?为什么呢?
  16. 什么是运营商?
  17. 识别前缀运算符和后缀运算符之间的区别。
  18. 强制转换运算符的目的是什么?
  19. 什么是优先?
  20. 是非判断:大多数 Java 操作符都是从左到右关联的。
  21. 什么是陈述?
  22. while 和 do-while 语句的区别是什么?
  23. break 和 continue 语句的区别是什么?
  24. 编写一个 OutputGradeLetter 应用(类名为 OutputGradeLetter ),其 main()方法执行前面讨论 if-else 语句时给出的等级字母代码序列。使用清单 2-1 中的 DumpArgs 应用作为该应用的基础。(您不需要包含 Javadoc 注释,但是如果您愿意,您可以这样做。)
  25. Create a Triangle application whose Triangle class’s main() method uses a pair of nested for statements along with System.out.print() to output a 10-row triangle of asterisks, where each row contains an odd number of asterisks (1, 3, 5, 7, and so on), as shown following:
![image](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/learn-java-andr-dev/img/star.jpg)

编译并运行该应用。

摘要

源代码需要被文档化,这样你(和任何维护它的人)就可以理解它,无论是现在还是将来。Java 提供了在源代码中嵌入文档的注释特性。支持单行、多行和文档注释。

一个单行注释占据了一行源代码的全部或者部分。该注释以 // 字符序列开始,并以解释文本继续。编译器忽略从 // 到出现 // 的行尾的所有内容。

一个多行注释占据了一行或多行源代码。该注释以 /* 字符序列开始,以说明性文本继续,以 / 字符序列结束。编译器会忽略从 / 到 */ 的所有内容。

一个 Javadoc 注释占据了一行或多行源代码。该注释以 /** 字符序列开始,以说明性文本继续,以 / 字符序列结束。编译器会忽略从 /* 到 */ 的所有内容。

标识符用于命名类、方法和其他源代码实体。一个标识符由字母(A-Z,A-Z,或其他人类字母表中的等价大写/小写字母)、数字(0-9 或其他人类字母表中的等价数字)、连接标点符号(如下划线)和货币符号(如美元符号$)组成。该名称必须以字母、货币符号或连接标点符号开头。并且它的长度不能超过它所在的行。有些标识符是 Java 保留的。例子包括摘要和案例。

应用处理不同类型的值,如整数、浮点值、字符和字符串。类型标识一组值(以及它们在内存中的表示)和一组将这些值转换成该组中其他值的操作。

原始类型是由语言定义的类型,其值不是对象。Java 支持布尔、字符、字节整数、短整数、整数、长整数、浮点和双精度浮点原语类型。

用户定义类型是由开发人员使用类、接口、枚举或注释类型定义的类型,其值是对象。用户定义类型也称为引用类型。

一个数组类型是一个引用类型,表示一个数组,一个在大小相等的连续槽中存储值的内存区域,通常被称为元素。这种类型由元素类型和一对或多对方括号组成,表示尺寸的数量。

应用操作存储在内存中的值,这些值通过使用变量特性在源代码中象征性地表示出来。一个变量是一个存储某种类型值的命名内存位置。

Java 为初始化变量和其他目的提供了表达式特性。一个表达式组合了文字、变量名、方法调用和操作符的某种排列。在运行时,它计算出一个值,该值的类型称为表达式的类型。

一个简单表达式是一个文字(一个逐字指定的值),一个变量名(包含一个值),或者一个方法调用(返回值)。Java 支持几种文字:字符串、布尔真和假,字符、整数、浮点和空。

一个复合表达式是一系列简单的表达式和操作符,其中一个操作符(源代码中象征性表示的一系列指令)将其操作数表达式值转换为另一个值。

Java 提供了许多操作符,这些操作符是根据它们接受的操作数的数量来分类的。一个一元运算符只取一个操作数,一个二元运算符取两个操作数,Java 的单个三元运算符取三个操作数。

运算符也分为前缀、后缀和中缀。一个前缀运算符是位于其操作数之前的一元运算符,后缀运算符是位于其操作数之后的一元运算符,中缀运算符是夹在其操作数之间的二元或三元运算符。

语句是程序的核心。它们给变量赋值,通过决策和/或重复执行其他语句来控制程序流程,并执行其他任务。一个语句可以用简单语句或复合语句来表达。

在第三章中,我继续通过检查 Java 语言对类和对象的支持来探索 Java 语言。您还会学到更多关于数组的知识。*

三、探索类和对象

在第二章中,我向你介绍了 Java 语言的基础。您现在知道了如何通过将语句插入到类的 main() 方法中来编写简单的应用。然而,当您试图以这种方式开发复杂的应用时,您一定会发现开发是乏味的、缓慢的,并且容易出错。类和对象通过简化应用架构来解决这些问题。

在第三章中,我向你介绍了 Java 对类和对象的支持。您将学习如何声明一个类并从该类中实例化对象,如何在该类中声明字段并访问这些字段,如何在该类中声明方法并调用它们,如何初始化类和对象,以及如何在不再需要对象时将其删除。

在第二章讨论变量的时候,我向你介绍了数组。您学习了数组变量,并发现了一种创建数组的简单方法。然而,Java 也提供了一种更强大、更灵活的方法来创建数组,这有点类似于创建对象的方式。本章还通过向您介绍这一功能扩展了第二章的的阵列覆盖范围。

声明类和实例化对象

在涉及类和对象的现代编程方法之前,应用遵循结构化编程,其中数据结构被创建来组织和存储数据项,而函数(返回值的命名代码序列)和过程(不返回值的命名代码序列)被用于操纵数据结构内容。这种数据与代码的分离使得建模真实世界的实体(比如银行账户和雇员)变得困难,并且经常导致复杂应用的维护问题。

比雅尼·斯特劳斯特鲁普(C++编程语言的创始人)等计算机科学家发现,通过将数据结构与函数和过程合并成称为类的离散单元,可以简化这种复杂性。这些类可以描述真实世界的实体并被实例化。结果对象被证明是建模这些实体的有效方法。

您首先学习如何声明一个类,然后学习如何在 new 操作符和构造函数的帮助下从这个类创建对象。最后,您将了解构造函数参数以及如何指定它们来初始化对象,了解局部变量以及如何指定它们来帮助控制构造函数中的代码流。

声明类

一个是制造对象(代码和数据的命名分组)的模板,也称为类实例,或简称实例。类概括了现实世界中的实体,而对象是这些实体在应用级别的具体表现。您可能会认为类是 cookie cutter,对象是 cookie cutter 创建的 cookie。

因为不能实例化不存在的类中的对象,所以必须首先声明该类。声明由一个标题和一个正文组成。至少,头文件由保留字 class 组成,后跟一个标识该类的名称(以便可以从源代码中的其他地方引用它)。正文以左括号字符( { )开始,以右括号( } )结束。夹在这些分隔符之间的是各种声明。考虑清单 3-1 。

清单 3-1 。声明骨架图像类

class Image
{
   // various member declarations
}

清单 3-1 声明了一个名为 Image 的类,它大概描述了某种在屏幕上显示的图像。按照惯例,类名以大写字母开头。此外,多单词类名中每个后续单词的第一个字母都要大写。这就是所谓的驼绒

用 New 运算符和构造函数实例化对象

Image 是一个用户定义类型的例子,可以从该类型创建对象。通过使用带有构造函数的 new 操作符来创建这些对象,如下所示:

Image image = new Image();

new 操作符分配内存来存储由 new 的唯一操作数指定类型的对象,在本例中恰好是 Image() 。对象存储在一个叫做的内存区域中。

图像后面的括号(圆括号)表示一个构造器,它是一段代码,通过以某种方式初始化来构造一个对象。 new 操作符在分配内存存储对象后立即调用(调用)构造函数。

当构造函数结束时, new 返回一个对该对象的引用(一个内存地址或其他标识符),这样就可以在应用的其他地方访问它。对于新创建的图像对象,其引用存储在一个名为图像的变量中,该变量的类型被指定为图像。(通常将变量称为对象,如在 image 对象中,尽管它只存储对象的引用而不是对象本身。)

注意 new 返回的引用在源代码中用关键字 this 表示。无论这个出现在哪里,它都代表当前对象。同样,存储引用的变量被称为引用变量

图像没有显式声明构造函数。当一个类没有声明构造函数时,Java 会隐式地为这个类创建一个构造函数。创建的构造函数被称为默认无参数构造函数,因为当调用构造函数时,在它的 ( 和 ) 字符之间没有出现参数(稍后讨论)。

注意当声明了至少一个构造函数时,Java 不会创建默认的无参数构造函数。

指定构造函数参数和局部变量

通过指定类名后跟一个参数列表,可以在类体内显式声明一个构造函数,参数列表是一个圆括号分隔的逗号分隔的零个或多个参数声明的列表。参数是一个构造函数或方法变量,当它被调用时,接收传递给构造函数或方法的表达式值。这个表达式值被称为自变量

清单 3-2 增强了清单 3-1 的图像类,声明了三个构造函数,它们带有声明零个、一个或两个参数的参数列表和一个用于测试该类的 main() 方法。

清单 3-2 。用三个构造函数和一个 main() 方法声明一个图像类

public class Image
{
   Image()
   {
      System.out.println("Image() called");
   }

   Image(String filename)
   {
      this(filename, null);
      System.out.println("Image(String filename) called");
   }

   Image(String filename, String imageType)
   {
      System.out.println("Image(String filename, String imageType) called");
      if (filename != null)
      {
         System.out.println("reading " + filename);
         if (imageType != null)
            System.out.println("interpreting " + filename + " as storing a " +
                               imageType + " image");
      }
      // Perform other initialization here.
   }

   public static void main(String[] args)
   {
      Image image = new Image();
      System.out.println();
      image = new Image("image.png");
      System.out.println();
      image = new Image("image.png", "PNG");
   }
}

清单 3-2 的图像类首先声明一个无参数构造函数,用于将图像对象初始化为默认值(无论它们是什么)。此构造函数模拟默认初始化。它通过调用 System.out.println() 来输出一条表示它已被调用的消息。

Image 接下来声明一个 Image(String filename) 构造函数,它的参数列表由一个参数声明组成——一个变量的类型后跟变量名。 java.lang.String 参数命名为 filename ,表示该构造函数从文件中获取图像内容。

注意在本章和其余章节中,我通常会在第一次使用预定义类型(如字符串)之前加上存储该类型的包层次结构。例如,字符串存储在 java 包的 lang 子包中。我这样做是为了帮助您了解类型存储在哪里,以便您可以更容易地指定将这些类型导入到源代码中的导入语句(而不必首先搜索类型的包)——您不必导入存储在 java.lang 包中的类型,但是为了完整起见,我仍然将 java.lang 包作为类型名的前缀。在第五章中,我会对包和导入声明有更多的说明。

一些构造函数依赖其他构造函数来帮助它们初始化它们的对象。这样做是为了避免冗余代码,冗余代码会增加对象的大小,不必要地从堆中取走可用于其他目的的内存。例如, Image(字符串文件名)依靠 Image(字符串文件名,字符串图像类型)将文件的图像内容读入内存。

虽然看起来不是这样,但是构造函数没有名字(但是,通常通过指定类名和参数列表来引用构造函数)。一个构造函数通过使用关键字 this 和圆括号分隔的逗号分隔的参数列表来调用另一个构造函数。例如, Image(字符串文件名)执行 this(文件名,空);执行图像(字符串文件名,字符串图像类型)。

注意你必须使用关键字 this 来调用另一个构造函数——你不能使用类名,就像在 Image() 中一样。 this() 构造函数调用(如果存在的话)必须是在构造函数中执行的第一个代码——该规则防止您在同一个构造函数中指定多个 this() 构造函数调用。最后,您不能在方法中指定 this()——构造函数只能由其他构造函数调用,并且只能在对象创建期间调用。(我将在本章后面讨论方法。)

如果存在,构造函数调用必须是构造函数中指定的第一个代码;否则,编译器会报告错误。因此,调用另一个构造函数的构造函数只有在另一个构造函数完成后才能执行额外的工作。例如, Image(字符串文件名)执行 System.out.println("Image(字符串文件名)调用");被调用的图像(字符串文件名,字符串图像类型)构造函数完成后。

Image(String filename,String imageType) 构造函数声明了一个 imageType 参数,该参数表示存储在文件中的图像类型——例如,可移植网络图形(PNG ) 图像。大概,构造函数使用 imageType 通过不检查文件内容来学习图像格式来加速处理。当 null 被传递给 imageType 时,正如 Image(String filename) 构造函数所发生的那样, Image(String filename,String imageType) 检查文件内容以学习格式。如果 null 也被传递给 filename , Image(String filename,String imageType) 不会读取该文件,但可能会通知试图创建 Image 对象的代码出现错误情况。

在声明了构造函数之后,清单 3-2 声明了一个 main() 方法 ,让您创建 Image 对象并查看输出消息。 main() 创建三个 Image 对象,调用第一个不带参数的构造函数,第二个带参数的构造函数 "image.png" ,第三个带参数的构造函数" Image . PNG "" PNG "。

注意传递给构造函数或方法的参数数量,或者运算符操作数的数量,被称为构造函数、方法或运算符的 arity

每个对象的引用被分配给一个名为 image 的引用变量,替换先前存储的第二个和第三个对象分配的引用。(每次出现 system . out . println();输出一个空行,使输出更容易阅读。)

main() 的出现将图像从仅仅一个类变成了一个应用。您通常将 main() 放在用于创建对象的类中,以测试这些类。当构建一个供他人使用的应用时,通常在一个类中声明 main() ,其目的是运行一个应用,而不是从那个类创建一个对象——然后应用只从那个类运行。参见第一章的 DumpArgs 和 EchoText 类的例子。

在将清单 3-2 保存到【Image.java】的之后,通过在命令行执行 javac Image.java 来编译这个文件。假设没有错误消息,通过指定 java Image 来执行应用。您应该观察到以下输出:

Image() called

Image(String filename, String imageType) called
reading image.png
Image(String filename) called

Image(String filename, String imageType) called
reading image.png
interpreting image.png as storing a PNG image

第一行输出表明 noargument 构造函数已经被调用。随后的输出行表明已经调用了第二个和第三个构造函数。

除了声明参数,构造函数还可以在其主体中声明变量来帮助它执行各种任务。例如,前面提到的 Image(String filename,String imageType) 构造函数可能会从一个(假设的) File 类创建一个对象,该类提供了读取文件内容的方法。在某些时候,构造函数实例化该类,并将实例的引用赋给一个变量,如下面的代码所示:

Image(String filename, String imageType)
{
   System.out.println("Image(String filename, String imageType) called");
   if (filename != null)
   {
      System.out.println("reading " + filename);
      File file = new File(filename);
      // Read file contents into object.
      if (imageType != null)
         System.out.println("interpreting " + filename + " as storing a " +
                            imageType + " image");
      else
         // Inspect image contents to learn image type.
         ; // Empty statement is used to make if-else syntactically valid.
   }
   // Perform other initialization here.
}

与文件名和图像类型参数一样,文件是构造函数的局部变量,称为局部变量以区别于参数。虽然这三个变量都是构造函数的局部变量,但是参数和局部变量之间有两个关键的区别:

  • 文件名和图像类型参数在构造函数开始执行时存在,并一直存在到执行离开构造函数。相比之下,文件在其声明点出现并继续存在,直到声明它的块被终止(通过一个右括号字符)。参数或局部变量的这个属性被称为生存期
  • 可以从构造函数的任何地方访问文件名和图像类型参数。相比之下,文件只能从它的声明点到声明它的块的末尾被访问。不能在声明前或声明块后访问局部变量,但嵌套子块可以访问局部变量。参数或局部变量的这个属性被称为范围

注意生存期和范围(也称为可见性)属性也适用于类、对象和字段(稍后讨论)。当加载到内存中时,类就存在了,当从内存中卸载时,类就不存在了,通常是在应用退出时。此外,加载的类通常对其他类可见。

一个对象的生命周期从通过 new 操作符创建它开始,直到它被垃圾收集器从内存中删除(在本章后面讨论)。它的范围取决于各种因素,例如当它的引用被赋给局部变量或字段时。我将在本章后面讨论字段。

字段的生存期取决于它是实例字段还是类字段。当字段属于一个对象(实例字段)时,它在对象被创建时存在,在对象从内存中消失时消失。当该字段属于一个类(类字段)时,该字段在该类被加载时开始存在,并在该类从内存中移除时消失。与对象一样,字段的范围取决于各种因素,例如字段是否被声明为具有私有访问权限——您将在本章的后面了解私有访问权限。

局部变量不能与参数同名,因为参数总是与局部变量具有相同的范围。但是,一个局部变量可以与另一个局部变量同名,前提是这两个变量位于不同的范围内(即位于不同的块内)。例如,您可以指定 int x = 1;在 if-else 语句的 if 块中,并指定 double x = 2.0;在语句对应的 else 块中,每个局部变量都是不同的。

注意对构造函数参数、自变量和局部变量的讨论也适用于方法参数、自变量和局部变量——我将在本章后面讨论方法。

封装状态和行为

类从模板的角度模拟现实世界的实体,例如汽车和储蓄账户。对象表示特定的实体,例如,John 的红色 Toyota Camry(汽车实例)和 Cuifen 的结余为两万美元的储蓄帐户(储蓄帐户实例)。

实体有属性,比如颜色红色,制造丰田,型号凯美瑞,余额两万美元。一个实体的属性集合被称为其状态。实体也有行为,如开门、开车、显示油耗、存款、取款、显示账户余额。

类和它的对象通过将状态和行为组合成一个单元来建模一个实体——类抽象状态,而它的对象提供具体的状态值。这种状态和行为的结合被称为封装。在结构化编程中,开发人员关注于通过结构化代码分别对行为进行建模,并通过存储供结构化代码操作的数据项的数据结构对状态进行建模,与此不同,使用类和对象的开发人员关注于通过声明封装状态和行为的类来对实体进行模板化,用这些类中的特定状态值来实例化对象以表示特定的实体,并通过它们的行为与对象进行交互。

在这一节中,我首先向您介绍 Java 表示状态的语言特性,然后向您介绍它表示行为的语言特性。因为一些状态和行为支持类的内部架构,并且不应该对那些想要使用该类的人可见,所以我通过介绍信息隐藏的重要概念来结束这一节。

通过字段表示状态

Java 让你通过字段来表示状态,这些字段是在类体内声明的变量。实体属性通过实例字段描述。因为 Java 也支持与类而不是对象相关联的状态,所以 Java 提供了类字段来描述这个类状态。

首先学习如何声明和访问实例字段,然后学习如何声明和访问类字段。在了解了如何声明只读实例和类字段之后,您将回顾从不同上下文访问字段的规则。

声明和访问实例字段

您可以通过最低限度地指定类型名,后跟命名字段的标识符,再跟一个分号字符()来声明实例字段。)。清单 3-3 展示了一个汽车类,它有三个实例字段声明。

清单 3-3 。用声明一个汽车类制造、型号和 numDoors 实例字段

class Car
{
   String make;
   String model;
   int numDoors;
}

清单 3-3 声明了两个字符串实例字段,名为 make 和 model 。它还声明了一个名为 numDoors 的 int 实例字段。按照惯例,字段名以小写字母开头,多词字段名中每个后续单词的第一个字母大写。

当创建一个对象时,实例字段被初始化为缺省的零值,您在源代码级别将它解释为文字值 false 、’\ u 0000’、 0 、 0L 、 0.0 、 0.0F 或 null (取决于元素类型)。例如,如果您要执行 Car Car = new Car();、 make 和 model 将被初始化为 null 和 numDoors 将被初始化为 0 。

您可以使用成员访问运算符()为对象的实例字段赋值或从中读取值。);左操作数指定对象的引用,右操作数指定要访问的实例字段。清单 3-4 使用这个操作符来初始化一个汽车对象的制造、型号和 numDoors 实例字段。

清单 3-4 。初始化汽车对象的实例字段

public class Car
{
   String make;
   String model;
   int numDoors;

   public static void main(String[] args)
   {
      Car car = new Car();
      car.make = "Toyota";
      car.model = "Camry";
      car.numDoors = 4;
   }
}

清单 3-4 展示了一个 main() 方法,它实例化了 Car 。 car 实例的 make 实例字段被赋予 "Toyota" 字符串,其 model 实例字段被赋予 "Camry" 字符串,其 numDoors 实例字段被赋予整数文字 4 。(字符串的双引号分隔字符串的字符序列,但不是字符串的一部分。)

您可以在声明实例字段时显式初始化该字段,以提供非零默认值,该值将覆盖默认的零值。清单 3-5 展示了这一点。

清单 3-5 。将汽车的 numDoors 实例字段初始化为默认非零值

public class Car
{
   String make;
   String model;
   int numDoors = 4;

   Car()
   {
   }

   public static void main(String[] args)
   {
      Car johnDoeCar = new Car();
      johnDoeCar.make = "Chevrolet";
      johnDoeCar.model = "Volt";
   }
}

清单 3-5 显式初始化 numDoors 到 4 ,因为开发者已经假设这个类建模的大多数汽车有四个门。当 Car 通过 Car() 构造函数初始化时,开发者只需要初始化那些有四个门的汽车的 make 和 model 实例字段。

直接初始化一个对象的实例字段通常不是一个好主意,当我讨论信息隐藏(在本章的后面)的时候你会知道为什么。相反,你应该在类的构造函数中执行这个初始化——参见清单 3-6 。

清单 3-6 。通过构造函数初始化汽车的实例字段

public class Car
{
   String make;
   String model;
   int numDoors;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int nDoors)
   {
      this.make = make;
      this.model = model;
      numDoors = nDoors;
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      Car yourCar = new Car("Mazda", "RX-8", 2);
   }
}

清单 3-6 的 Car 类声明 Car(String make,String model) 和 Car(String make,String model,int nDoors) 构造函数。第一个构造函数让您指定品牌和型号,而第二个构造函数让您指定三个实例字段的值。

第一个构造函数执行 this(make,model,4);将它的 make 和 model 参数的值连同默认值 4 一起传递给第二个构造函数。这样做展示了一种显式初始化实例字段的替代方法,从代码维护的角度来看,这种方法更可取。

Car(String make,String model,int numDoors) 构造函数演示了关键字 this 的另一种用法。具体来说,它演示了构造函数参数与类的实例字段同名的场景。在变量名前加上这个前缀。”使 Java 编译器创建访问实例字段的字节码。比如 this . make = make;将 make 参数的字符串对象引用分配给这个(当前) Car 对象的 make 实例字段。ifmake = make;相反,如果指定了,那么通过将 make 的值赋给自身,它将一事无成;Java 编译器可能不会生成代码来执行不必要的赋值。与此相反,“这个。"对于 numDoors = nDoors 来说是不必要的;赋值,从 nDoors 参数值初始化 numDoors 域。

注意以最小化错误(通过忘记在字段名前加上“ this。"),最好保持字段名和参数名的不同(例如 numDoors 和 nDoors )。或者,您可以在字段名前面加上下划线(例如,_ n 或者)。无论哪种方式,你都不用担心这个。”前缀(又忘了指定)。

声明和访问类字段

在许多情况下,您只需要实例字段。但是,您可能会遇到这样的情况:无论创建了多少个对象,您都需要一个字段的单一副本。

例如,假设您想要跟踪已经创建的 Car 对象的数量,并在该类中引入一个计数器实例字段(初始化为 0)。您还可以在该类的构造函数中放置代码,当创建一个对象时,该代码会将计数器的值增加 1。然而,因为每个对象都有自己的计数器实例字段的副本,所以这个字段的值永远不会超过 1。清单 3-7 通过用静态关键字作为字段声明的前缀,将计数器声明为类字段,解决了这个问题。

清单 3-7 。给汽车增加一个计数器类字段

public class Car
{
   String make;
   String model;
   int numDoors;
   static int counter;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int numDoors)
   {
      this.make = make;
      this.model = model;
      this.numDoors = numDoors;
      counter++; // This code is unsafe because counter can be accessed directly.
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      Car yourCar = new Car("Mazda", "RX-8", 2);
      System.out.println(Car.counter);
   }
}

清单 3-7 的静态前缀意味着计数器字段只有一个副本,而不是每个对象一个副本。当一个类被加载到内存中时,类字段被初始化为缺省的零值。例如,计数器被初始化为 0 。(与实例字段一样,您也可以在其声明中为类字段赋值。)每创建一个对象,计数器就会加 1,这得益于 Car(String make,String model,int numDoors) 构造函数中的 counter++ 表达式。

与实例字段不同,类字段通常通过成员访问操作符直接访问。虽然可以通过对象引用访问类字段(如在 myCar.counter 中),但通常使用类名访问类字段,如在 Car.counter 中。(也更容易看出代码正在访问一个类字段。)

注意因为 main() 方法是清单 3-7 的 Car 类的成员,你可以直接访问 counter ,就像在 System.out.println(counter)中一样;。然而,要在另一个类的 main() 方法的上下文中访问 counter ,您必须指定 Car.counter 。

如果运行清单 3-7 ,你会注意到它输出 2 ,因为已经创建了两个汽车对象。

声明只读实例和类字段

先前声明的字段既可以写入也可以读取。但是,您可能希望声明一个只读字段,例如,一个以 pi (3.14159…)等常数值命名的字段。Java 通过提供保留字 final 让你完成这个任务。

每个对象都接收自己的只读实例字段副本。此字段必须作为字段声明的一部分或在类的构造函数中初始化。当在构造函数中初始化时,只读实例字段被称为空白 final ,因为它没有值,直到在构造函数中给它赋值。因为构造函数可能会给每个对象的 blank final 赋予不同的值,所以这些只读变量并不是真正的常量。

如果您想要一个真正的常量,它是一个对所有对象都可用的只读值,您需要创建一个只读类字段。您可以通过在该字段的声明中包含保留字 static 和 final 来完成这项任务。

清单 3-8 显示了如何声明一个只读的类字段。

清单 3-8 。在雇员类中声明一个真常数

class Employee
{
   final static int RETIREMENT_AGE = 65;
}

清单 3-8 的 RETIREMENT_AGE 声明是编译时间常数的一个例子。因为它的值只有一个副本(由于静态关键字),并且因为这个值永远不会改变(由于 final 关键字),编译器可以通过将常数值插入到所有使用它的计算中来自由地优化编译后的代码。代码运行得更快,因为它不必访问只读的类字段。

查看字段访问规则

前面的字段访问示例可能看起来有些混乱,因为有时您可以直接指定字段的名称,而在其他时候您需要在字段名称前面加上对象引用或类名以及成员访问操作符。以下规则通过指导您如何从各种上下文中访问字段来消除这种混淆:

  • 从与类字段声明相同的类中的任意位置指定类字段的名称。示例:计数器
  • 指定类字段的类的名称,后跟成员访问运算符,再后跟该类外部的类字段的名称。示例: Car.counter
  • 将实例字段的名称指定为与实例字段声明相同的类中的任何实例方法、构造函数或实例初始值设定项(稍后讨论)。示例: numDoors
  • 指定一个对象引用,后面是成员访问操作符,再后面是实例字段的名称,该实例字段来自与实例字段声明相同的类中的任何类方法或类初始化器(稍后讨论),或者来自该类之外。例: Car car =新车();car . numdoors = 2;

尽管最终的规则似乎暗示您可以从类上下文中访问实例字段,但事实并非如此。相反,您是从对象上下文中访问该字段。

前面的访问规则并不详尽,因为还有两种字段访问场景需要考虑:声明一个与实例字段或类字段同名的局部变量(甚至是参数)。在任一场景中,局部变量/参数被称为隐藏(隐藏或屏蔽)字段。

如果您发现您声明了一个隐藏字段的局部变量或参数,您可以重命名该局部变量/参数,或者您可以使用带有保留字 this (实例字段)或类名(类字段)的成员访问运算符来显式标识该字段。例如,清单 3-6 的 Car(String make,String model,int nDoors) 构造函数通过指定诸如 this . make = make;区分实例字段和同名参数。

通过方法表现行为

Java 让你通过方法来表现行为,这些方法是在类的主体中声明的代码块。实体行为通过实例方法来描述。因为 Java 也支持与类而不是对象相关联的行为,所以 Java 提供了类方法来描述这些类行为。

首先学习如何声明和调用实例方法,然后学习如何创建实例方法调用链。接下来,您将了解如何声明和调用类方法,了解关于向方法传递参数的更多细节,并探索 Java 的 return 语句。在学习了如何递归调用方法作为迭代的替代方法,以及如何重载方法之后,您将回顾从不同上下文调用方法的规则。

声明和调用实例方法

您可以通过以下方式声明实例方法:最低限度地指定一个返回类型名称,后跟一个命名该方法的标识符,再跟一个参数列表,最后跟一个大括号分隔的主体。清单 3-9 展示了一个带有 printDetails() 实例方法的 Car 类。

清单 3-9 。在 Car 类中声明一个 printDetails() 实例方法

public class Car
{
   String make;
   String model;
   int numDoors;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int numDoors)
   {
      this.make = make;
      this.model = model;
      this.numDoors = numDoors;
   }

   void printDetails()
   {
      System.out.println("Make = " + make);
      System.out.println("Model = " + model);
      System.out.println("Number of doors = " + numDoors);
      System.out.println();
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      myCar.printDetails();
      Car yourCar = new Car("Mazda", "RX-8", 2);
      yourCar.printDetails();
   }
}

清单 3-9 声明了一个名为 printDetails() 的实例方法。按照惯例,方法名以小写字母开头,多单词方法名中每个后续单词的第一个字母大写。

方法类似于构造函数,因为它们有参数列表。当您呼叫方法时,会将引数传递给这些参数。因为 printDetails() 不接受参数,所以它的参数列表是空的。

注意一个方法的名字和它的参数的数量、类型和顺序被称为它的签名

当一个方法被调用时,其主体中的代码被执行。在 printDetails() 的情况下,该方法的主体执行一系列 System.out.println() 方法调用,以输出其 make 、 model 和 numDoors 实例字段的值。

与构造函数不同,方法被声明为具有返回类型。返回类型标识该方法返回的值的种类(例如, int count() 返回 32 位整数)。当一个方法不返回值时(并且 printDetails() 也不返回值),它的返回类型被替换为关键字 void ,如 void printDetails() 所示。

注意构造函数没有返回类型,因为它们不能返回值。如果一个构造函数可以返回一个任意值,那么 Java 将如何返回这个值呢?毕竟, new 操作符返回一个对象的引用; new 怎么也能返回一个构造函数的值呢?

使用成员访问操作符调用方法:左边的操作数指定对象的引用,右边的操作数指定要调用的方法。例如, myCar.printDetails() 和 yourCar.printDetails() 表达式调用 myCar 和 yourCar 对象上的 printDetails() 实例方法。

编译清单 3-9(【Car.java】)并运行这个应用( java Car )。您应该观察到以下输出,其不同的实例字段值证明了 printDetails() 与一个对象相关联:

Make = Toyota
Model = Camry
Number of doors = 4

Make = Mazda
Model = RX-8
Number of doors = 2

当实例方法被调用时,Java 将一个隐藏的参数传递给该方法(作为参数列表中最左边的参数)。该参数是对调用该方法的对象的引用。它在源代码级别通过保留字 this 表示。不需要在实例字段名前面加上“ this。"因为 Java 编译器会确保使用隐藏参数来访问实例字段,所以每当您试图访问实例字段名称而该名称又不是参数名称时,都会从方法内部调用。

方法调用堆栈

方法调用需要一个方法调用栈(也称为方法调用栈)来跟踪执行必须返回的语句。把方法调用栈想象成自助餐厅中一堆干净托盘的模拟——你从这堆托盘的顶部弹出(移除)干净托盘,洗碗机将把下一个干净托盘推到这堆托盘的顶部。

当一个方法被调用时,虚拟机将它的参数和第一条语句的地址推送到方法调用堆栈上。虚拟机还为方法的局部变量分配堆栈空间。当方法返回时,虚拟机移除局部变量空间,从堆栈中弹出地址和参数,并将执行转移到该地址处的语句。

将实例方法调用链接在一起

两个或多个实例方法调用可以通过成员访问操作符链接在一起,从而产生更紧凑的代码。为了完成实例方法调用链接,你需要稍微不同地重新构建你的实例方法,这在清单 3-10 中有所揭示。

清单 3-10 。实现实例方法,以便对这些方法的调用可以链接在一起

public class SavingsAccount
{
   int balance;

   SavingsAccount deposit(int amount)
   {
      balance += amount;
      return this;
   }

   SavingsAccount printBalance()
   {
      System.out.println(balance);
      return this;
   }

   public static void main(String[] args)
   {
      new SavingsAccount().deposit(1000).printBalance();
   }
}

清单 3-10 显示你必须指定类名作为实例方法的返回类型。 deposit() 和 printBalance() 都必须指定 SavingsAccount 作为返回类型。另外,必须指定 return this;(返回当前对象的引用)作为最后一条语句——我稍后讨论 return 语句。

例如,新建 SavingsAccount()。押金(1000)。print balance();创建一个 SavingsAccount 对象,使用返回的 SavingsAccount 引用调用 SavingsAccount 的 deposit() 实例方法,向储蓄帐户添加一千美元(为了方便起见,我忽略了美分),最后使用 deposit() 返回的 SavingsAccount 引用(同 SavingsAccount 实例)调用 saving

声明和调用类方法

在许多情况下,实例方法就是您所需要的。但是,您可能会遇到需要描述独立于任何对象的行为的情况。

例如,假设您想引入一个工具类(一个由静态 [class]方法组成的类),它的类方法执行各种类型的转换(比如从摄氏度转换到华氏度)。您不希望从该类创建一个对象来执行转换。相反,您只是想调用一个方法并获得它的结果。清单 3-11 通过提供一个带有一对类方法的转换类来解决这个需求。这些方法可以被调用,而不必创建一个转换对象。

清单 3-11 。一个带有一对类方法的转换工具类

class Conversions
{
   static double c2f(double degrees)
   {
      return degrees * 9.0 / 5.0 + 32;
   }

   static double f2c(double degrees)
   {
      return (degrees - 32) * 5.0 / 9.0;
   }
}

清单 3-11 的转换类声明了 c2f() 和 f2c() 方法,用于从摄氏度到华氏度的转换,反之亦然,并返回这些转换的结果。每个方法头(方法签名和其他信息)都以关键字 static 为前缀,将方法转换成类方法。

要执行一个类方法,通常要在它的名字前面加上类名。比如可以执行 conversions . c2f(100.0);找出相当于 100 摄氏度的华氏温度,以及 conversions . f2c(98.6);发现正常体温的摄氏当量。您不需要实例化转换,然后通过该实例调用这些方法,尽管您可以这样做(但这不是好的形式)。

注意每个应用至少有一个类方法。具体来说,应用必须指定 public static void main(String[]args)作为应用的入口点。静态保留字使得这个方法成为一个类方法。(我将在本章后面解释保留字 public 。)

因为类方法不是用引用当前对象的隐藏参数调用的, c2f() 、 f2c() 和 main() 不能访问对象的实例字段或调用其实例方法。这些类方法只能访问类字段和调用类方法。

向方法传递参数

方法调用包括传递给该方法的一系列(零个或多个)参数。Java 通过一种称为按值传递的参数传递方式将参数传递给方法,下面的示例将演示这种方式:

Employee emp = new Employee("John ");
int recommendedAnnualSalaryIncrease = 1000;
printReport(emp, recommendAnnualSalaryIncrease);
printReport(new Employee("Cuifen"), 1500);

按值传递将变量的值(例如,存储在 emp 中的引用值或存储在 recommended annual salary increase 中的 1000 值)或一些其他表达式的值(例如, new Employee("Cuifen") 或 1500 )传递给该方法。

由于传递值,您不能通过此参数的 printReport() 参数从 printReport() 内部将不同的 Employee 对象的引用分配给 emp 。毕竟,您只是将 emp 值的一个副本传递给了该方法。

许多方法和构造函数要求您在调用它们时传递固定数量的参数。然而,Java 也提供了传递可变数量的参数的能力——这样的方法/构造函数通常被称为 varargs 方法 / 构造函数 。若要声明采用可变数量的参数的方法或构造函数,请在该方法/构造函数的最右侧参数的类型名称后指定三个连续的句点。以下示例展示了一个接受可变数量参数的 sum() 方法:

double sum(double. . . values)
{
   int total = 0;
   for (int i = 0; i < values.length; i++)
      total += values[i];
   return total;
}

sum() 的实现合计传递给该方法的参数个数,例如 sum(10.0,20.0) 或 sum(30.0,40.0,50.0) 。(在后台,这些参数存储在一个一维数组中,如 values.length 和 values[i] 所示。)在对这些值求和之后,通过 return 语句返回这个总数。

通过 Return 语句从一个方法返回

不返回值(其返回类型设置为 void )的方法中语句的执行从第一条语句流向最后一条语句。然而,Java 的 return 语句让方法或构造函数在到达最后一个语句之前退出。如清单 3-12 所示,这种形式的 return 语句由保留字 return 后跟一个分号组成。

清单 3-12 。使用 Return 语句从方法中提前返回

public class Employee
{
   String name;

   Employee(String name)
   {
      setName(name);
   }

   void setName(String name)
   {
      if (name == null)
      {
         System.out.println("name cannot be null");
         return;
      }
      else
         this.name = name;
   }

   public static void main(String[] args)
   {
      Employee john = new Employee(null);
   }
}

清单 3-12 的雇员(字符串名称)构造函数调用 setName() 实例方法初始化 name 实例字段。为此提供一个单独的方法是一个好主意,因为它允许您在构造时以及以后初始化实例字段。(也许员工改变了他或她的名字。)

注意当从同一个类中的构造函数或方法调用一个类的实例或类方法时,只需指定方法的名称。不要用成员访问操作符和对象引用或类名作为方法调用的前缀。

setName() 使用 if 语句来检测将空引用分配给名称字段的企图。当检测到这种尝试时,它输出“名称不能为空”错误消息,并提前从该方法返回,以便不能分配空值(并替换先前分配的名称)。

注意使用 return 语句时,您可能会遇到编译器报告“不可达代码”错误信息的情况。当它检测到永远不会执行的代码并不必要地占用内存时,它就会这样做。您可能会在 switch 语句中遇到这个问题。比如,假设你指定 case 2:printUsageInstructions();返回;打破;作为本声明的一部分。编译器在检测到 return 语句后面的 break 语句时会报告一个错误,因为 break 语句是不可访问的;它永远不会被执行。

return 语句的前一种形式在返回值的方法中是非法的。对于这样的方法,Java 提供了 return 的替代版本,允许方法返回值(其类型必须与方法的返回类型相匹配)。以下示例演示了此版本:

double divide(double dividend, double divisor)
{
   if (divisor == 0.0)
   {
      System.out.println("cannot divide by zero");
      return 0.0;
   }
   return dividend / divisor;
}

divide() 使用 if 语句检测将其第一个参数除以 0.0 的尝试,并在检测到此尝试时输出错误消息。此外,它返回 0.0 来表示这种尝试。如果没有问题,则执行除法并返回结果。

注意不能在构造函数中使用这种形式的 return 语句,因为构造函数没有返回类型。

递归调用方法

一个方法通常执行可能包含对其他方法的调用的语句,比如 printDetails() 调用 System.out.println() 。然而,偶尔有一个方法调用本身是很方便的。这个场景被称为递归

例如,假设您需要编写一个方法来返回一个阶乘(一个特定整数之前的所有正整数的乘积)。比如 3!(该!是阶乘的数学符号)等于 3×2×1 或 6。

编写此方法的第一种方法可能由以下示例中的代码组成:

int factorial(int n)
{
   int product = 1;
   for (int i = 2; i <= n; i++)
      product *= i;
   return product;
}

虽然这段代码完成了它的任务(通过迭代), factorial() 也可以按照下面例子的递归方式编写:

int factorial(int n)
{
   if (n == 1)
      return 1; // base problem
   else
      return n * factorial(n - 1);
}

递归方法利用了能够用更简单的术语来表达问题的优势。根据这个例子,最简单的问题,也就是大家熟知的基数问题,是 1!(1).

当一个大于 1 的参数被传递给 factorial() 时,该方法通过用下一个更小的参数值调用自己,将问题分解成一个更简单的问题。最终会达到基数问题。

例如,调用 factorial(4) 会产生下面的表达式堆栈:

4 * factorial(3)
3 * factorial(2)
2 * factorial(1)

最后一个表达式在栈顶。当 factorial(1) 返回 1 时,这些表达式在堆栈开始展开时计算:

  • 2 阶乘(1) 现在变成 21 (2)
  • 3 阶乘(2) 现在变成了 32 (6)
  • 4 阶乘(3) 现在变成了 46 (24)

递归为表达许多问题提供了一种优雅的方式。其他示例包括在基于树的数据结构中搜索特定值,以及在分层文件系统中,查找并输出包含特定文本的所有文件的名称。

注意递归消耗堆栈空间,所以要确保你的递归最终以一个基本问题结束;否则,您将耗尽堆栈空间,您的应用将被迫终止。

重载方法

Java 允许您将名称相同但参数列表不同的方法引入到同一个类中。这个特性被称为方法重载。当编译器遇到方法调用表达式时,它会将被调用方法的参数列表与每个重载方法的参数列表进行比较,以寻找要调用的正确方法。

当两个同名方法的参数列表在参数的数量或顺序上不同时,它们会被重载。比如 Java 的 String 类提供了重载的 int indexOf(int ch) 和 int indexOf(int ch,int fromIndex) 方法。这些方法在参数计数上有所不同。(我在第七章的中探索字符串。)

当至少有一个参数的类型不同时,两个同名的方法被重载。比如 Java 的 java.lang.Math 类提供了重载的静态双 abs(双 a) 和静态 int abs(int a) 方法。一个方法的参数是一个 double;另一个方法的参数是一个 int (我在第七章的中探索数学。)

不能通过仅更改返回类型来重载方法。例如, double sum(double。。。值)和 int sum(double。。。值)不超载。这些方法没有重载是因为编译器在源代码中遇到 sum(1.0,2.0) 时,没有足够的信息来选择调用哪个方法。

查看方法调用规则

前面的方法调用示例可能看起来很混乱,因为有时您可以直接指定方法名,而在其他时候您需要在方法名前面加上对象引用或类名以及成员访问操作符。以下规则通过指导您如何从各种上下文中调用方法来消除这种混淆:

  • 从与类方法相同的类中的任意位置指定类方法的名称。例:c2f(37.0);
  • 指定类方法的类的名称,后跟成员访问运算符,再后跟该类外部的类方法的名称。示例:conversions . c2f(37.0);(你也可以通过一个对象实例调用一个类方法,但是这被认为是不好的形式,因为它隐藏了一个类方法被调用的事实。)
  • 指定实例方法的名称,该名称来自与实例方法在同一类中的任何实例方法、构造函数或实例初始值设定项。例: setName(名称);
  • 指定一个对象引用,后面是成员访问操作符,再后面是实例方法的名称,该实例方法来自与实例方法相同的类内或来自类外的任何类方法或类初始值设定项。例: Car 汽车=新车(“丰田”、“凯美瑞”);car . print details();

尽管后一条规则似乎意味着您可以从类上下文中调用实例方法,但事实并非如此。相反,您可以从对象上下文中调用该方法。

此外,不要忘记确保传递给方法的参数的数量,以及这些参数传递的顺序,并且这些参数的类型与它们在被调用的方法中对应的参数一致。

注意字段访问和方法调用规则合并在表达式 System.out.println()中;,其中最左边的成员访问操作符访问 java.lang.System 类中的 out 类字段(类型为 java.io.PrintStream ),最右边的成员访问操作符调用该字段的 println() 方法。您将在第十一章的中了解 PrintStream ,在第八章的中了解系统。

隐藏信息

每个类 X 都公开了一个接口(一个由构造函数、方法和【可能】字段组成的协议,它们可供从其他类创建的对象使用,用于创建和与 X 的对象通信)。

接口作为一个类和它的客户端之间的单向契约,客户端是外部的构造函数、方法和其他(面向初始化的)类实体(将在本章后面讨论),它们通过调用构造函数和方法以及访问字段(通常是公共静态最终字段或常量)与类的实例进行通信。契约是这样的,类承诺不改变它的接口,这将破坏依赖于该接口的客户端。

X 还提供了一个实现(公开的方法中的代码,以及可选的助手方法和可选的不应该公开的支持字段),它对接口进行编码。辅助方法是辅助暴露方法和不应该暴露的方法。

当设计一个类时,你的目标是公开一个有用的接口,同时隐藏该接口实现的细节。您隐藏了实现,以防止开发人员意外地访问您的类中不属于该类接口的部分,这样您就可以在不破坏客户端代码的情况下自由地更改实现。隐藏实现通常被称为信息隐藏。此外,许多开发人员认为实现隐藏是封装的一部分。

Java 通过提供四个级别的访问控制来支持实现隐藏,其中三个级别通过保留字来表示。您可以使用下列访问控制级别来控制对字段、方法和构造函数的访问,并使用其中两个级别来控制对类的访问:

  • Public :声明为 public 的字段、方法或构造函数可以从任何地方访问。类也可以被声明为公共的。
  • Protected :声明为 protected 的字段、方法或构造函数可以从与成员类相同的包中的所有类以及该类的子类中访问,而不考虑包。(我在第五章中讨论了包。)
  • 私有:声明为私有的字段、方法或构造函数不能从声明它的类之外访问。
  • Package-private :在没有访问控制保留字的情况下,一个字段、方法或构造函数只能被与成员类相同的包中的类访问。非公立班也是如此。公共、保护或私有的缺失意味着包私有。

注意声明为 public 的类必须存储在一个同名的文件中。例如,一个公共图像类必须存储在 Image.java 中。一个源文件只能声明一个公共顶级类。(也可以声明 public 的嵌套类,你将在第五章中学习如何这么做。)

您通常会将您的类的实例字段声明为私有,并提供特殊的公共实例方法来设置和获取它们的值。按照惯例,设置字段值的方法的名称以 set 开头,被称为 setters 。类似地,获取字段值的方法的名称带有前缀 get (或者 is ,对于布尔字段)并且被称为getter。清单 3-13 在一个雇员类声明的上下文中演示了这个模式。

清单 3-13 。接口与实现的分离

public class Employee
{
   private String name;

   public Employee(String name)
   {
      setName(name);
   }

   public void setName(String empName)
   {
      name = empName; // Assign the empName argument to the name field.
   }

   public String getName()
   {
      return name;
   }
}

清单 3-13 展示了一个由公共雇员类,它的公共构造函数,以及它的公共 setter/getter 方法组成的接口。这个类和这些成员可以从任何地方访问。该实现由私有名称字段和构造函数/方法代码组成,只能在雇员类中访问。

当您可以简单地省略 private 并直接访问 name 字段时,这么做似乎毫无意义。但是,假设您被告知要引入一个新的构造函数,它接受单独的姓和名参数,并引入新的方法,将雇员的姓和名设置/获取到这个类中。此外,假设已经确定名字和姓氏将比整个名字被更频繁地访问。清单 3-14 揭示了这些变化。

清单 3-14 。在不影响现有接口的情况下修改实现

public class Employee
{
   private String firstName;
   private String lastName;

   public Employee(String name)
   {
      setName(name);
   }

   public Employee(String firstName, String lastName)
   {
      setName(firstName + " " + lastName);
   }

   public void setName(String name)
   {
      // Assume that the first and last names are separated by a
      // single space character. indexOf() locates a character in a
      // string; substring() returns a portion of a string.
      setFirstName(name.substring(0, name.indexOf(' ')));
      setLastName(name.substring(name.indexOf(' ') + 1));
   }

   public String getName()
   {
      return getFirstName() + " " + getLastName();
   }

   public void setFirstName(String empFirstName)
   {
      firstName = empFirstName;
   }

   public String getFirstName()
   {
      return firstName;
   }

   public void setLastName(String empLastName)
   {
      lastName = empLastName;
   }

   public String getLastName()
   {
      return lastName;
   }
}

清单 3-14 显示出姓名字段已经被删除,取而代之的是新的名字和姓氏字段,这两个字段是为了提高性能而添加的。因为 setFirstName() 和 setLastName() 将比 setName() 被更频繁地调用,并且因为 getFirstName() 和 getLastName() 将比 getName() 被更频繁地调用,所以(在每种情况下)让前两个方法 set/getfirst names 和 lastName 更高效

清单 3-14 还揭示了 setName() 调用 setFirstName() 和 setLastName() ,以及 getName() 调用 getFirstName() 和 getLastName() ,而不是直接访问 firstName 和 lastName 字段。虽然在这个例子中避免直接访问这些字段是不必要的,但是设想另一个实现变化,向 setFirstName() 、 setLastName() 、 getFirstName() 和 getLastName() 添加更多代码;不调用这些方法将导致新代码无法执行。

当雇员的实现从清单 3-13 变为清单 3-14 所示时,客户端代码(实例化并使用一个类的代码,如雇员)不会中断,因为原始接口保持不变,尽管接口已经被扩展。这种缺少破损是由于隐藏了清单 3-13 的实现,尤其是名称字段。

注意 setName() 调用 String 类的 indexOf() 和 substring() 方法。你将在第七章的中了解这些和其他字符串方法。

Java 提供了一个鲜为人知的信息隐藏相关语言特性,让一个对象(或类方法/初始化器)访问另一个对象的私有字段或调用其私有方法。清单 3-15 提供了一个演示。

清单 3-15 。一个对象访问另一个对象的私有字段

public class PrivateAccess
{
   private int x;

   PrivateAccess(int x)
   {
      this.x = x;
   }

   boolean equalTo(PrivateAccess pa)
   {
      return pa.x == x;
   }

   public static void main(String[] args)
   {
      PrivateAccess pa1 = new PrivateAccess(10);
      PrivateAccess pa2 = new PrivateAccess(20);
      PrivateAccess pa3 = new PrivateAccess(10);
      System.out.println("pa1 equal to pa2: " + pa1.equalTo(pa2));
      System.out.println("pa2 equal to pa3: " + pa2.equalTo(pa3));
      System.out.println("pa1 equal to pa3: " + pa1.equalTo(pa3));
      System.out.println(pa2.x);
   }
}

清单 3-15 的 PrivateAccess 类声明了一个名为 x 的 private int 字段。它还声明了一个接受 PrivateAccess 参数的 equalTo() 方法。其思想是将参数对象与当前对象进行比较,以确定它们是否相等。

通过使用 == 运算符来比较参数对象的 x 实例字段的值与当前对象的 x 实例字段的值,当它们相同时返回布尔值 true,从而确定是否相等。令人困惑的是,Java 允许您指定 pa.x 来访问参数对象的私有实例字段。另外, main() 能够通过 pa2 对象直接访问 x 。

我之前介绍了 Java 的四个访问控制级别,并介绍了以下关于私有访问控制级别的声明:“声明为 private 的字段、方法或构造函数不能从声明它的类之外访问。”当你仔细考虑这个声明并检查清单 3-15 时,你会意识到 x 没有被声明它的 PrivateAccess 类之外的类访问。因此,没有违反私有访问控制级别。

唯一可以访问这个私有实例字段的代码是位于 PrivateAccess 类中的代码。如果您试图通过在另一个类的上下文中创建的 PrivateAccess 对象来访问 x ,编译器会报告一个错误。

能够从 PrivateAccess 内部直接访问 x 是一种性能增强;直接访问这个实现细节比调用返回其值的方法更快。

编译 PrivateAccess.java(javac PrivateAccess.java)并运行应用( java PrivateAccess )。您应该观察到以下输出:

pa1 equal to pa2: false
pa2 equal to pa3: false
pa1 equal to pa3: true
20

技巧养成开发有用的接口同时隐藏实现的习惯,因为这将为你在维护你的类时省去很多麻烦。

初始化类和对象

类和对象在使用前需要正确初始化。你已经知道了类字段在类加载后被初始化为缺省的零值,随后可以通过类字段初始化器在声明中给它们赋值来初始化,例如,static int counter = 1;。类似地,当一个对象的内存通过 new 分配时,实例字段被初始化为默认值,随后可以通过实例字段初始化器在它们的声明中给它们赋值来初始化;例如,int num doors = 4;。

已经讨论过的初始化的另一个方面是构造函数,它用于初始化一个对象,通常是通过给各种实例字段赋值,但也能够执行任意代码,例如打开文件和读取文件内容的代码。

Java 提供了两个额外的初始化特性:类初始化器和实例初始化器。在本节向您介绍了这些特性之后,我将讨论所有 Java 初始化器执行工作的顺序。

类初始化器

构造函数执行对象的初始化任务。从类初始化的角度来看,它们的对应物是类初始化器。

一个类初始化器是一个静态前缀的块,它被引入到一个类体中。它用于通过一系列语句初始化一个加载的类。例如,我曾经使用一个类初始化器来加载一个定制的数据库驱动程序类。清单 3-16 显示了加载细节。

清单 3-16 。通过类初始化器加载数据库驱动程序

class JDBCFilterDriver implements Driver
{
   static private Driver d;

   static
   {
      // Attempt to load JDBC-ODBC Bridge Driver and register that
      // driver.
      try
      {
         Class c = Class.forName("sun.jdbc.odbc.JdbcOdbcDriver");
         d = (Driver) c.newInstance();
         DriverManager.registerDriver(new JDBCFilterDriver());
      }
      catch (Exception e)
      {
         System.out.println(e);
      }
   }
   //. . .
}

清单 3-16 的 JDBCFilterDriver 类使用其类初始化器来加载和实例化描述 Java 的 JDBC-ODBC 桥驱动程序的类,并向 Java 的数据库驱动程序注册一个 JDBCFilterDriver 实例。虽然这个清单中面向 JDBC 的代码现在对您来说可能毫无意义,但是这个清单展示了类初始化器的用处。(我在第十四章中讨论 JDBC。)

一个类可以声明类初始化器和类字段初始化器的混合,如清单 3-17 所示。

清单 3-17 。混合类初始值设定项和类字段初始值设定项

class C
{
   static
   {
      System.out.println("class initializer 1");
   }

   static int counter = 1;

   static
   {
      System.out.println("class initializer 2");
      System.out.println("counter = " + counter);
   }
}

清单 3-17 声明了一个名为 C 的类,它指定了两个类初始化器和一个类字段初始化器。当 Java 编译器将声明了至少一个类初始值设定项或类字段初始值设定项的类编译到类文件中时,它会创建一个特殊的 void < clinit > () 类方法,该方法按照所有类初始值设定项和类字段初始值设定项出现的顺序(从上到下)存储它们的字节码等价物。

注意 < clinit > 不是一个有效的 Java 方法名,但是从运行时的角度来看是一个有效的名称。尖括号被选作名称的一部分,以防止与您可能在该类中声明的任何 clinit() 方法发生名称冲突。

对于类 C ,clinit>()将首先包含等同于 System.out.println("类初始化器 1 ")的字节码;,接下来它将包含相当于 static int counter = 1 的字节码;,它将最终包含与 system . out . println(" class initializer 2 ")等价的字节码;system . out . println(" counter = "+counter);。

当类 C 被加载到内存中时, < clinit > () 立即执行并生成以下输出:

class initializer 1
class initializer 2
counter = 1

实例初始化器

不是所有的类都可以有构造函数,当我介绍匿名类时,你会在第五章中发现。对于这些类,Java 提供了实例初始化器来处理实例初始化任务。

一个实例初始化器是一个块,它被引入到一个类主体中,而不是作为一个方法或构造函数的主体被引入。实例初始化器用于通过一系列语句初始化一个对象,如清单 3-18 所示。

清单 3-18 。通过实例初始化器初始化一对数组

class Graphics
{
   double[] sines;
   double[] cosines;

   {
      sines = new double[360];
      cosines = new double[sines.length];
      for (int degree = 0; degree < sines.length; degree++)
      {
         sines[degree] = Math.sin(Math.toRadians(degree));
         cosines[degree] = Math.cos(Math.toRadians(degree));
      }
   }
}

清单 3-18 的图形类使用一个实例初始化器来创建一个对象的正弦和余弦数组,并将这些数组的元素初始化为范围从 0 到 359 度的正弦和余弦。这样做是因为读取数组元素比在其他地方重复调用 Math.sin() 和 Math.cos() 要快;性能很重要。(在第七章我介绍了 Math.sin() 和 Math.cos() 。)

一个类可以声明实例初始化器和实例字段初始化器的混合,如清单 3-19 所示。

清单 3-19 。混合实例初始值设定项和实例字段初始值设定项

class C
{
   {
      System.out.println("instance initializer 1");
   }

   int counter = 1;

   {
      System.out.println("instance initializer 2");
      System.out.println("counter = " + counter);
   }
}

清单 3-19 声明了一个名为 C 的类,它指定了两个实例初始化器和一个实例字段初始化器。当 Java 编译器将一个类编译成 classfile 时,会创建一个特殊的 void < init > () 方法,在没有显式声明构造函数时,代表默认的无参数构造函数;否则,它为每个遇到的构造函数创建一个 < init > () 方法。此外,它在每个 < init > () 方法中存储所有实例初始化器和实例字段初始化器的字节码等价物,按照它们出现的顺序(从上到下)存储在构造函数代码之前。

注意 < init > 不是一个有效的 Java 方法名,但是从运行时的角度来看是一个有效的名称。选择尖括号作为名称的一部分是为了防止与您可能在该类中声明的任何 init() 方法冲突。

对于类 C , < init > () 将首先包含等同于 System.out.println("实例初始化器 1 ")的字节码;,接下来它将包含相当于 int counter = 1 的字节码;,它将最终包含与 System.out.println("实例初始化器 2 ")等价的字节码;system . out . println(" counter = "+counter);。

当 new C() 执行时, < init > () 立即执行并生成以下输出:

instance initializer 1
instance initializer 2
counter = 1

注意你应该很少需要使用实例初始化器,这在业界并不常用。其他开发人员在浏览源代码时可能会错过实例初始化器,并且可能会感到困惑。

初始化顺序

类的主体可以包含类字段初始值设定项、类初始值设定项、实例字段初始值设定项、实例初始值设定项和构造函数的混合。(你应该更喜欢构造函数而不是实例字段初始值设定项,尽管我很抱歉没有始终如一地这样做,并且将实例初始值设定项的使用限制在匿名类中,这在第五章中讨论过。)此外,类字段和实例字段初始化为默认值。理解所有这些初始化发生的顺序对于防止混淆是必要的,所以查看清单 3-20 。

清单 3-20 。一个完整的初始化演示

public class InitDemo
{
   static double double1;
   double double2;
   static int int1;
   int int2;
   static String string1;
   String string2;

   static
   {
      System.out.println("[class] double1 = " + double1);
      System.out.println("[class] int1 = " + int1);
      System.out.println("[class] string1 = " + string1);
      System.out.println();
   }

   {
      System.out.println("[instance] double2 = " + double2);
      System.out.println("[instance] int2 = " + int2);
      System.out.println("[instance] string2 = " + string2);
      System.out.println();
   }

   static
   {
      double1 = 1.0;
      int1 = 1000000000;
      string1 = "abc";
   }

   {
      double2 = 1.0;
      int2 = 1000000000;
      string2 = "abc";
   }

   InitDemo()
   {
      System.out.println("InitDemo() called");
      System.out.println();
   }

   static double double3 = 10.0;
   double double4 = 10.0;

   static
   {
      System.out.println("[class] double3 = " + double3);
      System.out.println();
   }

   {
      System.out.println("[instance] double4 = " + double3);
      System.out.println();
   }

   public static void main(String[] args)
   {
      System.out.println ("main() started");
      System.out.println();
      System.out.println("[class] double1 = " + double1);
      System.out.println("[class] double3 = " + double3);
      System.out.println("[class] int1 = " + int1);
      System.out.println("[class] string1 = " + string1);
      System.out.println();
      for (int i = 0; i < 2; i++)
      {
         System.out.println("About to create InitDemo object");
         System.out.println();
         InitDemo id = new InitDemo();
         System.out.println("id created");
         System.out.println();
         System.out.println("[instance] id.double2 = " + id.double2);
         System.out.println("[instance] id.double4 = " + id.double4);
         System.out.println("[instance] id.int2 = " + id.int2);
         System.out.println("[instance] id.string2 = " + id.string2);
         System.out.println();
      }
   }
}

清单 3-20 的 InitDemo 类声明了双精度浮点原始类型的两个类字段和两个实例字段,整数原始类型的一个类字段和一个实例字段,以及字符串引用类型的一个类字段和一个实例字段。它还引入了一个显式初始化的类字段、一个显式初始化的实例字段、三个类初始值设定项、三个实例初始值设定项和一个构造函数。如果您编译并运行此代码,您将会看到以下输出:

[class] double1 = 0.0
[class] int1 = 0
[class] string1 = null

[class] double3 = 10.0

main() started

[class] double1 = 1.0
[class] double3 = 10.0
[class] int1 = 1000000000
[class] string1 = abc

About to create InitDemo object

[instance] double2 = 0.0
[instance] int2 = 0
[instance] string2 = null

[instance] double4 = 10.0

InitDemo() called

id created

[instance] id.double2 = 1.0
[instance] id.double4 = 10.0
[instance] id.int2 = 1000000000
[instance] id.string2 = abc

About to create InitDemo object

[instance] double2 = 0.0
[instance] int2 = 0
[instance] string2 = null

[instance] double4 = 10.0

InitDemo() called

id created

[instance] id.double2 = 1.0
[instance] id.double4 = 10.0
[instance] id.int2 = 1000000000
[instance] id.string2 = abc

当您结合前面对类初始化器和实例初始化器的讨论来研究这个输出时,您会发现一些关于初始化的有趣事实:

  • 类字段在类加载后立即初始化为默认值或显式值。在一个类加载之后,所有的类字段都被归零为默认值。 < clinit > () 方法内的代码执行显式初始化。
  • 所有的类初始化都发生在 < clinit > () 方法返回之前。
  • 实例字段在对象创建期间初始化为默认值或显式值。当 new 为一个对象分配内存时,它将所有实例字段归零为默认值。一个 < init > () 方法内的代码执行显式初始化。
  • 所有实例初始化都发生在 < init > () 方法返回之前。

此外,因为初始化以自顶向下的方式发生,所以试图在声明类字段之前访问该字段的内容或者试图在声明实例字段之前访问该字段的内容会导致编译器报告非法前向引用

收集垃圾

对象是通过保留字 new 创建的,但是它们是如何被销毁的呢?如果没有销毁对象的方法,它们最终会填满堆的可用空间,应用将无法继续运行。Java 没有为开发人员提供从内存中删除它们的能力。相反,Java 通过提供一个垃圾收集器来处理这个任务,垃圾收集器是在后台运行的代码,偶尔会检查未被引用的对象。当垃圾收集器发现一个未被引用的对象(或者多个相互引用的对象,并且彼此之间没有其他引用——例如,只有 A 引用 B 并且只有 B 引用 A )时,它会将该对象从堆中移除,从而腾出更多的堆空间。

未引用对象是不能从应用内的任何地方访问的对象。例如,新员工(“约翰”、“多伊”);是一个未被引用的对象,因为 new 返回的员工引用被丢弃。相反,引用对象是应用存储至少一个引用的对象。例如, Employee emp =新员工(" John "," Doe ");是一个被引用的对象,因为变量 emp 包含对 Employee 对象的引用。

当应用移除其最后存储的引用时,被引用的对象变得不被引用。例如,如果 emp 是一个局部变量,它包含了对一个雇员对象的唯一引用,那么当声明 emp 的方法返回时,这个对象就变得不被引用了。一个应用也可以通过将 null 赋给它的引用变量来删除一个存储的引用。例如,EMP = null;删除对先前存储在 emp 中的 Employee 对象的引用。

Java 的垃圾收集器消除了不依赖垃圾收集器的 C++实现中的一种内存泄漏。在这些 C++实现中,开发人员必须在动态创建的对象超出范围之前销毁它们。如果它们在毁灭前消失,它们将留在堆中。最终,堆填满,应用停止。

尽管这种形式的内存泄漏在 Java 中不是问题,但一种相关形式的泄漏却是有问题的:不断地创建对象而忘记删除对每个对象的一个引用会导致堆被填满,应用最终会停止运行。这种形式的内存泄漏通常发生在集合(存储对象的基于对象的数据结构)的上下文中,并且对于长时间运行的应用来说是一个主要问题——web 服务器就是一个例子。对于寿命较短的应用,您通常不会注意到这种形式的内存泄漏。

考虑清单 3-21 中的。

清单 3-21 。一个内存泄漏堆栈

public class Stack
{
   private Object[] elements;
   private int top;

   public Stack(int size)
   {
      elements = new Object[size];
      top = −1; // indicate that stack is empty
   }

   public void push(Object o)
   {
      if (top + 1 == elements.length)
      {
         System.out.println("stack is full");
         return;
     }
     elements[++top] = o;
   }

   public Object pop()
   {
      if (top == −1)
      {
         System.out.println("stack is empty");
         return null;
      }
      Object element = elements[top--];
//      elements[top + 1] = null;
      return element;
   }

   public static void main(String[] args)
   {
      Stack stack = new Stack(2);
      stack.push("A");
      stack.push("B");
      stack.push("C");
      System.out.println(stack.pop());
      System.out.println(stack.pop());
      System.out.println(stack.pop());
   }
}

清单 3-21 描述了一个被称为的集合,这是一个按照后进先出的顺序存储元素的数据结构。堆栈对于记忆东西很有用,比如当一个方法停止执行并且必须返回到它的调用者时返回的指令。

Stack 提供了一个 push() 方法,用于将任意对象推送到堆栈的顶部,还提供了一个 pop() 方法,用于按照对象被推的相反顺序将对象从堆栈顶部弹出。

在创建了一个最多可以存储两个对象的堆栈对象后, main() 调用 push() 三次,将三个字符串对象推送到堆栈上。因为堆栈的内部数组只能存储两个对象,所以当 main() 试图推“C”时, push() 会输出一个错误消息。

此时, main() 试图从堆栈中弹出三个对象,将每个对象输出到标准输出设备。前两个 pop() 方法调用成功,但最后一个方法调用失败并输出错误消息,因为调用时堆栈为空。

当您运行此应用时,它会生成以下输出:

stack is full
B
A
stack is empty
null

栈类有一个问题:它泄漏内存。当你将一个对象压入堆栈时,它的引用存储在内部的元素数组中。当您从堆栈中弹出一个对象时,将获得该对象的引用,并且减少 top ,但是该引用将保留在数组中(直到您调用 push() )。

想象一个场景,其中栈对象的引用被分配给一个类字段,这意味着栈对象在应用的生命周期内一直存在。此外,假设您已经将三个 50 兆字节的图像对象压入堆栈,然后将它们弹出堆栈。在使用这些对象之后,您将 null 分配给它们的引用变量,认为它们将在下一次垃圾收集器运行时被垃圾收集。然而,这种情况不会发生,因为堆栈对象仍然维护其对这些对象的引用,因此 150 兆字节的堆空间对应用不可用,并且应用可能会耗尽内存。

这个问题的解决方案是让 pop() 在返回引用之前将 null 显式分配给元素条目。只需取消对元素[top + 1] = null 的注释;清单 3-21 中的行使这种情况发生。

您可能会认为,当不再需要引用变量的被引用对象时,您应该总是将空值分配给引用变量。然而,经常这样做并不能提高性能或者释放大量的堆空间,并且在不小心的时候会导致抛出 Java . lang . nullpointerexception 类的实例。(我将在第五章关于 Java 面向异常的语言特性的中讨论 NullPointerException )。通常在管理自己内存的类中取消引用变量,比如前面提到的 Stack 类。

注意要了解更多关于 Java 5 环境中的垃圾收集,请查看 Oracle 的“Java HotSpot 虚拟机中的内存管理”白皮书(www . Oracle . com/tech network/Java/javase/tech/Memory Management-white paper-1-150020 . pdf)。

重访数组

在第二章的中,我向您介绍了数组,它是内存区域(具体来说,是堆),在大小相等且连续的槽中存储值,称为元素。我还举了几个例子,包括下面这个例子:

char gradeLetters[] = { 'A', 'B', 'C', 'D', 'F' };

这里有一个名为 gradeLetters 的数组变量,它存储了对一个五元素内存区域的引用,该内存区域将字符 A 、 B 、 C 、 D 和 F 存储在连续且大小相等(16 位)的内存位置中。

注意我已经把 [] 括号放在了等级字母之后。尽管这是合法的,但习惯上还是将这些括号放在类型名之后,如 char[] gradeLetters = { 'A ',' B ',' C ',' D ',' F ' };。在本节中,我将演示这两种方法。

您可以通过指定个等级字母[ x ],来访问一个元素,其中 x 是一个标识数组元素的整数,被称为索引;第一个数组元素总是位于索引 0 处。以下示例显示了如何输出和更改第一个元素的值:

System.out.println(gradeLetters[0]); // Output the first grade letter.
gradeLetters[0] = 'a'; // Perhaps you prefer lowercase grade letters.

{ 'A ',' B ',' C ',' D ',' F' } 数组创建语法是语法糖的一个例子(简化语言的语法,使其使用起来更“甜蜜”)。在后台,用新的操作符创建数组,并初始化为这些值,如下所示:

char gradeLetters[] = new char[] { 'A', 'B', 'C', 'D', 'F' };

首先,分配一个五字符的内存区域。接下来,该区域的五个字符元素被初始化为 A 、 B 、 C 、 D 和 F 。最后,对这些元素的引用存储在数组变量 gradeLetters 中。

注意将整数值放在字符后面的方括号中是错误的。比如编译器在遇到 new char[5] { 'A ',' B ',' C ',' D ',' F' }中的 5 时报错;。

您可以将数组视为一种特殊的对象,尽管它不是与类实例是对象相同意义上的对象。这个伪对象有一个单独的只读长度字段,包含数组的大小(元素的数量)。例如, gradeLetters.length 返回 gradeLetters 数组中元素(5)的个数。

虽然您可以使用前两种方法中的任何一种来创建数组,但是您通常会指定第三种方法,这种方法不涉及显式的元素初始化,并且随后会初始化数组。下面的代码演示了这种方法:

char gradeLetters[] = new char[5];

您可以将元素的数量指定为方括号之间的正整数。运算符 new 将每个数组元素的存储位置中的位清零,您在源代码级别将其解释为文字值 false 、 '\u0000' 、 0 、 0L 、 0.0 、 0.0F 或 null (取决于元素类型)。

然后,您可以初始化数组,如下所示:

gradeLetters[0] = 'A';
gradeLetters[1] = 'B';
gradeLetters[2] = 'C';
gradeLetters[3] = 'D';
gradeLetters[4] = 'F';

但是,您可能会发现使用循环执行此任务更方便,如下所示:

for (int i = 0; i < gradeLetters.length; i++)
   gradeLetters[i] = 'A' + i;

前面的例子着重于创建一个数组,它的值共享一个通用的原始类型(字符,由 char 关键字表示)。您还可以创建对象引用的数组。例如,您可以创建一个数组来存储三个图像对象引用,如下所示:

Image[] imArray = { new Image("image0.png"), new Image("image1.png"), new Image("image2.png") };

这里有一个名为 imArray 的数组变量,它存储一个对三元素内存区域的引用,其中每个元素存储一个对 Image 对象的引用。图像对象位于内存中的其他地方。

您可以通过指定 imArray[x]来访问一个图像元素。以下示例假设存在一个返回图像长度(以字节为单位)的 getLength() 方法,并在第一个 Image 对象上调用该方法以返回第一个图像的长度,该长度随后被输出:

System.out.println(imArray[0].getLength());

与前面的 gradeLetters 示例一样,您可以将 new 操作符与语法 sugar initializer 结合起来,如下所示:

Image[] imArray = new Image[] { new Image("image0.png"), new Image("image1.png"),
                                new Image("image2.png") };

最后,您可以使用第三种方法,通过将每个元素中的所有位设置为 0,将每个对象引用初始化为空引用。这种方法演示如下:

Image[] imArray = new Image[3];

因为 new 将每个元素初始化为空引用,所以您必须显式初始化该数组,您可以方便地这样做,如下所示:

for (int i = 0; i < imArray.length; i++)
   imArray[i] = new Image("image" + i + ".png"); // image0.png, image1.png, and so on

“图像”+ i +”。png" 表达式使用字符串连接运算符( + )将图像与存储在变量 i 中的整数值的字符串等效项组合起来。png 。产生的字符串被传递给 Image 的 Image(字符串文件名)构造函数,产生的引用被存储在一个数组元素中。

注意根据循环的长度,在循环上下文中使用字符串连接操作符会导致大量不必要的字符串对象的创建。我会在第七章向你介绍 String 类的时候讨论这个话题。

前面的例子着重于创建一维数组。然而,你也可以创建多维数组(即二维或多维数组)。例如,考虑温度值的二维数组。

虽然您可以使用这三种方法中的任何一种来创建温度数组,但是当这些值变化很大时,第三种方法更可取。以下示例将此数组创建为一个三行两列的双精度浮点温度值表:

double[][] temperatures = new double[3][2];

注意双和温度之间的两组方括号。这两组括号表示二维数组(表格)。还要注意新和双后面的两组方括号。每个集合包含一个正整数值,表示每行的行数( 3 )或列数( 2 )。

注意创建多维数组时,与数组变量相关联的方括号对的数量和 new 后面的方括号对的数量以及类型名必须相同。

创建数组后,可以用合适的值填充它的元素。以下示例通过 Math.random() 将每个 temperatures 元素初始化为随机生成的温度值,该元素被访问为 temperatures【row】【col】,我将在第七章中对此进行解释:

for (int row = 0; row < temperatures.length; row++)
   for (int col = 0; col < temperatures[row].length; col++)
      temperatures[row][col] = Math.random() * 100;

外部 for 循环选择从第 0 行到数组长度的每一行(确定数组中的行数)。内部 for 循环选择从 0 到当前行数组长度的每一列(确定该数组表示的列数)。本质上,您看到的是一个一维行数组,其中每个元素都引用一个一维列数组。

随后,您可以使用另一个 for 循环以表格格式输出这些值,如以下示例所示,该代码不会尝试对齐完美列中的温度值:

for (int row = 0; row < temperatures.length; row++)
{
   for (int col = 0; col < temperatures[row].length; col++)
      System.out.print(temperatures[row][col] + " ");
   System.out.println();
}

Java 提供了创建多维数组的另一种方法,在这种方法中,您可以单独创建每个维度。例如,要以这种方式通过 new 创建以前的二维温度数组,首先创建一维行数组(外部数组),然后创建一维列数组(内部数组),如以下代码所示:

// Create the row array.
double[][] temperatures = new double[3][]; // Note the extra empty pair of brackets.
// Create a column array for each row.
for (int row = 0; row < temperatures.length; row++)
   temperatures[row] = new double[2]; // 2 columns per row

这种数组被称为不规则数组 ,因为每行可以有不同数量的列;该阵列不是矩形的,而是参差不齐的。

注意当创建行数组时,你必须指定一对额外的空括号作为跟随 new 的表达式的一部分。(对于三维数组——表格的一维数组,其中该数组的元素引用行数组——您必须指定两对空括号作为跟随 new 的表达式的一部分。)

练习

以下练习旨在测试您对第三章内容的理解:

  1. 什么是课?
  2. 如何声明一个类?
  3. 什么是对象?
  4. 如何实例化一个对象?
  5. 什么是构造函数?
  6. 是非判断:当一个类没有声明构造函数时,Java 会创建一个默认的无参数构造函数。
  7. 什么是参数表,什么是参数?
  8. 什么是参数列表,什么是参数?
  9. 是非判断:通过指定类名后跟一个参数列表来调用另一个构造函数。
  10. 定义 arity。
  11. 什么是局部变量?
  12. 定义寿命。
  13. 定义范围。
  14. 什么是封装?
  15. 定义字段。
  16. 实例字段和类字段有什么区别?
  17. 什么是空终值,它与真常量有什么不同?
  18. 如何防止字段被隐藏?
  19. 定义方法。
  20. 实例方法和类方法有什么区别?
  21. 定义递归。
  22. 怎么霸王一个方法?
  23. 什么是类初始化器,什么是实例初始化器?
  24. 定义垃圾收集器。
  25. 是非:String[]letters = new String[2]{“A”,“B”};是正确的语法。
  26. 什么是参差不齐的数组?
  27. factorial() 方法提供了一个尾递归的例子,这是递归的一个特例,其中方法的最后一条语句包含一个递归调用,这被称为尾调用。提供另一个尾部递归的例子。
  28. 创建一个包含姓名、作者和国际标准书号(ISBN)字段的 Book 类。提供合适的构造函数和返回字段值的 getter 方法。在这个类中引入一个 main() 方法,该方法创建一个 Book 对象的数组,并遍历该数组,输出每本书的名称、作者和 ISBN。

摘要

类是制造对象的模板,这些对象被命名为代码和数据的集合。类概括了现实世界中的实体,而对象是这些实体在应用级别的具体表现。

new 操作符分配内存来存储由 new 的唯一操作数指定类型的对象。这个运算符后面是一个构造函数,它是一个用于初始化对象的代码块。 new 在分配内存存储对象后立即调用构造函数。

Java 允许您通过字段来表示实体的状态,字段是在类的主体中声明的变量。实体属性通过实例字段来描述。因为 Java 也支持与类而不是对象相关联的状态,所以 Java 提供了类字段来描述这个类状态。

Java 允许你通过方法来表示一个实体的行为,这些方法是在一个类的主体中声明的命名代码块。实体行为通过实例方法来描述。因为 Java 也支持与类而不是对象相关联的行为,所以 Java 提供了类方法来描述这些类行为。

类和对象在使用前需要正确初始化。您已经了解了类字段在类加载后被初始化为缺省的零值,随后可以通过类字段初始化器在声明中给它们赋值来初始化。类似地,当对象的内存通过 new 分配时,实例字段被初始化为默认值,随后可以通过实例字段初始值设定项或构造函数在声明中给它们赋值来初始化。

Java 也支持这个任务的类初始化器和实例初始化器。一个类初始化器是一个被引入到类体中的带前缀的静态块。它用于通过一系列语句初始化一个加载的类。实例初始化器是引入到类体中的块,与作为方法或构造函数的体引入相对。实例初始化器用于通过一系列语句初始化一个对象。

对象是通过保留字 new 创建的,但是它们是如何被销毁的呢?如果没有销毁对象的方法,它们最终会填满堆的可用空间,应用将无法继续运行。Java 没有为开发人员提供从内存中删除它们的能力。相反,Java 通过提供垃圾收集器来处理这项任务,垃圾收集器是在后台运行的代码,偶尔会检查未引用的对象。

您可以将数组视为一种特殊的对象,尽管它不是与类实例是对象相同意义上的对象。这个伪对象有一个单独的只读长度字段,包含数组的大小(元素的数量)。

除了使用在第二章中首次出现的语法糖来创建数组之外,你还可以使用新的操作符来创建数组,可以使用也可以不使用语法糖。

在第四章中,我继续通过检查 Java 语言对继承、多态和接口的支持来探索 Java 语言。

四、探索继承、多态和接口

基于对象的语言是一种封装了对象的状态和行为的语言。Java 对封装的支持(在第三章中讨论)使它成为一种基于对象的语言。然而,Java 也是一种面向对象语言,因为它支持继承和多态(以及封装)。(面向对象语言是基于对象语言的子集。)在第四章中,我将向你介绍 Java 支持继承和多态的语言特性。此外,我还将向您介绍接口,这是 Java 的终极抽象类型机制。

构建类层次结构

我们倾向于用“汽车是交通工具”或“储蓄账户是银行账户”这样的话来对事物进行分类通过做出这些陈述,我们实际上是在说(从软件开发的角度来看)汽车继承了车辆状态(例如,品牌和颜色)和行为(例如,停放和显示里程),储蓄账户继承了银行账户状态(例如,余额)和行为(例如,存款和取款)。汽车、车辆、储蓄账户和银行账户是真实世界实体类别的示例,而继承是相似实体类别之间的层次关系,其中一个类别从至少一个其他实体类别继承状态和行为。从单一类别继承是单一继承,从至少两个类别继承是多重继承

Java 支持单继承和多继承以方便代码重用——为什么要多此一举?Java 支持类上下文中的单一继承,其中一个类通过类扩展从另一个类继承状态和行为。因为涉及到类,Java 把这种继承称为实现继承

Java 还支持接口上下文中的单继承和多继承,在接口上下文中,类通过接口实现从一个或多个接口继承行为模板,或者接口通过接口扩展从一个或多个接口继承行为模板。因为涉及到接口,Java 把这种继承称为接口继承。(我将在本章后面讨论接口。)

注意你通过小心地扩展类,实现接口,扩展接口来重用代码。你从接近你想要的东西开始,延伸它以达到你的目标。你不能通过简单的复制和粘贴来重用代码。复制和粘贴通常会导致冗余(即不可重用)和错误代码。

在这一节中,我首先通过关注类扩展向您介绍 Java 对实现继承的支持。然后我向您介绍一个特殊的类,它位于 Java 类层次结构的顶端。在向您介绍了组合(重用代码的实现继承的替代方法)之后,我将向您展示如何使用组合来克服实现继承的问题。

扩展类

Java 提供了保留字扩展,用于指定两个类之间的层次关系。例如,假设您有一个车辆类,并想引入一个汽车类作为一种车辆。清单 4-1 使用扩展来巩固这种关系。

清单 4-1 。通过扩展关联两个类

class Vehicle
{
   // member declarations
}

class Car extends Vehicle
{
   // member declarations
}

清单 4-1 编纂了一个被称为“是-是”关系的关系:汽车是一种交通工具。在这个关系中, Vehicle 被称为基类父类超类;而汽车又被称为的派生类的子类,或者的子类

注意你不能扩展一个最终类。例如,如果您将车辆声明为最终类车辆,编译器会在遇到类车辆扩展车辆时报告一个错误。当开发人员不希望这些类被扩展(出于安全或其他原因)时,他们会将自己的类声明为 final。

除了能够提供自己的成员声明, Car 还能够从它的 Vehicle 超类继承成员声明。如清单 4-2 所示,非私有继承的成员可以被 Car 类的成员访问。

清单 4-2 。继承成员

class Vehicle
{
   private String make;
   private String model;
   private int year;

   Vehicle(String make, String model, int year)
   {
      this.make = make;
      this.model = model;
      this.year = year;
   }

   String getMake()
   {
      return make;
   }

   String getModel()
   {
      return model;
   }

   int getYear()
   {
      return year;
   }
}

public class Car extends Vehicle
{
   private int numWheels;

  Car(String make, String model, int year, int numWheels)
   {
      super(make, model, year);
      this.numWheels = numWheels;
   }

   public static void main(String[] args)
   {
      Car car = new Car("Ford", "Fiesta", 2009, 4);
      System.out.println("Make = " + car.getMake());
      System.out.println("Model = " + car.getModel());
      System.out.println("Year = " + car.getYear());
      // Normally, you cannot access a private field via an object
      // reference. However, numWheels is being accessed from
      // within a method (main()) that is part of the Car class.
      System.out.println("Number of wheels = " + car.numWheels);
   }
}

清单 4-2 的车辆类声明了私有字段,用于存储车辆的品牌、型号和年份;将这些字段初始化为传递的参数的构造函数;和 getter 方法来检索这些字段的值。

Car 子类提供了一个私有的 numWheels 字段,一个初始化 Car 对象的 Vehicle 和 Car 层的构造函数,以及一个用于测试该类的 main() 类方法。

汽车的构造函数使用保留字超级用面向汽车的参数调用汽车的构造函数,然后初始化汽车的 numWheels 实例字段。 super() 调用类似于指定 this() 调用同一个类中的另一个构造函数,但是调用的是超类构造函数。

注意super()调用只能出现在构造函数中。此外,它必须是构造函数中指定的第一个代码。如果没有指定 super() ,并且超类没有无参数构造函数,编译器会报错,因为当 super() 不存在时,子类构造函数必须调用无参数超类构造函数。

Car 的 main() 方法创建一个 Car 对象,将该对象初始化为特定的品牌、型号、年份和车轮数量。四个 System.out.println() 方法调用随后输出该信息。

前三个 System.out.println() 方法调用通过调用 Car 实例继承的 getMake() 、 getModel() 和 getYear() 方法来检索它们的信息片段。最后的 System.out.println() 方法调用直接访问实例的 numWheels 字段。尽管直接访问一个实例字段通常不是一个好主意(这样做违反了信息隐藏),但是提供这种访问的 Car 的 main() 方法只是为了测试这个类,并不存在于使用这个类的实际应用中。

因为汽车被声明为公共类,清单 4-2 将被存储在一个名为 Car.java 的文件中。因此,执行 javac Car.java 将该源代码编译成 Vehicle.class 和 Car.class 。然后执行 java Car 来测试 Car 类。该执行会产生以下输出:

Make = Ford
Model = Fiesta
Year = 2009
Number of wheels = 4

注意实例不能被修改的类被称为不可变类。车就是一个例子。如果 Car 的 main() 方法(可以直接读取或写入 numWheels )不存在, Car 也将是不可变类的一个例子。同样,一个类不能继承构造函数,也不能继承私有的字段和方法。例如,汽车不继承车辆的构造者,也不继承车辆的私有制造、型号、年份字段。

子类可以覆盖(替换)一个继承的方法,这样子类的方法版本被调用。清单 4-3 向您展示了覆盖方法必须指定与被覆盖方法相同的名称、参数列表和返回类型。

清单 4-3 。重写方法

class Vehicle
{
   private String make;
   private String model;
   private int year;

   Vehicle(String make, String model, int year)
   {
      this.make = make;
      this.model = model;
      this.year = year;
   }

   void describe()
   {
      System.out.println(year + " " + make + " " + model);
   }
}

public class Car extends Vehicle
{
   private int numWheels;

   Car(String make, String model, int year, int numWheels)
   {
      super(make, model, year);
   }

   void describe()
   {
      System.out.print("This car is a "); // Print without newline – see Chapter 1.
      super.describe();
   }

   public static void main(String[] args)
   {
      Car car = new Car("Ford", "Fiesta", 2009, 4);
      car.describe();
   }
}

清单 4-3 的汽车类声明了一个 describe() 方法 ,该方法覆盖了汽车的 describe() 方法,以输出一个面向汽车的描述。该方法使用保留字 super 通过 super.describe()调用车辆的 describe() 方法;。

注意通过在方法名前加上保留字 super 和成员访问操作符,从覆盖子类方法中调用超类方法。如果不这样做,最终会递归调用子类的覆盖方法。使用超级和成员访问操作符从子类中访问非私有超类字段,通过声明同名字段来屏蔽这些字段。

如果您要编译清单 4-3 ( 贾瓦克 Car.java)并运行汽车应用( java 汽车),您会发现汽车的覆盖 describe() 方法代替汽车的覆盖 describe() 方法执行,并输出这辆汽车是 2009 款福特嘉年华。

注意你不能覆盖一个最终方法。例如,如果 Vehicle 的 describe() 方法被声明为 final void describe() ,当遇到试图在 Car 类中覆盖该方法时,编译器会报告一个错误。当开发人员不希望这些方法被覆盖(出于安全或其他原因)时,他们将方法声明为 final 。此外,您不能使重写方法的可访问性低于它所重写的方法。例如,如果 Car 的 describe() 方法被声明为 private void describe() ,编译器会报告一个错误,因为私有访问比默认的包访问更难访问。然而, describe() 可以通过将它声明为 public 而变得更容易访问,比如在 public void describe() 中。

假设您碰巧用下面显示的方法替换了清单 4-3 中的的 describe() 方法:

void describe(String owner)
{
   System.out.print("This car, which is owned by " + owner + ", is a ");
   super.describe();
}

修改后的 Car 类现在有两个 describe() 方法,前面明确声明的方法和从 Vehicle 继承的方法。void describe(String owner)方法不会覆盖 Vehicle 的 describe() 方法。相反,它重载此方法。

Java 编译器通过让您在子类的方法头前加上 @Override 注释来帮助您检测在编译时重载而不是覆盖方法的企图,如以下代码所示(我将在第六章的中讨论注释):

@Override
void describe()
{
   System.out.print("This car is a ");
   super.describe();
}

指定 @Override 告诉编译器这个方法覆盖了另一个方法。如果改为重载方法,编译器会报告错误。如果没有这个注释,编译器不会报告错误,因为方法重载是一个有效的特性。

提示养成用 @Override 注释作为覆盖方法前缀的习惯。这个习惯将帮助你更快地发现重载错误。

在第三章中,我讨论了类和对象的初始化顺序,在这里你学到了类成员总是首先被初始化,并且是自顶向下的顺序(同样的顺序也适用于实例成员)。实现继承增加了几个细节:

  • 超类的类初始化器总是在子类的类初始化器之前执行。
  • 子类的构造函数总是调用超类构造函数来初始化对象的超类层,然后初始化子类层。

Java 对实现继承的支持只允许你扩展一个类。您不能扩展多个类,因为这样做会导致问题。例如,假设 Java 支持多重实现继承,你决定通过清单 4-4 所示的类结构来建模一匹飞马(来自希腊神话)。

清单 4-4 。多重实现继承的虚拟演示

class Bird
{
   void describe()
   {
      // code that outputs a description of a bird's appearance and behaviors
   }
}

class Horse
{
   void describe()
   {
      // code that outputs a description of a horse's appearance and behaviors
   }
}

public class FlyingHorse extends Bird, Horse
{
   public static void main(String[] args)
   {
      FlyingHorse pegasus = new FlyingHorse();
      pegasus.describe();
   }
}

清单 4-4 的类结构揭示了由于鸟和马声明了一个描述()方法而产生的歧义。 FlyingHorse 继承了这些方法中的哪一个?一个相关的歧义来自于同名字段,可能是不同的类型。哪个字段是继承的?

终极超类

一个没有显式扩展另一个类的类隐式扩展了 Java 的对象类(位于 java.lang 包中——我会在下一章讨论包)。例如,清单 4-1 的车辆类扩展了对象,而车辆扩展了车辆。

对象是 Java 的终极超类,因为它是所有其他类的祖先,但它本身并不扩展任何其他类。对象提供了一组其他类继承的公共方法。表 4-1 描述了这些方法。

表 4-1 。对象的方法

方法 描述
对象克隆() 创建并返回当前对象的副本。
布尔等于(对象对象) 确定当前对象是否等于由 obj 标识的对象。
void finalize() 完成当前对象。
阶级<?> getClass() 返回当前对象的类对象。
int hashCode() 返回当前对象的哈希代码。
作废通知() 唤醒一个正在等待当前对象的监视器的线程。
见通知 All() 唤醒所有等待当前对象监视器的线程。
字符串 toString() 返回当前对象的字符串表示形式。
void wait() 使当前线程等待当前对象的监视器,直到它通过 notify() 或 notifyAll() 被唤醒。
无效等待(长超时) 使当前线程在当前对象的监视器上等待,直到它通过 notify() 或 notifyAll() 被唤醒,或者直到指定的超时值(以毫秒为单位)已经过去,以先到者为准。
void wait(长超时,int nanos) 使当前线程在当前对象的监视器上等待,直到它通过 notify() 或 notifyAll() 被唤醒,或者直到指定的超时值(以毫秒为单位)加上毫微秒值(以纳秒为单位)已经过去,以先到者为准。

我稍后将讨论 clone() 、 equals() 、 finalize() 、 hashCode() 和 toString() 方法,但是将 notify() 、 notifyAll() 和 wait() 方法的讨论推迟到第八章进行。

克隆

clone() 方法克隆(复制)一个对象而不调用构造函数。它将每个原语或引用字段的值复制到它在克隆中的对应物,这个任务被称为浅复制浅克隆 。清单 4-5 展示了这种行为。

清单 4-5 。浅克隆一个雇员对象

public class Employee implements Cloneable
{
   String name;
   int age;

   Employee(String name, int age)
   {
      this.name = name;
      this.age = age;
   }

   public static void main(String[] args) throws CloneNotSupportedException
   {
      Employee e1 = new Employee("John Doe", 46);
      Employee e2 = (Employee) e1.clone();
      System.out.println(e1 == e2); // Output: false
      System.out.println(e1.name == e2.name); // Output: true
   }
}

清单 4-5 声明了一个雇员类,带有姓名和年龄实例字段以及一个用于初始化这些字段的构造函数。 main() 方法使用此构造函数将一个新的 Employee 对象的这些字段的副本初始化为 John Doe 和 46 。

注意一个类必须实现 java.lang.Cloneable 接口,否则它的实例不能通过 Object 的 clone() 方法进行浅层克隆——该方法执行运行时检查,查看该类是否实现了 Cloneable 。(我将在本章后面讨论接口。)如果一个类没有实现 Cloneable , clone() 抛出 Java . lang . clonenotsupportedexception。(因为 CloneNotSupportedException 是一个被检查的异常,所以清单 4-5 需要通过将 throws CloneNotSupportedException 附加到 main() 方法的头来满足编译器的要求。我将在下一章讨论异常。)java.lang.String 类就是一个没有实现 Cloneable 的类的例子;因此,字符串对象不能被浅克隆。

将 Employee 对象的引用赋给局部变量 e1 后, main() 调用该变量上的 clone() 方法复制该对象,然后将结果引用赋给变量 e2 。因为 clone() 返回对象,所以需要 (Employee) 转换。

为了证明引用被分配给 e1 和 e2 的对象是不同的, main() 接下来通过 == 比较这些引用,并输出布尔结果,结果恰好为假。为了证明 Employee 对象是浅克隆的, main() 接下来通过 == 比较两个 Employee 对象的 name 字段中的引用,并输出布尔结果,结果恰好为真。

注意 对象的 clone() 方法最初被指定为 public 方法,这意味着可以从任何地方克隆任何对象。出于安全原因,这个访问后来被更改为 protected ,这意味着只有与要调用其 clone() 方法的类在同一个包中的代码,或者这个类的子类中的代码(不考虑包),才能调用 clone() 。

浅层克隆并不总是可取的,因为原始对象及其克隆通过它们的等效引用字段引用同一个对象。例如,清单 4-5 的两个雇员对象中的每一个都通过其名称字段引用同一个字符串对象。

虽然对于实例不可变的字符串来说不是问题,但是通过克隆的引用字段改变可变对象会导致原始(非克隆)对象通过其引用字段看到相同的改变。例如,假设您向雇员添加了一个名为雇佣日期的引用字段。该字段的类型为日期,具有年、月和日实例字段。因为日期是可变的,所以您可以在分配给雇佣日期的日期实例中更改这些字段的内容。

现在,假设您计划更改克隆的日期,但希望保留原始的雇员对象的日期。使用浅层克隆无法做到这一点,因为原始的雇员对象也可以看到这一变化。要解决这个问题,您必须修改克隆操作,以便它为雇员克隆的雇佣日期字段分配一个新的日期引用。这个任务被称为深度复制深度克隆 ,在清单 4-6 中演示。

清单 4-6 。深度克隆雇员对象

class Date
{
   int year, month, day;

   Date(int year, int month, int day)
   {
      this.year = year;
      this.month = month;
      this.day = day;
   }
}

public class Employee implements Cloneable
{
   String name;
   int age;
   Date hireDate;

   Employee(String name, int age, Date hireDate)
   {
      this.name = name;
      this.age = age;
      this.hireDate = hireDate;
   }

   @Override
   protected Object clone() throws CloneNotSupportedException
   {
      Employee emp = (Employee) super.clone();
      if (hireDate != null) // no point cloning a null object (one that doesn't exist)
         emp.hireDate = new Date(hireDate.year, hireDate.month, hireDate.day);
      return emp;
   }

   public static void main(String[] args) throws CloneNotSupportedException
   {
      Employee e1 = new Employee("John Doe", 46, new Date(2000, 1, 20));
      Employee e2 = (Employee) e1.clone();
      System.out.println(e1 == e2); // Output: false
      System.out.println(e1.name == e2.name); // Output: true
      System.out.println(e1.hireDate == e2.hireDate); // Output: false
      System.out.println(e2.hireDate.year + " " + e2.hireDate.month + " " +
                         e2.hireDate.day); // Output: 2000 1 20
   }
}

清单 4-6 声明日期和员工班次。 Date 类声明了 year 、 month 和 day 字段以及一个构造函数。(您可以在一行中声明一个逗号分隔的变量列表,前提是这些变量共享相同的类型,在本例中是 int 。)

Employee 覆盖 clone() 方法来深度克隆 hireDate 字段。该方法首先调用对象的 clone() 方法来浅克隆当前 Employee 对象的实例字段,然后将新对象的引用存储在 emp 中。假设 hireDate 不包含空引用,它接下来将新的 Date 对象的引用分配给 emp 的 hireDate 字段;该对象的字段被初始化为与原始 Employee 对象的 hireDate 实例相同的值。

此时,您有了一个雇员克隆,它具有浅克隆的姓名和年龄字段以及深克隆的雇佣日期字段。 clone() 方法通过返回这个雇员克隆来结束。

注意如果你没有从一个被覆盖的 clone() 方法中调用对象的 clone() 方法(因为你更喜欢深度克隆引用字段并自己对非引用字段进行浅层复制),那么包含被覆盖的 clone() 方法的类就没有必要实现可克隆的,但是为了一致性,它应该实现这个接口。字符串没有覆盖 clone() ,所以字符串对象不能被深度克隆。

相等

== 和!= 运算符比较两个原始值(如整数)是否相等( == )或不相等()!= )。这些操作符还比较两个引用,看它们是否引用同一个对象。后一种比较被称为 身份检查

不能使用 == 和!= 判断两个对象在逻辑上是否相同(或不相同)。例如,具有相同字段值的两个汽车对象在逻辑上是等价的。但是, == 报告它们不相等,因为它们的引用不同。

因为 == 和!= 尽可能快地执行比较,因为需要快速执行字符串比较(尤其是在对大量字符串进行排序时),所以 String 类包含特殊支持,允许通过 == 和比较文字字符串和字符串值常量表达式!= 。(我将在第七章的中介绍字符串时讨论这种支持。)以下语句演示了这些比较:

System.out.println("abc" == "abc"); // Output: true
System.out.println("abc" == "a" + "bc"); // Output: true
System.out.println("abc" == "Abc"); // Output: false
System.out.println("abc" != "def"); // Output: true
System.out.println("abc" == new String("abc")); // Output: false

认识到除了引用相等还需要支持逻辑相等,Java 在对象类中提供了一个 equals() 方法。因为这个方法默认比较引用,所以您需要覆盖 equals() 来比较对象内容。

在覆盖 equals() 之前,确保这是必要的。例如,Java 的 java.lang.StringBuffer 类不会覆盖 equals() 。也许这个类的设计者认为没有必要确定两个 StringBuffer 对象在逻辑上是否等价。

您不能用任意代码覆盖 equals() 。这样做可能会给应用带来灾难性的后果。相反,您需要遵守 Java 文档中为该方法指定的契约,这将在下面介绍。

equals() 方法实现了非空对象引用的等价关系:

  • 它是自反的:对于任何非空的参考值 xx 。equals( x ) 返回 true。
  • 对称:对于任意非空参考值 xyx 。equals(y)返回 true 当且仅当 y 。equals(x)返回 true。
  • 是过渡的:对于任意非空参考值 xyz ,if x 。equals(y)返回 true, y 。equals(z)返回 true,则 x 。equals(z)返回 true。
  • 一致:对于任意非空参考值 xy ,多次调用x。equals(y)始终返回 true 或始终返回 false,前提是在对象的 equals() 比较中使用的信息没有被修改。
  • 对于任意非空参考值 xx 、。equals(null) 返回 false。

尽管这份合同看起来有点吓人,但满足它并不难。为了证明,看看清单 4-7 的点类中 equals() 方法的实现。

清单 4-7 。逻辑上比较点对象

public class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Point))
         return false;
      Point p = (Point) o;
      return p.x == x && p.y == y;
   }

   public static void main(String[] args)
   {
      Point p1 = new Point(10, 20);
      Point p2 = new Point(20, 30);
      Point p3 = new Point(10, 20);
      // Test reflexivity
      System.out.println(p1.equals(p1)); // Output: true
      // Test symmetry
      System.out.println(p1.equals(p2)); // Output: false
      System.out.println(p2.equals(p1)); // Output: false
      // Test transitivity
      System.out.println(p2.equals(p3)); // Output: false
      System.out.println(p1.equals(p3)); // Output: true
      // Test nullability
      System.out.println(p1.equals(null)); // Output: false
      // Extra test to further prove the instanceof operator's usefulness.
      System.out.println(p1.equals("abc")); // Output: false
   }
}

清单 4-7 的覆盖 equals() 方法以 if 语句开始,该语句使用操作符的实例来确定传递给参数 o 的变量是否是 Point 类的实例。如果不是,If 语句执行返回 false。

点表达式的 o 实例满足契约的最后一部分:对于任意非空参考值 xx 。equals(null) 返回 false。因为空引用不是任何类的实例,所以将该值传递给 equals() 会导致表达式计算为 false。

Point 表达式的 o instance 还可以防止在向 equals() 传递除了 Point 对象之外的对象时,通过表达式 (Point) o 抛出 Java . lang . classcastexception 实例。(我将在下一章讨论异常。)

在转换之后,通过表达式 p.x == x & & p.y == y ,仅允许点与其他点进行比较,从而满足契约的自反性、对称性和传递性要求。

通过确保 equals() 方法是确定性的,满足了最终的契约要求,即一致性。换句话说,这个方法不依赖于任何可能随方法调用而改变的字段值。

提示通过首先使用 == 来确定 o 的引用是否标识当前对象,可以优化耗时的 equals() 方法的性能。只需指定 if (o == this)返回 true 作为 equals() 方法的第一条语句。这种优化在清单 4-7 的 equals() 方法中是不必要的,该方法具有令人满意的性能。

在重写等于()时,务必重写 hashCode() 方法 。在清单 4-7 中我没有这样做,因为我还没有正式引入 hashCode() 。

最终确定

终结是指通过 finalize() 方法进行清理,该方法被称为终结器。 finalize() 方法的 Java 文档声明 finalize() 是“当垃圾收集器确定不再有对对象的引用时,由垃圾收集器在对象上调用的”。一个子类覆盖了 finalize() 方法来释放系统资源或执行其他清理。

对象的版本 finalize() 什么都不做;您必须用任何需要的清理代码重写此方法。因为在应用终止之前,虚拟机可能永远不会调用 finalize() ,所以您应该提供一个显式的清理方法,并让 finalize() 调用这个方法,作为安全网,以防这个方法没有被调用。

注意永远不要依赖 finalize() 来释放有限的资源,比如文件描述符。例如,如果一个应用对象打开文件,期望它的 finalize() 方法将关闭它们,当一个缓慢的虚拟机调用 finalize() 很慢时,应用可能发现自己无法打开额外的文件。让这个问题变得更糟的是, finalize() 可能会在另一个虚拟机上被更频繁地调用,导致这个太多打开文件的问题没有暴露出来。开发人员可能会错误地认为应用在不同的虚拟机上表现一致。

如果你决定覆盖 finalize() ,你的对象的子类层必须给它的超类层一个执行终结的机会。您可以通过指定 super.finalize()来完成这项任务;作为方法中的最后一条语句,如下例所示:

protected void finalize() throws Throwable
{
   try
   {
      // Perform subclass cleanup.
   }
   finally
   {
      super.finalize();
   }
}

该示例的 finalize() 声明将 throws Throwable 附加到方法头,因为清理代码可能会抛出异常。如果抛出异常,执行离开方法,在没有 try-finally 的情况下,super . finalize();从不执行。(我将在第五章的中讨论异常并最终尝试。)

为了防止这种可能性,子类的清理代码在保留字 try 后面的块中执行。如果抛出异常,Java 的异常处理逻辑会执行跟在 finally 保留字和 super.finalize()后面的块;执行超类的 finalize() 方法。

注意finalize()方法经常被用来执行复活(使一个未被引用的对象被引用)以实现对象池,当这些对象创建起来很昂贵(时间方面)时,这些对象池回收相同的对象(数据库连接对象就是一个例子)。

当您将此(对当前对象的引用)赋给类或实例字段(或另一个长期变量)时,就会发生复活。例如,您可以指定 r = this;在 finalize() 内将标识为 this 的未引用对象分配给名为 r 的类字段。

由于复活的可能性,对于覆盖了 finalize() 的对象的垃圾收集会有严重的性能损失。

不能再次调用复活的对象的终结器。

散列码

hashCode() 方法返回一个 32 位整数,标识当前对象的散列码,一个对潜在的大量数据应用数学函数得到的小值。这个值的计算被称为哈希

当覆盖 equals() 时,您必须覆盖 hashCode() ,并且根据下面的契约,在 hashCode() 的 Java 文档中指定:

  • 在 Java 应用的执行过程中,只要对同一对象多次调用, hashCode() 方法必须始终返回相同的整数,前提是在对象的 equals(Object) 比较中使用的信息没有被修改。这个整数不需要从一个应用的一次执行到同一应用的另一次执行保持一致。
  • 如果根据 equals(Object) 方法,两个对象相等,那么在这两个对象上调用 hashCode() 方法必须产生相同的整数结果。
  • 根据 equals(Object) 方法,如果两个对象不相等,那么在这两个对象上调用 hashCode() 方法必须产生不同的整数结果。但是,程序员应该知道,为不相等的对象生成不同的整数结果可能会提高哈希表的性能。

不遵守这个契约,你的类的实例将不能与 Java 的基于散列的集合框架类一起正常工作,比如 java.util.HashMap 。(我会在第九章讨论 HashMap 和其他集合框架类。)

如果你覆盖了 equals() 而没有覆盖 hashCode() ,那么最重要的是违反了契约中的第二条:相等对象的 hash 码也必须相等。这种违反可能会导致严重的后果,如下例所示:

java.util.Map<Point, String> map = new java.util.HashMap<Point, String>();
map.put(p1, "first point");
System.out.println(map.get(p1)); // Output: first point
System.out.println(map.get(new Point(10, 20))); // Output: null

假设这个例子的语句被附加到清单 4-7 的 main() 方法中——Java . util .前缀和 < Point,String > 与包和泛型有关,我将在第五章和第六章中讨论。

在 main() 创建其 Point 对象并调用其 System.out.println() 方法后,它执行该示例的语句,这些语句执行以下任务:

  • 第一条语句实例化了 HashMap ,它位于 java.util 包中。
  • 第二条语句调用 HashMap 的 put() 方法来存储清单 4-7 的 p1 对象键和 HashMap 中的“第一点”值。
  • 第三条语句通过 hashmap 的 get() 方法检索其 Point key 逻辑上等于 p1 的 HashMap 条目的值。
  • 第四条语句相当于第三条语句,但返回空引用,而不是“第一点”。

虽然对象 p1 和 Point(10,20) 在逻辑上是等价的,但是这些对象具有不同的哈希代码,导致每个对象引用哈希表中不同的条目。如果一个对象没有存储(通过 put() )在那个条目中, get() 返回 null。

纠正这个问题需要覆盖 hashCode() 来为逻辑上等价的对象返回相同的整数值。当我在第九章的中讨论散列表时,我会告诉你如何完成这个任务。

字符串表示

方法返回当前对象的基于字符串的表示。这种表示默认为对象的类名,后面跟有 @ 符号,后面跟有对象散列码的十六进制表示。

例如,如果您要执行 system . out . println(P1);输出清单 4-7 的 p1 对象,你会看到一行类似于 Point@3e25a5 的输出。( System.out.println() 在后台调用 p1 继承的 toString() 方法。)

您应该努力覆盖 toString() 以便它返回一个简洁但有意义的对象描述。例如,你可以在清单 4-7 的点类中声明一个 toString() 方法,如下所示:

@Override
public String toString()
{
   return "(" + x + ", " + y + ")";
}

这次执行 system . out . println(P1);产生更有意义的输出,比如 (10,20) 。

构图

实现继承和组合提供了两种不同的重用代码的方法。如您所知,实现继承涉及用新类扩展一个类,这是基于它们之间的“是-a”关系:例如,一辆汽车是一辆汽车。

另一方面, composition 关注于从其他类中合成类,这是基于它们之间的“has-a”关系。例如,汽车有一个发动机、车轮 s 和一个转向轮。

在这一章中你已经看到了作文的例子。例如,清单 4-2 的汽车类包括串制造和串型号字段。清单 4-8 的 Car 类提供了另一个组合的例子。

清单 4-8 。一个汽车类,它的实例由其他对象组成

class Car extends Vehicle
{
   private Engine engine; // bicycles don't have engines
   private Wheel[] wheels; // boats don't have wheels
   private SteeringWheel steeringWheel; // hang gliders don't have steering wheels
}

清单 4-8 展示了组合和实现继承并不互相排斥。虽然没有显示,汽车继承了其汽车超类的各种成员,此外还提供了自己的引擎、车轮和方向盘字段。

实现继承的问题是

实现继承有潜在的危险,尤其是当开发人员对超类没有完全的控制权,或者超类没有考虑到扩展而设计和记录的时候。

问题是实现继承破坏了封装。子类依赖于超类中的实现细节。如果这些细节在超类的新版本中发生变化,子类可能会被破坏,即使子类没有被改变。

例如,假设您已经购买了一个 Java 类库,其中一个类描述了一个约会日历。虽然您没有访问这个类的源代码的权限,但是假设清单 4-9 描述了它的部分代码。

清单 4-9 。约会日历类

public class ApptCalendar
{
   private final static int MAX_APPT = 1000;
   private Appt[] appts;
   private int size;

   public ApptCalendar()
   {
      appts = new Appt[MAX_APPT];
      size = 0; // redundant because field automatically initialized to 0
                // adds clarity, however
   }

   public void addAppt(Appt appt)
   {
      if (size == appts.length)
         return; // array is full
      appts[size++] = appt;
   }

   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         addAppt(appts[i]);
   }
}

清单 4-9 的 ApptCalendar 类存储了一个约会数组,每个约会由一个 Appt 实例描述。对于这个讨论来说, Appt 的细节无关紧要。它可能像类 Appt {} 一样微不足道。

假设您想在一个文件中记录每个约会。因为没有提供日志记录功能,所以您用清单 4-10 的 LoggingApptCalendar 类扩展了 ApptCalendar ,该类在重写 addAppt() 和 add apput()方法时添加了日志记录行为。

清单 4-10 。扩展约会日历类

public class LoggingApptCalendar extends ApptCalendar
{
   // A constructor is not necessary because the Java compiler will add a
   // noargument constructor that calls the superclass's noargument
   // constructor by default.

   @Override
   public void addAppt(Appt appt)
   {
      Logger.log(appt.toString());
      super.addAppt(appt);
   }

   @Override
   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         Logger.log(appts[i].toString());
      super.addAppts(appts);
   }
}

清单 4-10 的 LoggingApptCalendar 类依赖于一个 Logger 类,它的 void log(String msg) 类方法将一个字符串记录到一个文件中(细节并不重要)。请注意使用 toString() 将 Appt 对象转换为 String 对象,然后将其传递给 log() 。

虽然这个类看起来还可以,但它并不像您预期的那样工作。假设您实例化了这个类,并通过 add apput()向该实例添加了几个 Appt 实例,如下所示:

LoggingApptCalendar lapptc = new LoggingApptCalendar();
lapptc.addAppts(new Appt[] { new Appt(), new Appt(), new Appt() });

如果还加一个 system . out . println(msg);方法调用记录器的 log(String msg) 方法,输出这个方法的参数,你会发现 log() 一共输出了六条消息;预期的三条消息(每个 Appt 对象一条)都是重复的。

当调用 LoggingApptCalendar 的 add apput()方法时,它首先为传递给 add apput()的 apput 数组中的每个 Appt 实例调用 Logger.log() 。该方法然后通过 super . add appendar(appapps)调用 ApptCalendar 的 add apput()方法;。

ApptCalendar 的 add apput()方法在其 apput 数组参数中为每个 Appt 实例调用 LoggingApptCalendar 的覆盖 addAppt() 方法。 addAppt() 执行 logger . log(appt . tostring());来记录它的 appt 参数的字符串表示,最后会有三条额外的记录消息。

如果您没有覆盖 add apparatus()方法,这个问题就会消失。然而,子类将被绑定到一个实现细节: ApptCalendar 的 add apput()方法调用 addAppt() 。

当细节没有被记录时,依赖于实现细节并不是一个好主意。(我之前说过,你无权访问 ApptCalendar 的源代码。)当一个细节没有被记录时,它可以在类的新版本中改变。

因为一个基类的改变会破坏一个子类,这个问题被称为脆弱基类问题 。脆弱性的一个相关原因也与重写方法有关,它发生在新方法被添加到后续版本的超类中时。

例如,假设一个新版本的库在 ApptCalendar 类中引入了一个新的 public void addAppt(Appt appt,boolean unique) 方法。当 unique 为 false 时,该方法将 appt 实例添加到日历中;并且,当 unique 为真时,它只在之前没有添加的情况下添加 appt 实例。

因为这个方法是在 LoggingApptCalendar 类创建之后添加的, LoggingApptCalendar 不会通过调用 Logger.log() 来覆盖新的 addAppt() 方法。因此,传递给新的 addAppt() 方法的 Appt 实例不会被记录。

这里还有另一个问题:你在子类中引入了一个不在超类中的方法。超类的新版本提供了一个匹配子类方法签名和返回类型的新方法。你的子类方法现在覆盖了超类方法,并且可能不满足超类方法的契约。

有一种方法可以让这些问题消失。不要扩展超类,而是在新类中创建一个私有字段,并让这个字段引用超类的一个实例。这个任务演示了组合,因为您正在新类和超类之间形成一个“has-a”关系。

此外,让每个新类的实例方法通过保存在私有字段中的超类实例调用相应的超类方法,并返回被调用方法的返回值。这个任务被称为转发,新方法被称为转发方法

清单 4-11 展示了一个改进的 LoggingApptCalendar 类,它使用组合和转发来永远消除脆弱的基类问题和未预料到的方法覆盖的额外问题。

清单 4-11 。合成的日志约会日历类

public class LoggingApptCalendar
{
   private ApptCalendar apptCal;

   public LoggingApptCalendar(ApptCalendar apptCal)
   {
      this.apptCal = apptCal;
   }

   public void addAppt(Appt appt)
   {
      Logger.log(appt.toString());
      apptCal.addAppt(appt);
   }

   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         Logger.log(appts[i].toString());
      apptCal.addAppts(appts);
   }
}

清单 4-11 的 LoggingApptCalendar 类不依赖于 ApptCalendar 类的实现细节。您可以向 ApptCalendar 添加新方法,它们不会破坏 LoggingApptCalendar 。

注意 LoggingApptCalendar 是一个包装类的例子,该类的实例包装其他实例。每个 LoggingApptCalendar 实例包装一个 ApptCalendar 实例。 LoggingApptCalendar 也是装饰设计模式的一个例子,在 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

什么时候应该扩展一个类,什么时候应该使用包装类?当超类和子类之间存在“is-a”关系时,扩展一个类,并且要么你对超类有控制权,要么超类已经为类扩展而设计和记录。否则,使用包装类。

“类扩展的设计和文档”是什么意思?设计意味着提供受保护的方法,这些方法与类的内部工作挂钩(以支持编写高效的子类),并确保构造函数和 clone() 方法永远不会调用可重写的方法。文档意味着清楚地陈述重写方法的影响。

注意包装类不应该在回调框架中使用,在一个对象框架中,一个对象将自己的引用传递给另一个对象(通过 this ),这样后一个对象可以在以后调用前一个对象的方法。这种“回调前一个对象的方法”被称为回调。因为被包装的对象不知道它的包装类,所以它只传递它的引用(通过 this ),结果回调不涉及包装类的方法。

改变形式

一些现实世界的实体可以改变它们的形态。例如,水(相对于星际空间而言,在地球上)天然是液体,但冷冻时会变成固体,加热到沸点时会变成气体。像蝴蝶这样经历蜕变的昆虫是另一个例子。

这种改变形式的能力被称为多态性,对于用编程语言建模非常有用。例如,绘制任意形状的代码可以通过引入单个形状类及其 draw() 方法,并通过为每个圆形实例、矩形实例以及存储在数组中的其他形状实例调用该方法来更简洁地表达。当形状的 draw() 方法被数组实例调用时,被调用的是圆形、矩形或其他形状实例的 draw() 方法。形的画()法有很多种形式。换句话说,这个方法是多态的。

Java 支持四种多态性 :

  • 强制:一个操作通过隐式的类型转换服务于多种类型。例如,除法可以让您将一个整数除以另一个整数,或将一个浮点值除以另一个浮点值。如果一个操作数是整数,另一个操作数是浮点值,编译器会强制(隐式转换)整数为浮点值,以防止类型错误。(没有支持整数操作数和浮点操作数的除法运算。)将子类对象引用传递给方法的超类参数是强制多态的另一个例子。编译器将子类类型强制为超类类型,以限制对超类的操作。
  • 重载:相同的操作符或方法名可以在不同的上下文中使用。例如, + 可用于执行整数加法、浮点加法或字符串连接,具体取决于其操作数的类型。同样,多个同名的方法可以出现在一个类中(通过声明和/或继承)。
  • 参数化:在一个类声明中,一个字段名可以关联不同的类型,一个方法名可以关联不同的参数和返回类型。然后,字段和方法可以在每个类实例中采用不同的类型。例如,一个字段可能是类型 java.lang.Integer 并且一个方法可能在一个类实例中返回一个整数,并且同一个字段可能是类型字符串并且同一个方法可能在另一个类实例中返回一个字符串。Java 通过泛型支持参数多态,我将在第六章中讨论。
  • 子类型:一个类型可以作为另一个类型的子类型。当子类型实例出现在超类型上下文中时,对子类型实例执行超类型操作会导致该操作的子类型版本执行。例如,假设圆是点的子类,并且两个类都包含一个 draw() 方法。将 Circle 实例分配给 Point 类型的变量,然后通过该变量调用 draw() 方法,导致 Circle 的 draw() 方法被调用。

许多开发人员不认为强制和重载是有效的多态类型。他们认为强制和重载只不过是类型转换和语法糖。相反,参数和子类型被认为是有效的多态类型。

在这一节中,我将通过首先检查向上转换和后期绑定来关注子类型多态性。然后,我将向您介绍抽象类和抽象方法、向下转换和运行时类型标识,以及协变返回类型。

上传和后期绑定

清单 4-7 的点类将一个点表示为一个 x-y 对。因为圆(在本例中)是一个表示其中心的 x-y 对,并且半径表示其范围,所以您可以用一个引入了半径字段的圆类来扩展点。查看清单 4-12 。

清单 4-12 。一个圆类扩展了点类

class Circle extends Point
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }
}

清单 4-12 的圆类将一个圆描述为一个点和一个半径,这意味着你可以将一个圆实例视为一个点实例。通过将 Circle 实例分配给 Point 变量来完成此任务,如下所示:

Circle c = new Circle(10, 20, 30);
Point p = c;

cast 操作符不需要从圆转换到点,因为通过点的接口访问圆实例是合法的。毕竟一个圆至少是一个点。这种赋值被称为向上转换,因为你隐式地向上转换类型层次结构(从 Circle 子类到 Point 超类)。这也是协方差的一个例子,一个具有更大范围值的类型(圆)被转换成一个具有更小范围值的类型(点)。

将 Circle 上升到 Point 后,不能调用 Circle 的 getRadius() 方法,因为该方法不是 Point 接口的一部分。在将子类型缩小为超类之后,失去对子类型特性的访问似乎没有什么用处,但对于实现子类型多态性却是必要的。

除了将子类实例向上转换为超类类型的变量之外,子类型多态性还包括在超类中声明一个方法,并在子类中覆盖这个方法。例如,假设点和圆是图形应用的一部分,您需要在每个类中引入一个 draw() 方法来分别绘制一个点和一个圆。你以清单 4-13 中的所示的类结构结束。

清单 4-13 。声明图形应用的点和圆类

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public String toString()
   {
      return "(" + x + ", " + y + ")";
   }

   void draw()
   {
      System.out.println("Point drawn at " + toString());
   }
}

class Circle extends Point
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }

   @Override
   void draw()
   {
      System.out.println("Circle drawn at " + super.toString() +
                         " with radius " + toString());
   }
}

清单 4-13 的 draw() 方法将最终绘制图形形状,但是通过 System.out.println() 方法调用模拟它们的行为在图形应用的早期测试阶段已经足够了。

现在您已经暂时完成了点和圆,您将想要在图形应用的模拟版本中测试它们的 draw() 方法。为了实现这个目标,你编写清单 4-14 中的的图形类。

清单 4-14 。一个图形类用于测试点的和圆的 draw() 方法

public class Graphics
{
   public static void main(String[] args)
   {
      Point[] points = new Point[] { new Point(10, 20), new Circle(10, 20, 30) };
      for (int i = 0; i < points.length; i++)
         points[i].draw();
   }
}

清单 4-14 的 main() 方法首先声明一个 Point s 的数组。向上造型是通过首先让数组的初始化器实例化 Circle 类,然后将这个实例的引用分配给 points 数组中的第二个元素来演示的。

继续, main() 使用 for 循环调用每个点元素的 draw() 方法。因为第一次迭代调用了 Point 的 draw() 方法,而第二次迭代调用了 Circle 的 draw() 方法,所以您会观察到以下输出:

Point drawn at (10, 20)
Circle drawn at (10, 20) with radius 30

Java 如何“知道”在第二次循环迭代时必须调用 Circle 的 draw() 方法?难道不应该调用点的 draw() 方法,因为圆由于向上转换而被视为点?

在编译时,编译器不知道调用哪个方法。它所能做的就是验证超类中存在一个方法,并验证方法调用的参数列表和返回类型与超类的方法声明相匹配。

编译器在编译后的代码中插入一条指令,在运行时获取并使用 points[i] 中的任何引用来调用正确的 draw() 方法,而不是知道调用哪个方法。这个任务被称为后期绑定

后期绑定用于调用非 final 实例方法。对于所有其他方法调用,编译器知道要调用哪个方法,并在编译后的代码中插入一条指令,该指令调用与变量的类型(而不是其值)相关联的方法。这个任务被称为早期绑定

如果要向上转换的数组是另一个数组的子类型,也可以从一个数组向上转换到另一个数组。考虑清单 4-15 中的。

清单 4-15 。演示阵列向上投射

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX() { return x; }
   int getY() { return y; }
}

class ColoredPoint extends Point
{
   private int color;

   ColoredPoint(int x, int y, int color)
   {
      super(x, y);
      this.color = color;
   }

   int getColor() { return color; }
}

public class UpcastArrayDemo
{
   public static void main(String[] args)
   {
      ColoredPoint[] cptArray = new ColoredPoint[1];
      cptArray[0] = new ColoredPoint(10, 20, 5);
      Point[] ptArray = cptArray;
      System.out.println(ptArray[0].getX()); // Output: 10
      System.out.println(ptArray[0].getY()); // Output: 20
 //      System.out.println(ptArray[0].getColor()); // Illegal
   }
}

清单 4-15 的 main() 方法首先创建一个由一个元素组成的 ColoredPoint 数组。然后实例化这个类,并将对象的引用分配给这个元素。因为 ColoredPoint[] 是 Point[] 的一个子类型, main() 能够将 cptArray 的 ColoredPoint[] 类型向上转换为 Point[] ,并将其引用分配给 p array。

main() 然后通过 ptArray[0] 调用 ColoredPoint 实例的 getX() 和 getY() 方法。它不能调用 getColor() ,因为 p array 的范围比 cptArray 窄。换句话说, getColor() 不是 Point 接口的一部分。

抽象类和抽象方法

假设新的需求要求您的图形应用必须包含一个矩形类。此外,该类必须包括一个 draw() 方法,并且该方法必须以类似于清单 4-14 的 Graphics 应用类中所示的方式进行测试。

与作为具有半径的点的圆相反,将矩形视为具有宽度和高度的点是没有意义的。更确切地说,一个矩形实例可能由一个表示其原点的点实例和一个表示其宽度和高度范围的点实例组成。

因为圆、点和矩形都是形状的例子,所以用自己的 draw() 方法声明一个 Shape 类比指定类 Rectangle extends Point 更有意义。清单 4-16 展示了形状的声明。

清单 4-16 。宣告一个形状类

class Shape
{
   void draw()
   {
   }
}

清单 4-16 的形状类声明了一个空的 draw() 方法,该方法的存在只是为了被覆盖和演示子类型多态性。

你现在可以重构清单 4-13 的点类来扩展清单 4-16 的形状类,保持圆形不变,并引入一个矩形类来扩展形状。然后你可以重构清单 4-14 的的 Graphics 类的 main() 方法来考虑形状。清单 4-17 展示了生成的图形类。

清单 4-17 。一个带有新的 main() 方法的图形类,该方法考虑了形状

public class Graphics
{
   public static void main(String[] args)
   {
      Shape[] shapes = new Shape[] { new Point(10, 20), new Circle(10, 20, 30),
                                     new Rectangle(20, 30, 15, 25) };
      for (int i = 0; i < shapes.length; i++)
         shapes[i].draw();
   }
}

因为点和矩形直接延伸形状,又因为圆通过延伸点间接延伸形状,清单 4-17 的 main() 方法会调用相应子类的 draw() 方法来响应形状【I】。draw();。

虽然形状让代码更加灵活,但是有一个问题。如何阻止开发人员实例化形状并将这个无意义的实例添加到形状数组中,如下所示?

Shape[] shapes = new Shape[] { new Point(10, 20), new Circle(10, 20, 30),
                               new Rectangle(20, 30, 15, 25), new Shape() };

实例化形状是什么意思?因为这个类描述的是一个抽象的概念,画一个通用的形状是什么意思?幸运的是,Java 为这个问题提供了一个解决方案,如清单 4-18 所示。

清单 4-18 。抽象出形状类

abstract class Shape
{
   abstract void draw(); // semicolon is required
}

清单 4-18 使用 Java 的抽象保留字声明一个不能实例化的类。当您尝试实例化该类时,编译器会报告一个错误。

提示养成声明描述通用类别(如形状、动物、车辆和账户)的类的习惯摘要。这样,您就不会无意中实例化它们。

抽象保留字也用于声明没有主体的方法。 draw() 方法不需要实体,因为它不能绘制抽象的形状。

当你试图声明一个既抽象又最终的类时,编译器会报告一个错误。例如,抽象最终类形状是一个错误,因为抽象类不能被实例化,最终类不能被扩展。当您将一个方法声明为抽象方法,但没有将其类声明为抽象方法时,编译器也会报告错误。例如,从清单 4-18 中的形状类的头中删除抽象会导致错误。这种移除是错误的,因为当非抽象(具体)类包含抽象方法时,它不能被实例化。最后,当你扩展一个抽象类时,扩展类必须覆盖抽象类的所有抽象方法,否则扩展类本身必须被声明为抽象的;否则,编译器将报告错误。

除了抽象方法之外,一个抽象类还可以包含非抽象方法。例如,清单 4-2 中的 Vehicle 类可以被声明为抽象。构造函数仍然存在,用于初始化私有字段,即使您不能实例化结果类。

向下转换和运行时类型识别

通过向上转换在类型层次结构中向上移动会导致无法访问子类型特征。例如,将一个 Circle 实例赋给 Point 变量 p 意味着不能使用 p 调用 Circle 的 getRadius() 方法。

但是,可以通过执行显式强制转换操作,再次访问 Circle 实例的 getRadius() 方法,例如,Circle c =(Circle)p;。这种赋值被称为向下转换,因为你是显式地向下移动类型层次结构(从点超类到圆子类)。这也是逆变的一个例子,具有较窄取值范围的类型(点)被转换为具有较宽取值范围的类型(圈)。

虽然向上转换总是安全的(超类的接口是子类接口的子集),但是向下转换就不一样了。清单 4-19 向你展示了当向下转换使用不当时,你会陷入什么样的麻烦。

清单 4-19 。向下抛掷的问题是

class A
{
}

class B extends A
{
   void m()
   {
   }
}

public class DowncastDemo
{
   public static void main(String[] args)
   {
      A a = new A();
      B b = (B) a;
      b.m();
   }
}

清单 4-19 展示了一个由名为 A 的超类和名为 B 的子类组成的类层次结构。虽然 A 没有声明任何成员,但是 B 声明了一个单独的 m() 方法。

第三个名为 DowncastDemo 的类提供了一个 main() 方法,该方法首先实例化 A ,然后尝试将该实例向下转换为 B ,并将结果赋给变量 b 。编译器不会抱怨,因为在同一类型层次结构中从超类向下转换到子类是合法的。

但是,如果允许赋值,应用在试图执行 b.m()时无疑会崩溃;。崩溃的发生是因为虚拟机试图调用一个不存在的方法——类 A 没有 m() 方法。

幸运的是,这种情况永远不会发生,因为虚拟机验证强制转换是合法的。因为它检测到 A 没有 m() 方法,所以它不允许通过抛出 ClassCastException 类的实例进行强制转换。

虚拟机的 cast 验证说明了运行时类型标识(或简称 RTTI )。强制转换验证通过检查强制转换运算符的操作数类型来执行 RTTI,以确定是否允许强制转换。显然,演员不应该被允许。

第二种形式的 RTTI 涉及到操作符的实例。该运算符检查左操作数是否是右操作数的实例,如果是,则返回 true。下面的例子将的实例引入到清单 4-19 中,以防止类抛出异常:

if(a instanceof B)
{
   B b = (B) a;
   b.m();
}

操作符的 instance 检测到变量 a 的实例不是从 B 创建的,并返回 false 以表明这一事实。因此,执行非法强制转换的代码将不会执行。(过度使用的实例可能表明糟糕的软件设计。)

因为子类型是一种超类型,所以当其左操作数是其右操作数超类型的子类型实例或超类型实例时,的 instance 将返回 true。以下示例演示了:

A a = new A();
B b = new B();
System.out.println(b instanceof A); // Output: true
System.out.println(a instanceof A); // Output: true

这个例子假设了清单 4-19 中所示的类结构,并实例化了超类 A 和子类 B 。第一个 System.out.println() 方法调用输出 true ,因为 b 的引用标识了 B 的实例,是 A 的子类;第二个 System.out.println() 方法调用输出 true ,因为 a 的引用标识了超类 A 的一个实例。

还可以从一个数组向下转换到另一个数组,前提是被向下转换的数组是另一个数组的超类型,并且它的元素类型是子类型的元素类型。考虑清单 4-20 中的。

清单 4-20 。演示数组向下转换

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX() { return x; }
   int getY() { return y; }
}

class ColoredPoint extends Point
{
   private int color;

   ColoredPoint(int x, int y, int color)
   {
      super(x, y);
      this.color = color;
   }

   int getColor() { return color; }
}

public class DowncastArrayDemo
{
   public static void main(String[] args)
   {
      ColoredPoint[] cptArray = new ColoredPoint[1];
      cptArray[0] = new ColoredPoint(10, 20, 5);
      Point[] ptArray = cptArray;
      System.out.println(ptArray[0].getX()); // Output: 10
      System.out.println(ptArray[0].getY()); // Output: 20
 //      System.out.println(ptArray[0].getColor()); // Illegal
      if (ptArray instanceof ColoredPoint[])
      {
         ColoredPoint cp = (ColoredPoint) ptArray[0];
         System.out.println(cp.getColor());
      }
   }
}

清单 4-20 类似于清单 4-15 ,除了它也演示了向下转换。注意它使用的实例来验证 p 数组的引用对象是类型 ColoredPoint[] 。如果该运算符返回 true,则可以安全地将 p array[0]从点向下转换到 ColoredPoint ,并将引用分配给 ColoredPoint 。

共变返回类型

协变返回类型是一种方法返回类型,在超类的方法声明中,它是子类的覆盖方法声明中返回类型的超类型。清单 4-21 展示了这种语言的特性。

清单 4-21 。协变返回类型的演示

class SuperReturnType
{
   @Override
   public String toString()
   {
      return "superclass return type";
   }
}

class SubReturnType extends SuperReturnType
{
   @Override
   public String toString()
   {
      return "subclass return type";
   }
}

class Superclass
{
   SuperReturnType createReturnType()
   {
      return new SuperReturnType();
   }
}

class Subclass extends Superclass
{
   @Override
   SubReturnType createReturnType()
   {
      return new SubReturnType ();
   }
}

public class CovarDemo
{
   public static void main(String[] args)
   {
      SuperReturnType suprt = new Superclass().createReturnType();
      System.out.println(suprt); // Output: superclass return type
      SubReturnType subrt = new Subclass().createReturnType();
      System.out.println(subrt); // Output: subclass return type
   }
}

清单 4-21 声明了 SuperReturnType 和超类超类和子 ReturnType 和子类子类;每个超类和子类都声明了一个 createReturnType() 方法。超类的方法将其返回类型设置为 SuperReturnType ,而子类的覆盖方法将其返回类型设置为 SubReturnType ,后者是 SuperReturnType 的子类。

协变返回类型最小化了向上转换和向下转换。例如,子类的 createReturnType() 方法不需要将其 SubReturnType 实例向上转换为其 SubReturnType 返回类型。此外,在给变量 subrt 赋值时,这个实例不需要向下转换为 SubReturnType 。

在没有协变返回类型的情况下,您会以清单 4-22 中的结束。

清单 4-22 。缺少协变返回类型时的向上转换和向下转换

class SuperReturnType
{
   @Override
   public String toString()
   {
      return "superclass return type";
   }
}

class SubReturnType extends SuperReturnType
{
   @Override
   public String toString()
   {
      return "subclass return type";
   }
}

class Superclass
{
   SuperReturnType createReturnType()
   {
      return new SuperReturnType();
   }
}

class Subclass extends Superclass
{
   @Override
   SuperReturnType createReturnType()
   {
      return new SubReturnType ();
   }
}

public class CovarDemo
{
   public static void main(String[] args)
   {
      SuperReturnType suprt = new Superclass().createReturnType();
      System.out.println(suprt); // Output: superclass return type
      SubReturnType subrt = (SubReturnType) new Subclass().createReturnType();
      System.out.println(subrt); // Output: subclass return type
   }
}

在清单 4-22 中,第一个加粗的代码显示了从子返回类型到超返回类型的向上转换,第二个加粗的代码使用所需的(子返回类型)转换运算符,在将子返回类型赋值之前,从超返回类型向下转换到子返回类型。

形式化类接口

在我对信息隐藏的介绍中(参见第三章,我提到每个类 X 都公开了一个接口(一个由构造函数、方法和【可能】字段组成的协议,它们对从其他类创建的对象可用,用于创建和与 X 的对象通信)。

Java 通过提供保留字接口将接口概念形式化,接口用于引入一个没有实现的类型。Java 还提供了声明、实现和扩展接口的语言特性。在本节中查看了接口声明、实现和扩展之后,我将解释使用接口的基本原理。

声明接口

一个接口声明由一个标题和一个主体组成。至少,报头由保留字 interface 和标识接口的名称组成。正文以左大括号字符开始,以右大括号结束。夹在这些分隔符之间的是常量和方法头声明。考虑清单 4-23 。

清单 4-23 。声明一个可绘制的界面

interface Drawable
{
   int RED = 1;   // For simplicity, integer constants are used. These constants are
   int GREEN = 2; // not that descriptive, as you will see.
   int BLUE = 3;
   int BLACK = 4;
   void draw(int color);
}

清单 4-23 声明了一个名为 Drawable 的接口。按照惯例,接口的名称以大写字母开头。此外,多词界面名称中每个后续词的第一个字母都要大写。

注意许多接口名称以 able 后缀结尾。例如,标准类库包括名为可调用、可比、可克隆、可迭代、可运行和可序列化的接口。使用这个后缀不是强制性的;标准类库还提供了名为 CharSequence 、 Collection 、 Executor 、 Future 、 Iterator 、 List 、 Map 、 Set 的接口。

Drawable 声明了四个标识颜色常数的字段。 Drawable 还声明了一个 draw() 方法,必须用这些常量之一来调用该方法,以指定用于绘制某物的颜色。

注意你可以在接口之前加上公共接口,以使你的接口可以被其包之外的代码访问。(我将在下一章讨论包)。否则,该接口只能由其包中的其他类型访问。你也可以在接口前面加上抽象来强调一个接口是抽象的。因为接口已经是抽象的,所以在接口的声明中指定抽象的是多余的。接口的字段被隐式声明为 public 、 static 和 final 。因此,用这些保留字来声明它们是多余的。因为这些字段是常量,所以必须显式初始化;否则,编译器会报告错误。最后,接口的方法被隐式声明为公共和抽象。因此,用这些保留字来声明它们是多余的。因为这些方法必须是实例方法,所以不要将它们声明为静态的,否则编译器会报错。

Drawable 标识一个类型,指定做什么(画一些东西)但不指定如何做。它将实现细节留给实现该接口的类。这些类的实例被称为 drawables ,因为它们知道如何绘制自己。

注意没有声明成员的接口被称为标记接口标记接口。它将元数据与类相关联。例如,可克隆的标记/标签接口的存在意味着它的实现类的实例可以被简单地克隆。RTTI 用于检测对象的类是否实现了标记/标签接口。例如,当对象的 clone() 方法通过 RTTI 检测到调用实例的类实现了 Cloneable 时,它会浅克隆该对象。

实现接口

接口本身是没有用的。为了让应用受益,接口需要由一个类来实现。Java 为此任务提供了实现保留字。这个保留字在清单 4-24 中有演示。

清单 4-24 。实现可绘制接口

class Point implements Drawable
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public String toString()
   {
      return "(" + x + ", " + y + ")";
   }

   @Override
   public void draw(int color)
   {
      System.out.println("Point drawn at " + toString() + " in color " + color);
   }
}

class Circle extends Point implements Drawable
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }

   @Override
   public void draw(int color)
   {
      System.out.println("Circle drawn at " + super.toString() +
                         " with radius " + toString() + " in color " + color);
   }
}

清单 4-24 改进了清单 4-13 的类层次结构,以利用清单 4-23 的可绘制接口。您会注意到,每个类 Point 和 Circle 都通过将 implements Drawable 子句附加到其类头来实现该接口。

若要实现接口,该类必须为每个接口方法头指定一个方法,该方法的头与接口的方法头具有相同的签名和返回类型,并且该方法头具有一个代码体。

注意当实现一个方法时,不要忘记接口的方法被隐式声明为 public 。如果您忘记在实现方法的声明中包含 public ,编译器将会报告一个错误,因为您试图为实现方法分配较弱的访问。

当一个类实现一个接口时,该类继承接口的常量和方法头,并通过提供实现来覆盖方法头(因此有了 @Override 注释)。这就是所谓的接口继承

原来圈的表头不需要实现 Drawable 子句。如果该子句不存在, Circle 继承了 Point 的 draw() 方法,仍然被认为是一个 Drawable ,无论它是否覆盖该方法。

接口指定一个类型,该类型的数据值是其类实现接口的对象,其行为是由接口指定的。这一事实意味着,只要对象的类实现了接口,就可以将对象的引用赋给接口类型的变量。以下示例提供了一个演示:

public static void main(String[] args)
{
   Drawable[] drawables = new Drawable[] { new Point(10, 20), new Circle(10, 20, 30) };
   for (int i = 0; i < drawables.length; i++)
      drawables[i].draw(Drawable.RED);
}

因为 Point 和 Circle 实例是依靠这些实现 Drawable 接口的类来绘制的,所以将 Point 和 Circle 实例引用分配给 Drawable 类型的变量(包括数组元素)是合法的。

当您运行此方法时,它会生成以下输出:

Point drawn at (10, 20) in color 1
Circle drawn at (10, 20) with radius 30 in color 1

清单 4-23 的可绘制界面对于绘制一个形状的轮廓很有用。假设您还需要填充形状的内部。你可以尝试通过声明清单 4-25 的可填充接口来满足这个需求。

清单 4-25 。声明一个可填充的界面

interface Fillable
{
   int RED = 1;
   int GREEN = 2;
   int BLUE = 3;
   int BLACK = 4;
   void fill(int color);
}

给定清单 4-23 和 4-25 ,您可以通过指定类点实现可绘制、可填充和类圆实现可绘制、可填充来声明点和圆类实现这两个接口。然后,您可以修改 main() 方法,也将可绘制内容视为可填充内容,以便您可以填充这些形状,如下所示:

public static void main(String[] args)
{
   Drawable[] drawables = new Drawable[] { new Point(10, 20),
                                           new Circle(10, 20, 30) };
   for (int i = 0; i < drawables.length; i++)
      drawables[i].draw(Drawable.RED);
   Fillable[] fillables = new Fillable[drawables.length];
   for (int i = 0; i < drawables.length; i++)
   {
      fillables[i] = (Fillable) drawables[i];
      fillables[i].fill(Fillable.GREEN);
   }
}

在调用每个 drawable 的 draw() 方法后, main() 创建一个与 Drawable 数组长度相同的 Fillable 数组。然后将每个 Drawable 数组元素复制到一个 Fillable 数组元素,然后调用 Fillable 的 fill() 方法。 (Fillable) 造型是必要的,因为 drawable 不是 Fillable。该强制转换操作将会成功,因为被复制的点和圆实例实现了可填充和可绘制。

提示通过在实现之后指定一个逗号分隔的接口名称列表,您可以列出您需要实现的任意多的接口。

实现多个接口会导致名称冲突,编译器会报告错误。例如,假设你试图编译清单 4-26 的接口和类声明。

清单 4-26 。碰撞界面

interface A
{
   int X = 1;
   void foo();
}

interface B
{
   int X = 1;
   int foo();
}

class Collision implements A, B
{
   @Override
   public void foo();

   @Override
   public int foo() { return X; }
}

清单 4-26 的 A 和 B 的每个接口都声明了一个名为 X 的常数。尽管每个常量都具有相同的类型和值,但当编译器在 Collision 的第二个 foo() 方法中遇到 X 时,它会报告一个错误,因为它不知道哪个 X 正在被继承。

说到 foo() ,编译器在遇到碰撞的第二个 foo() 声明时报错,因为 foo() 已经声明过了。不能通过仅更改方法的返回类型来重载方法。

编译器可能会报告额外的错误。例如,Java 7 编译器在被告知编译清单 4-26 中的时会这样说:

Collision.java:19: error: method foo() is already defined in class Collision
   public int foo() { return X; }
              ^
Collision.java:13: error: Collision is not abstract and does not override abstract method foo()
in B class Collision implements A, B
^
Collision.java:16: error: foo() in Collision cannot implement foo() in B
   public void foo();
               ^
  return type void is not compatible with int
Collision.java:19: error: reference to X is ambiguous, both variable X in A and variable X
in B match
   public int foo() { return X; }
                             ^
4 errors

扩展接口

正如子类可以通过保留字扩展来扩展超类一样,你可以使用这个保留字让一个子接口扩展一个超接口。这也被称为接口继承

例如, Drawable 和 Fillable 中重复的颜色常量,当你在一个实现类中单独指定它们的名字时,会导致名字冲突。为了避免这些名字冲突,在名字前面加上接口名和成员访问操作符,或者将这些常量放在它们自己的接口中,并让 Drawable 和 Fillable 扩展这个接口,如清单 4-27 所示。

清单 4-27 。扩展颜色接口

interface Colors
{
   int RED = 1;
   int GREEN = 2;
   int BLUE = 3;
   int BLACK = 4;
}

interface Drawable extends Colors
{
   void draw(int color);
}

interface Fillable extends Colors
{
   void fill(int color);
}

Drawable 和 Fillable 都从 Colors 继承常量,这对于编译器来说不是问题。这些常量只有一个副本(颜色为和),没有名称冲突的可能,所以编译器是满意的。

如果一个类可以通过在实现之后声明一个逗号分隔的接口名称列表来实现多个接口,那么看起来一个接口应该可以用类似的方式扩展多个接口。这个特性在清单 4-28 中进行了演示。

清单 4-28 。扩展一对接口

interface A
{
   int X = 1;
}

interface B
{
   double X = 2.0;
}

interface C extends A, B
{
}

尽管 C 继承了两个同名的常量 X 具有不同的类型和初始化器,清单 4-28 仍然可以编译。然而,如果你实现了 C ,然后试图访问 X ,如清单 4-29 所示,你将会遇到名称冲突。

清单 4-29 。发现名称冲突

class Collision implements C
{
   public void output()
   {
      System.out.println(X); // Which X is accessed?
   }
}

假设你引入一个 void foo();方法头声明成接口一个和一个 int foo();方法头声明进入接口 B 。这一次,当您试图编译修改后的清单 4-28 时,编译器将报告一个错误。

为什么要使用接口?

既然声明、实现和扩展接口的机制已经不存在了,那么您就可以关注使用它们的基本原理了。不幸的是,刚接触 Java 接口特性的人经常被告知,这个特性是为了解决 Java 不支持多实现继承的问题而创建的。虽然接口在这方面很有用,但这不是它们存在的理由。相反, Java 的接口特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。您应该始终对接口(由接口类型或抽象类提供)进行编码。

那些坚持敏捷软件开发(一组基于迭代开发的软件开发方法,强调保持代码简单,频繁测试,并在可交付时交付应用的功能部分)的人知道灵活编码的重要性。他们不能将他们的代码绑定到一个特定的实现上,因为下一次迭代的需求变化可能会导致一个新的实现,并且他们可能会发现他们自己重写了大量的代码,这浪费了时间并且减慢了开发。

接口通过将接口与实现分离来帮助您实现灵活性。例如,清单 4-17 的图形类中的 main() 方法从 Shape 类的子类中创建一个对象数组,然后遍历这些对象,调用每个对象的 draw() 方法。唯一可以被绘制的对象是那些子类形状的对象。

假设你也有一个层次结构的类来模拟电阻、晶体管和其他电子元件。每个元件都有自己的符号,可以在电子电路的原理图中显示。也许您想为每个绘制组件符号的类添加一个绘制功能。

您可以考虑将形状指定为电子元件类层次的超类。然而,电子元件不是形状(尽管它们有形状),所以将这些类放在以形状为根的类层次结构中是没有意义的。

但是,您可以让每个组件类实现 Drawable 接口,该接口允许您将实例化这些类的表达式添加到出现在清单 4-25 之前的 main() 方法中的 drawables 数组中(这样您就可以绘制它们的符号)。这是合法的,因为这些实例是可提取的。

只要有可能,您应该努力在代码中指定接口而不是类,以使您的代码能够适应变化。当使用 Java 的集合框架时尤其如此,我将在第九章详细讨论。

现在,考虑一个简单的例子,它由集合框架的 java.util.List 接口及其 java.util.ArrayList 和 java.util.LinkedList 实现类组成。以下示例展示了基于 ArrayList 类的不灵活代码:

ArrayList<String> arrayList = new ArrayList<String>();
void dump(ArrayList<String> arrayList)
{
   // suitable code to dump out the arrayList
}

这个例子使用基于泛型的参数化类型语言特性(我将在第六章中讨论)来识别存储在数组列表实例中的对象种类。在这个例子中,字符串对象被存储。

这个例子是不灵活的,因为它将 ArrayList 类硬连接到多个位置。这种硬连接使开发人员特别关注数组列表,而不是一般意义上的列表。

当需求改变时,缺乏关注是有问题的,或者可能是由剖析(分析运行中的应用以检查其性能)带来的性能问题,这表明开发人员应该使用 LinkedList 。

这个例子只需要很少的修改就可以满足新的需求。相比之下,更大的代码库可能需要更多的更改。尽管您只需要将 ArrayList 改为 LinkedList ,为了满足编译器的要求,请考虑将 arrayList 改为 linkedList 以保持语义(含义)清晰——您可能需要在整个源代码中多次更改引用 ArrayList 实例的名称。

在重构代码以适应 LinkedList 时,开发人员必然会浪费时间。相反,通过编写这个示例来使用等效的常量,可以节省时间。换句话说,这个例子可以被写成依赖于接口,并且只在一个地方指定数组列表。以下示例向您展示了结果代码的外观:

List<String> list = new ArrayList<String>();
void dump(List<String> list)
{
   // suitable code to dump out the list
}

这个例子比前一个例子灵活得多。如果一个需求或概要分析的变化建议使用 LinkedList 而不是 ArrayList ,只需用 Linked 替换 Array 就可以了。您甚至不必更改参数名。

注意 Java 提供了描述抽象类型(不能实例化的类型)的接口和抽象类。抽象类型代表抽象概念(例如,drawable 和 shape),这种类型的实例是没有意义的。

接口通过缺少实现来提高灵活性——Drawable 和 List 说明了这种灵活性。它们不依赖于任何单一的类层次结构,而是可以由任何层次结构中的任何类来实现。相反,抽象类支持实现,但可以是真正的抽象(例如,清单 4-18 的抽象形状类)。但是,它们仅限于出现在类层次结构的上层。

接口和抽象类可以一起使用。例如,集合框架的 java.util 包提供了 List、Map、和 Set 接口和 AbstractList、AbstractMap、和 AbstractSet 抽象类,它们提供了这些接口的框架实现。

通过实现许多接口方法,框架实现使您可以轻松地创建自己的接口实现来满足您独特的需求。如果它们不满足您的需要,您可以选择让您的类直接实现适当的接口。

练习

以下练习旨在测试您对第四章内容的理解:

  1. 什么是实现继承?
  2. Java 如何支持实现继承?
  3. 一个子类可以有两个或多个超类吗?
  4. 你如何防止一个类被子类化?
  5. 是非判断: super() 调用可以出现在任何方法中。
  6. 如果超类声明一个带一个或多个参数的构造函数,如果子类构造函数没有使用 super() 调用那个构造函数,为什么编译器会报错?
  7. 什么是不可变类?
  8. 是非判断:一个类可以继承构造函数。
  9. 重写一个方法是什么意思?
  10. 从超类方法的覆盖子类方法中调用超类方法需要什么?
  11. 如何防止方法被重写?
  12. 为什么不能使一个重写的子类方法比它所重写的超类方法更难访问呢?
  13. 如何告诉编译器一个方法覆盖了另一个方法?
  14. 为什么 Java 不支持多实现继承?
  15. Java 的终极超类叫什么?
  16. clone() 方法的目的是什么?
  17. 对象的 clone() 方法什么时候抛出 CloneNotSupportedException?
  18. 解释浅拷贝和深拷贝的区别。
  19. == 运算符可以用来判断两个对象在逻辑上是否等价吗?为什么或为什么不?
  20. Object 的 equals() 方法完成了什么?
  21. 表达式 "abc" == "a" + "bc" 返回真还是假?
  22. 如何优化一个耗时的 equals() 方法?
  23. finalize() 方法的目的是什么?
  24. 你应该依靠 finalize() 来关闭打开的文件吗?为什么或为什么不?
  25. 什么是哈希码?
  26. 是非判断:无论何时重写 equals() 方法,都应该重写 hashCode() 方法。
  27. 对象的 toString() 方法返回什么?
  28. 为什么要重写 toString() ?
  29. 定义构图。
  30. 是非判断:组合用于描述“是-a”关系,实现继承用于描述“有-a”关系。
  31. 识别实现继承的根本问题。你如何解决这个问题?
  32. 定义子类型多态性。
  33. 子类型多态性是如何实现的?
  34. 为什么要使用抽象类和抽象方法?
  35. 抽象类可以包含具体方法吗?
  36. 向下抛掷的目的是什么?
  37. 列出 RTTI 的两种形式。
  38. 什么是协变返回类型?
  39. 如何正式声明一个接口?
  40. 是非判断:你可以在接口声明前加上抽象保留字。
  41. 定义标记接口。
  42. 什么是接口继承?
  43. 你如何实现一个接口?
  44. 当您实现多个接口时,您可能会遇到什么问题?
  45. 如何形成接口的层次结构?
  46. 为什么 Java 的接口特性如此重要?
  47. 接口和抽象类完成什么?
  48. 接口和抽象类有什么不同?
  49. 通过声明动物、鸟、鱼、美洲知更鸟、家养金丝雀、虹鳟鱼和 SockeyeSalmon 类来建立动物层级的模型:
  • Animal 是 public 和 abstract ,声明基于 private String 的 kind 和 appearance 字段,声明一个 public 构造器,该构造器将这些字段初始化为传入的参数,声明 public 和 abstract eat() 和 move() 方法,这些方法不带参数并且其返回类型为 void
  • Bird 是 public 和 abstract ,扩展 Animal ,声明一个 public 构造函数,将它的 kind 和 appearance 参数值传递给它的超类构造函数,覆盖它的 eat() 方法以输出吃种子和昆虫(通过 System.out.println() ,并覆盖它的
  • 鱼是公和摘要;延伸动物;声明一个公共构造函数,将它的种类和外观参数值传递给它的超类构造函数;覆盖它的 eat() 方法来输出吃磷虾、藻类和昆虫;并覆盖它的 move() 方法来输出游过水。
  • AmericanRobin 是 public ,扩展了 Bird ,声明了一个 public noargument 构造函数,将" American robin "" red breast "传递给它的超类构造函数。
  • DomesticCanary 是 public ,扩展了 Bird ,声明了一个 public noargument 构造函数,将 "domesticcanary" 和 "yellow,orange,black,brown,white,red" 传递给它的超类构造函数。
  • RainbowTrout 是 public ,扩展了 Fish ,并声明了一个 public noargument 构造函数,该构造函数将" RainbowTrout ""几乎贯穿其整个身体长度的鲜艳斑点五彩条纹带"传递给其超类构造函数。
  • SockeyeSalmon 是 public ,扩展了 Fish ,声明了一个 public noargument 构造函数,将" SockeyeSalmon ""鲜红色带绿头"传递给它的超类构造函数。

注意为了简洁,我从动物层级抽象知更鸟、金丝雀、鳟鱼和鲑鱼中省略了概括知更鸟、金丝雀、鳟鱼和鲑鱼的类。也许您可能想在层次结构中包含这些类。

虽然这个练习展示了使用继承的自然场景的精确建模,但是它也揭示了类爆炸的可能性——太多的类可能被引入来建模一个场景,并且维护所有这些类可能是困难的。在使用继承建模时,请记住这一点。

  • 50.继续上一个练习,用一个 main() 方法声明一个 Animals 类。该方法首先声明一个 animals 数组,该数组被初始化为 AmericanRobin 、 RainbowTrout 、 DomesticCanary 和 SockeyeSalmon 对象。然后该方法遍历这个数组,首先输出 animals[i] (这导致 toString() 被调用),然后调用每个对象的 eat() 和 move() 方法(演示子类型多态性)。

  • 51. Continuing from the previous exercise, declare a public Countable interface with a String getID() method. Modify Animal to implement Countable and have this method return kind’s value. Modify Animals to initialize the animals array to AmericanRobin, RainbowTrout, DomesticCanary, SockeyeSalmon, RainbowTrout, and AmericanRobin objects. Also, introduce code that computes a census of each kind of animal. This code will use the Census class that is declared in Listing 4-30.

    清单 4-30 。普查类存储四种动物的普查数据

    public class Census
    {
       public final static int SIZE = 4;
       private String[] IDs;
       private int[] counts;
    
       public Census()
       {
          IDs = new String[SIZE];
          counts = new int[SIZE];
       }
    
       public String get(int index)
       {
          return IDs[index] + " " + counts[index];
       }
    
       public void update(String ID)
       {
          for (int i = 0; i < IDs.length; i++)
          {
             // If ID not already stored in the IDs array (which is indicated by
             // the first null entry that is found), store ID in this array, and
             // also assign 1 to the associated element in the counts array, to
             // initialize the census for that ID.
             if (IDs[i] == null)
             {
                IDs[i] = ID;
                counts[i] = 1;
                return;
             }
    
             // If a matching ID is found, increment the associated element in
             // the counts array to update the census for that ID.
             if (IDs[i].equals(ID))
             {
                counts[i]++;
                return;
             }
          }
       }
    }
    
    

摘要

继承是相似实体类别之间的层次关系,其中一个类别从至少一个其他实体类别继承状态和行为。从单一类别继承称为单一继承,从至少两个类别继承称为多重继承。

Java 支持单继承和多继承以方便代码重用——为什么要多此一举?Java 支持类上下文中的单一继承(通过保留字扩展),其中一个类通过类扩展从另一个类继承字段和方法。因为涉及到类,Java 把这种继承称为实现继承。Java 只在这样的接口上下文中支持多重继承:一个类通过接口实现(通过保留字实现)从一个或多个接口继承方法模板,或者一个接口通过接口扩展(通过保留字扩展)从一个或多个接口继承方法模板。因为涉及到接口,所以 Java 把这种继承称为接口继承。

一些现实世界的实体有能力改变它们的形态。改变形式的能力被称为多态,这对于用编程语言建模很有用。尽管 Java 支持强制、重载、参数和子类型类型的多态性,但在本章中,我只关注子类型多态性,这是通过向上转换和方法覆盖实现的。

每个类 X 都公开了一个接口(一个由构造函数、方法和[可能]字段组成的协议,这些接口对从其他类创建的对象可用,用于创建和与 X 的对象通信)。Java 通过提供保留字接口将接口概念形式化,接口用于引入一个没有实现的类型。

尽管许多人认为创建接口语言特性是为了解决 Java 不支持多实现继承的问题,但这并不是它存在的真正原因。相反,Java 的接口特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。您应该始终编写接口代码。

在第五章中,我继续通过关注嵌套类型、包、静态导入和异常来探索 Java 语言。

五、掌握高级语言功能:第一部分

在第二章到第四章中,我为学习 Java 语言打下了基础。在第五章中,我在此基础上向你介绍了一些 Java 更高级的语言特性,特别是那些与嵌套类型、包、静态导入和异常相关的特性。其他高级语言特性将在第六章中介绍。

掌握嵌套类型

在任何类之外声明的类被称为顶级类。Java 还支持嵌套类,这些类被声明为其他类或作用域的成员。嵌套类有助于实现顶级类架构。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三个类别被称为内部类

在这一节中,我将向您介绍静态成员类和内部类。对于每一种嵌套类,我都为您提供了一个简短的介绍、一个抽象的示例和一个更实用的示例。然后,我简要分析了在类中嵌套接口的主题。

静态成员类

一个静态成员类是一个封闭类的静态成员。虽然是封闭的,但它没有该类的封闭实例,并且不能访问封闭类的实例字段和调用其实例方法。然而,它可以访问封闭类的静态字段并调用其静态方法,甚至是那些被声明为私有的成员。清单 5-1 展示了一个静态成员类声明。

清单 5-1 。声明为静态成员类

class EnclosingClass
{
   private static int i;

   private static void m1()
   {
      System.out.println(i);
   }

   static void m2()
   {
      EnclosedClass.accessEnclosingClass();
   }

   static class EnclosedClass
   {
      static void accessEnclosingClass()
      {
         i = 1;
         m1();
      }

      void accessEnclosingClass2()
      {
         m2();
      }
   }
}

清单 5-1 声明了一个名为 EnclosingClass 的顶级类,具有类字段 i ,类方法 m1() 和 m2() ,以及静态成员类 EnclosedClass 。另外, EnclosedClass 声明了类方法 accessEnclosingClass()和实例方法 accessEnclosingClass 2()。

因为 accessEnclosingClass() 被声明为 static , m2() 必须加上前缀 EnclosedClass 和成员访问操作符才能调用这个方法。

清单 5-2 给出了一个应用类的源代码,演示了如何调用 EnclosedClass 的 accessEnclosingClass() 类方法,实例化 EnclosedClass 并调用其 accessEnclosingClass2() 实例方法。

清单 5-2 。调用静态成员类的类和实例方法

public class SMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass.EnclosedClass.accessEnclosingClass(); // Output: 1
      EnclosingClass.EnclosedClass ec = new EnclosingClass.EnclosedClass();
      ec.accessEnclosingClass2(); // Output: 1
   }
}

清单 5-2 的 main() 方法揭示了你必须在一个封闭类的名字前加上其封闭类的名字来调用一个类方法,例如, EnclosingClass。enclosed class . accessenclosingclass();。

这个清单还揭示了在实例化封闭类时,必须在封闭类的名称前加上其封闭类的名称,例如, EnclosingClass。enclosed class EC = new enclosing class。enclosed class();。然后,您可以以正常方式调用实例方法,例如,EC . accessenclosingclass 2();。

静态成员类有它们的用途。例如,清单 5-3 的 Double 和 Float 静态成员类提供了它们的封闭 Rectangle 类的不同实现。 Float 版本因其 32 位 float 字段而占用更少的内存,而 Double 版本因其 64 位 double 字段而提供更高的精度。

清单 5-3 。使用静态成员类声明其封闭类的多个实现

abstract class Rectangle
{
   abstract double getX();
   abstract double getY();
   abstract double getWidth();
   abstract double getHeight();

   static class Double extends Rectangle
   {
      private double x, y, width, height;

      Double(double x, double y, double width, double height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }

      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }

   static class Float extends Rectangle
   {
      private float x, y, width, height;

      Float(float x, float y, float width, float height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }

      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }

   // Prevent subclassing. Use the type-specific Double and Float
   // implementation subclass classes to instantiate.
   private Rectangle() {}

   boolean contains(double x, double y)
   {
      return (x >= getX() && x < getX() + getWidth()) &&
             (y >= getY() && y < getY() + getHeight());
   }
}

清单 5-3 的矩形类展示了嵌套的子类。每个 Double 和 Float 静态成员类继承抽象 Rectangle 类,提供私有浮点或双精度浮点字段并覆盖 Rectangle 的抽象方法以将这些字段的值作为 double 返回

矩形是抽象的,因为实例化这个类没有意义。因为用新的实现直接扩展矩形也没有意义(双双和浮动嵌套子类应该足够了),所以它的默认构造函数被声明为私有。相反,你必须实例化矩形。浮动(为了节省内存)或矩形。双(当需要精度时),如清单 5-4 中的所示。

清单 5-4 。创建和使用不同的矩形实现

public class SMCDemo
{
   public static void main(String[] args)
   {
      Rectangle r = new Rectangle.Double(10.0, 10.0, 20.0, 30.0);
      System.out.println("x = " + r.getX());
      System.out.println("y = " + r.getY());
      System.out.println("width = " + r.getWidth());
      System.out.println("height = " + r.getHeight());
      System.out.println("contains(15.0, 15.0) = " + r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = " + r.contains(0.0, 0.0));
      System.out.println();
      r = new Rectangle.Float(10.0f, 10.0f, 20.0f, 30.0f);
      System.out.println("x = " + r.getX());
      System.out.println("y = " + r.getY());
      System.out.println("width = " + r.getWidth());
      System.out.println("height = " + r.getHeight());
      System.out.println("contains(15.0, 15.0) = " + r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = " + r.contains(0.0, 0.0));
   }
}

清单 5-4 首先通过新矩形实例化矩形的 Double 子类。Double(10.0,10.0,20.0,30.0) 然后调用它的各种方法。继续,清单 5-4 通过新矩形实例化矩形的浮动子类。在此实例上调用矩形方法之前,Float(10.0f,10.0f,20.0f,30.0f) 。

编译两个清单(javac SMCDemo.java 或 javac *。java )并运行应用( java SMCDemo )。然后,您将看到以下输出:

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

Java 的类库包含许多静态成员类。例如, java.lang.Character 类包含一个名为子集的静态成员类,其实例代表 Unicode 字符集的子集。其他示例包括 Java . util . abstract map . simple entry 和 Java . io . objectinputstream . getfield。

注意当你编译一个包含静态成员类的封闭类时,编译器为静态成员类创建一个类文件,其名称由封闭类的名称、美元符号字符和静态成员类的名称组成。例如,编译清单 5-1 ,除了 EnclosingClass.class 之外,您还会发现 enclosing class$enclosed class . class。这种格式也适用于非静态成员类。

非静态成员类

非静态成员类是封闭类的非静态成员。非静态成员类的每个实例都隐式地与封闭类的一个实例相关联。非静态成员类的实例方法可以调用封闭类中的实例方法,并访问封闭类实例的非静态字段。清单 5-5 给出了一个非静态成员类声明。

清单 5-5 。声明为非静态成员类

class EnclosingClass
{
   private int i;

   private void m()
   {
      System.out.println(i);
   }

   class EnclosedClass
   {
      void accessEnclosingClass()
      {
         i = 1;
         m();
      }
   }
}

清单 5-5 声明了一个名为 EnclosingClass 的顶级类,带有实例字段 i ,实例方法 m1() ,以及非静态成员类 EnclosedClass 。此外, EnclosedClass 声明了实例方法 accessEnclosingClass() 。

因为 accessEnclosingClass() 是非静态的, EnclosedClass 必须被实例化才能调用该方法。这个实例化必须通过 EnclosingClass 的实例发生。清单 5-6 完成了这些任务。

清单 5-6 。调用非静态成员类的实例方法

public class NSMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.new EnclosedClass().accessEnclosingClass(); // Output: 1
   }
}

清单 5-6 的 main() 方法首先实例化 EnclosingClass ,并将其引用保存在局部变量 ec 中。然后, main() 使用这个引用作为 new 操作符的前缀来实例化 EnclosedClass ,然后使用其引用来调用 accessEnclosingClass() ,后者输出 1 。

注意在 new 前面加上对封闭类的引用是很少见的。相反,您通常会从构造函数或其封闭类的实例方法中调用封闭类的构造函数。

假设您需要维护一个待办事项列表,其中每个项目都由一个名称和一个描述组成。经过一番思考后,您创建了清单 5-7 中的的 ToDo 类来实现这些项目。

清单 5-7 。将待办事项实现为名称-描述对

class ToDo
{
   private String name;
   private String desc;

   ToDo(String name, String desc)
   {
      this.name = name;
      this.desc = desc;
   }

   String getName()
   {
      return name;
   }

   String getDesc()
   {
      return desc;
   }

   @Override
   public String toString()
   {
      return "Name = " + getName() + ", Desc = " + getDesc();
   }
}

接下来创建一个 ToDoList 类来存储 ToDo 实例。 ToDoList 使用其 ToDoArray 非静态成员类在一个可增长数组中存储 ToDo 实例——你不知道会存储多少个实例,而 Java 数组是固定长度的。参见清单 5-8 。

清单 5-8 。在一个 ToDoArray 实例中最多存储两个 ToDo 实例

class ToDoList
{
   private ToDoArray toDoArray;
   private int index = 0;

   ToDoList()
   {
      toDoArray = new ToDoArray(2);
   }

   boolean hasMoreElements()
   {
      return index < toDoArray.size();
   }

   ToDo nextElement()
   {
      return toDoArray.get(index++);
   }

   void add(ToDo item)
   {
      toDoArray.add(item);
   }

   private class ToDoArray
   {
      private ToDo[] toDoArray;
      private int index = 0;

      ToDoArray(int initSize)
      {
         toDoArray = new ToDo[initSize];
      }

      void add(ToDo item)
      {
         if (index >= toDoArray.length)
         {
            ToDo[] temp = new ToDo[toDoArray.length*2];
            for (int i = 0; i < toDoArray.length; i++)
               temp[i] = toDoArray[i];
            toDoArray = temp;
         }
         toDoArray[index++] = item;
      }

      ToDo get(int i)
      {
         return toDoArray[i];
      }

      int size()
      {
         return index;
      }
   }
}

除了提供一个 add() 方法来将 ToDo 实例存储在 ToDoArray 实例中, ToDoList 还提供了 hasmorelements()和 nextElement() 方法来迭代并返回存储的实例。清单 5-9 展示了这些方法。

清单 5-9 。创建并迭代 ToDo 实例的 ToDo listT4

public class NSMCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList();
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      while (toDoList.hasMoreElements())
         System.out.println(toDoList.nextElement());
   }
}

编译所有三个清单(javac NSMCDemo.java 或 javac *)。java )并运行应用( java NSMCDemo )。然后,您将看到以下输出:

Name = #1, Desc = Do laundry.
Name = #2, Desc = Buy groceries.
Name = #3, Desc = Vacuum apartment.
Name = #4, Desc = Write report.
Name = #5, Desc = Wash car.

Java 的类库提供了许多非静态成员类的例子。例如, java.util 包的 HashMap 类声明私有的 HashIterator 、 ValueIterator 、 KeyIterator 和 EntryIterator 类,用于迭代 HashMap 的值、键和条目。(我会在第九章的中讨论散列表。)

注意封闭类中的代码可以通过用封闭类的名称和成员访问操作符限定保留字 this 来获得对其封闭类实例的引用。例如,如果 accessEnclosingClass() 中的代码需要获得对其 EnclosingClass 实例的引用,它将指定 EnclosingClass.this 。

匿名类

一个匿名类 是一个没有名字的类。此外,它不是其封闭类的成员。相反,匿名类被同时声明(作为类的匿名扩展或作为接口的匿名实现)并在任何合法指定表达式的地方被实例化。清单 5-10 展示了一个匿名的类声明和实例化。

清单 5-10 。声明并实例化一个扩展类的匿名类

abstract class Speaker
{
   abstract void speak();
}

public class ACDemo
{
   public static void main(final String[] args)
   {
      new Speaker()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";

        @Override
         void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 5-10 引入了一个名为演讲者 的抽象类和一个名为 ACDemo 的具体类。后一个类的 main() 方法声明了一个匿名类,它扩展了 Speaker 并覆盖了它的 speak() 方法。当这个方法被调用时,它输出 main() 的第一个命令行参数,或者在没有参数时输出一个默认消息。

匿名类没有构造函数(因为匿名类没有名字)。但是,它的 classfile 包含一个执行实例初始化的 < init > () 方法。这个方法调用超类的 noargument 构造函数(在任何其他初始化之前),这就是在 new 之后指定 Speaker() 的原因。

匿名类实例应该能够访问周围范围的局部变量和参数。但是,实例可能比设计它的方法活得长(由于将实例的引用存储在字段中),并在方法返回后尝试访问不再存在的局部变量和参数。

因为 Java 不允许这种非法访问,这很可能会使虚拟机崩溃,所以它只允许匿名类实例访问被声明为 final 的局部变量和参数(参见清单 5-10 )。在匿名类实例中遇到最终的局部变量/参数名时,编译器会做两件事之一:

  • 如果变量的类型是原语类型(例如, int 或 double ,编译器会用变量的只读值替换其名称。
  • 如果变量的类型是引用(例如,字符串),编译器会在类文件中引入一个合成变量(一个人造变量)和代码,该代码将本地变量/参数的引用存储在合成变量中。

清单 5-11 展示了另一种匿名类声明和实例化。

清单 5-11 。声明并实例化一个实现接口的匿名类

interface Speakable
{
   void speak();
}

public class ACDemo
{
   public static void main(final String[] args)
   {
      new Speakable()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";

         @Override
         public void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 5-11 与清单 5-10 非常相似。然而,这个清单的匿名类实现了一个名为 Speakable 的接口,而不是子类化一个 Speaker 类。除了 < init > () 方法调用 java.lang.Object() (接口没有构造函数)之外,清单 5-11 的行为类似于清单 5-10 。

尽管匿名类没有构造函数,但是您可以提供一个实例初始化器来处理复杂的初始化。例如, new Office() {{addEmployee(新员工(“John Doe”));}};实例化 Office 的匿名子类,并通过调用 Office 的 addEmployee() 方法向该实例添加一个 Employee 对象。

为了方便起见,您经常会发现自己在创建和实例化匿名类。例如,假设您需要返回所有带有的文件名的列表。java 后缀。下面的例子向您展示了匿名类如何使用 java.io 包的文件和 FilenameFilter 类来简化实现这个目标:

String[] list = new File(directory).list(new FilenameFilter()
                {
                   @Override
                   public boolean accept(File f, String s)
                   {
                      return s.endsWith(".java");
                   }
                });

本地课程

一个局部类是一个在声明局部变量的任何地方声明的类。此外,它的作用域与局部变量相同。与匿名类不同,局部类有一个名字,可以重用。像匿名类一样,局部类只有在非静态上下文中使用时才有封闭实例。

局部类实例可以访问周围范围的局部变量和参数。然而,被访问的局部变量和参数必须被声明为最终的。例如,清单 5-12 的局部类声明访问一个最终参数和一个最终局部变量。

清单 5-12 。宣布为地方阶级

class EnclosingClass
{
   void m(final int x)
   {
      final int y = x * 2;
      class LocalClass
      {
         int a = x;
         int b = y;
      }
      LocalClass lc = new LocalClass();
      System.out.println(lc.a);
      System.out.println(lc.b);
   }
}

清单 5-12 用其实例方法 m() 声明了一个名为 LocalClass 的局部类 EnclosingClass 。这个局部类声明了一对实例字段( a 和 b ),当 LocalClass 被实例化: new EnclosingClass()时,它们被初始化为 final 参数 x 和 final 局部变量 y 的值。m(10);比如。

清单 5-13 展示了这个局部类。

清单 5-13 。示范一个地方班

public class LCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.m(10);
   }
}

实例化 EnclosingClass ,清单 5-13 的 main() 方法调用 m(10) 。被调用的 m() 方法将这个参数乘以 2;实例化 LocalClass ,其 < init > () 方法将参数和双精度值分配给它的一对实例字段(代替使用构造函数来执行此任务);并输出 LocalClass 实例字段。以下输出结果:

10
20

局部类有助于提高代码的清晰度,因为它们可以被移动到离需要它们的地方更近的地方。例如,清单 5-14 声明了一个迭代器 接口和一个 ToDoList 类,其 iterator() 方法返回其局部 Iter 类的一个实例作为迭代器实例(因为 Iter 实现了迭代器)。

清单 5-14 。迭代器接口 和 ToDoList 类

interface Iterator
{
   boolean hasMoreElements();
   Object nextElement();
}

class ToDoList
{
   private ToDo[] toDoList;
   private int index = 0;

   ToDoList(int size)
   {
      toDoList = new ToDo[size];
   }

   Iterator iterator()
   {
      class Iter implements Iterator
      {
         int index = 0;

         @Override
         public boolean hasMoreElements()
         {
            return index < toDoList.length;
         }

         @Override
         public Object nextElement()
         {
            return toDoList[index++];
         }
      }
      return new Iter();
   }

   void add(ToDo item)
   {
      toDoList[index++] = item;
   }
}

清单 5-15 演示了迭代器,重构后的 ToDoList 类,以及清单 5-7 的 ToDo 类。

清单 5-15 。使用可重用迭代器 创建并迭代 ToDo 实例的 ToDoList

public class LCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList(5);
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      Iterator iter = toDoList.iterator();
      while (iter.hasMoreElements())
         System.out.println(iter.nextElement());
   }
}

从迭代器()返回的迭代器实例返回待办事项的顺序与它们被添加到列表中的顺序相同。虽然只能使用一次返回的迭代器对象,但是每当需要新的迭代器对象时,都可以调用迭代器()。这个功能比清单 5-9 中的单次迭代器有了很大的改进。

类内的接口

接口可以嵌套在类中。一旦声明,一个接口被认为是静态的,即使它没有声明为 static 。例如,清单 5-16 声明了一个名为 X 的封闭类以及两个名为 A 和 B 的嵌套静态接口。

清单 5-16 。在类中声明一对接口

class X
{
   interface A
   {
   }

   static interface B
   {
   }
}

你可以用同样的方式访问清单 5-16 的接口。例如,您可以指定 C 类实现 X.A {} 或 D 类实现 X.B {} 。

与嵌套类一样,嵌套接口通过由嵌套类实现来帮助实现顶级类架构。总的来说,这些类型是嵌套的,因为它们不能(如在清单 5-14 的 Iter 局部类中)或者不需要出现在与顶级类相同的级别上并污染它的包命名空间。

注意在第四章的接口介绍中,我向你展示了如何在接口体中声明常量和方法头。也可以在接口体中声明接口和类。因为这样做的理由很少( java.util.Map.Entry 是一个例外),所以最好避免在接口中嵌套接口和/或类。

母带包

层次结构根据项目之间存在的层次关系来组织项目。例如,一个文件系统可能包含一个带有多个年份子目录的 taxes 目录,其中每个子目录包含与该年相关的税务信息。此外,封闭类可能包含多个嵌套类,这些嵌套类只在封闭类的上下文中有意义。

分层结构也有助于避免名称冲突。例如,在非分层文件系统(由单个目录组成)中,两个文件不能同名。相比之下,分层文件系统允许同名文件存在于不同的目录中。类似地,两个封闭类可以包含同名的嵌套类。名称冲突并不存在,因为项目被划分到不同的名称空间

Java 还支持将顶级用户定义类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

在这一节中,我将向您介绍软件包。在定义了这个术语并解释了为什么包名必须是惟一的之后,我给出了 package 和 import 语句。接下来我将解释虚拟机是如何搜索包和类型的,然后给出一个例子来展示如何使用包。在本节的最后,我将向您展示如何将一个类文件包封装到 JAR 文件中。

提示除了最普通的顶级类型和(通常)那些作为应用入口点的类(它们有 main() 方法),你应该考虑将你的类型(尤其是当它们可重用的时候)存储在包中。现在就养成这个习惯,因为在开发 Android 应用时,你会大量使用软件包。每个 Android 应用都必须存储在自己独特的包中。

什么是包?

一个是一个惟一的名称空间,可以包含顶级类、其他顶级类型和子包的组合。只有被声明为 public 的类型才能从包外被访问。此外,描述类接口的常量、构造函数、方法和嵌套类型必须声明为 public 才能从包外访问。

每个包都有一个名称,它必须是一个不可保留的标识符。成员访问操作符将包名与子包名分开,并将包或子包名与类型名分开。例如, graphics.shapes.Circle 中的两个成员访问操作符将包名 graphics 与 shapes 子包名分开,并将子包名 shapes 与 Circle 类型名分开。

注意Oracle 和 Google Android 的每个标准类库都将其许多类和其他顶级类型组织到多个包中。这些包中有许多是标准 java 包的子包。例子有 java.io (与输入/输出操作相关的类型) java.lang (面向语言的类型)java.net(面向网络的类型) java.util (工具类型)。

包名必须是唯一的

假设你有两个不同的 graphics.shapes 包,假设每个 shapes 子包包含一个接口不同的 Circle 类。当编译器遇到 system . out . println(new Circle(10.0,20.0,30.0)。area());在源代码中,需要验证 area() 方法存在。

编译器将搜索所有可访问的包,直到找到包含圆类的 graphics.shapes 包。如果找到的包包含适当的带有 area() 方法的 Circle 类,那么一切正常。否则,如果 Circle 类没有 area() 方法,编译器会报错。

这个场景说明了选择唯一的包名的重要性。具体来说,顶层包名必须是唯一的。选择这个名字的惯例是取你的互联网域名,然后反过来。例如,我会选择 ca.tutortutor 作为我的顶级包名,因为 tutortutor.ca 是我的域名。然后我会指定 ca . tutortutor . graphics . shapes . Circle 来访问 Circle 。

注意反向互联网域名并不总是有效的包名。它的一个或多个组件名可能以数字(【6.com】)开头,包含连字符()或其他非法字符(【aq-x.com】,或者是 Java 的保留字之一(【int.com】)。惯例要求在数字前加上下划线( com)。6 ),用下划线( com.aq_x )替换非法字符,用下划线( com.int )作为保留字的后缀。

程序包语句

package 语句标识源文件的类型所在的包。该语句由保留字 package 组成,后面是成员访问操作符分隔的包和子包名称列表,后面是分号。

例如,包图形;指定源文件的类型位于名为 graphics 的包中,包 graphics.shapes 指定源文件的类型位于图形包的形状子包中。

按照惯例,包名用小写表示。当名称由多个单词组成时,除了第一个单词以外,每个单词都要大写。

源文件中只能出现一个 package 语句。当它存在时,除了注释之外,在该语句之前不能有任何内容。

注意在源文件中指定多个 package 语句,或者在 package 语句上方放置除注释之外的任何内容,都会导致编译器报告错误。

Java 实现将包和子包的名称映射到同名的目录。例如,实现会将图形映射到名为图形的目录,并将图形.形状映射到图形的形状子目录。Java 编译器将实现包类型的类文件存储在相应的目录中。

注意当一个源文件不包含 package 语句时,该源文件的类型被称为属于未命名包。这个包对应于当前目录。

进口声明

想象一下,必须在源代码中为该类型的每次出现重复指定 ca . tutor tutor . graphics . shapes . circle 或其他冗长的包限定类型名。Java 提供了一种替代方法,让您不必指定包的细节。这个替代语句就是 import 语句。

import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。该语句由保留字 import 组成,后面是成员访问操作符分隔的包和子包名称列表,后面是类型名或 * (星号),后面是分号。

  • 符号是一个通配符,代表所有非限定的类型名。它告诉编译器在 import 语句的指定包中查找这样的名称,除非在以前搜索的包中找到了类型名。(使用通配符不会影响性能或导致代码膨胀,但会导致名称冲突,您将会看到这一点。)

比如导入 ca . tutortutor . graphics . shapes . circle;告诉编译器 ca . tutortutor . graphics . shapes 包中存在不合格的 Circle 类。同样,导入 ca . tutortutor . graphics . shapes . *;告诉编译器在遇到一个矩形类、一个三角形类、甚至一个雇员类(如果还没有找到雇员)时在这个包中查找。

提示你应该避免使用 * 通配符,这样其他开发人员可以很容易地看到源代码中使用了哪些类型。

因为 Java 是区分大小写的,所以在 import 语句中指定的包和子包名称的大小写必须与 package 语句中使用的大小写相同。

当导入语句出现在源代码中时,只有包语句和注释可以在它们之前。

注意在 import 语句上放置除 package 语句、import 语句、static import 语句(稍后讨论)和注释之外的任何内容都会导致编译器报告错误。

当使用通配符版本的 import 语句时,您可能会遇到名称冲突,因为任何非限定的类型名都与通配符匹配。例如,您有 graphics.shapes 和 geometry 包,每个包都包含一个 Circle 类,源代码以 import geometry 开始。*;和导入 graphics . shape . *;语句,并且它还包含一个不合格出现的圆。因为编译器不知道 Circle 是指 geometry 的 Circle 类还是 graphics.shape 的 Circle 类,所以报错。您可以通过用正确的包名限定圆圈来解决这个问题。

注意编译器自动从 java.lang 包中导入字符串类和其他类型,这就是为什么不需要用 java.lang 限定字符串的原因。

搜索包和类型

第一次开始使用包的 Java 新手经常会因为“没有找到类定义”和其他错误而感到沮丧。通过理解虚拟机如何搜索包和类型,可以部分避免这种挫折。

在这一节中,我将解释搜索过程是如何工作的。要理解这个过程,需要认识到编译器是一个特殊的 Java 应用,它在虚拟机的控制下运行。此外,还有两种不同形式的搜索。

编译时搜索

当编译器在源代码中遇到类型表达式(如方法调用)时,它必须找到该类型的声明,以验证表达式是合法的(例如,类型的类中存在一个方法,其参数类型与方法调用中传递的参数类型相匹配)。

编译器首先搜索 Java 平台包(包含类库类型)。然后它搜索扩展包(寻找扩展类型)。当在启动虚拟机时指定了 -sourcepath 命令行选项时(通过 javac ,编译器搜索指定路径的源文件。

注意 Java 平台包存储在 rt.jar 和其他一些重要的 jar 文件中。扩展包存储在一个名为 ext 的特殊扩展目录中。

否则,编译器会在用户类路径中(按从左到右的顺序)搜索包含该类型的第一个用户类文件或源文件。如果没有用户类路径,则搜索当前目录。如果没有匹配的包或者仍然找不到类型,编译器会报告一个错误。否则,编译器会将包信息记录在类文件中。

注意用户类路径是通过用于启动虚拟机的 -classpath 选项指定的,或者当不存在时,通过 CLASSPATH 环境变量指定。

运行时搜索

当编译器或任何其他 Java 应用运行时,虚拟机将遇到类型,并且必须通过称为类加载器的特殊代码加载它们相关的类文件。虚拟机将使用先前存储的与所遇到的类型相关联的包信息来搜索该类型的类文件。

虚拟机搜索 Java 平台包,然后是扩展包,接着是用户类路径(从左到右的顺序)以找到包含该类型的第一个类文件。如果没有用户类路径,则搜索当前目录。如果没有匹配的包或找不到类型,则报告“找不到类定义”错误。否则,类文件被加载到内存中。

注意无论是使用 -classpath 选项还是 CLASSPATH 环境变量来指定用户类路径,都有一个特定的格式必须遵循。在 Windows 下,这种格式表示为 path 1;path2...,其中 path1 、 path2 等是包目录的位置。在 Unix 和 Linux 下,这种格式变为 path1:path2:...。

玩包

假设您的应用需要将消息记录到控制台、文件或另一个目的地。它可以在日志库的帮助下完成这项任务。我对这个库的实现包括一个名为 Logger 的接口,一个名为 LoggerFactory 的抽象类,以及一对名为控制台和文件的包私有类。

注意我介绍的日志库是抽象工厂设计模式的一个例子,它在第 87 页的设计模式:可重用面向对象软件的元素中有介绍,作者是 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

清单 5-17 展示了记录器接口,它描述了记录消息的对象。

清单 5-17 。描述通过记录器接口记录消息的对象

package logging;

public interface Logger
{
   boolean connect();
   boolean disconnect();
   boolean log(String msg);
}

每个 connect() 、、disconnect() 和 log() 方法在成功时返回 true,在失败时返回 false。(在本章的后面,你会发现一种处理失败的更好的技巧。)这些方法没有显式地声明为 public ,因为接口的方法是隐式的 public 。

清单 5-18 展示了 LoggerFactory 抽象类。

清单 5-18 。获取用于将消息记录到特定目的地的记录器

package logging;

public abstract class LoggerFactory
{
   public final static int CONSOLE = 0;
   public final static int FILE = 1;

   public static Logger newLogger(int dstType, String... dstName)
   {
      switch (dstType)
      {
         case CONSOLE: return new Console(dstName.length == 0 ? null
                                                              : dstName[0]);
         case FILE   : return new File(dstName.length == 0 ? null
                                                           : dstName[0]);
         default     : return null;
      }
   }
}

newLogger() 返回一个记录器对象,用于将消息记录到适当的目的地。它使用 varargs(可变参数)特性(参见第三章)来选择性地接受额外的字符串参数,用于那些需要参数的目的地类型。例如,文件需要一个文件名。

清单 5-19 给出了包私有控制台类——这个类不能在日志包中的类之外访问,因为保留字类前面没有保留字公共。

清单 5-19 。将消息记录到控制台

package logging;

class Console implements Logger
{
   private String dstName;

   Console(String dstName)
   {
      this.dstName = dstName;
   }

   @Override
   public boolean connect()
   {
      return true;
   }

   @Override
   public boolean disconnect()
   {
      return true;
   }

   @Override
   public boolean log(String msg)
   {
      System.out.println(msg);
      return true;
   }
}

控制台的 package-private 构造函数保存其参数,该参数很可能是 null ,因为不需要字符串参数。也许控制台的未来版本会使用这个参数来标识多个控制台窗口中的一个。

清单 5-20 呈现了包私有文件类。

清单 5-20 。将消息记录到文件中(最终)

package logging;

class File implements Logger
{
   private String dstName;

   File(String dstName)
   {
      this.dstName = dstName;
   }

   @Override
   public boolean connect()
   {
      if (dstName == null)
         return false;
      System.out.println("opening file " + dstName);
      return true;
   }

   @Override
   public boolean disconnect()
   {
      if (dstName == null)
         return false;
      System.out.println("closing file " + dstName);
      return true;
   }

   @Override
   public boolean log(String msg)
   {
      if (dstName == null)
         return false;
      System.out.println("writing "+msg+" to file " + dstName);
      return true;
   }
}

与控制台不同,文件需要一个非空参数。每个方法首先验证这个参数不是 null 。如果参数为 null ,该方法返回 false 表示失败。(在第十一章的中,我重构了文件以包含适当的文件写入代码。)

日志库允许我们在应用中引入可移植的日志代码。除了调用 newLogger() 之外,不管日志记录的目的地是哪里,这段代码都将保持不变。清单 5-21 展示了一个测试这个库的应用。

清单 5-21 。测试日志库

import logging.Logger;
import logging.LoggerFactory;

public class TestLogger
{
   public static void main(String[] args)
   {
      Logger logger = LoggerFactory.newLogger(LoggerFactory.CONSOLE);
      if (logger.connect())
      {
         logger.log("test message #1");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to console-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE, "x.txt");
      if (logger.connect())
      {
         logger.log("test message #2");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE);
      if (logger.connect())
      {
         logger.log("test message #3");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
   }
}

按照步骤(假设已经安装了 JDK)创建日志包和测试日志应用,并运行该应用:

  1. 创建一个新目录,并使该目录成为当前目录。
  2. 在当前目录下创建一个日志目录。
  3. 将清单 5-17 复制到日志目录下一个名为【Logger.java 的文件中。
  4. 将清单 5-18 复制到日志目录下一个名为【LoggerFactory.java 的文件中。
  5. 将清单 5-19 复制到日志目录下一个名为【Console.java 的文件中。
  6. 将清单 5-20 复制到日志目录下一个名为【File.java 的文件中。
  7. 将清单 5-21 中的复制到当前目录中一个名为 TestLogger.java 的文件中。
  8. 执行 javac TestLogger.java,它也编译记录器的源文件。
  9. 执行 java 测试记录器。

完成上一步后,您应该观察到来自 TestLogger 应用的以下输出:

test message #1
opening file x.txt
writing test message #2 to file x.txt
closing file x.txt
cannot connect to file-based logger

当测井被移动到另一个位置时会发生什么?例如,将日志移动到根目录并运行测试日志。现在,您将看到一条错误消息,提示虚拟机没有找到日志包及其 LoggerFactory classfile。

您可以通过在运行 java 工具时指定 -classpath 或者将日志包的位置添加到 CLASSPATH 环境变量中来解决这个问题。例如,我选择在下面特定于 Windows 的命令行中使用 -classpath (我觉得这样更方便):

java -classpath \;. TestLogger

反斜杠代表 Windows 中的根目录。(我可以指定一个正斜杠作为替代。)此外,句点代表当前目录。如果它丢失了,虚拟机就会抱怨找不到 TestLogger classfile。

提示如果您发现一条虚拟机报告找不到应用类文件的错误消息,请尝试在类路径后面附加一个句点字符。这样做可能会解决问题。

包和 JAR 文件

JDK 提供了一个 jar 工具,用于归档 jar (Java 归档)文件中的类文件,也用于提取 JAR 文件的类文件。您可以将包存储在 JAR 文件中,这可能不足为奇,因为这极大地简化了基于包的类库的分发。

为了向您展示在 JAR 文件中存储一个包是多么容易,您将创建一个 logger.jar 文件,其中包含日志包的四个类文件( Logger.class 、 LoggerFactory.class 、 Console.class 和 File.class )。完成以下步骤来完成此任务:

  1. 确保当前目录包含之前创建的日志目录及其四个类文件。
  2. 执行 jar cf logger.jar logging*。类别。您也可以执行 jar cf logger.jar logging/*。类别。( c 选项代表“创建新的档案”, f 选项代表“指定档案文件名”。)

现在,您应该在当前目录中找到一个 logger.jar 文件。为了证明这个文件包含四个类文件,执行 jar tf logger.jar 。( t 选项代表“目录列表”。)

您可以通过将 logger.jar 添加到类路径来运行 TestLogger.class 。比如可以通过 java -classpath logger.jar 在 Windows 下运行 test logger;。测试记录器。

注意如果您需要日志功能,您可以像前面演示的那样创建自己的日志框架,或者利用标准类库中包含的 java.util.logging 包。

掌握静态导入

接口应该只用于声明类型。然而,一些开发人员违反了这一原则,使用接口只导出常量。这样的接口被称为常量接口 ,清单 5-22 中的给出了一个例子。

清单 5-22 。声明常量接口

interface Directions
{
   int NORTH = 0;
   int SOUTH = 1;
   int EAST = 2;
   int WEST = 3;
}

使用常量接口的开发人员这样做是为了避免在常量名称前加上其类名(如在 Math 中)。PI ,其中 PI 是 java.lang.Math 类中的常数)。他们通过实现接口来做到这一点——参见清单 5-23 。

清单 5-23 。实现常数接口

public class TrafficFlow implements Directions
{
   public static void main(String[] args)
   {
      showDirection((int) (Math.random()* 4));
   }

   static void showDirection(int dir)
   {
      switch (dir)
      {
         case NORTH: System.out.println("Moving north"); break;
         case SOUTH: System.out.println("Moving south"); break;
         case EAST : System.out.println("Moving east"); break;
         case WEST : System.out.println("Moving west");
      }
   }
}

清单 5-23 的 TrafficFlow 类实现了方向,唯一的目的是不必指定方向。北、两个方向。向南、方向。东和方向。西。

这是一个令人震惊的接口误用。这些常量只不过是一个实现细节,不允许泄露到类的导出接口中,因为它们可能会混淆类的用户(这些常量的目的是什么?).此外,它们代表了未来的承诺:即使当类不再使用这些常量时,接口也必须保留以确保二进制兼容性。

Java 5 引入了一种替代方案,既满足了对常量接口的需求,又避免了它们的问题。这个静态导入特性允许您导入一个类的静态成员,这样您就不必用它们的类名来限定它们。它是通过对 import 语句进行如下的小修改来实现的:

import static *packagespec* . *classname* . ( *staticmembername* | * );

静态导入语句在导入后指定静态。然后,它指定一个成员访问操作符分隔的包和子包名称列表,后面是成员访问操作符和类名。再次指定成员访问操作符,后跟一个静态成员名或星号通配符。

注意在静态导入语句上放置除了 package 语句、import/static import 语句和注释之外的任何内容都会导致编译器报告错误。

您可以指定一个静态成员名称,以便只导入该名称:

import static java.lang.Math.PI;  // Import the PI static field only.
import static java.lang.Math.cos; // Import the cos() static method only.

相反,您可以指定通配符来导入所有静态成员名称:

import static java.lang.Math.*;   // Import all static members from Math.

现在,您可以引用静态成员,而不必指定类名:

System.out.println(cos(PI));

使用多个静态导入语句会导致名称冲突,从而导致编译器报告错误。例如,假设您的 geom 包包含一个 Circle 类,其中有一个名为 PI 的静态成员。现在假设你指定导入静态 Java . lang . math . *;和导入静态 geom 圆. *;在你的源文件的顶部。最后,假设你指定了 system . out . println(PI);在文件代码的某个地方。编译器报告错误,因为它不知道 PI 是属于 Math 还是属于 Circle 。

主控异常

在理想情况下,应用运行时不会发生任何不好的事情。例如,当应用需要打开文件时,文件总是存在的,应用总是能够连接到远程计算机,并且当应用需要实例化对象时,虚拟机永远不会耗尽内存。

相比之下,真实世界的应用偶尔会尝试打开不存在的文件,尝试连接到无法与之通信的远程计算机,并且需要比虚拟机所能提供的更多的内存。您的目标是编写适当响应这些和其他异常情况(异常)的代码。

在这一节中,我将向您介绍异常。在定义了这个术语之后,我看一下在源代码中表示异常。然后,我将研究抛出和处理异常的主题,并通过讨论如何在方法返回之前执行清理任务来结束本文,无论是否抛出了异常。

什么是例外?

一个异常 是与应用正常行为的背离。例如,应用试图打开一个不存在的文件进行读取。正常行为是成功打开文件并开始读取其内容。但是,当文件不存在时,无法读取该文件。

这个例子说明了一个不可避免的异常。然而,一个变通办法是可能的。例如,应用可以检测到该文件不存在,并采取替代措施,这可能包括告诉用户该问题。不可避免的例外情况,如果有可能的解决办法,一定不能忽视。

由于代码编写得不好,可能会出现异常。例如,应用可能包含访问数组中每个元素的代码。由于疏忽,数组访问代码可能试图访问一个不存在的数组元素,从而导致异常。这种异常可以通过编写正确的代码来避免。

最后,可能会发生无法阻止且没有解决方法的异常。例如,虚拟机可能耗尽内存,或者可能找不到类文件。这种被称为错误的异常非常严重,以至于无法(或者至少是不可取的)解决;应用必须终止,向用户显示一条消息,解释它终止的原因。

在源代码中表示异常

异常可以通过错误代码或对象来表示。在讨论了每一种表示并解释了为什么对象更优越之后,我将向您介绍 Java 的异常和错误类层次结构,强调检查异常和运行时异常之间的区别。我通过讨论自定义异常类来结束关于在源代码中表示异常的讨论。

错误代码与对象

在源代码中表示异常的一种方法是使用错误代码。例如,一个方法可能在成功时返回 true,在发生异常时返回 false。或者,一个方法可能在成功时返回 0,并返回一个非零的整数值来标识特定类型的异常。

开发人员传统上设计方法来返回错误代码;我在清单 5-17 的记录器接口中的三种方法中的每一种方法中都展示了这一传统。每个方法在成功时返回 true,或者返回 false 来表示异常(例如,无法连接到记录器)。

尽管必须检查方法的返回值以确定它是否代表异常,但是错误代码很容易被忽略。例如,懒惰的开发人员可能会忽略来自记录器的 connect() 方法的返回代码,并试图调用 log() 。忽略错误代码是发明一种处理异常的新方法的原因之一。

这种新方法是基于对象的。当异常发生时,表示异常的对象由异常发生时正在运行的代码创建。描述异常周围上下文的详细信息存储在对象中。稍后将检查这些细节以解决异常。

然后对象被抛出或者交给虚拟机来搜索一个处理程序,可以处理异常的代码。(如果异常是一个错误,应用不应该提供一个处理程序,因为错误是如此严重[例如,虚拟机内存不足],以至于实际上对它们无能为力。)当处理程序被定位时,它的代码被执行以提供一个解决方法。否则,虚拟机终止该应用。

注意处理异常的代码可能是错误的来源,因为它通常没有经过彻底的测试。请务必测试任何处理异常的代码。

除了太容易被忽略之外,错误代码的布尔值或整数值还不如对象名有意义。比如 fileNotFound 不言而喻,但是 false 是什么意思呢?此外,对象可以包含导致异常的信息。这些细节有助于找到合适的解决方法。

可抛出的类层次结构

Java 提供了表示不同类型异常的类的层次结构。这些类根植于 java.lang.Throwable ,是所有throwable(异常和错误对象——简称为异常和错误——可以被抛出)的终极超类。表 5-1 标识和描述了大多数可抛出的构造函数和方法。

表 5-1。 Throwable 的构造函数和方法

方法 描述
Throwable() 创建一个包含空详细信息和原因的 throwable。
Throwable(字符串消息) 使用指定的详细消息和空原因创建一个 throwable。
Throwable(字符串消息,可抛出原因) 用指定的详细消息和原因创建一个 throwable。
可投掷(可投掷原因) 创建一个 throwable,其详细消息是非空原因或 null 的字符串表示形式。
【一次性填料跟踪() 填写执行堆栈跟踪。这个方法记录当前线程堆栈帧的当前状态信息。(我在第八章的中讨论线程。)
Throwable getCause() 返回这个抛出的原因。如果没有原因,则返回 null。
字符串 getMessage() 返回 throwable 的详细信息,可能为空。
StackTraceElement [] getStackTrace() 提供对由 printStackTrace() 打印的堆栈跟踪信息的编程访问,作为堆栈跟踪元素的数组,每个元素代表一个堆栈帧。
可抛出的 initCause(可抛出的原因) 将此 throwable 的原因初始化为指定的值。
void printStackTrace() 将这个 throwable 及其堆栈帧的回溯打印到标准错误流。
void set stack trace(stack trace element[]stack trace) 设置 getStackTrace() 返回的、 printStackTrace() 打印的堆栈跟踪元素及相关方法。

一个类的公共方法调用抛出各种异常的助手方法并不少见。公共方法可能不会记录从助手方法抛出的异常,因为它们是实现细节,通常对公共方法的调用方是不可见的。

但是,因为此异常可能有助于诊断问题,所以公共方法可以将较低级别的异常包装在公共方法的契约接口中记录的较高级别的异常中。包装的异常被称为原因 ,因为它的存在导致更高级别的异常被抛出。

通过调用 Throwable(Throwable cause)或 Throwable(String message,Throwable cause) 构造函数来创建原因,它们调用 initCause() 方法来存储原因。如果你没有调用任何一个构造函数,你可以直接调用 initCause() ,但是你必须在创建 throwable 之后立即这样做。调用 getCause() 方法返回原因。

当抛出异常时,它会留下一堆未完成的方法调用。 Throwable 的构造函数调用 fillInStackTrace() 记录该堆栈跟踪信息,通过调用 printStackTrace() 输出。

getStackTrace() 方法通过将该信息作为一组 Java . lang . stacktraceelement 实例返回来提供对堆栈跟踪的编程访问——每个实例代表一个条目。 StackTraceElement 提供了返回堆栈跟踪信息的方法。例如, String getMethodName() 返回未完成方法的名称。

setStackTrace() 方法是为远程过程调用(RPC)框架(参见)和其他高级系统而设计的,允许客户端在构造 throwable 或从序列化流中读取 throwable 时覆盖由 fillInStackTrace() 生成的默认堆栈跟踪。(我会在第十一章讨论序列化。)

沿着 throwable 层次结构向下,您会遇到 java.lang.Exception 和 java.lang.Error 类,它们分别代表异常和错误。每个类都提供了四个构造函数,将它们的参数传递给它们的 Throwable 对手,但是除了那些从 Throwable 继承的方法之外,没有提供其他方法。

Exception 本身又被 Java . lang . clonenotsupportedexception(在第四章中讨论过)、 java.lang.IOException (在第十一章中讨论过)等类子类化。同样, Error 本身也是 Java . lang . assertion Error(在第六章中讨论过)、Java . lang . out of memory Error 等类的子类。

注意切勿实例化可抛出、异常或错误。产生的对象没有意义,因为它们太普通了。

检查异常与运行时异常

一个检查异常是一个异常,它代表了一个可能恢复的问题,开发者必须提供一个解决方法。开发人员应该检查(检查)代码,以确保异常在抛出的方法中得到处理,或者被明确标识为在其他地方得到处理。

异常以及除了之外的所有子类 java.lang.RuntimeException (及其子类)描述了被检查的异常。例如,CloneNotSupportedException 和 IOException 类描述了被检查的异常。(CloneNotSupportedException 不应该被检查,因为对于这种异常没有运行时解决方法。)

运行时异常是一个代表编码错误的异常。这种异常也被称为未检查异常,因为它不需要被处理或显式识别——错误必须被修复。因为这些异常可能在许多地方发生,所以强制处理它们会很麻烦。

RuntimeException 及其子类描述未检查的异常。例如,Java . lang . arithmetic exception 描述了整数被零除等算术问题。另一个例子是 Java . lang . arrayindexoutofboundsexception,当你试图访问一个负索引或者索引大于等于数组长度的数组元素时抛出。(事后看来, RuntimeException 应该被命名为 UncheckedException ,因为所有的异常都发生在运行时。)

注意许多开发人员对检查异常不满意,因为处理它们涉及到很多工作。当库提供的方法应该抛出未检查的异常时,却抛出已检查的异常,这使得问题变得更加严重。因此,许多现代语言只支持未检查的异常。

自定义异常类

您可以声明自己的异常类。在这样做之前,问问你自己,标准类库中现有的异常类是否满足你的需要。如果你找到一个合适的类,你应该重用它。(为什么要多此一举?)其他开发人员将已经熟悉现有的类,这些知识将使您的代码更容易学习。当没有现有的类满足您的需求时,考虑一下是子类化异常还是运行时异常。换句话说,您的异常类是被选中还是未被选中?根据经验,如果你认为它会描述一个编码错误,你的类应该子类化 RuntimeException 。

提示当你命名你的类时,遵循提供一个异常后缀的惯例。这个后缀表明你的类描述了一个异常。

假设您正在创建一个 Media 类,它的静态方法是执行面向媒体的工具任务。例如,一种方法将非 MP3 媒体格式的声音文件转换成 MP3 格式。此方法将被传递源文件和目标文件参数,并将源文件转换为目标文件扩展名所暗示的格式。

在执行转换之前,该方法需要验证源文件的格式是否与其文件扩展名所暗示的格式一致。如果没有协议,就必须抛出一个异常。此外,这个异常必须存储预期的和现有的媒体格式,以便处理程序在向用户显示消息时可以识别它们。

因为 Java 的类库没有提供合适的异常类,所以您决定引入一个名为 InvalidMediaFormatException 的类。检测到无效的媒体格式并不是编码错误的结果,因此您还决定扩展异常以指示该异常已被检查。清单 5-24 展示了这个类的声明。

清单 5-24 。声明自定义异常类

package media;

public class InvalidMediaFormatException extends Exception
{
   private String expectedFormat;
   private String existingFormat;

   public InvalidMediaFormatException(String expectedFormat,
                                      String existingFormat)
   {
      super("Expected format: " + expectedFormat + ", Existing format: " +
            existingFormat);
      this.expectedFormat = expectedFormat;
      this.existingFormat = existingFormat;
   }

   public String getExpectedFormat()
   {
      return expectedFormat;
   }

   public String getExistingFormat()
   {
      return existingFormat;
   }
}

InvalidMediaFormatException 提供了一个构造函数,该构造函数调用 Exception 的公共异常(字符串消息)构造函数,该构造函数带有一个包含预期格式和现有格式的详细消息。在详细消息中捕获这样的细节是明智的,因为导致异常的问题可能很难重现。

InvalidMediaFormatException 还提供了返回这些格式的 getExpectedFormat()和 getExistingFormat() 方法 。也许处理程序会在消息中向用户提供这些信息。与详细消息不同,此消息可能是本地化的,以用户语言(法语、德语、英语等)表达。).

*抛出异常

现在您已经创建了一个 InvalidMediaFormatException 类,您可以声明 Media 类并开始编写其 convert() 方法。此方法的初始版本验证其参数,然后验证源文件的媒体格式是否与其文件扩展名所暗示的格式一致。查看清单 5-25 。

清单 5-25 。从 convert() 方法抛出异常

package media;

import java.io.IOException;

public final class Media
{
   public static void convert(String srcName, String dstName)
      throws InvalidMediaFormatException, IOException
   {
      if (srcName == null)
         throw new NullPointerException(srcName + " is null");
      if (dstName == null)
         throw new NullPointerException(dstName + " is null");
      // Code to access source file and verify that its format matches the
      // format implied by its file extension.
      //
      // Assume that the source file's extension is RM (for Real Media) and
      // that the file's internal signature suggests that its format is
      // Microsoft WAVE.
      String expectedFormat = "RM";
      String existingFormat = "WAVE";
      throw new InvalidMediaFormatException(expectedFormat, existingFormat);
   }
}

清单 5-25 声明媒体类为最终类,因为这个实用类将只包含类方法,没有理由扩展它。

Media 的 convert() 方法将 throws InvalidMediaFormatException,IOException 追加到它的头中。一个 throws 子句标识所有被检查的异常,这些异常被抛出该方法,并且必须由其他方法处理。它由保留字 throws 组成,后跟一个逗号分隔的已检查异常类名列表,并且总是被附加到方法头。 convert() 方法的 throws 子句表明该方法能够向虚拟机抛出 InvalidMediaException 或 IOException 实例。

convert() 还演示了 throw 语句,它由保留字 throw 后跟一个 Throwable 或子类的实例组成。(您通常会实例化一个异常子类。)该语句将实例抛出给虚拟机,然后虚拟机搜索合适的处理程序来处理异常。

throw 语句的第一个用途是当空引用作为源或目标文件名传递时,抛出一个 Java . lang . nullpointerexception 实例。这种未经检查的异常通常被抛出,以指示通过传递的空引用违反了协定。例如,您不能将空文件名传递给 convert() 。

throw 语句的第二个用途是抛出一个媒体。当预期的媒体格式与现有格式不匹配时,InvalidMediaFormatException 实例无效。在这个虚构的例子中,抛出了异常,因为预期的格式是 RM,而现有的格式是 WAVE。

与 InvalidMediaFormatException 不同, NullPointerException 没有在 convert() 的 throws 子句中列出,因为 NullPointerException 实例未被检查。它们可能发生得如此频繁,以至于迫使开发人员正确处理这些异常的负担太重。相反,开发人员应该编写尽量减少这种情况发生的代码。

虽然没有从 convert() 抛出, IOException 还是列在了这个方法的 throws 子句中,为重构这个方法做准备,以便在文件处理代码的帮助下执行转换。

NullPointerException 是一种当参数被证明无效时抛出的异常。Java . lang . illegalargumentexception 类概括了非法参数场景,以包括其他类型的非法参数。例如,当数字参数为负时,下面的方法抛出一个 IllegalArgumentException 实例:

public static double sqrt(double x)
{
   if (x < 0)
      throw new IllegalArgumentException(x + " is negative");
   // Calculate the square root of x.
}

在使用 throws 子句和 throw 语句时,有一些额外的事项需要记住:

  • 您可以将 throws 子句追加到构造函数中,并在构造函数执行过程中出错时抛出异常。将不会创建结果对象。
  • 当应用的 main() 方法抛出异常时,虚拟机终止应用并调用异常的 printStackTrace() 方法 将抛出异常时等待完成的嵌套方法调用序列打印到控制台。
  • 如果超类方法声明了一个 throws 子句,重写子类方法就不必声明 throws 子句。但是,如果子类方法确实声明了 throws 子句,则该子句不得包含未包含在超类方法的 throws 子句中的已检查异常类的名称,除非它们是异常子类的名称。例如,给定超类方法 void foo()抛出 IOException {} ,覆盖子类方法可以声明为 void foo() {} 、 void foo()抛出 IOException {} ,或者 void foo()抛出 file not found exception { }—Java . io . file not found exception 类子类 IOException 。
  • 当超类的名字出现时,被检查的异常类名不需要出现在 throws 子句中。
  • 当一个方法抛出一个检查过的异常,并且没有处理这个异常或者在其 throws 子句中列出这个异常时,编译器会报告一个错误。
  • 不要在 throws 子句中包含未检查的异常类的名称。这些名称不是必需的,因为这种异常永远不会发生。此外,它们只会弄乱源代码,并可能使试图理解这些代码的人感到困惑。
  • 您可以在方法的 throws 子句中声明检查的异常类名,而无需从方法中引发该类的实例。(也许这个方法还没有完全编码。)但是,Java 要求您提供代码来处理这个异常,即使它没有被抛出。

处理异常

方法通过指定包含一个或多个适当 catch 块的 try 语句来表明其处理一个或多个异常的意图。try 语句由保留字 try 组成,后跟一个大括号分隔的主体。将引发异常的代码放入这个块中。

catch 块由保留字 catch 组成,后面是圆括号分隔的指定异常类名的单参数列表,后面是大括号分隔的主体。您将处理异常的代码放置在此块中,这些异常的类型与 catch 块的参数列表的异常类参数的类型相匹配。

catch 块紧跟在 try 块之后指定。当抛出异常时,虚拟机将搜索一个处理程序。它首先检查 catch 块,看它的参数类型是否匹配,或者是已经抛出的异常的超类类型。

如果找到了 catch 块,它的主体就会执行,并处理异常。否则,虚拟机将继续执行方法调用堆栈,查找其 try 语句包含适当 catch 块的第一个方法。除非找到 catch 块或者执行离开了 main() 方法,否则这个过程将继续。

以下示例说明了 try and catch:

try
{
   int x = 1 / 0;
}
catch (ArithmeticException ae)
{
   System.out.println("attempt to divide by zero");
}

当执行进入 try 块时,会尝试将整数 1 除以整数 0。虚拟机通过实例化算术异常 并抛出该异常来响应。然后它检测 catch 块,该块能够处理抛出的 ArithmeticException 对象,并将执行转移到该块,该块调用 System.out.println() 输出适当的消息——异常得到处理。

因为 ArithmeticException 是未检查异常类型的一个例子,并且因为未检查异常表示必须修复的编码错误,所以您通常不会捕捉到它们,如前所述。相反,您应该修复导致抛出异常的问题。

提示您可能希望使用上一节中显示的缩写样式来命名 catch 块参数。这种约定不仅会产生更有意义的面向异常的参数名( ae 表示已经抛出了算术异常),还能帮助减少编译器错误。例如,为了方便起见,通常将 catch 块的参数命名为 e 。(为什么要打长名字?)然而,当先前声明的局部变量或参数也使用 e 作为其名称时,编译器将报告错误——多个同名的局部变量和参数不能存在于同一个范围内。

处理多种异常类型

可以在 try 块后指定多个 catch 块。例如,清单 5-25 的 convert() 方法指定了一个 throws 子句,表示 convert() 可以抛出当前抛出的 InvalidMediaFormatException 和重构 convert() 时抛出的 IOException 。该重构将导致 convert() 在无法读取源文件或写入目标文件时抛出 IOException ,在无法打开源文件或创建目标文件时抛出 FileNotFoundException(是 IOException 的子类)。所有这些异常都必须被处理,如清单 5-26 所示。

清单 5-26 。处理不同种类的异常

import java.io.FileNotFoundException;
import java.io.IOException;

import media.InvalidMediaFormatException;
import media.Media;

public class Converter
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Converter srcfile dstfile");
         return;
      }
      try
      {
         Media.convert(args[0], args[1]);
      }
      catch (InvalidMediaFormatException imfe)
      {
         System.out.println("Unable to convert " + args[0] + " to " + args[1]);
         System.out.println("Expecting " + args[0] + " to conform to " +
                            imfe.getExpectedFormat() + " format.");
         System.out.println("However, " + args[0] + " conformed to " +
                            imfe.getExistingFormat() + " format.");
      }
      catch (FileNotFoundException fnfe)
      {
      }
      catch (IOException ioe)
      {
      }
   }
}

对清单 5-26 中 Media 的 convert() 方法的调用被放在一个 try 块中,因为该方法能够抛出被检查的 InvalidMediaFormatException、 IOException 或 FileNotFoundException 类的实例——被检查的异常必须通过附加到该方法的 throws 子句来处理或声明抛出。

catch(InvalidMediaFormatException imfe)块的语句旨在向用户提供一条描述性的错误消息。更复杂的应用会将这些名称本地化,以便用户可以用自己的语言阅读消息。不输出面向开发人员的详细消息,因为在这个普通的应用中不需要。

注意面向开发人员的详细消息通常没有本地化。而是用开发者的语言来表达。用户永远不会看到详细消息。

虽然没有抛出,但是需要一个针对 IOException 的 catch 块,因为这个检查过的异常类型出现在 convert() 的 throws 子句中。因为 catch (IOException ioe) 块也可以处理抛出的 FileNotFoundException 实例(因为 FileNotFoundException 子类 IOException ),所以 catch(file not found exception fnfe)块在这一点上并不是必需的,但它的存在是为了分离对无法打开文件进行读取或创建文件进行写入的情况的处理(一旦重构了 convert() 就会解决这个问题)

假设当前目录包含清单 5-26 和一个包含 InvalidMediaFormatException.java 和 Media.java 的媒体子目录,编译这个清单(javac Converter.java,它也编译媒体的源文件,并运行应用,如 java Converter A B 所示。转换器通过呈现以下输出做出响应:

Unable to convert A to B
Expecting A to conform to RM format.
However, A conformed to WAVE format.

清单 5-26 的空 FileNotFoundException 和 IOException catch 块说明了一个常见的问题,即让 catch 块为空是因为它们不方便编码。除非有充分的理由,否则不要创建空的 catch 块。它吞掉了异常,而你不知道异常被抛出了。(为了简洁起见,我并不总是在本书的例子中编写 catch 块。)

注意当您在 try 主体后指定两个或更多具有相同参数类型的 catch 块时,编译器会报告错误。例:试试{ } catch(io exception ioe 1){ } catch(io exception ioe 2){ }。您必须将这些 catch 块合并成一个块。

尽管可以按任何顺序编写 catch 块,但当一个 catch 块的参数是另一个 catch 块的参数的超类型时,编译器会限制这种顺序。子类型参数 catch 块必须在超类型参数 catch 块之前;否则,将永远不会执行子类型参数 catch 块。

例如,FileNotFoundExceptioncatch 块必须在 IOException catch 块之前。如果编译器允许首先指定 IOException catch 块,那么 file not found exceptioncatch 块将永远不会执行,因为 FileNotFoundException 实例也是其 IOException 超类的实例。

再次抛出异常

在讨论 Throwable 类时,我讨论了在高级异常中包装低级异常。此活动通常发生在 catch 块中,如下例所示:

catch (IOException ioe)
{
   throw new ReportCreationException(ioe);
}

这个例子假设一个 helper 方法刚刚抛出了一个通用的 IOException 实例,作为尝试创建一个报告的结果。公共方法的契约声明在这种情况下抛出 ReportCreationException。为了满足约定,抛出后一个异常。为了让负责调试错误应用的开发人员满意, IOException 实例被包装在 ReportCreationException 实例中,该实例被抛出给公共方法的调用者。

有时,catch 块可能无法完全处理异常。也许它需要访问方法调用堆栈中某个祖先方法提供的信息。但是,catch 块可能能够部分处理该异常。在这种情况下,它应该部分处理异常,然后重新抛出异常,以便祖先方法中的处理程序可以完成对它的处理。另一种可能性是记录异常(供以后分析),这在下面的示例中进行了演示:

catch (FileNotFoundException fnfe)
{
   logger.log(fnfe);
   throw fnfe; // Rethrow the exception here.
}

执行清理

在某些情况下,您可能希望在执行过程中留下一个引发异常的方法之前执行清理代码。例如,您可能希望关闭一个已打开但无法写入的文件,这可能是因为磁盘空间不足。Java 为这种情况提供了 finally 块。

finally 块由保留字 finally 组成,后跟一个主体,提供清理代码。finally 块跟在 catch 块或 try 块后面。在前一种情况下,异常可能在最终执行之前被处理(也可能被重新抛出)。在后一种情况下,异常在最终执行后被处理(并且可能被重新抛出)。

清单 5-27 展示了模拟文件复制应用的 main() 方法的第一个场景。

清单 5-27 。在处理一个抛出的异常后,通过关闭文件进行清理

import java.io.IOException;

public class Copy
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcFile dstFile");
         return;
      }

      int fileHandleSrc = 0;
      int fileHandleDst = 1;
      try
      {
         fileHandleSrc = open(args[0]);
         fileHandleDst = create(args[1]);
         copy(fileHandleSrc, fileHandleDst);
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: " + ioe.getMessage());
         return;
      }
      finally
      {
         close(fileHandleSrc);
         close(fileHandleDst);
      }
   }

   static int open(String filename)
   {
      return 1; // Assume that filename is mapped to integer.
   }

   static int create(String filename)
   {
      return 2; // Assume that filename is mapped to integer.
   }

   static void close(int fileHandle)
   {
      System.out.println("closing file: " + fileHandle);
   }

   static void copy(int fileHandleSrc, int fileHandleDst) throws IOException
   {
      System.out.println("copying file " + fileHandleSrc + " to file " +
                         fileHandleDst);
      if (Math.random() < 0.5)
         throw new IOException("unable to copy file");
   }
}

清单 5-27 展示了一个复制应用类,它模拟了从源文件到目标文件的字节复制。try 块调用 open() 方法打开源文件,调用 create() 方法创建目标文件。每个方法都返回一个基于整数的文件句柄,它唯一地标识了文件。

接下来,这个块调用 copy() 方法来执行复制。在输出一个合适的消息后, copy() 调用 Math 类的 random() 方法(在第七章中正式讨论过)返回一个介于 0 和 1 之间的随机数。当这个方法返回一个小于 0.5 的值,这模拟了一个问题(可能磁盘已满),实例化 IOException 类并抛出这个实例。

虚拟机定位 try 块之后的 catch 块,并使其处理程序执行,从而输出一条消息。然后,允许执行 catch 块后面的 finally 块中的代码。它的目的是通过调用传递的文件句柄上的 close() 方法来关闭两个文件。

编译这段源代码(javac Copy.java),用两个任意参数运行应用( java Copy x.txt x.bak )。没有问题时,您应该观察到以下输出:

copying file 1 to file 2
closing file: 1
closing file: 2

当出现问题时,您应该观察到以下输出:

copying file 1 to file 2
I/O error: unable to copy file
closing file: 1
closing file: 2

无论是否发生 I/O 错误,请注意 finally 块是要执行的最后一个代码。即使 catch 块以 return 语句结束,finally 块也会执行。

此示例说明了处理引发的异常后的 finally 块执行。但是,您可能希望在处理异常之前执行清理。清单 5-28 展示了一个复制应用的变体,演示了这种替代方案。

清单 5-28 。在处理抛出的异常之前通过关闭文件进行清理

import java.io.IOException;

public class Copy
{
   public static void main(String[] args) throws IOException
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcFile dstFile");
         return;
      }

      int fileHandleSrc = 0;
      int fileHandleDst = 1;
      try
      {
         fileHandleSrc = open(args[0]);
         fileHandleDst = create(args[1]);
         copy(fileHandleSrc, fileHandleDst);
      }
      finally
      {
         close(fileHandleSrc);
         close(fileHandleDst);
      }
   }

   static int open(String filename)
   {
      return 1; // Assume that filename is mapped to integer.
   }

   static int create(String filename)
   {
      return 2; // Assume that filename is mapped to integer.
   }

   static void close(int fileHandle)
   {
      System.out.println("closing file: " + fileHandle);
   }

   static void copy(int fileHandleSrc, int fileHandleDst) throws IOException
   {
      System.out.println("copying file " + fileHandleSrc + " to file " +
                         fileHandleDst);
      if (Math.random() < 0.5)
         throw new IOException("unable to copy file");
   }
}

清单 5-28 与清单 5-27 中的几乎相同。唯一的区别是附加到 main() 方法头的 throws 子句和 catch 块的移除。当抛出 IOException 时,finally 块在执行离开 main() 方法之前执行。这一次,Java 的默认异常处理程序执行 printStackTrace() ,您会看到类似如下的输出:

copying file 1 to file 2
closing file: 1
closing file: 2
Exception in thread "main" java.io.IOException: unable to copy file
                at Copy.copy(Copy.java:48)
                at Copy.main(Copy.java:19)

练习

以下练习旨在测试您对第五章内容的理解:

  1. 什么是嵌套类?
  2. 识别四种嵌套类。
  3. 哪些嵌套类也被称为内部类?
  4. 是非判断:静态成员类有一个封闭实例。
  5. 如何从封闭类之外实例化一个非静态成员类?
  6. 什么时候需要声明局部变量和参数 final ?
  7. 是非判断:一个接口可以在一个类中声明,也可以在另一个接口中声明。
  8. 定义包。
  9. 如何确保包名是唯一的?
  10. 什么是包语句?
  11. 是非判断:您可以在一个源文件中指定多个 package 语句。
  12. 什么是进口陈述?
  13. 如何表明希望通过一条 import 语句导入多种类型?
  14. 在运行时搜索期间,当虚拟机找不到类文件时会发生什么?
  15. 如何指定虚拟机的用户类路径?
  16. 定义常量接口。
  17. 为什么使用常量接口?
  18. 为什么常量接口不好?
  19. 什么是静态导入语句?
  20. 如何指定静态导入语句?
  21. 什么是例外?
  22. 在表示异常方面,对象在哪些方面优于错误代码?
  23. 什么是可投掷的?
  24. getCause() 方法返回什么?
  25. 异常和错误有什么区别?
  26. 什么是检查异常?
  27. 什么是运行时异常?
  28. 在什么情况下你会引入自己的异常类?
  29. 是非判断:通过将 throw 语句追加到方法的头,可以使用该语句来标识从方法中引发的异常。
  30. try 语句的目的是什么,catch 块的目的是什么?
  31. finally 块的目的是什么?
  32. 2D 图形软件包支持二维绘图和转换(旋转,缩放,平移等)。).这些转换需要一个 3 乘 3 的矩阵(一个表格)。声明一个 G2D 类,它包含一个私有的矩阵非静态成员类。在 G2D 的无参数构造函数中实例化矩阵,将矩阵实例初始化为单位矩阵(除了左上角到右下角的元素为 1,其他元素均为 0 的矩阵)。
  33. 扩展日志包以支持一个空设备,其中的消息被丢弃。
  34. 修改日志包,使日志记录器的 connect() 方法在无法连接到其日志目的地时抛出 CannotConnectException ,另外两个方法在未调用 connect() 或抛出 CannotConnectException 时各抛出 NotConnectedException 。
  35. 修改测试记录器以适当地响应抛出的 CannotConnectException 和 NotConnectedException 对象。* *摘要

在任何类之外声明的类称为顶级类。Java 还支持嵌套类,即声明为其他类或作用域的成员的类。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三类被称为内部类。

Java 支持将顶级类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

package 语句标识源文件的类型所在的包。import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。

异常是与应用正常行为的差异。尽管可以用错误代码或对象来表示,但是 Java 使用对象,因为错误代码没有意义,并且不能包含导致异常的信息。

Java 提供了表示不同类型异常的类的层次结构。这些类根植于 Throwable 。沿着 throwable 层次向下,您会遇到异常和错误类,它们代表非错误异常和错误。

异常及其子类,除了 RuntimeException (及其子类)描述被检查的异常。之所以检查它们,是因为您必须检查代码,以确保异常在抛出或被识别为在其他地方处理时得到处理。

RuntimeException 及其子类描述未检查的异常。您不必处理这些异常,因为它们代表编码错误(修复错误)。尽管它们的类名可以出现在 throws 子句中,但这样做会增加混乱。

throw 语句向虚拟机抛出一个异常,虚拟机将搜索一个合适的处理程序。当检查异常时,其名称必须出现在方法的 throws 子句中,除非异常的超类的名称在该子句中列出。

方法通过指定 try 语句和适当的 catch 块来处理一个或多个异常。无论是否抛出异常,在抛出的异常离开方法之前,都可以包含 finally 块来执行清理代码。

第六章继续通过关注断言、注释、泛型和枚举来探索 Java 语言。*

六、掌握高级语言功能:第二部分

在第二章到第四章中,我为学习 Java 语言打下了基础,在第五章中,我在这个基础上引入了一些 Java 更高级的语言特性。在第六章的中,我将继续关注与断言、注释、泛型和枚举相关的高级语言特性。

掌握断言

写源代码不是一件容易的事情。太多的时候,错误被引入到代码中。如果在编译源代码之前没有发现 bug,那么它就会变成运行时代码,而运行时代码很可能会意外失败(或者没有失败的迹象,但给出错误的输出)。此时,很难确定失败的原因。

开发人员经常对应用的正确性做出假设,一些开发人员认为,在注释位置指定注释来陈述他们认为什么是真的就足以确定正确性。然而,注释对于防止错误是没有用的,因为编译器会忽略它们。

许多语言通过提供一种叫做断言的语言特性来解决这个问题,这种语言特性让开发人员编写关于应用正确性的假设。当应用运行时,如果断言失败,应用将终止,并显示一条消息,帮助开发人员诊断失败的原因。(您可能认为断言是编译器能够理解的注释。)

注意在他的“Assert Statements Shine Light Into Dark Corners”博客文章(www . drdobbs . com/CPP/Assert-Statements-Shine-Light-Into-Dark/240012746)中,计算机科学家安德鲁·克尼格提到断言是用来检测不变量故障的,其中不变量是你的代码中不应该改变的东西。例如,您可能希望在尝试通过二分搜索法算法搜索列表之前验证数据项列表已排序(不变量)的预期,该算法要求列表已排序。你可以使用断言来了解不变量是否成立。

在这一节中,我将向您介绍 Java 的断言语言特性。在定义了这个术语,向您展示了如何声明断言,并提供了示例之后,我将着眼于使用和避免断言。最后,您将学习如何通过 javac 编译器工具的命令行参数有选择地启用和禁用断言。

声明断言

断言是一个让你通过布尔表达式表达程序正确性假设的语句。如果该表达式的计算结果为 true,则继续执行下一条语句。否则,将引发一个标识失败原因的错误。

断言语句有两种形式,每种形式都以保留字 assert 开始:

assert expression1 ;
assert expression1 : expression2 ;

在该语句的两种形式中,表达式 1 是布尔表达式。在第二种形式中,表达式 2 是任何返回值的表达式。它不能调用返回类型为 void 的方法。

当表达式 1 评估为 false 时,该语句实例化类 java.lang.AssertionError 。第一个语句表单调用该类的 noargument 构造函数,它没有将标识失败细节的消息与 AssertionError 实例相关联。第二种形式调用一个 AssertionError 构造函数,其类型与 expression2 的值的类型相匹配。该值被传递给构造函数,其字符串表示形式被用作错误的详细信息。

当引发错误时,源文件的名称和引发错误的行号作为引发错误的堆栈跟踪的一部分输出到控制台。在许多情况下,这些信息足以确定导致失败的原因,应该使用断言语句的第一种形式。

清单 6-1 展示了断言语句的第一种形式。

清单 6-1 。抛出没有详细消息的断言错误

public class AssertionDemo
{
   public static void main(String[] args)
   {
      int x = 1;
      assert x == 0;
   }
}

当启用断言时(我将在后面讨论这个任务),运行前面的应用会产生以下输出:

Exception in thread "main" java.lang.AssertionError
        at AssertionDemo.main(AssertionDemo.java:6)

在其他情况下,需要更多的信息来帮助诊断失败的原因。例如,假设表达式 1 比较变量 x 和 y ,当 x 的值超过 y 的值时抛出错误。因为这种情况永远不会发生,所以您可能会使用第二种语句形式来输出这些值,以便可以诊断问题。

清单 6-2 展示了断言语句的第二种形式。

清单 6-2 。抛出带有详细消息的断言错误

public class AssertionDemo
{
   public static void main(String[] args)
   {
      int x = 1;
      assert x == 0: x;
   }
}

同样,假设断言是启用的。运行前面的应用会产生以下输出:

Exception in thread "main" java.lang.AssertionError: 1
        at AssertionDemo.main(AssertionDemo.java:6)

x 中的值被附加到第一个输出行的末尾,这有点神秘。为了使这个输出更有意义,您可能希望指定一个表达式,其中也包括变量的名称:assert x = = 0:" x = "+x;例如。

使用断言

在很多情况下应该使用断言。这些情况被组织成内部不变量、控制流不变量和契约设计类别。不变量是你的代码中不应该改变的东西。

内部不变量

一个内部不变量是面向表达式的行为,不期望改变。例如,清单 6-3 通过链式 if-else 语句引入了一个内部不变量,它根据水的温度输出水的状态。

清单 6-3 。发现内部不变量可以变化

public class IIDemo
{
   public static void main(String[] args)
   {
      double temperature = 50.0; // Celsius
      if (temperature < 0.0 )
         System.out.println("water has solidified");
      else
      if (temperature >= 100.0)
         System.out.println("water is boiling into a gas");
      else
      {
         // temperature > 0.0 and temperature < 100.0
         assert(temperature > 0.0 && temperature < 100.0): temperature;
         System.out.println("water is remaining in its liquid state");
      }
   }
}

开发人员可能只指定一个注释来陈述一个假设,即什么表达式导致最终的 else 到达。因为注释可能不足以检测出潜伏的 < 0.0 表达式 bug(水在零度也是固体),所以断言语句是必要的。

内部不变量的另一个例子与没有默认情况的 switch 语句有关。因为开发人员相信所有的路径都被覆盖了,所以避免了默认的情况。然而,这并不总是正确的,如清单 6-4 所示。

清单 6-4 。另一个错误的内部不变量

public class IIDemo
{
   final static int NORTH = 0;
   final static int SOUTH = 1;
   final static int EAST = 2;
   final static int WEST = 3;

   public static void main(String[] args)
   {
      int direction = (int) (Math.random() * 5 );
      switch (direction)
      {
         case NORTH: System.out.println("travelling north"); break;
         case SOUTH: System.out.println("travelling south"); break;
         case EAST : System.out.println("travelling east"); break;
         case WEST : System.out.println("travelling west"); break;
         default   : assert false;
      }
   }
}

清单 6-4 假设 switch 测试的表达式将只计算四个整数常量中的一个。但是, (int) (Math.random() * 5) 也可以返回 4,导致默认情况下执行 assert false;,总是抛出 AssertionError 。(您可能需要运行这个应用几次才能看到断言错误,但是首先您需要学习如何启用断言,这将在本章的后面讨论。)

提示当断言被禁用时,断言为假;不执行,错误未被发现。要一直检测这个 bug,替换 assert false;用投新的 AssertionError(方向);。

控制流不变量

控制流不变量是不期望改变的控制流。例如,清单 6-4 使用一个断言来测试一个假设,即开关的默认情况不会执行。清单 6-5 ,修复了清单 6-4 的 bug,提供了另一个例子。

清单 6-5 。一个错误的控制流不变量

public class CFDemo
{
   final static int NORTH = 0;
   final static int SOUTH = 1;
   final static int EAST = 2;
   final static int WEST = 3;

   public static void main(String[] args)
   {
      int direction = (int) (Math.random() * 4);
      switch (direction)
      {
         case NORTH: System.out.println("travelling north"); break;
         case SOUTH: System.out.println("travelling south"); break;
         case EAST : System.out.println("travelling east"); break;
         case WEST : System.out.println("travelling west");
         default   : assert false;
      }
   }
}

因为原来的 bug 已经修复了,所以永远不会达到默认情况。但是,省略终止 case WEST 的 break 语句会导致执行到达默认 case。这个控制流不变量被打破了。(同样,您可能需要运行这个应用几次才能看到断言错误,但是首先您需要学习如何启用断言,这将在本章后面讨论。)

注意使用断言语句来检测不应该执行的代码时要小心。如果断言语句不能根据詹姆斯·高斯林、比尔·乔伊、盖伊·斯蒂尔和吉拉德·布拉查(Addison-Wesley,2005;ISBN:0321246780;也可在【http://docs.oracle.com/javase/specs/】的 T5T7),编译器会报错。例如,为(;;);断言假;由于无限 for 循环阻止断言语句执行,导致编译器报告错误。

合同设计

契约式设计(【http://en.wikipedia.org/wiki/Design_by_contract】)是一种基于前置条件、后置条件和类不变量来设计软件的方法。断言语句支持非正式的契约式设计风格的开发。

前提条件

一个前提条件是当一个方法被调用时必须为真的东西。断言语句通常用于通过检查参数是否合法来满足助手方法的前提条件。清单 6-6 提供了一个例子。

清单 6-6 。验证前提条件

public class Lotto649
{
   public static void main(String[] args)
   {
      // Lotto 649 requires that six unique numbers be chosen.
      int[] selectedNumbers = new int[6];
      // Assign a unique random number from 1 to 49 (inclusive) to each slot
      // in the selectedNumbers array.
      for (int slot = 0; slot < selectedNumbers.length; slot++)
      {
           int num;
           // Obtain a random number from 1 to 49\. That number becomes the
           // selected number if it has not previously been chosen.
           try_again:
           do
           {
               num = rnd(49) + 1;
               for (int i = 0; i < slot; i++)
                    if (selectedNumbers[i] == num)
                        continue try_again;
               break;
           }
           while (true);
           // Assign selected number to appropriate slot.
           selectedNumbers[slot] = num;
      }
      // Sort all selected numbers into ascending order and then print these
      // numbers.
      sort(selectedNumbers);
      for (int i = 0; i < selectedNumbers.length; i++)
           System.out.print(selectedNumbers[i] + " ");
   }

   static int rnd(int limit)
   {
      // This method returns a random number (actually, a pseudorandom number)
      // ranging from 0 through limit - 1 (inclusive).
      assert limit > 1: "limit = " + limit;
      return (int) (Math.random() * limit);
   }

   static void sort(int[] x)
   {
      // This method sorts the integers in the passed array into ascending
      // order.
      for (int pass = 0; pass < x.length - 1; pass++)
         for (int i = x.length - 1; i > pass; i--)
            if (x[i] < x[pass])
            {
               int temp = x[i];
               x[i] = x[pass];
               x[pass] = temp;
            }
   }
}

清单 6-6 的应用模拟了 Lotto 6/49,这是加拿大的国家彩票游戏之一。 rnd() 助手方法 返回一个在 0 和限制 - 1 之间随机选择的整数。断言语句验证了前提条件,即限制的值必须为 2 或更高。

注意Sort()助手方法通过实现一种叫做冒泡排序的算法(完成某项任务的诀窍)将(排序)选择的数字数组的整数按升序排序。

冒泡排序的工作原理是对数组进行多次遍历。在每次传递期间,各种比较和交换确保下一个最小的元素值“冒泡”到数组的顶部,这将是索引 0 处的元素。

冒泡排序效率不高,但对于排序六元素数组来说绰绰有余。虽然我可以使用位于 java.util 包的 Arrays 类中的一个有效的 sort() 方法(例如,Arrays . sort(selected numbers));完成与清单 6-6 的排序(selectedNumbers)相同的目标;方法调用,但这样做更有效),我选择使用冒泡排序,因为我更喜欢等到第九章进入数组类。

后置条件

一个后置条件是在一个方法成功完成后必须为真的东西。断言语句通常用于通过检查结果是否合法来满足助手方法的后置条件。清单 6-7 提供了一个例子。

清单 6-7 。除了前提条件之外,还要验证后置条件

public class MergeArrays
{
   public static void main(String[] args)
   {
      int[] x = { 1, 2, 3, 4, 5 };
      int[] y = { 1, 2, 7, 9 };
      int[] result = merge(x, y);
      for (int i = 0; i < result.length; i++)
         System.out.println(result[i]);
   }

   static int[] merge(int[] a, int[] b)
   {
      if (a == null)
         throw new NullPointerException("a is null");
      if (b == null)
         throw new NullPointerException("b is null");
      int[] result = new int[a.length + b.length];
      // Precondition
      assert result.length == a.length + b.length: "length mismatch";
      for (int i = 0; i < a.length; i++)
         result[i] = a[i];
      for (int i = 0; i < b.length; i++)
         result[a.length + i - 1 ] = b[i];
      // Postcondition
      assert containsAll(result, a, b): "value missing from array";
      return result;
   }

   static boolean containsAll(int[] result, int[] a, int[] b)
   {
      for (int i = 0; i < a.length; i++)
         if (!contains(result, a[i]))
            return false;
      for (int i = 0; i < b.length; i++)
         if (!contains(result, b[i]))
            return false;
      return true;
   }

   static boolean contains(int[] a, int val)
   {
      for (int i = 0; i < a.length; i++)
         if (a[i] == val)
            return true;
      return false;
   }
}

清单 6-7 使用一个断言语句来验证合并后的两个数组中的所有值都出现在合并后的数组中。然而,后置条件并不满足,因为这个清单包含一个 bug。

清单 6-7 也显示了前置条件和后置条件一起使用。唯一的前提条件验证合并后的数组长度等于在合并逻辑之前被合并的数组的长度。

类不变量

一个类不变量是一种内部不变量,它在任何时候都适用于一个类的每个实例,除了当一个实例从一个一致状态转换到另一个一致状态的时候。

例如,假设一个类的实例包含数组,数组的值按升序排序。您可能希望在类中包含一个 isSorted() 方法 ,当数组仍然排序时返回 true,并验证修改数组的每个构造函数和方法都指定了 assert is sorted();在退出之前,满足构造函数或方法退出时数组仍然排序的假设。

避免断言

尽管有很多情况下应该使用断言,但也有一些情况下应该避免使用断言。例如,您不应该使用断言来检查传递给公共方法的参数,原因如下:

  • 检查公共方法的参数是该方法及其调用方之间存在的契约的一部分。如果您使用断言来检查这些参数,并且如果断言被禁用,那么就违反了该契约,因为参数将不会被检查。
  • 断言还防止引发适当的异常。例如,当一个非法参数被传递给一个公共方法时,通常会抛出 Java . lang . illegalargumentexception 或 Java . lang . nullpointerexception。然而, AssertionError 却被抛出。

您还应该避免使用断言来执行应用正常运行所需的工作。这项工作通常是作为断言的布尔表达式的副作用来执行的。当断言被禁用时,工作不会被执行。

例如,假设您有一个 Employee 对象的列表和一些空引用,它们也存储在这个列表中,您想要删除所有的空引用。通过下面的断言语句删除这些引用是不正确的:

assert employees.removeAll(null);

尽管断言语句不会抛出 AssertionError ,因为在雇员列表中至少有一个空引用,但是当断言被禁用时,依赖于该语句执行的应用将会失败。

与其依赖前面的代码来移除空引用,不如使用类似下面的代码:

boolean allNullsRemoved = employees.removeAll(null);
assert allNullsRemoved;

这一次,无论断言是启用还是禁用,所有的空引用都将被删除,并且您仍然可以指定一个断言来验证空引用是否已被删除。

启用和禁用断言

编译器在类文件中记录断言。但是,断言在运行时被禁用,因为它们会影响性能。断言可能会调用一个需要一段时间才能完成的方法,这会影响正在运行的应用的性能。

在测试关于类行为的假设之前,必须启用类文件的断言。在运行 java 应用启动工具时,通过指定 -enableassertions 或 -ea 命令行选项来完成这项任务。

-enableassertions 和 -ea 命令行选项允许您基于以下参数之一启用各种粒度的断言(除了没有参数的情况,您必须使用冒号将选项与其参数分开):

  • 无参数:断言在除系统类之外的所有类中都启用。
  • 包名??。。。:通过指定包名后跟,断言在指定的包及其子包中被启用。。。。
  • 。。。:断言在未命名的包中启用,这个包恰好是当前的目录。
  • ClassName :通过指定类名在命名类中启用断言。

例如,当通过 Java–ea merge arrays 运行 MergeArrays 应用时,可以启用除系统断言之外的所有断言。此外,您可以通过指定 Java–ea:logging test logger 来启用您可能添加到第五章的日志包中的任何断言。

通过指定–disable assessments 或–da 命令行选项,可以禁用断言,也可以以不同的粒度禁用断言。这些选项采用与 -enableassertions 和 -ea 相同的参数。例如,Java-ea–da:lone class main class 启用除了 loneclass 中的断言之外的所有断言。(将 loneclass 和 mainclass 视为您指定的实际类的占位符。)

前面的选项适用于所有的类装入器。除了不带参数时,它们也适用于系统类。这个异常简化了除系统类之外的所有类中断言语句的启用,这通常是所希望的。

要启用系统断言,请指定 -enablesystemassertions 或 -esa ,例如 Java-esa–ea:logging test logger。指定-disable system assessments 或 -dsa 来禁用系统断言。

掌握注释

在开发 Java 应用时,您可能希望用注释(将元数据[描述其他数据的数据]与各种应用元素相关联)。例如,您可能想要标识未完全实现的方法,以便不会忘记实现它们。Java 的注释语言特性让您可以完成这项任务。

在这一节中,我将向您介绍注释。在定义了这个术语并给出了三种编译器支持的注释作为例子之后,我将向您展示如何声明您自己的注释类型并使用这些类型来注释源代码。最后,您会发现如何处理自己的注释来完成有用的任务。

注意 Java 一直支持特别注释机制。例如, java.lang.Cloneable 接口标识了可以通过 java.lang.Object 的 clone() 方法 浅克隆其实例的类;瞬态保留字标记在序列化过程中被忽略的字段,而 @deprecated Javadoc 标记记录不再受支持的方法。相反,注释特性是注释代码的标准。

发现注释

一个注释是一个注释类型的实例,它将元数据与一个应用元素相关联。它在源代码中通过在类型名前面加上 @ 符号来表示。例如, @Readonly 是一个注释, Readonly 是它的类型。

注意你可以使用注释将元数据与构造函数、字段、局部变量、方法、包、参数和类型(注释、类、枚举和接口)关联起来。

编译器支持覆盖、弃用和 SuppressWarnings 注释类型。这些类型位于 java.lang 包中。

@Override 注释对于表达子类方法覆盖超类中的方法,而不是重载该方法是有用的。下面的示例显示了此批注用于作为重写方法的前缀:

@Override
public void draw(int color)
{
   // drawing code
}

@Deprecated 批注用于指示标记的应用元素 deprecated (逐步淘汰)且不应再使用。当不推荐使用的应用元素被非推荐使用的代码访问时,编译器会发出警告。

相比之下, @deprecated javadoc 标签和相关文本会警告您不要使用不推荐使用的项目,并告诉您应该使用什么来代替。下面的例子说明了 @Deprecated 和 @deprecated 可以一起使用:

/**
 * Allocates a <code>Date</code> object and initializes it so that
 * it represents midnight, local time, at the beginning of the day
 * specified by the <code>year</code>, <code>month</code>, and
 * <code>date</code> arguments.
 *
 * @param   year    the year minus 1900.
 * @param   month   the month between 0-11.
 * @param   date    the day of the month between 1-31.
 * @see     java.util.Calendar
 * @deprecated As of JDK version 1.1,
 * replaced by <code>Calendar.set(year + 1900, month, date)</code>
 * or <code>GregorianCalendar(year + 1900, month, date)</code>.
 */
@Deprecated
public Date(int year, int month, int date)
{
    this(year, month, date, 0, 0, 0);
}

这个例子摘录了 Java 的 Date 类中的一个构造函数(位于 java.util 包中)。它的 Javadoc 注释显示, Date(int year,int month,int date) 已经被弃用,取而代之的是在 Calendar 类(也位于 java.util 包中)中使用 set() 方法。我在第十章的中探索日期。)

当编译单元(通常是类或接口)引用不推荐使用的类、方法或字段时,编译器会取消警告。这个特性允许你修改遗留的 API 而不会产生不赞成的警告,在清单 6-8 中有演示。

清单 6-8 。从同一类声明中引用不推荐使用的字段

public class Employee
{
   /**
    * Employee's name
    * @deprecated New version uses firstName and lastName fields.
    */
   @Deprecated
   String name;
   String firstName;
   String lastName;

   public static void main(String[] args)
   {
      Employee emp = new Employee();
      emp.name = "John Doe";
   }
}

清单 6-8 声明了一个雇员类,该类带有一个名字字段,该字段已被弃用。虽然 Employee 的 main() 方法引用了 name ,但是编译器会抑制一个弃用警告,因为弃用和引用发生在同一个类中。

假设您通过引入一个新的 UseEmployee 类并将 Employee 的 main() 方法移到这个类来重构这个清单。清单 6-9 展示了最终的类结构。

清单 6-9 。从另一个类声明中引用不推荐使用的字段

class Employee
{
   /**
    * Employee's name
    * @deprecated New version uses firstName and lastName fields.
    */
   @Deprecated
   String name;
   String firstName;
   String lastName;
}

public class UseEmployee
{
   public static void main(String[] args)
   {
      Employee emp = new Employee();
      emp.name = "John Doe";
   }
}

如果您试图通过 javac 编译器工具编译该源代码,您将会发现以下消息:

Note: UseEmployee.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.

您需要将 -Xlint:deprecation 指定为 javac 的命令行参数之一(如在 javac-Xlint:deprecation UseEmployee.java)以发现不推荐的项目和引用该项目的代码:

Employee.java:18: warning: [deprecation] name in Employee has been deprecated
      emp.name = "John Doe";
         ^
1 warning

@SuppressWarnings 注释对于通过“不赞成”或“未检查”参数来抑制不赞成或未检查的警告很有用。(当混合使用泛型和前泛型遗留代码的代码时,会出现未检查的警告。我将在本章后面讨论泛型和未检查的警告。)

例如,当 UseEmployee 类的 main() 方法中的代码访问 Employee 类的 name 字段时,清单 6-10 使用带有 "deprecation" 参数的 @SuppressWarnings 来抑制编译器的反对警告。

清单 6-10 。取消先前的弃用警告

public class UseEmployee
{
   @SuppressWarnings("deprecation")
   public static void main(String[] args)
   {
      Employee emp = new Employee();
      emp.name = "John Doe";
   }
}

注意作为一种风格,你应该总是在嵌套最深的元素上指定 @SuppressWarnings ,在那里它是有效的。例如,如果您想要取消特定方法中的警告,您应该注释该方法而不是它的类。

声明注释类型和注释源代码

在注释源代码之前,您需要可以实例化的注释类型。除了 Override 、 Deprecated 和 SuppressWarnings 之外,Java 还提供了许多注释类型。Java 也允许你声明你自己的类型。

通过指定符号 @ ,紧接着是保留字接口,然后是类型名,最后是主体,来声明一个注释类型。例如,清单 6-11 使用 @interface 声明一个名为 Stub 的注释类型。

清单 6-11 。声明存根注释类型

public @interface Stub
{
}

除了名称之外不提供任何数据的注释类型的实例——它们的主体是空的——被称为标记注释,因为它们出于某种目的标记应用元素。如清单 6-12 所示, @Stub 用于标记空方法(Stub)。

清单 6-12 。注释一个被剔除的方法 ??

public class Deck // Describes a deck of cards.
{
   @Stub
   public void shuffle()
   {
      // This method is empty and will presumably be filled in with appropriate
      // code at some later date.
   }
}

清单 6-12 的 Deck 类声明了一个空的 shuffle() 方法 。这一事实通过实例化存根并在 shuffle() 的方法头前加上结果@存根注释来表示。

注意虽然标记接口(在第四章中介绍)似乎已经被标记注释所取代,但事实并非如此,因为标记接口比标记注释更有优势。一个优点是标记接口指定了由标记类实现的类型,这让您可以在编译时发现问题。例如,当一个类没有实现可克隆的接口时,它的实例不能通过对象的 clone() 方法进行浅层克隆。如果 Cloneable 已经被实现为一个标记注释,这个问题直到运行时才会被发现。

虽然标记注释很有用 (@Override 和 @Deprecated 就是很好的例子),但是您通常会希望增强注释类型,以便可以通过它的实例存储元数据。您可以通过向类型中添加元素来完成此任务。

一个元素是一个出现在注释类型主体中的方法头。它不能有参数或 throws 子句,它的返回类型必须是基元类型(如 int )、 java.lang.String 、 java.lang.Class 、枚举、注释类型或前面类型的数组。但是,它可以有默认值。

清单 6-13 向存根添加了三个元素。

清单 6-13 。向存根注释类型添加三个元素

public @interface Stub
{
   int id(); // A semicolon must terminate an element declaration.
   String dueDate();
   String developer() default "unassigned";
}

id() 元素指定了一个标识存根的 32 位整数。 dueDate() 元素指定了一个基于字符串的日期,该日期标识了何时实现方法存根。最后, developer() 指定负责编码方法存根的开发人员的基于字符串的名称。

与 id() 和 dueDate() 不同, developer() 是用默认值、“未赋值”来声明的。当您实例化存根并且没有在该实例中给 developer() 赋值时,就像清单 6-14 的情况一样,这个默认值被赋给 developer() 。

清单 6-14 。初始化一个存根实例的元素

public class Deck
{
   @Stub
   (
      id = 1,
      dueDate = "12/21/2012"
   )
   public void shuffle()
   {
   }
}

清单 6-14 展示了一个 @Stub 注释,它将其 id() 元素初始化为 1 ,将其 dueDate() 元素初始化为 "12/21/2012" 。每个元素名称没有尾随的 () ,两个元素初始化器的逗号分隔列表出现在 ( 和 ) 之间。

假设您决定用单个 String value()element 替换 Stub 的 id() 、 dueDate() 和 developer() 元素,该元素的字符串指定逗号分隔的 id、到期日期和开发人员姓名值。清单 6-15 展示了两种初始化值的方法。

清单 6-15 。初始化每个存根实例的值()元素

public class Deck
{
   @Stub(value = "1,12/21/2012,unassigned")
   public void shuffle()
   {
   }

   @Stub("2,12/21/2012,unassigned")
   public Card[] deal(int ncards)
   {
      return null;
   }
}

清单 6-15 揭示了对值()元素的特殊处理。当它是注释类型的唯一元素时,可以从初始化器中省略 value() 的名称和 = 。我用这个事实指定了清单 6-10 中的@ suppress warnings(" deprecation ")。

在注释类型声明中使用元注释

每个覆盖、弃用和抑制警告注释类型本身用元注释(注释注释类型的注释)进行注释。例如,清单 6-16 向您展示了 SuppressWarnings 注释类型是用两个元注释进行注释的。

清单 6-16 。带注释的 SuppressWarnings 类型声明

@Target(value={TYPE,FIELD,METHOD,PARAMETER,CONSTRUCTOR,LOCAL_VARIABLE})
@Retention(value=SOURCE)
public @interface SuppressWarnings

位于 java.lang.annotation 包中的目标注释类型,标识了注释类型所适用的应用元素的种类。 @Target 表示 @SuppressWarnings 批注可以用来批注类型、字段、方法、参数、构造函数和局部变量。

每个类型、字段、方法、参数、构造器和局部变量都是元素类型枚举的成员,该枚举也位于 java.lang.annotation 包中。(我将在本章后面讨论枚举。)

分配给 Target 的 value() 元素的逗号分隔值列表周围的 { 和 } 字符表示一个数组— value() 的返回类型是 String[] 。尽管这些大括号是必需的(除非数组包含一项),但是在初始化 @Target 时可以省略 value= ,因为 Target 只声明了一个 value() 元素。

位于 java.lang.annotation 包中的 Retention 注释类型,标识了一个注释类型的注释的保留期(也称为生存期)。 @Retention 表示 @SuppressWarnings 注释的生存期仅限于源代码——它们在编译后不存在。

SOURCE 是 RetentionPolicy enum 的成员之一(位于 java.lang.annotation 包中)。其他成员是类和运行时。这三个成员指定了以下保留策略:

  • CLASS :编译器在类文件中记录注释,但是虚拟机不保留它们(为了节省内存空间)。这是默认策略。
  • 运行时:编译器在类文件中记录注释,虚拟机保留它们,以便在运行时可以通过反射 API 读取它们。
  • SOURCE :编译器在使用注释后将其丢弃。

在清单 6-11 和清单 6-13 中显示的存根注释类型有两个问题。首先,缺少一个 @Target 元注释意味着您可以注释任何应用元素 @Stub 。然而,这种注释只有在应用于方法和构造函数时才有意义。查看清单 6-17 。

清单 6-17 。注释不需要的应用元素

@Stub("1,12/21/2012,unassigned")
public class Deck
{
   @Stub("2,12/21/2012,unassigned")
   private Card[] cardsRemaining = new Card[52];

   @Stub("3,12/21/2012,unassigned")
   public Deck()
   {
   }

   @Stub("4,12/21/2012,unassigned")
   public void shuffle()
   {
   }

   @Stub("5,12/21/2012,unassigned")
   public Card[] deal(@Stub("5,12/21/2012,unassigned") int ncards)
   {
      return null;
   }
}

清单 6-17 使用 @Stub 来注释 Deck 类、 cardsRemaining 字段和 ncards 参数,以及注释构造函数和两个方法。前三个应用元素不适合注释,因为它们不是存根。

您可以通过在存根注释类型声明的前面加上 @Target({ElementType。方法,ElementType。CONSTRUCTOR}) 以便存根只适用于方法和构造函数。这样做之后,当您试图编译清单 6-17 中的时, javac 编译器工具将输出以下错误消息:

Deck.java:1: error: annotation type not applicable to this kind of declaration
@Stub("1,12/21/2012,unassigned")
^
Deck.java:4: error: annotation type not applicable to this kind of declaration
   @Stub("2,12/21/2012,unassigned")
   ^
Deck.java:18: error: annotation type not applicable to this kind of declaration
   public Card[] deal(@Stub("5,12/21/2012,unassigned") int ncards)
                      ^
3 errors

第二个问题是默认的类保留策略使得在运行时无法处理@存根注释。您可以通过在存根类型声明前面加上 @Retention(RetentionPolicy)来解决这个问题。运行时)。

清单 6-18 展示了带有期望的@目标和@保留元注释的存根注释类型。

清单 6-18 。一个改版的存根标注类型

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface Stub
{
   String value();
}

注意 Java 还在 java.lang.annotation 包中提供了记录的和继承的元注释类型。由 javadoc 和类似工具记录的@记录的-注释的注释类型的实例,而由@继承的-注释的注释类型的实例是自动继承的。根据继承的 Java 文档,如果“用户查询一个类声明上的注释类型,而该类声明没有该类型的注释,那么该类的超类将自动查询该注释类型。这个过程将被重复,直到找到该类型的注释,或者到达类层次结构的顶部(对象)。如果没有超类具有这种类型的注释,那么查询将指示所讨论的类没有这样的注释。”

处理注释

声明一个注释类型并使用该类型注释源代码是不够的。除非您对这些注释做一些特殊的处理,否则它们将保持休眠状态。完成特定任务的一种方法是编写处理注释的应用。清单 6-19 的 StubFinder 应用就是这么做的。

清单 6-19 。 StubFinder 应用

import java.lang.reflect.Method;

public class StubFinder
{
   public static void main(String[] args) throws Exception
   {
      if (args.length != 1)
      {
         System.err.println("usage: java StubFinder classfile");
         return;
      }
      Method[] methods = Class.forName(args[0]).getMethods();
      for (int i = 0; i < methods.length; i++)
         if (methods[i].isAnnotationPresent(Stub.class))
         {
            Stub stub = methods[i].getAnnotation(Stub.class);
            String[] components = stub.value().split(",");
            System.out.println("Stub ID = " + components[0]);
            System.out.println("Stub Date = " + components[1]);
            System.out.println("Stub Developer = " + components[2]);
            System.out.println();
         }
   }
}

StubFinder 加载一个 classfile,其名称被指定为命令行参数,并输出与每个 public 方法头之前的每个 @Stub 注释相关联的元数据。这些注释是清单 6-18 的存根注释类型的实例。

StubFinder 接下来使用一个名为类的特殊类及其 forName() 类方法 来加载一个类文件。类还提供了一个 getMethods() 方法 ,该方法返回一个 Java . lang . reflect . method 对象的数组,这些对象描述了加载的类的公共方法。

对于每个循环迭代,调用一个方法对象的 isanotationpresent()方法 来确定该方法是否用存根类(称为存根.类)描述的注释进行了注释。

如果 isanotationpresent()返回 true,方法的 getAnnotation() 方法 被调用返回注释存根实例。调用该实例的 value() 方法来检索存储在注释中的字符串。

接下来, String 的 split() 方法 被调用,将字符串的 ID、日期和开发者值的逗号分隔列表拆分成一个由 String 对象组成的数组。然后每个对象连同描述性文本一起输出。(你会在第七章正式被介绍给 split() 。)

类的 forName() 方法 能够抛出各种异常,这些异常必须作为方法头的一部分进行处理或显式声明。为了简单起见,我选择在 main() 方法的头部添加一个 throws Exception 子句。

注意抛出异常有两个问题。首先,处理异常并给出合适的错误消息通常比通过抛出 main() 来“推卸责任”要好。其次,异常是通用的——它隐藏了抛出的异常类型的名称。然而,我发现在一次性工具中指定抛出异常很方便。

在编译完 StubFinder ( 贾瓦克 StubFinder.java)、存根 ( 贾瓦克 Stub.java)、以及清单 6-15 的 Deck 类(贾瓦克 Deck.java)之后,运行 StubFinder ,将 Deck 作为其单一命令行参数( java StubFinder Deck )。您将看到以下输出:

Stub ID = 1
Stub Date = 12/21/2012
Stub Developer = unassigned

Stub ID = 2
Stub Date = 12/21/2012
Stub Developer = unassigned

掌握泛型

Java 5 引入了 generics ,用于声明和使用与类型无关的类和接口的语言特性。当使用 Java 的集合框架时(我在第九章中介绍了),这些特性帮助你避免 Java . lang . classcastexceptions

注意虽然泛型的主要用途是集合框架,但是标准类库也包含了与这个框架无关的泛型化(为利用泛型而改造的)类: java.lang.Class 、 java.lang.ThreadLocal 和 Java . lang . ref . weak reference 就是三个例子。

在这一节中,我将向您介绍泛型。首先学习泛型如何在集合框架类的上下文中促进类型安全,然后在泛型类型和泛型方法的上下文中探索泛型。最后,您将了解数组上下文中的泛型。

集合和类型安全的需要

Java 的集合框架使得在各种容器(称为集合)中存储对象并在以后检索这些对象成为可能。例如,您可以将对象存储在列表、集合或映射中。然后,您可以检索单个对象,或者循环访问集合并检索所有对象。

在 Java 5 改革集合框架以利用泛型之前,没有办法阻止集合包含混合类型的对象。编译器在将一个对象添加到集合之前不会检查它的类型是否合适,这种静态类型检查的缺乏导致了 ClassCastException s 。

清单 6-20 展示了生成一个 ClassCastException 是多么容易。

清单 6-20 。缺乏类型安全导致运行时出现 ClassCastException

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

class Employee
{
   private String name;

   Employee(String name)
   {
      this.name = name;
   }

   String getName()
   {
      return name;
   }
}

public class TypeSafety
{
   public static void main(String[] args)
   {
      List employees = new ArrayList();
      employees.add(new Employee("John Doe"));
      employees.add(new Employee("Jane Smith"));
      employees.add("Jack Frost");
      Iterator iter = employees.iterator();
      while (iter.hasNext())
      {
         Employee emp = (Employee) iter.next();
         System.out.println(emp.getName());
      }
   }
}

清单 6-20 的 main() 方法首先实例化 java.util.ArrayList ,然后使用这个列表集合对象的引用将一对 Employee 对象添加到列表中。然后它添加了一个字符串对象,这违反了数组列表 应该只存储雇员对象的隐含契约。

main() next 获取一个 java.util.Iterator 实例用于迭代 Employee s 的列表,只要迭代器的 hasNext() 方法 返回 true,就调用其 next() 方法返回一个存储在数组列表中的对象。

next() 返回的对象必须向下转换为雇员,这样就可以调用雇员对象的 getName() 方法来返回雇员的姓名。该方法返回的字符串然后通过 System.out.println() 输出到标准输出设备。

(Employee) cast 检查由 next() 返回的每个对象的类型,以确保它是一个雇员。虽然这适用于前两个对象,但不适用于第三个对象。将“白色杀机”转换为雇员的尝试导致了 ClassCastException 。

因为假设列表是同质的,所以发生了 ClassCastException 。换句话说,列表只存储单一类型或一系列相关类型的对象。实际上,该列表是异构的,因为它可以存储任何对象。

清单 6-21 的基于泛型的同质列表避免了 ClassCastException 。

清单 6-21 。缺乏类型安全导致编译器错误

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

class Employee
{
   private String name;

   Employee(String name)
   {
      this.name = name;
   }

   String getName()
   {
      return name;
   }
}

public class TypeSafety
{
   public static void main(String[] args)
   {
      List<Employee> employees = new ArrayList<Employee>();
      employees.add(new Employee("John Doe"));
      employees.add(new Employee("Jane Smith"));
      employees.add("Jack Frost");
      Iterator<Employee> iter = employees.iterator();
      while (iter.hasNext())
      {
         Employee emp = iter.next();
         System.out.println(emp.getName());
      }
   }
}

清单 6-21 的重构的 main() 方法说明了泛型的核心特性,即参数化类型(一个类或接口名,后跟一个尖括号分隔的类型列表,标识了在该上下文中什么类型的对象是合法的)。

例如, java.util.List <员工>表示列表中只能存储员工对象。如图所示, <雇员> 指定必须与数组列表重复,如在数组列表<雇员> 中,它是存储雇员 s 的集合实现。

另外,迭代器<雇员> 表示迭代器() 返回一个迭代器,其 next() 方法只返回雇员对象。没有必要将 iter.next() 的返回值转换为 Employee 的值,因为编译器会代表您插入转换。

如果您试图编译这个清单,编译器会在遇到 employees.add("白色杀机")时报告一个错误;。错误信息会告诉你编译器在 Java . util . list接口中找不到 add(java.lang.String) 方法。

与前泛型列表接口中声明的 add(Object) 方法不同,泛型化列表接口的 add() 方法参数反映了接口的参数化类型名称。比如列表<员工> 隐含添加(员工)。

清单 6-20 揭示了导致 class cast exception(employees . add("白色杀机"))的不安全代码;)和触发异常的代码( (Employee) iter.next() )相当接近。但是,在较大的应用中,它们之间的距离往往更远。

您不必在寻找最终导致 ClassCastException 的不安全代码时处理愤怒的客户,您可以依靠编译器通过在编译期间检测到代码时报告错误来为您节省这种挫折和精力。在编译时检测类型安全违规是使用泛型的主要好处。

通用类型

泛型类型是一个类或接口,它通过声明一个形式类型参数列表 (尖括号之间的类型参数名称的逗号分隔列表)来引入一族参数化类型。该语法表示如下:

class *identifier* < *formal_type_parameter_list* > {}
interface *identifier* < *formal_type_parameter_list* > {}

例如,List为泛型类型,其中 List 为接口,类型参数 E 标识列表的元素类型。类似地, Map < K,V>是一个泛型类型,其中 Map 是一个接口,类型参数 K 和 V 标识 Map 的键和值类型。

注意当声明一个泛型类型时,习惯上指定单个大写字母作为类型参数名。此外,这些名称应该有意义。例如, E 表示元素, T 表示类型, K 表示键, V 表示值。如果可能,您应该避免选择在使用它的地方没有意义的类型参数名称。比如 List < E > 表示元素列表,但是 List < S > 是什么意思呢?

参数化类型是泛型类型的实例。每个参数化类型都用类型名替换泛型类型的类型参数。例如,List??(Employee 的 List )和 List??(String 的 List )就是基于 List的参数化类型的例子。同样, Map < String,Employee > 是基于 Map < K,V > 的参数化类型的一个例子。

替换类型形参的类型名称为实际类型实参 。泛型支持五种实际类型参数:

  • 具体类型 :将类或接口的名称传递给类型参数。例如,列出<员工>员工;指定列表元素是雇员实例。
  • 具体参数化类型 :参数化类型的名称被传递给类型参数。例如,列表<列表<字符串> >名称列表;指定列表元素是字符串列表。
  • 数组类型 :将数组传递给类型参数。例如,列表<字符串[] >国家;指定列表元素是由字符串组成的数组,可能是城市名。
  • 类型参数 :将一个类型参数传递给类型参数。例如,给定类声明类 X < E > {列表< E >队列;} , X 的类型参数 E 传递给列表的类型参数 E 。
  • 通配符??:?被传递给类型参数。比如列表<?>列表;指定列表元素未知。在本章的后面,你会学到通配符。

泛型类型还标识了一个原始类型 ,它是一个没有类型参数的泛型类型。比如列表<员工> 的 raw 类型是列表。Raw 类型是非泛型的,可以保存任何对象。

注意 Java 允许原始类型与泛型混合,以支持在泛型出现之前编写的大量遗留代码。但是,每当编译器在源代码中遇到原始类型时,它都会输出一条警告消息。

声明并使用自己的泛型类型

声明自己的泛型类型并不难。除了指定一个正式的类型参数列表之外,泛型类型还在它的整个实现过程中指定它的类型参数。例如,清单 6-22 声明了一个队列< E > 泛型类型。

清单 6-22 。声明并使用一个队列泛型类型

public class Queue<E>
{
   private E[] elements;
   private int head, tail;

   @SuppressWarnings("unchecked")
   Queue(int size)
   {
      if (size < 2)
         throw new IllegalArgumentException("" + size);
      elements = (E[]) new Object[size];
      head = 0;
      tail = 0;
   }

   void insert(E element) throws QueueFullException
   {
      if (isFull())
         throw new QueueFullException();
      elements[tail] = element;
      tail = (tail + 1) % elements.length;
   }

   E remove() throws QueueEmptyException
   {
      if (isEmpty())
         throw new QueueEmptyException();
      E element = elements[head];
      head = (head + 1) % elements.length;
      return element;
   }

   boolean isEmpty()
   {
      return head == tail;
   }

   boolean isFull()
   {
      return (tail + 1) % elements.length == head;
   }

   public static void main(String[] args)
      throws QueueFullException, QueueEmptyException
   {
      Queue<String> queue = new Queue<String>(6);
      System.out.println("Empty: " + queue.isEmpty());
      System.out.println("Full: " + queue.isFull());
      System.out.println("Adding A");
      queue.insert("A");
      System.out.println("Adding B");
      queue.insert("B");
      System.out.println("Adding C");
      queue.insert("C");
      System.out.println("Adding D");
      queue.insert("D");
      System.out.println("Adding E");
      queue.insert("E");
      System.out.println("Empty: " + queue.isEmpty());
      System.out.println("Full: " + queue.isFull());
      System.out.println("Removing " + queue.remove());
      System.out.println("Empty: " + queue.isEmpty());
      System.out.println("Full: " + queue.isFull());
      System.out.println("Adding F");
      queue.insert("F");
      while (!queue.isEmpty())
         System.out.println("Removing " + queue.remove());
      System.out.println("Empty: " + queue.isEmpty());
      System.out.println("Full: " + queue.isFull());
   }
}

class QueueEmptyException extends Exception
{
}

class QueueFullException extends Exception
{
}

清单 6-22 声明了队列 、 QueueEmptyException 和 QueueFullException 类。后两个类描述了从前一个类的方法中抛出的检查异常。

Queue 实现了一个队列,一个按照先进先出顺序存储元素的数据结构。一个元件在尾部插入,在头部移除。当头部等于尾部时,队列为空,当尾部比头部少一个时,队列为满。因此,一个大小为 n 的队列最多可以存储 n - 1 个元素。

注意到队列的 E 类型参数出现在整个源代码中。例如, E 出现在元素数组声明中来表示数组的元素类型。 E 也被指定为 insert() 的参数类型和 remove() 的返回类型。

E 也出现在 elements =(E[])new Object【size】;。(我稍后会解释为什么我指定了这个表达式,而不是指定更紧凑的元素= new E[size];表情。)

E[] 强制转换导致编译器警告该强制转换未被检查。编译器担心从对象[] 向下转换到 E[] 可能会导致违反类型安全,因为任何类型的对象都可以存储在对象[] 中。

在这个例子中,编译器的担心是不合理的。非 E 对象不可能出现在 E[] 数组中。因为警告在这个上下文中没有意义,所以通过在构造函数前面加上@ suppress warnings(" unchecked ")来取消警告。

注意抑制未检查的警告时要小心。您必须首先证明一个 ClassCastException 不会发生,然后您可以取消警告。

当您运行此应用时,它会生成以下输出:

Empty: true
Full: false
Adding A
Adding B
Adding C
Adding D
Adding E
Empty: false
Full: true
Removing A
Empty: false
Full: false
Adding F
Removing B
Removing C
Removing D
Removing E
Removing F
Empty: true
Full: false

类型参数界限

列表的 E 类型参数和映射< K,V>的 K 和 V 类型参数都是无界类型参数 的例子。您可以将任何实际类型参数传递给未绑定的类型参数。

有时有必要限制可以传递给类型参数的实际类型变量的种类。例如,您可能想要声明一个类,其实例只能存储抽象形状类的子类的实例(例如圆形和矩形)。

为了限制实际类型参数,您可以指定一个上限,这是一个可以作为实际类型参数的类型上限的类型。上限是通过保留字扩展后跟类型名来指定的。

例如,ShapesList将 Shape 标识为上界。可以指定 ShapesList <圆形>??、 ShapesList <矩形> ,甚至 ShapesList <形状> ,但不能指定 ShapesList <字符串> ,因为字符串不是形状的子类。

您可以为类型参数分配多个上限,其中第一个上限是一个类或接口,每个附加的上限是一个接口,方法是使用&字符( & )来分隔绑定名称。考虑清单 6-23 。

清单 6-23 。为类型参数指定多个上限 ??

abstract class Shape
{
}

class Circle extends Shape implements Comparable<Circle>
{
   private double x, y, radius;

   Circle(double x, double y, double radius)
   {
      this.x = x;
      this.y = y;
      this.radius = radius;
   }

   @Override
   public int compareTo(Circle circle)
   {
      if (radius < circle.radius)
         return -1;
      else
      if (radius > circle.radius)
         return 1;
      else
         return 0;
   }

   @Override
   public String toString()
   {
      return "(" + x + ", " + y + ", " + radius + ")";
   }
}

class SortedShapesList<S extends Shape & Comparable<S>>
{
   @SuppressWarnings("unchecked")
   private S[] shapes = (S[]) new Shape[2];
   private int index = 0;

   void add(S shape)
   {
      shapes[index++] = shape;
      if (index < 2)
         return;
      System.out.println("Before sort: " + this);
      sort();
      System.out.println("After sort: " + this);
   }

   private void sort()
   {
      if (index == 1)
         return;
      if (shapes[0].compareTo(shapes[1]) > 0)
      {
         S shape = (S) shapes[0];
         shapes[0] = shapes[1];
         shapes[1] = shape;
      }
   }

   @Override
   public String toString()
   {
      return shapes[0].toString() + " " + shapes[1].toString();
   }
}

public class SortedShapesListDemo
{
   public static void main(String[] args)
   {
      SortedShapesList<Circle> ssl = new SortedShapesList<Circle>();
      ssl.add(new Circle(100, 200, 300));
      ssl.add(new Circle(10, 20, 30));
   }
}

清单 6-23 的 Circle 类扩展了 Shape 并实现了 java.lang.Comparable 接口,用于指定 Circle 对象的自然排序。接口的 compareTo() 方法 通过返回值反映顺序来实现这种排序:

  • 当当前对象应该在以某种方式传递给 compareTo() 的对象之前时,返回负值。
  • 当 current 和 argument 对象相同时,将返回零值。
  • 当当前对象应该在 argument 对象之后时,返回一个正值。

Circle 的覆盖 compareTo() 方法根据半径比较两个 Circle 对象。该方法将半径较小的圆实例排在半径较大的圆实例之前。

sorted shapes listclass 指定 < S 扩展形状&可比< S > > 作为其参数列表。传递给 S 参数的实际类型实参必须是 Shape 的子类,并且还必须实现 Comparable 接口。

注意包含类型参数的类型参数界限被称为递归类型界限 。比如 S 中的可比扩展形状&可比就是一个递归类型绑定。递归类型界限很少见,通常与用于指定类型自然排序的可比接口一起出现。

Circle 满足这两个条件:它子类化形状并实现可比性。因此,编译器在遇到 main() 方法的 sorted shapes listSSL = new sorted shapes list();声明。

上限提供额外的静态类型检查,保证参数化类型遵守其界限。这种保证意味着可以安全地调用上限的方法。比如 sort() 可以调用 Comparable 的 compareTo() 方法。

如果您运行这个应用,您会发现下面的输出,它显示了两个圆形对象按照半径的升序排序:

Before sort: (100.0, 200.0, 300.0) (10.0, 20.0, 30.0)
After sort: (10.0, 20.0, 30.0) (100.0, 200.0, 300.0)

注意类型参数不能有下限。Angelika Langer 在她的“Java 泛型常见问题解答”中解释了这一限制的基本原理,网址为 。

类型参数范围

类型参数的范围(可见性)是其泛型类型,除非屏蔽了(隐藏)。此范围包括类型参数是其成员的形式类型参数列表。例如, SortedShapesList < S 中的 S 的范围扩展了 Shape&Comparable>是所有的 SortedShapesList 和形式类型参数表。

通过在嵌套类型的形式类型参数列表中声明同名的类型参数,可以屏蔽类型参数。例如,清单 6-24 屏蔽了一个封闭类的 T 类型参数。

清单 6-24 。屏蔽类型变量

class EnclosingClass<T>
{
   static class EnclosedClass<T extends Comparable<T>>
   {
   }
}

EnclosingClass 的 T 类型参数被 EnclosedClass 的 T 类型参数屏蔽,该参数指定了一个上限,在该上限处,只有那些实现了 compatible 接口的类型才能被传递给 EnclosedClass 。从 EnclosedClass 中引用 T 是指传递给 EnclosingClass 的有界的 T 而不是无界的 T 。

如果不需要屏蔽,最好为类型参数选择不同的名称。例如,您可以指定 enclosed class<U extends Comparable>。虽然 U 不像 T 那样是一个有意义的名字,但这种情况证明了这种选择的合理性。

需要通配符

假设你已经创建了一个字符串 的列表,并且想要输出这个列表。因为您可能会创建一个雇员 的列表和其他类型的列表,所以您希望该方法输出一个任意的列表的对象 。您最终创建了清单 6-25 。

清单 6-25 。试图输出对象的列表

import java.util.ArrayList;
import java.util.List;

public class OutputList
{
   public static void main(String[] args)
   {
      List<String> ls = new ArrayList<String>();
      ls.add("first");
      ls.add("second");
      ls.add("third");
      outputList(ls);
   }

   static void outputList(List<Object> list)
   {
      for (int i = 0; i < list.size(); i++)
         System.out.println(list.get(i));
   }
}

现在你已经完成了你的目标(或者你是这么认为的),你可以通过 OutputList.java 的 javac 编译清单 6-25 。令您惊讶的是,您会收到以下错误消息:

OutputList.java:12: error: method outputList in class OutputList cannot be applied to given types;
      outputList(ls);
      ^
  required: List<Object>
  found: List<String>
  reason: actual argument List<String> cannot be converted to List<Object> by method invocation conversion
1 error

这个错误消息是由于没有意识到泛型类型的基本规则:对于给定的 y 类型的子类型 x,并且给定 G 作为原始类型声明,G < x >不是 G的子类型。

要理解这个规则,必须刷新一下对子类型多态性的理解(见第四章)。基本上,一个子类型是它的父类型的一个特化类型。例如,圆是一种特殊的形状,而弦是一种特殊的物体。这种多态行为也适用于具有相同类型参数的相关参数化类型(例如, List < Object > 是一种专门的 Java . util . collection