Java7-入门手册-一-

305 阅读1小时+

Java7 入门手册(一)

协议:CC BY-NC-SA 4.0

零、简介

Java 7 是 Oracle 最新发布的流行 Java 语言和平台。《Java 7 入门》通过它的 12 个章节和 4 个附录,引导你了解这种语言和大量的平台 API。

image Java 是太阳微系统公司创造的,后来被甲骨文买断。

第一章(Java 入门)向您介绍 Java,并通过关注注释、标识符、变量、表达式和语句等基本概念开始涵盖 Java 语言。

第二章(发现类和对象)继续探索这种语言,展示了它处理类和对象的所有特性。您将了解与类声明和对象创建、封装、信息隐藏、继承、多态性、接口和垃圾收集相关的特性。

第三章(探索高级语言特性)关注与嵌套类、包、静态导入、异常、断言、注释、泛型和枚举相关的更高级的语言特性。后续章节将向您介绍第一章到第三章中没有涉及的几个特性。

第四章(Touring Language API)在很大程度上避开了涵盖语言特性(尽管它引入了类文字和 strictfp ),而专注于面向语言的 API。在本章中,您将了解 Math、StrictMath、Package、基本类型包装类、引用、反射、String、StringBuffer 和 StringBuilder、线程、BigDecimal 和 BigInteger。

第五章(收集对象)通过主要关注集合框架,开始探索 Java 的实用 API。然而,它还讨论了遗留的面向集合的 API 以及如何创建自己的集合。

第六章(浏览附加工具 API)通过展示并发工具以及对象和随机类,继续关注工具 API。

第七章(创建和丰富图形用户界面)将你从前面章节中出现的命令行用户界面转移到图形用户界面。首先学习抽象窗口工具包基础,然后从 Swing 和 Java 2D 的角度探索 Java 基础类。(附录 C 向您介绍了辅助功能和拖放。)

第八章(与文件系统交互)从文件、随机访问文件、流和写/读类的角度探讨了面向文件系统的 I/O。(新 I/O 包含在附录 c 中。)

第九章(与网络和数据库的交互)向你介绍 Java 的网络 API(例如套接字)。它还向您介绍了用于与数据库交互的 JDBC API。

第十章(解析、创建和转换 XML 文档)通过首先介绍 XML(包括 dtd 和模式)来深入 Java 的 XML 支持。接下来探索 SAX、DOM、StAX、XPath 和 XSLT APIs 甚至简要介绍了验证 API。在探索 XPath 时,您会遇到名称空间上下文、扩展函数和函数解析器,以及变量和变量解析器。

第十一章(使用 Web 服务)向您介绍 Java 对基于 SOAP 和 RESTful web 服务的支持。除了向你提供这些 web 服务类别的基础知识之外,第十一章还介绍了一些高级主题,比如使用 SAAJ API 与基于 SOAP 的 web 服务进行通信,而不必依赖 JAX WS。在深入学习本章之前,您会很高兴在第十章中学习了 XML。

第十二章 (Java 7 与 Android 相遇)通过向你展示如何使用 Java 编写 Android 应用的源代码,帮助你运用在前面章节中所学到的知识。本章向您介绍 Android,讨论其架构,向您展示如何安装必要的工具,并开发一个简单的应用。

除了创建这十二个章节,我还创建了四个附录:

附录 A(练习的解答)给出了第一章到第十二章末尾的编程练习的解答。

附录 B(脚本 API 和动态类型语言支持)向您介绍了 Java 的脚本 API 以及 Java 7 中新增的对动态类型语言的支持。

附录 C(零星内容)向您介绍了其他 API 和体系结构主题:可访问性、ByteArrayOutputStream 和 ByteArrayInputStream、类加载器、控制台、桌面、拖放、动态布局、扩展机制和服务加载器、文件分区空间、文件权限、格式化程序、图像 I/O、国际化、Java 本机接口、网络接口和接口地址、新 I/O(包括 NIO.2)、PipedOutputStream 和 PipedInputStream、首选项、扫描仪、安全性、智能卡、闪屏、StreamTokenizer、StringTokenizer、SwingWorker

附录 D(应用库)展示了演示 Java 各个方面的重要应用库,让您有机会对这项技术有更多的兴趣。

不幸的是,印刷书籍能容纳的知识是有限的。因此,附录 A、B、C 和 D 不包括在本书的页面中?加上这些附录将会超过 1000 页的按需印刷限制。相反,这些附录作为 PDF 文件免费分发。附录 A 和 B 与该书的相关代码文件捆绑在一起,位于 Apress 网站([www.apress.com/97814302390…](http://www.apress.com/9781430239093))。附录 C 和 D 捆绑在我的 TutorTutor 网站([tutortutor.ca/cgi-bin/makepage.cgi?/books/bj7](http://tutortutor.ca/cgi-bin/makepage.cgi?/books/bj7))上它们各自的代码文件中。

附录 C 和 D 是“活文档”,因为我会不时地给它们添加新的内容。当我第一次接触 Java 时,我爱上了这项技术,并梦想着写一本探索整个语言和所有标准版 API 的书。也许我会是第一个这样做的人。

实现这一目标有各种障碍。首先,组织大量的内容并不容易,Java 随着每个新版本的发布而变得越来越大,所以总有更多的东西要写。另一个障碍是,在一本 1000 页的书中不可能涵盖所有内容。此外还有时间限制,这使得不可能在短短几个月内完成所有工作。

正确的组织对于创作一本既满足 Java 初学者又满足更有经验的 Java 开发人员的书是必不可少的。遗憾的是,在我之前的Learn Java for Android Development一书中缺乏适当的组织导致了一些对初学者不友好的东西(这已经在许多场合被指出)。例如,第二章将基本特性(例如,表达式和语句)与对象和类混在一起,这种方法对于新手来说太混乱了。开始 Java 7 对 Java 语言的覆盖更有条理。

1000 页以内不可能面面俱到,这是按需印刷书籍的上限。出于这个原因,我把附录 C 和 D 设计成了这本书的“活”扩展。它们使我有可能完成对整个 Java 7 标准版的介绍。我甚至可能在附录 c 的一个单独的区域中介绍 Java 8 的新特性。

我花了将近六个月的时间写开始 Java 7 。考虑到这个项目的广阔范围,这是一个非常小的时间量。我将花费更多的时间来完成我的 Java 7 标准版之旅;我会偶尔在我的网站上发布更新的附录 C 和 D,带你更深入地了解这项技术。

如果你之前已经购买了一本Learn Java for Android Development,你可能会震惊地发现我剽窃了自己的许多内容。我这样做是为了加速start Java 7的开发,它包含了许多超出我前一本书的内容(例如,Swing 和 web 服务)。如果我没有利用它的前身,开始 Java 7 可能要花好几个月才能完成。(如果我认为为 Android 开发学习 Java】是废话,我不这样认为,我绝不会把它作为这本新书的基础。)

不要认为开始 Java 7学习 Android 开发 Java的翻版——事实并非如此。在开始 Java 7 的那些部分,我从它的前任那里偷了很多东西,典型地有许多变化和增加。例如,我重写了出现在第三章中的部分异常和泛型内容;我这样做是为了介绍 Java 7 的新特性,并更好地覆盖困难的主题。另外,第五章介绍了可导航集合和可导航地图,这是我在学习 Android 开发 Java中无法讨论的内容,因为这些特性是在 Java 6 中介绍的。(我写了学习 Android 开发的 Java教 Java 语言和 API 让读者为 Android 做好准备?Android 应用是用 Java 编写的。但是,Android 不支持 Java 5 以外的语言特性和 API。)

Java 入门 7 远远超出了为 Android 开发学习 Java,因为它还讨论了用户界面 API(例如,抽象窗口工具包、Swing 和 Java 2D)和 web 服务(JAX-WS 和 RESTful)。除了新的内容,您还会发现许多新的示例(例如,聊天服务器)和新的练习(例如,创建一个具有图形用户界面的网络 21 点游戏)。

学 Java 做 Android 开发的第十章的最后,我轻率地答应写以下免费章节:

第十一章:执行 I/O 冗余

第十二章:解析和创建 XML 文档

第十三章:访问网络

第十四章:访问数据库

第十五章:使用安全性

第十六章:零零碎碎

我本来打算写这几章,加到学习 Android 开发 Java】里。然而,我没有时间了,而且可能还会碰到我前面提到的按需印刷的限制。

考虑到初学者在学习 Android 开发 Java 时遇到的组织困难,我决定不在那本书的背景下写这些章节。相反,我以一种新的(希望组织得更好)的方式追求开始 Java 7 ,试图覆盖所有 Java,并试图创作一本广泛吸引 Java 初学者和老手的书。

虽然我不会像学习 Android 开发的 Java中描述的那样写前面提到的六个免费章节(我无论如何也不能信守整个承诺,因为我已经将 12 ,13 和 14 章整合到开始 Java 7 中作为章节 9 和 10 ),但是其他三个章节( 11 ,15 和 16)被合并到附录 C 中,这是免费的。久而久之,其他章节将出现在附录中;所以我最终会遵守我的诺言,但是用不同的方式。

image 我在这本书里不讨论写源代码的代码约定。相反,我采用了我自己的惯例,并试图在整本书中一致地应用它们。如果您对 Oracle 关于 Java 代码约定的说法感兴趣,请查看位于[www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/codeconv-138413.html)的“Java 编程语言的代码约定”文档。

一、Java 入门

欢迎来到 Java。这一章通过关注基本原理来启动你的技术之旅。首先,您会收到“什么是 Java?”问题。如果你以前没有接触过 Java,答案可能会让你大吃一惊。接下来,将向您介绍一些有助于您开始开发 Java 程序的基本工具,以及简化这些程序开发的 NetBeans 集成开发环境。最后,您将探索基本的语言特性。

Java 是什么?

Java 是一种描述程序的语言,Java 是一个运行用 Java 和其他语言(例如 Groovy、Jython 和 JRuby)编写的程序的平台。本节向您介绍 Java 语言和 Java 平台。

images 要发现 Java 的历史,可以查看维基百科的“Java(编程语言)”([en.wikipedia.org/wiki/Java_(programming_language)#History](http://en.wikipedia.org/wiki/Java_(programming_language)#History))和“Java(软件平台)”([en.wikipedia.org/wiki/Java_(software_platform)#History](http://en.wikipedia.org/wiki/Java_(software_platform)#History))词条。

Java 是一种语言

Java 是一种通用的、基于类的、面向对象的语言,模仿了 C 和 C++的模式,使得现有的 C/C++开发人员更容易移植到这种语言。毫不奇怪,Java 借鉴了这些语言的元素。下表列出了其中的一些元素:

  • Java 支持与 C/C++中相同的单行和多行注释样式来记录源代码。
  • Java 提供了在 C 和 C++语言中可以找到的ifswitchwhilefor和其他保留字。Java 还提供了trycatchclassprivate以及其他在 C++中可以找到但在 C 中找不到的保留字
  • 与 C 和 C++一样,Java 支持字符、整数和其他基本类型。此外,Java 共享相同的保留字来命名这些类型;比如char(表示字符)int(表示整数)。
  • Java 支持许多与 C/C++相同的操作符:算术操作符(+-*/%)和条件操作符(?:)就是例子。
  • Java 还支持使用大括号字符{}来分隔语句块。

虽然 Java 与 C 和 C++相似,但在许多方面也有所不同。下面的列表详细列出了其中的一些差异:

  • Java 支持另一种称为 Javadoc 的注释风格。
  • Java 提供了transientsynchronizedstrictfp,以及其他 C 或 C++中没有的保留字。
  • Java 的字符类型比 C 和 C++中的字符类型更大,Java 的整数类型不包括这些类型的无符号变体(例如,Java 没有 C/C++无符号长整型的等效类型),Java 的基本类型有保证的大小,而对于等效的 C/C++类型没有保证。
  • Java 并不支持所有的 C/C++运算符。比如没有sizeof运算符。另外,Java 提供了一些 C/C++中没有的操作符。比如>>>(无符号右移)instanceof都是 Java 独占的。
  • Java 提供了带标签的 break 和 continue 语句。C/C++ break 和 continue 语句的这些变体为 C/C++的 goto 语句提供了更安全的替代方法,Java 不支持 goto 语句。

images 注释、保留字、类型、操作符和语句都是基本语言特性的例子,这将在本章后面讨论。

一个 Java 程序开始时是符合 Java 语法的源代码,即把符号组合成有意义的实体的规则。Java 编译器将存储在文件扩展名为“.java”的文件中的源代码翻译成等价的可执行代码,称为“??”字节码,并存储在文件扩展名为“.class”的文件中。

images 注意存储编译后的 Java 代码的文件被称为class file,因为它们经常存储 Java 类的运行时表示,这是一个在第二章的中讨论的语言特性。

Java 语言的设计考虑了可移植性。理想情况下,Java 开发人员编写一次 Java 程序的源代码,将该源代码编译成字节码,然后在支持 Java 的任何平台(例如,Windows、Linux 和 Mac OS X)上运行该字节码,而不必改变源代码并重新编译。可移植性部分是通过确保基元类型跨平台具有相同的大小来实现的。例如,Java 的整数类型的大小总是 32 位。

Java 语言在设计时也考虑到了健壮性。Java 程序应该比 C/C++程序更不容易崩溃。Java 实现健壮性的部分原因是没有实现某些会降低程序健壮性的 C/C++特性。例如,指针(存储其他变量地址的变量)增加了程序崩溃的可能性,这也是 Java 不支持这个 C/C++特性的原因。

Java 是一个平台

Java 是一个执行基于 Java 的程序的平台。与具有物理处理器(例如,英特尔处理器)和操作系统(例如,Windows 7)的平台不同,Java 平台由虚拟机和执行环境组成。

一个虚拟机是一个基于软件的处理器,拥有自己的指令集。Java 虚拟机(JVM)的相关执行环境由一个巨大的预建功能库组成,通常被称为标准类库,Java 程序可以使用它来执行日常任务(例如,打开文件并读取其内容)。执行环境还包括将 JVM 连接到底层操作系统的“粘合”代码。

images 注意“粘合”代码由特定于平台的库组成,用于访问操作系统的窗口、网络和其他子系统。它还包含使用 Java 本地接口(JNI)在 Java 和操作系统之间架起桥梁的代码。我在附录 c 中讨论了 JNI。您可能还想查看维基百科的“Java Native Interface”条目([en.wikipedia.org/wiki/Java_Native_Interface](http://en.wikipedia.org/wiki/Java_Native_Interface))来了解 JNI。

当 Java 程序启动器启动 Java 平台时,JVM 被启动并被告知通过一个称为类加载器的组件将 Java 程序的起始类文件加载到内存中。加载类文件后,将执行以下任务:

  • 验证类文件的字节码指令序列,以确保它们不会危及 JVM 和底层环境的安全。验证确保指令序列不会找到利用 JVM 破坏环境和窃取敏感信息的方法。处理这个任务的组件被称为字节码验证器
  • 执行类文件的字节码指令的主序列。处理这个任务的组件被称为解释器,因为指令是被解释的(被识别并用于选择适当的本机处理器指令序列,以执行字节码指令所表示的等价内容)。当解释器发现一个字节码指令序列被重复执行时,它通知实时(JIT)编译器组件将这个序列编译成一个等价的本地指令序列。JIT 有助于 Java 程序获得比仅仅通过解释更快的执行速度。注意,JIT 和将源代码编译成字节码的 Java 编译器是两个独立的编译器,有两个不同的目标。

在执行过程中,一个类文件可能会引用另一个类文件。在这种情况下,使用类加载器来加载被引用的类文件,然后字节码验证器验证该类文件的字节码,解释器/JIT 执行另一个类文件中适当的字节码序列。

Java 平台的设计考虑了可移植性。通过提供底层操作系统的抽象,字节码指令序列应该在 Java 平台上一致地执行。然而,这并不总是在实践中得到证实。例如,许多 Java 平台依赖底层操作系统来调度线程(在第四章中讨论过),线程调度实现因操作系统而异。因此,你必须小心确保程序被设计来适应这些变化。

Java 平台的设计也考虑到了安全性。除了字节码验证器,该平台还提供了一个安全框架来帮助确保恶意程序不会破坏程序运行的底层环境。附录 C 讨论了 Java 的安全框架。

安装和使用 JDK 7

有三种软件开发工具包(SDK)用于开发不同种类的 Java 程序:

  • Java SE(标准版)软件开发工具包(被称为 JDK)用于创建面向桌面的独立应用和被称为小程序的网络浏览器嵌入式应用。本节稍后将向您介绍独立的应用。我不讨论小程序,因为它们不像以前那样受欢迎了。
  • Java ME(移动版)SDK 用于创建称为 MIDlets 和 Xlets 的应用。MIDlets 的目标是移动设备,这些设备具有小型图形显示器、简单的数字键盘接口和有限的基于 HTTP 的网络访问。Xlets 通常以面向电视的设备为目标,如蓝光光盘播放器。Java ME SDK 还要求安装 JDK。我不讨论 MIDlets 或 Xlets。
  • Java EE(企业版)SDK 用于创建基于组件的企业应用。组件包括servlet,它可以被认为是小程序的服务器等价物,以及基于 servlet 的 Java 服务器页面(JSP)。Java EE SDK 还要求安装 JDK。我不讨论 servlets。

本节首先向您展示如何安装这个最新的主要 Java SE 版本,从而向您介绍 JDK 7(也称为 Java 7 ,这是后面章节中使用的术语)。然后向您展示如何使用 JDK 7 工具开发一个简单的独立应用——从现在开始,我将使用更简短的应用术语。

安装 JDK 7

将您的浏览器指向[www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/javase/downloads/index-jsp-138363.html),并按照出现的网页上的说明下载适用于您的 Windows、Solaris 或 Linux 平台的 JDK 7 安装 exe 或 gzip tarball 文件。

下载完成后,运行 Windows 可执行文件或解压缩 Solaris/Linux gzip tarball,并修改您的PATH环境变量以包含生成的主目录的bin子目录,以便您可以从文件系统中的任何位置运行 JDK 7 工具。例如,在 Windows 平台上,您可以将C:\Program Files\Java\jdk1.7.0主目录包含在PATH中。你还应该更新你的JAVA_HOME环境变量以指向 JDK 7 的主目录,以确保任何依赖于 Java 的软件都能找到这个目录。

JDK 7 的主目录包含几个文件(如README.htmlLICENSE)和子目录。从本书的角度来看,最重要的子目录是bin,它包含了我们将在本书中用到的各种工具。下表列出了其中的一些工具:

  • jar:将类文件和资源文件打包成特殊 ZIP 文件的工具,文件扩展名为“.jar
  • java:运行应用的工具
  • javac:启动 Java 编译器编译一个或多个源文件的工具
  • 从 Javadoc 注释中生成基于 HTML 的特殊文档的工具

JDK 的工具运行在命令行环境中。您可以通过启动命令窗口(Windows)或 shell (Linux/Solaris)来实现这一点,它会显示一系列提示,提示您输入命令(程序名及其参数)。例如,命令窗口(在 Windows 平台上)提示您通过提供驱动器号和路径组合(如C:\)来输入命令。

您可以通过键入命令来响应提示,然后按回车键告诉操作系统执行该命令。例如,javac x.java后按回车键/回车键会导致操作系统启动javac工具,并将正在编译的源文件的名称(x.java)作为其命令行参数传递给该工具。如果您指定了星号(*)通配符,如在javac *.javajavac将编译当前目录中的所有源文件。要了解更多关于使用命令行的信息,请查看维基百科的“命令行界面”条目([en.wikipedia.org/wiki/Command-line_interface](http://en.wikipedia.org/wiki/Command-line_interface))。

另一个重要的子目录是jre,它存储了 JDK 的 Java 运行时环境(JRE)的私有副本。JRE 实现了 Java 平台,使得运行 Java 程序成为可能。对运行(而不是开发)Java 程序感兴趣的用户可以下载公共 JRE。因为 JDK 包含自己的 JRE 副本,所以开发人员不需要下载并安装公共 JRE。

images 注意 JDK 7 附带了外部文档,其中广泛引用了 Java 的许多API(参见[en.wikipedia.org/wiki/Application_programming_interface](http://en.wikipedia.org/wiki/Application_programming_interface)了解这个术语)。您可以从[www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/javase/downloads/index-jsp-138363.html)下载文档档案,这样您就可以离线查看该文档。然而,因为档案相当大,您可能更喜欢在[download.oracle.com/javase/7/docs/index.html](http://download.oracle.com/javase/7/docs/index.html)在线查看文档。

与 JDK 合作 7

一个应用由一个带有名为main的入口点方法的类组成。虽然对类和方法的适当讨论必须等到第二章才能进行,但是现在只要把类看作是创建对象的工厂就足够了(在第二章的中也讨论了),把方法看作是一个命名的指令序列,当方法被调用时执行。清单 1-1 向您介绍了您的第一个应用。

***清单 1-1。*来自爪哇的问候

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

清单 1-1 声明了一个名为HelloWorld的类,为这个简单的应用提供了一个框架。它还在这个类中声明了一个名为main的方法。当您运行这个应用时,您将很快了解如何这样做,调用的是这个入口点方法,执行的是它的指令。

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

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

代码块由一个单独的System.out.println("Hello, world!");方法调用组成。从左到右,System标识系统工具的标准类,out标识位于System中的对象变量,其方法允许您将各种类型的值输出到标准输出设备,可选地后跟一个换行符,println标识将其参数后跟一个换行符打印到标准输出的方法, 而"Hello, world!"是一个字符串(一个由双引号"字符分隔的字符序列,被视为一个单元)作为参数传递给println并写入标准输出(不写入开始的"和结束的"双引号字符; 这些字符分隔但不是字符串的一部分)。

images 注意所有桌面 Java/非 Java 应用都可以在命令行运行。在图形用户使用其控件输入和输出值(如文本字段)之前,这些应用在标准 I/O 的帮助下获得输入并生成输出,这是一种源于 Unix 操作系统的输入/输出机制,由标准输入、标准输出和标准错误设备组成。

用户将通过标准输入设备(通常是键盘,但也可以指定一个文件——Unix 将所有东西都视为文件)输入数据。应用的输出将出现在标准输出设备上(通常是计算机屏幕,但也可以是文件或打印机)。表示错误的输出消息将被输出到标准的错误设备(屏幕、文件或打印机),以便这些消息可以被单独处理。

现在您已经理解了清单 1-1 是如何工作的,您将想要创建这个应用。完成以下步骤来完成此任务:

  1. 将清单 1-1 中的复制到名为HelloWorld.java的文件中。
  2. 执行javac HelloWorld.java来编译这个源文件。如果您不指定“.java”文件扩展名,javac将会投诉。

如果一切顺利,您应该会在当前目录中看到一个HelloWorld.class文件。现在执行java HelloWorld来运行这个类文件的main()方法。不要指定“.class”文件扩展名,否则java会投诉。您应该观察到以下输出:

Hello, world!

恭喜你!您已经运行了第一个基于 Java 的应用。在本书中,你将有机会运行更多的应用。

安装和使用 NetBeans 7

对于小项目,在命令行使用 JDK 工具没什么大不了的。因为您可能会发现这种场景对于大型项目来说很乏味(甚至不可行),所以您应该考虑获得一个集成开发环境(IDE)工具。

三种流行的 Java 开发 ide 分别是 Eclipse ( [www.eclipse.org/](http://www.eclipse.org/))、IntelliJ IDEA ( [www.jetbrains.com/idea/](http://www.jetbrains.com/idea/)),可以免费试用但如果想继续使用就必须购买,还有 NetBeans ( [netbeans.org/](http://netbeans.org/))。因为 NetBeans 7 IDE 支持 JDK 7,所以我在这一部分重点介绍它。(IntelliJ IDEA 10.5 也支持 JDK 7。)

images 注意有关 JDK 7 特有的 NetBeans 7 IDE 增强功能列表,请查看位于[wiki.netbeans.org/NewAndNoteworthyNB70#JDK7_support](http://wiki.netbeans.org/NewAndNoteworthyNB70#JDK7_support)的页面。

本节将向您介绍如何安装 NetBeans 7 IDE。然后在开发HelloWorld时向您介绍这个 IDE。

images 注意 NetBeans 不仅仅是一个 IDE。它还是一个平台框架,通过利用模块化 NetBeans 体系结构,开发人员可以更快地创建应用。

安装 NetBeans 7

将浏览器指向[netbeans.org/downloads/](http://netbeans.org/downloads/)并执行以下任务:

  1. 选择适当的 IDE 语言(默认为英语)。
  2. 选择适当的平台(默认为 Windows)。
  3. 单击最左边一列(Java EE)下面的下载按钮,启动相应安装程序文件的下载过程。我选择下载 Windows 平台的英文 Java EE 安装程序,它是一个名为 netbeans-7.x-ml-javaee-windows.exe 的文件。(因为我在《Java 7 入门》中没有研究 Java EE,所以安装 NetBeans 的 Java EE 版本似乎没有意义。不过,如果你在读完这本书后决定探索 Java EE,你不妨现在就安装这个软件。)

运行安装程序。配置完成后,安装程序会显示一个欢迎对话框,让您选择要随 IDE 一起安装的应用服务器。确保 GlassFish Server 和 Apache Tomcat 复选框都处于选中状态(在探索 Java EE 时,您可能希望同时使用这两个应用服务器),然后单击 Next 按钮。

在出现的许可协议对话框中,阅读协议,通过选中复选框表示接受,然后单击下一步。在随后的 JUnit 许可协议对话框中重复此过程。

出现的 NetBeans IDE 7.0 安装对话框显示了 NetBeans 的默认安装位置(C:\Program Files\NetBeans 7.0在我的平台上)和 JDK 7 主目录位置(C:\Program Files\Java\jdk1.7.0在我的平台上)。如有必要,更改这些位置,然后单击下一步。

出现的 GlassFish 3.1 安装对话框显示了安装 GlassFish 应用服务器的默认位置(在我的平台上为C:\Program Files\glassfish-3.1)。如有必要,请更改此位置,然后单击“下一步”。

出现的 Apache Tomcat 7.0.11 安装对话框显示了 Apache Tomcat 应用服务器的默认安装位置(在我的平台上为C:\Program Files\Apache Software Foundation\Apache Tomcat 7.0.11)。如有必要,请更改此位置,然后单击“下一步”。

出现的“摘要”对话框显示了您选择的选项以及所有正在安装的软件的组合安装大小。查看这些信息后,单击“安装”按钮开始安装。

安装需要几分钟,最后会出现一个安装完成对话框。查看该对话框的信息后,单击“完成”按钮完成安装。

假设安装成功,启动这个 IDE。NetBeans 在执行各种初始化任务时首先显示一个闪屏,然后显示一个类似于图 1-1 所示的主窗口。

images

***图 1-1。*NetBeans 7 IDE 的主窗口最初会显示一个起始页选项卡。

如果您使用过以前版本的 NetBeans IDE,您可能希望单击“浏览”按钮来了解版本 7 与以前版本的不同之处。您将被带到一个提供 IDE 视频教程的网页,如 NetBeans IDE 7.0 概述。

使用 NetBeans 7

NetBeans 提供了一个用户界面,其主窗口分为菜单栏、工具栏、工作区和状态栏。工作区提供了一个起始页选项卡,用于了解 NetBeans、访问 NetBeans 项目等。

为了帮助您熟悉这个 IDE,我将向您展示如何创建一个重用清单 1-1 的源代码的HelloWorld项目。我还将向您展示如何编译和运行HelloWorld应用。完成以下步骤来创建HelloWorld项目:

  1. 从“文件”菜单中选择“新建项目”。
  2. 在生成的“新建项目”对话框的“选择项目”窗格中,确保 Java 是选定的类别,Java Application 是它们各自类别和项目列表中的选定项目。单击下一步。
  3. 在出现的名称和位置窗格中,在项目名称文本字段中输入**HelloWorld**。注意helloworld.HelloWorld出现在 Create Main Class 复选框(必须选中)右边的文本字段中。这个字符串的helloworld部分指的是存储这个字符串的HelloWorld类部分的包。(封装在第三章的中讨论。)点击完成。

NetBeans 花了一些时间创建了HelloWorld项目。完成后,NetBeans 会显示如图 1-2 所示的工作区。

images

***图 1-2。*工作空间分为多个工作区域。

创建HelloWorld后,NetBeans 将工作区组织成项目、编辑器、导航器和任务工作区。“项目”区域可帮助您管理项目,分为以下几个选项卡:

  • “项目”选项卡是项目的源文件和资源文件的主要入口点。它呈现了重要项目内容的逻辑视图。
  • “文件”选项卡提供了基于目录的项目视图。此视图包括“项目”选项卡上未显示的任何文件和文件夹。
  • “服务”选项卡显示了在 IDE 中注册的资源的逻辑视图,例如服务器、数据库和 web 服务。

编辑器区域帮助您编辑项目的源文件。每个文件都与其自己的选项卡相关联,选项卡上标有文件名。例如,图 1-2 显示了一个 HelloWorld.java 标签,它提供了这个源文件内容的框架版本。

navigator 区域显示 Navigator 选项卡,它提供了当前所选文件的紧凑视图,并且简化了文件各部分(例如,类和方法头)之间的导航。

最后,任务区域显示了一个 Tasks 选项卡,该选项卡显示了项目的各个文件需要解决的待办事项列表。每一项都由一个描述、一个文件名和文件中必须进行解析的位置组成。

用清单 1-1 中的替换 HelloWorld.java 标签的内容,将package helloworld;语句保留在文件的顶部,以防止 NetBeans 抱怨不正确的包。接下来,从“运行”菜单中选择“运行主项目”来编译和运行此应用。图 1-3 的输出页签显示HelloWorld的问候。

images

***图 1-3。*任务左侧出现一个输出选项卡,显示HelloWorld的问候。

images 提示要将命令行参数传递给应用,首先从文件菜单中选择项目属性。在出现的项目属性对话框中,在类别树中选择 Run,并输入参数(用空格分隔;例如,在结果窗格的“参数”文本字段中输入**first second third**

有关 NetBeans 7 IDE 的更多信息,请通过“起始页”选项卡学习教程,通过“帮助”菜单访问 IDE 帮助,并浏览位于[netbeans.org/kb/](http://netbeans.org/kb/)的 NetBeans 知识库。

Java 语言基础

大多数计算机语言支持注释、标识符、类型、变量、表达式和语句。Java 也不例外,这一节将从 Java 的角度向您介绍这些基本的语言特性。

评论

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

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

单行注释

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

int x = (int) (Math.random()*100); // Obtain a random x coordinate from 0 through 99.

单行注释对于在代码中插入简短但有意义的源代码解释非常有用。不要用它们来插入无用的信息。比如在声明变量的时候,不要插入// this variable is an integer之类无意义的注释。

多行注释

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

static boolean isLeapYear(int year) {    /*       A year is a leap year if it is divisible by 400, or divisible by 4 but       not also divisible by 100.    */    if (year%400 == 0)       return true;    else    if (year%100 == 0)       return false;    else    if (year%4 == 0)       return true;    else       return false; }

此示例介绍了一种确定年份是否为闰年的方法。理解这段代码的重要部分是多行注释,它阐明了决定year的值是否代表闰年的表达式。

images 注意不能将一个多行注释放在另一个多行注释中。例如,/*/* Nesting multiline comments is illegal! */*/不是有效的多行注释。

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
}

这个例子以描述main()方法的 Javadoc 注释开始。夹在/***/之间的是对方法的描述,它可以(但不包括)包括 HTML 标签(比如<p><code> / </code>),以及@param Javadoc 标签(一个@前缀的指令)。

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

  • @author标识源代码的作者。
  • @deprecated标识不应再使用的源代码实体(例如,方法)。
  • @param标识方法的一个参数。
  • @see提供另见参考。
  • @since标识实体最初发布的软件版本。
  • @return标识该方法返回的值的种类。

清单 1-2 用描述HelloWorld类及其main()方法的文档注释展示了我们的HelloWorld应用。

***清单 1-2。*来自 Java 的问候和文档注释

/**
    A simple class for introducing a Java application.

    @author Jeff Friesen
*/
class HelloWorld
{
   /**
      Application entry point

      @param args array of command-line arguments passed to this method
   */
   public static void main(String[] args)
   {
      System.out.println("hello, world!");
   }
}

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

javadoc -private HelloWorld.java

javadoc默认为public类和这些类的public / protected成员生成基于 HTML 的文档——你将在第二章中了解这些概念。因为HelloWorld不是public,指定javadoc HelloWorld.java会导致javadoc抱怨没有找到publicprotected类来记录。补救方法是指定javadoc-private命令行选项。

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

Loading source file HelloWorld.java... Constructing Javadoc information... Standard Doclet version 1.7.0 Building tree for all the packages and classes... Generating \HelloWorld.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入口点文件。将你的浏览器指向这个文件,你应该会看到一个类似于图 1-4 所示的页面。

images

***图 1-4。*进入HelloWorld的 javadoc 的入口点页面提供了对文档的简单访问。

images 注意 JDK 7 的外部文档具有与图 1-4 相似的外观和组织,因为该文档也是由javadoc生成的。

标识符

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

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

有效标识符的例子包括icounterloop10border$color_char。无效标识符的例子包括50y(以数字开头)和first#name ( #不是有效的标识符符号)。

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

几乎可以选择任何有效的标识符来命名类、方法或其他源代码实体。然而,一些标识符是为特殊目的而保留的;它们被称为保留字。Java 保留了以下标识符:abstractassertbooleanbreakbytecasecatchcharclassconstcontinuedefaultdodoubleenumelseextendsfalsefinalfinallyfloatforgotoifimplementsimportshortstaticstrictfpsuperswitchsynchronizedthisthrowthrowstransienttruetryvoidvolatilewhile。 如果您试图在这些保留字的用法上下文之外使用它们中的任何一个,编译器都会输出一条错误消息。

images Java 的大部分保留字也被称为关键字。三个例外是falsenulltrue,它们是文字(逐字指定的值)的示例。

类型

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

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

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

原始类型

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

images

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

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

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

字符类型的最小值和最大值指的是 Unicode ,它是世界上大多数书写系统所表达的文本的一致编码、表示和处理的标准。Unicode 是与通用字符集(??)一起开发的,通用字符集是一种对组成世界书面语言的各种符号进行编码的标准。 Unicode 0 是“第一个 Unicode 码位”的简写——码位是一个整数,表示一个符号(例如,A)或一个控制字符(例如,换行符或制表符),或者与其他码位组合形成一个符号。查看 Wikipedia 的“Unicode”条目([en.wikipedia.org/wiki/Unicode](http://en.wikipedia.org/wiki/Unicode))以了解有关该标准的更多信息,查看 Wikipedia 的“通用字符集”条目([en.wikipedia.org/wiki/Universal_Character_Set](http://en.wikipedia.org/wiki/Universal_Character_Set))以了解有关该标准的更多信息。

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

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

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

images

***图 1-5。*双字节整数值的二进制表示以符号位开始。

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

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

用户定义的类型

用户定义类型是由开发人员使用类、接口、枚举或注释类型定义的类型;其值是对象。比如 Java 的String类定义了字符串自定义类型;它的值描述字符串,它的方法执行各种字符串操作,比如将两个字符串连接在一起。第二章讨论了类、接口和方法。第三章讨论枚举和注释类型。

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

数组类型

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

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

变量

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

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

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

第一个示例声明一个名为counter的整数变量,第二个示例声明一个名为temperature的双精度浮点变量,第三个示例声明一个名为firstName的字符串变量,第四个示例声明一个名为ages的一维整数数组变量,第五个示例声明一个名为gradeLetters的一维字符数组变量,第六个示例声明一个名为matrix的二维浮点数组变量。没有字符串与firstName相关联,也没有数组与agesgradeLettersmatrix相关联。

images 注意方括号可以出现在类型名之后,也可以出现在变量名之后,但不能同时出现在两个地方。比如编译器遇到int[] x[];就报错。通常的做法是将方括号放在类型名之后(如在int[] ages;中),而不是变量名之后(如在char gradeLetters[];)。

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

int x, y[], z;

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

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

表情

先前声明的变量没有显式初始化为任何值。因此,它们要么被初始化为默认值(例如,int为 0,double为 0.0),要么保持未初始化,这取决于它们出现的上下文(在类中声明或在方法中声明)。第二章从字段、局部变量和参数的角度讨论变量上下文。

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

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

简单的表情

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

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

一个字符串文字由一对双引号括起来的 Unicode 字符序列组成;例如,"the quick brown fox jumps over the lazy dog." It还可能包含转义序列,这是一种特殊的语法,用于表示某些可打印和不可打印的字符,否则这些字符将不会出现在文本中。例如,"the quick brown \"fox\" jumps over the lazy dog."使用\"转义序列将fox用双引号括起来。

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

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

一个布尔文字由保留字true或保留字false组成。

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

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

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

  • 十进制格式是默认格式;比如127
  • 十六进制格式要求文字以0x0X开头,以十六进制数字(0 - 9A - Fa - f)继续;比如0x7F
  • 八进制格式要求文字以0为前缀,以八进制数字(0 - 7)为续;比如0177
  • 二进制格式要求文字以0b0B为前缀,并以0 s 和1 s 继续;例如,0b01111111

为了提高可读性,可以在数字之间插入下划线;比如204_555_1212。尽管您可以在数字之间插入多个连续的下划线(如在0b1111__0000中),但您不能指定前导下划线(如在_123中),因为编译器会将文字视为一个标识符。此外,不能指定尾部下划线(如123_)。一个浮点字面值由整数部分、小数点(用句点字符[ . ]表示)、小数部分、指数(以字母Ee开头)和类型后缀(字母DdFf)组成。大多数部分是可选的,但是必须有足够的信息来区分浮点文字和整数文字。示例包括0.1(双精度浮点)、89F(浮点)、600D(双精度浮点)和 1 3.08E+23(双精度浮点)。与整数文字一样,您可以通过在数字之间放置下划线来使浮点文字更容易阅读(例如,3.141_592_654)。

最后,将null文字赋给引用变量,以表明该变量不引用对象。

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

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;

最后四个例子使用数组初始化器来初始化agesgradelettersmatrixy数组。一个数组初始化器由一个用括号和逗号分隔的表达式列表组成,这些表达式(如matrix示例所示)本身可能就是数组初始化器。matrix示例生成如下所示的表格:

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

组织内存中的变量

也许你对变量在内存中是如何组织的很好奇。图 1-6 展示了counteragesmatrix变量的一个可能的高层组织,以及分配给agesmatrix的数组。

images

图 1-6。counter变量存储一个四字节的整数值,而agesmatrix存储对它们各自数组的四字节引用。

图 1-6 显示了counteragesmatrix中的每一个都存储在一个内存地址(在本例中从一个虚构的 20001000 值开始)并能被 4 整除(每个变量存储一个 4 字节的值),并且counter的 4 字节值存储在这个地址,agesmatrix的 4 字节内存位置中的每一个都存储其各自数组的 32 位地址(64 位地址最有可能在 64 位上使用此外,一维数组存储为值列表,而二维数组存储为地址的一维行数组,其中每个地址标识该行的值的一维列数组。

尽管图 1-6 暗示数组地址存储在agesmatrix中,这等同于地址引用,Java 实现可能等同于句柄(标识列表中槽的整数值)。对于ages及其参考阵列,该替代方案在图 1-7 中给出。

images

***图 1-7。*ages中存储一个句柄,由该句柄标识的列表项存储相关数组的地址。

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

以下示例显示了一个简单的表达式,其中一个变量被赋予另一个变量的值:

int counter1 = 1;
int counter2 = counter1;

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

boolean isLeap = isLeapYear(2011);

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

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

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

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

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

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

复合表达式

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

images 注意如果x的类型为字节整数或短整型,则该变量的值被加宽为整数。但是,如果x的类型是长整型、浮点型或双精度浮点型,5将被扩展到适当的类型。加法运算在扩大转换发生后执行。

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

运算符也分为前缀、后缀和中缀。一个前缀运算符是一元运算符,位于其操作数之前(如在-6中),一个后缀运算符是一元运算符,位于其操作数之后(如在x++中),一个中缀运算符是一个二元或三元运算符,夹在二元运算符的两个或三个操作数之间(如在x+5中)。表 1-3 显示了所有支持的运算符的符号、描述和优先级——优先级的概念将在本节末尾讨论。各种运算符描述都提到了“整数类型”,这是指定任何字节整数、短整数、整数或长整数的简写,除非“整数类型”被限定为 32 位整数。此外,“数字类型”是指除浮点和双精度浮点之外的任何整数类型。

images

images

images

images

images

images

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

加法运算符

加法运算符由加法(+)、减法(-)、后减量(--)、后增量(++)、前增量(--)、前增量(++)和字符串连接(+)组成。加法返回其操作数之和(例如,6+4返回 10),减法返回其操作数之差(例如,6-4返回 2,4-6返回 2),后减量从其变量操作数中减去 1 并返回变量的先前值(例如,x--),后增量向其变量操作数加 1 并返回变量的先前值(例如,x++),前增量从其变量操作数中减去 1 并返回变量的新值(例如 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,并且小于数组的长度;否则,JVM 抛出ArrayIndexOutOfBoundsException(参考第三章了解异常)。

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

赋值运算符

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

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

按位运算符

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

~0B00000000000000000000000010110101 results in 11111111111111111111111101001010
0B00011010&0B10110111 results in 00000000000000000000000000010010
0B00011010⁰B10110111 results in 00000000000000000000000010101101
0B00011010|0B10110111 results in 00000000000000000000000010111111

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

演员表

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

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

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

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

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

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

byte b = (byte) c;

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

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

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

条件运算符

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

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

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

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

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

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

boolean b = true;
int i = b ? 1 : 0; // 1 assigns to i
等式运算符

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

当涉及到对象操作数时(在第二章的中讨论),这些操作符不比较它们的内容。例如,"abc" == "xyz"不会将ax进行比较。相反,因为字符串实际上是存储在内存中的String对象(第四章进一步讨论了这个概念),==比较对这些对象的引用。

逻辑运算符

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

会员权限操作员

成员访问操作符(.)用于访问类的成员或对象的成员。例如,String s = "Hello"; int len = s.length();返回赋给变量s的字符串长度。它通过调用String类的length()方法成员来实现。第二章更详细地讨论了这个话题。

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

方法调用操作符

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

乘法运算符

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

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

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

对象创建操作员

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

关系运算符

关系运算符包括关系大于(>)、关系大于等于(>=)、关系小于(<)、关系小于等于(<=)和关系类型检查(instanceof)。前四个运算符比较它们的操作数,如果左操作数(分别)大于、大于或等于、小于或小于或等于右操作数,则返回 true。例如,5.0 > 32 >= 216.1 < 303.354.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。

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

一元减/加运算符

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

优先级和结合性

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

images 表 1-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)被赋给x——因为=是一个从右到左的关联运算符。

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

images 注意与 C++等语言不同,Java 不会让你重载运算符。然而,Java 重载了+++--操作符。

报表

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

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

本节将向您介绍 Java 的许多语句。其他声明将在后面的章节中介绍。比如第二章讨论 return 语句。

赋值语句

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

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

第一个例子将整数10赋给变量x,该变量可能也是整数类型。第二个例子将整数25分配给ages数组的第一个元素。第三个示例将10加到存储在counter中的值,并将总和存储在counter中。

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

决策陈述

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

If 语句

if 语句计算一个布尔表达式,并在该表达式计算结果为 true 时执行另一条语句。该语句具有以下语法:

if (*Boolean expression*)
   *statement*

如果由保留字if,后跟括号中的 Boolean expression ,后跟 statement 组成,当 Boolean expression 评估为真时执行。

以下示例演示了该语句:

if (numMonthlySales > 100)
   wage += bonus;

如果月销售额超过 100,numMonthlySales > 100的计算结果为真,并且执行wage += bonus;赋值语句。否则,该赋值语句不会执行。

If-Else 语句

if-else 语句计算一个布尔表达式,并根据该表达式的计算结果是 true 还是 false 来执行两个语句之一。该语句具有以下语法:

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

If-else 由保留字if组成,后跟括号中的 Boolean expression ,后跟 statement1Boolean expression 评估为真时执行,后跟 statement2Boolean expression 评估为假时执行。

以下示例演示了该语句:

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

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

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

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

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

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

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

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

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

如果 Boolean expression1 评估为真, statement1 执行。否则,如果 Boolean expression2 评估为真,则 statement2 执行。这种模式会一直持续下去,直到其中一个表达式的值为真,相应的语句执行,或者到达最后的else,执行 statementN (默认语句)。

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

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();
   else
      car.door.open();

如果car.door.isOpen()car.key.isPresent()都返回 true,car.start()执行。如果car.door.isOpen()返回真且car.key.isPresent()返回假,则car.door.open();执行。试图打开一扇敞开的门毫无意义。

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

if (car.door.isOpen())
{
   if (car.key.isPresent())
      car.start();
}
else
   car.door.open();

car.door.isOpen()返回 true 时,复合语句执行。当这个方法返回 false 时,car.door.open();执行,这是有意义的。

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

切换语句

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

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

Switch 由保留字switch组成,后面是圆括号中的 selector expression ,后面是正文。 selector expression 是任何计算结果为整数、字符或字符串值的表达式。例如,它可能计算 32 位整数或 16 位字符。

每种情况都以保留字case开始,以文字值和冒号(:继续,以要执行的语句继续,并可选地以 break 语句结束,这导致在 switch 语句之后继续执行。

在评估了 selector expression 之后,switch 将该值与每个案例的值进行比较,直到找到匹配为止。如果匹配,则执行 case 语句。例如,如果 selector expression 的值匹配 value1statement1 执行。

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

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

如果没有一个 case 的值与 selector expression 的值相匹配,并且如果存在一个默认 case(由后面跟一个冒号的default保留字表示),则执行默认 case 的语句。

以下示例演示了该语句:

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.");
}

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

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

循环语句

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

对于声明

for 语句允许您对一条语句进行特定次数的循环,甚至可以无限循环。该语句具有以下语法:

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

For 由保留字for组成,后面是括号中的头,后面是要执行的语句。标题由可选的 initialize 段、可选的 test 段、可选的 update 段组成。一个非可选的分号将前两个部分与下一个部分分开。

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

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

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

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

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

public static void main(String[] args)
{
   for (int i = 0; i < args.length; i++)
      switch (args[i])
      {
         case "-v":
         case "-V": System.out.println("version 1.0");
                    break;
         default  : showUsage();
      }
}

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

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

args[i]表达式充当 switch 语句的选择器表达式。如果这个String对象包含-V,则执行第二种情况,调用System.out.println()输出版本号消息。随后的 break 语句防止执行陷入默认情况,即当使用意外参数调用main()时,调用showUsage()输出使用信息。

如果这个String对象包含-v,在第一个案例之后缺少 break 语句会导致第二个案例的执行,调用System.out.println()。这个例子演示了偶尔需要对案例进行分组来执行公共代码。

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

以下示例使用 for 语句输出先前声明的matrix数组的内容,为了方便起见,这里重新声明了该数组:

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返回该表格数组中的行数。对于每一行,表达式matrix[row].length返回该行的列数。后一个表达式表明每一行可以有不同的列数,尽管在本例中每一行都有相同的列数。

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

此示例生成以下输出:

1.0 2.0 3.0
4.0 5.0 6.0
While 语句

while 语句在其布尔表达式计算为 true 时重复执行语句。该语句具有以下语法:

while (*Boolean expression*)
   *statement*

While 由保留字while组成,后面跟着一个带圆括号的 Boolean expression 头,后面跟着一个 statement 来反复执行。

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

提示用户输入特定字符是 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),则该表达式的计算结果为真。

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

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

从左到右,System表示系统工具的标准类别,in表示位于System中的对象,该对象提供从标准输入设备输入一个或多个字节的方法,read()返回下一个字节(如果没有更多的字节,则返回-1).

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

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

假设ch包含 c 的整数值。条件和评估ch != 'C',它评估为真。看到表达式为真,条件和评估ch != 'c',评估为假。while 语句再次终止。

images 注意一个 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 语句在其布尔表达式计算为 true 时重复执行语句。与在循环顶部计算布尔表达式的 while 语句不同,do-while 在循环底部计算布尔表达式。该语句具有以下语法:

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

Do-while 由do保留字组成,后面跟着一个 statement 来重复执行,后面跟着while保留字,后面跟着一个带圆括号的 Boolean expression 头,后面跟着一个分号。

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

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

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

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

循环空语句

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

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

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

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

for (int i = 0; i < 10; i++); // this ; represents the empty statement
   System.out.println("Hello");
Break 和带标签的 Break 语句

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

images 注意无限循环也可能由循环头的布尔表达式产生,该表达式通过相等或不等运算符将浮点值与非零值进行比较,因为许多浮点值具有不精确的内部表示。例如,下面的代码片段永远不会结束,因为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 和 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 时,执行break outer;终止两个 for 循环。这条语句将执行转移到外层 for 循环之后的第一条语句,恰好是System.out.println("Both loops terminated.");

将生成以下输出:

i=0, j=0
i=0, j=1
i=0, j=2
i=1, j=0
Both loops terminated.
Continue 和带标签的 Continue 语句

continue 语句跳过当前循环迭代的剩余部分,重新计算头的布尔表达式,并执行另一次迭代(如果为真)或终止循环(如果为假)。Continue 由保留字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 时,执行continue outer;以终止内部 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. 声明一个EchoArgs类,其main()方法输出其命令行参数,每行一个参数。将这个类存储在一个名为EchoArgs.java的文件中。编译此源代码(javac EchoArgs.java)并运行应用;例如,java EchoArgs A B C。您应该会看到ABC分别出现在单独的一行上。

  2. 声明一个Circle类,其main()方法声明一个名为PI的双精度浮点变量,该变量初始化为3.14159,声明一个名为radius的双精度浮点变量,该变量初始化为15,计算并输出圆的周长(PI乘以直径),计算并输出圆的面积(PI乘以半径的平方)。编译并运行该应用。

  3. 声明一个Input类,其main()方法声明如下:public static void main(String[] args) throws java.io.IOException—不用担心throws java.io.IOException;你将在第三章中了解这一语言特性。继续,将“中断和带标签的中断语句”一节中的“循环,直到输入 C 或 C”示例插入到main()方法中。编译并运行该应用。出现提示时,键入一个键,然后按 Enter/Return 键。当您键入多个键(例如 abc )并按 Enter/Return 键时会发生什么?

  4. Declare a Triangle class whose 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 follows:                    *                   ***                  *****                 *******                *********               ***********              *************             ***************            *****************           *******************

    编译并运行该应用。

  5. 声明一个OutputReversedInt类,其main()方法声明一个名为xint变量,该变量被赋予一个正整数。这个声明后面是一个 while 循环,它反向输出这个整数的数字。例如,876432094输出为490234678

总结

Java 是一种描述程序的语言。这种通用的、基于类的、面向对象的语言模仿了 C 和 C++的模式,使现有的 C/C++开发人员更容易迁移到 Java。

Java 也是一个运行用 Java 和其他语言(例如 Groovy、Jython 和 JRuby)编写的程序的平台。与具有物理处理器(例如,英特尔处理器)和操作系统(例如,Windows 7)的平台不同,Java 平台由虚拟机和执行环境组成。

在开发 Java 程序之前,您需要确定要开发哪种程序,然后安装合适的软件。使用 JDK 开发独立的应用和小程序,使用 Java ME SDK 开发 MIDlets 和 Xlets,使用 Java EE SDK 开发 servlets 和 JSP。

对于小项目,在命令行使用 JDK 工具没什么大不了的。因为对于较大的项目,您可能会发现这种情况很乏味(甚至不可行),所以您还应该考虑获得一个 IDE,如 NetBeans 7,它包括对 JDK 7 引入的那些语言功能的支持。

大多数计算机语言支持注释、标识符、类型、变量、表达式和语句。注释让你记录你的源代码;标识符命名事物(例如,类和方法);类型标识值的集合(以及它们在内存中的表示)和将这些值转换成该集合中其他值的操作集合;变量存储值;表达式组合了变量、方法调用、文字和运算符;and 语句是程序的核心,包括赋值、判定、循环、break 和标签 break,以及 continue 和标签 continue。

现在您已经对 Java 的基本语言特性有了基本的了解,您已经准备好学习 Java 对类和对象的语言支持了。第二章向您介绍这种支持。

二、探索类和对象

第一章通过主要关注从注释到语句的基本语言特性,温和地向你介绍了 Java 语言。仅使用这些特性,您就可以创建简单的应用(如HelloWorld和本章练习中提到的应用),这些应用让人想起用 c 等结构化编程语言编写的应用。

images 结构化编程是一种编程范式,它通过数据结构(命名的数据项集合)函数(命名的代码块,将值返回给调用它们的代码【将程序执行传递给它们】)和过程(命名的代码块,不向它们的调用者返回值)在程序上实施逻辑结构。结构化程序使用顺序(一个语句跟在另一个语句后面)、选择/选择(if/switch)、重复/迭代(for/while/do)编程构造;不鼓励使用可能有害的 GOTO 语句(见[en.wikipedia.org/wiki/GOTO](http://en.wikipedia.org/wiki/GOTO))。

结构化程序将数据与行为分开。这种分离使得对现实世界的实体(比如银行账户和雇员)建模变得困难,并且当程序变得复杂时,经常导致维护上的麻烦。相比之下,类和对象将数据和行为组合成程序实体;基于类和对象的程序通常更容易理解和维护。

第二章通过关注 Java 对类和对象的支持,带你更深入地了解 Java 语言。您首先学习如何声明类并从这些类创建对象,然后学习如何通过字段和方法将状态和行为封装到这些程序实体中。在学习了类和对象初始化之后,通过探索 Java 的面向继承和面向多态的语言特性,您将超越这个基于对象的编程模型,进入面向对象的编程

至此,本章介绍了 Java 的一个更令人困惑的语言特性:接口。您将学习什么是接口,它们如何与类相关,以及是什么让它们如此有用。

Java 程序创建占用内存的对象。为了降低内存不足的可能性,Java 虚拟机(JVM)的垃圾收集器偶尔会通过定位不再使用的对象并删除这些垃圾来释放内存,从而执行垃圾收集。第二章最后向您介绍垃圾收集过程。

声明类和创建对象

结构化程序创建组织和存储数据项的数据结构,并通过函数和过程操纵存储在这些数据结构中的数据。结构化程序的基本单元是它的数据结构和操作它们的函数或过程。尽管 Java 允许您以类似的方式创建应用,但这种语言实际上是关于声明类和从这些类创建对象。这些程序实体是 Java 程序的基本单元。

本节首先向您展示如何声明一个类,然后向您展示如何在new操作符和构造函数的帮助下从这个类创建对象。这一节将向您展示如何指定构造函数参数和局部变量。最后,您将学习如何使用用于从类创建对象的相同的new操作符来创建数组。

声明类别

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

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

***清单 2-1。*声明一个骨骼Image

class Image
{
   // various member declarations
}

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

用 new 运算符和构造函数创建对象

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

Image image = new Image();

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

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

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

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

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

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

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

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

清单 2-2 增强了清单 2-1 的Image类,声明了三个构造函数,它们的参数列表声明了零个、一个或两个参数;和一个测试这个类的main()方法。

**清单 2-2。**用三个构造函数和一个main()方法声明一个Image

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");    } }

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

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

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

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

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

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

如果存在,构造函数调用必须是构造函数中指定的第一个代码;否则,编译器会报告错误。因此,调用另一个构造函数的构造函数只能在另一个构造函数完成后执行额外的工作。例如,Image(String filename)在被调用的Image(String filename, String imageType)构造函数完成后执行System.out.println("Image(String filename) called");

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

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

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

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

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

将清单 2-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.
}

filenameimageType参数一样,file是一个对于构造函数来说是局部的变量,并且被称为局部变量以区别于参数。尽管这三个变量都是构造函数的局部变量,但参数和局部变量之间有两个主要区别:

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

images 注意生存期和范围(也称为可见性)属性也适用于类、对象和字段(稍后讨论)。当加载到内存中时,类就存在了,当从内存中卸载时,类就不存在了,通常是在应用退出时。此外,加载的类通常对其他类是可见的,但情况并非总是如此——附录 C 在介绍类加载器时会对此问题有更多的说明。

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

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

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

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

用 new 运算符创建数组

操作符new也用于在堆中创建一个对象数组,它是第一章中数组初始化器的一个替代。

images 注意一个数组被实现为一个特殊的 Java 对象,它的只读length字段包含数组的大小(元素的数量)。您将在本章的后面了解字段。

创建数组时,指定new,后跟一个标识数组中存储的值的类型的名称,后跟一对或多对方括号,表示数组占用的维数。最左边的一对方括号必须包含指定数组大小(元素数量)的整数表达式,而其余的一对方括号包含整数表达式或为空。

例如,您可以使用new创建对象引用的一维数组,如以下示例所示,该示例创建了一个可以存储十个Image对象引用的一维数组:

Image[] imArray = new Image[10];

当您创建一维数组时,new将每个数组元素的存储位置中的位置零,您在源代码级别将这些位解释为文字值false'\u0000'00L0.00.0Fnull(取决于元素类型)。在前面的例子中,imArray的每个元素都被初始化为null,这代表了的空引用(对无对象的引用)。

创建数组后,需要为其元素分配对象引用。以下示例通过创建Image对象并将其引用分配给imArray元素来演示此任务:

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

"image"+i+".png"表达式使用字符串连接运算符(+)将image与存储在变量i中的整数值的字符串等效项.png组合起来。结果字符串被传递给ImageImage(String filename)构造函数。

images 警告根据循环的长度,在循环上下文中使用字符串连接操作符会导致大量不必要的String对象创建。我会在第四章向你介绍String类的时候讨论这个话题。

您还可以使用new来创建原始类型值的数组(比如整数或双精度浮点数)。例如,假设您要创建一个双精度浮点温度值的二维三行两列数组。以下示例完成了这项任务:

double[][] temperatures = new double[3][2];

创建一个二维数组后,您会希望用合适的值填充它的元素。下面的例子通过Math.random()将每个temperatures元素初始化为一个随机生成的温度值,我将在第四章的中解释:

for (int row = 0; row < temperatures.length; row++)
   for (int col = 0; col < temperatures[row].length; col++)
      temperatures[row][col] = Math.random()*100;

随后,您可以使用 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

这种数组被称为不规则数组,因为每行可以有不同数量的列;该阵列不是矩形的,而是参差不齐的。

images 注意当创建行数组时,你必须额外指定一对空括号作为new后面表达式的一部分。(对于三维数组——表格的一维数组,其中该数组的元素引用行数组——您必须指定两对空括号作为跟随new的表达式的一部分。)

如果需要,你可以将第一章的数组初始化语法与new结合起来。例如,Image[] imArray = new Image[] { new Image("image0.png"), new Image("image1.png") };创建一对Image对象和一个两元素的Image数组对象,初始化为Image对象的引用,并将数组的引用赋给imArray

以这种方式创建数组时,不允许在方括号之间指定整数表达式。比如编译器遇到Image[] imArray = new Image[2] { new Image("image0.png"), new Image("image1.png") };就报错。要纠正此错误,请移除方括号之间的2

封装状态和行为

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

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

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

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

通过字段表示状态

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

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

声明和访问实例字段

您可以声明一个实例字段,方法是至少指定一个类型名,后跟一个命名该字段的标识符,再跟一个分号字符(;)。清单 2-3 展示了一个带有三个实例字段声明的Car类。

**清单 2-3。**用makemodelnumDoors实例字段声明一个Car

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

清单 2-3 声明了两个名为makemodelString实例字段。它还声明了一个名为numDoorsint实例字段。按照惯例,字段名以小写字母开头,多词字段名中每个后续单词的第一个字母大写。

当创建一个对象时,实例字段被初始化为缺省的零值,您在源代码级别将它解释为文字值false'\u0000'00L0.00.0Fnull(取决于元素类型)。例如,如果您要执行Car car = new Car();,那么makemodel将被初始化为null,而numDoors将被初始化为0

您可以使用成员访问运算符(.)为对象的实例字段赋值或从中读取值;左操作数指定对象的引用,右操作数指定要访问的实例字段。清单 2-4 使用这个操作符来初始化一个Car对象的makemodelnumDoors实例字段。

***清单 2-4。*初始化一个Car对象的实例字段

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;    } }

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

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

***清单 2-5。*CarnumDoors实例字段初始化为默认非零值

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";
   }
}

清单 2-5 明确地将numDoors初始化为4,因为开发者已经假设这个类建模的大多数汽车都有四个门。当通过Car()构造函数初始化Car时,开发者只需要初始化那些有四个门的汽车的makemodel实例字段。

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

**清单 2-6。**通过构造函数初始化Car的实例字段

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);    } }

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

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

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

声明和访问类字段

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

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

***清单 2-7。*Car 增加一个counter类字段

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++;    }    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);    } }

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

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

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

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

声明只读实例和类字段

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

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

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

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

***清单 2-8。*Employee类中声明一个真常数

class Employee
{
   final static int RETIREMENT_AGE = 65;
}

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

查看字段访问规则

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

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

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

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

如果发现声明了隐藏字段的局部变量或参数,可以重命名局部变量/参数,也可以使用带保留字的成员访问运算符this(实例字段)或类名(类字段)来显式标识字段。例如,清单 2-6 的Car(String make, String model, int nDoors)构造函数通过指定像this.make = make;这样的语句来区分实例字段和同名参数,从而演示了后一种解决方案。

通过方法表现行为

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

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

声明和调用实例方法

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

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

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();
   }
}

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

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

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

当一个方法被调用时,其主体中的代码被执行。对于printDetails(),该方法的主体执行一系列的System.out.println()方法调用,以输出其makemodelnumDoors实例字段的值。

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

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

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

编译清单 2-9 ( javac 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.”,因为在这种情况下会假定使用“this.”。

方法调用栈

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

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

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

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

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

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();
   }
}

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

例如,new SavingsAccount().deposit(1000).printBalance();创建一个SavingsAccount对象,使用返回的SavingsAccount引用调用SavingsAccountdeposit()实例方法,向储蓄账户添加一千美元(为了方便起见,我忽略了美分),最后使用deposit()返回的SavingsAccount引用(与SavingsAccount实例相同)调用SavingsAccountprintBalance()实例方法输出账户余额。

声明和调用类方法

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

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

***清单 2-11。*一个Conversions工具类和一对类方法

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;
   }
}

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

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

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

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

向方法传递参数

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

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

按值传递将变量的值(例如,存储在emp中的引用值或存储在recommendedAnnualSalaryIncrease中的 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.lengthvalues[i]所示。)在对这些值求和之后,通过 return 语句返回这个总数。

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

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

***清单 2-12。*使用 return 语句从方法中提前返回

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);
   }
}

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

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

setName()使用 if 语句检测向name字段分配空引用的企图。当检测到这种尝试时,它输出“name cannot be null”错误消息,并过早地从方法中返回,以便不能分配空值(并替换以前分配的名称)。

images 注意使用 return 语句时,可能会遇到编译器报告“代码不可达”错误信息的情况。当它检测到永远不会执行的代码并不必要地占用内存时,它就会这样做。您可能会在 switch 语句中遇到这个问题。例如,假设您指定case "-v": printUsageInstructions(); return; break;作为该语句的一部分。编译器在检测到 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来表示这个尝试。如果没有问题,则执行除法并返回结果。

images 注意不能在构造函数中使用这种形式的 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*factorial(1)现在变成了 2*1 (2)
  • 3*factorial(2)现在变成了 3*2 (6)
  • 4*factorial(3)现在变成了 4*6 (24)

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

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

重载方法

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

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

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

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

检查方法调用规则

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

  • 从与类方法相同的类中的任意位置指定类方法的名称。示例:c2f(37.0);
  • 指定类方法的类的名称,后跟成员访问运算符,再后跟该类外部的类方法的名称。例子:Conversions.c2f(37.0);(你也可以通过一个对象实例调用一个类方法,但是这被认为是不好的形式,因为它隐藏了一个类方法被调用的事实。)
  • 指定实例方法的名称,该名称来自与实例方法在同一类中的任何实例方法、构造函数或实例初始值设定项。示例:setName(name);
  • 指定一个对象引用,后面是成员访问运算符,再后面是实例方法的名称,该实例方法来自与实例方法相同的类内的任何类方法或类初始值设定项,或者来自类外。示例:Car car = new Car("Toyota", "Camry"); car.printDetails();

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

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

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

隐藏信息

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

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

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

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

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

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

images 注意声明为public的类必须保存在同名文件中。例如,一个public Image类必须存储在Image.java中。一个源文件只能声明一个public类。

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

***清单 2-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;
   }
}

清单 2-13 展示了一个由public Employee类、它的public构造函数和它的public setter/getter 方法组成的接口。这个类和这些成员可以从任何地方访问。该实现由private name字段和构造函数/方法代码组成,只能在Employee类中访问。

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

***清单 2-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;    } }

清单 2-14 显示name字段已经被删除,取而代之的是新的firstNamelastName字段,它们是为了提高性能而添加的。因为setFirstName()setLastName()将比setName()被更频繁地调用,并且因为getFirstName()getLastName()将比getName()被更频繁地调用,所以(在每种情况下)让前两个方法设置/获取firstNamelastName的值比将任一值合并到name的值中/从name的值中提取该值更有性能。

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

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

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

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

***清单 2-15。*一个对象访问另一个对象的private字段

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**);
   }
}

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

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

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

唯一可以访问这个private实例字段的代码是位于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

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

初始化类和对象

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

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

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

类初始化器

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

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

***清单 2-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);       }    **}**    //... }

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

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

***清单 2-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);
   }
}

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

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

对于类C<clinit>()将首先包含等同于System.out.println("class initializer 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 提供了实例初始化器来处理实例初始化任务。

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

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

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

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

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

***清单 2-19。*混合实例初始化器和实例字段初始化器

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

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

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

对于类C<init>()将首先包含等同于System.out.println("instance initializer 1");的字节码,然后包含等同于int counter = 1;的字节码,最后包含等同于System.out.println("instance initializer 2"); System.out.println("counter = "+counter);的字节码。

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

instance initializer 1
instance initializer 2
counter = 1

images 注意您应该很少需要使用实例初始化器,这在工业中并不常见。

初始化顺序

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

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

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();       }    } }

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

`[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>()方法返回之前。

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

继承状态和行为

我们倾向于用“汽车是交通工具”或“储蓄账户是银行账户”这样的话来对事物进行分类通过这些陈述,我们实际上是在说,汽车继承了车辆状态(例如,品牌和颜色)和行为(例如,停放和显示里程),储蓄账户继承了银行账户状态(例如,余额)和行为(例如,存款和取款)。汽车、车辆、储蓄账户和银行账户是真实世界实体类别的示例,而继承是相似实体类别之间的层次关系,其中一个类别从至少一个其他实体类别继承状态和行为。从单一类别继承称为单一继承,从至少两个类别继承称为多重继承

Java 支持单继承和多继承以方便代码重用——为什么要多此一举?Java 支持类上下文中的单一继承,其中一个类通过类扩展从另一个类继承状态和行为。因为涉及到类,Java 把这种继承称为实现继承

Java 只在接口上下文中支持多重继承,在接口上下文中,类通过接口实现从一个或多个接口继承行为模板,或者接口通过接口扩展从一个或多个接口继承行为模板。因为涉及到接口,Java 把这种继承称为接口继承。(我将在本章后面讨论接口。)

本节通过首先关注类扩展,向您介绍 Java 对实现继承的支持。然后介绍一个特殊的类,它位于 Java 类层次结构的顶端。在向您介绍了组合(重用代码的实现继承的替代方法)之后,本节将向您展示如何使用组合来克服实现继承的问题。

扩展类

Java 提供了保留字extends来指定两个类之间的层次关系。例如,假设您有一个Vehicle类,并想引入扩展了VehicleCarTruck类。清单 2-21 使用extends来巩固这些关系。

***清单 2-21。*通过extends关联类

class Vehicle
{
   // member declarations
}
class Car extends Vehicle
{
   // member declarations
}
class Truck extends Vehicle
{
   // Member declarations
}

清单 2-21 编纂了被称为“是-a”关系的关系:汽车或卡车是一种交通工具。在这个关系中,Vehicle被称为基类父类超类;并且CarTruck中的每一个都被称为派生类子类子类

images 注意不能扩展final类。例如,如果您将Vehicle声明为final class Vehicle,编译器会在遇到class Car extends Vehicleclass Truck extends Vehicle时报告一个错误。当开发人员不希望这些类被扩展时(出于安全或其他原因),他们会声明自己的类final

除了能够提供自己的成员声明,CarTruck都能够从其Vehicle超类继承成员声明。如清单 2-22 所示,CarTruck类的成员可以访问非private继承的成员。

***清单 2-22。*继承成员

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;    } } 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("Toyota", "Camry", 2011, 4);       System.out.println("Make = "+car.getMake());       System.out.println("Model = "+car.getModel());       System.out.println("Year = "+car.getYear());       System.out.println("Number of wheels = "+car.numWheels);       System.out.println();       car = new Car("Aptera Motors", "Aptera 2e/2h", 2012, 3);       System.out.println("Make = "+car.getMake());       System.out.println("Model = "+car.getModel());       System.out.println("Year = "+car.getYear());       System.out.println("Number of wheels = "+car.numWheels);    } } class Truck extends Vehicle {    private boolean isExtendedCab;    Truck(String make, String model, int year, boolean isExtendedCab)    {       super(make, model, year);       this.isExtendedCab = isExtendedCab;    }    public static void main(String[] args)    {       Truck truck = new Truck("Chevrolet", "Silverado", 2011, true);       System.out.println("Make = "+truck.getMake());       System.out.println("Model = "+truck.getModel());       System.out.println("Year = "+truck.getYear());       System.out.println("Extended cab = "+truck.isExtendedCab);    } }

清单 2-22 的Vehicle类声明了private字段,用于存储车辆的品牌、型号和年份;将这些字段初始化为传递的参数的构造函数;和 getter 方法来检索这些字段的值。

Car子类提供了一个private numWheels字段,一个初始化Car对象的VehicleCar层的构造函数,以及一个用于测试该类的main()类方法。类似地,Truck子类提供了一个private isExtendedCab字段,一个初始化Truck对象的VehicleTruck层的构造函数,以及一个用于测试该类的main()类方法。

CarTruck的构造函数使用保留字super调用Vehicle的带有面向Vehicle参数的构造函数,然后分别初始化CarnumWheelsTruckisExtendedCab实例字段。super()调用类似于指定this()调用同一个类中的另一个构造函数,但是调用的是超类构造函数。

images 注意super()调用只能出现在构造函数中。此外,它必须是构造函数中指定的第一个代码。如果没有指定super(),并且超类没有无参数构造函数,编译器会报告错误,因为当super()不存在时,子类构造函数必须调用无参数超类构造函数。

Carmain()方法创建两个Car对象,将每个对象初始化为特定的品牌、型号、年份和车轮数量。四个System.out.println()方法调用随后输出每个对象的信息。类似地,Truckmain()方法创建一个单独的Truck对象,并将该对象初始化为一个特定的品牌、型号、年份和标志(布尔值 true/false ),表明卡车是一个扩展的驾驶室。前三个System.out.println()方法调用通过调用CarTruck实例继承的getMake()getModel()getYear()方法来检索它们的信息片段。

最后的System.out.println()方法调用直接访问实例的numWheelsisExtendedCab实例字段。虽然直接访问实例字段通常不是一个好主意(因为它违反了信息隐藏),但是提供这种访问的CarTruck类的main()方法中的每一个都只是为了测试这些类,而不会存在于使用这些类的实际应用中。

假设清单 2-22 存储在一个名为Vehicle.java的文件中,执行javac Vehicle.java将这个源代码编译成Vehicle.classCar.classTruck.class类文件。然后执行java Car来测试Car类。该执行会产生以下输出:

Make = Toyota
Model = Camry
Year = 2011
Number of wheels = 4

Make = Aptera Motors
Model = Aptera 2e/2h
Year = 2012
Number of wheels = 3

继续执行java Truck来测试Truck类。该执行会产生以下输出:

Make = Chevrolet
Model = Silverado
Year = 2011
Extended cab = true

images 注意一个实例不能被修改的类被称为不可变类Vehicle就是一个例子。如果没有CarTruckmain()方法,它们可以直接读/写numWheelsisExtendedCab,那么CarTruck也将是不可变类的例子。此外,类不能继承构造函数,也不能继承私有字段和方法。例如,Car不继承Vehicle的构造函数,也不继承Vehicle的私有makemodelyear字段。

子类可以覆盖(替换)一个继承的方法,这样子类的方法版本被调用。清单 2-23 展示了覆盖方法必须指定与被覆盖方法相同的名称、参数列表和返回类型。

***清单 2-23。*覆盖一个方法

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);    } } 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();    } }

清单 2-23 的Car类声明了一个describe()方法,该方法覆盖了Vehicledescribe()方法以输出一个面向汽车的描述。该方法使用保留字super通过super.describe();调用Vehicledescribe()方法。

images 注意通过在方法名前加上保留字super和成员访问操作符,从覆盖的子类方法中调用超类方法。如果不这样做,最终会递归调用子类的覆盖方法。使用super和成员访问操作符从子类中访问非private超类字段,通过声明同名字段来屏蔽这些字段。

如果您要编译清单 2-23 ( javac Vehicle.java)并运行Car应用(java Car),您会发现执行Car的覆盖describe()方法而不是Vehicle的覆盖describe()方法,并输出This car is a 2009 Ford Fiesta

images 注意不能覆盖final方法。例如,如果Vehicledescribe()方法被声明为final void describe(),编译器会在遇到试图在Car类中覆盖该方法时报告一个错误。开发人员声明他们的方法final时,他们不希望这些方法被覆盖(出于安全或其他原因)。此外,您不能使重写方法的可访问性低于它所重写的方法。例如,如果Cardescribe()方法被声明为private void describe(),编译器会报告一个错误,因为私有访问比默认的包访问更难访问。然而,describe()可以通过声明它public而变得更容易访问,就像在public void describe()中一样。

假设您要用下面显示的方法替换清单 2-23 中的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)方法不会覆盖Vehicledescribe()方法。相反,它重载此方法。

Java 编译器通过让你在子类的方法头前加上@Override注释,帮助你在编译时检测重载而不是覆盖方法的企图,如下所示——我在第三章中讨论了注释:

@Override
void describe()
{
   System.out.print("This car is a ");
   super.describe();
}

指定@Override告诉编译器这个方法覆盖了另一个方法。如果改为重载方法,编译器会报告错误。如果没有这个注释,编译器不会报告错误,因为方法重载是一个有效的特性。

images 提示养成用@Override注释作为覆盖方法前缀的习惯。这个习惯将帮助你更快地发现重载错误。

我在前面介绍了类和对象的初始化顺序,从中您了解到类成员总是首先被初始化,并且是按照自顶向下的顺序(同样的顺序也适用于实例成员)。实现继承增加了一些细节:

  • 超类的类初始化器总是在子类的类初始化器之前执行。
  • 在初始化子类层之前,子类的构造函数总是调用超类构造函数来初始化对象的超类层。

Java 对实现继承的支持只允许你扩展一个类。您不能扩展多个类,因为这样做会导致问题。例如,假设 Java 支持多实现继承,你决定通过清单 2-24 所示的类结构来建模一个飞马(来自希腊神话)。

***清单 2-24。*多重实现继承的虚构演示

class Horse {    void describe()    {       // Code that outputs a description of a horse's appearance and behaviors.    } } class Bird {    void describe()    {       // Code that outputs a description of a bird's appearance and behaviors.    } } class FlyingHorse extends Horse, Bird {    public static void main(String[] args)    {       FlyingHorse pegasus = new FlyingHorse();       pegasus.describe();    } }

这个类结构揭示了由于每个HorseBird声明一个describe()方法而导致的歧义。FlyingHorse继承了这些方法中的哪一个?一个相关的歧义来自于同名字段,可能是不同的类型。哪个字段是继承的?

终极超类

一个没有显式扩展另一个类的类隐式扩展了 Java 的Object类(位于java.lang包中——我会在下一章讨论包)。例如,清单 2-1 的Image类扩展Object,而清单 2-21 的CarTruck类扩展Vehicle,后者扩展Object

是 Java 的终极超类,因为它是所有其他类的祖先,但它本身并不扩展任何其他类。Object提供了一组其他类继承的通用方法。表 2-1 描述了这些方法。

images

images

我将很快讨论clone()equals()finalize()hashCode()toString()方法,但是将getClass()notify()notifyAll()wait()方法的讨论推迟到第四章的部分。

images 第六章向您介绍了java.util.Objects类,它提供了几个空安全或允许空的类方法,用于比较两个对象,计算对象的哈希代码,要求引用不能为空,以及返回对象的字符串表示。

克隆

clone()方法克隆(复制)一个对象而不调用构造函数。它将每个原语或引用字段的值复制到它在克隆中的对应物,这个任务被称为浅复制浅克隆。清单 2-25 展示了这种行为。

***清单 2-25。*浅浅克隆一个Employee物体

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
   }
}

清单 2-25 声明了一个带有nameage实例字段的Employee类,以及一个初始化这些字段的构造函数。main()方法使用这个构造函数初始化一个新的Employee对象的这些字段的副本到John Doe46

images 注意一个类必须实现java.lang.Cloneable接口,否则它的实例不能通过Objectclone()方法被简单地克隆——该方法执行运行时检查,以查看该类是否实现了Cloneable。(我将在本章后面讨论接口。)如果一个类没有实现Cloneableclone()抛出java.lang.CloneNotSupportedException。(因为CloneNotSupportedException是一个被检查的异常,所以清单 2-25 有必要通过将throws CloneNotSupportedException附加到main()方法的头来满足编译器。我将在下一章讨论异常。)String是一个没有实现Cloneable的类的例子;因此,String对象不能被浅克隆。

在将Employee对象的引用赋给局部变量e1后,main()调用该变量的clone()方法复制该对象,然后将结果引用赋给变量e2。因为clone()返回Object,所以需要(Employee)强制转换。

为了证明其引用被分配给e1e2的对象是不同的,main()接下来通过==比较这些引用并输出布尔结果,结果恰好为假。为了证明Employee对象是浅克隆的,main()接下来通过==比较两个Employee对象的name字段中的引用,并输出布尔结果,结果恰好为真。

images 注意 Objectclone()方法最初被指定为public方法,这意味着可以从任何地方克隆任何对象。出于安全原因,这个访问后来被改为protected,这意味着只有与要调用其clone()方法的类在同一个包内的代码,或者这个类的子类内的代码(不考虑包)才能调用clone()

浅层克隆并不总是可取的,因为原始对象及其克隆通过它们的等效引用字段引用同一个对象。例如,清单 2-25 的两个Employee对象中的每一个都通过其name字段引用同一个String对象。

虽然对于实例不可变的String来说不是问题,但是通过克隆的引用字段改变可变对象会导致原始(非克隆)对象通过其引用字段看到相同的改变。例如,假设您向Employee添加了一个名为hireDate的引用字段。该字段属于Date类型,具有yearmonthday实例字段。因为Date是可变的,所以您可以在分配给hireDateDate实例中更改这些字段的内容。

现在,假设您计划更改克隆的日期,但希望保留原始Employee对象的日期。使用浅层克隆无法做到这一点,因为原始的Employee对象也可以看到这一变化。要解决这个问题,您必须修改克隆操作,以便它为Employee克隆的hireDate字段分配一个新的Date引用。这个任务被称为深度复制深度克隆,在清单 2-26 中演示。

***清单 2-26。*深度克隆一个Employee物体

class Date {    int year, month, day;    Date(int year, int month, int day)    {       this.year = year;       this.month = month;       this.day = day;    } } 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 does not 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    } }

清单 2-26 声明了DateEmployee类。Date类声明了yearmonthday字段以及一个构造函数。

Employee覆盖clone()方法来深度克隆hireDate字段。该方法首先调用Objectclone()方法,浅克隆当前Employee对象的实例字段,然后将新对象的引用存储在emp中。接下来,它将新的Date对象的引用分配给emphireDate字段;该对象的字段被初始化为与原始Employee对象的hireDate实例相同的值。

此时,您有了一个带有浅克隆的nameage字段的Employee克隆,以及一个深克隆的hireDate字段。clone()方法通过返回这个Employee克隆来结束。

images 注意如果你不是从一个覆盖的clone()方法中调用Objectclone()方法(因为你更喜欢深度克隆引用字段,并自己对非引用字段进行浅层复制),那么包含覆盖的clone()方法的类就没有必要实现Cloneable,但是为了一致性,它应该实现这个接口。String不覆盖clone(),所以String对象不能被深度克隆。

平等

==!=操作符比较两个原始值(如整数)是否相等(==)或不相等(!=)。这些操作符还比较两个引用,看它们是否引用同一个对象。后一种比较被称为身份检查

您不能使用==!=来确定两个对象在逻辑上是否相同。例如,具有相同字段值的两个Truck对象在逻辑上是等价的。但是,==报告它们不相等,因为它们的引用不同。

images 注意因为==!=执行可能最快的比较,并且因为字符串比较需要快速执行(特别是当排序大量字符串时),String类包含特殊支持,允许通过==!=比较文字字符串和字符串值常量表达式。(我将在第四章的中介绍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 在Object类中提供了一个equals()方法。因为这个方法默认比较引用,所以需要覆盖equals()来比较对象内容。

在覆盖equals()之前,确保这是必要的。例如,Java 的java.lang.StringBuffer类(在第四章中讨论过)不会覆盖equals()。也许这个类的设计者认为没有必要确定两个StringBuffer对象在逻辑上是否等价。

您不能用任意代码覆盖equals()。这样做可能会给应用带来灾难性的后果。相反,您需要遵守 Java 文档中为该方法指定的契约,这是我接下来要介绍的。

equals()方法实现了非空对象引用的等价关系:

  • 是自反的:对于任何非空的参考值 xx .equals(*x*)返回 true。
  • 对称:对于任何非空的参考值*x**y.equals(*y*)返回 true 当且仅当 y .equals(*x*)返回 true。* ** 是传递性的:对于任何非空的参考值 xyz ,如果 x .equals(*y*)返回 true, y .equals(*z*)返回 true,那么 x .equals(*z*)返回 true。* 一致:对于任何非空的参考值 xy ,多次调用 x .equals(*y*)一致返回真或一致返回假,前提是没有修改对象上equals()比较中使用的信息。* 对于任何非空的参考值 xx .equals(null)返回 false。*

*虽然这份合同可能看起来有点吓人,但满足它并不困难。为了证明,看一下清单 2-27 的Point类中equals()方法的实现。

***清单 2-27。*逻辑上比较Point对象

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    } }

清单 2-27 的覆盖equals()方法以 if 语句开始,该语句使用instanceof操作符来确定传递给参数o的变量是否是Point类的实例。如果不是,If 语句执行return false;

o instanceof Point表达式满足契约的最后一部分:对于任何非空的引用值 xx .equals(null)返回 false。因为空引用不是任何类的实例,所以将该值传递给equals()会导致表达式计算为 false。

在将一个对象而不是一个Point对象传递给equals()的情况下,o instanceof Point表达式还可以防止通过表达式(Point) o抛出一个java.lang.ClassCastException实例。(我将在下一章讨论异常。)

在转换之后,契约的自反性、对称性和传递性要求通过只允许通过表达式p.x == x && p.y == yPoint与其他Point进行比较来满足。

通过确保equals()方法是确定性的,满足了最终的契约要求,即一致性。换句话说,这个方法不依赖于任何可能随方法调用而改变的字段值。

images 提示你可以优化耗时的equals()方法的性能,首先使用==来确定o的引用是否标识当前对象。简单地指定if (o == this) return true;作为equals()方法的第一条语句。这种优化在清单 2-27 的equals()方法中是不必要的,该方法具有令人满意的性能。

在重写equals()时,务必重写hashCode()方法。我没有在清单 2-27 中这样做,因为我还没有正式介绍hashCode()

定稿

终结指的是通过finalize()方法进行清理,这个方法被称为终结器finalize()方法的 Java 文档声明,finalize()是“当垃圾收集器确定不再有对对象的引用时,由垃圾收集器在对象上调用的”。一个子类覆盖了finalize()方法来释放系统资源或执行其他清理。

Object的版本finalize()什么都不做;您必须用任何需要的清理代码重写此方法。因为 JVM 可能永远不会在应用终止前调用finalize(),所以您应该提供一个显式的清理方法,并让finalize()调用这个方法作为安全网,以防这个方法没有被调用。

images 注意永远不要依赖finalize()来释放有限的资源,如图形上下文或文件描述符。例如,如果一个应用对象打开文件,期望它的finalize()方法将关闭它们,当一个迟缓的 JVM 调用finalize()很慢时,应用可能发现自己无法打开额外的文件。使这个问题变得更糟的是,finalize()可能在另一个 JVM 上被更频繁地调用,导致这个太多打开文件的问题没有暴露出来。因此,开发人员可能会错误地认为应用在不同的 JVM 上表现一致。

如果你决定覆盖finalize(),你的对象的子类层必须给它的超类层一个完成的机会。您可以通过将super.finalize();指定为方法中的最后一条语句来完成这项任务,下面的示例演示了这一点:

@Override
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()方法经常被用来执行复活(使一个未被引用的对象被引用),以实现对象池,当这些对象创建起来很昂贵(时间方面)时,这些对象池回收相同的对象(数据库连接对象就是一个例子)。

当您将this(对当前对象的引用)赋给一个类或实例字段(或另一个长期变量)时,就会发生复活。例如,您可以在finalize()中指定r = this;,将标识为this的未引用对象分配给名为r的类字段。

由于复活的可能性,对于覆盖了finalize()的对象的垃圾收集会有严重的性能损失。你将在第四章中了解到这个惩罚和一个更好的替代方案。

images 注意复活的对象的终结器不能被再次调用。

哈希码

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<>();
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

假设这个例子的语句被附加到清单 2-27 的main()方法中——前缀java.util.<Point, String><>与包和泛型有关,我将在第三章中讨论。

main()创建其Point对象并调用其System.out.println()方法后,它执行该示例的语句,这些语句执行以下任务:

  • 第一条语句实例化了HashMap,它在java.util包中。
  • 第二条语句调用HashMapput()方法将清单 2-27 的p1对象键和"first point"值存储在散列表中。
  • 第三条语句通过HashMapget()方法检索 hashmap 条目的值,该条目的Point键在逻辑上等于p1
  • 第四个语句相当于第三个语句,但是返回空引用而不是"first point"

虽然对象p1Point(10, 20)在逻辑上是等价的,但是这些对象有不同的散列码,导致每个对象引用 hashmap 中不同的条目。如果一个对象没有存储(通过put())在那个条目中,get()返回 null。

纠正这个问题需要覆盖hashCode()来为逻辑上等价的对象返回相同的整数值。当我在第五章的中讨论HashMap时,我会告诉你如何完成这个任务。

字符串表示法

toString()方法返回当前对象的基于字符串的表示。这种表示默认为对象的类名,后跟@符号,再后跟对象散列码的十六进制表示。

例如,如果你要执行System.out.println(p1);来输出清单 2-27 的p1对象,你会看到一行类似于Point@3e25a5的输出。(System.out.println()调用p1在幕后继承的toString()方法。)

您应该努力覆盖toString(),以便它返回一个简洁但有意义的对象描述。例如,你可以在清单 2-27 的Point类中声明一个类似如下的toString()方法:

@Override
public String toString()
{
   return "("+x+", "+y+")";
}

这一次,执行System.out.println(p1);会产生更有意义的输出,比如(10, 20)

构成

实现继承和组合提供了两种不同的重用代码的方法。正如您所了解的,实现继承与用新类扩展一个类有关,这是基于它们之间的“是-a”关系:例如,CarVehicle

另一方面,合成关注于从其他类中合成类,这是基于它们之间的“has-a”关系。例如,一个Car有一个EngineWheel和一个SteeringWheel

在这一章中你已经看到了作文的例子。例如,清单 2-3 的Car类包括String makeString model字段。清单 2-28 的Car类提供了另一个组合的例子。

***清单 2-28。*一个Car类,它的实例由其他对象组成

class Car extends Vehicle
{
   private Engine engine;
   private Wheel[] wheels;
   private SteeringWheel steeringWheel;
}

清单 2-28 展示了组合和实现继承并不相互排斥。虽然没有显示出来,但是除了提供自己的enginewheelssteeringwheel实例字段之外,Car从其Vehicle超类继承了各种成员。

实现继承的麻烦

实现继承有潜在的危险,尤其是当开发人员不能完全控制超类,或者超类的设计和记录没有考虑扩展的时候。

问题是实现继承破坏了封装。子类依赖于超类中的实现细节。如果这些细节在超类的新版本中发生了变化,那么即使子类没有被改变,子类也可能会被破坏。

例如,假设您已经购买了一个 Java 类库,其中一个类描述了一个约会日历。尽管你不能访问这个类的源代码,但是假设清单 2-29 描述了它的部分代码。

***清单 2-29。*约会日历类

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]);
   }
}

清单 2-29 的ApptCalendar类存储了一个约会数组,每个约会由一个Appt实例描述。对于这个讨论,Appt的细节无关紧要——它可能像class Appt {}一样微不足道。

假设您想在一个文件中记录每个约会。因为没有提供日志记录功能,所以您用清单 2-30 的的LoggingApptCalendar类扩展了ApptCalendar,它在重写addAppt()addAppts()方法中添加了日志记录行为。

***清单 2-30。*扩展约会日历类

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);
   }
}

清单 2-30 的LoggingApptCalendar类依赖于一个Logger类,它的void log(String msg)类方法将一个字符串记录到一个文件中(细节并不重要)。注意使用toString()将一个Appt对象转换成一个String对象,然后传递给log()

尽管这个类看起来不错,但它并不像您预期的那样工作。假设您实例化了这个类,并通过addAppts()向这个实例添加了几个Appt实例,如下所示:

LoggingApptCalendar lapptc = new LoggingApptCalendar();
lapptc.addAppts(new Appt[] {new Appt(), new Appt(), new Appt()});

如果你也给Loggerlog(String msg)方法添加一个System.out.println(msg);方法调用,把这个方法的参数输出到标准输出,你会发现log()总共输出了六条消息;预期的三条消息(每个Appt对象一条)都是重复的。

当调用LoggingApptCalendaraddAppts()方法时,它首先为传递给addAppts()appts数组中的每个Appt实例调用Logger.log()。这个方法然后通过super.addAppts(appts);调用ApptCalendaraddAppts()方法。

ApptCalendaraddAppts()方法为其appts数组参数中的每个Appt实例调用LoggingApptCalendar的覆盖addAppt()方法。addAppt()执行Logger.log(appt.toString());来记录它的appt参数的字符串表示,最后会有三条额外的记录消息。

如果您没有覆盖addAppts()方法,这个问题就会消失。然而,子类将被绑定到一个实现细节:ApptCalendaraddAppts()方法调用addAppt()

当细节没有被记录时,依赖于实现细节不是一个好主意。(我之前说过你无权访问ApptCalendar的源代码。)当一个细节没有被记录时,它可以在类的新版本中改变。

因为基类的改变会破坏子类,这个问题被称为脆弱基类问题。脆弱性的一个相关原因也与重写方法有关,它发生在新方法被添加到后续版本的超类中时。

例如,假设一个新版本的库在ApptCalendar类中引入了一个新的public void addAppt(Appt appt, boolean unique)方法。当unique为假时,该方法将appt实例添加到日历中,当unique为真时,只有当appt实例以前没有被添加时,该方法才会添加它。

因为这个方法是在创建了LoggingApptCalendar类之后添加的,LoggingApptCalendar不会通过调用Logger.log()来覆盖新的addAppt()方法。因此,传递给新的addAppt()方法的Appt实例不会被记录。

这里还有另一个问题:你在子类中引入了一个不在超类中的方法。超类的新版本提供了一个匹配子类方法签名和返回类型的新方法。您的子类方法现在覆盖了超类方法,并且可能不履行超类方法的契约。

有一种方法可以让这些问题消失。不是扩展超类,而是在一个新类中创建一个私有字段,并让这个字段引用“超类”的一个实例这个任务演示了组合,因为您正在新类和“超类”之间形成“has-a”关系

此外,让每个新类的实例方法通过保存在私有字段中的“超类”实例调用相应的“超类”方法,并返回被调用方法的返回值。这个任务被称为转发,新的方法被称为转发方法

清单 2-31 展示了一个改进的LoggingApptCalendar类,它使用组合和转发来永远消除脆弱的基类问题和意外方法覆盖的额外问题。

***清单 2-31。*一个由日志记录组成的约会日历类

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);
   }
}

清单 2-31 的LoggingApptCalendar类不依赖于ApptCalendar类的实现细节。你可以给ApptCalendar添加新方法,它们不会破坏LoggingApptCalendar

images 注意 清单 2-31 的LoggingApptCalendar类是包装类的一个例子,该类的实例包装其他实例。每个LoggingApptCalendar实例包装一个ApptCalendar实例。LoggingApptCalendar也是装饰设计模式的一个例子,在 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

什么时候应该扩展一个类,什么时候应该使用包装类?当超类和子类之间存在“is-a”关系时,扩展一个类,并且要么你对超类有控制权,要么超类已经为类扩展而设计和记录。否则,使用包装类。

“类扩展的设计和文档”是什么意思?设计意味着提供挂钩到类内部工作的protected方法(以支持编写高效的子类),并确保构造函数和clone()方法从不调用可重写的方法。文档意味着清楚地陈述重写方法的影响。

images 注意包装类不应该用在回调框架中,这是一个对象框架,其中一个对象将自己的引用传递给另一个对象(通过this),这样后一个对象可以在以后调用前一个对象的方法。这种“回调前一个对象的方法”被称为回调。因为被包装的对象不知道它的包装类,所以它只传递它的引用(通过this),结果回调不涉及包装类的方法。

改变形态

一些现实世界的实体可以改变它们的形态。例如,水(相对于星际空间而言,在地球上)天然是液体,但冷冻时会变成固体,加热到沸点时会变成气体。像蝴蝶这样经历蜕变的昆虫是另一个例子。

这种改变形式的能力被称为多态性,对编程语言中的建模非常有用。例如,绘制任意形状的代码可以通过引入单个Shape类及其draw()方法,并通过为每个Circle实例、Rectangle实例和存储在数组中的其他Shape实例调用该方法来更简洁地表达。当对数组实例调用Shapedraw()方法时,调用的是CircleRectangle或其他Shape实例的draw()方法。我们说Shapedraw()方法有多种形式,或者说这个方法是多态的。

Java 支持四种多态性:

  • 强制:一个操作通过隐式的类型转换服务于多种类型。例如,除法允许您将一个整数除以另一个整数,或者将一个浮点值除以另一个浮点值。如果一个操作数是整数,另一个操作数是浮点值,编译器会强制(隐式转换)整数为浮点值,以防止类型错误。(没有支持整数操作数和浮点操作数的除法运算。)将子类对象引用传递给方法的超类参数是强制多态的另一个例子。编译器将子类类型强制转换为超类类型,以限制对超类的操作。
  • 重载:相同的操作符或方法名可以在不同的上下文中使用。例如,+可用于执行整数加法、浮点加法或字符串连接,具体取决于其操作数的类型。同样,多个同名的方法可以出现在一个类中(通过声明和/或继承)。
  • 参数化:在一个类声明中,一个字段名可以关联不同的类型,一个方法名可以关联不同的参数和返回类型。然后,字段和方法可以在每个类实例中采用不同的类型。例如,一个字段可能是java.lang.Integer类型,一个方法可能在一个类实例中返回一个Integer引用,同样的字段可能是String类型,同样的方法可能在另一个类实例中返回一个String引用。Java 通过泛型支持参数多态,我将在第三章的中讨论。
  • 子类型:一个类型可以作为另一个类型的子类型。当子类型实例出现在超类型上下文中时,对子类型实例执行超类型操作会导致该操作的子类型版本执行。例如,假设CirclePoint的子类,并且两个类都包含一个draw()方法。将一个Circle实例分配给一个Point类型的变量,然后通过这个变量调用draw()方法,导致Circledraw()方法被调用。子类型多态性与实现继承相结合。

许多开发人员不认为强制和重载是有效的多态类型。他们认为强制和重载只不过是类型转换和语法糖(简化语言的语法,使其使用起来更“甜蜜”)。相反,参数和子类型被认为是有效的多态类型。

本节通过向上转换和后期绑定向您介绍子类型多态性。然后我们继续学习抽象类和抽象方法,向下转换和运行时类型识别,以及协变返回类型。

上抛和后期绑定

清单 2-27 的Point类将一个点表示为一个 x-y 对。因为圆(在本例中)是一个表示其中心的 x-y 对,并且半径表示其范围,所以您可以使用引入了radius字段的Circle类来扩展Point。查看清单 2-32 。

***清单 2-32。*一个Circle类扩展了Point

class Circle extends Point {    private int radius;    Circle(int x, int y, int radius)    {       super(x, y);       this.radius = radius;    }    int getRadius()    {       return radius;    } }

清单 2-32 的Circle类将一个Circle描述为一个带有radiusPoint,这意味着你可以将一个Circle实例视为一个Point实例。通过将Circle实例分配给Point变量来完成这项任务,如下所示:

Circle c = new Circle(10, 20, 30);
Point p = c;

cast 操作符不需要从Circle转换到Point,因为通过Point的接口访问Circle实例是合法的。毕竟,一辆Circle至少是一辆Point。这种赋值被称为向上转换,因为你隐式地向上转换类型层次结构(从Circle子类到Point超类)。这也是协方差的一个例子,具有较宽范围值的类型(Circle)被转换为具有较窄范围值的类型(Point)。

在将Circle提升为Point之后,你不能调用CirclegetRadius()方法,因为这个方法不是Point接口的一部分。在将子类型缩小到超类之后,失去对子类型特性的访问似乎没有什么用处,但是对于实现子类型多态性是必要的。

除了将子类实例向上转换为超类类型的变量之外,子类型多态性还包括在超类中声明一个方法,并在子类中覆盖这个方法。例如,假设PointCircle是图形应用的一部分,您需要在每个类中引入一个draw()方法来分别绘制一个点和一个圆。你以清单 2-33 中的所示的类结构结束。

***清单 2-33。*声明图形应用的PointCircle

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());    } }

清单 2-33 的draw()方法将最终绘制图形形状,但是在图形应用的早期测试阶段,通过System.out.println()方法调用模拟它们的行为就足够了。

现在您已经暂时完成了PointCircle,您想要在图形应用的模拟版本中测试它们的draw()方法。为了实现这个目标,你编写清单 2-34 的Graphics类。

清单 2-34*。*一个Graphics类,用于测试PointCircledraw()方法

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();
   }
}

清单 2-34 的main()方法首先声明一个Point的数组。向上转换是通过首先让数组的初始化器实例化Circle类,然后将这个实例的引用赋给points数组中的第二个元素来演示的。

继续,main()使用 for 循环调用每个Point元素的draw()方法。因为第一次迭代调用了Pointdraw()方法,而第二次迭代调用了Circledraw()方法,所以您会观察到以下输出:

Point drawn at (10, 20)
Circle drawn at (10, 20) with radius 30

Java 如何“知道”它必须在第二次循环迭代时调用Circledraw()方法?难道它不应该调用Pointdraw()方法,因为Circle由于向上转换而被视为Point方法吗?

在编译时,编译器不知道调用哪个方法。它所能做的就是验证超类中存在一个方法,并验证方法调用的参数列表和返回类型与超类的方法声明相匹配。

编译器在编译后的代码中插入一条指令,在运行时获取并使用points[1]中的任何引用来调用正确的draw()方法,而不是知道调用哪个方法。这个任务被称为后期绑定

后期绑定用于调用非final实例方法。对于所有其他方法调用,编译器知道要调用哪个方法,并在编译后的代码中插入一条指令,该指令调用与变量的类型(而不是其值)相关联的方法。这个任务被称为早期绑定

如果要向上转换的数组是另一个数组的子类型,也可以从一个数组向上转换到另一个数组。考虑清单 2-35 中的。

***清单 2-35。*演示阵法升级

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; } } 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    } }

清单 2-35 的main()方法首先创建一个由一个元素组成的ColoredPoint数组。然后实例化这个类,并将对象的引用分配给这个元素。因为ColoredPoint[]Point[]的一个子类型,main()能够将cptArrayColoredPoint[]类型向上转换为Point[],并将其引用赋给ptArraymain()然后通过ptArray[0]调用ColoredPoint实例的getX()getY()方法。它不能调用getColor(),因为ptArray的范围比cptArray窄。换句话说,getColor()不是Point接口的一部分。

抽象类和抽象方法

假设新的需求要求您的图形应用必须包含一个Rectangle类。此外,这个类必须包含一个draw()方法,并且这个方法必须以类似于清单 2-34 的Graphics类中所示的方式进行测试。

与具有半径的PointCircle相反,将Rectangle视为具有宽度和高度的Point是没有意义的。更确切地说,Rectangle实例可能由一个表示其来源的Point和一个表示其宽度和高度范围的Point组成。

因为圆、点和矩形都是形状的例子,所以用自己的draw()方法声明一个Shape类比指定class Rectangle extends Point更有意义。清单 2-36 展示了Shape的声明。

***清单 2-36。*声明一个Shape

class Shape
{
   void draw()
   {
   }
}

清单 2-36 的Shape类声明了一个空的draw()方法,它的存在只是为了被覆盖和演示子类型多态性。

你现在可以重构清单 2-33 的Point类来扩展清单 2-36 的Shape类,保持Circle不变,并引入一个扩展ShapeRectangle类。然后你可以重构清单 2-34 的Graphics类的main()方法来考虑Shape。检查以下main()方法:

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();
}

因为PointRectangle直接扩展了Shape,又因为Circle通过扩展Point间接扩展了Shape,所以main()通过调用正确子类的draw()方法来响应shapes[i].draw();

虽然Shape让代码更加灵活,但是有一个问题。如何阻止某人实例化Shape并将这个无意义的实例添加到shapes数组中,如下所示?

Shape[] shapes = new Shape[] { new Point(10, 20), new Circle(10, 20, 30),
                               new Rectangle(20, 30, 15, 25), new Shape() };

实例化Shape是什么意思?因为这个类描述的是一个抽象的概念,画一个通用的形状是什么意思?幸运的是,Java 为这个问题提供了一个解决方案,如清单 2-37 所示。

***清单 2-37。*抽象出Shape

abstract class Shape
{
   abstract void draw(); // semicolon is required
}

清单 2-37 使用 Java 的abstract保留字来声明一个不能被实例化的类。如果试图实例化该类,编译器会报告错误。

images 提示养成声明描述通用类别的类的习惯(例如,形状、动物、车辆和账户)abstract。这样,您就不会无意中实例化它们。

abstract保留字也用于声明一个没有主体的方法——当你提供一个主体或者省略分号时,编译器会报告一个错误。draw()方法不需要主体,因为它不能绘制抽象的形状。

images 注意当你试图声明一个既抽象又最终的类时,编译器会报告一个错误。例如,abstract final class Shape是一个错误,因为抽象类不能被实例化,最终类不能被扩展。当您将一个方法声明为抽象方法,但没有将其类声明为抽象方法时,编译器也会报告错误。例如,从清单 2-37 中的Shape类的头中删除abstract会导致错误。这种移除是错误的,因为当非abstract(具体)类包含抽象方法时,它不能被实例化。最后,当你扩展一个抽象类时,扩展类必须覆盖所有抽象类的抽象方法,否则扩展类本身必须被声明为抽象的;否则,编译器将报告错误。

除了abstract方法之外,抽象类还可以包含非abstract方法,或者用非abstract方法代替abstract方法。例如,清单 2-22 的Vehicle类可以被声明为abstract。构造函数仍然存在,用于初始化私有字段,即使您不能实例化结果类。

向下转换和运行时类型识别

通过向上转换在类型层次中向上移动会导致无法访问子类型特征。例如,给Point变量p分配一个Circle实例意味着你不能用p调用CirclegetRadius()方法。

但是,通过执行显式的强制转换操作,可以再次访问Circle实例的getRadius()方法;例如,Circle c = (Circle) p;。这种赋值被称为向下转换,因为你是显式地向下移动类型层次结构(从Point超类到Circle子类)。这也是逆变的一个例子,具有较窄值域(Point)的类型被转换为具有较宽值域(Circle)的类型。

虽然向上转换总是安全的(超类的接口是子类接口的子集),但是向下转换就不一样了。清单 2-38 向你展示了当向下转换使用不当时,你会陷入什么样的麻烦。

***清单 2-38。*向下抛掷的麻烦

class A
{
}
class B extends A
{
   void m() {}
}
class DowncastDemo
{
   public static void main(String[] args)
   {
      A a = new A();
      B b = (B) a;
      b.m();
   }
}

清单 2-38 展示了一个由名为A的超类和名为B的子类组成的类层次结构。虽然A没有声明任何成员,但是B声明了一个m()方法。

第三个名为DowncastDemo的类提供了一个main()方法,该方法首先实例化A,然后尝试将该实例向下转换为B,并将结果赋给变量b。编译器不会抱怨,因为在同一类型层次结构中从超类向下转换到子类是合法的。

但是,如果允许赋值,应用在试图执行b.m();时无疑会崩溃。崩溃的发生是因为 JVM 试图调用一个不存在的方法——类A没有m()方法。

幸运的是,这种情况永远不会发生,因为 JVM 会验证强制转换是合法的。因为它检测到A没有m()方法,所以它不允许通过抛出ClassCastException类的实例进行强制转换。

JVM 的 cast 验证说明了运行时类型标识(或简称为 RTTI)。强制转换验证通过检查强制转换运算符的操作数类型来执行 RTTI,以确定是否应该允许强制转换。显然,演员不应该被允许。

RTTI 的第二种形式涉及到instanceof操作符。该运算符检查左操作数是否是右操作数的实例,如果是,则返回 true。下面的例子介绍instanceof到清单 2-38 防止ClassCastException:

if (a instanceof B) {    B b = (B) a;    b.m(); }

instanceof操作符检测到变量a的实例不是从B创建的,并返回 false 来表明这一事实。因此,执行非法强制转换的代码将不会执行。(过度使用instanceof大概说明软件设计很差。)

因为子类型是一种超类型,所以当其左操作数是其右操作数超类型的子类型实例或超类型实例时,instanceof将返回 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

这个例子假设了清单 2-38 中所示的类结构,并实例化了超类A和子类B。第一个System.out.println()方法调用输出true,因为b的引用标识了A子类的一个实例;第二个System.out.println()方法调用输出true,因为a的引用标识了超类A的一个实例。

还可以从一个数组向下转换到另一个数组,前提是被向下转换的数组是另一个数组的超类型,并且其元素类型是子类型的元素类型。考虑清单 2-39 中的。

***清单 2-39。*演示阵下投

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; } } 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());       }    } }

清单 2-39 类似于清单 2-35 ,除了它也演示了向下转换。注意它使用了instanceof来验证ptArray的引用对象是否属于ColoredPoint[]类型。如果该运算符返回 true,则可以安全地将ptArray[0]Point向下转换到ColoredPoint,并将引用分配给ColoredPoint

到目前为止,你已经遇到了两种形式的 RTTI。Java 还支持第三种形式,即反射。当我在第四章讲述反射时,我会向你介绍这种形式的 RTTI。

协变返回类型

协变返回类型是一种方法返回类型,在超类的方法声明中,它是子类的覆盖方法声明中返回类型的超类型。清单 2-40 展示了这个特性。

***清单 2-40。*协变回报类型的演示

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**();    } } 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    } }

清单 2-40 声明了SuperReturnTypeSuperclass超类,以及SubReturnTypeSubclass子类;SuperclassSubclass都声明了一个createReturnType()方法。Superclass的方法将其返回类型设置为SuperReturnType,而Subclass的覆盖方法将其返回类型设置为SubReturnType,即SuperReturnType的子类。

协变返回类型最小化了向上转换和向下转换。例如,SubclasscreateReturnType()方法不需要将其SubReturnType实例向上转换为其SubReturnType返回类型。此外,在给变量subrt赋值时,这个实例不需要向下转换为SubReturnType

在没有协变返回类型的情况下,您会以清单 2-41 中的结束。

***清单 2-41。*在没有协变返回类型的情况下向上转换和向下转换

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**();    } } 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    } }

在清单 2-41 的中,第一个加粗的代码显示了从SubReturnTypeSuperReturnType的向上转换,第二个加粗的代码在分配给subrt之前,使用所需的(SubReturnType)转换运算符从SuperReturnType向下转换到SubReturnType

形式化类接口

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

Java 通过提供保留字interface来形式化接口概念,保留字用于引入一个没有实现的类型。Java 还提供了声明、实现和扩展接口的语言特性。在查看了接口声明、实现和扩展之后,本节将解释使用接口的基本原理。

声明接口

一个接口声明由一个标题和一个主体组成。报头至少由保留字interface组成,后跟一个标识接口的名称。正文以左大括号字符开始,以右大括号结束。夹在这些分隔符之间的是常量和方法头声明。考虑清单 2-42 中的。

***清单 2-42。*声明一个Drawable界面

interface Drawable
{
   int RED = 1;   // For simplicity, integer constants are used. These
   int GREEN = 2; // constants are not that descriptive, as you will see.
   int BLUE = 3;
   int BLACK = 4;
   void draw(int color);
}

清单 2-42 声明了一个名为Drawable的接口。按照惯例,接口的名称以大写字母开头。此外,多词界面名称中每个后续词的第一个字母都要大写。

images 注意许多接口名称都以able后缀结尾。例如,Java 的标准类库包括名为AdjustableCallableComparableCloneableIterableRunnableSerializable的接口。使用这个后缀不是强制性的;标准类库还提供了名为CharSequenceCollectionCompositeExecutorFutureIteratorListMapSet的接口。

Drawable声明识别颜色常数的四个字段。Drawable还声明了一个draw()方法,必须用这些常量中的一个来调用它,以指定用来绘制某物的颜色。

images 注意你可以在interface之前加上public,让你的接口可以被它的包之外的代码访问。(我将在第三章中讨论包。)否则,该接口只能由其包中的其他类型访问。您也可以在interface之前加上abstract,以强调接口是抽象的。因为接口已经是抽象的,所以在接口的声明中指定abstract是多余的。接口的字段被隐式声明为publicstaticfinal。因此,用这些保留字来声明它们是多余的。因为这些字段是常量,所以必须显式初始化;否则,编译器会报告错误。最后,接口的方法被隐式声明为publicabstract。因此,用这些保留字来声明它们是多余的。因为这些方法必须是实例方法,所以不要声明它们static,否则编译器会报告错误。

Drawable标识指定做什么(画什么)但不指定如何做的类型。它将实现细节留给实现该接口的类。这些类的实例被称为 drawables ,因为它们知道如何绘制自己。

images注意没有声明成员的接口被称为标记接口标记接口。它将元数据与类相关联。例如,Cloneable标记/标签接口声明它的实现类的实例可以被简单地克隆。RTTI 用于检测对象的类是否实现了标记/标签接口。例如,当Objectclone()方法通过 RTTI 检测到调用实例的类实现了Cloneable时,它会简单地克隆对象。

实现接口

接口本身是没有用的。为了让应用受益,接口需要由一个类来实现。Java 为此任务提供了implements保留字。清单 2-43 展示了如何使用implements来实现前面提到的Drawable接口。

***清单 2-43。*实现Drawable接口

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);    } }

清单 2-43 改进了清单 2-33 的类层次结构,以利用清单 2-42 的Drawable接口。您会注意到每个类PointCircle都通过将implements Drawable子句附加到类头来实现这个接口。

若要实现接口,该类必须为每个接口方法头指定一个方法,该方法的头与接口的方法头具有相同的签名和返回类型,并指定一个与方法头一起使用的代码体。

images 注意当实现一个方法时,不要忘记接口的方法是隐式声明的public。如果您忘记在实现方法的声明中包含public,编译器将会报告一个错误,因为您试图将较弱的访问分配给实现方法。

当一个类实现一个接口时,该类继承接口的常量和方法头,并通过提供实现覆盖方法头(因此有了@Override注释)。这就是所谓的接口继承

原来Circle的头不需要implements Drawable子句。如果这个子句不存在,Circle继承Pointdraw()方法,并且仍然被认为是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);
}

因为PointCircle实例通过这些实现Drawable接口的类是可绘制的,所以将PointCircle实例引用分配给Drawable类型的变量(包括数组元素)是合法的。

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

Point drawn at (10, 20) in color 1
Circle drawn at (10, 20) with radius 30 in color 1

清单 2-42 的Drawable界面对于绘制一个形状的轮廓很有用。假设您还需要填充形状的内部。你可以通过声明清单 2-44 的的Fillable接口来满足这个需求。

***清单 2-44。*声明一个Fillable界面

interface Fillable
{
   int RED = 1;
   int GREEN = 2;
   int BLUE = 3;
   int BLACK = 4;
   void fill(int color);
}

给定清单 2-42 和清单 2-44 ,您可以通过指定class Point implements Drawable, Fillableclass Circle implements Drawable, Fillable来声明PointCircle类实现了这两个接口。然后你可以修改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()方法。造型是必要的,因为 drawable 不是 fillable。这个强制转换操作将会成功,因为被复制的PointCircle实例实现了FillableDrawable

images 提示通过在implements后面指定一个逗号分隔的接口名称列表,您可以列出您需要实现的任意多的接口。

实现多个接口会导致名称冲突,编译器会报告错误。例如,假设你试图编译清单 2-45 的接口和类声明。

***清单 2-45。*碰撞界面

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; }
}

清单 2-45 中每个的AB接口都声明了一个名为X的常量。尽管每个常量都有相同的类型和值,但是当编译器在Collision的第二个foo()方法中遇到X时,它会报告一个错误,因为它不知道哪个X正在被继承。

说到foo(),编译器在遇到Collision的第二个foo()声明时报错,因为foo()已经被声明了。不能通过仅更改方法的返回类型来重载方法。

编译器可能会报告额外的错误。例如,Java 7 编译器在被告知编译清单 2-45 中的时会这样说:

Collision.java:16: error: foo() is already defined in Collision
   public int foo() { return X; }
              ^
Collision.java:11: error: Collision is not abstract and does not override abstract![images](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ef977f725e624e9e988fdcc17b42605b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770710178&x-signature=dog2u5Rty1YPyETixIeaQTo%2F%2BlE%3D) method
foo() in B
class Collision implements A, B
^
Collision.java:14: error: foo() in Collision cannot implement foo() in B
   public void foo();
               ^
  return type void is not compatible with int
Collision.java:16: error: reference to X is ambiguous, both variable X in A and![images](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ef977f725e624e9e988fdcc17b42605b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1770710178&x-signature=dog2u5Rty1YPyETixIeaQTo%2F%2BlE%3D) variable X
in B match
   public int foo() { return X; }
                             ^
4 errors
扩展接口

正如子类可以通过保留字extends扩展超类一样,你可以使用这个保留字让一个子接口扩展一个超接口。这也被称为接口继承

例如,当你在一个实现类中单独指定颜色常量的名字时,DrawableFillable中重复的颜色常量会导致名字冲突。为了避免这些名字冲突,在名字前面加上接口名和成员访问操作符,或者把这些常量放在它们自己的接口中,让DrawableFillable扩展这个接口,如清单 2-46 所示。

***清单 2-46。*扩展Colors接口

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);
}

对于编译器来说,DrawableFillable各自从Colors继承常数不是问题。这些常量只有一个副本(在Colors中),没有名称冲突的可能性,所以编译器是满意的。

如果一个类可以通过在implements后声明一个逗号分隔的接口名称列表来实现多个接口,那么看起来一个接口应该可以用类似的方式扩展多个接口。清单 2-47 展示了这个特性。

***清单 2-47。*扩展一对接口

interface A
{
   int X = 1;
}
interface B
{
   double X = 2.0;
}
interface C extends A, B
{
}

清单 2-47 将会编译,即使C继承了两个同名的常量X,它们具有不同的返回类型和初始化器。然而,如果你实现了C,然后试图访问X,如清单 2-48 中的所示,你将会遇到名称冲突。

***清单 2-48。*发现域名冲突

class Collision implements C
{
   public void output()
   {
      System.out.println(**X**); // Which X is accessed?
   }
}

假设您在接口A中引入了一个void foo();方法头声明,在接口B中引入了一个int foo();方法头声明。这一次,当您试图编译修改后的清单 2-47 时,编译器会报告一个错误。

为什么要使用接口?

既然声明、实现和扩展接口的机制已经不存在了,我们可以把重点放在使用它们的基本原理上。不幸的是,刚接触 Java 接口特性的人经常被告知,这个特性是为了解决 Java 不支持多实现继承的问题而创建的。虽然接口在这方面很有用,但这不是它们存在的理由。相反, Java 的Iinterfaces 特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。您应该始终编写接口代码。

那些坚持敏捷软件开发(一组基于迭代开发的软件开发方法,强调保持代码简单,频繁测试,并在可交付时交付应用的功能部分)的人知道灵活编码的重要性。他们不能将他们的代码绑定到一个特定的实现上,因为下一次迭代的需求变化可能会导致一个新的实现,并且他们可能会发现他们自己重写了大量的代码,这浪费了时间并且减慢了开发。

接口通过将接口与实现分离来帮助您实现灵活性。例如,清单 2-36 后面的main()方法从Shape类的子类创建一个对象数组,然后遍历这些对象,调用每个对象的draw()方法。唯一可以绘制的对象是那些子类Shape的对象。

假设你也有一个层次结构的类来模拟电阻、晶体管和其他电子元件。每个元件都有自己的符号,可以在电子电路的原理图中显示。也许您想为每个绘制组件符号的类添加一个绘制功能。

你可以考虑将Shape指定为电子元件类层次的超类。然而,电子元件不是形状(尽管它们有形状),所以将这些类放在以Shape为根的类层次结构中是没有意义的。

然而,您可以让每个组件类实现Drawable接口,该接口允许您将实例化这些类的表达式添加到出现在清单 2-44 之前的main()方法中的drawables数组中(这样您就可以绘制它们的符号)。这是合法的,因为这些实例是可提取的。

只要有可能,您应该努力在代码中指定接口而不是类,以使您的代码能够适应变化。当使用 Java 的集合框架时尤其如此,我将在第五章详细讨论。

现在,考虑一个简单的例子,它由集合框架的java.util.List接口及其java.util.ArrayListjava.util.LinkedList实现类组成。以下示例展示了基于ArrayList类的不灵活代码:

ArrayList<String> arrayList = new ArrayList<String>(); void dump(ArrayList<String> arrayList) {    // suitable code to dump out the arrayList }

这个例子使用基于泛型的参数化类型语言特性(我将在第三章的中讨论)来识别存储在ArrayList实例中的对象种类。在这个例子中,String对象被存储。

这个例子是不灵活的,因为它将ArrayList类硬连接到多个位置。这种硬连接使开发人员特别关注数组列表,而不是一般意义上的列表。

当需求发生变化,或者由分析(分析正在运行的应用以检查其性能)带来的性能问题,表明开发人员应该使用LinkedList时,缺乏关注是有问题的。

这个例子只需要很少的修改就可以满足新的需求。相比之下,更大的代码库可能需要更多的更改。尽管您只需要将ArrayList改为LinkedList,为了让编译器满意,请考虑将arrayList改为linkedList,以保持语义(含义)清晰——您可能需要在整个源代码中多次更改引用一个ArrayList实例的名称。

开发人员在重构代码以适应LinkedList的同时必然会浪费时间。相反,开发人员可以通过编写这个示例来使用等效的常量,从而节省时间。换句话说,这个例子可以被写成依赖于接口,并且只在一个地方指定ArrayList。以下示例向您展示了结果代码的外观:

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),这种类型的实例是没有意义的。

接口通过缺少实现来提高灵活性— DrawableList展示了这种灵活性。它们不依赖于任何单一的类层次结构,而是可以由任何层次结构中的任何类来实现。

抽象类支持实现,但是可以是真正的抽象(例如,清单 2-37 的抽象Shape类)。但是,它们仅限于出现在类层次结构的上层。

接口和抽象类可以一起使用。例如,集合框架的java.util包提供了ListMapSet接口;以及AbstractListAbstractMapAbstractSet抽象类,它们提供这些接口的框架实现。

框架实现使您可以轻松创建自己的接口实现,以满足您独特的需求。如果它们不能满足您的需要,您可以选择让您的类直接实现适当的接口。

收集垃圾

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

未引用的对象是一个不能从应用中的任何地方访问的对象。例如,new Employee("John", "Doe");是一个未引用的对象,因为由new返回的Employee引用被丢弃了。相反,引用对象是应用存储至少一个引用的对象。例如,Employee emp = new Employee("John", "Doe");是一个被引用的对象,因为变量emp包含对Employee对象的引用。

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

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

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

考虑清单 2-49 中的。

***清单 2-49。*内存泄漏堆栈

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());    } }

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

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

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

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

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

stack is full
B
A
stack is empty
null

Stack类有一个问题:它会泄漏内存。当你把一个对象推到堆栈上时,它的引用被存储在内部的elements数组中。当您从堆栈中弹出一个对象时,会获得该对象的引用,并且top会递减,但是该引用会保留在数组中(直到您调用push())。

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

这个问题的解决方案是让pop()在返回引用之前显式地将null赋值给elements条目。只需取消清单 2-49 中的elements[top+1] = null;行的注释就可以实现这一点。

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

images 注意垃圾收集是一个复杂的过程,并导致了为 JVM 开发的各种垃圾收集器。如果您想了解更多关于垃圾收集的知识,我建议您从阅读位于[www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf)的“Java HotSpot 虚拟机中的内存管理”白皮书开始。接下来,您将想要了解垃圾优先收集器,这是 Java 7 中的新特性。查看“垃圾优先的垃圾收集器”白皮书([www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/javase/tech/g1-intro-jsp-135488.html))来了解这个垃圾收集器。有关 Java 垃圾收集过程的更多信息,您可以在[www.oracle.com/technetwork…](http://www.oracle.com/technetwork/java/javase/tech/index-jsp-140228.html)的 Oracle“Java 热点垃圾收集”页面上浏览其他白皮书。

第四章通过向您介绍 Java 的参考 API,进一步追求垃圾收集,它让您的应用在对象即将终结或已经终结时接收通知。

images 在整本书中,我经常在广义和狭义的上下文中提到 API 。一方面,我将引用称为 API,但我也将引用的各个类称为 API 本身。

演习

以下练习旨在测试您对类和对象的理解:

  1. 清单 2-2 展示了一个有三个构造函数的Image类和一个测试这个类的main()方法。通过引入名为widthheight的私有int字段和名为image的私有一维字节数组字段来扩展Image。重构Image()构造函数,通过this(null)调用Image(String filename)构造函数。重构Image(String filename, String imageType)构造函数,使得当filename引用不为空时,它创建一个任意大小的字节数组,可能借助于像(int) (Math.random()*100000)这样的表达式(返回一个随机生成的 0 到 99999 之间的整数,包括 0 和 99999),并将这个数组的引用分配给image字段。类似地,它为width字段指定任意宽度,为height字段指定任意高度。如果filename包含空值,它将为widthheight分别赋值-1。接下来,引入返回各自字段值的getWidth()getHeight()getImage()方法,并引入返回分配给image字段的数组长度的getSize()方法(如果image包含空引用,则返回 0)。最后,重构main()方法,使得对于每个构造函数,下面的方法调用序列发生:System.out.println("Image = "+image.getImage()); System.out.println("Size = "+image.getSize()); System.out.println("Width = "+image.getWidth()); System.out.println("Height = "+image.getHeight());

  2. Model part of an animal hierarchy by declaring Animal, Bird, Fish, AmericanRobin, DomesticCanary, RainbowTrout, and SockeyeSalmon classes:

    Animalpublicabstract,声明基于private Stringkindappearance字段,声明将这些字段初始化为传入参数的public构造函数,声明不接受参数且返回类型为voidpublicabstract eat()move()方法,并覆盖toString()方法以输出kindappearance的内容。

    Birdpublicabstract,扩展Animal,声明一个public构造函数,将它的kindappearance参数值传递给它的超类构造函数,重写它的eat()方法输出eats seeds and insects(通过System.out.println()),重写move()方法输出flies through the air

    Fishpublicabstract,扩展Animal,声明一个public构造函数,将它的kindappearance参数值传递给它的超类构造函数,重写它的eat()方法输出eats krill, algae, and insects,重写它的move()方法输出swims through the water

    AmericanRobinpublic,扩展了Bird,声明了一个public无参数构造器,将"americanrobin""red breast"传递给它的超类构造器。

    DomesticCanarypublic,扩展了Bird,并声明了一个public无参数构造函数,将"domesticcanary""yellow, orange, black, brown, white, red"传递给它的超类构造函数。

    RainbowTroutpublic,扩展了Fish,并声明了一个public无参数构造函数,将"rainbowtrout""bands of brilliant speckled multicolored stripes running nearly the whole length of its body"传递给它的超类构造函数。

    SockeyeSalmonpublic,扩展了Fish,并声明了一个public无参数构造函数,将"sockeyesalmon""bright red with a green head"传递给它的超类构造函数。

    为了简洁起见,我省略了Animal层次中概括知更鸟、金丝雀、鳟鱼和鲑鱼的abstractCanaryTroutSalmon类。也许您可能想在层次结构中包含这些类。

    虽然这个练习展示了使用继承的自然场景的精确建模,但是它也揭示了类爆炸的可能性——太多的类可能被引入来建模一个场景,并且维护所有这些类可能是困难的。在使用继承建模时,请记住这一点。

  3. 继续上一个练习,用一个main()方法声明一个Animals类。该方法首先声明一个初始化为AmericanRobinRainbowTroutDomesticCanarySockeyeSalmon对象的animals数组。该方法然后遍历这个数组,首先输出animals[i](这导致toString()被调用),然后调用每个对象的eat()move()方法(演示子类型多态性)。

  4. 继续上一个练习,用一个String getID()方法声明一个public Countable接口。修改Animal来实现Countable,并让这个方法返回kind的值。修改Animalsanimals数组初始化为AmericanRobinRainbowTroutDomesticCanarySockeyeSalmonRainbowTroutAmericanRobin对象。此外,引入计算每种动物数量的代码。这段代码将使用在清单 2-50 中声明的Census类。

清单 2-50。Census类存储四种动物的普查数据

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 把这种继承称为接口继承。

一些现实世界的实体可以改变它们的形态。例如,水自然是液体,但冻结时会变成固体,加热到沸点时会变成气体。像蝴蝶这样经历蜕变的昆虫是另一个例子。

改变形式的能力被称为多态,这对于用编程语言建模很有用。例如,绘制任意形状的代码可以通过引入单个Shape类及其draw()方法,并通过为每个Circle实例、Rectangle实例和存储在数组中的其他Shape实例调用该方法来更简洁地表达。当对数组实例调用Shapedraw()方法时,调用的是CircleRectangle或其他Shape实例的draw()方法。我们说Shapedraw()方法有多种形式,或者说这个方法是多态的。

每个类 X 都公开一个接口(一个由构造函数、方法和[可能的]字段组成的协议,这些接口对从其他类创建的对象可用,用于创建和与 X 的对象通信)。Java 通过提供保留字interface来形式化接口概念,该保留字用于在没有实现的情况下引入类型。尽管许多人认为这种语言特性是作为 Java 缺乏对多实现继承支持的一种变通方法而创建的,但这并不是它存在的真正原因。相反,Java 的接口特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。

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

现在您已经理解了 Java 对类和对象的支持,您已经准备好探索这种语言对更高级特性的支持,比如包和泛型。第三章向你介绍 Java 对这些和其他高级语言特性的支持。*